Compare commits

..
Author SHA1 Message Date
easyai 3056cf8fca feat(gateway): 补齐桌面端高级媒体直连接口
ci / verify (pull_request) Successful in 15m34s
新增图片矢量化、视频超分、每日用量、计价与任务隔离能力,并通过环境变量解析平台凭据。

已通过 Go 全量门禁、迁移检查、镜像构建以及 Vectorizer 五格式和 Topaz 3 秒视频真实 DEV 验收。
2026-07-22 14:02:53 +08:00
easyai cbebfd7baa chore(ci): 推进生产迁移基线至 v0.4.4 (#23)
ci / verify (push) Successful in 11m18s
生产发布、健康检查与隔离恢复演练均已通过;仅推进生产迁移基线。
2026-07-22 11:19:59 +08:00
easyai ee0256adb4 chore(ci): 推进生产迁移基线至 v0.4.4
ci / verify (pull_request) Successful in 14m30s
2026-07-22 11:04:42 +08:00
easyai fce76a30ba fix(keling): 统一 Omni 模型别名与生产绑定 (#22)
ci / verify (push) Successful in 12m40s
release-ci / verify-tag (push) Successful in 14m2s
线上真实任务验证发现官方兼容接口的模型别名与生产绑定不一致;统一使用原生模型名匹配候选,同时保留通用接口旧别名兼容。
2026-07-22 10:23:18 +08:00
easyai d37fb3ea60 fix(keling): 统一 Omni 模型别名与生产绑定
ci / verify (pull_request) Successful in 21m44s
2026-07-22 10:00:30 +08:00
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
76 changed files with 5741 additions and 5229 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
FROM ${API_RUNTIME_IMAGE} AS api FROM ${API_RUNTIME_IMAGE} AS api
RUN apk add --no-cache ca-certificates tzdata wget && \ RUN apk add --no-cache ca-certificates tzdata wget ffmpeg && \
adduser -D -H -u 10001 appuser adduser -D -H -u 10001 appuser
WORKDIR /app WORKDIR /app
+5 -3
View File
@@ -70,8 +70,10 @@ scripts/deploy-compose.sh
部署成功后默认访问地址: 部署成功后默认访问地址:
- Web: `http://127.0.0.1:5178` - Web: `http://127.0.0.1:5178`
- API: `http://127.0.0.1:8088/healthz` - API: `http://127.0.0.1:8088/api/v1/healthz`
- Web 反代 API: `http://127.0.0.1:5178/gateway-api/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 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 ```bash
docker compose -f docker-compose.yml restart web docker compose -f docker-compose.yml restart web
+1577 -3036
View File
File diff suppressed because it is too large Load Diff
+1034 -1996
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,282 @@
package clients
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestVectorizerClientMultipartAndPrivateResumeState(t *testing.T) {
var receivedPath string
var receivedFields map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
if username, password, ok := r.BasicAuth(); !ok || username != "id" || password != "secret" {
t.Fatalf("unexpected vectorizer auth")
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("parse multipart: %v", err)
}
receivedFields = map[string]string{}
for key, values := range r.MultipartForm.Value {
receivedFields[key] = values[0]
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("X-Image-Token", "private-image-token")
w.Header().Set("X-Receipt", "private-receipt")
_, _ = io.WriteString(w, `<svg xmlns="http://www.w3.org/2000/svg"/>`)
}))
defer server.Close()
privateState := map[string]any{}
response, err := (VectorizerClient{LookupIP: func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
}}).Run(context.Background(), Request{
Kind: "images.vectorize", Model: "easy-image-vectorizer-1",
Body: map[string]any{"source": map[string]any{"url": "https://example.com/input.png"}, "format": "svg", "maxColors": 16, "cleanupLevel": "strong"},
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"accessKey": "id", "secretKey": "secret"}),
OnRemoteTaskSubmitted: func(_ string, payload map[string]any) error { privateState = payload; return nil },
})
if err != nil {
t.Fatalf("run vectorizer: %v", err)
}
if receivedPath != "/vectorize" || receivedFields["image.url"] == "" || receivedFields["output.file_format"] != "svg" || receivedFields["processing.max_colors"] != "16" {
t.Fatalf("unexpected multipart request: path=%s fields=%+v", receivedPath, receivedFields)
}
if privateState["imageToken"] != "private-image-token" || privateState["receipt"] != "private-receipt" {
t.Fatalf("private resume state missing: %+v", privateState)
}
serialized := strings.ToLower(toJSONForTest(response.Result))
if strings.Contains(serialized, "private-image-token") || strings.Contains(serialized, "private-receipt") {
t.Fatalf("private vectorizer state leaked into response: %s", serialized)
}
}
func TestVectorizerClientRejectsPrivateSourceBeforeProviderCall(t *testing.T) {
providerCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { providerCalls++ }))
defer server.Close()
_, err := (VectorizerClient{LookupIP: func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
}}).Run(context.Background(), Request{
Body: map[string]any{"source": map[string]any{"url": "https://assets.example.test/input.png"}},
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"apiKey": "key"}),
})
if err == nil || !strings.Contains(err.Error(), "blocked network") {
t.Fatalf("expected blocked source error, got %v", err)
}
if providerCalls != 0 {
t.Fatalf("provider received %d calls for blocked source", providerCalls)
}
}
func TestVectorizerClientReusesImageTokenForExtraFormat(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/download" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatal(err)
}
if r.FormValue("image.token") != "token" || r.FormValue("receipt") != "receipt" || r.FormValue("output.file_format") != "pdf" {
t.Fatalf("unexpected reuse form: %+v", r.MultipartForm.Value)
}
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("%PDF-1.7\n"))
}))
defer server.Close()
response, err := (VectorizerClient{}).Run(context.Background(), Request{
Body: map[string]any{"_vectorizer_image_token": "token", "_vectorizer_receipt": "receipt", "format": "pdf"},
Candidate: storeCandidate(server.URL, "vectorizer", map[string]any{"apiKey": "key"}),
})
if err != nil {
t.Fatal(err)
}
item := response.Result["data"].([]any)[0].(map[string]any)
if item["type"] != "file" || item["mime_type"] != "application/pdf" {
t.Fatalf("unexpected pdf result: %+v", item)
}
}
func TestTopazClientUploadsPartsPollsAndValidatesOutput(t *testing.T) {
var mu sync.Mutex
called := map[string]int{}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
called[r.Method+" "+r.URL.Path]++
mu.Unlock()
switch {
case r.Method == http.MethodGet && r.URL.Path == "/source.mp4":
_, _ = w.Write([]byte("small-video-source"))
case r.Method == http.MethodPost && r.URL.Path == "/video/":
if r.Header.Get("X-API-Key") != "topaz-key" {
t.Fatalf("missing Topaz API key")
}
_, _ = io.WriteString(w, `{"requestId":"job-1"}`)
case r.Method == http.MethodPatch && r.URL.Path == "/video/job-1/accept":
_, _ = io.WriteString(w, `{"urls":["`+server.URL+`/upload/1","`+server.URL+`/upload/2"]}`)
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/upload/"):
w.Header().Set("ETag", `"etag-`+strings.TrimPrefix(r.URL.Path, "/upload/")+`"`)
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPatch && r.URL.Path == "/video/job-1/complete-upload/":
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{}`)
case r.Method == http.MethodGet && r.URL.Path == "/video/job-1/status":
_, _ = io.WriteString(w, `{"status":"completed","download":{"url":"`+server.URL+`/output.mp4"}}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
phases := []string{}
client := TopazClient{Probe: func(_ context.Context, target string) (TopazVideoMetadata, error) {
if strings.HasPrefix(target, "http") {
return TopazVideoMetadata{Width: 640, Height: 360, Duration: 3, FrameRate: 24, HasAudio: true}, nil
}
return TopazVideoMetadata{Width: 320, Height: 180, Duration: 3, FrameRate: 24, FrameCount: 72, HasAudio: true}, nil
}}
response, err := client.Run(context.Background(), Request{
Model: "easy-proteus-standard-4",
Body: map[string]any{"video_url": server.URL + "/source.mp4", "output_width": 640, "output_height": 360, "preserve_audio": true},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{"allowPrivateSourceDownloads": true, "pollIntervalMs": 1, "pollTimeoutMs": 100}),
OnRemoteTaskSubmitted: func(_ string, payload map[string]any) error {
phases = append(phases, payload["phase"].(string))
return nil
},
OnRemoteTaskPolled: func(_ string, payload map[string]any) error {
phases = append(phases, payload["phase"].(string))
return nil
},
})
if err != nil {
t.Fatalf("run Topaz: %v", err)
}
if response.RequestID != "job-1" || !containsStringForTest(phases, "created") || !containsStringForTest(phases, "uploaded") {
t.Fatalf("unexpected Topaz response/phases: response=%+v phases=%+v", response, phases)
}
if called["PUT /upload/1"] != 1 || called["PUT /upload/2"] != 1 || called["PATCH /video/job-1/complete-upload/"] != 1 {
t.Fatalf("upload lifecycle incomplete: %+v", called)
}
}
func TestTopazClientResumesUploadedTaskWithoutDownloadingSource(t *testing.T) {
var sourceCalls int
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/source.mp4" {
sourceCalls++
}
if r.URL.Path == "/video/job-resume/status" {
_, _ = io.WriteString(w, `{"status":"completed","download":{"url":"`+server.URL+`/result.mp4"}}`)
return
}
http.NotFound(w, r)
}))
defer server.Close()
_, err := (TopazClient{Probe: func(context.Context, string) (TopazVideoMetadata, error) {
return TopazVideoMetadata{Width: 640, Height: 360, Duration: 3, FrameRate: 24}, nil
}}).Run(context.Background(), Request{
RemoteTaskID: "job-resume", RemoteTaskPayload: map[string]any{"phase": "uploaded", "targetResolution": map[string]any{"width": 640, "height": 360}},
Body: map[string]any{"video_url": server.URL + "/source.mp4"},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{"pollIntervalMs": 1, "pollTimeoutMs": 100}),
})
if err != nil {
t.Fatal(err)
}
if sourceCalls != 0 {
t.Fatalf("resume downloaded source %d times", sourceCalls)
}
}
func TestTopazClientReportsProviderFailureAndPollingTimeout(t *testing.T) {
tests := []struct {
name string
statusBody string
timeoutMS int
want string
}{
{name: "provider failure", statusBody: `{"status":"failed","message":"upstream rejected"}`, timeoutMS: 100, want: "upstream rejected"},
{name: "poll timeout", statusBody: `{"status":"processing"}`, timeoutMS: 3, want: "polling timed out"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/video/job/status" {
http.NotFound(w, r)
return
}
_, _ = io.WriteString(w, test.statusBody)
}))
defer server.Close()
_, err := (TopazClient{}).Run(context.Background(), Request{
RemoteTaskID: "job", RemoteTaskPayload: map[string]any{"phase": "uploaded", "targetResolution": map[string]any{"width": 640, "height": 360}},
Body: map[string]any{"video_url": "https://assets.example.test/source.mp4"},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{
"pollIntervalMs": 1, "pollTimeoutMs": test.timeoutMS,
}),
})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("expected %q error, got %v", test.want, err)
}
})
}
}
func TestTopazClientRejectsOversizedInputBeforeProbe(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/source.mp4" {
_, _ = io.WriteString(w, "too-large")
return
}
http.NotFound(w, r)
}))
defer server.Close()
probeCalls := 0
_, err := (TopazClient{Probe: func(context.Context, string) (TopazVideoMetadata, error) {
probeCalls++
return TopazVideoMetadata{}, nil
}}).Run(context.Background(), Request{
Body: map[string]any{"video_url": server.URL + "/source.mp4"},
Candidate: storeCandidateWithConfig(server.URL, "topaz", map[string]any{"apiKey": "topaz-key"}, map[string]any{
"allowPrivateSourceDownloads": true, "maxInputBytes": 3,
}),
})
if err == nil || !strings.Contains(err.Error(), "size limit") {
t.Fatalf("expected input size limit error, got %v", err)
}
if probeCalls != 0 {
t.Fatalf("oversized input was probed %d times", probeCalls)
}
}
func storeCandidate(baseURL, specType string, credentials map[string]any) store.RuntimeModelCandidate {
return storeCandidateWithConfig(baseURL, specType, credentials, nil)
}
func storeCandidateWithConfig(baseURL, specType string, credentials, config map[string]any) store.RuntimeModelCandidate {
return store.RuntimeModelCandidate{BaseURL: baseURL, SpecType: specType, Provider: specType, Credentials: credentials, PlatformConfig: config}
}
func toJSONForTest(value any) string {
raw, _ := json.Marshal(value)
return string(raw)
}
func containsStringForTest(values []string, expected string) bool {
for _, value := range values {
if value == expected {
return true
}
}
return false
}
+617
View File
@@ -0,0 +1,617 @@
package clients
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
topazDefaultPollInterval = 15 * time.Second
topazDefaultPollTimeout = 60 * time.Minute
topazDefaultMaxInput = int64(2 << 30)
)
type TopazClient struct {
HTTPClient *http.Client
Probe func(context.Context, string) (TopazVideoMetadata, error)
}
type TopazVideoMetadata struct {
Width int
Height int
Duration float64
FrameRate float64
FrameCount int
HasAudio bool
}
type topazSource struct {
Path string
Container string
Size int64
MD5 string
Resolution map[string]any
Duration float64
FrameRate float64
FrameCount int
}
func (c TopazClient) Run(ctx context.Context, request Request) (Response, error) {
startedAt := time.Now()
apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
if apiKey == "" {
return Response{}, &ClientError{Code: "missing_credentials", Message: "Topaz API key is required", Retryable: false}
}
requestID := strings.TrimSpace(request.RemoteTaskID)
payload := cloneMapAny(request.RemoteTaskPayload)
var source *topazSource
var target map[string]int
if requestID == "" || strings.TrimSpace(firstNonEmptyString(payload["phase"])) != "uploaded" {
videoURL := strings.TrimSpace(firstNonEmptyString(request.Body["video_url"], request.Body["videoUrl"]))
if videoURL == "" {
return Response{}, &ClientError{Code: "invalid_parameter", Message: "video_url is required", Param: "video_url", StatusCode: http.StatusBadRequest}
}
prepared, err := c.prepareSource(ctx, request, videoURL)
if err != nil {
return Response{}, err
}
source = &prepared
defer os.Remove(prepared.Path)
target = topazTargetResolution(request.Body, prepared.Resolution)
if requestID == "" {
created, err := c.createRequest(ctx, request, apiKey, prepared, target)
if err != nil {
return Response{}, annotateResponseError(err, "", startedAt, time.Now())
}
requestID = strings.TrimSpace(firstNonEmptyString(created["requestId"], created["request_id"], created["id"]))
if requestID == "" {
return Response{}, &ClientError{Code: "invalid_response", Message: "Topaz create response is missing requestId", Retryable: false}
}
payload = map[string]any{"phase": "created", "targetResolution": target}
if request.OnRemoteTaskSubmitted != nil {
if err := request.OnRemoteTaskSubmitted(requestID, payload); err != nil {
return Response{}, err
}
}
}
if err := c.uploadSource(ctx, request, apiKey, requestID, prepared); err != nil {
return Response{}, annotateResponseError(err, requestID, startedAt, time.Now())
}
payload = map[string]any{"phase": "uploaded", "targetResolution": target}
if request.OnRemoteTaskPolled != nil {
if err := request.OnRemoteTaskPolled(requestID, payload); err != nil {
return Response{}, err
}
}
}
if target == nil {
target = topazTargetFromPayload(payload, request.Body)
}
completed, err := c.poll(ctx, request, apiKey, requestID, target)
if err != nil {
return Response{}, annotateResponseError(err, requestID, startedAt, time.Now())
}
outputURL := topazDownloadURL(completed)
if outputURL == "" {
return Response{}, &ClientError{Code: "invalid_response", Message: "Topaz completed without a download URL", RequestID: requestID, Retryable: false}
}
metadata, err := c.probe(ctx, outputURL)
if err != nil {
return Response{}, &ClientError{Code: "invalid_response", Message: "cannot validate Topaz output metadata: " + err.Error(), RequestID: requestID, Retryable: true}
}
if target["width"] > 0 && target["height"] > 0 && (metadata.Width+4 < target["width"] || metadata.Height+4 < target["height"]) {
return Response{}, &ClientError{Code: "invalid_response", Message: fmt.Sprintf("Topaz output resolution %dx%d does not reach target %dx%d", metadata.Width, metadata.Height, target["width"], target["height"]), RequestID: requestID, Retryable: false}
}
finishedAt := time.Now()
resultItem := map[string]any{
"type": "video",
"url": outputURL,
"video_url": outputURL,
"width": metadata.Width,
"height": metadata.Height,
"target_resolution": fmt.Sprintf("%dx%d", target["width"], target["height"]),
"duration": metadata.Duration,
"target_frame_rate": metadata.FrameRate,
"slow_motion_rate": numericValue(request.Body["slow_motion_rate"], 1),
}
if source != nil {
resultItem["source_resolution"] = fmt.Sprintf("%vx%v", source.Resolution["width"], source.Resolution["height"])
resultItem["source_frame_rate"] = source.FrameRate
resultItem["duration"] = source.Duration
}
return Response{
Result: map[string]any{
"status": "success",
"model": request.Model,
"task_id": requestID,
"upstream_task_id": requestID,
"data": []any{resultItem},
},
RequestID: requestID,
Progress: append(providerProgress(request), Progress{Phase: "polling", Progress: 0.9, Message: "Topaz upscale completed", Payload: map[string]any{"upstreamTaskId": requestID}}),
ResponseStartedAt: startedAt,
ResponseFinishedAt: finishedAt,
ResponseDurationMS: responseDurationMS(startedAt, finishedAt),
}, nil
}
func (c TopazClient) prepareSource(ctx context.Context, request Request, rawURL string) (topazSource, error) {
parsed, err := url.Parse(rawURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video_url must be an http(s) URL", Param: "video_url", StatusCode: http.StatusBadRequest}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return topazSource{}, err
}
downloadClient := topazSourceHTTPClient(httpClient(request.HTTPClient, c.HTTPClient), boolishDefault(request.Candidate.PlatformConfig["allowPrivateSourceDownloads"], false))
resp, err := downloadClient.Do(req)
if err != nil {
return topazSource{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video_url download failed: " + resp.Status, Param: "video_url", StatusCode: http.StatusBadRequest}
}
limit := int64(numericValue(firstPresent(request.Candidate.PlatformConfig["maxInputBytes"], request.Candidate.PlatformConfig["max_input_bytes"]), float64(topazDefaultMaxInput)))
if limit <= 0 {
limit = topazDefaultMaxInput
}
tmp, err := os.CreateTemp("", "easyai-topaz-*"+topazContainerExtension(parsed.Path))
if err != nil {
return topazSource{}, err
}
path := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(path)
}
hash := md5.New()
written, err := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(resp.Body, limit+1))
closeErr := tmp.Close()
if err != nil || closeErr != nil || written <= 0 || written > limit {
cleanup()
if written > limit {
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "video source exceeds configured size limit", Param: "video_url", StatusCode: http.StatusBadRequest}
}
return topazSource{}, firstError(err, closeErr)
}
metadata, err := c.probe(ctx, path)
if err != nil || metadata.Width <= 0 || metadata.Height <= 0 {
cleanup()
return topazSource{}, &ClientError{Code: "invalid_parameter", Message: "cannot probe source video metadata", Param: "video_url", StatusCode: http.StatusBadRequest}
}
frameRate := metadata.FrameRate
if frameRate <= 0 {
frameRate = numericValue(request.Candidate.PlatformConfig["frameRate"], 30)
}
duration := math.Max(1, metadata.Duration)
frameCount := metadata.FrameCount
if frameCount <= 0 {
frameCount = int(math.Round(duration * frameRate))
}
return topazSource{
Path: path, Container: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."), Size: written,
MD5: hex.EncodeToString(hash.Sum(nil)), Resolution: map[string]any{"width": metadata.Width, "height": metadata.Height},
Duration: duration, FrameRate: frameRate, FrameCount: frameCount,
}, nil
}
func (c TopazClient) createRequest(ctx context.Context, request Request, apiKey string, source topazSource, target map[string]int) (map[string]any, error) {
model := topazModelName(firstNonEmptyString(request.Candidate.ProviderModelName, request.Model))
scale := math.Max(float64(target["width"])/numericValue(source.Resolution["width"], 1), float64(target["height"])/numericValue(source.Resolution["height"], 1))
filter := map[string]any{"model": model}
if scale > 1 {
key := "scale"
if model == "slf-2" || model == "slhq-1" || model == "slm-1" || model == "slp-2.5" {
key = "upscaling_factor"
}
filter[key] = math.Round(scale*1000) / 1000
}
preserveAudio := boolishDefault(request.Body["preserve_audio"], true)
filters := []any{filter}
targetFrameRate := numericValue(firstPresent(request.Body["target_frame_rate"], request.Body["output_frame_rate"]), source.FrameRate)
slowMotionRate := numericValue(request.Body["slow_motion_rate"], 1)
if targetFrameRate <= 0 {
targetFrameRate = source.FrameRate
}
if slowMotionRate <= 0 {
slowMotionRate = 1
}
if targetFrameRate != source.FrameRate || slowMotionRate > 1 {
interpolationModel := strings.TrimSpace(firstNonEmptyString(request.Body["frame_interpolation_model"]))
if interpolationModel == "" {
return nil, &ClientError{Code: "invalid_parameter", Message: "frame_interpolation_model is required when output frame rate or slow motion changes", Param: "frame_interpolation_model", StatusCode: http.StatusBadRequest}
}
interpolation := map[string]any{"model": topazModelName(interpolationModel)}
if targetFrameRate > 0 {
interpolation["fps"] = targetFrameRate
}
if slowMotionRate > 1 {
interpolation["slowmo"] = slowMotionRate
}
filters = append(filters, interpolation)
}
body := map[string]any{
"source": map[string]any{"container": source.Container, "size": source.Size, "duration": source.Duration, "frameCount": source.FrameCount, "frameRate": source.FrameRate, "resolution": source.Resolution},
"filters": filters,
"output": map[string]any{"resolution": target, "frameRate": targetFrameRate, "audioCodec": "AAC", "audioTransfer": map[bool]string{true: "Copy", false: "None"}[preserveAudio], "dynamicCompressionLevel": "High", "videoEncoder": "H265", "videoProfile": "Main", "container": "mp4"},
}
return c.topazJSON(ctx, request, apiKey, http.MethodPost, "/video/", body)
}
func (c TopazClient) uploadSource(ctx context.Context, request Request, apiKey, requestID string, source topazSource) error {
accepted, err := c.topazJSON(ctx, request, apiKey, http.MethodPatch, "/video/"+url.PathEscape(requestID)+"/accept", nil)
if err != nil {
return err
}
urls := stringList(accepted["urls"])
if len(urls) == 0 {
return &ClientError{Code: "invalid_response", Message: "Topaz accept response has no upload URLs", RequestID: requestID}
}
file, err := os.Open(source.Path)
if err != nil {
return err
}
defer file.Close()
chunkSize := (source.Size + int64(len(urls)) - 1) / int64(len(urls))
results := make([]any, 0, len(urls))
for index, uploadURL := range urls {
remaining := source.Size - int64(index)*chunkSize
if remaining <= 0 {
break
}
length := minInt64(chunkSize, remaining)
section := io.NewSectionReader(file, int64(index)*chunkSize, length)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, section)
if err != nil {
return err
}
req.ContentLength = length
req.Header.Set("Content-Type", topazContainerContentType(source.Container))
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &ClientError{Code: "provider_failed", Message: "Topaz part upload failed: " + resp.Status, StatusCode: resp.StatusCode, Retryable: resp.StatusCode >= 500}
}
etag := strings.Trim(resp.Header.Get("ETag"), `"`)
if etag == "" {
return &ClientError{Code: "invalid_response", Message: "Topaz part upload is missing ETag", Retryable: false}
}
results = append(results, map[string]any{"partNum": index + 1, "eTag": etag})
}
_, err = c.topazJSON(ctx, request, apiKey, http.MethodPatch, "/video/"+url.PathEscape(requestID)+"/complete-upload/", map[string]any{"md5Hash": source.MD5, "uploadResults": results})
return err
}
func (c TopazClient) poll(ctx context.Context, request Request, apiKey, requestID string, target map[string]int) (map[string]any, error) {
interval := universalDurationConfig(request.Candidate.PlatformConfig, topazDefaultPollInterval, "pollIntervalMs", "poll_interval_ms")
timeout := universalDurationConfig(request.Candidate.PlatformConfig, topazDefaultPollTimeout, "pollTimeoutMs", "poll_timeout_ms", "timeoutMs")
deadline := time.NewTimer(timeout)
defer deadline.Stop()
for {
status, err := c.topazJSON(ctx, request, apiKey, http.MethodGet, "/video/"+url.PathEscape(requestID)+"/status", nil)
if err != nil {
return nil, err
}
state := strings.ToLower(strings.TrimSpace(firstNonEmptyString(status["status"], status["state"])))
if request.OnRemoteTaskPolled != nil {
if err := request.OnRemoteTaskPolled(requestID, map[string]any{"phase": "uploaded", "status": state, "targetResolution": target}); err != nil {
return nil, err
}
}
switch state {
case "complete", "completed", "success", "succeeded":
return status, nil
case "failed", "failure", "cancelled", "canceled", "error":
return nil, &ClientError{Code: "provider_failed", Message: firstNonEmptyString(status["message"], "Topaz task failed"), RequestID: requestID, Retryable: false}
}
select {
case <-ctx.Done():
return nil, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true}
case <-deadline.C:
return nil, &ClientError{Code: "timeout", Message: "Topaz task polling timed out", RequestID: requestID, Retryable: true}
case <-time.After(interval):
}
}
}
func (c TopazClient) topazJSON(ctx context.Context, request Request, apiKey, method, path string, body map[string]any) (map[string]any, error) {
var reader io.Reader
if body != nil {
raw, _ := json.Marshal(body)
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, providerURL(request.Candidate.BaseURL, path), reader)
if err != nil {
return nil, err
}
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
result, decodeErr := decodeHTTPResponse(resp)
if decodeErr != nil {
return nil, decodeErr
}
return result, nil
}
func (c TopazClient) probe(ctx context.Context, target string) (TopazVideoMetadata, error) {
if c.Probe != nil {
return c.Probe(ctx, target)
}
return probeTopazVideo(ctx, target)
}
func probeTopazVideo(ctx context.Context, target string) (TopazVideoMetadata, error) {
if _, err := exec.LookPath("ffprobe"); err != nil {
return TopazVideoMetadata{}, err
}
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
output, err := exec.CommandContext(probeCtx, "ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_type,width,height,avg_frame_rate,nb_frames", "-of", "json", target).Output()
if err != nil {
return TopazVideoMetadata{}, err
}
var decoded struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
Width int `json:"width"`
Height int `json:"height"`
AverageRate string `json:"avg_frame_rate"`
FrameCount string `json:"nb_frames"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &decoded); err != nil {
return TopazVideoMetadata{}, err
}
metadata := TopazVideoMetadata{}
metadata.Duration, _ = strconv.ParseFloat(decoded.Format.Duration, 64)
for _, stream := range decoded.Streams {
if stream.CodecType == "audio" {
metadata.HasAudio = true
}
if stream.CodecType != "video" || stream.Width <= 0 || stream.Height <= 0 {
continue
}
metadata.Width, metadata.Height = stream.Width, stream.Height
metadata.FrameRate = parseTopazRate(stream.AverageRate)
metadata.FrameCount, _ = strconv.Atoi(stream.FrameCount)
}
if metadata.Width <= 0 || metadata.Height <= 0 {
return TopazVideoMetadata{}, errors.New("video stream metadata is missing")
}
return metadata, nil
}
func topazTargetResolution(body map[string]any, source map[string]any) map[string]int {
width := int(numericValue(body["output_width"], 0))
height := int(numericValue(body["output_height"], 0))
if width > 0 && height > 0 {
return map[string]int{"width": width, "height": height}
}
value := strings.ToLower(strings.TrimSpace(firstNonEmptyString(body["target_resolution"], body["output_resolution"], "1080p")))
if parsedWidth, parsedHeight, ok := parseTopazSize(value); ok {
return map[string]int{"width": parsedWidth, "height": parsedHeight}
}
base := map[string][2]int{"480p": {854, 480}, "720p": {1280, 720}, "1080p": {1920, 1080}, "1440p": {2560, 1440}, "2k": {2560, 1440}, "2160p": {3840, 2160}, "4k": {3840, 2160}}
value = strings.TrimSuffix(value, "_upscale")
target, ok := base[value]
if !ok {
target = base["1080p"]
}
sourceWidth := numericValue(source["width"], 1)
sourceHeight := numericValue(source["height"], 1)
if sourceWidth == sourceHeight {
side := minInt(target[0], target[1])
return map[string]int{"width": side, "height": side}
}
if sourceWidth > sourceHeight {
return map[string]int{"width": int(math.Round(sourceWidth / sourceHeight * float64(target[1]))), "height": target[1]}
}
return map[string]int{"width": target[1], "height": int(math.Round(sourceHeight / sourceWidth * float64(target[1])))}
}
func topazTargetFromPayload(payload map[string]any, body map[string]any) map[string]int {
if raw, ok := payload["targetResolution"].(map[string]any); ok {
return map[string]int{"width": int(numericValue(raw["width"], 0)), "height": int(numericValue(raw["height"], 0))}
}
return topazTargetResolution(body, map[string]any{"width": 16, "height": 9})
}
func topazDownloadURL(result map[string]any) string {
if download, ok := result["download"].(map[string]any); ok {
return firstNonEmptyString(download["url"], download["downloadUrl"])
}
return firstNonEmptyString(result["download_url"], result["downloadUrl"], result["url"])
}
func topazModelName(model string) string {
switch strings.TrimSpace(model) {
case "easy-proteus-standard-4":
return "prob-4"
case "easy-starlight-fast-2":
return "slf-2"
case "easy-starlight-hq-1":
return "slhq-1"
case "easy-starlight-mini-1":
return "slm-1"
default:
return strings.TrimSpace(model)
}
}
func topazContainerExtension(path string) string {
ext := strings.ToLower(filepath.Ext(strings.Split(path, "?")[0]))
switch ext {
case ".mov", ".mkv", ".webm", ".mp4":
return ext
default:
return ".mp4"
}
}
func topazContainerContentType(container string) string {
switch strings.ToLower(container) {
case "mov":
return "video/quicktime"
case "mkv":
return "video/x-matroska"
case "webm":
return "video/webm"
default:
return "video/mp4"
}
}
func parseTopazRate(value string) float64 {
parts := strings.Split(value, "/")
if len(parts) == 2 {
numerator, _ := strconv.ParseFloat(parts[0], 64)
denominator, _ := strconv.ParseFloat(parts[1], 64)
if denominator > 0 {
return numerator / denominator
}
}
parsed, _ := strconv.ParseFloat(value, 64)
return parsed
}
func parseTopazSize(value string) (int, int, bool) {
parts := strings.Split(strings.ReplaceAll(value, " ", ""), "x")
if len(parts) != 2 {
return 0, 0, false
}
width, errWidth := strconv.Atoi(parts[0])
height, errHeight := strconv.Atoi(parts[1])
return width, height, errWidth == nil && errHeight == nil && width > 0 && height > 0
}
func stringList(value any) []string {
items, ok := value.([]any)
if !ok {
if values, ok := value.([]string); ok {
return values
}
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
if text := strings.TrimSpace(fmt.Sprint(item)); text != "" {
out = append(out, text)
}
}
return out
}
func boolishDefault(value any, fallback bool) bool {
if value == nil {
return fallback
}
switch typed := value.(type) {
case bool:
return typed
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
if err == nil {
return parsed
}
}
return fallback
}
func firstError(values ...error) error {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func minInt64(left, right int64) int64 {
if left < right {
return left
}
return right
}
func minInt(left, right int) int {
if left < right {
return left
}
return right
}
func topazSourceHTTPClient(base *http.Client, allowPrivate bool) *http.Client {
client := *base
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
if client.Timeout <= 0 {
client.Timeout = 10 * time.Minute
}
if allowPrivate {
return &client
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil || len(addresses) == 0 {
return nil, errors.New("video source DNS resolution failed")
}
for _, address := range addresses {
if topazBlockedAddress(address.IP) {
return nil, errors.New("video source resolved to a blocked network")
}
}
dialer := &net.Dialer{Timeout: 10 * time.Second}
var attempts []error
for _, resolved := range addresses {
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
if dialErr == nil {
return connection, nil
}
attempts = append(attempts, dialErr)
}
return nil, errors.Join(attempts...)
}
client.Transport = transport
return &client
}
func topazBlockedAddress(ip net.IP) bool {
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
+271
View File
@@ -0,0 +1,271 @@
package clients
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/textproto"
"net/url"
"strings"
"time"
)
const vectorizerMaxResponseBytes = 128 << 20
// VectorizerClient implements the Vectorizer.AI binary vectorization API.
// Gateway async semantics are supplied by the outer River-backed task runner.
type VectorizerClient struct {
HTTPClient *http.Client
LookupIP func(context.Context, string) ([]net.IPAddr, error)
}
func (c VectorizerClient) Run(ctx context.Context, request Request) (Response, error) {
startedAt := time.Now()
format := strings.ToLower(strings.TrimSpace(firstNonEmptyString(request.Body["format"], request.Body["output_format"])))
if format == "" {
format = "svg"
}
if !vectorizerFormatAllowed(format) {
return Response{}, &ClientError{Code: "invalid_parameter", Message: "vectorizer format must be svg, eps, pdf, dxf, or png", Param: "format", StatusCode: http.StatusBadRequest}
}
imageToken := strings.TrimSpace(firstNonEmptyString(request.Body["_vectorizer_image_token"]))
receipt := strings.TrimSpace(firstNonEmptyString(request.Body["_vectorizer_receipt"]))
endpoint := "vectorize"
fields := map[string]string{"output.file_format": format}
if imageToken != "" {
endpoint = "download"
fields["image.token"] = imageToken
if receipt != "" {
fields["receipt"] = receipt
}
} else {
imageURL := vectorizerImageURL(request.Body)
if imageURL == "" {
return Response{}, &ClientError{Code: "invalid_parameter", Message: "vectorizer source image URL is required", Param: "source.url", StatusCode: http.StatusBadRequest}
}
if err := c.validateSourceURL(ctx, imageURL, boolishDefault(request.Candidate.PlatformConfig["allowPrivateSourceDownloads"], false)); err != nil {
return Response{}, err
}
fields["image.url"] = imageURL
fields["mode"] = firstNonEmptyString(request.Candidate.PlatformConfig["mode"], "production")
fields["policy.retention_days"] = firstNonEmptyString(request.Candidate.PlatformConfig["retentionDays"], request.Candidate.PlatformConfig["retention_days"], "7")
fields["processing.shapes.min_area_px"] = vectorizerCleanupMinArea(request.Body)
if maxColors := vectorizerMaxColors(request.Body); maxColors > 0 {
fields["processing.max_colors"] = fmt.Sprint(maxColors)
}
}
appendVectorizerOutputFields(fields, format)
payload, contentType, err := vectorizerMultipartBody(fields)
if err != nil {
return Response{}, err
}
url := providerURL(request.Candidate.BaseURL, endpoint)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, payload)
if err != nil {
return Response{}, err
}
req.Header.Set("Content-Type", contentType)
applyVectorizerAuth(req, request.Candidate.Credentials)
client := httpClient(request.HTTPClient, c.HTTPClient)
resp, err := client.Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
defer resp.Body.Close()
requestID := requestIDFromHTTPResponse(resp)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
result, decodeErr := decodeHTTPResponse(resp)
if decodeErr != nil {
return Response{}, annotateResponseError(decodeErr, requestID, startedAt, time.Now())
}
return Response{}, &ClientError{Code: "provider_failed", Message: firstNonEmptyString(result["message"], result["error"], resp.Status), RequestID: requestID, StatusCode: resp.StatusCode, Retryable: resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500}
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, vectorizerMaxResponseBytes+1))
if err != nil {
return Response{}, &ClientError{Code: "invalid_response", Message: err.Error(), RequestID: requestID, Retryable: true}
}
if len(raw) == 0 || len(raw) > vectorizerMaxResponseBytes {
return Response{}, &ClientError{Code: "invalid_response", Message: "vectorizer response is empty or too large", RequestID: requestID, Retryable: false}
}
returnedToken := strings.TrimSpace(resp.Header.Get("X-Image-Token"))
returnedReceipt := strings.TrimSpace(resp.Header.Get("X-Receipt"))
if request.OnRemoteTaskSubmitted != nil && returnedToken != "" {
digest := sha256.Sum256([]byte(returnedToken))
if err := request.OnRemoteTaskSubmitted("vectorizer-"+hex.EncodeToString(digest[:8]), map[string]any{
"imageToken": returnedToken,
"receipt": returnedReceipt,
}); err != nil {
return Response{}, err
}
}
finishedAt := time.Now()
mimeType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
if mimeType == "" || mimeType == "application/octet-stream" {
mimeType = vectorizerContentType(format)
}
return Response{
Result: map[string]any{
"status": "success",
"model": request.Model,
"data": []any{map[string]any{
"type": vectorizerOutputKind(format),
"b64_json": base64.StdEncoding.EncodeToString(raw),
"mime_type": mimeType,
"format": format,
}},
"vectorizer": map[string]any{
"format": format,
"creditsCharged": numericHeader(resp.Header.Get("X-Credits-Charged")),
"creditsCalculated": numericHeader(resp.Header.Get("X-Credits-Calculated")),
},
},
RequestID: requestID,
Progress: append(providerProgress(request), Progress{Phase: "uploading", Progress: 0.9, Message: "vectorizer result received"}),
ResponseStartedAt: startedAt,
ResponseFinishedAt: finishedAt,
ResponseDurationMS: responseDurationMS(startedAt, finishedAt),
}, nil
}
func (c VectorizerClient) validateSourceURL(ctx context.Context, rawURL string, allowPrivate bool) error {
parsed, err := url.Parse(rawURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || strings.TrimSpace(parsed.Hostname()) == "" || parsed.User != nil {
return &ClientError{Code: "invalid_parameter", Message: "vectorizer source URL must be a public http(s) URL without userinfo", Param: "source.url", StatusCode: http.StatusBadRequest}
}
if allowPrivate {
return nil
}
lookup := c.LookupIP
if lookup == nil {
lookup = net.DefaultResolver.LookupIPAddr
}
addresses, err := lookup(ctx, parsed.Hostname())
if err != nil || len(addresses) == 0 {
return &ClientError{Code: "invalid_parameter", Message: "vectorizer source DNS resolution failed", Param: "source.url", StatusCode: http.StatusBadRequest}
}
for _, address := range addresses {
if topazBlockedAddress(address.IP) {
return &ClientError{Code: "invalid_parameter", Message: "vectorizer source resolved to a blocked network", Param: "source.url", StatusCode: http.StatusBadRequest}
}
}
return nil
}
func vectorizerImageURL(body map[string]any) string {
if source, ok := body["source"].(map[string]any); ok {
return firstNonEmptyString(source["url"], source["image_url"], source["imageUrl"])
}
return firstNonEmptyString(body["image_url"], body["imageUrl"])
}
func vectorizerMultipartBody(fields map[string]string) (*bytes.Buffer, string, error) {
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
for key, value := range fields {
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"`, strings.ReplaceAll(key, `"`, `\"`)))
part, err := writer.CreatePart(header)
if err != nil {
return nil, "", err
}
if _, err := io.WriteString(part, value); err != nil {
return nil, "", err
}
}
if err := writer.Close(); err != nil {
return nil, "", err
}
return &payload, writer.FormDataContentType(), nil
}
func applyVectorizerAuth(req *http.Request, credentials map[string]any) {
username := credential(credentials, "username", "accessKey", "access_key", "apiId", "api_id", "id")
password := credential(credentials, "password", "secretKey", "secret_key", "apiSecret", "api_secret", "secret")
if username != "" || password != "" {
req.SetBasicAuth(username, password)
return
}
if apiKey := credential(credentials, "apiKey", "api_key", "token"); apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
}
func vectorizerFormatAllowed(format string) bool {
switch format {
case "svg", "eps", "pdf", "dxf", "png":
return true
default:
return false
}
}
func vectorizerCleanupMinArea(body map[string]any) string {
cleanup := strings.ToLower(strings.TrimSpace(firstNonEmptyString(body["cleanupLevel"], body["cleanup_level"])))
switch cleanup {
case "low":
return "0"
case "strong":
return "1"
default:
return "0.125"
}
}
func vectorizerMaxColors(body map[string]any) int {
for _, key := range []string{"maxColors", "max_colors"} {
if value := int(numericValue(body[key], 0)); value == 0 || value == 2 || value == 4 || value == 8 || value == 16 || value == 32 {
return value
}
}
return 0
}
func appendVectorizerOutputFields(fields map[string]string, format string) {
if format == "svg" {
fields["output.svg.version"] = "svg_1_1"
fields["output.svg.fixed_size"] = "false"
fields["output.svg.adobe_compatibility_mode"] = "true"
}
if format == "dxf" {
fields["output.dxf.compatibility_level"] = "lines_and_arcs"
}
}
func vectorizerContentType(format string) string {
switch format {
case "svg":
return "image/svg+xml"
case "eps":
return "application/postscript"
case "pdf":
return "application/pdf"
case "dxf":
return "application/dxf"
default:
return "image/png"
}
}
func vectorizerOutputKind(format string) string {
if format == "svg" || format == "png" {
return "image"
}
return "file"
}
func numericHeader(value string) any {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return numericValue(value, 0)
}
@@ -0,0 +1,62 @@
package httpapi
import (
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestAdvancedMediaScopeAliases(t *testing.T) {
tests := []struct {
kind string
scope string
}{
{kind: "images.vectorize", scope: "image_vectorize"},
{kind: "images.vectorize", scope: "image"},
{kind: "images.vectorize", scope: "vectorize"},
{kind: "videos.upscales", scope: "video_enhance"},
{kind: "videos.upscales", scope: "video"},
{kind: "videos.upscales", scope: "video_upscale"},
}
for _, test := range tests {
user := &auth.User{APIKeyID: "key", APIKeyScopes: []string{test.scope}}
if !apiKeyScopeAllowed(user, test.kind) {
t.Fatalf("scope %q should allow %q", test.scope, test.kind)
}
}
if apiKeyScopeAllowed(&auth.User{APIKeyID: "key", APIKeyScopes: []string{"chat"}}, "videos.upscales") {
t.Fatal("chat scope must not allow video upscale")
}
}
func TestFillDailyTokenUsageDaysKeepsGapsAndStreaks(t *testing.T) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
from := time.Date(2026, 7, 20, 0, 0, 0, 0, location)
to := time.Date(2026, 7, 24, 0, 0, 0, 0, location)
items, summary := fillDailyTokenUsageDays(map[string]store.DailyTokenUsage{
"2026-07-20": {Date: "2026-07-20", TotalTokens: 10, TaskCount: 1},
"2026-07-22": {Date: "2026-07-22", TotalTokens: 20, TaskCount: 1},
"2026-07-23": {Date: "2026-07-23", TotalTokens: 30, TaskCount: 2},
"2026-07-24": {Date: "2026-07-24", TotalTokens: 40, TaskCount: 1},
}, from, to)
if len(items) != 5 || items[1].Date != "2026-07-21" || items[1].TaskCount != 0 {
t.Fatalf("daily usage should contain continuous zero days: %+v", items)
}
if summary.CumulativeTokens != 100 || summary.PeakDailyTokens != 40 || summary.CurrentStreakDays != 3 || summary.LongestStreakDays != 3 {
t.Fatalf("unexpected daily usage summary: %+v", summary)
}
}
func TestAdvancedMediaDefaultModels(t *testing.T) {
if got := canonicalTaskModelName("images.vectorize", ""); got != "easy-image-vectorizer-1" {
t.Fatalf("vectorizer default model=%q", got)
}
if got := canonicalTaskModelName("videos.upscales", ""); got != "easy-proteus-standard-4" {
t.Fatalf("Topaz default model=%q", got)
}
}
@@ -0,0 +1,45 @@
package httpapi
import "net/http"
// createImageVectorizeTask godoc
// @Summary 图片矢量化
// @Description 将 URL 位图转换为 SVG、EPS、PDF、DXF 或 PNG;设置 X-Async=true 时返回统一异步任务结构。可使用同一所有者历史任务的 vectorizerTaskId 复用上游 image token。
// @Tags images
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Param input body ImageVectorizeRequest true "图片矢量化请求"
// @Success 200 {object} CompatibleResponse
// @Success 202 {object} TaskAcceptedResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/images/vectorize [post]
func (s *Server) createImageVectorizeTask() http.Handler {
return s.createTask("images.vectorize", true)
}
// createVideoUpscaleTask godoc
// @Summary 视频超分
// @Description 原生执行 Topaz 视频增强流程;设置 X-Async=true 时返回统一异步任务结构,结果在任务完成前持久化到 Gateway 文件存储。
// @Tags videos
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Param input body VideoUpscaleRequest true "视频超分请求"
// @Success 200 {object} CompatibleResponse
// @Success 202 {object} TaskAcceptedResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/videos/upscales [post]
func (s *Server) createVideoUpscaleTask() http.Handler {
return s.createTask("videos.upscales", true)
}
@@ -9,8 +9,8 @@ import (
const ( const (
opsManagementSkillDownloadPath = "/api/v1/public/skills/ai-gateway-ops-management/download" opsManagementSkillDownloadPath = "/api/v1/public/skills/ai-gateway-ops-management/download"
apiDocsJSONPath = "/api-docs-json" apiDocsJSONPath = "/api/v1/openapi.json"
apiDocsYAMLPath = "/api-docs-yaml" apiDocsYAMLPath = "/api/v1/openapi.yaml"
) )
// getOpsManagementSkillMetadata godoc // getOpsManagementSkillMetadata godoc
@@ -64,7 +64,7 @@ func (s *Server) downloadOpsManagementSkill(w http.ResponseWriter, _ *http.Reque
// @Tags agent-resources // @Tags agent-resources
// @Produce json // @Produce json
// @Success 200 {object} map[string]interface{} // @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) { func (s *Server) apiDocsJSON(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -77,7 +77,7 @@ func (s *Server) apiDocsJSON(w http.ResponseWriter, _ *http.Request) {
// @Tags agent-resources // @Tags agent-resources
// @Produce application/yaml // @Produce application/yaml
// @Success 200 {string} string // @Success 200 {string} string
// @Router /api-docs-yaml [get] // @Router /api/v1/openapi.yaml [get]
func (s *Server) apiDocsYAML(w http.ResponseWriter, _ *http.Request) { func (s *Server) apiDocsYAML(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/yaml; charset=utf-8") w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -32,7 +32,7 @@ func TestGetOpsManagementSkillMetadata(t *testing.T) {
if len(metadata.Modules) != 1 || metadata.Modules[0] != "model-runtime" { if len(metadata.Modules) != 1 || metadata.Modules[0] != "model-runtime" {
t.Fatalf("unexpected metadata modules: %+v", metadata.Modules) 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) t.Fatalf("unexpected API docs paths: %+v", metadata)
} }
} }
@@ -74,7 +74,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
server := &Server{} server := &Server{}
jsonResponse := httptest.NewRecorder() 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 { if jsonResponse.Code != http.StatusOK {
t.Fatalf("expected JSON docs status 200, got %d", jsonResponse.Code) 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) t.Fatalf("decode embedded Swagger JSON: %v", err)
} }
for _, path := range []string{ for _, path := range []string{
"/api-docs-json", "/api/v1/openapi.json",
"/api/v1/public/skills/ai-gateway-ops-management/download", "/api/v1/public/skills/ai-gateway-ops-management/download",
"/api/admin/catalog/providers", "/api/admin/catalog/providers",
"/api/admin/catalog/base-models", "/api/admin/catalog/base-models",
@@ -102,7 +102,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
} }
yamlResponse := httptest.NewRecorder() 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 { if yamlResponse.Code != http.StatusOK {
t.Fatalf("expected YAML docs status 200, got %d", yamlResponse.Code) t.Fatalf("expected YAML docs status 200, got %d", yamlResponse.Code)
} }
@@ -58,17 +58,18 @@ func TestPlanTaskResponseTreatsAPIV1EmbeddingAndRerankAsSynchronousCompatibleRes
} }
} }
func TestPlanTaskResponseKeepsAsyncTaskModeForOtherAPIV1Tasks(t *testing.T) { func TestPlanTaskResponseUsesCompatibleAPIV1MediaResponsesAndKeepsAsyncOptIn(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil) defaultRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
req.Header.Set("X-Async", "true") defaultPlan := planTaskResponse("images.generations", true, map[string]any{}, defaultRequest)
if defaultPlan.asyncMode || !defaultPlan.compatibleMode {
plan := planTaskResponse("images.generations", false, map[string]any{"stream": true}, req) t.Fatalf("canonical /api/v1 media endpoints should default to synchronous compatible responses, got %+v", defaultPlan)
if !plan.asyncMode {
t.Fatal("non-chat /api/v1 task endpoints should keep X-Async task mode")
} }
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"` Result map[string]any `json:"result"`
} `json:"task"` } `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, "model": defaultImageModel,
"runMode": "simulation", "runMode": "simulation",
"prompt": "a tiny gateway console", "prompt": "a tiny gateway console",
@@ -512,7 +512,9 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
"quality": "medium", "quality": "medium",
"simulation": true, "simulation": true,
"simulationDurationMs": 5, "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"] == "" { if imageResponse.Task.Status != "succeeded" || imageResponse.Task.Result["id"] == "" {
t.Fatalf("unexpected image generation task: %+v", imageResponse.Task) 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"` Result map[string]any `json:"result"`
} `json:"task"` } `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, "model": defaultImageModel,
"runMode": "simulation", "runMode": "simulation",
"prompt": "replace background with clean studio light", "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", "mask": "https://example.com/mask.png",
"simulation": true, "simulation": true,
"simulationDurationMs": 5, "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"] == "" { if imageEditResponse.Task.Status != "succeeded" || imageEditResponse.Task.Result["id"] == "" {
t.Fatalf("unexpected image edit task: %+v", imageEditResponse.Task) t.Fatalf("unexpected image edit task: %+v", imageEditResponse.Task)
} }
@@ -1196,17 +1200,20 @@ WHERE reference_type = 'gateway_task'
}, http.StatusCreated, &videoRoutePlatformModel) }, http.StatusCreated, &videoRoutePlatformModel)
var textToVideoTask struct { var textToVideoTask struct {
Task struct { Task struct {
ID string `json:"id"`
Status string `json:"status"` Status string `json:"status"`
ModelType string `json:"modelType"` ModelType string `json:"modelType"`
} `json:"task"` } `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, "model": videoRouteModel,
"runMode": "simulation", "runMode": "simulation",
"simulation": true, "simulation": true,
"simulationDurationMs": 5, "simulationDurationMs": 5,
"prompt": "text to video route", "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" { 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) 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"` Metrics map[string]any `json:"metrics"`
} `json:"task"` } `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, "model": videoRouteModel,
"runMode": "simulation", "runMode": "simulation",
"simulation": true, "simulation": true,
"simulationDurationMs": 5, "simulationDurationMs": 5,
"prompt": "image to video route", "prompt": "image to video route",
"image": "https://example.com/source.png", "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" { 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) t.Fatalf("image-to-video request should use image_to_video model_type: %+v", imageToVideoTask.Task)
} }
@@ -0,0 +1,101 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type dailyTokenUsageSummary struct {
CumulativeTokens int64 `json:"cumulativeTokens"`
PeakDailyTokens int64 `json:"peakDailyTokens"`
CurrentStreakDays int `json:"currentStreakDays"`
LongestStreakDays int `json:"longestStreakDays"`
}
// dailyTokenUsage godoc
// @Summary 查询每日 Token 与资源点用量
// @Description 按 IANA 时区返回连续自然日用量;API Key 仅统计当前 Key,JWT 统计当前用户。
// @Tags workspace
// @Produce json
// @Security BearerAuth
// @Param from query string true "开始日期 YYYY-MM-DD"
// @Param to query string true "结束日期 YYYY-MM-DD"
// @Param timezone query string false "IANA 时区" default(UTC)
// @Success 200 {object} DailyTokenUsageResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/token-usage/daily [get]
func (s *Server) dailyTokenUsage(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
timezone := strings.TrimSpace(r.URL.Query().Get("timezone"))
if timezone == "" {
timezone = "UTC"
}
location, err := time.LoadLocation(timezone)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid IANA timezone")
return
}
from, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(r.URL.Query().Get("from")), location)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid from date")
return
}
to, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(r.URL.Query().Get("to")), location)
if err != nil || to.Before(from) || to.Sub(from) > 399*24*time.Hour {
writeError(w, http.StatusBadRequest, "invalid to date or date range exceeds 400 days")
return
}
stored, err := s.store.ListDailyTokenUsage(r.Context(), user, from, to, timezone)
if err != nil {
s.logger.Error("list daily token usage failed", "error", err)
writeError(w, http.StatusInternalServerError, "list daily token usage failed")
return
}
byDate := make(map[string]store.DailyTokenUsage, len(stored))
for _, item := range stored {
byDate[item.Date] = item
}
items, summary := fillDailyTokenUsageDays(byDate, from, to)
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "tokenDays": items, "summary": summary,
"range": map[string]string{"from": from.Format("2006-01-02"), "to": to.Format("2006-01-02"), "timezone": timezone},
})
}
func fillDailyTokenUsageDays(byDate map[string]store.DailyTokenUsage, from, to time.Time) ([]store.DailyTokenUsage, dailyTokenUsageSummary) {
items := make([]store.DailyTokenUsage, 0, 32)
summary := dailyTokenUsageSummary{}
currentRun := 0
for day := from; !day.After(to); day = day.AddDate(0, 0, 1) {
key := day.Format("2006-01-02")
item, found := byDate[key]
if !found {
item.Date = key
}
items = append(items, item)
summary.CumulativeTokens += item.TotalTokens
if item.TotalTokens > summary.PeakDailyTokens {
summary.PeakDailyTokens = item.TotalTokens
}
if item.TaskCount > 0 {
currentRun++
if currentRun > summary.LongestStreakDays {
summary.LongestStreakDays = currentRun
}
} else {
currentRun = 0
}
}
summary.CurrentStreakDays = currentRun
return items, summary
}
@@ -26,7 +26,6 @@ const maxGatewayUploadBytes = 256 << 20
// @Failure 502 {object} ErrorEnvelope // @Failure 502 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope // @Failure 503 {object} ErrorEnvelope
// @Router /api/v1/files/upload [post] // @Router /api/v1/files/upload [post]
// @Router /v1/files/upload [post]
func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) { func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes) r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
if err := r.ParseMultipartForm(32 << 20); err != nil { 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 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) { func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context()) user, ok := auth.UserFromContext(r.Context())
if !ok { if !ok {
@@ -395,6 +410,18 @@ func geminiUsageMetadataFromOutput(output map[string]any) map[string]any {
return meta 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) { func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil { if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err)) 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) { func (s *Server) geminiFilesUploadFinalize(w http.ResponseWriter, r *http.Request) {
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil { if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err)) 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(), CreatedAt: time.Now(),
} }
s.geminiUploadSessions.Store(uploadID, session) 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") w.Header().Set("X-Goog-Upload-Status", "active")
writeJSON(w, http.StatusOK, map[string]any{}) 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) { func (s *Server) finalizeGeminiFilesUpload(w http.ResponseWriter, r *http.Request, uploadID string, session geminiUploadSession) {
if uploadID == "" { if uploadID == "" {
uploadID = newGeminiUploadID() 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) { func TestGeminiImageTaskBodyMapsTextOnlyToImageGenerate(t *testing.T) {
mapping, err := geminiImageTaskBody("gemini-image", map[string]any{ mapping, err := geminiImageTaskBody("gemini-image", map[string]any{
"contents": []any{ "contents": []any{
+64 -30
View File
@@ -31,7 +31,7 @@ const (
// @Tags system // @Tags system
// @Produce json // @Produce json
// @Success 200 {object} HealthResponse // @Success 200 {object} HealthResponse
// @Router /healthz [get] // @Router /api/v1/healthz [get]
func (s *Server) health(w http.ResponseWriter, r *http.Request) { func (s *Server) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"ok": true, "ok": true,
@@ -48,7 +48,7 @@ func (s *Server) health(w http.ResponseWriter, r *http.Request) {
// @Produce json // @Produce json
// @Success 200 {object} ReadyResponse // @Success 200 {object} ReadyResponse
// @Failure 503 {object} ErrorEnvelope // @Failure 503 {object} ErrorEnvelope
// @Router /readyz [get] // @Router /api/v1/readyz [get]
func (s *Server) ready(w http.ResponseWriter, r *http.Request) { func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), postgresReadinessTimeout) ctx, cancel := context.WithTimeout(r.Context(), postgresReadinessTimeout)
defer cancel() defer cancel()
@@ -1021,7 +1021,7 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// createTask godoc // createTask godoc
// @Summary 创建或执行 AI 任务 // @Summary 创建或执行 AI 任务
// @Description 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流 // @Description 统一公开入口按 model 选择平台模型并默认同步返回兼容响应;设置 X-Async=true 时异步创建任务并返回 202
// @Tags tasks // @Tags tasks
// @Accept json // @Accept json
// @Produce 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/music/generations [post]
// @Router /api/v1/speech/generations [post] // @Router /api/v1/speech/generations [post]
// @Router /api/v1/voice_clone [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 { func (s *Server) createTask(kind string, compatible bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context()) user, ok := auth.UserFromContext(r.Context())
@@ -1086,11 +1070,15 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
return return
} }
} }
model := requestModelName(body) requestedModel := requestModelName(body)
model := canonicalTaskModelName(kind, requestedModel)
if model == "" { if model == "" {
writeError(w, http.StatusBadRequest, "model is required") writeError(w, http.StatusBadRequest, "model is required")
return return
} }
if model != requestedModel {
body["model"] = model
}
if !apiKeyScopeAllowed(user, kind) { if !apiKeyScopeAllowed(user, kind) {
writeError(w, http.StatusForbidden, "api key scope does not allow this capability") writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
return return
@@ -1214,8 +1202,6 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope // @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope // @Failure 502 {object} ErrorEnvelope
// @Router /chat/completions [post]
// @Router /v1/chat/completions [post]
func openAIChatCompletionsDoc() {} func openAIChatCompletionsDoc() {}
// openAIResponsesDoc godoc // openAIResponsesDoc godoc
@@ -1234,8 +1220,6 @@ func openAIChatCompletionsDoc() {}
// @Failure 402 {object} ErrorEnvelope // @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable" // @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
// @Router /responses [post]
// @Router /v1/responses [post]
// @Router /api/v1/responses [post] // @Router /api/v1/responses [post]
func openAIResponsesDoc() {} func openAIResponsesDoc() {}
@@ -1411,6 +1395,12 @@ func apiKeyScopeAllowed(user *auth.User, kind string) bool {
if required == "voice_clone" && (scope == "audio" || scope == "text_to_speech" || scope == "speech" || scope == "tts") { if required == "voice_clone" && (scope == "audio" || scope == "text_to_speech" || scope == "speech" || scope == "tts") {
return true return true
} }
if required == "image_vectorize" && (scope == "image" || scope == "vectorize") {
return true
}
if required == "video_enhance" && (scope == "video" || scope == "video_upscale" || scope == "upscale") {
return true
}
} }
return false return false
} }
@@ -1422,6 +1412,25 @@ func requestModelName(body map[string]any) string {
return modelNameFromValue(body["model"]) return modelNameFromValue(body["model"])
} }
func canonicalTaskModelName(kind string, model string) string {
model = strings.TrimSpace(model)
if model == "" {
switch kind {
case "images.vectorize":
return "easy-image-vectorizer-1"
case "videos.upscales":
return "easy-proteus-standard-4"
}
}
if kind != "videos.generations" {
return model
}
if canonical, ok := canonicalKlingOmniModel(model); ok {
return canonical
}
return model
}
func modelNameFromValue(value any) string { func modelNameFromValue(value any) string {
switch typed := value.(type) { switch typed := value.(type) {
case string: case string:
@@ -1446,8 +1455,12 @@ func scopeForTaskKind(kind string) string {
return "rerank" return "rerank"
case "images.generations", "images.edits": case "images.generations", "images.edits":
return "image" return "image"
case "images.vectorize":
return "image_vectorize"
case "videos.generations": case "videos.generations":
return "video" return "video"
case "videos.upscales":
return "video_enhance"
case "song.generations", "music.generations": case "song.generations", "music.generations":
return "music" return "music"
case "speech.generations": case "speech.generations":
@@ -1660,7 +1673,6 @@ func matchedRateLimitRule(policy map[string]any, metric string) map[string]any {
// @Failure 500 {object} ErrorEnvelope // @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks [get] // @Router /api/workspace/tasks [get]
// @Router /api/v1/tasks [get] // @Router /api/v1/tasks [get]
// @Router /tasks [get]
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context()) user, ok := auth.UserFromContext(r.Context())
if !ok { if !ok {
@@ -1769,10 +1781,18 @@ func boolValue(body map[string]any, key string) bool {
// @Failure 500 {object} ErrorEnvelope // @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks/{taskID} [get] // @Router /api/workspace/tasks/{taskID} [get]
// @Router /api/v1/tasks/{taskID} [get] // @Router /api/v1/tasks/{taskID} [get]
// @Router /tasks/{taskID} [get]
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) { func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID")) task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err == nil { if err == nil {
if !runner.TaskAccessibleToUser(task, user) {
writeError(w, http.StatusNotFound, "task not found")
return
}
cancelState := runner.DescribeTaskCancellation(task) cancelState := runner.DescribeTaskCancellation(task)
task.Cancellable = &cancelState.Cancellable task.Cancellable = &cancelState.Cancellable
task.Submitted = &cancelState.Submitted task.Submitted = &cancelState.Submitted
@@ -1802,8 +1822,6 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} ErrorEnvelope // @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks/{taskID}/cancel [post] // @Router /api/workspace/tasks/{taskID}/cancel [post]
// @Router /api/v1/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) { func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context()) user, ok := auth.UserFromContext(r.Context())
if !ok { if !ok {
@@ -1840,8 +1858,12 @@ func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} ErrorEnvelope // @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks/{taskID}/param-preprocessing [get] // @Router /api/workspace/tasks/{taskID}/param-preprocessing [get]
// @Router /api/v1/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) { func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID")) task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err != nil { if err != nil {
if store.IsNotFound(err) { if store.IsNotFound(err) {
@@ -1852,6 +1874,10 @@ func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusInternalServerError, "get task failed") writeError(w, http.StatusInternalServerError, "get task failed")
return return
} }
if !runner.TaskAccessibleToUser(task, user) {
writeError(w, http.StatusNotFound, "task not found")
return
}
logs, err := s.store.ListTaskParamPreprocessingLogs(r.Context(), task.ID) logs, err := s.store.ListTaskParamPreprocessingLogs(r.Context(), task.ID)
if err != nil { if err != nil {
s.logger.Error("list task parameter preprocessing logs failed", "taskID", task.ID, "error", err) s.logger.Error("list task parameter preprocessing logs failed", "taskID", task.ID, "error", err)
@@ -1874,8 +1900,12 @@ func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request)
// @Failure 500 {object} ErrorEnvelope // @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks/{taskID}/events [get] // @Router /api/workspace/tasks/{taskID}/events [get]
// @Router /api/v1/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) { func (s *Server) taskEvents(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID")) task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
if err != nil { if err != nil {
if store.IsNotFound(err) { if store.IsNotFound(err) {
@@ -1885,6 +1915,10 @@ func (s *Server) taskEvents(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "get task failed") writeError(w, http.StatusInternalServerError, "get task failed")
return return
} }
if !runner.TaskAccessibleToUser(task, user) {
writeError(w, http.StatusNotFound, "task not found")
return
}
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
@@ -132,7 +132,7 @@ func (s *Server) requireKelingAPIKey(next http.Handler) http.Handler {
// @Failure 429 {object} KelingCompatibleEnvelope // @Failure 429 {object} KelingCompatibleEnvelope
// @Failure 500 {object} KelingCompatibleEnvelope // @Failure 500 {object} KelingCompatibleEnvelope
// @Failure 503 {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) { func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
requestID := kelingCompatRequestID(r) requestID := kelingCompatRequestID(r)
user, ok := auth.UserFromContext(r.Context()) 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 403 {object} KelingCompatibleEnvelope
// @Failure 404 {object} KelingCompatibleEnvelope // @Failure 404 {object} KelingCompatibleEnvelope
// @Failure 500 {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) { func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
requestID := kelingCompatRequestID(r) requestID := kelingCompatRequestID(r)
user, ok := auth.UserFromContext(r.Context()) user, ok := auth.UserFromContext(r.Context())
@@ -269,7 +269,7 @@ func normalizeKelingOmniRequest(input map[string]any) (map[string]any, *kelingCo
if sound != "on" && sound != "off" { if sound != "on" && sound != "off" {
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be on or off") return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be on or off")
} }
if model == "kling-o1" && sound == "on" { if model == klingO1Model && sound == "on" {
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 does not support generated audio; sound must be off") return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 does not support generated audio; sound must be off")
} }
@@ -347,7 +347,7 @@ func normalizeKelingOmniRequest(input map[string]any) (map[string]any, *kelingCo
if duration < 3 || duration > maxDuration { if duration < 3 || duration > maxDuration {
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("duration for %s must be an integer between 3 and %d seconds", requestedModel, maxDuration)) return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("duration for %s must be an integer between 3 and %d seconds", requestedModel, maxDuration))
} }
if model == "kling-o1" && (len(images) == 0 || hasFirstFrame) && duration != 5 && duration != 10 { if model == klingO1Model && (len(images) == 0 || hasFirstFrame) && duration != 5 && duration != 10 {
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 text-to-video and first-frame generation only support 5 or 10 seconds") return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 text-to-video and first-frame generation only support 5 or 10 seconds")
} }
} }
@@ -532,14 +532,14 @@ func normalizeKelingMultiPrompts(value any) ([]any, int, *kelingCompatError) {
} }
func kelingCompatModel(value string) (string, int, bool) { func kelingCompatModel(value string) (string, int, bool) {
switch strings.ToLower(strings.TrimSpace(value)) { model, ok := canonicalKlingOmniModel(value)
case "kling-video-o1", "kling-o1": if !ok {
return "kling-o1", 10, true
case "kling-v3-omni", "kling-3.0-omni":
return "kling-3.0-omni", 15, true
default:
return "", 0, false return "", 0, false
} }
if model == klingO1Model {
return model, 10, true
}
return model, 15, true
} }
func kelingCompatObjectList(value any, field string) ([]map[string]any, *kelingCompatError) { func kelingCompatObjectList(value any, field string) ([]map[string]any, *kelingCompatError) {
@@ -27,7 +27,7 @@ func TestNormalizeKelingOmniRequestMapsOfficialFields(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("normalize Kling request: %v", err) t.Fatalf("normalize Kling request: %v", err)
} }
if normalized["model"] != "kling-3.0-omni" || if normalized["model"] != "kling-v3-omni" ||
normalized["modelType"] != "omni_video" || normalized["modelType"] != "omni_video" ||
normalized["resolution"] != "1080p" || normalized["resolution"] != "1080p" ||
normalized["aspect_ratio"] != "9:16" || normalized["aspect_ratio"] != "9:16" ||
@@ -49,6 +49,24 @@ func TestNormalizeKelingOmniRequestMapsOfficialFields(t *testing.T) {
} }
} }
func TestCanonicalTaskModelNameNormalizesKelingOmniAliases(t *testing.T) {
tests := map[string]string{
"kling-o1": "kling-video-o1",
"kling-video-o1": "kling-video-o1",
"kling-3.0-omni": "kling-v3-omni",
"kling-3-omni": "kling-v3-omni",
"kling-v3-omni": "kling-v3-omni",
}
for input, expected := range tests {
if got := canonicalTaskModelName("videos.generations", input); got != expected {
t.Fatalf("canonicalTaskModelName(%q) = %q, want %q", input, got, expected)
}
}
if got := canonicalTaskModelName("chat.completions", "kling-o1"); got != "kling-o1" {
t.Fatalf("non-video model must not be rewritten, got %q", got)
}
}
func TestNormalizeKelingOmniRequestBuildsMultiShotMedia(t *testing.T) { func TestNormalizeKelingOmniRequestBuildsMultiShotMedia(t *testing.T) {
normalized, err := normalizeKelingOmniRequest(map[string]any{ normalized, err := normalizeKelingOmniRequest(map[string]any{
"model_name": "kling-3.0-omni", "model_name": "kling-3.0-omni",
@@ -70,7 +88,7 @@ func TestNormalizeKelingOmniRequestBuildsMultiShotMedia(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("normalize multi-shot request: %v", err) t.Fatalf("normalize multi-shot request: %v", err)
} }
if normalized["model"] != "kling-3.0-omni" || normalized["duration"] != 5 || normalized["multi_shot"] != true || normalized["shot_type"] != "customize" { if normalized["model"] != "kling-v3-omni" || normalized["duration"] != 5 || normalized["multi_shot"] != true || normalized["shot_type"] != "customize" {
t.Fatalf("unexpected multi-shot fields: %+v", normalized) t.Fatalf("unexpected multi-shot fields: %+v", normalized)
} }
content, _ := normalized["content"].([]any) content, _ := normalized["content"].([]any)
@@ -100,9 +100,9 @@ func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
_, err = db.CreatePlatformModel(ctx, store.CreatePlatformModelInput{ _, err = db.CreatePlatformModel(ctx, store.CreatePlatformModelInput{
PlatformID: platform.ID, PlatformID: platform.ID,
CanonicalModelKey: "keling:kling-video-o1", CanonicalModelKey: "keling:kling-video-o1",
ModelName: "kling-o1", ModelName: "kling-video-o1",
ProviderModelName: "kling-video-o1", ProviderModelName: "kling-video-o1",
ModelAlias: "kling-o1", ModelAlias: "",
ModelType: store.StringList{"omni_video", "video_generate"}, ModelType: store.StringList{"omni_video", "video_generate"},
DisplayName: "Kling O1 Compatible Test", DisplayName: "Kling O1 Compatible Test",
Capabilities: map[string]any{ Capabilities: map[string]any{
@@ -145,7 +145,7 @@ func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
_, secondAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "second", suffix, false) _, secondAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "second", suffix, false)
var created KelingCompatibleEnvelope 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", "model_name": "kling-video-o1",
"prompt": "A clean product reveal", "prompt": "A clean product reveal",
"mode": "std", "mode": "std",
@@ -162,7 +162,7 @@ func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
taskID := stringFromKelingCompat(createdData["task_id"]) taskID := stringFromKelingCompat(createdData["task_id"])
var hidden KelingCompatibleEnvelope 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 { if hidden.Code != 1203 {
t.Fatalf("cross-user task must be hidden: %+v", hidden) 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) deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
var response KelingCompatibleEnvelope 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) data, _ := response.Data.(map[string]any)
switch data["task_status"] { switch data["task_status"] {
case "succeed": case "succeed":
@@ -134,16 +134,22 @@ WHERE username = $1`, username); err != nil {
ResponseDurationMS int64 `json:"responseDurationMs"` ResponseDurationMS int64 `json:"responseDurationMs"`
} `json:"task"` } `json:"task"`
} }
doJSON( doJSONWithHeaders(
t, t,
server.URL, server.URL,
http.MethodPost, http.MethodPost,
"/api/v1/videos/generations", "/api/v1/videos/generations",
apiKeyResponse.Secret, apiKeyResponse.Secret,
request, request,
map[string]string{"X-Async": "true"},
http.StatusAccepted, http.StatusAccepted,
&response, &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 task := response.Task
if task.ID == "" || if task.ID == "" ||
+20 -7
View File
@@ -27,6 +27,15 @@ func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
handler := func(next http.HandlerFunc) http.Handler { handler := func(next http.HandlerFunc) http.Handler {
return s.requireUser(auth.PermissionBasic, http.HandlerFunc(next)) 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("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", handler(s.klingV1ListOmniVideos))
mux.Handle("GET /kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo)) 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{} // @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{} // @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} ErrorEnvelope // @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) { func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
var native map[string]any var native map[string]any
if err := decodeKlingJSON(r, &native); err != nil { 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{} // @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{} // @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} ErrorEnvelope // @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) { func (s *Server) klingV2CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
model, ok := klingV2ProviderModel(r.PathValue("model")) model, ok := klingV2ProviderModel(r.PathValue("model"))
if !ok { if !ok {
@@ -433,7 +442,7 @@ func validateKlingCompatBody(model string, body map[string]any) error {
// @Param taskID path string true "任务 ID" // @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]interface{} // @Success 200 {object} map[string]interface{}
// @Failure 404 {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) { func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context()) user, _ := auth.UserFromContext(r.Context())
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v1", r.PathValue("taskID")) 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 pageNum query int false "页码" default(1)
// @Param pageSize query int false "每页数量" default(30) // @Param pageSize query int false "每页数量" default(30)
// @Success 200 {object} map[string]interface{} // @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) { func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context()) user, _ := auth.UserFromContext(r.Context())
page, err := positiveQueryInt(r.URL.Query().Get("pageNum"), 1) 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 task_ids query string false "逗号分隔的任务 ID"
// @Param external_task_ids query string false "逗号分隔的外部任务 ID" // @Param external_task_ids query string false "逗号分隔的外部任务 ID"
// @Success 200 {object} map[string]interface{} // @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) { func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context()) user, _ := auth.UserFromContext(r.Context())
taskIDs := splitKlingIDs(r.URL.Query().Get("task_ids")) taskIDs := splitKlingIDs(r.URL.Query().Get("task_ids"))
@@ -525,7 +534,7 @@ func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
// @Security BearerAuth // @Security BearerAuth
// @Param input body map[string]interface{} true "游标、数量、时间范围和筛选条件" // @Param input body map[string]interface{} true "游标、数量、时间范围和筛选条件"
// @Success 200 {object} map[string]interface{} // @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) { func (s *Server) klingV2ListTasks(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context()) user, _ := auth.UserFromContext(r.Context())
var body map[string]any var body map[string]any
@@ -686,7 +695,11 @@ func klingV2Status(status string) string {
} }
func klingV2ProviderModel(pathModel string) (string, bool) { func klingV2ProviderModel(pathModel string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(pathModel)) { return canonicalKlingOmniModel(pathModel)
}
func canonicalKlingOmniModel(value string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "kling-o1", "kling-video-o1": case "kling-o1", "kling-video-o1":
return klingO1Model, true return klingO1Model, true
case "kling-v3-omni", "kling-3.0-omni", "kling-3-omni": case "kling-v3-omni", "kling-3.0-omni", "kling-3-omni":
@@ -138,12 +138,18 @@ WHERE platform_id = $1::uuid
} }
var response struct { var response struct {
Task struct { Task struct {
ID string `json:"id"`
Status string `json:"status"` Status string `json:"status"`
ModelType string `json:"modelType"` ModelType string `json:"modelType"`
ResolvedModel string `json:"resolvedModel"` ResolvedModel string `json:"resolvedModel"`
} `json:"task"` } `json:"task"`
} }
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, request, http.StatusAccepted, &response) 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) resolvedModel, resolved := klingV2ProviderModel(response.Task.ResolvedModel)
if response.Task.Status != "succeeded" || response.Task.ModelType != expectedModelType || !resolved || resolvedModel != model { 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) t.Fatalf("generic video request without modelType should use inferred capability: %+v", response.Task)
@@ -159,7 +165,7 @@ WHERE platform_id = $1::uuid
createV1 := func(model string, duration int, externalID string) string { createV1 := func(model string, duration int, externalID string) string {
t.Helper() t.Helper()
var response map[string]any 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, "model_name": model,
"prompt": "兼容接口模拟任务", "prompt": "兼容接口模拟任务",
"duration": duration, "duration": duration,
@@ -186,14 +192,14 @@ WHERE platform_id = $1::uuid
waitKlingV1SimulationTask(t, server.URL, apiKeyResponse.Secret, taskID) waitKlingV1SimulationTask(t, server.URL, apiKeyResponse.Secret, taskID)
} }
var listResponse map[string]any 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) items, _ := listResponse["data"].([]any)
if len(items) < 2 { if len(items) < 2 {
t.Fatalf("V1 task list did not return compatibility tasks: %#v", listResponse) t.Fatalf("V1 task list did not return compatibility tasks: %#v", listResponse)
} }
var duplicateResponse map[string]any 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, "model_name": klingO1Model, "prompt": "duplicate", "duration": 5,
"external_task_id": "compat-o1-" + suffix, "external_task_id": "compat-o1-" + suffix,
"runMode": "simulation", "simulation": true, "runMode": "simulation", "simulation": true,
@@ -203,7 +209,7 @@ WHERE platform_id = $1::uuid
} }
var v2Response map[string]any 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 模拟任务"}}, "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"}, "settings": map[string]any{"duration": 3, "resolution": "720p", "aspect_ratio": "16:9", "audio": "off"},
"options": map[string]any{"external_task_id": "compat-v2-" + suffix}, "options": map[string]any{"external_task_id": "compat-v2-" + suffix},
@@ -224,7 +230,7 @@ func waitKlingV1SimulationTask(t *testing.T, baseURL string, apiKey string, task
deadline := time.Now().Add(5 * time.Second) deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
var response map[string]any 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) data, _ := response["data"].(map[string]any)
switch data["task_status"] { switch data["task_status"] {
case "succeed": case "succeed":
@@ -242,7 +248,7 @@ func waitKlingV2SimulationTask(t *testing.T, baseURL string, apiKey string, task
deadline := time.Now().Add(5 * time.Second) deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
var response map[string]any 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) items, _ := response["data"].([]any)
if len(items) == 1 { if len(items) == 1 {
data, _ := items[0].(map[string]any) data, _ := items[0].(map[string]any)
@@ -1141,6 +1141,10 @@ func canonicalCapabilityFilterValue(value string) string {
return "text_embedding" return "text_embedding"
case "rerank", "reranks": case "rerank", "reranks":
return "text_rerank" return "text_rerank"
case "vectorize", "image_vectorizer":
return "image_vectorize"
case "video_upscale", "upscale":
return "video_enhance"
case "model": case "model":
return "model_3d" return "model_3d"
default: default:
@@ -1164,6 +1168,10 @@ func capabilityFilterValueForTag(tag string) string {
return "structured_output" return "structured_output"
case "数字人": case "数字人":
return "digital_human" return "digital_human"
case "图片矢量化":
return "image_vectorize"
case "视频增强":
return "video_enhance"
case "重排序": case "重排序":
return "text_rerank" return "text_rerank"
case "3D 模型": case "3D 模型":
@@ -1194,11 +1202,13 @@ func capabilityLabel(value string) string {
"image_generate": "图像生成", "image_generate": "图像生成",
"image_edit": "图像编辑", "image_edit": "图像编辑",
"image_analysis": "图像分析", "image_analysis": "图像分析",
"image_vectorize": "图片矢量化",
"video_generate": "视频生成", "video_generate": "视频生成",
"image_to_video": "图生视频", "image_to_video": "图生视频",
"text_to_video": "文生视频", "text_to_video": "文生视频",
"video_edit": "视频编辑", "video_edit": "视频编辑",
"video_understanding": "视频理解", "video_understanding": "视频理解",
"video_enhance": "视频增强",
"audio_generate": "音频生成", "audio_generate": "音频生成",
"text_to_speech": "语音合成", "text_to_speech": "语音合成",
"voice_clone": "音色克隆", "voice_clone": "音色克隆",
+54 -12
View File
@@ -23,8 +23,8 @@ type SkillBundleMetadataResponse struct {
Modules []string `json:"modules" example:"model-runtime"` Modules []string `json:"modules" example:"model-runtime"`
FileName string `json:"fileName" example:"ai-gateway-ops-management-v1.0.2.zip"` 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"` DownloadPath string `json:"downloadPath" example:"/api/v1/public/skills/ai-gateway-ops-management/download"`
APIDocsJSONPath string `json:"apiDocsJsonPath" example:"/api-docs-json"` APIDocsJSONPath string `json:"apiDocsJsonPath" example:"/api/v1/openapi.json"`
APIDocsYAMLPath string `json:"apiDocsYamlPath" example:"/api-docs-yaml"` APIDocsYAMLPath string `json:"apiDocsYamlPath" example:"/api/v1/openapi.yaml"`
} }
type ErrorEnvelope struct { type ErrorEnvelope struct {
@@ -210,17 +210,31 @@ type PricingEstimateResponse struct {
RequestFingerprint string `json:"requestFingerprint" example:"76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f"` RequestFingerprint string `json:"requestFingerprint" example:"76ef6a537de8e71bd1ca93acadc078dbdbfa9f17e45224e4f9df59f535d2886f"`
} }
type DailyTokenUsageResponse struct {
Items []store.DailyTokenUsage `json:"items"`
TokenDays []store.DailyTokenUsage `json:"tokenDays"`
Summary dailyTokenUsageSummary `json:"summary"`
Range map[string]string `json:"range"`
}
type TaskRequest struct { type TaskRequest struct {
Model string `json:"model" example:"gpt-4o-mini"` Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages,omitempty"` Messages []ChatMessage `json:"messages,omitempty"`
Input interface{} `json:"input,omitempty"` Input interface{} `json:"input,omitempty"`
Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"` Query string `json:"query,omitempty" example:"Which document mentions EasyAI Gateway?"`
Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."` Documents []string `json:"documents,omitempty"`
TextFileID string `json:"text_file_id,omitempty" example:""` Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"`
VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"` Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."`
Stream *bool `json:"stream,omitempty" example:"false"` TextFileID string `json:"text_file_id,omitempty" example:""`
RunMode string `json:"runMode,omitempty" example:"simulation"` VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"`
MaxTokens *int `json:"max_tokens,omitempty" example:"512"` AudioURL string `json:"audio_url,omitempty" example:"https://example.com/source-voice.mp3"`
DisplayName string `json:"display_name,omitempty" example:"EasyAI DEV acceptance"`
PromptAudioURL string `json:"prompt_audio_url,omitempty" example:"https://example.com/prompt-voice.mp3"`
PromptText string `json:"prompt_text,omitempty" example:"EasyAI voice clone prompt."`
PreviewModel string `json:"preview_model,omitempty" example:"speech-2.8-hd"`
Stream *bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
// MaxCompletionTokens includes visible output and reasoning tokens. // MaxCompletionTokens includes visible output and reasoning tokens.
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"` MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"` MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"`
@@ -368,6 +382,34 @@ type ImageEditRequest struct {
RunMode string `json:"runMode,omitempty" example:"simulation"` RunMode string `json:"runMode,omitempty" example:"simulation"`
} }
type ImageVectorizeSource struct {
URL string `json:"url,omitempty" example:"https://example.com/source.png"`
VectorizerTaskID string `json:"vectorizerTaskId,omitempty" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
}
type ImageVectorizeRequest struct {
Model string `json:"model,omitempty" example:"easy-image-vectorizer-1"`
Source ImageVectorizeSource `json:"source"`
Format string `json:"format,omitempty" example:"svg" enums:"svg,eps,pdf,dxf,png"`
MaxColors int `json:"maxColors,omitempty" example:"16" enums:"0,2,4,8,16,32"`
CleanupLevel string `json:"cleanupLevel,omitempty" example:"standard" enums:"low,standard,strong"`
}
type VideoUpscaleRequest struct {
Model string `json:"model,omitempty" example:"easy-proteus-standard-4"`
VideoURL string `json:"video_url" example:"https://example.com/source.mp4"`
Operation string `json:"operation,omitempty" example:"upscale" enums:"upscale"`
TargetResolution string `json:"target_resolution,omitempty" example:"1080p"`
OutputWidth int `json:"output_width,omitempty" example:"1920"`
OutputHeight int `json:"output_height,omitempty" example:"1080"`
PreserveAudio *bool `json:"preserve_audio,omitempty" example:"true"`
Duration float64 `json:"duration,omitempty" example:"3"`
SourceResolution string `json:"source_resolution,omitempty" example:"480p"`
SourceFrameRate float64 `json:"source_frame_rate,omitempty" example:"24"`
TargetFrameRate float64 `json:"target_frame_rate,omitempty" example:"24"`
SlowMotionRate float64 `json:"slow_motion_rate,omitempty" example:"1"`
}
type VideoGenerationRequest struct { type VideoGenerationRequest struct {
Model string `json:"model" example:"video-model"` Model string `json:"model" example:"video-model"`
Prompt string `json:"prompt" example:"A cinematic drone shot over mountains"` Prompt string `json:"prompt" example:"A cinematic drone shot over mountains"`
@@ -161,7 +161,7 @@ func validPricingRuleSetInput(input store.PricingRuleSetInput) bool {
return false return false
} }
switch calculator := strings.TrimSpace(rule.CalculatorType); calculator { switch calculator := strings.TrimSpace(rule.CalculatorType); calculator {
case "", "token_usage", "unit_weight", "duration_weight": case "", "token_usage", "unit_weight", "duration_weight", "transition_matrix":
default: default:
return false return false
} }
@@ -0,0 +1,76 @@
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/images/vectorize",
"/api/v1/videos/generations",
"/api/v1/videos/upscales",
"/api/v1/pricing/estimate",
"/api/workspace/token-usage/daily",
"/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) { func TestSecurityEventWriteAuditFailsClosedBeforeMutation(t *testing.T) {
server := &Server{} server := &Server{}
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/security-events/connection/verify", nil) request := httptest.NewRequest(http.MethodPost, "/api/admin/system/security-events/connection/verify", nil)
+26 -6
View File
@@ -127,12 +127,16 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", server.health) mux.HandleFunc("GET /healthz", server.health)
mux.HandleFunc("GET /readyz", server.ready) 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.Handle("GET /metrics", securityEventMetrics.DynamicHandler(db))
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset) mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset) mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset) mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
mux.HandleFunc("GET /api-docs-json", server.apiDocsJSON) mux.HandleFunc("GET /api-docs-json", server.apiDocsJSON)
mux.HandleFunc("GET /api-docs-yaml", server.apiDocsYAML) 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/metadata", server.getOpsManagementSkillMetadata)
mux.HandleFunc("GET /api/v1/public/skills/ai-gateway-ops-management/download", server.downloadOpsManagementSkill) mux.HandleFunc("GET /api/v1/public/skills/ai-gateway-ops-management/download", server.downloadOpsManagementSkill)
@@ -191,6 +195,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("GET /api/workspace/user-groups", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listCurrentUserGroups))) mux.Handle("GET /api/workspace/user-groups", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listCurrentUserGroups)))
mux.Handle("GET /api/workspace/wallet", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getWallet))) mux.Handle("GET /api/workspace/wallet", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getWallet)))
mux.Handle("GET /api/workspace/wallet/transactions", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions))) mux.Handle("GET /api/workspace/wallet/transactions", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions)))
mux.Handle("GET /api/workspace/token-usage/daily", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.dailyTokenUsage)))
mux.Handle("GET /api/workspace/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks))) mux.Handle("GET /api/workspace/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /api/workspace/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask))) mux.Handle("GET /api/workspace/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("POST /api/workspace/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask))) mux.Handle("POST /api/workspace/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
@@ -252,12 +257,15 @@ 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/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("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/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/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", false))) 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", false))) 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", false))) 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", false))) 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", false))) mux.Handle("POST /api/v1/images/vectorize", server.requireUser(auth.PermissionBasic, server.createImageVectorizeTask()))
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", true)))
mux.Handle("POST /api/v1/videos/upscales", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
mux.Handle("POST /api/v1/video/upscale", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
mux.Handle("POST /api/v1/video/generations", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createLegacyVolcesVideoGeneration))) 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("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getLegacyVolcesVideoResult)))
mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true))) mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
@@ -275,10 +283,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", 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("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("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.registerGeminiGenerateContentRoutes(mux)
server.registerKlingCompatibilityRoutes(mux) server.registerKlingCompatibilityRoutes(mux)
mux.Handle("POST /upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload))) 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 /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", 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("GET /api/v1/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("POST /api/v1/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask))) mux.Handle("POST /api/v1/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
@@ -301,8 +315,14 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true))) mux.Handle("POST /v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
mux.Handle("POST /images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true))) mux.Handle("POST /images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true))) mux.Handle("POST /v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
mux.Handle("POST /images/vectorize", server.requireUser(auth.PermissionBasic, server.createImageVectorizeTask()))
mux.Handle("POST /v1/images/vectorize", server.requireUser(auth.PermissionBasic, server.createImageVectorizeTask()))
mux.Handle("POST /video/upscale", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
mux.Handle("POST /v1/video/upscale", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
mux.Handle("POST /v1/videos/omni-video", server.requireKelingAPIKey(http.HandlerFunc(server.createKelingOmniVideo))) 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("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 /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 /v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
mux.Handle("POST /music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true))) mux.Handle("POST /music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
@@ -16,8 +16,6 @@ import (
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope // @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/voice_clone/voices [get] // @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) { func (s *Server) listClonedVoices(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context()) user, ok := auth.UserFromContext(r.Context())
if !ok { if !ok {
@@ -51,8 +49,6 @@ func (s *Server) listClonedVoices(w http.ResponseWriter, r *http.Request) {
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope // @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/voice_clone/voices/{voiceID} [delete] // @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) { func (s *Server) deleteClonedVoice(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context()) user, ok := auth.UserFromContext(r.Context())
if !ok { if !ok {
@@ -17,13 +17,13 @@ const volcesContentsCompatibilityMarker = "volces_contents_generations_v3"
// createVolcesContentsGenerationTask godoc // createVolcesContentsGenerationTask godoc
// @Summary 创建火山内容生成任务 // @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 // @Tags volces-compatible
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Success 200 {object} map[string]any // @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) { func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context()) user, ok := auth.UserFromContext(r.Context())
if !ok || user == nil { if !ok || user == nil {
@@ -48,8 +48,9 @@ func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
// @Tags volces-compatible // @Tags volces-compatible
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]any // @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) { func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
task, ok := s.volcesCompatibleTaskForUser(w, r) task, ok := s.volcesCompatibleTaskForUser(w, r)
if !ok { if !ok {
@@ -64,7 +65,7 @@ func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Success 200 {object} map[string]any // @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) { func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context()) user, ok := auth.UserFromContext(r.Context())
if !ok || user == nil { if !ok || user == nil {
@@ -104,8 +105,9 @@ func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *htt
// @Tags volces-compatible // @Tags volces-compatible
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]any // @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) { func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
task, ok := s.volcesCompatibleTaskForUser(w, r) task, ok := s.volcesCompatibleTaskForUser(w, r)
if !ok { if !ok {
@@ -170,6 +172,7 @@ func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *htt
// @Tags volces-compatible // @Tags volces-compatible
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]any // @Success 200 {object} map[string]any
// @Router /api/v1/ai/result/{taskID} [get] // @Router /api/v1/ai/result/{taskID} [get]
func (s *Server) getLegacyVolcesVideoResult(w http.ResponseWriter, r *http.Request) { func (s *Server) getLegacyVolcesVideoResult(w http.ResponseWriter, r *http.Request) {
+4 -1
View File
@@ -105,7 +105,10 @@ func (manager *Manager) SecurityEventReceiver() http.Handler {
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil { if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
return runtime.SecurityEvents return runtime.SecurityEvents
} }
return manager.SecurityEventManager() if securityEventManager := manager.SecurityEventManager(); securityEventManager != nil {
return securityEventManager
}
return nil
} }
// SecurityEventManager resolves the manager used by administrative recovery // 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) { func TestSecurityEventManagerPrefersActiveRuntime(t *testing.T) {
active := &securityevents.ConnectionManager{} active := &securityevents.ConnectionManager{}
prepared := &securityevents.ConnectionManager{} prepared := &securityevents.ConnectionManager{}
@@ -0,0 +1,32 @@
package runner
import (
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestCandidateWithEnvironmentCredentialsResolvesNamesAtRuntime(t *testing.T) {
t.Setenv("TEST_GATEWAY_PROVIDER_SECRET", "secret-value")
candidate, err := candidateWithEnvironmentCredentials(store.RuntimeModelCandidate{
Credentials: map[string]any{"safe": "existing"},
PlatformConfig: map[string]any{"credentialEnv": map[string]any{
"apiKey": "TEST_GATEWAY_PROVIDER_SECRET",
}},
})
if err != nil {
t.Fatal(err)
}
if candidate.Credentials["apiKey"] != "secret-value" || candidate.Credentials["safe"] != "existing" {
t.Fatalf("credentials not resolved: %+v", candidate.Credentials)
}
}
func TestCandidateWithEnvironmentCredentialsDoesNotLeakMissingValue(t *testing.T) {
candidate := store.RuntimeModelCandidate{PlatformConfig: map[string]any{"credentialEnv": map[string]any{"apiKey": "MISSING_GATEWAY_PROVIDER_SECRET"}}}
_, err := candidateWithEnvironmentCredentials(candidate)
if err == nil || strings.Contains(err.Error(), "MISSING_GATEWAY_PROVIDER_SECRET") {
t.Fatalf("missing credential error should be generic: %v", err)
}
}
@@ -39,6 +39,20 @@ func TestVideoModelTypeInferenceReadsContentArray(t *testing.T) {
} }
} }
func TestAdvancedMediaModelTypeInference(t *testing.T) {
if got := modelTypeFromKind("images.vectorize", nil); got != "image_vectorize" {
t.Fatalf("images.vectorize model type = %s", got)
}
if got := modelTypeFromKind("videos.upscales", nil); got != "video_enhance" {
t.Fatalf("videos.upscales model type = %s", got)
}
for _, alias := range []string{"vectorize", "image-vectorizer", "image_vectorize"} {
if got := canonicalModelType(alias); got != "image_vectorize" {
t.Fatalf("canonical image vectorize alias %q = %q", alias, got)
}
}
}
func TestVideoContentTextContributesToTokenEstimate(t *testing.T) { func TestVideoContentTextContributesToTokenEstimate(t *testing.T) {
tokens := estimateRequestTokens(map[string]any{ tokens := estimateRequestTokens(map[string]any{
"model": "demo-video", "model": "demo-video",
+169
View File
@@ -3,6 +3,7 @@ package runner
import ( import (
"context" "context"
"math" "math"
"strconv"
"strings" "strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
@@ -131,6 +132,20 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
resource = "image_edit" resource = "image_edit"
baseKey = "editBase" baseKey = "editBase"
} }
if kind == "images.vectorize" {
resource = "image_vectorize"
unit = "conversion"
baseKey = "vectorizeBase"
}
if kind == "videos.upscales" {
resource = "video_enhance"
unit = "5s_video"
baseKey = "videoEnhanceBase"
inputs := resolveVideoEnhancePricingInputs(body, response)
amount, details := videoEnhanceAmount(config, inputs, float64(count), resourcePrice(config, resource, baseKey, "basePrice"))
amount = math.Ceil(amount*1e8) / 1e8 * discount
return []any{billingLineWithDetails(candidate, resource, unit, float64(count)*inputs.DurationUnits, roundPrice(amount), discount, simulated, details)}
}
if kind == "videos.generations" { if kind == "videos.generations" {
resource = "video" resource = "video"
unit = "5s_video" unit = "5s_video"
@@ -193,6 +208,160 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
return []any{billingLine(candidate, resource, unit, count, roundPrice(amount), discount, simulated)} return []any{billingLine(candidate, resource, unit, count, roundPrice(amount), discount, simulated)}
} }
type videoEnhancePricingInputs struct {
DurationSeconds, DurationUnits, SourceFrameRate, TargetFrameRate, SlowMotionRate float64
SourceResolution, TargetResolution, Model string
}
var videoEnhanceResolutionPixels = map[string]float64{
"480p": 854 * 480, "720p": 1280 * 720, "1080p": 1920 * 1080,
"1440p": 2560 * 1440, "2160p": 3840 * 2160,
}
var videoEnhanceResolutionBaseline = map[string]float64{"480p": 1, "720p": 1.4, "1080p": 1.8, "1440p": 2.1, "2160p": 2.6}
func resolveVideoEnhancePricingInputs(body map[string]any, response clients.Response) videoEnhancePricingInputs {
merged := cloneMap(body)
if data, ok := response.Result["data"].([]any); ok && len(data) > 0 {
if item, ok := data[0].(map[string]any); ok {
for _, key := range []string{"duration", "source_resolution", "target_resolution", "source_frame_rate", "target_frame_rate", "slow_motion_rate"} {
if merged[key] == nil && item[key] != nil {
merged[key] = item[key]
}
}
}
}
duration := floatFromAny(merged["duration"])
if duration <= 0 {
duration = 5
}
targetValue := firstNonEmptyString(stringFromMap(merged, "target_resolution"), stringFromMap(merged, "output_resolution"), stringFromMap(merged, "resolution"))
if dimensions := videoEnhanceDimensionsValue(merged, "output_width", "output_height"); dimensions != "" {
targetValue = dimensions
}
target := normalizeVideoEnhanceResolution(targetValue, "1080p")
sourceValue := firstNonEmptyString(stringFromMap(merged, "source_resolution"), stringFromMap(merged, "resolution"))
if dimensions := videoEnhanceDimensionsValue(merged, "source_width", "source_height"); dimensions != "" {
sourceValue = dimensions
}
source := normalizeVideoEnhanceResolution(sourceValue, target)
sourceFPS := floatFromAny(firstPresentValue(merged, "source_frame_rate", "frame_rate", "frameRate"))
if sourceFPS <= 0 {
sourceFPS = 24
}
targetFPS := floatFromAny(firstPresentValue(merged, "target_frame_rate", "output_frame_rate"))
if targetFPS <= 0 {
targetFPS = sourceFPS
}
slowMotion := floatFromAny(merged["slow_motion_rate"])
if slowMotion <= 0 {
slowMotion = 1
}
return videoEnhancePricingInputs{
DurationSeconds: duration, DurationUnits: math.Max(1, math.Round(duration)/5),
SourceResolution: source, TargetResolution: target,
SourceFrameRate: sourceFPS, TargetFrameRate: targetFPS, SlowMotionRate: slowMotion,
Model: firstNonEmptyString(stringFromMap(merged, "model"), stringFromMap(merged, "enhancement_model")),
}
}
func videoEnhanceDimensionsValue(values map[string]any, widthKey, heightKey string) string {
width := int(math.Round(floatFromAny(values[widthKey])))
height := int(math.Round(floatFromAny(values[heightKey])))
if width <= 0 || height <= 0 {
return ""
}
return strconv.Itoa(width) + "x" + strconv.Itoa(height)
}
func normalizeVideoEnhanceResolution(value, fallback string) string {
normalized := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(value)), "_upscale")
if normalized == "2k" {
normalized = "1440p"
}
if normalized == "4k" {
normalized = "2160p"
}
if width, height, ok := parseVideoEnhanceDimensions(normalized); ok {
pixels := float64(width * height)
for _, bucket := range []string{"480p", "720p", "1080p", "1440p", "2160p"} {
if pixels <= videoEnhanceResolutionPixels[bucket] {
return bucket
}
}
return "2160p"
}
if _, ok := videoEnhanceResolutionPixels[normalized]; ok {
return normalized
}
if _, ok := videoEnhanceResolutionPixels[fallback]; ok {
return fallback
}
return "1080p"
}
func parseVideoEnhanceDimensions(value string) (int, int, bool) {
parts := strings.Split(strings.ReplaceAll(value, " ", ""), "x")
if len(parts) != 2 {
return 0, 0, false
}
width, widthErr := strconv.Atoi(parts[0])
height, heightErr := strconv.Atoi(parts[1])
return width, height, widthErr == nil && heightErr == nil && width > 0 && height > 0
}
func videoEnhanceAmount(config map[string]any, input videoEnhancePricingInputs, count, basePrice float64) (float64, map[string]any) {
transitionKey := input.SourceResolution + "->" + input.TargetResolution
minimumResolutionWeight := videoEnhanceResolutionBaseline[input.TargetResolution]
resolutionWeight, configuredResolution := pricingDynamicWeight(config, "video_enhance", "resolutionTransitions", transitionKey)
if !configuredResolution {
resolutionWeight = math.Max(minimumResolutionWeight, videoEnhanceResolutionPixels[input.TargetResolution]/videoEnhanceResolutionPixels[input.SourceResolution])
} else {
resolutionWeight = math.Max(minimumResolutionWeight, resolutionWeight)
}
sourceFPS := math.Max(1, math.Round(input.SourceFrameRate))
targetFPS := math.Max(1, math.Round(input.TargetFrameRate))
frameKey := strconv.Itoa(int(sourceFPS)) + "->" + strconv.Itoa(int(targetFPS))
minimumFrameWeight := math.Max(1, targetFPS/24)
frameWeight, configuredFrame := pricingDynamicWeight(config, "video_enhance", "frameRateTransitions", frameKey)
if !configuredFrame {
frameWeight = minimumFrameWeight
} else {
frameWeight = math.Max(minimumFrameWeight, frameWeight)
}
modelWeight, configuredModel := pricingDynamicWeight(config, "video_enhance", "modelWeights", input.Model)
if !configuredModel {
modelWeight = 1
}
markup, foundMarkup := pricingDynamicWeight(config, "video_enhance", "", "markup")
if !foundMarkup {
markup = 1
}
amount := count * input.DurationUnits * basePrice * resolutionWeight * frameWeight * input.SlowMotionRate * modelWeight * markup
return amount, map[string]any{
"count": count, "durationSeconds": input.DurationSeconds, "durationUnit": "5s", "durationUnitCount": input.DurationUnits,
"sourceResolution": input.SourceResolution, "targetResolution": input.TargetResolution,
"resolutionTransitionKey": transitionKey, "resolutionTransitionWeight": resolutionWeight, "resolutionTransitionFallback": !configuredResolution,
"sourceFrameRate": input.SourceFrameRate, "targetFrameRate": input.TargetFrameRate,
"frameRateTransitionKey": frameKey, "frameRateTransitionWeight": frameWeight, "frameRateTransitionFallback": !configuredFrame,
"slowMotionRate": input.SlowMotionRate, "model": input.Model, "modelWeight": modelWeight, "markup": markup,
}
}
func pricingDynamicWeight(config map[string]any, resource, group, key string) (float64, bool) {
resourceConfig, _ := config[resource].(map[string]any)
dynamic, _ := resourceConfig["dynamicWeight"].(map[string]any)
if group == "" {
value, ok := dynamic[key]
weight := floatFromAny(value)
return weight, ok && weight > 0
}
values, _ := dynamic[group].(map[string]any)
value, ok := values[key]
weight := floatFromAny(value)
return weight, ok && weight > 0
}
func (s *Service) effectiveBillingConfig(ctx context.Context, candidate store.RuntimeModelCandidate) map[string]any { func (s *Service) effectiveBillingConfig(ctx context.Context, candidate store.RuntimeModelCandidate) map[string]any {
var inheritedRuleSetConfig map[string]any var inheritedRuleSetConfig map[string]any
if ruleSetID := firstNonEmptyString(candidate.BasePricingRuleSetID, candidate.PlatformPricingRuleSetID); ruleSetID != "" && s.store != nil { if ruleSetID := firstNonEmptyString(candidate.BasePricingRuleSetID, candidate.PlatformPricingRuleSetID); ruleSetID != "" && s.store != nil {
+58
View File
@@ -93,6 +93,64 @@ func TestVideoBillingEstimateProratesFiveSecondUnitsAndDynamicWeights(t *testing
} }
} }
func TestAdvancedMediaBillingUsesConversionAndTransitionMatrix(t *testing.T) {
service := &Service{}
vectorCandidate := store.RuntimeModelCandidate{ModelName: "easy-image-vectorizer-1", BaseBillingConfig: map[string]any{
"image_vectorize": map[string]any{"basePrice": 200},
}}
vectorLine := firstBillingLine(t, service.billings(context.Background(), nil, "images.vectorize", map[string]any{"count": 1}, vectorCandidate, clients.Response{}, true))
if vectorLine["resourceType"] != "image_vectorize" || vectorLine["unit"] != "conversion" || floatFromAny(vectorLine["amount"]) != 200 {
t.Fatalf("unexpected vectorizer billing: %+v", vectorLine)
}
topazCandidate := store.RuntimeModelCandidate{ModelName: "easy-proteus-standard-4", BaseBillingConfig: map[string]any{
"video_enhance": map[string]any{
"basePrice": 100,
"dynamicWeight": map[string]any{
"resolutionTransitions": map[string]any{"720p->1080p": 1.8},
"frameRateTransitions": map[string]any{"24->60": 2.5},
"markup": 1,
},
},
}}
topazLine := firstBillingLine(t, service.billings(context.Background(), nil, "videos.upscales", map[string]any{
"count": 1, "duration": 5, "source_resolution": "720p", "target_resolution": "1080p",
"source_frame_rate": 24, "target_frame_rate": 60, "slow_motion_rate": 2,
"model": "easy-proteus-standard-4",
}, topazCandidate, clients.Response{}, true))
if got, want := floatFromAny(topazLine["amount"]), 900.0; got != want {
t.Fatalf("video enhance amount=%v, want=%v, line=%+v", got, want, topazLine)
}
if topazLine["resolutionTransitionKey"] != "720p->1080p" || topazLine["frameRateTransitionKey"] != "24->60" {
t.Fatalf("missing transition evidence: %+v", topazLine)
}
}
func TestVideoEnhancePricingFallbackUsesPixelsAndBaseline(t *testing.T) {
amount, details := videoEnhanceAmount(map[string]any{"video_enhance": map[string]any{"dynamicWeight": map[string]any{}}}, videoEnhancePricingInputs{
DurationSeconds: 5, DurationUnits: 1, SourceResolution: "720p", TargetResolution: "2160p",
SourceFrameRate: 30, TargetFrameRate: 60, SlowMotionRate: 1,
}, 1, 100)
if amount != 2250 {
t.Fatalf("fallback amount=%v, want=2250 details=%+v", amount, details)
}
}
func TestVideoEnhancePricingPrefersProbedDimensions(t *testing.T) {
inputs := resolveVideoEnhancePricingInputs(map[string]any{
"duration": 3,
"source_resolution": "180p",
"source_width": 320,
"source_height": 180,
"target_resolution": "720p",
"output_width": 1280,
"output_height": 720,
}, clients.Response{})
if inputs.SourceResolution != "480p" || inputs.TargetResolution != "720p" {
t.Fatalf("dimension buckets = %s->%s, want 480p->720p", inputs.SourceResolution, inputs.TargetResolution)
}
}
func TestMusicBillingUsesSongResourceAndOutputCount(t *testing.T) { func TestMusicBillingUsesSongResourceAndOutputCount(t *testing.T) {
service := &Service{} service := &Service{}
candidate := store.RuntimeModelCandidate{ candidate := store.RuntimeModelCandidate{
+22
View File
@@ -659,6 +659,28 @@ func (s *Service) billingsWithResolvedPricingV2(
resource = "image_edit" resource = "image_edit"
baseKey = "editBase" baseKey = "editBase"
} }
if kind == "images.vectorize" {
resource = "image_vectorize"
unit = "conversion"
baseKey = "vectorizeBase"
}
if kind == "videos.upscales" {
resource = "video_enhance"
unit = "5s_video"
baseKey = "videoEnhanceBase"
inputs := resolveVideoEnhancePricingInputs(body, response)
price, priceErr := pricing.requiredPrice(resource, baseKey, "basePrice")
if priceErr != nil {
return nil, 0, resolvedPricing{}, priceErr
}
rawAmount, details := videoEnhanceAmount(pricing.Config, inputs, float64(count), price.Float64())
rawAmount = math.Ceil(rawAmount*1e8) / 1e8
amount, amountErr := fixedAmountFromAny(rawAmount * discount.Float64())
if amountErr != nil {
return nil, 0, resolvedPricing{}, pricing.calculationError(resource, amountErr)
}
return []any{buildLine(resource, unit, float64(count)*inputs.DurationUnits, amount, details)}, amount, pricing, nil
}
if kind == "videos.generations" { if kind == "videos.generations" {
resource = "video" resource = "video"
unit = "5s_video" unit = "5s_video"
+103 -1
View File
@@ -5,6 +5,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -90,6 +92,8 @@ func New(cfg config.Config, db *store.Store, logger *slog.Logger, observers ...b
"volces": clients.VolcesClient{HTTPClient: httpClients.none}, "volces": clients.VolcesClient{HTTPClient: httpClients.none},
"keling": clients.KelingClient{HTTPClient: httpClients.none}, "keling": clients.KelingClient{HTTPClient: httpClients.none},
"kling": clients.KelingClient{HTTPClient: httpClients.none}, "kling": clients.KelingClient{HTTPClient: httpClients.none},
"vectorizer": clients.VectorizerClient{HTTPClient: httpClients.none},
"topaz": clients.TopazClient{HTTPClient: httpClients.none},
"universal": clients.UniversalClient{HTTPClient: httpClients.none, ScriptExecutor: scriptExecutor}, "universal": clients.UniversalClient{HTTPClient: httpClients.none, ScriptExecutor: scriptExecutor},
"simulation": clients.SimulationClient{}, "simulation": clients.SimulationClient{},
}, },
@@ -857,6 +861,11 @@ func billingItemsFixedTotal(items []any) fixedAmount {
} }
func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, pricing resolvedPricing, attemptNo int, onDelta clients.StreamDelta, responseExecution responseExecutionContext, singleSourceProtected bool, cacheAffinityPolicy map[string]any, cacheAffinityRecordKeys []string) (clients.Response, error) { func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user *auth.User, body map[string]any, preprocessing parameterPreprocessingLog, candidate store.RuntimeModelCandidate, pricing resolvedPricing, attemptNo int, onDelta clients.StreamDelta, responseExecution responseExecutionContext, singleSourceProtected bool, cacheAffinityPolicy map[string]any, cacheAffinityRecordKeys []string) (clients.Response, error) {
var err error
candidate, err = candidateWithEnvironmentCredentials(candidate)
if err != nil {
return clients.Response{}, err
}
simulated := isSimulation(task, candidate) simulated := isSimulation(task, candidate)
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing)) baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
reservations := s.rateLimitReservations(ctx, user, candidate, body) reservations := s.rateLimitReservations(ctx, user, candidate, body)
@@ -949,6 +958,15 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
}) })
return clients.Response{}, err return clients.Response{}, err
} }
providerBody, err = s.preparePrivateProviderRequest(ctx, user, task.Kind, providerBody)
if err != nil {
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
AttemptID: attemptID, Status: "failed", Retryable: false,
Metrics: mergeMetrics(baseAttemptMetrics, map[string]any{"error": err.Error(), "retryable": false}),
ErrorCode: clients.ErrorCode(err), ErrorMessage: err.Error(),
})
return clients.Response{}, err
}
providerBody, err = s.hydrateProviderRequestAssets(ctx, providerBody, candidate) providerBody, err = s.hydrateProviderRequestAssets(ctx, providerBody, candidate)
if err != nil { if err != nil {
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{ _ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
@@ -1421,6 +1439,8 @@ func modelTypeFromKind(kind string, body map[string]any) string {
return "image_edit" return "image_edit"
} }
return "image_generate" return "image_generate"
case "images.vectorize":
return "image_vectorize"
case "videos.generations": case "videos.generations":
if videoRequestHasVideoOrAudioReference(body) { if videoRequestHasVideoOrAudioReference(body) {
return "omni_video" return "omni_video"
@@ -1429,6 +1449,8 @@ func modelTypeFromKind(kind string, body map[string]any) string {
return "image_to_video" return "image_to_video"
} }
return "video_generate" return "video_generate"
case "videos.upscales":
return "video_enhance"
case "song.generations", "music.generations": case "song.generations", "music.generations":
return "audio_generate" return "audio_generate"
case "speech.generations": case "speech.generations":
@@ -1463,6 +1485,10 @@ func canonicalModelType(value string) string {
return "text_to_speech" return "text_to_speech"
case "voice", "voice_clone", "voiceclone", "voice.cloning": case "voice", "voice_clone", "voiceclone", "voice.cloning":
return "voice_clone" return "voice_clone"
case "vectorize", "image_vectorizer", "image_vectorize":
return "image_vectorize"
case "video_upscale", "video_enhance", "upscale":
return "video_enhance"
default: default:
return normalized return normalized
} }
@@ -1470,7 +1496,7 @@ func canonicalModelType(value string) string {
func isKnownModelType(value string) bool { func isKnownModelType(value string) bool {
switch value { switch value {
case "text_generate", "text_embedding", "text_rerank", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni", "audio_generate", "text_to_speech", "voice_clone": case "text_generate", "text_embedding", "text_rerank", "image_generate", "image_edit", "image_vectorize", "video_generate", "video_enhance", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni", "audio_generate", "text_to_speech", "voice_clone":
return true return true
default: default:
return false return false
@@ -1703,6 +1729,14 @@ func validateRequest(kind string, body map[string]any) error {
if strings.TrimSpace(stringFromMap(body, "prompt")) == "" { if strings.TrimSpace(stringFromMap(body, "prompt")) == "" {
return errors.New("prompt is required") return errors.New("prompt is required")
} }
case "images.vectorize":
if vectorizerSourceURL(body) == "" && vectorizerSourceTaskID(body) == "" {
return &clients.ClientError{Code: "invalid_parameter", Message: "source.url or source.vectorizerTaskId is required", Param: "source", StatusCode: 400}
}
case "videos.upscales":
if firstNonEmptyString(stringFromMap(body, "video_url"), stringFromMap(body, "videoUrl")) == "" {
return &clients.ClientError{Code: "invalid_parameter", Message: "video_url is required", Param: "video_url", StatusCode: 400}
}
case "song.generations", "music.generations": case "song.generations", "music.generations":
if strings.TrimSpace(stringFromMap(body, "prompt")) == "" { if strings.TrimSpace(stringFromMap(body, "prompt")) == "" {
return errors.New("prompt is required") return errors.New("prompt is required")
@@ -1722,6 +1756,74 @@ func validateRequest(kind string, body map[string]any) error {
return nil return nil
} }
var credentialEnvironmentName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`)
func candidateWithEnvironmentCredentials(candidate store.RuntimeModelCandidate) (store.RuntimeModelCandidate, error) {
references, _ := candidate.PlatformConfig["credentialEnv"].(map[string]any)
if len(references) == 0 {
references, _ = candidate.PlatformConfig["credential_env"].(map[string]any)
}
if len(references) == 0 {
return candidate, nil
}
credentials := cloneMap(candidate.Credentials)
for credentialName, rawEnvironmentName := range references {
environmentName := strings.TrimSpace(fmt.Sprint(rawEnvironmentName))
if !credentialEnvironmentName.MatchString(environmentName) {
return candidate, &clients.ClientError{Code: "missing_credentials", Message: "platform credential environment reference is invalid", Retryable: false}
}
value, ok := os.LookupEnv(environmentName)
if !ok || strings.TrimSpace(value) == "" {
return candidate, &clients.ClientError{Code: "missing_credentials", Message: "platform credential environment variable is not configured", Retryable: false}
}
credentials[credentialName] = value
}
candidate.Credentials = credentials
return candidate, nil
}
func (s *Service) preparePrivateProviderRequest(ctx context.Context, user *auth.User, kind string, body map[string]any) (map[string]any, error) {
if kind != "images.vectorize" {
return body, nil
}
taskID := vectorizerSourceTaskID(body)
if taskID == "" {
return body, nil
}
sourceTask, err := s.store.GetTask(ctx, taskID)
if err != nil || !taskAccessibleToUser(sourceTask, user) || sourceTask.Kind != "images.vectorize" {
return nil, &clients.ClientError{Code: "not_found", Message: "source vectorizer task not found", StatusCode: 404, Retryable: false}
}
imageToken := strings.TrimSpace(fmt.Sprint(sourceTask.RemoteTaskPayload["imageToken"]))
if imageToken == "" {
return nil, &clients.ClientError{Code: "invalid_parameter", Message: "source vectorizer task cannot be reused", Param: "source.vectorizerTaskId", StatusCode: 400, Retryable: false}
}
privateBody := cloneMap(body)
privateBody["_vectorizer_image_token"] = imageToken
if receipt := strings.TrimSpace(fmt.Sprint(sourceTask.RemoteTaskPayload["receipt"])); receipt != "" {
privateBody["_vectorizer_receipt"] = receipt
}
return privateBody, nil
}
func vectorizerSourceURL(body map[string]any) string {
if source, ok := body["source"].(map[string]any); ok {
return strings.TrimSpace(firstNonEmptyString(stringFromMap(source, "url"), stringFromMap(source, "image_url"), stringFromMap(source, "imageUrl")))
}
return strings.TrimSpace(firstNonEmptyString(stringFromMap(body, "image_url"), stringFromMap(body, "imageUrl")))
}
func vectorizerSourceTaskID(body map[string]any) string {
for _, key := range []string{"source", "sourceContext"} {
if source, ok := body[key].(map[string]any); ok {
if taskID := strings.TrimSpace(firstNonEmptyString(stringFromMap(source, "vectorizerTaskId"), stringFromMap(source, "vectorizer_task_id"), stringFromMap(source, "taskId"), stringFromMap(source, "task_id"))); taskID != "" {
return taskID
}
}
}
return strings.TrimSpace(firstNonEmptyString(stringFromMap(body, "vectorizerTaskId"), stringFromMap(body, "vectorizer_task_id")))
}
func hasRerankDocuments(value any) bool { func hasRerankDocuments(value any) bool {
switch typed := value.(type) { switch typed := value.(type) {
case []any: case []any:
+7
View File
@@ -211,6 +211,13 @@ func taskAccessibleToUser(task store.GatewayTask, user *auth.User) bool {
return userID != "" && strings.TrimSpace(task.UserID) == userID return userID != "" && strings.TrimSpace(task.UserID) == userID
} }
// TaskAccessibleToUser applies the same ownership boundary to every public
// task surface. API-key requests are deliberately scoped to the exact key,
// while JWT requests may see the current gateway user's tasks.
func TaskAccessibleToUser(task store.GatewayTask, user *auth.User) bool {
return taskAccessibleToUser(task, user)
}
func gatewayUserIDForAuth(user *auth.User) string { func gatewayUserIDForAuth(user *auth.User) string {
if user == nil { if user == nil {
return "" return ""
+80 -3
View File
@@ -8,10 +8,12 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"mime" "mime"
"mime/multipart" "mime/multipart"
"net"
"net/http" "net/http"
"net/textproto" "net/textproto"
"net/url" "net/url"
@@ -85,6 +87,11 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
if err != nil { if err != nil {
return nil, &clients.ClientError{Code: "upload_config_failed", Message: err.Error(), Retryable: true} return nil, &clients.ClientError{Code: "upload_config_failed", Message: err.Error(), Retryable: true}
} }
// Topaz download URLs are short-lived. Persist them before the task can be
// marked succeeded even when the global policy normally keeps URL media.
if taskKind == "videos.upscales" {
policy.UploadURLMedia = true
}
decisions := make([]generatedAssetDecision, len(data)) decisions := make([]generatedAssetDecision, len(data))
needsUpload := false needsUpload := false
changed := false changed := false
@@ -723,7 +730,8 @@ func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURL
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
resp, err := http.DefaultClient.Do(req) allowLocal := strings.HasPrefix(strings.TrimSpace(asset.URL), "/")
resp, err := generatedAssetHTTPClient(allowLocal).Do(req)
if err != nil { if err != nil {
return nil, "", &clients.ClientError{Code: "upload_source_fetch_failed", Message: err.Error(), Retryable: true} return nil, "", &clients.ClientError{Code: "upload_source_fetch_failed", Message: err.Error(), Retryable: true}
} }
@@ -752,6 +760,47 @@ func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURL
return payload, strings.TrimSpace(strings.Split(contentType, ";")[0]), nil return payload, strings.TrimSpace(strings.Split(contentType, ";")[0]), nil
} }
func generatedAssetHTTPClient(allowLocal bool) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil || len(addresses) == 0 {
return nil, errors.New("generated media DNS resolution failed")
}
for _, address := range addresses {
if generatedAssetBlockedAddress(address.IP) && !(allowLocal && address.IP.IsLoopback()) {
return nil, errors.New("generated media resolved to a blocked network")
}
}
dialer := &net.Dialer{Timeout: 10 * time.Second}
var attempts []error
for _, resolved := range addresses {
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
if dialErr == nil {
return connection, nil
}
attempts = append(attempts, dialErr)
}
return nil, errors.Join(attempts...)
}
return &http.Client{
Transport: transport,
Timeout: 10 * time.Minute,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
func generatedAssetBlockedAddress(ip net.IP) bool {
return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
func (s *Service) generatedAssetFetchURL(raw string) (string, error) { func (s *Service) generatedAssetFetchURL(raw string) (string, error) {
value := strings.TrimSpace(raw) value := strings.TrimSpace(raw)
if value == "" { if value == "" {
@@ -1304,6 +1353,9 @@ func mediaContentTypeFromItem(item map[string]any) string {
func mediaKindForAsset(taskKind string, item map[string]any, sourceKey string, contentType string) string { func mediaKindForAsset(taskKind string, item map[string]any, sourceKey string, contentType string) string {
contentType = strings.ToLower(strings.TrimSpace(contentType)) contentType = strings.ToLower(strings.TrimSpace(contentType))
if generatedContentTypeIsDocument(contentType) {
return "file"
}
if strings.HasPrefix(contentType, "image/") { if strings.HasPrefix(contentType, "image/") {
return "image" return "image"
} }
@@ -1314,6 +1366,9 @@ func mediaKindForAsset(taskKind string, item map[string]any, sourceKey string, c
return "audio" return "audio"
} }
itemType := strings.ToLower(strings.TrimSpace(stringFromAny(item["type"]))) itemType := strings.ToLower(strings.TrimSpace(stringFromAny(item["type"])))
if itemType == "file" || strings.Contains(itemType, "document") {
return "file"
}
if strings.Contains(itemType, "video") { if strings.Contains(itemType, "video") {
return "video" return "video"
} }
@@ -1346,6 +1401,8 @@ func defaultContentTypeForGeneratedAsset(kind string) string {
return "video/mp4" return "video/mp4"
case "audio": case "audio":
return "audio/mpeg" return "audio/mpeg"
case "file":
return "application/octet-stream"
default: default:
return "image/png" return "image/png"
} }
@@ -1354,10 +1411,10 @@ func defaultContentTypeForGeneratedAsset(kind string) string {
func resolvedGeneratedAssetContentType(declared string, kind string, payload []byte) string { func resolvedGeneratedAssetContentType(declared string, kind string, payload []byte) string {
declared = normalizeGeneratedContentType(declared) declared = normalizeGeneratedContentType(declared)
detected := detectGeneratedAssetContentType(payload) detected := detectGeneratedAssetContentType(payload)
if generatedContentTypeIsMedia(detected) { if generatedContentTypeIsMedia(detected) || generatedContentTypeIsDocument(detected) {
return detected return detected
} }
if generatedContentTypeIsMedia(declared) { if generatedContentTypeIsMedia(declared) || generatedContentTypeIsDocument(declared) {
return declared return declared
} }
return defaultContentTypeForGeneratedAsset(kind) return defaultContentTypeForGeneratedAsset(kind)
@@ -1380,6 +1437,15 @@ func generatedContentTypeIsMedia(contentType string) bool {
strings.HasPrefix(contentType, "audio/") strings.HasPrefix(contentType, "audio/")
} }
func generatedContentTypeIsDocument(contentType string) bool {
switch normalizeGeneratedContentType(contentType) {
case "application/pdf", "application/postscript", "application/eps", "application/dxf", "image/vnd.dxf":
return true
default:
return false
}
}
func generatedAssetKindFromContentType(fallback string, contentType string) string { func generatedAssetKindFromContentType(fallback string, contentType string) string {
contentType = normalizeGeneratedContentType(contentType) contentType = normalizeGeneratedContentType(contentType)
if strings.HasPrefix(contentType, "image/") { if strings.HasPrefix(contentType, "image/") {
@@ -1391,6 +1457,9 @@ func generatedAssetKindFromContentType(fallback string, contentType string) stri
if strings.HasPrefix(contentType, "audio/") { if strings.HasPrefix(contentType, "audio/") {
return "audio" return "audio"
} }
if generatedContentTypeIsDocument(contentType) {
return "file"
}
fallback = strings.ToLower(strings.TrimSpace(fallback)) fallback = strings.ToLower(strings.TrimSpace(fallback))
if fallback != "" { if fallback != "" {
return fallback return fallback
@@ -1434,6 +1503,14 @@ func randomHexSuffix(byteCount int) string {
func fileExtensionForContentType(contentType string, kind string) string { func fileExtensionForContentType(contentType string, kind string) string {
normalized := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) normalized := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
switch normalized { switch normalized {
case "application/pdf":
return ".pdf"
case "application/postscript", "application/eps":
return ".eps"
case "application/dxf", "image/vnd.dxf":
return ".dxf"
}
switch normalized {
case "image/jpeg", "image/jpg": case "image/jpeg", "image/jpg":
return ".jpg" return ".jpg"
case "image/webp": case "image/webp":
+19
View File
@@ -52,6 +52,25 @@ func TestGeneratedAssetDecisionUploadsInlineImageBase64(t *testing.T) {
} }
} }
func TestGeneratedAssetDecisionUploadsVectorDocumentWithoutChangingType(t *testing.T) {
item := map[string]any{
"type": "file",
"b64_json": base64.StdEncoding.EncodeToString([]byte("%PDF-1.7\n")),
"mime_type": "application/pdf",
}
decision, err := generatedAssetDecisionForItem("images.vectorize", item, defaultGeneratedAssetUploadPolicy())
if err != nil {
t.Fatal(err)
}
if decision.Inline == nil || decision.Inline.Kind != "file" || decision.Inline.ContentType != "application/pdf" {
t.Fatalf("unexpected vector document decision: %+v", decision)
}
contentType := resolvedGeneratedAssetContentType(decision.Inline.ContentType, decision.Inline.Kind, decision.Inline.Bytes)
if contentType != "application/pdf" || fileExtensionForContentType(contentType, "file") != ".pdf" {
t.Fatalf("vector document type changed: contentType=%s", contentType)
}
}
func TestGeneratedAssetDecisionUploadsInlineVideoBuffer(t *testing.T) { func TestGeneratedAssetDecisionUploadsInlineVideoBuffer(t *testing.T) {
item := map[string]any{ item := map[string]any{
"type": "video", "type": "video",
+4
View File
@@ -215,6 +215,10 @@ func (s *Service) DeleteClonedVoice(ctx context.Context, user *auth.User, rawID
if !ok { if !ok {
return DeletedClonedVoiceResult{}, &clients.ClientError{Code: "cloned_voice_platform_unavailable", Message: "cloned voice platform binding is unavailable", StatusCode: 400, Retryable: false} return DeletedClonedVoiceResult{}, &clients.ClientError{Code: "cloned_voice_platform_unavailable", Message: "cloned voice platform binding is unavailable", StatusCode: 400, Retryable: false}
} }
candidate, err = candidateWithEnvironmentCredentials(candidate)
if err != nil {
return DeletedClonedVoiceResult{}, err
}
requestHTTPClient, err := s.httpClientForCandidate(candidate, false) requestHTTPClient, err := s.httpClientForCandidate(candidate, false)
if err != nil { if err != nil {
return DeletedClonedVoiceResult{}, err return DeletedClonedVoiceResult{}, err
@@ -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. - 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. - 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`. - 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 ## Module Routing
@@ -2,7 +2,7 @@
## Required Inputs ## 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. - Administrator JWT with the `manager` or `admin` role.
- Target provider documentation and authorization material. - Target provider documentation and authorization material.
- Clear requested outcome and whether real upstream calls are allowed. - 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: Do not place credentials in files or reusable commands. Use shell environment variables:
```bash ```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>' 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 \ curl --fail-with-body \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"account":"<admin-account>","password":"<admin-password>"}' \ -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. 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 ```bash
curl --fail-with-body \ curl --fail-with-body \
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
"$GATEWAY_BASE_URL/api/v1/me" "$GATEWAY_PUBLIC_API_BASE/me"
``` ```
## Request Pattern ## Request Pattern
@@ -47,7 +48,7 @@ curl --fail-with-body \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-X POST \ -X POST \
-d '<json-body>' \ -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. 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: The live machine-readable documents are:
- `<gateway-api-base-url>/api-docs-json` - `<gateway-origin>/api/v1/openapi.json`
- `<gateway-api-base-url>/api-docs-yaml` - `<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. 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 ```bash
curl --fail-with-body \ curl --fail-with-body \
-H "Authorization: Bearer <user-jwt>" \ -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. 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, "simulation": true,
"stream": false "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. 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 ## 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.
+4
View File
@@ -486,8 +486,12 @@ func modelTypeAliases(value string) []string {
return []string{"image_generate"} return []string{"image_generate"}
case "images.edits": case "images.edits":
return []string{"image_edit"} return []string{"image_edit"}
case "images.vectorize", "vectorize":
return []string{"image_vectorize"}
case "video", "videos.generations": case "video", "videos.generations":
return []string{"video_generate"} return []string{"video_generate"}
case "videos.upscales", "video_upscale":
return []string{"video_enhance"}
case "omni_video": case "omni_video":
return []string{"video_generate", "image_to_video", "omni_video"} return []string{"video_generate", "image_to_video", "omni_video"}
case "song", "music", "song.generations", "music.generations", "music_generate": case "song", "music", "song.generations", "music.generations", "music_generate":
+68
View File
@@ -0,0 +1,68 @@
package store
import (
"context"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
)
type DailyTokenUsage struct {
Date string `json:"date"`
TotalTokens int64 `json:"totalTokens"`
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CachedInputTokens int64 `json:"cachedInputTokens"`
ResourcePoints float64 `json:"resourcePoints"`
TaskCount int64 `json:"taskCount"`
}
func (s *Store) ListDailyTokenUsage(ctx context.Context, user *auth.User, from, to time.Time, timezone string) ([]DailyTokenUsage, error) {
gatewayUserID := localGatewayUserID(user)
apiKeyID := ""
userID := ""
if user != nil {
apiKeyID = strings.TrimSpace(user.APIKeyID)
userID = strings.TrimSpace(user.ID)
}
if gatewayUserID == "" && userID == "" {
return nil, ErrLocalUserRequired
}
rows, err := s.pool.Query(ctx, `
SELECT to_char(created_at AT TIME ZONE $6, 'YYYY-MM-DD') AS usage_day,
COALESCE(sum(
CASE WHEN jsonb_typeof(usage->'totalTokens') = 'number' THEN (usage->>'totalTokens')::numeric
WHEN jsonb_typeof(usage->'total_tokens') = 'number' THEN (usage->>'total_tokens')::numeric
ELSE COALESCE(CASE WHEN jsonb_typeof(usage->'inputTokens') = 'number' THEN (usage->>'inputTokens')::numeric ELSE 0 END, 0)
+ COALESCE(CASE WHEN jsonb_typeof(usage->'outputTokens') = 'number' THEN (usage->>'outputTokens')::numeric ELSE 0 END, 0)
END
), 0)::bigint AS total_tokens,
COALESCE(sum(CASE WHEN jsonb_typeof(usage->'inputTokens') = 'number' THEN (usage->>'inputTokens')::numeric WHEN jsonb_typeof(usage->'input_tokens') = 'number' THEN (usage->>'input_tokens')::numeric ELSE 0 END), 0)::bigint,
COALESCE(sum(CASE WHEN jsonb_typeof(usage->'outputTokens') = 'number' THEN (usage->>'outputTokens')::numeric WHEN jsonb_typeof(usage->'output_tokens') = 'number' THEN (usage->>'output_tokens')::numeric ELSE 0 END), 0)::bigint,
COALESCE(sum(CASE WHEN jsonb_typeof(usage->'cachedInputTokens') = 'number' THEN (usage->>'cachedInputTokens')::numeric WHEN jsonb_typeof(usage->'cached_input_tokens') = 'number' THEN (usage->>'cached_input_tokens')::numeric ELSE 0 END), 0)::bigint,
COALESCE(sum(final_charge_amount), 0)::float8,
count(*)::bigint
FROM gateway_tasks
WHERE status = 'succeeded'
AND ((NULLIF($1, '')::uuid IS NOT NULL AND gateway_user_id = NULLIF($1, '')::uuid)
OR (NULLIF($1, '')::uuid IS NULL AND NULLIF($2, '') IS NOT NULL AND user_id = $2))
AND (NULLIF($3, '') IS NULL OR api_key_id = $3)
AND created_at >= ($4::date::timestamp AT TIME ZONE $6)
AND created_at < (($5::date + 1)::timestamp AT TIME ZONE $6)
GROUP BY usage_day
ORDER BY usage_day`, gatewayUserID, userID, apiKeyID, from.Format("2006-01-02"), to.Format("2006-01-02"), timezone)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]DailyTokenUsage, 0)
for rows.Next() {
var item DailyTokenUsage
if err := rows.Scan(&item.Date, &item.TotalTokens, &item.InputTokens, &item.OutputTokens, &item.CachedInputTokens, &item.ResourcePoints, &item.TaskCount); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
@@ -0,0 +1,79 @@
package store
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
)
func TestListDailyTokenUsageScopesAPIKeyAndAppliesIANATimezone(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run daily usage PostgreSQL integration tests")
}
ctx := context.Background()
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var databaseName string
if err := db.pool.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.ToLower(databaseName), "test") {
t.Fatalf("refusing to use non-test database %q", databaseName)
}
suffix := fmt.Sprint(time.Now().UnixNano())
var userID, otherUserID string
if err := db.pool.QueryRow(ctx, `INSERT INTO gateway_users (user_key, username) VALUES ($1, $2) RETURNING id::text`, "daily-user-"+suffix, "daily-user-"+suffix).Scan(&userID); err != nil {
t.Fatal(err)
}
if err := db.pool.QueryRow(ctx, `INSERT INTO gateway_users (user_key, username) VALUES ($1, $2) RETURNING id::text`, "daily-other-"+suffix, "daily-other-"+suffix).Scan(&otherUserID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_tasks WHERE gateway_user_id IN ($1::uuid, $2::uuid)`, userID, otherUserID)
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_users WHERE id IN ($1::uuid, $2::uuid)`, userID, otherUserID)
})
insertTask := func(ownerID, keyID, createdAt string, totalTokens int, points float64) {
t.Helper()
_, err := db.pool.Exec(ctx, `
INSERT INTO gateway_tasks (kind, user_id, gateway_user_id, api_key_id, model, status, usage, final_charge_amount, created_at, finished_at)
VALUES ('chat.completions', $1::text, ($1::text)::uuid, $2::text, 'daily-test-model', 'succeeded', jsonb_build_object('totalTokens', $3::bigint, 'inputTokens', $3::bigint - 1, 'outputTokens', 1), $4::numeric, $5::timestamptz, $5::timestamptz)`, ownerID, keyID, totalTokens, points, createdAt)
if err != nil {
t.Fatal(err)
}
}
insertTask(userID, "daily-key-a-"+suffix, "2026-07-20T15:30:00Z", 10, 1)
insertTask(userID, "daily-key-a-"+suffix, "2026-07-21T16:30:00Z", 20, 2)
insertTask(userID, "daily-key-b-"+suffix, "2026-07-20T16:30:00Z", 100, 10)
insertTask(otherUserID, "daily-key-a-"+suffix, "2026-07-20T16:30:00Z", 999, 99)
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
from := time.Date(2026, 7, 20, 0, 0, 0, 0, location)
to := time.Date(2026, 7, 22, 0, 0, 0, 0, location)
keyItems, err := db.ListDailyTokenUsage(ctx, &auth.User{GatewayUserID: userID, APIKeyID: "daily-key-a-" + suffix}, from, to, "Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
if len(keyItems) != 2 || keyItems[0].Date != "2026-07-20" || keyItems[0].TotalTokens != 10 || keyItems[1].Date != "2026-07-22" || keyItems[1].TotalTokens != 20 {
t.Fatalf("API key usage leaked another key/user or ignored timezone: %+v", keyItems)
}
userItems, err := db.ListDailyTokenUsage(ctx, &auth.User{GatewayUserID: userID}, from, to, "Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
if len(userItems) != 3 || userItems[1].Date != "2026-07-21" || userItems[1].TotalTokens != 100 {
t.Fatalf("JWT user usage did not include all owned API keys: %+v", userItems)
}
}
@@ -54,9 +54,13 @@ func billingResourcesForModelTypes(modelTypes []string) map[string]bool {
resources["image"] = true resources["image"] = true
case "images.edits", "image_edit": case "images.edits", "image_edit":
resources["image_edit"] = true resources["image_edit"] = true
case "image_vectorize", "images.vectorize":
resources["image_vectorize"] = true
case "video", "videos.generations", "video_generate", "image_to_video", "text_to_video", case "video", "videos.generations", "video_generate", "image_to_video", "text_to_video",
"video_edit", "omni_video", "video_reference", "video_first_last_frame": "video_edit", "omni_video", "video_reference", "video_first_last_frame":
resources["video"] = true resources["video"] = true
case "video_enhance", "videos.upscales", "video_upscale":
resources["video_enhance"] = true
case "audio", "text_to_speech", "speech", "voice_clone": case "audio", "text_to_speech", "speech", "voice_clone":
resources["audio"] = true resources["audio"] = true
case "music", "music_generate", "audio_generate": case "music", "music_generate", "audio_generate":
@@ -100,8 +104,12 @@ func billingConfigKeyAllowed(key string, resources map[string]bool) bool {
return resources["image"] return resources["image"]
case "image_edit", "imageedit", "editbase": case "image_edit", "imageedit", "editbase":
return resources["image_edit"] return resources["image_edit"]
case "image_vectorize", "imagevectorize", "vectorizebase":
return resources["image_vectorize"]
case "video", "videobase": case "video", "videobase":
return resources["video"] return resources["video"]
case "video_enhance", "videoenhance", "videoenhancebase":
return resources["video_enhance"]
case "audio", "audiobase": case "audio", "audiobase":
return resources["audio"] return resources["audio"]
case "music", "musicbase": case "music", "musicbase":
+2 -2
View File
@@ -29,7 +29,7 @@ const (
) )
func defaultAPIKeyScopes() []string { func defaultAPIKeyScopes() []string {
return []string{"chat", "embedding", "rerank", "image", "video", "music", "audio", "voice_clone"} return []string{"chat", "embedding", "rerank", "image", "image_vectorize", "video", "video_enhance", "music", "audio", "voice_clone"}
} }
func normalizeAPIKeyScopes(scopes []string) []string { func normalizeAPIKeyScopes(scopes []string) []string {
@@ -517,7 +517,7 @@ type GatewayTask struct {
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
AttemptCount int `json:"attemptCount"` AttemptCount int `json:"attemptCount"`
RemoteTaskID string `json:"remoteTaskId,omitempty"` RemoteTaskID string `json:"remoteTaskId,omitempty"`
RemoteTaskPayload map[string]any `json:"remoteTaskPayload,omitempty"` RemoteTaskPayload map[string]any `json:"-"`
Result map[string]any `json:"result,omitempty"` Result map[string]any `json:"result,omitempty"`
Billings []any `json:"billings,omitempty"` Billings []any `json:"billings,omitempty"`
Usage map[string]any `json:"usage"` Usage map[string]any `json:"usage"`
+7 -1
View File
@@ -331,7 +331,7 @@ ORDER BY priority ASC, resource_type ASC, rule_key ASC`, id)
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s currency does not match its rule set", ruleKey) return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s currency does not match its rule set", ruleKey)
} }
switch calculatorType { switch calculatorType {
case "token_usage", "unit_weight", "duration_weight": case "token_usage", "unit_weight", "duration_weight", "transition_matrix":
default: default:
return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s uses unsupported calculator %s", ruleKey, calculatorType) return EffectivePricingConfig{}, fmt.Errorf("pricing rule %s uses unsupported calculator %s", ruleKey, calculatorType)
} }
@@ -386,8 +386,12 @@ func ValidateEffectivePricingRuleShape(resourceType string, unit string, calcula
allowed = unit == "1k_tokens" && calculatorType == "token_usage" allowed = unit == "1k_tokens" && calculatorType == "token_usage"
case "image", "image_edit": case "image", "image_edit":
allowed = unit == "image" && calculatorType == "unit_weight" allowed = unit == "image" && calculatorType == "unit_weight"
case "image_vectorize":
allowed = unit == "conversion" && calculatorType == "unit_weight"
case "video": case "video":
allowed = unit == "5s" && calculatorType == "duration_weight" allowed = unit == "5s" && calculatorType == "duration_weight"
case "video_enhance":
allowed = unit == "5s" && calculatorType == "transition_matrix"
case "music": case "music":
allowed = (unit == "song" || unit == "item") && calculatorType == "unit_weight" allowed = (unit == "song" || unit == "item") && calculatorType == "unit_weight"
case "audio": case "audio":
@@ -415,6 +419,8 @@ func DefaultEffectivePricingCalculator(resourceType string) string {
return "token_usage" return "token_usage"
case "video": case "video":
return "duration_weight" return "duration_weight"
case "video_enhance":
return "transition_matrix"
default: default:
return "unit_weight" return "unit_weight"
} }
@@ -13,6 +13,8 @@ func TestValidateEffectivePricingRuleShape(t *testing.T) {
{name: "text", resource: "text_input", unit: "1k_tokens", calculator: "token_usage", valid: true}, {name: "text", resource: "text_input", unit: "1k_tokens", calculator: "token_usage", valid: true},
{name: "image", resource: "image", unit: "image", calculator: "unit_weight", valid: true}, {name: "image", resource: "image", unit: "image", calculator: "unit_weight", valid: true},
{name: "video", resource: "video", unit: "5s", calculator: "duration_weight", valid: true}, {name: "video", resource: "video", unit: "5s", calculator: "duration_weight", valid: true},
{name: "video enhance", resource: "video_enhance", unit: "5s", calculator: "transition_matrix", valid: true},
{name: "image vectorize", resource: "image_vectorize", unit: "conversion", calculator: "unit_weight", valid: true},
{name: "wrong video unit", resource: "video", unit: "second", calculator: "duration_weight"}, {name: "wrong video unit", resource: "video", unit: "second", calculator: "duration_weight"},
{name: "wrong image calculator", resource: "image", unit: "image", calculator: "token_usage"}, {name: "wrong image calculator", resource: "image", unit: "image", calculator: "token_usage"},
} }
@@ -0,0 +1,25 @@
package store
import (
"encoding/json"
"strings"
"testing"
)
func TestGatewayTaskJSONOmitsPrivateRemoteTaskPayload(t *testing.T) {
raw, err := json.Marshal(GatewayTask{
ID: "task-private-state",
RemoteTaskID: "safe-upstream-id",
RemoteTaskPayload: map[string]any{"imageToken": "private-image-token", "receipt": "private-receipt"},
})
if err != nil {
t.Fatal(err)
}
serialized := strings.ToLower(string(raw))
if strings.Contains(serialized, "private-image-token") || strings.Contains(serialized, "private-receipt") || strings.Contains(serialized, "remotetaskpayload") {
t.Fatalf("private provider recovery state leaked into task JSON: %s", serialized)
}
if !strings.Contains(serialized, "safe-upstream-id") {
t.Fatalf("public upstream task id should remain available: %s", serialized)
}
}
+1 -3
View File
@@ -528,13 +528,11 @@ WHERE id = $1::uuid
} }
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
UPDATE gateway_task_attempts UPDATE gateway_task_attempts
SET remote_task_id = NULLIF($2::text, ''), SET remote_task_id = NULLIF($2::text, '')
response_snapshot = COALESCE(response_snapshot, '{}'::jsonb) || jsonb_build_object('remote_task_payload', $3::jsonb)
WHERE id = $1::uuid WHERE id = $1::uuid
AND status = 'running'`, AND status = 'running'`,
attemptID, attemptID,
remoteTaskID, remoteTaskID,
string(payloadJSON),
) )
return err return err
}) })
@@ -0,0 +1,338 @@
INSERT INTO model_catalog_providers (
provider_key, provider_code, display_name, provider_type, default_base_url,
default_auth_type, source, capability_schema, status, metadata
)
VALUES
('vectorizer', 'vectorizer', 'Vectorizer.AI', 'vectorizer', 'https://api.vectorizer.ai/api/v1',
'Basic', 'gateway', '{"modelTypes":["image_vectorize"]}'::jsonb, 'active', '{"source":"gateway.native"}'::jsonb),
('topaz', 'topaz', 'Topaz Labs', 'topaz', 'https://api.topazlabs.com',
'APIKey', 'gateway', '{"modelTypes":["video_enhance"]}'::jsonb, 'active', '{"source":"gateway.native"}'::jsonb)
ON CONFLICT (provider_key) DO UPDATE
SET provider_code = EXCLUDED.provider_code,
display_name = EXCLUDED.display_name,
provider_type = EXCLUDED.provider_type,
default_base_url = EXCLUDED.default_base_url,
default_auth_type = EXCLUDED.default_auth_type,
capability_schema = EXCLUDED.capability_schema,
updated_at = now();
INSERT INTO model_pricing_rule_sets (
rule_set_key, name, description, category, currency, status, metadata
)
VALUES (
'desktop-advanced-media-v1', '桌面端高级媒体定价',
'图片矢量化按次计费,视频超分按时长、分辨率、帧率、慢动作和模型权重计费。',
'media', 'resource', 'active', '{"source":"gateway.native","version":1}'::jsonb
)
ON CONFLICT (rule_set_key) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
currency = EXCLUDED.currency,
status = EXCLUDED.status,
metadata = EXCLUDED.metadata,
updated_at = now();
INSERT INTO model_pricing_rules (
rule_set_id, rule_key, display_name, scope_type, resource_type, unit,
base_price, currency, dynamic_weight, calculator_type, dimension_schema,
formula_config, priority, status, metadata
)
VALUES
(
(SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'desktop-advanced-media-v1'),
'image_vectorize', '图片转矢量', 'model', 'image_vectorize', 'conversion',
200, 'resource', '{}'::jsonb, 'unit_weight',
'{"dimensions":["count"],"defaults":{"count":1}}'::jsonb,
'{"formula":"count * base_price"}'::jsonb, 10, 'active',
'{"operationType":"image_vectorize"}'::jsonb
),
(
(SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'desktop-advanced-media-v1'),
'video_enhance', '视频增强', 'model', 'video_enhance', '5s',
100, 'resource',
'{
"resolutionTransitions": {
"480p->480p":1,"480p->720p":1.4,"480p->1080p":1.8,"480p->1440p":2.1,"480p->2160p":2.6,
"720p->720p":1.4,"720p->1080p":1.8,"720p->1440p":2.1,"720p->2160p":2.6,
"1080p->1080p":1.8,"1080p->1440p":2.1,"1080p->2160p":2.6,
"1440p->1440p":2.1,"1440p->2160p":2.6,"2160p->2160p":2.6
},
"frameRateTransitions": {
"24->24":1,"24->30":1.25,"24->60":2.5,"24->120":5,"24->240":10,
"30->30":1.25,"30->60":2.5,"30->120":5,"30->240":10,
"60->60":2.5,"60->120":5,"60->240":10,"120->120":5,"120->240":10,"240->240":10
},
"modelWeights": {
"easy-proteus-standard-4":1,"easy-starlight-fast-2":1,
"easy-starlight-hq-1":1,"easy-starlight-mini-1":1
},
"markup":1
}'::jsonb,
'transition_matrix',
'{"dimensions":["count","duration_seconds","source_resolution","target_resolution","source_frame_rate","target_frame_rate","slow_motion_rate","model"],"defaults":{"count":1,"duration_seconds":5,"source_resolution":"720p","target_resolution":"1080p","source_frame_rate":24,"target_frame_rate":24,"slow_motion_rate":1}}'::jsonb,
'{"formula":"ceil(count * max(1, round(duration_seconds) / 5) * base_price * resolution_transition_weight * frame_rate_transition_weight * slow_motion_rate * model_weight * markup)"}'::jsonb,
20, 'active', '{"operationType":"video_upscale"}'::jsonb
),
(
(SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'desktop-advanced-media-v1'),
'music_generation', '音乐生成', 'model', 'music', 'song',
20, 'resource', '{}'::jsonb, 'unit_weight',
'{"dimensions":["count"],"defaults":{"count":1}}'::jsonb,
'{"formula":"count * base_price"}'::jsonb,
30, 'active', '{"operationType":"audio_generate"}'::jsonb
),
(
(SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1'),
'music_generation', '音乐生成', 'model', 'music', 'song',
20, 'resource', '{}'::jsonb, 'unit_weight',
'{"dimensions":["count"],"defaults":{"count":1}}'::jsonb,
'{"formula":"count * base_price"}'::jsonb,
60, 'active', '{"operationType":"audio_generate","source":"gateway.catalog.compat"}'::jsonb
)
ON CONFLICT (rule_set_id, rule_key) WHERE rule_set_id IS NOT NULL DO UPDATE
SET display_name = EXCLUDED.display_name,
resource_type = EXCLUDED.resource_type,
unit = EXCLUDED.unit,
base_price = EXCLUDED.base_price,
currency = EXCLUDED.currency,
dynamic_weight = EXCLUDED.dynamic_weight,
calculator_type = EXCLUDED.calculator_type,
dimension_schema = EXCLUDED.dimension_schema,
formula_config = EXCLUDED.formula_config,
priority = EXCLUDED.priority,
status = EXCLUDED.status,
metadata = EXCLUDED.metadata,
updated_at = now();
WITH model_defs(provider_key, canonical_key, provider_model_name, display_name, model_type, capabilities, billing_config, rate_limits, metadata) AS (
VALUES
(
'vectorizer', 'vectorizer:easy-image-vectorizer-1', 'easy-image-vectorizer-1', '图片转矢量 v1',
'["image_vectorize"]'::jsonb,
'{"image_vectorize":{"support_url_input":true,"support_base64_input":false,"input_format_allowed":["png","jpg","jpeg","webp","bmp","gif"],"output_format_allowed":["svg","eps","pdf","dxf","png"],"max_colors_options":[0,2,4,8,16,32],"cleanup_levels":["low","standard","strong"],"async_task":true},"originalTypes":["image_vectorize"]}'::jsonb,
'{"image_vectorize":{"basePrice":200,"baseWeight":1},"currency":"resource"}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":2,"leaseTtlSeconds":180}]}'::jsonb,
'{"source":"gateway.native","sourceSpecType":"vectorizer","alias":"easy-image-vectorizer-1","description":"将位图转换为 SVG、EPS、PDF、DXF 或 PNG。","selectable":true}'::jsonb
),
(
'topaz', 'topaz:easy-proteus-standard-4', 'prob-4', '标准视频超分 v4', '["video_enhance"]'::jsonb,
'{"video_enhance":{"input_video_required":true,"input_resolutions":["480p","720p","1080p","1440p","2160p"],"output_resolutions":["720p","1080p","2k","4k"],"operations":["upscale"],"preserve_audio":true,"async_task":true,"max_file_size_mb":2048},"originalTypes":["video_enhance"]}'::jsonb,
'{"video_enhance":{"basePrice":100,"baseWeight":1},"currency":"resource"}'::jsonb,
'{"rules":[{"metric":"concurrent","limit":2,"leaseTtlSeconds":7200}]}'::jsonb,
'{"source":"gateway.native","sourceSpecType":"topaz","alias":"easy-proteus-standard-4","selectable":true}'::jsonb
),
(
'topaz', 'topaz:easy-starlight-fast-2', 'slf-2', '快速视频超分 v2', '["video_enhance"]'::jsonb,
'{"video_enhance":{"input_video_required":true,"input_resolutions":["480p","720p","1080p","1440p","2160p"],"output_resolutions":["720p","1080p","2k","4k"],"operations":["upscale"],"preserve_audio":true,"async_task":true,"max_file_size_mb":2048},"originalTypes":["video_enhance"]}'::jsonb,
'{"video_enhance":{"basePrice":100,"baseWeight":1},"currency":"resource"}'::jsonb,
'{"rules":[{"metric":"concurrent","limit":2,"leaseTtlSeconds":7200}]}'::jsonb,
'{"source":"gateway.native","sourceSpecType":"topaz","alias":"easy-starlight-fast-2","selectable":true}'::jsonb
),
(
'topaz', 'topaz:easy-starlight-hq-1', 'slhq-1', '高质量视频超分 v1', '["video_enhance"]'::jsonb,
'{"video_enhance":{"input_video_required":true,"input_resolutions":["480p","720p","1080p","1440p","2160p"],"output_resolutions":["720p","1080p","2k","4k"],"operations":["upscale"],"preserve_audio":true,"async_task":true,"max_file_size_mb":2048},"originalTypes":["video_enhance"]}'::jsonb,
'{"video_enhance":{"basePrice":100,"baseWeight":1},"currency":"resource"}'::jsonb,
'{"rules":[{"metric":"concurrent","limit":1,"leaseTtlSeconds":7200}]}'::jsonb,
'{"source":"gateway.native","sourceSpecType":"topaz","alias":"easy-starlight-hq-1","selectable":true}'::jsonb
),
(
'topaz', 'topaz:easy-starlight-mini-1', 'slm-1', '轻量视频修复增强 v1', '["video_enhance"]'::jsonb,
'{"video_enhance":{"input_video_required":true,"input_resolutions":["480p","720p","1080p","1440p","2160p"],"output_resolutions":["720p","1080p","2k","4k"],"operations":["upscale","restore","denoise"],"preserve_audio":true,"async_task":true,"max_file_size_mb":2048},"originalTypes":["video_enhance"]}'::jsonb,
'{"video_enhance":{"basePrice":100,"baseWeight":1},"currency":"resource"}'::jsonb,
'{"rules":[{"metric":"concurrent","limit":1,"leaseTtlSeconds":7200}]}'::jsonb,
'{"source":"gateway.native","sourceSpecType":"topaz","alias":"easy-starlight-mini-1","selectable":true}'::jsonb
)
)
INSERT INTO base_model_catalog (
provider_id, provider_key, canonical_model_key, provider_model_name, model_type, display_name,
capabilities, base_billing_config, default_rate_limit_policy, pricing_rule_set_id,
metadata, catalog_type, status
)
SELECT provider.id, defs.provider_key, defs.canonical_key, defs.provider_model_name, defs.model_type, defs.display_name,
defs.capabilities, defs.billing_config, defs.rate_limits,
(SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'desktop-advanced-media-v1'),
defs.metadata, 'system', 'active'
FROM model_defs defs
JOIN model_catalog_providers provider ON provider.provider_key = defs.provider_key
ON CONFLICT (canonical_model_key) DO UPDATE
SET provider_id = EXCLUDED.provider_id,
provider_key = EXCLUDED.provider_key,
provider_model_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.provider_model_name ELSE base_model_catalog.provider_model_name END,
model_type = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.model_type ELSE base_model_catalog.model_type END,
display_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.display_name ELSE base_model_catalog.display_name END,
capabilities = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.capabilities ELSE base_model_catalog.capabilities END,
base_billing_config = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.base_billing_config ELSE base_model_catalog.base_billing_config END,
default_rate_limit_policy = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.default_rate_limit_policy ELSE base_model_catalog.default_rate_limit_policy END,
pricing_rule_set_id = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.pricing_rule_set_id ELSE base_model_catalog.pricing_rule_set_id END,
metadata = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.metadata ELSE base_model_catalog.metadata END,
status = CASE WHEN base_model_catalog.customized_at IS NULL THEN 'active' ELSE base_model_catalog.status END,
updated_at = now();
INSERT INTO integration_platforms (
provider, platform_key, name, base_url, auth_type, credentials, config,
default_pricing_mode, default_discount_factor, retry_policy, rate_limit_policy,
priority, status, disabled_reason
)
VALUES
(
'vectorizer', 'vectorizer-native', 'Vectorizer.AI Native', 'https://api.vectorizer.ai/api/v1', 'basic', '{}'::jsonb,
'{"sourceSpecType":"vectorizer","credentialEnv":{"accessKey":"VECTORIZER_API_ID","secretKey":"VECTORIZER_API_SECRET"},"mode":"production","retentionDays":"7"}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":2,"leaseTtlSeconds":180}]}'::jsonb,
120, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'topaz', 'topaz-native', 'Topaz Labs Native', 'https://api.topazlabs.com', 'api_key', '{}'::jsonb,
'{"sourceSpecType":"topaz","credentialEnv":{"apiKey":"TOPAZ_API_KEY"},"maxInputBytes":2147483648,"pollIntervalMs":15000,"pollTimeoutMs":3600000}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"concurrent","limit":2,"leaseTtlSeconds":7200}]}'::jsonb,
130, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'minimax', 'minimax-native-dev', 'MiniMax Native DEV', 'https://api.minimaxi.com/v1', 'bearer', '{}'::jsonb,
'{"sourceSpecType":"minimax","credentialEnv":{"apiKey":"MINIMAX_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}'::jsonb,
110, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'minimax-openai', 'minimax-openai-native-dev', 'MiniMax OpenAI Native DEV', 'https://api.minimaxi.com/v1', 'bearer', '{}'::jsonb,
'{"sourceSpecType":"openai","credentialEnv":{"apiKey":"MINIMAX_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}'::jsonb,
105, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'volces', 'volces-native-dev', 'Volcengine Native DEV', 'https://ark.cn-beijing.volces.com/api/v3', 'bearer', '{}'::jsonb,
'{"sourceSpecType":"volces","credentialEnv":{"apiKey":"VOLCES_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":3,"leaseTtlSeconds":600}]}'::jsonb,
100, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'aliyun-bailian-openai', 'aliyun-bailian-openai-native-dev', 'Aliyun Bailian OpenAI Native DEV', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'bearer', '{}'::jsonb,
'{"sourceSpecType":"openai","credentialEnv":{"apiKey":"ALIYUN_BAILIAN_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}'::jsonb,
100, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'aliyun-bailian-openai', 'aliyun-bailian-rerank-native-dev', 'Aliyun Bailian Rerank Native DEV', 'https://dashscope.aliyuncs.com/compatible-api/v1', 'bearer', '{}'::jsonb,
'{"sourceSpecType":"openai","credentialEnv":{"apiKey":"ALIYUN_BAILIAN_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":60,"windowSeconds":60},{"metric":"concurrent","limit":5,"leaseTtlSeconds":300}]}'::jsonb,
100, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
),
(
'suno', 'suno-native-dev', 'Suno Native DEV', 'https://api.cqtai.com/api/cqt', 'api_key', '{}'::jsonb,
'{"sourceSpecType":"suno","credentialEnv":{"apiKey":"SUNO_API_KEY"}}'::jsonb,
'inherit_discount', 1, '{"enabled":true,"maxAttempts":2,"retryOn":["rate_limit","timeout","server_error","network"]}'::jsonb,
'{"rules":[{"metric":"rpm","limit":30,"windowSeconds":60},{"metric":"concurrent","limit":2,"leaseTtlSeconds":1200}]}'::jsonb,
100, 'disabled', '需要通过环境变量注入 DEV/部署凭据后显式启用'
)
ON CONFLICT (platform_key) DO UPDATE
SET name = EXCLUDED.name,
base_url = EXCLUDED.base_url,
auth_type = EXCLUDED.auth_type,
credentials = '{}'::jsonb,
config = EXCLUDED.config,
default_pricing_mode = EXCLUDED.default_pricing_mode,
default_discount_factor = EXCLUDED.default_discount_factor,
retry_policy = EXCLUDED.retry_policy,
rate_limit_policy = EXCLUDED.rate_limit_policy,
priority = EXCLUDED.priority,
updated_at = now();
WITH platform_model_defs(platform_key, canonical_key, model_name, provider_model_name, model_alias) AS (
VALUES
('vectorizer-native', 'vectorizer:easy-image-vectorizer-1', 'easy-image-vectorizer-1', 'easy-image-vectorizer-1', 'easy-image-vectorizer-1'),
('topaz-native', 'topaz:easy-proteus-standard-4', 'easy-proteus-standard-4', 'prob-4', 'easy-proteus-standard-4'),
('topaz-native', 'topaz:easy-starlight-fast-2', 'easy-starlight-fast-2', 'slf-2', 'easy-starlight-fast-2'),
('topaz-native', 'topaz:easy-starlight-hq-1', 'easy-starlight-hq-1', 'slhq-1', 'easy-starlight-hq-1'),
('topaz-native', 'topaz:easy-starlight-mini-1', 'easy-starlight-mini-1', 'slm-1', 'easy-starlight-mini-1')
)
INSERT INTO platform_models (
platform_id, base_model_id, model_name, provider_model_name, model_alias, model_type,
display_name, capabilities, pricing_mode, pricing_rule_set_id, billing_config,
retry_policy, rate_limit_policy, enabled
)
SELECT platform.id, base.id, defs.model_name, defs.provider_model_name, defs.model_alias, base.model_type,
base.display_name, base.capabilities, 'inherit_discount', base.pricing_rule_set_id, base.base_billing_config,
'{"enabled":true,"maxAttempts":2}'::jsonb, base.default_rate_limit_policy, true
FROM platform_model_defs defs
JOIN integration_platforms platform ON platform.platform_key = defs.platform_key
JOIN base_model_catalog base ON base.canonical_model_key = defs.canonical_key
ON CONFLICT (platform_id, model_name) DO UPDATE
SET base_model_id = EXCLUDED.base_model_id,
provider_model_name = EXCLUDED.provider_model_name,
model_alias = EXCLUDED.model_alias,
model_type = EXCLUDED.model_type,
display_name = EXCLUDED.display_name,
capabilities = EXCLUDED.capabilities,
pricing_mode = EXCLUDED.pricing_mode,
pricing_rule_set_id = EXCLUDED.pricing_rule_set_id,
billing_config = EXCLUDED.billing_config,
retry_policy = EXCLUDED.retry_policy,
rate_limit_policy = EXCLUDED.rate_limit_policy,
enabled = EXCLUDED.enabled,
updated_at = now();
INSERT INTO platform_models (
platform_id, base_model_id, model_name, provider_model_name, model_alias, model_type,
display_name, capabilities, pricing_mode, pricing_rule_set_id, billing_config,
retry_policy, rate_limit_policy, enabled
)
SELECT platform.id, base.id, base.provider_model_name, base.provider_model_name,
COALESCE(NULLIF(base.metadata->>'alias', ''), base.provider_model_name), base.model_type,
base.display_name, base.capabilities, 'inherit_discount',
CASE WHEN platform.platform_key = 'suno-native-dev'
THEN (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'desktop-advanced-media-v1')
ELSE base.pricing_rule_set_id
END,
base.base_billing_config, '{"enabled":true,"maxAttempts":2}'::jsonb,
base.default_rate_limit_policy, true
FROM integration_platforms platform
JOIN base_model_catalog base ON
(platform.platform_key = 'minimax-native-dev'
AND base.provider_key = 'minimax'
AND base.model_type ?| ARRAY['text_to_speech', 'voice_clone', 'video_generate'])
OR
(platform.platform_key = 'minimax-openai-native-dev'
AND base.provider_key = 'minimax-openai'
AND base.model_type ?| ARRAY['text_generate'])
OR
(platform.platform_key = 'volces-native-dev'
AND base.provider_key = 'volces'
AND base.model_type ?| ARRAY['image_generate', 'image_edit'])
OR
(platform.platform_key = 'aliyun-bailian-openai-native-dev'
AND base.provider_key = 'aliyun-bailian-openai'
AND base.model_type ?| ARRAY['text_embedding'])
OR
(platform.platform_key = 'aliyun-bailian-rerank-native-dev'
AND base.provider_key = 'aliyun-bailian-openai'
AND base.model_type ?| ARRAY['text_rerank'])
OR
(platform.platform_key = 'suno-native-dev'
AND base.provider_key = 'suno'
AND base.model_type ?| ARRAY['audio_generate'])
WHERE platform.platform_key IN (
'minimax-native-dev', 'minimax-openai-native-dev', 'volces-native-dev',
'aliyun-bailian-openai-native-dev', 'aliyun-bailian-rerank-native-dev', 'suno-native-dev'
)
AND base.status = 'active'
ON CONFLICT (platform_id, model_name) DO UPDATE
SET base_model_id = EXCLUDED.base_model_id,
provider_model_name = EXCLUDED.provider_model_name,
model_alias = EXCLUDED.model_alias,
model_type = EXCLUDED.model_type,
display_name = EXCLUDED.display_name,
capabilities = EXCLUDED.capabilities,
pricing_mode = EXCLUDED.pricing_mode,
pricing_rule_set_id = EXCLUDED.pricing_rule_set_id,
billing_config = EXCLUDED.billing_config,
retry_policy = EXCLUDED.retry_policy,
rate_limit_policy = EXCLUDED.rate_limit_policy,
enabled = EXCLUDED.enabled,
updated_at = now();
+1 -1
View File
@@ -24,7 +24,7 @@
"outputs": ["{projectRoot}/docs/swagger.json", "{projectRoot}/docs/swagger.yaml"], "outputs": ["{projectRoot}/docs/swagger.json", "{projectRoot}/docs/swagger.yaml"],
"options": { "options": {
"cwd": "apps/api", "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": { "test": {
+2 -2
View File
@@ -266,8 +266,8 @@ describe('Public Agent resources', () => {
modules: ['model-runtime'], modules: ['model-runtime'],
fileName: 'ai-gateway-ops-management-v1.0.2.zip', fileName: 'ai-gateway-ops-management-v1.0.2.zip',
downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download', downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download',
apiDocsJsonPath: '/api-docs-json', apiDocsJsonPath: '/api/v1/openapi.json',
apiDocsYamlPath: '/api-docs-yaml', apiDocsYamlPath: '/api/v1/openapi.yaml',
}; };
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(metadata), { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(metadata), {
status: 200, status: 200,
+11 -11
View File
@@ -35,17 +35,17 @@ interface ApiGuideItem {
} }
export const apiDocs: ApiDocItem[] = [ 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: '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: '/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' }, { 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: '/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embeddingAPI Key 需要 embedding 权限。' }, { 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: '/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_scoreAPI Key 需要 rerank 权限。' }, { 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: '/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' }, { 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: '/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 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: '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: '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: '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: '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[] = [ const guideItems: ApiGuideItem[] = [
@@ -73,8 +73,8 @@ const defaultOpsSkillMetadata: GatewaySkillBundleMetadata = {
modules: ['model-runtime'], modules: ['model-runtime'],
fileName: 'ai-gateway-ops-management.zip', fileName: 'ai-gateway-ops-management.zip',
downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download', downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download',
apiDocsJsonPath: '/api-docs-json', apiDocsJsonPath: '/api/v1/openapi.json',
apiDocsYamlPath: '/api-docs-yaml', apiDocsYamlPath: '/api/v1/openapi.yaml',
}; };
export function ApiDocsPage(props: { export function ApiDocsPage(props: {
@@ -545,8 +545,8 @@ function GuideDetails(props: { onCreateApiKey: () => void; section: ApiGuideSect
return ( return (
<> <>
<GuideSection title="1. 确认 Base URL"> <GuideSection title="1. 确认 Base URL">
<p>Base URL Gateway Base URL </p> <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"\ncurl "$EASYAI_BASE_URL/healthz"`}</pre> <pre>{`export EASYAI_BASE_URL="https://your-gateway.example.com/api/v1"\ncurl "$EASYAI_BASE_URL/healthz"`}</pre>
</GuideSection> </GuideSection>
<GuideSection title="2. 创建并使用 API Key"> <GuideSection title="2. 创建并使用 API Key">
<p> API Key Key chatembeddingrerankimage video </p> <p> API Key Key chatembeddingrerankimage video </p>
+1 -1
View File
@@ -1 +1 @@
fba9759bc7a5fbca5e9d1465bdd7a91de6a10928 fce76a30ba2d7f9aa514418a2e09be7bbe7aaf4e
+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;
}
+7
View File
@@ -0,0 +1,7 @@
services:
api:
env_file:
- ./.local-secrets/dev-real.env
environment:
APP_ENV: development
BILLING_ENGINE_MODE: ${AI_GATEWAY_COMPOSE_BILLING_ENGINE_MODE:-enforce}
+2 -2
View File
@@ -87,7 +87,7 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 20 retries: 20
@@ -117,7 +117,7 @@ services:
api: api:
condition: service_healthy condition: service_healthy
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 20 retries: 20
+33
View File
@@ -33,6 +33,10 @@ server {
return 404; return 404;
} }
location = /api/v1/metrics {
return 404;
}
location = /gateway-api/api/v1/auth/login { location = /gateway-api/api/v1/auth/login {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -47,6 +51,35 @@ server {
proxy_pass http://api:8088/api/v1/auth/login; 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/ { location /gateway-api/ {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
+4 -4
View File
@@ -91,7 +91,7 @@ flowchart LR
QUEUE --> PG QUEUE --> PG
QUEUE --> CALLBACK -->|POST task progress callback to server-main| WSGW QUEUE --> CALLBACK -->|POST task progress callback to server-main| WSGW
QUEUE -->|settlement event| BILL QUEUE -->|settlement event| BILL
API -->|POST /v1/files/upload| FILES API -->|POST /api/v1/files/upload| FILES
``` ```
## 4. Monorepo 方案 ## 4. Monorepo 方案
@@ -1785,9 +1785,9 @@ SimulationClient 根据 `simulation_profile` 生成确定性行为:
- `/chat/completions` - `/chat/completions`
- `/images/generations` - `/images/generations`
- `/video/generations` - `/video/generations`
- `/v1/chat/completions` - `/api/v1/chat/completions`
- `/v1/images/generations` - `/api/v1/images/generations`
- `/v1/video/generations` - `/api/v1/video/generations`
内部 `OpenaiService` 变成薄门面: 内部 `OpenaiService` 变成薄门面:
+2 -2
View File
@@ -5,11 +5,11 @@
生产环境统一配置: 生产环境统一配置:
```text ```text
baseURL = https://ai.51easyai.com/gateway-api/kling baseURL = https://ai.51easyai.com/api/v1/kling
Authorization = Bearer <EasyAI Gateway API Key> Authorization = Bearer <EasyAI Gateway API Key>
``` ```
本地环境使用 `baseURL = http://localhost:8088/kling` 本地环境使用 `baseURL = http://localhost:8088/api/v1/kling``/gateway-api/kling``/kling` 路径仅作为兼容别名保留。
## V1AK/SK 旧版协议兼容) ## V1AK/SK 旧版协议兼容)
+17 -11
View File
@@ -2,14 +2,20 @@
EasyAI AI Gateway 提供 Kling 旧版 Omni 协议兼容接口。调用方继续使用 Gateway API Key,任务仍经过网关候选选择、异步队列、审计和计费;响应中的 `task_id` 是网关任务 UUID,不是上游任务 ID。 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` | 时长范围 | | 请求 `model_name` | 网关候选模型 | 可灵原生 `model_name` | 时长范围 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `kling-video-o1``kling-o1` | `kling-o1` | `kling-video-o1` | 310 秒 | | `kling-video-o1``kling-o1` | `kling-video-o1` | `kling-video-o1` | 310 秒 |
| `kling-v3-omni``kling-3.0-omni` | `kling-3.0-omni` | `kling-v3-omni` | 315 秒 | | `kling-v3-omni``kling-3.0-omni``kling-3-omni` | `kling-v3-omni` | `kling-v3-omni` | 315 秒 |
网关别名用于候选匹配,原生模型名用于发往 TranStreams 的 Kling Omni 请求;两类名称不会混用 上述旧别名会在入口处自动归一为可灵原生模型名,候选匹配不依赖平台模型额外配置别名
`kling-video-o1` 的纯文生视频和首帧生视频只接受 5 或 10 秒;3–10 秒中的其他整数需要使用普通参考图等支持该时长的 Omni 输入。`kling-v3-omni` 接受 315 秒。 `kling-video-o1` 的纯文生视频和首帧生视频只接受 5 或 10 秒;3–10 秒中的其他整数需要使用普通参考图等支持该时长的 Omni 输入。`kling-v3-omni` 接受 315 秒。
@@ -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 ```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 "Authorization: Bearer $GATEWAY_API_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
@@ -61,7 +67,7 @@ curl -sS -X POST "$GATEWAY_BASE_URL/v1/videos/omni-video" \
```bash ```bash
curl -sS \ curl -sS \
-H "Authorization: Bearer $GATEWAY_API_KEY" \ -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` `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}` 轮询。 标准接口仍为 `POST /api/v1/videos/generations`。异步调用需要 `X-Async: true`,再通过 `GET /api/v1/tasks/{taskId}` 轮询。
```bash ```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 "Authorization: Bearer $GATEWAY_API_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-Async: true" \ -H "X-Async: true" \
@@ -103,7 +109,7 @@ curl -sS -X POST "$GATEWAY_BASE_URL/api/v1/videos/generations" \
"resolution": "1080p", "resolution": "1080p",
"aspect_ratio": "9:16", "aspect_ratio": "9:16",
"duration": 5, "duration": 5,
"audio": true, "audio": false,
"watermark": false, "watermark": false,
"runMode": "real" "runMode": "real"
}' }'
@@ -112,7 +118,7 @@ curl -sS -X POST "$GATEWAY_BASE_URL/api/v1/videos/generations" \
```bash ```bash
curl -sS \ curl -sS \
-H "Authorization: Bearer $GATEWAY_API_KEY" \ -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 状态码仍反映错误类型。 业务码分类:`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 ```bash
curl -fsS https://ai.51easyai.com/gateway-api/healthz curl -fsS https://ai.51easyai.com/api/v1/healthz
curl -fsS https://ai.51easyai.com/gateway-api/readyz 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' 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` - `POST /api/v1/contents/generations/tasks`
- `GET /api/v3/contents/generations/tasks` - `GET /api/v1/contents/generations/tasks`
- `GET /api/v3/contents/generations/tasks/{taskID}` - `GET /api/v1/contents/generations/tasks/{taskID}`
- `DELETE /api/v3/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` 是保留的网关附加字段。 列表接口兼容火山的 `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 文件上传 ### 1.2 文件上传
```http ```http
POST /v1/files/upload POST /api/v1/files/upload
Authorization: Bearer ${USER_JWT_OR_SK} Authorization: Bearer ${USER_JWT_OR_SK}
Content-Type: multipart/form-data Content-Type: multipart/form-data
+2 -2
View File
@@ -53,7 +53,7 @@
| ID | 任务 | 接口 / 方式 | 成功判定 | 状态 | 结果记录 | | 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-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-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` | 未执行 | 待填写 | | SETUP-04 | 创建内部测试用户组 | `POST /api/v1/user-groups` | 创建 `loopback-allow-group``loopback-deny-group``loopback-limit-group` | 未执行 | 待填写 |
@@ -91,7 +91,7 @@
| ID | 能力 | 请求 | 成功判定 | 状态 | 结果记录 | | 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-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-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-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 待填写 | | TASK-VIDEO-01 | 文生视频成功 | `POST /api/v1/videos/generations`,模型 `豆包Seedance-1.5-pro`,仅传 prompt | `task.status=succeeded`,返回可下载或可播放的视频结果,任务事件完整 | 未执行 | taskId、video URL、duration、charge 待填写 |
+5 -5
View File
@@ -10,7 +10,7 @@ overrides:
brace-expansion@>=2.0.0 <2.1.2: 2.1.2 brace-expansion@>=2.0.0 <2.1.2: 2.1.2
dompurify@<=3.4.10: 3.4.11 dompurify@<=3.4.10: 3.4.11
esbuild@>=0.27.3 <0.28.1: 0.28.1 esbuild@>=0.27.3 <0.28.1: 0.28.1
fast-uri@>=3.0.0 <3.1.3: 3.1.3 fast-uri@>=3.0.0 <3.1.4: 3.1.4
form-data@>=4.0.0 <4.0.6: 4.0.6 form-data@>=4.0.0 <4.0.6: 4.0.6
js-yaml@<4.3.0: 4.3.0 js-yaml@<4.3.0: 4.3.0
mermaid@>=11.0.0-alpha.1 <=11.14.0: 11.15.0 mermaid@>=11.0.0-alpha.1 <=11.14.0: 11.15.0
@@ -3035,8 +3035,8 @@ packages:
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-uri@3.1.3: fast-uri@3.1.4:
resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
@@ -7182,7 +7182,7 @@ snapshots:
ajv@8.20.0: ajv@8.20.0:
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
fast-uri: 3.1.3 fast-uri: 3.1.4
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
@@ -7851,7 +7851,7 @@ snapshots:
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-uri@3.1.3: {} fast-uri@3.1.4: {}
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
+1 -1
View File
@@ -12,7 +12,7 @@ overrides:
'brace-expansion@>=2.0.0 <2.1.2': 2.1.2 'brace-expansion@>=2.0.0 <2.1.2': 2.1.2
'dompurify@<=3.4.10': 3.4.11 'dompurify@<=3.4.10': 3.4.11
'esbuild@>=0.27.3 <0.28.1': 0.28.1 'esbuild@>=0.27.3 <0.28.1': 0.28.1
'fast-uri@>=3.0.0 <3.1.3': 3.1.3 'fast-uri@>=3.0.0 <3.1.4': 3.1.4
'form-data@>=4.0.0 <4.0.6': 4.0.6 'form-data@>=4.0.0 <4.0.6': 4.0.6
'js-yaml@<4.3.0': 4.3.0 'js-yaml@<4.3.0': 4.3.0
'mermaid@>=11.0.0-alpha.1 <=11.14.0': 11.15.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}")" api_port="$(published_port api 8088 "${AI_GATEWAY_API_PORT:-8088}")"
web_port="$(published_port web 80 "${AI_GATEWAY_WEB_PORT:-5178}")" 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 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}/readyz" '"ok":true' 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}/gateway-api/healthz" "easyai-ai-gateway" 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" wait_for_http "web app" "http://127.0.0.1:${web_port}/" "EasyAI AI Gateway"
echo "[ai-gateway] deployment succeeded" echo "[ai-gateway] deployment succeeded"
echo "[ai-gateway] Web: http://127.0.0.1:${web_port}" 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 case "$ACTION" in