Compare commits

..
Author SHA1 Message Date
easyai 9872068596 Merge pull request #21: 统一公开 API 为 /api/v1 前缀
ci / verify (push) Successful in 12m50s
release-ci / verify-tag (push) Successful in 16m11s
合并公开 API V1 前缀统一、兼容路由、OpenAPI、部署健康检查与验证测试。
2026-07-22 09:10:09 +08:00
easyai a23b28c27d test(api): 等待异步任务完成后校验结果
ci / verify (pull_request) Successful in 12m38s
真实队列环境中 X-Async 请求会先返回 queued。集成测试改为轮询任务完成并重新读取详情,避免把正确的异步受理状态误判为失败。
2026-07-22 08:56:30 +08:00
easyai eb37b568ae fix(deploy): 使用 /api/v1 执行发布健康检查
ci / verify (pull_request) Failing after 3m41s
将 Compose 发布脚本的 API、就绪和 Web 反代探针切换到统一公开前缀,避免发布完成后仍只验证旧兼容入口。\n\n验证:bash -n;ShellCheck;ci-build-images-test.sh;pipeline-test.sh。
2026-07-22 08:50:55 +08:00
easyai a8d1c550ef chore(deps): 升级 fast-uri 修复高危漏洞
ci / verify (pull_request) Failing after 3m45s
将构建链路中的 fast-uri 锁定到 3.1.4,修复 host confusion 高危公告,不改变应用运行时依赖。\n\n验证:pnpm install --frozen-lockfile;pnpm audit --audit-level high;pnpm lint;pnpm test;pnpm build。
2026-07-22 08:48:40 +08:00
easyai 5432760cf7 fix(identity): 避免未配置 SSF 时触发空指针
SecurityEventReceiver 不再把空 ConnectionManager 包装为非空接口;未启用 SSF 时公开端点明确返回 404。补充运行时和 HTTP 回归测试。\n\n验证:go test ./internal/identityruntime ./internal/httpapi。
2026-07-22 08:48:36 +08:00
easyai 7c5a999e32 feat(api): 统一公开接口为 /api/v1 前缀
将通用生成、Gemini、可灵、火山、健康检查与 OpenAPI 的推荐入口统一到 /api/v1,并保留历史路径作为兼容别名。同步更新代理配置、接入文档、接口清单和前缀回归测试。\n\n验证:go vet ./...;go test ./...;pnpm openapi;pnpm lint;pnpm test;pnpm build;公开 OpenAPI 71 个方法与接口清单机器比对一致。
2026-07-22 08:48:32 +08:00
easyai f7a5f2e808 Merge pull request #20: 前移生产迁移基线至 v0.4.2
ci / verify (push) Successful in 9m43s
2026-07-22 05:42:59 +08:00
easyai 152f9d1206 chore(deploy): 前移生产迁移基线至 v0.4.2
ci / verify (pull_request) Successful in 9m42s
2026-07-22 05:32:39 +08:00
easyai d95cecd0eb Merge pull request #19: 修复 Gemini 兼容平台版本路径重复拼接
ci / verify (push) Successful in 9m45s
release-ci / verify-tag (push) Successful in 9m54s
修复 Gemini 兼容平台已包含 /v1 时重复追加 /v1beta 的问题。

