Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5954afef55 | ||
|
|
929a7a172c | ||
|
|
16149260c7 | ||
|
|
c72c43aaaa | ||
|
|
84aea01b5b | ||
|
|
293ef24bb7 | ||
|
|
0f1765b226 | ||
|
|
1fa58ba901 | ||
|
|
55595570c2 | ||
|
|
fba9759bc7 | ||
|
|
3d3460ce63 | ||
|
|
000ee1bbfd | ||
|
|
d0cfd0a385 | ||
|
|
0818f55235 | ||
|
|
fe83da56d2 | ||
|
|
002422b753 | ||
|
|
24b778b3ba | ||
|
|
d7951cfdd2 | ||
|
|
ddd68cfebd | ||
|
|
5a71643099 | ||
|
|
b04a7d9d3d | ||
|
|
6b675c406e | ||
|
|
56d4a3a6b7 | ||
|
|
276c0612d8 | ||
|
|
d818e7947a | ||
|
|
d5c2c58c67 | ||
|
|
9d4501bc42 | ||
|
|
e280c0875c | ||
|
|
142dcc7932 | ||
|
|
e3dfe8162b | ||
|
|
69b0c107d3 | ||
|
|
e533ec2367 | ||
|
|
bfa17a3aba | ||
|
|
86c374b5c2 | ||
|
|
505b074b47 | ||
|
|
8c38714296 | ||
|
|
1e55f7df8b | ||
|
|
0aa9b3e88f | ||
|
|
3561efa7da | ||
|
|
b7bb9ed8d5 | ||
|
|
8beb8501fa | ||
|
|
257ee09e58 | ||
|
|
1362970229 | ||
|
|
3c82c7b492 | ||
|
|
c879de18e2 | ||
|
|
62d25fcb11 | ||
|
|
5b2b94b1bd | ||
|
|
7cea21f765 | ||
|
|
5114686c35 | ||
|
|
01a013c809 | ||
|
|
dcf5c4f340 | ||
|
|
d956524690 | ||
|
|
c070cda22a | ||
|
|
f8f8e9a7ca | ||
|
|
82a494051d | ||
|
|
4426eeccf7 | ||
|
|
5b8178b703 | ||
|
|
b179162330 | ||
|
|
a312ad880d | ||
|
|
6cc24fa978 | ||
|
|
1df051ea6c | ||
|
|
b638bfaa4b | ||
|
|
86509d5c43 | ||
|
|
f226f9c953 | ||
|
|
74c20b1976 | ||
|
|
cc3bbeccc2 | ||
|
|
a6e95be0c5 | ||
|
|
c67726ca6b | ||
|
|
e77ec9e842 | ||
|
|
5e8fb4276f | ||
|
|
06f95c5d86 | ||
|
|
1f8c5a3d03 | ||
|
|
6ba46514c1 | ||
|
|
91ea0e6a2d | ||
|
|
2d6c16fec0 | ||
|
|
0d18df54f1 | ||
|
|
bdcf9af5d6 | ||
|
|
5ee267ecbd | ||
|
|
db85487b73 | ||
|
|
745811cc6d | ||
|
|
af13a0444e | ||
|
|
cdfca61304 | ||
|
|
a767dc42c0 | ||
|
|
e0a356ee0c | ||
|
|
b175d545ff | ||
|
|
1fce3a535d | ||
|
|
a9e23cb237 | ||
|
|
96bbd3a2f6 | ||
|
|
c2ce42fead | ||
|
|
2a73e18123 | ||
|
|
ffb85b73af | ||
|
|
a24eb1aeb0 | ||
|
|
8e33d1d33e | ||
|
|
192e924dfb | ||
|
|
29f24222fd | ||
|
|
2ccf041b35 | ||
|
|
42ddc70b27 | ||
|
|
9efeb16fd1 | ||
|
|
f30aaeb2d4 | ||
|
|
30ad0e9f2c | ||
|
|
82c8dd352c | ||
|
|
39b6da0ada | ||
|
|
dd1ddd6ead | ||
|
|
85d72a1c8c | ||
|
|
053bc260c7 | ||
|
|
8ca68eb3cd | ||
|
|
6e0a5fe397 | ||
|
|
46bac51703 | ||
|
|
c5c82bb528 | ||
|
|
d345c070ae | ||
|
|
a81a7b5200 | ||
|
|
17b1f77e1d | ||
|
|
9c300de72c | ||
|
|
03abc0eab7 | ||
|
|
f8d766b916 | ||
|
|
b9c0e1a7a5 | ||
|
|
1e82253f43 | ||
|
|
8847d973a8 | ||
|
|
b7351f3b9b | ||
|
|
797edeedf4 | ||
|
|
24eb68cc09 |
+27
-14
@@ -23,26 +23,39 @@ CONFIG_JWT_SECRET=this is a very secret secret
|
||||
# - hybrid: both sources are accepted and separated by gateway_users.source.
|
||||
IDENTITY_MODE=hybrid
|
||||
|
||||
# Auth Center stable OIDC verification. Keep legacy HS256 during the staged rollout.
|
||||
OIDC_ENABLED=false
|
||||
OIDC_ISSUER=https://auth.51easyai.com/issuer/shared
|
||||
OIDC_AUDIENCE=https://api.51easyai.com/gateway
|
||||
OIDC_TENANT_ID=
|
||||
OIDC_ROLE_PREFIX=gateway.
|
||||
OIDC_REQUIRED_SCOPES=gateway.access
|
||||
OIDC_JWKS_CACHE_TTL_SECONDS=300
|
||||
OIDC_ACCEPT_LEGACY_HS256=true
|
||||
OIDC_INTROSPECTION_ENABLED=false
|
||||
OIDC_INTROSPECTION_CLIENT_ID=
|
||||
OIDC_INTROSPECTION_CLIENT_SECRET=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_REDIRECT_URI=http://localhost:5178/auth/callback
|
||||
# Billing engine rollout mode:
|
||||
# - observe: keep legacy billing decisions and compare effective-pricing-v2 in logs.
|
||||
# - enforce: require v2 pricing, reserve the candidate maximum, then settle asynchronously.
|
||||
# - hold: reject new production generation before any upstream request; existing settlements continue.
|
||||
BILLING_ENGINE_MODE=observe
|
||||
|
||||
# Unified identity business settings are managed in System Settings > Unified
|
||||
# Identity. Deployment only supplies the SecretStore and infrastructure timing.
|
||||
AI_GATEWAY_PUBLIC_BASE_URL=http://localhost:8088
|
||||
IDENTITY_SECRET_STORE=file
|
||||
IDENTITY_SECRET_DIR=.local-secrets/identity
|
||||
# Kubernetes uses one pre-provisioned empty Secret and narrowly scoped RBAC.
|
||||
# IDENTITY_SECRET_STORE=kubernetes
|
||||
# IDENTITY_KUBERNETES_NAMESPACE=easyai
|
||||
# IDENTITY_KUBERNETES_SECRET_NAME=easyai-gateway-identity
|
||||
# IDENTITY_KUBERNETES_API_SERVER=https://kubernetes.default.svc
|
||||
IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60
|
||||
IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180
|
||||
IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60
|
||||
AI_GATEWAY_WEB_BASE_URL=http://localhost:5178
|
||||
AI_GATEWAY_WEB_BASE_PATH=/
|
||||
AI_GATEWAY_GO_BUILD_IMAGE=golang:1.26.3-alpine
|
||||
AI_GATEWAY_API_RUNTIME_IMAGE=alpine:3.22
|
||||
AI_GATEWAY_NODE_BUILD_IMAGE=node:22-alpine
|
||||
AI_GATEWAY_WEB_RUNTIME_IMAGE=nginx:1.27-alpine
|
||||
|
||||
# Opt-in, billable China Kling V1 integration tests. Keep real AK/SK only in
|
||||
# .env.local (gitignored); never commit them.
|
||||
KELING_LIVE_TEST=0
|
||||
KELING_TEST_BASE_URL=https://api-beijing.klingai.com/v1
|
||||
KELING_TEST_ACCESS_KEY=
|
||||
KELING_TEST_SECRET_KEY=
|
||||
|
||||
# Used when the gateway delegates OpenAPI sk-* validation, user/group sync, file upload, and settlement callbacks.
|
||||
SERVER_MAIN_BASE_URL=http://localhost:3000
|
||||
SERVER_MAIN_INTERNAL_TOKEN=change-me
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: easyai-gateway-ci-unprivileged-v2
|
||||
services:
|
||||
postgres:
|
||||
image: docker.io/library/postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
env:
|
||||
POSTGRES_USER: easyai_test
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
POSTGRES_DB: easyai_gateway_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U easyai_test -d easyai_gateway_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 5s
|
||||
--health-retries 30
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: ghcr.m.daocloud.io/aquasecurity/trivy-db:2
|
||||
AI_GATEWAY_DATABASE_URL: postgresql://easyai_test@postgres:5432/easyai_gateway_test?sslmode=disable
|
||||
AI_GATEWAY_TEST_DATABASE_URL: postgresql://easyai_test@postgres:5432/easyai_gateway_test?sslmode=disable
|
||||
steps:
|
||||
- name: Checkout without external Actions
|
||||
env:
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CI_SERVER_URL: ${{ github.server_url }}
|
||||
CI_SHA: ${{ github.sha }}
|
||||
CI_JOB_TOKEN: ${{ github.token }}
|
||||
CI_EVENT_BEFORE: ${{ github.event.before }}
|
||||
CI_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
test -n "$CI_JOB_TOKEN"
|
||||
authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n')
|
||||
git init .
|
||||
git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \
|
||||
fetch --no-tags "$CI_SERVER_URL/$CI_REPOSITORY.git" "$CI_SHA"
|
||||
for comparison_sha in "$CI_EVENT_BEFORE" "$CI_PR_BASE_SHA"; do
|
||||
case "$comparison_sha" in
|
||||
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*)
|
||||
if test "${#comparison_sha}" -eq 40 && \
|
||||
test "$comparison_sha" != 0000000000000000000000000000000000000000; then
|
||||
git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \
|
||||
fetch --no-tags "$CI_SERVER_URL/$CI_REPOSITORY.git" "$comparison_sha"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
unset authorization CI_JOB_TOKEN
|
||||
test ! -f .git/shallow
|
||||
git checkout --detach "$CI_SHA"
|
||||
test "$(git rev-parse HEAD)" = "$CI_SHA"
|
||||
- name: Verify pinned host toolchains
|
||||
run: |
|
||||
go version
|
||||
node --version
|
||||
pnpm --version
|
||||
docker-compose version
|
||||
shellcheck --version
|
||||
trivy --version
|
||||
govulncheck -version
|
||||
- name: Verify production migration safety
|
||||
env:
|
||||
CI_EVENT_NAME: ${{ github.event_name }}
|
||||
CI_EVENT_BEFORE: ${{ github.event.before }}
|
||||
CI_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
production_base=$(cat deploy/ci/production-migration-base)
|
||||
immutable_base=$CI_EVENT_BEFORE
|
||||
if test "$CI_EVENT_NAME" = pull_request; then
|
||||
immutable_base=$CI_PR_BASE_SHA
|
||||
fi
|
||||
case "$immutable_base" in
|
||||
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*)
|
||||
test "${#immutable_base}" -eq 40
|
||||
test "$immutable_base" != 0000000000000000000000000000000000000000
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
git merge-base --is-ancestor "$immutable_base" HEAD
|
||||
node ./scripts/ci-validate-migrations.mjs \
|
||||
"$production_base" "$immutable_base"
|
||||
- name: Verify Go formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l apps/api)
|
||||
test -z "$unformatted" || {
|
||||
printf 'Go files require gofmt:\n%s\n' "$unformatted" >&2
|
||||
exit 1
|
||||
}
|
||||
- name: Migrate PostgreSQL 16 integration database
|
||||
working-directory: apps/api
|
||||
run: go run ./cmd/migrate
|
||||
- name: Verify Go code
|
||||
working-directory: apps/api
|
||||
env:
|
||||
GOFLAGS: "-p=1"
|
||||
GOMAXPROCS: "1"
|
||||
run: |
|
||||
go vet ./...
|
||||
go test ./...
|
||||
govulncheck ./...
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
- name: Audit JavaScript dependencies
|
||||
run: pnpm audit --audit-level high
|
||||
- name: Validate deployment configuration
|
||||
run: |
|
||||
docker-compose -f docker-compose.yml config --quiet
|
||||
shellcheck scripts/ci-build-images.sh scripts/ci-validate-semver.sh \
|
||||
scripts/provision-ci-runner.sh tests/ci/ci-build-images-test.sh \
|
||||
tests/ci/migrations-test.sh tests/ci/pipeline-test.sh \
|
||||
tests/ci/semver-test.sh
|
||||
./tests/ci/ci-build-images-test.sh
|
||||
./tests/ci/migrations-test.sh
|
||||
./tests/ci/pipeline-test.sh
|
||||
./tests/ci/semver-test.sh
|
||||
- name: Scan repository
|
||||
run: |
|
||||
trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL \
|
||||
--ignore-unfixed --exit-code 1 --timeout 15m --skip-dirs .git \
|
||||
--skip-dirs node_modules .
|
||||
@@ -0,0 +1,114 @@
|
||||
name: release-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
verify-tag:
|
||||
runs-on: easyai-gateway-ci-unprivileged-v2
|
||||
services:
|
||||
postgres:
|
||||
image: docker.io/library/postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
env:
|
||||
POSTGRES_USER: easyai_test
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
POSTGRES_DB: easyai_gateway_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U easyai_test -d easyai_gateway_test"
|
||||
--health-interval 2s
|
||||
--health-timeout 5s
|
||||
--health-retries 30
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: ghcr.m.daocloud.io/aquasecurity/trivy-db:2
|
||||
AI_GATEWAY_DATABASE_URL: postgresql://easyai_test@postgres:5432/easyai_gateway_test?sslmode=disable
|
||||
AI_GATEWAY_TEST_DATABASE_URL: postgresql://easyai_test@postgres:5432/easyai_gateway_test?sslmode=disable
|
||||
steps:
|
||||
- name: Checkout without external Actions
|
||||
env:
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CI_SERVER_URL: ${{ github.server_url }}
|
||||
CI_SHA: ${{ github.sha }}
|
||||
CI_JOB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
test -n "$CI_JOB_TOKEN"
|
||||
authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n')
|
||||
git init .
|
||||
git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \
|
||||
fetch --no-tags "$CI_SERVER_URL/$CI_REPOSITORY.git" "$CI_SHA"
|
||||
unset authorization CI_JOB_TOKEN
|
||||
test ! -f .git/shallow
|
||||
git checkout --detach FETCH_HEAD
|
||||
- name: Verify pinned host toolchains
|
||||
run: |
|
||||
go version
|
||||
node --version
|
||||
pnpm --version
|
||||
docker-compose version
|
||||
shellcheck --version
|
||||
trivy --version
|
||||
govulncheck -version
|
||||
- name: Verify release tag ancestry
|
||||
env:
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CI_SERVER_URL: ${{ github.server_url }}
|
||||
CI_SHA: ${{ github.sha }}
|
||||
CI_JOB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
tag_name=${GITHUB_REF#refs/tags/}
|
||||
./scripts/ci-validate-semver.sh "$tag_name"
|
||||
authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n')
|
||||
git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \
|
||||
fetch --no-tags "$CI_SERVER_URL/$CI_REPOSITORY.git" \
|
||||
+refs/heads/main:refs/remotes/origin/main
|
||||
unset authorization CI_JOB_TOKEN
|
||||
test ! -f .git/shallow
|
||||
test "$(git rev-parse HEAD)" = "$CI_SHA"
|
||||
git merge-base --is-ancestor "$CI_SHA" refs/remotes/origin/main
|
||||
- name: Verify production migration safety
|
||||
run: |
|
||||
production_base=$(cat deploy/ci/production-migration-base)
|
||||
node ./scripts/ci-validate-migrations.mjs "$production_base"
|
||||
- name: Verify Go formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l apps/api)
|
||||
test -z "$unformatted" || {
|
||||
printf 'Go files require gofmt:\n%s\n' "$unformatted" >&2
|
||||
exit 1
|
||||
}
|
||||
- name: Migrate PostgreSQL 16 integration database
|
||||
working-directory: apps/api
|
||||
run: go run ./cmd/migrate
|
||||
- name: Verify Go code
|
||||
working-directory: apps/api
|
||||
env:
|
||||
GOFLAGS: "-p=1"
|
||||
GOMAXPROCS: "1"
|
||||
run: |
|
||||
go vet ./...
|
||||
go test ./...
|
||||
govulncheck ./...
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
- name: Audit JavaScript dependencies
|
||||
run: pnpm audit --audit-level high
|
||||
- name: Validate deployment configuration
|
||||
run: |
|
||||
docker-compose -f docker-compose.yml config --quiet
|
||||
shellcheck scripts/ci-build-images.sh scripts/ci-validate-semver.sh \
|
||||
scripts/provision-ci-runner.sh tests/ci/ci-build-images-test.sh \
|
||||
tests/ci/migrations-test.sh tests/ci/pipeline-test.sh \
|
||||
tests/ci/semver-test.sh
|
||||
./tests/ci/ci-build-images-test.sh
|
||||
./tests/ci/migrations-test.sh
|
||||
./tests/ci/pipeline-test.sh
|
||||
./tests/ci/semver-test.sh
|
||||
- name: Scan repository
|
||||
run: |
|
||||
trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL \
|
||||
--ignore-unfixed --exit-code 1 --timeout 15m --skip-dirs .git \
|
||||
--skip-dirs node_modules .
|
||||
@@ -4,9 +4,13 @@ node_modules/
|
||||
.turbo/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.local-secrets/
|
||||
.gateway-local-password
|
||||
*.log
|
||||
|
||||
apps/api/bin/
|
||||
apps/api/gateway
|
||||
apps/api/tmp/
|
||||
apps/api/data/
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# EasyAI AI Gateway 智能体协作规则
|
||||
|
||||
本文件是本仓库内 AI 编码智能体的项目级执行约束。开始工作前必须先阅读本文件,并以实际代码、测试和运行结果作为结论依据。
|
||||
|
||||
## 协作语言
|
||||
|
||||
1. 面向用户的回复、进度更新、验收报告、代码审查意见、Issue、PR 标题和 PR 描述默认使用中文。
|
||||
2. Git 提交信息使用 `<type>(<scope>): <中文摘要>` 格式,`scope` 可省略。
|
||||
3. `type` 使用小写英文,可选值为 `feat`、`fix`、`docs`、`test`、`refactor`、`perf`、`build`、`ci`、`chore`、`revert`。
|
||||
4. 提交摘要和正文必须使用中文;专有名词、协议名称、命令、路径、代码标识符和第三方原始错误可保留原文。
|
||||
5. 摘要应简洁明确,末尾不加句号,不得使用“更新代码”“修复问题”等无法说明意图的模糊描述。
|
||||
6. 非简单变更应在提交正文或 PR 描述中用中文说明原因、影响、风险和验证结果。
|
||||
|
||||
## 仓库边界
|
||||
|
||||
1. 后端位于 `apps/api`,前端位于 `apps/web`,共享 TypeScript 契约位于 `packages/contracts`。
|
||||
2. 修改 HTTP 接口、请求或响应类型后,必须执行 `pnpm openapi` 并提交匹配的 OpenAPI 产物。
|
||||
3. 数据库迁移只能新增,禁止修改已经进入生产基线的历史迁移;迁移必须通过生产迁移安全检查。
|
||||
4. 不得把 `.env`、密码、Secret、Token、授权码、私钥或生产凭据提交到 Git、日志、测试输出或验收证据中。
|
||||
5. 当前工作区存在用户改动时必须保留,不得覆盖、清理、重置或混入当前任务提交;需要隔离时使用独立分支、克隆或 worktree。
|
||||
|
||||
## 验证要求
|
||||
|
||||
根据改动范围执行最小充分验证;准备合并或发布时执行完整门禁:
|
||||
|
||||
```bash
|
||||
cd apps/api && go vet ./... && go test ./... && govulncheck ./...
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm build
|
||||
pnpm audit --audit-level high
|
||||
docker compose -f docker-compose.yml config --quiet
|
||||
./tests/ci/ci-build-images-test.sh
|
||||
./tests/ci/migrations-test.sh
|
||||
./tests/ci/pipeline-test.sh
|
||||
./tests/ci/semver-test.sh
|
||||
```
|
||||
|
||||
修改 Shell 脚本后还必须执行 `bash -n` 和 ShellCheck。修改 Go 文件后必须确认 `gofmt -l` 没有输出。
|
||||
|
||||
## Git 与 CI/CD
|
||||
|
||||
1. 一个提交只包含一个逻辑变更,提交前检查暂存差异并确认不含敏感信息。
|
||||
2. `main` 只能通过短生命周期分支和 PR 合并,禁止直接推送或强制推送。
|
||||
3. PR 必须通过精确的 `ci / verify (pull_request)` 状态后才能合并;合并后还要确认同一 SHA 的 `ci / verify (push)` 成功。
|
||||
4. 生产版本仅使用稳定 SemVer `vMAJOR.MINOR.PATCH` 标签,并同时要求 `release-ci / verify-tag (push)` 成功。
|
||||
5. 未验证 protected tag、发布账本、数据库备份、健康检查和回滚路径时,不得声称 CI/CD 或生产发布已经完成。
|
||||
+2
-10
@@ -1,4 +1,4 @@
|
||||
ARG GO_VERSION=1.26.3
|
||||
ARG GO_VERSION=1.26.5
|
||||
ARG NODE_VERSION=22
|
||||
ARG GO_BUILD_IMAGE=golang:${GO_VERSION}-alpine
|
||||
ARG API_RUNTIME_IMAGE=alpine:3.22
|
||||
@@ -71,17 +71,9 @@ COPY packages packages
|
||||
COPY apps/web apps/web
|
||||
|
||||
ARG VITE_GATEWAY_API_BASE_URL=/gateway-api
|
||||
ARG VITE_OIDC_ENABLED=false
|
||||
ARG VITE_OIDC_ISSUER=
|
||||
ARG VITE_OIDC_CLIENT_ID=
|
||||
ARG VITE_OIDC_REDIRECT_URI=
|
||||
ARG VITE_BASE_PATH=/
|
||||
ENV VITE_GATEWAY_API_BASE_URL=$VITE_GATEWAY_API_BASE_URL
|
||||
ENV VITE_OIDC_ENABLED=$VITE_OIDC_ENABLED \
|
||||
VITE_OIDC_ISSUER=$VITE_OIDC_ISSUER \
|
||||
VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID \
|
||||
VITE_OIDC_REDIRECT_URI=$VITE_OIDC_REDIRECT_URI \
|
||||
VITE_BASE_PATH=$VITE_BASE_PATH
|
||||
ENV VITE_BASE_PATH=$VITE_BASE_PATH
|
||||
RUN pnpm --filter @easyai-ai-gateway/web build
|
||||
|
||||
FROM ${WEB_RUNTIME_IMAGE} AS web
|
||||
|
||||
@@ -36,6 +36,22 @@ pnpm dev
|
||||
- PostgreSQL: 目标版本 18,默认使用宿主机 `localhost:5432` 上的 `easyai-pgvector` 实例,并使用独立库 `easyai_ai_gateway`
|
||||
- 身份模式: 默认 `IDENTITY_MODE=hybrid`,可同时测试 Gateway 本地账号注册登录、可选邀请码和 `server-main` JWT / API Key 对接。
|
||||
|
||||
### Auth Center 统一认证
|
||||
|
||||
统一认证不再通过 `OIDC_*` 或 `VITE_OIDC_*` 业务环境变量配置。管理员先在 Auth Center 的通用“应用接入”向导选择 OIDC 登录、API 验证、机器调用、Token Introspection 和 SSF 会话撤销等标准能力,再到 Gateway 的“系统设置 → 统一认证”填写 Auth Center 地址、一次性接入码、API/Web 公网地址和本地租户映射。Gateway 自动领取标准 Manifest 与一次性机器凭据,验证通过后热切换,无需重启。
|
||||
|
||||
部署环境只提供 SecretStore 等启动级配置:
|
||||
|
||||
```dotenv
|
||||
IDENTITY_SECRET_STORE=file
|
||||
IDENTITY_SECRET_DIR=.local-secrets/identity
|
||||
IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60
|
||||
IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180
|
||||
IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60
|
||||
```
|
||||
|
||||
OIDC 用户通过签名、Issuer、Audience、`tid`、Scope 和应用角色校验后,Gateway 可按当前 Active Revision 的策略创建本地业务投影。Web Console 使用公共 Client + PKCE + Gateway BFF Session;浏览器 JavaScript 只持有 HttpOnly 随机 Session Cookie。可选 SSF/CAEP 能力自动复用同一机器凭据,推送不健康时降级到 RFC 7662,内省也不可用时 OIDC Fail Closed。完整行为见 [统一认证运行时配置](docs/standard-identity-runtime-configuration.md)、[OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)和 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。
|
||||
|
||||
`pnpm dev` 会先创建数据库并执行 migration,然后并行启动:
|
||||
|
||||
- `api:dev`:通过 `scripts/go-watch.mjs` 运行 Go API,监听 `.go`、`go.mod`、`go.sum` 变化并自动重启后端进程;watcher 会按进程组终止旧的 `go run` 和其子进程,避免热更新时残留进程占用 API 端口。
|
||||
@@ -89,6 +105,10 @@ Web 容器的 Nginx 配置通过 bind mount 挂载自仓库文件 [docker/nginx.
|
||||
docker compose -f docker-compose.yml restart web
|
||||
```
|
||||
|
||||
## 生产 CI/CD
|
||||
|
||||
Gitea Actions 会在隔离的 rootless DinD Runner 中对 Pull Request、`main` Push 和版本 Tag 执行完整质量门禁;Tag 使用独立的 `release-ci / verify-tag (push)` context,不能复用旧的 `main` 成功状态。源码 Job 没有宿主 Docker、`sudo` 或生产部署权限。部署仓的 root-owned dispatcher 只在相同 SHA 的 `main` 与 Tag context 都成功后,用固定命令构建并扫描镜像,再以 Registry digest 发布 `ai.51easyai.com`。安装 Runner、Fork PR 审批、发布验证和回滚步骤见 [生产 CI/CD 运行手册](docs/runbooks/production-ci-cd.md),信任边界见 [ADR-001](docs/decisions/001-production-cicd.md)。
|
||||
|
||||
Compose 默认使用独立容器数据库 `postgres:18-alpine`,数据卷会保留在 `postgres_data` 和 `api_data`。为避免本地开发 `.env` 中的 `localhost` 数据库地址污染容器部署,compose 使用 `AI_GATEWAY_COMPOSE_*` 变量作为容器部署专用覆盖,例如:
|
||||
|
||||
```bash
|
||||
@@ -105,6 +125,8 @@ AI_GATEWAY_COMPOSE_DATABASE_URL='postgresql://easyai:easyai2025@postgres:5432/ea
|
||||
pnpm openapi
|
||||
```
|
||||
|
||||
中国区可灵 O1 / 3.0 Omni 的 V1 AK/SK 与 API 2.0 兼容接入方式见 [可灵兼容接口说明](docs/kling-compatible-api.md)。
|
||||
|
||||
|
||||
默认 EasyAI 部署里,`easyai-pgvector` 在容器网络内的连接串是:
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: cfg.LogLevel,
|
||||
}))
|
||||
if err := cfg.Validate(); err != nil {
|
||||
logger.Error("invalid gateway configuration", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
const identityPairingStartReservationMigration = "../../migrations/0068_identity_pairing_start_reservation.sql"
|
||||
|
||||
func TestIdentityPairingStartReservationMigrationDefinesCurrentLifecycle(t *testing.T) {
|
||||
payload, err := os.ReadFile(identityPairingStartReservationMigration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := strings.ToLower(string(payload))
|
||||
for _, required := range []string{
|
||||
"state text not null default 'starting'",
|
||||
"revision_id uuid unique references gateway_identity_configuration_revisions",
|
||||
"updated_at timestamptz not null default now()",
|
||||
"gateway_identity_pairing_start_state_check",
|
||||
"idx_gateway_identity_pairing_start_expiry",
|
||||
"on conflict do nothing",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity pairing start reservation migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"exchange_token text", "client_secret", "machine_secret", "secret_value", "drop ", "do $"} {
|
||||
if strings.Contains(content, forbidden) {
|
||||
t.Fatalf("identity pairing start reservation migration contains forbidden content %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPairingStartReservationMigrationSeedsOutstandingPairing(t *testing.T) {
|
||||
pool := newIdentityMigrationPostgresTestSchema(t)
|
||||
ctx := context.Background()
|
||||
for _, migration := range []string{
|
||||
"../../migrations/0065_identity_configuration_revisions.sql",
|
||||
"../../migrations/0066_identity_onboarding_exchanges.sql",
|
||||
} {
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, migration)
|
||||
}
|
||||
|
||||
revisionID := uuid.NewString()
|
||||
pairingID := uuid.NewString()
|
||||
expiresAt := time.Now().UTC().Add(30 * time.Minute).Truncate(time.Microsecond)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_configuration_revisions (
|
||||
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
|
||||
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
|
||||
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
|
||||
t.Fatalf("seed identity revision for pairing reservation migration: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_onboarding_exchanges (
|
||||
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at
|
||||
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,$5)`,
|
||||
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID, expiresAt); err != nil {
|
||||
t.Fatalf("seed identity exchange for pairing reservation migration: %v", err)
|
||||
}
|
||||
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationMigration)
|
||||
|
||||
var attemptID, state, reservedRevisionID string
|
||||
var gotExpiresAt, updatedAt time.Time
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT attempt_id::text,state,revision_id::text,expires_at,updated_at
|
||||
FROM gateway_identity_pairing_start_reservation`).Scan(
|
||||
&attemptID, &state, &reservedRevisionID, &gotExpiresAt, &updatedAt,
|
||||
); err != nil {
|
||||
t.Fatalf("read pairing start reservation: %v", err)
|
||||
}
|
||||
if attemptID != pairingID || state != "paired" || reservedRevisionID != revisionID ||
|
||||
!gotExpiresAt.Equal(expiresAt) || updatedAt.IsZero() {
|
||||
t.Fatalf("unexpected pairing reservation attempt=%q state=%q revision=%q expires=%v updated=%v",
|
||||
attemptID, state, reservedRevisionID, gotExpiresAt, updatedAt)
|
||||
}
|
||||
|
||||
_, err := pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET state='starting'`)
|
||||
requirePairingReservationMigrationCode(t, err, "23514", "starting reservation with revision")
|
||||
_, err = pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET revision_id=$1::uuid`, uuid.NewString())
|
||||
requirePairingReservationMigrationCode(t, err, "23503", "reservation with unknown revision")
|
||||
}
|
||||
|
||||
func requirePairingReservationMigrationCode(t *testing.T, err error, wantCode, operation string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("%s unexpectedly satisfied migration constraints", operation)
|
||||
}
|
||||
var postgresError *pgconn.PgError
|
||||
if !errors.As(err, &postgresError) || postgresError.Code != wantCode {
|
||||
t.Fatalf("%s error=%v, want PostgreSQL code %s", operation, err, wantCode)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
if _, err := conn.Exec(ctx, "SET standard_conforming_strings = on"); err != nil {
|
||||
logger.Error("enforce standard SQL string semantics failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
@@ -64,6 +68,14 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
logger.Error("begin migration failed", "version", version, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Pin string parsing semantics inside every migration transaction. A
|
||||
// previous migration may have changed the session GUC, while each file is
|
||||
// parsed and executed independently.
|
||||
if _, err := tx.Exec(ctx, "SET LOCAL standard_conforming_strings = on"); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
logger.Error("enforce migration SQL string semantics failed", "version", version, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
logger.Error("execute migration failed", "version", version, "error", err)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestSecurityEventSchemaMigrationsDefineCurrentLifecycle(t *testing.T) {
|
||||
streamPayload, err := os.ReadFile("../../migrations/0063_oidc_security_events.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read security event stream migration: %v", err)
|
||||
}
|
||||
streamSQL := string(streamPayload)
|
||||
for _, statement := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS gateway_security_event_stream_state",
|
||||
"CREATE TABLE IF NOT EXISTS gateway_security_event_verification_challenges",
|
||||
"CREATE INDEX IF NOT EXISTS idx_gateway_security_event_challenges_expiry",
|
||||
} {
|
||||
if !strings.Contains(streamSQL, statement) {
|
||||
t.Fatalf("security event stream migration is missing %q", statement)
|
||||
}
|
||||
}
|
||||
|
||||
connectionPayload, err := os.ReadFile("../../migrations/0064_security_event_connections.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read security event connection migration: %v", err)
|
||||
}
|
||||
connectionSQL := string(connectionPayload)
|
||||
for _, statement := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS gateway_security_event_connections",
|
||||
"management_client_id text",
|
||||
"management_credential_ref text",
|
||||
"CREATE TABLE IF NOT EXISTS gateway_security_event_connection_idempotency",
|
||||
} {
|
||||
if !strings.Contains(connectionSQL, statement) {
|
||||
t.Fatalf("security event connection migration is missing %q", statement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityConfigurationRevisionMigrationDefinesSecretReferencesAndSingleActiveRevision(t *testing.T) {
|
||||
payload, err := os.ReadFile("../../migrations/0065_identity_configuration_revisions.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(payload)
|
||||
for _, required := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS gateway_identity_configuration_revisions",
|
||||
"machine_credential_ref text",
|
||||
"session_encryption_key_ref text",
|
||||
"security_event_configuration_url text",
|
||||
"WHERE state = 'active'",
|
||||
"CHECK (state IN ('draft','validated','active','superseded','failed'))",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity configuration migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"machine_secret", "client_secret", "exchange_token text", "onboarding_code text"} {
|
||||
if strings.Contains(strings.ToLower(content), forbidden) {
|
||||
t.Fatalf("identity configuration migration stores forbidden secret field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityOnboardingExchangeMigrationStoresOnlySecretReferences(t *testing.T) {
|
||||
payload, err := os.ReadFile("../../migrations/0066_identity_onboarding_exchanges.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := strings.ToLower(string(payload))
|
||||
for _, required := range []string{
|
||||
"create table if not exists gateway_identity_onboarding_exchanges",
|
||||
"exchange_token_ref text not null",
|
||||
"cleanup_status text not null default 'none'",
|
||||
"security_event_credential_handoff_unsafe",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity onboarding migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"exchange_token text", "onboarding_code", "client_secret", "machine_secret"} {
|
||||
if strings.Contains(content, forbidden) {
|
||||
t.Fatalf("identity onboarding migration stores forbidden secret field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityOnboardingMigrationDefinesCancellationLifecycle(t *testing.T) {
|
||||
payload, err := os.ReadFile("../../migrations/0066_identity_onboarding_exchanges.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := strings.ToLower(string(payload))
|
||||
for _, required := range []string{
|
||||
"'cancelled'",
|
||||
"cleanup_status text not null default 'none'",
|
||||
"cleanup_completed_at",
|
||||
"idx_gateway_identity_onboarding_cleanup",
|
||||
"last_error_category",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity pairing cancellation migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"exchange_token text", "client_secret", "machine_secret"} {
|
||||
if strings.Contains(content, forbidden) {
|
||||
t.Fatalf("identity pairing cancellation migration stores forbidden secret field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityOnboardingMigrationDefinesFinalErrorCategories(t *testing.T) {
|
||||
payload, err := os.ReadFile("../../migrations/0066_identity_onboarding_exchanges.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := strings.ToLower(string(payload))
|
||||
for _, required := range []string{
|
||||
"gateway_identity_onboarding_error_category_check",
|
||||
"security_event_credential_handoff_unsafe",
|
||||
"security_event_connection_binding_missing",
|
||||
"security_event_connection_binding_unavailable",
|
||||
"security_event_connection_binding_invalid",
|
||||
"security_event_connection_binding_mismatch",
|
||||
"cleanup_security_event_credential_handoff_unsafe",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity pairing error category migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPairingErrorCategoriesExecuteAgainstCurrentSchema(t *testing.T) {
|
||||
pool := newIdentityMigrationPostgresTestSchema(t)
|
||||
ctx := context.Background()
|
||||
for _, migration := range []string{
|
||||
"../../migrations/0065_identity_configuration_revisions.sql",
|
||||
"../../migrations/0066_identity_onboarding_exchanges.sql",
|
||||
"../../migrations/0067_identity_secret_cleanup_queue.sql",
|
||||
"../../migrations/0068_identity_pairing_start_reservation.sql",
|
||||
} {
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, migration)
|
||||
}
|
||||
|
||||
revisionID, pairingID := uuid.NewString(), uuid.NewString()
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_configuration_revisions (
|
||||
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
|
||||
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
|
||||
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
|
||||
t.Fatalf("seed identity revision: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_onboarding_exchanges (
|
||||
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at
|
||||
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,now() + interval '30 minutes')`,
|
||||
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil {
|
||||
t.Fatalf("seed onboarding exchange: %v", err)
|
||||
}
|
||||
|
||||
for _, category := range []string{
|
||||
"security_event_credential_handoff_unsafe",
|
||||
"security_event_connection_binding_missing",
|
||||
"security_event_connection_binding_unavailable",
|
||||
"security_event_connection_binding_invalid",
|
||||
"security_event_connection_binding_mismatch",
|
||||
"cleanup_security_event_credential_handoff_unsafe",
|
||||
} {
|
||||
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET last_error_category=$2 WHERE id=$1::uuid`, pairingID, category); err != nil {
|
||||
t.Fatalf("persist supported error category %q: %v", category, err)
|
||||
}
|
||||
}
|
||||
_, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET last_error_category='credential_secret_leaked' WHERE id=$1::uuid`, pairingID)
|
||||
requireIdentityMigrationCheckViolation(t, err, "unknown upgraded identity pairing category")
|
||||
}
|
||||
|
||||
func TestIdentitySecretCleanupQueueMigrationStoresOnlyReferences(t *testing.T) {
|
||||
payload, err := os.ReadFile("../../migrations/0067_identity_secret_cleanup_queue.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := strings.ToLower(string(payload))
|
||||
for _, required := range []string{
|
||||
"create table if not exists gateway_identity_secret_cleanup_queue",
|
||||
"secret_ref text primary key",
|
||||
"not_before timestamptz not null",
|
||||
"status text not null default 'pending'",
|
||||
"claim_token uuid",
|
||||
"lease_expires_at timestamptz",
|
||||
"gateway_identity_secret_cleanup_claim_check",
|
||||
"idx_gateway_identity_secret_cleanup_due",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("identity Secret cleanup migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"secret_value", "client_secret", "machine_secret", "token text"} {
|
||||
if strings.Contains(content, forbidden) {
|
||||
t.Fatalf("identity Secret cleanup migration stores forbidden value field %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPairingCancellationLifecycleRejectsPartialStates(t *testing.T) {
|
||||
pool := newIdentityMigrationPostgresTestSchema(t)
|
||||
ctx := context.Background()
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0065_identity_configuration_revisions.sql")
|
||||
applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0066_identity_onboarding_exchanges.sql")
|
||||
|
||||
revisionID, pairingID := uuid.NewString(), uuid.NewString()
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_configuration_revisions (
|
||||
id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url
|
||||
) VALUES ($1::uuid,'draft','https://auth.test.example','default',
|
||||
'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil {
|
||||
t.Fatalf("seed legacy identity revision: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_identity_onboarding_exchanges (
|
||||
id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at,last_error_category
|
||||
) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,now() + interval '30 minutes','pairing_step_failed')`,
|
||||
pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil {
|
||||
t.Fatalf("seed legacy onboarding exchange: %v", err)
|
||||
}
|
||||
|
||||
var status, cleanupStatus, errorCategory string
|
||||
var cancelledAt, cleanupCompletedAt *time.Time
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT status,cleanup_status,last_error_category,cancelled_at,cleanup_completed_at
|
||||
FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, pairingID).
|
||||
Scan(&status, &cleanupStatus, &errorCategory, &cancelledAt, &cleanupCompletedAt); err != nil {
|
||||
t.Fatalf("read migrated onboarding exchange: %v", err)
|
||||
}
|
||||
if status != "credentials_saved" || cleanupStatus != "none" || errorCategory != "pairing_step_failed" ||
|
||||
cancelledAt != nil || cleanupCompletedAt != nil {
|
||||
t.Fatalf("unexpected migrated exchange status=%q cleanup=%q error=%q cancelled=%v completed=%v",
|
||||
status, cleanupStatus, errorCategory, cancelledAt, cleanupCompletedAt)
|
||||
}
|
||||
|
||||
_, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges SET status='cancelled' WHERE id=$1::uuid`, pairingID)
|
||||
requireIdentityMigrationCheckViolation(t, err, "cancelled status without cleanup intent")
|
||||
|
||||
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET status='cancelled',cleanup_status='pending',cancelled_at=now()
|
||||
WHERE id=$1::uuid`, pairingID); err != nil {
|
||||
t.Fatalf("persist valid pending cleanup lifecycle: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET cleanup_status='completed' WHERE id=$1::uuid`, pairingID)
|
||||
requireIdentityMigrationCheckViolation(t, err, "completed cleanup without completion timestamp")
|
||||
|
||||
if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET cleanup_status='completed',cleanup_completed_at=now()
|
||||
WHERE id=$1::uuid`, pairingID); err != nil {
|
||||
t.Fatalf("persist valid completed cleanup lifecycle: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges
|
||||
SET last_error_category='still-invalid' WHERE id=$1::uuid`, pairingID)
|
||||
requireIdentityMigrationCheckViolation(t, err, "invalid post-migration error category")
|
||||
|
||||
if err := pool.QueryRow(ctx, `SELECT status,cleanup_status FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, pairingID).
|
||||
Scan(&status, &cleanupStatus); err != nil {
|
||||
t.Fatalf("read completed cleanup lifecycle: %v", err)
|
||||
}
|
||||
if status != "cancelled" || cleanupStatus != "completed" {
|
||||
t.Fatalf("completed cleanup lifecycle status=%q cleanup=%q", status, cleanupStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func applyIdentityMigrationTestFile(t *testing.T, ctx context.Context, pool *pgxpool.Pool, path string) {
|
||||
t.Helper()
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", path, err)
|
||||
}
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin migration %s: %v", path, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(payload)); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
t.Fatalf("execute migration %s: %v", path, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
t.Fatalf("commit migration %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireIdentityMigrationCheckViolation(t *testing.T, err error, operation string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("%s unexpectedly satisfied migration constraints", operation)
|
||||
}
|
||||
var postgresError *pgconn.PgError
|
||||
if !errors.As(err, &postgresError) || postgresError.Code != "23514" {
|
||||
t.Fatalf("%s error=%v, want PostgreSQL check violation", operation, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newIdentityMigrationPostgresTestSchema(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity migration PostgreSQL integration tests")
|
||||
}
|
||||
ctx := context.Background()
|
||||
admin, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect identity migration test database: %v", err)
|
||||
}
|
||||
var databaseName string
|
||||
if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("read identity migration test database name: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(databaseName), "test") {
|
||||
admin.Close()
|
||||
t.Fatalf("refusing to use non-test database %q", databaseName)
|
||||
}
|
||||
|
||||
schemaName := "gateway_identity_migration_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
schemaIdentifier := pgx.Identifier{schemaName}.Sanitize()
|
||||
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create identity migration test schema: %v", err)
|
||||
}
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
|
||||
admin.Close()
|
||||
t.Fatalf("parse identity migration test database URL: %v", err)
|
||||
}
|
||||
config.ConnConfig.RuntimeParams["search_path"] = schemaName
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
_, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`)
|
||||
admin.Close()
|
||||
t.Fatalf("connect identity migration test schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pool.Close()
|
||||
if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil {
|
||||
t.Errorf("drop identity migration test schema: %v", err)
|
||||
}
|
||||
admin.Close()
|
||||
})
|
||||
return pool
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package docs
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed swagger.json
|
||||
var SwaggerJSON []byte
|
||||
|
||||
//go:embed swagger.yaml
|
||||
var SwaggerYAML []byte
|
||||
+3089
-172
File diff suppressed because it is too large
Load Diff
+2106
-137
File diff suppressed because it is too large
Load Diff
+12
-6
@@ -1,21 +1,27 @@
|
||||
module github.com/easyai/easyai-ai-gateway/apps/api
|
||||
|
||||
go 1.23
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.5
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/riverqueue/river v0.24.0
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0
|
||||
github.com/riverqueue/river/rivertype v0.24.0
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -30,7 +36,7 @@ require (
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.uber.org/goleak v1.3.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+18
-6
@@ -1,3 +1,7 @@
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -7,12 +11,16 @@ github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwW
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 h1:3U8dTgyNBhEQ/GVw0jZW5q+93Zw2gAZPRWhJ9TwV3rM=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
|
||||
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -61,15 +69,19 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+142
-43
@@ -22,6 +22,10 @@ import (
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
OIDCSessionCookieName = "easyai_gateway_oidc_session"
|
||||
localBreakGlassTokenPurpose = "local_break_glass_manager"
|
||||
legacyAccessTokenPurpose = "legacy_access"
|
||||
|
||||
PermissionPublic Permission = "public"
|
||||
PermissionBasic Permission = "basic"
|
||||
PermissionCreat Permission = "creat"
|
||||
@@ -30,23 +34,27 @@ const (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
Roles []string `json:"role,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||
TenantKey string `json:"tenantKey,omitempty"`
|
||||
SSOID string `json:"sso_id,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||
UserGroupID string `json:"userGroupId,omitempty"`
|
||||
UserGroupKey string `json:"userGroupKey,omitempty"`
|
||||
UserGroupKeys []string `json:"userGroupKeys,omitempty"`
|
||||
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||
APIKeySecret string `json:"apiKeySecret,omitempty"`
|
||||
APIKeyName string `json:"apiKeyName,omitempty"`
|
||||
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
|
||||
APIKeyScopes []string `json:"apiKeyScopes,omitempty"`
|
||||
ID string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
Roles []string `json:"role,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||
TenantKey string `json:"tenantKey,omitempty"`
|
||||
SSOID string `json:"sso_id,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||
UserGroupID string `json:"userGroupId,omitempty"`
|
||||
UserGroupKey string `json:"userGroupKey,omitempty"`
|
||||
UserGroupKeys []string `json:"userGroupKeys,omitempty"`
|
||||
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||
APIKeySecret string `json:"apiKeySecret,omitempty"`
|
||||
APIKeyName string `json:"apiKeyName,omitempty"`
|
||||
APIKeyPrefix string `json:"apiKeyPrefix,omitempty"`
|
||||
APIKeyScopes []string `json:"apiKeyScopes,omitempty"`
|
||||
TokenExpiresAt time.Time `json:"-"`
|
||||
TokenIssuedAt time.Time `json:"-"`
|
||||
Issuer string `json:"-"`
|
||||
TokenPurpose string `json:"-"`
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
@@ -55,16 +63,32 @@ const userContextKey contextKey = "easyai-auth-user"
|
||||
|
||||
var ErrUnauthorized = errors.New("unauthorized")
|
||||
|
||||
type RequestAuthError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *RequestAuthError) Error() string { return e.Code }
|
||||
|
||||
func NewRequestAuthError(status int, code, message string) error {
|
||||
return &RequestAuthError{Status: status, Code: code, Message: message}
|
||||
}
|
||||
|
||||
type Authenticator struct {
|
||||
JWTSecret string
|
||||
ServerMainBaseURL string
|
||||
ServerMainInternalToken string
|
||||
ServerMainInternalKey string
|
||||
ServerMainInternalSecret string
|
||||
HTTPClient *http.Client
|
||||
LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error)
|
||||
OIDCVerifier *OIDCVerifier
|
||||
LegacyJWTEnabled bool
|
||||
JWTSecret string
|
||||
ServerMainBaseURL string
|
||||
ServerMainInternalToken string
|
||||
ServerMainInternalKey string
|
||||
ServerMainInternalSecret string
|
||||
HTTPClient *http.Client
|
||||
LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error)
|
||||
OIDCVerifier *OIDCVerifier
|
||||
OIDCVerifierProvider func() *OIDCVerifier
|
||||
OIDCSessionResolver func(ctx context.Context, sessionID string) (*User, error)
|
||||
OIDCSessionResolverProvider func(ctx context.Context, sessionID string) (*User, error)
|
||||
LegacyJWTEnabled bool
|
||||
LegacyJWTEnabledProvider func() bool
|
||||
}
|
||||
|
||||
func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator {
|
||||
@@ -84,17 +108,31 @@ func UserFromContext(ctx context.Context) (*User, bool) {
|
||||
return user, ok
|
||||
}
|
||||
|
||||
func WithUser(ctx context.Context, user *User) context.Context {
|
||||
return context.WithValue(ctx, userContextKey, user)
|
||||
}
|
||||
|
||||
func (a *Authenticator) Require(permission Permission, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.Authenticate(r)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), ErrUnauthorized.Error()+":") {
|
||||
slog.WarnContext(r.Context(), "OIDC authentication rejected", "reason", err.Error())
|
||||
}
|
||||
if permission == PermissionPublic {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
var requestError *RequestAuthError
|
||||
if errors.As(err, &requestError) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(requestError.Status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
|
||||
"message": requestError.Message, "status": requestError.Status, "code": requestError.Code,
|
||||
}})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), ErrUnauthorized.Error()+":") {
|
||||
slog.WarnContext(r.Context(), "OIDC authentication rejected", "reason", err.Error())
|
||||
}
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -102,20 +140,42 @@ func (a *Authenticator) Require(permission Permission, next http.Handler) http.H
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), user)))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
|
||||
token := extractBearer(r.Header.Get("Authorization"))
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(r.Header.Get("x-comfy-api-key"))
|
||||
var token string
|
||||
if authorization := strings.TrimSpace(r.Header.Get("Authorization")); authorization != "" {
|
||||
token = extractBearer(authorization)
|
||||
if token == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
} else if value := strings.TrimSpace(r.Header.Get("x-comfy-api-key")); value != "" {
|
||||
token = value
|
||||
} else if value := strings.TrimSpace(r.Header.Get("x-goog-api-key")); value != "" {
|
||||
token = value
|
||||
} else if value := strings.TrimSpace(r.URL.Query().Get("key")); value != "" {
|
||||
// Query credentials are retained only for API compatibility with
|
||||
// providers that use `?key=sk-*`. Bearer/OIDC tokens in URLs would leak
|
||||
// through browser history, reverse-proxy logs, and diagnostics.
|
||||
if !strings.HasPrefix(value, "sk-") {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
token = value
|
||||
}
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(r.Header.Get("x-goog-api-key"))
|
||||
}
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(r.URL.Query().Get("key"))
|
||||
if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil {
|
||||
sessionID := strings.TrimSpace(cookie.Value)
|
||||
resolver := a.OIDCSessionResolver
|
||||
if a.OIDCSessionResolverProvider != nil {
|
||||
resolver = a.OIDCSessionResolverProvider
|
||||
}
|
||||
if sessionID == "" || resolver == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
return resolver(r.Context(), sessionID)
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return nil, ErrUnauthorized
|
||||
@@ -125,15 +185,35 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
|
||||
}
|
||||
algorithm := jwtAlgorithm(token)
|
||||
if algorithm == "RS256" || algorithm == "ES256" {
|
||||
if a.OIDCVerifier == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
return a.OIDCVerifier.Verify(r.Context(), token)
|
||||
return a.AuthenticateOIDCAccessToken(r.Context(), token)
|
||||
}
|
||||
if !a.LegacyJWTEnabled {
|
||||
user, err := a.verifyJWT(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.legacyJWTEnabled() || isLocalBreakGlassManager(user) {
|
||||
return user, nil
|
||||
}
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
|
||||
func (a *Authenticator) AuthenticateOIDCAccessToken(ctx context.Context, token string) (*User, error) {
|
||||
algorithm := jwtAlgorithm(token)
|
||||
verifier := a.OIDCVerifier
|
||||
if a.OIDCVerifierProvider != nil {
|
||||
verifier = a.OIDCVerifierProvider()
|
||||
}
|
||||
if verifier == nil || algorithm != "RS256" && algorithm != "ES256" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
return a.verifyJWT(token)
|
||||
return verifier.Verify(ctx, token)
|
||||
}
|
||||
|
||||
func (a *Authenticator) legacyJWTEnabled() bool {
|
||||
if a.LegacyJWTEnabledProvider != nil {
|
||||
return a.LegacyJWTEnabledProvider()
|
||||
}
|
||||
return a.LegacyJWTEnabled
|
||||
}
|
||||
|
||||
func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
|
||||
@@ -169,6 +249,7 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) {
|
||||
APIKeyName: stringClaim(claims, "apiKeyName"),
|
||||
APIKeyPrefix: stringClaim(claims, "apiKeyPrefix"),
|
||||
APIKeyScopes: stringSliceClaim(claims, "apiKeyScopes"),
|
||||
TokenPurpose: stringClaim(claims, "tokenPurpose"),
|
||||
}
|
||||
if user.Source == "" {
|
||||
user.Source = "gateway"
|
||||
@@ -184,6 +265,10 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
|
||||
ttl = time.Hour
|
||||
}
|
||||
now := time.Now()
|
||||
tokenPurpose := legacyAccessTokenPurpose
|
||||
if user.Source == "gateway" && hasManagerRole(user.Roles) {
|
||||
tokenPurpose = localBreakGlassTokenPurpose
|
||||
}
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"username": user.Username,
|
||||
@@ -200,6 +285,7 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
|
||||
"apiKeyName": user.APIKeyName,
|
||||
"apiKeyPrefix": user.APIKeyPrefix,
|
||||
"apiKeyScopes": user.APIKeyScopes,
|
||||
"tokenPurpose": tokenPurpose,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(ttl).Unix(),
|
||||
}
|
||||
@@ -207,6 +293,19 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) {
|
||||
return token.SignedString([]byte(a.JWTSecret))
|
||||
}
|
||||
|
||||
func isLocalBreakGlassManager(user *User) bool {
|
||||
return user != nil && user.Source == "gateway" && user.TokenPurpose == localBreakGlassTokenPurpose && hasManagerRole(user.Roles)
|
||||
}
|
||||
|
||||
func hasManagerRole(roles []string) bool {
|
||||
for _, role := range roles {
|
||||
if role == "manager" || role == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) {
|
||||
if a.LocalAPIKeyVerifier != nil {
|
||||
user, err := a.LocalAPIKeyVerifier(ctx, apiKey)
|
||||
|
||||
+139
-21
@@ -5,12 +5,14 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -21,18 +23,37 @@ import (
|
||||
)
|
||||
|
||||
const maxOIDCResponseBytes = 1 << 20
|
||||
const defaultOIDCHTTPTimeout = 10 * time.Second
|
||||
|
||||
type OIDCConfig struct {
|
||||
Issuer string
|
||||
Audience string
|
||||
TenantID string
|
||||
RolePrefix string
|
||||
RequiredScopes []string
|
||||
JWKSCacheTTL time.Duration
|
||||
IntrospectionEnabled bool
|
||||
IntrospectionClientID string
|
||||
IntrospectionClientSecret string
|
||||
HTTPClient *http.Client
|
||||
AppEnv string
|
||||
Issuer string
|
||||
Audience string
|
||||
TenantID string
|
||||
RolePrefix string
|
||||
RequiredScopes []string
|
||||
JWKSCacheTTL time.Duration
|
||||
IntrospectionEnabled bool
|
||||
IntrospectionClientID string
|
||||
IntrospectionClientSecret string
|
||||
IntrospectionCredentialProvider func(context.Context) (string, []byte, error)
|
||||
HTTPClient *http.Client
|
||||
SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error)
|
||||
IntrospectionObserver func(string)
|
||||
JWKSRefreshFailureObserver func()
|
||||
}
|
||||
|
||||
type OIDCSecurityEventIdentity struct {
|
||||
Issuer string
|
||||
TenantID string
|
||||
Subject string
|
||||
IssuedAt time.Time
|
||||
}
|
||||
|
||||
type OIDCSecurityEventEvaluation struct {
|
||||
Enabled bool
|
||||
Revoked bool
|
||||
RequireIntrospection bool
|
||||
}
|
||||
|
||||
type OIDCVerifier struct {
|
||||
@@ -71,10 +92,11 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
|
||||
config.Audience = strings.TrimSpace(config.Audience)
|
||||
config.TenantID = strings.TrimSpace(config.TenantID)
|
||||
config.RolePrefix = strings.TrimSpace(config.RolePrefix)
|
||||
if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
|
||||
if err := validatePublicURL(config.Issuer, config.AppEnv); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" {
|
||||
return nil, errors.New("issuer, audience, tenant and role prefix are required")
|
||||
}
|
||||
if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
|
||||
if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil &&
|
||||
(strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") {
|
||||
return nil, errors.New("introspection client credentials are required when introspection is enabled")
|
||||
}
|
||||
if config.JWKSCacheTTL <= 0 {
|
||||
@@ -82,13 +104,30 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) {
|
||||
}
|
||||
client := config.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
transport.DisableCompression = true
|
||||
client = &http.Client{Timeout: defaultOIDCHTTPTimeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
}, Transport: transport}
|
||||
}
|
||||
return &OIDCVerifier{config: config, client: client, keys: map[string]any{}}, nil
|
||||
}
|
||||
|
||||
// ValidateConfiguration eagerly validates Discovery, JWKS and, when enabled,
|
||||
// the RFC 7662 endpoint and machine credential before a runtime is activated.
|
||||
func (v *OIDCVerifier) ValidateConfiguration(ctx context.Context) error {
|
||||
if err := v.refresh(ctx, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil {
|
||||
if _, err := v.introspect(ctx, "identity-configuration-probe"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"}))
|
||||
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
|
||||
@@ -117,9 +156,14 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
if !ok || stringClaim(claims, "sub") == "" || stringClaim(claims, "tid") != v.config.TenantID {
|
||||
return nil, oidcUnauthorized("stable identity claims are invalid", nil)
|
||||
}
|
||||
expiresAt, ok := numericDateClaim(claims["exp"])
|
||||
if !ok {
|
||||
return nil, oidcUnauthorized("exp is invalid", nil)
|
||||
}
|
||||
if _, ok := numericDateClaim(claims["nbf"]); !ok {
|
||||
return nil, oidcUnauthorized("nbf is missing", nil)
|
||||
}
|
||||
issuedAt, hasIssuedAt := numericDateClaim(claims["iat"])
|
||||
scopes := scopeClaims(claims)
|
||||
if !containsAll(scopes, v.config.RequiredScopes) {
|
||||
return nil, oidcUnauthorized("required scope is missing", nil)
|
||||
@@ -131,7 +175,34 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
if len(roles) == 0 {
|
||||
return nil, oidcUnauthorized("mapped role is missing", nil)
|
||||
}
|
||||
if v.config.IntrospectionEnabled {
|
||||
if v.config.SecurityEventEvaluator != nil {
|
||||
evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{
|
||||
Issuer: v.config.Issuer, TenantID: v.config.TenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt,
|
||||
})
|
||||
if evaluateErr != nil {
|
||||
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
|
||||
}
|
||||
if evaluation.Enabled && !hasIssuedAt {
|
||||
return nil, oidcUnauthorized("iat is required when security events are enabled", nil)
|
||||
}
|
||||
if evaluation.Revoked {
|
||||
return nil, oidcUnauthorized("token was issued before the revocation watermark", nil)
|
||||
}
|
||||
if evaluation.RequireIntrospection {
|
||||
active, introspectionErr := v.introspect(ctx, raw)
|
||||
if introspectionErr != nil {
|
||||
return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_INTROSPECTION_UNAVAILABLE", "认证中心内省暂时不可用")
|
||||
}
|
||||
if !active {
|
||||
return nil, oidcUnauthorized("token is inactive", nil)
|
||||
}
|
||||
} else if !evaluation.Enabled && v.config.IntrospectionEnabled {
|
||||
active, introspectionErr := v.introspect(ctx, raw)
|
||||
if introspectionErr != nil || !active {
|
||||
return nil, oidcUnauthorized("token is inactive", introspectionErr)
|
||||
}
|
||||
}
|
||||
} else if v.config.IntrospectionEnabled {
|
||||
active, err := v.introspect(ctx, raw)
|
||||
if err != nil || !active {
|
||||
return nil, oidcUnauthorized("token is inactive", err)
|
||||
@@ -143,7 +214,7 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||
}
|
||||
return &User{
|
||||
ID: stringClaim(claims, "sub"), Username: username, Roles: roles,
|
||||
TenantID: v.config.TenantID, Source: "oidc",
|
||||
TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -156,6 +227,9 @@ func oidcUnauthorized(reason string, cause error) error {
|
||||
|
||||
func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) {
|
||||
if err := v.refresh(ctx, false); err != nil {
|
||||
if v.config.JWKSRefreshFailureObserver != nil {
|
||||
v.config.JWKSRefreshFailureObserver()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
v.mu.Lock()
|
||||
@@ -165,6 +239,9 @@ func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) {
|
||||
return key, nil
|
||||
}
|
||||
if err := v.refresh(ctx, true); err != nil {
|
||||
if v.config.JWKSRefreshFailureObserver != nil {
|
||||
v.config.JWKSRefreshFailureObserver()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
v.mu.Lock()
|
||||
@@ -187,10 +264,10 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
|
||||
if err := v.fetchJSON(ctx, discoveryURL, &discovery); err != nil {
|
||||
return err
|
||||
}
|
||||
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil {
|
||||
if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI, v.config.AppEnv) != nil {
|
||||
return errors.New("OIDC discovery metadata is invalid")
|
||||
}
|
||||
if v.config.IntrospectionEnabled && validatePublicURL(discovery.IntrospectionEndpoint) != nil {
|
||||
if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint, v.config.AppEnv) != nil {
|
||||
return errors.New("OIDC introspection metadata is invalid")
|
||||
}
|
||||
var set jsonWebKeySet
|
||||
@@ -217,6 +294,12 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error {
|
||||
}
|
||||
|
||||
func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error) {
|
||||
outcome := "failed"
|
||||
defer func() {
|
||||
if v.config.IntrospectionObserver != nil {
|
||||
v.config.IntrospectionObserver(outcome)
|
||||
}
|
||||
}()
|
||||
v.mu.Lock()
|
||||
endpoint := v.introspectionEndpoint
|
||||
v.mu.Unlock()
|
||||
@@ -230,7 +313,19 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.SetBasicAuth(v.config.IntrospectionClientID, v.config.IntrospectionClientSecret)
|
||||
clientID := v.config.IntrospectionClientID
|
||||
secret := []byte(v.config.IntrospectionClientSecret)
|
||||
if v.config.IntrospectionCredentialProvider != nil {
|
||||
clientID, secret, err = v.config.IntrospectionCredentialProvider(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
defer clear(secret)
|
||||
if strings.TrimSpace(clientID) == "" || len(secret) < 16 {
|
||||
return false, errors.New("OIDC introspection credential is unavailable")
|
||||
}
|
||||
request.SetBasicAuth(clientID, string(secret))
|
||||
response, err := v.client.Do(request)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -247,6 +342,11 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error)
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
return false, errors.New("OIDC introspection response is invalid")
|
||||
}
|
||||
if result.Active {
|
||||
outcome = "active"
|
||||
} else {
|
||||
outcome = "inactive"
|
||||
}
|
||||
return result.Active, nil
|
||||
}
|
||||
|
||||
@@ -307,7 +407,7 @@ func decodeBigInt(value string) (*big.Int, error) {
|
||||
return new(big.Int).SetBytes(payload), nil
|
||||
}
|
||||
|
||||
func validatePublicURL(raw string) error {
|
||||
func validatePublicURL(raw, appEnv string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return errors.New("invalid URL")
|
||||
@@ -315,12 +415,30 @@ func validatePublicURL(raw string) error {
|
||||
if parsed.Scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
if parsed.Scheme == "http" && (parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "localhost") {
|
||||
if parsed.Scheme == "http" && isLocalOIDCEnvironment(appEnv) && isLoopbackOIDCHost(parsed.Hostname()) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("URL must use HTTPS")
|
||||
}
|
||||
|
||||
func isLocalOIDCEnvironment(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "development", "dev", "local", "test":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopbackOIDCHost(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(value)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func numericDateClaim(value any) (time.Time, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var ErrOIDCInvalidGrant = errors.New("OIDC refresh token is invalid")
|
||||
|
||||
var errOIDCResponseTooLarge = errors.New("OIDC response exceeds size limit")
|
||||
|
||||
type OIDCPublicClientConfig struct {
|
||||
AppEnv string
|
||||
Issuer string
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
PostLogoutRedirectURI string
|
||||
Scopes []string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type OIDCTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type OIDCPublicClient struct {
|
||||
config OIDCPublicClientConfig
|
||||
client *http.Client
|
||||
mu sync.Mutex
|
||||
oauth2Config *oauth2.Config
|
||||
metadata oidcClientDiscovery
|
||||
idTokenVerifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
type oidcClientDiscovery struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
}
|
||||
|
||||
func NewOIDCPublicClient(config OIDCPublicClientConfig) (*OIDCPublicClient, error) {
|
||||
config.Issuer = strings.TrimRight(strings.TrimSpace(config.Issuer), "/")
|
||||
config.ClientID = strings.TrimSpace(config.ClientID)
|
||||
config.RedirectURI = strings.TrimSpace(config.RedirectURI)
|
||||
config.PostLogoutRedirectURI = strings.TrimSpace(config.PostLogoutRedirectURI)
|
||||
config.Scopes = normalizedScopes(config.Scopes)
|
||||
for _, scope := range config.Scopes {
|
||||
if strings.EqualFold(scope, oidc.ScopeOfflineAccess) {
|
||||
return nil, errors.New("offline_access is not allowed for Gateway browser sessions")
|
||||
}
|
||||
}
|
||||
if validatePublicURL(config.Issuer, config.AppEnv) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI, config.AppEnv) != nil || validatePublicURL(config.PostLogoutRedirectURI, config.AppEnv) != nil {
|
||||
return nil, errors.New("issuer, public client id and exact redirect URLs are required")
|
||||
}
|
||||
return &OIDCPublicClient{config: config, client: newOIDCHTTPClient(config.HTTPClient)}, nil
|
||||
}
|
||||
|
||||
// ValidateConfiguration eagerly checks public-client Discovery metadata.
|
||||
func (c *OIDCPublicClient) ValidateConfiguration(ctx context.Context) error {
|
||||
_, _, err := c.configuration(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier string) (string, error) {
|
||||
if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) {
|
||||
return "", errors.New("state, nonce and PKCE verifier are required")
|
||||
}
|
||||
config, _, err := c.configuration(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return config.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(pkceVerifier)), nil
|
||||
}
|
||||
|
||||
func (c *OIDCPublicClient) ExchangeCode(ctx context.Context, code, verifier string) (OIDCTokenResponse, error) {
|
||||
if strings.TrimSpace(code) == "" || !validPKCEVerifier(verifier) {
|
||||
return OIDCTokenResponse{}, errors.New("authorization code and PKCE verifier are required")
|
||||
}
|
||||
config, _, err := c.configuration(ctx)
|
||||
if err != nil {
|
||||
return OIDCTokenResponse{}, err
|
||||
}
|
||||
token, err := config.Exchange(c.requestContext(ctx), code, oauth2.VerifierOption(verifier))
|
||||
if err != nil {
|
||||
return OIDCTokenResponse{}, oidcTokenError(err)
|
||||
}
|
||||
return oidcTokenResponse(token)
|
||||
}
|
||||
|
||||
func (c *OIDCPublicClient) Refresh(ctx context.Context, refreshToken string) (OIDCTokenResponse, error) {
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
return OIDCTokenResponse{}, ErrOIDCInvalidGrant
|
||||
}
|
||||
config, _, err := c.configuration(ctx)
|
||||
if err != nil {
|
||||
return OIDCTokenResponse{}, err
|
||||
}
|
||||
token, err := config.TokenSource(c.requestContext(ctx), &oauth2.Token{RefreshToken: refreshToken}).Token()
|
||||
if err != nil {
|
||||
return OIDCTokenResponse{}, oidcTokenError(err)
|
||||
}
|
||||
return oidcTokenResponse(token)
|
||||
}
|
||||
|
||||
func (c *OIDCPublicClient) VerifyIDToken(ctx context.Context, raw, expectedNonce string) (string, error) {
|
||||
expectedNonce = strings.TrimSpace(expectedNonce)
|
||||
if strings.TrimSpace(raw) == "" || expectedNonce == "" {
|
||||
return "", oidcUnauthorized("ID token validation context is invalid", nil)
|
||||
}
|
||||
if _, _, err := c.configuration(ctx); err != nil {
|
||||
return "", oidcUnauthorized("ID token provider discovery failed", err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
verifier := c.idTokenVerifier
|
||||
c.mu.Unlock()
|
||||
if verifier == nil {
|
||||
return "", oidcUnauthorized("ID token verifier is unavailable", nil)
|
||||
}
|
||||
token, err := verifier.Verify(c.requestContext(ctx), raw)
|
||||
if err != nil {
|
||||
return "", oidcUnauthorized("ID token signature or registered claims are invalid", nil)
|
||||
}
|
||||
if token.Subject == "" || token.Nonce != expectedNonce {
|
||||
return "", oidcUnauthorized("ID token subject or nonce is invalid", nil)
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := token.Claims(&claims); err != nil {
|
||||
return "", oidcUnauthorized("ID token claims are invalid", nil)
|
||||
}
|
||||
if _, ok := numericDateClaim(claims["nbf"]); !ok {
|
||||
return "", oidcUnauthorized("ID token nbf is missing", nil)
|
||||
}
|
||||
return token.Subject, nil
|
||||
}
|
||||
|
||||
func (c *OIDCPublicClient) RevokeRefreshToken(ctx context.Context, refreshToken string) error {
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
return nil
|
||||
}
|
||||
_, metadata, err := c.configuration(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if metadata.RevocationEndpoint == "" {
|
||||
return errors.New("OIDC revocation endpoint is unavailable")
|
||||
}
|
||||
response, err := c.postForm(ctx, metadata.RevocationEndpoint, url.Values{
|
||||
"client_id": {c.config.ClientID}, "token": {refreshToken}, "token_type_hint": {"refresh_token"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOIDCResponseBytes))
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("OIDC revocation returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OIDCPublicClient) EndSessionURL(ctx context.Context, idTokenHint string) (string, error) {
|
||||
_, metadata, err := c.configuration(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if metadata.EndSessionEndpoint == "" {
|
||||
return c.config.PostLogoutRedirectURI, nil
|
||||
}
|
||||
parsed, err := url.Parse(metadata.EndSessionEndpoint)
|
||||
if err != nil {
|
||||
return "", errors.New("OIDC end session endpoint is invalid")
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("client_id", c.config.ClientID)
|
||||
query.Set("post_logout_redirect_uri", c.config.PostLogoutRedirectURI)
|
||||
if strings.TrimSpace(idTokenHint) != "" {
|
||||
query.Set("id_token_hint", idTokenHint)
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (c *OIDCPublicClient) postForm(ctx context.Context, endpoint string, form url.Values) (*http.Response, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
return c.client.Do(request)
|
||||
}
|
||||
|
||||
func (c *OIDCPublicClient) configuration(ctx context.Context) (*oauth2.Config, oidcClientDiscovery, error) {
|
||||
c.mu.Lock()
|
||||
if c.oauth2Config != nil {
|
||||
config, metadata := c.oauth2Config, c.metadata
|
||||
c.mu.Unlock()
|
||||
return config, metadata, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
provider, err := oidc.NewProvider(c.requestContext(ctx), c.config.Issuer)
|
||||
if err != nil {
|
||||
return nil, oidcClientDiscovery{}, errors.New("OIDC discovery failed")
|
||||
}
|
||||
var metadata oidcClientDiscovery
|
||||
if err := provider.Claims(&metadata); err != nil || metadata.Issuer != c.config.Issuer ||
|
||||
validatePublicURL(metadata.AuthorizationEndpoint, c.config.AppEnv) != nil || validatePublicURL(metadata.TokenEndpoint, c.config.AppEnv) != nil || validatePublicURL(metadata.JWKSURI, c.config.AppEnv) != nil ||
|
||||
metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint, c.config.AppEnv) != nil ||
|
||||
metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint, c.config.AppEnv) != nil {
|
||||
return nil, oidcClientDiscovery{}, errors.New("OIDC discovery metadata is invalid")
|
||||
}
|
||||
endpoint := provider.Endpoint()
|
||||
// Gateway is a public client. Force client_id into the form and never probe
|
||||
// HTTP Basic authentication with an empty client secret.
|
||||
endpoint.AuthStyle = oauth2.AuthStyleInParams
|
||||
config := &oauth2.Config{
|
||||
ClientID: c.config.ClientID, RedirectURL: c.config.RedirectURI,
|
||||
Endpoint: endpoint, Scopes: append([]string(nil), c.config.Scopes...),
|
||||
}
|
||||
verifierContext := oidc.ClientContext(context.Background(), c.client)
|
||||
idTokenVerifier := provider.VerifierContext(verifierContext, &oidc.Config{
|
||||
ClientID: c.config.ClientID, SupportedSigningAlgs: []string{oidc.RS256, oidc.ES256},
|
||||
})
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.oauth2Config == nil {
|
||||
c.oauth2Config = config
|
||||
c.metadata = metadata
|
||||
c.idTokenVerifier = idTokenVerifier
|
||||
}
|
||||
return c.oauth2Config, c.metadata, nil
|
||||
}
|
||||
|
||||
func (c *OIDCPublicClient) requestContext(ctx context.Context) context.Context {
|
||||
return oidc.ClientContext(ctx, c.client)
|
||||
}
|
||||
|
||||
func oidcTokenError(err error) error {
|
||||
var retrieveError *oauth2.RetrieveError
|
||||
if errors.As(err, &retrieveError) {
|
||||
if retrieveError.ErrorCode == "invalid_grant" {
|
||||
return ErrOIDCInvalidGrant
|
||||
}
|
||||
if retrieveError.Response != nil {
|
||||
return fmt.Errorf("OIDC token endpoint returned HTTP %d", retrieveError.Response.StatusCode)
|
||||
}
|
||||
}
|
||||
return errors.New("OIDC token endpoint request failed")
|
||||
}
|
||||
|
||||
func oidcTokenResponse(token *oauth2.Token) (OIDCTokenResponse, error) {
|
||||
if token == nil || strings.TrimSpace(token.AccessToken) == "" {
|
||||
return OIDCTokenResponse{}, errors.New("OIDC token response is invalid")
|
||||
}
|
||||
idToken, _ := token.Extra("id_token").(string)
|
||||
return OIDCTokenResponse{
|
||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, IDToken: idToken,
|
||||
TokenType: token.TokenType, ExpiresIn: int(token.ExpiresIn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validPKCEVerifier(value string) bool {
|
||||
if len(value) < 43 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || strings.ContainsRune("-._~", char) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func newOIDCHTTPClient(base *http.Client) *http.Client {
|
||||
if base == nil {
|
||||
base = &http.Client{Timeout: defaultOIDCHTTPTimeout}
|
||||
}
|
||||
client := *base
|
||||
transport := client.Transport
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
client.Transport = oidcResponseLimitTransport{base: transport}
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &client
|
||||
}
|
||||
|
||||
type oidcResponseLimitTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t oidcResponseLimitTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
response, err := t.base.RoundTrip(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.Body = &oidcLimitedReadCloser{body: response.Body, remaining: maxOIDCResponseBytes}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type oidcLimitedReadCloser struct {
|
||||
body io.ReadCloser
|
||||
remaining int64
|
||||
}
|
||||
|
||||
func (r *oidcLimitedReadCloser) Read(buffer []byte) (int, error) {
|
||||
if r.remaining == 0 {
|
||||
var extra [1]byte
|
||||
if count, err := r.body.Read(extra[:]); count > 0 {
|
||||
return 0, errOIDCResponseTooLarge
|
||||
} else {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if int64(len(buffer)) > r.remaining {
|
||||
buffer = buffer[:r.remaining]
|
||||
}
|
||||
count, err := r.body.Read(buffer)
|
||||
r.remaining -= int64(count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *oidcLimitedReadCloser) Close() error {
|
||||
return r.body.Close()
|
||||
}
|
||||
|
||||
func normalizedScopes(scopes []string) []string {
|
||||
result := make([]string, 0, len(scopes)+1)
|
||||
seen := map[string]struct{}{}
|
||||
for _, scope := range append([]string{oidc.ScopeOpenID}, scopes...) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
result = append(result, scope)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T) {
|
||||
const pkceVerifier = "test-pkce-verifier-with-at-least-43-characters-1234"
|
||||
var issuer string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
|
||||
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
|
||||
"revocation_endpoint": issuer + "/revoke",
|
||||
"end_session_endpoint": issuer + "/logout",
|
||||
})
|
||||
case "/token":
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
values, _ := url.ParseQuery(string(body))
|
||||
if values.Get("client_secret") != "" || strings.Contains(r.Header.Get("Authorization"), "Basic") {
|
||||
t.Error("public client token request must not contain client credentials")
|
||||
http.Error(w, "invalid client authentication", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if values.Get("grant_type") != "authorization_code" || values.Get("client_id") != "gateway-public" || values.Get("code_verifier") != pkceVerifier {
|
||||
t.Error("token request omitted authorization code, public client id, or PKCE verifier")
|
||||
http.Error(w, "invalid token request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-token", "refresh_token": "refresh-token",
|
||||
"id_token": "id-token", "expires_in": 300, "token_type": "Bearer",
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
|
||||
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/gateway-api/api/v1/auth/oidc/callback",
|
||||
PostLogoutRedirectURI: "https://gateway.example.com/", Scopes: []string{"openid", "profile", "gateway.access"}, HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.ValidateConfiguration(context.Background()); err != nil {
|
||||
t.Fatalf("ValidateConfiguration() error = %v", err)
|
||||
}
|
||||
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, _ := url.Parse(authorizationURL)
|
||||
query := parsed.Query()
|
||||
digest := sha256.Sum256([]byte(pkceVerifier))
|
||||
expectedChallenge := base64.RawURLEncoding.EncodeToString(digest[:])
|
||||
if query.Get("response_type") != "code" || query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") != expectedChallenge {
|
||||
t.Fatalf("authorization request is not PKCE S256: %v", query)
|
||||
}
|
||||
if _, err := client.ExchangeCode(context.Background(), "authorization-code", pkceVerifier); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) {
|
||||
_, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "production",
|
||||
Issuer: "https://auth.example.com", ClientID: "gateway-public",
|
||||
RedirectURI: "https://gateway.example.com/api/v1/auth/oidc/callback",
|
||||
PostLogoutRedirectURI: "https://gateway.example.com/",
|
||||
Scopes: []string{"openid", "gateway.access", "offline_access"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "offline_access") {
|
||||
t.Fatalf("NewOIDCPublicClient() error = %v, want offline_access rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCPublicClientVerifiesIDTokenNonceAndAudience(t *testing.T) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var issuer string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
|
||||
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "ES256"},
|
||||
})
|
||||
case "/jwks":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
|
||||
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, ClientID: "gateway-public-client", RedirectURI: "https://gateway.example.com/callback",
|
||||
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idToken := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) {
|
||||
claims["aud"] = "gateway-public-client"
|
||||
claims["nonce"] = "expected-nonce"
|
||||
})
|
||||
subject, err := client.VerifyIDToken(context.Background(), idToken, "expected-nonce")
|
||||
if err != nil || subject != "platform-subject" {
|
||||
t.Fatalf("VerifyIDToken() subject=%q err=%v", subject, err)
|
||||
}
|
||||
if _, err := client.VerifyIDToken(context.Background(), idToken, "wrong-nonce"); err == nil {
|
||||
t.Fatal("ID token with mismatched nonce was accepted")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(jwt.MapClaims)
|
||||
}{
|
||||
{name: "wrong audience", mutate: func(claims jwt.MapClaims) { claims["aud"] = "other-client" }},
|
||||
{name: "missing nbf", mutate: func(claims jwt.MapClaims) { delete(claims, "nbf") }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
invalid := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) {
|
||||
claims["aud"] = "gateway-public-client"
|
||||
claims["nonce"] = "expected-nonce"
|
||||
test.mutate(claims)
|
||||
})
|
||||
if _, err := client.VerifyIDToken(context.Background(), invalid, "expected-nonce"); err == nil {
|
||||
t.Fatal("invalid ID token was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) {
|
||||
var issuer string
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
|
||||
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
|
||||
"revocation_endpoint": issuer + "/revoke",
|
||||
"end_session_endpoint": issuer + "/logout",
|
||||
})
|
||||
case "/token", "/revoke":
|
||||
requests++
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
values, _ := url.ParseQuery(string(body))
|
||||
if values.Get("client_secret") != "" || r.Header.Get("Authorization") != "" {
|
||||
t.Error("public client request contained client authentication")
|
||||
http.Error(w, "invalid client authentication", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/token" {
|
||||
if values.Get("grant_type") != "refresh_token" || values.Get("refresh_token") != "old-refresh" {
|
||||
t.Error("refresh request omitted grant type or refresh token")
|
||||
http.Error(w, "invalid refresh request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 300})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
|
||||
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.Refresh(context.Background(), "old-refresh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.RevokeRefreshToken(context.Background(), "new-refresh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requests != 2 {
|
||||
t.Fatalf("requests = %d, want 2", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testing.T) {
|
||||
var issuer string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
|
||||
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
|
||||
})
|
||||
case "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"sensitive-provider-detail"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
|
||||
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback",
|
||||
PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = client.Refresh(context.Background(), "redacted-refresh-token")
|
||||
if !errors.Is(err, ErrOIDCInvalidGrant) || strings.Contains(err.Error(), "sensitive-provider-detail") {
|
||||
t.Fatalf("invalid_grant mapping was not stable and redacted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCPublicClientRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) {
|
||||
var issuer, insecureField string
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path != "/.well-known/openid-configuration" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"issuer": issuer, "authorization_endpoint": issuer + "/authorize",
|
||||
"token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks",
|
||||
"revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "ES256"},
|
||||
}
|
||||
metadata[insecureField] = "http://127.0.0.1:1/oidc-endpoint"
|
||||
_ = json.NewEncoder(w).Encode(metadata)
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
|
||||
for _, field := range []string{
|
||||
"authorization_endpoint", "token_endpoint", "jwks_uri", "revocation_endpoint", "end_session_endpoint",
|
||||
} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
insecureField = field
|
||||
client, err := NewOIDCPublicClient(OIDCPublicClientConfig{
|
||||
AppEnv: "production", Issuer: issuer, ClientID: "gateway-public",
|
||||
RedirectURI: "https://gateway.example.com/callback", PostLogoutRedirectURI: "https://gateway.example.com/",
|
||||
HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.ValidateConfiguration(context.Background()); err == nil {
|
||||
t.Fatalf("production accepted loopback HTTP %s", field)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestAuthenticateResolvesOpaqueOIDCSessionCookie(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
var resolved string
|
||||
authenticator.OIDCSessionResolver = func(_ context.Context, raw string) (*User, error) {
|
||||
resolved = raw
|
||||
return &User{ID: "platform-subject", Source: "oidc", Roles: []string{"user"}}, nil
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "opaque-session-id"})
|
||||
user, err := authenticator.Authenticate(request)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticate OIDC session cookie: %v", err)
|
||||
}
|
||||
if user.ID != "platform-subject" || user.Source != "oidc" {
|
||||
t.Fatalf("unexpected session user: %#v", user)
|
||||
}
|
||||
if resolved != "opaque-session-id" {
|
||||
t.Fatalf("session resolver received %q", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateBearerTakesPrecedenceOverOIDCSessionCookie(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
localToken, err := authenticator.SignJWT(&User{ID: "local-user", Source: "gateway", Roles: []string{"user"}}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+localToken)
|
||||
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "not-a-token"})
|
||||
|
||||
user, err := authenticator.Authenticate(request)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticate bearer token: %v", err)
|
||||
}
|
||||
if user.ID != "local-user" || user.Source != "gateway" {
|
||||
t.Fatalf("cookie overrode explicit bearer credentials: %#v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRejectsMalformedExplicitCredentialInsteadOfFallingBackToCookie(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
var resolved bool
|
||||
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
|
||||
resolved = true
|
||||
return &User{ID: "cookie-manager", Source: "gateway", Roles: []string{"manager"}}, nil
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
|
||||
request.Header.Set("Authorization", "not-a-bearer-credential")
|
||||
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "valid-cookie-session"})
|
||||
|
||||
if _, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("malformed explicit credential error=%v, want unauthorized", err)
|
||||
}
|
||||
if resolved {
|
||||
t.Fatal("malformed Authorization header fell back to the OIDC session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRejectsManagerJWTInQueryWithoutCookieFallback(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
managerToken, err := authenticator.SignJWT(&User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resolved bool
|
||||
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
|
||||
resolved = true
|
||||
return &User{ID: "cookie-manager", Source: "gateway", Roles: []string{"manager"}}, nil
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable?key="+managerToken, nil)
|
||||
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "valid-cookie-session"})
|
||||
|
||||
if _, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("manager query JWT error=%v, want unauthorized", err)
|
||||
}
|
||||
if resolved {
|
||||
t.Fatal("rejected query credential fell back to the OIDC session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRejectsInvalidOIDCSessionCookie(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "not-a-token"})
|
||||
if _, err := authenticator.Authenticate(request); err == nil {
|
||||
t.Fatal("invalid OIDC session cookie was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorReadsOIDCSessionResolverAndLegacyPolicyDynamically(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
currentUser := &User{ID: "first", Roles: []string{"manager"}}
|
||||
authenticator.OIDCSessionResolverProvider = func(ctx context.Context, sessionID string) (*User, error) {
|
||||
return currentUser, nil
|
||||
}
|
||||
authenticator.LegacyJWTEnabledProvider = func() bool { return false }
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "opaque-session-id"})
|
||||
user, err := authenticator.Authenticate(request)
|
||||
if err != nil || user.ID != "first" {
|
||||
t.Fatalf("first runtime session resolution failed: user=%#v err=%v", user, err)
|
||||
}
|
||||
currentUser = &User{ID: "second", Roles: []string{"manager"}}
|
||||
user, err = authenticator.Authenticate(request)
|
||||
if err != nil || user.ID != "second" {
|
||||
t.Fatalf("swapped runtime session resolution failed: user=%#v err=%v", user, err)
|
||||
}
|
||||
if authenticator.legacyJWTEnabled() {
|
||||
t.Fatal("dynamic legacy JWT policy was ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateKeepsOnlySignedBreakGlassManagerWhenLegacyJWTDisabled(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
authenticator.LegacyJWTEnabledProvider = func() bool { return false }
|
||||
|
||||
managerToken, err := authenticator.SignJWT(&User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
managerRequest := httptest.NewRequest(http.MethodGet, "/api/admin/system/identity/configuration", nil)
|
||||
managerRequest.Header.Set("Authorization", "Bearer "+managerToken)
|
||||
manager, err := authenticator.Authenticate(managerRequest)
|
||||
if err != nil || manager.ID != "manager" {
|
||||
t.Fatalf("signed break-glass manager was rejected: user=%#v err=%v", manager, err)
|
||||
}
|
||||
|
||||
userToken, err := authenticator.SignJWT(&User{ID: "user", Source: "gateway", Roles: []string{"user"}}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userRequest := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
userRequest.Header.Set("Authorization", "Bearer "+userToken)
|
||||
if _, err := authenticator.Authenticate(userRequest); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("ordinary local JWT error = %v, want unauthorized", err)
|
||||
}
|
||||
|
||||
legacyManager := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": "legacy-manager", "source": "gateway", "role": []string{"manager"},
|
||||
"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
legacyManagerToken, err := legacyManager.SignedString([]byte(authenticator.JWTSecret))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyRequest := httptest.NewRequest(http.MethodGet, "/api/admin/system/identity/configuration", nil)
|
||||
legacyRequest.Header.Set("Authorization", "Bearer "+legacyManagerToken)
|
||||
if _, err := authenticator.Authenticate(legacyRequest); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("legacy manager without token purpose error = %v, want unauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRouteIgnoresExpiredOptionalOIDCSession(t *testing.T) {
|
||||
authenticator := New("local-jwt-secret", "", "")
|
||||
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
|
||||
return nil, &RequestAuthError{Status: http.StatusUnauthorized, Code: "OIDC_SESSION_EXPIRED", Message: "expired"}
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/public/catalog/providers", nil)
|
||||
request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "opaque-session-id"})
|
||||
recorder := httptest.NewRecorder()
|
||||
authenticator.Require(PermissionPublic, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("public route status = %d, want %d", recorder.Code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -42,12 +43,16 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) {
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
verifier, err := NewOIDCVerifier(OIDCConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1",
|
||||
RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifier.ValidateConfiguration(context.Background()); err != nil {
|
||||
t.Fatalf("ValidateConfiguration() error = %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
kid string
|
||||
@@ -81,6 +86,7 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
verifier, _ := NewOIDCVerifier(OIDCConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
||||
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
||||
})
|
||||
@@ -135,10 +141,13 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
verifier, err := NewOIDCVerifier(OIDCConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
||||
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
||||
IntrospectionEnabled: true, IntrospectionClientID: "gateway-api",
|
||||
IntrospectionClientSecret: "introspection-secret",
|
||||
IntrospectionEnabled: true,
|
||||
IntrospectionCredentialProvider: func(context.Context) (string, []byte, error) {
|
||||
return "gateway-api", []byte("introspection-secret"), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -153,6 +162,141 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *testing.T) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
active, introspectionAvailable, introspectionCalls := true, true, 0
|
||||
var issuer string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks", "introspection_endpoint": issuer + "/introspect"})
|
||||
case "/jwks":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}})
|
||||
case "/introspect":
|
||||
introspectionCalls++
|
||||
if !introspectionAvailable {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"active": active})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
evaluation := OIDCSecurityEventEvaluation{RequireIntrospection: true}
|
||||
verifier, err := NewOIDCVerifier(OIDCConfig{
|
||||
AppEnv: "test",
|
||||
Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
||||
RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(),
|
||||
IntrospectionEnabled: true,
|
||||
IntrospectionClientID: "gateway-introspection", IntrospectionClientSecret: "introspection-secret",
|
||||
SecurityEventEvaluator: func(_ context.Context, identity OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) {
|
||||
if identity.Subject != "platform-subject" {
|
||||
t.Fatal("security event evaluator received incomplete identity")
|
||||
}
|
||||
return evaluation, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, nil)
|
||||
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
|
||||
t.Fatalf("fallback token error=%v calls=%d", err, introspectionCalls)
|
||||
}
|
||||
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
|
||||
if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 {
|
||||
t.Fatalf("healthy push token error=%v calls=%d", err, introspectionCalls)
|
||||
}
|
||||
evaluation = OIDCSecurityEventEvaluation{Revoked: true}
|
||||
if _, err := verifier.Verify(context.Background(), raw); err == nil || introspectionCalls != 1 {
|
||||
t.Fatalf("revoked token error=%v calls=%d", err, introspectionCalls)
|
||||
}
|
||||
evaluation = OIDCSecurityEventEvaluation{RequireIntrospection: true}
|
||||
introspectionAvailable = false
|
||||
_, err = verifier.Verify(context.Background(), raw)
|
||||
var requestError *RequestAuthError
|
||||
if !errors.As(err, &requestError) || requestError.Status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("introspection outage error=%T %v", err, err)
|
||||
}
|
||||
withoutIssuedAt := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) { delete(claims, "iat") })
|
||||
evaluation = OIDCSecurityEventEvaluation{}
|
||||
introspectionAvailable = true
|
||||
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err != nil {
|
||||
t.Fatalf("unconfigured security events unexpectedly required iat: %v", err)
|
||||
}
|
||||
evaluation = OIDCSecurityEventEvaluation{Enabled: true}
|
||||
if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err == nil {
|
||||
t.Fatal("configured security events accepted a token without iat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCVerifierRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var issuer, jwksURI, introspectionEndpoint string
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch request.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": issuer, "jwks_uri": jwksURI, "introspection_endpoint": introspectionEndpoint,
|
||||
})
|
||||
case "/jwks":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{rsaJWK("rsa-key", &key.PublicKey)}})
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
issuer = server.URL
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
jwks string
|
||||
introspection string
|
||||
}{
|
||||
{name: "JWKS", jwks: "http://127.0.0.1:1/jwks"},
|
||||
{name: "introspection", jwks: issuer + "/jwks", introspection: "http://localhost:1/introspect"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
jwksURI, introspectionEndpoint = test.jwks, test.introspection
|
||||
config := OIDCConfig{
|
||||
AppEnv: "production", Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.",
|
||||
HTTPClient: server.Client(),
|
||||
}
|
||||
if test.introspection != "" {
|
||||
config.IntrospectionEnabled = true
|
||||
config.IntrospectionCredentialProvider = func(context.Context) (string, []byte, error) {
|
||||
return "gateway-machine", []byte("machine-secret-long-enough"), nil
|
||||
}
|
||||
}
|
||||
verifier, createErr := NewOIDCVerifier(config)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
if validateErr := verifier.ValidateConfiguration(context.Background()); validateErr == nil {
|
||||
t.Fatalf("production accepted loopback HTTP %s endpoint", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCURLPolicyAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
|
||||
for _, appEnv := range []string{"", "production", "staging"} {
|
||||
if err := validatePublicURL("http://127.0.0.2:18003/issuer/easyai", appEnv); err == nil {
|
||||
t.Fatalf("%s accepted loopback HTTP OIDC URL", appEnv)
|
||||
}
|
||||
}
|
||||
for _, appEnv := range []string{"local", "development", "dev", "test"} {
|
||||
if err := validatePublicURL("http://127.0.0.2:18003/issuer/easyai", appEnv); err != nil {
|
||||
t.Fatalf("%s rejected loopback HTTP OIDC URL: %v", appEnv, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const OpenAIReasoningEffortValidationMessage = "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh, max"
|
||||
|
||||
var (
|
||||
openAIReasoningEfforts = map[string]struct{}{
|
||||
"none": {},
|
||||
"minimal": {},
|
||||
"low": {},
|
||||
"medium": {},
|
||||
"high": {},
|
||||
"xhigh": {},
|
||||
"max": {},
|
||||
}
|
||||
volcesChatReasoningEfforts = map[string]struct{}{
|
||||
"minimal": {},
|
||||
"low": {},
|
||||
"medium": {},
|
||||
"high": {},
|
||||
}
|
||||
zhipuReasoningEfforts = map[string]struct{}{
|
||||
"none": {},
|
||||
"minimal": {},
|
||||
"low": {},
|
||||
"medium": {},
|
||||
"high": {},
|
||||
"xhigh": {},
|
||||
}
|
||||
)
|
||||
|
||||
func ValidateOpenAIReasoningEffort(value any) error {
|
||||
effort := normalizedReasoningString(value)
|
||||
if effort == "" {
|
||||
return nil
|
||||
}
|
||||
if isOpenAIReasoningEffort(effort) {
|
||||
return nil
|
||||
}
|
||||
return &ClientError{Code: "invalid_parameter", Message: OpenAIReasoningEffortValidationMessage, Retryable: false}
|
||||
}
|
||||
|
||||
func applyOpenAIChatReasoningParams(body map[string]any, candidate store.RuntimeModelCandidate) {
|
||||
effort := normalizedReasoningString(body["reasoning_effort"])
|
||||
if effort == "" || !isOpenAIReasoningEffort(effort) {
|
||||
return
|
||||
}
|
||||
body["reasoning_effort"] = effort
|
||||
|
||||
switch {
|
||||
case isAliyunBailianOpenAI(candidate):
|
||||
applyAliyunReasoning(body, candidate, effort)
|
||||
case isDeepSeekOpenAI(candidate):
|
||||
applyHighMaxThinkingReasoning(body, effort)
|
||||
case isZhipuOpenAI(candidate):
|
||||
applyZhipuReasoning(body, candidate, effort)
|
||||
case isVolcesOpenAI(candidate):
|
||||
applyVolcesReasoning(body, candidate, effort)
|
||||
}
|
||||
}
|
||||
|
||||
func applyAliyunReasoning(body map[string]any, candidate store.RuntimeModelCandidate, effort string) {
|
||||
defer delete(body, "thinking_budget_tokens")
|
||||
|
||||
if effort == "none" {
|
||||
body["enable_thinking"] = false
|
||||
delete(body, "reasoning_effort")
|
||||
return
|
||||
}
|
||||
|
||||
body["enable_thinking"] = true
|
||||
model := chatReasoningModelName(body, candidate)
|
||||
if isAliyunHighMaxReasoningModel(model) {
|
||||
body["reasoning_effort"] = highMaxReasoningEffort(effort)
|
||||
return
|
||||
}
|
||||
|
||||
delete(body, "reasoning_effort")
|
||||
if isAliyunThinkingBudgetModel(model) {
|
||||
if budget, ok := positiveIntFromAny(body["thinking_budget_tokens"]); ok {
|
||||
body["thinking_budget"] = budget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyHighMaxThinkingReasoning(body map[string]any, effort string) {
|
||||
if effort == "none" {
|
||||
body["thinking"] = map[string]any{"type": "disabled"}
|
||||
delete(body, "reasoning_effort")
|
||||
return
|
||||
}
|
||||
body["thinking"] = map[string]any{"type": "enabled"}
|
||||
body["reasoning_effort"] = highMaxReasoningEffort(effort)
|
||||
}
|
||||
|
||||
func applyZhipuReasoning(body map[string]any, candidate store.RuntimeModelCandidate, effort string) {
|
||||
if effort == "none" {
|
||||
body["thinking"] = map[string]any{"type": "disabled"}
|
||||
delete(body, "reasoning_effort")
|
||||
return
|
||||
}
|
||||
body["thinking"] = map[string]any{"type": "enabled"}
|
||||
if !isZhipuReasoningEffortModel(chatReasoningModelName(body, candidate)) {
|
||||
delete(body, "reasoning_effort")
|
||||
return
|
||||
}
|
||||
if mapped := zhipuReasoningEffort(effort); mapped != "" {
|
||||
body["reasoning_effort"] = mapped
|
||||
return
|
||||
}
|
||||
delete(body, "reasoning_effort")
|
||||
}
|
||||
|
||||
func applyVolcesReasoning(body map[string]any, _ store.RuntimeModelCandidate, effort string) {
|
||||
if effort == "none" {
|
||||
body["thinking"] = map[string]any{"type": "disabled"}
|
||||
delete(body, "reasoning_effort")
|
||||
return
|
||||
}
|
||||
|
||||
body["thinking"] = map[string]any{"type": "enabled"}
|
||||
if mapped := volcesChatReasoningEffort(effort); mapped != "" {
|
||||
body["reasoning_effort"] = mapped
|
||||
return
|
||||
}
|
||||
delete(body, "reasoning_effort")
|
||||
}
|
||||
|
||||
func normalizedReasoningString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
text = fmt.Sprint(value)
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(text))
|
||||
}
|
||||
|
||||
func isOpenAIReasoningEffort(effort string) bool {
|
||||
_, ok := openAIReasoningEfforts[effort]
|
||||
return ok
|
||||
}
|
||||
|
||||
func chatReasoningModelName(body map[string]any, candidate store.RuntimeModelCandidate) string {
|
||||
for _, value := range []any{
|
||||
body["model"],
|
||||
candidate.ProviderModelName,
|
||||
candidate.ModelName,
|
||||
candidate.ModelAlias,
|
||||
} {
|
||||
if text := normalizedReasoningString(value); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func providerCode(candidate store.RuntimeModelCandidate) string {
|
||||
return normalizedReasoningString(candidate.Provider)
|
||||
}
|
||||
|
||||
func baseURLCode(candidate store.RuntimeModelCandidate) string {
|
||||
return normalizedReasoningString(candidate.BaseURL)
|
||||
}
|
||||
|
||||
func isAliyunBailianOpenAI(candidate store.RuntimeModelCandidate) bool {
|
||||
provider := providerCode(candidate)
|
||||
baseURL := baseURLCode(candidate)
|
||||
return provider == "aliyun-bailian-openai" || strings.Contains(baseURL, "dashscope.")
|
||||
}
|
||||
|
||||
func isVolcesOpenAI(candidate store.RuntimeModelCandidate) bool {
|
||||
provider := providerCode(candidate)
|
||||
baseURL := baseURLCode(candidate)
|
||||
return provider == "volces-openai" || strings.Contains(baseURL, "volces.com") || strings.Contains(baseURL, "byteplus.com")
|
||||
}
|
||||
|
||||
func isDeepSeekOpenAI(candidate store.RuntimeModelCandidate) bool {
|
||||
provider := providerCode(candidate)
|
||||
baseURL := baseURLCode(candidate)
|
||||
return provider == "deepseek-openai" || strings.Contains(baseURL, "api.deepseek.com")
|
||||
}
|
||||
|
||||
func isZhipuOpenAI(candidate store.RuntimeModelCandidate) bool {
|
||||
provider := providerCode(candidate)
|
||||
baseURL := baseURLCode(candidate)
|
||||
return provider == "zhipu-openai" || strings.Contains(baseURL, "bigmodel.cn") || strings.Contains(baseURL, "api.z.ai")
|
||||
}
|
||||
|
||||
func isAliyunHighMaxReasoningModel(model string) bool {
|
||||
return strings.Contains(model, "deepseek-v4") || strings.HasPrefix(model, "glm-")
|
||||
}
|
||||
|
||||
func isAliyunThinkingBudgetModel(model string) bool {
|
||||
return strings.Contains(model, "qwen") || strings.Contains(model, "qwq") || strings.Contains(model, "qvq") || strings.Contains(model, "kimi")
|
||||
}
|
||||
|
||||
func isVolcesReasoningEffortModel(model string) bool {
|
||||
return strings.HasPrefix(model, "doubao-seed-2-")
|
||||
}
|
||||
|
||||
func isZhipuReasoningEffortModel(model string) bool {
|
||||
return model == "" || strings.HasPrefix(model, "glm-5.2") || strings.HasPrefix(model, "glm-5-2")
|
||||
}
|
||||
|
||||
func highMaxReasoningEffort(effort string) string {
|
||||
if effort == "xhigh" || effort == "max" {
|
||||
return "max"
|
||||
}
|
||||
return "high"
|
||||
}
|
||||
|
||||
func zhipuReasoningEffort(effort string) string {
|
||||
if effort == "max" {
|
||||
return "xhigh"
|
||||
}
|
||||
if _, ok := zhipuReasoningEfforts[effort]; ok {
|
||||
return effort
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func volcesChatReasoningEffort(effort string) string {
|
||||
switch effort {
|
||||
case "none":
|
||||
return "minimal"
|
||||
case "xhigh", "max":
|
||||
return "high"
|
||||
default:
|
||||
if _, ok := volcesChatReasoningEfforts[effort]; ok {
|
||||
return effort
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func positiveIntFromAny(value any) (int, bool) {
|
||||
if value == nil {
|
||||
return 0, false
|
||||
}
|
||||
var number float64
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
number = float64(typed)
|
||||
case int64:
|
||||
number = float64(typed)
|
||||
case float64:
|
||||
number = typed
|
||||
case float32:
|
||||
number = float64(typed)
|
||||
case jsonNumber:
|
||||
parsed, err := typed.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
number = parsed
|
||||
case string:
|
||||
parsed := normalizedReasoningString(typed)
|
||||
if parsed == "" {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseFloat(parsed, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
number = value
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
if math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int(math.Floor(number)), true
|
||||
}
|
||||
|
||||
type jsonNumber interface {
|
||||
Float64() (float64, error)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestCurrentOpenAIReasoningEffortMaxProviderMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
candidate store.RuntimeModelCandidate
|
||||
expected string
|
||||
}{
|
||||
{name: "generic", candidate: store.RuntimeModelCandidate{Provider: "openai"}, expected: "max"},
|
||||
{name: "deepseek", candidate: store.RuntimeModelCandidate{Provider: "deepseek-openai"}, expected: "max"},
|
||||
{name: "zhipu", candidate: store.RuntimeModelCandidate{Provider: "zhipu-openai", ProviderModelName: "glm-5.2"}, expected: "xhigh"},
|
||||
{name: "volces", candidate: store.RuntimeModelCandidate{Provider: "volces-openai", ProviderModelName: "doubao-seed-2-0-pro-260215"}, expected: "high"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := map[string]any{"model": test.candidate.ProviderModelName, "reasoning_effort": "max"}
|
||||
applyOpenAIChatReasoningParams(body, test.candidate)
|
||||
if body["reasoning_effort"] != test.expected {
|
||||
t.Fatalf("expected reasoning_effort %q, got %#v", test.expected, body["reasoning_effort"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -343,6 +344,190 @@ func TestOpenAIClientChatContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClientChatReasoningParamsByProvider(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
provider string
|
||||
model string
|
||||
baseURL string
|
||||
body map[string]any
|
||||
assertion func(t *testing.T, body map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "generic openai keeps openai reasoning effort",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "xhigh",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
if body["reasoning_effort"] != "xhigh" {
|
||||
t.Fatalf("generic openai should keep xhigh, got %+v", body)
|
||||
}
|
||||
if _, ok := body["thinking"]; ok {
|
||||
t.Fatalf("generic openai should not receive thinking object: %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "aliyun qwen enables thinking and maps budget",
|
||||
provider: "aliyun-bailian-openai",
|
||||
model: "qwen3.7-plus",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "high",
|
||||
"thinking_budget_tokens": 4096,
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
if body["enable_thinking"] != true || body["thinking_budget"] != float64(4096) {
|
||||
t.Fatalf("aliyun qwen should use enable_thinking/thinking_budget, got %+v", body)
|
||||
}
|
||||
if _, ok := body["reasoning_effort"]; ok {
|
||||
t.Fatalf("aliyun qwen should not receive reasoning_effort: %+v", body)
|
||||
}
|
||||
if _, ok := body["thinking_budget_tokens"]; ok {
|
||||
t.Fatalf("internal thinking_budget_tokens should be removed: %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "aliyun qwen disables thinking for none",
|
||||
provider: "aliyun-bailian-openai",
|
||||
model: "qwen3.7-plus",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "none",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
if body["enable_thinking"] != false {
|
||||
t.Fatalf("aliyun qwen none should disable thinking, got %+v", body)
|
||||
}
|
||||
if _, ok := body["reasoning_effort"]; ok {
|
||||
t.Fatalf("aliyun qwen none should remove reasoning_effort: %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "same deepseek model on aliyun uses aliyun protocol",
|
||||
provider: "aliyun-bailian-openai",
|
||||
model: "deepseek-v4",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "xhigh",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
if body["enable_thinking"] != true || body["reasoning_effort"] != "max" {
|
||||
t.Fatalf("aliyun deepseek should use enable_thinking and max, got %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deepseek official uses thinking object and high max effort",
|
||||
provider: "deepseek-openai",
|
||||
model: "deepseek-v4",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "xhigh",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
thinking, _ := body["thinking"].(map[string]any)
|
||||
if thinking["type"] != "enabled" || body["reasoning_effort"] != "max" {
|
||||
t.Fatalf("deepseek official should use thinking enabled and max, got %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "volces seed none disables thinking",
|
||||
provider: "volces-openai",
|
||||
model: "doubao-seed-2-0-pro-260215",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "none",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
thinking, _ := body["thinking"].(map[string]any)
|
||||
if thinking["type"] != "disabled" {
|
||||
t.Fatalf("volces seed none should disable thinking, got %+v", body)
|
||||
}
|
||||
if _, ok := body["reasoning_effort"]; ok {
|
||||
t.Fatalf("volces seed none should remove reasoning_effort: %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "volces seed enables thinking and maps xhigh",
|
||||
provider: "volces-openai",
|
||||
model: "doubao-seed-2-0-pro-260215",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "xhigh",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
thinking, _ := body["thinking"].(map[string]any)
|
||||
if thinking["type"] != "enabled" || body["reasoning_effort"] != "high" {
|
||||
t.Fatalf("volces seed xhigh should enable thinking and map to high, got %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zhipu preserves xhigh for glm 5.2",
|
||||
provider: "zhipu-openai",
|
||||
model: "glm-5.2",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "xhigh",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
thinking, _ := body["thinking"].(map[string]any)
|
||||
if thinking["type"] != "enabled" || body["reasoning_effort"] != "xhigh" {
|
||||
t.Fatalf("zhipu glm should use thinking enabled and preserve xhigh, got %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var captured map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-reasoning",
|
||||
"object": "chat.completion",
|
||||
"model": captured["model"],
|
||||
"choices": []any{map[string]any{
|
||||
"message": map[string]any{"role": "assistant", "content": "ok"},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
body := map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
||||
}
|
||||
for key, value := range tc.body {
|
||||
body[key] = value
|
||||
}
|
||||
baseURL := tc.baseURL
|
||||
if baseURL == "" {
|
||||
baseURL = server.URL
|
||||
}
|
||||
|
||||
_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "chat.completions",
|
||||
Model: tc.model,
|
||||
Body: body,
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: tc.provider,
|
||||
BaseURL: baseURL,
|
||||
ProviderModelName: tc.model,
|
||||
ModelName: tc.model,
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run openai client: %v", err)
|
||||
}
|
||||
tc.assertion(t, captured)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageFromOpenAIUsageTracksKnownCachedInputZero(t *testing.T) {
|
||||
knownZero := usageFromOpenAIUsage(map[string]any{
|
||||
"prompt_tokens": 100,
|
||||
@@ -1121,6 +1306,25 @@ func TestGeminiClientImageGenerateBuildsNativeImageBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiGenerationConfigInitializesMissingImageConfig(t *testing.T) {
|
||||
config := geminiGenerationConfig(map[string]any{
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "4K",
|
||||
}, true)
|
||||
|
||||
imageConfig, ok := config["imageConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("imageConfig should be initialized, got %+v", config)
|
||||
}
|
||||
if imageConfig["aspectRatio"] != "16:9" || imageConfig["imageSize"] != "4K" {
|
||||
t.Fatalf("unexpected imageConfig: %+v", imageConfig)
|
||||
}
|
||||
modalities, ok := config["responseModalities"].([]any)
|
||||
if !ok || len(modalities) != 1 || modalities[0] != "IMAGE" {
|
||||
t.Fatalf("image response modality should be initialized, got %+v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiClientImageEditPreservesNativeContentsAndFileData(t *testing.T) {
|
||||
var captured map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1221,7 +1425,24 @@ func TestGeminiClientChatRestoresToolContext(t *testing.T) {
|
||||
"function": map[string]any{
|
||||
"name": "get_weather",
|
||||
"description": "lookup weather",
|
||||
"parameters": map[string]any{"type": "object"},
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"location": map[string]any{
|
||||
"anyOf": []any{
|
||||
map[string]any{"type": "string"},
|
||||
map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"city": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"city"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []any{"location"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
@@ -1265,6 +1486,18 @@ func TestGeminiClientChatRestoresToolContext(t *testing.T) {
|
||||
if declaration["name"] != "get_weather" || declaration["description"] != "lookup weather" {
|
||||
t.Fatalf("tool declaration was not converted for Gemini: %+v", captured["tools"])
|
||||
}
|
||||
if _, exists := declaration["parameters"]; exists {
|
||||
t.Fatalf("Gemini tool declaration must not use OpenAPI parameters: %+v", declaration)
|
||||
}
|
||||
parameters, _ := declaration["parametersJsonSchema"].(map[string]any)
|
||||
properties, _ := parameters["properties"].(map[string]any)
|
||||
location, _ := properties["location"].(map[string]any)
|
||||
anyOf, _ := location["anyOf"].([]any)
|
||||
nestedObject, _ := anyOf[1].(map[string]any)
|
||||
required, _ := nestedObject["required"].([]any)
|
||||
if len(anyOf) != 2 || len(required) != 1 || required[0] != "city" {
|
||||
t.Fatalf("Gemini JSON Schema was not preserved: %+v", declaration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiClientChatConvertsFunctionCallResponse(t *testing.T) {
|
||||
@@ -1325,10 +1558,39 @@ func TestGeminiClientChatConvertsFunctionCallResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGeminiURLAcceptsVersionedBaseURL(t *testing.T) {
|
||||
got := geminiURL("https://generativelanguage.googleapis.com/v1beta", "gemini-2.5-flash", "test-key")
|
||||
want := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected gemini url: %s", got)
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "default version",
|
||||
baseURL: "https://generativelanguage.googleapis.com",
|
||||
want: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key",
|
||||
},
|
||||
{
|
||||
name: "google beta version",
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
want: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key",
|
||||
},
|
||||
{
|
||||
name: "compatible v1 endpoint",
|
||||
baseURL: "https://cloud.dataeyes.ai/v1",
|
||||
want: "https://cloud.dataeyes.ai/v1/models/gemini-2.5-flash:generateContent?key=test-key",
|
||||
},
|
||||
{
|
||||
name: "openai suffix after version",
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
want: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := geminiURL(test.baseURL, "gemini-2.5-flash", "test-key")
|
||||
if got != test.want {
|
||||
t.Fatalf("unexpected gemini url: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1489,6 +1751,119 @@ func TestVolcesClientImageEditPreservesExplicitSequentialDisabledAndClampsMaxIma
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientSeedreamProGenerationUsesRealModelAndSingleOutput(t *testing.T) {
|
||||
var captured map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "img-seedream-pro",
|
||||
"data": []any{map[string]any{"url": "https://example.com/out.png"}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "images.generations",
|
||||
ModelType: "image_generate",
|
||||
Model: "Seedream-5.0-Pro",
|
||||
Body: map[string]any{
|
||||
"model": "Seedream-5.0-Pro",
|
||||
"prompt": "draw a mountain",
|
||||
"resolution": "2K",
|
||||
"n": 4,
|
||||
"sequential_image_generation": "auto",
|
||||
"sequential_image_generation_options": map[string]any{"max_images": 4},
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ModelName: "Seedream-5.0-Pro",
|
||||
ModelAlias: "Seedream-5.0-Pro",
|
||||
ProviderModelName: "doubao-seedream-5-0-pro-260628",
|
||||
Credentials: map[string]any{"apiKey": "volces-key"},
|
||||
Capabilities: map[string]any{
|
||||
"image_generate": map[string]any{
|
||||
"output_multiple_images": false,
|
||||
"output_max_images_count": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run Seedream Pro generation: %v", err)
|
||||
}
|
||||
if captured["model"] != "doubao-seedream-5-0-pro-260628" || captured["size"] != "2K" {
|
||||
t.Fatalf("Seedream Pro should use the real model and resolution size, got %+v", captured)
|
||||
}
|
||||
if captured["n"] != float64(1) {
|
||||
t.Fatalf("Seedream Pro should force single-image output, got %+v", captured)
|
||||
}
|
||||
if _, ok := captured["sequential_image_generation"]; ok {
|
||||
t.Fatalf("Seedream Pro request must not include sequential image generation, got %+v", captured)
|
||||
}
|
||||
if _, ok := captured["sequential_image_generation_options"]; ok {
|
||||
t.Fatalf("Seedream Pro request must not include sequential image options, got %+v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientSeedreamProEditForwardsTenReferencesAndCustomSize(t *testing.T) {
|
||||
var captured map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "img-seedream-pro-edit",
|
||||
"data": []any{map[string]any{"url": "https://example.com/out.png"}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
images := make([]any, 10)
|
||||
for index := range images {
|
||||
images[index] = fmt.Sprintf("https://example.com/reference-%d.png", index+1)
|
||||
}
|
||||
_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "images.edits",
|
||||
ModelType: "image_edit",
|
||||
Model: "Seedream-5.0-Pro",
|
||||
Body: map[string]any{
|
||||
"model": "Seedream-5.0-Pro",
|
||||
"prompt": "combine the references",
|
||||
"image": images,
|
||||
"width": 1600,
|
||||
"height": 900,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
ModelName: "Seedream-5.0-Pro",
|
||||
ModelAlias: "Seedream-5.0-Pro",
|
||||
ProviderModelName: "doubao-seedream-5-0-pro-260628",
|
||||
Credentials: map[string]any{"apiKey": "volces-key"},
|
||||
Capabilities: map[string]any{
|
||||
"image_edit": map[string]any{
|
||||
"input_multiple_images": true,
|
||||
"input_max_images_count": 10,
|
||||
"output_multiple_images": false,
|
||||
"output_max_images_count": 1,
|
||||
"input_max_file_size_bytes": 30 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run Seedream Pro edit: %v", err)
|
||||
}
|
||||
gotImages, _ := captured["image"].([]any)
|
||||
if captured["model"] != "doubao-seedream-5-0-pro-260628" || captured["size"] != "1600x900" || len(gotImages) != 10 {
|
||||
t.Fatalf("Seedream Pro edit should preserve ten references and custom size, got %+v", captured)
|
||||
}
|
||||
if _, ok := captured["sequential_image_generation"]; ok {
|
||||
t.Fatalf("Seedream Pro edit must not include sequential image generation, got %+v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
||||
var submitPath string
|
||||
var pollPath string
|
||||
@@ -1607,6 +1982,77 @@ func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientVideoRetriesTransientPollAndKeepsOfficialResult(t *testing.T) {
|
||||
polls := 0
|
||||
persisted := make([]string, 0)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /contents/generations/tasks":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-retry"})
|
||||
case "GET /contents/generations/tasks/cgt-retry":
|
||||
polls++
|
||||
if polls == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"try later"}}`))
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "cgt-retry", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
|
||||
"created_at": 123, "updated_at": 124, "content": map[string]any{"video_url": "https://example.com/retry.mp4"},
|
||||
"usage": map[string]any{"total_tokens": 8}, "seed": 7,
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations", Model: "seedance", Body: map[string]any{"model": "seedance", "prompt": "retry"},
|
||||
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "doubao-seedance-2-0-mini-260615", Credentials: map[string]any{"apiKey": "key"}, PlatformConfig: map[string]any{"volcesPollIntervalMs": 100, "volcesPollRetryMaxMs": 100, "volcesPollTimeoutSeconds": 2}},
|
||||
OnRemoteTaskPolled: func(remoteTaskID string, payload map[string]any) error {
|
||||
persisted = append(persisted, remoteTaskID+":"+stringFromAny(payload["status"]))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run retrying Volces video: %v", err)
|
||||
}
|
||||
if polls != 2 || len(persisted) != 1 || persisted[0] != "cgt-retry:succeeded" {
|
||||
t.Fatalf("unexpected poll state polls=%d persisted=%+v", polls, persisted)
|
||||
}
|
||||
if response.Result["updated_at"] != float64(124) || response.Result["seed"] != float64(7) || response.Result["raw"] == nil {
|
||||
t.Fatalf("official result fields lost: %+v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientDeletesOfficialVideoTask(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete || r.URL.Path != "/contents/generations/tasks/cgt-delete" {
|
||||
t.Fatalf("unexpected delete request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer delete-key" {
|
||||
t.Fatalf("unexpected delete authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-delete", "status": "cancelled"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, _, err := (VolcesClient{HTTPClient: server.Client()}).DeleteVideoTask(context.Background(), Request{
|
||||
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, Credentials: map[string]any{"apiKey": "delete-key"}},
|
||||
RemoteTaskID: "cgt-delete",
|
||||
})
|
||||
if err != nil || result["status"] != "cancelled" {
|
||||
t.Fatalf("unexpected delete response result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesCancelledTaskUsesDedicatedCancellationCode(t *testing.T) {
|
||||
if got := volcesTaskErrorCode(map[string]any{"status": "cancelled"}); got != "volces_task_cancelled" {
|
||||
t.Fatalf("cancelled task error code = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientVideoRejectsDuplicateFirstFrameBeforeSubmit(t *testing.T) {
|
||||
var submitted bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1660,6 +2106,7 @@ func TestVolcesVideoBodyAllowsOnlyTaskPayloadFields(t *testing.T) {
|
||||
"cameraFixed": false,
|
||||
"watermark": true,
|
||||
"seed": -1,
|
||||
"tools": []any{map[string]any{"type": "web_search"}},
|
||||
"task_id": "local-task-id",
|
||||
"runMode": "simulation",
|
||||
"fps": 24,
|
||||
@@ -1707,7 +2154,7 @@ func TestVolcesVideoBodyAllowsOnlyTaskPayloadFields(t *testing.T) {
|
||||
allowedTopLevel := map[string]bool{
|
||||
"model": true, "content": true, "callback_url": true, "return_last_frame": true, "execution_expires_after": true,
|
||||
"generate_audio": true, "draft": true, "resolution": true, "ratio": true, "duration": true,
|
||||
"seed": true, "camera_fixed": true, "watermark": true,
|
||||
"seed": true, "camera_fixed": true, "watermark": true, "tools": true,
|
||||
}
|
||||
for key := range body {
|
||||
if !allowedTopLevel[key] {
|
||||
@@ -1728,6 +2175,10 @@ func TestVolcesVideoBodyAllowsOnlyTaskPayloadFields(t *testing.T) {
|
||||
body["watermark"] != true {
|
||||
t.Fatalf("unexpected direct video fields: %+v", body)
|
||||
}
|
||||
tools, ok := body["tools"].([]any)
|
||||
if !ok || len(tools) != 1 {
|
||||
t.Fatalf("expected web search tools to pass through, got %+v", body["tools"])
|
||||
}
|
||||
|
||||
content, ok := body["content"].([]map[string]any)
|
||||
if !ok || len(content) != 5 {
|
||||
@@ -1782,6 +2233,31 @@ func TestVolcesVideoBodyPrefersFramesOverDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesVideoBodyBuildsWebSearchToolsFromFlag(t *testing.T) {
|
||||
body := volcesVideoBody(Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "video_generate",
|
||||
Model: "豆包Seedance-2.0",
|
||||
Body: map[string]any{
|
||||
"model": "豆包Seedance-2.0",
|
||||
"prompt": "查找最新城市夜景素材再生成视频",
|
||||
"enable_web_search": true,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
ProviderModelName: "doubao-seedance-2-0-260128",
|
||||
},
|
||||
})
|
||||
|
||||
tools, ok := body["tools"].([]any)
|
||||
if !ok || len(tools) != 1 {
|
||||
t.Fatalf("expected generated web search tools, got %+v", body["tools"])
|
||||
}
|
||||
tool, ok := tools[0].(map[string]any)
|
||||
if !ok || tool["type"] != "web_search" {
|
||||
t.Fatalf("unexpected generated web search tool: %+v", tools[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientVideoResumePollsExistingTaskID(t *testing.T) {
|
||||
var submitCalled bool
|
||||
var pollPath string
|
||||
@@ -1937,6 +2413,256 @@ func TestKelingClientVideoSubmitsAndPollsImageTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingClient30TurboUsesModelEndpointAndTasksAPI(t *testing.T) {
|
||||
var submitPath string
|
||||
var pollPath string
|
||||
var pollQuery string
|
||||
var gotAuth string
|
||||
var submittedPayload map[string]any
|
||||
var submittedTaskPayload map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /text-to-video/kling-3.0-turbo":
|
||||
submitPath = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&submittedPayload); err != nil {
|
||||
t.Fatalf("decode keling 3.0 turbo submit: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "req-turbo-submit",
|
||||
"data": map[string]any{
|
||||
"id": "turbo-task-1",
|
||||
"status": "submitted",
|
||||
},
|
||||
})
|
||||
case "GET /tasks":
|
||||
pollPath = r.URL.Path
|
||||
pollQuery = r.URL.RawQuery
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "req-turbo-poll",
|
||||
"data": []any{
|
||||
map[string]any{
|
||||
"id": "turbo-task-1",
|
||||
"status": "succeeded",
|
||||
"create_time": 789,
|
||||
"outputs": []any{
|
||||
map[string]any{
|
||||
"type": "video",
|
||||
"url": "https://example.com/turbo.mp4",
|
||||
"watermark_url": "https://example.com/turbo-watermark.mp4",
|
||||
"duration": "8",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "video_generate",
|
||||
Model: "可灵3.0 Turbo",
|
||||
Body: map[string]any{
|
||||
"prompt": "A cinematic city reveal",
|
||||
"duration": 8,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"callback_url": "https://example.com/callback",
|
||||
"external_task_id": "external-1",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL + "/v1",
|
||||
Provider: "keling",
|
||||
AuthType: "APIKey",
|
||||
ModelName: "可灵3.0 Turbo",
|
||||
ProviderModelName: "kling-3.0-turbo",
|
||||
Credentials: map[string]any{"apiKey": "kling-api-key"},
|
||||
PlatformConfig: map[string]any{
|
||||
"kelingPollIntervalMs": 100,
|
||||
"kelingPollTimeoutSeconds": 1,
|
||||
},
|
||||
},
|
||||
OnRemoteTaskSubmitted: func(remoteTaskID string, payload map[string]any) error {
|
||||
if remoteTaskID != "turbo-task-1" {
|
||||
t.Fatalf("unexpected remote task id: %s", remoteTaskID)
|
||||
}
|
||||
submittedTaskPayload = payload
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run keling 3.0 turbo video: %v", err)
|
||||
}
|
||||
if submitPath != "/text-to-video/kling-3.0-turbo" ||
|
||||
pollPath != "/tasks" ||
|
||||
pollQuery != "task_ids=turbo-task-1" ||
|
||||
gotAuth != "Bearer kling-api-key" {
|
||||
t.Fatalf("unexpected keling 3.0 turbo paths/auth submit=%s poll=%s?%s auth=%s", submitPath, pollPath, pollQuery, gotAuth)
|
||||
}
|
||||
if submittedTaskPayload["endpoint"] != "/text-to-video/kling-3.0-turbo" ||
|
||||
submittedTaskPayload["taskApi"] != "keling_tasks_v2" {
|
||||
t.Fatalf("unexpected submitted task payload: %+v", submittedTaskPayload)
|
||||
}
|
||||
settings, _ := submittedPayload["settings"].(map[string]any)
|
||||
options, _ := submittedPayload["options"].(map[string]any)
|
||||
if submittedPayload["prompt"] != "A cinematic city reveal" ||
|
||||
numericValue(settings["duration"], 0) != 8 ||
|
||||
settings["resolution"] != "1080p" ||
|
||||
settings["aspect_ratio"] != "9:16" ||
|
||||
options["callback_url"] != "https://example.com/callback" ||
|
||||
options["external_task_id"] != "external-1" {
|
||||
t.Fatalf("unexpected keling 3.0 turbo payload: %+v", submittedPayload)
|
||||
}
|
||||
data, _ := response.Result["data"].([]any)
|
||||
item, _ := data[0].(map[string]any)
|
||||
if response.Result["upstream_task_id"] != "turbo-task-1" ||
|
||||
item["url"] != "https://example.com/turbo.mp4" ||
|
||||
item["watermark_url"] != "https://example.com/turbo-watermark.mp4" {
|
||||
t.Fatalf("unexpected keling 3.0 turbo response: %+v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingClient30TurboRejectsLegacyCredentials(t *testing.T) {
|
||||
_, err := (KelingClient{}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
Body: map[string]any{"prompt": "A cinematic city reveal"},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
AuthType: "AccessKey-SecretKey",
|
||||
ProviderModelName: "kling-3.0-turbo",
|
||||
Credentials: map[string]any{"accessKey": "ak", "secretKey": "sk"},
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "new API key") {
|
||||
t.Fatalf("expected keling 3.0 turbo API key requirement, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingClient30TurboResumePollsWithoutSubmitting(t *testing.T) {
|
||||
var submitCalled bool
|
||||
var pollQuery string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /text-to-video/kling-3.0-turbo":
|
||||
submitCalled = true
|
||||
t.Fatalf("resume should not submit a new keling 3.0 turbo task")
|
||||
case "GET /tasks":
|
||||
pollQuery = r.URL.RawQuery
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "req-turbo-resume",
|
||||
"data": []any{
|
||||
map[string]any{
|
||||
"id": "turbo-existing",
|
||||
"status": "succeeded",
|
||||
"outputs": []any{
|
||||
map[string]any{"type": "video", "url": "https://example.com/resumed-turbo.mp4"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "video_generate",
|
||||
RemoteTaskID: "turbo-existing",
|
||||
Body: map[string]any{},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL + "/v1",
|
||||
Provider: "keling",
|
||||
AuthType: "APIKey",
|
||||
ProviderModelName: "kling-3.0-turbo",
|
||||
Credentials: map[string]any{"apiKey": "kling-api-key"},
|
||||
PlatformConfig: map[string]any{
|
||||
"kelingPollIntervalMs": 100,
|
||||
"kelingPollTimeoutSeconds": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resume keling 3.0 turbo video: %v", err)
|
||||
}
|
||||
if submitCalled || pollQuery != "task_ids=turbo-existing" {
|
||||
t.Fatalf("resume should only poll existing task, submit=%v query=%s", submitCalled, pollQuery)
|
||||
}
|
||||
data, _ := response.Result["data"].([]any)
|
||||
item, _ := data[0].(map[string]any)
|
||||
if item["url"] != "https://example.com/resumed-turbo.mp4" {
|
||||
t.Fatalf("unexpected resumed keling 3.0 turbo response: %+v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeling30TurboPayloadBuildsFirstFrameAndMultiShotRequests(t *testing.T) {
|
||||
imagePayload, endpoint, err := keling30TurboPayload(Request{
|
||||
Body: map[string]any{
|
||||
"duration": 5,
|
||||
"resolution": "720p",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "The subject looks toward the camera"},
|
||||
map[string]any{
|
||||
"type": "image_url",
|
||||
"role": "first_frame",
|
||||
"image_url": map[string]any{"url": "https://example.com/first.png"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build keling 3.0 turbo image payload: %v", err)
|
||||
}
|
||||
if endpoint != "/image-to-video/kling-3.0-turbo" {
|
||||
t.Fatalf("unexpected image endpoint: %s", endpoint)
|
||||
}
|
||||
if _, ok := mapFromAny(imagePayload["settings"])["aspect_ratio"]; ok {
|
||||
t.Fatalf("image-to-video settings should not contain aspect_ratio: %+v", imagePayload)
|
||||
}
|
||||
contents, _ := imagePayload["contents"].([]any)
|
||||
frame := mapFromAny(contents[1])
|
||||
if frame["type"] != "first_frame" || frame["url"] != "https://example.com/first.png" {
|
||||
t.Fatalf("unexpected image contents: %+v", imagePayload["contents"])
|
||||
}
|
||||
|
||||
shotPayload, _, err := keling30TurboPayload(Request{
|
||||
Body: map[string]any{
|
||||
"resolution": "720p",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "role": "shot_prompt", "shot_index": 1, "duration": 4, "text": "A car enters the tunnel"},
|
||||
map[string]any{"type": "text", "role": "shot_prompt", "shot_index": 2, "duration": 3, "text": "The headlights fill the frame"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build keling 3.0 turbo shot payload: %v", err)
|
||||
}
|
||||
if shotPayload["prompt"] != "shot 1, 4s, A car enters the tunnel; shot 2, 3s, The headlights fill the frame;" ||
|
||||
numericValue(mapFromAny(shotPayload["settings"])["duration"], 0) != 7 {
|
||||
t.Fatalf("unexpected shot payload: %+v", shotPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeling30TurboPayloadRejectsLastFrame(t *testing.T) {
|
||||
_, _, err := keling30TurboPayload(Request{
|
||||
Body: map[string]any{
|
||||
"prompt": "Move forward",
|
||||
"last_frame": "https://example.com/last.png",
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "last frame") {
|
||||
t.Fatalf("expected unsupported last frame error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniPayloadConvertsGatewayContent(t *testing.T) {
|
||||
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
@@ -2005,6 +2731,47 @@ func TestKelingOmniPayloadConvertsGatewayContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniPayloadPreservesLegacyV1Options(t *testing.T) {
|
||||
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"multi_shot": true,
|
||||
"shot_type": "customize",
|
||||
"multi_prompt": []any{
|
||||
map[string]any{"index": 1, "prompt": "镜头一", "duration": 7},
|
||||
map[string]any{"index": 2, "prompt": "镜头二", "duration": 8},
|
||||
},
|
||||
"resolution": "1080p",
|
||||
"callback_url": "https://example.com/callback",
|
||||
"external_task_id": "client-task-1",
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
"voice_list": []any{map[string]any{"voice_id": "voice-1"}},
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build keling legacy V1 payload: %v", err)
|
||||
}
|
||||
if len(cleanupIDs) != 0 {
|
||||
t.Fatalf("unexpected cleanup ids: %+v", cleanupIDs)
|
||||
}
|
||||
if payload["multi_shot"] != true || payload["shot_type"] != "customize" || payload["duration"] != "15" {
|
||||
t.Fatalf("unexpected multi-shot payload: %+v", payload)
|
||||
}
|
||||
if payload["callback_url"] != "https://example.com/callback" || payload["external_task_id"] != "client-task-1" {
|
||||
t.Fatalf("legacy task options were not preserved: %+v", payload)
|
||||
}
|
||||
watermark := mapFromAny(payload["watermark_info"])
|
||||
if watermark["enabled"] != true || len(mapListFromAny(payload["voice_list"])) != 1 {
|
||||
t.Fatalf("watermark or voice options were not preserved: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingClientVideoResumePollsWithoutSubmitting(t *testing.T) {
|
||||
var submitCalled bool
|
||||
var pollPath string
|
||||
|
||||
@@ -61,11 +61,13 @@ func geminiURL(baseURL string, model string, apiKey string) string {
|
||||
base = "https://generativelanguage.googleapis.com"
|
||||
}
|
||||
base = strings.TrimSuffix(base, "/openai")
|
||||
if strings.HasSuffix(base, "/v1beta") {
|
||||
base = strings.TrimSuffix(base, "/v1beta")
|
||||
if !strings.HasSuffix(base, "/v1") &&
|
||||
!strings.HasSuffix(base, "/v1beta") &&
|
||||
!strings.HasSuffix(base, "/v1alpha") {
|
||||
base += "/v1beta"
|
||||
}
|
||||
escapedModel := url.PathEscape(model)
|
||||
return fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", base, escapedModel, url.QueryEscape(apiKey))
|
||||
return fmt.Sprintf("%s/models/%s:generateContent?key=%s", base, escapedModel, url.QueryEscape(apiKey))
|
||||
}
|
||||
|
||||
func geminiBody(request Request) map[string]any {
|
||||
@@ -195,14 +197,23 @@ func geminiApplyRequestOptions(body map[string]any, request Request, imageRespon
|
||||
func geminiGenerationConfig(body map[string]any, imageResponse bool) map[string]any {
|
||||
source := mapFromAny(firstPresent(body["generationConfig"], body["generation_config"]))
|
||||
out := cloneMapAny(source)
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
if aspectRatio := firstNonEmptyString(body["aspect_ratio"], body["aspectRatio"]); aspectRatio != "" {
|
||||
imageConfig := cloneMapAny(mapFromAny(firstPresent(out["imageConfig"], out["image_config"])))
|
||||
if imageConfig == nil {
|
||||
imageConfig = map[string]any{}
|
||||
}
|
||||
imageConfig["aspectRatio"] = aspectRatio
|
||||
out["imageConfig"] = imageConfig
|
||||
delete(out, "image_config")
|
||||
}
|
||||
if imageSize := firstNonEmptyString(body["resolution"], body["imageSize"], body["image_size"], body["size"]); imageSize != "" {
|
||||
imageConfig := cloneMapAny(mapFromAny(firstPresent(out["imageConfig"], out["image_config"])))
|
||||
if imageConfig == nil {
|
||||
imageConfig = map[string]any{}
|
||||
}
|
||||
imageConfig["imageSize"] = imageSize
|
||||
out["imageConfig"] = imageConfig
|
||||
delete(out, "image_config")
|
||||
@@ -523,7 +534,7 @@ func geminiToolsFromOpenAITools(value any) []any {
|
||||
declaration["description"] = description
|
||||
}
|
||||
if parameters, ok := function["parameters"]; ok {
|
||||
declaration["parameters"] = parameters
|
||||
declaration["parametersJsonSchema"] = parameters
|
||||
}
|
||||
declarations = append(declarations, declaration)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -32,14 +34,34 @@ func (c KelingClient) Run(ctx context.Context, request Request) (Response, error
|
||||
if request.Kind != "videos.generations" {
|
||||
return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported keling request kind", Retryable: false}
|
||||
}
|
||||
token, err := kelingAuthToken(request.Candidate)
|
||||
token, err := kelingAuthTokenForRequest(request)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
return c.runVideo(ctx, request, token)
|
||||
}
|
||||
|
||||
func kelingAuthTokenForRequest(request Request) (string, error) {
|
||||
if kelingIs30TurboRequest(request) {
|
||||
apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
|
||||
if apiKey == "" {
|
||||
return "", &ClientError{
|
||||
Code: "missing_credentials",
|
||||
Message: "keling 3.0 turbo requires the new API key; legacy accessKey/secretKey credentials do not support new models",
|
||||
Retryable: false,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
return apiKey, nil
|
||||
}
|
||||
return kelingAuthToken(request.Candidate)
|
||||
}
|
||||
|
||||
func (c KelingClient) runVideo(ctx context.Context, request Request, token string) (Response, error) {
|
||||
if kelingIs30TurboRequest(request) {
|
||||
return c.runTaskAPIVideo(ctx, request, token)
|
||||
}
|
||||
|
||||
submitStartedAt := time.Now()
|
||||
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
|
||||
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
|
||||
@@ -143,6 +165,105 @@ func (c KelingClient) runVideo(ctx context.Context, request Request, token strin
|
||||
}
|
||||
}
|
||||
|
||||
func (c KelingClient) runTaskAPIVideo(ctx context.Context, request Request, token string) (Response, error) {
|
||||
submitStartedAt := time.Now()
|
||||
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
|
||||
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
|
||||
taskAPIBaseURL := kelingTaskAPIBaseURL(request.Candidate.BaseURL)
|
||||
|
||||
if upstreamTaskID == "" {
|
||||
payload, endpoint, err := keling30TurboPayload(request)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
submitResult, requestID, err := c.postJSONAt(ctx, request, taskAPIBaseURL, endpoint, token, payload)
|
||||
submitRequestID = requestID
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
|
||||
}
|
||||
upstreamTaskID = strings.TrimSpace(stringFromAny(kelingData(submitResult)["id"]))
|
||||
if upstreamTaskID == "" {
|
||||
return Response{}, &ClientError{Code: "invalid_response", Message: "keling 3.0 turbo task id is missing", RequestID: submitRequestID, Retryable: false}
|
||||
}
|
||||
if request.OnRemoteTaskSubmitted != nil {
|
||||
if err := request.OnRemoteTaskSubmitted(upstreamTaskID, map[string]any{
|
||||
"endpoint": endpoint,
|
||||
"taskApi": "keling_tasks_v2",
|
||||
"submit": submitResult,
|
||||
}); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interval := kelingPollInterval(request)
|
||||
timeout := kelingPollTimeout(request)
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastStatus string
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: submitRequestID, Retryable: true}
|
||||
default:
|
||||
}
|
||||
|
||||
pollStartedAt := time.Now()
|
||||
pollResult, pollRequestID, err := c.getJSONAt(
|
||||
ctx,
|
||||
request,
|
||||
taskAPIBaseURL,
|
||||
"/tasks?task_ids="+url.QueryEscape(upstreamTaskID),
|
||||
token,
|
||||
)
|
||||
pollFinishedAt := time.Now()
|
||||
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
|
||||
}
|
||||
|
||||
task := kelingTaskAPITask(pollResult, upstreamTaskID)
|
||||
lastStatus = strings.ToLower(strings.TrimSpace(stringFromAny(task["status"])))
|
||||
switch lastStatus {
|
||||
case "succeeded", "succeed":
|
||||
return Response{
|
||||
Result: kelingTaskAPIVideoSuccessResult(request, upstreamTaskID, task, pollResult),
|
||||
RequestID: requestID,
|
||||
Progress: kelingVideoProgress(request, upstreamTaskID),
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
}, nil
|
||||
case "failed":
|
||||
return Response{}, &ClientError{
|
||||
Code: "keling_task_failed",
|
||||
Message: kelingTaskAPIErrorMessage(request.Candidate, task, pollResult),
|
||||
RequestID: requestID,
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true}
|
||||
case <-deadline.C:
|
||||
return Response{}, &ClientError{
|
||||
Code: "timeout",
|
||||
Message: fmt.Sprintf("keling 3.0 turbo task %s did not finish before timeout; last status: %s", upstreamTaskID, lastStatus),
|
||||
RequestID: requestID,
|
||||
Retryable: true,
|
||||
}
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c KelingClient) prepareVideoTask(ctx context.Context, request Request, token string) (kelingPreparedTask, error) {
|
||||
if kelingIsOmniRequest(request) {
|
||||
payload, cleanupIDs, err := c.kelingOmniPayload(ctx, request, token)
|
||||
@@ -217,8 +338,11 @@ func kelingVideoPayload(ctx context.Context, request Request) (map[string]any, s
|
||||
if value, ok := body["cfg_scale"]; ok && numericValue(value, 0) > 0 {
|
||||
payload["cfg_scale"] = value
|
||||
}
|
||||
if boolValue(body, "audio") || boolValue(body, "output_audio") {
|
||||
payload["sound"] = "on"
|
||||
if sound, ok := kelingSoundSetting(body); ok {
|
||||
if sound == "on" && !kelingSupportsGeneratedSound(request.Candidate) {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support generated audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
payload["sound"] = sound
|
||||
}
|
||||
if mode := kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")); mode != "" {
|
||||
payload["mode"] = mode
|
||||
@@ -299,15 +423,21 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
}
|
||||
uploadedElementIDs = append(uploadedElementIDs, createdIDs...)
|
||||
shots := kelingShotPrompts(content)
|
||||
hasMultiPrompt := len(shots) > 0
|
||||
rawMultiPrompt := mapListFromAny(body["multi_prompt"])
|
||||
hasMultiPrompt := len(shots) > 0 || len(rawMultiPrompt) > 0
|
||||
multiShot := boolValue(body, "multi_shot") || hasMultiPrompt
|
||||
hasVideo := len(videos) > 0
|
||||
hasVideoEdit := kelingHasBaseVideo(videos)
|
||||
hasFirstFrame := kelingHasFirstFrame(images)
|
||||
|
||||
watermarkEnabled := boolValue(body, "watermark")
|
||||
if watermarkInfo := mapFromAny(body["watermark_info"]); watermarkInfo != nil {
|
||||
watermarkEnabled = boolValue(watermarkInfo, "enabled")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model_name": upstreamModelName(request.Candidate),
|
||||
"model_name": kelingOmniUpstreamModelName(request.Candidate),
|
||||
"mode": kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")),
|
||||
"watermark_info": map[string]any{"enabled": false},
|
||||
"watermark_info": map[string]any{"enabled": watermarkEnabled},
|
||||
"negative_prompt": strings.TrimSpace(stringFromAny(body["negative_prompt"])),
|
||||
}
|
||||
if !hasMultiPrompt {
|
||||
@@ -328,29 +458,67 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
if len(elements) > 0 {
|
||||
payload["element_list"] = elements
|
||||
}
|
||||
if (boolValue(body, "audio") || boolValue(body, "output_audio")) && !hasVideo {
|
||||
payload["sound"] = "on"
|
||||
if voices := mapListFromAny(body["voice_list"]); len(voices) > 0 {
|
||||
payload["voice_list"] = voices
|
||||
}
|
||||
if hasMultiPrompt {
|
||||
payload["multi_shot"] = true
|
||||
payload["shot_type"] = "customize"
|
||||
total := 0.0
|
||||
multiPrompt := make([]any, 0, len(shots))
|
||||
for index, shot := range shots {
|
||||
duration := shot.duration
|
||||
if duration <= 0 {
|
||||
duration = 5
|
||||
}
|
||||
total += duration
|
||||
multiPrompt = append(multiPrompt, map[string]any{
|
||||
"index": index + 1,
|
||||
"prompt": shot.text,
|
||||
"duration": fmtDuration(duration, 5),
|
||||
})
|
||||
if sound, ok := kelingSoundSetting(body); ok {
|
||||
if sound == "on" && !kelingSupportsGeneratedSound(request.Candidate) {
|
||||
return nil, nil, &ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support generated audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
delete(payload, "prompt")
|
||||
payload["multi_prompt"] = multiPrompt
|
||||
payload["duration"] = fmtDuration(total, 0)
|
||||
if !hasVideo {
|
||||
payload["sound"] = sound
|
||||
}
|
||||
}
|
||||
if multiShot {
|
||||
payload["multi_shot"] = true
|
||||
shotType := strings.TrimSpace(firstNonEmptyStringValue(body, "shot_type", "shotType"))
|
||||
if shotType == "" {
|
||||
if hasMultiPrompt {
|
||||
shotType = "customize"
|
||||
} else {
|
||||
shotType = "intelligence"
|
||||
}
|
||||
}
|
||||
payload["shot_type"] = shotType
|
||||
if shotType == "customize" {
|
||||
total := 0.0
|
||||
multiPrompt := make([]any, 0, len(rawMultiPrompt)+len(shots))
|
||||
if len(rawMultiPrompt) > 0 {
|
||||
for index, item := range rawMultiPrompt {
|
||||
duration := numericValue(item["duration"], 0)
|
||||
total += duration
|
||||
multiPrompt = append(multiPrompt, map[string]any{
|
||||
"index": int(math.Round(numericValue(item["index"], float64(index+1)))),
|
||||
"prompt": strings.TrimSpace(stringFromAny(item["prompt"])),
|
||||
"duration": fmtDuration(duration, 0),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
for index, shot := range shots {
|
||||
duration := shot.duration
|
||||
if duration <= 0 {
|
||||
duration = 5
|
||||
}
|
||||
total += duration
|
||||
multiPrompt = append(multiPrompt, map[string]any{
|
||||
"index": index + 1,
|
||||
"prompt": shot.text,
|
||||
"duration": fmtDuration(duration, 5),
|
||||
})
|
||||
}
|
||||
}
|
||||
delete(payload, "prompt")
|
||||
payload["multi_prompt"] = multiPrompt
|
||||
if total > 0 {
|
||||
payload["duration"] = fmtDuration(total, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if callbackURL := strings.TrimSpace(firstNonEmptyStringValue(body, "callback_url", "callbackUrl")); callbackURL != "" {
|
||||
payload["callback_url"] = callbackURL
|
||||
}
|
||||
if externalTaskID := strings.TrimSpace(firstNonEmptyStringValue(body, "external_task_id", "externalTaskId")); externalTaskID != "" {
|
||||
payload["external_task_id"] = externalTaskID
|
||||
}
|
||||
deleteEmptyStringFields(payload)
|
||||
if hasVideoEdit {
|
||||
@@ -400,8 +568,12 @@ func (c KelingClient) kelingOmniElementList(ctx context.Context, request Request
|
||||
}
|
||||
|
||||
func (c KelingClient) postJSON(ctx context.Context, request Request, path string, token string, body map[string]any) (map[string]any, string, error) {
|
||||
return c.postJSONAt(ctx, request, request.Candidate.BaseURL, path, token, body)
|
||||
}
|
||||
|
||||
func (c KelingClient) postJSONAt(ctx context.Context, request Request, baseURL string, path string, token string, body map[string]any) (map[string]any, string, error) {
|
||||
raw, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, path), bytes.NewReader(raw))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -423,7 +595,11 @@ func (c KelingClient) postJSON(ctx context.Context, request Request, path string
|
||||
}
|
||||
|
||||
func (c KelingClient) getJSON(ctx context.Context, request Request, path string, token string) (map[string]any, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(request.Candidate.BaseURL, path), nil)
|
||||
return c.getJSONAt(ctx, request, request.Candidate.BaseURL, path, token)
|
||||
}
|
||||
|
||||
func (c KelingClient) getJSONAt(ctx context.Context, request Request, baseURL string, path string, token string) (map[string]any, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -560,6 +736,139 @@ func kelingIsOmniRequest(request Request) bool {
|
||||
request.Candidate.Capabilities["omni"] != nil
|
||||
}
|
||||
|
||||
func kelingOmniUpstreamModelName(candidate store.RuntimeModelCandidate) string {
|
||||
model := strings.TrimSpace(upstreamModelName(candidate))
|
||||
switch strings.ToLower(model) {
|
||||
case "kling-o1":
|
||||
return "kling-video-o1"
|
||||
case "kling-3.0-omni":
|
||||
return "kling-v3-omni"
|
||||
default:
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
func kelingIs30TurboRequest(request Request) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(upstreamModelName(request.Candidate))) {
|
||||
case "kling-3.0-turbo", "kling-v3-turbo", "kling-3-0-turbo":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func kelingTaskAPIBaseURL(baseURL string) string {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if strings.HasSuffix(strings.ToLower(trimmed), "/v1") {
|
||||
return trimmed[:len(trimmed)-len("/v1")]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func keling30TurboPayload(request Request) (map[string]any, string, error) {
|
||||
body := cleanProviderBody(request.Body)
|
||||
content := contentItems(body["content"])
|
||||
if len(content) == 0 {
|
||||
content = buildVolcesContentFromBody(body)
|
||||
}
|
||||
|
||||
shots := kelingShotPrompts(content)
|
||||
if len(shots) > 6 {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo supports at most 6 shots", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
prompt := firstKelingPrompt(content)
|
||||
duration := numericValue(body["duration"], 5)
|
||||
if len(shots) > 0 {
|
||||
var promptBuilder strings.Builder
|
||||
duration = 0
|
||||
for index, shot := range shots {
|
||||
if shot.duration < 1 || math.Abs(shot.duration-math.Round(shot.duration)) > 1e-9 {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo shot duration must be an integer of at least 1 second", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
duration += shot.duration
|
||||
fmt.Fprintf(&promptBuilder, "shot %d, %ds, %s; ", index+1, int(math.Round(shot.duration)), shot.text)
|
||||
}
|
||||
prompt = strings.TrimSpace(promptBuilder.String())
|
||||
}
|
||||
if prompt == "" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo video prompt is required", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if math.Abs(duration-math.Round(duration)) > 1e-9 || duration < 3 || duration > 15 {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo duration must be an integer between 3 and 15 seconds", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
|
||||
firstFrame, lastFrame, referenceImages := kelingImageInputs(content)
|
||||
if lastFrame != "" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo image-to-video supports first frame only; last frame is not supported", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
imageCount := len(referenceImages)
|
||||
if firstFrame != "" {
|
||||
imageCount++
|
||||
}
|
||||
if imageCount > 1 {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo image-to-video supports exactly one first-frame image", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if firstFrame == "" && len(referenceImages) == 1 {
|
||||
firstFrame = referenceImages[0]
|
||||
}
|
||||
isImageToVideo := firstFrame != ""
|
||||
|
||||
resolution := strings.TrimSpace(firstNonEmptyStringValue(body, "resolution", "size"))
|
||||
if resolution == "" {
|
||||
resolution = "720p"
|
||||
}
|
||||
if resolution != "720p" && resolution != "1080p" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo resolution must be 720p or 1080p", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
|
||||
promptLimit := 3072
|
||||
if isImageToVideo {
|
||||
promptLimit = 2500
|
||||
}
|
||||
if utf8.RuneCountInString(prompt) > promptLimit {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("keling 3.0 turbo prompt exceeds %d characters", promptLimit), StatusCode: 400, Retryable: false}
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"duration": int(math.Round(duration)),
|
||||
"resolution": resolution,
|
||||
}
|
||||
options := map[string]any{
|
||||
"watermark_info": map[string]any{"enabled": boolValue(body, "watermark")},
|
||||
}
|
||||
if callbackURL := strings.TrimSpace(firstNonEmptyStringValue(body, "callback_url", "callbackUrl")); callbackURL != "" {
|
||||
options["callback_url"] = callbackURL
|
||||
}
|
||||
if externalTaskID := strings.TrimSpace(firstNonEmptyStringValue(body, "external_task_id", "externalTaskId")); externalTaskID != "" {
|
||||
options["external_task_id"] = externalTaskID
|
||||
}
|
||||
|
||||
if isImageToVideo {
|
||||
return map[string]any{
|
||||
"contents": []any{
|
||||
map[string]any{"type": "prompt", "text": prompt},
|
||||
map[string]any{"type": "first_frame", "url": firstFrame},
|
||||
},
|
||||
"settings": settings,
|
||||
"options": options,
|
||||
}, "/image-to-video/kling-3.0-turbo", nil
|
||||
}
|
||||
|
||||
aspectRatio := strings.TrimSpace(firstNonEmptyStringValue(body, "aspect_ratio", "aspectRatio", "ratio"))
|
||||
if aspectRatio == "" || aspectRatio == "adaptive" || aspectRatio == "keep_ratio" {
|
||||
aspectRatio = "16:9"
|
||||
}
|
||||
if aspectRatio != "16:9" && aspectRatio != "9:16" && aspectRatio != "1:1" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo aspect_ratio must be 16:9, 9:16, or 1:1", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
settings["aspect_ratio"] = aspectRatio
|
||||
return map[string]any{
|
||||
"prompt": prompt,
|
||||
"settings": settings,
|
||||
"options": options,
|
||||
}, "/text-to-video/kling-3.0-turbo", nil
|
||||
}
|
||||
|
||||
func firstKelingPrompt(content []map[string]any) string {
|
||||
for _, item := range content {
|
||||
if stringFromAny(item["type"]) == "text" && stringFromAny(item["role"]) != "shot_prompt" && item["shot_index"] == nil {
|
||||
@@ -784,6 +1093,54 @@ func kelingModeByResolution(resolution string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func kelingSoundSetting(body map[string]any) (string, bool) {
|
||||
if sound := strings.ToLower(strings.TrimSpace(stringFromAny(body["sound"]))); sound == "on" || sound == "off" {
|
||||
return sound, true
|
||||
}
|
||||
for _, key := range []string{"audio", "output_audio", "generate_audio"} {
|
||||
if enabled, ok := kelingBoolFieldValue(body, key); ok {
|
||||
if enabled {
|
||||
return "on", true
|
||||
}
|
||||
return "off", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func kelingSupportsGeneratedSound(candidate store.RuntimeModelCandidate) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(upstreamModelName(candidate))) {
|
||||
case "kling-o1", "kling-video-o1":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func kelingWatermarkEnabled(body map[string]any) bool {
|
||||
if enabled, ok := kelingBoolFieldValue(body, "watermark"); ok {
|
||||
return enabled
|
||||
}
|
||||
info := mapFromAny(body["watermark_info"])
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
enabled, _ := kelingBoolFieldValue(info, "enabled")
|
||||
return enabled
|
||||
}
|
||||
|
||||
func kelingBoolFieldValue(body map[string]any, key string) (bool, bool) {
|
||||
if body == nil {
|
||||
return false, false
|
||||
}
|
||||
value, ok := body[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
typed, ok := value.(bool)
|
||||
return typed, ok
|
||||
}
|
||||
|
||||
func kelingCameraControl(body map[string]any) map[string]any {
|
||||
cameraControl := strings.TrimSpace(stringFromAny(body["camera_control"]))
|
||||
if cameraControl == "" {
|
||||
@@ -822,6 +1179,30 @@ func kelingTaskStatus(result map[string]any) string {
|
||||
return strings.ToLower(strings.TrimSpace(stringFromAny(kelingData(result)["task_status"])))
|
||||
}
|
||||
|
||||
func kelingTaskAPITask(result map[string]any, taskID string) map[string]any {
|
||||
tasks := mapListFromAny(result["data"])
|
||||
for _, task := range tasks {
|
||||
if strings.TrimSpace(stringFromAny(task["id"])) == taskID {
|
||||
return task
|
||||
}
|
||||
}
|
||||
if len(tasks) > 0 {
|
||||
return tasks[0]
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func kelingTaskAPIErrorMessage(candidate store.RuntimeModelCandidate, task map[string]any, result map[string]any) string {
|
||||
message := strings.TrimSpace(stringFromAny(task["message"]))
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(stringFromAny(result["message"]))
|
||||
}
|
||||
if message == "" {
|
||||
message = "keling 3.0 turbo video task failed"
|
||||
}
|
||||
return fmt.Sprintf("Platform:%s,Code:%v,requestId:%s,message:%s", candidate.Provider, result["code"], stringFromAny(result["request_id"]), message)
|
||||
}
|
||||
|
||||
func kelingTaskErrorCode(result map[string]any) string {
|
||||
if code := intFromAny(result["code"]); code != 0 {
|
||||
return fmt.Sprintf("keling_%d", code)
|
||||
@@ -866,15 +1247,61 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
continue
|
||||
}
|
||||
item := map[string]any{"url": url, "video_url": url, "type": "video"}
|
||||
if duration := intFromAny(video["duration"]); duration > 0 {
|
||||
if id := strings.TrimSpace(stringFromAny(video["id"])); id != "" {
|
||||
item["id"] = id
|
||||
}
|
||||
if duration := firstPresent(video["duration"]); duration != nil && strings.TrimSpace(stringFromAny(duration)) != "" {
|
||||
item["duration"] = duration
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromAny(video["watermark_url"])); watermarkURL != "" {
|
||||
item["watermark_url"] = watermarkURL
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
created := intFromAny(data["created_at"])
|
||||
if created == 0 {
|
||||
created = int(nowUnix())
|
||||
}
|
||||
modelName := upstreamModelName(request.Candidate)
|
||||
if kelingIsOmniRequest(request) {
|
||||
modelName = kelingOmniUpstreamModelName(request.Candidate)
|
||||
}
|
||||
return map[string]any{
|
||||
"id": upstreamTaskID,
|
||||
"object": "video.generation",
|
||||
"created": created,
|
||||
"model": modelName,
|
||||
"status": "succeeded",
|
||||
"upstream_task_id": upstreamTaskID,
|
||||
"data": items,
|
||||
"raw": raw,
|
||||
}
|
||||
}
|
||||
|
||||
func kelingTaskAPIVideoSuccessResult(request Request, upstreamTaskID string, task map[string]any, raw map[string]any) map[string]any {
|
||||
outputs := mapListFromAny(task["outputs"])
|
||||
items := make([]any, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
if strings.ToLower(strings.TrimSpace(stringFromAny(output["type"]))) != "video" {
|
||||
continue
|
||||
}
|
||||
videoURL := strings.TrimSpace(stringFromAny(output["url"]))
|
||||
if videoURL == "" {
|
||||
continue
|
||||
}
|
||||
item := map[string]any{"url": videoURL, "video_url": videoURL, "type": "video"}
|
||||
if duration := numericValue(output["duration"], 0); duration > 0 {
|
||||
item["duration"] = duration
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromAny(output["watermark_url"])); watermarkURL != "" {
|
||||
item["watermark_url"] = watermarkURL
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
created := intFromAny(task["create_time"])
|
||||
if created == 0 {
|
||||
created = int(nowUnix())
|
||||
}
|
||||
return map[string]any{
|
||||
"id": upstreamTaskID,
|
||||
"object": "video.generation",
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingOmniPayloadPreservesCompatibleSettings(t *testing.T) {
|
||||
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"prompt": "A product reveal",
|
||||
"duration": 3,
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "16:9",
|
||||
"audio": false,
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
"external_task_id": "external-1",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build compatible Omni payload: %v", err)
|
||||
}
|
||||
if len(cleanupIDs) != 0 ||
|
||||
payload["model_name"] != "kling-video-o1" ||
|
||||
payload["mode"] != "std" ||
|
||||
payload["sound"] != "off" ||
|
||||
payload["duration"] != "3" ||
|
||||
payload["aspect_ratio"] != "16:9" ||
|
||||
payload["external_task_id"] != "external-1" {
|
||||
t.Fatalf("unexpected compatible Omni payload: %+v", payload)
|
||||
}
|
||||
watermark, _ := payload["watermark_info"].(map[string]any)
|
||||
if watermark["enabled"] != true {
|
||||
t.Fatalf("watermark setting was not preserved: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniUpstreamModelNameSeparatesGatewayAliases(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"kling-o1": "kling-video-o1",
|
||||
"kling-video-o1": "kling-video-o1",
|
||||
"kling-3.0-omni": "kling-v3-omni",
|
||||
"kling-v3-omni": "kling-v3-omni",
|
||||
}
|
||||
for configured, want := range tests {
|
||||
got := kelingOmniUpstreamModelName(store.RuntimeModelCandidate{ProviderModelName: configured})
|
||||
if got != want {
|
||||
t.Fatalf("configured=%s got=%s want=%s", configured, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniRejectsGeneratedAudioForO1(t *testing.T) {
|
||||
_, _, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Body: map[string]any{
|
||||
"prompt": "A beach",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": true,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{ProviderModelName: "kling-video-o1"},
|
||||
}, "token")
|
||||
if err == nil || ErrorCode(err) != "invalid_parameter" {
|
||||
t.Fatalf("expected generated-audio rejection for O1, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniResumeReturnsUpstreamFailureCodeAndModel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/videos/omni-video/remote-failed" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-key" {
|
||||
t.Fatalf("unexpected Authorization header: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "failure-request",
|
||||
"data": map[string]any{
|
||||
"task_id": "remote-failed",
|
||||
"task_status": "failed",
|
||||
"task_status_msg": "content policy rejection",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
RemoteTaskID: "remote-failed",
|
||||
RemoteTaskPayload: map[string]any{"endpoint": "/videos/omni-video"},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Credentials: map[string]any{"apiKey": "upstream-key"},
|
||||
PlatformConfig: map[string]any{
|
||||
"kelingPollIntervalMs": 10,
|
||||
"kelingPollTimeoutSeconds": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil || ErrorCode(err) != "keling_task_failed" || !strings.Contains(err.Error(), "content policy rejection") {
|
||||
t.Fatalf("expected preserved Keling task failure, got code=%q err=%v", ErrorCode(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniPayloadPreservesIntelligentMultiShot(t *testing.T) {
|
||||
payload, _, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"prompt": "Create three coherent shots",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"multi_shot": true,
|
||||
"shot_type": "intelligence",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build intelligent multi-shot payload: %v", err)
|
||||
}
|
||||
if payload["multi_shot"] != true || payload["shot_type"] != "intelligence" || payload["prompt"] != "Create three coherent shots" {
|
||||
t.Fatalf("unexpected intelligent multi-shot payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingVideoSuccessResultPreservesOfficialVideoMetadata(t *testing.T) {
|
||||
result := kelingVideoSuccessResult(Request{Candidate: store.RuntimeModelCandidate{ProviderModelName: "kling-video-o1"}}, "remote-1", map[string]any{
|
||||
"data": map[string]any{
|
||||
"task_result": map[string]any{
|
||||
"videos": []any{map[string]any{
|
||||
"id": "video-1",
|
||||
"url": "https://example.com/video.mp4",
|
||||
"watermark_url": "https://example.com/watermarked.mp4",
|
||||
"duration": "3",
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
data, _ := result["data"].([]any)
|
||||
video, _ := data[0].(map[string]any)
|
||||
if video["id"] != "video-1" || video["watermark_url"] != "https://example.com/watermarked.mp4" || video["duration"] != "3" {
|
||||
t.Fatalf("official video metadata was lost: %+v", video)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
// TestKelingLegacyV1LiveOmni is opt-in because it creates billable upstream
|
||||
// video tasks. Credentials must only be supplied through local environment
|
||||
// variables; the test never prints them.
|
||||
func TestKelingLegacyV1LiveOmni(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("KELING_LIVE_TEST")) != "1" {
|
||||
t.Skip("set KELING_LIVE_TEST=1 to run billable Kling V1 integration tests")
|
||||
}
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("KELING_TEST_BASE_URL")), "/")
|
||||
accessKey := strings.TrimSpace(os.Getenv("KELING_TEST_ACCESS_KEY"))
|
||||
secretKey := strings.TrimSpace(os.Getenv("KELING_TEST_SECRET_KEY"))
|
||||
if baseURL == "" || accessKey == "" || secretKey == "" {
|
||||
t.Fatal("KELING_TEST_BASE_URL, KELING_TEST_ACCESS_KEY, and KELING_TEST_SECRET_KEY are required")
|
||||
}
|
||||
|
||||
models := []string{"kling-video-o1", "kling-v3-omni"}
|
||||
if selected := strings.TrimSpace(os.Getenv("KELING_LIVE_TEST_MODELS")); selected != "" {
|
||||
models = strings.Split(selected, ",")
|
||||
}
|
||||
for _, model := range models {
|
||||
model = strings.TrimSpace(model)
|
||||
t.Run(model, func(t *testing.T) {
|
||||
duration := 3
|
||||
if model == "kling-video-o1" {
|
||||
duration = 5
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
response, err := (KelingClient{}).Run(ctx, Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Model: model,
|
||||
Body: map[string]any{
|
||||
"prompt": "清晨的湖面上,一只白色纸鹤缓慢飞过,镜头平稳推进",
|
||||
"duration": duration,
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "720p",
|
||||
"sound": "off",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: baseURL,
|
||||
Provider: "keling",
|
||||
AuthType: "AccessKey-SecretKey",
|
||||
ProviderModelName: model,
|
||||
Credentials: map[string]any{
|
||||
"accessKey": accessKey,
|
||||
"secretKey": secretKey,
|
||||
},
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
PlatformConfig: map[string]any{
|
||||
"kelingPollIntervalMs": 5000,
|
||||
"kelingPollTimeoutSeconds": 840,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Kling V1 %s live task failed: %v", model, err)
|
||||
}
|
||||
items, _ := response.Result["data"].([]any)
|
||||
if len(items) == 0 {
|
||||
t.Fatalf("Kling V1 %s returned no video", model)
|
||||
}
|
||||
item, _ := items[0].(map[string]any)
|
||||
if strings.TrimSpace(stringFromAny(item["url"])) == "" {
|
||||
t.Fatalf("Kling V1 %s returned an empty video URL", model)
|
||||
}
|
||||
if strings.TrimSpace(response.RequestID) == "" {
|
||||
t.Fatalf("Kling V1 %s returned an empty request id", model)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,19 +20,50 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
if apiKey == "" {
|
||||
return Response{}, &ClientError{Code: "missing_credentials", Message: "openai api key is required", Retryable: false}
|
||||
}
|
||||
endpoint := openAIEndpoint(request.Kind)
|
||||
protocol := request.UpstreamProtocol
|
||||
if protocol == "" && request.Kind == "responses" {
|
||||
protocol = ProtocolOpenAIResponses
|
||||
}
|
||||
endpointKind := request.Kind
|
||||
if request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions {
|
||||
endpointKind = "chat.completions"
|
||||
}
|
||||
endpoint := openAIEndpoint(endpointKind)
|
||||
if endpoint == "" {
|
||||
return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported openai request kind", Retryable: false}
|
||||
}
|
||||
body := cloneBody(request.Body)
|
||||
if request.Kind == "chat.completions" {
|
||||
if request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions {
|
||||
var convertErr error
|
||||
body, convertErr = ResponsesRequestToChat(request.Body, request.PreviousResponseTurns)
|
||||
if convertErr != nil {
|
||||
return Response{}, convertErr
|
||||
}
|
||||
}
|
||||
if endpointKind == "chat.completions" {
|
||||
body = NormalizeChatCompletionRequestBody(body)
|
||||
applyOpenAIChatReasoningParams(body, request.Candidate)
|
||||
body = FilterOpenAIChatRequestBody(body)
|
||||
} else if request.Kind == "responses" {
|
||||
body = FilterOpenAIResponsesRequestBody(body)
|
||||
if _, hasInput := body["input"]; !hasInput {
|
||||
if messages, hasMessages := request.Body["messages"]; hasMessages {
|
||||
body["input"] = messages
|
||||
}
|
||||
}
|
||||
delete(body, "messages")
|
||||
if request.UpstreamPreviousResponseID != "" {
|
||||
body["previous_response_id"] = request.UpstreamPreviousResponseID
|
||||
} else {
|
||||
delete(body, "previous_response_id")
|
||||
}
|
||||
}
|
||||
body["model"] = upstreamModelName(request.Candidate)
|
||||
stream := openAIEndpointSupportsStream(request.Kind) && (request.Stream || boolValue(body, "stream"))
|
||||
ensureOpenAIStreamUsage(body, request.Kind, stream)
|
||||
stream := openAIEndpointSupportsStream(endpointKind) && (request.Stream || boolValue(body, "stream"))
|
||||
ensureOpenAIStreamUsage(body, endpointKind, stream)
|
||||
raw, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(openAIBaseURL(request.Kind, request.Candidate), endpoint), bytes.NewReader(raw))
|
||||
upstreamEndpoint := joinURL(openAIBaseURL(endpointKind, request.Candidate), endpoint)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamEndpoint, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
@@ -44,7 +75,38 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
requestID := requestIDFromHTTPResponse(resp)
|
||||
result, err := decodeOpenAIResponse(resp, stream, request.StreamDelta)
|
||||
var result map[string]any
|
||||
upstreamResponseID := ""
|
||||
if request.Kind == "responses" && protocol == ProtocolOpenAIResponses && stream {
|
||||
result, upstreamResponseID, err = decodeNativeResponsesStream(resp, request.StreamDelta)
|
||||
} else {
|
||||
var streamDelta StreamDelta = request.StreamDelta
|
||||
var adapter *chatResponsesStreamAdapter
|
||||
if request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions && stream {
|
||||
adapter = newChatResponsesStreamAdapter(request.PublicResponseID, request.Model)
|
||||
streamDelta = func(event StreamDeltaEvent) error { return adapter.delta(event, request.StreamDelta) }
|
||||
}
|
||||
result, err = decodeOpenAIResponse(resp, stream, streamDelta)
|
||||
if err == nil && endpointKind == "chat.completions" {
|
||||
result = NormalizeChatCompletionResult(result)
|
||||
}
|
||||
if err == nil && request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions {
|
||||
chatResult := result
|
||||
upstreamResponseID = requestIDFromResult(chatResult)
|
||||
result = ChatResultToResponse(chatResult, request.PublicResponseID, request.Model, request.Body)
|
||||
if adapter != nil {
|
||||
err = adapter.done(result, request.StreamDelta)
|
||||
}
|
||||
if err == nil {
|
||||
return Response{
|
||||
Result: result, InternalResult: chatResult, RequestID: firstNonEmptyString(requestID, upstreamResponseID), Usage: usageFromOpenAI(chatResult),
|
||||
Progress: providerProgress(request), ResponseStartedAt: responseStartedAt, ResponseFinishedAt: time.Now(),
|
||||
UpstreamProtocol: protocol, UpstreamEndpoint: endpoint, UpstreamResponseID: upstreamResponseID,
|
||||
PublicResponseID: request.PublicResponseID, ResponseConverted: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && request.Kind == "chat.completions" {
|
||||
result = NormalizeChatCompletionResult(result)
|
||||
}
|
||||
@@ -55,6 +117,15 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
if requestID == "" {
|
||||
requestID = requestIDFromResult(result)
|
||||
}
|
||||
if request.Kind == "responses" && protocol == ProtocolOpenAIResponses {
|
||||
if upstreamResponseID == "" {
|
||||
upstreamResponseID = requestIDFromResult(result)
|
||||
}
|
||||
}
|
||||
publicResponseID := request.PublicResponseID
|
||||
if request.Kind == "responses" && protocol == ProtocolOpenAIResponses {
|
||||
publicResponseID = upstreamResponseID
|
||||
}
|
||||
return Response{
|
||||
Result: result,
|
||||
RequestID: requestID,
|
||||
@@ -63,6 +134,10 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
ResponseStartedAt: responseStartedAt,
|
||||
ResponseFinishedAt: responseFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
|
||||
UpstreamProtocol: protocol,
|
||||
UpstreamEndpoint: endpoint,
|
||||
UpstreamResponseID: upstreamResponseID,
|
||||
PublicResponseID: publicResponseID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Keep these lists aligned with openai-node 6.47.0 and the public OpenAI API
|
||||
// reference. The Gateway accepts a small, explicit set of routing extensions at
|
||||
// ingress, but only protocol fields (plus controlled provider adaptations) are
|
||||
// allowed across the upstream boundary.
|
||||
var openAIChatRequestParameters = stringSet(
|
||||
"messages", "model", "audio", "frequency_penalty", "function_call", "functions",
|
||||
"logit_bias", "logprobs", "max_completion_tokens", "max_tokens", "metadata",
|
||||
"modalities", "moderation", "n", "parallel_tool_calls", "prediction",
|
||||
"presence_penalty", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention",
|
||||
"reasoning_effort", "response_format", "safety_identifier", "seed", "service_tier",
|
||||
"stop", "store", "stream", "stream_options", "temperature", "tool_choice", "tools",
|
||||
"top_logprobs", "top_p", "user", "verbosity", "web_search_options",
|
||||
)
|
||||
|
||||
var openAIResponsesRequestParameters = stringSet(
|
||||
"background", "context_management", "conversation", "include", "input", "instructions",
|
||||
"max_output_tokens", "max_tool_calls", "metadata", "model", "moderation",
|
||||
"parallel_tool_calls", "previous_response_id", "prompt", "prompt_cache_key",
|
||||
"prompt_cache_options", "prompt_cache_retention", "reasoning", "safety_identifier",
|
||||
"service_tier", "store", "stream", "stream_options", "temperature", "text",
|
||||
"tool_choice", "tools", "top_logprobs", "top_p", "truncation", "user",
|
||||
)
|
||||
|
||||
var gatewayOpenAIRequestExtensions = stringSet(
|
||||
"runMode", "run_mode", "conversationId", "conversation_id", "sessionId", "session_id",
|
||||
"requestId", "request_id", "signal", "userMessage", "user_message", "platformId",
|
||||
"platform_id", "options", "enable_thinking", "thinking_budget_tokens", "enable_web_search",
|
||||
"modelType", "model_type", "capability", "capabilityType", "mode", "simulation", "testMode",
|
||||
"cacheAffinityKey", "cache_affinity_key", "simulationDurationMs", "simulationDurationSeconds",
|
||||
"simulationMinDurationMs", "simulationMaxDurationMs", "simulationMinDurationSeconds",
|
||||
"simulationMaxDurationSeconds", "simulationDurationMinMs", "simulationDurationMaxMs",
|
||||
"simulationDurationMinSeconds", "simulationDurationMaxSeconds", "simulationFailure",
|
||||
"simulationProfile", "simulationUsage",
|
||||
)
|
||||
|
||||
var gatewayResponsesRequestExtensions = stringSet("messages", "presence_penalty", "frequency_penalty")
|
||||
|
||||
var controlledOpenAIChatProviderParameters = stringSet(
|
||||
"enable_thinking", "thinking_budget", "thinking", "enable_web_search",
|
||||
)
|
||||
|
||||
var controlledOpenAIResponsesProviderParameters = stringSet("presence_penalty", "frequency_penalty")
|
||||
|
||||
func ValidateOpenAIRequestParameters(kind string, body map[string]any) error {
|
||||
allowed := openAIChatRequestParameters
|
||||
if kind == "responses" {
|
||||
allowed = openAIResponsesRequestParameters
|
||||
}
|
||||
unknown := make([]string, 0)
|
||||
for key := range body {
|
||||
if _, ok := allowed[key]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := gatewayOpenAIRequestExtensions[key]; ok {
|
||||
continue
|
||||
}
|
||||
if kind == "responses" {
|
||||
if _, ok := gatewayResponsesRequestExtensions[key]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
unknown = append(unknown, key)
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
return &ClientError{
|
||||
Code: "invalid_parameter",
|
||||
Message: fmt.Sprintf("Unknown parameter: %s", unknown[0]),
|
||||
Param: unknown[0],
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
func FilterOpenAIChatRequestBody(body map[string]any) map[string]any {
|
||||
return filterOpenAIRequestBody(body, openAIChatRequestParameters, controlledOpenAIChatProviderParameters)
|
||||
}
|
||||
|
||||
func FilterOpenAIResponsesRequestBody(body map[string]any) map[string]any {
|
||||
return filterOpenAIRequestBody(body, openAIResponsesRequestParameters, controlledOpenAIResponsesProviderParameters)
|
||||
}
|
||||
|
||||
func filterOpenAIRequestBody(body map[string]any, allowed map[string]struct{}, extensions map[string]struct{}) map[string]any {
|
||||
out := make(map[string]any, len(body))
|
||||
for key, value := range body {
|
||||
if _, ok := allowed[key]; ok {
|
||||
out[key] = value
|
||||
continue
|
||||
}
|
||||
if _, ok := extensions[key]; ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringSet(values ...string) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
out[value] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) {
|
||||
body := map[string]any{}
|
||||
for key := range openAIChatRequestParameters {
|
||||
body[key] = "sentinel-" + key
|
||||
}
|
||||
body["conversationId"] = "internal"
|
||||
body["unknown"] = "must-not-leak"
|
||||
|
||||
filtered := FilterOpenAIChatRequestBody(body)
|
||||
for key := range openAIChatRequestParameters {
|
||||
if _, ok := filtered[key]; !ok {
|
||||
t.Fatalf("official Chat parameter %q was removed", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"conversationId", "unknown"} {
|
||||
if _, ok := filtered[key]; ok {
|
||||
t.Fatalf("internal/unknown parameter %q leaked upstream", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesOfficialParametersSurviveBoundary(t *testing.T) {
|
||||
body := map[string]any{}
|
||||
for key := range openAIResponsesRequestParameters {
|
||||
body[key] = "sentinel-" + key
|
||||
}
|
||||
body["request_id"] = "internal"
|
||||
body["unknown"] = "must-not-leak"
|
||||
|
||||
filtered := FilterOpenAIResponsesRequestBody(body)
|
||||
for key := range openAIResponsesRequestParameters {
|
||||
if _, ok := filtered[key]; !ok {
|
||||
t.Fatalf("official Responses parameter %q was removed", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"request_id", "unknown"} {
|
||||
if _, ok := filtered[key]; ok {
|
||||
t.Fatalf("internal/unknown parameter %q leaked upstream", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOpenAIRequestParametersRejectsUnknownTopLevelField(t *testing.T) {
|
||||
err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "rogue": true})
|
||||
if err == nil || ErrorCode(err) != "invalid_parameter" || !strings.Contains(err.Error(), "rogue") {
|
||||
t.Fatalf("expected OpenAI-style invalid_parameter for rogue field, got %v", err)
|
||||
}
|
||||
if ErrorParam(err) != "rogue" {
|
||||
t.Fatalf("expected rogue parameter attribution, got %q", ErrorParam(err))
|
||||
}
|
||||
if err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "messages": []any{}, "request_id": "internal"}); err != nil {
|
||||
t.Fatalf("expected controlled Responses extensions to remain accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOpenAIRequestParametersAcceptsInternalSimulationAndAffinityFields(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "demo", "messages": []any{}, "simulation": true,
|
||||
"simulationDurationMs": 5, "simulationUsage": map[string]any{"inputTokens": 10},
|
||||
"cacheAffinityKey": "conversation-1",
|
||||
}
|
||||
if err := ValidateOpenAIRequestParameters("chat.completions", body); err != nil {
|
||||
t.Fatalf("expected documented gateway extensions to remain accepted, got %v", err)
|
||||
}
|
||||
filtered := FilterOpenAIChatRequestBody(body)
|
||||
for _, key := range []string{"simulation", "simulationDurationMs", "simulationUsage", "cacheAffinityKey"} {
|
||||
if _, ok := filtered[key]; ok {
|
||||
t.Fatalf("gateway-only field %q leaked upstream", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesFallbackMapsEquivalentCurrentParameters(t *testing.T) {
|
||||
body, err := ResponsesRequestToChat(map[string]any{
|
||||
"input": "hello", "store": false, "metadata": map[string]any{"trace": "1"},
|
||||
"request_id": "internal-request", "platform_id": "internal-platform",
|
||||
"moderation": map[string]any{"type": "auto"}, "prompt_cache_key": "cache-key",
|
||||
"prompt_cache_options": map[string]any{"type": "ephemeral"}, "prompt_cache_retention": "in_memory",
|
||||
"safety_identifier": "safe", "service_tier": "priority", "top_logprobs": 3,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
"text": map[string]any{"format": map[string]any{"type": "text"}, "verbosity": "low"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("convert Responses request: %v", err)
|
||||
}
|
||||
for _, key := range []string{"store", "metadata", "moderation", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention", "safety_identifier", "service_tier", "top_logprobs", "stream_options", "verbosity"} {
|
||||
if _, ok := body[key]; !ok {
|
||||
t.Fatalf("equivalent parameter %q was not mapped", key)
|
||||
}
|
||||
}
|
||||
if body["logprobs"] != true {
|
||||
t.Fatalf("top_logprobs fallback must enable Chat logprobs: %+v", body)
|
||||
}
|
||||
for _, key := range []string{"request_id", "platform_id"} {
|
||||
if _, ok := body[key]; ok {
|
||||
t.Fatalf("internal Responses parameter %q leaked into Chat fallback: %+v", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ProtocolOpenAIChatCompletions = "openai_chat_completions"
|
||||
ProtocolOpenAIResponses = "openai_responses"
|
||||
ProtocolAnthropicMessages = "anthropic_messages"
|
||||
)
|
||||
|
||||
var supportedResponseFallbackParameters = map[string]struct{}{
|
||||
"model": {}, "input": {}, "messages": {}, "instructions": {}, "tools": {}, "tool_choice": {},
|
||||
"parallel_tool_calls": {}, "max_output_tokens": {}, "temperature": {}, "top_p": {},
|
||||
"presence_penalty": {}, "frequency_penalty": {}, "reasoning": {}, "text": {},
|
||||
"stream": {}, "stream_options": {}, "store": {}, "previous_response_id": {}, "metadata": {}, "user": {},
|
||||
"moderation": {}, "prompt_cache_key": {}, "prompt_cache_options": {}, "prompt_cache_retention": {},
|
||||
"safety_identifier": {}, "service_tier": {}, "top_logprobs": {},
|
||||
}
|
||||
|
||||
func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[string]any, error) {
|
||||
for key := range body {
|
||||
if _, internal := gatewayOpenAIRequestExtensions[key]; internal {
|
||||
continue
|
||||
}
|
||||
if _, ok := supportedResponseFallbackParameters[key]; !ok {
|
||||
return nil, unsupportedResponseParameter(key)
|
||||
}
|
||||
}
|
||||
messages := make([]any, 0)
|
||||
for _, turn := range history {
|
||||
priorInput := turn.Request["input"]
|
||||
if priorInput == nil {
|
||||
priorInput = turn.Request["messages"]
|
||||
}
|
||||
priorMessages, err := responseInputMessages(priorInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = append(messages, priorMessages...)
|
||||
if assistant := chatAssistantMessage(turn.Internal, turn.Response); assistant != nil {
|
||||
messages = append(messages, assistant)
|
||||
}
|
||||
}
|
||||
if instructions := strings.TrimSpace(stringFromAny(body["instructions"])); instructions != "" {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": instructions})
|
||||
}
|
||||
if rawMessages, ok := body["messages"].([]any); ok {
|
||||
messages = append(messages, rawMessages...)
|
||||
} else {
|
||||
currentMessages, err := responseInputMessages(body["input"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = append(messages, currentMessages...)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, &ClientError{Code: "invalid_parameter", Message: "input is required", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
out := map[string]any{"messages": messages}
|
||||
for _, key := range []string{
|
||||
"temperature", "top_p", "presence_penalty", "frequency_penalty", "parallel_tool_calls",
|
||||
"stream", "stream_options", "store", "metadata", "user", "moderation", "prompt_cache_key",
|
||||
"prompt_cache_options", "prompt_cache_retention", "safety_identifier", "service_tier",
|
||||
} {
|
||||
if value, ok := body[key]; ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
if value, ok := body["top_logprobs"]; ok {
|
||||
out["top_logprobs"] = value
|
||||
out["logprobs"] = true
|
||||
}
|
||||
if value, ok := body["max_output_tokens"]; ok {
|
||||
out["max_tokens"] = value
|
||||
}
|
||||
if rawReasoning, ok := body["reasoning"]; ok {
|
||||
reasoning, ok := rawReasoning.(map[string]any)
|
||||
if !ok {
|
||||
return nil, unsupportedResponseParameter("reasoning")
|
||||
}
|
||||
for key := range reasoning {
|
||||
if key != "effort" {
|
||||
return nil, unsupportedResponseParameter("reasoning." + key)
|
||||
}
|
||||
}
|
||||
if effort, ok := reasoning["effort"]; ok {
|
||||
out["reasoning_effort"] = effort
|
||||
}
|
||||
}
|
||||
if rawText, ok := body["text"]; ok {
|
||||
responseFormat, verbosity, err := responseTextParams(rawText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if responseFormat != nil {
|
||||
out["response_format"] = responseFormat
|
||||
}
|
||||
if verbosity != nil {
|
||||
out["verbosity"] = verbosity
|
||||
}
|
||||
}
|
||||
if rawTools, ok := body["tools"]; ok {
|
||||
tools, err := responseToolsToChat(rawTools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["tools"] = tools
|
||||
}
|
||||
if rawChoice, ok := body["tool_choice"]; ok {
|
||||
choice, err := responseToolChoiceToChat(rawChoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["tool_choice"] = choice
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func responseInputMessages(value any) ([]any, error) {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
return []any{map[string]any{"role": "user", "content": typed}}, nil
|
||||
case []any:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, raw := range typed {
|
||||
switch item := raw.(type) {
|
||||
case string:
|
||||
out = append(out, map[string]any{"role": "user", "content": item})
|
||||
case map[string]any:
|
||||
switch stringFromAny(item["type"]) {
|
||||
case "function_call_output":
|
||||
callID := firstNonEmptyString(item["call_id"], item["id"])
|
||||
if callID == "" {
|
||||
return nil, &ClientError{Code: "invalid_parameter", Message: "function_call_output.call_id is required", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": toolResultContent(item["output"])})
|
||||
case "message", "":
|
||||
role := firstNonEmptyString(item["role"], "user")
|
||||
out = append(out, map[string]any{"role": role, "content": responseContentToChat(item["content"])})
|
||||
default:
|
||||
return nil, unsupportedResponseParameter("input.type=" + stringFromAny(item["type"]))
|
||||
}
|
||||
default:
|
||||
return nil, unsupportedResponseParameter("input")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case map[string]any:
|
||||
return responseInputMessages([]any{typed})
|
||||
default:
|
||||
return nil, unsupportedResponseParameter("input")
|
||||
}
|
||||
}
|
||||
|
||||
func responseContentToChat(value any) any {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
out = append(out, raw)
|
||||
continue
|
||||
}
|
||||
switch stringFromAny(item["type"]) {
|
||||
case "input_text", "output_text", "text":
|
||||
out = append(out, map[string]any{"type": "text", "text": stringFromAny(item["text"])})
|
||||
case "input_image":
|
||||
url := firstNonEmptyString(item["image_url"], item["url"])
|
||||
out = append(out, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
|
||||
default:
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func responseToolsToChat(value any) ([]any, error) {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, unsupportedResponseParameter("tools")
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
for _, raw := range items {
|
||||
tool, ok := raw.(map[string]any)
|
||||
if !ok || stringFromAny(tool["type"]) != "function" {
|
||||
return nil, &ClientError{Code: "unsupported_response_tool", Message: "Chat fallback only supports custom function tools", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
function := map[string]any{
|
||||
"name": tool["name"],
|
||||
"description": tool["description"],
|
||||
"parameters": tool["parameters"],
|
||||
}
|
||||
if strict, ok := tool["strict"]; ok {
|
||||
function["strict"] = strict
|
||||
}
|
||||
out = append(out, map[string]any{"type": "function", "function": function})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func responseToolChoiceToChat(value any) (any, error) {
|
||||
if text, ok := value.(string); ok {
|
||||
switch text {
|
||||
case "auto", "none", "required":
|
||||
return text, nil
|
||||
default:
|
||||
return nil, unsupportedResponseParameter("tool_choice")
|
||||
}
|
||||
}
|
||||
choice, ok := value.(map[string]any)
|
||||
if !ok || stringFromAny(choice["type"]) != "function" || stringFromAny(choice["name"]) == "" {
|
||||
return nil, unsupportedResponseParameter("tool_choice")
|
||||
}
|
||||
return map[string]any{"type": "function", "function": map[string]any{"name": choice["name"]}}, nil
|
||||
}
|
||||
|
||||
func responseTextParams(value any) (map[string]any, any, error) {
|
||||
text, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil, unsupportedResponseParameter("text")
|
||||
}
|
||||
for key := range text {
|
||||
if key != "format" && key != "verbosity" {
|
||||
return nil, nil, unsupportedResponseParameter("text." + key)
|
||||
}
|
||||
}
|
||||
verbosity := text["verbosity"]
|
||||
format, ok := text["format"].(map[string]any)
|
||||
if !ok || len(format) == 0 {
|
||||
return nil, verbosity, nil
|
||||
}
|
||||
switch stringFromAny(format["type"]) {
|
||||
case "text":
|
||||
return map[string]any{"type": "text"}, verbosity, nil
|
||||
case "json_object":
|
||||
return map[string]any{"type": "json_object"}, verbosity, nil
|
||||
case "json_schema":
|
||||
return map[string]any{"type": "json_schema", "json_schema": map[string]any{
|
||||
"name": format["name"], "schema": format["schema"], "strict": format["strict"],
|
||||
}}, verbosity, nil
|
||||
default:
|
||||
return nil, nil, unsupportedResponseParameter("text.format.type")
|
||||
}
|
||||
}
|
||||
|
||||
func chatAssistantMessage(internal map[string]any, visible map[string]any) map[string]any {
|
||||
if choices, ok := internal["choices"].([]any); ok && len(choices) > 0 {
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
if message, ok := choice["message"].(map[string]any); ok {
|
||||
return cloneMapAny(message)
|
||||
}
|
||||
}
|
||||
output, _ := visible["output"].([]any)
|
||||
message := map[string]any{"role": "assistant"}
|
||||
textParts := make([]string, 0)
|
||||
toolCalls := make([]any, 0)
|
||||
for _, raw := range output {
|
||||
item, _ := raw.(map[string]any)
|
||||
switch stringFromAny(item["type"]) {
|
||||
case "message":
|
||||
content, _ := item["content"].([]any)
|
||||
for _, rawContent := range content {
|
||||
part, _ := rawContent.(map[string]any)
|
||||
if stringFromAny(part["type"]) == "output_text" {
|
||||
textParts = append(textParts, stringFromAny(part["text"]))
|
||||
}
|
||||
}
|
||||
case "function_call":
|
||||
toolCalls = append(toolCalls, map[string]any{
|
||||
"id": item["call_id"], "type": "function",
|
||||
"function": map[string]any{"name": item["name"], "arguments": item["arguments"]},
|
||||
})
|
||||
}
|
||||
}
|
||||
message["content"] = strings.Join(textParts, "")
|
||||
if len(toolCalls) > 0 {
|
||||
message["tool_calls"] = toolCalls
|
||||
}
|
||||
if len(textParts) == 0 && len(toolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func ChatResultToResponse(chat map[string]any, publicID string, model string, requestBody map[string]any) map[string]any {
|
||||
createdAt := time.Now().Unix()
|
||||
if value := intFromAny(chat["created"]); value > 0 {
|
||||
createdAt = int64(value)
|
||||
}
|
||||
output := make([]any, 0)
|
||||
outputText := ""
|
||||
choices, _ := chat["choices"].([]any)
|
||||
if len(choices) > 0 {
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
message, _ := choice["message"].(map[string]any)
|
||||
if content := visibleChatContent(message["content"]); content != "" {
|
||||
outputText = content
|
||||
output = append(output, map[string]any{
|
||||
"id": "msg_" + responseIDSuffix(publicID), "type": "message", "status": "completed", "role": "assistant",
|
||||
"content": []any{map[string]any{"type": "output_text", "text": content, "annotations": []any{}, "logprobs": []any{}}},
|
||||
})
|
||||
}
|
||||
toolCalls, _ := message["tool_calls"].([]any)
|
||||
for index, rawToolCall := range toolCalls {
|
||||
toolCall, _ := rawToolCall.(map[string]any)
|
||||
function, _ := toolCall["function"].(map[string]any)
|
||||
callID := firstNonEmptyString(toolCall["id"], fmt.Sprintf("call_%d", index))
|
||||
output = append(output, map[string]any{
|
||||
"id": "fc_" + responseIDSuffix(publicID) + fmt.Sprintf("_%d", index),
|
||||
"type": "function_call", "status": "completed", "call_id": callID,
|
||||
"name": stringFromAny(function["name"]), "arguments": stringFromAny(function["arguments"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
usage := responseUsageFromChat(chat["usage"])
|
||||
out := map[string]any{
|
||||
"id": publicID, "object": "response", "created_at": createdAt, "status": "completed",
|
||||
"model": model, "output": output, "output_text": outputText, "error": nil, "incomplete_details": nil,
|
||||
"usage": usage,
|
||||
}
|
||||
if previousResponseID := strings.TrimSpace(stringFromAny(requestBody["previous_response_id"])); previousResponseID != "" {
|
||||
out["previous_response_id"] = previousResponseID
|
||||
} else {
|
||||
out["previous_response_id"] = nil
|
||||
}
|
||||
applyResponseFinishReason(out, choices)
|
||||
for _, key := range []string{"instructions", "parallel_tool_calls", "temperature", "top_p", "tools", "tool_choice", "metadata"} {
|
||||
if value, ok := requestBody[key]; ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyResponseFinishReason(response map[string]any, choices []any) {
|
||||
if len(choices) == 0 {
|
||||
return
|
||||
}
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
switch strings.TrimSpace(stringFromAny(choice["finish_reason"])) {
|
||||
case "length":
|
||||
response["status"] = "incomplete"
|
||||
response["incomplete_details"] = map[string]any{"reason": "max_output_tokens"}
|
||||
case "content_filter":
|
||||
response["status"] = "incomplete"
|
||||
response["incomplete_details"] = map[string]any{"reason": "content_filter"}
|
||||
}
|
||||
}
|
||||
|
||||
func responseUsageFromChat(value any) map[string]any {
|
||||
usage, _ := value.(map[string]any)
|
||||
normalized := usageFromOpenAIUsage(usage)
|
||||
return map[string]any{
|
||||
"input_tokens": normalized.InputTokens,
|
||||
"input_tokens_details": map[string]any{"cached_tokens": normalized.CachedInputTokens},
|
||||
"output_tokens": normalized.OutputTokens,
|
||||
"output_tokens_details": map[string]any{"reasoning_tokens": reasoningTokensFromUsage(usage)},
|
||||
"total_tokens": normalized.TotalTokens,
|
||||
}
|
||||
}
|
||||
|
||||
func reasoningTokensFromUsage(usage map[string]any) int {
|
||||
for _, key := range []string{"completion_tokens_details", "output_tokens_details"} {
|
||||
if details, ok := usage[key].(map[string]any); ok {
|
||||
if value := intFromAny(details["reasoning_tokens"]); value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func visibleChatContent(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
items, _ := value.([]any)
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]any)
|
||||
if text := stringFromAny(firstPresent(item["text"], item["content"])); text != "" && !isReasoningContentBlock(item) {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func decodeNativeResponsesStream(resp *http.Response, onDelta StreamDelta) (map[string]any, string, error) {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
|
||||
return nil, "", &ClientError{Code: statusCodeName(resp.StatusCode), Message: errorMessage(raw, resp.Status), StatusCode: resp.StatusCode, RequestID: requestIDFromHTTPResponse(resp), Retryable: HTTPRetryable(resp.StatusCode)}
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
var completed map[string]any
|
||||
upstreamID := ""
|
||||
rawLines := make([]string, 0)
|
||||
eventName := ""
|
||||
dataLines := make([]string, 0)
|
||||
processFrame := func() error {
|
||||
payload := strings.TrimSpace(strings.Join(dataLines, "\n"))
|
||||
frameEventName := eventName
|
||||
eventName = ""
|
||||
dataLines = dataLines[:0]
|
||||
if payload == "" || payload == "[DONE]" {
|
||||
return nil
|
||||
}
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &event); err != nil {
|
||||
return nil
|
||||
}
|
||||
if stringFromAny(event["type"]) == "" && frameEventName != "" {
|
||||
event["type"] = frameEventName
|
||||
}
|
||||
if failure := responseStreamError(event); failure != nil {
|
||||
return failure
|
||||
}
|
||||
if response, ok := event["response"].(map[string]any); ok && upstreamID == "" {
|
||||
upstreamID = stringFromAny(response["id"])
|
||||
}
|
||||
if stringFromAny(event["type"]) == "response.completed" {
|
||||
completed, _ = event["response"].(map[string]any)
|
||||
if onDelta != nil {
|
||||
return onDelta(StreamDeltaEvent{Event: event})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if stringFromAny(event["type"]) == "response.failed" {
|
||||
if onDelta != nil {
|
||||
if err := onDelta(StreamDeltaEvent{Event: event}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
failed, _ := event["response"].(map[string]any)
|
||||
failure, _ := failed["error"].(map[string]any)
|
||||
code := firstNonEmptyString(failure["code"], "response_failed")
|
||||
message := firstNonEmptyString(failure["message"], "upstream Responses request failed")
|
||||
return &ClientError{Code: code, Message: message, StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if onDelta != nil {
|
||||
return onDelta(StreamDeltaEvent{Text: nativeResponseDeltaText(event), Event: event})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for scanner.Scan() {
|
||||
rawLine := scanner.Text()
|
||||
rawLines = append(rawLines, rawLine)
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" {
|
||||
if err := processFrame(); err != nil {
|
||||
return nil, upstreamID, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "event:") {
|
||||
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
}
|
||||
}
|
||||
if err := processFrame(); err != nil {
|
||||
return nil, upstreamID, err
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, upstreamID, &ClientError{Code: "stream_read_error", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
if completed == nil {
|
||||
raw := strings.TrimSpace(strings.Join(rawLines, "\n"))
|
||||
var response map[string]any
|
||||
if raw != "" && json.Unmarshal([]byte(raw), &response) == nil {
|
||||
if failure := responseStreamError(response); failure != nil {
|
||||
return nil, upstreamID, failure
|
||||
}
|
||||
if stringFromAny(response["object"]) == "response" {
|
||||
upstreamID = stringFromAny(response["id"])
|
||||
return response, upstreamID, nil
|
||||
}
|
||||
}
|
||||
return nil, upstreamID, &ClientError{Code: "invalid_response", Message: "Responses stream ended without response.completed", Retryable: false}
|
||||
}
|
||||
return completed, upstreamID, nil
|
||||
}
|
||||
|
||||
func responseStreamError(event map[string]any) error {
|
||||
if stringFromAny(event["type"]) != "error" && event["error"] == nil {
|
||||
return nil
|
||||
}
|
||||
failure, _ := event["error"].(map[string]any)
|
||||
if failure == nil {
|
||||
failure = event
|
||||
}
|
||||
code := firstNonEmptyString(failure["code"], "response_failed")
|
||||
message := firstNonEmptyString(failure["message"], "upstream Responses stream failed")
|
||||
return &ClientError{Code: code, Message: message, StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
|
||||
func nativeResponseDeltaText(event map[string]any) string {
|
||||
if stringFromAny(event["type"]) == "response.output_text.delta" {
|
||||
return stringFromAny(event["delta"])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type chatResponsesStreamAdapter struct {
|
||||
publicID string
|
||||
model string
|
||||
started bool
|
||||
sequence int
|
||||
nextOutput int
|
||||
textOutput int
|
||||
textAdded bool
|
||||
text strings.Builder
|
||||
tools map[int]*chatResponseStreamTool
|
||||
}
|
||||
|
||||
type chatResponseStreamTool struct {
|
||||
ItemID string
|
||||
CallID string
|
||||
Name string
|
||||
Arguments strings.Builder
|
||||
Added bool
|
||||
OutputIndex int
|
||||
}
|
||||
|
||||
func newChatResponsesStreamAdapter(publicID string, model string) *chatResponsesStreamAdapter {
|
||||
return &chatResponsesStreamAdapter{publicID: publicID, model: model, textOutput: -1, tools: map[int]*chatResponseStreamTool{}}
|
||||
}
|
||||
|
||||
func (a *chatResponsesStreamAdapter) emit(onDelta StreamDelta, event map[string]any) error {
|
||||
if onDelta == nil {
|
||||
return nil
|
||||
}
|
||||
event["sequence_number"] = a.sequence
|
||||
a.sequence++
|
||||
return onDelta(StreamDeltaEvent{Text: nativeResponseDeltaText(event), Event: event})
|
||||
}
|
||||
|
||||
func (a *chatResponsesStreamAdapter) start(onDelta StreamDelta) error {
|
||||
if a.started || onDelta == nil {
|
||||
return nil
|
||||
}
|
||||
a.started = true
|
||||
response := map[string]any{"id": a.publicID, "object": "response", "created_at": time.Now().Unix(), "status": "in_progress", "model": a.model, "output": []any{}}
|
||||
for _, eventType := range []string{"response.created", "response.in_progress"} {
|
||||
if err := a.emit(onDelta, map[string]any{"type": eventType, "response": response}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *chatResponsesStreamAdapter) delta(event StreamDeltaEvent, onDelta StreamDelta) error {
|
||||
if onDelta == nil || event.Event == nil {
|
||||
return nil
|
||||
}
|
||||
if err := a.start(onDelta); err != nil {
|
||||
return err
|
||||
}
|
||||
choices, _ := event.Event["choices"].([]any)
|
||||
for _, rawChoice := range choices {
|
||||
choice, _ := rawChoice.(map[string]any)
|
||||
delta, _ := choice["delta"].(map[string]any)
|
||||
if content := stringFromAny(delta["content"]); content != "" {
|
||||
if !a.textAdded {
|
||||
a.textAdded = true
|
||||
a.textOutput = a.nextOutput
|
||||
a.nextOutput++
|
||||
itemID := "msg_" + responseIDSuffix(a.publicID)
|
||||
if err := a.emit(onDelta, map[string]any{
|
||||
"type": "response.output_item.added", "response_id": a.publicID, "output_index": a.textOutput,
|
||||
"item": map[string]any{"id": itemID, "type": "message", "status": "in_progress", "role": "assistant", "content": []any{}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.emit(onDelta, map[string]any{
|
||||
"type": "response.content_part.added", "response_id": a.publicID, "item_id": itemID,
|
||||
"output_index": a.textOutput, "content_index": 0,
|
||||
"part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}, "logprobs": []any{}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.text.WriteString(content)
|
||||
if err := a.emit(onDelta, map[string]any{
|
||||
"type": "response.output_text.delta", "response_id": a.publicID,
|
||||
"item_id": "msg_" + responseIDSuffix(a.publicID), "output_index": a.textOutput, "content_index": 0, "delta": content,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
toolCalls, _ := delta["tool_calls"].([]any)
|
||||
for _, rawToolCall := range toolCalls {
|
||||
toolCall, _ := rawToolCall.(map[string]any)
|
||||
index := intFromAny(toolCall["index"])
|
||||
tool := a.tools[index]
|
||||
if tool == nil {
|
||||
tool = &chatResponseStreamTool{ItemID: fmt.Sprintf("fc_%s_%d", responseIDSuffix(a.publicID), index), OutputIndex: a.nextOutput}
|
||||
a.nextOutput++
|
||||
a.tools[index] = tool
|
||||
}
|
||||
if callID := stringFromAny(toolCall["id"]); callID != "" {
|
||||
tool.CallID = callID
|
||||
}
|
||||
function, _ := toolCall["function"].(map[string]any)
|
||||
if name := stringFromAny(function["name"]); name != "" {
|
||||
tool.Name += name
|
||||
}
|
||||
arguments := stringFromAny(function["arguments"])
|
||||
if !tool.Added && (tool.CallID != "" || tool.Name != "" || arguments != "") {
|
||||
tool.Added = true
|
||||
if err := a.emit(onDelta, map[string]any{
|
||||
"type": "response.output_item.added", "response_id": a.publicID, "output_index": tool.OutputIndex,
|
||||
"item": map[string]any{"id": tool.ItemID, "type": "function_call", "status": "in_progress", "call_id": tool.CallID, "name": tool.Name, "arguments": ""},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if arguments != "" {
|
||||
tool.Arguments.WriteString(arguments)
|
||||
if err := a.emit(onDelta, map[string]any{
|
||||
"type": "response.function_call_arguments.delta", "response_id": a.publicID,
|
||||
"item_id": tool.ItemID, "output_index": tool.OutputIndex, "delta": arguments,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *chatResponsesStreamAdapter) done(result map[string]any, onDelta StreamDelta) error {
|
||||
if onDelta == nil {
|
||||
return nil
|
||||
}
|
||||
a.alignOutput(result)
|
||||
if a.text.Len() > 0 {
|
||||
text := a.text.String()
|
||||
itemID := "msg_" + responseIDSuffix(a.publicID)
|
||||
if err := a.emit(onDelta, map[string]any{"type": "response.output_text.done", "response_id": a.publicID, "item_id": itemID, "output_index": a.textOutput, "content_index": 0, "text": text}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.emit(onDelta, map[string]any{
|
||||
"type": "response.content_part.done", "response_id": a.publicID, "item_id": itemID,
|
||||
"output_index": a.textOutput, "content_index": 0,
|
||||
"part": map[string]any{"type": "output_text", "text": text, "annotations": []any{}, "logprobs": []any{}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.emit(onDelta, map[string]any{"type": "response.output_item.done", "response_id": a.publicID, "output_index": a.textOutput, "item": firstResponseOutputItem(result, "message")}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tools := make([]*chatResponseStreamTool, 0, len(a.tools))
|
||||
for _, tool := range a.tools {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
sort.Slice(tools, func(i, j int) bool { return tools[i].OutputIndex < tools[j].OutputIndex })
|
||||
for _, tool := range tools {
|
||||
arguments := tool.Arguments.String()
|
||||
if err := a.emit(onDelta, map[string]any{"type": "response.function_call_arguments.done", "response_id": a.publicID, "item_id": tool.ItemID, "output_index": tool.OutputIndex, "arguments": arguments}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.emit(onDelta, map[string]any{"type": "response.output_item.done", "response_id": a.publicID, "output_index": tool.OutputIndex, "item": responseOutputItemByID(result, tool.ItemID)}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *chatResponsesStreamAdapter) alignOutput(response map[string]any) {
|
||||
items, _ := response["output"].([]any)
|
||||
if len(items) == 0 || a.nextOutput == 0 {
|
||||
return
|
||||
}
|
||||
ordered := make([]any, a.nextOutput)
|
||||
if a.textOutput >= 0 {
|
||||
ordered[a.textOutput] = firstResponseOutputItem(response, "message")
|
||||
}
|
||||
for _, tool := range a.tools {
|
||||
ordered[tool.OutputIndex] = responseOutputItemByID(response, tool.ItemID)
|
||||
}
|
||||
result := make([]any, 0, len(ordered))
|
||||
for _, item := range ordered {
|
||||
if item != nil {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
response["output"] = result
|
||||
}
|
||||
|
||||
func firstResponseOutputItem(response map[string]any, itemType string) any {
|
||||
items, _ := response["output"].([]any)
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]any)
|
||||
if stringFromAny(item["type"]) == itemType {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func responseOutputItemByID(response map[string]any, id string) any {
|
||||
items, _ := response["output"].([]any)
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]any)
|
||||
if stringFromAny(item["id"]) == id {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func responseIDSuffix(publicID string) string {
|
||||
return strings.TrimPrefix(publicID, "resp_")
|
||||
}
|
||||
|
||||
func unsupportedResponseParameter(parameter string) error {
|
||||
return &ClientError{Code: "unsupported_response_parameter", Message: "Chat fallback does not support Responses parameter: " + parameter, StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestOpenAIResponsesNativeUsesResponsesEndpointAndPreservesVendorIDs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/responses" {
|
||||
t.Fatalf("expected /responses, got %s", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["messages"] != nil {
|
||||
t.Fatalf("native Responses request must not contain messages: %+v", body)
|
||||
}
|
||||
input, _ := body["input"].([]any)
|
||||
if len(input) != 1 {
|
||||
t.Fatalf("native Responses request must translate controlled messages to input: %+v", body)
|
||||
}
|
||||
if body["previous_response_id"] != "resp_upstream_parent" {
|
||||
t.Fatalf("expected translated upstream previous id, got %+v", body["previous_response_id"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "resp_upstream_child", "object": "response", "status": "completed", "previous_response_id": "resp_upstream_parent",
|
||||
"output": []any{
|
||||
map[string]any{
|
||||
"type": "message",
|
||||
"content": []any{map[string]any{"type": "output_text", "text": "ok"}},
|
||||
},
|
||||
},
|
||||
"usage": map[string]any{"input_tokens": 2, "output_tokens": 1, "total_tokens": 3},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
||||
Kind: "responses", Model: "Demo",
|
||||
Body: map[string]any{"messages": []any{map[string]any{"role": "user", "content": "hello"}}},
|
||||
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
||||
UpstreamProtocol: ProtocolOpenAIResponses, PublicResponseID: "resp_12345678901234567890123456789012",
|
||||
PublicPreviousResponseID: "resp_abcdefghijklmnopqrstuvwxyz123456", UpstreamPreviousResponseID: "resp_upstream_parent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Result["id"] != "resp_upstream_child" || response.Result["previous_response_id"] != "resp_upstream_parent" {
|
||||
t.Fatalf("vendor response ids were not preserved: %+v", response.Result)
|
||||
}
|
||||
if response.PublicResponseID != "resp_upstream_child" || response.UpstreamResponseID != "resp_upstream_child" || response.UpstreamEndpoint != "/responses" || response.ResponseConverted {
|
||||
t.Fatalf("unexpected native response metadata: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesChatFallbackPreservesHistoryToolsUsageAndReasoningInternally(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/chat/completions" {
|
||||
t.Fatalf("expected /chat/completions, got %s", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages, _ := body["messages"].([]any)
|
||||
if len(messages) != 4 {
|
||||
t.Fatalf("expected prior user/assistant plus current system/user/tool messages, got %+v", messages)
|
||||
}
|
||||
if body["max_tokens"] != float64(128) || body["reasoning_effort"] != "high" {
|
||||
t.Fatalf("expected mapped max/reasoning fields: %+v", body)
|
||||
}
|
||||
tools, _ := body["tools"].([]any)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected mapped function tool: %+v", body["tools"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-upstream", "object": "chat.completion", "created": 1710000000, "model": "demo",
|
||||
"choices": []any{map[string]any{"index": 0, "finish_reason": "tool_calls", "message": map[string]any{
|
||||
"role": "assistant", "content": "visible", "reasoning_content": "hidden reasoning",
|
||||
"tool_calls": []any{map[string]any{"id": "call_stable", "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{\"x\":1}"}}},
|
||||
}}},
|
||||
"usage": map[string]any{"prompt_tokens": 20, "completion_tokens": 5, "total_tokens": 25, "prompt_tokens_details": map[string]any{"cached_tokens": 7}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
||||
Kind: "responses", Model: "Demo", Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
||||
UpstreamProtocol: ProtocolOpenAIChatCompletions, PublicResponseID: "resp_12345678901234567890123456789012",
|
||||
PreviousResponseTurns: []ResponseTurn{{
|
||||
Request: map[string]any{"input": "prior user", "instructions": "must not be replayed"},
|
||||
Internal: map[string]any{
|
||||
"choices": []any{map[string]any{
|
||||
"message": map[string]any{"role": "assistant", "content": "prior assistant"},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
Body: map[string]any{
|
||||
"instructions": "current instruction", "previous_response_id": "resp_parent", "input": []any{map[string]any{"type": "function_call_output", "call_id": "call_prior", "output": "done"}},
|
||||
"max_output_tokens": 128, "reasoning": map[string]any{"effort": "high"},
|
||||
"tools": []any{map[string]any{"type": "function", "name": "lookup", "description": "demo", "parameters": map[string]any{"type": "object"}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !response.ResponseConverted || response.UpstreamProtocol != ProtocolOpenAIChatCompletions || response.UpstreamEndpoint != "/chat/completions" {
|
||||
t.Fatalf("unexpected conversion metadata: %+v", response)
|
||||
}
|
||||
if response.Result["previous_response_id"] != "resp_parent" {
|
||||
t.Fatalf("Chat fallback response lost previous_response_id: %+v", response.Result)
|
||||
}
|
||||
output, _ := response.Result["output"].([]any)
|
||||
if len(output) != 2 {
|
||||
t.Fatalf("expected message plus function call: %+v", response.Result)
|
||||
}
|
||||
call, _ := output[1].(map[string]any)
|
||||
if call["call_id"] != "call_stable" || call["arguments"] != "{\"x\":1}" {
|
||||
t.Fatalf("function call continuity lost: %+v", call)
|
||||
}
|
||||
if strings.Contains(string(mustJSON(t, response.Result)), "hidden reasoning") {
|
||||
t.Fatalf("visible Responses result leaked reasoning: %+v", response.Result)
|
||||
}
|
||||
if !strings.Contains(string(mustJSON(t, response.InternalResult)), "hidden reasoning") {
|
||||
t.Fatalf("internal snapshot must retain reasoning for continuation: %+v", response.InternalResult)
|
||||
}
|
||||
usage, _ := response.Result["usage"].(map[string]any)
|
||||
details, _ := usage["input_tokens_details"].(map[string]any)
|
||||
if details["cached_tokens"] != 7 {
|
||||
t.Fatalf("cached usage was not preserved: %+v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesNativeStreamForwardsEventsWithoutChatAggregation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_upstream\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n"))
|
||||
_, _ = w.Write([]byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"response_id\":\"resp_upstream\",\"delta\":\"hello\"}\n\n"))
|
||||
_, _ = w.Write([]byte("event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_upstream\",\"object\":\"response\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
events := make([]StreamDeltaEvent, 0)
|
||||
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
||||
Kind: "responses", Model: "Demo", Body: map[string]any{"input": "hello", "stream": true}, Stream: true,
|
||||
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
||||
UpstreamProtocol: ProtocolOpenAIResponses, PublicResponseID: "resp_12345678901234567890123456789012",
|
||||
StreamDelta: func(event StreamDeltaEvent) error { events = append(events, event); return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 3 || events[0].Event["type"] != "response.created" || events[1].Event["type"] != "response.output_text.delta" || events[2].Event["type"] != "response.completed" {
|
||||
t.Fatalf("unexpected forwarded events: %+v", events)
|
||||
}
|
||||
if response.Result["object"] != "response" || response.Result["choices"] != nil || response.UpstreamResponseID != "resp_upstream" {
|
||||
t.Fatalf("native stream was rewritten as Chat: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesChatFallbackStreamsFunctionArgumentFragments(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/chat/completions" {
|
||||
t.Fatalf("expected chat fallback endpoint, got %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tool\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_stream_0\",\"type\":\"function\",\"function\":{\"name\":\"lookup_x\",\"arguments\":\"{\\\"x\\\":\"}},{\"index\":1,\"id\":\"call_stream_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup_y\",\"arguments\":\"{\\\"y\\\":\"}}]},\"finish_reason\":null}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tool\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"1}\"}},{\"index\":1,\"function\":{\"arguments\":\"2}\"}}]},\"finish_reason\":null}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tool\",\"object\":\"chat.completion.chunk\",\"model\":\"demo\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3,\"total_tokens\":5}}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
events := make([]StreamDeltaEvent, 0)
|
||||
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
||||
Kind: "responses", Model: "Demo", Body: map[string]any{
|
||||
"input": "call it", "stream": true,
|
||||
"tools": []any{map[string]any{"type": "function", "name": "lookup", "parameters": map[string]any{"type": "object"}}},
|
||||
}, Stream: true,
|
||||
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
||||
UpstreamProtocol: ProtocolOpenAIChatCompletions, PublicResponseID: "resp_12345678901234567890123456789012",
|
||||
StreamDelta: func(event StreamDeltaEvent) error { events = append(events, event); return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
types := make([]string, 0, len(events))
|
||||
fragments := map[string]string{}
|
||||
outputIndexes := map[int]bool{}
|
||||
for _, event := range events {
|
||||
eventType := stringFromAny(event.Event["type"])
|
||||
types = append(types, eventType)
|
||||
if sequence := intFromAny(event.Event["sequence_number"]); sequence != len(types)-1 {
|
||||
t.Fatalf("converted event sequence is not contiguous at %d: %+v", len(types)-1, event.Event)
|
||||
}
|
||||
if eventType == "response.function_call_arguments.delta" {
|
||||
itemID := stringFromAny(event.Event["item_id"])
|
||||
fragments[itemID] += stringFromAny(event.Event["delta"])
|
||||
outputIndexes[intFromAny(event.Event["output_index"])] = true
|
||||
}
|
||||
}
|
||||
if !containsTestString(types, "response.created") || !containsTestString(types, "response.in_progress") || !containsTestString(types, "response.output_item.added") || !containsTestString(types, "response.function_call_arguments.done") || len(fragments) != 2 || len(outputIndexes) != 2 {
|
||||
t.Fatalf("unexpected converted stream events types=%v fragments=%v indexes=%v", types, fragments, outputIndexes)
|
||||
}
|
||||
output, _ := response.Result["output"].([]any)
|
||||
if len(output) != 2 {
|
||||
t.Fatalf("expected two final function calls: %+v", response.Result)
|
||||
}
|
||||
first, _ := output[0].(map[string]any)
|
||||
second, _ := output[1].(map[string]any)
|
||||
if first["call_id"] != "call_stream_0" || first["arguments"] != "{\"x\":1}" || second["call_id"] != "call_stream_1" || second["arguments"] != "{\"y\":2}" {
|
||||
t.Fatalf("parallel streamed calls were not aggregated: %+v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResultToResponseMapsIncompleteFinishReason(t *testing.T) {
|
||||
response := ChatResultToResponse(map[string]any{
|
||||
"choices": []any{map[string]any{"finish_reason": "length", "message": map[string]any{"role": "assistant", "content": "partial"}}},
|
||||
}, "resp_12345678901234567890123456789012", "demo", map[string]any{})
|
||||
if response["status"] != "incomplete" {
|
||||
t.Fatalf("expected incomplete response, got %+v", response)
|
||||
}
|
||||
details, _ := response["incomplete_details"].(map[string]any)
|
||||
if details["reason"] != "max_output_tokens" {
|
||||
t.Fatalf("finish reason was not mapped: %+v", details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeResponsesStreamSupportsMultilineDataFrames(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("event: response.completed\n"))
|
||||
_, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\n"))
|
||||
_, _ = w.Write([]byte("data: \"response\":{\"id\":\"resp_vendor-with-dash\",\"object\":\"response\",\"status\":\"completed\",\"output\":[]}}\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (OpenAIClient{}).Run(context.Background(), Request{
|
||||
Kind: "responses", Model: "Demo", Body: map[string]any{"input": "hello", "stream": true}, Stream: true,
|
||||
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
|
||||
UpstreamProtocol: ProtocolOpenAIResponses,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Result["id"] != "resp_vendor-with-dash" {
|
||||
t.Fatalf("multiline frame was not decoded: %+v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponsesStreamAdapterEmitsTextItemLifecycle(t *testing.T) {
|
||||
adapter := newChatResponsesStreamAdapter("resp_12345678901234567890123456789012", "demo")
|
||||
events := make([]string, 0)
|
||||
onDelta := func(event StreamDeltaEvent) error {
|
||||
events = append(events, stringFromAny(event.Event["type"]))
|
||||
return nil
|
||||
}
|
||||
err := adapter.delta(StreamDeltaEvent{Event: map[string]any{
|
||||
"choices": []any{map[string]any{"delta": map[string]any{"content": "hello"}}},
|
||||
}}, onDelta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := ChatResultToResponse(map[string]any{
|
||||
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "hello"}}},
|
||||
}, adapter.publicID, "demo", map[string]any{})
|
||||
if err := adapter.done(result, onDelta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"response.created", "response.in_progress", "response.output_item.added", "response.content_part.added",
|
||||
"response.output_text.delta", "response.output_text.done", "response.content_part.done", "response.output_item.done",
|
||||
}
|
||||
if strings.Join(events, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("unexpected text event lifecycle got=%v want=%v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesChatFallbackRejectsBuiltInToolsAndUnknownParameters(t *testing.T) {
|
||||
_, err := ResponsesRequestToChat(map[string]any{"input": "hello", "tools": []any{map[string]any{"type": "web_search_preview"}}}, nil)
|
||||
if ErrorCode(err) != "unsupported_response_tool" {
|
||||
t.Fatalf("expected unsupported_response_tool, got %v", err)
|
||||
}
|
||||
_, err = ResponsesRequestToChat(map[string]any{"input": "hello", "conversation": "conv_1"}, nil)
|
||||
if ErrorCode(err) != "unsupported_response_parameter" {
|
||||
t.Fatalf("expected unsupported_response_parameter, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesChatFallbackKeepsClientManagedStateAuthoritative(t *testing.T) {
|
||||
input := []any{
|
||||
map[string]any{"type": "message", "role": "user", "content": "first"},
|
||||
map[string]any{"type": "message", "role": "assistant", "content": "second"},
|
||||
map[string]any{"type": "message", "role": "user", "content": "third"},
|
||||
}
|
||||
body, err := ResponsesRequestToChat(map[string]any{"input": input}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages, _ := body["messages"].([]any)
|
||||
if len(messages) != len(input) {
|
||||
t.Fatalf("client-managed input was supplemented by Gateway history: %+v", messages)
|
||||
}
|
||||
for index, raw := range messages {
|
||||
message, _ := raw.(map[string]any)
|
||||
expected, _ := input[index].(map[string]any)
|
||||
if message["role"] != expected["role"] || message["content"] != expected["content"] {
|
||||
t.Fatalf("client-managed message %d changed: got=%+v want=%+v", index, message, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func containsTestString(values []string, expected string) bool {
|
||||
for _, value := range values {
|
||||
if value == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -11,20 +11,33 @@ import (
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Kind string
|
||||
ModelType string
|
||||
Model string
|
||||
Body map[string]any
|
||||
Candidate store.RuntimeModelCandidate
|
||||
HTTPClient *http.Client
|
||||
RemoteTaskID string
|
||||
RemoteTaskPayload map[string]any
|
||||
OnRemoteTaskSubmitted func(remoteTaskID string, payload map[string]any) error
|
||||
Stream bool
|
||||
StreamDelta StreamDelta
|
||||
Kind string
|
||||
ModelType string
|
||||
Model string
|
||||
Body map[string]any
|
||||
Candidate store.RuntimeModelCandidate
|
||||
HTTPClient *http.Client
|
||||
RemoteTaskID string
|
||||
RemoteTaskPayload map[string]any
|
||||
OnRemoteTaskSubmitted func(remoteTaskID string, payload map[string]any) error
|
||||
OnRemoteTaskPolled func(remoteTaskID string, payload map[string]any) error
|
||||
Stream bool
|
||||
StreamDelta StreamDelta
|
||||
UpstreamProtocol string
|
||||
PublicResponseID string
|
||||
PublicPreviousResponseID string
|
||||
UpstreamPreviousResponseID string
|
||||
PreviousResponseTurns []ResponseTurn
|
||||
}
|
||||
|
||||
type ResponseTurn struct {
|
||||
Request map[string]any
|
||||
Response map[string]any
|
||||
Internal map[string]any
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
AttemptID string
|
||||
Result map[string]any
|
||||
RequestID string
|
||||
Usage Usage
|
||||
@@ -33,6 +46,14 @@ type Response struct {
|
||||
ResponseStartedAt time.Time
|
||||
ResponseFinishedAt time.Time
|
||||
ResponseDurationMS int64
|
||||
UpstreamProtocol string
|
||||
UpstreamEndpoint string
|
||||
UpstreamResponseID string
|
||||
PublicResponseID string
|
||||
ParentResponseID string
|
||||
ResponseChainDepth int
|
||||
ResponseConverted bool
|
||||
InternalResult map[string]any
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
@@ -84,6 +105,7 @@ type VoiceCloneDeleter interface {
|
||||
type ClientError struct {
|
||||
Code string
|
||||
Message string
|
||||
Param string
|
||||
StatusCode int
|
||||
RequestID string
|
||||
ResponseStartedAt time.Time
|
||||
@@ -92,6 +114,14 @@ type ClientError struct {
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
func ErrorParam(err error) string {
|
||||
var clientErr *ClientError
|
||||
if errors.As(err, &clientErr) {
|
||||
return clientErr.Param
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *ClientError) Error() string {
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
|
||||
@@ -44,6 +44,8 @@ func (c UniversalClient) Run(ctx context.Context, request Request) (Response, er
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, submitRequestID, startedAt, time.Now())
|
||||
}
|
||||
submitResult = universalEffectiveResult(submitResult)
|
||||
submitRequestID = firstNonEmptyString(submitRequestID, requestIDFromResult(submitResult))
|
||||
if isUniversalSuccess(submitResult) && submitResult["data"] != nil {
|
||||
return Response{
|
||||
Result: normalizeUniversalResult(request, submitResult, ""),
|
||||
@@ -157,6 +159,7 @@ func (c UniversalClient) universalPollUntilDone(ctx context.Context, executor *s
|
||||
if err != nil {
|
||||
return nil, "", annotateResponseError(err, firstNonEmptyString(pollRequestID, requestID, upstreamTaskID), pollStarted, pollFinished)
|
||||
}
|
||||
result = universalEffectiveResult(result)
|
||||
lastResult = result
|
||||
requestID = firstNonEmptyString(pollRequestID, requestID, requestIDFromResult(result), upstreamTaskID)
|
||||
if isUniversalSuccess(result) {
|
||||
@@ -239,6 +242,14 @@ func universalScriptContext(request Request, modelType string, payload map[strin
|
||||
return selectedBase + "/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
context["creatRequestURL"] = context["createRequestURL"]
|
||||
processedParams := cloneBody(request.Body)
|
||||
processedParams["model"] = upstreamModelName(request.Candidate)
|
||||
context["processedParams"] = processedParams
|
||||
context["preProcessParams"] = func(params map[string]any, _ ...string) map[string]any {
|
||||
processed := cloneMapAny(params)
|
||||
processed["model"] = upstreamModelName(request.Candidate)
|
||||
return processed
|
||||
}
|
||||
context["resolveGetTaskURL"] = func(taskID string) string {
|
||||
return resolveUniversalTaskURL(request.Candidate.PlatformConfig, taskID)
|
||||
}
|
||||
@@ -389,6 +400,20 @@ func universalStatus(result map[string]any) string {
|
||||
return strings.ToLower(strings.TrimSpace(firstNonEmptyString(result["status"], result["state"], result["task_status"])))
|
||||
}
|
||||
|
||||
func universalEffectiveResult(result map[string]any) map[string]any {
|
||||
nested, ok := result["result"].(map[string]any)
|
||||
if !ok || nested == nil || isUniversalFailure(result) {
|
||||
return result
|
||||
}
|
||||
out := cloneMapAny(nested)
|
||||
for _, key := range []string{"status", "request_id", "requestId", "upstream_task_id", "task_id", "taskId", "id"} {
|
||||
if out[key] == nil && result[key] != nil {
|
||||
out[key] = result[key]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func universalTaskID(result map[string]any) string {
|
||||
return firstNonEmptyString(result["upstream_task_id"], result["task_id"], result["taskId"], result["id"])
|
||||
}
|
||||
|
||||
@@ -92,6 +92,47 @@ func TestUniversalClientDefaultSubmitAndPoll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniversalClientSupportsServerMainScriptContextAndNestedResult(t *testing.T) {
|
||||
request := Request{
|
||||
Kind: "images.generations",
|
||||
ModelType: "image_generate",
|
||||
Model: "custom-image",
|
||||
Body: map[string]any{"model": "custom-image", "prompt": "hello"},
|
||||
Candidate: testUniversalCandidate(map[string]any{
|
||||
"customGetParamsScript": map[string]any{
|
||||
"image_generate": `async function getParams(params, context) {
|
||||
const processed = await context.preProcessParams(params, context.type);
|
||||
return { prompt: processed.prompt + "-" + processed.model + "-" + context.processedParams.model };
|
||||
}`,
|
||||
},
|
||||
"customSubmitScript": map[string]any{
|
||||
"image_generate": `async function submitTask(payload) {
|
||||
return {
|
||||
status: "success",
|
||||
result: {
|
||||
status: "success",
|
||||
data: [{ url: "https://cdn.example/" + payload.prompt + ".png" }]
|
||||
}
|
||||
};
|
||||
}`,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
response, err := (UniversalClient{}).Run(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("run failed: %v", err)
|
||||
}
|
||||
data, ok := response.Result["data"].([]any)
|
||||
if !ok || len(data) != 1 {
|
||||
t.Fatalf("unexpected nested result: %#v", response.Result)
|
||||
}
|
||||
image, ok := data[0].(map[string]any)
|
||||
if !ok || image["url"] != "https://cdn.example/hello-provider-model-provider-model.png" {
|
||||
t.Fatalf("unexpected image result: %#v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniversalClientResumeSkipsSubmit(t *testing.T) {
|
||||
request := Request{
|
||||
Kind: "videos.generations",
|
||||
|
||||
@@ -74,12 +74,13 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
|
||||
submitStartedAt := time.Now()
|
||||
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
|
||||
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
|
||||
taskPath := volcesVideoTaskPath(request)
|
||||
if upstreamTaskID == "" {
|
||||
body := volcesVideoBody(request)
|
||||
if err := validateVolcesVideoTaskBody(body); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body)
|
||||
submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, taskPath, apiKey, body)
|
||||
submitRequestID = requestID
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
|
||||
@@ -99,66 +100,120 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
|
||||
timeout := volcesPollTimeout(request)
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
nextPoll := time.NewTimer(0)
|
||||
defer nextPoll.Stop()
|
||||
|
||||
var lastResult map[string]any
|
||||
lastRequestID := firstNonEmpty(submitRequestID, upstreamTaskID)
|
||||
transientFailures := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: submitRequestID, Retryable: true}
|
||||
default:
|
||||
}
|
||||
|
||||
pollStartedAt := time.Now()
|
||||
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks/"+upstreamTaskID, apiKey)
|
||||
pollFinishedAt := time.Now()
|
||||
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
|
||||
}
|
||||
lastResult = pollResult
|
||||
|
||||
switch volcesTaskStatus(pollResult) {
|
||||
case "succeeded":
|
||||
result := volcesVideoSuccessResult(request, upstreamTaskID, pollResult)
|
||||
return Response{
|
||||
Result: result,
|
||||
RequestID: requestID,
|
||||
Usage: volcesVideoUsage(pollResult),
|
||||
Progress: volcesVideoProgress(request, upstreamTaskID),
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
}, nil
|
||||
case "failed", "cancelled":
|
||||
return Response{}, &ClientError{
|
||||
Code: volcesTaskErrorCode(pollResult),
|
||||
Message: volcesTaskErrorMessage(pollResult),
|
||||
RequestID: requestID,
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true}
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: lastRequestID, Retryable: true}
|
||||
case <-deadline.C:
|
||||
return Response{}, &ClientError{
|
||||
Code: "timeout",
|
||||
Message: fmt.Sprintf("volces video task %s did not finish before timeout; last status: %s", upstreamTaskID, volcesTaskStatus(lastResult)),
|
||||
RequestID: requestID,
|
||||
RequestID: lastRequestID,
|
||||
Retryable: true,
|
||||
}
|
||||
case <-ticker.C:
|
||||
case <-nextPoll.C:
|
||||
pollStartedAt := time.Now()
|
||||
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
|
||||
pollFinishedAt := time.Now()
|
||||
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||
lastRequestID = requestID
|
||||
if err != nil {
|
||||
err = annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
|
||||
if !IsRetryable(err) {
|
||||
return Response{}, err
|
||||
}
|
||||
transientFailures++
|
||||
resetVolcesPollTimer(nextPoll, volcesRetryPollInterval(request, interval, transientFailures))
|
||||
continue
|
||||
}
|
||||
transientFailures = 0
|
||||
lastResult = pollResult
|
||||
if request.OnRemoteTaskPolled != nil {
|
||||
if err := request.OnRemoteTaskPolled(upstreamTaskID, pollResult); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
|
||||
switch volcesTaskStatus(pollResult) {
|
||||
case "succeeded":
|
||||
result := volcesVideoSuccessResult(request, upstreamTaskID, pollResult)
|
||||
return Response{
|
||||
Result: result,
|
||||
RequestID: requestID,
|
||||
Usage: volcesVideoUsage(pollResult),
|
||||
Progress: volcesVideoProgress(request, upstreamTaskID),
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
}, nil
|
||||
case "failed", "cancelled":
|
||||
return Response{}, &ClientError{
|
||||
Code: volcesTaskErrorCode(pollResult),
|
||||
Message: volcesTaskErrorMessage(pollResult),
|
||||
RequestID: requestID,
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
resetVolcesPollTimer(nextPoll, interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteVideoTask calls the official contents-generations cancellation endpoint.
|
||||
// It is intentionally separate from Run so task cancellation can use the same
|
||||
// provider credentials that submitted the remote task.
|
||||
func (c VolcesClient) DeleteVideoTask(ctx context.Context, request Request) (map[string]any, string, error) {
|
||||
apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
|
||||
if apiKey == "" {
|
||||
return nil, "", &ClientError{Code: "missing_credentials", Message: "volces api key is required", Retryable: false}
|
||||
}
|
||||
remoteTaskID := strings.TrimSpace(request.RemoteTaskID)
|
||||
if remoteTaskID == "" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "volces remote task id is required", Retryable: false}
|
||||
}
|
||||
taskPath := volcesVideoTaskPath(request) + "/" + remoteTaskID
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, joinURL(request.Candidate.BaseURL, taskPath), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
response, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
requestID := requestIDFromHTTPResponse(response)
|
||||
result, err := decodeHTTPResponse(response)
|
||||
if err != nil {
|
||||
return result, requestID, annotateResponseError(err, requestID, time.Now(), time.Now())
|
||||
}
|
||||
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
|
||||
return result, firstNonEmpty(requestID, envelopeRequestID), err
|
||||
}
|
||||
|
||||
func volcesVideoTaskPath(request Request) string {
|
||||
path := firstNonEmptyStringValue(
|
||||
request.Candidate.PlatformConfig,
|
||||
"volcesVideoTaskPath",
|
||||
"videoTaskPath",
|
||||
)
|
||||
if path == "" {
|
||||
return "/contents/generations/tasks"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return strings.TrimRight(path, "/")
|
||||
}
|
||||
|
||||
func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) {
|
||||
raw, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
|
||||
@@ -173,7 +228,11 @@ func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL str
|
||||
}
|
||||
requestID := requestIDFromHTTPResponse(resp)
|
||||
result, err := decodeHTTPResponse(resp)
|
||||
return result, requestID, err
|
||||
if err != nil {
|
||||
return result, requestID, err
|
||||
}
|
||||
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
|
||||
return result, firstNonEmpty(requestID, envelopeRequestID), err
|
||||
}
|
||||
|
||||
func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, error) {
|
||||
@@ -188,7 +247,64 @@ func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL stri
|
||||
}
|
||||
requestID := requestIDFromHTTPResponse(resp)
|
||||
result, err := decodeHTTPResponse(resp)
|
||||
return result, requestID, err
|
||||
if err != nil {
|
||||
return result, requestID, err
|
||||
}
|
||||
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
|
||||
return result, firstNonEmpty(requestID, envelopeRequestID), err
|
||||
}
|
||||
|
||||
func normalizeVolcesCompatibleResult(result map[string]any) (map[string]any, string, error) {
|
||||
requestID := firstNonEmpty(
|
||||
stringFromAny(result["request_id"]),
|
||||
stringFromAny(result["requestId"]),
|
||||
)
|
||||
if errorObject, ok := result["error"].(map[string]any); ok {
|
||||
code := firstNonEmpty(
|
||||
stringFromAny(errorObject["code"]),
|
||||
stringFromAny(errorObject["type"]),
|
||||
"volces_compatible_error",
|
||||
)
|
||||
message := strings.TrimSpace(stringFromAny(errorObject["message"]))
|
||||
if message == "" {
|
||||
message = "volces compatible request failed"
|
||||
}
|
||||
return result, requestID, &ClientError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
RequestID: requestID,
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
rawCode, hasCode := result["code"]
|
||||
if !hasCode {
|
||||
return result, requestID, nil
|
||||
}
|
||||
code, validCode := volcesIntFromAny(rawCode)
|
||||
if !validCode {
|
||||
return result, requestID, nil
|
||||
}
|
||||
if code != 0 {
|
||||
message := strings.TrimSpace(stringFromAny(result["message"]))
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("volces compatible request failed with code %d", code)
|
||||
}
|
||||
return result, requestID, &ClientError{
|
||||
Code: fmt.Sprintf("volces_%d", code),
|
||||
Message: message,
|
||||
RequestID: requestID,
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
data, ok := result["data"].(map[string]any)
|
||||
if !ok {
|
||||
return result, requestID, nil
|
||||
}
|
||||
normalized := cloneBody(data)
|
||||
if requestID != "" && requestIDFromResult(normalized) == "" {
|
||||
normalized["request_id"] = requestID
|
||||
}
|
||||
return normalized, requestID, nil
|
||||
}
|
||||
|
||||
func volcesImageBody(request Request) map[string]any {
|
||||
@@ -336,6 +452,16 @@ func addVolcesVideoTaskParams(out map[string]any, body map[string]any) {
|
||||
copyVolcesBoolParam(out, "return_last_frame", body, "return_last_frame", "returnLastFrame")
|
||||
copyVolcesIntParam(out, "execution_expires_after", body, "execution_expires_after", "executionExpiresAfter")
|
||||
copyVolcesBoolParam(out, "generate_audio", body, "generate_audio", "generateAudio", "audio")
|
||||
if tools, ok := body["tools"]; ok {
|
||||
out["tools"] = tools
|
||||
} else {
|
||||
for _, key := range []string{"enable_web_search", "enableWebSearch"} {
|
||||
if value, ok := volcesBoolFromAny(body[key]); ok && value {
|
||||
out["tools"] = []any{map[string]any{"type": "web_search"}}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
copyVolcesBoolParam(out, "draft", body, "draft")
|
||||
copyVolcesStringParam(out, "resolution", body, "resolution", "size")
|
||||
copyVolcesStringParam(out, "ratio", body, "ratio", "aspect_ratio", "aspectRatio")
|
||||
@@ -771,14 +897,22 @@ func supportsMultipleOutputs(request Request, capabilityName string) bool {
|
||||
}
|
||||
|
||||
func normalizeVolcesSequentialImageGeneration(body map[string]any, request Request) {
|
||||
if !supportsMultipleOutputs(request, request.ModelType) {
|
||||
delete(body, "sequential_image_generation")
|
||||
delete(body, "sequential_image_generation_options")
|
||||
if numericValue(body["n"], 1) > 1 {
|
||||
body["n"] = 1
|
||||
}
|
||||
if numericValue(body["batch_size"], 1) > 1 {
|
||||
body["batch_size"] = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
options, hasOptions := volcesSequentialImageGenerationOptions(body)
|
||||
normalizeVolcesSequentialMaxImages(options, request)
|
||||
if _, explicit := body["sequential_image_generation"]; explicit {
|
||||
return
|
||||
}
|
||||
if !supportsMultipleOutputs(request, request.ModelType) {
|
||||
return
|
||||
}
|
||||
count := requestedVolcesSequentialImageCount(body, options)
|
||||
if count <= 1 {
|
||||
return
|
||||
@@ -902,6 +1036,9 @@ func volcesTaskErrorCode(result map[string]any) string {
|
||||
return code
|
||||
}
|
||||
status := volcesTaskStatus(result)
|
||||
if status == "cancelled" {
|
||||
return "volces_task_cancelled"
|
||||
}
|
||||
if status != "" {
|
||||
return status
|
||||
}
|
||||
@@ -920,6 +1057,10 @@ func volcesTaskErrorMessage(result map[string]any) string {
|
||||
}
|
||||
|
||||
func volcesVideoSuccessResult(request Request, upstreamTaskID string, raw map[string]any) map[string]any {
|
||||
result := cloneMapAny(raw)
|
||||
if result == nil {
|
||||
result = map[string]any{}
|
||||
}
|
||||
content, _ := raw["content"].(map[string]any)
|
||||
videoURL := strings.TrimSpace(stringFromAny(content["video_url"]))
|
||||
created := intFromAny(raw["created_at"])
|
||||
@@ -930,16 +1071,17 @@ func volcesVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if videoURL != "" {
|
||||
data = append(data, map[string]any{"url": videoURL, "type": "video"})
|
||||
}
|
||||
return map[string]any{
|
||||
"id": upstreamTaskID,
|
||||
"object": "video.generation",
|
||||
"created": created,
|
||||
"model": upstreamModelName(request.Candidate),
|
||||
"status": "succeeded",
|
||||
"upstream_task_id": upstreamTaskID,
|
||||
"data": data,
|
||||
"raw": raw,
|
||||
result["id"] = firstNonEmpty(stringFromAny(raw["id"]), upstreamTaskID)
|
||||
if strings.TrimSpace(stringFromAny(result["model"])) == "" {
|
||||
result["model"] = upstreamModelName(request.Candidate)
|
||||
}
|
||||
result["status"] = "succeeded"
|
||||
result["object"] = "video.generation"
|
||||
result["created"] = created
|
||||
result["upstream_task_id"] = upstreamTaskID
|
||||
result["data"] = data
|
||||
result["raw"] = cloneMapAny(raw)
|
||||
return result
|
||||
}
|
||||
|
||||
func volcesVideoUsage(raw map[string]any) Usage {
|
||||
@@ -979,6 +1121,37 @@ func volcesPollTimeout(request Request) time.Duration {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func volcesRetryPollInterval(request Request, normal time.Duration, failures int) time.Duration {
|
||||
if failures < 1 {
|
||||
return normal
|
||||
}
|
||||
max := time.Duration(numericValue(firstPresent(request.Candidate.PlatformConfig["volcesPollRetryMaxMs"], request.Body["pollRetryMaxMs"], request.Body["poll_retry_max_ms"]), 30000)) * time.Millisecond
|
||||
if max < normal {
|
||||
max = normal
|
||||
}
|
||||
delay := normal
|
||||
for attempt := 1; attempt < failures && delay < max; attempt++ {
|
||||
delay *= 2
|
||||
}
|
||||
if delay > max {
|
||||
return max
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func resetVolcesPollTimer(timer *time.Timer, delay time.Duration) {
|
||||
if delay < 100*time.Millisecond {
|
||||
delay = 100 * time.Millisecond
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(delay)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
volcesAssetDefaultEndpoint = "https://ark.cn-beijing.volcengineapi.com"
|
||||
volcesAssetRegion = "cn-beijing"
|
||||
volcesAssetService = "ark"
|
||||
volcesAssetVersion = "2024-01-01"
|
||||
)
|
||||
|
||||
type VolcesAssetClient struct {
|
||||
HTTPClient *http.Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type VolcesAssetCredentials struct {
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type VolcesAssetResult struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name,omitempty"`
|
||||
URL string `json:"URL,omitempty"`
|
||||
AssetType string `json:"AssetType,omitempty"`
|
||||
GroupID string `json:"GroupId,omitempty"`
|
||||
Status string `json:"Status,omitempty"`
|
||||
Error map[string]any `json:"Error,omitempty"`
|
||||
ProjectName string `json:"ProjectName,omitempty"`
|
||||
CreateTime string `json:"CreateTime,omitempty"`
|
||||
UpdateTime string `json:"UpdateTime,omitempty"`
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) CreateAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) {
|
||||
var result struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
requestID, err := c.call(ctx, credentials, "CreateAsset", body, &result)
|
||||
return VolcesAssetResult{ID: result.ID}, requestID, err
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) GetAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) {
|
||||
var result VolcesAssetResult
|
||||
requestID, err := c.call(ctx, credentials, "GetAsset", body, &result)
|
||||
return result, requestID, err
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) call(ctx context.Context, credentials VolcesAssetCredentials, action string, body map[string]any, target any) (string, error) {
|
||||
accessKey := strings.TrimSpace(credentials.AccessKey)
|
||||
secretKey := strings.TrimSpace(credentials.SecretKey)
|
||||
if accessKey == "" || secretKey == "" {
|
||||
return "", &ClientError{Code: "missing_credentials", Message: "volces portrait asset accessKey and secretKey are required", Retryable: false}
|
||||
}
|
||||
endpoint := strings.TrimRight(strings.TrimSpace(credentials.Endpoint), "/")
|
||||
if endpoint == "" {
|
||||
endpoint = volcesAssetDefaultEndpoint
|
||||
}
|
||||
baseURL, err := url.Parse(endpoint)
|
||||
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
||||
return "", &ClientError{Code: "invalid_configuration", Message: "invalid volces portrait asset endpoint", Retryable: false}
|
||||
}
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal volces asset request: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if c.Now != nil {
|
||||
now = c.Now().UTC()
|
||||
}
|
||||
xDate := now.Format("20060102T150405Z")
|
||||
contentSHA := sha256HexBytes(bodyJSON)
|
||||
requestURL := *baseURL
|
||||
requestURL.Path = "/"
|
||||
requestURL.RawPath = ""
|
||||
requestURL.RawQuery = canonicalVolcesAssetQuery(map[string]string{"Action": action, "Version": volcesAssetVersion})
|
||||
headers := map[string]string{
|
||||
"content-type": "application/json",
|
||||
"host": baseURL.Host,
|
||||
"x-content-sha256": contentSHA,
|
||||
"x-date": xDate,
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Host = baseURL.Host
|
||||
req.Header.Set("Content-Type", headers["content-type"])
|
||||
req.Header.Set("X-Content-Sha256", headers["x-content-sha256"])
|
||||
req.Header.Set("X-Date", headers["x-date"])
|
||||
req.Header.Set("Authorization", volcesAssetAuthorization(accessKey, secretKey, http.MethodPost, "/", requestURL.RawQuery, headers, contentSHA, xDate))
|
||||
|
||||
response, err := httpClient(nil, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var envelope struct {
|
||||
ResponseMetadata struct {
|
||||
RequestID string `json:"RequestId"`
|
||||
Error struct {
|
||||
Code string `json:"Code"`
|
||||
Message string `json:"Message"`
|
||||
} `json:"Error"`
|
||||
} `json:"ResponseMetadata"`
|
||||
Result json.RawMessage `json:"Result"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
|
||||
return requestIDFromHTTPResponse(response), &ClientError{Code: "invalid_response", Message: "decode volces portrait asset response: " + err.Error(), Retryable: HTTPRetryable(response.StatusCode), StatusCode: response.StatusCode}
|
||||
}
|
||||
requestID := firstNonEmpty(requestIDFromHTTPResponse(response), envelope.ResponseMetadata.RequestID)
|
||||
if envelope.ResponseMetadata.Error.Code != "" || response.StatusCode >= http.StatusBadRequest {
|
||||
message := strings.TrimSpace(envelope.ResponseMetadata.Error.Message)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(envelope.ResponseMetadata.Error.Code)
|
||||
}
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("volces %s failed with status %d", action, response.StatusCode)
|
||||
}
|
||||
return requestID, &ClientError{Code: firstNonEmpty(envelope.ResponseMetadata.Error.Code, "volces_asset_error"), Message: message, RequestID: requestID, StatusCode: response.StatusCode, Retryable: HTTPRetryable(response.StatusCode)}
|
||||
}
|
||||
if len(envelope.Result) == 0 || string(envelope.Result) == "null" {
|
||||
return requestID, &ClientError{Code: "invalid_response", Message: "volces " + action + " returned empty result", RequestID: requestID, Retryable: false}
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Result, target); err != nil {
|
||||
return requestID, &ClientError{Code: "invalid_response", Message: "decode volces " + action + " result: " + err.Error(), RequestID: requestID, Retryable: false}
|
||||
}
|
||||
return requestID, nil
|
||||
}
|
||||
|
||||
func volcesAssetAuthorization(accessKey string, secretKey string, method string, path string, canonicalQuery string, headers map[string]string, bodySHA string, xDate string) string {
|
||||
signedHeaders := []string{"content-type", "host", "x-content-sha256", "x-date"}
|
||||
canonicalHeaderLines := make([]string, 0, len(signedHeaders))
|
||||
for _, key := range signedHeaders {
|
||||
canonicalHeaderLines = append(canonicalHeaderLines, key+":"+strings.TrimSpace(headers[key]))
|
||||
}
|
||||
canonicalRequest := strings.Join([]string{
|
||||
strings.ToUpper(method), path, canonicalQuery,
|
||||
strings.Join(canonicalHeaderLines, "\n") + "\n",
|
||||
strings.Join(signedHeaders, ";"), bodySHA,
|
||||
}, "\n")
|
||||
date := xDate
|
||||
if len(date) >= 8 {
|
||||
date = date[:8]
|
||||
}
|
||||
scope := strings.Join([]string{date, volcesAssetRegion, volcesAssetService, "request"}, "/")
|
||||
stringToSign := strings.Join([]string{"HMAC-SHA256", xDate, scope, sha256HexString(canonicalRequest)}, "\n")
|
||||
kDate := hmacSHA256([]byte(secretKey), date)
|
||||
kRegion := hmacSHA256(kDate, volcesAssetRegion)
|
||||
kService := hmacSHA256(kRegion, volcesAssetService)
|
||||
kSigning := hmacSHA256(kService, "request")
|
||||
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
return "HMAC-SHA256 Credential=" + accessKey + "/" + scope + ", SignedHeaders=" + strings.Join(signedHeaders, ";") + ", Signature=" + signature
|
||||
}
|
||||
|
||||
func canonicalVolcesAssetQuery(values map[string]string) string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(values[key]))
|
||||
}
|
||||
return strings.ReplaceAll(strings.Join(parts, "&"), "+", "%20")
|
||||
}
|
||||
|
||||
func sha256HexBytes(value []byte) string {
|
||||
digest := sha256.Sum256(value)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func sha256HexString(value string) string { return sha256HexBytes([]byte(value)) }
|
||||
|
||||
func hmacSHA256(key []byte, value string) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write([]byte(value))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVolcesAssetClientSignsCreateAndReadsAsset(t *testing.T) {
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.URL.Query().Get("Action"))
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/" || r.URL.Query().Get("Version") != "2024-01-01" {
|
||||
t.Fatalf("unexpected asset request %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
if r.Header.Get("X-Date") != "20260718T010203Z" {
|
||||
t.Fatalf("unexpected x-date: %q", r.Header.Get("X-Date"))
|
||||
}
|
||||
if !strings.HasPrefix(r.Header.Get("Authorization"), "HMAC-SHA256 Credential=asset-ak/") {
|
||||
t.Fatalf("missing Volces authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
digest := sha256.Sum256(raw)
|
||||
if got := r.Header.Get("X-Content-Sha256"); got != hex.EncodeToString(digest[:]) {
|
||||
t.Fatalf("content hash mismatch got=%q", got)
|
||||
}
|
||||
switch r.URL.Query().Get("Action") {
|
||||
case "CreateAsset":
|
||||
if body["GroupId"] != "group-1" || body["AssetType"] != "Image" {
|
||||
t.Fatalf("unexpected CreateAsset body: %+v", body)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ResponseMetadata": map[string]any{"RequestId": "create-rid"}, "Result": map[string]any{"Id": "asset-1"}})
|
||||
case "GetAsset":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ResponseMetadata": map[string]any{"RequestId": "get-rid"}, "Result": map[string]any{"Id": "asset-1", "Status": "Active", "AssetType": "Image"}})
|
||||
default:
|
||||
t.Fatalf("unexpected Action: %q", r.URL.Query().Get("Action"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := VolcesAssetClient{HTTPClient: server.Client(), Now: func() time.Time {
|
||||
return time.Date(2026, 7, 18, 1, 2, 3, 0, time.UTC)
|
||||
}}
|
||||
credentials := VolcesAssetCredentials{AccessKey: "asset-ak", SecretKey: "asset-sk", Endpoint: server.URL}
|
||||
created, requestID, err := client.CreateAsset(context.Background(), credentials, map[string]any{"GroupId": "group-1", "URL": "https://example.com/person.png", "AssetType": "Image", "ProjectName": "default"})
|
||||
if err != nil || created.ID != "asset-1" || requestID != "create-rid" {
|
||||
t.Fatalf("unexpected CreateAsset result=%+v requestID=%s err=%v", created, requestID, err)
|
||||
}
|
||||
asset, requestID, err := client.GetAsset(context.Background(), credentials, map[string]any{"Id": "asset-1", "ProjectName": "default"})
|
||||
if err != nil || asset.Status != "Active" || requestID != "get-rid" {
|
||||
t.Fatalf("unexpected GetAsset result=%+v requestID=%s err=%v", asset, requestID, err)
|
||||
}
|
||||
if strings.Join(calls, ",") != "CreateAsset,GetAsset" {
|
||||
t.Fatalf("unexpected actions: %+v", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestVolcesClientSupportsDeyunEnvelope(t *testing.T) {
|
||||
var submitted bool
|
||||
var polled bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer deyun-secret" {
|
||||
t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /c39/api/v3/video/tasks":
|
||||
submitted = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "deyun-submit-request",
|
||||
"data": map[string]any{"id": "deyun-task-1"},
|
||||
})
|
||||
case "GET /c39/api/v3/video/tasks/deyun-task-1":
|
||||
polled = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "deyun-poll-request",
|
||||
"data": map[string]any{
|
||||
"id": "deyun-task-1",
|
||||
"status": "succeeded",
|
||||
"created_at": 123,
|
||||
"content": map[string]any{
|
||||
"video_url": "https://example.com/deyun.mp4",
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "video_generate",
|
||||
Model: "deyun-seedance-2.0-canary",
|
||||
Body: map[string]any{
|
||||
"prompt": "A red cube rotates on a white table",
|
||||
"resolution": "480p",
|
||||
"ratio": "16:9",
|
||||
"duration": 4,
|
||||
"generate_audio": false,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL + "/c39/api/v3",
|
||||
ProviderModelName: "doubao-seedance-2-0",
|
||||
Credentials: map[string]any{"apiKey": "deyun-secret"},
|
||||
PlatformConfig: map[string]any{
|
||||
"volcesPollIntervalMs": 100,
|
||||
"volcesPollTimeoutSeconds": 1,
|
||||
"volcesVideoTaskPath": "/video/tasks",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run deyun-compatible video task: %v", err)
|
||||
}
|
||||
if !submitted || !polled {
|
||||
t.Fatalf("expected submit and poll, submitted=%v polled=%v", submitted, polled)
|
||||
}
|
||||
if response.RequestID != "deyun-poll-request" {
|
||||
t.Fatalf("unexpected request id: %s", response.RequestID)
|
||||
}
|
||||
data, _ := response.Result["data"].([]any)
|
||||
item, _ := data[0].(map[string]any)
|
||||
if response.Result["upstream_task_id"] != "deyun-task-1" || item["url"] != "https://example.com/deyun.mp4" {
|
||||
t.Fatalf("unexpected response: %+v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVolcesCompatibleResultPreservesNativeResponse(t *testing.T) {
|
||||
native := map[string]any{"id": "native-task", "status": "queued"}
|
||||
got, requestID, err := normalizeVolcesCompatibleResult(native)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize native response: %v", err)
|
||||
}
|
||||
if got["id"] != "native-task" || requestID != "" {
|
||||
t.Fatalf("native response changed unexpectedly: %+v requestID=%q", got, requestID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVolcesCompatibleResultRejectsBusinessError(t *testing.T) {
|
||||
_, requestID, err := normalizeVolcesCompatibleResult(map[string]any{
|
||||
"code": 1004,
|
||||
"message": "Authorization is expired",
|
||||
"request_id": "deyun-error-request",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected business error")
|
||||
}
|
||||
if requestID != "deyun-error-request" || ErrorCode(err) != "volces_1004" {
|
||||
t.Fatalf("unexpected error metadata requestID=%q code=%q err=%v", requestID, ErrorCode(err), err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Authorization is expired") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVolcesCompatibleResultRejectsHTTP200ErrorObject(t *testing.T) {
|
||||
_, _, err := normalizeVolcesCompatibleResult(map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "ModelNotOpen",
|
||||
"message": "model service is not activated",
|
||||
"type": "Not Found",
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP 200 error object to fail")
|
||||
}
|
||||
if ErrorCode(err) != "ModelNotOpen" || !strings.Contains(err.Error(), "not activated") {
|
||||
t.Fatalf("unexpected error: code=%q err=%v", ErrorCode(err), err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -14,45 +15,46 @@ const (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
IdentityMode string
|
||||
JWTSecret string
|
||||
ServerMainBaseURL string
|
||||
ServerMainInternalToken string
|
||||
ServerMainInternalKey string
|
||||
ServerMainInternalSecret string
|
||||
OIDCEnabled bool
|
||||
OIDCIssuer string
|
||||
OIDCAudience string
|
||||
OIDCTenantID string
|
||||
OIDCRolePrefix string
|
||||
OIDCRequiredScopes []string
|
||||
OIDCJWKSCacheTTLSeconds int
|
||||
OIDCAcceptLegacyHS256 bool
|
||||
OIDCIntrospectionEnabled bool
|
||||
OIDCIntrospectionClientID string
|
||||
OIDCIntrospectionClientSecret string
|
||||
PublicBaseURL string
|
||||
WebBaseURL string
|
||||
LocalGeneratedStorageDir string
|
||||
LocalUploadedStorageDir string
|
||||
LocalTempAssetTTLHours int
|
||||
TaskProgressCallbackEnabled bool
|
||||
TaskProgressCallbackURL string
|
||||
TaskProgressCallbackTimeoutMS string
|
||||
TaskProgressCallbackMaxAttempts string
|
||||
CORSAllowedOrigin string
|
||||
GlobalHTTPProxy string
|
||||
GlobalHTTPProxySource string
|
||||
LogLevel slog.Level
|
||||
AppEnv string
|
||||
HTTPAddr string
|
||||
DatabaseURL string
|
||||
IdentityMode string
|
||||
JWTSecret string
|
||||
ServerMainBaseURL string
|
||||
ServerMainInternalToken string
|
||||
ServerMainInternalKey string
|
||||
ServerMainInternalSecret string
|
||||
IdentitySecretStore string
|
||||
IdentitySecretDir string
|
||||
IdentityKubernetesNamespace string
|
||||
IdentityKubernetesSecretName string
|
||||
IdentityKubernetesAPIServer string
|
||||
IdentityKubernetesTokenFile string
|
||||
IdentityKubernetesCAFile string
|
||||
IdentitySecurityEventHeartbeatIntervalSeconds int
|
||||
IdentitySecurityEventStaleAfterSeconds int
|
||||
IdentitySecurityEventClockSkewSeconds int
|
||||
PublicBaseURL string
|
||||
WebBaseURL string
|
||||
LocalGeneratedStorageDir string
|
||||
LocalUploadedStorageDir string
|
||||
LocalTempAssetTTLHours int
|
||||
TaskProgressCallbackEnabled bool
|
||||
TaskProgressCallbackURL string
|
||||
TaskProgressCallbackTimeoutMS string
|
||||
TaskProgressCallbackMaxAttempts string
|
||||
CORSAllowedOrigin string
|
||||
GlobalHTTPProxy string
|
||||
GlobalHTTPProxySource string
|
||||
LogLevel slog.Level
|
||||
BillingEngineMode string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
globalProxy := LoadGlobalHTTPProxyStatus()
|
||||
appEnv := env("APP_ENV", "development")
|
||||
return Config{
|
||||
AppEnv: env("APP_ENV", "development"),
|
||||
AppEnv: appEnv,
|
||||
HTTPAddr: env("HTTP_ADDR", ":8088"),
|
||||
DatabaseURL: gatewayDatabaseURL(),
|
||||
IdentityMode: env("IDENTITY_MODE", "hybrid"),
|
||||
@@ -61,26 +63,25 @@ func Load() Config {
|
||||
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
|
||||
"/",
|
||||
),
|
||||
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
||||
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
|
||||
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
|
||||
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
|
||||
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
|
||||
OIDCAudience: env("OIDC_AUDIENCE", ""),
|
||||
OIDCTenantID: env("OIDC_TENANT_ID", ""),
|
||||
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
|
||||
OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")),
|
||||
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
|
||||
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
|
||||
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
|
||||
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
|
||||
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
|
||||
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
|
||||
WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"),
|
||||
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))),
|
||||
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)),
|
||||
LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24),
|
||||
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
|
||||
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
||||
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
|
||||
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
|
||||
IdentitySecretStore: env("IDENTITY_SECRET_STORE", "file"),
|
||||
IdentitySecretDir: env("IDENTITY_SECRET_DIR", ".local-secrets/identity"),
|
||||
IdentityKubernetesNamespace: env("IDENTITY_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")),
|
||||
IdentityKubernetesSecretName: env("IDENTITY_KUBERNETES_SECRET_NAME", "easyai-gateway-identity"),
|
||||
IdentityKubernetesAPIServer: env("IDENTITY_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"),
|
||||
IdentityKubernetesTokenFile: env("IDENTITY_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"),
|
||||
IdentityKubernetesCAFile: env("IDENTITY_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"),
|
||||
IdentitySecurityEventHeartbeatIntervalSeconds: envInt("IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60),
|
||||
IdentitySecurityEventStaleAfterSeconds: envInt("IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180),
|
||||
IdentitySecurityEventClockSkewSeconds: envInt("IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60),
|
||||
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
|
||||
WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"),
|
||||
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))),
|
||||
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)),
|
||||
LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24),
|
||||
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
|
||||
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
|
||||
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
|
||||
),
|
||||
@@ -90,9 +91,39 @@ func Load() Config {
|
||||
GlobalHTTPProxy: globalProxy.HTTPProxy,
|
||||
GlobalHTTPProxySource: globalProxy.Source,
|
||||
LogLevel: logLevel(env("LOG_LEVEL", "info")),
|
||||
BillingEngineMode: strings.ToLower(env("BILLING_ENGINE_MODE", "observe")),
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
switch strings.ToLower(strings.TrimSpace(c.BillingEngineMode)) {
|
||||
case "", "observe", "enforce", "hold":
|
||||
default:
|
||||
return errors.New("BILLING_ENGINE_MODE must be observe, enforce, or hold")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.IdentitySecretStore)) {
|
||||
case "":
|
||||
case "file":
|
||||
if strings.TrimSpace(c.IdentitySecretDir) == "" {
|
||||
return errors.New("IDENTITY_SECRET_DIR is required for the file SecretStore")
|
||||
}
|
||||
case "kubernetes":
|
||||
if strings.TrimSpace(c.IdentityKubernetesNamespace) == "" || strings.TrimSpace(c.IdentityKubernetesSecretName) == "" {
|
||||
return errors.New("Kubernetes identity SecretStore requires namespace and Secret name")
|
||||
}
|
||||
default:
|
||||
return errors.New("IDENTITY_SECRET_STORE must be file or kubernetes")
|
||||
}
|
||||
if c.IdentitySecurityEventHeartbeatIntervalSeconds != 0 || c.IdentitySecurityEventStaleAfterSeconds != 0 || c.IdentitySecurityEventClockSkewSeconds != 0 {
|
||||
if c.IdentitySecurityEventHeartbeatIntervalSeconds <= 0 ||
|
||||
c.IdentitySecurityEventStaleAfterSeconds < 2*c.IdentitySecurityEventHeartbeatIntervalSeconds ||
|
||||
c.IdentitySecurityEventClockSkewSeconds < 0 || c.IdentitySecurityEventClockSkewSeconds > 300 {
|
||||
return errors.New("identity security event heartbeat, stale threshold, or clock skew is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GlobalHTTPProxyStatus struct {
|
||||
HTTPProxy string
|
||||
Source string
|
||||
@@ -147,17 +178,6 @@ func normalizePostgresURL(raw string) string {
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
items := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func withDatabase(raw string, databaseName string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || databaseName == "" {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadIdentitySecretStoreUsesNewEnvironmentNamesAndIgnoresLegacyBusinessValues(t *testing.T) {
|
||||
t.Setenv("IDENTITY_SECRET_STORE", "file")
|
||||
t.Setenv("IDENTITY_SECRET_DIR", ".test-secrets/identity")
|
||||
t.Setenv("OIDC_ISSUER", "https://legacy-sensitive.example/issuer")
|
||||
t.Setenv("OIDC_INTROSPECTION_CLIENT_SECRET", "legacy-sensitive-secret")
|
||||
t.Setenv("OIDC_SESSION_ENCRYPTION_KEY", "legacy-sensitive-session-key")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.IdentitySecretStore != "file" || cfg.IdentitySecretDir != ".test-secrets/identity" {
|
||||
t.Fatalf("identity SecretStore = %q %q", cfg.IdentitySecretStore, cfg.IdentitySecretDir)
|
||||
}
|
||||
printed := fmt.Sprintf("%+v", cfg)
|
||||
for _, legacyValue := range []string{"legacy-sensitive.example", "legacy-sensitive-secret", "legacy-sensitive-session-key"} {
|
||||
if strings.Contains(printed, legacyValue) {
|
||||
t.Fatalf("legacy identity business setting was loaded into Config: %q", legacyValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIdentityFileSecretStoreRequiresDirectory(t *testing.T) {
|
||||
cfg := Config{IdentitySecretStore: "file"}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "IDENTITY_SECRET_DIR") {
|
||||
t.Fatalf("Validate() error = %v, want missing identity secret directory", err)
|
||||
}
|
||||
cfg.IdentitySecretDir = ".test-secrets/identity"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("valid file SecretStore was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIdentityKubernetesSecretStore(t *testing.T) {
|
||||
cfg := Config{IdentitySecretStore: "kubernetes", IdentityKubernetesSecretName: "easyai-gateway-identity"}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "namespace") {
|
||||
t.Fatalf("Validate() error = %v, want missing namespace", err)
|
||||
}
|
||||
cfg.IdentityKubernetesNamespace = "easyai"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("valid Kubernetes SecretStore was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIdentitySecurityEventTiming(t *testing.T) {
|
||||
cfg := Config{
|
||||
IdentitySecurityEventHeartbeatIntervalSeconds: 60,
|
||||
IdentitySecurityEventStaleAfterSeconds: 60,
|
||||
IdentitySecurityEventClockSkewSeconds: 60,
|
||||
}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "heartbeat") {
|
||||
t.Fatalf("Validate() error = %v, want invalid stale threshold", err)
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,9 @@ func (s *Server) listAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} AccessRuleListResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/api-keys/access-rules [get]
|
||||
func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -47,7 +48,7 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListAPIKeyAccessRules(r.Context(), user)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
s.logger.Error("list api key access rules failed", "error", err)
|
||||
@@ -57,6 +58,32 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// listAPIKeyAssignableModels godoc
|
||||
// @Summary 列出 API Key 可分配模型
|
||||
// @Description 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。
|
||||
// @Tags api-keys
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} PlatformModelListResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/api-keys/assignable-models [get]
|
||||
func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
models, err := s.store.ListAPIKeyAssignablePlatformModels(r.Context(), user)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
s.logger.Error("list api key assignable models failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list api key assignable models failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)})
|
||||
}
|
||||
|
||||
// createAccessRule godoc
|
||||
// @Summary 创建访问规则
|
||||
// @Description 管理端创建一条访问控制规则。
|
||||
@@ -157,7 +184,7 @@ func (s *Server) batchAPIKeyAccessRules(w http.ResponseWriter, r *http.Request)
|
||||
items, err := s.store.BatchAPIKeyAccessRules(r.Context(), input, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
if store.IsNotFound(err) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
gatewaydocs "github.com/easyai/easyai-ai-gateway/apps/api/docs"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/skillbundle"
|
||||
)
|
||||
|
||||
const (
|
||||
opsManagementSkillDownloadPath = "/api/v1/public/skills/ai-gateway-ops-management/download"
|
||||
apiDocsJSONPath = "/api-docs-json"
|
||||
apiDocsYAMLPath = "/api-docs-yaml"
|
||||
)
|
||||
|
||||
// getOpsManagementSkillMetadata godoc
|
||||
// @Summary 获取 AI Gateway 运维管理 SKILL 元数据
|
||||
// @Description 返回公开运维管理 SKILL 的名称、版本、模块、下载文件名和机器可读接口文档路径。
|
||||
// @Tags agent-resources
|
||||
// @Produce json
|
||||
// @Success 200 {object} SkillBundleMetadataResponse
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/public/skills/ai-gateway-ops-management/metadata [get]
|
||||
func (s *Server) getOpsManagementSkillMetadata(w http.ResponseWriter, _ *http.Request) {
|
||||
metadata, err := skillbundle.LoadMetadata()
|
||||
if err != nil {
|
||||
s.logger.Error("load operations skill metadata failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "operations skill metadata unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, opsManagementSkillMetadataResponse(metadata))
|
||||
}
|
||||
|
||||
// downloadOpsManagementSkill godoc
|
||||
// @Summary 下载 AI Gateway 运维管理 SKILL
|
||||
// @Description 下载可交给 Agent 使用的 ai-gateway-ops-management ZIP 包。
|
||||
// @Tags agent-resources
|
||||
// @Produce application/zip
|
||||
// @Success 200 {file} binary
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/public/skills/ai-gateway-ops-management/download [get]
|
||||
func (s *Server) downloadOpsManagementSkill(w http.ResponseWriter, _ *http.Request) {
|
||||
metadata, err := skillbundle.LoadMetadata()
|
||||
if err != nil {
|
||||
s.logger.Error("load operations skill metadata failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "operations skill metadata unavailable")
|
||||
return
|
||||
}
|
||||
archive, err := skillbundle.BuildArchive()
|
||||
if err != nil {
|
||||
s.logger.Error("build operations skill archive failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "operations skill download unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+skillbundle.FileName(metadata)+`"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(archive)
|
||||
}
|
||||
|
||||
// apiDocsJSON godoc
|
||||
// @Summary 获取 AI Gateway Swagger JSON
|
||||
// @Description 返回当前构建内嵌的完整机器可读 Swagger JSON,供 Agent 在 SKILL references 未覆盖接口时查询。
|
||||
// @Tags agent-resources
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api-docs-json [get]
|
||||
func (s *Server) apiDocsJSON(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(gatewaydocs.SwaggerJSON)
|
||||
}
|
||||
|
||||
// apiDocsYAML godoc
|
||||
// @Summary 获取 AI Gateway Swagger YAML
|
||||
// @Description 返回当前构建内嵌的完整机器可读 Swagger YAML。
|
||||
// @Tags agent-resources
|
||||
// @Produce application/yaml
|
||||
// @Success 200 {string} string
|
||||
// @Router /api-docs-yaml [get]
|
||||
func (s *Server) apiDocsYAML(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(gatewaydocs.SwaggerYAML)
|
||||
}
|
||||
|
||||
func opsManagementSkillMetadataResponse(metadata skillbundle.Metadata) SkillBundleMetadataResponse {
|
||||
return SkillBundleMetadataResponse{
|
||||
Name: metadata.Name,
|
||||
Version: metadata.Version,
|
||||
DisplayName: skillbundle.DisplayName,
|
||||
Modules: metadata.Modules,
|
||||
FileName: skillbundle.FileName(metadata),
|
||||
DownloadPath: opsManagementSkillDownloadPath,
|
||||
APIDocsJSONPath: apiDocsJSONPath,
|
||||
APIDocsYAMLPath: apiDocsYAMLPath,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetOpsManagementSkillMetadata(t *testing.T) {
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/public/skills/ai-gateway-ops-management/metadata", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.getOpsManagementSkillMetadata(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected metadata status 200, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var metadata SkillBundleMetadataResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &metadata); err != nil {
|
||||
t.Fatalf("decode metadata: %v", err)
|
||||
}
|
||||
if metadata.Name != "ai-gateway-ops-management" || metadata.Version != "1.0.2" {
|
||||
t.Fatalf("unexpected metadata: %+v", metadata)
|
||||
}
|
||||
if len(metadata.Modules) != 1 || metadata.Modules[0] != "model-runtime" {
|
||||
t.Fatalf("unexpected metadata modules: %+v", metadata.Modules)
|
||||
}
|
||||
if metadata.APIDocsJSONPath != "/api-docs-json" || metadata.APIDocsYAMLPath != "/api-docs-yaml" {
|
||||
t.Fatalf("unexpected API docs paths: %+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadOpsManagementSkill(t *testing.T) {
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/public/skills/ai-gateway-ops-management/download", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.downloadOpsManagementSkill(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected download status 200, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if response.Header().Get("Content-Type") != "application/zip" {
|
||||
t.Fatalf("unexpected content type: %q", response.Header().Get("Content-Type"))
|
||||
}
|
||||
if disposition := response.Header().Get("Content-Disposition"); !strings.Contains(disposition, "ai-gateway-ops-management-v1.0.2.zip") {
|
||||
t.Fatalf("unexpected content disposition: %q", disposition)
|
||||
}
|
||||
raw := response.Body.Bytes()
|
||||
archive, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("open downloaded archive: %v", err)
|
||||
}
|
||||
foundSkill := false
|
||||
for _, file := range archive.File {
|
||||
if file.Name == "SKILL.md" {
|
||||
foundSkill = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSkill {
|
||||
t.Fatalf("downloaded archive does not contain SKILL.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedAPIDocs(t *testing.T) {
|
||||
server := &Server{}
|
||||
|
||||
jsonResponse := httptest.NewRecorder()
|
||||
server.apiDocsJSON(jsonResponse, httptest.NewRequest(http.MethodGet, "/api-docs-json", nil))
|
||||
if jsonResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected JSON docs status 200, got %d", jsonResponse.Code)
|
||||
}
|
||||
if jsonResponse.Header().Get("Content-Type") != "application/json; charset=utf-8" {
|
||||
t.Fatalf("unexpected JSON docs content type: %q", jsonResponse.Header().Get("Content-Type"))
|
||||
}
|
||||
var document struct {
|
||||
Paths map[string]json.RawMessage `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonResponse.Body.Bytes(), &document); err != nil {
|
||||
t.Fatalf("decode embedded Swagger JSON: %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/api-docs-json",
|
||||
"/api/v1/public/skills/ai-gateway-ops-management/download",
|
||||
"/api/admin/catalog/providers",
|
||||
"/api/admin/catalog/base-models",
|
||||
"/api/admin/platforms",
|
||||
"/api/admin/runtime/policy-sets",
|
||||
"/api/admin/pricing/rule-sets",
|
||||
} {
|
||||
if document.Paths[path] == nil {
|
||||
t.Fatalf("embedded Swagger JSON missing %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
yamlResponse := httptest.NewRecorder()
|
||||
server.apiDocsYAML(yamlResponse, httptest.NewRequest(http.MethodGet, "/api-docs-yaml", nil))
|
||||
if yamlResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected YAML docs status 200, got %d", yamlResponse.Code)
|
||||
}
|
||||
if yamlResponse.Header().Get("Content-Type") != "application/yaml; charset=utf-8" {
|
||||
t.Fatalf("unexpected YAML docs content type: %q", yamlResponse.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(yamlResponse.Body.String(), "/api/v1/public/skills/ai-gateway-ops-management/metadata") {
|
||||
t.Fatalf("embedded Swagger YAML missing operations skill metadata path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestAPIKeyAssignableModelsIgnoreAPIKeyRules(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the API key assignable-model integration flow")
|
||||
}
|
||||
ctx := context.Background()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer server.Close()
|
||||
|
||||
suffixText := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
username := "api_key_assignable_" + suffixText
|
||||
password := "password123"
|
||||
var registerResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®isterResponse)
|
||||
|
||||
testPool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect test pool: %v", err)
|
||||
}
|
||||
defer testPool.Close()
|
||||
if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote test user: %v", err)
|
||||
}
|
||||
|
||||
var loginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username,
|
||||
"password": password,
|
||||
}, http.StatusOK, &loginResponse)
|
||||
|
||||
createAPIKey := func(name string) string {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
APIKey struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"apiKey"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", loginResponse.AccessToken, map[string]any{
|
||||
"name": name,
|
||||
}, http.StatusCreated, &response)
|
||||
return response.APIKey.ID
|
||||
}
|
||||
firstAPIKeyID := createAPIKey("first assignable key")
|
||||
secondAPIKeyID := createAPIKey("second assignable key")
|
||||
|
||||
var platform struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
|
||||
"provider": "openai",
|
||||
"platformKey": "api-key-assignable-" + suffixText,
|
||||
"name": "API Key Assignable Test",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"authType": "bearer",
|
||||
"credentials": map[string]any{"mode": "simulation"},
|
||||
"config": map[string]any{"testMode": true},
|
||||
}, http.StatusCreated, &platform)
|
||||
|
||||
var model struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
modelName := "api-key-assignable-model-" + suffixText
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
|
||||
"canonicalModelKey": "openai:gpt-4o-mini",
|
||||
"modelName": modelName,
|
||||
"modelAlias": modelName,
|
||||
"modelType": []string{"text_generate"},
|
||||
"displayName": "API Key Assignable Model",
|
||||
}, http.StatusCreated, &model)
|
||||
|
||||
assertAssignable := func() {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
ModelName string `json:"modelName"`
|
||||
} `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys/assignable-models", loginResponse.AccessToken, nil, http.StatusOK, &response)
|
||||
if !modelListContains(response.Items, model.ID) {
|
||||
t.Fatalf("user-owned model should remain assignable regardless of API key rules: %+v", response.Items)
|
||||
}
|
||||
}
|
||||
assignModel := func(apiKeyID string) {
|
||||
t.Helper()
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys/access-rules/batch", loginResponse.AccessToken, map[string]any{
|
||||
"subjectType": "api_key",
|
||||
"subjectId": apiKeyID,
|
||||
"effect": "allow",
|
||||
"upsertResources": []map[string]any{{
|
||||
"resourceType": "platform_model",
|
||||
"resourceId": model.ID,
|
||||
"priority": 100,
|
||||
"minPermissionLevel": 0,
|
||||
"status": "active",
|
||||
}},
|
||||
"deleteResources": []map[string]any{},
|
||||
}, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
assertAssignable()
|
||||
assignModel(firstAPIKeyID)
|
||||
assertAssignable()
|
||||
assignModel(secondAPIKeyID)
|
||||
assertAssignable()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestReadyReturnsPostgresUnavailableWithinTwoSeconds(t *testing.T) {
|
||||
db := newExhaustedPostgresStore(t)
|
||||
server := &Server{store: db, logger: slog.New(slog.NewJSONHandler(io.Discard, nil))}
|
||||
requestContext, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
request := httptest.NewRequest(http.MethodGet, "/readyz", nil).WithContext(requestContext)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
startedAt := time.Now()
|
||||
server.ready(recorder, request)
|
||||
elapsed := time.Since(startedAt)
|
||||
|
||||
assertUnavailableResponse(t, recorder, "POSTGRES_UNAVAILABLE", "postgres unavailable")
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("readiness timeout took %s, want no more than 3s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginReturnsAuthStoreUnavailableWithinFiveSeconds(t *testing.T) {
|
||||
db := newExhaustedPostgresStore(t)
|
||||
var logs bytes.Buffer
|
||||
server := &Server{store: db, logger: slog.New(slog.NewJSONHandler(&logs, nil))}
|
||||
requestContext, cancel := context.WithTimeout(context.Background(), 7*time.Second)
|
||||
defer cancel()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"account":"timeout-test-account","password":"timeout-test-password"}`)).WithContext(requestContext)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
startedAt := time.Now()
|
||||
server.login(recorder, request)
|
||||
elapsed := time.Since(startedAt)
|
||||
|
||||
assertUnavailableResponse(t, recorder, "AUTH_STORE_UNAVAILABLE", "authentication service temporarily unavailable")
|
||||
if elapsed > 6*time.Second {
|
||||
t.Fatalf("login timeout took %s, want no more than 6s", elapsed)
|
||||
}
|
||||
logOutput := logs.String()
|
||||
for _, field := range []string{"postgres_pool_max_connections", "postgres_pool_acquired_connections", "postgres_pool_idle_connections", "postgres_pool_empty_acquire_count", "postgres_pool_canceled_acquire_count"} {
|
||||
if !strings.Contains(logOutput, field) {
|
||||
t.Fatalf("login failure log did not include %q: %s", field, logOutput)
|
||||
}
|
||||
}
|
||||
if strings.Contains(logOutput, "timeout-test-account") || strings.Contains(logOutput, "timeout-test-password") {
|
||||
t.Fatalf("login failure log exposed credentials: %s", logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func newExhaustedPostgresStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run PostgreSQL availability timeout tests")
|
||||
}
|
||||
parsed, err := url.Parse(databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test database URL: %v", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("pool_max_conns", "1")
|
||||
query.Set("pool_min_conns", "0")
|
||||
parsed.RawQuery = query.Encode()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
db, err := store.Connect(ctx, parsed.String())
|
||||
if err != nil {
|
||||
t.Fatalf("connect timeout test store: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
connection, err := db.Pool().Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("exhaust timeout test pool: %v", err)
|
||||
}
|
||||
t.Cleanup(connection.Release)
|
||||
return db
|
||||
}
|
||||
|
||||
func assertUnavailableResponse(t *testing.T, recorder *httptest.ResponseRecorder, expectedCode, expectedMessage string) {
|
||||
t.Helper()
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503; body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var envelope ErrorEnvelope
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode unavailable response: %v", err)
|
||||
}
|
||||
if envelope.Error.Code != expectedCode {
|
||||
t.Fatalf("error code = %q, want %q; body=%s", envelope.Error.Code, expectedCode, recorder.Body.String())
|
||||
}
|
||||
if envelope.Error.Message != expectedMessage {
|
||||
t.Fatalf("error message = %q, want %q; body=%s", envelope.Error.Message, expectedMessage, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
type walletBalanceRequest struct {
|
||||
Currency string `json:"currency" example:"USD"`
|
||||
Balance float64 `json:"balance" example:"100"`
|
||||
Balance json.Number `json:"balance" swaggertype:"number" example:"100"`
|
||||
Reason string `json:"reason" example:"manual recharge"`
|
||||
IdempotencyKey string `json:"idempotencyKey" example:"wallet-set-20260514-001"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
@@ -21,7 +21,7 @@ type walletBalanceRequest struct {
|
||||
|
||||
type walletRechargeRequest struct {
|
||||
Currency string `json:"currency" example:"resource"`
|
||||
Amount float64 `json:"amount" example:"100"`
|
||||
Amount json.Number `json:"amount" swaggertype:"number" example:"100"`
|
||||
Reason string `json:"reason" example:"manual recharge"`
|
||||
IdempotencyKey string `json:"idempotencyKey" example:"wallet-recharge-20260514-001"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
@@ -50,10 +50,6 @@ func (s *Server) setUserWalletBalance(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if input.Balance < 0 {
|
||||
writeError(w, http.StatusBadRequest, "wallet balance cannot be negative")
|
||||
return
|
||||
}
|
||||
gatewayUserID := strings.TrimSpace(r.PathValue("userID"))
|
||||
reason := strings.TrimSpace(input.Reason)
|
||||
if reason == "" {
|
||||
@@ -67,7 +63,7 @@ func (s *Server) setUserWalletBalance(w http.ResponseWriter, r *http.Request) {
|
||||
next, err := s.store.SetUserWalletBalanceTx(r.Context(), tx, store.WalletBalanceAdjustmentInput{
|
||||
GatewayUserID: gatewayUserID,
|
||||
Currency: input.Currency,
|
||||
Balance: input.Balance,
|
||||
BalanceText: input.Balance.String(),
|
||||
Reason: reason,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
Metadata: input.Metadata,
|
||||
@@ -89,6 +85,10 @@ func (s *Server) setUserWalletBalance(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
case errors.Is(err, store.ErrWalletBalanceUnchanged):
|
||||
writeError(w, http.StatusBadRequest, "wallet balance is unchanged")
|
||||
case errors.Is(err, store.ErrInvalidWalletAmount):
|
||||
writeError(w, http.StatusBadRequest, "wallet balance must be a non-negative decimal with at most nine fractional digits", "invalid_wallet_amount")
|
||||
case errors.Is(err, store.ErrBalanceBelowFrozen):
|
||||
writeError(w, http.StatusConflict, "wallet balance cannot be below frozen balance", "balance_below_frozen")
|
||||
default:
|
||||
s.logger.Error("set user wallet balance failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "set user wallet balance failed")
|
||||
@@ -126,10 +126,6 @@ func (s *Server) rechargeUserWalletBalance(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if input.Amount <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "wallet recharge amount must be positive")
|
||||
return
|
||||
}
|
||||
gatewayUserID := strings.TrimSpace(r.PathValue("userID"))
|
||||
reason := strings.TrimSpace(input.Reason)
|
||||
if reason == "" {
|
||||
@@ -143,7 +139,7 @@ func (s *Server) rechargeUserWalletBalance(w http.ResponseWriter, r *http.Reques
|
||||
next, err := s.store.RechargeUserWalletBalanceTx(r.Context(), tx, store.WalletRechargeInput{
|
||||
GatewayUserID: gatewayUserID,
|
||||
Currency: input.Currency,
|
||||
Amount: input.Amount,
|
||||
AmountText: input.Amount.String(),
|
||||
Reason: reason,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
Metadata: input.Metadata,
|
||||
@@ -163,6 +159,8 @@ func (s *Server) rechargeUserWalletBalance(w http.ResponseWriter, r *http.Reques
|
||||
switch {
|
||||
case store.IsNotFound(err):
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
case errors.Is(err, store.ErrInvalidWalletAmount):
|
||||
writeError(w, http.StatusBadRequest, "wallet recharge amount must be a positive decimal with at most nine fractional digits", "invalid_wallet_amount")
|
||||
default:
|
||||
s.logger.Error("recharge user wallet balance failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "recharge user wallet balance failed")
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// listBillingSettlements godoc
|
||||
// @Summary 查询计费结算队列
|
||||
// @Description 管理端分页查询待结算、重试失败和人工复核记录。
|
||||
// @Tags billing
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param status query string false "结算状态"
|
||||
// @Param action query string false "动作:settle 或 release"
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param pageSize query int false "每页数量" default(50)
|
||||
// @Success 200 {object} BillingSettlementListResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/admin/runtime/billing-settlements [get]
|
||||
func (s *Server) listBillingSettlements(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
page, err := positiveQueryInt(query.Get("page"), 1)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid page", "invalid_request")
|
||||
return
|
||||
}
|
||||
pageSize, err := positiveQueryInt(query.Get("pageSize"), 50)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid pageSize", "invalid_request")
|
||||
return
|
||||
}
|
||||
result, err := s.store.ListBillingSettlements(r.Context(), store.BillingSettlementListFilter{
|
||||
Status: query.Get("status"), Action: query.Get("action"), Page: page, PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list billing settlements failed", "error_category", "billing_settlement_list_failed")
|
||||
writeError(w, http.StatusInternalServerError, "list billing settlements failed", "billing_settlement_list_failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// retryBillingSettlement godoc
|
||||
// @Summary 重试计费结算
|
||||
// @Description Manager 使用单值 Idempotency-Key 将重试失败或人工复核记录重新放入队列,并记录审计日志。
|
||||
// @Tags billing
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param settlementId path string true "结算记录 ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Success 200 {object} BillingSettlementRetryResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/admin/runtime/billing-settlements/{settlementId}/retry [post]
|
||||
func (s *Server) retryBillingSettlement(w http.ResponseWriter, r *http.Request) {
|
||||
key, ok := singleIdempotencyKey(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "a single Idempotency-Key is required", "idempotency_key_required")
|
||||
return
|
||||
}
|
||||
keyHash := sha256.Sum256([]byte(key))
|
||||
settlementID := strings.TrimSpace(r.PathValue("settlementId"))
|
||||
if _, err := uuid.Parse(settlementID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid billing settlement ID", "invalid_request")
|
||||
return
|
||||
}
|
||||
actor, _ := auth.UserFromContext(r.Context())
|
||||
var item store.BillingSettlement
|
||||
var audit store.AuditLog
|
||||
var replayed bool
|
||||
err := s.store.InTx(r.Context(), func(tx store.Tx) error {
|
||||
var err error
|
||||
item, replayed, err = s.store.RetryBillingSettlementTx(r.Context(), tx, settlementID, hex.EncodeToString(keyHash[:]))
|
||||
if err != nil || replayed {
|
||||
return err
|
||||
}
|
||||
audit, err = s.store.RecordAuditLogTx(r.Context(), tx, billingSettlementRetryAuditInput(r, actor, item))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case store.IsNotFound(err):
|
||||
writeError(w, http.StatusNotFound, "billing settlement not found", "billing_settlement_not_found")
|
||||
case errors.Is(err, store.ErrBillingSettlementNotRetryable):
|
||||
writeError(w, http.StatusConflict, "billing settlement is not retryable", "billing_settlement_not_retryable")
|
||||
default:
|
||||
s.logger.Error("retry billing settlement failed", "settlementID", settlementID, "error_category", "billing_settlement_retry_failed")
|
||||
writeError(w, http.StatusInternalServerError, "retry billing settlement failed", "billing_settlement_retry_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if replayed {
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
}
|
||||
response := map[string]any{"settlement": item}
|
||||
if !replayed {
|
||||
response["auditLog"] = audit
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func singleIdempotencyKey(r *http.Request) (string, bool) {
|
||||
values := r.Header.Values("Idempotency-Key")
|
||||
if len(values) != 1 {
|
||||
return "", false
|
||||
}
|
||||
value := strings.TrimSpace(values[0])
|
||||
if value == "" || len(value) > 255 || strings.Contains(value, ",") {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func billingSettlementRetryAuditInput(r *http.Request, actor *auth.User, item store.BillingSettlement) store.AuditLogInput {
|
||||
input := store.AuditLogInput{
|
||||
Category: "billing", Action: "billing.settlement.retry",
|
||||
TargetType: "billing_settlement", TargetID: item.ID,
|
||||
RequestIP: requestIP(r), UserAgent: r.UserAgent(),
|
||||
AfterState: map[string]any{"status": item.Status, "attempts": item.Attempts},
|
||||
Metadata: map[string]any{"taskId": item.TaskID, "action": item.Action, "currency": item.Currency},
|
||||
}
|
||||
if actor != nil {
|
||||
input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID))
|
||||
input.ActorUserID = actor.ID
|
||||
input.ActorUsername = actor.Username
|
||||
input.ActorSource = actor.Source
|
||||
input.ActorRoles = actor.Roles
|
||||
}
|
||||
return input
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSingleIdempotencyKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest("POST", "/", nil)
|
||||
request.Header.Add("Idempotency-Key", "retry-1")
|
||||
if got, ok := singleIdempotencyKey(request); !ok || got != "retry-1" {
|
||||
t.Fatalf("got key=%q ok=%v", got, ok)
|
||||
}
|
||||
|
||||
request.Header.Add("Idempotency-Key", "retry-2")
|
||||
if _, ok := singleIdempotencyKey(request); ok {
|
||||
t.Fatal("multiple Idempotency-Key values must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,30 @@ func TestWriteCompatibleTaskResponseReturnsJSONWhenStreamIsFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCompatibleTaskResponseMapsInvalidParameterToBadRequest(t *testing.T) {
|
||||
executor := &fakeTaskExecutor{
|
||||
runErr: &clients.ClientError{
|
||||
Code: "invalid_parameter",
|
||||
Message: "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh, max",
|
||||
Retryable: false,
|
||||
},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/completions", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
writeCompatibleTaskResponse(context.Background(), recorder, req, executor, "chat.completions", "gpt-test", store.GatewayTask{ID: "task-test"}, &auth.User{}, false, false)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d want=%d body=%s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "invalid_parameter") {
|
||||
t.Fatalf("response should include invalid_parameter code: %s", recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"type":"invalid_request_error"`) || !strings.Contains(recorder.Body.String(), `"param":null`) {
|
||||
t.Fatalf("response should use OpenAI error fields: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCompatibleTaskResponseReturnsSSEWhenStreamIsTrue(t *testing.T) {
|
||||
executor := &fakeTaskExecutor{
|
||||
deltas: []clients.StreamDeltaEvent{{Text: "hel"}, {Text: "lo"}},
|
||||
@@ -284,15 +308,22 @@ type fakeTaskExecutor struct {
|
||||
streamCalls int
|
||||
deltas []clients.StreamDeltaEvent
|
||||
output map[string]any
|
||||
runErr error
|
||||
}
|
||||
|
||||
func (f *fakeTaskExecutor) Execute(context.Context, store.GatewayTask, *auth.User) (runner.Result, error) {
|
||||
f.executeCalls++
|
||||
if f.runErr != nil {
|
||||
return runner.Result{}, f.runErr
|
||||
}
|
||||
return runner.Result{Output: f.output}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTaskExecutor) ExecuteStream(_ context.Context, _ store.GatewayTask, _ *auth.User, onDelta clients.StreamDelta) (runner.Result, error) {
|
||||
f.streamCalls++
|
||||
if f.runErr != nil {
|
||||
return runner.Result{}, f.runErr
|
||||
}
|
||||
for _, delta := range f.deltas {
|
||||
if err := onDelta(delta); err != nil {
|
||||
return runner.Result{}, err
|
||||
|
||||
@@ -48,6 +48,7 @@ func TestCoreLocalFlow(t *testing.T) {
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
BillingEngineMode: "enforce",
|
||||
TaskProgressCallbackEnabled: true,
|
||||
TaskProgressCallbackURL: "http://callback.local/task-progress",
|
||||
CORSAllowedOrigin: "*",
|
||||
@@ -73,6 +74,12 @@ func TestCoreLocalFlow(t *testing.T) {
|
||||
if registerResponse.AccessToken == "" {
|
||||
t.Fatal("register did not return access token")
|
||||
}
|
||||
ordinaryUsername := "smoke_user_" + suffixText
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": ordinaryUsername,
|
||||
"email": ordinaryUsername + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, &struct{}{})
|
||||
|
||||
var duplicateResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
@@ -125,9 +132,29 @@ func TestCoreLocalFlow(t *testing.T) {
|
||||
t.Fatalf("connect test pool: %v", err)
|
||||
}
|
||||
defer testPool.Close()
|
||||
assertSeedreamProCatalogMigration(t, ctx, testPool)
|
||||
if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote smoke user: %v", err)
|
||||
}
|
||||
serverMainCtx, cancelServerMain := context.WithCancel(ctx)
|
||||
serverMain := httptest.NewServer(NewServerWithContext(serverMainCtx, config.Config{
|
||||
AppEnv: "test", HTTPAddr: ":0", DatabaseURL: databaseURL, IdentityMode: "server-main",
|
||||
JWTSecret: "test-secret", CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
var breakGlassLogin struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, serverMain.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username, "password": password,
|
||||
}, http.StatusOK, &breakGlassLogin)
|
||||
if breakGlassLogin.AccessToken == "" {
|
||||
t.Fatal("server-main break-glass manager login did not return access token")
|
||||
}
|
||||
doJSON(t, serverMain.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": ordinaryUsername, "password": password,
|
||||
}, http.StatusForbidden, &map[string]any{})
|
||||
serverMain.Close()
|
||||
cancelServerMain()
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username,
|
||||
"password": password,
|
||||
@@ -418,7 +445,6 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
t.Fatalf("unexpected compatible chat response: %+v", compatChat)
|
||||
}
|
||||
|
||||
cancelMarker := "cancel-stream-" + suffixText
|
||||
cancelCtx, cancelRequest := context.WithCancel(context.Background())
|
||||
cancelPayload := map[string]any{
|
||||
"model": defaultTextModel,
|
||||
@@ -427,7 +453,6 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
"stream": true,
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 250,
|
||||
"cancelTestId": cancelMarker,
|
||||
}
|
||||
cancelRaw, err := json.Marshal(cancelPayload)
|
||||
if err != nil {
|
||||
@@ -440,15 +465,27 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
cancelReq.Header.Set("Authorization", "Bearer "+apiKeyResponse.Secret)
|
||||
cancelReq.Header.Set("Content-Type", "application/json")
|
||||
cancelErrCh := make(chan error, 1)
|
||||
cancelTaskIDCh := make(chan string, 1)
|
||||
go func() {
|
||||
resp, err := http.DefaultClient.Do(cancelReq)
|
||||
if resp != nil {
|
||||
cancelTaskIDCh <- strings.TrimSpace(resp.Header.Get("X-Gateway-Task-Id"))
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
} else {
|
||||
cancelTaskIDCh <- ""
|
||||
}
|
||||
cancelErrCh <- err
|
||||
}()
|
||||
cancelTaskID := waitForTaskIDByRequestMarker(t, ctx, testPool, cancelMarker, 2*time.Second)
|
||||
var cancelTaskID string
|
||||
select {
|
||||
case cancelTaskID = <-cancelTaskIDCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("cancelled stream did not return response headers")
|
||||
}
|
||||
if cancelTaskID == "" {
|
||||
t.Fatal("cancelled stream response did not expose X-Gateway-Task-Id")
|
||||
}
|
||||
cancelRequest()
|
||||
select {
|
||||
case <-cancelErrCh:
|
||||
@@ -778,6 +815,7 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
|
||||
"currency": "resource",
|
||||
"rules": []map[string]any{
|
||||
{"ruleKey": "text_input", "displayName": "Text Input", "resourceType": "text_input", "unit": "1k_tokens", "basePrice": 1},
|
||||
{"ruleKey": "text_cached_input", "displayName": "Text Cached Input", "resourceType": "text_cached_input", "unit": "1k_tokens", "basePrice": 0.5},
|
||||
{"ruleKey": "text_output", "displayName": "Text Output", "resourceType": "text_output", "unit": "1k_tokens", "basePrice": 2},
|
||||
{"ruleKey": "image", "displayName": "Image", "resourceType": "image", "unit": "image", "basePrice": 7},
|
||||
{"ruleKey": "image_edit", "displayName": "Image Edit", "resourceType": "image_edit", "unit": "image", "basePrice": 11},
|
||||
@@ -809,6 +847,101 @@ WHERE gateway_user_id = $1::uuid
|
||||
AND currency = 'resource'`, smokeGatewayUserID).Scan(&walletBalanceBefore); err != nil {
|
||||
t.Fatalf("read wallet balance before pricing task: %v", err)
|
||||
}
|
||||
var estimateSideEffectsBefore struct {
|
||||
Tasks int
|
||||
Transactions int
|
||||
Frozen string
|
||||
}
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT (SELECT count(*) FROM gateway_tasks),
|
||||
(SELECT count(*) FROM gateway_wallet_transactions WHERE gateway_user_id=$1::uuid),
|
||||
(SELECT frozen_balance::text FROM gateway_wallet_accounts WHERE gateway_user_id=$1::uuid AND currency='resource')`,
|
||||
smokeGatewayUserID,
|
||||
).Scan(&estimateSideEffectsBefore.Tasks, &estimateSideEffectsBefore.Transactions, &estimateSideEffectsBefore.Frozen); err != nil {
|
||||
t.Fatalf("read estimate side effects before request: %v", err)
|
||||
}
|
||||
var pricingEstimate struct {
|
||||
TotalAmount float64 `json:"totalAmount"`
|
||||
ReservationAmount float64 `json:"reservationAmount"`
|
||||
CandidateCount int `json:"candidateCount"`
|
||||
PricingVersion string `json:"pricingVersion"`
|
||||
RequestFingerprint string `json:"requestFingerprint"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/pricing/estimate", apiKeyResponse.Secret, map[string]any{
|
||||
"kind": "chat.completions", "model": pricingModel, "max_completion_tokens": 32,
|
||||
"messages": []map[string]any{{"role": "user", "content": "estimate only"}},
|
||||
}, http.StatusOK, &pricingEstimate)
|
||||
if pricingEstimate.TotalAmount <= 0 || pricingEstimate.ReservationAmount < pricingEstimate.TotalAmount ||
|
||||
pricingEstimate.CandidateCount != 1 || pricingEstimate.PricingVersion != "effective-pricing-v2" || pricingEstimate.RequestFingerprint == "" {
|
||||
t.Fatalf("unexpected effective pricing estimate: %+v", pricingEstimate)
|
||||
}
|
||||
var estimateSideEffectsAfter struct {
|
||||
Tasks int
|
||||
Transactions int
|
||||
Frozen string
|
||||
}
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT (SELECT count(*) FROM gateway_tasks),
|
||||
(SELECT count(*) FROM gateway_wallet_transactions WHERE gateway_user_id=$1::uuid),
|
||||
(SELECT frozen_balance::text FROM gateway_wallet_accounts WHERE gateway_user_id=$1::uuid AND currency='resource')`,
|
||||
smokeGatewayUserID,
|
||||
).Scan(&estimateSideEffectsAfter.Tasks, &estimateSideEffectsAfter.Transactions, &estimateSideEffectsAfter.Frozen); err != nil {
|
||||
t.Fatalf("read estimate side effects after request: %v", err)
|
||||
}
|
||||
if estimateSideEffectsAfter != estimateSideEffectsBefore {
|
||||
t.Fatalf("pricing estimate must be read-only, before=%+v after=%+v", estimateSideEffectsBefore, estimateSideEffectsAfter)
|
||||
}
|
||||
|
||||
idempotencyPayload := map[string]any{
|
||||
"model": pricingModel, "runMode": "simulation", "simulation": true, "simulationDurationMs": 5,
|
||||
"messages": []map[string]any{{"role": "user", "content": "idempotent replay"}},
|
||||
}
|
||||
idempotencyHeaders := map[string]string{"Idempotency-Key": "chat-replay-" + suffixText}
|
||||
var idempotentFirst map[string]any
|
||||
firstReplayHeaders := doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, idempotencyPayload, idempotencyHeaders, http.StatusOK, &idempotentFirst)
|
||||
var idempotentSecond map[string]any
|
||||
secondReplayHeaders := doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, idempotencyPayload, idempotencyHeaders, http.StatusOK, &idempotentSecond)
|
||||
idempotentTaskID := firstReplayHeaders.Get("X-Gateway-Task-Id")
|
||||
if idempotentTaskID == "" || secondReplayHeaders.Get("X-Gateway-Task-Id") != idempotentTaskID || secondReplayHeaders.Get("Idempotent-Replayed") != "true" {
|
||||
t.Fatalf("non-stream replay headers first=%v second=%v", firstReplayHeaders, secondReplayHeaders)
|
||||
}
|
||||
var idempotentAttempts int
|
||||
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_task_attempts WHERE task_id=$1::uuid`, idempotentTaskID).Scan(&idempotentAttempts); err != nil {
|
||||
t.Fatalf("count idempotent task attempts: %v", err)
|
||||
}
|
||||
if idempotentAttempts != 1 {
|
||||
t.Fatalf("idempotent request called upstream %d times, want 1", idempotentAttempts)
|
||||
}
|
||||
idempotencyPayload["messages"] = []map[string]any{{"role": "user", "content": "different request"}}
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, idempotencyPayload, idempotencyHeaders, http.StatusConflict, nil)
|
||||
|
||||
streamPayload := map[string]any{
|
||||
"model": pricingModel, "runMode": "simulation", "simulation": true, "simulationDurationMs": 5, "stream": true,
|
||||
"messages": []map[string]any{{"role": "user", "content": "stream replay"}},
|
||||
}
|
||||
streamRaw, err := json.Marshal(streamPayload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
streamKey := "stream-replay-" + suffixText
|
||||
streamRequest, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/chat/completions", bytes.NewReader(streamRaw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
streamRequest.Header.Set("Authorization", "Bearer "+apiKeyResponse.Secret)
|
||||
streamRequest.Header.Set("Content-Type", "application/json")
|
||||
streamRequest.Header.Set("Idempotency-Key", streamKey)
|
||||
streamResponse, err := http.DefaultClient.Do(streamRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("execute first idempotent stream: %v", err)
|
||||
}
|
||||
_, _ = io.ReadAll(streamResponse.Body)
|
||||
_ = streamResponse.Body.Close()
|
||||
if streamResponse.StatusCode != http.StatusOK || streamResponse.Header.Get("X-Gateway-Task-Id") == "" {
|
||||
t.Fatalf("first idempotent stream status=%d headers=%v", streamResponse.StatusCode, streamResponse.Header)
|
||||
}
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, streamPayload,
|
||||
map[string]string{"Idempotency-Key": streamKey}, http.StatusConflict, nil)
|
||||
var pricingTask struct {
|
||||
Task struct {
|
||||
ID string `json:"id"`
|
||||
@@ -819,7 +952,7 @@ WHERE gateway_user_id = $1::uuid
|
||||
}
|
||||
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||
"model": pricingModel,
|
||||
"runMode": "simulation",
|
||||
"runMode": "production",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"messages": []map[string]any{{"role": "user", "content": "priced ping"}},
|
||||
@@ -827,6 +960,20 @@ WHERE gateway_user_id = $1::uuid
|
||||
if pricingTask.Task.Status != "succeeded" || !floatNear(pricingTask.Task.FinalChargeAmount, 0.028) {
|
||||
t.Fatalf("custom pricing rule set should drive text billing, got task=%+v", pricingTask.Task)
|
||||
}
|
||||
settlementDeadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
var billingStatus string
|
||||
if err := testPool.QueryRow(ctx, `SELECT billing_status FROM gateway_tasks WHERE id = $1::uuid`, pricingTask.Task.ID).Scan(&billingStatus); err != nil {
|
||||
t.Fatalf("read pricing task billing status: %v", err)
|
||||
}
|
||||
if billingStatus == "settled" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(settlementDeadline) {
|
||||
t.Fatalf("pricing task billing did not settle before deadline, status=%s", billingStatus)
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
var walletBalanceAfter float64
|
||||
var walletSpentAfter float64
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
@@ -1458,7 +1605,7 @@ WHERE m.platform_id = $1::uuid
|
||||
t.Fatalf("workspace task list should include persisted task records, got %+v", workspaceTaskList.Items)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+taskResponse.Task.ID+"/events", nil)
|
||||
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+pricingTask.Task.ID+"/events", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build events request: %v", err)
|
||||
}
|
||||
@@ -1507,6 +1654,17 @@ WHERE m.platform_id = $1::uuid
|
||||
t.Fatal("task progress callback outbox should receive events")
|
||||
}
|
||||
|
||||
restartModel := "worker-restart-" + suffixText
|
||||
createSimulationTextPlatformModel(
|
||||
t,
|
||||
server.URL,
|
||||
loginResponse.AccessToken,
|
||||
"openai-worker-restart-"+suffixText,
|
||||
"OpenAI Worker Restart",
|
||||
restartModel,
|
||||
1,
|
||||
nil,
|
||||
)
|
||||
var restartAsyncTask struct {
|
||||
TaskID string `json:"taskId"`
|
||||
Task struct {
|
||||
@@ -1516,7 +1674,7 @@ WHERE m.platform_id = $1::uuid
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/responses", apiKeyResponse.Secret, map[string]any{
|
||||
"model": defaultTextModel,
|
||||
"model": restartModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 2000,
|
||||
@@ -1538,6 +1696,7 @@ WHERE m.platform_id = $1::uuid
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
BillingEngineMode: "enforce",
|
||||
TaskProgressCallbackEnabled: true,
|
||||
TaskProgressCallbackURL: "http://callback.local/task-progress",
|
||||
CORSAllowedOrigin: "*",
|
||||
@@ -1696,6 +1855,9 @@ func TestOriginAllowedSupportsCommaSeparatedOrigins(t *testing.T) {
|
||||
if originAllowed("http://127.0.0.1:5179", allowed) {
|
||||
t.Fatal("unexpected origin should not be allowed")
|
||||
}
|
||||
if originAllowed("https://evil.example.com", "*") {
|
||||
t.Fatal("credentialed wildcard CORS origin should not be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func assertRuntimeRecoveryReleasesPendingRateReservations(t *testing.T, ctx context.Context, db *store.Store) {
|
||||
@@ -1794,12 +1956,48 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
}
|
||||
}
|
||||
|
||||
func assertSeedreamProCatalogMigration(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
var valid bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM base_model_catalog model
|
||||
WHERE model.provider_key = 'volces'
|
||||
AND model.canonical_model_key = 'volces:doubao-seedream-5-0-pro-260628'
|
||||
AND model.provider_model_name = 'doubao-seedream-5-0-pro-260628'
|
||||
AND model.display_name = 'Seedream-5.0-Pro'
|
||||
AND model.display_name !~ '[[:space:]]'
|
||||
AND model.model_type = '["image_edit","image_generate"]'::jsonb
|
||||
AND model.capabilities->'image_edit'->>'input_multiple_images' = 'true'
|
||||
AND model.capabilities->'image_edit'->>'input_max_images_count' = '10'
|
||||
AND model.capabilities->'image_edit'->>'input_max_file_size_bytes' = '31457280'
|
||||
AND model.capabilities->'image_edit'->'output_resolutions' = '["1K","2K"]'::jsonb
|
||||
AND model.capabilities->'image_edit'->'output_size_range' = '[921600,4624220]'::jsonb
|
||||
AND model.capabilities->'image_edit'->'aspect_ratio_range' = '[0.0625,16]'::jsonb
|
||||
AND model.capabilities->'image_edit'->>'output_multiple_images' = 'false'
|
||||
AND model.capabilities->'image_edit'->>'output_max_images_count' = '1'
|
||||
AND model.capabilities->'image_generate'->>'allow_custom_width_height_size' = 'true'
|
||||
AND model.capabilities->'image_generate'->>'output_multiple_images' = 'false'
|
||||
AND model.capabilities->>'stream' = 'false'
|
||||
AND model.capabilities->>'supportWebSearch' = 'false'
|
||||
AND model.default_rate_limit_policy->'rules'->0->>'limit' = '500'
|
||||
AND model.default_snapshot->>'modelAlias' = 'Seedream-5.0-Pro'
|
||||
AND model.default_snapshot->>'displayName' = 'Seedream 5.0 Pro'
|
||||
)`).Scan(&valid); err != nil {
|
||||
t.Fatalf("query Seedream 5.0 Pro catalog migration: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Fatal("Seedream 5.0 Pro catalog migration does not match the expected alias, upstream model, capabilities, or rate limit")
|
||||
}
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, baseURL string, method string, path string, token string, payload any, expectedStatus int, out any) {
|
||||
t.Helper()
|
||||
doJSONWithHeaders(t, baseURL, method, path, token, payload, nil, expectedStatus, out)
|
||||
}
|
||||
|
||||
func doJSONWithHeaders(t *testing.T, baseURL string, method string, path string, token string, payload any, headers map[string]string, expectedStatus int, out any) {
|
||||
func doJSONWithHeaders(t *testing.T, baseURL string, method string, path string, token string, payload any, headers map[string]string, expectedStatus int, out any) http.Header {
|
||||
t.Helper()
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
@@ -1836,16 +2034,22 @@ func doJSONWithHeaders(t *testing.T, baseURL string, method string, path string,
|
||||
t.Fatalf("decode %s %s response: %v body=%s", method, path, err, string(raw))
|
||||
}
|
||||
}
|
||||
return resp.Header.Clone()
|
||||
}
|
||||
|
||||
func doAPIV1ChatCompletionAndLoadTask(t *testing.T, ctx context.Context, pool *pgxpool.Pool, baseURL string, token string, payload map[string]any, marker string, expectedStatus int, responseOut any, taskDetailOut any) string {
|
||||
t.Helper()
|
||||
payload["integrationTestMarker"] = marker
|
||||
_ = ctx
|
||||
_ = pool
|
||||
_ = marker
|
||||
if responseOut == nil {
|
||||
responseOut = &map[string]any{}
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/chat/completions", token, payload, expectedStatus, responseOut)
|
||||
taskID := waitForTaskIDByRequestField(t, ctx, pool, "integrationTestMarker", marker, 2*time.Second)
|
||||
responseHeaders := doJSONWithHeaders(t, baseURL, http.MethodPost, "/api/v1/chat/completions", token, payload, nil, expectedStatus, responseOut)
|
||||
taskID := strings.TrimSpace(responseHeaders.Get("X-Gateway-Task-Id"))
|
||||
if taskID == "" {
|
||||
t.Fatal("chat completion response did not expose X-Gateway-Task-Id")
|
||||
}
|
||||
if taskDetailOut != nil {
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/tasks/"+taskID, token, nil, http.StatusOK, taskDetailOut)
|
||||
}
|
||||
@@ -1981,11 +2185,6 @@ func waitForTaskStatus(t *testing.T, baseURL string, token string, taskID string
|
||||
return detail
|
||||
}
|
||||
|
||||
func waitForTaskIDByRequestMarker(t *testing.T, ctx context.Context, pool *pgxpool.Pool, marker string, timeout time.Duration) string {
|
||||
t.Helper()
|
||||
return waitForTaskIDByRequestField(t, ctx, pool, "cancelTestId", marker, timeout)
|
||||
}
|
||||
|
||||
func waitForTaskIDByRequestField(t *testing.T, ctx context.Context, pool *pgxpool.Pool, key string, value string, timeout time.Duration) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type gatewayTaskCreationStage string
|
||||
|
||||
const (
|
||||
gatewayTaskCreationPrepare gatewayTaskCreationStage = "prepare"
|
||||
gatewayTaskCreationStore gatewayTaskCreationStage = "store"
|
||||
)
|
||||
|
||||
type gatewayTaskCreationError struct {
|
||||
Stage gatewayTaskCreationStage
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *gatewayTaskCreationError) Error() string {
|
||||
if e == nil || e.Err == nil {
|
||||
return "gateway task creation failed"
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *gatewayTaskCreationError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (s *Server) prepareAndCreateGatewayTask(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
user *auth.User,
|
||||
kind string,
|
||||
model string,
|
||||
body map[string]any,
|
||||
async bool,
|
||||
) (store.GatewayTask, error) {
|
||||
prepared, err := s.prepareTaskRequest(ctx, r, user, body)
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, &gatewayTaskCreationError{Stage: gatewayTaskCreationPrepare, Err: err}
|
||||
}
|
||||
task, err := s.store.CreateTask(ctx, store.CreateTaskInput{
|
||||
Kind: kind,
|
||||
Model: model,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
Async: async,
|
||||
Request: prepared.Body,
|
||||
ConversationID: prepared.ConversationID,
|
||||
NewMessageCount: prepared.NewMessageCount,
|
||||
MessageRefs: prepared.MessageRefs,
|
||||
}, user)
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, &gatewayTaskCreationError{
|
||||
Stage: gatewayTaskCreationStore,
|
||||
Err: fmt.Errorf("create task: %w", err),
|
||||
}
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
@@ -39,13 +40,13 @@ type geminiUploadSession struct {
|
||||
}
|
||||
|
||||
var geminiGenerateContentRoutePrefixes = []string{
|
||||
"/api/v1/models/",
|
||||
"/v1beta/models/",
|
||||
"/v1/models/",
|
||||
"/models/",
|
||||
}
|
||||
|
||||
func (s *Server) registerGeminiGenerateContentRoutes(mux *http.ServeMux) {
|
||||
handler := s.auth.Require(auth.PermissionBasic, http.HandlerFunc(s.geminiGenerateContent))
|
||||
handler := s.requireUser(auth.PermissionBasic, http.HandlerFunc(s.geminiGenerateContent))
|
||||
for _, prefix := range geminiGenerateContentRoutePrefixes {
|
||||
mux.Handle("POST "+prefix, geminiGenerateContentRouteHandler(prefix, handler))
|
||||
}
|
||||
@@ -94,6 +95,11 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
|
||||
return
|
||||
}
|
||||
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", "invalid_idempotency_key")
|
||||
return
|
||||
}
|
||||
prepared, err := s.prepareTaskRequest(r.Context(), r, user, mapping.Body)
|
||||
if err != nil {
|
||||
s.logger.Warn("prepare gemini task request failed", "kind", mapping.Kind, "error", err)
|
||||
@@ -104,7 +110,7 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, status, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
task, err := s.store.CreateTask(r.Context(), store.CreateTaskInput{
|
||||
createInput := store.CreateTaskInput{
|
||||
Kind: mapping.Kind,
|
||||
Model: mapping.Model,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
@@ -113,10 +119,33 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
ConversationID: prepared.ConversationID,
|
||||
NewMessageCount: prepared.NewMessageCount,
|
||||
MessageRefs: prepared.MessageRefs,
|
||||
}, user)
|
||||
}
|
||||
if hasIdempotencyKey {
|
||||
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
|
||||
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(mapping.Kind, false, false, prepared.Body)
|
||||
}
|
||||
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
|
||||
if err != nil {
|
||||
s.logger.Error("create gemini task failed", "kind", mapping.Kind, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "create task failed")
|
||||
if errors.Is(err, store.ErrIdempotencyKeyReused) {
|
||||
writeError(w, http.StatusConflict, "Idempotency-Key was reused for a different request", "idempotency_key_reused")
|
||||
return
|
||||
}
|
||||
s.logger.Error("create gemini task failed", "kind", mapping.Kind, "error_category", "task_create_failed")
|
||||
writeError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
|
||||
return
|
||||
}
|
||||
task := created.Task
|
||||
if created.Replayed {
|
||||
if s.billingMetrics != nil {
|
||||
s.billingMetrics.ObserveBillingEvent("idempotent_replay")
|
||||
}
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
w.Header().Set("X-Gateway-Task-Id", task.ID)
|
||||
if task.Status == "succeeded" {
|
||||
writeJSON(w, http.StatusOK, geminiGenerateContentResponse(task.Result, mapping.Model))
|
||||
return
|
||||
}
|
||||
writeIdempotentTaskReplay(w, task, true)
|
||||
return
|
||||
}
|
||||
runCtx, cancelRun := s.requestExecutionContext(r)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
)
|
||||
|
||||
func TestGeminiGenerateContentModelFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -25,9 +33,9 @@ func TestGeminiGenerateContentModelFromPath(t *testing.T) {
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "bare model path",
|
||||
prefix: "/models/",
|
||||
requestPath: "/models/gemini-image:generateContent",
|
||||
name: "gateway api v1 model",
|
||||
prefix: "/api/v1/models/",
|
||||
requestPath: "/api/v1/models/gemini-image:generateContent",
|
||||
wantModel: "gemini-image",
|
||||
wantOK: true,
|
||||
},
|
||||
@@ -61,6 +69,39 @@ func TestGeminiGenerateContentModelFromPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGeminiGenerateContentRoutes(t *testing.T) {
|
||||
server := &Server{
|
||||
auth: auth.New("test-secret", "", ""),
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/v1/models", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
server.registerGeminiGenerateContentRoutes(mux)
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
status int
|
||||
}{
|
||||
{method: http.MethodGet, path: "/api/v1/models", status: http.StatusNoContent},
|
||||
{method: http.MethodPost, path: "/api/v1/models/gemini-image:generateContent", status: http.StatusUnauthorized},
|
||||
{method: http.MethodPost, path: "/v1/models/gemini-image:generateContent", status: http.StatusUnauthorized},
|
||||
{method: http.MethodPost, path: "/v1beta/models/gemini-image:generateContent", status: http.StatusUnauthorized},
|
||||
{method: http.MethodPost, path: "/models/gemini-image:generateContent", status: http.StatusNotFound},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, httptest.NewRequest(tt.method, tt.path, nil))
|
||||
if response.Code != tt.status {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.Code, tt.status, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiImageTaskBodyMapsTextOnlyToImageGenerate(t *testing.T) {
|
||||
mapping, err := geminiImageTaskBody("gemini-image", map[string]any{
|
||||
"contents": []any{
|
||||
|
||||
@@ -17,6 +17,14 @@ import (
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
postgresReadinessTimeout = 2 * time.Second
|
||||
localLoginStoreTimeout = 5 * time.Second
|
||||
errorCodePostgresDown = "POSTGRES_UNAVAILABLE"
|
||||
errorCodeAuthStoreDown = "AUTH_STORE_UNAVAILABLE"
|
||||
authStoreUnavailableError = "authentication service temporarily unavailable"
|
||||
)
|
||||
|
||||
// health godoc
|
||||
// @Summary 健康检查
|
||||
// @Description 返回服务进程、运行环境和身份模式,供负载均衡或人工排障使用。
|
||||
@@ -42,8 +50,11 @@ func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /readyz [get]
|
||||
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.Ping(r.Context()); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "postgres unavailable")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), postgresReadinessTimeout)
|
||||
defer cancel()
|
||||
if err := s.store.Ping(ctx); err != nil {
|
||||
s.logPostgresUnavailable("postgres readiness check failed")
|
||||
writeError(w, http.StatusServiceUnavailable, "postgres unavailable", errorCodePostgresDown)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
@@ -57,6 +68,8 @@ func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} auth.User
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /api/v1/me [get]
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
@@ -77,7 +90,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.localIdentityEnabled() {
|
||||
if !s.localIdentityEnabled() || !s.ordinaryLocalJWTEnabled() {
|
||||
writeError(w, http.StatusForbidden, "local registration is disabled")
|
||||
return
|
||||
}
|
||||
@@ -109,7 +122,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// login godoc
|
||||
// @Summary 本地登录
|
||||
// @Description 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。
|
||||
// @Description 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。非本地身份模式只允许本地应急 Manager 登录。
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -119,35 +132,72 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.localIdentityEnabled() {
|
||||
writeError(w, http.StatusForbidden, "local login is disabled")
|
||||
return
|
||||
}
|
||||
var input store.LocalLoginInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
user, err := s.store.AuthenticateLocalUser(r.Context(), input)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), localLoginStoreTimeout)
|
||||
defer cancel()
|
||||
user, err := s.store.AuthenticateLocalUser(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidCredentials) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid account or password")
|
||||
return
|
||||
}
|
||||
if store.IsPostgresUnavailable(err) {
|
||||
s.logPostgresUnavailable("login authentication store unavailable")
|
||||
writeError(w, http.StatusServiceUnavailable, authStoreUnavailableError, errorCodeAuthStoreDown)
|
||||
return
|
||||
}
|
||||
s.logger.Error("login local user failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "login failed")
|
||||
return
|
||||
}
|
||||
if !s.localLoginAllowed(user) {
|
||||
writeError(w, http.StatusForbidden, "local login is disabled except for break-glass managers")
|
||||
return
|
||||
}
|
||||
s.writeAuthResponse(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) logPostgresUnavailable(message string) {
|
||||
if s.logger == nil || s.store == nil || s.store.Pool() == nil {
|
||||
return
|
||||
}
|
||||
statistics := s.store.Pool().Stat()
|
||||
s.logger.Error(message,
|
||||
"error_category", "postgres_unavailable",
|
||||
"postgres_pool_max_connections", statistics.MaxConns(),
|
||||
"postgres_pool_total_connections", statistics.TotalConns(),
|
||||
"postgres_pool_acquired_connections", statistics.AcquiredConns(),
|
||||
"postgres_pool_idle_connections", statistics.IdleConns(),
|
||||
"postgres_pool_empty_acquire_count", statistics.EmptyAcquireCount(),
|
||||
"postgres_pool_canceled_acquire_count", statistics.CanceledAcquireCount(),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) localIdentityEnabled() bool {
|
||||
mode := strings.ToLower(strings.TrimSpace(s.cfg.IdentityMode))
|
||||
return mode == "" || mode == "standalone" || mode == "hybrid"
|
||||
}
|
||||
|
||||
func (s *Server) localLoginAllowed(user store.GatewayUser) bool {
|
||||
for _, role := range user.Roles {
|
||||
if role == "manager" || role == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return s.localIdentityEnabled() && s.ordinaryLocalJWTEnabled()
|
||||
}
|
||||
|
||||
func (s *Server) ordinaryLocalJWTEnabled() bool {
|
||||
return s.identityRuntime == nil || s.identityRuntime.LegacyJWTEnabled()
|
||||
}
|
||||
|
||||
func (s *Server) writeAuthResponse(w http.ResponseWriter, status int, user store.GatewayUser) {
|
||||
authUser := authUserFromGatewayUser(user)
|
||||
const ttl = 24 * time.Hour
|
||||
@@ -414,6 +464,10 @@ func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
model, err := s.store.CreatePlatformModel(r.Context(), input)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
|
||||
return
|
||||
}
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "base model not found")
|
||||
return
|
||||
@@ -458,6 +512,10 @@ func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
models, err := s.store.ReplacePlatformModels(r.Context(), platformID, input.Models)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
|
||||
return
|
||||
}
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "base model not found")
|
||||
return
|
||||
@@ -630,6 +688,8 @@ func (s *Server) listUserGroups(w http.ResponseWriter, r *http.Request) {
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} UserGroupListResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/user-groups [get]
|
||||
func (s *Server) listCurrentUserGroups(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -669,6 +729,8 @@ func compactAuthStrings(values ...string) []string {
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} APIKeyListResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/api-keys [get]
|
||||
func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -689,8 +751,9 @@ func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} PlayableAPIKeyListResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/playground/api-keys [get]
|
||||
func (s *Server) listPlayableAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -698,7 +761,7 @@ func (s *Server) listPlayableAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListPlayableAPIKeys(r.Context(), user)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
s.logger.Error("list playable api keys failed", "error", err)
|
||||
@@ -719,6 +782,8 @@ func (s *Server) listPlayableAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
// @Success 201 {object} store.CreatedAPIKey
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/api-keys [post]
|
||||
func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -731,7 +796,7 @@ func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
created, err := s.store.CreateAPIKey(r.Context(), input, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
s.logger.Error("create api key failed", "error", err)
|
||||
@@ -768,7 +833,11 @@ func (s *Server) updateAPIKeyScopes(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrLocalUserRequired) || errors.Is(err, store.ErrInvalidAPIKeyScopes) {
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrInvalidAPIKeyScopes) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -801,7 +870,7 @@ func (s *Server) disableAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
if store.IsNotFound(err) {
|
||||
@@ -833,7 +902,7 @@ func (s *Server) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
if store.IsNotFound(err) {
|
||||
@@ -858,6 +927,7 @@ func (s *Server) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 429 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/pricing/estimate [post]
|
||||
func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -882,10 +952,24 @@ func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
estimate, err := s.runner.Estimate(r.Context(), kind, model, body, user)
|
||||
if err != nil {
|
||||
if s.billingMetrics != nil {
|
||||
s.billingMetrics.ObserveBillingEvent("estimate_failed")
|
||||
}
|
||||
if runner.IsPricingUnavailable(err) {
|
||||
if s.billingMetrics != nil {
|
||||
s.billingMetrics.ObserveBillingEvent("pricing_unavailable")
|
||||
}
|
||||
writeErrorWithDetails(w, http.StatusServiceUnavailable, runErrorMessage(err), runErrorDetails(err), "pricing_unavailable")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrNoModelCandidate) {
|
||||
writeErrorWithDetails(w, statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), store.ModelCandidateErrorCode(err))
|
||||
return
|
||||
}
|
||||
if code := clients.ErrorCode(err); code == "bad_request" || code == "invalid_parameter" {
|
||||
writeErrorWithDetails(w, http.StatusBadRequest, runErrorMessage(err), runErrorDetails(err), code)
|
||||
return
|
||||
}
|
||||
s.logger.Error("estimate pricing failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "estimate pricing failed")
|
||||
return
|
||||
@@ -943,6 +1027,7 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
|
||||
// @Param Idempotency-Key header string false "可选请求幂等键;同一用户范围内唯一"
|
||||
// @Param input body TaskRequest true "AI 任务请求,字段随任务类型变化"
|
||||
// @Success 200 {object} CompatibleResponse
|
||||
// @Success 202 {object} TaskAcceptedResponse
|
||||
@@ -953,7 +1038,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 429 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /api/v1/responses [post]
|
||||
// @Router /api/v1/embeddings [post]
|
||||
// @Router /api/v1/reranks [post]
|
||||
// @Router /api/v1/images/generations [post]
|
||||
@@ -963,10 +1047,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
|
||||
// @Router /api/v1/music/generations [post]
|
||||
// @Router /api/v1/speech/generations [post]
|
||||
// @Router /api/v1/voice_clone [post]
|
||||
// @Router /chat/completions [post]
|
||||
// @Router /v1/chat/completions [post]
|
||||
// @Router /responses [post]
|
||||
// @Router /v1/responses [post]
|
||||
// @Router /embeddings [post]
|
||||
// @Router /v1/embeddings [post]
|
||||
// @Router /reranks [post]
|
||||
@@ -1000,6 +1080,12 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
writeError(w, status, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
if kind == "chat.completions" || kind == "responses" {
|
||||
if err := clients.ValidateOpenAIRequestParameters(kind, body); err != nil {
|
||||
writeErrorWithDetails(w, http.StatusBadRequest, err.Error(), map[string]any{"param": clients.ErrorParam(err)}, clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
model := requestModelName(body)
|
||||
if model == "" {
|
||||
writeError(w, http.StatusBadRequest, "model is required")
|
||||
@@ -1010,6 +1096,11 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return
|
||||
}
|
||||
responsePlan := planTaskResponse(kind, compatible, body, r)
|
||||
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Idempotency-Key must contain one non-empty value", "invalid_idempotency_key")
|
||||
return
|
||||
}
|
||||
prepared, err := s.prepareTaskRequest(r.Context(), r, user, body)
|
||||
if err != nil {
|
||||
s.logger.Warn("prepare task request failed", "kind", kind, "error", err)
|
||||
@@ -1021,7 +1112,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
task, err := s.store.CreateTask(r.Context(), store.CreateTaskInput{
|
||||
createInput := store.CreateTaskInput{
|
||||
Kind: kind,
|
||||
Model: model,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
@@ -1030,10 +1121,32 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
ConversationID: prepared.ConversationID,
|
||||
NewMessageCount: prepared.NewMessageCount,
|
||||
MessageRefs: prepared.MessageRefs,
|
||||
}, user)
|
||||
}
|
||||
if hasIdempotencyKey {
|
||||
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
|
||||
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(kind, responsePlan.asyncMode, responsePlan.streamMode, prepared.Body)
|
||||
}
|
||||
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
|
||||
if err != nil {
|
||||
s.logger.Error("create task failed", "kind", kind, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "create task failed")
|
||||
if errors.Is(err, store.ErrIdempotencyKeyReused) {
|
||||
writeError(w, http.StatusConflict, "Idempotency-Key was reused for a different request", "idempotency_key_reused")
|
||||
return
|
||||
}
|
||||
s.logger.Error("create task failed", "kind", kind, "error_category", "task_create_failed")
|
||||
writeError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
|
||||
return
|
||||
}
|
||||
task := created.Task
|
||||
if created.Replayed {
|
||||
if s.billingMetrics != nil {
|
||||
s.billingMetrics.ObserveBillingEvent("idempotent_replay")
|
||||
}
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
if responsePlan.streamMode {
|
||||
writeError(w, http.StatusConflict, "streaming idempotent replay is not supported", "idempotency_stream_replay_unsupported")
|
||||
return
|
||||
}
|
||||
writeIdempotentTaskReplay(w, task, responsePlan.compatibleMode)
|
||||
return
|
||||
}
|
||||
if responsePlan.asyncMode {
|
||||
@@ -1071,7 +1184,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
// @Produce text/event-stream
|
||||
// @Security BearerAuth
|
||||
// @Param X-Async header bool false "该接口忽略此参数"
|
||||
// @Param input body TaskRequest true "Chat Completions 请求"
|
||||
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
|
||||
// @Success 200 {object} ChatCompletionCompatibleResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
@@ -1085,6 +1198,47 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
|
||||
return s.createTask("chat.completions", false)
|
||||
}
|
||||
|
||||
// openAIChatCompletionsDoc godoc
|
||||
// @Summary 创建 OpenAI Chat Completions
|
||||
// @Description OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter。
|
||||
// @Tags chat
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Produce text/event-stream
|
||||
// @Security BearerAuth
|
||||
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
|
||||
// @Success 200 {object} ChatCompletionCompatibleResponse
|
||||
// @Failure 400 {object} ErrorEnvelope "invalid_parameter"
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 402 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 429 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /chat/completions [post]
|
||||
// @Router /v1/chat/completions [post]
|
||||
func openAIChatCompletionsDoc() {}
|
||||
|
||||
// openAIResponsesDoc godoc
|
||||
// @Summary 创建 OpenAI Responses
|
||||
// @Description 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 previous_response_id 时由调用方管理完整状态,Gateway 以本轮 input/messages 为准且不追加本地历史。
|
||||
// @Tags responses
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Produce text/event-stream
|
||||
// @Security BearerAuth
|
||||
// @Param input body ResponsesRequest true "Responses 请求;Chat 回退只支持自定义 function tools"
|
||||
// @Success 200 {object} ResponsesCompatibleResponse
|
||||
// @Header 200 {string} X-Gateway-Task-Id "网关审计任务 ID"
|
||||
// @Failure 400 {object} ErrorEnvelope "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter"
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 402 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
|
||||
// @Router /responses [post]
|
||||
// @Router /v1/responses [post]
|
||||
// @Router /api/v1/responses [post]
|
||||
func openAIResponsesDoc() {}
|
||||
|
||||
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
|
||||
base := context.WithoutCancel(r.Context())
|
||||
if s.ctx == nil {
|
||||
@@ -1116,6 +1270,7 @@ type taskExecutor interface {
|
||||
}
|
||||
|
||||
func writeCompatibleTaskResponse(runCtx context.Context, w http.ResponseWriter, r *http.Request, executor taskExecutor, kind string, model string, task store.GatewayTask, user *auth.User, streamMode bool, includeUsage bool) {
|
||||
w.Header().Set("X-Gateway-Task-Id", task.ID)
|
||||
if streamMode {
|
||||
flusher := prepareCompatibleStream(w)
|
||||
streamWriter := newCompatibleStreamWriter(kind, model, includeUsage)
|
||||
@@ -1306,8 +1461,20 @@ func scopeForTaskKind(kind string) string {
|
||||
|
||||
func statusFromRunError(err error) int {
|
||||
switch {
|
||||
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy":
|
||||
case clients.ErrorCode(err) == "billing_hold":
|
||||
return http.StatusServiceUnavailable
|
||||
case runner.IsPricingUnavailable(err):
|
||||
return http.StatusServiceUnavailable
|
||||
case clients.ErrorCode(err) == "invalid_previous_response_id" || clients.ErrorCode(err) == "response_chain_too_deep" || clients.ErrorCode(err) == "unsupported_model_protocol" || clients.ErrorCode(err) == "unsupported_response_tool" || clients.ErrorCode(err) == "unsupported_response_parameter":
|
||||
return http.StatusBadRequest
|
||||
case clients.ErrorCode(err) == "response_chain_unavailable":
|
||||
return http.StatusServiceUnavailable
|
||||
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "invalid_parameter" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy":
|
||||
return http.StatusBadRequest
|
||||
case store.ModelCandidateErrorCode(err) == "invalid_parameter":
|
||||
return http.StatusBadRequest
|
||||
case store.ModelCandidateErrorCode(err) == "model_capability_configuration_error":
|
||||
return http.StatusInternalServerError
|
||||
case clients.ErrorCode(err) == "cloned_voice_not_found":
|
||||
return http.StatusNotFound
|
||||
case store.ModelCandidateErrorCode(err) == "platform_cooling_down" || store.ModelCandidateErrorCode(err) == "model_cooling_down":
|
||||
@@ -1326,6 +1493,9 @@ func statusFromRunError(err error) int {
|
||||
}
|
||||
|
||||
func runErrorCode(err error) string {
|
||||
if runner.IsPricingUnavailable(err) {
|
||||
return "pricing_unavailable"
|
||||
}
|
||||
if errors.Is(err, store.ErrNoModelCandidate) {
|
||||
return store.ModelCandidateErrorCode(err)
|
||||
}
|
||||
@@ -1343,6 +1513,9 @@ func runErrorMessage(err error) string {
|
||||
}
|
||||
|
||||
func runErrorDetails(err error) map[string]any {
|
||||
if detail := runner.PricingUnavailableDetails(err); len(detail) > 0 {
|
||||
return map[string]any{"pricing": detail}
|
||||
}
|
||||
if detail := rateLimitErrorDetail(err); len(detail) > 0 {
|
||||
return map[string]any{"rateLimit": detail}
|
||||
}
|
||||
@@ -1482,6 +1655,8 @@ func matchedRateLimitRule(policy map[string]any, metric string) map[string]any {
|
||||
// @Success 200 {object} TaskListResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks [get]
|
||||
// @Router /api/v1/tasks [get]
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type identityConfigurationView struct {
|
||||
Active *identity.Revision `json:"active"`
|
||||
Draft *identity.Revision `json:"draft"`
|
||||
Previous *identity.Revision `json:"previous"`
|
||||
Pairing *identity.PairingExchange `json:"pairing,omitempty"`
|
||||
Runtime identityRuntimeStatus `json:"runtime"`
|
||||
}
|
||||
|
||||
type identityRuntimeStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RevisionID string `json:"revisionId,omitempty"`
|
||||
Login string `json:"login"`
|
||||
JIT string `json:"jit"`
|
||||
TokenIntrospection string `json:"tokenIntrospection"`
|
||||
SessionRevocation string `json:"sessionRevocation"`
|
||||
LastTraceID string `json:"lastTraceId,omitempty"`
|
||||
LastAuditID string `json:"lastAuditId,omitempty"`
|
||||
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
|
||||
}
|
||||
|
||||
type publicIdentityConfiguration struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
OIDCLogin bool `json:"oidcLogin"`
|
||||
LoginURL string `json:"loginUrl,omitempty"`
|
||||
LogoutURL string `json:"logoutUrl,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type identityPolicyPatch struct {
|
||||
LocalTenantKey *string `json:"localTenantKey"`
|
||||
RolePrefix *string `json:"rolePrefix"`
|
||||
JITEnabled *bool `json:"jitEnabled"`
|
||||
LegacyJWTEnabled *bool `json:"legacyJwtEnabled"`
|
||||
SessionIdleSeconds *int `json:"sessionIdleSeconds"`
|
||||
SessionAbsoluteSeconds *int `json:"sessionAbsoluteSeconds"`
|
||||
SessionRefreshSeconds *int `json:"sessionRefreshSeconds"`
|
||||
}
|
||||
|
||||
type storedIdentityResponse struct {
|
||||
Status int `json:"status"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
AuditID string `json:"auditId,omitempty"`
|
||||
}
|
||||
|
||||
type identityWriteOperation struct {
|
||||
operation, key, requestHash string
|
||||
release func()
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type identityPairingWorker struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (operation *identityWriteOperation) close() {
|
||||
if operation != nil {
|
||||
operation.once.Do(operation.release)
|
||||
}
|
||||
}
|
||||
|
||||
// getPublicIdentityConfiguration godoc
|
||||
// @Summary 获取公开统一认证状态
|
||||
// @Description 返回 Web 运行时需要的启用状态和非敏感登录、退出入口,不返回 Client、Secret 或内部标识。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Success 200 {object} publicIdentityConfiguration
|
||||
// @Router /api/v1/public/identity [get]
|
||||
func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if runtime == nil || runtime.Revision.State != identity.RevisionActive {
|
||||
writeJSON(w, http.StatusOK, publicIdentityConfiguration{Status: "disabled"})
|
||||
return
|
||||
}
|
||||
view := publicIdentityConfiguration{Enabled: true, Status: "active"}
|
||||
if oidcRuntimeReady(runtime) {
|
||||
view.OIDCLogin = true
|
||||
view.LoginURL = "/api/v1/auth/oidc/login"
|
||||
view.LogoutURL = "/api/v1/auth/oidc/logout"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// getIdentityConfiguration godoc
|
||||
// @Summary 获取统一认证配置
|
||||
// @Description 返回 Active、Draft、Previous Revision、配对进度和脱敏运行时健康状态。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} identityConfigurationView
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/configuration [get]
|
||||
func (s *Server) getIdentityConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
view := identityConfigurationView{Runtime: s.identityRuntimeStatus(r)}
|
||||
if revision, err := s.store.ActiveIdentityConfigurationRevision(r.Context()); err == nil {
|
||||
view.Active = &revision
|
||||
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
|
||||
writeError(w, http.StatusServiceUnavailable, "统一认证配置暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
|
||||
return
|
||||
}
|
||||
if revision, err := s.store.LatestInactiveIdentityConfigurationRevision(r.Context()); err == nil {
|
||||
view.Draft = &revision
|
||||
if pairing, pairingErr := s.store.IdentityPairingExchangeForRevision(r.Context(), revision.ID); pairingErr == nil {
|
||||
view.Pairing = &pairing
|
||||
}
|
||||
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
|
||||
writeError(w, http.StatusServiceUnavailable, "统一认证草稿暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
|
||||
return
|
||||
}
|
||||
if revision, err := s.store.LatestSupersededIdentityConfigurationRevision(r.Context()); err == nil {
|
||||
view.Previous = &revision
|
||||
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
|
||||
writeError(w, http.StatusServiceUnavailable, "统一认证历史版本暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// startIdentityPairing godoc
|
||||
// @Summary 启动统一认证配对
|
||||
// @Description 使用一次性接入码创建 Draft 和异步 Exchange。接入码仅允许出现在请求 Body。
|
||||
// @Tags identity
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "首次配对固定为 W/\"0\""
|
||||
// @Param body body identity.PairingInput true "配对参数"
|
||||
// @Success 202 {object} identity.PairingExchange
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/pairings [post]
|
||||
func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||
var input identity.PairingInput
|
||||
if !decodeIdentityRequest(w, r, &input) {
|
||||
return
|
||||
}
|
||||
operation, ok := s.beginIdentityWrite(w, r, "pairing.start", 0, input)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.start", "pending", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairing, err := s.identityPairing.Start(r.Context(), input, traceID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "pairing.start", "", traceID, err)
|
||||
return
|
||||
}
|
||||
pairing.AuthCenterAuditID = ""
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
|
||||
s.startIdentityPairingWorker(pairing.ID)
|
||||
}
|
||||
|
||||
// getIdentityPairing godoc
|
||||
// @Summary 查询统一认证配对状态
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param pairingID path string true "配对 ID"
|
||||
// @Success 200 {object} identity.PairingExchange
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/pairings/{pairingID} [get]
|
||||
func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
pairing, err := s.store.IdentityPairingExchange(r.Context(), r.PathValue("pairingID"))
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "pairing.get", r.PathValue("pairingID"), ensureIdentityTraceID(w, r), err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", identityETag(pairing.Version))
|
||||
writeJSON(w, http.StatusOK, pairing)
|
||||
}
|
||||
|
||||
// cancelIdentityPairing godoc
|
||||
// @Summary 放弃本地统一认证配对
|
||||
// @Description 原子封存未激活 Revision,并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端 Exchange;下一次凭据交付会轮换机器凭据。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param pairingID path string true "配对 ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Pairing ETag"
|
||||
// @Success 202 {object} identity.PairingExchange
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/pairings/{pairingID}/cancel [post]
|
||||
func (s *Server) cancelIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairingID := r.PathValue("pairingID")
|
||||
operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.cancel", expectedVersion, struct {
|
||||
PairingID string `json:"pairingId"`
|
||||
}{PairingID: pairingID})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.cancel", pairingID, traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairing, err := s.identityPairing.Cancel(r.Context(), pairingID, expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "pairing.cancel", pairingID, traceID, err)
|
||||
return
|
||||
}
|
||||
done := s.stopIdentityPairingWorker(pairing.ID)
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
|
||||
s.startIdentityPairingCleanupWorker(pairing.ID, done)
|
||||
}
|
||||
|
||||
// retireIdentityPairingSecurityEventConflict godoc
|
||||
// @Summary 安全退役阻塞配对的旧 SSF 连接
|
||||
// @Description 仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision 的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param pairingID path string true "配对 ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Pairing ETag"
|
||||
// @Success 202 {object} identity.PairingExchange
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event [post]
|
||||
func (s *Server) retireIdentityPairingSecurityEventConflict(w http.ResponseWriter, r *http.Request) {
|
||||
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairingID := r.PathValue("pairingID")
|
||||
operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.retire_security_event_conflict", expectedVersion, struct {
|
||||
PairingID string `json:"pairingId"`
|
||||
}{PairingID: pairingID})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.retire_security_event_conflict", pairingID, traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pairing, err := s.identityPairing.RetireConflictingSecurityEvents(r.Context(), pairingID, expectedVersion)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "pairing.retire_security_event_conflict", pairingID, traceID, err)
|
||||
return
|
||||
}
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
|
||||
s.startIdentityPairingWorker(pairing.ID)
|
||||
}
|
||||
|
||||
// updateIdentityDraftPolicy godoc
|
||||
// @Summary 修改统一认证 Draft 策略
|
||||
// @Tags identity
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param revisionID path string true "Revision ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Revision ETag"
|
||||
// @Param body body identityPolicyPatch true "Gateway 本地策略"
|
||||
// @Success 200 {object} identity.Revision
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/revisions/{revisionID} [patch]
|
||||
func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
var patch identityPolicyPatch
|
||||
if !decodeIdentityRequest(w, r, &patch) {
|
||||
return
|
||||
}
|
||||
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.policy", expectedVersion, patch)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer operation.close()
|
||||
revision, err := s.store.IdentityConfigurationRevision(r.Context(), r.PathValue("revisionID"))
|
||||
if err != nil || revision.Version != expectedVersion {
|
||||
s.writeIdentityError(w, r, "revision.policy", r.PathValue("revisionID"), ensureIdentityTraceID(w, r), firstIdentityError(err, identity.ErrRevisionConflict))
|
||||
return
|
||||
}
|
||||
policy := identity.RevisionPolicy{
|
||||
LocalTenantKey: revision.LocalTenantKey, RolePrefix: revision.RolePrefix, JITEnabled: revision.JITEnabled,
|
||||
LegacyJWTEnabled: revision.LegacyJWTEnabled, SessionIdleSeconds: revision.SessionIdleSeconds,
|
||||
SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds,
|
||||
}
|
||||
applyIdentityPolicyPatch(&policy, patch)
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.policy", revision.ID, traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
updated, err := s.store.UpdateIdentityRevisionPolicy(r.Context(), revision.ID, expectedVersion, policy)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "revision.policy", revision.ID, traceID, err)
|
||||
return
|
||||
}
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusOK, updated, updated.Version, auditID)
|
||||
}
|
||||
|
||||
// validateIdentityRevision godoc
|
||||
// @Summary 验证统一认证 Revision
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param revisionID path string true "Revision ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Revision ETag"
|
||||
// @Success 200 {object} identity.Revision
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/revisions/{revisionID}/validate [post]
|
||||
func (s *Server) validateIdentityRevision(w http.ResponseWriter, r *http.Request) {
|
||||
s.runIdentityRevisionAction(w, r, "revision.validate", func(expected int64, traceID, auditID string) (identity.Revision, error) {
|
||||
return s.identityRuntime.Validate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
|
||||
})
|
||||
}
|
||||
|
||||
// activateIdentityRevision godoc
|
||||
// @Summary 激活统一认证 Revision
|
||||
// @Description 在完整候选 Runtime 验证成功且本地 Break-glass Manager 可用后原子热切换。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param revisionID path string true "Revision ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Revision ETag"
|
||||
// @Success 200 {object} identity.Revision
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/revisions/{revisionID}/activate [post]
|
||||
func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request) {
|
||||
s.runIdentityRevisionAction(w, r, "revision.activate", func(expected int64, traceID, auditID string) (identity.Revision, error) {
|
||||
return s.identityRuntime.Activate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
|
||||
})
|
||||
}
|
||||
|
||||
// rollbackIdentityRevision godoc
|
||||
// @Summary 请求恢复历史统一认证 Revision
|
||||
// @Description 当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param revisionID path string true "Revision ID"
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Revision ETag"
|
||||
// @Success 200 {object} identity.Revision
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/revisions/{revisionID}/rollback [post]
|
||||
func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request) {
|
||||
s.runIdentityRevisionAction(w, r, "revision.rollback", func(expected int64, traceID, auditID string) (identity.Revision, error) {
|
||||
return s.identityRuntime.Rollback(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
|
||||
})
|
||||
}
|
||||
|
||||
// disableIdentityConfiguration godoc
|
||||
// @Summary 禁用统一认证
|
||||
// @Description 将 Revision 转为只读 Superseded 审计历史,清理 BFF Session,并继续允许本地管理登录;重新接入必须使用新的接入码。
|
||||
// @Tags identity
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param Idempotency-Key header string true "幂等键"
|
||||
// @Param If-Match header string true "当前 Active Revision ETag"
|
||||
// @Success 200 {object} identity.Revision
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Failure 412 {object} ErrorEnvelope
|
||||
// @Failure 428 {object} ErrorEnvelope
|
||||
// @Router /api/admin/system/identity/disable [post]
|
||||
func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.disable", expectedVersion, struct{}{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.disable", "active", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
disabled, err := s.identityRuntime.Disable(r.Context(), expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, "revision.disable", "active", traceID, err)
|
||||
return
|
||||
}
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusOK, disabled, disabled.Version, auditID)
|
||||
}
|
||||
|
||||
func (s *Server) runIdentityRevisionAction(w http.ResponseWriter, r *http.Request, action string, run func(int64, string, string) (identity.Revision, error)) {
|
||||
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
operation, ok := s.beginIdentityWriteWithVersion(w, r, action, expectedVersion, struct{}{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer operation.close()
|
||||
traceID := ensureIdentityTraceID(w, r)
|
||||
auditID, ok := s.requireIdentityConfigurationAudit(w, r, action, r.PathValue("revisionID"), traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
revision, err := run(expectedVersion, traceID, auditID)
|
||||
if err != nil {
|
||||
s.writeIdentityError(w, r, action, r.PathValue("revisionID"), traceID, err)
|
||||
return
|
||||
}
|
||||
s.completeIdentityWrite(w, r, operation, http.StatusOK, revision, revision.Version, auditID)
|
||||
}
|
||||
|
||||
func (s *Server) identityRuntimeStatus(r *http.Request) identityRuntimeStatus {
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if runtime == nil {
|
||||
return identityRuntimeStatus{Login: "disabled", JIT: "disabled", TokenIntrospection: "disabled", SessionRevocation: "disabled"}
|
||||
}
|
||||
revision := runtime.Revision
|
||||
status := identityRuntimeStatus{
|
||||
Enabled: true, RevisionID: revision.ID, Login: capabilityHealth(runtime.PublicClient != nil),
|
||||
JIT: capabilityHealth(revision.JITEnabled), TokenIntrospection: capabilityHealth(revision.TokenIntrospection),
|
||||
SessionRevocation: capabilityHealth(revision.SessionRevocation), LastTraceID: revision.LastTraceID,
|
||||
LastAuditID: revision.LastAuditID, LastErrorCategory: revision.LastErrorCategory,
|
||||
}
|
||||
if revision.SessionRevocation && runtime.SecurityEvents != nil {
|
||||
if connection, err := runtime.SecurityEvents.Get(r.Context()); err == nil && connection.LifecycleStatus != "active" {
|
||||
status.SessionRevocation = connection.HealthMode
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func capabilityHealth(enabled bool) string {
|
||||
if enabled {
|
||||
return "healthy"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
|
||||
func decodeIdentityRequest(w http.ResponseWriter, r *http.Request, output any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(output); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST")
|
||||
return false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) beginIdentityWrite(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) {
|
||||
if parsed, ok := requiredIdentityVersion(w, r); !ok || parsed != version {
|
||||
if ok {
|
||||
writeError(w, http.StatusPreconditionFailed, "统一认证配置版本已变化", "IDENTITY_VERSION_CONFLICT")
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return s.beginIdentityWriteWithVersion(w, r, operation, version, request)
|
||||
}
|
||||
|
||||
func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) {
|
||||
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
if key == "" || len(key) > 255 {
|
||||
writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "IDEMPOTENCY_KEY_REQUIRED")
|
||||
return nil, false
|
||||
}
|
||||
requestHash := identityRequestHash(operation, version, request)
|
||||
s.identityManagementMu.Lock()
|
||||
write := &identityWriteOperation{operation: operation, key: key, requestHash: requestHash, release: s.identityManagementMu.Unlock}
|
||||
if s.store == nil {
|
||||
return write, true
|
||||
}
|
||||
recorded, err := s.store.IdentityManagementRequest(r.Context(), operation, key)
|
||||
if errors.Is(err, store.ErrIdentityManagementRequestNotFound) {
|
||||
return write, true
|
||||
}
|
||||
if err != nil {
|
||||
write.close()
|
||||
writeError(w, http.StatusServiceUnavailable, "幂等状态暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE")
|
||||
return nil, false
|
||||
}
|
||||
if recorded.RequestHash != requestHash {
|
||||
write.close()
|
||||
writeError(w, http.StatusConflict, "Idempotency-Key 已用于其他请求", "IDEMPOTENCY_KEY_REUSED")
|
||||
return nil, false
|
||||
}
|
||||
var response storedIdentityResponse
|
||||
if json.Unmarshal(recorded.Response, &response) != nil {
|
||||
write.close()
|
||||
writeError(w, http.StatusServiceUnavailable, "幂等响应暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE")
|
||||
return nil, false
|
||||
}
|
||||
write.close()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
if response.ETag != "" {
|
||||
w.Header().Set("ETag", response.ETag)
|
||||
}
|
||||
if response.AuditID != "" {
|
||||
w.Header().Set("X-Audit-Id", response.AuditID)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(response.Status)
|
||||
_, _ = w.Write(response.Body)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func identityRequestHash(operation string, version int64, request any) string {
|
||||
encoded, _ := json.Marshal(request)
|
||||
digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...))
|
||||
return fmt.Sprintf("%x", digest[:])
|
||||
}
|
||||
|
||||
func (s *Server) completeIdentityWrite(w http.ResponseWriter, r *http.Request, operation *identityWriteOperation, status int, payload any, version int64, auditID string) {
|
||||
body, _ := json.Marshal(payload)
|
||||
stored, _ := json.Marshal(storedIdentityResponse{Status: status, Body: body, ETag: identityETag(version), AuditID: auditID})
|
||||
if s.store != nil {
|
||||
if err := s.store.RecordIdentityManagementRequest(r.Context(), store.IdentityManagementRequest{
|
||||
Operation: operation.operation, Key: operation.key, RequestHash: operation.requestHash, Response: stored,
|
||||
}); err != nil && s.logger != nil {
|
||||
s.logger.ErrorContext(r.Context(), "identity idempotency response could not be recorded", "operation", operation.operation, "error_category", "idempotency_store_failed")
|
||||
}
|
||||
}
|
||||
operation.close()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", identityETag(version))
|
||||
if auditID != "" {
|
||||
w.Header().Set("X-Audit-Id", auditID)
|
||||
}
|
||||
writeJSON(w, status, payload)
|
||||
}
|
||||
|
||||
func requiredIdentityVersion(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
value := strings.Trim(strings.TrimPrefix(strings.TrimSpace(r.Header.Get("If-Match")), "W/"), `"`)
|
||||
version, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || version < 0 {
|
||||
writeError(w, http.StatusPreconditionRequired, "If-Match is required", "IF_MATCH_REQUIRED")
|
||||
return 0, false
|
||||
}
|
||||
return version, true
|
||||
}
|
||||
|
||||
func identityETag(version int64) string { return fmt.Sprintf(`W/"%d"`, version) }
|
||||
|
||||
func applyIdentityPolicyPatch(policy *identity.RevisionPolicy, patch identityPolicyPatch) {
|
||||
if patch.LocalTenantKey != nil {
|
||||
policy.LocalTenantKey = strings.TrimSpace(*patch.LocalTenantKey)
|
||||
}
|
||||
if patch.RolePrefix != nil {
|
||||
policy.RolePrefix = strings.TrimSpace(*patch.RolePrefix)
|
||||
}
|
||||
if patch.JITEnabled != nil {
|
||||
policy.JITEnabled = *patch.JITEnabled
|
||||
}
|
||||
if patch.LegacyJWTEnabled != nil {
|
||||
policy.LegacyJWTEnabled = *patch.LegacyJWTEnabled
|
||||
}
|
||||
if patch.SessionIdleSeconds != nil {
|
||||
policy.SessionIdleSeconds = *patch.SessionIdleSeconds
|
||||
}
|
||||
if patch.SessionAbsoluteSeconds != nil {
|
||||
policy.SessionAbsoluteSeconds = *patch.SessionAbsoluteSeconds
|
||||
}
|
||||
if patch.SessionRefreshSeconds != nil {
|
||||
policy.SessionRefreshSeconds = *patch.SessionRefreshSeconds
|
||||
}
|
||||
}
|
||||
|
||||
func firstIdentityError(actual, fallback error) error {
|
||||
if actual != nil {
|
||||
return actual
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func ensureIdentityTraceID(w http.ResponseWriter, r *http.Request) string {
|
||||
return ensureSecurityEventTraceID(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) writeIdentityError(w http.ResponseWriter, r *http.Request, action, targetID, traceID string, err error) {
|
||||
status, message, code := identityErrorProjection(err)
|
||||
s.recordIdentityConfigurationAudit(r, action, targetID, "failure", traceID, code)
|
||||
writeError(w, status, message, code)
|
||||
}
|
||||
|
||||
func identityErrorProjection(err error) (int, string, string) {
|
||||
var categorized interface{ SafeErrorCategory() string }
|
||||
safeCategory := ""
|
||||
if errors.As(err, &categorized) {
|
||||
safeCategory = categorized.SafeErrorCategory()
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, identity.ErrRevisionNotFound):
|
||||
return http.StatusNotFound, "统一认证配置不存在", "IDENTITY_CONFIGURATION_NOT_FOUND"
|
||||
case errors.Is(err, identity.ErrRevisionConflict):
|
||||
return http.StatusPreconditionFailed, "统一认证配置版本或状态已变化", "IDENTITY_VERSION_CONFLICT"
|
||||
case errors.Is(err, identity.ErrBreakGlassRequired):
|
||||
return http.StatusConflict, "请先保留至少一个可用的本地应急管理员凭据", "BREAK_GLASS_MANAGER_REQUIRED"
|
||||
case errors.Is(err, identity.ErrLocalTenantInvalid):
|
||||
return http.StatusConflict, "本地租户映射无效", "IDENTITY_LOCAL_TENANT_INVALID"
|
||||
case errors.Is(err, identity.ErrPairingInProgress):
|
||||
return http.StatusConflict, "请先完成或放弃当前统一认证配对", "IDENTITY_PAIRING_IN_PROGRESS"
|
||||
case errors.Is(err, identity.ErrActiveConfigurationHandoffRequired):
|
||||
return http.StatusConflict, "当前仍有 Active 统一认证配置;请先禁用,再使用新接入码配对", "IDENTITY_ACTIVE_CONFIGURATION_HANDOFF_REQUIRED"
|
||||
case errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired):
|
||||
return http.StatusConflict, "旧版本关联的远端 OAuth/SSF 资源可能已变化;请禁用后使用新接入码恢复", "IDENTITY_ROLLBACK_CONFIGURATION_HANDOFF_REQUIRED"
|
||||
case errors.Is(err, identity.ErrSecurityEventRetirementPending):
|
||||
return http.StatusConflict, "旧安全事件 Stream 尚未完成断开;系统会继续重试,请稍后再次禁用", "IDENTITY_SECURITY_EVENT_RETIREMENT_PENDING"
|
||||
case errors.Is(err, identity.ErrPairingNotCancellable):
|
||||
return http.StatusConflict, "当前统一认证配置不能放弃", "IDENTITY_PAIRING_NOT_CANCELLABLE"
|
||||
case errors.Is(err, identity.ErrPairingConflictNotResolvable):
|
||||
return http.StatusConflict, "当前配对已不需要退役旧安全事件连接,请刷新状态", "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE"
|
||||
case safeCategory == "credential_handoff_unsafe":
|
||||
return http.StatusConflict, "旧安全事件连接与本次认证中心不匹配,系统已拒绝发送凭据;请先在原配置下断开旧连接", "IDENTITY_SECURITY_EVENT_HANDOFF_UNSAFE"
|
||||
case err != nil && (strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required")):
|
||||
return http.StatusBadRequest, "统一认证配置无效", "IDENTITY_CONFIGURATION_INVALID"
|
||||
default:
|
||||
return http.StatusBadGateway, "认证中心或统一认证服务暂时不可用", "IDENTITY_SERVICE_UNAVAILABLE"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) recordIdentityConfigurationAudit(r *http.Request, action, targetID, outcome, traceID, errorCategory string) string {
|
||||
if s.store == nil {
|
||||
return ""
|
||||
}
|
||||
actor, _ := auth.UserFromContext(r.Context())
|
||||
input := store.AuditLogInput{
|
||||
Category: "identity", Action: "identity." + action, TargetType: "identity_configuration_revision",
|
||||
TargetID: firstNonEmptyText(targetID, "pending"), RequestIP: limitAuditText(requestIP(r), 128),
|
||||
UserAgent: limitAuditText(r.UserAgent(), 512), Metadata: map[string]any{
|
||||
"outcome": outcome, "traceId": traceID, "errorCategory": errorCategory,
|
||||
},
|
||||
}
|
||||
if actor != nil {
|
||||
input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID))
|
||||
input.ActorUserID, input.ActorUsername, input.ActorSource, input.ActorRoles = actor.ID, actor.Username, actor.Source, actor.Roles
|
||||
}
|
||||
audit, err := s.store.RecordAuditLog(r.Context(), input)
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.WarnContext(r.Context(), "record identity audit failed", "action", action, "error_category", "audit_store_failed", "trace_id", traceID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return audit.ID
|
||||
}
|
||||
|
||||
func (s *Server) requireIdentityConfigurationAudit(w http.ResponseWriter, r *http.Request, action, targetID, traceID string) (string, bool) {
|
||||
auditID := s.recordIdentityConfigurationAudit(r, action, targetID, "requested", traceID, "")
|
||||
if auditID == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "统一认证审计暂时不可用,操作未执行", "IDENTITY_AUDIT_UNAVAILABLE")
|
||||
return "", false
|
||||
}
|
||||
w.Header().Set("X-Audit-Id", auditID)
|
||||
return auditID, true
|
||||
}
|
||||
|
||||
func (s *Server) startIdentityPairingWorker(pairingID string) {
|
||||
workerContext, cancel := context.WithCancel(s.ctx)
|
||||
worker := &identityPairingWorker{cancel: cancel, done: make(chan struct{})}
|
||||
if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, worker); loaded {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
s.identityPairingWorkers.CompareAndDelete(pairingID, worker)
|
||||
close(worker.done)
|
||||
}()
|
||||
delay := time.Second
|
||||
for {
|
||||
pairing, err := s.identityPairing.Continue(workerContext, pairingID)
|
||||
if err == nil {
|
||||
if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired || pairing.Status == identity.PairingCancelled {
|
||||
return
|
||||
}
|
||||
delay = time.Second
|
||||
} else {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_step_failed"))
|
||||
}
|
||||
if delay < 15*time.Second {
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-workerContext.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) stopIdentityPairingWorker(pairingID string) <-chan struct{} {
|
||||
if value, ok := s.identityPairingWorkers.Load(pairingID); ok {
|
||||
worker := value.(*identityPairingWorker)
|
||||
worker.cancel()
|
||||
return worker.done
|
||||
}
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
return done
|
||||
}
|
||||
|
||||
func (s *Server) startIdentityPairingCleanupWorker(pairingID string, processingDone <-chan struct{}) {
|
||||
if processingDone == nil {
|
||||
processingDone = s.stopIdentityPairingWorker(pairingID)
|
||||
}
|
||||
if _, loaded := s.identityCleanupWorkers.LoadOrStore(pairingID, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer s.identityCleanupWorkers.Delete(pairingID)
|
||||
if processingDone != nil {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-processingDone:
|
||||
}
|
||||
}
|
||||
delay := time.Second
|
||||
for {
|
||||
pairing, err := s.identityPairing.Cleanup(s.ctx, pairingID)
|
||||
if err == nil {
|
||||
if pairing.CleanupStatus == identity.PairingCleanupCompleted {
|
||||
return
|
||||
}
|
||||
delay = time.Second
|
||||
} else {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, identity.ErrPairingNotCancellable) {
|
||||
return
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("identity pairing cleanup failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_cleanup_failed"))
|
||||
}
|
||||
if delay < 15*time.Second {
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
)
|
||||
|
||||
func TestRequiredIdentityWriteHeaders(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/identity", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
if _, ok := requiredIdentityVersion(recorder, request); ok || recorder.Code != http.StatusPreconditionRequired {
|
||||
t.Fatalf("missing If-Match status=%d", recorder.Code)
|
||||
}
|
||||
|
||||
request.Header.Set("If-Match", `W/"7"`)
|
||||
recorder = httptest.NewRecorder()
|
||||
version, ok := requiredIdentityVersion(recorder, request)
|
||||
if !ok || version != 7 {
|
||||
t.Fatalf("weak ETag version=%d ok=%v", version, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityErrorProjectionProtectsInternalDetails(t *testing.T) {
|
||||
status, message, code := identityErrorProjection(assertionError("upstream response contained secret-token"))
|
||||
if status != http.StatusBadGateway || code != "IDENTITY_SERVICE_UNAVAILABLE" || message == "upstream response contained secret-token" {
|
||||
t.Fatalf("unsafe error projection status=%d code=%q message=%q", status, code, message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPairingRecoveryErrorsUseStableConflictResponses(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
err error
|
||||
code string
|
||||
}{
|
||||
{err: identity.ErrPairingInProgress, code: "IDENTITY_PAIRING_IN_PROGRESS"},
|
||||
{err: identity.ErrPairingNotCancellable, code: "IDENTITY_PAIRING_NOT_CANCELLABLE"},
|
||||
{err: identity.ErrPairingConflictNotResolvable, code: "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE"},
|
||||
} {
|
||||
status, message, code := identityErrorProjection(test.err)
|
||||
if status != http.StatusConflict || code != test.code || message == "" || errors.Is(assertionError(message), test.err) {
|
||||
t.Fatalf("err=%v status=%d code=%q message=%q", test.err, status, code, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityWriteHashScopesPairingCancellationToTarget(t *testing.T) {
|
||||
type cancellationRequest struct {
|
||||
PairingID string `json:"pairingId"`
|
||||
}
|
||||
first := identityRequestHash("pairing.cancel", 7, cancellationRequest{PairingID: "pairing-a"})
|
||||
second := identityRequestHash("pairing.cancel", 7, cancellationRequest{PairingID: "pairing-b"})
|
||||
if first == second {
|
||||
t.Fatal("pairing cancellation idempotency hash must include the target pairing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityWriteAuditFailsClosedBeforeMutation(t *testing.T) {
|
||||
server := &Server{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
if _, ok := server.requireIdentityConfigurationAudit(recorder, request, "revision.disable", "active", "trace-test"); ok {
|
||||
t.Fatal("identity write unexpectedly continued without durable audit storage")
|
||||
}
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("audit failure status=%d, want 503", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type assertionError string
|
||||
|
||||
func (err assertionError) Error() string { return string(err) }
|
||||
@@ -0,0 +1,126 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
)
|
||||
|
||||
const identityPairingReconcileInterval = 5 * time.Second
|
||||
|
||||
type identityPairingReconciliationStore interface {
|
||||
PendingIdentityPairingExchanges(context.Context) ([]identity.PairingExchange, error)
|
||||
CompletedIdentityPairingsAwaitingActivation(context.Context) ([]identity.PairingExchange, error)
|
||||
PendingIdentityPairingCleanups(context.Context) ([]identity.PairingExchange, error)
|
||||
}
|
||||
|
||||
type identityPairingRestorer interface {
|
||||
RestoreCompletedSecurityEvents(context.Context, string) error
|
||||
}
|
||||
|
||||
func (s *Server) reconcileCanonicalIdentityPairing() {
|
||||
reconcileCanonicalIdentityPairing(
|
||||
s.ctx,
|
||||
s.store,
|
||||
s.identityPairing,
|
||||
&s.identityRestoredPairings,
|
||||
s.startIdentityPairingWorker,
|
||||
func(pairingID string) { s.startIdentityPairingCleanupWorker(pairingID, nil) },
|
||||
s.logger,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) runIdentityPairingCoordinator() {
|
||||
ticker := time.NewTicker(identityPairingReconcileInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.reconcileIdentityRuntime()
|
||||
s.reconcileCanonicalIdentityPairing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileCanonicalIdentityPairing(
|
||||
ctx context.Context,
|
||||
repository identityPairingReconciliationStore,
|
||||
restorer identityPairingRestorer,
|
||||
restored *sync.Map,
|
||||
startWorker func(string),
|
||||
startCleanup func(string),
|
||||
logger *slog.Logger,
|
||||
) {
|
||||
cleanups, cleanupErr := repository.PendingIdentityPairingCleanups(ctx)
|
||||
if cleanupErr != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("pending identity pairing cleanups could not be resumed", "error_category", "pairing_cleanup_resume_failed")
|
||||
}
|
||||
} else {
|
||||
for _, pairing := range cleanups {
|
||||
startCleanup(pairing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
pending, err := repository.PendingIdentityPairingExchanges(ctx)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("pending identity pairings could not be resumed", "error_category", "pairing_resume_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
forgetOtherRestoredPairings(restored, "")
|
||||
startWorker(pending[0].ID)
|
||||
return
|
||||
}
|
||||
|
||||
completed, err := repository.CompletedIdentityPairingsAwaitingActivation(ctx)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("completed identity pairings could not be loaded", "error_category", "pairing_receiver_restore_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(completed) == 0 {
|
||||
forgetOtherRestoredPairings(restored, "")
|
||||
return
|
||||
}
|
||||
|
||||
pairingID := completed[0].ID
|
||||
forgetOtherRestoredPairings(restored, pairingID)
|
||||
if _, loaded := restored.LoadOrStore(pairingID, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
if err := restorer.RestoreCompletedSecurityEvents(ctx, pairingID); err != nil {
|
||||
restored.Delete(pairingID)
|
||||
if logger != nil {
|
||||
logger.Warn("completed identity pairing receiver could not be restored", "pairing_id", pairingID, "error_category", "pairing_receiver_restore_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) reconcileIdentityRuntime() {
|
||||
if s.identityRuntime == nil || !s.identityRuntime.ReconciliationRequired() {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(s.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.identityRuntime.ReconcileActive(ctx); err != nil && s.logger != nil {
|
||||
s.logger.Warn("identity runtime reconciliation failed and will retry", "error_category", "identity_runtime_reconciliation_failed")
|
||||
}
|
||||
}
|
||||
|
||||
func forgetOtherRestoredPairings(restored *sync.Map, keepID string) {
|
||||
restored.Range(func(key, _ any) bool {
|
||||
if key != keepID {
|
||||
restored.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
)
|
||||
|
||||
type identityPairingCoordinatorStore struct {
|
||||
pending []identity.PairingExchange
|
||||
completed []identity.PairingExchange
|
||||
cleanups []identity.PairingExchange
|
||||
pendingErr error
|
||||
completedErr error
|
||||
cleanupErr error
|
||||
}
|
||||
|
||||
func (store identityPairingCoordinatorStore) PendingIdentityPairingExchanges(context.Context) ([]identity.PairingExchange, error) {
|
||||
return store.pending, store.pendingErr
|
||||
}
|
||||
|
||||
func (store identityPairingCoordinatorStore) CompletedIdentityPairingsAwaitingActivation(context.Context) ([]identity.PairingExchange, error) {
|
||||
return store.completed, store.completedErr
|
||||
}
|
||||
|
||||
func (store identityPairingCoordinatorStore) PendingIdentityPairingCleanups(context.Context) ([]identity.PairingExchange, error) {
|
||||
return store.cleanups, store.cleanupErr
|
||||
}
|
||||
|
||||
type identityPairingCoordinatorRestorer struct {
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (restorer *identityPairingCoordinatorRestorer) RestoreCompletedSecurityEvents(context.Context, string) error {
|
||||
restorer.calls++
|
||||
return restorer.err
|
||||
}
|
||||
|
||||
func TestReconcileCanonicalIdentityPairingRetriesFailedRestore(t *testing.T) {
|
||||
repository := identityPairingCoordinatorStore{
|
||||
completed: []identity.PairingExchange{{ID: "pairing-1"}},
|
||||
}
|
||||
restorer := &identityPairingCoordinatorRestorer{err: errors.New("SecretStore temporarily unavailable")}
|
||||
restored := &sync.Map{}
|
||||
|
||||
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
|
||||
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
|
||||
|
||||
if restorer.calls != 2 {
|
||||
t.Fatalf("restore calls = %d, want 2", restorer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCanonicalIdentityPairingRestoresSuccessfulPairingOnce(t *testing.T) {
|
||||
repository := identityPairingCoordinatorStore{
|
||||
completed: []identity.PairingExchange{{ID: "pairing-1"}},
|
||||
}
|
||||
restorer := &identityPairingCoordinatorRestorer{}
|
||||
restored := &sync.Map{}
|
||||
|
||||
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
|
||||
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil)
|
||||
|
||||
if restorer.calls != 1 {
|
||||
t.Fatalf("restore calls = %d, want 1", restorer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCanonicalIdentityPairingPrioritizesPendingWork(t *testing.T) {
|
||||
repository := identityPairingCoordinatorStore{
|
||||
pending: []identity.PairingExchange{{ID: "pending-pairing"}},
|
||||
completed: []identity.PairingExchange{{ID: "completed-pairing"}},
|
||||
}
|
||||
restorer := &identityPairingCoordinatorRestorer{}
|
||||
restored := &sync.Map{}
|
||||
started := ""
|
||||
|
||||
reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(pairingID string) {
|
||||
started = pairingID
|
||||
}, func(string) {}, nil)
|
||||
|
||||
if started != "pending-pairing" {
|
||||
t.Fatalf("started pairing = %q, want pending-pairing", started)
|
||||
}
|
||||
if restorer.calls != 0 {
|
||||
t.Fatalf("restore calls = %d, want 0", restorer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCanonicalIdentityPairingForgetsTerminalPairing(t *testing.T) {
|
||||
restored := &sync.Map{}
|
||||
restored.Store("terminal-pairing", struct{}{})
|
||||
|
||||
reconcileCanonicalIdentityPairing(
|
||||
context.Background(),
|
||||
identityPairingCoordinatorStore{},
|
||||
&identityPairingCoordinatorRestorer{},
|
||||
restored,
|
||||
func(string) {},
|
||||
func(string) {},
|
||||
nil,
|
||||
)
|
||||
|
||||
if _, ok := restored.Load("terminal-pairing"); ok {
|
||||
t.Fatal("terminal pairing restore marker was not removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCanonicalIdentityPairingResumesCommittedCleanup(t *testing.T) {
|
||||
repository := identityPairingCoordinatorStore{
|
||||
cleanups: []identity.PairingExchange{{ID: "cancelled-pairing"}},
|
||||
}
|
||||
restorer := &identityPairingCoordinatorRestorer{}
|
||||
restored := &sync.Map{}
|
||||
startedCleanup := ""
|
||||
|
||||
reconcileCanonicalIdentityPairing(
|
||||
context.Background(),
|
||||
repository,
|
||||
restorer,
|
||||
restored,
|
||||
func(string) {},
|
||||
func(pairingID string) { startedCleanup = pairingID },
|
||||
nil,
|
||||
)
|
||||
|
||||
if startedCleanup != "cancelled-pairing" {
|
||||
t.Fatalf("started cleanup = %q, want cancelled-pairing", startedCleanup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupCoordinatorCancelsPairingWorkerBeforeCleanup(t *testing.T) {
|
||||
serverContext, cancelServer := context.WithCancel(context.Background())
|
||||
cancelServer()
|
||||
workerContext, cancelWorker := context.WithCancel(context.Background())
|
||||
server := &Server{ctx: serverContext}
|
||||
server.identityPairingWorkers.Store("pairing-1", &identityPairingWorker{cancel: cancelWorker, done: make(chan struct{})})
|
||||
|
||||
server.startIdentityPairingCleanupWorker("pairing-1", nil)
|
||||
|
||||
select {
|
||||
case <-workerContext.Done():
|
||||
default:
|
||||
t.Fatal("coordinator cleanup did not cancel the pairing worker first")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
|
||||
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
|
||||
)
|
||||
|
||||
type oidcTokenVerifier interface {
|
||||
Verify(context.Context, string) (*auth.User, error)
|
||||
}
|
||||
|
||||
// identityRequestRuntime is an immutable request-level snapshot. A handler that
|
||||
// starts with one runtime keeps using it even when an administrator activates a
|
||||
// new revision while that request is in flight.
|
||||
type identityRequestRuntime struct {
|
||||
Revision identity.Revision
|
||||
Verifier oidcTokenVerifier
|
||||
PublicClient oidcPublicClient
|
||||
Sessions oidcSessionManager
|
||||
SessionCipher *oidcsession.Cipher
|
||||
SecurityEvents *ssfreceiver.ConnectionManager
|
||||
CookieSecure bool
|
||||
BrowserEnabled bool
|
||||
}
|
||||
|
||||
func (s *Server) currentIdentityRuntime() *identityRequestRuntime {
|
||||
if s.identityRuntime != nil {
|
||||
runtime := s.identityRuntime.Current()
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
return &identityRequestRuntime{
|
||||
Revision: runtime.Revision, Verifier: runtime.Verifier, PublicClient: runtime.PublicClient,
|
||||
Sessions: runtime.Sessions, SessionCipher: runtime.SessionCipher, SecurityEvents: runtime.SecurityEvents,
|
||||
CookieSecure: runtime.CookieSecure, BrowserEnabled: runtime.PublicClient != nil,
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility path for focused HTTP tests. NewServer never uses these
|
||||
// static fields after identity revisions are enabled.
|
||||
if s.identityTestRevision.ID == "" && s.identityTestRevision.Issuer == "" && s.oidcClient == nil && s.oidcSessions == nil && s.oidcSessionCipher == nil && s.securityEventManager == nil && !s.identityTestBrowserEnabled {
|
||||
return nil
|
||||
}
|
||||
revision := s.identityTestRevision
|
||||
if revision.State == "" {
|
||||
revision.State = identity.RevisionActive
|
||||
}
|
||||
webBaseURL := revision.WebBaseURL
|
||||
if webBaseURL == "" {
|
||||
webBaseURL = s.cfg.WebBaseURL
|
||||
}
|
||||
if webBaseURL == "" {
|
||||
webBaseURL = s.cfg.CORSAllowedOrigin
|
||||
}
|
||||
revision.WebBaseURL = webBaseURL
|
||||
if revision.PublicBaseURL == "" {
|
||||
revision.PublicBaseURL = s.cfg.PublicBaseURL
|
||||
}
|
||||
if revision.SessionAbsoluteSeconds <= 0 {
|
||||
revision.SessionAbsoluteSeconds = 28800
|
||||
}
|
||||
var verifier oidcTokenVerifier
|
||||
if s.auth != nil {
|
||||
verifier = s.auth.OIDCVerifier
|
||||
}
|
||||
return &identityRequestRuntime{
|
||||
Revision: revision,
|
||||
Verifier: verifier, PublicClient: s.oidcClient, Sessions: s.oidcSessions,
|
||||
SessionCipher: s.oidcSessionCipher, SecurityEvents: s.securityEventManager,
|
||||
CookieSecure: s.identityTestCookieSecure || strings.HasPrefix(strings.ToLower(revision.PublicBaseURL), "https://"),
|
||||
BrowserEnabled: s.identityTestBrowserEnabled || s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) currentSecurityEventManager() *ssfreceiver.ConnectionManager {
|
||||
if s.identityRuntime != nil {
|
||||
return s.identityRuntime.SecurityEventManager()
|
||||
}
|
||||
if runtime := s.currentIdentityRuntime(); runtime != nil {
|
||||
return runtime.SecurityEvents
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) oidcCookieSecure() bool {
|
||||
if runtime := s.currentIdentityRuntime(); runtime != nil {
|
||||
return runtime.CookieSecure
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func originMatchesBaseURL(origin, baseURL string) bool {
|
||||
originURL, err := url.Parse(strings.TrimSpace(origin))
|
||||
if err != nil || originURL.User != nil || originURL.Path != "" || originURL.RawQuery != "" || originURL.Fragment != "" {
|
||||
return false
|
||||
}
|
||||
base, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(originURL.Scheme, base.Scheme) && strings.EqualFold(originURL.Host, base.Host)
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const kelingOmniCompatibilityMarker = "keling_omni_v1"
|
||||
|
||||
type kelingCompatRequestIDKey struct{}
|
||||
|
||||
type KelingOmniVideoRequest struct {
|
||||
ModelName string `json:"model_name" example:"kling-v3-omni"`
|
||||
Prompt string `json:"prompt" example:"A quiet street in the rain with natural ambient sound"`
|
||||
MultiShot bool `json:"multi_shot" example:"false"`
|
||||
ShotType string `json:"shot_type,omitempty" example:"customize"`
|
||||
MultiPrompt []KelingOmniMultiPrompt `json:"multi_prompt,omitempty"`
|
||||
ImageList []KelingOmniImageInput `json:"image_list,omitempty"`
|
||||
ElementList []KelingOmniElementInput `json:"element_list,omitempty"`
|
||||
VideoList []KelingOmniVideoInput `json:"video_list,omitempty"`
|
||||
Sound string `json:"sound" enums:"on,off" example:"on"`
|
||||
Mode string `json:"mode" enums:"std,pro,4k" example:"pro"`
|
||||
AspectRatio string `json:"aspect_ratio" enums:"16:9,9:16,1:1" example:"9:16"`
|
||||
Duration any `json:"duration" swaggertype:"string" example:"5"`
|
||||
WatermarkInfo KelingOmniWatermarkInfo `json:"watermark_info,omitempty"`
|
||||
CallbackURL string `json:"callback_url,omitempty"`
|
||||
ExternalTask string `json:"external_task_id,omitempty"`
|
||||
}
|
||||
|
||||
type KelingOmniMultiPrompt struct {
|
||||
Index int `json:"index" example:"1"`
|
||||
Prompt string `json:"prompt" example:"A wide establishing shot"`
|
||||
Duration any `json:"duration" swaggertype:"string" example:"3"`
|
||||
}
|
||||
|
||||
type KelingOmniImageInput struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
Type string `json:"type,omitempty" enums:"first_frame,end_frame"`
|
||||
}
|
||||
|
||||
type KelingOmniElementInput struct {
|
||||
ElementID any `json:"element_id"`
|
||||
}
|
||||
|
||||
type KelingOmniVideoInput struct {
|
||||
VideoURL string `json:"video_url"`
|
||||
ReferType string `json:"refer_type,omitempty" enums:"base,feature"`
|
||||
KeepOriginalSound string `json:"keep_original_sound,omitempty" enums:"yes,no"`
|
||||
}
|
||||
|
||||
type KelingOmniWatermarkInfo struct {
|
||||
Enabled bool `json:"enabled" example:"false"`
|
||||
}
|
||||
|
||||
type KelingCompatibleEnvelope struct {
|
||||
Code int `json:"code" example:"0"`
|
||||
Message string `json:"message" example:"SUCCEED"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type kelingCompatError struct {
|
||||
HTTPStatus int
|
||||
Code int
|
||||
Message string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func (e *kelingCompatError) Error() string {
|
||||
if e == nil {
|
||||
return "keling compatibility error"
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func newKelingCompatError(status int, code int, message string) *kelingCompatError {
|
||||
return &kelingCompatError{HTTPStatus: status, Code: code, Message: message}
|
||||
}
|
||||
|
||||
func (s *Server) requireKelingAPIKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := newKelingCompatRequestID(r)
|
||||
r = r.WithContext(context.WithValue(r.Context(), kelingCompatRequestIDKey{}, requestID))
|
||||
user, err := s.auth.Authenticate(r)
|
||||
if err != nil {
|
||||
code := 1002
|
||||
message := "Authorization is invalid"
|
||||
if strings.TrimSpace(r.Header.Get("Authorization")) == "" {
|
||||
code = 1001
|
||||
message = "Authorization is required"
|
||||
}
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, code, message))
|
||||
return
|
||||
}
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "a Gateway API Key is required"))
|
||||
return
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusForbidden, 1103, "API Key scope does not allow video generation"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), user)))
|
||||
})
|
||||
}
|
||||
|
||||
// createKelingOmniVideo godoc
|
||||
// @Summary 创建 Kling Omni 视频任务
|
||||
// @Description 兼容 Kling 旧版 /v1/videos/omni-video;Bearer token 必须为 Gateway API Key。任务固定异步执行,返回的 task_id 为网关任务 ID。
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param input body KelingOmniVideoRequest true "Kling Omni 官方兼容请求"
|
||||
// @Success 200 {object} KelingCompatibleEnvelope
|
||||
// @Failure 400 {object} KelingCompatibleEnvelope
|
||||
// @Failure 401 {object} KelingCompatibleEnvelope
|
||||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||||
// @Failure 429 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Failure 503 {object} KelingCompatibleEnvelope
|
||||
// @Router /v1/videos/omni-video [post]
|
||||
func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := kelingCompatRequestID(r)
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid"))
|
||||
return
|
||||
}
|
||||
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
|
||||
if err != nil {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, err.Error()))
|
||||
return
|
||||
}
|
||||
normalized, compatErr := normalizeKelingOmniRequest(body)
|
||||
if compatErr != nil {
|
||||
writeKelingCompatError(w, requestID, compatErr)
|
||||
return
|
||||
}
|
||||
model := strings.TrimSpace(stringFromKelingCompat(normalized["model"]))
|
||||
if normalized["resolution"] == "2160p" {
|
||||
candidates, candidateErr := s.store.ListModelCandidates(r.Context(), model, "omni_video", user)
|
||||
if candidateErr != nil {
|
||||
writeKelingCompatError(w, requestID, kelingCompatGatewayError(candidateErr))
|
||||
return
|
||||
}
|
||||
if !kelingCompatCandidatesSupport4K(candidates) {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, "mode=4k is not enabled by the selected model capabilities"))
|
||||
return
|
||||
}
|
||||
}
|
||||
task, createErr := s.prepareAndCreateGatewayTask(
|
||||
r.Context(),
|
||||
r,
|
||||
user,
|
||||
"videos.generations",
|
||||
model,
|
||||
normalized,
|
||||
true,
|
||||
)
|
||||
if createErr != nil {
|
||||
var staged *gatewayTaskCreationError
|
||||
if errors.As(createErr, &staged) && staged.Stage == gatewayTaskCreationPrepare {
|
||||
writeKelingCompatError(w, requestID, kelingCompatGatewayError(staged.Err))
|
||||
return
|
||||
}
|
||||
s.logger.Error("create Kling-compatible task failed", "error", createErr)
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "create task failed"))
|
||||
return
|
||||
}
|
||||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||||
s.logger.Error("enqueue Kling-compatible task failed", "taskId", task.ID, "error", err)
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusServiceUnavailable, 5001, "video task queue is unavailable"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
|
||||
Code: 0,
|
||||
Message: "SUCCEED",
|
||||
RequestID: requestID,
|
||||
Data: kelingCompatTaskData(task),
|
||||
})
|
||||
}
|
||||
|
||||
// getKelingOmniVideo godoc
|
||||
// @Summary 查询 Kling Omni 视频任务
|
||||
// @Description 按创建接口返回的网关 task_id 查询任务;仅允许创建任务的 Gateway 用户访问。
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param taskID path string true "网关任务 ID"
|
||||
// @Success 200 {object} KelingCompatibleEnvelope
|
||||
// @Failure 401 {object} KelingCompatibleEnvelope
|
||||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||||
// @Failure 404 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Router /v1/videos/omni-video/{taskID} [get]
|
||||
func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := kelingCompatRequestID(r)
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid"))
|
||||
return
|
||||
}
|
||||
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
|
||||
return
|
||||
}
|
||||
s.logger.Error("get Kling-compatible task failed", "error", err)
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "query task failed"))
|
||||
return
|
||||
}
|
||||
if !kelingCompatTaskOwnedBy(task, user) || !isKelingCompatTask(task) {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
|
||||
Code: 0,
|
||||
Message: "SUCCEED",
|
||||
RequestID: requestID,
|
||||
Data: kelingCompatTaskData(task),
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeKelingOmniRequest(input map[string]any) (map[string]any, *kelingCompatError) {
|
||||
if input == nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
if callbackURL := strings.TrimSpace(stringFromKelingCompat(input["callback_url"])); callbackURL != "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "callback_url is not supported by this Gateway endpoint")
|
||||
}
|
||||
requestedModel := strings.TrimSpace(stringFromKelingCompat(input["model_name"]))
|
||||
if requestedModel == "" {
|
||||
requestedModel = "kling-video-o1"
|
||||
}
|
||||
model, maxDuration, ok := kelingCompatModel(requestedModel)
|
||||
if !ok {
|
||||
return nil, newKelingCompatError(http.StatusNotFound, 1203, "unsupported model_name: "+requestedModel)
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["mode"])))
|
||||
if mode == "" {
|
||||
mode = "pro"
|
||||
}
|
||||
resolutionByMode := map[string]string{"std": "720p", "pro": "1080p", "4k": "2160p"}
|
||||
resolution := resolutionByMode[mode]
|
||||
if resolution == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "mode must be std, pro, or 4k")
|
||||
}
|
||||
|
||||
sound := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["sound"])))
|
||||
if sound == "" {
|
||||
sound = "off"
|
||||
}
|
||||
if sound != "on" && sound != "off" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be on or off")
|
||||
}
|
||||
if model == "kling-o1" && sound == "on" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 does not support generated audio; sound must be off")
|
||||
}
|
||||
|
||||
content := make([]any, 0)
|
||||
prompt := strings.TrimSpace(stringFromKelingCompat(input["prompt"]))
|
||||
images, hasFirstFrame, imageErr := normalizeKelingImageList(input["image_list"])
|
||||
if imageErr != nil {
|
||||
return nil, imageErr
|
||||
}
|
||||
content = append(content, images...)
|
||||
elements, elementErr := normalizeKelingElementList(input["element_list"])
|
||||
if elementErr != nil {
|
||||
return nil, elementErr
|
||||
}
|
||||
content = append(content, elements...)
|
||||
videos, hasBaseVideo, hasVideo, videoErr := normalizeKelingVideoList(input["video_list"])
|
||||
if videoErr != nil {
|
||||
return nil, videoErr
|
||||
}
|
||||
content = append(content, videos...)
|
||||
if hasVideo && sound == "on" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be off when video_list is provided")
|
||||
}
|
||||
|
||||
multiShot, multiShotPresent, boolErr := kelingCompatOptionalBool(input, "multi_shot")
|
||||
if boolErr != nil {
|
||||
return nil, boolErr
|
||||
}
|
||||
shotType := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["shot_type"])))
|
||||
multiPrompts, shotDuration, multiPromptErr := normalizeKelingMultiPrompts(input["multi_prompt"])
|
||||
if multiPromptErr != nil {
|
||||
return nil, multiPromptErr
|
||||
}
|
||||
if !multiShotPresent {
|
||||
multiShot = false
|
||||
}
|
||||
if multiShot {
|
||||
if shotType != "customize" && shotType != "intelligence" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "shot_type must be customize or intelligence when multi_shot is true")
|
||||
}
|
||||
if shotType == "customize" && len(multiPrompts) == 0 {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt is required for customized multi-shot generation")
|
||||
}
|
||||
if shotType == "intelligence" && len(multiPrompts) > 0 {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt is only supported when shot_type is customize")
|
||||
}
|
||||
} else if len(multiPrompts) > 0 || shotType != "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "shot_type and multi_prompt require multi_shot=true")
|
||||
}
|
||||
if (len(multiPrompts) == 0 || shotType == "intelligence") && prompt == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "prompt is required")
|
||||
}
|
||||
if prompt != "" {
|
||||
content = append([]any{map[string]any{"type": "text", "text": prompt}}, content...)
|
||||
}
|
||||
content = append(content, multiPrompts...)
|
||||
|
||||
duration, durationProvided, durationErr := kelingCompatOptionalInt(input, "duration")
|
||||
if durationErr != nil {
|
||||
return nil, durationErr
|
||||
}
|
||||
if hasBaseVideo {
|
||||
if durationProvided {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "duration is not supported for base video editing")
|
||||
}
|
||||
} else {
|
||||
if len(multiPrompts) > 0 {
|
||||
if durationProvided && duration != shotDuration {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "duration must equal the sum of multi_prompt durations")
|
||||
}
|
||||
duration = shotDuration
|
||||
} else if !durationProvided {
|
||||
duration = 5
|
||||
}
|
||||
if duration < 3 || duration > maxDuration {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("duration for %s must be an integer between 3 and %d seconds", requestedModel, maxDuration))
|
||||
}
|
||||
if model == "kling-o1" && (len(images) == 0 || hasFirstFrame) && duration != 5 && duration != 10 {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 text-to-video and first-frame generation only support 5 or 10 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
aspectRatio := strings.TrimSpace(stringFromKelingCompat(input["aspect_ratio"]))
|
||||
if aspectRatio != "" && aspectRatio != "16:9" && aspectRatio != "9:16" && aspectRatio != "1:1" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio must be 16:9, 9:16, or 1:1")
|
||||
}
|
||||
if hasFirstFrame || hasBaseVideo {
|
||||
if aspectRatio != "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio is not supported with a first frame or base video")
|
||||
}
|
||||
} else if aspectRatio == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio is required when no first frame or base video is provided")
|
||||
}
|
||||
|
||||
watermarkEnabled, watermarkErr := kelingCompatWatermarkEnabled(input["watermark_info"])
|
||||
if watermarkErr != nil {
|
||||
return nil, watermarkErr
|
||||
}
|
||||
externalTaskID := strings.TrimSpace(stringFromKelingCompat(input["external_task_id"]))
|
||||
normalized := map[string]any{
|
||||
"model": model,
|
||||
"model_name": requestedModel,
|
||||
"modelType": "omni_video",
|
||||
"runMode": "real",
|
||||
"content": content,
|
||||
"resolution": resolution,
|
||||
"mode": mode,
|
||||
"sound": sound,
|
||||
"audio": sound == "on",
|
||||
"multi_shot": multiShot,
|
||||
"watermark": watermarkEnabled,
|
||||
"watermark_info": map[string]any{"enabled": watermarkEnabled},
|
||||
"external_task_id": externalTaskID,
|
||||
"_gateway_compatibility": kelingOmniCompatibilityMarker,
|
||||
}
|
||||
if prompt != "" {
|
||||
normalized["prompt"] = prompt
|
||||
}
|
||||
if shotType != "" {
|
||||
normalized["shot_type"] = shotType
|
||||
}
|
||||
if !hasBaseVideo {
|
||||
normalized["duration"] = duration
|
||||
}
|
||||
if aspectRatio != "" {
|
||||
normalized["aspect_ratio"] = aspectRatio
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeKelingImageList(value any) ([]any, bool, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "image_list")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
hasFirstFrame := false
|
||||
hasEndFrame := false
|
||||
for index, item := range items {
|
||||
url := strings.TrimSpace(stringFromKelingCompat(item["image_url"]))
|
||||
if url == "" {
|
||||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("image_list[%d].image_url is required", index))
|
||||
}
|
||||
frameType := strings.TrimSpace(stringFromKelingCompat(item["type"]))
|
||||
role := "reference_image"
|
||||
switch frameType {
|
||||
case "":
|
||||
case "first_frame":
|
||||
role = "first_frame"
|
||||
hasFirstFrame = true
|
||||
case "end_frame":
|
||||
role = "last_frame"
|
||||
hasEndFrame = true
|
||||
default:
|
||||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("image_list[%d].type must be first_frame or end_frame", index))
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"type": "image_url",
|
||||
"role": role,
|
||||
"image_url": map[string]any{"url": url},
|
||||
})
|
||||
}
|
||||
if hasEndFrame && !hasFirstFrame {
|
||||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, "end_frame requires first_frame")
|
||||
}
|
||||
return out, hasFirstFrame, nil
|
||||
}
|
||||
|
||||
func normalizeKelingElementList(value any) ([]any, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "element_list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
id := item["element_id"]
|
||||
if strings.TrimSpace(stringFromKelingCompat(id)) == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("element_list[%d].element_id is required", index))
|
||||
}
|
||||
out = append(out, map[string]any{"type": "element", "element": map[string]any{"element_id": id}})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeKelingVideoList(value any) ([]any, bool, bool, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "video_list")
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
if len(items) > 1 {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, "video_list supports at most one video")
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
hasBase := false
|
||||
for index, item := range items {
|
||||
url := strings.TrimSpace(stringFromKelingCompat(item["video_url"]))
|
||||
if url == "" {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].video_url is required", index))
|
||||
}
|
||||
referType := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(item["refer_type"])))
|
||||
if referType == "" {
|
||||
referType = "base"
|
||||
}
|
||||
if referType != "base" && referType != "feature" {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].refer_type must be base or feature", index))
|
||||
}
|
||||
keepSound := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(item["keep_original_sound"])))
|
||||
if keepSound != "" && keepSound != "yes" && keepSound != "no" {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].keep_original_sound must be yes or no", index))
|
||||
}
|
||||
nested := map[string]any{"url": url, "refer_type": referType}
|
||||
if keepSound != "" {
|
||||
nested["keep_original_sound"] = keepSound
|
||||
}
|
||||
role := "video_feature"
|
||||
if referType == "base" {
|
||||
role = "video_base"
|
||||
hasBase = true
|
||||
}
|
||||
out = append(out, map[string]any{"type": "video_url", "role": role, "video_url": nested})
|
||||
}
|
||||
return out, hasBase, len(items) > 0, nil
|
||||
}
|
||||
|
||||
func normalizeKelingMultiPrompts(value any) ([]any, int, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "multi_prompt")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(items) > 6 {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt supports at most six shots")
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
seen := map[int]bool{}
|
||||
total := 0
|
||||
for index, item := range items {
|
||||
shotIndex, ok := kelingCompatInt(item["index"])
|
||||
if !ok || shotIndex < 1 || shotIndex > 6 || seen[shotIndex] {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].index must be a unique integer from 1 to 6", index))
|
||||
}
|
||||
seen[shotIndex] = true
|
||||
prompt := strings.TrimSpace(stringFromKelingCompat(item["prompt"]))
|
||||
if prompt == "" {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].prompt is required", index))
|
||||
}
|
||||
duration, ok := kelingCompatInt(item["duration"])
|
||||
if !ok || duration < 1 {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].duration must be an integer of at least 1 second", index))
|
||||
}
|
||||
total += duration
|
||||
out = append(out, map[string]any{
|
||||
"type": "text",
|
||||
"role": "shot_prompt",
|
||||
"shot_index": shotIndex,
|
||||
"text": prompt,
|
||||
"duration": duration,
|
||||
})
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func kelingCompatModel(value string) (string, int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "kling-video-o1", "kling-o1":
|
||||
return "kling-o1", 10, true
|
||||
case "kling-v3-omni", "kling-3.0-omni":
|
||||
return "kling-3.0-omni", 15, true
|
||||
default:
|
||||
return "", 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatObjectList(value any, field string) ([]map[string]any, *kelingCompatError) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, field+" must be an array")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for index, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("%s[%d] must be an object", field, index))
|
||||
}
|
||||
out = append(out, object)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func kelingCompatOptionalInt(body map[string]any, key string) (int, bool, *kelingCompatError) {
|
||||
value, present := body[key]
|
||||
if !present || value == nil || strings.TrimSpace(stringFromKelingCompat(value)) == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
parsed, ok := kelingCompatInt(value)
|
||||
if !ok {
|
||||
return 0, true, newKelingCompatError(http.StatusBadRequest, 1201, key+" must be an integer")
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func kelingCompatOptionalBool(body map[string]any, key string) (bool, bool, *kelingCompatError) {
|
||||
value, present := body[key]
|
||||
if !present || value == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
parsed, ok := value.(bool)
|
||||
if !ok {
|
||||
return false, true, newKelingCompatError(http.StatusBadRequest, 1201, key+" must be a boolean")
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func kelingCompatWatermarkEnabled(value any) (bool, *kelingCompatError) {
|
||||
if value == nil {
|
||||
return false, nil
|
||||
}
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false, newKelingCompatError(http.StatusBadRequest, 1201, "watermark_info must be an object")
|
||||
}
|
||||
enabled, present := object["enabled"]
|
||||
if !present {
|
||||
return false, nil
|
||||
}
|
||||
result, ok := enabled.(bool)
|
||||
if !ok {
|
||||
return false, newKelingCompatError(http.StatusBadRequest, 1201, "watermark_info.enabled must be a boolean")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func kelingCompatInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case int64:
|
||||
return int(typed), true
|
||||
case float64:
|
||||
if math.Abs(typed-math.Round(typed)) > 1e-9 {
|
||||
return 0, false
|
||||
}
|
||||
return int(math.Round(typed)), true
|
||||
case json.Number:
|
||||
parsed, err := strconv.Atoi(typed.String())
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func stringFromKelingCompat(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
if math.Abs(typed-math.Round(typed)) < 1e-9 {
|
||||
return strconv.FormatInt(int64(math.Round(typed)), 10)
|
||||
}
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatTaskData(task store.GatewayTask) map[string]any {
|
||||
data := map[string]any{
|
||||
"task_id": task.ID,
|
||||
"task_status": kelingCompatTaskStatus(task.Status),
|
||||
"task_info": map[string]any{
|
||||
"external_task_id": strings.TrimSpace(stringFromKelingCompat(task.Request["external_task_id"])),
|
||||
},
|
||||
"created_at": task.CreatedAt.UnixMilli(),
|
||||
"updated_at": task.UpdatedAt.UnixMilli(),
|
||||
"watermark_info": map[string]any{
|
||||
"enabled": kelingCompatTaskWatermark(task.Request),
|
||||
},
|
||||
}
|
||||
if message := kelingCompatTaskMessage(task); message != "" {
|
||||
data["task_status_msg"] = message
|
||||
}
|
||||
if kelingCompatTaskStatus(task.Status) == "failed" {
|
||||
data["task_status_code"] = kelingCompatBusinessCode(task.ErrorCode, kelingCompatTaskMessage(task))
|
||||
}
|
||||
if videos := kelingCompatTaskVideos(task.Result); len(videos) > 0 {
|
||||
data["task_result"] = map[string]any{"videos": videos}
|
||||
}
|
||||
if task.FinalChargeAmount > 0 {
|
||||
data["final_unit_deduction"] = strconv.FormatFloat(task.FinalChargeAmount, 'f', -1, 64)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func kelingCompatTaskStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "succeeded", "success", "completed":
|
||||
return "succeed"
|
||||
case "failed", "cancelled", "canceled":
|
||||
return "failed"
|
||||
case "running", "processing":
|
||||
return "processing"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatTaskVideos(result map[string]any) []any {
|
||||
raw, _ := result["data"].([]any)
|
||||
out := make([]any, 0, len(raw))
|
||||
for _, itemValue := range raw {
|
||||
item, _ := itemValue.(map[string]any)
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
url := strings.TrimSpace(stringFromKelingCompat(firstKelingCompatValue(item["url"], item["video_url"])))
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
video := map[string]any{"url": url}
|
||||
if id := strings.TrimSpace(stringFromKelingCompat(item["id"])); id != "" {
|
||||
video["id"] = id
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromKelingCompat(item["watermark_url"])); watermarkURL != "" {
|
||||
video["watermark_url"] = watermarkURL
|
||||
}
|
||||
if duration := strings.TrimSpace(stringFromKelingCompat(item["duration"])); duration != "" {
|
||||
video["duration"] = duration
|
||||
}
|
||||
out = append(out, video)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstKelingCompatValue(values ...any) any {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(stringFromKelingCompat(value)) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kelingCompatTaskMessage(task store.GatewayTask) string {
|
||||
return strings.TrimSpace(firstNonEmpty(task.ErrorMessage, task.Error, task.Message))
|
||||
}
|
||||
|
||||
func kelingCompatTaskWatermark(request map[string]any) bool {
|
||||
if value, ok := request["watermark"].(bool); ok {
|
||||
return value
|
||||
}
|
||||
if info, ok := request["watermark_info"].(map[string]any); ok {
|
||||
value, _ := info["enabled"].(bool)
|
||||
return value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kelingCompatCandidatesSupport4K(candidates []store.RuntimeModelCandidate) bool {
|
||||
for _, candidate := range candidates {
|
||||
if strings.ToLower(strings.TrimSpace(candidate.Provider)) != "keling" {
|
||||
continue
|
||||
}
|
||||
capability, _ := candidate.Capabilities["omni_video"].(map[string]any)
|
||||
if capability == nil {
|
||||
capability, _ = candidate.Capabilities["omni"].(map[string]any)
|
||||
}
|
||||
for _, resolution := range kelingCompatStringList(capability["output_resolutions"]) {
|
||||
switch strings.ToLower(strings.TrimSpace(resolution)) {
|
||||
case "2160p", "4k":
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kelingCompatStringList(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return typed
|
||||
case []any:
|
||||
result := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if text := strings.TrimSpace(stringFromKelingCompat(item)); text != "" {
|
||||
result = append(result, text)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatGatewayError(err error) *kelingCompatError {
|
||||
if err == nil {
|
||||
return newKelingCompatError(http.StatusInternalServerError, 5000, "unknown gateway error")
|
||||
}
|
||||
codeText := clients.ErrorCode(err)
|
||||
businessCode := kelingCompatBusinessCode(codeText, err.Error())
|
||||
status := http.StatusInternalServerError
|
||||
switch businessCode {
|
||||
case 1101:
|
||||
status = http.StatusPaymentRequired
|
||||
case 1103:
|
||||
status = http.StatusForbidden
|
||||
case 1201:
|
||||
status = http.StatusBadRequest
|
||||
case 1203:
|
||||
status = http.StatusNotFound
|
||||
case 1302, 1303:
|
||||
status = http.StatusTooManyRequests
|
||||
case 5001:
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
return newKelingCompatError(status, businessCode, err.Error())
|
||||
}
|
||||
|
||||
func kelingCompatBusinessCode(errorCode string, message string) int {
|
||||
combined := strings.ToLower(strings.TrimSpace(errorCode + " " + message))
|
||||
containsAny := func(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if strings.Contains(combined, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case containsAny("insufficient_balance", "insufficient balance", "balance_not_enough", "wallet balance", "余额不足", "欠费", "quota exceeded"):
|
||||
return 1101
|
||||
case containsAny("permission_denied", "permission denied", "forbidden", "access denied", "scope does not allow"):
|
||||
return 1103
|
||||
case containsAny("concurrent", "concurrency"):
|
||||
return 1303
|
||||
case containsAny("rate_limit", "rate limit", "too many requests", "rpm", "tpm"):
|
||||
return 1302
|
||||
case containsAny("no_model_candidate", "no model candidate", "model_not_found", "unsupported model", "resource not found"):
|
||||
return 1203
|
||||
case containsAny("invalid_parameter", "invalid parameter", "bad_request", "parameter_preprocessing", "duration", "aspect_ratio"):
|
||||
return 1201
|
||||
case containsAny("upload_", "request_asset_", "network", "timeout", "upstream", "service unavailable", "bad gateway"):
|
||||
return 5001
|
||||
default:
|
||||
return 5000
|
||||
}
|
||||
}
|
||||
|
||||
func isKelingCompatTask(task store.GatewayTask) bool {
|
||||
return task.Kind == "videos.generations" && strings.TrimSpace(stringFromKelingCompat(task.Request["_gateway_compatibility"])) == kelingOmniCompatibilityMarker
|
||||
}
|
||||
|
||||
func kelingCompatTaskOwnedBy(task store.GatewayTask, user *auth.User) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
taskOwner := strings.TrimSpace(firstNonEmpty(task.GatewayUserID, task.UserID))
|
||||
requestOwner := strings.TrimSpace(firstNonEmpty(user.GatewayUserID, user.ID))
|
||||
return taskOwner != "" && requestOwner != "" && taskOwner == requestOwner
|
||||
}
|
||||
|
||||
func newKelingCompatRequestID(r *http.Request) string {
|
||||
if r != nil {
|
||||
if value := strings.TrimSpace(firstNonEmpty(r.Header.Get("X-Request-ID"), r.Header.Get("X-Request-Id"))); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
random := make([]byte, 16)
|
||||
if _, err := rand.Read(random); err == nil {
|
||||
return hex.EncodeToString(random)
|
||||
}
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
|
||||
func kelingCompatRequestID(r *http.Request) string {
|
||||
if r != nil {
|
||||
if value, ok := r.Context().Value(kelingCompatRequestIDKey{}).(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return newKelingCompatRequestID(r)
|
||||
}
|
||||
|
||||
func writeKelingCompatError(w http.ResponseWriter, requestID string, err *kelingCompatError) {
|
||||
if err == nil {
|
||||
err = newKelingCompatError(http.StatusInternalServerError, 5000, "internal error")
|
||||
}
|
||||
if err.RequestID != "" {
|
||||
requestID = err.RequestID
|
||||
}
|
||||
if requestID == "" {
|
||||
requestID = newKelingCompatRequestID(nil)
|
||||
}
|
||||
status := err.HTTPStatus
|
||||
if status == 0 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
writeJSON(w, status, KelingCompatibleEnvelope{
|
||||
Code: err.Code,
|
||||
Message: err.Message,
|
||||
RequestID: requestID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestNormalizeKelingOmniRequestMapsOfficialFields(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "A rainy street with natural ambience",
|
||||
"mode": "pro",
|
||||
"sound": "on",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": "5",
|
||||
"external_task_id": "external-1",
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize Kling request: %v", err)
|
||||
}
|
||||
if normalized["model"] != "kling-3.0-omni" ||
|
||||
normalized["modelType"] != "omni_video" ||
|
||||
normalized["resolution"] != "1080p" ||
|
||||
normalized["aspect_ratio"] != "9:16" ||
|
||||
normalized["duration"] != 5 ||
|
||||
normalized["audio"] != true ||
|
||||
normalized["sound"] != "on" ||
|
||||
normalized["watermark"] != true ||
|
||||
normalized["external_task_id"] != "external-1" ||
|
||||
normalized["_gateway_compatibility"] != kelingOmniCompatibilityMarker {
|
||||
t.Fatalf("unexpected normalized request: %+v", normalized)
|
||||
}
|
||||
content, _ := normalized["content"].([]any)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("unexpected content: %+v", normalized["content"])
|
||||
}
|
||||
text, _ := content[0].(map[string]any)
|
||||
if text["type"] != "text" || text["text"] != "A rainy street with natural ambience" {
|
||||
t.Fatalf("unexpected text content: %+v", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingOmniRequestBuildsMultiShotMedia(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-3.0-omni",
|
||||
"multi_shot": true,
|
||||
"shot_type": "customize",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 5,
|
||||
"multi_prompt": []any{
|
||||
map[string]any{"index": 1, "prompt": "First shot", "duration": "2"},
|
||||
map[string]any{"index": 2, "prompt": "Second shot", "duration": 3},
|
||||
},
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://example.com/reference.png"},
|
||||
},
|
||||
"element_list": []any{
|
||||
map[string]any{"element_id": float64(123)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize multi-shot request: %v", err)
|
||||
}
|
||||
if normalized["model"] != "kling-3.0-omni" || normalized["duration"] != 5 || normalized["multi_shot"] != true || normalized["shot_type"] != "customize" {
|
||||
t.Fatalf("unexpected multi-shot fields: %+v", normalized)
|
||||
}
|
||||
content, _ := normalized["content"].([]any)
|
||||
if len(content) != 4 {
|
||||
t.Fatalf("expected image, element and two shot prompts, got %+v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingOmniRequestRejectsUnsupportedCombinations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{
|
||||
name: "callback",
|
||||
body: map[string]any{"callback_url": "https://example.com/callback"},
|
||||
},
|
||||
{
|
||||
name: "unknown model",
|
||||
body: map[string]any{"model_name": "kling-unknown", "prompt": "x", "aspect_ratio": "16:9"},
|
||||
},
|
||||
{
|
||||
name: "o1 duration",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "aspect_ratio": "16:9", "duration": 11},
|
||||
},
|
||||
{
|
||||
name: "o1 text to video three seconds",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "aspect_ratio": "16:9", "duration": 3},
|
||||
},
|
||||
{
|
||||
name: "o1 generated audio",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "sound": "on", "aspect_ratio": "16:9", "duration": 5},
|
||||
},
|
||||
{
|
||||
name: "video sound",
|
||||
body: map[string]any{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "edit",
|
||||
"sound": "on",
|
||||
"video_list": []any{map[string]any{"video_url": "https://example.com/base.mp4", "refer_type": "base"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "first frame ratio",
|
||||
body: map[string]any{
|
||||
"prompt": "animate",
|
||||
"aspect_ratio": "16:9",
|
||||
"image_list": []any{map[string]any{"image_url": "https://example.com/first.png", "type": "first_frame"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, item := range tests {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
_, err := normalizeKelingOmniRequest(item.body)
|
||||
if err == nil || err.Code != 1201 && err.Code != 1203 {
|
||||
t.Fatalf("expected official compatibility error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingO1AllowsThreeSecondsWithReferenceImage(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "Use the landscape as a visual reference",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 3,
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://placehold.co/1024x1024/png"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reference-image O1 request should allow three seconds: %v", err)
|
||||
}
|
||||
if normalized["duration"] != 3 {
|
||||
t.Fatalf("unexpected duration: %+v", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatTaskDataAndOwnership(t *testing.T) {
|
||||
createdAt := time.Unix(100, 0)
|
||||
task := store.GatewayTask{
|
||||
ID: "task-1",
|
||||
Kind: "videos.generations",
|
||||
GatewayUserID: "user-1",
|
||||
Status: "succeeded",
|
||||
FinalChargeAmount: 2.5,
|
||||
Request: map[string]any{
|
||||
"_gateway_compatibility": kelingOmniCompatibilityMarker,
|
||||
"external_task_id": "external-1",
|
||||
"watermark": true,
|
||||
},
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"id": "video-1",
|
||||
"url": "https://example.com/video.mp4",
|
||||
"watermark_url": "https://example.com/watermarked.mp4",
|
||||
"duration": "5",
|
||||
}}},
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(time.Second),
|
||||
}
|
||||
if !isKelingCompatTask(task) || !kelingCompatTaskOwnedBy(task, &auth.User{GatewayUserID: "user-1"}) {
|
||||
t.Fatalf("expected task ownership and compatibility marker")
|
||||
}
|
||||
if kelingCompatTaskOwnedBy(task, &auth.User{GatewayUserID: "user-2"}) {
|
||||
t.Fatalf("cross-user task access must be rejected")
|
||||
}
|
||||
data := kelingCompatTaskData(task)
|
||||
if data["task_status"] != "succeed" || data["final_unit_deduction"] != "2.5" {
|
||||
t.Fatalf("unexpected task data: %+v", data)
|
||||
}
|
||||
result, _ := data["task_result"].(map[string]any)
|
||||
videos, _ := result["videos"].([]any)
|
||||
video, _ := videos[0].(map[string]any)
|
||||
if video["id"] != "video-1" || video["watermark_url"] != "https://example.com/watermarked.mp4" || video["duration"] != "5" {
|
||||
t.Fatalf("unexpected compatible video: %+v", video)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireKelingAPIKeyWritesOfficialAuthEnvelope(t *testing.T) {
|
||||
server := &Server{auth: auth.New("secret", "", "")}
|
||||
server.auth.LocalAPIKeyVerifier = func(_ context.Context, key string) (*auth.User, error) {
|
||||
if key != "sk-gw-valid" {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
return &auth.User{ID: "user-1", GatewayUserID: "user-1", APIKeyID: "key-1", APIKeyScopes: []string{"video"}}, nil
|
||||
}
|
||||
handler := server.requireKelingAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := auth.UserFromContext(r.Context()); !ok {
|
||||
t.Fatal("authenticated user is missing")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
missing := httptest.NewRecorder()
|
||||
handler.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil))
|
||||
if missing.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing auth status=%d body=%s", missing.Code, missing.Body.String())
|
||||
}
|
||||
var missingBody KelingCompatibleEnvelope
|
||||
if err := json.Unmarshal(missing.Body.Bytes(), &missingBody); err != nil || missingBody.Code != 1001 || missingBody.RequestID == "" {
|
||||
t.Fatalf("unexpected missing auth envelope: %+v err=%v", missingBody, err)
|
||||
}
|
||||
|
||||
invalid := httptest.NewRecorder()
|
||||
invalidRequest := httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil)
|
||||
invalidRequest.Header.Set("Authorization", "Bearer sk-gw-invalid")
|
||||
handler.ServeHTTP(invalid, invalidRequest)
|
||||
var invalidBody KelingCompatibleEnvelope
|
||||
if err := json.Unmarshal(invalid.Body.Bytes(), &invalidBody); err != nil || invalid.Code != http.StatusUnauthorized || invalidBody.Code != 1002 {
|
||||
t.Fatalf("unexpected invalid auth envelope: status=%d body=%+v err=%v", invalid.Code, invalidBody, err)
|
||||
}
|
||||
|
||||
valid := httptest.NewRecorder()
|
||||
validRequest := httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil)
|
||||
validRequest.Header.Set("Authorization", "Bearer sk-gw-valid")
|
||||
handler.ServeHTTP(valid, validRequest)
|
||||
if valid.Code != http.StatusNoContent {
|
||||
t.Fatalf("valid API Key status=%d body=%s", valid.Code, valid.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatErrorImplementsError(t *testing.T) {
|
||||
err := newKelingCompatError(http.StatusBadRequest, 1201, "invalid")
|
||||
if !errors.Is(err, err) || err.Error() != "invalid" {
|
||||
t.Fatalf("unexpected error behavior: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatBusinessCodeMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
code string
|
||||
message string
|
||||
want int
|
||||
}{
|
||||
{code: "insufficient_balance", want: 1101},
|
||||
{code: "permission_denied", want: 1103},
|
||||
{code: "invalid_parameter", want: 1201},
|
||||
{code: "no_model_candidate", want: 1203},
|
||||
{code: "rate_limit_exceeded", want: 1302},
|
||||
{code: "concurrency_limit", want: 1303},
|
||||
{code: "network", want: 5001},
|
||||
{code: "unknown", want: 5000},
|
||||
}
|
||||
for _, item := range tests {
|
||||
if got := kelingCompatBusinessCode(item.code, item.message); got != item.want {
|
||||
t.Fatalf("code=%q message=%q got=%d want=%d", item.code, item.message, got, item.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatCandidatesSupport4K(t *testing.T) {
|
||||
candidates := []store.RuntimeModelCandidate{
|
||||
{Provider: "keling", Capabilities: map[string]any{"omni_video": map[string]any{"output_resolutions": []any{"720p", "1080p"}}}},
|
||||
{Provider: "keling", Capabilities: map[string]any{"omni_video": map[string]any{"output_resolutions": []any{"2160p"}}}},
|
||||
}
|
||||
if !kelingCompatCandidatesSupport4K(candidates) {
|
||||
t.Fatal("expected 2160p Keling capability to enable mode=4k")
|
||||
}
|
||||
if kelingCompatCandidatesSupport4K(candidates[:1]) {
|
||||
t.Fatal("mode=4k must stay disabled without an explicit capability")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling-compatible HTTP integration flow")
|
||||
}
|
||||
|
||||
var upstreamTaskSequence atomic.Int64
|
||||
var upstreamPayloadMu sync.Mutex
|
||||
var upstreamPayloads []map[string]any
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-keling-key" {
|
||||
t.Fatalf("unexpected upstream Authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/videos/omni-video":
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode upstream request: %v", err)
|
||||
}
|
||||
upstreamPayloadMu.Lock()
|
||||
upstreamPayloads = append(upstreamPayloads, payload)
|
||||
upstreamPayloadMu.Unlock()
|
||||
id := "upstream-" + strconv.FormatInt(upstreamTaskSequence.Add(1), 10)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "submit-" + id,
|
||||
"data": map[string]any{"task_id": id, "task_status": "submitted"},
|
||||
})
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/videos/omni-video/upstream-"):
|
||||
id := strings.TrimPrefix(r.URL.Path, "/videos/omni-video/")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "poll-" + id,
|
||||
"data": map[string]any{
|
||||
"task_id": id,
|
||||
"task_status": "succeed",
|
||||
"created_at": time.Now().UnixMilli(),
|
||||
"task_result": map[string]any{"videos": []any{map[string]any{
|
||||
"id": "video-" + id,
|
||||
"url": "https://example.com/" + id + ".mp4",
|
||||
"watermark_url": "https://example.com/" + id + "-watermark.mp4",
|
||||
"duration": "3",
|
||||
}}},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected upstream request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
platform, err := db.CreatePlatform(ctx, store.CreatePlatformInput{
|
||||
Provider: "keling",
|
||||
PlatformKey: "keling-compatible-test-" + suffix,
|
||||
Name: "Kling Compatible Test",
|
||||
BaseURL: upstream.URL,
|
||||
AuthType: "APIKey",
|
||||
Credentials: map[string]any{"apiKey": "upstream-keling-key"},
|
||||
Config: map[string]any{
|
||||
"kelingPollIntervalMs": 100,
|
||||
"kelingPollTimeoutSeconds": 5,
|
||||
},
|
||||
Priority: 1,
|
||||
Status: "enabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create test platform: %v", err)
|
||||
}
|
||||
_, err = db.CreatePlatformModel(ctx, store.CreatePlatformModelInput{
|
||||
PlatformID: platform.ID,
|
||||
CanonicalModelKey: "keling:kling-video-o1",
|
||||
ModelName: "kling-o1",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
ModelAlias: "kling-o1",
|
||||
ModelType: store.StringList{"omni_video", "video_generate"},
|
||||
DisplayName: "Kling O1 Compatible Test",
|
||||
Capabilities: map[string]any{
|
||||
"omni_video": map[string]any{
|
||||
"supported_modes": []any{"text_to_video", "image_reference"},
|
||||
"output_resolutions": []any{"720p", "1080p"},
|
||||
"aspect_ratio_allowed": []any{"16:9", "9:16", "1:1"},
|
||||
"duration_options": []any{3, 4, 5, 6, 7, 8, 9, 10},
|
||||
"output_audio": false,
|
||||
"max_images": 7,
|
||||
},
|
||||
"video_generate": map[string]any{
|
||||
"supported_modes": []any{"text_to_video"},
|
||||
"output_resolutions": []any{"720p", "1080p"},
|
||||
"aspect_ratio_allowed": []any{"16:9", "9:16", "1:1"},
|
||||
"duration_options": []any{3, 4, 5, 6, 7, 8, 9, 10},
|
||||
"output_audio": true,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create test platform model: %v", err)
|
||||
}
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
gateway := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer gateway.Close()
|
||||
|
||||
firstUserToken, firstAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "first", suffix, true)
|
||||
_ = firstUserToken
|
||||
_, secondAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "second", suffix, false)
|
||||
|
||||
var created KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodPost, "/v1/videos/omni-video", firstAPIKey, map[string]any{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "A clean product reveal",
|
||||
"mode": "std",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": "3",
|
||||
"sound": "off",
|
||||
"image_list": []any{map[string]any{"image_url": "https://example.com/reference.png"}},
|
||||
"external_task_id": "compat-http-1",
|
||||
}, http.StatusOK, &created)
|
||||
createdData, _ := created.Data.(map[string]any)
|
||||
if created.Code != 0 || created.RequestID == "" || strings.TrimSpace(stringFromKelingCompat(createdData["task_id"])) == "" || createdData["task_status"] != "submitted" {
|
||||
t.Fatalf("unexpected compatible create response: %+v", created)
|
||||
}
|
||||
taskID := stringFromKelingCompat(createdData["task_id"])
|
||||
|
||||
var hidden KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/v1/videos/omni-video/"+taskID, secondAPIKey, nil, http.StatusNotFound, &hidden)
|
||||
if hidden.Code != 1203 {
|
||||
t.Fatalf("cross-user task must be hidden: %+v", hidden)
|
||||
}
|
||||
|
||||
completed := waitForKelingCompatTask(t, gateway.URL, firstAPIKey, taskID, 5*time.Second)
|
||||
if completed.Code != 0 {
|
||||
t.Fatalf("compatible task failed: %+v", completed)
|
||||
}
|
||||
completedData, _ := completed.Data.(map[string]any)
|
||||
if completedData["task_status"] != "succeed" {
|
||||
t.Fatalf("compatible task did not succeed: %+v", completedData)
|
||||
}
|
||||
taskResult, _ := completedData["task_result"].(map[string]any)
|
||||
videos, _ := taskResult["videos"].([]any)
|
||||
video, _ := videos[0].(map[string]any)
|
||||
if video["id"] == "" || video["watermark_url"] == "" || video["duration"] != "3" {
|
||||
t.Fatalf("compatible result lost video metadata: %+v", video)
|
||||
}
|
||||
|
||||
var standard struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
doJSONWithHeaders(t, gateway.URL, http.MethodPost, "/api/v1/videos/generations", firstAPIKey, map[string]any{
|
||||
"model": "kling-o1",
|
||||
"prompt": "A second product reveal",
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 3,
|
||||
"audio": false,
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &standard)
|
||||
if standard.TaskID == "" {
|
||||
t.Fatal("standard video generation did not return taskId")
|
||||
}
|
||||
waitForTaskStatus(t, gateway.URL, firstAPIKey, standard.TaskID, []string{"succeeded"}, 5*time.Second)
|
||||
|
||||
var unsupportedAudio struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
doJSONWithHeaders(t, gateway.URL, http.MethodPost, "/api/v1/videos/generations", firstAPIKey, map[string]any{
|
||||
"model": "kling-o1",
|
||||
"prompt": "An O1 request that must not silently ignore audio",
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true,
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &unsupportedAudio)
|
||||
if unsupportedAudio.TaskID == "" {
|
||||
t.Fatal("unsupported O1 audio request did not return taskId")
|
||||
}
|
||||
waitForTaskStatus(t, gateway.URL, firstAPIKey, unsupportedAudio.TaskID, []string{"failed"}, 5*time.Second)
|
||||
var failedAudioTask store.GatewayTask
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/api/v1/tasks/"+unsupportedAudio.TaskID, firstAPIKey, nil, http.StatusOK, &failedAudioTask)
|
||||
if failedAudioTask.ErrorCode != "invalid_parameter" || !strings.Contains(failedAudioTask.ErrorMessage, "does not support generated audio") {
|
||||
t.Fatalf("O1 audio request must fail visibly before upstream submission: %+v", failedAudioTask)
|
||||
}
|
||||
|
||||
upstreamPayloadMu.Lock()
|
||||
defer upstreamPayloadMu.Unlock()
|
||||
if len(upstreamPayloads) != 2 {
|
||||
t.Fatalf("expected two upstream submissions, got %d", len(upstreamPayloads))
|
||||
}
|
||||
compatiblePayload := upstreamPayloads[0]
|
||||
if compatiblePayload["model_name"] != "kling-video-o1" || compatiblePayload["mode"] != "std" || compatiblePayload["sound"] != "off" || compatiblePayload["duration"] != "3" || compatiblePayload["aspect_ratio"] != "16:9" || compatiblePayload["external_task_id"] != "compat-http-1" {
|
||||
t.Fatalf("unexpected compatible upstream payload: %+v", compatiblePayload)
|
||||
}
|
||||
}
|
||||
|
||||
func createKelingCompatIntegrationUser(t *testing.T, ctx context.Context, db *store.Store, baseURL string, prefix string, suffix string, fund bool) (string, string) {
|
||||
t.Helper()
|
||||
username := fmt.Sprintf("kling_compat_%s_%s", prefix, suffix)
|
||||
password := "password123"
|
||||
var registered struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®istered)
|
||||
var apiKey struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/api-keys", registered.AccessToken, map[string]any{
|
||||
"name": "Kling compatible integration key",
|
||||
"scopes": []string{"video"},
|
||||
}, http.StatusCreated, &apiKey)
|
||||
if fund {
|
||||
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote integration user: %v", err)
|
||||
}
|
||||
var loggedIn struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username,
|
||||
"password": password,
|
||||
}, http.StatusOK, &loggedIn)
|
||||
var gatewayUserID string
|
||||
if err := db.Pool().QueryRow(ctx, `SELECT id::text FROM gateway_users WHERE username = $1`, username).Scan(&gatewayUserID); err != nil {
|
||||
t.Fatalf("read integration user id: %v", err)
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPatch, "/api/admin/users/"+gatewayUserID+"/wallet", loggedIn.AccessToken, map[string]any{
|
||||
"currency": "resource",
|
||||
"balance": 1000,
|
||||
"reason": "seed Kling compatible integration wallet",
|
||||
}, http.StatusOK, nil)
|
||||
}
|
||||
return registered.AccessToken, apiKey.Secret
|
||||
}
|
||||
|
||||
func waitForKelingCompatTask(t *testing.T, baseURL string, apiKey string, taskID string, timeout time.Duration) KelingCompatibleEnvelope {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
var response KelingCompatibleEnvelope
|
||||
doJSON(t, baseURL, http.MethodGet, "/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response.Data.(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
return response
|
||||
case "failed":
|
||||
t.Fatalf("Kling-compatible task failed: %+v", response)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for Kling-compatible task %s", taskID)
|
||||
return KelingCompatibleEnvelope{}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKeling30TurboSimulationFlow(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling simulation integration flow")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer server.Close()
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
username := "kling_sim_" + suffix
|
||||
password := "password123"
|
||||
var registerResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®isterResponse)
|
||||
|
||||
var apiKeyResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", registerResponse.AccessToken, map[string]any{
|
||||
"name": "kling simulation key",
|
||||
}, http.StatusCreated, &apiKeyResponse)
|
||||
|
||||
if _, err := db.Pool().Exec(ctx, `
|
||||
UPDATE gateway_users
|
||||
SET roles = '["admin"]'::jsonb
|
||||
WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote Kling simulation user: %v", err)
|
||||
}
|
||||
var loginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username,
|
||||
"password": password,
|
||||
}, http.StatusOK, &loginResponse)
|
||||
|
||||
var gatewayUserID string
|
||||
if err := db.Pool().QueryRow(
|
||||
ctx,
|
||||
`SELECT id::text FROM gateway_users WHERE username = $1`,
|
||||
username,
|
||||
).Scan(&gatewayUserID); err != nil {
|
||||
t.Fatalf("read Kling simulation user id: %v", err)
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPatch, "/api/admin/users/"+gatewayUserID+"/wallet", loginResponse.AccessToken, map[string]any{
|
||||
"currency": "resource",
|
||||
"balance": 1000,
|
||||
"reason": "seed Kling simulation wallet",
|
||||
}, http.StatusOK, nil)
|
||||
|
||||
var platform struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
|
||||
"provider": "keling",
|
||||
"platformKey": "keling-simulation-" + suffix,
|
||||
"name": "Kling Simulation",
|
||||
"baseUrl": "https://api-beijing.klingai.com/v1",
|
||||
"authType": "AccessKey-SecretKey",
|
||||
"credentials": map[string]any{
|
||||
"accessKey": "legacy-ak",
|
||||
"secretKey": "legacy-sk",
|
||||
},
|
||||
}, http.StatusCreated, &platform)
|
||||
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
|
||||
"canonicalModelKey": "keling:kling-3.0-turbo",
|
||||
"modelName": "kling-3.0-turbo",
|
||||
"providerModelName": "kling-3.0-turbo",
|
||||
"modelAlias": "可灵3.0 Turbo",
|
||||
"modelType": []string{"video_generate", "image_to_video"},
|
||||
"displayName": "可灵3.0 Turbo",
|
||||
}, http.StatusCreated, nil)
|
||||
|
||||
assertKeling30TurboSimulationTask := func(
|
||||
name string,
|
||||
request map[string]any,
|
||||
expectedModelType string,
|
||||
) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var response struct {
|
||||
Task struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
RunMode string `json:"runMode"`
|
||||
ModelType string `json:"modelType"`
|
||||
ResolvedModel string `json:"resolvedModel"`
|
||||
Result map[string]any `json:"result"`
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
BillingSummary map[string]any `json:"billingSummary"`
|
||||
FinalChargeAmount float64 `json:"finalChargeAmount"`
|
||||
ResponseDurationMS int64 `json:"responseDurationMs"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(
|
||||
t,
|
||||
server.URL,
|
||||
http.MethodPost,
|
||||
"/api/v1/videos/generations",
|
||||
apiKeyResponse.Secret,
|
||||
request,
|
||||
http.StatusAccepted,
|
||||
&response,
|
||||
)
|
||||
|
||||
task := response.Task
|
||||
if task.ID == "" ||
|
||||
task.Status != "succeeded" ||
|
||||
task.RunMode != "simulation" ||
|
||||
task.ModelType != expectedModelType ||
|
||||
task.ResolvedModel != "kling-3.0-turbo" {
|
||||
t.Fatalf("unexpected Kling simulation task: %+v", task)
|
||||
}
|
||||
data, _ := task.Result["data"].([]any)
|
||||
item, _ := data[0].(map[string]any)
|
||||
if item["video_url"] != "/static/simulation/video.mp4" ||
|
||||
item["assetSource"] != "simulation" {
|
||||
t.Fatalf("unexpected Kling simulation result: %+v", task.Result)
|
||||
}
|
||||
if task.FinalChargeAmount <= 0 ||
|
||||
task.BillingSummary["finalCharge"] == nil ||
|
||||
task.Metrics["parameterPreprocessingSummary"] == nil ||
|
||||
task.ResponseDurationMS <= 0 {
|
||||
t.Fatalf("Kling simulation should preserve billing, preprocessing and timing: %+v", task)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
assertKeling30TurboSimulationTask("text-to-video", map[string]any{
|
||||
"model": "可灵3.0 Turbo",
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"prompt": "A cinematic city reveal",
|
||||
"duration": 8,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": true,
|
||||
}, "video_generate")
|
||||
|
||||
assertKeling30TurboSimulationTask("image-to-video", map[string]any{
|
||||
"model": "可灵3.0 Turbo",
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"prompt": "The subject looks toward the camera",
|
||||
"image": "https://example.com/first.png",
|
||||
"duration": 5,
|
||||
"resolution": "720p",
|
||||
"audio": true,
|
||||
}, "image_to_video")
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
klingCompatProvider = "kling"
|
||||
klingO1Model = "kling-video-o1"
|
||||
klingV3OmniModel = "kling-v3-omni"
|
||||
)
|
||||
|
||||
func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
|
||||
handler := func(next http.HandlerFunc) http.Handler {
|
||||
return s.requireUser(auth.PermissionBasic, http.HandlerFunc(next))
|
||||
}
|
||||
mux.Handle("POST /kling/v1/videos/omni-video", handler(s.klingV1CreateOmniVideo))
|
||||
mux.Handle("GET /kling/v1/videos/omni-video", handler(s.klingV1ListOmniVideos))
|
||||
mux.Handle("GET /kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo))
|
||||
|
||||
// Kling API 2.0 uses model-specific paths and a shared /tasks resource.
|
||||
mux.Handle("POST /kling/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
|
||||
mux.Handle("GET /kling/tasks", handler(s.klingV2GetTasks))
|
||||
mux.Handle("POST /kling/tasks", handler(s.klingV2ListTasks))
|
||||
// Versioned aliases help clients that keep the protocol version in their base path.
|
||||
mux.Handle("POST /kling/v2/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
|
||||
mux.Handle("GET /kling/v2/tasks", handler(s.klingV2GetTasks))
|
||||
mux.Handle("POST /kling/v2/tasks", handler(s.klingV2ListTasks))
|
||||
}
|
||||
|
||||
// klingV1CreateOmniVideo godoc
|
||||
// @Summary 创建可灵 V1 Omni 视频任务
|
||||
// @Description 兼容中国区可灵 V1 /v1/videos/omni-video;用户使用网关 API Key,网关在服务端使用 AK/SK 调用上游。
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param input body map[string]interface{} true "可灵 V1 Omni 请求"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /kling/v1/videos/omni-video [post]
|
||||
func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
var native map[string]any
|
||||
if err := decodeKlingJSON(r, &native); err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
|
||||
return
|
||||
}
|
||||
model := strings.TrimSpace(stringFromRequestAny(native["model_name"]))
|
||||
if model == "" {
|
||||
model = klingO1Model
|
||||
}
|
||||
s.createKlingCompatTask(w, r, "v1", model, native)
|
||||
}
|
||||
|
||||
// klingV2CreateOmniVideo godoc
|
||||
// @Summary 创建可灵 API 2.0 Omni 视频任务
|
||||
// @Description 兼容可灵 API 2.0 的模型路径;调用方使用网关 API Key,网关转换并使用中国区 V1 AK/SK 上游。
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param model path string true "模型路径(kling-o1 或 kling-v3-omni)"
|
||||
// @Param input body map[string]interface{} true "可灵 API 2.0 请求"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /kling/omni-video/{model} [post]
|
||||
func (s *Server) klingV2CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
model, ok := klingV2ProviderModel(r.PathValue("model"))
|
||||
if !ok {
|
||||
writeKlingCompatError(w, http.StatusNotFound, "unsupported Kling Omni model", "model_not_found")
|
||||
return
|
||||
}
|
||||
var native map[string]any
|
||||
if err := decodeKlingJSON(r, &native); err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
|
||||
return
|
||||
}
|
||||
s.createKlingCompatTask(w, r, "v2", model, native)
|
||||
}
|
||||
|
||||
func (s *Server) createKlingCompatTask(w http.ResponseWriter, r *http.Request, version string, model string, native map[string]any) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeKlingCompatError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
|
||||
return
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||||
writeKlingCompatError(w, http.StatusForbidden, "api key scope does not allow video generation", "permission_denied")
|
||||
return
|
||||
}
|
||||
body, externalTaskID, err := klingCompatTaskBody(version, model, native)
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
prepared, err := s.prepareTaskRequest(r.Context(), r, user, body)
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
createInput := store.CreateTaskInput{
|
||||
Kind: "videos.generations",
|
||||
Model: model,
|
||||
ExternalTaskID: externalTaskID,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
Async: true,
|
||||
Request: prepared.Body,
|
||||
}
|
||||
if idempotencyKey, hasKey, keyErr := optionalTaskIdempotencyKey(r); keyErr != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid Idempotency-Key", "invalid_idempotency_key")
|
||||
return
|
||||
} else if hasKey {
|
||||
createInput.IdempotencyKeyHash = taskIdempotencyKeyHash(idempotencyKey)
|
||||
createInput.IdempotencyRequestHash = taskIdempotencyRequestHash(createInput.Kind, true, false, prepared.Body)
|
||||
}
|
||||
created, err := s.store.CreateTaskIdempotent(r.Context(), createInput, user)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrIdempotencyKeyReused):
|
||||
writeKlingCompatError(w, http.StatusConflict, err.Error(), "idempotency_key_reused")
|
||||
case errors.Is(err, store.ErrExternalTaskIDReused):
|
||||
writeKlingCompatError(w, http.StatusConflict, err.Error(), "external_task_id_reused")
|
||||
default:
|
||||
s.logger.Error("create Kling compatibility task failed", "version", version, "model", model, "error", err)
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
task := created.Task
|
||||
if !created.Replayed {
|
||||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, err.Error(), "enqueue_failed")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
}
|
||||
if version == "v2" {
|
||||
writeJSON(w, http.StatusOK, klingV2Envelope(task))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, klingV1Envelope(task))
|
||||
}
|
||||
|
||||
func klingCompatTaskBody(version string, model string, native map[string]any) (map[string]any, string, error) {
|
||||
if model != klingO1Model && model != klingV3OmniModel {
|
||||
return nil, "", &clients.ClientError{Code: "invalid_parameter", Message: "model_name must be kling-video-o1 or kling-v3-omni", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if native == nil {
|
||||
native = map[string]any{}
|
||||
}
|
||||
body := cloneMap(native)
|
||||
if version == "v2" {
|
||||
body = klingV2ToLegacyBody(native)
|
||||
}
|
||||
body["model"] = model
|
||||
body["modelType"] = "omni_video"
|
||||
body["_compat_provider"] = klingCompatProvider
|
||||
body["_kling_compat_version"] = version
|
||||
body["content"] = klingLegacyContent(body)
|
||||
mode := strings.TrimSpace(stringFromRequestAny(body["mode"]))
|
||||
if mode == "" && version == "v1" {
|
||||
// The legacy Omni API defaults to professional (1080p) mode.
|
||||
mode = "pro"
|
||||
body["mode"] = mode
|
||||
}
|
||||
if mode != "" {
|
||||
resolution, ok := klingResolutionFromMode(mode)
|
||||
if !ok {
|
||||
return nil, "", &clients.ClientError{Code: "invalid_parameter", Message: "mode must be std, pro, or 4k", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
body["resolution"] = resolution
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["sound"])), "on") {
|
||||
body["audio"] = true
|
||||
}
|
||||
externalTaskID := strings.TrimSpace(stringFromRequestAny(body["external_task_id"]))
|
||||
if err := validateKlingCompatBody(model, body); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return body, externalTaskID, nil
|
||||
}
|
||||
|
||||
func klingV2ToLegacyBody(native map[string]any) map[string]any {
|
||||
body := map[string]any{}
|
||||
for _, key := range []string{"runMode", "simulation", "simulationDurationMs", "simulationProfile"} {
|
||||
if value, ok := native[key]; ok {
|
||||
body[key] = value
|
||||
}
|
||||
}
|
||||
settings, _ := native["settings"].(map[string]any)
|
||||
options, _ := native["options"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
if options == nil {
|
||||
options = map[string]any{}
|
||||
}
|
||||
if resolution := strings.TrimSpace(stringFromRequestAny(settings["resolution"])); resolution != "" {
|
||||
switch strings.ToLower(resolution) {
|
||||
case "720p":
|
||||
body["mode"] = "std"
|
||||
case "1080p":
|
||||
body["mode"] = "pro"
|
||||
case "4k", "2160p":
|
||||
body["mode"] = "4k"
|
||||
default:
|
||||
body["mode"] = resolution
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"aspect_ratio", "duration", "multi_shot", "shot_type", "multi_prompt"} {
|
||||
if value, ok := settings[key]; ok {
|
||||
body[key] = value
|
||||
}
|
||||
}
|
||||
audio := strings.ToLower(strings.TrimSpace(stringFromRequestAny(settings["audio"])))
|
||||
if audio == "native" || audio == "on" {
|
||||
body["sound"] = "on"
|
||||
} else {
|
||||
body["sound"] = "off"
|
||||
}
|
||||
for _, key := range []string{"callback_url", "external_task_id", "watermark_info"} {
|
||||
if value, ok := options[key]; ok {
|
||||
body[key] = value
|
||||
}
|
||||
}
|
||||
contents, _ := native["contents"].([]any)
|
||||
imageList := make([]any, 0)
|
||||
videoList := make([]any, 0)
|
||||
elementList := make([]any, 0)
|
||||
for _, raw := range contents {
|
||||
item, _ := raw.(map[string]any)
|
||||
kind := strings.ToLower(strings.TrimSpace(stringFromRequestAny(item["type"])))
|
||||
switch kind {
|
||||
case "prompt":
|
||||
body["prompt"] = stringFromRequestAny(item["text"])
|
||||
case "first_frame", "last_frame", "refer_image", "reference_image":
|
||||
image := map[string]any{"image_url": firstNonEmptyRequestString(item, "url", "image_url")}
|
||||
if kind == "first_frame" {
|
||||
image["type"] = "first_frame"
|
||||
} else if kind == "last_frame" {
|
||||
image["type"] = "end_frame"
|
||||
}
|
||||
imageList = append(imageList, image)
|
||||
case "feature_video", "base_video", "refer_video", "reference_video":
|
||||
referType := "feature"
|
||||
if kind == "base_video" {
|
||||
referType = "base"
|
||||
}
|
||||
video := map[string]any{
|
||||
"video_url": firstNonEmptyRequestString(item, "url", "video_url"),
|
||||
"refer_type": referType,
|
||||
"keep_original_sound": firstNonEmptyRequestString(item, "keep_original_sound", "keepOriginalSound"),
|
||||
}
|
||||
if audio == "original" && video["keep_original_sound"] == "" {
|
||||
video["keep_original_sound"] = "yes"
|
||||
}
|
||||
videoList = append(videoList, video)
|
||||
case "element":
|
||||
elementList = append(elementList, map[string]any{"element_id": firstPresentRequest(item["element_id"], item["id"])})
|
||||
}
|
||||
}
|
||||
if len(imageList) > 0 {
|
||||
body["image_list"] = imageList
|
||||
}
|
||||
if len(videoList) > 0 {
|
||||
body["video_list"] = videoList
|
||||
}
|
||||
if len(elementList) > 0 {
|
||||
body["element_list"] = elementList
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func klingLegacyContent(body map[string]any) []any {
|
||||
content := make([]any, 0)
|
||||
if prompt := strings.TrimSpace(stringFromRequestAny(body["prompt"])); prompt != "" {
|
||||
content = append(content, map[string]any{"type": "text", "text": prompt})
|
||||
}
|
||||
for _, raw := range mapListFromRequest(body["image_list"]) {
|
||||
role := "reference_image"
|
||||
switch strings.TrimSpace(stringFromRequestAny(raw["type"])) {
|
||||
case "first_frame":
|
||||
role = "first_frame"
|
||||
case "end_frame", "last_frame":
|
||||
role = "last_frame"
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "image_url", "role": role,
|
||||
"image_url": map[string]any{"url": firstNonEmptyRequestString(raw, "image_url", "url")},
|
||||
})
|
||||
}
|
||||
for _, raw := range mapListFromRequest(body["video_list"]) {
|
||||
referType := firstNonEmptyRequestString(raw, "refer_type", "referType")
|
||||
role := "video_feature"
|
||||
if referType == "base" {
|
||||
role = "video_base"
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "video_url", "role": role,
|
||||
"video_url": map[string]any{
|
||||
"url": firstNonEmptyRequestString(raw, "video_url", "url"),
|
||||
"refer_type": referType,
|
||||
"keep_original_sound": firstNonEmptyRequestString(raw, "keep_original_sound", "keepOriginalSound"),
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, raw := range mapListFromRequest(body["element_list"]) {
|
||||
content = append(content, map[string]any{
|
||||
"type": "element",
|
||||
"element": map[string]any{"element_id": firstPresentRequest(raw["element_id"], raw["id"])},
|
||||
})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func validateKlingCompatBody(model string, body map[string]any) error {
|
||||
maxDuration := 10
|
||||
if model == klingV3OmniModel {
|
||||
maxDuration = 15
|
||||
}
|
||||
if duration, ok := klingCompatInt(body["duration"]); ok && (duration < 3 || duration > maxDuration) {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("duration must be between 3 and %d seconds for %s", maxDuration, model), StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if model == klingO1Model && boolFromRequestAny(body["multi_shot"]) {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support multi_shot", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if model == klingO1Model && strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["mode"])), "4k") {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support 4k mode", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if ratio := strings.TrimSpace(stringFromRequestAny(body["aspect_ratio"])); ratio != "" && ratio != "16:9" && ratio != "9:16" && ratio != "1:1" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "aspect_ratio must be 16:9, 9:16, or 1:1", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if sound := strings.ToLower(strings.TrimSpace(stringFromRequestAny(body["sound"]))); sound != "" && sound != "on" && sound != "off" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "sound must be on or off", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
prompt := strings.TrimSpace(stringFromRequestAny(body["prompt"]))
|
||||
if utf8.RuneCountInString(prompt) > 2500 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "prompt must not exceed 2500 characters", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
images := mapListFromRequest(body["image_list"])
|
||||
videos := mapListFromRequest(body["video_list"])
|
||||
elements := mapListFromRequest(body["element_list"])
|
||||
for _, image := range images {
|
||||
if firstNonEmptyRequestString(image, "image_url", "url") == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every image_list item requires image_url", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
for _, video := range videos {
|
||||
if firstNonEmptyRequestString(video, "video_url", "url") == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every video_list item requires video_url", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if referType := strings.TrimSpace(firstNonEmptyRequestString(video, "refer_type", "referType")); referType != "" && referType != "base" && referType != "feature" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "video refer_type must be base or feature", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
for _, element := range elements {
|
||||
if klingStringAny(firstPresentRequest(element["element_id"], element["id"])) == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every element_list item requires element_id", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
if model == klingO1Model && len(images) == 0 && len(videos) == 0 && len(elements) == 0 {
|
||||
if duration, ok := klingCompatInt(body["duration"]); ok && duration != 5 && duration != 10 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-video-o1 text-only generation supports duration 5 or 10 seconds", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
if len(videos) > 1 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "video_list supports at most one video", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if len(images)+len(elements) > 7 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "image_list and element_list support at most seven combined references", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if len(videos) > 0 && len(images)+len(elements) > 4 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "requests with video input support at most four image and element references", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if len(videos) > 0 && strings.EqualFold(strings.TrimSpace(stringFromRequestAny(body["sound"])), "on") {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "sound must be off when video_list is provided", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if model == klingV3OmniModel && len(videos) > 0 {
|
||||
if duration, ok := klingCompatInt(body["duration"]); ok && duration > 10 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "kling-v3-omni video-reference generation supports at most 10 seconds", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
multiShot := boolFromRequestAny(body["multi_shot"])
|
||||
shotType := strings.ToLower(strings.TrimSpace(stringFromRequestAny(body["shot_type"])))
|
||||
if shotType != "" && shotType != "customize" && shotType != "intelligence" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "shot_type must be customize or intelligence", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
if multiShot && shotType == "customize" {
|
||||
multiPrompt := mapListFromRequest(body["multi_prompt"])
|
||||
if len(multiPrompt) == 0 || len(multiPrompt) > 6 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "customize multi-shot requires between one and six multi_prompt items", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
totalDuration := 0
|
||||
for _, shot := range multiPrompt {
|
||||
shotPrompt := strings.TrimSpace(stringFromRequestAny(shot["prompt"]))
|
||||
shotDuration, ok := klingCompatInt(shot["duration"])
|
||||
if shotPrompt == "" || utf8.RuneCountInString(shotPrompt) > 2500 || !ok || shotDuration <= 0 {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "every multi_prompt item requires prompt and a positive integer duration", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
totalDuration += shotDuration
|
||||
}
|
||||
if totalDuration < 3 || totalDuration > maxDuration {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("multi_prompt duration must total between 3 and %d seconds", maxDuration), StatusCode: 400, Retryable: false}
|
||||
}
|
||||
}
|
||||
if (!multiShot || shotType == "intelligence" || shotType == "") && prompt == "" {
|
||||
return &clients.ClientError{Code: "invalid_parameter", Message: "prompt is required for single-shot and intelligence multi-shot generation", StatusCode: 400, Retryable: false}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// klingV1GetOmniVideo godoc
|
||||
// @Summary 查询可灵 V1 Omni 视频任务
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param taskID path string true "任务 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]interface{}
|
||||
// @Router /kling/v1/videos/omni-video/{taskID} [get]
|
||||
func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v1", r.PathValue("taskID"))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeKlingCompatError(w, http.StatusNotFound, "task not found", "task_not_found")
|
||||
return
|
||||
}
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "get task failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, klingV1Envelope(task))
|
||||
}
|
||||
|
||||
// klingV1ListOmniVideos godoc
|
||||
// @Summary 分页查询可灵 V1 Omni 视频任务
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param pageNum query int false "页码" default(1)
|
||||
// @Param pageSize query int false "每页数量" default(30)
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/v1/videos/omni-video [get]
|
||||
func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
page, err := positiveQueryInt(r.URL.Query().Get("pageNum"), 1)
|
||||
if err != nil || page > 1000 {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid pageNum", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
pageSize, err := positiveQueryInt(r.URL.Query().Get("pageSize"), 30)
|
||||
if err != nil || pageSize > 500 {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid pageSize", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
result, err := s.store.ListCompatTasks(r.Context(), user, store.CompatTaskListFilter{Provider: klingCompatProvider, Version: "v1", Page: page, PageSize: pageSize})
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "list tasks failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
data := make([]any, 0, len(result.Items))
|
||||
for _, task := range result.Items {
|
||||
data = append(data, klingV1TaskData(task))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"code": 0, "message": "success", "request_id": klingRequestID(result.Items), "data": data})
|
||||
}
|
||||
|
||||
// klingV2GetTasks godoc
|
||||
// @Summary 按 ID 查询可灵 API 2.0 任务
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param task_ids query string false "逗号分隔的任务 ID"
|
||||
// @Param external_task_ids query string false "逗号分隔的外部任务 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/tasks [get]
|
||||
func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
taskIDs := splitKlingIDs(r.URL.Query().Get("task_ids"))
|
||||
externalIDs := splitKlingIDs(r.URL.Query().Get("external_task_ids"))
|
||||
if (len(taskIDs) == 0) == (len(externalIDs) == 0) {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "choose exactly one of task_ids or external_task_ids", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
identifiers := taskIDs
|
||||
if len(externalIDs) > 0 {
|
||||
identifiers = externalIDs
|
||||
}
|
||||
data := make([]any, 0, len(identifiers))
|
||||
for _, identifier := range identifiers {
|
||||
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v2", identifier)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "get task failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
data = append(data, klingV2TaskData(task))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"code": 0, "message": "success", "request_id": klingRequestIDFromAny(data), "data": data})
|
||||
}
|
||||
|
||||
// klingV2ListTasks godoc
|
||||
// @Summary 分页查询可灵 API 2.0 任务
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param input body map[string]interface{} true "游标、数量、时间范围和筛选条件"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/tasks [post]
|
||||
func (s *Server) klingV2ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
var body map[string]any
|
||||
if err := decodeKlingJSON(r, &body); err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid json body", "invalid_json_body")
|
||||
return
|
||||
}
|
||||
page, err := klingCursorPage(stringFromRequestAny(body["cursor"]))
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid cursor", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
limit, ok := klingCompatInt(body["limit"])
|
||||
if !ok || limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 500 {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "limit must not exceed 500", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
createdFrom, err := klingMillisTime(body["start_time"])
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid start_time", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
createdTo, err := klingMillisTime(body["end_time"])
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusBadRequest, "invalid end_time", "invalid_parameter")
|
||||
return
|
||||
}
|
||||
statuses := klingInternalStatuses(body["filters"])
|
||||
result, err := s.store.ListCompatTasks(r.Context(), user, store.CompatTaskListFilter{
|
||||
Provider: klingCompatProvider, Version: "v2", Statuses: statuses,
|
||||
CreatedFrom: createdFrom, CreatedTo: createdTo, Page: page, PageSize: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeKlingCompatError(w, http.StatusInternalServerError, "list tasks failed", "task_query_failed")
|
||||
return
|
||||
}
|
||||
items := make([]any, 0, len(result.Items))
|
||||
for _, task := range result.Items {
|
||||
items = append(items, klingV2TaskData(task))
|
||||
}
|
||||
hasMore := page*limit < result.Total
|
||||
nextCursor := ""
|
||||
if hasMore {
|
||||
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(page + 1)))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"code": 0, "message": "success", "request_id": klingRequestID(result.Items),
|
||||
"data": map[string]any{"result": items, "count": len(items), "next_cursor": nextCursor, "has_more": hasMore},
|
||||
})
|
||||
}
|
||||
|
||||
func klingV1Envelope(task store.GatewayTask) map[string]any {
|
||||
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV1TaskData(task)}
|
||||
}
|
||||
|
||||
func klingV1TaskData(task store.GatewayTask) map[string]any {
|
||||
data := map[string]any{
|
||||
"task_id": task.ID, "task_status": klingV1Status(task.Status),
|
||||
"task_info": map[string]any{"external_task_id": task.ExternalTaskID},
|
||||
"created_at": task.CreatedAt.UnixMilli(), "updated_at": task.UpdatedAt.UnixMilli(),
|
||||
}
|
||||
if task.ErrorMessage != "" || task.Error != "" {
|
||||
data["task_status_msg"] = firstNonEmpty(task.ErrorMessage, task.Error)
|
||||
}
|
||||
if watermarkInfo, ok := task.Request["watermark_info"].(map[string]any); ok {
|
||||
data["watermark_info"] = watermarkInfo
|
||||
}
|
||||
videos := klingTaskVideos(task)
|
||||
if len(videos) > 0 {
|
||||
data["task_result"] = map[string]any{"videos": videos}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func klingV2Envelope(task store.GatewayTask) map[string]any {
|
||||
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV2TaskData(task)}
|
||||
}
|
||||
|
||||
func klingV2TaskData(task store.GatewayTask) map[string]any {
|
||||
data := map[string]any{
|
||||
"id": task.ID, "status": klingV2Status(task.Status),
|
||||
"create_time": task.CreatedAt.UnixMilli(), "update_time": task.UpdatedAt.UnixMilli(),
|
||||
"external_id": task.ExternalTaskID,
|
||||
}
|
||||
if message := firstNonEmpty(task.ErrorMessage, task.Error); message != "" {
|
||||
data["message"] = message
|
||||
}
|
||||
if outputs := klingV2Outputs(task); len(outputs) > 0 {
|
||||
data["outputs"] = outputs
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func klingTaskVideos(task store.GatewayTask) []any {
|
||||
items, _ := task.Result["data"].([]any)
|
||||
videos := make([]any, 0, len(items))
|
||||
for index, raw := range items {
|
||||
item, _ := raw.(map[string]any)
|
||||
url := firstNonEmptyRequestString(item, "url", "video_url")
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
video := map[string]any{"id": firstNonEmptyRequestString(item, "id")}
|
||||
if video["id"] == "" {
|
||||
video["id"] = fmt.Sprintf("%s-%d", task.ID, index+1)
|
||||
}
|
||||
video["url"] = url
|
||||
if watermarkURL := firstNonEmptyRequestString(item, "watermark_url"); watermarkURL != "" {
|
||||
video["watermark_url"] = watermarkURL
|
||||
}
|
||||
if duration := klingStringAny(item["duration"]); duration != "" {
|
||||
video["duration"] = duration
|
||||
}
|
||||
videos = append(videos, video)
|
||||
}
|
||||
return videos
|
||||
}
|
||||
|
||||
func klingV2Outputs(task store.GatewayTask) []any {
|
||||
videos := klingTaskVideos(task)
|
||||
outputs := make([]any, 0, len(videos))
|
||||
for _, raw := range videos {
|
||||
video, _ := raw.(map[string]any)
|
||||
output := cloneMap(video)
|
||||
output["type"] = "video"
|
||||
outputs = append(outputs, output)
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
func klingV1Status(status string) string {
|
||||
switch status {
|
||||
case "succeeded":
|
||||
return "succeed"
|
||||
case "failed", "cancelled":
|
||||
return "failed"
|
||||
case "running":
|
||||
return "processing"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func klingV2Status(status string) string {
|
||||
switch status {
|
||||
case "succeeded":
|
||||
return "succeeded"
|
||||
case "failed", "cancelled":
|
||||
return "failed"
|
||||
case "running":
|
||||
return "processing"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func klingV2ProviderModel(pathModel string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(pathModel)) {
|
||||
case "kling-o1", "kling-video-o1":
|
||||
return klingO1Model, true
|
||||
case "kling-v3-omni", "kling-3.0-omni", "kling-3-omni":
|
||||
return klingV3OmniModel, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func klingResolutionFromMode(mode string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "std":
|
||||
return "720p", true
|
||||
case "pro":
|
||||
return "1080p", true
|
||||
case "4k":
|
||||
return "2160p", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func decodeKlingJSON(r *http.Request, target any) error {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("multiple json values")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeKlingCompatError(w http.ResponseWriter, status int, message string, code string) {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
code = "invalid_request"
|
||||
}
|
||||
writeJSON(w, status, map[string]any{
|
||||
"code": klingCompatErrorCode(status),
|
||||
"message": message,
|
||||
"request_id": "",
|
||||
"error": code,
|
||||
})
|
||||
}
|
||||
|
||||
func klingCompatErrorCode(status int) int {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return 1001
|
||||
case http.StatusUnauthorized:
|
||||
return 1100
|
||||
case http.StatusForbidden:
|
||||
return 1302
|
||||
case http.StatusNotFound:
|
||||
return 1201
|
||||
case http.StatusConflict:
|
||||
return 1200
|
||||
case http.StatusTooManyRequests:
|
||||
return 1400
|
||||
default:
|
||||
return 5000
|
||||
}
|
||||
}
|
||||
|
||||
func mapListFromRequest(value any) []map[string]any {
|
||||
items, _ := value.([]any)
|
||||
if len(items) == 0 {
|
||||
if typed, ok := value.([]map[string]any); ok {
|
||||
return typed
|
||||
}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if mapped, ok := item.(map[string]any); ok {
|
||||
out = append(out, mapped)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstPresentRequest(values ...any) any {
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
if strings.TrimSpace(text) != "" {
|
||||
return value
|
||||
}
|
||||
continue
|
||||
}
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolFromRequestAny(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case string:
|
||||
parsed, _ := strconv.ParseBool(strings.TrimSpace(typed))
|
||||
return parsed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func klingCompatInt(value any) (int, bool) {
|
||||
text := klingStringAny(value)
|
||||
if text == "" {
|
||||
return 0, false
|
||||
}
|
||||
number, err := strconv.Atoi(text)
|
||||
return number, err == nil
|
||||
}
|
||||
|
||||
func splitKlingIDs(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func klingCursorPage(cursor string) (int, error) {
|
||||
cursor = strings.TrimSpace(cursor)
|
||||
if cursor == "" {
|
||||
return 1, nil
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
page, err := strconv.Atoi(string(decoded))
|
||||
if err != nil || page <= 0 {
|
||||
return 0, errors.New("invalid cursor")
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func klingMillisTime(value any) (*time.Time, error) {
|
||||
text := klingStringAny(value)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
millis, err := strconv.ParseInt(text, 10, 64)
|
||||
if err != nil || millis < 0 {
|
||||
return nil, errors.New("invalid millisecond timestamp")
|
||||
}
|
||||
parsed := time.UnixMilli(millis)
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func klingInternalStatuses(filters any) []string {
|
||||
statuses := make([]string, 0)
|
||||
for _, filter := range mapListFromRequest(filters) {
|
||||
if stringFromRequestAny(filter["key"]) != "status" {
|
||||
continue
|
||||
}
|
||||
values, _ := filter["values"].([]any)
|
||||
for _, value := range values {
|
||||
switch strings.ToLower(strings.TrimSpace(stringFromRequestAny(value))) {
|
||||
case "submitted":
|
||||
statuses = append(statuses, "queued")
|
||||
case "processing":
|
||||
statuses = append(statuses, "running")
|
||||
case "succeeded":
|
||||
statuses = append(statuses, "succeeded")
|
||||
case "failed":
|
||||
statuses = append(statuses, "failed", "cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
func klingRequestID(tasks []store.GatewayTask) string {
|
||||
if len(tasks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return firstNonEmpty(tasks[0].RequestID, tasks[0].ID)
|
||||
}
|
||||
|
||||
func klingRequestIDFromAny(items []any) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
item, _ := items[0].(map[string]any)
|
||||
return stringFromRequestAny(item["id"])
|
||||
}
|
||||
|
||||
func klingStringAny(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(typed), 'f', -1, 32)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKlingCompatibilitySimulationFlow(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling compatibility integration flow")
|
||||
}
|
||||
ctx := context.Background()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var upgradedBaseModels int
|
||||
if err := db.Pool().QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM base_model_catalog
|
||||
WHERE provider_key = 'keling'
|
||||
AND provider_model_name IN ('kling-video-o1', 'kling-v3-omni')
|
||||
AND model_type @> '["video_generate","image_to_video","omni_video"]'::jsonb
|
||||
AND capabilities ? 'video_generate'
|
||||
AND capabilities ? 'image_to_video'
|
||||
AND capabilities ? 'omni_video'
|
||||
AND metadata->'rawModel'->'types' @> '["video_generate","image_to_video","omni_video"]'::jsonb`).Scan(&upgradedBaseModels); err != nil {
|
||||
t.Fatalf("read upgraded Kling Omni base model capabilities: %v", err)
|
||||
}
|
||||
if upgradedBaseModels != 2 {
|
||||
t.Fatalf("expected both Kling Omni base models to expose base video capabilities, got %d", upgradedBaseModels)
|
||||
}
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer server.Close()
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
username := "kling_compat_" + suffix
|
||||
password := "password123"
|
||||
var registerResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®isterResponse)
|
||||
var apiKeyResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", registerResponse.AccessToken, map[string]any{
|
||||
"name": "Kling compatibility key",
|
||||
}, http.StatusCreated, &apiKeyResponse)
|
||||
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote compatibility user: %v", err)
|
||||
}
|
||||
var loginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username, "password": password,
|
||||
}, http.StatusOK, &loginResponse)
|
||||
|
||||
var platform struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
|
||||
"provider": "keling",
|
||||
"platformKey": "kling-compat-" + suffix,
|
||||
"name": "Kling Compatibility Simulation",
|
||||
"baseUrl": "https://api-beijing.klingai.com/v1",
|
||||
"authType": "AccessKey-SecretKey",
|
||||
"credentials": map[string]any{"accessKey": "test-ak", "secretKey": "test-sk"},
|
||||
}, http.StatusCreated, &platform)
|
||||
for _, model := range []string{klingO1Model, klingV3OmniModel} {
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
|
||||
"canonicalModelKey": "keling:" + model,
|
||||
"modelName": model,
|
||||
"providerModelName": model,
|
||||
"modelAlias": model,
|
||||
"modelType": []string{"omni_video"},
|
||||
"displayName": model,
|
||||
}, http.StatusCreated, nil)
|
||||
}
|
||||
var upgradedPlatformModels int
|
||||
if err := db.Pool().QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM platform_models
|
||||
WHERE platform_id = $1::uuid
|
||||
AND model_type @> '["video_generate","image_to_video","omni_video"]'::jsonb
|
||||
AND capabilities ? 'video_generate'
|
||||
AND capabilities ? 'image_to_video'
|
||||
AND capabilities ? 'omni_video'`, platform.ID).Scan(&upgradedPlatformModels); err != nil {
|
||||
t.Fatalf("read upgraded Kling Omni platform model capabilities: %v", err)
|
||||
}
|
||||
if upgradedPlatformModels != 2 {
|
||||
t.Fatalf("expected both Kling Omni platform models to expose base video capabilities, got %d", upgradedPlatformModels)
|
||||
}
|
||||
|
||||
assertGenericVideoGeneration := func(name string, model string, image string, expectedModelType string) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := map[string]any{
|
||||
"model": model,
|
||||
"prompt": "通用视频接口模拟任务",
|
||||
"duration": 5,
|
||||
"resolution": "720p",
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}
|
||||
if image != "" {
|
||||
request["image"] = image
|
||||
}
|
||||
var response struct {
|
||||
Task struct {
|
||||
Status string `json:"status"`
|
||||
ModelType string `json:"modelType"`
|
||||
ResolvedModel string `json:"resolvedModel"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, request, http.StatusAccepted, &response)
|
||||
resolvedModel, resolved := klingV2ProviderModel(response.Task.ResolvedModel)
|
||||
if response.Task.Status != "succeeded" || response.Task.ModelType != expectedModelType || !resolved || resolvedModel != model {
|
||||
t.Fatalf("generic video request without modelType should use inferred capability: %+v", response.Task)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, model := range []string{klingO1Model, klingV3OmniModel} {
|
||||
assertGenericVideoGeneration(model+"-text-to-video", model, "", "video_generate")
|
||||
assertGenericVideoGeneration(model+"-image-to-video", model, "https://example.com/first.png", "image_to_video")
|
||||
}
|
||||
|
||||
createV1 := func(model string, duration int, externalID string) string {
|
||||
t.Helper()
|
||||
var response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": model,
|
||||
"prompt": "兼容接口模拟任务",
|
||||
"duration": duration,
|
||||
"mode": "std",
|
||||
"external_task_id": externalID,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusOK, &response)
|
||||
if response["code"] != float64(0) {
|
||||
t.Fatalf("unexpected V1 response: %#v", response)
|
||||
}
|
||||
data, _ := response["data"].(map[string]any)
|
||||
taskID, _ := data["task_id"].(string)
|
||||
if taskID == "" {
|
||||
t.Fatalf("V1 response missing task id: %#v", response)
|
||||
}
|
||||
return taskID
|
||||
}
|
||||
|
||||
o1TaskID := createV1(klingO1Model, 5, "compat-o1-"+suffix)
|
||||
v3TaskID := createV1(klingV3OmniModel, 15, "compat-v3-"+suffix)
|
||||
for _, taskID := range []string{o1TaskID, v3TaskID} {
|
||||
waitKlingV1SimulationTask(t, server.URL, apiKeyResponse.Secret, taskID)
|
||||
}
|
||||
var listResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodGet, "/kling/v1/videos/omni-video?pageNum=1&pageSize=10", apiKeyResponse.Secret, nil, http.StatusOK, &listResponse)
|
||||
items, _ := listResponse["data"].([]any)
|
||||
if len(items) < 2 {
|
||||
t.Fatalf("V1 task list did not return compatibility tasks: %#v", listResponse)
|
||||
}
|
||||
|
||||
var duplicateResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": klingO1Model, "prompt": "duplicate", "duration": 5,
|
||||
"external_task_id": "compat-o1-" + suffix,
|
||||
"runMode": "simulation", "simulation": true,
|
||||
}, http.StatusConflict, &duplicateResponse)
|
||||
if duplicateResponse["code"] != float64(1200) || duplicateResponse["error"] != "external_task_id_reused" {
|
||||
t.Fatalf("unexpected duplicate external id response: %#v", duplicateResponse)
|
||||
}
|
||||
|
||||
var v2Response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
|
||||
"contents": []any{map[string]any{"type": "prompt", "text": "API 2.0 模拟任务"}},
|
||||
"settings": map[string]any{"duration": 3, "resolution": "720p", "aspect_ratio": "16:9", "audio": "off"},
|
||||
"options": map[string]any{"external_task_id": "compat-v2-" + suffix},
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusOK, &v2Response)
|
||||
v2Data, _ := v2Response["data"].(map[string]any)
|
||||
v2TaskID, _ := v2Data["id"].(string)
|
||||
if v2TaskID == "" {
|
||||
t.Fatalf("V2 response missing task id: %#v", v2Response)
|
||||
}
|
||||
waitKlingV2SimulationTask(t, server.URL, apiKeyResponse.Secret, v2TaskID)
|
||||
}
|
||||
|
||||
func waitKlingV1SimulationTask(t *testing.T, baseURL string, apiKey string, taskID string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response["data"].(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
return
|
||||
case "failed":
|
||||
t.Fatalf("V1 simulation task failed: %#v", response)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("V1 simulation task %s timed out", taskID)
|
||||
}
|
||||
|
||||
func waitKlingV2SimulationTask(t *testing.T, baseURL string, apiKey string, taskID string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v2/tasks?task_ids="+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
items, _ := response["data"].([]any)
|
||||
if len(items) == 1 {
|
||||
data, _ := items[0].(map[string]any)
|
||||
switch data["status"] {
|
||||
case "succeeded":
|
||||
return
|
||||
case "failed":
|
||||
t.Fatalf("V2 simulation task failed: %#v", response)
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("V2 simulation task %s timed out", taskID)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKlingV1O1CompatibilityBody(t *testing.T) {
|
||||
body, externalID, err := klingCompatTaskBody("v1", klingO1Model, map[string]any{
|
||||
"prompt": "一只纸鹤飞过湖面",
|
||||
"duration": json.Number("10"),
|
||||
"aspect_ratio": "16:9",
|
||||
"sound": "on",
|
||||
"external_task_id": "client-o1-1",
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build O1 compatibility body: %v", err)
|
||||
}
|
||||
if externalID != "client-o1-1" || body["model"] != klingO1Model || body["modelType"] != "omni_video" {
|
||||
t.Fatalf("unexpected identity fields: %#v", body)
|
||||
}
|
||||
if body["mode"] != "pro" || body["resolution"] != "1080p" || body["audio"] != true {
|
||||
t.Fatalf("unexpected V1 defaults: %#v", body)
|
||||
}
|
||||
content := mapListFromRequest(body["content"])
|
||||
if len(content) != 1 || content[0]["type"] != "text" || content[0]["text"] != "一只纸鹤飞过湖面" {
|
||||
t.Fatalf("unexpected canonical content: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlingV1V3OmniCompatibilityBody(t *testing.T) {
|
||||
body, _, err := klingCompatTaskBody("v1", klingV3OmniModel, map[string]any{
|
||||
"multi_shot": true,
|
||||
"shot_type": "customize",
|
||||
"mode": "4k",
|
||||
"multi_prompt": []any{
|
||||
map[string]any{"index": json.Number("1"), "prompt": "推近人物", "duration": json.Number("7")},
|
||||
map[string]any{"index": json.Number("2"), "prompt": "切到城市远景", "duration": json.Number("8")},
|
||||
},
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://example.com/first.png", "type": "first_frame"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build 3.0 Omni compatibility body: %v", err)
|
||||
}
|
||||
if body["resolution"] != "2160p" {
|
||||
t.Fatalf("4k mode was not normalized: %#v", body)
|
||||
}
|
||||
content := mapListFromRequest(body["content"])
|
||||
if len(content) != 1 || content[0]["role"] != "first_frame" {
|
||||
t.Fatalf("image input was not normalized: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlingV2CompatibilityBody(t *testing.T) {
|
||||
body, externalID, err := klingCompatTaskBody("v2", klingV3OmniModel, map[string]any{
|
||||
"contents": []any{
|
||||
map[string]any{"type": "prompt", "text": "让角色向镜头挥手"},
|
||||
map[string]any{"type": "first_frame", "url": "https://example.com/first.png"},
|
||||
map[string]any{"type": "element", "id": json.Number("42")},
|
||||
},
|
||||
"settings": map[string]any{
|
||||
"resolution": "1080p",
|
||||
"duration": json.Number("15"),
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": "native",
|
||||
},
|
||||
"options": map[string]any{
|
||||
"external_task_id": "client-v2-1",
|
||||
"callback_url": "https://example.com/callback",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build V2 compatibility body: %v", err)
|
||||
}
|
||||
if externalID != "client-v2-1" || body["mode"] != "pro" || body["audio"] != true {
|
||||
t.Fatalf("unexpected V2 settings: %#v", body)
|
||||
}
|
||||
if len(mapListFromRequest(body["image_list"])) != 1 || len(mapListFromRequest(body["element_list"])) != 1 {
|
||||
t.Fatalf("unexpected V2 references: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlingCompatibilityValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
body map[string]any
|
||||
}{
|
||||
{name: "O1 duration", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 11}},
|
||||
{name: "O1 text-only flexible duration", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 3}},
|
||||
{name: "O1 4k", model: klingO1Model, body: map[string]any{"prompt": "test", "duration": 5, "mode": "4k"}},
|
||||
{name: "O1 multi-shot", model: klingO1Model, body: map[string]any{"multi_shot": true, "multi_prompt": []any{map[string]any{"prompt": "test", "duration": 3}}}},
|
||||
{name: "custom multi-shot without prompts", model: klingV3OmniModel, body: map[string]any{"multi_shot": true, "shot_type": "customize"}},
|
||||
{name: "intelligence multi-shot without prompt", model: klingV3OmniModel, body: map[string]any{"multi_shot": true, "shot_type": "intelligence"}},
|
||||
{name: "video with native audio", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "sound": "on", "video_list": []any{map[string]any{"video_url": "https://example.com/input.mp4"}}}},
|
||||
{name: "video duration too long", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "duration": 15, "video_list": []any{map[string]any{"video_url": "https://example.com/input.mp4"}}}},
|
||||
{name: "too many references", model: klingV3OmniModel, body: map[string]any{"prompt": "test", "image_list": []any{
|
||||
map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{},
|
||||
}}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, _, err := klingCompatTaskBody("v1", test.model, test.body); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, _, err := klingCompatTaskBody("v1", klingV3OmniModel, map[string]any{"prompt": "test", "duration": 15}); err != nil {
|
||||
t.Fatalf("3.0 Omni should allow a 15-second duration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeKlingJSONRejectsTrailingData(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/kling/v1/videos/omni-video", strings.NewReader(`{"prompt":"ok"} trailing`))
|
||||
var body map[string]any
|
||||
if err := decodeKlingJSON(request, &body); err == nil {
|
||||
t.Fatal("expected trailing JSON error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteKlingCompatErrorUsesOfficialNumericEnvelope(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
writeKlingCompatError(recorder, http.StatusBadRequest, "bad request", "invalid_parameter")
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode error response: %v", err)
|
||||
}
|
||||
if body["code"] != float64(1001) || body["error"] != "invalid_parameter" {
|
||||
t.Fatalf("unexpected error envelope: %#v", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestLocalLoginPolicyAlwaysPreservesBreakGlassManager(t *testing.T) {
|
||||
serverMain := &Server{cfg: config.Config{IdentityMode: "server-main"}}
|
||||
if !serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"manager"}}) {
|
||||
t.Fatal("server-main mode rejected a local break-glass manager")
|
||||
}
|
||||
if !serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"admin"}}) {
|
||||
t.Fatal("server-main mode rejected a local break-glass admin")
|
||||
}
|
||||
if serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"user"}}) {
|
||||
t.Fatal("server-main mode accepted an ordinary local user")
|
||||
}
|
||||
|
||||
hybrid := &Server{cfg: config.Config{IdentityMode: "hybrid"}}
|
||||
if !hybrid.localLoginAllowed(store.GatewayUser{Roles: []string{"user"}}) {
|
||||
t.Fatal("hybrid mode rejected an ordinary local user")
|
||||
}
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func buildModelCatalog(
|
||||
current = &catalogGroup{
|
||||
key: key,
|
||||
alias: firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName),
|
||||
displayName: firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName),
|
||||
displayName: firstNonEmpty(model.DisplayName, model.ModelAlias, model.ModelName),
|
||||
modelName: model.ModelName,
|
||||
modelType: cloneStringSlice(model.ModelType),
|
||||
capabilities: cloneObject(model.Capabilities),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
@@ -114,6 +115,36 @@ func TestBuildModelCatalogAggregatesSources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelCatalogKeepsDisplayNameSeparateFromCallAlias(t *testing.T) {
|
||||
models := []store.PlatformModel{
|
||||
{
|
||||
ID: "seedream-pro",
|
||||
PlatformID: "platform-volces",
|
||||
ModelName: "Seedream-5.0-Pro",
|
||||
ProviderModelName: "doubao-seedream-5-0-pro-260628",
|
||||
ModelAlias: "Seedream-5.0-Pro",
|
||||
ModelType: store.StringList{"image_edit", "image_generate"},
|
||||
DisplayName: "Seedream 5.0 Pro",
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
platforms := []store.Platform{
|
||||
{ID: "platform-volces", Provider: "volces", Name: "火山引擎", Status: "enabled"},
|
||||
}
|
||||
|
||||
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, nil)
|
||||
if len(response.Items) != 1 {
|
||||
t.Fatalf("expected one Seedream Pro catalog item, got %+v", response.Items)
|
||||
}
|
||||
item := response.Items[0]
|
||||
if item.Alias != "Seedream-5.0-Pro" || item.DisplayName != "Seedream 5.0 Pro" || item.ModelName != "Seedream-5.0-Pro" {
|
||||
t.Fatalf("catalog should separate the call alias from the display name, got %+v", item)
|
||||
}
|
||||
if strings.Contains(item.Alias, " ") {
|
||||
t.Fatalf("Seedream Pro call alias must not contain spaces, got %q", item.Alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelCatalogUsesBaseModelProviderForProviderFilters(t *testing.T) {
|
||||
models := []store.PlatformModel{
|
||||
{
|
||||
|
||||
@@ -7,46 +7,57 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) platformModelResponse(ctx context.Context, model store.PlatformModel) store.PlatformModel {
|
||||
return s.platformModelResponseWithRuleSets(model, s.responsePricingRuleSetConfigs(ctx, []store.PlatformModel{model}))
|
||||
}
|
||||
|
||||
func (s *Server) platformModelResponseWithRuleSets(model store.PlatformModel, ruleSetConfigs map[string]map[string]any) store.PlatformModel {
|
||||
model.Capabilities = store.EffectivePlatformModelCapabilities(model.BaseCapabilities, model.Capabilities)
|
||||
model.Capabilities = enrichResponseCapabilities(model)
|
||||
model = s.withEffectiveResponseBillingConfig(ctx, model)
|
||||
model = withEffectiveResponseBillingConfig(model, ruleSetConfigs)
|
||||
return store.FilterPlatformModelBillingConfig(model)
|
||||
}
|
||||
|
||||
func (s *Server) platformModelResponses(ctx context.Context, models []store.PlatformModel) []store.PlatformModel {
|
||||
ruleSetConfigs := s.responsePricingRuleSetConfigs(ctx, models)
|
||||
items := make([]store.PlatformModel, len(models))
|
||||
for i, model := range models {
|
||||
items[i] = s.platformModelResponse(ctx, model)
|
||||
items[i] = s.platformModelResponseWithRuleSets(model, ruleSetConfigs)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *Server) withEffectiveResponseBillingConfig(ctx context.Context, model store.PlatformModel) store.PlatformModel {
|
||||
config := model.BillingConfig
|
||||
if model.PricingRuleSetID != "" {
|
||||
if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, model.PricingRuleSetID); err == nil && len(ruleSetConfig) > 0 {
|
||||
config = ruleSetConfig
|
||||
func (s *Server) responsePricingRuleSetConfigs(ctx context.Context, models []store.PlatformModel) map[string]map[string]any {
|
||||
configs := map[string]map[string]any{}
|
||||
if s.store == nil {
|
||||
return configs
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, model := range models {
|
||||
for _, id := range []string{firstNonEmpty(model.BasePricingRuleSetID, model.PlatformPricingRuleSetID), model.PricingRuleSetID} {
|
||||
if id != "" {
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(model.BillingConfigOverride) > 0 {
|
||||
config = mergeResponseBillingConfig(config, model.BillingConfigOverride)
|
||||
for id := range ids {
|
||||
if config, err := s.store.PricingRuleSetBillingConfig(ctx, id); err == nil && len(config) > 0 {
|
||||
configs[id] = config
|
||||
}
|
||||
}
|
||||
model.BillingConfig = config
|
||||
return model
|
||||
return configs
|
||||
}
|
||||
|
||||
func mergeResponseBillingConfig(base map[string]any, override map[string]any) map[string]any {
|
||||
if len(base) == 0 && len(override) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(base)+len(override))
|
||||
for key, value := range base {
|
||||
out[key] = value
|
||||
}
|
||||
for key, value := range override {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
func withEffectiveResponseBillingConfig(model store.PlatformModel, ruleSetConfigs map[string]map[string]any) store.PlatformModel {
|
||||
inheritedRuleSetConfig := ruleSetConfigs[firstNonEmpty(model.BasePricingRuleSetID, model.PlatformPricingRuleSetID)]
|
||||
modelRuleSetConfig := ruleSetConfigs[model.PricingRuleSetID]
|
||||
model.BillingConfig = store.ResolveEffectiveBillingConfig(store.EffectiveBillingConfigInput{
|
||||
BaseConfig: model.BaseBillingConfig,
|
||||
LegacyPlatformModelConfig: model.BillingConfig,
|
||||
InheritedRuleSetConfig: inheritedRuleSetConfig,
|
||||
ModelRuleSetConfig: modelRuleSetConfig,
|
||||
Override: model.BillingConfigOverride,
|
||||
})
|
||||
return model
|
||||
}
|
||||
|
||||
func enrichResponseCapabilities(model store.PlatformModel) map[string]any {
|
||||
|
||||
@@ -173,6 +173,41 @@ func TestPlatformModelResponsePreservesTextGenerateFieldsOverFallbacks(t *testin
|
||||
assertStringListValue(t, textGenerate["thinkingEffortLevels"], []string{"minimal", "low", "medium"})
|
||||
}
|
||||
|
||||
func TestPlatformModelResponseUsesBaseBillingConfigWithoutMaterializedSnapshot(t *testing.T) {
|
||||
model := store.PlatformModel{
|
||||
ModelName: "base-priced-model",
|
||||
ModelType: store.StringList{"video_generate"},
|
||||
BaseBillingConfig: map[string]any{
|
||||
"video": map[string]any{"basePrice": float64(416)},
|
||||
},
|
||||
}
|
||||
|
||||
response := (&Server{}).platformModelResponse(context.Background(), model)
|
||||
video, ok := response.BillingConfig["video"].(map[string]any)
|
||||
if !ok || video["basePrice"] != float64(416) {
|
||||
t.Fatalf("expected base billing price 416, got %#v", response.BillingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveResponseBillingConfigPrefersBaseRuleOverLegacySnapshot(t *testing.T) {
|
||||
model := store.PlatformModel{
|
||||
BasePricingRuleSetID: "seedance-pricing",
|
||||
BillingConfig: map[string]any{
|
||||
"video": map[string]any{"basePrice": float64(100)},
|
||||
},
|
||||
}
|
||||
response := withEffectiveResponseBillingConfig(model, map[string]map[string]any{
|
||||
"seedance-pricing": {
|
||||
"video": map[string]any{"basePrice": float64(416)},
|
||||
},
|
||||
})
|
||||
|
||||
video, ok := response.BillingConfig["video"].(map[string]any)
|
||||
if !ok || video["basePrice"] != float64(416) {
|
||||
t.Fatalf("expected base rule price 416, got %#v", response.BillingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func textGenerateCapabilities(t *testing.T, model store.PlatformModel) map[string]any {
|
||||
t.Helper()
|
||||
capabilities, ok := model.Capabilities["text_generate"].(map[string]any)
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const oidcJITTenantID = "6e6d0a0f-8b08-41ca-bda6-f1a58f065bc3"
|
||||
|
||||
func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run OIDC JIT HTTP integration tests")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate test signing key: %v", err)
|
||||
}
|
||||
var issuer string
|
||||
var gatewayBaseURL string
|
||||
var expectedPKCEChallenge string
|
||||
validSubject := ""
|
||||
issuerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/.well-known/openid-configuration":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": issuer, "jwks_uri": issuer + "/jwks",
|
||||
"authorization_endpoint": issuer + "/authorize", "token_endpoint": issuer + "/token",
|
||||
"revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout",
|
||||
})
|
||||
case "/jwks":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{oidcJITECJWK("jit-key", &key.PublicKey)}})
|
||||
case "/authorize":
|
||||
if r.URL.Query().Get("response_type") != "code" || r.URL.Query().Get("client_id") != "gateway-public-test" ||
|
||||
r.URL.Query().Get("code_challenge_method") != "S256" || r.URL.Query().Get("nonce") == "" {
|
||||
http.Error(w, "invalid authorization request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
expectedPKCEChallenge = r.URL.Query().Get("code_challenge")
|
||||
callback := gatewayBaseURL + "/api/v1/auth/oidc/callback?code=test-code&state=" + url.QueryEscape(r.URL.Query().Get("state")) +
|
||||
"&nonce=" + url.QueryEscape(r.URL.Query().Get("nonce"))
|
||||
http.Redirect(w, r, callback, http.StatusSeeOther)
|
||||
case "/token":
|
||||
if err := r.ParseForm(); err != nil || r.Form.Get("grant_type") != "authorization_code" ||
|
||||
r.Form.Get("client_id") != "gateway-public-test" || r.Form.Get("client_secret") != "" || r.Header.Get("Authorization") != "" {
|
||||
http.Error(w, "invalid token request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
digest := sha256.Sum256([]byte(r.Form.Get("code_verifier")))
|
||||
if base64.RawURLEncoding.EncodeToString(digest[:]) != expectedPKCEChallenge {
|
||||
http.Error(w, "invalid PKCE verifier", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accessToken := signedOIDCJITToken(t, key, issuer, validSubject, nil)
|
||||
idToken := signedOIDCJITToken(t, key, issuer, validSubject, func(claims jwt.MapClaims) {
|
||||
claims["aud"] = "gateway-public-test"
|
||||
// The nonce is retained from the authorization request by this test issuer.
|
||||
claims["nonce"] = currentOIDCTestNonce
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": accessToken, "refresh_token": "opaque-test-refresh-token", "id_token": idToken,
|
||||
"token_type": "Bearer", "expires_in": 300,
|
||||
})
|
||||
case "/revoke":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/logout":
|
||||
http.Redirect(w, r, r.URL.Query().Get("post_logout_redirect_uri"), http.StatusSeeOther)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer issuerServer.Close()
|
||||
issuer = issuerServer.URL
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102150405.000000000")
|
||||
validSubject = "platform-http-jit-" + suffix
|
||||
rejectedSubjects := []string{
|
||||
"platform-http-scope-" + suffix,
|
||||
"platform-http-role-" + suffix,
|
||||
"platform-http-tenant-" + suffix,
|
||||
"platform-http-disabled-jit-" + suffix,
|
||||
"platform-http-missing-tenant-" + suffix,
|
||||
}
|
||||
allSubjects := append([]string{validSubject}, rejectedSubjects...)
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Pool().Exec(context.Background(), `
|
||||
DELETE FROM gateway_audit_logs
|
||||
WHERE target_id IN (SELECT id::text FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::text[]));
|
||||
DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::text[]);`, allSubjects)
|
||||
})
|
||||
|
||||
baseConfig := config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-only-jwt-secret",
|
||||
IdentitySecretStore: "file",
|
||||
IdentitySecretDir: t.TempDir(),
|
||||
LocalGeneratedStorageDir: t.TempDir(),
|
||||
LocalUploadedStorageDir: t.TempDir(),
|
||||
LocalTempAssetTTLHours: 1,
|
||||
CORSAllowedOrigin: "http://localhost:5178",
|
||||
TaskProgressCallbackEnabled: false,
|
||||
}
|
||||
previous, previousErr := db.ActiveIdentityConfigurationRevision(ctx)
|
||||
if previousErr != nil && !errors.Is(previousErr, identity.ErrRevisionNotFound) {
|
||||
t.Fatalf("read previous active identity revision: %v", previousErr)
|
||||
}
|
||||
testRevisionIDs := make([]string, 0, 3)
|
||||
t.Cleanup(func() {
|
||||
restoreOIDCJITIdentityRevision(t, context.Background(), db, previous, previousErr == nil, testRevisionIDs)
|
||||
})
|
||||
activeRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", true)
|
||||
testRevisionIDs = append(testRevisionIDs, activeRevision.ID)
|
||||
activeRevision, _, err = db.ActivateIdentityRevision(ctx, activeRevision.ID, activeRevision.Version, "oidc-jit-test", "oidc-jit-test")
|
||||
if err != nil {
|
||||
t.Fatalf("activate OIDC JIT identity revision: %v", err)
|
||||
}
|
||||
server := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer server.Close()
|
||||
gatewayBaseURL = server.URL
|
||||
|
||||
validToken := signedOIDCJITToken(t, key, issuer, validSubject, nil)
|
||||
var me auth.User
|
||||
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/v1/me", validToken, nil, http.StatusOK, &me)
|
||||
if me.ID != validSubject || me.Source != "oidc" || me.GatewayUserID == "" || me.GatewayTenantID == "" || me.TenantKey != "default" || me.UserGroupID == "" {
|
||||
t.Fatalf("OIDC /me did not include the local Gateway projection")
|
||||
}
|
||||
sessionCookie := createOIDCBFFSessionCookie(t, server.URL)
|
||||
request, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/me", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.AddCookie(sessionCookie)
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("execute cookie-authenticated /me: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("cookie-authenticated /me status = %d, want 200", response.StatusCode)
|
||||
}
|
||||
var cookieMe auth.User
|
||||
if err := json.NewDecoder(response.Body).Decode(&cookieMe); err != nil {
|
||||
t.Fatalf("decode cookie-authenticated /me: %v", err)
|
||||
}
|
||||
if cookieMe.GatewayUserID != me.GatewayUserID || cookieMe.ID != me.ID {
|
||||
t.Fatalf("new-tab cookie resolved a different Gateway user: %#v", cookieMe)
|
||||
}
|
||||
var automaticallyCreatedAPIKeys int
|
||||
if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_api_keys WHERE gateway_user_id = $1::uuid`, me.GatewayUserID).Scan(&automaticallyCreatedAPIKeys); err != nil {
|
||||
t.Fatalf("count pre-created API keys: %v", err)
|
||||
}
|
||||
if automaticallyCreatedAPIKeys != 0 {
|
||||
t.Fatalf("OIDC JIT created %d API keys before explicit user action", automaticallyCreatedAPIKeys)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/workspace/user-groups",
|
||||
"/api/workspace/wallet",
|
||||
"/api/workspace/tasks",
|
||||
"/api/v1/api-keys",
|
||||
} {
|
||||
doOIDCJITJSON(t, server.URL, http.MethodGet, path, validToken, nil, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
var createdKey struct {
|
||||
Secret string `json:"secret"`
|
||||
APIKey struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"apiKey"`
|
||||
}
|
||||
doOIDCJITJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", validToken, map[string]any{"name": "OIDC JIT integration key"}, http.StatusCreated, &createdKey)
|
||||
if createdKey.Secret == "" || createdKey.APIKey.ID == "" {
|
||||
t.Fatal("OIDC user API Key creation returned incomplete data")
|
||||
}
|
||||
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys", validToken, nil, http.StatusOK, nil)
|
||||
doOIDCJITJSON(t, server.URL, http.MethodDelete, "/api/v1/api-keys/"+createdKey.APIKey.ID, validToken, nil, http.StatusNoContent, nil)
|
||||
|
||||
var users struct {
|
||||
Items []store.GatewayUser `json:"items"`
|
||||
}
|
||||
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/admin/users", validToken, nil, http.StatusOK, &users)
|
||||
foundOIDCUser := false
|
||||
for _, user := range users.Items {
|
||||
if user.ID == me.GatewayUserID {
|
||||
foundOIDCUser = user.Source == "oidc" && user.ExternalUserID == validSubject
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundOIDCUser {
|
||||
t.Fatal("admin user list did not expose the OIDC Gateway projection")
|
||||
}
|
||||
|
||||
negativeTokens := []struct {
|
||||
subject string
|
||||
mutate func(jwt.MapClaims)
|
||||
}{
|
||||
{rejectedSubjects[0], func(claims jwt.MapClaims) { claims["scope"] = "openid" }},
|
||||
{rejectedSubjects[1], func(claims jwt.MapClaims) { claims["roles"] = []string{"other.admin"} }},
|
||||
{rejectedSubjects[2], func(claims jwt.MapClaims) { claims["tid"] = "wrong-tenant" }},
|
||||
}
|
||||
for _, negative := range negativeTokens {
|
||||
token := signedOIDCJITToken(t, key, issuer, negative.subject, negative.mutate)
|
||||
doOIDCJITJSON(t, server.URL, http.MethodGet, "/api/v1/me", token, nil, http.StatusUnauthorized, nil)
|
||||
}
|
||||
var rejectedWrites int
|
||||
if err := db.Pool().QueryRow(ctx, `SELECT count(*) FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::text[])`, rejectedSubjects[:3]).Scan(&rejectedWrites); err != nil {
|
||||
t.Fatalf("count rejected OIDC writes: %v", err)
|
||||
}
|
||||
if rejectedWrites != 0 {
|
||||
t.Fatalf("rejected OIDC tokens created %d local users", rejectedWrites)
|
||||
}
|
||||
|
||||
if _, err := db.DisableActiveIdentityRevision(ctx, activeRevision.Version, "oidc-jit-disabled", "oidc-jit-disabled"); err != nil {
|
||||
t.Fatalf("disable initial OIDC JIT revision: %v", err)
|
||||
}
|
||||
disabledJITRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", false)
|
||||
testRevisionIDs = append(testRevisionIDs, disabledJITRevision.ID)
|
||||
disabledJITRevision, _, err = db.ActivateIdentityRevision(ctx, disabledJITRevision.ID, disabledJITRevision.Version, "oidc-jit-disabled", "oidc-jit-disabled")
|
||||
if err != nil {
|
||||
t.Fatalf("activate disabled-JIT revision: %v", err)
|
||||
}
|
||||
disabledJITServer := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer disabledJITServer.Close()
|
||||
assertOIDCJITError(t, disabledJITServer.URL, signedOIDCJITToken(t, key, issuer, rejectedSubjects[3], nil), http.StatusForbidden, errorCodeGatewayUserNotProvisioned)
|
||||
|
||||
missingTenantRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "missing-tenant-"+suffix, true)
|
||||
testRevisionIDs = append(testRevisionIDs, missingTenantRevision.ID)
|
||||
if _, _, activateErr := db.ActivateIdentityRevision(ctx, missingTenantRevision.ID, missingTenantRevision.Version, "oidc-jit-missing-tenant", "oidc-jit-missing-tenant"); !errors.Is(activateErr, identity.ErrLocalTenantInvalid) {
|
||||
t.Fatalf("missing local tenant activation error = %v, want %v", activateErr, identity.ErrLocalTenantInvalid)
|
||||
}
|
||||
|
||||
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET status = 'disabled' WHERE id = $1::uuid`, me.GatewayUserID); err != nil {
|
||||
t.Fatalf("disable projected user: %v", err)
|
||||
}
|
||||
assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled)
|
||||
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET status = 'active' WHERE id = $1::uuid`, me.GatewayUserID); err != nil {
|
||||
t.Fatalf("restore projected user for delete test: %v", err)
|
||||
}
|
||||
if err := db.DeleteGatewayUser(ctx, me.GatewayUserID); err != nil {
|
||||
t.Fatalf("delete projected user: %v", err)
|
||||
}
|
||||
assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled)
|
||||
}
|
||||
|
||||
func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store, cfg config.Config, issuer, localTenantKey string, jitEnabled bool) identity.Revision {
|
||||
t.Helper()
|
||||
draft, err := identity.NewDraft(identity.PairingInput{
|
||||
AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178",
|
||||
LocalTenantKey: localTenantKey, LegacyJWTEnabled: true,
|
||||
}, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("create OIDC JIT draft: %v", err)
|
||||
}
|
||||
draft.JITEnabled = jitEnabled
|
||||
draft, err = db.CreateIdentityConfigurationRevision(ctx, draft)
|
||||
if err != nil {
|
||||
t.Fatalf("persist OIDC JIT draft: %v", err)
|
||||
}
|
||||
secrets, err := identitySecretStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("create identity SecretStore: %v", err)
|
||||
}
|
||||
sessionReference := "oidc-jit-session-" + draft.ID
|
||||
if err := secrets.Put(ctx, sessionReference, bytes.Repeat([]byte{8}, 32)); err != nil {
|
||||
t.Fatalf("store OIDC JIT session key: %v", err)
|
||||
}
|
||||
if err := db.QueueIdentitySecretCleanup(ctx, sessionReference, time.Now().Add(10*time.Minute)); err != nil {
|
||||
t.Fatalf("stage OIDC JIT session key for adoption: %v", err)
|
||||
}
|
||||
draft, err = db.ApplyIdentityManifest(ctx, draft.ID, draft.Version, identity.ManifestApplication{
|
||||
Manifest: identity.ManifestV1{
|
||||
SchemaVersion: 1, Issuer: issuer, TenantID: oidcJITTenantID, ApplicationID: uuid.NewString(),
|
||||
Capabilities: []string{"oidc_login", "api_access"}, Audience: "gateway-api", Scopes: []string{"gateway.access"},
|
||||
Clients: identity.ManifestClients{BrowserLogin: &identity.ManifestClient{ClientID: "gateway-public-test"}},
|
||||
},
|
||||
SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test", AppEnv: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply OIDC JIT manifest: %v", err)
|
||||
}
|
||||
draft, err = db.MarkIdentityRevisionValidated(ctx, draft.ID, draft.Version, "oidc-jit-test", "oidc-jit-test")
|
||||
if err != nil {
|
||||
t.Fatalf("validate OIDC JIT revision: %v", err)
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
func restoreOIDCJITIdentityRevision(t *testing.T, ctx context.Context, db *store.Store, previous identity.Revision, hadPrevious bool, testRevisionIDs []string) {
|
||||
t.Helper()
|
||||
isTestRevision := func(id string) bool {
|
||||
for _, testID := range testRevisionIDs {
|
||||
if id == testID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if active, err := db.ActiveIdentityConfigurationRevision(ctx); err == nil && isTestRevision(active.ID) {
|
||||
if _, disableErr := db.DisableActiveIdentityRevision(ctx, active.Version, "oidc-jit-cleanup", "oidc-jit-cleanup"); disableErr != nil {
|
||||
t.Errorf("disable test identity revision during cleanup: %v", disableErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
if hadPrevious {
|
||||
refreshed, err := db.IdentityConfigurationRevision(ctx, previous.ID)
|
||||
if err != nil {
|
||||
t.Errorf("reload previous identity revision during cleanup: %v", err)
|
||||
return
|
||||
}
|
||||
if refreshed.State == identity.RevisionSuperseded {
|
||||
refreshed, err = db.MarkIdentityRevisionValidated(ctx, refreshed.ID, refreshed.Version, "oidc-jit-restore", "oidc-jit-restore")
|
||||
if err == nil {
|
||||
_, _, err = db.ActivateIdentityRevision(ctx, refreshed.ID, refreshed.Version, "oidc-jit-restore", "oidc-jit-restore")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("restore previous identity revision: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(testRevisionIDs) > 0 {
|
||||
if _, err := db.Pool().Exec(ctx, `DELETE FROM gateway_identity_configuration_revisions WHERE id = ANY($1::uuid[])`, testRevisionIDs); err != nil {
|
||||
t.Errorf("delete test identity revisions: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var currentOIDCTestNonce string
|
||||
|
||||
func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie {
|
||||
t.Helper()
|
||||
parsedBaseURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &http.Client{Jar: jar, CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if request.URL.Path == "/api/v1/auth/oidc/callback" {
|
||||
currentOIDCTestNonce = request.URL.Query().Get("nonce")
|
||||
}
|
||||
if len(via) > 10 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
// Follow the synthetic issuer's authorization redirect, then stop before
|
||||
// the callback redirects the browser to the separately hosted web app.
|
||||
if request.URL.Host != parsedBaseURL.Host && request.URL.Path != "/authorize" {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
response, err := client.Get(baseURL + "/api/v1/auth/oidc/login?returnTo=%2Fapi%2Fv1%2Fme")
|
||||
if err != nil {
|
||||
t.Fatalf("complete OIDC BFF login: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusSeeOther {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
t.Fatalf("OIDC BFF login status = %d, want 200: %s", response.StatusCode, body)
|
||||
}
|
||||
for _, cookie := range jar.Cookies(parsedBaseURL) {
|
||||
if cookie.Name == auth.OIDCSessionCookieName {
|
||||
if strings.Count(cookie.Value, ".") == 2 {
|
||||
t.Fatal("browser session cookie contains a JWT instead of an opaque session ID")
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatal("OIDC browser session cookie was not returned")
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertOIDCJITError(t *testing.T, baseURL string, token string, expectedStatus int, expectedCode string) {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
doOIDCJITJSON(t, baseURL, http.MethodGet, "/api/v1/me", token, nil, expectedStatus, &envelope)
|
||||
if envelope.Error.Code != expectedCode {
|
||||
t.Fatalf("error code = %q, want %q", envelope.Error.Code, expectedCode)
|
||||
}
|
||||
}
|
||||
|
||||
func doOIDCJITJSON(t *testing.T, baseURL string, method string, path string, token string, payload any, expectedStatus int, output any) {
|
||||
t.Helper()
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal OIDC JIT request: %v", err)
|
||||
}
|
||||
body = bytes.NewReader(raw)
|
||||
}
|
||||
request, err := http.NewRequest(method, baseURL+path, body)
|
||||
if err != nil {
|
||||
t.Fatalf("build %s %s request: %v", method, path, err)
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
if payload != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("execute %s %s: %v", method, path, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s %s response: %v", method, path, err)
|
||||
}
|
||||
if response.StatusCode != expectedStatus {
|
||||
t.Fatalf("%s %s status=%d, want=%d", method, path, response.StatusCode, expectedStatus)
|
||||
}
|
||||
if output != nil && len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, output); err != nil {
|
||||
t.Fatalf("decode %s %s response: %v", method, path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func signedOIDCJITToken(t *testing.T, key *ecdsa.PrivateKey, issuer string, subject string, mutate func(jwt.MapClaims)) string {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"iss": issuer, "aud": "gateway-api", "sub": subject, "tid": oidcJITTenantID,
|
||||
"preferred_username": "oidc-jit-acceptance", "roles": []string{"gateway.admin"},
|
||||
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(claims)
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
token.Header["kid"] = "jit-key"
|
||||
raw, err := token.SignedString(key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign OIDC JIT test token: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func oidcJITECJWK(kid string, key *ecdsa.PublicKey) map[string]any {
|
||||
return map[string]any{
|
||||
"kid": kid,
|
||||
"kty": "EC",
|
||||
"use": "sig",
|
||||
"alg": "ES256",
|
||||
"crv": "P-256",
|
||||
"x": base64.RawURLEncoding.EncodeToString(key.X.FillBytes(make([]byte, 32))),
|
||||
"y": base64.RawURLEncoding.EncodeToString(key.Y.FillBytes(make([]byte, 32))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
errorCodeOIDCBrowserSessionDisabled = "OIDC_BROWSER_SESSION_DISABLED"
|
||||
errorCodeOIDCSessionInvalid = "OIDC_SESSION_INVALID"
|
||||
errorCodeOIDCSessionExpired = "OIDC_SESSION_EXPIRED"
|
||||
errorCodeOIDCSessionStoreUnavailable = "OIDC_SESSION_STORE_UNAVAILABLE"
|
||||
errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED"
|
||||
errorCodeOIDCLoginInvalid = "OIDC_LOGIN_INVALID"
|
||||
errorCodeOIDCTokenExchangeFailed = "OIDC_TOKEN_EXCHANGE_FAILED"
|
||||
|
||||
oidcLoginFailureCookieMissing = "COOKIE_MISSING"
|
||||
oidcLoginFailureTransactionInvalid = "TRANSACTION_INVALID"
|
||||
oidcLoginFailureStateMismatch = "STATE_MISMATCH"
|
||||
oidcLoginFailureAuthorizationResponseMissing = "AUTHORIZATION_RESPONSE_MISSING"
|
||||
)
|
||||
|
||||
// startOIDCLogin godoc
|
||||
// @Summary 开始 OIDC 公共客户端登录
|
||||
// @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。
|
||||
// @Tags auth
|
||||
// @Param returnTo query string false "登录后返回的站内相对路径"
|
||||
// @Success 303
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /api/v1/auth/oidc/login [get]
|
||||
func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if !oidcRuntimeReady(runtime) {
|
||||
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
|
||||
return
|
||||
}
|
||||
returnTo := strings.TrimSpace(r.URL.Query().Get("returnTo"))
|
||||
if returnTo == "" {
|
||||
returnTo = "/"
|
||||
}
|
||||
transaction, err := oidcsession.NewLoginTransaction(returnTo, time.Now())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
|
||||
return
|
||||
}
|
||||
encoded, err := runtime.SessionCipher.EncodeLoginTransaction(transaction)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(r.Context(), "encode OIDC login transaction failed", "error", err)
|
||||
writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
|
||||
return
|
||||
}
|
||||
authorizationURL, err := runtime.PublicClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err)
|
||||
writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE")
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oidcsession.LoginTransactionCookieName, Value: encoded,
|
||||
Path: s.oidcCallbackCookiePath(), MaxAge: 600, Expires: time.Now().Add(10 * time.Minute),
|
||||
HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(w, r, authorizationURL, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// completeOIDCLogin godoc
|
||||
// @Summary 完成 OIDC 公共客户端登录
|
||||
// @Description Gateway 使用 client_id、授权码和 PKCE verifier 换取 Token,不发送 Client Secret;Token 加密存入服务端 Session。
|
||||
// @Tags auth
|
||||
// @Success 303
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /api/v1/auth/oidc/callback [get]
|
||||
func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if !oidcRuntimeReady(runtime) {
|
||||
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
|
||||
return
|
||||
}
|
||||
s.clearOIDCLoginCookie(w)
|
||||
cookie, err := r.Cookie(oidcsession.LoginTransactionCookieName)
|
||||
if err != nil {
|
||||
s.writeOIDCLoginTransactionError(w, r, "登录事务无效或已过期", oidcLoginFailureCookieMissing)
|
||||
return
|
||||
}
|
||||
transaction, err := runtime.SessionCipher.DecodeLoginTransaction(cookie.Value, time.Now())
|
||||
if err != nil {
|
||||
s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureTransactionInvalid)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("state") == "" || r.URL.Query().Get("state") != transaction.State {
|
||||
s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureStateMismatch)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("code") == "" {
|
||||
s.writeOIDCLoginTransactionError(w, r, "认证中心回调缺少必要参数,请重新登录", oidcLoginFailureAuthorizationResponseMissing)
|
||||
return
|
||||
}
|
||||
tokens, err := runtime.PublicClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier)
|
||||
if err != nil || tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.IDToken == "" {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心登录结果无效,请重新登录", errorCodeOIDCTokenExchangeFailed)
|
||||
return
|
||||
}
|
||||
identity, err := runtime.Verifier.Verify(r.Context(), tokens.AccessToken)
|
||||
if err != nil || identity == nil {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
||||
return
|
||||
}
|
||||
idSubject, err := runtime.PublicClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce)
|
||||
if err != nil || idSubject != identity.ID {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
||||
return
|
||||
}
|
||||
projection, err := s.resolveOIDCUserProjectionForRevision(r.Context(), r, identity, runtime.Revision)
|
||||
if err != nil {
|
||||
s.writeOIDCCallbackProjectionError(w, r, err)
|
||||
return
|
||||
}
|
||||
if projection.User == nil || projection.User.GatewayUserID == "" {
|
||||
s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user"))
|
||||
return
|
||||
}
|
||||
rawSession, err := runtime.Sessions.Create(r.Context(), oidcsession.TokenBundle{
|
||||
AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, IDToken: tokens.IDToken,
|
||||
}, projection.User)
|
||||
if err != nil {
|
||||
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "登录会话保存失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/",
|
||||
MaxAge: runtime.Revision.SessionAbsoluteSeconds, Expires: now.Add(time.Duration(runtime.Revision.SessionAbsoluteSeconds) * time.Second),
|
||||
HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
s.recordOIDCSessionAudit(r, projection.User)
|
||||
http.Redirect(w, r, oidcReturnLocation(runtime.Revision.WebBaseURL, transaction.ReturnTo), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// logoutOIDCSession godoc
|
||||
// @Summary 注销 OIDC 登录会话
|
||||
// @Description 删除 Gateway Session、撤销公共 Client Refresh Token,并跳转认证中心退出地址。
|
||||
// @Tags auth
|
||||
// @Success 303
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /api/v1/auth/oidc/logout [post]
|
||||
func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if !oidcRuntimeReady(runtime) {
|
||||
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
|
||||
return
|
||||
}
|
||||
var bundle oidcsession.TokenBundle
|
||||
if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
|
||||
bundle, err = runtime.Sessions.Delete(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.clearOIDCSessionCookie(w)
|
||||
if bundle.RefreshToken != "" {
|
||||
if err := runtime.PublicClient.RevokeRefreshToken(r.Context(), bundle.RefreshToken); err != nil {
|
||||
s.logger.WarnContext(r.Context(), "revoke OIDC refresh token failed", "error", err)
|
||||
}
|
||||
}
|
||||
// Do not put the encrypted-at-rest ID Token into a browser-visible redirect URL.
|
||||
location, err := runtime.PublicClient.EndSessionURL(r.Context(), "")
|
||||
if err != nil {
|
||||
location = runtime.Revision.WebBaseURL + "/"
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Redirect(w, r, location, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// deleteOIDCBrowserSession godoc
|
||||
// @Summary 删除本地 OIDC 浏览器会话
|
||||
// @Tags auth
|
||||
// @Success 204
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /api/v1/auth/oidc/session [delete]
|
||||
func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if runtime != nil && runtime.Sessions != nil {
|
||||
if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
|
||||
if _, err := runtime.Sessions.Delete(r.Context(), cookie.Value); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
s.clearOIDCSessionCookie(w)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) oidcBrowserSessionReady() bool {
|
||||
return oidcRuntimeReady(s.currentIdentityRuntime())
|
||||
}
|
||||
|
||||
func oidcRuntimeReady(runtime *identityRequestRuntime) bool {
|
||||
return runtime != nil && runtime.BrowserEnabled && runtime.Verifier != nil && runtime.PublicClient != nil && runtime.Sessions != nil && runtime.SessionCipher != nil
|
||||
}
|
||||
|
||||
func (s *Server) clearOIDCLoginCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oidcsession.LoginTransactionCookieName, Value: "", Path: s.oidcCallbackCookiePath(),
|
||||
Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.oidcCookieSecure(), SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) oidcCallbackCookiePath() string {
|
||||
return "/api/v1/auth/oidc/callback"
|
||||
}
|
||||
|
||||
func (s *Server) clearOIDCSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.OIDCSessionCookieName, Value: "", Path: "/",
|
||||
Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.oidcCookieSecure(), SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) oidcReturnLocation(returnTo string) string {
|
||||
if runtime := s.currentIdentityRuntime(); runtime != nil {
|
||||
return oidcReturnLocation(runtime.Revision.WebBaseURL, returnTo)
|
||||
}
|
||||
return returnTo
|
||||
}
|
||||
|
||||
func oidcReturnLocation(webBaseURL, returnTo string) string {
|
||||
if base := strings.TrimRight(strings.TrimSpace(webBaseURL), "/"); base != "" {
|
||||
return base + returnTo
|
||||
}
|
||||
return returnTo
|
||||
}
|
||||
|
||||
func (s *Server) writeOIDCCallbackError(w http.ResponseWriter, r *http.Request, status int, message, code string) {
|
||||
s.writeOIDCCallbackErrorWithDiagnostics(w, r, status, message, code, "", "")
|
||||
}
|
||||
|
||||
func (s *Server) writeOIDCLoginTransactionError(w http.ResponseWriter, r *http.Request, message, reason string) {
|
||||
diagnosticID := newOIDCDiagnosticID()
|
||||
if s.logger != nil {
|
||||
s.logger.WarnContext(r.Context(), "OIDC login transaction rejected",
|
||||
"event", "oidc_login_transaction_rejected",
|
||||
"reason", reason,
|
||||
"diagnosticId", diagnosticID,
|
||||
)
|
||||
}
|
||||
s.writeOIDCCallbackErrorWithDiagnostics(w, r, http.StatusBadRequest, message, errorCodeOIDCLoginInvalid, reason, diagnosticID)
|
||||
}
|
||||
|
||||
func (s *Server) writeOIDCCallbackErrorWithDiagnostics(w http.ResponseWriter, r *http.Request, status int, message, code, reason, diagnosticID string) {
|
||||
base := ""
|
||||
if runtime := s.currentIdentityRuntime(); runtime != nil {
|
||||
base = strings.TrimRight(strings.TrimSpace(runtime.Revision.WebBaseURL), "/")
|
||||
}
|
||||
if parsed, err := url.Parse(base); base != "" && err == nil && parsed.Host != "" && (parsed.Scheme == "https" || parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")) {
|
||||
query := parsed.Query()
|
||||
query.Set("oidcError", code)
|
||||
if reason != "" {
|
||||
query.Set("oidcErrorReason", reason)
|
||||
}
|
||||
if diagnosticID != "" {
|
||||
query.Set("oidcDiagnosticId", diagnosticID)
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
http.Redirect(w, r, parsed.String(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if reason != "" || diagnosticID != "" {
|
||||
writeErrorWithDetails(w, status, message, map[string]any{
|
||||
"reason": reason, "diagnosticId": diagnosticID,
|
||||
}, code)
|
||||
return
|
||||
}
|
||||
writeError(w, status, message, code)
|
||||
}
|
||||
|
||||
func newOIDCDiagnosticID() string {
|
||||
var value [12]byte
|
||||
if _, err := rand.Read(value[:]); err == nil {
|
||||
return base64.RawURLEncoding.EncodeToString(value[:])
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(time.Now().UTC().Format(time.RFC3339Nano)))
|
||||
}
|
||||
|
||||
func (s *Server) writeOIDCCallbackProjectionError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrOIDCUserNotProvisioned):
|
||||
s.writeOIDCCallbackError(w, r, http.StatusForbidden, "该账号尚未开通 EasyAI Gateway", errorCodeGatewayUserNotProvisioned)
|
||||
case errors.Is(err, store.ErrOIDCUserDisabled):
|
||||
s.writeOIDCCallbackError(w, r, http.StatusForbidden, "该 Gateway 账号已停用,请联系管理员", errorCodeGatewayUserDisabled)
|
||||
case errors.Is(err, store.ErrOIDCTenantUnavailable):
|
||||
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "Gateway 租户尚未就绪,请联系管理员", errorCodeGatewayTenantUnavailable)
|
||||
default:
|
||||
s.logger.ErrorContext(r.Context(), "resolve OIDC gateway user during callback failed", "error", err)
|
||||
s.writeOIDCCallbackError(w, r, http.StatusServiceUnavailable, "Gateway 账号初始化失败,请稍后重试", errorCodeGatewayProvisioningFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) recordOIDCSessionAudit(r *http.Request, user *auth.User) {
|
||||
if s.store == nil || user == nil {
|
||||
return
|
||||
}
|
||||
audit, err := s.store.RecordAuditLog(r.Context(), store.AuditLogInput{
|
||||
Category: "identity", Action: "identity.oidc_session.created",
|
||||
ActorGatewayUserID: user.GatewayUserID, ActorUsername: user.Username, ActorSource: "oidc", ActorRoles: user.Roles,
|
||||
TargetType: "gateway_user", TargetID: user.GatewayUserID,
|
||||
TargetGatewayUserID: user.GatewayUserID, TargetGatewayTenantID: user.GatewayTenantID,
|
||||
RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512),
|
||||
Metadata: map[string]any{"sessionMode": "public-client-pkce-bff"},
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.WarnContext(r.Context(), "record OIDC session audit failed", "error", err)
|
||||
return
|
||||
}
|
||||
s.logger.InfoContext(r.Context(), "OIDC server session created", "gatewayUserId", user.GatewayUserID, "auditId", audit.ID)
|
||||
}
|
||||
|
||||
func (s *Server) startOIDCSessionCleanup(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if runtime == nil || runtime.Sessions == nil {
|
||||
continue
|
||||
}
|
||||
if _, err := runtime.Sessions.Cleanup(ctx); err != nil {
|
||||
s.logger.WarnContext(ctx, "cleanup expired OIDC sessions failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if runtime == nil || !runtime.BrowserEnabled || hasExplicitCredential(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := r.Cookie(auth.OIDCSessionCookieName); err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin != "" && !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) || origin == "" && !isSafeHTTPMethod(r.Method) {
|
||||
writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isSafeHTTPMethod(method string) bool {
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasExplicitCredential(r *http.Request) bool {
|
||||
return extractBearerCredential(r.Header.Get("Authorization")) != "" ||
|
||||
strings.TrimSpace(r.Header.Get("x-comfy-api-key")) != "" ||
|
||||
strings.TrimSpace(r.Header.Get("x-goog-api-key")) != "" ||
|
||||
strings.HasPrefix(strings.TrimSpace(r.URL.Query().Get("key")), "sk-")
|
||||
}
|
||||
|
||||
func extractBearerCredential(value string) string {
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") {
|
||||
return fields[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
|
||||
)
|
||||
|
||||
func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testing.T) {
|
||||
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
||||
client := &fakeOIDCClient{authorizationURL: "https://auth.example.com/authorize?request=redacted"}
|
||||
server := &Server{
|
||||
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client,
|
||||
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
|
||||
identityTestCookieSecure: true, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace%3Ftab%3Dwallet", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
server.startOIDCLogin(recorder, request)
|
||||
response := recorder.Result()
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusSeeOther || response.Header.Get("Location") != client.authorizationURL {
|
||||
t.Fatalf("login status=%d location=%q", response.StatusCode, response.Header.Get("Location"))
|
||||
}
|
||||
cookies := response.Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != oidcsession.LoginTransactionCookieName || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteLaxMode || cookies[0].MaxAge != 600 {
|
||||
t.Fatalf("unsafe login transaction cookie: %#v", cookies)
|
||||
}
|
||||
transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, cookies[0].Expires.Add(-time.Minute))
|
||||
if err != nil || transaction.ReturnTo != "/workspace?tab=wallet" {
|
||||
t.Fatalf("transaction=%#v err=%v", transaction, err)
|
||||
}
|
||||
if client.state == "" || client.nonce == "" || client.challenge == "" {
|
||||
t.Fatal("authorization redirect omitted state, nonce or PKCE challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
|
||||
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
||||
server := &Server{
|
||||
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{},
|
||||
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
server.startOIDCLogin(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=https%3A%2F%2Fevil.example", nil))
|
||||
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" {
|
||||
t.Fatalf("open redirect status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
|
||||
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transaction, err := oidcsession.NewLoginTransaction("/", time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedTransaction, err := cipher.EncodeLoginTransaction(transaction)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name, cookie, state, code, wantReason string
|
||||
}{
|
||||
{name: "cookie missing", state: "sensitive-state-marker", code: "sensitive-code-marker", wantReason: "COOKIE_MISSING"},
|
||||
{name: "transaction invalid", cookie: "invalid-encrypted-cookie", state: "sensitive-state-marker", code: "sensitive-code-marker", wantReason: "TRANSACTION_INVALID"},
|
||||
{name: "state mismatch", cookie: encodedTransaction, state: "sensitive-state-marker", code: "sensitive-code-marker", wantReason: "STATE_MISMATCH"},
|
||||
{name: "authorization response missing", cookie: encodedTransaction, state: transaction.State, wantReason: "AUTHORIZATION_RESPONSE_MISSING"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
server := &Server{
|
||||
cfg: config.Config{WebBaseURL: "http://localhost:5178"},
|
||||
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{},
|
||||
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
|
||||
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
|
||||
}
|
||||
query := url.Values{}
|
||||
if test.state != "" {
|
||||
query.Set("state", test.state)
|
||||
}
|
||||
if test.code != "" {
|
||||
query.Set("code", test.code)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/callback?"+query.Encode(), nil)
|
||||
if test.cookie != "" {
|
||||
request.AddCookie(&http.Cookie{Name: oidcsession.LoginTransactionCookieName, Value: test.cookie})
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.completeOIDCLogin(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("callback status=%d, want 303", recorder.Code)
|
||||
}
|
||||
location, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if location.Query().Get("oidcError") != errorCodeOIDCLoginInvalid || location.Query().Get("oidcErrorReason") != test.wantReason {
|
||||
t.Fatalf("callback error=%q reason=%q", location.Query().Get("oidcError"), location.Query().Get("oidcErrorReason"))
|
||||
}
|
||||
diagnosticID := location.Query().Get("oidcDiagnosticId")
|
||||
if diagnosticID == "" || !strings.Contains(logs.String(), `"diagnosticId":"`+diagnosticID+`"`) || !strings.Contains(logs.String(), `"reason":"`+test.wantReason+`"`) {
|
||||
t.Fatalf("missing correlated safe diagnostic: location=%q logs=%s", location.RawQuery, logs.String())
|
||||
}
|
||||
for _, secretMarker := range []string{"sensitive-state-marker", "sensitive-code-marker", "invalid-encrypted-cookie"} {
|
||||
if strings.Contains(location.RawQuery, secretMarker) || strings.Contains(logs.String(), secretMarker) {
|
||||
t.Fatalf("OIDC diagnostic leaked callback security material")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
|
||||
sessions := &fakeOIDCSessions{}
|
||||
server := &Server{oidcSessions: sessions, identityTestCookieSecure: true}
|
||||
request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/session", nil)
|
||||
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "opaque-session"})
|
||||
recorder := httptest.NewRecorder()
|
||||
server.deleteOIDCBrowserSession(recorder, request)
|
||||
response := recorder.Result()
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent || sessions.deleted != "opaque-session" {
|
||||
t.Fatalf("delete status=%d session=%q", response.StatusCode, sessions.deleted)
|
||||
}
|
||||
cookies := response.Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != auth.OIDCSessionCookieName || cookies[0].MaxAge >= 0 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("OIDC session cookie was not safely expired: %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) {
|
||||
server := &Server{
|
||||
cfg: config.Config{CORSAllowedOrigin: "https://gateway.example.com"},
|
||||
identityTestRevision: identity.Revision{WebBaseURL: "https://gateway.example.com"},
|
||||
identityTestBrowserEnabled: true,
|
||||
}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := server.protectOIDCSessionCookie(next)
|
||||
for _, test := range []struct {
|
||||
name, method, origin string
|
||||
bearer bool
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden},
|
||||
{name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden},
|
||||
{name: "foreign origin cannot read cookie authenticated data", method: http.MethodGet, origin: "https://evil.example", wantStatus: http.StatusForbidden},
|
||||
{name: "allowed origin", method: http.MethodPatch, origin: "https://gateway.example.com", wantStatus: http.StatusNoContent},
|
||||
{name: "safe request", method: http.MethodGet, wantStatus: http.StatusNoContent},
|
||||
{name: "explicit bearer bypasses cookie csrf", method: http.MethodPost, bearer: true, wantStatus: http.StatusNoContent},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(test.method, "/api/workspace/tasks", nil)
|
||||
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "session-token"})
|
||||
request.Header.Set("Origin", test.origin)
|
||||
if test.bearer {
|
||||
request.Header.Set("Authorization", "Bearer explicit-token")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != test.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, test.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSessionCSRFMalformedAuthorizationCannotBypassForeignOrigin(t *testing.T) {
|
||||
authenticator := auth.New("local-jwt-secret", "", "")
|
||||
authenticator.OIDCSessionResolver = func(context.Context, string) (*auth.User, error) {
|
||||
return &auth.User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, nil
|
||||
}
|
||||
server := &Server{
|
||||
auth: authenticator,
|
||||
identityTestRevision: identity.Revision{WebBaseURL: "https://gateway.example.com"},
|
||||
identityTestBrowserEnabled: true,
|
||||
}
|
||||
called := false
|
||||
handler := server.protectOIDCSessionCookie(server.requireAdmin(auth.PermissionManager, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
|
||||
request.Header.Set("Origin", "https://evil.example.com")
|
||||
request.Header.Set("Authorization", "malformed")
|
||||
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "manager-session"})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden || called {
|
||||
t.Fatalf("malformed Authorization CSRF status=%d handler_called=%t", recorder.Code, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRouteRejectsManagerJWTInQueryAndAcceptsAuthorizationHeader(t *testing.T) {
|
||||
authenticator := auth.New("local-jwt-secret", "", "")
|
||||
managerToken, err := authenticator.SignJWT(&auth.User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{auth: authenticator}
|
||||
handler := server.requireAdmin(auth.PermissionManager, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
queryRequest := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable?key="+managerToken, nil)
|
||||
queryRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(queryRecorder, queryRequest)
|
||||
if queryRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("query manager JWT status=%d, want 401", queryRecorder.Code)
|
||||
}
|
||||
|
||||
headerRequest := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil)
|
||||
headerRequest.Header.Set("Authorization", "Bearer "+managerToken)
|
||||
headerRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(headerRecorder, headerRequest)
|
||||
if headerRecorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("Authorization manager JWT status=%d, want 204", headerRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSUsesOnlyCurrentActiveIdentityWebOriginWithoutRestart(t *testing.T) {
|
||||
server := &Server{
|
||||
cfg: config.Config{CORSAllowedOrigin: "https://bootstrap.example.com"},
|
||||
identityTestRevision: identity.Revision{
|
||||
ID: "active-revision", State: identity.RevisionActive, WebBaseURL: "https://gateway.example.com",
|
||||
},
|
||||
}
|
||||
handler := server.cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
|
||||
request := func(origin string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodOptions, "/api/admin/system/identity/configuration", nil)
|
||||
r.Header.Set("Origin", origin)
|
||||
r.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
active := request("https://gateway.example.com")
|
||||
if active.Header().Get("Access-Control-Allow-Origin") != "https://gateway.example.com" || active.Header().Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Fatalf("active Web origin headers=%v", active.Header())
|
||||
}
|
||||
if evil := request("https://evil.example.com"); evil.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatalf("evil origin was allowed: headers=%v", evil.Header())
|
||||
}
|
||||
|
||||
server.identityTestRevision = identity.Revision{}
|
||||
if disabled := request("https://gateway.example.com"); disabled.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatalf("disabled Revision retained dynamic origin: headers=%v", disabled.Header())
|
||||
}
|
||||
if bootstrap := request("https://bootstrap.example.com"); bootstrap.Header().Get("Access-Control-Allow-Origin") != "https://bootstrap.example.com" {
|
||||
t.Fatalf("deployment bootstrap origin stopped working: headers=%v", bootstrap.Header())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
|
||||
server := &Server{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
||||
request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "irrelevant-cookie"})
|
||||
recorder := httptest.NewRecorder()
|
||||
server.protectOIDCSessionCookie(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("OIDC-disabled request status = %d, want 204", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeOIDCClient struct {
|
||||
authorizationURL string
|
||||
state, nonce, challenge string
|
||||
}
|
||||
|
||||
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge string) (string, error) {
|
||||
f.state, f.nonce, f.challenge = state, nonce, challenge
|
||||
return f.authorizationURL, nil
|
||||
}
|
||||
func (f *fakeOIDCClient) ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error) {
|
||||
return auth.OIDCTokenResponse{}, nil
|
||||
}
|
||||
func (f *fakeOIDCClient) VerifyIDToken(context.Context, string, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeOIDCClient) Refresh(context.Context, string) (auth.OIDCTokenResponse, error) {
|
||||
return auth.OIDCTokenResponse{}, nil
|
||||
}
|
||||
func (f *fakeOIDCClient) RevokeRefreshToken(context.Context, string) error { return nil }
|
||||
func (f *fakeOIDCClient) EndSessionURL(context.Context, string) (string, error) {
|
||||
return "https://gateway.example.com/", nil
|
||||
}
|
||||
|
||||
type fakeOIDCSessions struct{ deleted string }
|
||||
|
||||
func (f *fakeOIDCSessions) Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error) {
|
||||
return "opaque-session", nil
|
||||
}
|
||||
func (f *fakeOIDCSessions) Resolve(context.Context, string) (*auth.User, error) { return nil, nil }
|
||||
func (f *fakeOIDCSessions) Delete(_ context.Context, raw string) (oidcsession.TokenBundle, error) {
|
||||
f.deleted = raw
|
||||
return oidcsession.TokenBundle{}, nil
|
||||
}
|
||||
func (f *fakeOIDCSessions) Cleanup(context.Context) (int64, error) { return 0, nil }
|
||||
@@ -0,0 +1,101 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
errorCodeGatewayUserNotProvisioned = "GATEWAY_USER_NOT_PROVISIONED"
|
||||
errorCodeGatewayUserDisabled = "GATEWAY_USER_DISABLED"
|
||||
errorCodeGatewayTenantUnavailable = "GATEWAY_TENANT_UNAVAILABLE"
|
||||
errorCodeGatewayProvisioningFailed = "GATEWAY_USER_PROVISIONING_FAILED"
|
||||
)
|
||||
|
||||
type oidcUserResolver interface {
|
||||
ResolveOrProvisionOIDCUser(context.Context, store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error)
|
||||
}
|
||||
|
||||
func (s *Server) requireUser(permission auth.Permission, next http.Handler) http.Handler {
|
||||
return s.auth.Require(permission, s.resolveGatewayUser(next))
|
||||
}
|
||||
|
||||
func (s *Server) resolveGatewayUser(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil || !strings.EqualFold(strings.TrimSpace(user.Source), "oidc") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
result, err := s.resolveOIDCUserProjection(r.Context(), r, user)
|
||||
if err != nil {
|
||||
s.writeOIDCUserResolutionError(w, r, err)
|
||||
return
|
||||
}
|
||||
if result.User == nil || strings.TrimSpace(result.User.GatewayUserID) == "" {
|
||||
s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user"))
|
||||
return
|
||||
}
|
||||
if result.Created {
|
||||
s.logger.InfoContext(r.Context(), "OIDC gateway user provisioned",
|
||||
"gatewayUserId", result.User.GatewayUserID,
|
||||
"auditId", result.AuditID,
|
||||
)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), result.User)))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) resolveOIDCUserProjection(ctx context.Context, r *http.Request, user *auth.User) (store.ResolveOrProvisionOIDCUserResult, error) {
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if runtime == nil {
|
||||
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("active identity runtime is unavailable")
|
||||
}
|
||||
return s.resolveOIDCUserProjectionForRevision(ctx, r, user, runtime.Revision)
|
||||
}
|
||||
|
||||
func (s *Server) resolveOIDCUserProjectionForRevision(ctx context.Context, r *http.Request, user *auth.User, revision identity.Revision) (store.ResolveOrProvisionOIDCUserResult, error) {
|
||||
if s.oidcUserResolver == nil {
|
||||
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable")
|
||||
}
|
||||
return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{
|
||||
Issuer: revision.Issuer,
|
||||
Subject: user.ID,
|
||||
Username: user.Username,
|
||||
Roles: user.Roles,
|
||||
TenantID: user.TenantID,
|
||||
GatewayTenantKey: revision.LocalTenantKey,
|
||||
ProvisioningEnabled: revision.JITEnabled,
|
||||
RequestIP: limitAuditText(requestIP(r), 128),
|
||||
UserAgent: limitAuditText(r.UserAgent(), 512),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) writeOIDCUserResolutionError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrOIDCUserNotProvisioned):
|
||||
writeError(w, http.StatusForbidden, "该账号尚未开通 EasyAI Gateway", errorCodeGatewayUserNotProvisioned)
|
||||
case errors.Is(err, store.ErrOIDCUserDisabled):
|
||||
writeError(w, http.StatusForbidden, "该 Gateway 账号已停用,请联系管理员", errorCodeGatewayUserDisabled)
|
||||
case errors.Is(err, store.ErrOIDCTenantUnavailable):
|
||||
writeError(w, http.StatusServiceUnavailable, "Gateway 租户尚未就绪,请联系管理员", errorCodeGatewayTenantUnavailable)
|
||||
default:
|
||||
s.logger.ErrorContext(r.Context(), "resolve OIDC gateway user failed", "error", err, "path", r.URL.Path)
|
||||
writeError(w, http.StatusServiceUnavailable, "Gateway 账号初始化失败,请稍后重试", errorCodeGatewayProvisioningFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func limitAuditText(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
runes := []rune(value)
|
||||
if limit > 0 && len(runes) > limit {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type fakeOIDCUserResolver struct {
|
||||
result store.ResolveOrProvisionOIDCUserResult
|
||||
err error
|
||||
calls int
|
||||
input store.ResolveOrProvisionOIDCUserInput
|
||||
}
|
||||
|
||||
func (f *fakeOIDCUserResolver) ResolveOrProvisionOIDCUser(_ context.Context, input store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error) {
|
||||
f.calls++
|
||||
f.input = input
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
|
||||
resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{
|
||||
ID: "platform-user",
|
||||
Username: "alice",
|
||||
Roles: []string{"basic"},
|
||||
TenantID: "external-tenant",
|
||||
Source: "oidc",
|
||||
GatewayUserID: "21dd9ccb-3793-4023-ab31-4d04982ca4d3",
|
||||
GatewayTenantID: "8f17f3ac-136e-4d0f-b097-655e2a6240a3",
|
||||
TenantKey: "default",
|
||||
UserGroupID: "6dcf86f2-8eaf-4b43-8e69-181315db24f0",
|
||||
}}}
|
||||
server := &Server{
|
||||
identityTestRevision: identity.Revision{
|
||||
Issuer: "https://auth.test.example/issuer", LocalTenantKey: "default", JITEnabled: true,
|
||||
},
|
||||
oidcUserResolver: resolver,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user.GatewayUserID == "" || user.GatewayTenantID == "" || user.UserGroupID == "" {
|
||||
t.Fatalf("resolved Gateway context missing: %+v", user)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
|
||||
ID: "platform-user",
|
||||
Username: "alice",
|
||||
Roles: []string{"basic"},
|
||||
TenantID: "external-tenant",
|
||||
Source: "oidc",
|
||||
}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.resolveGatewayUser(next).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", recorder.Code)
|
||||
}
|
||||
if resolver.calls != 1 || resolver.input.Subject != "platform-user" || resolver.input.GatewayTenantKey != "default" || !resolver.input.ProvisioningEnabled {
|
||||
t.Fatalf("unexpected resolver call: calls=%d input=%+v", resolver.calls, resolver.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGatewayUserLeavesNonOIDCIdentityChainsUnchanged(t *testing.T) {
|
||||
for _, source := range []string{"gateway", "api_key", "server-main"} {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
resolver := &fakeOIDCUserResolver{}
|
||||
server := &Server{oidcUserResolver: resolver, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
original := &auth.User{ID: "local-user", Source: source, GatewayUserID: "local-user"}
|
||||
request = request.WithContext(auth.WithUser(request.Context(), original))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.resolveGatewayUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resolved, _ := auth.UserFromContext(r.Context())
|
||||
if resolved != original {
|
||||
t.Fatalf("non-OIDC identity context was replaced: %+v", resolved)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusNoContent || resolver.calls != 0 {
|
||||
t.Fatalf("status=%d resolver calls=%d", recorder.Code, resolver.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{name: "not provisioned", err: store.ErrOIDCUserNotProvisioned, status: http.StatusForbidden, code: "GATEWAY_USER_NOT_PROVISIONED"},
|
||||
{name: "disabled", err: store.ErrOIDCUserDisabled, status: http.StatusForbidden, code: "GATEWAY_USER_DISABLED"},
|
||||
{name: "tenant unavailable", err: store.ErrOIDCTenantUnavailable, status: http.StatusServiceUnavailable, code: "GATEWAY_TENANT_UNAVAILABLE"},
|
||||
{name: "storage failure", err: errors.New("database unavailable"), status: http.StatusServiceUnavailable, code: "GATEWAY_USER_PROVISIONING_FAILED"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := &Server{
|
||||
identityTestRevision: identity.Revision{Issuer: "https://auth.test.example", LocalTenantKey: "default"},
|
||||
oidcUserResolver: &fakeOIDCUserResolver{err: test.err},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
|
||||
ID: "platform-user", Source: "oidc", TenantID: "external-tenant",
|
||||
}))
|
||||
recorder := httptest.NewRecorder()
|
||||
server.resolveGatewayUser(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("next handler must not run")
|
||||
})).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != test.status {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, test.status)
|
||||
}
|
||||
var envelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode error envelope: %v", err)
|
||||
}
|
||||
if envelope.Error.Code != test.code || envelope.Error.Status != test.status || envelope.Error.Message == "" {
|
||||
t.Fatalf("unexpected error envelope: %+v", envelope)
|
||||
}
|
||||
if envelope.Error.Message == test.err.Error() {
|
||||
t.Fatalf("internal error leaked to response: %q", envelope.Error.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,17 @@ type ReadyResponse struct {
|
||||
OK bool `json:"ok" example:"true"`
|
||||
}
|
||||
|
||||
type SkillBundleMetadataResponse struct {
|
||||
Name string `json:"name" example:"ai-gateway-ops-management"`
|
||||
Version string `json:"version" example:"1.0.2"`
|
||||
DisplayName string `json:"displayName" example:"AI Gateway 运维管理"`
|
||||
Modules []string `json:"modules" example:"model-runtime"`
|
||||
FileName string `json:"fileName" example:"ai-gateway-ops-management-v1.0.2.zip"`
|
||||
DownloadPath string `json:"downloadPath" example:"/api/v1/public/skills/ai-gateway-ops-management/download"`
|
||||
APIDocsJSONPath string `json:"apiDocsJsonPath" example:"/api-docs-json"`
|
||||
APIDocsYAMLPath string `json:"apiDocsYamlPath" example:"/api-docs-yaml"`
|
||||
}
|
||||
|
||||
type ErrorEnvelope struct {
|
||||
Error ErrorPayload `json:"error"`
|
||||
}
|
||||
@@ -24,6 +35,8 @@ type ErrorPayload struct {
|
||||
Message string `json:"message" example:"invalid json body"`
|
||||
Status int `json:"status" example:"400"`
|
||||
Code string `json:"code,omitempty" example:"rate_limit"`
|
||||
Type string `json:"type,omitempty" example:"invalid_request_error"`
|
||||
Param any `json:"param,omitempty"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
@@ -101,6 +114,18 @@ type AuditLogListResponse struct {
|
||||
Items []store.AuditLog `json:"items"`
|
||||
}
|
||||
|
||||
type BillingSettlementListResponse struct {
|
||||
Items []store.BillingSettlement `json:"items"`
|
||||
Total int `json:"total" example:"42"`
|
||||
Page int `json:"page" example:"1"`
|
||||
PageSize int `json:"pageSize" example:"50"`
|
||||
}
|
||||
|
||||
type BillingSettlementRetryResponse struct {
|
||||
Settlement store.BillingSettlement `json:"settlement"`
|
||||
AuditLog store.AuditLog `json:"auditLog"`
|
||||
}
|
||||
|
||||
type WalletTransactionListResponse struct {
|
||||
Items []store.GatewayWalletTransaction `json:"items"`
|
||||
Total int `json:"total" example:"42"`
|
||||
@@ -175,26 +200,38 @@ type PricingEstimateRequest struct {
|
||||
}
|
||||
|
||||
type PricingEstimateResponse struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
Resolver string `json:"resolver" example:"effective-pricing-v1"`
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
Resolver string `json:"resolver" example:"effective-pricing-v2"`
|
||||
TotalAmount float64 `json:"totalAmount" example:"1.25"`
|
||||
ReservationAmount float64 `json:"reservationAmount" example:"2.75"`
|
||||
Currency string `json:"currency" example:"resource"`
|
||||
CandidateCount int `json:"candidateCount" example:"2"`
|
||||
PricingVersion string `json:"pricingVersion" example:"effective-pricing-v2"`
|
||||
RequestFingerprint string `json:"requestFingerprint" example:"76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f"`
|
||||
}
|
||||
|
||||
type TaskRequest struct {
|
||||
Model string `json:"model" example:"gpt-4o-mini"`
|
||||
Messages []ChatMessage `json:"messages,omitempty"`
|
||||
Input string `json:"input,omitempty" example:"Tell me a short story"`
|
||||
Input interface{} `json:"input,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"`
|
||||
Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."`
|
||||
TextFileID string `json:"text_file_id,omitempty" example:""`
|
||||
VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"`
|
||||
Stream bool `json:"stream,omitempty" example:"false"`
|
||||
Stream *bool `json:"stream,omitempty" example:"false"`
|
||||
RunMode string `json:"runMode,omitempty" example:"simulation"`
|
||||
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
|
||||
// ReasoningEffort 推理深度,OpenAI-compatible 请求字段;开放字符串,取值随 provider 和模型能力而定,常见值为 none、minimal、low、medium、high、xhigh,也可配置 max 等供应商自定义值。
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
|
||||
// MaxCompletionTokens includes visible output and reasoning tokens.
|
||||
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"`
|
||||
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium" enums:"none,minimal,low,medium,high,xhigh,max"`
|
||||
Size string `json:"size,omitempty" example:"1024x1024"`
|
||||
Duration int `json:"duration,omitempty" example:"5"`
|
||||
Resolution string `json:"resolution,omitempty" example:"720p"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"`
|
||||
Audio *bool `json:"audio,omitempty" example:"false"`
|
||||
Watermark *bool `json:"watermark,omitempty" example:"false"`
|
||||
MakeInstrumental bool `json:"makeInstrumental,omitempty" example:"false"`
|
||||
CustomMode bool `json:"customMode,omitempty" example:"false"`
|
||||
Style string `json:"style,omitempty" example:"city pop, bright synth"`
|
||||
@@ -212,26 +249,100 @@ type TaskRequest struct {
|
||||
}
|
||||
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model" example:"gpt-4o-mini"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty" example:"0.7"`
|
||||
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
|
||||
// ReasoningEffort 推理深度,OpenAI-compatible 请求字段;开放字符串,取值随 provider 和模型能力而定,常见值为 none、minimal、low、medium、high、xhigh,也可配置 max 等供应商自定义值。
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
|
||||
Stream bool `json:"stream,omitempty" example:"false"`
|
||||
RunMode string `json:"runMode,omitempty" example:"simulation"`
|
||||
Model string `json:"model" example:"gpt-4o-mini"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
Audio map[string]interface{} `json:"audio,omitempty"`
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" example:"0"`
|
||||
FunctionCall interface{} `json:"function_call,omitempty"`
|
||||
Functions []map[string]interface{} `json:"functions,omitempty"`
|
||||
LogitBias map[string]interface{} `json:"logit_bias,omitempty"`
|
||||
Logprobs *bool `json:"logprobs,omitempty"`
|
||||
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Modalities []string `json:"modalities,omitempty"`
|
||||
Moderation interface{} `json:"moderation,omitempty"`
|
||||
N *int `json:"n,omitempty" example:"1"`
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
Prediction interface{} `json:"prediction,omitempty"`
|
||||
PresencePenalty *float64 `json:"presence_penalty,omitempty" example:"0"`
|
||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||
PromptCacheOptions map[string]interface{} `json:"prompt_cache_options,omitempty"`
|
||||
PromptCacheRetention string `json:"prompt_cache_retention,omitempty" enums:"in_memory,24h"`
|
||||
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium" enums:"none,minimal,low,medium,high,xhigh,max"`
|
||||
ResponseFormat interface{} `json:"response_format,omitempty"`
|
||||
SafetyIdentifier string `json:"safety_identifier,omitempty"`
|
||||
Seed *int `json:"seed,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
Stop interface{} `json:"stop,omitempty"`
|
||||
Store *bool `json:"store,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty" example:"false"`
|
||||
StreamOptions map[string]interface{} `json:"stream_options,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty" example:"0.7"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
Tools []map[string]interface{} `json:"tools,omitempty"`
|
||||
TopLogprobs *int `json:"top_logprobs,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty" example:"1"`
|
||||
User string `json:"user,omitempty"`
|
||||
Verbosity string `json:"verbosity,omitempty"`
|
||||
WebSearchOptions map[string]interface{} `json:"web_search_options,omitempty"`
|
||||
RunMode string `json:"runMode,omitempty" example:"simulation"`
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role" example:"user"`
|
||||
Content string `json:"content" example:"Hello"`
|
||||
Role string `json:"role" example:"user"`
|
||||
Content interface{} `json:"content,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ToolCalls interface{} `json:"tool_calls,omitempty"`
|
||||
FunctionCall interface{} `json:"function_call,omitempty"`
|
||||
}
|
||||
|
||||
type ResponsesRequest struct {
|
||||
Model string `json:"model" example:"gpt-4o-mini"`
|
||||
Input interface{} `json:"input" example:"Tell me a short story"`
|
||||
Stream bool `json:"stream,omitempty" example:"false"`
|
||||
RunMode string `json:"runMode,omitempty" example:"simulation"`
|
||||
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
|
||||
Background *bool `json:"background,omitempty"`
|
||||
ContextManagement []map[string]interface{} `json:"context_management,omitempty"`
|
||||
Conversation interface{} `json:"conversation,omitempty"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
Input interface{} `json:"input"`
|
||||
Instructions string `json:"instructions,omitempty" example:"Answer concisely"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"`
|
||||
MaxToolCalls *int `json:"max_tool_calls,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Moderation interface{} `json:"moderation,omitempty"`
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty" example:"true"`
|
||||
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_0123456789abcdef0123456789abcdef"`
|
||||
Prompt interface{} `json:"prompt,omitempty"`
|
||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||
PromptCacheOptions map[string]interface{} `json:"prompt_cache_options,omitempty"`
|
||||
PromptCacheRetention string `json:"prompt_cache_retention,omitempty" enums:"in_memory,24h"`
|
||||
Reasoning map[string]interface{} `json:"reasoning,omitempty"`
|
||||
SafetyIdentifier string `json:"safety_identifier,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
Store *bool `json:"store,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty" example:"false"`
|
||||
StreamOptions map[string]interface{} `json:"stream_options,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty" example:"0.7"`
|
||||
Text map[string]interface{} `json:"text,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
Tools []map[string]interface{} `json:"tools,omitempty"`
|
||||
TopLogprobs *int `json:"top_logprobs,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty" example:"1"`
|
||||
Truncation string `json:"truncation,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
type ResponsesCompatibleResponse struct {
|
||||
ID string `json:"id" example:"resp_0123456789abcdef0123456789abcdef"`
|
||||
Object string `json:"object" example:"response"`
|
||||
CreatedAt int64 `json:"created_at" example:"1710000000"`
|
||||
Status string `json:"status" example:"completed"`
|
||||
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
|
||||
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_abcdef0123456789abcdef0123456789"`
|
||||
Output []map[string]interface{} `json:"output"`
|
||||
OutputText string `json:"output_text,omitempty" example:"Hello"`
|
||||
Usage map[string]interface{} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type ImageGenerationRequest struct {
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const seedancePortraitAssetCategory = "seedance_portrait_asset"
|
||||
|
||||
// getSeedancePortraitAssetCapability godoc
|
||||
// @Summary 查询 Seedance 真人资产能力
|
||||
// @Description 返回当前网关是否已配置可创建、同步和引用的火山 Seedance 真人资产平台。
|
||||
// @Tags portrait-assets
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} runner.PortraitAssetCapability
|
||||
// @Router /api/v1/resource/material/seedance-portrait-assets/capability [get]
|
||||
func (s *Server) getSeedancePortraitAssetCapability(w http.ResponseWriter, r *http.Request) {
|
||||
capability, err := s.runner.PortraitAssetCapability(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("get portrait asset capability failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "get portrait asset capability failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, capability)
|
||||
}
|
||||
|
||||
// listSeedancePortraitAssets godoc
|
||||
// @Summary 列出 Seedance 真人资产
|
||||
// @Description 返回当前用户的真人资产;兼容 server-main material 列表响应字段。
|
||||
// @Tags portrait-assets
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/resource/material/user/materials [get]
|
||||
func (s *Server) listSeedancePortraitAssets(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if category := strings.TrimSpace(r.URL.Query().Get("category")); category != seedancePortraitAssetCategory {
|
||||
writeError(w, http.StatusNotFound, "material category not found")
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListPortraitAssets(r.Context(), user, store.PortraitAssetListFilter{
|
||||
Keyword: r.URL.Query().Get("keyword"),
|
||||
SourceType: firstNonEmptyQuery(r, "fileType", "sourceType"),
|
||||
Page: portraitAssetQueryInt(r, "pageNumber", "page"),
|
||||
PageSize: portraitAssetQueryInt(r, "pageSize"),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list portrait assets failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list portrait assets failed")
|
||||
return
|
||||
}
|
||||
responseItems := make([]any, 0, len(items.Items))
|
||||
for _, item := range items.Items {
|
||||
responseItems = append(responseItems, s.portraitAssetResponse(r, item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": responseItems,
|
||||
"total": items.Total,
|
||||
"page": items.Page,
|
||||
"pageSize": items.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// createSeedancePortraitAsset godoc
|
||||
// @Summary 上传并创建 Seedance 真人资产
|
||||
// @Description 文件先写入网关文件存储;仅在 private_avatar_eligible=true 时登记到火山 Assets。创建后会立即触发一次状态同步。
|
||||
// @Tags portrait-assets
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param file formData file true "真人资产源文件(图片、视频或音频)"
|
||||
// @Param data formData string true "material JSON,category 必须是 seedance_portrait_asset"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/resource/material [post]
|
||||
func (s *Server) createSeedancePortraitAsset(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
|
||||
if err := r.ParseMultipartForm(multipartTaskMemoryBytes); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form-data body")
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(r.FormValue("data"))), &data); err != nil || data == nil {
|
||||
writeError(w, http.StatusBadRequest, "data must be a JSON object")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(portraitAssetString(data["category"])) != seedancePortraitAssetCategory {
|
||||
writeError(w, http.StatusBadRequest, "category must be seedance_portrait_asset")
|
||||
return
|
||||
}
|
||||
privateEligible, _ := data["private_avatar_eligible"].(bool)
|
||||
if !privateEligible {
|
||||
writeError(w, http.StatusBadRequest, "private_avatar_eligible must be true after the user confirms authorization", "portrait_asset_authorization_required")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
payload, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "read portrait asset file failed")
|
||||
return
|
||||
}
|
||||
contentType := strings.TrimSpace(header.Header.Get("Content-Type"))
|
||||
if contentType == "" && len(payload) > 0 {
|
||||
contentType = http.DetectContentType(payload)
|
||||
}
|
||||
sourceType := strings.ToLower(strings.TrimSpace(firstNonEmpty(portraitAssetString(data["fileType"]), portraitAssetString(data["sourceType"]))))
|
||||
if !portraitAssetSourceMatchesContentType(sourceType, contentType) {
|
||||
writeError(w, http.StatusBadRequest, "fileType must be image, video, or audio and match the uploaded file", "portrait_asset_unsupported_type")
|
||||
return
|
||||
}
|
||||
upload, err := s.runner.UploadFile(r.Context(), runner.FileUploadPayload{
|
||||
Bytes: payload, ContentType: contentType, FileName: header.Filename, Source: "seedance-portrait-asset", Scene: store.FileStorageSceneUpload,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("upload portrait asset failed", "error", err)
|
||||
writeError(w, http.StatusBadGateway, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(portraitAssetString(upload["url"]))
|
||||
if url == "" {
|
||||
writeError(w, http.StatusBadGateway, "portrait asset upload returned no URL", "portrait_asset_source_url_required")
|
||||
return
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
asset, reused, err := s.runner.CreatePortraitAsset(r.Context(), user, runner.PortraitAssetCreateInput{
|
||||
Name: strings.TrimSpace(portraitAssetString(data["name"])),
|
||||
Description: strings.TrimSpace(portraitAssetString(data["description"])),
|
||||
SourceType: sourceType,
|
||||
URL: url,
|
||||
Preview: firstNonEmpty(portraitAssetString(data["preview"]), url),
|
||||
MimeType: contentType,
|
||||
ByteSize: int64(len(payload)),
|
||||
SourceSHA256: hex.EncodeToString(digest[:]),
|
||||
PrivateAvatarEligible: privateEligible,
|
||||
Metadata: map[string]any{
|
||||
"tags": data["tags"],
|
||||
"materialGroupId": data["material_group_id"],
|
||||
"uploadedFileName": header.Filename,
|
||||
"uploadAssetStorage": upload["assetStorage"],
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
writePortraitAssetError(w, err)
|
||||
return
|
||||
}
|
||||
_, _ = s.runner.SyncPortraitAssets(r.Context(), user, []string{asset.ID})
|
||||
asset, _, err = s.refreshPortraitAssetForResponse(r, user, asset.ID, asset)
|
||||
if err != nil {
|
||||
s.logger.Error("refresh portrait asset after create failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "refresh portrait asset failed")
|
||||
return
|
||||
}
|
||||
response := map[string]any{"asset": s.portraitAssetResponse(r, asset)}
|
||||
if reused {
|
||||
response["dedupe"] = map[string]any{"reused": true, "code": "PORTRAIT_ASSET_REUSED", "reason": "same_source", "message": "已复用相同源文件的真人资产,并触发状态刷新。"}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// syncSeedancePortraitAssets godoc
|
||||
// @Summary 同步 Seedance 真人资产状态
|
||||
// @Description 调用火山 CreateAsset/GetAsset;多次调用可把 Processing 状态刷新为 Active 或 Failed。
|
||||
// @Tags portrait-assets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} runner.PortraitAssetSyncResponse
|
||||
// @Router /api/v1/resource/material/seedance-portrait-assets/sync [post]
|
||||
func (s *Server) syncSeedancePortraitAssets(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if len(request.IDs) == 0 {
|
||||
writeJSON(w, http.StatusOK, runner.PortraitAssetSyncResponse{SyncedIDs: []string{}, Skipped: []runner.PortraitAssetIssue{}, Failed: []runner.PortraitAssetIssue{}, Assets: []store.PortraitAsset{}})
|
||||
return
|
||||
}
|
||||
response, err := s.runner.SyncPortraitAssets(r.Context(), user, request.IDs)
|
||||
if err != nil {
|
||||
s.logger.Error("sync portrait assets failed", "error", err)
|
||||
writePortraitAssetError(w, err)
|
||||
return
|
||||
}
|
||||
assets := make([]any, 0, len(response.Assets))
|
||||
for _, asset := range response.Assets {
|
||||
assets = append(assets, s.portraitAssetResponse(r, asset))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"requested": response.Requested, "accepted": response.Accepted, "syncedIds": response.SyncedIDs,
|
||||
"skipped": response.Skipped, "failed": response.Failed, "assets": assets,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) refreshPortraitAssetForResponse(r *http.Request, user *auth.User, assetID string, fallback store.PortraitAsset) (store.PortraitAsset, bool, error) {
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(r.Context(), user, assetID)
|
||||
if err != nil || !found {
|
||||
return fallback, found, err
|
||||
}
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (s *Server) portraitAssetResponse(r *http.Request, asset store.PortraitAsset) map[string]any {
|
||||
active, total, lastError, updatedAt, err := s.store.PortraitAssetBindingSummary(r.Context(), asset.ID)
|
||||
if err != nil {
|
||||
active, total, lastError, updatedAt = 0, 0, asset.LastError, asset.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
summaryStatus := asset.Status
|
||||
if summaryStatus == "not_synced" && total == 0 {
|
||||
summaryStatus = "not_synced"
|
||||
}
|
||||
response := map[string]any{
|
||||
"id": asset.ID, "name": asset.Name, "description": asset.Description, "url": asset.URL, "preview": firstNonEmpty(asset.Preview, asset.URL),
|
||||
"type": "personal", "fileType": asset.SourceType, "sourceType": asset.SourceType, "size": asset.ByteSize,
|
||||
"privateAvatarEligible": asset.PrivateAvatarEligible,
|
||||
"createdAt": asset.CreatedAt.UTC().Format(time.RFC3339Nano), "updatedAt": asset.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"seedanceAssetSummary": map[string]any{
|
||||
"eligible": asset.PrivateAvatarEligible, "status": summaryStatus, "provider": "volces", "activePlatformCount": active,
|
||||
"totalPlatformCount": total, "sourceType": asset.SourceType, "updatedAt": updatedAt,
|
||||
},
|
||||
}
|
||||
if lastError != "" {
|
||||
response["seedanceAssetSummary"].(map[string]any)["lastError"] = lastError
|
||||
}
|
||||
if asset.SourceType == "image" {
|
||||
response["thumbnail"] = firstNonEmpty(asset.Preview, asset.URL)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func writePortraitAssetError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
if clientErr := clients.ErrorCode(err); clientErr != "client_error" {
|
||||
switch clientErr {
|
||||
case "portrait_asset_not_found":
|
||||
status = http.StatusNotFound
|
||||
case "portrait_asset_processing":
|
||||
status = http.StatusServiceUnavailable
|
||||
case "portrait_asset_authorization_required", "portrait_asset_unsupported_type", "portrait_asset_source_url_required", "portrait_asset_id_required", "portrait_asset_unsupported_model", "portrait_asset_audio_only":
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeError(w, status, err.Error(), clientErr)
|
||||
return
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
}
|
||||
|
||||
func portraitAssetSourceMatchesContentType(sourceType string, contentType string) bool {
|
||||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||
switch sourceType {
|
||||
case "image":
|
||||
return strings.HasPrefix(contentType, "image/")
|
||||
case "video":
|
||||
return strings.HasPrefix(contentType, "video/")
|
||||
case "audio":
|
||||
return strings.HasPrefix(contentType, "audio/")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetQueryInt(r *http.Request, keys ...string) int {
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(r.URL.Query().Get(key))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
var parsed int
|
||||
if _, err := fmt.Sscan(value, &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonEmptyQuery(r *http.Request, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func portraitAssetString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
)
|
||||
|
||||
func TestPricingUnavailableUsesStructuredServiceUnavailableError(t *testing.T) {
|
||||
err := &runner.PricingUnavailableError{
|
||||
Reason: "missing, invalid, or not explicitly free",
|
||||
ResourceType: "image",
|
||||
RuleSetID: "rule-set-id",
|
||||
}
|
||||
if got := statusFromRunError(err); got != http.StatusServiceUnavailable {
|
||||
t.Fatalf("statusFromRunError()=%d, want %d", got, http.StatusServiceUnavailable)
|
||||
}
|
||||
if got := runErrorCode(err); got != "pricing_unavailable" {
|
||||
t.Fatalf("runErrorCode()=%q, want pricing_unavailable", got)
|
||||
}
|
||||
details := runErrorDetails(err)
|
||||
pricing, _ := details["pricing"].(map[string]any)
|
||||
if pricing["resourceType"] != "image" || pricing["ruleSetId"] != "rule-set-id" {
|
||||
t.Fatalf("unexpected pricing details: %+v", details)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
@@ -146,10 +147,45 @@ func validPricingRuleSetInput(input store.PricingRuleSetInput) bool {
|
||||
if strings.TrimSpace(input.RuleSetKey) == "" || strings.TrimSpace(input.Name) == "" || len(input.Rules) == 0 {
|
||||
return false
|
||||
}
|
||||
if currency := strings.TrimSpace(input.Currency); currency != "" && currency != "resource" {
|
||||
return false
|
||||
}
|
||||
for _, rule := range input.Rules {
|
||||
if strings.TrimSpace(rule.ResourceType) == "" || strings.TrimSpace(rule.Unit) == "" {
|
||||
return false
|
||||
}
|
||||
if rule.BasePrice < 0 || (rule.BasePrice == 0 && !rule.IsFree) {
|
||||
return false
|
||||
}
|
||||
if currency := strings.TrimSpace(rule.Currency); currency != "" && currency != "resource" {
|
||||
return false
|
||||
}
|
||||
switch calculator := strings.TrimSpace(rule.CalculatorType); calculator {
|
||||
case "", "token_usage", "unit_weight", "duration_weight":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
calculator := strings.TrimSpace(rule.CalculatorType)
|
||||
if calculator == "" {
|
||||
calculator = store.DefaultEffectivePricingCalculator(rule.ResourceType)
|
||||
}
|
||||
if store.ValidateEffectivePricingRuleShape(strings.TrimSpace(rule.ResourceType), store.NormalizeEffectivePricingRuleUnit(rule.ResourceType, rule.Unit), calculator) != nil {
|
||||
return false
|
||||
}
|
||||
effectiveFrom, fromOK := pricingEffectiveTime(rule.EffectiveFrom)
|
||||
effectiveTo, toOK := pricingEffectiveTime(rule.EffectiveTo)
|
||||
if !fromOK || !toOK || (!effectiveFrom.IsZero() && !effectiveTo.IsZero() && !effectiveFrom.Before(effectiveTo)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pricingEffectiveTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, true
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestPricingRuleSetRejectsImplicitZeroAndUnsupportedCalculator(t *testing.T) {
|
||||
base := store.PricingRuleSetInput{
|
||||
RuleSetKey: "test", Name: "test",
|
||||
Rules: []store.PricingRuleInput{{
|
||||
ResourceType: "image", Unit: "image", BasePrice: 0,
|
||||
Currency: "resource", CalculatorType: "unit_weight",
|
||||
}},
|
||||
}
|
||||
if validPricingRuleSetInput(base) {
|
||||
t.Fatal("implicit zero price must be rejected")
|
||||
}
|
||||
base.Rules[0].IsFree = true
|
||||
if !validPricingRuleSetInput(base) {
|
||||
t.Fatal("explicit free price should be accepted")
|
||||
}
|
||||
base.Rules[0].CalculatorType = "formula"
|
||||
if validPricingRuleSetInput(base) {
|
||||
t.Fatal("arbitrary formula calculator must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -63,12 +63,14 @@ func (s *Server) prepareTaskRequest(ctx context.Context, r *http.Request, user *
|
||||
return preparedTaskRequest{}, err
|
||||
}
|
||||
inputs := make([]store.ConversationMessageInput, 0, len(messages))
|
||||
messageHashes := make([]any, 0, len(messages))
|
||||
for _, rawMessage := range messages {
|
||||
message, _ := rawMessage.(map[string]any)
|
||||
if message == nil {
|
||||
message = map[string]any{"content": rawMessage}
|
||||
}
|
||||
hash, assetHashes := canonicalConversationMessageHash(message)
|
||||
messageHashes = append(messageHashes, hash)
|
||||
inputs = append(inputs, store.ConversationMessageInput{
|
||||
Hash: hash,
|
||||
Role: stringFromRequestAny(message["role"]),
|
||||
@@ -83,6 +85,7 @@ func (s *Server) prepareTaskRequest(ctx context.Context, r *http.Request, user *
|
||||
preparedBody["conversationId"] = conversationKey
|
||||
preparedBody["conversationRecordId"] = conversationID
|
||||
preparedBody["messageRefs"] = messageRefsForRequest(refs)
|
||||
preparedBody["messageHashes"] = messageHashes
|
||||
preparedBody["newMessageCount"] = newCount
|
||||
delete(preparedBody, "messages")
|
||||
result.ConversationID = conversationID
|
||||
|
||||
@@ -25,6 +25,12 @@ func writeErrorWithDetails(w http.ResponseWriter, status int, message string, de
|
||||
if len(codes) > 0 {
|
||||
if code := strings.TrimSpace(codes[0]); code != "" {
|
||||
errorPayload["code"] = code
|
||||
if code == "invalid_parameter" || code == "unsupported_response_parameter" {
|
||||
errorPayload["type"] = "invalid_request_error"
|
||||
if _, ok := details["param"]; !ok {
|
||||
errorPayload["param"] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, value := range details {
|
||||
@@ -33,6 +39,10 @@ func writeErrorWithDetails(w http.ResponseWriter, status int, message string, de
|
||||
writeJSON(w, status, map[string]any{"error": errorPayload})
|
||||
}
|
||||
|
||||
func writeLocalUserRequired(w http.ResponseWriter) {
|
||||
writeError(w, http.StatusForbidden, "该账号尚未开通 EasyAI Gateway", errorCodeGatewayUserNotProvisioned)
|
||||
}
|
||||
|
||||
func sendSSE(w http.ResponseWriter, event string, payload any) {
|
||||
bytes, _ := json.Marshal(payload)
|
||||
_, _ = fmt.Fprintf(w, "event: %s\n", event)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
)
|
||||
|
||||
func TestResponsesStreamWriterForwardsStandardEventsAndNeverWritesDoneMarker(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
writer := newCompatibleStreamWriter("responses", "demo", true)
|
||||
writer.writeDelta(recorder, clients.StreamDeltaEvent{Event: map[string]any{
|
||||
"type": "response.created", "response": map[string]any{"id": "resp_123", "status": "in_progress"},
|
||||
}})
|
||||
writer.writeDelta(recorder, clients.StreamDeltaEvent{Event: map[string]any{
|
||||
"type": "response.function_call_arguments.delta", "item_id": "fc_1", "delta": "{\"x\":",
|
||||
}})
|
||||
writer.writeDelta(recorder, clients.StreamDeltaEvent{Event: map[string]any{
|
||||
"type": "response.completed", "sequence_number": 2, "response": map[string]any{"id": "resp_123", "status": "completed", "output": []any{}},
|
||||
}})
|
||||
writer.writeDone(recorder, map[string]any{"id": "resp_123", "object": "response", "status": "completed", "output": []any{}})
|
||||
body := recorder.Body.String()
|
||||
for _, expected := range []string{"event: response.created", "event: response.function_call_arguments.delta", "event: response.completed"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("missing %q in stream: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "[DONE]") || strings.Contains(body, "chat.completion") {
|
||||
t.Fatalf("Responses stream leaked Chat framing: %s", body)
|
||||
}
|
||||
if strings.Count(body, "event: response.completed") != 1 {
|
||||
t.Fatalf("Responses stream duplicated response.completed: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type securityEventConnectionRequest struct {
|
||||
TransmitterIssuer string `json:"transmitter_issuer"`
|
||||
ManagementClientID string `json:"management_client_id"`
|
||||
ManagementClientSecret string `json:"management_client_secret"`
|
||||
}
|
||||
|
||||
type securityEventConnectionResponse struct {
|
||||
Connected bool `json:"connected"`
|
||||
Connection ssfreceiver.ConnectionView `json:"connection"`
|
||||
TraceID string `json:"traceId,omitempty"`
|
||||
AuditID string `json:"auditId,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
||||
ensureSecurityEventTraceID(w, r)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
manager := s.currentSecurityEventManager()
|
||||
if manager == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()})
|
||||
return
|
||||
}
|
||||
connection, err := manager.Get(r.Context())
|
||||
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "security event connection state is unavailable", "security_event_state_unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, connection.Version))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"connected": true, "connection": connection, "prerequisites": s.securityEventPrerequisites()})
|
||||
}
|
||||
|
||||
func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := ensureSecurityEventTraceID(w, r)
|
||||
manager := s.currentSecurityEventManager()
|
||||
if manager == nil {
|
||||
writeError(w, http.StatusConflict, "OIDC must be configured before connecting security events", "security_event_prerequisite_missing")
|
||||
return
|
||||
}
|
||||
idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request securityEventConnectionRequest
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || strings.TrimSpace(request.TransmitterIssuer) == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid security event connection request", "invalid_request")
|
||||
return
|
||||
}
|
||||
request.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(request.TransmitterIssuer), "/")
|
||||
request.ManagementClientID = strings.TrimSpace(request.ManagementClientID)
|
||||
if (request.ManagementClientID == "") != (request.ManagementClientSecret == "") || len(request.ManagementClientID) > 200 || len(request.ManagementClientSecret) > 512 {
|
||||
writeError(w, http.StatusBadRequest, "machine Client ID and Secret must be provided together", "invalid_machine_credential")
|
||||
return
|
||||
}
|
||||
secret := []byte(request.ManagementClientSecret)
|
||||
defer clear(secret)
|
||||
secretDigest := sha256.Sum256(secret)
|
||||
requestHash := securityEventOperationHash("connect", request.TransmitterIssuer+"\x00"+request.ManagementClientID+"\x00"+fmt.Sprintf("%x", secretDigest[:]))
|
||||
s.securityEventManagementMu.Lock()
|
||||
defer s.securityEventManagementMu.Unlock()
|
||||
if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) {
|
||||
return
|
||||
}
|
||||
current, currentErr := manager.Get(r.Context())
|
||||
switch {
|
||||
case currentErr == nil:
|
||||
if !matchConnectionVersion(w, r, current.Version) {
|
||||
return
|
||||
}
|
||||
case errors.Is(currentErr, store.ErrSecurityEventConnectionNotFound):
|
||||
current = ssfreceiver.ConnectionView{}
|
||||
if !matchConnectionVersion(w, r, 0) {
|
||||
return
|
||||
}
|
||||
default:
|
||||
writeError(w, http.StatusServiceUnavailable, "security event connection state is unavailable", "security_event_state_unavailable")
|
||||
return
|
||||
}
|
||||
auditID, ok := s.requireSecurityEventConnectionAudit(w, r, "connect", traceID, current)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
connection, err := manager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey)
|
||||
if err != nil {
|
||||
s.writeSecurityEventConnectionError(w, r, manager, "connect", traceID, err)
|
||||
return
|
||||
}
|
||||
s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, auditID, connection)
|
||||
}
|
||||
|
||||
func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := ensureSecurityEventTraceID(w, r)
|
||||
s.securityEventManagementMu.Lock()
|
||||
defer s.securityEventManagementMu.Unlock()
|
||||
manager := s.currentSecurityEventManager()
|
||||
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "verify", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
connection, err := manager.Verify(r.Context())
|
||||
if err != nil {
|
||||
s.writeSecurityEventConnectionError(w, r, manager, "verify", traceID, err)
|
||||
return
|
||||
}
|
||||
s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, auditID, connection)
|
||||
}
|
||||
|
||||
func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := ensureSecurityEventTraceID(w, r)
|
||||
s.securityEventManagementMu.Lock()
|
||||
defer s.securityEventManagementMu.Unlock()
|
||||
manager := s.currentSecurityEventManager()
|
||||
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "rotate", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
connection, err := manager.RotateCredential(r.Context())
|
||||
if err != nil {
|
||||
s.writeSecurityEventConnectionError(w, r, manager, "rotate", traceID, err)
|
||||
return
|
||||
}
|
||||
s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, auditID, connection)
|
||||
}
|
||||
|
||||
func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := ensureSecurityEventTraceID(w, r)
|
||||
s.securityEventManagementMu.Lock()
|
||||
defer s.securityEventManagementMu.Unlock()
|
||||
manager := s.currentSecurityEventManager()
|
||||
idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "disconnect", traceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
connection, err := manager.Disconnect(r.Context())
|
||||
if err != nil {
|
||||
s.writeSecurityEventConnectionError(w, r, manager, "disconnect", traceID, err)
|
||||
return
|
||||
}
|
||||
s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, auditID, connection)
|
||||
}
|
||||
|
||||
func (s *Server) securityEventPrerequisites() map[string]any {
|
||||
runtime := s.currentIdentityRuntime()
|
||||
if runtime == nil {
|
||||
return map[string]any{
|
||||
"oidcConfigured": false, "introspectionClientConfigured": false,
|
||||
"publicBaseUrlConfigured": false, "managementClientId": "", "credentialInputSupported": false,
|
||||
}
|
||||
}
|
||||
revision := runtime.Revision
|
||||
return map[string]any{
|
||||
"oidcConfigured": revision.Issuer != "" && revision.TenantID != "",
|
||||
"introspectionClientConfigured": revision.MachineClientID != "" && revision.MachineCredentialRef != "",
|
||||
"publicBaseUrlConfigured": revision.PublicBaseURL != "",
|
||||
"managementClientId": revision.MachineClientID,
|
||||
"credentialInputSupported": false,
|
||||
}
|
||||
}
|
||||
|
||||
func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
value := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
if value == "" || len(value) > 255 {
|
||||
writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "idempotency_key_required")
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, string, bool) {
|
||||
if manager == nil {
|
||||
writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found")
|
||||
return "", "", "", false
|
||||
}
|
||||
idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r)
|
||||
if !ok {
|
||||
return "", "", "", false
|
||||
}
|
||||
requestHash := securityEventOperationHash(operation, normalizedConnectionETag(r.Header.Get("If-Match")))
|
||||
if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) {
|
||||
return "", "", "", false
|
||||
}
|
||||
connection, err := manager.Get(r.Context())
|
||||
if err != nil {
|
||||
s.writeSecurityEventConnectionError(w, r, manager, operation, traceID, err)
|
||||
return "", "", "", false
|
||||
}
|
||||
if !matchConnectionVersion(w, r, connection.Version) {
|
||||
return "", "", "", false
|
||||
}
|
||||
auditID, ok := s.requireSecurityEventConnectionAudit(w, r, operation, traceID, connection)
|
||||
if !ok {
|
||||
return "", "", "", false
|
||||
}
|
||||
return idempotencyKey, requestHash, auditID, true
|
||||
}
|
||||
|
||||
func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash string) bool {
|
||||
if s.store == nil {
|
||||
return false
|
||||
}
|
||||
record, err := s.store.SecurityEventConnectionIdempotency(r.Context(), operation, idempotencyKey)
|
||||
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "security event idempotency state is unavailable", "security_event_state_unavailable")
|
||||
return true
|
||||
}
|
||||
if record.RequestHash != requestHash {
|
||||
writeError(w, http.StatusConflict, "Idempotency-Key was already used for a different request", "idempotency_key_reused")
|
||||
return true
|
||||
}
|
||||
var payload securityEventConnectionResponse
|
||||
if json.Unmarshal(record.Response, &payload) != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "security event idempotency response is unavailable", "security_event_state_unavailable")
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, payload.Connection.Version))
|
||||
if payload.AuditID != "" {
|
||||
w.Header().Set("X-Audit-Id", payload.AuditID)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, payload)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash, traceID, auditID string, connection ssfreceiver.ConnectionView) {
|
||||
if auditID != "" {
|
||||
w.Header().Set("X-Audit-Id", auditID)
|
||||
}
|
||||
payload := securityEventConnectionResponse{Connected: true, Connection: connection, TraceID: traceID, AuditID: auditID}
|
||||
encoded, _ := json.Marshal(payload)
|
||||
if s.store != nil {
|
||||
if err := s.store.RecordSecurityEventConnectionIdempotency(r.Context(), operation, idempotencyKey, requestHash, encoded); err != nil && s.logger != nil {
|
||||
s.logger.Error("security event idempotency result could not be recorded", "error_category", "idempotency_store_failed", "operation", operation)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, connection.Version))
|
||||
writeJSON(w, http.StatusAccepted, payload)
|
||||
}
|
||||
|
||||
func securityEventOperationHash(operation, canonicalRequest string) string {
|
||||
digest := sha256.Sum256([]byte(operation + "\x00" + canonicalRequest))
|
||||
return fmt.Sprintf("%x", digest[:])
|
||||
}
|
||||
|
||||
func normalizedConnectionETag(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "W/")
|
||||
return strings.Trim(value, `"`)
|
||||
}
|
||||
|
||||
func matchConnectionVersion(w http.ResponseWriter, r *http.Request, expected int64) bool {
|
||||
value := strings.TrimSpace(r.Header.Get("If-Match"))
|
||||
value = strings.TrimPrefix(value, "W/")
|
||||
value = strings.Trim(value, `"`)
|
||||
version, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusPreconditionRequired, "If-Match is required", "if_match_required")
|
||||
return false
|
||||
}
|
||||
if version != expected {
|
||||
writeError(w, http.StatusPreconditionFailed, "security event connection version changed", "version_conflict")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) writeSecurityEventConnectionError(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string, err error) {
|
||||
status, message, code := securityEventConnectionErrorProjection(err)
|
||||
connection := ssfreceiver.ConnectionView{}
|
||||
if manager != nil {
|
||||
connection, _ = manager.Get(r.Context())
|
||||
}
|
||||
errorCategory := code
|
||||
if connection.LastErrorCategory != nil && *connection.LastErrorCategory != "" {
|
||||
errorCategory = *connection.LastErrorCategory
|
||||
}
|
||||
if auditID := s.recordSecurityEventConnectionAudit(r, operation, "failure", errorCategory, traceID, connection); auditID != "" {
|
||||
w.Header().Set("X-Audit-Id", auditID)
|
||||
}
|
||||
writeError(w, status, message, code)
|
||||
}
|
||||
|
||||
func securityEventConnectionErrorProjection(err error) (int, string, string) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrSecurityEventConnectionNotFound):
|
||||
return http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found"
|
||||
case errors.Is(err, store.ErrSecurityEventConnectionConflict):
|
||||
return http.StatusConflict, "security event connection conflicts with current state", "security_event_connection_conflict"
|
||||
case strings.Contains(err.Error(), "configured"), strings.Contains(err.Error(), "invalid"), strings.Contains(err.Error(), "HTTPS"):
|
||||
return http.StatusConflict, "security event connection prerequisites are incomplete", "security_event_prerequisite_missing"
|
||||
default:
|
||||
return http.StatusBadGateway, "authentication center security event service is unavailable", "security_event_transmitter_unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
func ensureSecurityEventTraceID(w http.ResponseWriter, r *http.Request) string {
|
||||
traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id"))
|
||||
if !validSecurityEventDiagnosticID(traceID) {
|
||||
traceID = uuid.NewString()
|
||||
}
|
||||
w.Header().Set("X-Trace-Id", traceID)
|
||||
return traceID
|
||||
}
|
||||
|
||||
func validSecurityEventDiagnosticID(value string) bool {
|
||||
if len(value) < 8 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
|
||||
character >= '0' && character <= '9' || strings.ContainsRune("-_.", character) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) recordSecurityEventConnectionAudit(r *http.Request, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) string {
|
||||
if s.store == nil {
|
||||
return ""
|
||||
}
|
||||
actor, _ := auth.UserFromContext(r.Context())
|
||||
input := securityEventConnectionAuditInput(r, actor, operation, outcome, errorCategory, traceID, connection)
|
||||
audit, err := s.store.RecordAuditLog(r.Context(), input)
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.WarnContext(r.Context(), "record security event connection audit failed", "operation", operation, "outcome", outcome, "error_category", "audit_store_failed", "trace_id", traceID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return audit.ID
|
||||
}
|
||||
|
||||
func (s *Server) requireSecurityEventConnectionAudit(w http.ResponseWriter, r *http.Request, operation, traceID string, connection ssfreceiver.ConnectionView) (string, bool) {
|
||||
auditID := s.recordSecurityEventConnectionAudit(r, operation, "requested", "", traceID, connection)
|
||||
if auditID == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "security event audit is unavailable; operation was not executed", "security_event_audit_unavailable")
|
||||
return "", false
|
||||
}
|
||||
w.Header().Set("X-Audit-Id", auditID)
|
||||
return auditID, true
|
||||
}
|
||||
|
||||
func securityEventConnectionAuditInput(r *http.Request, actor *auth.User, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) store.AuditLogInput {
|
||||
input := store.AuditLogInput{
|
||||
Category: "identity", Action: "identity.security_event_connection." + operation,
|
||||
TargetType: "security_event_connection", TargetID: firstNonEmptyText(connection.ConnectionID, "singleton"),
|
||||
RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512),
|
||||
AfterState: map[string]any{"lifecycleStatus": connection.LifecycleStatus, "healthMode": connection.HealthMode},
|
||||
Metadata: map[string]any{"outcome": outcome, "errorCategory": errorCategory, "traceId": traceID},
|
||||
}
|
||||
if actor != nil {
|
||||
input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID))
|
||||
input.ActorUserID, input.ActorUsername, input.ActorSource = actor.ID, actor.Username, actor.Source
|
||||
input.ActorRoles = actor.Roles
|
||||
}
|
||||
return input
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
|
||||
)
|
||||
|
||||
func TestSecurityEventConnectionTraceAndAuditProjection(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPut, "/connection", nil)
|
||||
request.Header.Set("Authorization", "Bearer must-not-escape")
|
||||
recorder := httptest.NewRecorder()
|
||||
traceID := ensureSecurityEventTraceID(recorder, request)
|
||||
if traceID == "" || recorder.Header().Get("X-Trace-Id") != traceID {
|
||||
t.Fatalf("trace header=%q trace=%q", recorder.Header().Get("X-Trace-Id"), traceID)
|
||||
}
|
||||
actor := &auth.User{ID: "admin-id", Username: "admin", Source: "local", Roles: []string{"manager"}}
|
||||
connection := ssfreceiver.ConnectionView{ConnectionID: "connection-id", LifecycleStatus: "error", HealthMode: "introspection_fallback"}
|
||||
input := securityEventConnectionAuditInput(request, actor, "connect", "failure", "management_token_failed", traceID, connection)
|
||||
payload, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if input.Action != "identity.security_event_connection.connect" || input.TargetID != "connection-id" || input.Metadata["traceId"] != traceID {
|
||||
t.Fatalf("audit input=%#v", input)
|
||||
}
|
||||
for _, forbidden := range []string{"must-not-escape", "authorization_header", "push_bearer", "credential_ref"} {
|
||||
if strings.Contains(strings.ToLower(string(payload)), forbidden) {
|
||||
t.Fatalf("audit payload exposed forbidden field %q: %s", forbidden, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventConnectionWriteHeaders(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/connection/verify", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
if _, ok := requiredConnectionIdempotencyKey(recorder, request); ok || recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing Idempotency-Key status=%d", recorder.Code)
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodPut, "/connection", nil)
|
||||
recorder = httptest.NewRecorder()
|
||||
if matchConnectionVersion(recorder, request, 0) || recorder.Code != http.StatusPreconditionRequired {
|
||||
t.Fatalf("initial connection without If-Match status=%d", recorder.Code)
|
||||
}
|
||||
request.Header.Set("If-Match", `W/"0"`)
|
||||
recorder = httptest.NewRecorder()
|
||||
if !matchConnectionVersion(recorder, request, 0) {
|
||||
t.Fatalf("initial zero version was rejected: status=%d", recorder.Code)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodPost, "/connection/verify", nil)
|
||||
request.Header.Set("If-Match", `W/"7"`)
|
||||
recorder = httptest.NewRecorder()
|
||||
if !matchConnectionVersion(recorder, request, 7) {
|
||||
t.Fatalf("valid weak ETag was rejected: status=%d", recorder.Code)
|
||||
}
|
||||
|
||||
request.Header.Set("If-Match", `"6"`)
|
||||
recorder = httptest.NewRecorder()
|
||||
if matchConnectionVersion(recorder, request, 7) || recorder.Code != http.StatusPreconditionFailed {
|
||||
t.Fatalf("stale ETag status=%d", recorder.Code)
|
||||
}
|
||||
if normalizedConnectionETag(`W/"7"`) != "7" ||
|
||||
securityEventOperationHash("verify", "7") == securityEventOperationHash("verify", "8") {
|
||||
t.Fatal("security event idempotency request fingerprint is not stable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventPrerequisitesNeverExposeSecrets(t *testing.T) {
|
||||
server := &Server{identityTestRevision: identity.Revision{
|
||||
Issuer: "https://auth.example/issuer/shared", TenantID: "stable-tenant",
|
||||
MachineClientID: "gateway-machine", MachineCredentialRef: "identity-machine-test",
|
||||
PublicBaseURL: "https://gateway.example",
|
||||
}}
|
||||
payload, err := json.Marshal(server.securityEventPrerequisites())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(payload), "must-not-escape") || strings.Contains(strings.ToLower(string(payload)), "secret") {
|
||||
t.Fatalf("secret escaped in prerequisites: %s", payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package httpapi
|
||||
|
||||
import "net/http"
|
||||
|
||||
// receiveSecurityEvent accepts an RFC 8417 Security Event Token over RFC 8935 Push.
|
||||
//
|
||||
// @Summary Receive an SSF Security Event Token
|
||||
// @Description Optional endpoint. It validates a stream-specific Bearer before parsing and verifying an ES256 SET. A committed event and a duplicate jti both return an empty 202 response.
|
||||
// @Tags Security Events
|
||||
// @Accept application/secevent+jwt
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer stream-specific-secret"
|
||||
// @Param set body string true "Compact signed Security Event Token"
|
||||
// @Success 202
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/security-events/ssf [post]
|
||||
func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) {
|
||||
receiver := s.securityEventReceiver
|
||||
if s.identityRuntime != nil {
|
||||
receiver = s.identityRuntime.SecurityEventReceiver()
|
||||
}
|
||||
if receiver == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
receiver.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
|
||||
)
|
||||
|
||||
type preparedReceiverBuilder struct {
|
||||
receiver http.Handler
|
||||
}
|
||||
|
||||
func (builder *preparedReceiverBuilder) Build(context.Context, identity.Revision) (*identityruntime.Runtime, error) {
|
||||
return &identityruntime.Runtime{}, nil
|
||||
}
|
||||
|
||||
func (builder *preparedReceiverBuilder) PreparedSecurityEventReceiver() http.Handler {
|
||||
return builder.receiver
|
||||
}
|
||||
|
||||
func TestReceiveSecurityEventUsesPreparedReceiverBeforeFirstActivation(t *testing.T) {
|
||||
called := false
|
||||
builder := &preparedReceiverBuilder{receiver: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
})}
|
||||
server := &Server{identityRuntime: identityruntime.NewManager(nil, builder)}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.receiveSecurityEvent(response, request)
|
||||
|
||||
if !called || response.Code != http.StatusAccepted {
|
||||
t.Fatalf("prepared SSF receiver called=%t status=%d", called, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventWriteAuditFailsClosedBeforeMutation(t *testing.T) {
|
||||
server := &Server{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/security-events/connection/verify", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
if _, ok := server.requireSecurityEventConnectionAudit(
|
||||
recorder, request, "verify", "trace-test", securityevents.ConnectionView{},
|
||||
); ok {
|
||||
t.Fatal("security event write unexpectedly continued without durable audit storage")
|
||||
}
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("audit failure status=%d, want 503", recorder.Code)
|
||||
}
|
||||
}
|
||||
+263
-106
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -10,18 +11,55 @@ import (
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
ctx context.Context
|
||||
cfg config.Config
|
||||
store *store.Store
|
||||
auth *auth.Authenticator
|
||||
runner *runner.Service
|
||||
logger *slog.Logger
|
||||
geminiUploadSessions sync.Map
|
||||
ctx context.Context
|
||||
cfg config.Config
|
||||
store *store.Store
|
||||
oidcUserResolver oidcUserResolver
|
||||
auth *auth.Authenticator
|
||||
oidcClient oidcPublicClient
|
||||
oidcSessions oidcSessionManager
|
||||
oidcSessionCipher *oidcsession.Cipher
|
||||
runner *runner.Service
|
||||
logger *slog.Logger
|
||||
geminiUploadSessions sync.Map
|
||||
securityEventReceiver http.Handler
|
||||
securityEventManager *ssfreceiver.ConnectionManager
|
||||
identityRuntime *identityruntime.Manager
|
||||
identityPairing *identity.PairingService
|
||||
identityManagementMu sync.Mutex
|
||||
securityEventManagementMu sync.Mutex
|
||||
identityPairingWorkers sync.Map
|
||||
identityCleanupWorkers sync.Map
|
||||
identityRestoredPairings sync.Map
|
||||
identityTestRevision identity.Revision
|
||||
identityTestCookieSecure bool
|
||||
identityTestBrowserEnabled bool
|
||||
billingMetrics *ssfreceiver.Metrics
|
||||
}
|
||||
|
||||
type oidcPublicClient interface {
|
||||
AuthorizationURL(context.Context, string, string, string) (string, error)
|
||||
ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error)
|
||||
VerifyIDToken(context.Context, string, string) (string, error)
|
||||
Refresh(context.Context, string) (auth.OIDCTokenResponse, error)
|
||||
RevokeRefreshToken(context.Context, string) error
|
||||
EndSessionURL(context.Context, string) (string, error)
|
||||
}
|
||||
|
||||
type oidcSessionManager interface {
|
||||
Create(context.Context, oidcsession.TokenBundle, *auth.User) (string, error)
|
||||
Resolve(context.Context, string) (*auth.User, error)
|
||||
Delete(context.Context, string) (oidcsession.TokenBundle, error)
|
||||
Cleanup(context.Context) (int64, error)
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Handler {
|
||||
@@ -29,45 +67,84 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
||||
}
|
||||
|
||||
func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Store, logger *slog.Logger) http.Handler {
|
||||
securityEventMetrics := &ssfreceiver.Metrics{}
|
||||
server := &Server{
|
||||
ctx: ctx,
|
||||
cfg: cfg,
|
||||
store: db,
|
||||
auth: auth.New(cfg.JWTSecret, cfg.ServerMainBaseURL, cfg.ServerMainInternalToken),
|
||||
runner: runner.New(cfg, db, logger),
|
||||
logger: logger,
|
||||
ctx: ctx,
|
||||
cfg: cfg,
|
||||
store: db,
|
||||
oidcUserResolver: db,
|
||||
auth: auth.New(cfg.JWTSecret, cfg.ServerMainBaseURL, cfg.ServerMainInternalToken),
|
||||
runner: runner.New(cfg, db, logger, securityEventMetrics),
|
||||
logger: logger,
|
||||
billingMetrics: securityEventMetrics,
|
||||
}
|
||||
server.auth.LegacyJWTEnabled = !cfg.OIDCEnabled || cfg.OIDCAcceptLegacyHS256
|
||||
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
|
||||
server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret
|
||||
if cfg.OIDCEnabled {
|
||||
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
|
||||
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
|
||||
RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes,
|
||||
JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second,
|
||||
IntrospectionEnabled: cfg.OIDCIntrospectionEnabled,
|
||||
IntrospectionClientID: cfg.OIDCIntrospectionClientID,
|
||||
IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
panic("invalid OIDC configuration: " + err.Error())
|
||||
secretStore, err := identitySecretStore(cfg)
|
||||
if err != nil {
|
||||
panic("invalid identity SecretStore: " + err.Error())
|
||||
}
|
||||
go ssfreceiver.RunSecurityEventRetirementWorker(ctx, db)
|
||||
go ssfreceiver.RunIdentitySecretCleanupWorker(ctx, db, secretStore)
|
||||
runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{
|
||||
AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute,
|
||||
HeartbeatInterval: time.Duration(cfg.IdentitySecurityEventHeartbeatIntervalSeconds) * time.Second,
|
||||
StaleAfter: time.Duration(cfg.IdentitySecurityEventStaleAfterSeconds) * time.Second,
|
||||
ClockSkew: time.Duration(cfg.IdentitySecurityEventClockSkewSeconds) * time.Second,
|
||||
}, securityEventMetrics)
|
||||
server.identityRuntime = identityruntime.NewManager(db, runtimeBuilder)
|
||||
if err := server.identityRuntime.LoadActive(ctx); err != nil && logger != nil {
|
||||
logger.Error("load active identity runtime failed; local management login remains available", "error_category", "identity_runtime_load_failed")
|
||||
}
|
||||
server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) {
|
||||
return identity.NewOnboardingClient(baseURL, nil, cfg.AppEnv)
|
||||
}, server.identityRuntime, cfg.AppEnv)
|
||||
server.reconcileCanonicalIdentityPairing()
|
||||
go server.runIdentityPairingCoordinator()
|
||||
server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier {
|
||||
if runtime := server.identityRuntime.Current(); runtime != nil {
|
||||
return runtime.Verifier
|
||||
}
|
||||
server.auth.OIDCVerifier = verifier
|
||||
return nil
|
||||
}
|
||||
server.auth.OIDCSessionResolverProvider = func(ctx context.Context, sessionID string) (*auth.User, error) {
|
||||
runtime := server.identityRuntime.Current()
|
||||
if runtime == nil || runtime.Sessions == nil {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
user, resolveErr := runtime.Sessions.Resolve(ctx, sessionID)
|
||||
return user, oidcSessionRequestError(resolveErr)
|
||||
}
|
||||
server.auth.LegacyJWTEnabledProvider = func() bool {
|
||||
return server.identityRuntime.LegacyJWTEnabled()
|
||||
}
|
||||
server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
|
||||
server.runner.StartAsyncQueueWorker(ctx)
|
||||
server.runner.StartBillingSettlementWorker(ctx)
|
||||
server.startLocalTempAssetCleanup(ctx)
|
||||
server.startOIDCSessionCleanup(ctx)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", server.health)
|
||||
mux.HandleFunc("GET /readyz", server.ready)
|
||||
mux.Handle("GET /metrics", securityEventMetrics.DynamicHandler(db))
|
||||
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
|
||||
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
|
||||
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
|
||||
mux.HandleFunc("GET /api-docs-json", server.apiDocsJSON)
|
||||
mux.HandleFunc("GET /api-docs-yaml", server.apiDocsYAML)
|
||||
mux.HandleFunc("GET /api/v1/public/skills/ai-gateway-ops-management/metadata", server.getOpsManagementSkillMetadata)
|
||||
mux.HandleFunc("GET /api/v1/public/skills/ai-gateway-ops-management/download", server.downloadOpsManagementSkill)
|
||||
|
||||
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
|
||||
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
|
||||
mux.Handle("GET /api/v1/me", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.me)))
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/login", server.startOIDCLogin)
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin)
|
||||
mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession)
|
||||
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
|
||||
mux.HandleFunc("GET /api/v1/public/identity", server.getPublicIdentityConfiguration)
|
||||
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
|
||||
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
|
||||
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
|
||||
mux.Handle("GET /api/v1/public/catalog/base-models", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listBaseModels)))
|
||||
mux.Handle("GET /api/v1/public/client-customization", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.getPublicClientCustomizationSettings)))
|
||||
@@ -101,41 +178,59 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /api/admin/access-rules/batch", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.batchAccessRules)))
|
||||
mux.Handle("PATCH /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateAccessRule)))
|
||||
mux.Handle("DELETE /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteAccessRule)))
|
||||
mux.Handle("GET /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeys)))
|
||||
mux.Handle("POST /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
|
||||
mux.Handle("GET /api/v1/api-keys/access-rules", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
|
||||
mux.Handle("POST /api/v1/api-keys/access-rules/batch", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.batchAPIKeyAccessRules)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/scopes", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.updateAPIKeyScopes)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
|
||||
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
|
||||
mux.Handle("GET /api/playground/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys)))
|
||||
mux.Handle("GET /api/workspace/desktop-config", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getDesktopConfig)))
|
||||
mux.Handle("GET /api/workspace/user-groups", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listCurrentUserGroups)))
|
||||
mux.Handle("GET /api/workspace/wallet", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getWallet)))
|
||||
mux.Handle("GET /api/workspace/wallet/transactions", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions)))
|
||||
mux.Handle("GET /api/workspace/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
mux.Handle("GET /api/workspace/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||
mux.Handle("POST /api/workspace/tasks/{taskID}/cancel", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
|
||||
mux.Handle("GET /api/workspace/tasks/{taskID}/param-preprocessing", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
|
||||
mux.Handle("GET /api/workspace/tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
|
||||
mux.Handle("GET /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeys)))
|
||||
mux.Handle("POST /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
|
||||
mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
|
||||
mux.Handle("POST /api/v1/api-keys/access-rules/batch", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.batchAPIKeyAccessRules)))
|
||||
mux.Handle("GET /api/v1/api-keys/assignable-models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAssignableModels)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/scopes", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.updateAPIKeyScopes)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
|
||||
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
|
||||
mux.Handle("GET /api/playground/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys)))
|
||||
mux.Handle("GET /api/workspace/desktop-config", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getDesktopConfig)))
|
||||
mux.Handle("GET /api/workspace/user-groups", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listCurrentUserGroups)))
|
||||
mux.Handle("GET /api/workspace/wallet", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getWallet)))
|
||||
mux.Handle("GET /api/workspace/wallet/transactions", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions)))
|
||||
mux.Handle("GET /api/workspace/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
mux.Handle("GET /api/workspace/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||
mux.Handle("POST /api/workspace/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
|
||||
mux.Handle("GET /api/workspace/tasks/{taskID}/param-preprocessing", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
|
||||
mux.Handle("GET /api/workspace/tasks/{taskID}/events", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
|
||||
mux.Handle("GET /api/admin/pricing/rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRules)))
|
||||
mux.Handle("GET /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRuleSets)))
|
||||
mux.Handle("POST /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPricingRuleSet)))
|
||||
mux.Handle("PATCH /api/admin/pricing/rule-sets/{ruleSetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePricingRuleSet)))
|
||||
mux.Handle("DELETE /api/admin/pricing/rule-sets/{ruleSetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deletePricingRuleSet)))
|
||||
mux.Handle("POST /api/v1/pricing/estimate", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing)))
|
||||
mux.Handle("POST /api/v1/pricing/estimate", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing)))
|
||||
mux.Handle("GET /api/admin/runtime/policy-sets", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRuntimePolicySets)))
|
||||
mux.Handle("POST /api/admin/runtime/policy-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createRuntimePolicySet)))
|
||||
mux.Handle("PATCH /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRuntimePolicySet)))
|
||||
mux.Handle("DELETE /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet)))
|
||||
mux.Handle("GET /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getRunnerPolicy)))
|
||||
mux.Handle("PATCH /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRunnerPolicy)))
|
||||
mux.Handle("GET /api/admin/runtime/billing-settlements", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listBillingSettlements)))
|
||||
mux.Handle("POST /api/admin/runtime/billing-settlements/{settlementId}/retry", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.retryBillingSettlement)))
|
||||
mux.Handle("POST /api/admin/runtime/model-rate-limits/{platformModelID}/restore", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.restorePlatformModelRuntimeStatus)))
|
||||
mux.Handle("GET /api/admin/config/network-proxy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getNetworkProxyConfig)))
|
||||
mux.Handle("GET /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getFileStorageSettings)))
|
||||
mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings)))
|
||||
mux.Handle("GET /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getClientCustomizationSettings)))
|
||||
mux.Handle("PATCH /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateClientCustomizationSettings)))
|
||||
mux.Handle("GET /api/admin/system/identity/configuration", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityConfiguration)))
|
||||
mux.Handle("POST /api/admin/system/identity/pairings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.startIdentityPairing)))
|
||||
mux.Handle("GET /api/admin/system/identity/pairings/{pairingID}", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityPairing)))
|
||||
mux.Handle("POST /api/admin/system/identity/pairings/{pairingID}/cancel", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.cancelIdentityPairing)))
|
||||
mux.Handle("POST /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.retireIdentityPairingSecurityEventConflict)))
|
||||
mux.Handle("PATCH /api/admin/system/identity/revisions/{revisionID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateIdentityDraftPolicy)))
|
||||
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/validate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.validateIdentityRevision)))
|
||||
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/activate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.activateIdentityRevision)))
|
||||
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/rollback", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rollbackIdentityRevision)))
|
||||
mux.Handle("POST /api/admin/system/identity/disable", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.disableIdentityConfiguration)))
|
||||
mux.Handle("GET /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getSecurityEventConnection)))
|
||||
mux.Handle("PUT /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.putSecurityEventConnection)))
|
||||
mux.Handle("POST /api/admin/system/identity/security-events/connection/verify", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.verifySecurityEventConnection)))
|
||||
mux.Handle("POST /api/admin/system/identity/security-events/connection/rotate-credential", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rotateSecurityEventConnectionCredential)))
|
||||
mux.Handle("DELETE /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteSecurityEventConnection)))
|
||||
mux.Handle("GET /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listFileStorageChannels)))
|
||||
mux.Handle("POST /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createFileStorageChannel)))
|
||||
mux.Handle("PATCH /api/admin/system/file-storage/channels/{channelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageChannel)))
|
||||
@@ -150,71 +245,122 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /api/admin/platform-models", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel)))
|
||||
mux.Handle("DELETE /api/admin/platform-models/{modelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deletePlatformModel)))
|
||||
mux.Handle("GET /api/admin/models", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModels)))
|
||||
mux.Handle("GET /api/v1/model-catalog", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModelCatalog)))
|
||||
mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayablePlatforms)))
|
||||
mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||
mux.Handle("GET /api/v1/playground/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||
mux.Handle("GET /api/v1/model-catalog", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listModelCatalog)))
|
||||
mux.Handle("GET /api/v1/platforms", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayablePlatforms)))
|
||||
mux.Handle("GET /api/v1/models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||
mux.Handle("GET /api/v1/playground/models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||
mux.Handle("GET /api/admin/runtime/rate-limit-windows", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
|
||||
mux.Handle("GET /api/admin/runtime/model-rate-limits", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModelRateLimitStatuses)))
|
||||
mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createAPIV1ChatCompletions()))
|
||||
mux.Handle("POST /api/v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", false)))
|
||||
mux.Handle("POST /api/v1/embeddings", server.auth.Require(auth.PermissionBasic, server.createTask("embeddings", false)))
|
||||
mux.Handle("POST /api/v1/reranks", server.auth.Require(auth.PermissionBasic, server.createTask("reranks", false)))
|
||||
mux.Handle("POST /api/v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", false)))
|
||||
mux.Handle("POST /api/v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", false)))
|
||||
mux.Handle("POST /api/v1/videos/generations", server.auth.Require(auth.PermissionBasic, server.createTask("videos.generations", false)))
|
||||
mux.Handle("POST /api/v1/song/generations", server.auth.Require(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /api/v1/music/generations", server.auth.Require(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
mux.Handle("POST /api/v1/speech/generations", server.auth.Require(auth.PermissionBasic, server.createTask("speech.generations", true)))
|
||||
mux.Handle("POST /api/v1/voice_clone", server.auth.Require(auth.PermissionBasic, server.createTask("voice.clone", true)))
|
||||
mux.Handle("GET /api/v1/voice_clone/voices", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
|
||||
mux.Handle("DELETE /api/v1/voice_clone/voices/{voiceID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
|
||||
mux.Handle("POST /api/v1/files/upload", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
|
||||
mux.Handle("POST /api/v1/chat/completions", server.requireUser(auth.PermissionBasic, server.createAPIV1ChatCompletions()))
|
||||
mux.Handle("POST /api/v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", false)))
|
||||
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", false)))
|
||||
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", false)))
|
||||
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", false)))
|
||||
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", false)))
|
||||
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", false)))
|
||||
mux.Handle("POST /api/v1/video/generations", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createLegacyVolcesVideoGeneration)))
|
||||
mux.Handle("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getLegacyVolcesVideoResult)))
|
||||
mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /api/v1/music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
mux.Handle("POST /api/v1/speech/generations", server.requireUser(auth.PermissionBasic, server.createTask("speech.generations", true)))
|
||||
mux.Handle("POST /api/v1/voice_clone", server.requireUser(auth.PermissionBasic, server.createTask("voice.clone", true)))
|
||||
mux.Handle("GET /api/v1/voice_clone/voices", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
|
||||
mux.Handle("DELETE /api/v1/voice_clone/voices/{voiceID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
|
||||
mux.Handle("POST /api/v1/files/upload", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
|
||||
mux.Handle("GET /api/v1/resource/material/seedance-portrait-assets/capability", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getSeedancePortraitAssetCapability)))
|
||||
mux.Handle("GET /api/v1/resource/material/user/materials", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listSeedancePortraitAssets)))
|
||||
mux.Handle("POST /api/v1/resource/material", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createSeedancePortraitAsset)))
|
||||
mux.Handle("POST /api/v1/resource/material/seedance-portrait-assets/sync", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.syncSeedancePortraitAssets)))
|
||||
mux.Handle("POST /api/v3/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createVolcesContentsGenerationTask)))
|
||||
mux.Handle("GET /api/v3/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
|
||||
mux.Handle("GET /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
|
||||
mux.Handle("DELETE /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
|
||||
server.registerGeminiGenerateContentRoutes(mux)
|
||||
mux.Handle("POST /upload/{version}/files", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
|
||||
mux.Handle("POST /upload/{version}/files/{uploadID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
|
||||
mux.Handle("GET /api/v1/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
mux.Handle("GET /api/v1/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||
mux.Handle("POST /api/v1/tasks/{taskID}/cancel", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
|
||||
mux.Handle("GET /api/v1/tasks/{taskID}/param-preprocessing", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
|
||||
mux.Handle("GET /api/v1/tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
|
||||
mux.Handle("GET /tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
mux.Handle("GET /tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||
mux.Handle("POST /tasks/{taskID}/cancel", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
|
||||
mux.Handle("GET /tasks/{taskID}/param-preprocessing", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
|
||||
mux.Handle("GET /tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
|
||||
mux.Handle("POST /chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", true)))
|
||||
mux.Handle("POST /v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", true)))
|
||||
mux.Handle("POST /responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", true)))
|
||||
mux.Handle("POST /v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", true)))
|
||||
mux.Handle("POST /embeddings", server.auth.Require(auth.PermissionBasic, server.createTask("embeddings", true)))
|
||||
mux.Handle("POST /v1/embeddings", server.auth.Require(auth.PermissionBasic, server.createTask("embeddings", true)))
|
||||
mux.Handle("POST /reranks", server.auth.Require(auth.PermissionBasic, server.createTask("reranks", true)))
|
||||
mux.Handle("POST /v1/reranks", server.auth.Require(auth.PermissionBasic, server.createTask("reranks", true)))
|
||||
mux.Handle("POST /images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", true)))
|
||||
mux.Handle("POST /v1/images/generations", server.auth.Require(auth.PermissionBasic, server.createTask("images.generations", true)))
|
||||
mux.Handle("POST /images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /v1/images/edits", server.auth.Require(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /song/generations", server.auth.Require(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /v1/song/generations", server.auth.Require(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /music/generations", server.auth.Require(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
mux.Handle("POST /v1/music/generations", server.auth.Require(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
mux.Handle("POST /speech/generations", server.auth.Require(auth.PermissionBasic, server.createTask("speech.generations", true)))
|
||||
mux.Handle("POST /v1/speech/generations", server.auth.Require(auth.PermissionBasic, server.createTask("speech.generations", true)))
|
||||
mux.Handle("POST /voice_clone", server.auth.Require(auth.PermissionBasic, server.createTask("voice.clone", true)))
|
||||
mux.Handle("POST /v1/voice_clone", server.auth.Require(auth.PermissionBasic, server.createTask("voice.clone", true)))
|
||||
mux.Handle("GET /voice_clone/voices", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
|
||||
mux.Handle("GET /v1/voice_clone/voices", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
|
||||
mux.Handle("DELETE /voice_clone/voices/{voiceID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
|
||||
mux.Handle("DELETE /v1/voice_clone/voices/{voiceID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
|
||||
mux.Handle("POST /v1/files/upload", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
|
||||
mux.Handle("POST /v1/tasks/{taskID}/cancel", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
|
||||
server.registerKlingCompatibilityRoutes(mux)
|
||||
mux.Handle("POST /upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
|
||||
mux.Handle("POST /upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
|
||||
mux.Handle("GET /api/v1/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
mux.Handle("GET /api/v1/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||
mux.Handle("POST /api/v1/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
|
||||
mux.Handle("GET /api/v1/tasks/{taskID}/param-preprocessing", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
|
||||
mux.Handle("GET /api/v1/tasks/{taskID}/events", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
|
||||
mux.Handle("GET /tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
mux.Handle("GET /tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||
mux.Handle("POST /tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
|
||||
mux.Handle("GET /tasks/{taskID}/param-preprocessing", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskParamPreprocessing)))
|
||||
mux.Handle("GET /tasks/{taskID}/events", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
|
||||
mux.Handle("POST /chat/completions", server.requireUser(auth.PermissionBasic, server.createTask("chat.completions", true)))
|
||||
mux.Handle("POST /v1/chat/completions", server.requireUser(auth.PermissionBasic, server.createTask("chat.completions", true)))
|
||||
mux.Handle("POST /responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
|
||||
mux.Handle("POST /v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
|
||||
mux.Handle("POST /embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
|
||||
mux.Handle("POST /v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
|
||||
mux.Handle("POST /reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
|
||||
mux.Handle("POST /v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
|
||||
mux.Handle("POST /images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
|
||||
mux.Handle("POST /v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
|
||||
mux.Handle("POST /images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /v1/videos/omni-video", server.requireKelingAPIKey(http.HandlerFunc(server.createKelingOmniVideo)))
|
||||
mux.Handle("GET /v1/videos/omni-video/{taskID}", server.requireKelingAPIKey(http.HandlerFunc(server.getKelingOmniVideo)))
|
||||
mux.Handle("POST /song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
mux.Handle("POST /v1/music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
mux.Handle("POST /speech/generations", server.requireUser(auth.PermissionBasic, server.createTask("speech.generations", true)))
|
||||
mux.Handle("POST /v1/speech/generations", server.requireUser(auth.PermissionBasic, server.createTask("speech.generations", true)))
|
||||
mux.Handle("POST /voice_clone", server.requireUser(auth.PermissionBasic, server.createTask("voice.clone", true)))
|
||||
mux.Handle("POST /v1/voice_clone", server.requireUser(auth.PermissionBasic, server.createTask("voice.clone", true)))
|
||||
mux.Handle("GET /voice_clone/voices", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
|
||||
mux.Handle("GET /v1/voice_clone/voices", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
|
||||
mux.Handle("DELETE /voice_clone/voices/{voiceID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
|
||||
mux.Handle("DELETE /v1/voice_clone/voices/{voiceID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
|
||||
mux.Handle("POST /v1/files/upload", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
|
||||
mux.Handle("POST /v1/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
|
||||
|
||||
return server.recover(server.cors(mux))
|
||||
return server.recover(server.cors(server.protectOIDCSessionCookie(mux)))
|
||||
}
|
||||
|
||||
func identitySecretStore(cfg config.Config) (ssfreceiver.SecretStore, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.IdentitySecretStore)) {
|
||||
case "", "file":
|
||||
directory := cfg.IdentitySecretDir
|
||||
if directory == "" {
|
||||
directory = ".local-secrets/identity"
|
||||
}
|
||||
return ssfreceiver.NewFileSecretStore(directory)
|
||||
case "kubernetes":
|
||||
return ssfreceiver.NewKubernetesSecretStore(ssfreceiver.KubernetesSecretStoreConfig{
|
||||
Namespace: cfg.IdentityKubernetesNamespace, SecretName: cfg.IdentityKubernetesSecretName,
|
||||
APIServer: cfg.IdentityKubernetesAPIServer, TokenFile: cfg.IdentityKubernetesTokenFile,
|
||||
CAFile: cfg.IdentityKubernetesCAFile,
|
||||
})
|
||||
default:
|
||||
return nil, errors.New("unsupported identity SecretStore")
|
||||
}
|
||||
}
|
||||
|
||||
func oidcSessionRequestError(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, oidcsession.ErrSessionExpired):
|
||||
return auth.NewRequestAuthError(http.StatusUnauthorized, "OIDC_SESSION_EXPIRED", "登录会话已过期,请重新登录")
|
||||
case errors.Is(err, oidcsession.ErrSessionRefreshUnavailable):
|
||||
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_REFRESH_UNAVAILABLE", "认证中心暂时不可用,请稍后重试")
|
||||
case errors.Is(err, oidcsession.ErrSessionStoreUnavailable):
|
||||
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_STORE_UNAVAILABLE", "登录会话存储暂时不可用")
|
||||
case errors.Is(err, oidcsession.ErrSecurityStateUnavailable):
|
||||
return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用")
|
||||
case errors.Is(err, oidcsession.ErrGatewayUserDisabled):
|
||||
return auth.NewRequestAuthError(http.StatusForbidden, "GATEWAY_USER_DISABLED", "该 Gateway 账号已停用,请联系管理员")
|
||||
default:
|
||||
return auth.NewRequestAuthError(http.StatusUnauthorized, "OIDC_SESSION_INVALID", "登录会话无效,请重新登录")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) http.Handler {
|
||||
return s.auth.Require(permission, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
return s.requireUser(permission, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
if user != nil && strings.TrimSpace(user.APIKeyID) != "" {
|
||||
writeError(w, http.StatusForbidden, "admin api does not accept api key credentials")
|
||||
@@ -227,11 +373,11 @@ func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) htt
|
||||
func (s *Server) cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && originAllowed(origin, s.cfg.CORSAllowedOrigin) {
|
||||
if origin != "" && s.corsOriginAllowed(origin) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key, If-Match, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
@@ -242,10 +388,21 @@ func (s *Server) cors(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) corsOriginAllowed(origin string) bool {
|
||||
if originAllowed(origin, s.cfg.CORSAllowedOrigin) {
|
||||
return true
|
||||
}
|
||||
if s.identityRuntime != nil {
|
||||
return originMatchesBaseURL(origin, s.identityRuntime.TrustedWebBaseURL())
|
||||
}
|
||||
runtime := s.currentIdentityRuntime()
|
||||
return runtime != nil && originMatchesBaseURL(origin, runtime.Revision.WebBaseURL)
|
||||
}
|
||||
|
||||
func originAllowed(origin string, allowed string) bool {
|
||||
for _, item := range strings.Split(allowed, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "*" || strings.EqualFold(origin, item) {
|
||||
if item != "*" && strings.EqualFold(origin, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ type compatibleStreamWriter struct {
|
||||
sentRole bool
|
||||
sentFinish bool
|
||||
sentUsage bool
|
||||
responseSequence int
|
||||
sentResponseDone bool
|
||||
}
|
||||
|
||||
func newCompatibleStreamWriter(kind string, model string, includeUsage bool) *compatibleStreamWriter {
|
||||
@@ -42,6 +44,19 @@ func newCompatibleStreamWriter(kind string, model string, includeUsage bool) *co
|
||||
|
||||
func (s *compatibleStreamWriter) writeDelta(w http.ResponseWriter, event clients.StreamDeltaEvent) {
|
||||
if s.kind == "responses" {
|
||||
if event.Event != nil {
|
||||
eventType, _ := event.Event["type"].(string)
|
||||
if eventType != "" {
|
||||
if sequence := intFromStreamValue(event.Event["sequence_number"]); sequence >= s.responseSequence {
|
||||
s.responseSequence = sequence + 1
|
||||
}
|
||||
if eventType == "response.completed" {
|
||||
s.sentResponseDone = true
|
||||
}
|
||||
sendSSE(w, eventType, event.Event)
|
||||
return
|
||||
}
|
||||
}
|
||||
if event.Text != "" {
|
||||
sendSSE(w, "response.output_text.delta", map[string]any{"type": "response.output_text.delta", "delta": event.Text})
|
||||
}
|
||||
@@ -67,7 +82,11 @@ func (s *compatibleStreamWriter) writeDelta(w http.ResponseWriter, event clients
|
||||
|
||||
func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[string]any) {
|
||||
if s.kind == "responses" {
|
||||
sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "response": output})
|
||||
if s.sentResponseDone {
|
||||
return
|
||||
}
|
||||
sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "sequence_number": s.responseSequence, "response": output})
|
||||
s.sentResponseDone = true
|
||||
return
|
||||
}
|
||||
s.captureOutputMetadata(output)
|
||||
@@ -88,6 +107,19 @@ func (s *compatibleStreamWriter) writeDone(w http.ResponseWriter, output map[str
|
||||
s.writeDoneMarker(w)
|
||||
}
|
||||
|
||||
func intFromStreamValue(value any) int {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func (s *compatibleStreamWriter) writeChatChunk(w http.ResponseWriter, chunk map[string]any) {
|
||||
chunk = clients.NormalizeChatCompletionStreamEvent(chunk)
|
||||
s.captureChunkMetadata(chunk)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
var errInvalidTaskIdempotencyKey = errors.New("invalid Idempotency-Key header")
|
||||
|
||||
func optionalTaskIdempotencyKey(r *http.Request) (string, bool, error) {
|
||||
values := r.Header.Values("Idempotency-Key")
|
||||
if len(values) == 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
if len(values) != 1 {
|
||||
return "", false, errInvalidTaskIdempotencyKey
|
||||
}
|
||||
value := strings.TrimSpace(values[0])
|
||||
if value == "" || len(value) > 255 || strings.Contains(value, ",") {
|
||||
return "", false, errInvalidTaskIdempotencyKey
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func taskIdempotencyKeyHash(key string) string {
|
||||
digest := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func taskIdempotencyRequestHash(kind string, async bool, stream bool, body map[string]any) string {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"kind": kind, "async": async, "stream": stream, "request": body,
|
||||
})
|
||||
digest := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func writeIdempotentTaskReplay(w http.ResponseWriter, task store.GatewayTask, compatible bool) {
|
||||
w.Header().Set("Idempotent-Replayed", "true")
|
||||
w.Header().Set("X-Gateway-Task-Id", task.ID)
|
||||
if !compatible || task.AsyncMode || (task.Status != "succeeded" && task.Status != "failed" && task.Status != "cancelled") {
|
||||
writeTaskAccepted(w, task)
|
||||
return
|
||||
}
|
||||
if task.Status == "succeeded" {
|
||||
writeJSON(w, http.StatusOK, task.Result)
|
||||
return
|
||||
}
|
||||
status := storedTaskErrorStatus(task.ErrorCode)
|
||||
message := strings.TrimSpace(task.ErrorMessage)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(task.Error)
|
||||
}
|
||||
if message == "" {
|
||||
message = "task failed"
|
||||
}
|
||||
code := strings.TrimSpace(task.ErrorCode)
|
||||
if code == "" {
|
||||
code = "task_failed"
|
||||
}
|
||||
writeError(w, status, message, code)
|
||||
}
|
||||
|
||||
func storedTaskErrorStatus(code string) int {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "pricing_unavailable", "response_chain_unavailable", "billing_hold":
|
||||
return http.StatusServiceUnavailable
|
||||
case "insufficient_balance":
|
||||
return http.StatusPaymentRequired
|
||||
case "bad_request", "invalid_parameter", "invalid_previous_response_id", "unsupported_operation":
|
||||
return http.StatusBadRequest
|
||||
case "no_model_candidate", "cloned_voice_not_found":
|
||||
return http.StatusNotFound
|
||||
case "rate_limit", "platform_cooling_down", "model_cooling_down":
|
||||
return http.StatusTooManyRequests
|
||||
default:
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOptionalTaskIdempotencyKeyRejectsMultipleValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := httptest.NewRequest("POST", "/", nil)
|
||||
if _, present, err := optionalTaskIdempotencyKey(request); err != nil || present {
|
||||
t.Fatalf("missing key present=%v err=%v", present, err)
|
||||
}
|
||||
request.Header.Add("Idempotency-Key", "one")
|
||||
request.Header.Add("Idempotency-Key", "two")
|
||||
if _, _, err := optionalTaskIdempotencyKey(request); err == nil {
|
||||
t.Fatal("multiple keys must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskIdempotencyRequestHashIsCanonical(t *testing.T) {
|
||||
t.Parallel()
|
||||
first := map[string]any{"model": "m", "n": float64(1), "nested": map[string]any{"b": true, "a": "x"}}
|
||||
second := map[string]any{"nested": map[string]any{"a": "x", "b": true}, "n": float64(1), "model": "m"}
|
||||
if taskIdempotencyRequestHash("images.generations", false, false, first) != taskIdempotencyRequestHash("images.generations", false, false, second) {
|
||||
t.Fatal("equivalent JSON objects must have the same request hash")
|
||||
}
|
||||
if taskIdempotencyRequestHash("images.generations", true, false, first) == taskIdempotencyRequestHash("images.generations", false, false, first) {
|
||||
t.Fatal("async response semantics must be part of the request hash")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const volcesContentsCompatibilityMarker = "volces_contents_generations_v3"
|
||||
|
||||
// createVolcesContentsGenerationTask godoc
|
||||
// @Summary 创建火山内容生成任务
|
||||
// @Description 兼容火山方舟 POST /api/v3/contents/generations/tasks。网关 task id 是查询与取消用的公开 id;上游 id 另以 upstream_task_id 保留。
|
||||
// @Tags volces-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks [post]
|
||||
func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
task, err := s.createVolcesCompatibleTask(r, user, body)
|
||||
if err != nil {
|
||||
writeVolcesCompatibleTaskError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, volcesCompatibleTask(task))
|
||||
}
|
||||
|
||||
// getVolcesContentsGenerationTask godoc
|
||||
// @Summary 查询火山内容生成任务
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks/{taskID} [get]
|
||||
func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, volcesCompatibleTask(task))
|
||||
}
|
||||
|
||||
// listVolcesContentsGenerationTasks godoc
|
||||
// @Summary 列出火山内容生成任务
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks [get]
|
||||
func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
page := portraitAssetQueryInt(r, "page_num", "pageNumber", "page")
|
||||
pageSize := portraitAssetQueryInt(r, "page_size", "pageSize")
|
||||
tasks, err := s.store.ListVolcesCompatibleTasks(r.Context(), user, store.VolcesCompatibleTaskListFilter{
|
||||
CompatibilityMarker: volcesContentsCompatibilityMarker,
|
||||
Status: r.URL.Query().Get("filter.status"),
|
||||
Model: r.URL.Query().Get("filter.model"),
|
||||
TaskIDs: r.URL.Query()["filter.task_ids"],
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list Volces-compatible tasks failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list tasks failed")
|
||||
return
|
||||
}
|
||||
items := make([]any, 0)
|
||||
for _, task := range tasks.Items {
|
||||
items = append(items, volcesCompatibleTask(task))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "total": tasks.Total,
|
||||
"page_num": tasks.Page, "page_size": tasks.PageSize,
|
||||
// data/page are retained as additive gateway fields for existing callers.
|
||||
"data": items, "page": tasks.Page,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteVolcesContentsGenerationTask godoc
|
||||
// @Summary 取消火山内容生成任务
|
||||
// @Description 取消网关任务;对于已提交且保存了上游任务标识的 Volces 视频任务,同时调用火山 DELETE 接口并持久化取消状态。
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks/{taskID} [delete]
|
||||
func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
result, err := s.runner.CancelVolcesVideoTask(r.Context(), task, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, runner.ErrTaskAccessDenied) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("cancel Volces-compatible task failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "cancel task failed")
|
||||
return
|
||||
}
|
||||
updated, err := s.store.GetTask(r.Context(), task.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "get cancelled task failed")
|
||||
return
|
||||
}
|
||||
response := volcesCompatibleTask(updated)
|
||||
response["cancelled"] = result.Cancelled
|
||||
response["cancellable"] = result.Cancellable
|
||||
response["message"] = result.Message
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// createLegacyVolcesVideoGeneration godoc
|
||||
// @Summary 创建 server-main 兼容视频任务
|
||||
// @Description 兼容 server-main 的 /api/v1/video/generations,返回 submitted 和 task_id;额外保留火山任务字段。
|
||||
// @Tags volces-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/video/generations [post]
|
||||
func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
task, err := s.createVolcesCompatibleTask(r, user, body)
|
||||
if err != nil {
|
||||
writeVolcesCompatibleTaskError(w, err)
|
||||
return
|
||||
}
|
||||
response := volcesCompatibleTask(task)
|
||||
response["status"] = "submitted"
|
||||
response["task_id"] = task.ID
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// getLegacyVolcesVideoResult godoc
|
||||
// @Summary 查询 server-main 兼容视频结果
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/ai/result/{taskID} [get]
|
||||
func (s *Server) getLegacyVolcesVideoResult(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
compat := volcesCompatibleTask(task)
|
||||
legacyStatus := "process"
|
||||
switch compat["status"] {
|
||||
case "succeeded":
|
||||
legacyStatus = "success"
|
||||
case "failed", "cancelled":
|
||||
legacyStatus = "failed"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": legacyStatus, "task_id": task.ID, "data": compat["content"], "result": compat,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createVolcesCompatibleTask(r *http.Request, user *auth.User, body map[string]any) (store.GatewayTask, error) {
|
||||
model := strings.TrimSpace(volcesCompatString(body["model"]))
|
||||
if model == "" {
|
||||
return store.GatewayTask{}, &clients.ClientError{Code: "invalid_parameter", Message: "model is required", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||||
return store.GatewayTask{}, &clients.ClientError{Code: "forbidden", Message: "api key scope does not allow video generation", StatusCode: http.StatusForbidden, Retryable: false}
|
||||
}
|
||||
body["_gateway_compatibility"] = volcesContentsCompatibilityMarker
|
||||
task, err := s.prepareAndCreateGatewayTask(r.Context(), r, user, "videos.generations", model, body, true)
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, err
|
||||
}
|
||||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||||
return store.GatewayTask{}, &clients.ClientError{Code: "enqueue_failed", Message: err.Error(), StatusCode: http.StatusServiceUnavailable, Retryable: true}
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Server) volcesCompatibleTaskForUser(w http.ResponseWriter, r *http.Request) (store.GatewayTask, bool) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
s.logger.Error("get Volces-compatible task failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "get task failed")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
if !isVolcesCompatibleTask(task) || !kelingCompatTaskOwnedBy(task, user) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
return task, true
|
||||
}
|
||||
|
||||
func isVolcesCompatibleTask(task store.GatewayTask) bool {
|
||||
return task.Kind == "videos.generations" && strings.TrimSpace(volcesCompatString(task.Request["_gateway_compatibility"])) == volcesContentsCompatibilityMarker
|
||||
}
|
||||
|
||||
func volcesCompatibleTask(task store.GatewayTask) map[string]any {
|
||||
response := cloneVolcesCompatibleMap(task.Result)
|
||||
if len(response) == 0 {
|
||||
response = cloneVolcesCompatibleMap(task.RemoteTaskPayload)
|
||||
}
|
||||
if response == nil {
|
||||
response = map[string]any{}
|
||||
}
|
||||
response["id"] = task.ID
|
||||
response["model"] = firstNonEmpty(volcesCompatString(response["model"]), task.Model)
|
||||
response["status"] = volcesCompatibleTaskStatus(task.Status)
|
||||
response["created_at"] = task.CreatedAt.Unix()
|
||||
response["updated_at"] = task.UpdatedAt.Unix()
|
||||
if task.RemoteTaskID != "" {
|
||||
response["upstream_task_id"] = task.RemoteTaskID
|
||||
}
|
||||
for _, key := range []string{"content", "seed", "resolution", "ratio", "duration", "frames", "framespersecond"} {
|
||||
if response[key] == nil && task.Request[key] != nil {
|
||||
response[key] = task.Request[key]
|
||||
}
|
||||
}
|
||||
if len(task.Usage) > 0 && response["usage"] == nil {
|
||||
response["usage"] = task.Usage
|
||||
}
|
||||
if task.Status == "failed" || task.Status == "cancelled" {
|
||||
response["error"] = map[string]any{"code": firstNonEmpty(task.ErrorCode, strings.ToUpper(task.Status)), "message": firstNonEmpty(task.ErrorMessage, task.Error, task.Message)}
|
||||
}
|
||||
response["gateway_task_id"] = task.ID
|
||||
response["gateway_status"] = task.Status
|
||||
response["billings"] = task.Billings
|
||||
response["billing_summary"] = task.BillingSummary
|
||||
response["final_charge_amount"] = task.FinalChargeAmount
|
||||
return response
|
||||
}
|
||||
|
||||
func volcesCompatibleTaskStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "succeeded", "success", "completed":
|
||||
return "succeeded"
|
||||
case "failed":
|
||||
return "failed"
|
||||
case "cancelled", "canceled":
|
||||
return "cancelled"
|
||||
case "running", "processing":
|
||||
return "running"
|
||||
default:
|
||||
return "queued"
|
||||
}
|
||||
}
|
||||
|
||||
func cloneVolcesCompatibleMap(source map[string]any) map[string]any {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeVolcesCompatibleTaskError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
var staged *gatewayTaskCreationError
|
||||
if errors.As(err, &staged) {
|
||||
err = staged.Err
|
||||
}
|
||||
var clientErr *clients.ClientError
|
||||
if errors.As(err, &clientErr) && clientErr.StatusCode > 0 {
|
||||
status = clientErr.StatusCode
|
||||
} else if errors.As(err, &clientErr) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeError(w, status, err.Error(), clients.ErrorCode(err))
|
||||
}
|
||||
|
||||
func volcesCompatString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestVolcesCompatibleTaskPreservesOfficialFieldsAndGatewayBilling(t *testing.T) {
|
||||
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
|
||||
task := store.GatewayTask{
|
||||
ID: "gateway-task-1", Kind: "videos.generations", Status: "succeeded", Model: "doubao-seedance-2-0-mini-260615",
|
||||
RemoteTaskID: "cgt-upstream-1", CreatedAt: now, UpdatedAt: now.Add(time.Second),
|
||||
Result: map[string]any{
|
||||
"id": "cgt-upstream-1", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
|
||||
"content": map[string]any{"video_url": "https://example.com/out.mp4"}, "usage": map[string]any{"total_tokens": 9},
|
||||
},
|
||||
Billings: []any{map[string]any{"amount": 3}}, BillingSummary: map[string]any{"currency": "resource"}, FinalChargeAmount: 3,
|
||||
}
|
||||
got := volcesCompatibleTask(task)
|
||||
if got["id"] != task.ID || got["upstream_task_id"] != task.RemoteTaskID || got["status"] != "succeeded" {
|
||||
t.Fatalf("unexpected compatibility identity/status: %+v", got)
|
||||
}
|
||||
content, _ := got["content"].(map[string]any)
|
||||
if content["video_url"] != "https://example.com/out.mp4" || got["usage"] == nil || got["billings"] == nil {
|
||||
t.Fatalf("official or billing fields were lost: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
// @Param currency query string false "币种" default(USD)
|
||||
// @Success 200 {object} store.WalletSummary
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/wallet [get]
|
||||
func (s *Server) getWallet(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -49,6 +51,8 @@ func (s *Server) getWallet(w http.ResponseWriter, r *http.Request) {
|
||||
// @Success 200 {object} WalletTransactionListResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/wallet/transactions [get]
|
||||
func (s *Server) listWalletTransactions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRevisionNotFound = errors.New("identity configuration revision not found")
|
||||
ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state")
|
||||
ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required")
|
||||
ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid")
|
||||
ErrActiveConfigurationHandoffRequired = errors.New("active identity configuration requires an explicit remote resource handoff before re-pairing")
|
||||
ErrRollbackConfigurationHandoffRequired = errors.New("rollback requires a fresh remote resource handoff")
|
||||
ErrSecurityEventRetirementPending = errors.New("active security event connection must retire before identity can be disabled")
|
||||
)
|
||||
|
||||
type RevisionPolicy struct {
|
||||
LocalTenantKey string `json:"localTenantKey"`
|
||||
RolePrefix string `json:"rolePrefix"`
|
||||
JITEnabled bool `json:"jitEnabled"`
|
||||
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
|
||||
SessionIdleSeconds int `json:"sessionIdleSeconds"`
|
||||
SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"`
|
||||
SessionRefreshSeconds int `json:"sessionRefreshSeconds"`
|
||||
}
|
||||
|
||||
func (policy RevisionPolicy) Validate() error {
|
||||
if strings.TrimSpace(policy.LocalTenantKey) == "" || strings.TrimSpace(policy.RolePrefix) == "" {
|
||||
return errors.New("local tenant mapping and role prefix are required")
|
||||
}
|
||||
if policy.SessionIdleSeconds <= 0 || policy.SessionAbsoluteSeconds <= policy.SessionIdleSeconds ||
|
||||
policy.SessionRefreshSeconds <= 0 || policy.SessionRefreshSeconds >= policy.SessionIdleSeconds {
|
||||
return errors.New("identity session policy is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type RevisionState string
|
||||
|
||||
const (
|
||||
RevisionDraft RevisionState = "draft"
|
||||
RevisionValidated RevisionState = "validated"
|
||||
RevisionActive RevisionState = "active"
|
||||
RevisionSuperseded RevisionState = "superseded"
|
||||
RevisionFailed RevisionState = "failed"
|
||||
)
|
||||
|
||||
type Revision struct {
|
||||
ID string `json:"id"`
|
||||
State RevisionState `json:"state"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
AuthCenterURL string `json:"authCenterUrl"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
ApplicationID string `json:"applicationId,omitempty"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
BrowserClientID string `json:"browserClientId,omitempty"`
|
||||
MachineClientID string `json:"machineClientId,omitempty"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
RolePrefix string `json:"rolePrefix"`
|
||||
LocalTenantKey string `json:"localTenantKey"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
WebBaseURL string `json:"webBaseUrl"`
|
||||
JITEnabled bool `json:"jitEnabled"`
|
||||
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
|
||||
TokenIntrospection bool `json:"tokenIntrospection"`
|
||||
SessionRevocation bool `json:"sessionRevocation"`
|
||||
SecurityEventIssuer string `json:"securityEventIssuer,omitempty"`
|
||||
SecurityEventConfigURL string `json:"securityEventConfigurationUrl,omitempty"`
|
||||
SecurityEventAudience string `json:"securityEventAudience,omitempty"`
|
||||
MachineCredentialRef string `json:"-"`
|
||||
SessionEncryptionKeyRef string `json:"-"`
|
||||
SessionIdleSeconds int `json:"sessionIdleSeconds"`
|
||||
SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"`
|
||||
SessionRefreshSeconds int `json:"sessionRefreshSeconds"`
|
||||
Version int64 `json:"version"`
|
||||
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
|
||||
LastTraceID string `json:"lastTraceId,omitempty"`
|
||||
LastAuditID string `json:"lastAuditId,omitempty"`
|
||||
ValidatedAt *time.Time `json:"validatedAt,omitempty"`
|
||||
ActivatedAt *time.Time `json:"activatedAt,omitempty"`
|
||||
SupersededAt *time.Time `json:"supersededAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PairingInput struct {
|
||||
AuthCenterURL string `json:"authCenterUrl"`
|
||||
OnboardingCode string `json:"onboardingCode"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
WebBaseURL string `json:"webBaseUrl"`
|
||||
LocalTenantKey string `json:"localTenantKey"`
|
||||
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
|
||||
}
|
||||
|
||||
type ConsumerMetadata struct {
|
||||
PublicBaseURL string `json:"public_base_url"`
|
||||
WebBaseURL string `json:"web_base_url"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
LogoutURIs []string `json:"logout_uris"`
|
||||
ReceiverEndpoint string `json:"receiver_endpoint,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestApplication struct {
|
||||
Manifest ManifestV1
|
||||
MachineCredentialRef string
|
||||
SessionEncryptionKeyRef string
|
||||
TraceID string
|
||||
AuditID string
|
||||
AppEnv string
|
||||
}
|
||||
|
||||
func NewDraft(input PairingInput, appEnv string) (Revision, error) {
|
||||
if _, err := input.ConsumerMetadata(false, appEnv); err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
authCenter, _ := exactBaseURL(input.AuthCenterURL, appEnv)
|
||||
publicBase, _ := exactBaseURL(input.PublicBaseURL, appEnv)
|
||||
webBase, _ := exactBaseURL(input.WebBaseURL, appEnv)
|
||||
return Revision{
|
||||
ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1,
|
||||
AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey),
|
||||
PublicBaseURL: publicBase, WebBaseURL: webBase, JITEnabled: true, LegacyJWTEnabled: input.LegacyJWTEnabled,
|
||||
Scopes: []string{}, Capabilities: []string{}, SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800,
|
||||
SessionRefreshSeconds: 60, Version: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ApplyManifest(revision Revision, input ManifestApplication) (Revision, error) {
|
||||
if revision.State != RevisionDraft {
|
||||
return Revision{}, ErrRevisionConflict
|
||||
}
|
||||
if err := input.Manifest.Validate(input.AppEnv); err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
capabilities := make(map[string]bool, len(input.Manifest.Capabilities))
|
||||
for _, capability := range input.Manifest.Capabilities {
|
||||
capabilities[capability] = true
|
||||
}
|
||||
if capabilities["machine_to_machine"] && strings.TrimSpace(input.MachineCredentialRef) == "" {
|
||||
return Revision{}, errors.New("machine credential reference is required")
|
||||
}
|
||||
if capabilities["oidc_login"] && strings.TrimSpace(input.SessionEncryptionKeyRef) == "" {
|
||||
return Revision{}, errors.New("session encryption key reference is required")
|
||||
}
|
||||
revision.Issuer = strings.TrimRight(input.Manifest.Issuer, "/")
|
||||
revision.TenantID = input.Manifest.TenantID
|
||||
revision.ApplicationID = input.Manifest.ApplicationID
|
||||
revision.Audience = input.Manifest.Audience
|
||||
revision.Scopes = append([]string(nil), input.Manifest.Scopes...)
|
||||
revision.Capabilities = append([]string(nil), input.Manifest.Capabilities...)
|
||||
if input.Manifest.Clients.BrowserLogin != nil {
|
||||
revision.BrowserClientID = input.Manifest.Clients.BrowserLogin.ClientID
|
||||
}
|
||||
if input.Manifest.Clients.MachineToMachine != nil {
|
||||
revision.MachineClientID = input.Manifest.Clients.MachineToMachine.ClientID
|
||||
}
|
||||
revision.TokenIntrospection = capabilities["token_introspection"]
|
||||
revision.SessionRevocation = capabilities["session_revocation"]
|
||||
if input.Manifest.SecurityEvents != nil {
|
||||
revision.SecurityEventIssuer = input.Manifest.SecurityEvents.TransmitterIssuer
|
||||
revision.SecurityEventConfigURL = input.Manifest.SecurityEvents.ConfigurationEndpoint
|
||||
revision.SecurityEventAudience = input.Manifest.SecurityEvents.Audience
|
||||
}
|
||||
revision.MachineCredentialRef = strings.TrimSpace(input.MachineCredentialRef)
|
||||
revision.SessionEncryptionKeyRef = strings.TrimSpace(input.SessionEncryptionKeyRef)
|
||||
revision.LastTraceID = strings.TrimSpace(input.TraceID)
|
||||
revision.LastAuditID = strings.TrimSpace(input.AuditID)
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func CanTransition(from, to RevisionState) bool {
|
||||
switch from {
|
||||
case RevisionDraft:
|
||||
return to == RevisionValidated || to == RevisionFailed
|
||||
case RevisionValidated:
|
||||
return to == RevisionActive || to == RevisionFailed
|
||||
case RevisionActive:
|
||||
return to == RevisionSuperseded
|
||||
case RevisionSuperseded:
|
||||
return to == RevisionValidated
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (input PairingInput) ConsumerMetadata(sessionRevocation bool, appEnv string) (ConsumerMetadata, error) {
|
||||
authCenter, err := exactBaseURL(input.AuthCenterURL, appEnv)
|
||||
if err != nil {
|
||||
return ConsumerMetadata{}, errors.New("auth center URL is invalid")
|
||||
}
|
||||
_ = authCenter
|
||||
publicBase, err := exactBaseURL(input.PublicBaseURL, appEnv)
|
||||
if err != nil {
|
||||
return ConsumerMetadata{}, errors.New("public base URL is invalid")
|
||||
}
|
||||
webBase, err := exactBaseURL(input.WebBaseURL, appEnv)
|
||||
if err != nil {
|
||||
return ConsumerMetadata{}, errors.New("web base URL is invalid")
|
||||
}
|
||||
if strings.TrimSpace(input.LocalTenantKey) == "" {
|
||||
return ConsumerMetadata{}, errors.New("local tenant mapping is required")
|
||||
}
|
||||
metadata := ConsumerMetadata{
|
||||
PublicBaseURL: publicBase, WebBaseURL: webBase,
|
||||
RedirectURIs: []string{publicBase + "/api/v1/auth/oidc/callback"},
|
||||
LogoutURIs: []string{webBase + "/"},
|
||||
}
|
||||
if sessionRevocation {
|
||||
metadata.ReceiverEndpoint = publicBase + "/api/v1/security-events/ssf"
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func exactBaseURL(raw, appEnv string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("invalid public URL")
|
||||
}
|
||||
hostname := strings.ToLower(parsed.Hostname())
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
return "", errors.New("base URL must not contain a path")
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "https" && !(scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(hostname)) {
|
||||
return "", errors.New("public URL must use HTTPS")
|
||||
}
|
||||
port := parsed.Port()
|
||||
if scheme == "https" && port == "443" || scheme == "http" && port == "80" {
|
||||
port = ""
|
||||
}
|
||||
host := hostname
|
||||
if strings.Contains(hostname, ":") {
|
||||
host = "[" + hostname + "]"
|
||||
}
|
||||
if port != "" {
|
||||
host = net.JoinHostPort(hostname, port)
|
||||
}
|
||||
return scheme + "://" + host, nil
|
||||
}
|
||||
|
||||
func isLocalIdentityEnvironment(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "development", "dev", "local", "test":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateRevisionURLs re-applies the deployment environment URL policy when
|
||||
// constructing a Runtime. This protects against legacy or directly persisted
|
||||
// revisions bypassing the pairing boundary.
|
||||
func ValidateRevisionURLs(revision Revision, appEnv string) error {
|
||||
urls := []struct {
|
||||
label string
|
||||
raw string
|
||||
base bool
|
||||
}{
|
||||
{label: "auth center", raw: revision.AuthCenterURL, base: true},
|
||||
{label: "issuer", raw: revision.Issuer},
|
||||
{label: "public base", raw: revision.PublicBaseURL, base: true},
|
||||
{label: "web base", raw: revision.WebBaseURL, base: true},
|
||||
}
|
||||
for _, candidate := range urls {
|
||||
var err error
|
||||
if candidate.base {
|
||||
_, err = exactBaseURL(candidate.raw, appEnv)
|
||||
} else {
|
||||
err = validatePublicIdentityURL(candidate.raw, appEnv)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("identity " + candidate.label + " URL must use HTTPS in this environment")
|
||||
}
|
||||
}
|
||||
if revision.SessionRevocation || revision.SecurityEventIssuer != "" || revision.SecurityEventConfigURL != "" {
|
||||
if validatePublicIdentityURL(revision.SecurityEventIssuer, appEnv) != nil ||
|
||||
validatePublicIdentityURL(revision.SecurityEventConfigURL, appEnv) != nil {
|
||||
return errors.New("identity security event URLs must use HTTPS in this environment")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRevisionStateTransitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
from, to RevisionState
|
||||
allowed bool
|
||||
}{
|
||||
{RevisionDraft, RevisionValidated, true},
|
||||
{RevisionDraft, RevisionFailed, true},
|
||||
{RevisionValidated, RevisionActive, true},
|
||||
{RevisionValidated, RevisionFailed, true},
|
||||
{RevisionActive, RevisionSuperseded, true},
|
||||
{RevisionSuperseded, RevisionValidated, true},
|
||||
{RevisionSuperseded, RevisionActive, false},
|
||||
{RevisionDraft, RevisionActive, false},
|
||||
{RevisionFailed, RevisionActive, false},
|
||||
{RevisionActive, RevisionValidated, false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := CanTransition(test.from, test.to); got != test.allowed {
|
||||
t.Errorf("CanTransition(%q, %q)=%v, want %v", test.from, test.to, got, test.allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionNeverSerializesSecretValues(t *testing.T) {
|
||||
type secretFields interface {
|
||||
MachineSecret() string
|
||||
}
|
||||
var _ = any((*Revision)(nil))
|
||||
if _, exposesSecret := any((*Revision)(nil)).(secretFields); exposesSecret {
|
||||
t.Fatal("Revision unexpectedly exposes a machine secret value")
|
||||
}
|
||||
revision := Revision{MachineCredentialRef: "identity-machine-example", SessionEncryptionKeyRef: "identity-session-example"}
|
||||
if revision.MachineCredentialRef == "" || revision.SessionEncryptionKeyRef == "" {
|
||||
t.Fatal("Revision must retain SecretStore references")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePairingInputDerivesExactGatewayURIs(t *testing.T) {
|
||||
input := PairingInput{
|
||||
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
|
||||
WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
|
||||
}
|
||||
metadata, err := input.ConsumerMetadata(true, "production")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.RedirectURIs[0] != "https://api.example.com/api/v1/auth/oidc/callback" ||
|
||||
metadata.LogoutURIs[0] != "https://gateway.example.com/" ||
|
||||
metadata.ReceiverEndpoint != "https://api.example.com/api/v1/security-events/ssf" {
|
||||
t.Fatalf("unexpected derived metadata: %#v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePairingInputRejectsRemoteHTTPAndURLCredentials(t *testing.T) {
|
||||
for _, input := range []PairingInput{
|
||||
{AuthCenterURL: "http://auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"},
|
||||
{AuthCenterURL: "https://user:password@auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"},
|
||||
} {
|
||||
if _, err := input.ConsumerMetadata(false, "production"); err == nil {
|
||||
t.Fatalf("unsafe pairing input accepted: %#v", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testing.T) {
|
||||
draft, err := NewDraft(PairingInput{
|
||||
AuthCenterURL: "https://auth.example.com", OnboardingCode: "onb1.must-never-be-persisted",
|
||||
PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com",
|
||||
LocalTenantKey: "default", LegacyJWTEnabled: true,
|
||||
}, "production")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if draft.State != RevisionDraft || draft.SessionIdleSeconds != 1800 || draft.SessionAbsoluteSeconds != 28800 || draft.SessionRefreshSeconds != 60 {
|
||||
t.Fatalf("unexpected draft defaults: %#v", draft)
|
||||
}
|
||||
payload, _ := json.Marshal(draft)
|
||||
if strings.Contains(string(payload), "must-never-be-persisted") || strings.Contains(string(payload), "onboardingCode") {
|
||||
t.Fatalf("draft exposed onboarding code: %s", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDraftAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) {
|
||||
localInput := PairingInput{
|
||||
AuthCenterURL: "http://localhost:18000", PublicBaseURL: "http://127.0.0.1:18089",
|
||||
WebBaseURL: "http://localhost:5178", LocalTenantKey: "default",
|
||||
}
|
||||
secureInput := PairingInput{
|
||||
AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com",
|
||||
WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default",
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*PairingInput)
|
||||
}{
|
||||
{name: "auth center", mutate: func(input *PairingInput) { input.AuthCenterURL = localInput.AuthCenterURL }},
|
||||
{name: "public base", mutate: func(input *PairingInput) { input.PublicBaseURL = localInput.PublicBaseURL }},
|
||||
{name: "web base", mutate: func(input *PairingInput) { input.WebBaseURL = localInput.WebBaseURL }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
input := secureInput
|
||||
test.mutate(&input)
|
||||
for _, appEnv := range []string{"", "production", "staging"} {
|
||||
if _, err := NewDraft(input, appEnv); err == nil {
|
||||
t.Fatalf("%s accepted loopback HTTP %s URL", appEnv, test.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, appEnv := range []string{"local", "development", "dev", "test"} {
|
||||
draft, err := NewDraft(localInput, appEnv)
|
||||
if err != nil {
|
||||
t.Fatalf("%s rejected loopback HTTP pairing URLs: %v", appEnv, err)
|
||||
}
|
||||
if draft.AuthCenterURL != localInput.AuthCenterURL || draft.PublicBaseURL != localInput.PublicBaseURL || draft.WebBaseURL != localInput.WebBaseURL {
|
||||
t.Fatalf("%s changed normalized loopback URLs: %#v", appEnv, draft)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user