Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3056cf8fca | ||
|
|
cbebfd7baa | ||
|
|
ee0256adb4 | ||
|
|
fce76a30ba | ||
|
|
d37fb3ea60 | ||
|
|
9872068596 | ||
|
|
a23b28c27d | ||
|
|
eb37b568ae | ||
|
|
a8d1c550ef | ||
|
|
5432760cf7 | ||
|
|
7c5a999e32 | ||
|
|
f7a5f2e808 | ||
|
|
152f9d1206 | ||
|
|
d95cecd0eb |
+1
-1
@@ -36,7 +36,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -70,8 +70,10 @@ scripts/deploy-compose.sh
|
||||
部署成功后默认访问地址:
|
||||
|
||||
- Web: `http://127.0.0.1:5178`
|
||||
- API: `http://127.0.0.1:8088/healthz`
|
||||
- Web 反代 API: `http://127.0.0.1:5178/gateway-api/healthz`
|
||||
- API: `http://127.0.0.1:8088/api/v1/healthz`
|
||||
- Web 反代公开 API: `http://127.0.0.1:5178/api/v1/healthz`
|
||||
|
||||
公开接口统一使用 `/api/v1` 前缀,完整分组清单见 [公开 API V1 清单](docs/public-api-v1.md)。
|
||||
|
||||
常用覆盖项:
|
||||
|
||||
@@ -99,7 +101,7 @@ scripts/deploy-compose.sh clean
|
||||
docker login --username=<your-aliyun-account> registry.cn-shanghai.aliyuncs.com
|
||||
```
|
||||
|
||||
Web 容器的 Nginx 配置通过 bind mount 挂载自仓库文件 [docker/nginx.conf](docker/nginx.conf),可直接修改该文件调整静态资源和 `/gateway-api` 反向代理配置。修改后执行以下命令使配置生效:
|
||||
Web 容器的 Nginx 配置通过 bind mount 挂载自仓库文件 [docker/nginx.conf](docker/nginx.conf),可直接修改该文件调整静态资源、规范 `/api/v1` 公开入口和旧 `/gateway-api` 兼容反向代理。修改后执行以下命令使配置生效:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml restart web
|
||||
|
||||
+1577
-3036
File diff suppressed because it is too large
Load Diff
+1034
-1996
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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 (
|
||||
opsManagementSkillDownloadPath = "/api/v1/public/skills/ai-gateway-ops-management/download"
|
||||
apiDocsJSONPath = "/api-docs-json"
|
||||
apiDocsYAMLPath = "/api-docs-yaml"
|
||||
apiDocsJSONPath = "/api/v1/openapi.json"
|
||||
apiDocsYAMLPath = "/api/v1/openapi.yaml"
|
||||
)
|
||||
|
||||
// getOpsManagementSkillMetadata godoc
|
||||
@@ -64,7 +64,7 @@ func (s *Server) downloadOpsManagementSkill(w http.ResponseWriter, _ *http.Reque
|
||||
// @Tags agent-resources
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api-docs-json [get]
|
||||
// @Router /api/v1/openapi.json [get]
|
||||
func (s *Server) apiDocsJSON(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -77,7 +77,7 @@ func (s *Server) apiDocsJSON(w http.ResponseWriter, _ *http.Request) {
|
||||
// @Tags agent-resources
|
||||
// @Produce application/yaml
|
||||
// @Success 200 {string} string
|
||||
// @Router /api-docs-yaml [get]
|
||||
// @Router /api/v1/openapi.yaml [get]
|
||||
func (s *Server) apiDocsYAML(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestGetOpsManagementSkillMetadata(t *testing.T) {
|
||||
if len(metadata.Modules) != 1 || metadata.Modules[0] != "model-runtime" {
|
||||
t.Fatalf("unexpected metadata modules: %+v", metadata.Modules)
|
||||
}
|
||||
if metadata.APIDocsJSONPath != "/api-docs-json" || metadata.APIDocsYAMLPath != "/api-docs-yaml" {
|
||||
if metadata.APIDocsJSONPath != "/api/v1/openapi.json" || metadata.APIDocsYAMLPath != "/api/v1/openapi.yaml" {
|
||||
t.Fatalf("unexpected API docs paths: %+v", metadata)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
|
||||
server := &Server{}
|
||||
|
||||
jsonResponse := httptest.NewRecorder()
|
||||
server.apiDocsJSON(jsonResponse, httptest.NewRequest(http.MethodGet, "/api-docs-json", nil))
|
||||
server.apiDocsJSON(jsonResponse, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil))
|
||||
if jsonResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected JSON docs status 200, got %d", jsonResponse.Code)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
|
||||
t.Fatalf("decode embedded Swagger JSON: %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/api-docs-json",
|
||||
"/api/v1/openapi.json",
|
||||
"/api/v1/public/skills/ai-gateway-ops-management/download",
|
||||
"/api/admin/catalog/providers",
|
||||
"/api/admin/catalog/base-models",
|
||||
@@ -102,7 +102,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
|
||||
}
|
||||
|
||||
yamlResponse := httptest.NewRecorder()
|
||||
server.apiDocsYAML(yamlResponse, httptest.NewRequest(http.MethodGet, "/api-docs-yaml", nil))
|
||||
server.apiDocsYAML(yamlResponse, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil))
|
||||
if yamlResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected YAML docs status 200, got %d", yamlResponse.Code)
|
||||
}
|
||||
|
||||
@@ -58,17 +58,18 @@ func TestPlanTaskResponseTreatsAPIV1EmbeddingAndRerankAsSynchronousCompatibleRes
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTaskResponseKeepsAsyncTaskModeForOtherAPIV1Tasks(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
req.Header.Set("X-Async", "true")
|
||||
|
||||
plan := planTaskResponse("images.generations", false, map[string]any{"stream": true}, req)
|
||||
|
||||
if !plan.asyncMode {
|
||||
t.Fatal("non-chat /api/v1 task endpoints should keep X-Async task mode")
|
||||
func TestPlanTaskResponseUsesCompatibleAPIV1MediaResponsesAndKeepsAsyncOptIn(t *testing.T) {
|
||||
defaultRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
defaultPlan := planTaskResponse("images.generations", true, map[string]any{}, defaultRequest)
|
||||
if defaultPlan.asyncMode || !defaultPlan.compatibleMode {
|
||||
t.Fatalf("canonical /api/v1 media endpoints should default to synchronous compatible responses, got %+v", defaultPlan)
|
||||
}
|
||||
if plan.compatibleMode {
|
||||
t.Fatal("non-compatible /api/v1 task endpoints should not return OpenAI-compatible payloads")
|
||||
|
||||
asyncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
asyncRequest.Header.Set("X-Async", "true")
|
||||
asyncPlan := planTaskResponse("images.generations", true, map[string]any{}, asyncRequest)
|
||||
if !asyncPlan.asyncMode || !asyncPlan.compatibleMode {
|
||||
t.Fatalf("canonical /api/v1 media endpoints should keep compatible mode when X-Async is enabled, got %+v", asyncPlan)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -504,7 +504,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
Result map[string]any `json:"result"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
|
||||
"model": defaultImageModel,
|
||||
"runMode": "simulation",
|
||||
"prompt": "a tiny gateway console",
|
||||
@@ -512,7 +512,9 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
"quality": "medium",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusAccepted, &imageResponse)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageResponse)
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageResponse.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageResponse.Task)
|
||||
if imageResponse.Task.Status != "succeeded" || imageResponse.Task.Result["id"] == "" {
|
||||
t.Fatalf("unexpected image generation task: %+v", imageResponse.Task)
|
||||
}
|
||||
@@ -524,7 +526,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
Result map[string]any `json:"result"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{
|
||||
"model": defaultImageModel,
|
||||
"runMode": "simulation",
|
||||
"prompt": "replace background with clean studio light",
|
||||
@@ -532,7 +534,9 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
"mask": "https://example.com/mask.png",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusAccepted, &imageEditResponse)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageEditResponse)
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageEditResponse.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageEditResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageEditResponse.Task)
|
||||
if imageEditResponse.Task.Status != "succeeded" || imageEditResponse.Task.Result["id"] == "" {
|
||||
t.Fatalf("unexpected image edit task: %+v", imageEditResponse.Task)
|
||||
}
|
||||
@@ -1196,17 +1200,20 @@ WHERE reference_type = 'gateway_task'
|
||||
}, http.StatusCreated, &videoRoutePlatformModel)
|
||||
var textToVideoTask struct {
|
||||
Task struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ModelType string `json:"modelType"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
"model": videoRouteModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"prompt": "text to video route",
|
||||
}, http.StatusAccepted, &textToVideoTask)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &textToVideoTask)
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, textToVideoTask.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+textToVideoTask.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &textToVideoTask.Task)
|
||||
if textToVideoTask.Task.Status != "succeeded" || textToVideoTask.Task.ModelType != "video_generate" {
|
||||
t.Fatalf("text-to-video request should use video_generate model_type: %+v", textToVideoTask.Task)
|
||||
}
|
||||
@@ -1218,14 +1225,16 @@ WHERE reference_type = 'gateway_task'
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
"model": videoRouteModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"prompt": "image to video route",
|
||||
"image": "https://example.com/source.png",
|
||||
}, http.StatusAccepted, &imageToVideoTask)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageToVideoTask)
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageToVideoTask.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageToVideoTask.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageToVideoTask.Task)
|
||||
if imageToVideoTask.Task.Status != "succeeded" || imageToVideoTask.Task.ModelType != "image_to_video" {
|
||||
t.Fatalf("image-to-video request should use image_to_video model_type: %+v", imageToVideoTask.Task)
|
||||
}
|
||||
|
||||
@@ -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 503 {object} ErrorEnvelope
|
||||
// @Router /api/v1/files/upload [post]
|
||||
// @Router /v1/files/upload [post]
|
||||
func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
|
||||
@@ -75,6 +75,21 @@ func geminiGenerateContentModelFromPath(prefix string, requestPath string) (stri
|
||||
return model, true
|
||||
}
|
||||
|
||||
// geminiGenerateContent godoc
|
||||
// @Summary Gemini generateContent 兼容接口
|
||||
// @Description 使用统一 /api/v1 前缀接收 Gemini generateContent 请求;旧 /v1 和 /v1beta 路径保留兼容。
|
||||
// @Tags gemini-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param model path string true "模型名称"
|
||||
// @Param input body map[string]interface{} true "Gemini generateContent 请求"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /api/v1/models/{model}:generateContent [post]
|
||||
func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -395,6 +410,18 @@ func geminiUsageMetadataFromOutput(output map[string]any) map[string]any {
|
||||
return meta
|
||||
}
|
||||
|
||||
// geminiFilesUpload godoc
|
||||
// @Summary Gemini Files 上传接口
|
||||
// @Description 使用统一 /api/v1 前缀启动或直接完成 Gemini Files 上传。
|
||||
// @Tags gemini-compatible
|
||||
// @Accept octet-stream
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param version path string true "Gemini 版本(v1 或 v1beta)"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /api/v1/gemini/upload/{version}/files [post]
|
||||
func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
@@ -412,6 +439,18 @@ func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// geminiFilesUploadFinalize godoc
|
||||
// @Summary 完成 Gemini Files 分段上传
|
||||
// @Tags gemini-compatible
|
||||
// @Accept octet-stream
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param version path string true "Gemini 版本(v1 或 v1beta)"
|
||||
// @Param uploadID path string true "上传会话 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /api/v1/gemini/upload/{version}/files/{uploadID} [post]
|
||||
func (s *Server) geminiFilesUploadFinalize(w http.ResponseWriter, r *http.Request) {
|
||||
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
@@ -440,11 +479,18 @@ func (s *Server) startGeminiFilesUpload(w http.ResponseWriter, r *http.Request)
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
s.geminiUploadSessions.Store(uploadID, session)
|
||||
w.Header().Set("X-Goog-Upload-URL", absoluteRequestURL(r, "/upload/"+session.Version+"/files/"+uploadID))
|
||||
w.Header().Set("X-Goog-Upload-URL", absoluteRequestURL(r, geminiUploadPath(r, session.Version, uploadID)))
|
||||
w.Header().Set("X-Goog-Upload-Status", "active")
|
||||
writeJSON(w, http.StatusOK, map[string]any{})
|
||||
}
|
||||
|
||||
func geminiUploadPath(r *http.Request, version string, uploadID string) string {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v1/gemini/upload/") {
|
||||
return "/api/v1/gemini/upload/" + version + "/files/" + uploadID
|
||||
}
|
||||
return "/upload/" + version + "/files/" + uploadID
|
||||
}
|
||||
|
||||
func (s *Server) finalizeGeminiFilesUpload(w http.ResponseWriter, r *http.Request, uploadID string, session geminiUploadSession) {
|
||||
if uploadID == "" {
|
||||
uploadID = newGeminiUploadID()
|
||||
|
||||
@@ -102,6 +102,18 @@ func TestRegisterGeminiGenerateContentRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiUploadPathKeepsCanonicalV1Prefix(t *testing.T) {
|
||||
canonical := httptest.NewRequest(http.MethodPost, "/api/v1/gemini/upload/v1beta/files", nil)
|
||||
if got := geminiUploadPath(canonical, "v1beta", "upload-1"); got != "/api/v1/gemini/upload/v1beta/files/upload-1" {
|
||||
t.Fatalf("canonical upload path = %q", got)
|
||||
}
|
||||
|
||||
legacy := httptest.NewRequest(http.MethodPost, "/upload/v1beta/files", nil)
|
||||
if got := geminiUploadPath(legacy, "v1beta", "upload-1"); got != "/upload/v1beta/files/upload-1" {
|
||||
t.Fatalf("legacy upload path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiImageTaskBodyMapsTextOnlyToImageGenerate(t *testing.T) {
|
||||
mapping, err := geminiImageTaskBody("gemini-image", map[string]any{
|
||||
"contents": []any{
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Success 200 {object} HealthResponse
|
||||
// @Router /healthz [get]
|
||||
// @Router /api/v1/healthz [get]
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
@@ -48,7 +48,7 @@ func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
// @Produce json
|
||||
// @Success 200 {object} ReadyResponse
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /readyz [get]
|
||||
// @Router /api/v1/readyz [get]
|
||||
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), postgresReadinessTimeout)
|
||||
defer cancel()
|
||||
@@ -1021,7 +1021,7 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// createTask godoc
|
||||
// @Summary 创建或执行 AI 任务
|
||||
// @Description 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。
|
||||
// @Description 统一公开入口按 model 选择平台模型并默认同步返回兼容响应;设置 X-Async=true 时异步创建任务并返回 202。
|
||||
// @Tags tasks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -1047,22 +1047,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
|
||||
// @Router /api/v1/music/generations [post]
|
||||
// @Router /api/v1/speech/generations [post]
|
||||
// @Router /api/v1/voice_clone [post]
|
||||
// @Router /embeddings [post]
|
||||
// @Router /v1/embeddings [post]
|
||||
// @Router /reranks [post]
|
||||
// @Router /v1/reranks [post]
|
||||
// @Router /images/generations [post]
|
||||
// @Router /v1/images/generations [post]
|
||||
// @Router /images/edits [post]
|
||||
// @Router /v1/images/edits [post]
|
||||
// @Router /song/generations [post]
|
||||
// @Router /v1/song/generations [post]
|
||||
// @Router /music/generations [post]
|
||||
// @Router /v1/music/generations [post]
|
||||
// @Router /speech/generations [post]
|
||||
// @Router /v1/speech/generations [post]
|
||||
// @Router /voice_clone [post]
|
||||
// @Router /v1/voice_clone [post]
|
||||
func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
@@ -1086,11 +1070,15 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return
|
||||
}
|
||||
}
|
||||
model := requestModelName(body)
|
||||
requestedModel := requestModelName(body)
|
||||
model := canonicalTaskModelName(kind, requestedModel)
|
||||
if model == "" {
|
||||
writeError(w, http.StatusBadRequest, "model is required")
|
||||
return
|
||||
}
|
||||
if model != requestedModel {
|
||||
body["model"] = model
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, kind) {
|
||||
writeError(w, http.StatusForbidden, "api key scope does not allow this capability")
|
||||
return
|
||||
@@ -1214,8 +1202,6 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 429 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /chat/completions [post]
|
||||
// @Router /v1/chat/completions [post]
|
||||
func openAIChatCompletionsDoc() {}
|
||||
|
||||
// openAIResponsesDoc godoc
|
||||
@@ -1234,8 +1220,6 @@ func openAIChatCompletionsDoc() {}
|
||||
// @Failure 402 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
|
||||
// @Router /responses [post]
|
||||
// @Router /v1/responses [post]
|
||||
// @Router /api/v1/responses [post]
|
||||
func openAIResponsesDoc() {}
|
||||
|
||||
@@ -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") {
|
||||
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
|
||||
}
|
||||
@@ -1422,6 +1412,25 @@ func requestModelName(body map[string]any) string {
|
||||
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 {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
@@ -1446,8 +1455,12 @@ func scopeForTaskKind(kind string) string {
|
||||
return "rerank"
|
||||
case "images.generations", "images.edits":
|
||||
return "image"
|
||||
case "images.vectorize":
|
||||
return "image_vectorize"
|
||||
case "videos.generations":
|
||||
return "video"
|
||||
case "videos.upscales":
|
||||
return "video_enhance"
|
||||
case "song.generations", "music.generations":
|
||||
return "music"
|
||||
case "speech.generations":
|
||||
@@ -1660,7 +1673,6 @@ func matchedRateLimitRule(policy map[string]any, metric string) map[string]any {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks [get]
|
||||
// @Router /api/v1/tasks [get]
|
||||
// @Router /tasks [get]
|
||||
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -1769,10 +1781,18 @@ func boolValue(body map[string]any, key string) bool {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID} [get]
|
||||
// @Router /api/v1/tasks/{taskID} [get]
|
||||
// @Router /tasks/{taskID} [get]
|
||||
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
|
||||
if err == nil {
|
||||
if !runner.TaskAccessibleToUser(task, user) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
cancelState := runner.DescribeTaskCancellation(task)
|
||||
task.Cancellable = &cancelState.Cancellable
|
||||
task.Submitted = &cancelState.Submitted
|
||||
@@ -1802,8 +1822,6 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID}/cancel [post]
|
||||
// @Router /api/v1/tasks/{taskID}/cancel [post]
|
||||
// @Router /v1/tasks/{taskID}/cancel [post]
|
||||
// @Router /tasks/{taskID}/cancel [post]
|
||||
func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -1840,8 +1858,12 @@ func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID}/param-preprocessing [get]
|
||||
// @Router /api/v1/tasks/{taskID}/param-preprocessing [get]
|
||||
// @Router /tasks/{taskID}/param-preprocessing [get]
|
||||
func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
|
||||
if err != nil {
|
||||
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")
|
||||
return
|
||||
}
|
||||
if !runner.TaskAccessibleToUser(task, user) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
logs, err := s.store.ListTaskParamPreprocessingLogs(r.Context(), task.ID)
|
||||
if err != nil {
|
||||
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
|
||||
// @Router /api/workspace/tasks/{taskID}/events [get]
|
||||
// @Router /api/v1/tasks/{taskID}/events [get]
|
||||
// @Router /tasks/{taskID}/events [get]
|
||||
func (s *Server) taskEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
|
||||
if err != nil {
|
||||
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")
|
||||
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("Cache-Control", "no-cache")
|
||||
|
||||
@@ -132,7 +132,7 @@ func (s *Server) requireKelingAPIKey(next http.Handler) http.Handler {
|
||||
// @Failure 429 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Failure 503 {object} KelingCompatibleEnvelope
|
||||
// @Router /v1/videos/omni-video [post]
|
||||
// @Router /api/v1/videos/omni-video [post]
|
||||
func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := kelingCompatRequestID(r)
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
@@ -206,7 +206,7 @@ func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||||
// @Failure 404 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Router /v1/videos/omni-video/{taskID} [get]
|
||||
// @Router /api/v1/videos/omni-video/{taskID} [get]
|
||||
func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := kelingCompatRequestID(r)
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
@@ -269,7 +269,7 @@ func normalizeKelingOmniRequest(input map[string]any) (map[string]any, *kelingCo
|
||||
if sound != "on" && sound != "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")
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ func normalizeKelingOmniRequest(input map[string]any) (map[string]any, *kelingCo
|
||||
if duration < 3 || duration > maxDuration {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("duration for %s must be an integer between 3 and %d seconds", requestedModel, maxDuration))
|
||||
}
|
||||
if model == "kling-o1" && (len(images) == 0 || hasFirstFrame) && duration != 5 && duration != 10 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -532,14 +532,14 @@ func normalizeKelingMultiPrompts(value any) ([]any, int, *kelingCompatError) {
|
||||
}
|
||||
|
||||
func kelingCompatModel(value string) (string, int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "kling-video-o1", "kling-o1":
|
||||
return "kling-o1", 10, true
|
||||
case "kling-v3-omni", "kling-3.0-omni":
|
||||
return "kling-3.0-omni", 15, true
|
||||
default:
|
||||
model, ok := canonicalKlingOmniModel(value)
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
if model == klingO1Model {
|
||||
return model, 10, true
|
||||
}
|
||||
return model, 15, true
|
||||
}
|
||||
|
||||
func kelingCompatObjectList(value any, field string) ([]map[string]any, *kelingCompatError) {
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestNormalizeKelingOmniRequestMapsOfficialFields(t *testing.T) {
|
||||
if err != nil {
|
||||
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["resolution"] != "1080p" ||
|
||||
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) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-3.0-omni",
|
||||
@@ -70,7 +88,7 @@ func TestNormalizeKelingOmniRequestBuildsMultiShotMedia(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("normalize multi-shot request: %v", err)
|
||||
}
|
||||
if normalized["model"] != "kling-3.0-omni" || normalized["duration"] != 5 || normalized["multi_shot"] != true || normalized["shot_type"] != "customize" {
|
||||
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)
|
||||
}
|
||||
content, _ := normalized["content"].([]any)
|
||||
|
||||
@@ -100,9 +100,9 @@ func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
|
||||
_, err = db.CreatePlatformModel(ctx, store.CreatePlatformModelInput{
|
||||
PlatformID: platform.ID,
|
||||
CanonicalModelKey: "keling:kling-video-o1",
|
||||
ModelName: "kling-o1",
|
||||
ModelName: "kling-video-o1",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
ModelAlias: "kling-o1",
|
||||
ModelAlias: "",
|
||||
ModelType: store.StringList{"omni_video", "video_generate"},
|
||||
DisplayName: "Kling O1 Compatible Test",
|
||||
Capabilities: map[string]any{
|
||||
@@ -145,7 +145,7 @@ func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
|
||||
_, secondAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "second", suffix, false)
|
||||
|
||||
var created KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodPost, "/v1/videos/omni-video", firstAPIKey, map[string]any{
|
||||
doJSON(t, gateway.URL, http.MethodPost, "/api/v1/videos/omni-video", firstAPIKey, map[string]any{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "A clean product reveal",
|
||||
"mode": "std",
|
||||
@@ -162,7 +162,7 @@ func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
|
||||
taskID := stringFromKelingCompat(createdData["task_id"])
|
||||
|
||||
var hidden KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/v1/videos/omni-video/"+taskID, secondAPIKey, nil, http.StatusNotFound, &hidden)
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/api/v1/videos/omni-video/"+taskID, secondAPIKey, nil, http.StatusNotFound, &hidden)
|
||||
if hidden.Code != 1203 {
|
||||
t.Fatalf("cross-user task must be hidden: %+v", hidden)
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func waitForKelingCompatTask(t *testing.T, baseURL string, apiKey string, taskID
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
var response KelingCompatibleEnvelope
|
||||
doJSON(t, baseURL, http.MethodGet, "/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response.Data.(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
|
||||
@@ -134,16 +134,22 @@ WHERE username = $1`, username); err != nil {
|
||||
ResponseDurationMS int64 `json:"responseDurationMs"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(
|
||||
doJSONWithHeaders(
|
||||
t,
|
||||
server.URL,
|
||||
http.MethodPost,
|
||||
"/api/v1/videos/generations",
|
||||
apiKeyResponse.Secret,
|
||||
request,
|
||||
map[string]string{"X-Async": "true"},
|
||||
http.StatusAccepted,
|
||||
&response,
|
||||
)
|
||||
if response.Task.ID == "" {
|
||||
t.Fatal("async Kling simulation response did not return a task id")
|
||||
}
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, response.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+response.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &response.Task)
|
||||
|
||||
task := response.Task
|
||||
if task.ID == "" ||
|
||||
|
||||
@@ -27,6 +27,15 @@ func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
|
||||
handler := func(next http.HandlerFunc) http.Handler {
|
||||
return s.requireUser(auth.PermissionBasic, http.HandlerFunc(next))
|
||||
}
|
||||
// /api/v1 is the canonical public prefix. The historical /kling paths
|
||||
// remain registered below so existing clients can migrate without downtime.
|
||||
mux.Handle("POST /api/v1/kling/v1/videos/omni-video", handler(s.klingV1CreateOmniVideo))
|
||||
mux.Handle("GET /api/v1/kling/v1/videos/omni-video", handler(s.klingV1ListOmniVideos))
|
||||
mux.Handle("GET /api/v1/kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo))
|
||||
mux.Handle("POST /api/v1/kling/v2/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
|
||||
mux.Handle("GET /api/v1/kling/v2/tasks", handler(s.klingV2GetTasks))
|
||||
mux.Handle("POST /api/v1/kling/v2/tasks", handler(s.klingV2ListTasks))
|
||||
|
||||
mux.Handle("POST /kling/v1/videos/omni-video", handler(s.klingV1CreateOmniVideo))
|
||||
mux.Handle("GET /kling/v1/videos/omni-video", handler(s.klingV1ListOmniVideos))
|
||||
mux.Handle("GET /kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo))
|
||||
@@ -52,7 +61,7 @@ func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /kling/v1/videos/omni-video [post]
|
||||
// @Router /api/v1/kling/v1/videos/omni-video [post]
|
||||
func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
var native map[string]any
|
||||
if err := decodeKlingJSON(r, &native); err != nil {
|
||||
@@ -78,7 +87,7 @@ func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request)
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /kling/omni-video/{model} [post]
|
||||
// @Router /api/v1/kling/v2/omni-video/{model} [post]
|
||||
func (s *Server) klingV2CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
model, ok := klingV2ProviderModel(r.PathValue("model"))
|
||||
if !ok {
|
||||
@@ -433,7 +442,7 @@ func validateKlingCompatBody(model string, body map[string]any) error {
|
||||
// @Param taskID path string true "任务 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]interface{}
|
||||
// @Router /kling/v1/videos/omni-video/{taskID} [get]
|
||||
// @Router /api/v1/kling/v1/videos/omni-video/{taskID} [get]
|
||||
func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v1", r.PathValue("taskID"))
|
||||
@@ -456,7 +465,7 @@ func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
// @Param pageNum query int false "页码" default(1)
|
||||
// @Param pageSize query int false "每页数量" default(30)
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/v1/videos/omni-video [get]
|
||||
// @Router /api/v1/kling/v1/videos/omni-video [get]
|
||||
func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
page, err := positiveQueryInt(r.URL.Query().Get("pageNum"), 1)
|
||||
@@ -489,7 +498,7 @@ func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
|
||||
// @Param task_ids query string false "逗号分隔的任务 ID"
|
||||
// @Param external_task_ids query string false "逗号分隔的外部任务 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/tasks [get]
|
||||
// @Router /api/v1/kling/v2/tasks [get]
|
||||
func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
taskIDs := splitKlingIDs(r.URL.Query().Get("task_ids"))
|
||||
@@ -525,7 +534,7 @@ func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
|
||||
// @Security BearerAuth
|
||||
// @Param input body map[string]interface{} true "游标、数量、时间范围和筛选条件"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/tasks [post]
|
||||
// @Router /api/v1/kling/v2/tasks [post]
|
||||
func (s *Server) klingV2ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
var body map[string]any
|
||||
@@ -686,7 +695,11 @@ func klingV2Status(status string) string {
|
||||
}
|
||||
|
||||
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":
|
||||
return klingO1Model, true
|
||||
case "kling-v3-omni", "kling-3.0-omni", "kling-3-omni":
|
||||
|
||||
@@ -138,12 +138,18 @@ WHERE platform_id = $1::uuid
|
||||
}
|
||||
var response struct {
|
||||
Task struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ModelType string `json:"modelType"`
|
||||
ResolvedModel string `json:"resolvedModel"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, request, http.StatusAccepted, &response)
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, request, map[string]string{"X-Async": "true"}, http.StatusAccepted, &response)
|
||||
if response.Task.ID == "" {
|
||||
t.Fatal("async generic video response did not return a task id")
|
||||
}
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, response.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+response.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &response.Task)
|
||||
resolvedModel, resolved := klingV2ProviderModel(response.Task.ResolvedModel)
|
||||
if response.Task.Status != "succeeded" || response.Task.ModelType != expectedModelType || !resolved || resolvedModel != model {
|
||||
t.Fatalf("generic video request without modelType should use inferred capability: %+v", response.Task)
|
||||
@@ -159,7 +165,7 @@ WHERE platform_id = $1::uuid
|
||||
createV1 := func(model string, duration int, externalID string) string {
|
||||
t.Helper()
|
||||
var response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": model,
|
||||
"prompt": "兼容接口模拟任务",
|
||||
"duration": duration,
|
||||
@@ -186,14 +192,14 @@ WHERE platform_id = $1::uuid
|
||||
waitKlingV1SimulationTask(t, server.URL, apiKeyResponse.Secret, taskID)
|
||||
}
|
||||
var listResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodGet, "/kling/v1/videos/omni-video?pageNum=1&pageSize=10", apiKeyResponse.Secret, nil, http.StatusOK, &listResponse)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/kling/v1/videos/omni-video?pageNum=1&pageSize=10", apiKeyResponse.Secret, nil, http.StatusOK, &listResponse)
|
||||
items, _ := listResponse["data"].([]any)
|
||||
if len(items) < 2 {
|
||||
t.Fatalf("V1 task list did not return compatibility tasks: %#v", listResponse)
|
||||
}
|
||||
|
||||
var duplicateResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": klingO1Model, "prompt": "duplicate", "duration": 5,
|
||||
"external_task_id": "compat-o1-" + suffix,
|
||||
"runMode": "simulation", "simulation": true,
|
||||
@@ -203,7 +209,7 @@ WHERE platform_id = $1::uuid
|
||||
}
|
||||
|
||||
var v2Response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
|
||||
"contents": []any{map[string]any{"type": "prompt", "text": "API 2.0 模拟任务"}},
|
||||
"settings": map[string]any{"duration": 3, "resolution": "720p", "aspect_ratio": "16:9", "audio": "off"},
|
||||
"options": map[string]any{"external_task_id": "compat-v2-" + suffix},
|
||||
@@ -224,7 +230,7 @@ func waitKlingV1SimulationTask(t *testing.T, baseURL string, apiKey string, task
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response["data"].(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
@@ -242,7 +248,7 @@ func waitKlingV2SimulationTask(t *testing.T, baseURL string, apiKey string, task
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v2/tasks?task_ids="+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/kling/v2/tasks?task_ids="+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
items, _ := response["data"].([]any)
|
||||
if len(items) == 1 {
|
||||
data, _ := items[0].(map[string]any)
|
||||
|
||||
@@ -1141,6 +1141,10 @@ func canonicalCapabilityFilterValue(value string) string {
|
||||
return "text_embedding"
|
||||
case "rerank", "reranks":
|
||||
return "text_rerank"
|
||||
case "vectorize", "image_vectorizer":
|
||||
return "image_vectorize"
|
||||
case "video_upscale", "upscale":
|
||||
return "video_enhance"
|
||||
case "model":
|
||||
return "model_3d"
|
||||
default:
|
||||
@@ -1164,6 +1168,10 @@ func capabilityFilterValueForTag(tag string) string {
|
||||
return "structured_output"
|
||||
case "数字人":
|
||||
return "digital_human"
|
||||
case "图片矢量化":
|
||||
return "image_vectorize"
|
||||
case "视频增强":
|
||||
return "video_enhance"
|
||||
case "重排序":
|
||||
return "text_rerank"
|
||||
case "3D 模型":
|
||||
@@ -1194,11 +1202,13 @@ func capabilityLabel(value string) string {
|
||||
"image_generate": "图像生成",
|
||||
"image_edit": "图像编辑",
|
||||
"image_analysis": "图像分析",
|
||||
"image_vectorize": "图片矢量化",
|
||||
"video_generate": "视频生成",
|
||||
"image_to_video": "图生视频",
|
||||
"text_to_video": "文生视频",
|
||||
"video_edit": "视频编辑",
|
||||
"video_understanding": "视频理解",
|
||||
"video_enhance": "视频增强",
|
||||
"audio_generate": "音频生成",
|
||||
"text_to_speech": "语音合成",
|
||||
"voice_clone": "音色克隆",
|
||||
|
||||
@@ -23,8 +23,8 @@ type SkillBundleMetadataResponse struct {
|
||||
Modules []string `json:"modules" example:"model-runtime"`
|
||||
FileName string `json:"fileName" example:"ai-gateway-ops-management-v1.0.2.zip"`
|
||||
DownloadPath string `json:"downloadPath" example:"/api/v1/public/skills/ai-gateway-ops-management/download"`
|
||||
APIDocsJSONPath string `json:"apiDocsJsonPath" example:"/api-docs-json"`
|
||||
APIDocsYAMLPath string `json:"apiDocsYamlPath" example:"/api-docs-yaml"`
|
||||
APIDocsJSONPath string `json:"apiDocsJsonPath" example:"/api/v1/openapi.json"`
|
||||
APIDocsYAMLPath string `json:"apiDocsYamlPath" example:"/api/v1/openapi.yaml"`
|
||||
}
|
||||
|
||||
type ErrorEnvelope struct {
|
||||
@@ -210,17 +210,31 @@ type PricingEstimateResponse struct {
|
||||
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 {
|
||||
Model string `json:"model" example:"gpt-4o-mini"`
|
||||
Messages []ChatMessage `json:"messages,omitempty"`
|
||||
Input interface{} `json:"input,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"`
|
||||
Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."`
|
||||
TextFileID string `json:"text_file_id,omitempty" example:""`
|
||||
VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"`
|
||||
Stream *bool `json:"stream,omitempty" example:"false"`
|
||||
RunMode string `json:"runMode,omitempty" example:"simulation"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
|
||||
Model string `json:"model" example:"gpt-4o-mini"`
|
||||
Messages []ChatMessage `json:"messages,omitempty"`
|
||||
Input interface{} `json:"input,omitempty"`
|
||||
Query string `json:"query,omitempty" example:"Which document mentions EasyAI Gateway?"`
|
||||
Documents []string `json:"documents,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"`
|
||||
Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."`
|
||||
TextFileID string `json:"text_file_id,omitempty" example:""`
|
||||
VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"`
|
||||
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 *int `json:"max_completion_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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
Model string `json:"model" example:"video-model"`
|
||||
Prompt string `json:"prompt" example:"A cinematic drone shot over mountains"`
|
||||
|
||||
@@ -161,7 +161,7 @@ func validPricingRuleSetInput(input store.PricingRuleSetInput) bool {
|
||||
return false
|
||||
}
|
||||
switch calculator := strings.TrimSpace(rule.CalculatorType); calculator {
|
||||
case "", "token_usage", "unit_weight", "duration_weight":
|
||||
case "", "token_usage", "unit_weight", "duration_weight", "transition_matrix":
|
||||
default:
|
||||
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) {
|
||||
server := &Server{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/security-events/connection/verify", nil)
|
||||
|
||||
@@ -127,12 +127,16 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", server.health)
|
||||
mux.HandleFunc("GET /readyz", server.ready)
|
||||
mux.HandleFunc("GET /api/v1/healthz", server.health)
|
||||
mux.HandleFunc("GET /api/v1/readyz", server.ready)
|
||||
mux.Handle("GET /metrics", securityEventMetrics.DynamicHandler(db))
|
||||
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
|
||||
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
|
||||
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
|
||||
mux.HandleFunc("GET /api-docs-json", server.apiDocsJSON)
|
||||
mux.HandleFunc("GET /api-docs-yaml", server.apiDocsYAML)
|
||||
mux.HandleFunc("GET /api/v1/openapi.json", server.apiDocsJSON)
|
||||
mux.HandleFunc("GET /api/v1/openapi.yaml", server.apiDocsYAML)
|
||||
mux.HandleFunc("GET /api/v1/public/skills/ai-gateway-ops-management/metadata", server.getOpsManagementSkillMetadata)
|
||||
mux.HandleFunc("GET /api/v1/public/skills/ai-gateway-ops-management/download", server.downloadOpsManagementSkill)
|
||||
|
||||
@@ -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/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/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/{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)))
|
||||
@@ -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/model-rate-limits", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModelRateLimitStatuses)))
|
||||
mux.Handle("POST /api/v1/chat/completions", server.requireUser(auth.PermissionBasic, server.createAPIV1ChatCompletions()))
|
||||
mux.Handle("POST /api/v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", false)))
|
||||
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", false)))
|
||||
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", false)))
|
||||
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", false)))
|
||||
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", false)))
|
||||
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", false)))
|
||||
mux.Handle("POST /api/v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
|
||||
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
|
||||
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
|
||||
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
|
||||
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /api/v1/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("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getLegacyVolcesVideoResult)))
|
||||
mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
@@ -275,10 +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/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
|
||||
mux.Handle("DELETE /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
|
||||
mux.Handle("POST /api/v1/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createVolcesContentsGenerationTask)))
|
||||
mux.Handle("GET /api/v1/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
|
||||
mux.Handle("GET /api/v1/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
|
||||
mux.Handle("DELETE /api/v1/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
|
||||
server.registerGeminiGenerateContentRoutes(mux)
|
||||
server.registerKlingCompatibilityRoutes(mux)
|
||||
mux.Handle("POST /upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
|
||||
mux.Handle("POST /upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
|
||||
mux.Handle("POST /api/v1/gemini/upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
|
||||
mux.Handle("POST /api/v1/gemini/upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
|
||||
mux.Handle("GET /api/v1/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
mux.Handle("GET /api/v1/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||
mux.Handle("POST /api/v1/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
|
||||
@@ -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 /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("GET /v1/videos/omni-video/{taskID}", server.requireKelingAPIKey(http.HandlerFunc(server.getKelingOmniVideo)))
|
||||
mux.Handle("POST /api/v1/videos/omni-video", server.requireKelingAPIKey(http.HandlerFunc(server.createKelingOmniVideo)))
|
||||
mux.Handle("GET /api/v1/videos/omni-video/{taskID}", server.requireKelingAPIKey(http.HandlerFunc(server.getKelingOmniVideo)))
|
||||
mux.Handle("POST /song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/voice_clone/voices [get]
|
||||
// @Router /v1/voice_clone/voices [get]
|
||||
// @Router /voice_clone/voices [get]
|
||||
func (s *Server) listClonedVoices(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -51,8 +49,6 @@ func (s *Server) listClonedVoices(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /api/v1/voice_clone/voices/{voiceID} [delete]
|
||||
// @Router /v1/voice_clone/voices/{voiceID} [delete]
|
||||
// @Router /voice_clone/voices/{voiceID} [delete]
|
||||
func (s *Server) deleteClonedVoice(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
|
||||
@@ -17,13 +17,13 @@ const volcesContentsCompatibilityMarker = "volces_contents_generations_v3"
|
||||
|
||||
// createVolcesContentsGenerationTask godoc
|
||||
// @Summary 创建火山内容生成任务
|
||||
// @Description 兼容火山方舟 POST /api/v3/contents/generations/tasks。网关 task id 是查询与取消用的公开 id;上游 id 另以 upstream_task_id 保留。
|
||||
// @Description 统一公开入口兼容火山方舟内容生成任务。网关 task id 是查询与取消用的公开 id;上游 id 另以 upstream_task_id 保留。
|
||||
// @Tags volces-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks [post]
|
||||
// @Router /api/v1/contents/generations/tasks [post]
|
||||
func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
@@ -48,8 +48,9 @@ func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param taskID path string true "任务 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks/{taskID} [get]
|
||||
// @Router /api/v1/contents/generations/tasks/{taskID} [get]
|
||||
func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
@@ -64,7 +65,7 @@ func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks [get]
|
||||
// @Router /api/v1/contents/generations/tasks [get]
|
||||
func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
@@ -104,8 +105,9 @@ func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *htt
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param taskID path string true "任务 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks/{taskID} [delete]
|
||||
// @Router /api/v1/contents/generations/tasks/{taskID} [delete]
|
||||
func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
@@ -170,6 +172,7 @@ func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *htt
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param taskID path string true "任务 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/ai/result/{taskID} [get]
|
||||
func (s *Server) getLegacyVolcesVideoResult(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -105,7 +105,10 @@ func (manager *Manager) SecurityEventReceiver() http.Handler {
|
||||
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
|
||||
return runtime.SecurityEvents
|
||||
}
|
||||
return manager.SecurityEventManager()
|
||||
if securityEventManager := manager.SecurityEventManager(); securityEventManager != nil {
|
||||
return securityEventManager
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecurityEventManager resolves the manager used by administrative recovery
|
||||
|
||||
@@ -630,6 +630,14 @@ func TestSecurityEventManagerExposesPreparedRecoveryManagerWithoutActiveRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventReceiverReturnsNilWithoutConfiguredManager(t *testing.T) {
|
||||
manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{})
|
||||
|
||||
if manager.SecurityEventReceiver() != nil {
|
||||
t.Fatal("unconfigured security event receiver should be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventManagerPrefersActiveRuntime(t *testing.T) {
|
||||
active := &securityevents.ConnectionManager{}
|
||||
prepared := &securityevents.ConnectionManager{}
|
||||
|
||||
@@ -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) {
|
||||
tokens := estimateRequestTokens(map[string]any{
|
||||
"model": "demo-video",
|
||||
|
||||
@@ -3,6 +3,7 @@ package runner
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
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" {
|
||||
resource = "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)}
|
||||
}
|
||||
|
||||
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 {
|
||||
var inheritedRuleSetConfig map[string]any
|
||||
if ruleSetID := firstNonEmptyString(candidate.BasePricingRuleSetID, candidate.PlatformPricingRuleSetID); ruleSetID != "" && s.store != nil {
|
||||
|
||||
@@ -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) {
|
||||
service := &Service{}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
|
||||
@@ -659,6 +659,28 @@ func (s *Service) billingsWithResolvedPricingV2(
|
||||
resource = "image_edit"
|
||||
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" {
|
||||
resource = "video"
|
||||
unit = "5s_video"
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -90,6 +92,8 @@ func New(cfg config.Config, db *store.Store, logger *slog.Logger, observers ...b
|
||||
"volces": clients.VolcesClient{HTTPClient: httpClients.none},
|
||||
"keling": 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},
|
||||
"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) {
|
||||
var err error
|
||||
candidate, err = candidateWithEnvironmentCredentials(candidate)
|
||||
if err != nil {
|
||||
return clients.Response{}, err
|
||||
}
|
||||
simulated := isSimulation(task, candidate)
|
||||
baseAttemptMetrics := mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), parameterPreprocessingMetrics(preprocessing))
|
||||
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
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||
@@ -1421,6 +1439,8 @@ func modelTypeFromKind(kind string, body map[string]any) string {
|
||||
return "image_edit"
|
||||
}
|
||||
return "image_generate"
|
||||
case "images.vectorize":
|
||||
return "image_vectorize"
|
||||
case "videos.generations":
|
||||
if videoRequestHasVideoOrAudioReference(body) {
|
||||
return "omni_video"
|
||||
@@ -1429,6 +1449,8 @@ func modelTypeFromKind(kind string, body map[string]any) string {
|
||||
return "image_to_video"
|
||||
}
|
||||
return "video_generate"
|
||||
case "videos.upscales":
|
||||
return "video_enhance"
|
||||
case "song.generations", "music.generations":
|
||||
return "audio_generate"
|
||||
case "speech.generations":
|
||||
@@ -1463,6 +1485,10 @@ func canonicalModelType(value string) string {
|
||||
return "text_to_speech"
|
||||
case "voice", "voice_clone", "voiceclone", "voice.cloning":
|
||||
return "voice_clone"
|
||||
case "vectorize", "image_vectorizer", "image_vectorize":
|
||||
return "image_vectorize"
|
||||
case "video_upscale", "video_enhance", "upscale":
|
||||
return "video_enhance"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
@@ -1470,7 +1496,7 @@ func canonicalModelType(value string) string {
|
||||
|
||||
func isKnownModelType(value string) bool {
|
||||
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
|
||||
default:
|
||||
return false
|
||||
@@ -1703,6 +1729,14 @@ func validateRequest(kind string, body map[string]any) error {
|
||||
if strings.TrimSpace(stringFromMap(body, "prompt")) == "" {
|
||||
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":
|
||||
if strings.TrimSpace(stringFromMap(body, "prompt")) == "" {
|
||||
return errors.New("prompt is required")
|
||||
@@ -1722,6 +1756,74 @@ func validateRequest(kind string, body map[string]any) error {
|
||||
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 {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
|
||||
@@ -211,6 +211,13 @@ func taskAccessibleToUser(task store.GatewayTask, user *auth.User) bool {
|
||||
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 {
|
||||
if user == nil {
|
||||
return ""
|
||||
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
@@ -85,6 +87,11 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
|
||||
if err != nil {
|
||||
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))
|
||||
needsUpload := false
|
||||
changed := false
|
||||
@@ -723,7 +730,8 @@ func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURL
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
value := strings.TrimSpace(raw)
|
||||
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 {
|
||||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||
if generatedContentTypeIsDocument(contentType) {
|
||||
return "file"
|
||||
}
|
||||
if strings.HasPrefix(contentType, "image/") {
|
||||
return "image"
|
||||
}
|
||||
@@ -1314,6 +1366,9 @@ func mediaKindForAsset(taskKind string, item map[string]any, sourceKey string, c
|
||||
return "audio"
|
||||
}
|
||||
itemType := strings.ToLower(strings.TrimSpace(stringFromAny(item["type"])))
|
||||
if itemType == "file" || strings.Contains(itemType, "document") {
|
||||
return "file"
|
||||
}
|
||||
if strings.Contains(itemType, "video") {
|
||||
return "video"
|
||||
}
|
||||
@@ -1346,6 +1401,8 @@ func defaultContentTypeForGeneratedAsset(kind string) string {
|
||||
return "video/mp4"
|
||||
case "audio":
|
||||
return "audio/mpeg"
|
||||
case "file":
|
||||
return "application/octet-stream"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
@@ -1354,10 +1411,10 @@ func defaultContentTypeForGeneratedAsset(kind string) string {
|
||||
func resolvedGeneratedAssetContentType(declared string, kind string, payload []byte) string {
|
||||
declared = normalizeGeneratedContentType(declared)
|
||||
detected := detectGeneratedAssetContentType(payload)
|
||||
if generatedContentTypeIsMedia(detected) {
|
||||
if generatedContentTypeIsMedia(detected) || generatedContentTypeIsDocument(detected) {
|
||||
return detected
|
||||
}
|
||||
if generatedContentTypeIsMedia(declared) {
|
||||
if generatedContentTypeIsMedia(declared) || generatedContentTypeIsDocument(declared) {
|
||||
return declared
|
||||
}
|
||||
return defaultContentTypeForGeneratedAsset(kind)
|
||||
@@ -1380,6 +1437,15 @@ func generatedContentTypeIsMedia(contentType string) bool {
|
||||
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 {
|
||||
contentType = normalizeGeneratedContentType(contentType)
|
||||
if strings.HasPrefix(contentType, "image/") {
|
||||
@@ -1391,6 +1457,9 @@ func generatedAssetKindFromContentType(fallback string, contentType string) stri
|
||||
if strings.HasPrefix(contentType, "audio/") {
|
||||
return "audio"
|
||||
}
|
||||
if generatedContentTypeIsDocument(contentType) {
|
||||
return "file"
|
||||
}
|
||||
fallback = strings.ToLower(strings.TrimSpace(fallback))
|
||||
if fallback != "" {
|
||||
return fallback
|
||||
@@ -1434,6 +1503,14 @@ func randomHexSuffix(byteCount int) string {
|
||||
func fileExtensionForContentType(contentType string, kind string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
|
||||
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":
|
||||
return ".jpg"
|
||||
case "image/webp":
|
||||
|
||||
@@ -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) {
|
||||
item := map[string]any{
|
||||
"type": "video",
|
||||
|
||||
@@ -215,6 +215,10 @@ func (s *Service) DeleteClonedVoice(ctx context.Context, user *auth.User, rawID
|
||||
if !ok {
|
||||
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)
|
||||
if err != nil {
|
||||
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.
|
||||
- Prefer a supported standard client before using `universal` scripts. Use custom scripts only when the upstream contract cannot be represented by the existing OpenAI, Gemini, or provider-specific clients.
|
||||
- Do not invent platform config fields or assume an arbitrary config key is enforced. For `universal`, use only the recognized keys documented in `references/model-universal-platforms.md`; treat any extra key as script-owned data available through `context.env`.
|
||||
- Use the module references as the primary API source. Only when the required API is absent, inspect `<gateway-api-base-url>/api-docs-json`; continue only when path, method, schema, authentication, permission, and side effects are unambiguous.
|
||||
- Use the module references as the primary API source. Only when the required API is absent, inspect `<gateway-origin>/api/v1/openapi.json`; continue only when path, method, schema, authentication, permission, and side effects are unambiguous.
|
||||
|
||||
## Module Routing
|
||||
|
||||
|
||||
+8
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
## Required Inputs
|
||||
|
||||
- Gateway API base URL. When using the bundled Web deployment this commonly includes `/gateway-api`; direct API access commonly uses port `8088`.
|
||||
- Gateway origin and public API base URL. Public API access always ends with `/api/v1`; direct local access commonly uses `http://127.0.0.1:8088/api/v1`.
|
||||
- Administrator JWT with the `manager` or `admin` role.
|
||||
- Target provider documentation and authorization material.
|
||||
- Clear requested outcome and whether real upstream calls are allowed.
|
||||
@@ -10,7 +10,8 @@
|
||||
Do not place credentials in files or reusable commands. Use shell environment variables:
|
||||
|
||||
```bash
|
||||
export GATEWAY_BASE_URL='https://gateway.example.com/gateway-api'
|
||||
export GATEWAY_ORIGIN='https://gateway.example.com'
|
||||
export GATEWAY_PUBLIC_API_BASE="$GATEWAY_ORIGIN/api/v1"
|
||||
export GATEWAY_ADMIN_TOKEN='<administrator-jwt>'
|
||||
```
|
||||
|
||||
@@ -24,7 +25,7 @@ For standalone or hybrid deployments, local login can return a JWT:
|
||||
curl --fail-with-body \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"account":"<admin-account>","password":"<admin-password>"}' \
|
||||
"$GATEWAY_BASE_URL/api/v1/auth/login"
|
||||
"$GATEWAY_PUBLIC_API_BASE/auth/login"
|
||||
```
|
||||
|
||||
Do not use local login when the deployment requires OIDC or server-main identity. Obtain the deployment's administrator access token instead.
|
||||
@@ -34,7 +35,7 @@ Verify identity and role before writes:
|
||||
```bash
|
||||
curl --fail-with-body \
|
||||
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
|
||||
"$GATEWAY_BASE_URL/api/v1/me"
|
||||
"$GATEWAY_PUBLIC_API_BASE/me"
|
||||
```
|
||||
|
||||
## Request Pattern
|
||||
@@ -47,7 +48,7 @@ curl --fail-with-body \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
-d '<json-body>' \
|
||||
"$GATEWAY_BASE_URL/api/admin/<resource>"
|
||||
"$GATEWAY_ORIGIN/api/admin/<resource>"
|
||||
```
|
||||
|
||||
Always read current state before PATCH, DELETE, reset, disable, or full replacement. PATCH handlers for providers, base models, pricing rule sets, runtime policy sets, runner policy, and platforms write complete resource shapes rather than merging every omitted field.
|
||||
@@ -67,7 +68,7 @@ Obtain explicit confirmation after showing the current snapshot and impact befor
|
||||
|
||||
The live machine-readable documents are:
|
||||
|
||||
- `<gateway-api-base-url>/api-docs-json`
|
||||
- `<gateway-api-base-url>/api-docs-yaml`
|
||||
- `<gateway-origin>/api/v1/openapi.json`
|
||||
- `<gateway-origin>/api/v1/openapi.yaml`
|
||||
|
||||
Use them only when this Skill does not document the required operation. Before acting, confirm the exact path, method, body, authentication, permission, response, and side effect. Do not infer a write operation from a similarly named endpoint.
|
||||
|
||||
+3
-3
@@ -33,7 +33,7 @@ Use an authorized user JWT:
|
||||
```bash
|
||||
curl --fail-with-body \
|
||||
-H "Authorization: Bearer <user-jwt>" \
|
||||
"$GATEWAY_BASE_URL/api/v1/model-catalog"
|
||||
"$GATEWAY_PUBLIC_API_BASE/model-catalog"
|
||||
```
|
||||
|
||||
Confirm model alias, model types, provider source, effective capabilities, pricing summary, rate limits, permissions, and enabled state.
|
||||
@@ -57,7 +57,7 @@ curl --fail-with-body \
|
||||
"simulation": true,
|
||||
"stream": false
|
||||
}' \
|
||||
"$GATEWAY_BASE_URL/v1/chat/completions"
|
||||
"$GATEWAY_PUBLIC_API_BASE/chat/completions"
|
||||
```
|
||||
|
||||
Simulation verifies Gateway routing, permissions, parameter normalization, pricing, and task behavior, but it does not execute the real universal submit or poll scripts. Validate universal scripts separately against a local mock or approved provider test environment before enabling production traffic.
|
||||
@@ -82,4 +82,4 @@ With explicit approval, run one real minimal request and verify upstream request
|
||||
|
||||
## Final Report
|
||||
|
||||
Report resource IDs, before/after behavior, requests used for verification, simulation or real mode, billing evidence, remaining risks, rollback readiness, and whether `/api-docs-json` was used. Never include credentials or raw secret-bearing payloads.
|
||||
Report resource IDs, before/after behavior, requests used for verification, simulation or real mode, billing evidence, remaining risks, rollback readiness, and whether `/api/v1/openapi.json` was used. Never include credentials or raw secret-bearing payloads.
|
||||
|
||||
@@ -486,8 +486,12 @@ func modelTypeAliases(value string) []string {
|
||||
return []string{"image_generate"}
|
||||
case "images.edits":
|
||||
return []string{"image_edit"}
|
||||
case "images.vectorize", "vectorize":
|
||||
return []string{"image_vectorize"}
|
||||
case "video", "videos.generations":
|
||||
return []string{"video_generate"}
|
||||
case "videos.upscales", "video_upscale":
|
||||
return []string{"video_enhance"}
|
||||
case "omni_video":
|
||||
return []string{"video_generate", "image_to_video", "omni_video"}
|
||||
case "song", "music", "song.generations", "music.generations", "music_generate":
|
||||
|
||||
@@ -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
|
||||
case "images.edits", "image_edit":
|
||||
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",
|
||||
"video_edit", "omni_video", "video_reference", "video_first_last_frame":
|
||||
resources["video"] = true
|
||||
case "video_enhance", "videos.upscales", "video_upscale":
|
||||
resources["video_enhance"] = true
|
||||
case "audio", "text_to_speech", "speech", "voice_clone":
|
||||
resources["audio"] = true
|
||||
case "music", "music_generate", "audio_generate":
|
||||
@@ -100,8 +104,12 @@ func billingConfigKeyAllowed(key string, resources map[string]bool) bool {
|
||||
return resources["image"]
|
||||
case "image_edit", "imageedit", "editbase":
|
||||
return resources["image_edit"]
|
||||
case "image_vectorize", "imagevectorize", "vectorizebase":
|
||||
return resources["image_vectorize"]
|
||||
case "video", "videobase":
|
||||
return resources["video"]
|
||||
case "video_enhance", "videoenhance", "videoenhancebase":
|
||||
return resources["video_enhance"]
|
||||
case "audio", "audiobase":
|
||||
return resources["audio"]
|
||||
case "music", "musicbase":
|
||||
|
||||
@@ -29,7 +29,7 @@ const (
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -517,7 +517,7 @@ type GatewayTask struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
RemoteTaskID string `json:"remoteTaskId,omitempty"`
|
||||
RemoteTaskPayload map[string]any `json:"remoteTaskPayload,omitempty"`
|
||||
RemoteTaskPayload map[string]any `json:"-"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Billings []any `json:"billings,omitempty"`
|
||||
Usage map[string]any `json:"usage"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
switch calculatorType {
|
||||
case "token_usage", "unit_weight", "duration_weight":
|
||||
case "token_usage", "unit_weight", "duration_weight", "transition_matrix":
|
||||
default:
|
||||
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"
|
||||
case "image", "image_edit":
|
||||
allowed = unit == "image" && calculatorType == "unit_weight"
|
||||
case "image_vectorize":
|
||||
allowed = unit == "conversion" && calculatorType == "unit_weight"
|
||||
case "video":
|
||||
allowed = unit == "5s" && calculatorType == "duration_weight"
|
||||
case "video_enhance":
|
||||
allowed = unit == "5s" && calculatorType == "transition_matrix"
|
||||
case "music":
|
||||
allowed = (unit == "song" || unit == "item") && calculatorType == "unit_weight"
|
||||
case "audio":
|
||||
@@ -415,6 +419,8 @@ func DefaultEffectivePricingCalculator(resourceType string) string {
|
||||
return "token_usage"
|
||||
case "video":
|
||||
return "duration_weight"
|
||||
case "video_enhance":
|
||||
return "transition_matrix"
|
||||
default:
|
||||
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: "image", resource: "image", unit: "image", calculator: "unit_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 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)
|
||||
}
|
||||
}
|
||||
@@ -528,13 +528,11 @@ WHERE id = $1::uuid
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gateway_task_attempts
|
||||
SET remote_task_id = NULLIF($2::text, ''),
|
||||
response_snapshot = COALESCE(response_snapshot, '{}'::jsonb) || jsonb_build_object('remote_task_payload', $3::jsonb)
|
||||
SET remote_task_id = NULLIF($2::text, '')
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'running'`,
|
||||
attemptID,
|
||||
remoteTaskID,
|
||||
string(payloadJSON),
|
||||
)
|
||||
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();
|
||||
@@ -24,7 +24,7 @@
|
||||
"outputs": ["{projectRoot}/docs/swagger.json", "{projectRoot}/docs/swagger.yaml"],
|
||||
"options": {
|
||||
"cwd": "apps/api",
|
||||
"command": "go run github.com/swaggo/swag/cmd/swag@v1.16.4 init --parseInternal -d ./cmd/gateway,./internal/httpapi,./internal/store,./internal/auth,./internal/identity -g main.go -o docs --outputTypes json,yaml"
|
||||
"command": "go run github.com/swaggo/swag/cmd/swag@v1.16.4 init --parseInternal -d ./cmd/gateway,./internal/httpapi,./internal/store,./internal/auth,./internal/identity,./internal/runner -g main.go -o docs --outputTypes json,yaml"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
|
||||
@@ -266,8 +266,8 @@ describe('Public Agent resources', () => {
|
||||
modules: ['model-runtime'],
|
||||
fileName: 'ai-gateway-ops-management-v1.0.2.zip',
|
||||
downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download',
|
||||
apiDocsJsonPath: '/api-docs-json',
|
||||
apiDocsYamlPath: '/api-docs-yaml',
|
||||
apiDocsJsonPath: '/api/v1/openapi.json',
|
||||
apiDocsYamlPath: '/api/v1/openapi.yaml',
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(metadata), {
|
||||
status: 200,
|
||||
|
||||
@@ -35,17 +35,17 @@ interface ApiGuideItem {
|
||||
}
|
||||
|
||||
export const apiDocs: ApiDocItem[] = [
|
||||
{ key: 'chat', group: '文本', kind: 'chat.completions', method: 'POST', path: '/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' },
|
||||
{ key: 'responses', group: '文本', kind: 'responses', method: 'POST', path: '/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' },
|
||||
{ key: 'embeddings', group: '文本', kind: 'embeddings', method: 'POST', path: '/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embedding,API Key 需要 embedding 权限。' },
|
||||
{ key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_score,API Key 需要 rerank 权限。' },
|
||||
{ key: 'imageGeneration', group: '图片', kind: 'images.generations', method: 'POST', path: '/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' },
|
||||
{ key: 'imageEdit', group: '图片', kind: 'images.edits', method: 'POST', path: '/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' },
|
||||
{ key: 'chat', group: '文本', kind: 'chat.completions', method: 'POST', path: '/api/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' },
|
||||
{ key: 'responses', group: '文本', kind: 'responses', method: 'POST', path: '/api/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' },
|
||||
{ key: 'embeddings', group: '文本', kind: 'embeddings', method: 'POST', path: '/api/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embedding,API Key 需要 embedding 权限。' },
|
||||
{ key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/api/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_score,API Key 需要 rerank 权限。' },
|
||||
{ key: 'imageGeneration', group: '图片', kind: 'images.generations', method: 'POST', path: '/api/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' },
|
||||
{ key: 'imageEdit', group: '图片', kind: 'images.edits', method: 'POST', path: '/api/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' },
|
||||
{ key: 'videoGeneration', group: '视频', kind: 'videos.generations', method: 'POST', path: '/api/v1/videos/generations', title: '生成视频', lead: '视频生成任务接口,支持文生视频、首尾帧、图片/视频/音频参考,以及时长、分辨率、画幅和声音等模型能力参数。' },
|
||||
{ key: 'asyncMode', group: '异步任务', title: '异步模式', lead: '所有 AI 任务创建接口使用同一种异步开启方式:保留原接口和原请求 Body,只需增加 X-Async: true。' },
|
||||
{ key: 'taskRetrieve', group: '异步任务', kind: 'tasks.retrieve', method: 'GET', path: '/api/v1/tasks/{taskID}', title: '取回任务', lead: '使用异步提交返回的 taskId 查询任务状态、结果、错误、用量、计费和执行尝试;queued、running、submitting 为进行中状态。' },
|
||||
{ key: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' },
|
||||
{ key: 'files', group: '文件', method: 'POST', path: '/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
|
||||
{ key: 'files', group: '文件', method: 'POST', path: '/api/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' },
|
||||
];
|
||||
|
||||
const guideItems: ApiGuideItem[] = [
|
||||
@@ -73,8 +73,8 @@ const defaultOpsSkillMetadata: GatewaySkillBundleMetadata = {
|
||||
modules: ['model-runtime'],
|
||||
fileName: 'ai-gateway-ops-management.zip',
|
||||
downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download',
|
||||
apiDocsJsonPath: '/api-docs-json',
|
||||
apiDocsYamlPath: '/api-docs-yaml',
|
||||
apiDocsJsonPath: '/api/v1/openapi.json',
|
||||
apiDocsYamlPath: '/api/v1/openapi.yaml',
|
||||
};
|
||||
|
||||
export function ApiDocsPage(props: {
|
||||
@@ -545,8 +545,8 @@ function GuideDetails(props: { onCreateApiKey: () => void; section: ApiGuideSect
|
||||
return (
|
||||
<>
|
||||
<GuideSection title="1. 确认 Base URL">
|
||||
<p>Base URL 由当前 Gateway 部署环境提供。将接口文档中的路径拼接到 Base URL 后调用,不要重复添加末尾斜杠。</p>
|
||||
<pre>{`export EASYAI_BASE_URL="https://your-gateway.example.com"\ncurl "$EASYAI_BASE_URL/healthz"`}</pre>
|
||||
<p>公开 API Base URL 固定以 <code>/api/v1</code> 结尾。接口卡片展示的是完整路径;SDK 配置 Base URL 后只追加资源路径,不要再次添加 <code>/api/v1</code>。</p>
|
||||
<pre>{`export EASYAI_BASE_URL="https://your-gateway.example.com/api/v1"\ncurl "$EASYAI_BASE_URL/healthz"`}</pre>
|
||||
</GuideSection>
|
||||
<GuideSection title="2. 创建并使用 API Key">
|
||||
<p>登录后在“用户工作台 → API Key”创建 Key,并为它分配实际需要的 chat、embedding、rerank、image 或 video 权限。密钥只在创建或重置时完整展示,请立即安全保存。</p>
|
||||
|
||||
@@ -1 +1 @@
|
||||
fba9759bc7a5fbca5e9d1465bdd7a91de6a10928
|
||||
fce76a30ba2d7f9aa514418a2e09be7bbe7aaf4e
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -87,7 +87,7 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8088/readyz | grep -q '\"ok\":true'"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8088/api/v1/readyz | grep -q '\"ok\":true'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
api:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/gateway-api/healthz | grep -q 'easyai-ai-gateway'"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/v1/healthz | grep -q 'easyai-ai-gateway'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
@@ -33,6 +33,10 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location = /api/v1/metrics {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location = /gateway-api/api/v1/auth/login {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -47,6 +51,35 @@ server {
|
||||
proxy_pass http://api:8088/api/v1/auth/login;
|
||||
}
|
||||
|
||||
location = /api/v1/auth/login {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 15s;
|
||||
proxy_send_timeout 15s;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://api:8088;
|
||||
}
|
||||
|
||||
# Canonical public API. Keep the request URI so /api/v1 reaches the
|
||||
# versioned handlers without another prefix rewrite.
|
||||
location ^~ /api/v1/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_pass http://api:8088;
|
||||
}
|
||||
|
||||
location /gateway-api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
+4
-4
@@ -91,7 +91,7 @@ flowchart LR
|
||||
QUEUE --> PG
|
||||
QUEUE --> CALLBACK -->|POST task progress callback to server-main| WSGW
|
||||
QUEUE -->|settlement event| BILL
|
||||
API -->|POST /v1/files/upload| FILES
|
||||
API -->|POST /api/v1/files/upload| FILES
|
||||
```
|
||||
|
||||
## 4. Monorepo 方案
|
||||
@@ -1785,9 +1785,9 @@ SimulationClient 根据 `simulation_profile` 生成确定性行为:
|
||||
- `/chat/completions`
|
||||
- `/images/generations`
|
||||
- `/video/generations`
|
||||
- `/v1/chat/completions`
|
||||
- `/v1/images/generations`
|
||||
- `/v1/video/generations`
|
||||
- `/api/v1/chat/completions`
|
||||
- `/api/v1/images/generations`
|
||||
- `/api/v1/video/generations`
|
||||
|
||||
内部 `OpenaiService` 变成薄门面:
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
生产环境统一配置:
|
||||
|
||||
```text
|
||||
baseURL = https://ai.51easyai.com/gateway-api/kling
|
||||
baseURL = https://ai.51easyai.com/api/v1/kling
|
||||
Authorization = Bearer <EasyAI Gateway API Key>
|
||||
```
|
||||
|
||||
本地环境使用 `baseURL = http://localhost:8088/kling`。
|
||||
本地环境使用 `baseURL = http://localhost:8088/api/v1/kling`。旧 `/gateway-api/kling` 和 `/kling` 路径仅作为兼容别名保留。
|
||||
|
||||
## V1(AK/SK 旧版协议兼容)
|
||||
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
|
||||
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` | 3–10 秒 |
|
||||
| `kling-v3-omni`、`kling-3.0-omni` | `kling-3.0-omni` | `kling-v3-omni` | 3–15 秒 |
|
||||
| `kling-video-o1`、`kling-o1` | `kling-video-o1` | `kling-video-o1` | 3–10 秒 |
|
||||
| `kling-v3-omni`、`kling-3.0-omni`、`kling-3-omni` | `kling-v3-omni` | `kling-v3-omni` | 3–15 秒 |
|
||||
|
||||
网关别名用于候选匹配,原生模型名用于发往 TranStreams 的 Kling Omni 请求;两类名称不会混用。
|
||||
上述旧别名会在入口处自动归一为可灵原生模型名,候选匹配不依赖平台模型额外配置别名。
|
||||
|
||||
`kling-video-o1` 的纯文生视频和首帧生视频只接受 5 或 10 秒;3–10 秒中的其他整数需要使用普通参考图等支持该时长的 Omni 输入。`kling-v3-omni` 接受 3–15 秒。
|
||||
|
||||
@@ -21,10 +27,10 @@ EasyAI AI Gateway 提供 Kling 旧版 Omni 协议兼容接口。调用方继续
|
||||
|
||||
## 创建任务
|
||||
|
||||
`POST /v1/videos/omni-video` 固定异步受理,不需要 `X-Async`,成功返回 HTTP 200。
|
||||
`POST /api/v1/videos/omni-video` 固定异步受理,不需要 `X-Async`,成功返回 HTTP 200。
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$GATEWAY_BASE_URL/v1/videos/omni-video" \
|
||||
curl -sS -X POST "$GATEWAY_ORIGIN/api/v1/videos/omni-video" \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -61,7 +67,7 @@ curl -sS -X POST "$GATEWAY_BASE_URL/v1/videos/omni-video" \
|
||||
```bash
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
"$GATEWAY_BASE_URL/v1/videos/omni-video/$TASK_ID"
|
||||
"$GATEWAY_ORIGIN/api/v1/videos/omni-video/$TASK_ID"
|
||||
```
|
||||
|
||||
`task_status` 为 `submitted`、`processing`、`succeed` 或 `failed`。成功时结果位于 `data.task_result.videos`:
|
||||
@@ -93,7 +99,7 @@ curl -sS \
|
||||
标准接口仍为 `POST /api/v1/videos/generations`。异步调用需要 `X-Async: true`,再通过 `GET /api/v1/tasks/{taskId}` 轮询。
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$GATEWAY_BASE_URL/api/v1/videos/generations" \
|
||||
curl -sS -X POST "$GATEWAY_PUBLIC_API_BASE/videos/generations" \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Async: true" \
|
||||
@@ -103,7 +109,7 @@ curl -sS -X POST "$GATEWAY_BASE_URL/api/v1/videos/generations" \
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true,
|
||||
"audio": false,
|
||||
"watermark": false,
|
||||
"runMode": "real"
|
||||
}'
|
||||
@@ -112,7 +118,7 @@ curl -sS -X POST "$GATEWAY_BASE_URL/api/v1/videos/generations" \
|
||||
```bash
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
"$GATEWAY_BASE_URL/api/v1/tasks/$TASK_ID"
|
||||
"$GATEWAY_PUBLIC_API_BASE/tasks/$TASK_ID"
|
||||
```
|
||||
|
||||
## 错误格式
|
||||
@@ -129,4 +135,4 @@ curl -sS \
|
||||
|
||||
业务码分类:`1001/1002` 为鉴权错误,`1101/1103` 为余额或权限错误,`1201/1203` 为参数或资源错误,`1302/1303` 为限流错误,`5000/5001` 为网关或上游服务错误。HTTP 状态码仍反映错误类型。
|
||||
|
||||
OpenAPI 文档由服务的 `/openapi.json` 和 `/openapi.yaml` 提供。
|
||||
OpenAPI 文档由服务的 `/api/v1/openapi.json` 和 `/api/v1/openapi.yaml` 提供。
|
||||
|
||||
@@ -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。
|
||||
@@ -126,9 +126,11 @@ dispatcher 以完整 Git SHA 发布 Registry Tag,并把 Registry 返回的 dig
|
||||
|
||||
## 发布后验证
|
||||
|
||||
宿主 Nginx 的 `ai.51easyai.com` TLS server 必须包含仓库中的 `deploy/nginx/ai.51easyai.com-api-v1.inc` 等价规则,保留完整 URI 转发到 `127.0.0.1:8088`。修改前先备份现有配置,执行 `nginx -t` 成功后才能 reload。
|
||||
|
||||
```bash
|
||||
curl -fsS https://ai.51easyai.com/gateway-api/healthz
|
||||
curl -fsS https://ai.51easyai.com/gateway-api/readyz
|
||||
curl -fsS https://ai.51easyai.com/api/v1/healthz
|
||||
curl -fsS https://ai.51easyai.com/api/v1/readyz
|
||||
ssh root@110.42.51.33 'cd /root/easyai-ai-gateway-deploy && ./gateway-ops.sh ps'
|
||||
```
|
||||
|
||||
|
||||
@@ -33,10 +33,12 @@
|
||||
|
||||
## 火山任务兼容路由
|
||||
|
||||
- `POST /api/v3/contents/generations/tasks`
|
||||
- `GET /api/v3/contents/generations/tasks`
|
||||
- `GET /api/v3/contents/generations/tasks/{taskID}`
|
||||
- `DELETE /api/v3/contents/generations/tasks/{taskID}`
|
||||
- `POST /api/v1/contents/generations/tasks`
|
||||
- `GET /api/v1/contents/generations/tasks`
|
||||
- `GET /api/v1/contents/generations/tasks/{taskID}`
|
||||
- `DELETE /api/v1/contents/generations/tasks/{taskID}`
|
||||
|
||||
旧 `/api/v3/contents/generations/tasks` 路径仅作为火山客户端兼容别名保留。
|
||||
|
||||
列表接口兼容火山的 `page_num`、`page_size`、`filter.status`、`filter.task_ids`(可重复)和 `filter.model`,并返回官方 `items`、`total` 字段;`data`、`page` 是保留的网关附加字段。
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Content-Type: application/json
|
||||
### 1.2 文件上传
|
||||
|
||||
```http
|
||||
POST /v1/files/upload
|
||||
POST /api/v1/files/upload
|
||||
Authorization: Bearer ${USER_JWT_OR_SK}
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
| ID | 任务 | 接口 / 方式 | 成功判定 | 状态 | 结果记录 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| SETUP-01 | 确认服务可用 | `GET /healthz`、`GET /readyz` | `healthz.ok=true`,`readyz.ok=true` | 未执行 | 待填写 |
|
||||
| SETUP-01 | 确认服务可用 | `GET /api/v1/healthz`、`GET /api/v1/readyz` | `healthz.ok=true`,`readyz.ok=true` | 未执行 | 待填写 |
|
||||
| SETUP-02 | 准备管理员权限 | 本地注册 / 登录,必要时将测试用户提升为 `admin` 或 `manager` | `GET /api/v1/me` 返回 `role` 具备 `manager` 权限 | 未执行 | 待填写 |
|
||||
| SETUP-03 | 记录用户提供的真实平台、模型和 KEY | `GET /api/v1/platforms`、`GET /api/v1/models` | Chat 模型、`doubao-4.5图像编辑`、`豆包Seedance-1.5-pro` 均已启用,并能被管理员看到 | 未执行 | 待填写 |
|
||||
| SETUP-04 | 创建内部测试用户组 | `POST /api/v1/user-groups` | 创建 `loopback-allow-group`、`loopback-deny-group`、`loopback-limit-group` | 未执行 | 待填写 |
|
||||
@@ -91,7 +91,7 @@
|
||||
| ID | 能力 | 请求 | 成功判定 | 状态 | 结果记录 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| TASK-CHAT-01 | Chat 成功 | `POST /api/v1/chat/completions`,真实 Chat 模型 | `task.status=succeeded`,`result.object=chat.completion`,`choices[0].message.content` 非空 | 未执行 | taskId、requestId、content 摘要、charge 待填写 |
|
||||
| TASK-CHAT-02 | Chat 兼容路由成功 | `POST /v1/chat/completions`,真实 Chat 模型 | HTTP 200,返回 `object=chat.completion`,内容非空 | 未执行 | requestId、content 摘要待填写 |
|
||||
| TASK-CHAT-02 | Chat 同步兼容响应成功 | `POST /api/v1/chat/completions`,真实 Chat 模型 | HTTP 200,返回 `object=chat.completion`,内容非空 | 未执行 | requestId、content 摘要待填写 |
|
||||
| TASK-IMAGE-01 | 文生图成功 | `POST /api/v1/images/generations`,模型 `doubao-4.5图像编辑` 或用户补充的文生图模型 | `task.status=succeeded`,`result.id` 非空,`data[0].url` 或文件 URL 可访问 | 未执行 | taskId、image URL、charge 待填写 |
|
||||
| TASK-IMAGE-02 | 图生图成功 | `POST /api/v1/images/edits`,模型 `doubao-4.5图像编辑`,传入测试源图和 mask | `task.status=succeeded`,`result.id` 非空,`data[0].url` 或文件 URL 可访问 | 未执行 | taskId、source URL、mask URL、image URL、charge 待填写 |
|
||||
| TASK-VIDEO-01 | 文生视频成功 | `POST /api/v1/videos/generations`,模型 `豆包Seedance-1.5-pro`,仅传 prompt | `task.status=succeeded`,返回可下载或可播放的视频结果,任务事件完整 | 未执行 | taskId、video URL、duration、charge 待填写 |
|
||||
|
||||
Generated
+5
-5
@@ -10,7 +10,7 @@ overrides:
|
||||
brace-expansion@>=2.0.0 <2.1.2: 2.1.2
|
||||
dompurify@<=3.4.10: 3.4.11
|
||||
esbuild@>=0.27.3 <0.28.1: 0.28.1
|
||||
fast-uri@>=3.0.0 <3.1.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
|
||||
js-yaml@<4.3.0: 4.3.0
|
||||
mermaid@>=11.0.0-alpha.1 <=11.14.0: 11.15.0
|
||||
@@ -3035,8 +3035,8 @@ packages:
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-uri@3.1.3:
|
||||
resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==}
|
||||
fast-uri@3.1.4:
|
||||
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
@@ -7182,7 +7182,7 @@ snapshots:
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.3
|
||||
fast-uri: 3.1.4
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
@@ -7851,7 +7851,7 @@ snapshots:
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-uri@3.1.3: {}
|
||||
fast-uri@3.1.4: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ overrides:
|
||||
'brace-expansion@>=2.0.0 <2.1.2': 2.1.2
|
||||
'dompurify@<=3.4.10': 3.4.11
|
||||
'esbuild@>=0.27.3 <0.28.1': 0.28.1
|
||||
'fast-uri@>=3.0.0 <3.1.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
|
||||
'js-yaml@<4.3.0': 4.3.0
|
||||
'mermaid@>=11.0.0-alpha.1 <=11.14.0': 11.15.0
|
||||
|
||||
@@ -248,14 +248,14 @@ deploy() {
|
||||
api_port="$(published_port api 8088 "${AI_GATEWAY_API_PORT:-8088}")"
|
||||
web_port="$(published_port web 80 "${AI_GATEWAY_WEB_PORT:-5178}")"
|
||||
|
||||
wait_for_http "api health" "http://127.0.0.1:${api_port}/healthz" "easyai-ai-gateway"
|
||||
wait_for_http "api readiness" "http://127.0.0.1:${api_port}/readyz" '"ok":true'
|
||||
wait_for_http "web reverse proxy" "http://127.0.0.1:${web_port}/gateway-api/healthz" "easyai-ai-gateway"
|
||||
wait_for_http "api health" "http://127.0.0.1:${api_port}/api/v1/healthz" "easyai-ai-gateway"
|
||||
wait_for_http "api readiness" "http://127.0.0.1:${api_port}/api/v1/readyz" '"ok":true'
|
||||
wait_for_http "web reverse proxy" "http://127.0.0.1:${web_port}/api/v1/healthz" "easyai-ai-gateway"
|
||||
wait_for_http "web app" "http://127.0.0.1:${web_port}/" "EasyAI AI Gateway"
|
||||
|
||||
echo "[ai-gateway] deployment succeeded"
|
||||
echo "[ai-gateway] Web: http://127.0.0.1:${web_port}"
|
||||
echo "[ai-gateway] API: http://127.0.0.1:${api_port}/healthz"
|
||||
echo "[ai-gateway] API: http://127.0.0.1:${api_port}/api/v1/healthz"
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
|
||||
Reference in New Issue
Block a user