验证:精确 ci / verify (pull_request) 已通过。
2026-07-22 05:04:12 +08:00
easyai 5954afef55 fix(gemini): 保留兼容平台的 API 版本路径
ci / verify (pull_request) Successful in 9m42s
2026-07-22 04:53:42 +08:00
easyai 929a7a172c Merge pull request #18: 修复 Gemini 图像默认参数与 server-main 脚本兼容
ci / verify (push) Successful in 9m44s
release-ci / verify-tag (push) Successful in 9m46s
合并已通过 ci / verify (pull_request) 的图像参数与脚本兼容修复。
2026-07-22 04:17:44 +08:00
easyai 16149260c7 fix(universal): 映射脚本上游模型名
ci / verify (pull_request) Successful in 9m51s
让 server-main 参数预处理兼容上下文使用平台绑定的真实模型名,避免把用户侧别名提交给上游。
2026-07-22 03:56:00 +08:00
easyai c72c43aaaa fix(universal): 兼容 server-main 脚本语义
ci / verify (pull_request) Successful in 9m52s
补充历史脚本依赖的参数预处理上下文,并识别嵌套同步结果,使迁移后的 Universal 图片通道可按原配置执行。
2026-07-22 03:50:59 +08:00
easyai 84aea01b5b build(web): 修复高危间接依赖漏洞
ci / verify (pull_request) Successful in 9m48s
将 fast-uri 约束到已修复的 3.1.3,确保高危依赖审计通过。
2026-07-22 03:39:48 +08:00
easyai 293ef24bb7 build(api): 升级存在漏洞的 Go 依赖 2026-07-22 03:37:34 +08:00
easyai 0f1765b226 fix(gemini): 初始化图像生成配置对象 2026-07-22 03:37:28 +08:00
easyai 1fa58ba901 Merge pull request #17: 前移生产迁移基线至 v0.4.0
ci / verify (push) Successful in 10m15s
生产版本 v0.4.0 已完成发布、健康检查和恢复演练,前移生产迁移安全基线。
2026-07-22 02:59:54 +08:00
easyai 55595570c2 chore(deploy): 前移生产迁移基线至 v0.4.0
ci / verify (pull_request) Successful in 10m18s
2026-07-22 02:48:38 +08:00
easyai fba9759bc7 Merge pull request 'fix(kling): 补齐全模态基础视频能力' (#16) from codex/omni-default-video-capabilities into main
ci / verify (push) Successful in 12m8s
release-ci / verify-tag (push) Successful in 9m56s
2026-07-22 02:00:02 +08:00
easyai 3d3460ce63 test(kling): 兼容全模态模型展示别名
ci / verify (pull_request) Successful in 11m22s
集成测试通过现有可灵别名归一规则校验解析模型,兼容共享数据库返回 kling-o1 等目录展示名,同时保留任务成功与推断模型类型断言。
2026-07-22 01:48:10 +08:00
easyai 000ee1bbfd fix(kling): 补齐全模态基础视频能力
ci / verify (pull_request) Failing after 4m10s
让 omni_video 模型默认同时注册 video_generate 与 image_to_video,兼容通用视频接口在不显式传 modelType 时的能力推断。

新增迁移同步可灵 O1 与 3.0 Omni 的基础目录、平台映射、能力详情和默认快照;集成测试覆盖两个模型的无 modelType 文生视频与图生视频。
2026-07-22 01:41:23 +08:00
wangbo d0cfd0a385 Merge pull request 'feat: 合并网关能力与兼容性优化' (#15) from codex/gateway-feature-batch-20260721 into main
ci / verify (push) Successful in 10m38s
2026-07-22 01:17:45 +08:00
54 changed files with 2752 additions and 5194 deletions
+5 -3
View File
@@ -70,8 +70,10 @@ scripts/deploy-compose.sh
部署成功后默认访问地址:
- Web: `http://127.0.0.1:5178`
- API: `http://127.0.0.1:8088/healthz`
- Web 反代 API: `http://127.0.0.1:5178/gateway-api/healthz`
- API: `http://127.0.0.1:8088/api/v1/healthz`
- Web 反代公开 API: `http://127.0.0.1:5178/api/v1/healthz`
公开接口统一使用 `/api/v1` 前缀,完整分组清单见 [公开 API V1 清单](docs/public-api-v1.md)。
常用覆盖项:
@@ -99,7 +101,7 @@ scripts/deploy-compose.sh clean
docker login --username=<your-aliyun-account> registry.cn-shanghai.aliyuncs.com
```
Web 容器的 Nginx 配置通过 bind mount 挂载自仓库文件 [docker/nginx.conf](docker/nginx.conf),可直接修改该文件调整静态资源 `/gateway-api` 反向代理配置。修改后执行以下命令使配置生效:
Web 容器的 Nginx 配置通过 bind mount 挂载自仓库文件 [docker/nginx.conf](docker/nginx.conf),可直接修改该文件调整静态资源、规范 `/api/v1` 公开入口和旧 `/gateway-api` 兼容反向代理。修改后执行以下命令使配置生效:
```bash
docker compose -f docker-compose.yml restart web
+1110 -3032
View File
File diff suppressed because it is too large Load Diff
+716 -1993
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -36,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.37.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
)
+4 -4
View File
@@ -73,10 +73,10 @@ 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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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=
+52 -4
View File
@@ -1306,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) {
@@ -1539,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)
}
})
}
}
+14 -3
View File
@@ -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")
+25
View File
@@ -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",
@@ -9,8 +9,8 @@ import (
const (
opsManagementSkillDownloadPath = "/api/v1/public/skills/ai-gateway-ops-management/download"
apiDocsJSONPath = "/api-docs-json"
apiDocsYAMLPath = "/api-docs-yaml"
apiDocsJSONPath = "/api/v1/openapi.json"
apiDocsYAMLPath = "/api/v1/openapi.yaml"
)
// getOpsManagementSkillMetadata godoc
@@ -64,7 +64,7 @@ func (s *Server) downloadOpsManagementSkill(w http.ResponseWriter, _ *http.Reque
// @Tags agent-resources
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api-docs-json [get]
// @Router /api/v1/openapi.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)
@@ -77,7 +77,7 @@ func (s *Server) apiDocsJSON(w http.ResponseWriter, _ *http.Request) {
// @Tags agent-resources
// @Produce application/yaml
// @Success 200 {string} string
// @Router /api-docs-yaml [get]
// @Router /api/v1/openapi.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)
@@ -32,7 +32,7 @@ func TestGetOpsManagementSkillMetadata(t *testing.T) {
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" {
if metadata.APIDocsJSONPath != "/api/v1/openapi.json" || metadata.APIDocsYAMLPath != "/api/v1/openapi.yaml" {
t.Fatalf("unexpected API docs paths: %+v", metadata)
}
}
@@ -74,7 +74,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
server := &Server{}
jsonResponse := httptest.NewRecorder()
server.apiDocsJSON(jsonResponse, httptest.NewRequest(http.MethodGet, "/api-docs-json", nil))
server.apiDocsJSON(jsonResponse, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil))
if jsonResponse.Code != http.StatusOK {
t.Fatalf("expected JSON docs status 200, got %d", jsonResponse.Code)
}
@@ -88,7 +88,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
t.Fatalf("decode embedded Swagger JSON: %v", err)
}
for _, path := range []string{
"/api-docs-json",
"/api/v1/openapi.json",
"/api/v1/public/skills/ai-gateway-ops-management/download",
"/api/admin/catalog/providers",
"/api/admin/catalog/base-models",
@@ -102,7 +102,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
}
yamlResponse := httptest.NewRecorder()
server.apiDocsYAML(yamlResponse, httptest.NewRequest(http.MethodGet, "/api-docs-yaml", nil))
server.apiDocsYAML(yamlResponse, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil))
if yamlResponse.Code != http.StatusOK {
t.Fatalf("expected YAML docs status 200, got %d", yamlResponse.Code)
}
@@ -58,17 +58,18 @@ func TestPlanTaskResponseTreatsAPIV1EmbeddingAndRerankAsSynchronousCompatibleRes
}
}
func TestPlanTaskResponseKeepsAsyncTaskModeForOtherAPIV1Tasks(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
req.Header.Set("X-Async", "true")
plan := planTaskResponse("images.generations", false, map[string]any{"stream": true}, req)
if !plan.asyncMode {
t.Fatal("non-chat /api/v1 task endpoints should keep X-Async task mode")
func TestPlanTaskResponseUsesCompatibleAPIV1MediaResponsesAndKeepsAsyncOptIn(t *testing.T) {
defaultRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
defaultPlan := planTaskResponse("images.generations", true, map[string]any{}, defaultRequest)
if defaultPlan.asyncMode || !defaultPlan.compatibleMode {
t.Fatalf("canonical /api/v1 media endpoints should default to synchronous compatible responses, got %+v", defaultPlan)
}
if plan.compatibleMode {
t.Fatal("non-compatible /api/v1 task endpoints should not return OpenAI-compatible payloads")
asyncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
asyncRequest.Header.Set("X-Async", "true")
asyncPlan := planTaskResponse("images.generations", true, map[string]any{}, asyncRequest)
if !asyncPlan.asyncMode || !asyncPlan.compatibleMode {
t.Fatalf("canonical /api/v1 media endpoints should keep compatible mode when X-Async is enabled, got %+v", asyncPlan)
}
}
@@ -504,7 +504,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
Result map[string]any `json:"result"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
"model": defaultImageModel,
"runMode": "simulation",
"prompt": "a tiny gateway console",
@@ -512,7 +512,9 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
"quality": "medium",
"simulation": true,
"simulationDurationMs": 5,
}, http.StatusAccepted, &imageResponse)
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageResponse)
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageResponse.Task.ID, []string{"succeeded"}, 10*time.Second)
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageResponse.Task)
if imageResponse.Task.Status != "succeeded" || imageResponse.Task.Result["id"] == "" {
t.Fatalf("unexpected image generation task: %+v", imageResponse.Task)
}
@@ -524,7 +526,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
Result map[string]any `json:"result"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{
"model": defaultImageModel,
"runMode": "simulation",
"prompt": "replace background with clean studio light",
@@ -532,7 +534,9 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
"mask": "https://example.com/mask.png",
"simulation": true,
"simulationDurationMs": 5,
}, http.StatusAccepted, &imageEditResponse)
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageEditResponse)
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageEditResponse.Task.ID, []string{"succeeded"}, 10*time.Second)
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageEditResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageEditResponse.Task)
if imageEditResponse.Task.Status != "succeeded" || imageEditResponse.Task.Result["id"] == "" {
t.Fatalf("unexpected image edit task: %+v", imageEditResponse.Task)
}
@@ -1196,17 +1200,20 @@ WHERE reference_type = 'gateway_task'
}, http.StatusCreated, &videoRoutePlatformModel)
var textToVideoTask struct {
Task struct {
ID string `json:"id"`
Status string `json:"status"`
ModelType string `json:"modelType"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
"model": videoRouteModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"prompt": "text to video route",
}, http.StatusAccepted, &textToVideoTask)
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &textToVideoTask)
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, textToVideoTask.Task.ID, []string{"succeeded"}, 10*time.Second)
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+textToVideoTask.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &textToVideoTask.Task)
if textToVideoTask.Task.Status != "succeeded" || textToVideoTask.Task.ModelType != "video_generate" {
t.Fatalf("text-to-video request should use video_generate model_type: %+v", textToVideoTask.Task)
}
@@ -1218,14 +1225,16 @@ WHERE reference_type = 'gateway_task'
Metrics map[string]any `json:"metrics"`
} `json:"task"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
"model": videoRouteModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"prompt": "image to video route",
"image": "https://example.com/source.png",
}, http.StatusAccepted, &imageToVideoTask)
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageToVideoTask)
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageToVideoTask.Task.ID, []string{"succeeded"}, 10*time.Second)
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageToVideoTask.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageToVideoTask.Task)
if imageToVideoTask.Task.Status != "succeeded" || imageToVideoTask.Task.ModelType != "image_to_video" {
t.Fatalf("image-to-video request should use image_to_video model_type: %+v", imageToVideoTask.Task)
}
@@ -26,7 +26,6 @@ const maxGatewayUploadBytes = 256 << 20
// @Failure 502 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/files/upload [post]
// @Router /v1/files/upload [post]
func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
if err := r.ParseMultipartForm(32 << 20); err != nil {
+47 -1
View File
@@ -75,6 +75,21 @@ func geminiGenerateContentModelFromPath(prefix string, requestPath string) (stri
return model, true
}
// geminiGenerateContent godoc
// @Summary Gemini generateContent 兼容接口
// @Description 使用统一 /api/v1 前缀接收 Gemini generateContent 请求;旧 /v1 和 /v1beta 路径保留兼容。
// @Tags gemini-compatible
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param model path string true "模型名称"
// @Param input body map[string]interface{} true "Gemini generateContent 请求"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/models/{model}:generateContent [post]
func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -395,6 +410,18 @@ func geminiUsageMetadataFromOutput(output map[string]any) map[string]any {
return meta
}
// geminiFilesUpload godoc
// @Summary Gemini Files 上传接口
// @Description 使用统一 /api/v1 前缀启动或直接完成 Gemini Files 上传。
// @Tags gemini-compatible
// @Accept octet-stream
// @Produce json
// @Security BearerAuth
// @Param version path string true "Gemini 版本(v1 或 v1beta"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Router /api/v1/gemini/upload/{version}/files [post]
func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
@@ -412,6 +439,18 @@ func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
})
}
// geminiFilesUploadFinalize godoc
// @Summary 完成 Gemini Files 分段上传
// @Tags gemini-compatible
// @Accept octet-stream
// @Produce json
// @Security BearerAuth
// @Param version path string true "Gemini 版本(v1 或 v1beta"
// @Param uploadID path string true "上传会话 ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Router /api/v1/gemini/upload/{version}/files/{uploadID} [post]
func (s *Server) geminiFilesUploadFinalize(w http.ResponseWriter, r *http.Request) {
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
@@ -440,11 +479,18 @@ func (s *Server) startGeminiFilesUpload(w http.ResponseWriter, r *http.Request)
CreatedAt: time.Now(),
}
s.geminiUploadSessions.Store(uploadID, session)
w.Header().Set("X-Goog-Upload-URL", absoluteRequestURL(r, "/upload/"+session.Version+"/files/"+uploadID))
w.Header().Set("X-Goog-Upload-URL", absoluteRequestURL(r, geminiUploadPath(r, session.Version, uploadID)))
w.Header().Set("X-Goog-Upload-Status", "active")
writeJSON(w, http.StatusOK, map[string]any{})
}
func geminiUploadPath(r *http.Request, version string, uploadID string) string {
if strings.HasPrefix(r.URL.Path, "/api/v1/gemini/upload/") {
return "/api/v1/gemini/upload/" + version + "/files/" + uploadID
}
return "/upload/" + version + "/files/" + uploadID
}
func (s *Server) finalizeGeminiFilesUpload(w http.ResponseWriter, r *http.Request, uploadID string, session geminiUploadSession) {
if uploadID == "" {
uploadID = newGeminiUploadID()
@@ -102,6 +102,18 @@ func TestRegisterGeminiGenerateContentRoutes(t *testing.T) {
}
}
func TestGeminiUploadPathKeepsCanonicalV1Prefix(t *testing.T) {
canonical := httptest.NewRequest(http.MethodPost, "/api/v1/gemini/upload/v1beta/files", nil)
if got := geminiUploadPath(canonical, "v1beta", "upload-1"); got != "/api/v1/gemini/upload/v1beta/files/upload-1" {
t.Fatalf("canonical upload path = %q", got)
}
legacy := httptest.NewRequest(http.MethodPost, "/upload/v1beta/files", nil)
if got := geminiUploadPath(legacy, "v1beta", "upload-1"); got != "/upload/v1beta/files/upload-1" {
t.Fatalf("legacy upload path = %q", got)
}
}
func TestGeminiImageTaskBodyMapsTextOnlyToImageGenerate(t *testing.T) {
mapping, err := geminiImageTaskBody("gemini-image", map[string]any{
"contents": []any{
+3 -29
View File
@@ -31,7 +31,7 @@ const (
// @Tags system
// @Produce json
// @Success 200 {object} HealthResponse
// @Router /healthz [get]
// @Router /api/v1/healthz [get]
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
@@ -48,7 +48,7 @@ func (s *Server) health(w http.ResponseWriter, r *http.Request) {
// @Produce json
// @Success 200 {object} ReadyResponse
// @Failure 503 {object} ErrorEnvelope
// @Router /readyz [get]
// @Router /api/v1/readyz [get]
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), postgresReadinessTimeout)
defer cancel()
@@ -1021,7 +1021,7 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// createTask godoc
// @Summary 创建或执行 AI 任务
// @Description 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流
// @Description 统一公开入口按 model 选择平台模型并默认同步返回兼容响应;设置 X-Async=true 时异步创建任务并返回 202
// @Tags tasks
// @Accept json
// @Produce json
@@ -1047,22 +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 /embeddings [post]
// @Router /v1/embeddings [post]
// @Router /reranks [post]
// @Router /v1/reranks [post]
// @Router /images/generations [post]
// @Router /v1/images/generations [post]
// @Router /images/edits [post]
// @Router /v1/images/edits [post]
// @Router /song/generations [post]
// @Router /v1/song/generations [post]
// @Router /music/generations [post]
// @Router /v1/music/generations [post]
// @Router /speech/generations [post]
// @Router /v1/speech/generations [post]
// @Router /voice_clone [post]
// @Router /v1/voice_clone [post]
func (s *Server) createTask(kind string, compatible bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
@@ -1214,8 +1198,6 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
// @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
@@ -1234,8 +1216,6 @@ func openAIChatCompletionsDoc() {}
// @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() {}
@@ -1660,7 +1640,6 @@ func matchedRateLimitRule(policy map[string]any, metric string) map[string]any {
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks [get]
// @Router /api/v1/tasks [get]
// @Router /tasks [get]
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -1769,7 +1748,6 @@ func boolValue(body map[string]any, key string) bool {
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks/{taskID} [get]
// @Router /api/v1/tasks/{taskID} [get]
// @Router /tasks/{taskID} [get]
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err == nil {
@@ -1802,8 +1780,6 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks/{taskID}/cancel [post]
// @Router /api/v1/tasks/{taskID}/cancel [post]
// @Router /v1/tasks/{taskID}/cancel [post]
// @Router /tasks/{taskID}/cancel [post]
func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -1840,7 +1816,6 @@ func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks/{taskID}/param-preprocessing [get]
// @Router /api/v1/tasks/{taskID}/param-preprocessing [get]
// @Router /tasks/{taskID}/param-preprocessing [get]
func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request) {
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err != nil {
@@ -1874,7 +1849,6 @@ func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request)
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks/{taskID}/events [get]
// @Router /api/v1/tasks/{taskID}/events [get]
// @Router /tasks/{taskID}/events [get]
func (s *Server) taskEvents(w http.ResponseWriter, r *http.Request) {
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err != nil {
@@ -132,7 +132,7 @@ func (s *Server) requireKelingAPIKey(next http.Handler) http.Handler {
// @Failure 429 {object} KelingCompatibleEnvelope
// @Failure 500 {object} KelingCompatibleEnvelope
// @Failure 503 {object} KelingCompatibleEnvelope
// @Router /v1/videos/omni-video [post]
// @Router /api/v1/videos/omni-video [post]
func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
requestID := kelingCompatRequestID(r)
user, ok := auth.UserFromContext(r.Context())
@@ -206,7 +206,7 @@ func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
// @Failure 403 {object} KelingCompatibleEnvelope
// @Failure 404 {object} KelingCompatibleEnvelope
// @Failure 500 {object} KelingCompatibleEnvelope
// @Router /v1/videos/omni-video/{taskID} [get]
// @Router /api/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())
@@ -145,7 +145,7 @@ func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
_, 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{
doJSON(t, gateway.URL, http.MethodPost, "/api/v1/videos/omni-video", firstAPIKey, map[string]any{
"model_name": "kling-video-o1",
"prompt": "A clean product reveal",
"mode": "std",
@@ -162,7 +162,7 @@ func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
taskID := stringFromKelingCompat(createdData["task_id"])
var hidden KelingCompatibleEnvelope
doJSON(t, gateway.URL, http.MethodGet, "/v1/videos/omni-video/"+taskID, secondAPIKey, nil, http.StatusNotFound, &hidden)
doJSON(t, gateway.URL, http.MethodGet, "/api/v1/videos/omni-video/"+taskID, secondAPIKey, nil, http.StatusNotFound, &hidden)
if hidden.Code != 1203 {
t.Fatalf("cross-user task must be hidden: %+v", hidden)
}
@@ -278,7 +278,7 @@ func waitForKelingCompatTask(t *testing.T, baseURL string, apiKey string, taskID
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)
doJSON(t, baseURL, http.MethodGet, "/api/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
data, _ := response.Data.(map[string]any)
switch data["task_status"] {
case "succeed":
@@ -134,16 +134,22 @@ WHERE username = $1`, username); err != nil {
ResponseDurationMS int64 `json:"responseDurationMs"`
} `json:"task"`
}
doJSON(
doJSONWithHeaders(
t,
server.URL,
http.MethodPost,
"/api/v1/videos/generations",
apiKeyResponse.Secret,
request,
map[string]string{"X-Async": "true"},
http.StatusAccepted,
&response,
)
if response.Task.ID == "" {
t.Fatal("async Kling simulation response did not return a task id")
}
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, response.Task.ID, []string{"succeeded"}, 10*time.Second)
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+response.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &response.Task)
task := response.Task
if task.ID == "" ||
+15 -6
View File
@@ -27,6 +27,15 @@ func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
handler := func(next http.HandlerFunc) http.Handler {
return s.requireUser(auth.PermissionBasic, http.HandlerFunc(next))
}
// /api/v1 is the canonical public prefix. The historical /kling paths
// remain registered below so existing clients can migrate without downtime.
mux.Handle("POST /api/v1/kling/v1/videos/omni-video", handler(s.klingV1CreateOmniVideo))
mux.Handle("GET /api/v1/kling/v1/videos/omni-video", handler(s.klingV1ListOmniVideos))
mux.Handle("GET /api/v1/kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo))
mux.Handle("POST /api/v1/kling/v2/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
mux.Handle("GET /api/v1/kling/v2/tasks", handler(s.klingV2GetTasks))
mux.Handle("POST /api/v1/kling/v2/tasks", handler(s.klingV2ListTasks))
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))
@@ -52,7 +61,7 @@ func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} ErrorEnvelope
// @Router /kling/v1/videos/omni-video [post]
// @Router /api/v1/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 {
@@ -78,7 +87,7 @@ func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request)
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} ErrorEnvelope
// @Router /kling/omni-video/{model} [post]
// @Router /api/v1/kling/v2/omni-video/{model} [post]
func (s *Server) klingV2CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
model, ok := klingV2ProviderModel(r.PathValue("model"))
if !ok {
@@ -433,7 +442,7 @@ func validateKlingCompatBody(model string, body map[string]any) error {
// @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]
// @Router /api/v1/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"))
@@ -456,7 +465,7 @@ func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
// @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]
// @Router /api/v1/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)
@@ -489,7 +498,7 @@ func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
// @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]
// @Router /api/v1/kling/v2/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"))
@@ -525,7 +534,7 @@ func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
// @Security BearerAuth
// @Param input body map[string]interface{} true "游标、数量、时间范围和筛选条件"
// @Success 200 {object} map[string]interface{}
// @Router /kling/tasks [post]
// @Router /api/v1/kling/v2/tasks [post]
func (s *Server) klingV2ListTasks(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
var body map[string]any
@@ -29,6 +29,23 @@ func TestKlingCompatibilitySimulationFlow(t *testing.T) {
}
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{
@@ -89,11 +106,66 @@ func TestKlingCompatibilitySimulationFlow(t *testing.T) {
"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 {
ID string `json:"id"`
Status string `json:"status"`
ModelType string `json:"modelType"`
ResolvedModel string `json:"resolvedModel"`
} `json:"task"`
}
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, request, map[string]string{"X-Async": "true"}, http.StatusAccepted, &response)
if response.Task.ID == "" {
t.Fatal("async generic video response did not return a task id")
}
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, response.Task.ID, []string{"succeeded"}, 10*time.Second)
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+response.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &response.Task)
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{
doJSON(t, server.URL, http.MethodPost, "/api/v1/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
"model_name": model,
"prompt": "兼容接口模拟任务",
"duration": duration,
@@ -120,14 +192,14 @@ func TestKlingCompatibilitySimulationFlow(t *testing.T) {
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)
doJSON(t, server.URL, http.MethodGet, "/api/v1/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{
doJSON(t, server.URL, http.MethodPost, "/api/v1/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,
@@ -137,7 +209,7 @@ func TestKlingCompatibilitySimulationFlow(t *testing.T) {
}
var v2Response map[string]any
doJSON(t, server.URL, http.MethodPost, "/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
doJSON(t, server.URL, http.MethodPost, "/api/v1/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},
@@ -158,7 +230,7 @@ func waitKlingV1SimulationTask(t *testing.T, baseURL string, apiKey string, task
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)
doJSON(t, baseURL, http.MethodGet, "/api/v1/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
data, _ := response["data"].(map[string]any)
switch data["task_status"] {
case "succeed":
@@ -176,7 +248,7 @@ func waitKlingV2SimulationTask(t *testing.T, baseURL string, apiKey string, task
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)
doJSON(t, baseURL, http.MethodGet, "/api/v1/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)
+2 -2
View File
@@ -23,8 +23,8 @@ type SkillBundleMetadataResponse struct {
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"`
APIDocsJSONPath string `json:"apiDocsJsonPath" example:"/api/v1/openapi.json"`
APIDocsYAMLPath string `json:"apiDocsYamlPath" example:"/api/v1/openapi.yaml"`
}
type ErrorEnvelope struct {
@@ -0,0 +1,72 @@
package httpapi
import (
"encoding/json"
"strings"
"testing"
gatewaydocs "github.com/easyai/easyai-ai-gateway/apps/api/docs"
)
func TestOpenAPIPublicRoutesUseCanonicalV1Prefix(t *testing.T) {
var document struct {
Paths map[string]any `json:"paths"`
}
if err := json.Unmarshal(gatewaydocs.SwaggerJSON, &document); err != nil {
t.Fatalf("decode embedded OpenAPI document: %v", err)
}
legacyPrefixes := []string{
"/v1/",
"/v1beta/",
"/kling/",
"/upload/",
"/api/v3/",
"/chat/",
"/images/",
"/song/",
"/music/",
"/speech/",
"/voice_clone",
"/tasks",
}
legacyExact := map[string]bool{
"/healthz": true,
"/readyz": true,
"/api-docs-json": true,
"/api-docs-yaml": true,
"/responses": true,
"/embeddings": true,
"/reranks": true,
}
for route := range document.Paths {
if legacyExact[route] {
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
}
for _, prefix := range legacyPrefixes {
if strings.HasPrefix(route, prefix) {
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
}
}
}
required := []string{
"/api/v1/healthz",
"/api/v1/readyz",
"/api/v1/openapi.json",
"/api/v1/chat/completions",
"/api/v1/responses",
"/api/v1/images/generations",
"/api/v1/videos/generations",
"/api/v1/models/{model}:generateContent",
"/api/v1/videos/omni-video",
"/api/v1/kling/v1/videos/omni-video",
"/api/v1/kling/v2/omni-video/{model}",
"/api/v1/contents/generations/tasks",
}
for _, route := range required {
if _, ok := document.Paths[route]; !ok {
t.Errorf("canonical public route missing from OpenAPI: %s", route)
}
}
}
@@ -40,6 +40,18 @@ func TestReceiveSecurityEventUsesPreparedReceiverBeforeFirstActivation(t *testin
}
}
func TestReceiveSecurityEventReturnsNotFoundWithoutConfiguredReceiver(t *testing.T) {
server := &Server{identityRuntime: identityruntime.NewManager(nil, &preparedReceiverBuilder{})}
request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", nil)
response := httptest.NewRecorder()
server.receiveSecurityEvent(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("unconfigured SSF status=%d, want %d", response.Code, http.StatusNotFound)
}
}
func TestSecurityEventWriteAuditFailsClosedBeforeMutation(t *testing.T) {
server := &Server{}
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/security-events/connection/verify", nil)
+18 -6
View File
@@ -127,12 +127,16 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", server.health)
mux.HandleFunc("GET /readyz", server.ready)
mux.HandleFunc("GET /api/v1/healthz", server.health)
mux.HandleFunc("GET /api/v1/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/openapi.json", server.apiDocsJSON)
mux.HandleFunc("GET /api/v1/openapi.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)
@@ -252,12 +256,12 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
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.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/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", true)))
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)))
@@ -275,10 +279,16 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
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)))
mux.Handle("POST /api/v1/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createVolcesContentsGenerationTask)))
mux.Handle("GET /api/v1/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
mux.Handle("GET /api/v1/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
mux.Handle("DELETE /api/v1/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
server.registerGeminiGenerateContentRoutes(mux)
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("POST /api/v1/gemini/upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
mux.Handle("POST /api/v1/gemini/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)))
@@ -303,6 +313,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
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 /api/v1/videos/omni-video", server.requireKelingAPIKey(http.HandlerFunc(server.createKelingOmniVideo)))
mux.Handle("GET /api/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)))
@@ -16,8 +16,6 @@ import (
// @Failure 401 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/voice_clone/voices [get]
// @Router /v1/voice_clone/voices [get]
// @Router /voice_clone/voices [get]
func (s *Server) listClonedVoices(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -51,8 +49,6 @@ func (s *Server) listClonedVoices(w http.ResponseWriter, r *http.Request) {
// @Failure 404 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/voice_clone/voices/{voiceID} [delete]
// @Router /v1/voice_clone/voices/{voiceID} [delete]
// @Router /voice_clone/voices/{voiceID} [delete]
func (s *Server) deleteClonedVoice(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -17,13 +17,13 @@ const volcesContentsCompatibilityMarker = "volces_contents_generations_v3"
// createVolcesContentsGenerationTask godoc
// @Summary 创建火山内容生成任务
// @Description 兼容火山方舟 POST /api/v3/contents/generations/tasks。网关 task id 是查询与取消用的公开 id;上游 id 另以 upstream_task_id 保留。
// @Description 统一公开入口兼容火山方舟内容生成任务。网关 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]
// @Router /api/v1/contents/generations/tasks [post]
func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok || user == nil {
@@ -49,7 +49,7 @@ func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]any
// @Router /api/v3/contents/generations/tasks/{taskID} [get]
// @Router /api/v1/contents/generations/tasks/{taskID} [get]
func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
task, ok := s.volcesCompatibleTaskForUser(w, r)
if !ok {
@@ -64,7 +64,7 @@ func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]any
// @Router /api/v3/contents/generations/tasks [get]
// @Router /api/v1/contents/generations/tasks [get]
func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok || user == nil {
@@ -105,7 +105,7 @@ func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *htt
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]any
// @Router /api/v3/contents/generations/tasks/{taskID} [delete]
// @Router /api/v1/contents/generations/tasks/{taskID} [delete]
func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
task, ok := s.volcesCompatibleTaskForUser(w, r)
if !ok {
+4 -1
View File
@@ -105,7 +105,10 @@ func (manager *Manager) SecurityEventReceiver() http.Handler {
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
return runtime.SecurityEvents
}
return manager.SecurityEventManager()
if securityEventManager := manager.SecurityEventManager(); securityEventManager != nil {
return securityEventManager
}
return nil
}
// SecurityEventManager resolves the manager used by administrative recovery
@@ -630,6 +630,14 @@ func TestSecurityEventManagerExposesPreparedRecoveryManagerWithoutActiveRuntime(
}
}
func TestSecurityEventReceiverReturnsNilWithoutConfiguredManager(t *testing.T) {
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{})
if manager.SecurityEventReceiver() != nil {
t.Fatal("unconfigured security event receiver should be nil")
}
}
func TestSecurityEventManagerPrefersActiveRuntime(t *testing.T) {
active := &securityevents.ConnectionManager{}
prepared := &securityevents.ConnectionManager{}
@@ -18,7 +18,7 @@ Use this skill to operate AI Gateway administration APIs through documented, evi
- Reuse existing pricing rules, runtime policy sets, providers, protocol clients, base models, and platforms whenever their effective behavior satisfies the target. Do not create a near-duplicate resource merely because the upstream account, base URL, or provider-side model name differs.
- Prefer a supported standard client before using `universal` scripts. Use custom scripts only when the upstream contract cannot be represented by the existing OpenAI, Gemini, or provider-specific clients.
- Do not invent platform config fields or assume an arbitrary config key is enforced. For `universal`, use only the recognized keys documented in `references/model-universal-platforms.md`; treat any extra key as script-owned data available through `context.env`.
- Use the module references as the primary API source. Only when the required API is absent, inspect `<gateway-api-base-url>/api-docs-json`; continue only when path, method, schema, authentication, permission, and side effects are unambiguous.
- Use the module references as the primary API source. Only when the required API is absent, inspect `<gateway-origin>/api/v1/openapi.json`; continue only when path, method, schema, authentication, permission, and side effects are unambiguous.
## Module Routing
@@ -2,7 +2,7 @@
## Required Inputs
- Gateway API base URL. When using the bundled Web deployment this commonly includes `/gateway-api`; direct API access commonly uses port `8088`.
- Gateway origin and public API base URL. Public API access always ends with `/api/v1`; direct local access commonly uses `http://127.0.0.1:8088/api/v1`.
- Administrator JWT with the `manager` or `admin` role.
- Target provider documentation and authorization material.
- Clear requested outcome and whether real upstream calls are allowed.
@@ -10,7 +10,8 @@
Do not place credentials in files or reusable commands. Use shell environment variables:
```bash
export GATEWAY_BASE_URL='https://gateway.example.com/gateway-api'
export GATEWAY_ORIGIN='https://gateway.example.com'
export GATEWAY_PUBLIC_API_BASE="$GATEWAY_ORIGIN/api/v1"
export GATEWAY_ADMIN_TOKEN='<administrator-jwt>'
```
@@ -24,7 +25,7 @@ For standalone or hybrid deployments, local login can return a JWT:
curl --fail-with-body \
-H 'Content-Type: application/json' \
-d '{"account":"<admin-account>","password":"<admin-password>"}' \
"$GATEWAY_BASE_URL/api/v1/auth/login"
"$GATEWAY_PUBLIC_API_BASE/auth/login"
```
Do not use local login when the deployment requires OIDC or server-main identity. Obtain the deployment's administrator access token instead.
@@ -34,7 +35,7 @@ Verify identity and role before writes:
```bash
curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/v1/me"
"$GATEWAY_PUBLIC_API_BASE/me"
```
## Request Pattern
@@ -47,7 +48,7 @@ curl --fail-with-body \
-H 'Content-Type: application/json' \
-X POST \
-d '<json-body>' \
"$GATEWAY_BASE_URL/api/admin/<resource>"
"$GATEWAY_ORIGIN/api/admin/<resource>"
```
Always read current state before PATCH, DELETE, reset, disable, or full replacement. PATCH handlers for providers, base models, pricing rule sets, runtime policy sets, runner policy, and platforms write complete resource shapes rather than merging every omitted field.
@@ -67,7 +68,7 @@ Obtain explicit confirmation after showing the current snapshot and impact befor
The live machine-readable documents are:
- `<gateway-api-base-url>/api-docs-json`
- `<gateway-api-base-url>/api-docs-yaml`
- `<gateway-origin>/api/v1/openapi.json`
- `<gateway-origin>/api/v1/openapi.yaml`
Use them only when this Skill does not document the required operation. Before acting, confirm the exact path, method, body, authentication, permission, response, and side effect. Do not infer a write operation from a similarly named endpoint.
@@ -33,7 +33,7 @@ Use an authorized user JWT:
```bash
curl --fail-with-body \
-H "Authorization: Bearer <user-jwt>" \
"$GATEWAY_BASE_URL/api/v1/model-catalog"
"$GATEWAY_PUBLIC_API_BASE/model-catalog"
```
Confirm model alias, model types, provider source, effective capabilities, pricing summary, rate limits, permissions, and enabled state.
@@ -57,7 +57,7 @@ curl --fail-with-body \
"simulation": true,
"stream": false
}' \
"$GATEWAY_BASE_URL/v1/chat/completions"
"$GATEWAY_PUBLIC_API_BASE/chat/completions"
```
Simulation verifies Gateway routing, permissions, parameter normalization, pricing, and task behavior, but it does not execute the real universal submit or poll scripts. Validate universal scripts separately against a local mock or approved provider test environment before enabling production traffic.
@@ -82,4 +82,4 @@ With explicit approval, run one real minimal request and verify upstream request
## Final Report
Report resource IDs, before/after behavior, requests used for verification, simulation or real mode, billing evidence, remaining risks, rollback readiness, and whether `/api-docs-json` was used. Never include credentials or raw secret-bearing payloads.
Report resource IDs, before/after behavior, requests used for verification, simulation or real mode, billing evidence, remaining risks, rollback readiness, and whether `/api/v1/openapi.json` was used. Never include credentials or raw secret-bearing payloads.
+2
View File
@@ -488,6 +488,8 @@ func modelTypeAliases(value string) []string {
return []string{"image_edit"}
case "video", "videos.generations":
return []string{"video_generate"}
case "omni_video":
return []string{"video_generate", "image_to_video", "omni_video"}
case "song", "music", "song.generations", "music.generations", "music_generate":
return []string{"audio_generate"}
case "speech", "speech.generations", "tts":
@@ -9,6 +9,19 @@ func TestNormalizeModelMatchKeyRemovesWhitespace(t *testing.T) {
}
}
func TestNormalizeModelTypeListExpandsOmniVideoBaseCapabilities(t *testing.T) {
got := normalizeModelTypeList([]string{"omni_video"})
want := StringList{"video_generate", "image_to_video", "omni_video"}
if len(got) != len(want) {
t.Fatalf("omni_video should include text-to-video and image-to-video capabilities: got=%v want=%v", got, want)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("omni_video capability mismatch at %d: got=%v want=%v", index, got, want)
}
}
}
func TestTaskBillingModelIdentityKeepsRequestedModelPrimary(t *testing.T) {
identity := taskBillingModelIdentity(GatewayTask{
Model: "doubao-5.0 图像编辑",
@@ -0,0 +1,182 @@
WITH keling_omni_models(provider_model_name, model_type, capabilities) AS (
VALUES
(
'kling-video-o1',
'["video_generate","image_to_video","omni_video"]'::jsonb,
'{
"video_generate": {
"output_resolutions": ["720p", "1080p"],
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10],
"output_audio": false,
"input_audio": false,
"prompt_length_limit": {
"max": 2500,
"count_mode": "non_ascii_weighted",
"label": "可灵口径"
}
},
"image_to_video": {
"output_resolutions": ["720p", "1080p"],
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10],
"input_first_frame": true,
"input_last_frame": false,
"input_first_last_frame": true,
"input_reference_generate_single": true,
"input_reference_generate_multiple": true,
"max_images": 7,
"max_images_for_last_frame": 2,
"support_video_effect_template": false,
"output_audio": false,
"input_audio": false,
"prompt_length_limit": {
"max": 2500,
"count_mode": "non_ascii_weighted",
"label": "可灵口径"
}
},
"omni_video": {
"supported_modes": ["text_to_video", "image_reference", "element_reference", "first_last_frame", "video_reference", "video_edit"],
"output_resolutions": ["720p", "1080p"],
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10],
"output_audio": false,
"input_audio": false,
"max_videos": 1,
"max_audios": 0,
"max_images": 7,
"max_elements": 7,
"max_images_and_elements": 7,
"limits_with_video": {"max_images_and_elements": 4},
"max_images_for_last_frame": 2,
"support_instruction_edit": true,
"prompt_length_limit": {
"max": 2500,
"count_mode": "non_ascii_weighted",
"label": "可灵口径"
}
},
"originalTypes": ["video_generate", "image_to_video", "omni_video"]
}'::jsonb
),
(
'kling-v3-omni',
'["video_generate","image_to_video","omni_video"]'::jsonb,
'{
"video_generate": {
"output_resolutions": ["720p", "1080p", "2160p"],
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"output_audio": true,
"input_audio": false,
"prompt_length_limit": {
"max": 2500,
"count_mode": "non_ascii_weighted",
"label": "可灵口径"
}
},
"image_to_video": {
"output_resolutions": ["720p", "1080p", "2160p"],
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"input_first_frame": true,
"input_last_frame": false,
"input_first_last_frame": true,
"input_reference_generate_single": true,
"input_reference_generate_multiple": true,
"max_images": 7,
"max_images_for_last_frame": 2,
"support_video_effect_template": false,
"output_audio": true,
"input_audio": false,
"prompt_length_limit": {
"max": 2500,
"count_mode": "non_ascii_weighted",
"label": "可灵口径"
}
},
"omni_video": {
"supported_modes": ["text_to_video", "image_reference", "element_reference", "first_last_frame", "video_reference", "video_edit", "multi_shot"],
"output_resolutions": ["720p", "1080p", "2160p"],
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"output_audio": true,
"input_audio": false,
"max_videos": 1,
"max_audios": 0,
"max_images": 7,
"max_elements": 7,
"max_images_and_elements": 7,
"limits_with_video": {
"max_images_and_elements": 4,
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10]
},
"max_images_for_last_frame": 2,
"support_instruction_edit": true,
"prompt_length_limit": {
"max": 2500,
"count_mode": "non_ascii_weighted",
"label": "可灵口径"
}
},
"originalTypes": ["video_generate", "image_to_video", "omni_video"]
}'::jsonb
)
)
UPDATE base_model_catalog model
SET model_type = defs.model_type,
capabilities = defs.capabilities,
metadata = COALESCE(model.metadata, '{}'::jsonb)
|| jsonb_build_object(
'originalTypes', defs.model_type,
'rawModel', COALESCE(model.metadata->'rawModel', '{}'::jsonb)
|| jsonb_build_object(
'types', defs.model_type,
'capabilities', defs.capabilities
)
),
default_snapshot = CASE
WHEN COALESCE(model.default_snapshot, '{}'::jsonb) = '{}'::jsonb THEN model.default_snapshot
ELSE jsonb_set(
jsonb_set(
jsonb_set(model.default_snapshot, '{modelType}', defs.model_type, true),
'{capabilities}',
defs.capabilities,
true
),
'{metadata}',
COALESCE(model.default_snapshot->'metadata', '{}'::jsonb)
|| jsonb_build_object(
'originalTypes', defs.model_type,
'rawModel', COALESCE(model.default_snapshot->'metadata'->'rawModel', '{}'::jsonb)
|| jsonb_build_object(
'types', defs.model_type,
'capabilities', defs.capabilities
)
),
true
)
END,
updated_at = now()
FROM keling_omni_models defs
WHERE model.provider_model_name = defs.provider_model_name
AND model.model_type @> '["omni_video"]'::jsonb;
WITH keling_omni_models(provider_model_name, model_type, capabilities) AS (
SELECT DISTINCT ON (model.provider_model_name)
model.provider_model_name,
model.model_type,
model.capabilities
FROM base_model_catalog model
WHERE model.provider_model_name IN ('kling-video-o1', 'kling-v3-omni')
AND model.model_type @> '["omni_video"]'::jsonb
ORDER BY model.provider_model_name, (model.provider_key = 'keling') DESC, model.created_at ASC
)
UPDATE platform_models model
SET model_type = defs.model_type,
capabilities = defs.capabilities,
updated_at = now()
FROM keling_omni_models defs
WHERE COALESCE(NULLIF(model.provider_model_name, ''), model.model_name) = defs.provider_model_name
AND model.model_type @> '["omni_video"]'::jsonb;
+1 -1
View File
@@ -24,7 +24,7 @@
"outputs": ["{projectRoot}/docs/swagger.json", "{projectRoot}/docs/swagger.yaml"],
"options": {
"cwd": "apps/api",
"command": "go run github.com/swaggo/swag/cmd/swag@v1.16.4 init --parseInternal -d ./cmd/gateway,./internal/httpapi,./internal/store,./internal/auth,./internal/identity -g main.go -o docs --outputTypes json,yaml"
"command": "go run github.com/swaggo/swag/cmd/swag@v1.16.4 init --parseInternal -d ./cmd/gateway,./internal/httpapi,./internal/store,./internal/auth,./internal/identity,./internal/runner -g main.go -o docs --outputTypes json,yaml"
}
},
"test": {
+2 -2
View File
@@ -266,8 +266,8 @@ describe('Public Agent resources', () => {
modules: ['model-runtime'],
fileName: 'ai-gateway-ops-management-v1.0.2.zip',
downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download',
apiDocsJsonPath: '/api-docs-json',
apiDocsYamlPath: '/api-docs-yaml',
apiDocsJsonPath: '/api/v1/openapi.json',
apiDocsYamlPath: '/api/v1/openapi.yaml',
};
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(metadata), {
status: 200,
+11 -11
View File
@@ -35,17 +35,17 @@ interface ApiGuideItem {
}
export const apiDocs: ApiDocItem[] = [
{ key: 'chat', group: '文本', kind: 'chat.completions', method: 'POST', path: '/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' },
{ key: 'responses', group: '文本', kind: 'responses', method: 'POST', path: '/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' },
{ key: 'embeddings', group: '文本', kind: 'embeddings', method: 'POST', path: '/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embeddingAPI Key 需要 embedding 权限。' },
{ key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_scoreAPI Key 需要 rerank 权限。' },
{ key: 'imageGeneration', group: '图片', kind: 'images.generations', method: 'POST', path: '/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' },
{ key: 'imageEdit', group: '图片', kind: 'images.edits', method: 'POST', path: '/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' },
{ key: 'chat', group: '文本', kind: 'chat.completions', method: 'POST', path: '/api/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' },
{ key: 'responses', group: '文本', kind: 'responses', method: 'POST', path: '/api/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' },
{ key: 'embeddings', group: '文本', kind: 'embeddings', method: 'POST', path: '/api/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embeddingAPI Key 需要 embedding 权限。' },
{ key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/api/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_scoreAPI Key 需要 rerank 权限。' },
{ key: 'imageGeneration', group: '图片', kind: 'images.generations', method: 'POST', path: '/api/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' },
{ key: 'imageEdit', group: '图片', kind: 'images.edits', method: 'POST', path: '/api/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' },
{ key: 'videoGeneration', group: '视频', kind: 'videos.generations', method: 'POST', path: '/api/v1/videos/generations', title: '生成视频', lead: '视频生成任务接口,支持文生视频、首尾帧、图片/视频/音频参考,以及时长、分辨率、画幅和声音等模型能力参数。' },
{ key: 'asyncMode', group: '异步任务', title: '异步模式', lead: '所有 AI 任务创建接口使用同一种异步开启方式:保留原接口和原请求 Body,只需增加 X-Async: true。' },
{ key: 'taskRetrieve', group: '异步任务', kind: 'tasks.retrieve', method: 'GET', path: '/api/v1/tasks/{taskID}', title: '取回任务', lead: '使用异步提交返回的 taskId 查询任务状态、结果、错误、用量、计费和执行尝试;queued、running、submitting 为进行中状态。' },
{ key: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' },
{ key: 'files', group: '文件', method: 'POST', path: '/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
{ key: 'files', group: '文件', method: 'POST', path: '/api/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
];
const guideItems: ApiGuideItem[] = [
@@ -73,8 +73,8 @@ const defaultOpsSkillMetadata: GatewaySkillBundleMetadata = {
modules: ['model-runtime'],
fileName: 'ai-gateway-ops-management.zip',
downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download',
apiDocsJsonPath: '/api-docs-json',
apiDocsYamlPath: '/api-docs-yaml',
apiDocsJsonPath: '/api/v1/openapi.json',
apiDocsYamlPath: '/api/v1/openapi.yaml',
};
export function ApiDocsPage(props: {
@@ -545,8 +545,8 @@ function GuideDetails(props: { onCreateApiKey: () => void; section: ApiGuideSect
return (
<>
<GuideSection title="1. 确认 Base URL">
<p>Base URL Gateway Base URL </p>
<pre>{`export EASYAI_BASE_URL="https://your-gateway.example.com"\ncurl "$EASYAI_BASE_URL/healthz"`}</pre>
<p> API Base URL <code>/api/v1</code> SDK Base URL <code>/api/v1</code></p>
<pre>{`export EASYAI_BASE_URL="https://your-gateway.example.com/api/v1"\ncurl "$EASYAI_BASE_URL/healthz"`}</pre>
</GuideSection>
<GuideSection title="2. 创建并使用 API Key">
<p> API Key Key chatembeddingrerankimage video </p>
+1 -1
View File
@@ -1 +1 @@
2d6c16fec0bec9c0288e5cb142b458af982fff8f
d95cecd0ebac48bda7ff0a2dcf453bb02b85ca7e
+19
View File
@@ -0,0 +1,19 @@
# Include inside the TLS server block for ai.51easyai.com.
# The application owns routing below /api/v1; the edge proxy preserves the URI.
location = /api/v1/metrics {
return 404;
}
location ^~ /api/v1/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:8088;
}
+2 -2
View File
@@ -87,7 +87,7 @@ services:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8088/readyz | grep -q '\"ok\":true'"]
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8088/api/v1/readyz | grep -q '\"ok\":true'"]
interval: 10s
timeout: 5s
retries: 20
@@ -117,7 +117,7 @@ services:
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/gateway-api/healthz | grep -q 'easyai-ai-gateway'"]
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/v1/healthz | grep -q 'easyai-ai-gateway'"]
interval: 10s
timeout: 5s
retries: 20
+33
View File
@@ -33,6 +33,10 @@ server {
return 404;
}
location = /api/v1/metrics {
return 404;
}
location = /gateway-api/api/v1/auth/login {
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -47,6 +51,35 @@ server {
proxy_pass http://api:8088/api/v1/auth/login;
}
location = /api/v1/auth/login {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 3s;
proxy_read_timeout 15s;
proxy_send_timeout 15s;
proxy_redirect off;
proxy_pass http://api:8088;
}
# Canonical public API. Keep the request URI so /api/v1 reaches the
# versioned handlers without another prefix rewrite.
location ^~ /api/v1/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://api:8088;
}
location /gateway-api/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
+4 -4
View File
@@ -91,7 +91,7 @@ flowchart LR
QUEUE --> PG
QUEUE --> CALLBACK -->|POST task progress callback to server-main| WSGW
QUEUE -->|settlement event| BILL
API -->|POST /v1/files/upload| FILES
API -->|POST /api/v1/files/upload| FILES
```
## 4. Monorepo 方案
@@ -1785,9 +1785,9 @@ SimulationClient 根据 `simulation_profile` 生成确定性行为:
- `/chat/completions`
- `/images/generations`
- `/video/generations`
- `/v1/chat/completions`
- `/v1/images/generations`
- `/v1/video/generations`
- `/api/v1/chat/completions`
- `/api/v1/images/generations`
- `/api/v1/video/generations`
内部 `OpenaiService` 变成薄门面:
+2 -2
View File
@@ -5,11 +5,11 @@
生产环境统一配置:
```text
baseURL = https://ai.51easyai.com/gateway-api/kling
baseURL = https://ai.51easyai.com/api/v1/kling
Authorization = Bearer <EasyAI Gateway API Key>
```
本地环境使用 `baseURL = http://localhost:8088/kling`
本地环境使用 `baseURL = http://localhost:8088/api/v1/kling``/gateway-api/kling``/kling` 路径仅作为兼容别名保留。
## V1AK/SK 旧版协议兼容)
+12 -6
View File
@@ -2,6 +2,12 @@
EasyAI AI Gateway 提供 Kling 旧版 Omni 协议兼容接口。调用方继续使用 Gateway API Key,任务仍经过网关候选选择、异步队列、审计和计费;响应中的 `task_id` 是网关任务 UUID,不是上游任务 ID。
```bash
export GATEWAY_ORIGIN="https://ai.51easyai.com"
export GATEWAY_PUBLIC_API_BASE="$GATEWAY_ORIGIN/api/v1"
export GATEWAY_API_KEY="<EasyAI Gateway API Key>"
```
## 模型与参数映射
| 请求 `model_name` | 网关模型别名 | TranStreams 原生 `model_name` | 时长范围 |
@@ -21,10 +27,10 @@ EasyAI AI Gateway 提供 Kling 旧版 Omni 协议兼容接口。调用方继续
## 创建任务
`POST /v1/videos/omni-video` 固定异步受理,不需要 `X-Async`,成功返回 HTTP 200。
`POST /api/v1/videos/omni-video` 固定异步受理,不需要 `X-Async`,成功返回 HTTP 200。
```bash
curl -sS -X POST "$GATEWAY_BASE_URL/v1/videos/omni-video" \
curl -sS -X POST "$GATEWAY_ORIGIN/api/v1/videos/omni-video" \
-H "Authorization: Bearer $GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
@@ -61,7 +67,7 @@ curl -sS -X POST "$GATEWAY_BASE_URL/v1/videos/omni-video" \
```bash
curl -sS \
-H "Authorization: Bearer $GATEWAY_API_KEY" \
"$GATEWAY_BASE_URL/v1/videos/omni-video/$TASK_ID"
"$GATEWAY_ORIGIN/api/v1/videos/omni-video/$TASK_ID"
```
`task_status``submitted``processing``succeed``failed`。成功时结果位于 `data.task_result.videos`
@@ -93,7 +99,7 @@ curl -sS \
标准接口仍为 `POST /api/v1/videos/generations`。异步调用需要 `X-Async: true`,再通过 `GET /api/v1/tasks/{taskId}` 轮询。
```bash
curl -sS -X POST "$GATEWAY_BASE_URL/api/v1/videos/generations" \
curl -sS -X POST "$GATEWAY_PUBLIC_API_BASE/videos/generations" \
-H "Authorization: Bearer $GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Async: true" \
@@ -112,7 +118,7 @@ curl -sS -X POST "$GATEWAY_BASE_URL/api/v1/videos/generations" \
```bash
curl -sS \
-H "Authorization: Bearer $GATEWAY_API_KEY" \
"$GATEWAY_BASE_URL/api/v1/tasks/$TASK_ID"
"$GATEWAY_PUBLIC_API_BASE/tasks/$TASK_ID"
```
## 错误格式
@@ -129,4 +135,4 @@ curl -sS \
业务码分类:`1001/1002` 为鉴权错误,`1101/1103` 为余额或权限错误,`1201/1203` 为参数或资源错误,`1302/1303` 为限流错误,`5000/5001` 为网关或上游服务错误。HTTP 状态码仍反映错误类型。
OpenAPI 文档由服务的 `/openapi.json``/openapi.yaml` 提供。
OpenAPI 文档由服务的 `/api/v1/openapi.json``/api/v1/openapi.yaml` 提供。
+137
View File
@@ -0,0 +1,137 @@
# EasyAI Gateway 公开 API V1 清单
生产公开 API 的统一 Base URL
```text
https://ai.51easyai.com/api/v1
```
下表路径均以 `/api/v1` 开头。调用方使用 `Authorization: Bearer <API Key>`;标记为“公开”的接口不要求用户登录,OIDC 与 SSF 接口按各自协议鉴权。
`/gateway-api``/v1`、无版本路径、`/kling``/v1beta``/upload``/api/v3` 入口仅作为兼容别名保留,不再用于新接入文档。
## 运行状态与接口发现
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/v1/healthz` | 存活检查 |
| GET | `/api/v1/readyz` | 就绪检查 |
| GET | `/api/v1/openapi.json` | OpenAPI JSON |
| GET | `/api/v1/openapi.yaml` | OpenAPI YAML |
| GET | `/api/v1/public/identity` | 公开身份配置 |
| GET | `/api/v1/public/client-customization` | 公开客户端配置 |
| GET | `/api/v1/public/catalog/providers` | 公开供应商目录 |
| GET | `/api/v1/public/catalog/base-models` | 公开基础模型目录 |
| GET | `/api/v1/public/skills/ai-gateway-ops-management/metadata` | 运维 Skill 元数据 |
| GET | `/api/v1/public/skills/ai-gateway-ops-management/download` | 下载运维 Skill |
## 账号、授权与 API Key
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/v1/auth/register` | 注册本地账号 |
| POST | `/api/v1/auth/login` | 本地账号登录 |
| GET | `/api/v1/auth/oidc/login` | 发起 OIDC 登录 |
| GET | `/api/v1/auth/oidc/callback` | OIDC 回调 |
| POST | `/api/v1/auth/oidc/logout` | OIDC 登出 |
| DELETE | `/api/v1/auth/oidc/session` | 删除浏览器会话 |
| GET | `/api/v1/me` | 当前用户 |
| GET, POST | `/api/v1/api-keys` | 查询、创建 API Key |
| GET | `/api/v1/api-keys/access-rules` | 查询 Key 访问规则 |
| POST | `/api/v1/api-keys/access-rules/batch` | 批量设置 Key 访问规则 |
| GET | `/api/v1/api-keys/assignable-models` | 查询可分配模型 |
| PATCH | `/api/v1/api-keys/{apiKeyID}/scopes` | 更新 Key 权限范围 |
| PATCH | `/api/v1/api-keys/{apiKeyID}/disable` | 禁用 Key |
| DELETE | `/api/v1/api-keys/{apiKeyID}` | 删除 Key |
## 模型、平台与计费查询
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/v1/model-catalog` | 模型能力目录 |
| GET | `/api/v1/platforms` | 当前用户可用平台 |
| GET | `/api/v1/models` | 当前用户可用模型 |
| GET | `/api/v1/playground/models` | Playground 可用模型 |
| POST | `/api/v1/pricing/estimate` | 请求价格预估 |
## 通用与 OpenAI 兼容生成接口
这些接口默认同步返回兼容响应;需要异步执行时增加 `X-Async: true`,并使用任务接口取回结果。
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/v1/chat/completions` | Chat Completions,支持 SSE |
| POST | `/api/v1/responses` | Responses,支持 SSE |
| POST | `/api/v1/embeddings` | 文本向量 |
| POST | `/api/v1/reranks` | 文本重排序 |
| POST | `/api/v1/images/generations` | 文生图 |
| POST | `/api/v1/images/edits` | 图片编辑 |
| POST | `/api/v1/videos/generations` | 文生视频、图生视频及多模态视频 |
| POST | `/api/v1/song/generations` | 歌曲生成 |
| POST | `/api/v1/music/generations` | 音乐生成 |
| POST | `/api/v1/speech/generations` | 语音生成 |
| POST | `/api/v1/voice_clone` | 声音克隆 |
| GET | `/api/v1/voice_clone/voices` | 查询克隆声音 |
| DELETE | `/api/v1/voice_clone/voices/{voiceID}` | 删除克隆声音 |
| POST | `/api/v1/files/upload` | 上传生成任务输入文件 |
## 异步任务
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/v1/tasks` | 查询任务列表 |
| GET | `/api/v1/tasks/{taskID}` | 查询任务详情和结果 |
| POST | `/api/v1/tasks/{taskID}/cancel` | 取消任务 |
| GET | `/api/v1/tasks/{taskID}/events` | 查询任务事件 |
| GET | `/api/v1/tasks/{taskID}/param-preprocessing` | 查询参数预处理记录 |
## Gemini 兼容接口
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/v1/models/{model}:generateContent` | Gemini generateContent |
| POST | `/api/v1/gemini/upload/{version}/files` | Gemini Files 启动或直接上传,`version``v1``v1beta` |
| POST | `/api/v1/gemini/upload/{version}/files/{uploadID}` | 完成 Gemini 分段上传 |
## 可灵兼容接口
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/v1/videos/omni-video` | 可灵官方 V1 Omni 创建任务 |
| GET | `/api/v1/videos/omni-video/{taskID}` | 可灵官方 V1 Omni 查询任务 |
| POST | `/api/v1/kling/v1/videos/omni-video` | 网关可灵 V1 创建任务 |
| GET | `/api/v1/kling/v1/videos/omni-video` | 网关可灵 V1 任务列表 |
| GET | `/api/v1/kling/v1/videos/omni-video/{taskID}` | 网关可灵 V1 查询任务 |
| POST | `/api/v1/kling/v2/omni-video/{model}` | 网关可灵 API 2.0 创建任务 |
| GET | `/api/v1/kling/v2/tasks` | 网关可灵 API 2.0 查询任务 |
| POST | `/api/v1/kling/v2/tasks` | 网关可灵 API 2.0 任务列表 |
可灵客户端可使用 `https://ai.51easyai.com/api/v1/kling` 作为 Base URL,然后继续请求 `/v1/...``/v2/...`
## 火山兼容与真人资产
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/v1/contents/generations/tasks` | 创建火山内容生成任务 |
| GET | `/api/v1/contents/generations/tasks` | 查询火山内容生成任务列表 |
| GET | `/api/v1/contents/generations/tasks/{taskID}` | 查询火山内容生成任务 |
| DELETE | `/api/v1/contents/generations/tasks/{taskID}` | 删除或取消火山内容生成任务 |
| POST | `/api/v1/video/generations` | server-main 兼容视频创建接口 |
| GET | `/api/v1/ai/result/{taskID}` | server-main 兼容结果查询接口 |
| GET | `/api/v1/resource/material/seedance-portrait-assets/capability` | 真人资产能力 |
| GET | `/api/v1/resource/material/user/materials` | 查询用户真人资产 |
| POST | `/api/v1/resource/material` | 上传真人资产 |
| POST | `/api/v1/resource/material/seedance-portrait-assets/sync` | 同步真人资产到平台 |
## 安全集成
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/v1/security-events/ssf` | RFC 8935 Security Event 接收端点 |
## 不属于公开 API 的路径
- `/api/admin/...`:管理后台接口。
- `/api/workspace/...``/api/playground/...`Web/BFF 内部接口。
- `/metrics`:仅监控网络可访问。
- `/static/...`:生成结果和上传文件的资源 URL,不是 API Base URL。
+4 -2
View File
@@ -126,9 +126,11 @@ dispatcher 以完整 Git SHA 发布 Registry Tag,并把 Registry 返回的 dig
## 发布后验证
宿主 Nginx 的 `ai.51easyai.com` TLS server 必须包含仓库中的 `deploy/nginx/ai.51easyai.com-api-v1.inc` 等价规则,保留完整 URI 转发到 `127.0.0.1:8088`。修改前先备份现有配置,执行 `nginx -t` 成功后才能 reload。
```bash
curl -fsS https://ai.51easyai.com/gateway-api/healthz
curl -fsS https://ai.51easyai.com/gateway-api/readyz
curl -fsS https://ai.51easyai.com/api/v1/healthz
curl -fsS https://ai.51easyai.com/api/v1/readyz
ssh root@110.42.51.33 'cd /root/easyai-ai-gateway-deploy && ./gateway-ops.sh ps'
```
+6 -4
View File
@@ -33,10 +33,12 @@
## 火山任务兼容路由
- `POST /api/v3/contents/generations/tasks`
- `GET /api/v3/contents/generations/tasks`
- `GET /api/v3/contents/generations/tasks/{taskID}`
- `DELETE /api/v3/contents/generations/tasks/{taskID}`
- `POST /api/v1/contents/generations/tasks`
- `GET /api/v1/contents/generations/tasks`
- `GET /api/v1/contents/generations/tasks/{taskID}`
- `DELETE /api/v1/contents/generations/tasks/{taskID}`
`/api/v3/contents/generations/tasks` 路径仅作为火山客户端兼容别名保留。
列表接口兼容火山的 `page_num``page_size``filter.status``filter.task_ids`(可重复)和 `filter.model`,并返回官方 `items``total` 字段;`data``page` 是保留的网关附加字段。
+1 -1
View File
@@ -36,7 +36,7 @@ Content-Type: application/json
### 1.2 文件上传
```http
POST /v1/files/upload
POST /api/v1/files/upload
Authorization: Bearer ${USER_JWT_OR_SK}
Content-Type: multipart/form-data
+2 -2
View File
@@ -53,7 +53,7 @@
| ID | 任务 | 接口 / 方式 | 成功判定 | 状态 | 结果记录 |
| --- | --- | --- | --- | --- | --- |
| SETUP-01 | 确认服务可用 | `GET /healthz``GET /readyz` | `healthz.ok=true``readyz.ok=true` | 未执行 | 待填写 |
| SETUP-01 | 确认服务可用 | `GET /api/v1/healthz``GET /api/v1/readyz` | `healthz.ok=true``readyz.ok=true` | 未执行 | 待填写 |
| SETUP-02 | 准备管理员权限 | 本地注册 / 登录,必要时将测试用户提升为 `admin``manager` | `GET /api/v1/me` 返回 `role` 具备 `manager` 权限 | 未执行 | 待填写 |
| SETUP-03 | 记录用户提供的真实平台、模型和 KEY | `GET /api/v1/platforms``GET /api/v1/models` | Chat 模型、`doubao-4.5图像编辑``豆包Seedance-1.5-pro` 均已启用,并能被管理员看到 | 未执行 | 待填写 |
| SETUP-04 | 创建内部测试用户组 | `POST /api/v1/user-groups` | 创建 `loopback-allow-group``loopback-deny-group``loopback-limit-group` | 未执行 | 待填写 |
@@ -91,7 +91,7 @@
| ID | 能力 | 请求 | 成功判定 | 状态 | 结果记录 |
| --- | --- | --- | --- | --- | --- |
| TASK-CHAT-01 | Chat 成功 | `POST /api/v1/chat/completions`,真实 Chat 模型 | `task.status=succeeded``result.object=chat.completion``choices[0].message.content` 非空 | 未执行 | taskId、requestId、content 摘要、charge 待填写 |
| TASK-CHAT-02 | Chat 兼容路由成功 | `POST /v1/chat/completions`,真实 Chat 模型 | HTTP 200,返回 `object=chat.completion`,内容非空 | 未执行 | requestId、content 摘要待填写 |
| TASK-CHAT-02 | Chat 同步兼容响应成功 | `POST /api/v1/chat/completions`,真实 Chat 模型 | HTTP 200,返回 `object=chat.completion`,内容非空 | 未执行 | requestId、content 摘要待填写 |
| TASK-IMAGE-01 | 文生图成功 | `POST /api/v1/images/generations`,模型 `doubao-4.5图像编辑` 或用户补充的文生图模型 | `task.status=succeeded``result.id` 非空,`data[0].url` 或文件 URL 可访问 | 未执行 | taskId、image URL、charge 待填写 |
| TASK-IMAGE-02 | 图生图成功 | `POST /api/v1/images/edits`,模型 `doubao-4.5图像编辑`,传入测试源图和 mask | `task.status=succeeded``result.id` 非空,`data[0].url` 或文件 URL 可访问 | 未执行 | taskId、source URL、mask URL、image URL、charge 待填写 |
| TASK-VIDEO-01 | 文生视频成功 | `POST /api/v1/videos/generations`,模型 `豆包Seedance-1.5-pro`,仅传 prompt | `task.status=succeeded`,返回可下载或可播放的视频结果,任务事件完整 | 未执行 | taskId、video URL、duration、charge 待填写 |
+8 -7
View File
@@ -6,17 +6,18 @@ settings:
overrides:
'@babel/core@<=7.29.0': 7.29.6
'@nx/js>picomatch': 4.0.5
'@nx/vite>picomatch': 4.0.5
'@nx/workspace>picomatch': 4.0.5
axios@<1.18.0: 1.18.0
brace-expansion@>=2.0.0 <2.1.2: 2.1.2
dompurify@<=3.4.10: 3.4.11
esbuild@>=0.27.3 <0.28.1: 0.28.1
fast-uri@>=3.0.0 <3.1.4: 3.1.4
form-data@>=4.0.0 <4.0.6: 4.0.6
js-yaml@<4.3.0: 4.3.0
mermaid@>=11.0.0-alpha.1 <=11.14.0: 11.15.0
minimatch@>=9.0.0 <9.0.7: 9.0.7
'@nx/js>picomatch': 4.0.5
'@nx/vite>picomatch': 4.0.5
'@nx/workspace>picomatch': 4.0.5
tmp@<0.2.7: 0.2.7
importers:
@@ -3034,8 +3035,8 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fast-uri@3.1.4:
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
@@ -7181,7 +7182,7 @@ snapshots:
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.2
fast-uri: 3.1.4
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -7850,7 +7851,7 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-uri@3.1.2: {}
fast-uri@3.1.4: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
+1
View File
@@ -12,6 +12,7 @@ overrides:
'brace-expansion@>=2.0.0 <2.1.2': 2.1.2
'dompurify@<=3.4.10': 3.4.11
'esbuild@>=0.27.3 <0.28.1': 0.28.1
'fast-uri@>=3.0.0 <3.1.4': 3.1.4
'form-data@>=4.0.0 <4.0.6': 4.0.6
'js-yaml@<4.3.0': 4.3.0
'mermaid@>=11.0.0-alpha.1 <=11.14.0': 11.15.0
+4 -4
View File
@@ -248,14 +248,14 @@ deploy() {
api_port="$(published_port api 8088 "${AI_GATEWAY_API_PORT:-8088}")"
web_port="$(published_port web 80 "${AI_GATEWAY_WEB_PORT:-5178}")"
wait_for_http "api health" "http://127.0.0.1:${api_port}/healthz" "easyai-ai-gateway"
wait_for_http "api readiness" "http://127.0.0.1:${api_port}/readyz" '"ok":true'
wait_for_http "web reverse proxy" "http://127.0.0.1:${web_port}/gateway-api/healthz" "easyai-ai-gateway"
wait_for_http "api health" "http://127.0.0.1:${api_port}/api/v1/healthz" "easyai-ai-gateway"
wait_for_http "api readiness" "http://127.0.0.1:${api_port}/api/v1/readyz" '"ok":true'
wait_for_http "web reverse proxy" "http://127.0.0.1:${web_port}/api/v1/healthz" "easyai-ai-gateway"
wait_for_http "web app" "http://127.0.0.1:${web_port}/" "EasyAI AI Gateway"
echo "[ai-gateway] deployment succeeded"
echo "[ai-gateway] Web: http://127.0.0.1:${web_port}"
echo "[ai-gateway] API: http://127.0.0.1:${api_port}/healthz"
echo "[ai-gateway] API: http://127.0.0.1:${api_port}/api/v1/healthz"
}
case "$ACTION" in