Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9872068596 | ||
|
|
a23b28c27d | ||
|
|
eb37b568ae | ||
|
|
a8d1c550ef | ||
|
|
5432760cf7 | ||
|
|
7c5a999e32 | ||
|
|
f7a5f2e808 | ||
|
|
152f9d1206 | ||
|
|
d95cecd0eb | ||
|
|
5954afef55 | ||
|
|
929a7a172c | ||
|
|
16149260c7 | ||
|
|
c72c43aaaa | ||
|
|
84aea01b5b | ||
|
|
293ef24bb7 | ||
|
|
0f1765b226 | ||
|
|
1fa58ba901 | ||
|
|
55595570c2 | ||
|
|
fba9759bc7 | ||
|
|
3d3460ce63 | ||
|
|
000ee1bbfd | ||
|
|
d0cfd0a385 | ||
|
|
0818f55235 | ||
|
|
fe83da56d2 | ||
|
|
002422b753 | ||
|
|
24b778b3ba | ||
|
|
d7951cfdd2 | ||
|
|
ddd68cfebd | ||
|
|
5a71643099 | ||
|
|
b04a7d9d3d | ||
|
|
6b675c406e | ||
|
|
56d4a3a6b7 | ||
|
|
276c0612d8 | ||
|
|
d818e7947a | ||
|
|
d5c2c58c67 |
@@ -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
|
||||
|
||||
+1335
-2899
File diff suppressed because it is too large
Load Diff
+871
-1907
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -36,7 +36,7 @@ require (
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.uber.org/goleak v1.3.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+4
-4
@@ -73,10 +73,10 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -1306,6 +1306,25 @@ func TestGeminiClientImageGenerateBuildsNativeImageBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiGenerationConfigInitializesMissingImageConfig(t *testing.T) {
|
||||
config := geminiGenerationConfig(map[string]any{
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "4K",
|
||||
}, true)
|
||||
|
||||
imageConfig, ok := config["imageConfig"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("imageConfig should be initialized, got %+v", config)
|
||||
}
|
||||
if imageConfig["aspectRatio"] != "16:9" || imageConfig["imageSize"] != "4K" {
|
||||
t.Fatalf("unexpected imageConfig: %+v", imageConfig)
|
||||
}
|
||||
modalities, ok := config["responseModalities"].([]any)
|
||||
if !ok || len(modalities) != 1 || modalities[0] != "IMAGE" {
|
||||
t.Fatalf("image response modality should be initialized, got %+v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiClientImageEditPreservesNativeContentsAndFileData(t *testing.T) {
|
||||
var captured map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1539,10 +1558,39 @@ func TestGeminiClientChatConvertsFunctionCallResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGeminiURLAcceptsVersionedBaseURL(t *testing.T) {
|
||||
got := geminiURL("https://generativelanguage.googleapis.com/v1beta", "gemini-2.5-flash", "test-key")
|
||||
want := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected gemini url: %s", got)
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "default version",
|
||||
baseURL: "https://generativelanguage.googleapis.com",
|
||||
want: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key",
|
||||
},
|
||||
{
|
||||
name: "google beta version",
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
want: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key",
|
||||
},
|
||||
{
|
||||
name: "compatible v1 endpoint",
|
||||
baseURL: "https://cloud.dataeyes.ai/v1",
|
||||
want: "https://cloud.dataeyes.ai/v1/models/gemini-2.5-flash:generateContent?key=test-key",
|
||||
},
|
||||
{
|
||||
name: "openai suffix after version",
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
want: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := geminiURL(test.baseURL, "gemini-2.5-flash", "test-key")
|
||||
if got != test.want {
|
||||
t.Fatalf("unexpected gemini url: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1934,6 +1982,77 @@ func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientVideoRetriesTransientPollAndKeepsOfficialResult(t *testing.T) {
|
||||
polls := 0
|
||||
persisted := make([]string, 0)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /contents/generations/tasks":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-retry"})
|
||||
case "GET /contents/generations/tasks/cgt-retry":
|
||||
polls++
|
||||
if polls == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"try later"}}`))
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "cgt-retry", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
|
||||
"created_at": 123, "updated_at": 124, "content": map[string]any{"video_url": "https://example.com/retry.mp4"},
|
||||
"usage": map[string]any{"total_tokens": 8}, "seed": 7,
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations", Model: "seedance", Body: map[string]any{"model": "seedance", "prompt": "retry"},
|
||||
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "doubao-seedance-2-0-mini-260615", Credentials: map[string]any{"apiKey": "key"}, PlatformConfig: map[string]any{"volcesPollIntervalMs": 100, "volcesPollRetryMaxMs": 100, "volcesPollTimeoutSeconds": 2}},
|
||||
OnRemoteTaskPolled: func(remoteTaskID string, payload map[string]any) error {
|
||||
persisted = append(persisted, remoteTaskID+":"+stringFromAny(payload["status"]))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run retrying Volces video: %v", err)
|
||||
}
|
||||
if polls != 2 || len(persisted) != 1 || persisted[0] != "cgt-retry:succeeded" {
|
||||
t.Fatalf("unexpected poll state polls=%d persisted=%+v", polls, persisted)
|
||||
}
|
||||
if response.Result["updated_at"] != float64(124) || response.Result["seed"] != float64(7) || response.Result["raw"] == nil {
|
||||
t.Fatalf("official result fields lost: %+v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientDeletesOfficialVideoTask(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete || r.URL.Path != "/contents/generations/tasks/cgt-delete" {
|
||||
t.Fatalf("unexpected delete request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer delete-key" {
|
||||
t.Fatalf("unexpected delete authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-delete", "status": "cancelled"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, _, err := (VolcesClient{HTTPClient: server.Client()}).DeleteVideoTask(context.Background(), Request{
|
||||
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, Credentials: map[string]any{"apiKey": "delete-key"}},
|
||||
RemoteTaskID: "cgt-delete",
|
||||
})
|
||||
if err != nil || result["status"] != "cancelled" {
|
||||
t.Fatalf("unexpected delete response result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesCancelledTaskUsesDedicatedCancellationCode(t *testing.T) {
|
||||
if got := volcesTaskErrorCode(map[string]any{"status": "cancelled"}); got != "volces_task_cancelled" {
|
||||
t.Fatalf("cancelled task error code = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesClientVideoRejectsDuplicateFirstFrameBeforeSubmit(t *testing.T) {
|
||||
var submitted bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -61,11 +61,13 @@ func geminiURL(baseURL string, model string, apiKey string) string {
|
||||
base = "https://generativelanguage.googleapis.com"
|
||||
}
|
||||
base = strings.TrimSuffix(base, "/openai")
|
||||
if strings.HasSuffix(base, "/v1beta") {
|
||||
base = strings.TrimSuffix(base, "/v1beta")
|
||||
if !strings.HasSuffix(base, "/v1") &&
|
||||
!strings.HasSuffix(base, "/v1beta") &&
|
||||
!strings.HasSuffix(base, "/v1alpha") {
|
||||
base += "/v1beta"
|
||||
}
|
||||
escapedModel := url.PathEscape(model)
|
||||
return fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", base, escapedModel, url.QueryEscape(apiKey))
|
||||
return fmt.Sprintf("%s/models/%s:generateContent?key=%s", base, escapedModel, url.QueryEscape(apiKey))
|
||||
}
|
||||
|
||||
func geminiBody(request Request) map[string]any {
|
||||
@@ -195,14 +197,23 @@ func geminiApplyRequestOptions(body map[string]any, request Request, imageRespon
|
||||
func geminiGenerationConfig(body map[string]any, imageResponse bool) map[string]any {
|
||||
source := mapFromAny(firstPresent(body["generationConfig"], body["generation_config"]))
|
||||
out := cloneMapAny(source)
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
if aspectRatio := firstNonEmptyString(body["aspect_ratio"], body["aspectRatio"]); aspectRatio != "" {
|
||||
imageConfig := cloneMapAny(mapFromAny(firstPresent(out["imageConfig"], out["image_config"])))
|
||||
if imageConfig == nil {
|
||||
imageConfig = map[string]any{}
|
||||
}
|
||||
imageConfig["aspectRatio"] = aspectRatio
|
||||
out["imageConfig"] = imageConfig
|
||||
delete(out, "image_config")
|
||||
}
|
||||
if imageSize := firstNonEmptyString(body["resolution"], body["imageSize"], body["image_size"], body["size"]); imageSize != "" {
|
||||
imageConfig := cloneMapAny(mapFromAny(firstPresent(out["imageConfig"], out["image_config"])))
|
||||
if imageConfig == nil {
|
||||
imageConfig = map[string]any{}
|
||||
}
|
||||
imageConfig["imageSize"] = imageSize
|
||||
out["imageConfig"] = imageConfig
|
||||
delete(out, "image_config")
|
||||
|
||||
@@ -338,8 +338,11 @@ func kelingVideoPayload(ctx context.Context, request Request) (map[string]any, s
|
||||
if value, ok := body["cfg_scale"]; ok && numericValue(value, 0) > 0 {
|
||||
payload["cfg_scale"] = value
|
||||
}
|
||||
if boolValue(body, "audio") || boolValue(body, "output_audio") {
|
||||
payload["sound"] = "on"
|
||||
if sound, ok := kelingSoundSetting(body); ok {
|
||||
if sound == "on" && !kelingSupportsGeneratedSound(request.Candidate) {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support generated audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
payload["sound"] = sound
|
||||
}
|
||||
if mode := kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")); mode != "" {
|
||||
payload["mode"] = mode
|
||||
@@ -432,7 +435,7 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
watermarkEnabled = boolValue(watermarkInfo, "enabled")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model_name": upstreamModelName(request.Candidate),
|
||||
"model_name": kelingOmniUpstreamModelName(request.Candidate),
|
||||
"mode": kelingModeByResolution(firstNonEmptyStringValue(body, "resolution", "size")),
|
||||
"watermark_info": map[string]any{"enabled": watermarkEnabled},
|
||||
"negative_prompt": strings.TrimSpace(stringFromAny(body["negative_prompt"])),
|
||||
@@ -458,8 +461,13 @@ func (c KelingClient) kelingOmniPayload(ctx context.Context, request Request, to
|
||||
if voices := mapListFromAny(body["voice_list"]); len(voices) > 0 {
|
||||
payload["voice_list"] = voices
|
||||
}
|
||||
if (boolValue(body, "audio") || boolValue(body, "output_audio")) && !hasVideo {
|
||||
payload["sound"] = "on"
|
||||
if sound, ok := kelingSoundSetting(body); ok {
|
||||
if sound == "on" && !kelingSupportsGeneratedSound(request.Candidate) {
|
||||
return nil, nil, &ClientError{Code: "invalid_parameter", Message: "kling-video-o1 does not support generated audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !hasVideo {
|
||||
payload["sound"] = sound
|
||||
}
|
||||
}
|
||||
if multiShot {
|
||||
payload["multi_shot"] = true
|
||||
@@ -728,6 +736,18 @@ func kelingIsOmniRequest(request Request) bool {
|
||||
request.Candidate.Capabilities["omni"] != nil
|
||||
}
|
||||
|
||||
func kelingOmniUpstreamModelName(candidate store.RuntimeModelCandidate) string {
|
||||
model := strings.TrimSpace(upstreamModelName(candidate))
|
||||
switch strings.ToLower(model) {
|
||||
case "kling-o1":
|
||||
return "kling-video-o1"
|
||||
case "kling-3.0-omni":
|
||||
return "kling-v3-omni"
|
||||
default:
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
func kelingIs30TurboRequest(request Request) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(upstreamModelName(request.Candidate))) {
|
||||
case "kling-3.0-turbo", "kling-v3-turbo", "kling-3-0-turbo":
|
||||
@@ -1073,6 +1093,54 @@ func kelingModeByResolution(resolution string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func kelingSoundSetting(body map[string]any) (string, bool) {
|
||||
if sound := strings.ToLower(strings.TrimSpace(stringFromAny(body["sound"]))); sound == "on" || sound == "off" {
|
||||
return sound, true
|
||||
}
|
||||
for _, key := range []string{"audio", "output_audio", "generate_audio"} {
|
||||
if enabled, ok := kelingBoolFieldValue(body, key); ok {
|
||||
if enabled {
|
||||
return "on", true
|
||||
}
|
||||
return "off", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func kelingSupportsGeneratedSound(candidate store.RuntimeModelCandidate) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(upstreamModelName(candidate))) {
|
||||
case "kling-o1", "kling-video-o1":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func kelingWatermarkEnabled(body map[string]any) bool {
|
||||
if enabled, ok := kelingBoolFieldValue(body, "watermark"); ok {
|
||||
return enabled
|
||||
}
|
||||
info := mapFromAny(body["watermark_info"])
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
enabled, _ := kelingBoolFieldValue(info, "enabled")
|
||||
return enabled
|
||||
}
|
||||
|
||||
func kelingBoolFieldValue(body map[string]any, key string) (bool, bool) {
|
||||
if body == nil {
|
||||
return false, false
|
||||
}
|
||||
value, ok := body[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
typed, ok := value.(bool)
|
||||
return typed, ok
|
||||
}
|
||||
|
||||
func kelingCameraControl(body map[string]any) map[string]any {
|
||||
cameraControl := strings.TrimSpace(stringFromAny(body["camera_control"]))
|
||||
if cameraControl == "" {
|
||||
@@ -1182,7 +1250,7 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if id := strings.TrimSpace(stringFromAny(video["id"])); id != "" {
|
||||
item["id"] = id
|
||||
}
|
||||
if duration := intFromAny(video["duration"]); duration > 0 {
|
||||
if duration := firstPresent(video["duration"]); duration != nil && strings.TrimSpace(stringFromAny(duration)) != "" {
|
||||
item["duration"] = duration
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromAny(video["watermark_url"])); watermarkURL != "" {
|
||||
@@ -1194,11 +1262,15 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if created == 0 {
|
||||
created = int(nowUnix())
|
||||
}
|
||||
modelName := upstreamModelName(request.Candidate)
|
||||
if kelingIsOmniRequest(request) {
|
||||
modelName = kelingOmniUpstreamModelName(request.Candidate)
|
||||
}
|
||||
return map[string]any{
|
||||
"id": upstreamTaskID,
|
||||
"object": "video.generation",
|
||||
"created": created,
|
||||
"model": upstreamModelName(request.Candidate),
|
||||
"model": modelName,
|
||||
"status": "succeeded",
|
||||
"upstream_task_id": upstreamTaskID,
|
||||
"data": items,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingOmniPayloadPreservesCompatibleSettings(t *testing.T) {
|
||||
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"prompt": "A product reveal",
|
||||
"duration": 3,
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "16:9",
|
||||
"audio": false,
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
"external_task_id": "external-1",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build compatible Omni payload: %v", err)
|
||||
}
|
||||
if len(cleanupIDs) != 0 ||
|
||||
payload["model_name"] != "kling-video-o1" ||
|
||||
payload["mode"] != "std" ||
|
||||
payload["sound"] != "off" ||
|
||||
payload["duration"] != "3" ||
|
||||
payload["aspect_ratio"] != "16:9" ||
|
||||
payload["external_task_id"] != "external-1" {
|
||||
t.Fatalf("unexpected compatible Omni payload: %+v", payload)
|
||||
}
|
||||
watermark, _ := payload["watermark_info"].(map[string]any)
|
||||
if watermark["enabled"] != true {
|
||||
t.Fatalf("watermark setting was not preserved: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniUpstreamModelNameSeparatesGatewayAliases(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"kling-o1": "kling-video-o1",
|
||||
"kling-video-o1": "kling-video-o1",
|
||||
"kling-3.0-omni": "kling-v3-omni",
|
||||
"kling-v3-omni": "kling-v3-omni",
|
||||
}
|
||||
for configured, want := range tests {
|
||||
got := kelingOmniUpstreamModelName(store.RuntimeModelCandidate{ProviderModelName: configured})
|
||||
if got != want {
|
||||
t.Fatalf("configured=%s got=%s want=%s", configured, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniRejectsGeneratedAudioForO1(t *testing.T) {
|
||||
_, _, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Body: map[string]any{
|
||||
"prompt": "A beach",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"audio": true,
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{ProviderModelName: "kling-video-o1"},
|
||||
}, "token")
|
||||
if err == nil || ErrorCode(err) != "invalid_parameter" {
|
||||
t.Fatalf("expected generated-audio rejection for O1, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniResumeReturnsUpstreamFailureCodeAndModel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/videos/omni-video/remote-failed" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-key" {
|
||||
t.Fatalf("unexpected Authorization header: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "failure-request",
|
||||
"data": map[string]any{
|
||||
"task_id": "remote-failed",
|
||||
"task_status": "failed",
|
||||
"task_status_msg": "content policy rejection",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
RemoteTaskID: "remote-failed",
|
||||
RemoteTaskPayload: map[string]any{"endpoint": "/videos/omni-video"},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
BaseURL: server.URL,
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Credentials: map[string]any{"apiKey": "upstream-key"},
|
||||
PlatformConfig: map[string]any{
|
||||
"kelingPollIntervalMs": 10,
|
||||
"kelingPollTimeoutSeconds": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil || ErrorCode(err) != "keling_task_failed" || !strings.Contains(err.Error(), "content policy rejection") {
|
||||
t.Fatalf("expected preserved Keling task failure, got code=%q err=%v", ErrorCode(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingOmniPayloadPreservesIntelligentMultiShot(t *testing.T) {
|
||||
payload, _, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
|
||||
Kind: "videos.generations",
|
||||
ModelType: "omni_video",
|
||||
Body: map[string]any{
|
||||
"prompt": "Create three coherent shots",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"multi_shot": true,
|
||||
"shot_type": "intelligence",
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
ProviderModelName: "kling-v3-omni",
|
||||
Capabilities: map[string]any{"omni_video": map[string]any{}},
|
||||
},
|
||||
}, "token")
|
||||
if err != nil {
|
||||
t.Fatalf("build intelligent multi-shot payload: %v", err)
|
||||
}
|
||||
if payload["multi_shot"] != true || payload["shot_type"] != "intelligence" || payload["prompt"] != "Create three coherent shots" {
|
||||
t.Fatalf("unexpected intelligent multi-shot payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingVideoSuccessResultPreservesOfficialVideoMetadata(t *testing.T) {
|
||||
result := kelingVideoSuccessResult(Request{Candidate: store.RuntimeModelCandidate{ProviderModelName: "kling-video-o1"}}, "remote-1", map[string]any{
|
||||
"data": map[string]any{
|
||||
"task_result": map[string]any{
|
||||
"videos": []any{map[string]any{
|
||||
"id": "video-1",
|
||||
"url": "https://example.com/video.mp4",
|
||||
"watermark_url": "https://example.com/watermarked.mp4",
|
||||
"duration": "3",
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
data, _ := result["data"].([]any)
|
||||
video, _ := data[0].(map[string]any)
|
||||
if video["id"] != "video-1" || video["watermark_url"] != "https://example.com/watermarked.mp4" || video["duration"] != "3" {
|
||||
t.Fatalf("official video metadata was lost: %+v", video)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Request struct {
|
||||
RemoteTaskID string
|
||||
RemoteTaskPayload map[string]any
|
||||
OnRemoteTaskSubmitted func(remoteTaskID string, payload map[string]any) error
|
||||
OnRemoteTaskPolled func(remoteTaskID string, payload map[string]any) error
|
||||
Stream bool
|
||||
StreamDelta StreamDelta
|
||||
UpstreamProtocol string
|
||||
|
||||
@@ -44,6 +44,8 @@ func (c UniversalClient) Run(ctx context.Context, request Request) (Response, er
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, submitRequestID, startedAt, time.Now())
|
||||
}
|
||||
submitResult = universalEffectiveResult(submitResult)
|
||||
submitRequestID = firstNonEmptyString(submitRequestID, requestIDFromResult(submitResult))
|
||||
if isUniversalSuccess(submitResult) && submitResult["data"] != nil {
|
||||
return Response{
|
||||
Result: normalizeUniversalResult(request, submitResult, ""),
|
||||
@@ -157,6 +159,7 @@ func (c UniversalClient) universalPollUntilDone(ctx context.Context, executor *s
|
||||
if err != nil {
|
||||
return nil, "", annotateResponseError(err, firstNonEmptyString(pollRequestID, requestID, upstreamTaskID), pollStarted, pollFinished)
|
||||
}
|
||||
result = universalEffectiveResult(result)
|
||||
lastResult = result
|
||||
requestID = firstNonEmptyString(pollRequestID, requestID, requestIDFromResult(result), upstreamTaskID)
|
||||
if isUniversalSuccess(result) {
|
||||
@@ -239,6 +242,14 @@ func universalScriptContext(request Request, modelType string, payload map[strin
|
||||
return selectedBase + "/" + strings.TrimLeft(path, "/")
|
||||
}
|
||||
context["creatRequestURL"] = context["createRequestURL"]
|
||||
processedParams := cloneBody(request.Body)
|
||||
processedParams["model"] = upstreamModelName(request.Candidate)
|
||||
context["processedParams"] = processedParams
|
||||
context["preProcessParams"] = func(params map[string]any, _ ...string) map[string]any {
|
||||
processed := cloneMapAny(params)
|
||||
processed["model"] = upstreamModelName(request.Candidate)
|
||||
return processed
|
||||
}
|
||||
context["resolveGetTaskURL"] = func(taskID string) string {
|
||||
return resolveUniversalTaskURL(request.Candidate.PlatformConfig, taskID)
|
||||
}
|
||||
@@ -389,6 +400,20 @@ func universalStatus(result map[string]any) string {
|
||||
return strings.ToLower(strings.TrimSpace(firstNonEmptyString(result["status"], result["state"], result["task_status"])))
|
||||
}
|
||||
|
||||
func universalEffectiveResult(result map[string]any) map[string]any {
|
||||
nested, ok := result["result"].(map[string]any)
|
||||
if !ok || nested == nil || isUniversalFailure(result) {
|
||||
return result
|
||||
}
|
||||
out := cloneMapAny(nested)
|
||||
for _, key := range []string{"status", "request_id", "requestId", "upstream_task_id", "task_id", "taskId", "id"} {
|
||||
if out[key] == nil && result[key] != nil {
|
||||
out[key] = result[key]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func universalTaskID(result map[string]any) string {
|
||||
return firstNonEmptyString(result["upstream_task_id"], result["task_id"], result["taskId"], result["id"])
|
||||
}
|
||||
|
||||
@@ -92,6 +92,47 @@ func TestUniversalClientDefaultSubmitAndPoll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniversalClientSupportsServerMainScriptContextAndNestedResult(t *testing.T) {
|
||||
request := Request{
|
||||
Kind: "images.generations",
|
||||
ModelType: "image_generate",
|
||||
Model: "custom-image",
|
||||
Body: map[string]any{"model": "custom-image", "prompt": "hello"},
|
||||
Candidate: testUniversalCandidate(map[string]any{
|
||||
"customGetParamsScript": map[string]any{
|
||||
"image_generate": `async function getParams(params, context) {
|
||||
const processed = await context.preProcessParams(params, context.type);
|
||||
return { prompt: processed.prompt + "-" + processed.model + "-" + context.processedParams.model };
|
||||
}`,
|
||||
},
|
||||
"customSubmitScript": map[string]any{
|
||||
"image_generate": `async function submitTask(payload) {
|
||||
return {
|
||||
status: "success",
|
||||
result: {
|
||||
status: "success",
|
||||
data: [{ url: "https://cdn.example/" + payload.prompt + ".png" }]
|
||||
}
|
||||
};
|
||||
}`,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
response, err := (UniversalClient{}).Run(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("run failed: %v", err)
|
||||
}
|
||||
data, ok := response.Result["data"].([]any)
|
||||
if !ok || len(data) != 1 {
|
||||
t.Fatalf("unexpected nested result: %#v", response.Result)
|
||||
}
|
||||
image, ok := data[0].(map[string]any)
|
||||
if !ok || image["url"] != "https://cdn.example/hello-provider-model-provider-model.png" {
|
||||
t.Fatalf("unexpected image result: %#v", response.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniversalClientResumeSkipsSubmit(t *testing.T) {
|
||||
request := Request{
|
||||
Kind: "videos.generations",
|
||||
|
||||
@@ -100,66 +100,105 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
|
||||
timeout := volcesPollTimeout(request)
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
nextPoll := time.NewTimer(0)
|
||||
defer nextPoll.Stop()
|
||||
|
||||
var lastResult map[string]any
|
||||
lastRequestID := firstNonEmpty(submitRequestID, upstreamTaskID)
|
||||
transientFailures := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: submitRequestID, Retryable: true}
|
||||
default:
|
||||
}
|
||||
|
||||
pollStartedAt := time.Now()
|
||||
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
|
||||
pollFinishedAt := time.Now()
|
||||
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
|
||||
}
|
||||
lastResult = pollResult
|
||||
|
||||
switch volcesTaskStatus(pollResult) {
|
||||
case "succeeded":
|
||||
result := volcesVideoSuccessResult(request, upstreamTaskID, pollResult)
|
||||
return Response{
|
||||
Result: result,
|
||||
RequestID: requestID,
|
||||
Usage: volcesVideoUsage(pollResult),
|
||||
Progress: volcesVideoProgress(request, upstreamTaskID),
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
}, nil
|
||||
case "failed", "cancelled":
|
||||
return Response{}, &ClientError{
|
||||
Code: volcesTaskErrorCode(pollResult),
|
||||
Message: volcesTaskErrorMessage(pollResult),
|
||||
RequestID: requestID,
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true}
|
||||
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: lastRequestID, Retryable: true}
|
||||
case <-deadline.C:
|
||||
return Response{}, &ClientError{
|
||||
Code: "timeout",
|
||||
Message: fmt.Sprintf("volces video task %s did not finish before timeout; last status: %s", upstreamTaskID, volcesTaskStatus(lastResult)),
|
||||
RequestID: requestID,
|
||||
RequestID: lastRequestID,
|
||||
Retryable: true,
|
||||
}
|
||||
case <-ticker.C:
|
||||
case <-nextPoll.C:
|
||||
pollStartedAt := time.Now()
|
||||
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
|
||||
pollFinishedAt := time.Now()
|
||||
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||
lastRequestID = requestID
|
||||
if err != nil {
|
||||
err = annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
|
||||
if !IsRetryable(err) {
|
||||
return Response{}, err
|
||||
}
|
||||
transientFailures++
|
||||
resetVolcesPollTimer(nextPoll, volcesRetryPollInterval(request, interval, transientFailures))
|
||||
continue
|
||||
}
|
||||
transientFailures = 0
|
||||
lastResult = pollResult
|
||||
if request.OnRemoteTaskPolled != nil {
|
||||
if err := request.OnRemoteTaskPolled(upstreamTaskID, pollResult); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
|
||||
switch volcesTaskStatus(pollResult) {
|
||||
case "succeeded":
|
||||
result := volcesVideoSuccessResult(request, upstreamTaskID, pollResult)
|
||||
return Response{
|
||||
Result: result,
|
||||
RequestID: requestID,
|
||||
Usage: volcesVideoUsage(pollResult),
|
||||
Progress: volcesVideoProgress(request, upstreamTaskID),
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
}, nil
|
||||
case "failed", "cancelled":
|
||||
return Response{}, &ClientError{
|
||||
Code: volcesTaskErrorCode(pollResult),
|
||||
Message: volcesTaskErrorMessage(pollResult),
|
||||
RequestID: requestID,
|
||||
ResponseStartedAt: submitStartedAt,
|
||||
ResponseFinishedAt: pollFinishedAt,
|
||||
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
resetVolcesPollTimer(nextPoll, interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteVideoTask calls the official contents-generations cancellation endpoint.
|
||||
// It is intentionally separate from Run so task cancellation can use the same
|
||||
// provider credentials that submitted the remote task.
|
||||
func (c VolcesClient) DeleteVideoTask(ctx context.Context, request Request) (map[string]any, string, error) {
|
||||
apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
|
||||
if apiKey == "" {
|
||||
return nil, "", &ClientError{Code: "missing_credentials", Message: "volces api key is required", Retryable: false}
|
||||
}
|
||||
remoteTaskID := strings.TrimSpace(request.RemoteTaskID)
|
||||
if remoteTaskID == "" {
|
||||
return nil, "", &ClientError{Code: "invalid_parameter", Message: "volces remote task id is required", Retryable: false}
|
||||
}
|
||||
taskPath := volcesVideoTaskPath(request) + "/" + remoteTaskID
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, joinURL(request.Candidate.BaseURL, taskPath), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
response, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
requestID := requestIDFromHTTPResponse(response)
|
||||
result, err := decodeHTTPResponse(response)
|
||||
if err != nil {
|
||||
return result, requestID, annotateResponseError(err, requestID, time.Now(), time.Now())
|
||||
}
|
||||
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
|
||||
return result, firstNonEmpty(requestID, envelopeRequestID), err
|
||||
}
|
||||
|
||||
func volcesVideoTaskPath(request Request) string {
|
||||
path := firstNonEmptyStringValue(
|
||||
request.Candidate.PlatformConfig,
|
||||
@@ -997,6 +1036,9 @@ func volcesTaskErrorCode(result map[string]any) string {
|
||||
return code
|
||||
}
|
||||
status := volcesTaskStatus(result)
|
||||
if status == "cancelled" {
|
||||
return "volces_task_cancelled"
|
||||
}
|
||||
if status != "" {
|
||||
return status
|
||||
}
|
||||
@@ -1015,6 +1057,10 @@ func volcesTaskErrorMessage(result map[string]any) string {
|
||||
}
|
||||
|
||||
func volcesVideoSuccessResult(request Request, upstreamTaskID string, raw map[string]any) map[string]any {
|
||||
result := cloneMapAny(raw)
|
||||
if result == nil {
|
||||
result = map[string]any{}
|
||||
}
|
||||
content, _ := raw["content"].(map[string]any)
|
||||
videoURL := strings.TrimSpace(stringFromAny(content["video_url"]))
|
||||
created := intFromAny(raw["created_at"])
|
||||
@@ -1025,16 +1071,17 @@ func volcesVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
||||
if videoURL != "" {
|
||||
data = append(data, map[string]any{"url": videoURL, "type": "video"})
|
||||
}
|
||||
return map[string]any{
|
||||
"id": upstreamTaskID,
|
||||
"object": "video.generation",
|
||||
"created": created,
|
||||
"model": upstreamModelName(request.Candidate),
|
||||
"status": "succeeded",
|
||||
"upstream_task_id": upstreamTaskID,
|
||||
"data": data,
|
||||
"raw": raw,
|
||||
result["id"] = firstNonEmpty(stringFromAny(raw["id"]), upstreamTaskID)
|
||||
if strings.TrimSpace(stringFromAny(result["model"])) == "" {
|
||||
result["model"] = upstreamModelName(request.Candidate)
|
||||
}
|
||||
result["status"] = "succeeded"
|
||||
result["object"] = "video.generation"
|
||||
result["created"] = created
|
||||
result["upstream_task_id"] = upstreamTaskID
|
||||
result["data"] = data
|
||||
result["raw"] = cloneMapAny(raw)
|
||||
return result
|
||||
}
|
||||
|
||||
func volcesVideoUsage(raw map[string]any) Usage {
|
||||
@@ -1074,6 +1121,37 @@ func volcesPollTimeout(request Request) time.Duration {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func volcesRetryPollInterval(request Request, normal time.Duration, failures int) time.Duration {
|
||||
if failures < 1 {
|
||||
return normal
|
||||
}
|
||||
max := time.Duration(numericValue(firstPresent(request.Candidate.PlatformConfig["volcesPollRetryMaxMs"], request.Body["pollRetryMaxMs"], request.Body["poll_retry_max_ms"]), 30000)) * time.Millisecond
|
||||
if max < normal {
|
||||
max = normal
|
||||
}
|
||||
delay := normal
|
||||
for attempt := 1; attempt < failures && delay < max; attempt++ {
|
||||
delay *= 2
|
||||
}
|
||||
if delay > max {
|
||||
return max
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func resetVolcesPollTimer(timer *time.Timer, delay time.Duration) {
|
||||
if delay < 100*time.Millisecond {
|
||||
delay = 100 * time.Millisecond
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(delay)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
volcesAssetDefaultEndpoint = "https://ark.cn-beijing.volcengineapi.com"
|
||||
volcesAssetRegion = "cn-beijing"
|
||||
volcesAssetService = "ark"
|
||||
volcesAssetVersion = "2024-01-01"
|
||||
)
|
||||
|
||||
type VolcesAssetClient struct {
|
||||
HTTPClient *http.Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type VolcesAssetCredentials struct {
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type VolcesAssetResult struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name,omitempty"`
|
||||
URL string `json:"URL,omitempty"`
|
||||
AssetType string `json:"AssetType,omitempty"`
|
||||
GroupID string `json:"GroupId,omitempty"`
|
||||
Status string `json:"Status,omitempty"`
|
||||
Error map[string]any `json:"Error,omitempty"`
|
||||
ProjectName string `json:"ProjectName,omitempty"`
|
||||
CreateTime string `json:"CreateTime,omitempty"`
|
||||
UpdateTime string `json:"UpdateTime,omitempty"`
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) CreateAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) {
|
||||
var result struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
requestID, err := c.call(ctx, credentials, "CreateAsset", body, &result)
|
||||
return VolcesAssetResult{ID: result.ID}, requestID, err
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) GetAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) {
|
||||
var result VolcesAssetResult
|
||||
requestID, err := c.call(ctx, credentials, "GetAsset", body, &result)
|
||||
return result, requestID, err
|
||||
}
|
||||
|
||||
func (c VolcesAssetClient) call(ctx context.Context, credentials VolcesAssetCredentials, action string, body map[string]any, target any) (string, error) {
|
||||
accessKey := strings.TrimSpace(credentials.AccessKey)
|
||||
secretKey := strings.TrimSpace(credentials.SecretKey)
|
||||
if accessKey == "" || secretKey == "" {
|
||||
return "", &ClientError{Code: "missing_credentials", Message: "volces portrait asset accessKey and secretKey are required", Retryable: false}
|
||||
}
|
||||
endpoint := strings.TrimRight(strings.TrimSpace(credentials.Endpoint), "/")
|
||||
if endpoint == "" {
|
||||
endpoint = volcesAssetDefaultEndpoint
|
||||
}
|
||||
baseURL, err := url.Parse(endpoint)
|
||||
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
||||
return "", &ClientError{Code: "invalid_configuration", Message: "invalid volces portrait asset endpoint", Retryable: false}
|
||||
}
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal volces asset request: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if c.Now != nil {
|
||||
now = c.Now().UTC()
|
||||
}
|
||||
xDate := now.Format("20060102T150405Z")
|
||||
contentSHA := sha256HexBytes(bodyJSON)
|
||||
requestURL := *baseURL
|
||||
requestURL.Path = "/"
|
||||
requestURL.RawPath = ""
|
||||
requestURL.RawQuery = canonicalVolcesAssetQuery(map[string]string{"Action": action, "Version": volcesAssetVersion})
|
||||
headers := map[string]string{
|
||||
"content-type": "application/json",
|
||||
"host": baseURL.Host,
|
||||
"x-content-sha256": contentSHA,
|
||||
"x-date": xDate,
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Host = baseURL.Host
|
||||
req.Header.Set("Content-Type", headers["content-type"])
|
||||
req.Header.Set("X-Content-Sha256", headers["x-content-sha256"])
|
||||
req.Header.Set("X-Date", headers["x-date"])
|
||||
req.Header.Set("Authorization", volcesAssetAuthorization(accessKey, secretKey, http.MethodPost, "/", requestURL.RawQuery, headers, contentSHA, xDate))
|
||||
|
||||
response, err := httpClient(nil, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var envelope struct {
|
||||
ResponseMetadata struct {
|
||||
RequestID string `json:"RequestId"`
|
||||
Error struct {
|
||||
Code string `json:"Code"`
|
||||
Message string `json:"Message"`
|
||||
} `json:"Error"`
|
||||
} `json:"ResponseMetadata"`
|
||||
Result json.RawMessage `json:"Result"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
|
||||
return requestIDFromHTTPResponse(response), &ClientError{Code: "invalid_response", Message: "decode volces portrait asset response: " + err.Error(), Retryable: HTTPRetryable(response.StatusCode), StatusCode: response.StatusCode}
|
||||
}
|
||||
requestID := firstNonEmpty(requestIDFromHTTPResponse(response), envelope.ResponseMetadata.RequestID)
|
||||
if envelope.ResponseMetadata.Error.Code != "" || response.StatusCode >= http.StatusBadRequest {
|
||||
message := strings.TrimSpace(envelope.ResponseMetadata.Error.Message)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(envelope.ResponseMetadata.Error.Code)
|
||||
}
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("volces %s failed with status %d", action, response.StatusCode)
|
||||
}
|
||||
return requestID, &ClientError{Code: firstNonEmpty(envelope.ResponseMetadata.Error.Code, "volces_asset_error"), Message: message, RequestID: requestID, StatusCode: response.StatusCode, Retryable: HTTPRetryable(response.StatusCode)}
|
||||
}
|
||||
if len(envelope.Result) == 0 || string(envelope.Result) == "null" {
|
||||
return requestID, &ClientError{Code: "invalid_response", Message: "volces " + action + " returned empty result", RequestID: requestID, Retryable: false}
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Result, target); err != nil {
|
||||
return requestID, &ClientError{Code: "invalid_response", Message: "decode volces " + action + " result: " + err.Error(), RequestID: requestID, Retryable: false}
|
||||
}
|
||||
return requestID, nil
|
||||
}
|
||||
|
||||
func volcesAssetAuthorization(accessKey string, secretKey string, method string, path string, canonicalQuery string, headers map[string]string, bodySHA string, xDate string) string {
|
||||
signedHeaders := []string{"content-type", "host", "x-content-sha256", "x-date"}
|
||||
canonicalHeaderLines := make([]string, 0, len(signedHeaders))
|
||||
for _, key := range signedHeaders {
|
||||
canonicalHeaderLines = append(canonicalHeaderLines, key+":"+strings.TrimSpace(headers[key]))
|
||||
}
|
||||
canonicalRequest := strings.Join([]string{
|
||||
strings.ToUpper(method), path, canonicalQuery,
|
||||
strings.Join(canonicalHeaderLines, "\n") + "\n",
|
||||
strings.Join(signedHeaders, ";"), bodySHA,
|
||||
}, "\n")
|
||||
date := xDate
|
||||
if len(date) >= 8 {
|
||||
date = date[:8]
|
||||
}
|
||||
scope := strings.Join([]string{date, volcesAssetRegion, volcesAssetService, "request"}, "/")
|
||||
stringToSign := strings.Join([]string{"HMAC-SHA256", xDate, scope, sha256HexString(canonicalRequest)}, "\n")
|
||||
kDate := hmacSHA256([]byte(secretKey), date)
|
||||
kRegion := hmacSHA256(kDate, volcesAssetRegion)
|
||||
kService := hmacSHA256(kRegion, volcesAssetService)
|
||||
kSigning := hmacSHA256(kService, "request")
|
||||
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
return "HMAC-SHA256 Credential=" + accessKey + "/" + scope + ", SignedHeaders=" + strings.Join(signedHeaders, ";") + ", Signature=" + signature
|
||||
}
|
||||
|
||||
func canonicalVolcesAssetQuery(values map[string]string) string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(values[key]))
|
||||
}
|
||||
return strings.ReplaceAll(strings.Join(parts, "&"), "+", "%20")
|
||||
}
|
||||
|
||||
func sha256HexBytes(value []byte) string {
|
||||
digest := sha256.Sum256(value)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func sha256HexString(value string) string { return sha256HexBytes([]byte(value)) }
|
||||
|
||||
func hmacSHA256(key []byte, value string) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write([]byte(value))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVolcesAssetClientSignsCreateAndReadsAsset(t *testing.T) {
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.URL.Query().Get("Action"))
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/" || r.URL.Query().Get("Version") != "2024-01-01" {
|
||||
t.Fatalf("unexpected asset request %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
if r.Header.Get("X-Date") != "20260718T010203Z" {
|
||||
t.Fatalf("unexpected x-date: %q", r.Header.Get("X-Date"))
|
||||
}
|
||||
if !strings.HasPrefix(r.Header.Get("Authorization"), "HMAC-SHA256 Credential=asset-ak/") {
|
||||
t.Fatalf("missing Volces authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
digest := sha256.Sum256(raw)
|
||||
if got := r.Header.Get("X-Content-Sha256"); got != hex.EncodeToString(digest[:]) {
|
||||
t.Fatalf("content hash mismatch got=%q", got)
|
||||
}
|
||||
switch r.URL.Query().Get("Action") {
|
||||
case "CreateAsset":
|
||||
if body["GroupId"] != "group-1" || body["AssetType"] != "Image" {
|
||||
t.Fatalf("unexpected CreateAsset body: %+v", body)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ResponseMetadata": map[string]any{"RequestId": "create-rid"}, "Result": map[string]any{"Id": "asset-1"}})
|
||||
case "GetAsset":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ResponseMetadata": map[string]any{"RequestId": "get-rid"}, "Result": map[string]any{"Id": "asset-1", "Status": "Active", "AssetType": "Image"}})
|
||||
default:
|
||||
t.Fatalf("unexpected Action: %q", r.URL.Query().Get("Action"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := VolcesAssetClient{HTTPClient: server.Client(), Now: func() time.Time {
|
||||
return time.Date(2026, 7, 18, 1, 2, 3, 0, time.UTC)
|
||||
}}
|
||||
credentials := VolcesAssetCredentials{AccessKey: "asset-ak", SecretKey: "asset-sk", Endpoint: server.URL}
|
||||
created, requestID, err := client.CreateAsset(context.Background(), credentials, map[string]any{"GroupId": "group-1", "URL": "https://example.com/person.png", "AssetType": "Image", "ProjectName": "default"})
|
||||
if err != nil || created.ID != "asset-1" || requestID != "create-rid" {
|
||||
t.Fatalf("unexpected CreateAsset result=%+v requestID=%s err=%v", created, requestID, err)
|
||||
}
|
||||
asset, requestID, err := client.GetAsset(context.Background(), credentials, map[string]any{"Id": "asset-1", "ProjectName": "default"})
|
||||
if err != nil || asset.Status != "Active" || requestID != "get-rid" {
|
||||
t.Fatalf("unexpected GetAsset result=%+v requestID=%s err=%v", asset, requestID, err)
|
||||
}
|
||||
if strings.Join(calls, ",") != "CreateAsset,GetAsset" {
|
||||
t.Fatalf("unexpected actions: %+v", calls)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,32 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// listAPIKeyAssignableModels godoc
|
||||
// @Summary 列出 API Key 可分配模型
|
||||
// @Description 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。
|
||||
// @Tags api-keys
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} PlatformModelListResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/api-keys/assignable-models [get]
|
||||
func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
models, err := s.store.ListAPIKeyAssignablePlatformModels(r.Context(), user)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
s.logger.Error("list api key assignable models failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list api key assignable models failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)})
|
||||
}
|
||||
|
||||
// createAccessRule godoc
|
||||
// @Summary 创建访问规则
|
||||
// @Description 管理端创建一条访问控制规则。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestAPIKeyAssignableModelsIgnoreAPIKeyRules(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the API key assignable-model integration flow")
|
||||
}
|
||||
ctx := context.Background()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer server.Close()
|
||||
|
||||
suffixText := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
username := "api_key_assignable_" + suffixText
|
||||
password := "password123"
|
||||
var registerResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®isterResponse)
|
||||
|
||||
testPool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect test pool: %v", err)
|
||||
}
|
||||
defer testPool.Close()
|
||||
if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote test user: %v", err)
|
||||
}
|
||||
|
||||
var loginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username,
|
||||
"password": password,
|
||||
}, http.StatusOK, &loginResponse)
|
||||
|
||||
createAPIKey := func(name string) string {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
APIKey struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"apiKey"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", loginResponse.AccessToken, map[string]any{
|
||||
"name": name,
|
||||
}, http.StatusCreated, &response)
|
||||
return response.APIKey.ID
|
||||
}
|
||||
firstAPIKeyID := createAPIKey("first assignable key")
|
||||
secondAPIKeyID := createAPIKey("second assignable key")
|
||||
|
||||
var platform struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
|
||||
"provider": "openai",
|
||||
"platformKey": "api-key-assignable-" + suffixText,
|
||||
"name": "API Key Assignable Test",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"authType": "bearer",
|
||||
"credentials": map[string]any{"mode": "simulation"},
|
||||
"config": map[string]any{"testMode": true},
|
||||
}, http.StatusCreated, &platform)
|
||||
|
||||
var model struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
modelName := "api-key-assignable-model-" + suffixText
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
|
||||
"canonicalModelKey": "openai:gpt-4o-mini",
|
||||
"modelName": modelName,
|
||||
"modelAlias": modelName,
|
||||
"modelType": []string{"text_generate"},
|
||||
"displayName": "API Key Assignable Model",
|
||||
}, http.StatusCreated, &model)
|
||||
|
||||
assertAssignable := func() {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
ModelName string `json:"modelName"`
|
||||
} `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys/assignable-models", loginResponse.AccessToken, nil, http.StatusOK, &response)
|
||||
if !modelListContains(response.Items, model.ID) {
|
||||
t.Fatalf("user-owned model should remain assignable regardless of API key rules: %+v", response.Items)
|
||||
}
|
||||
}
|
||||
assignModel := func(apiKeyID string) {
|
||||
t.Helper()
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys/access-rules/batch", loginResponse.AccessToken, map[string]any{
|
||||
"subjectType": "api_key",
|
||||
"subjectId": apiKeyID,
|
||||
"effect": "allow",
|
||||
"upsertResources": []map[string]any{{
|
||||
"resourceType": "platform_model",
|
||||
"resourceId": model.ID,
|
||||
"priority": 100,
|
||||
"minPermissionLevel": 0,
|
||||
"status": "active",
|
||||
}},
|
||||
"deleteResources": []map[string]any{},
|
||||
}, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
assertAssignable()
|
||||
assignModel(firstAPIKeyID)
|
||||
assertAssignable()
|
||||
assignModel(secondAPIKeyID)
|
||||
assertAssignable()
|
||||
}
|
||||
@@ -58,17 +58,18 @@ func TestPlanTaskResponseTreatsAPIV1EmbeddingAndRerankAsSynchronousCompatibleRes
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTaskResponseKeepsAsyncTaskModeForOtherAPIV1Tasks(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
req.Header.Set("X-Async", "true")
|
||||
|
||||
plan := planTaskResponse("images.generations", false, map[string]any{"stream": true}, req)
|
||||
|
||||
if !plan.asyncMode {
|
||||
t.Fatal("non-chat /api/v1 task endpoints should keep X-Async task mode")
|
||||
func TestPlanTaskResponseUsesCompatibleAPIV1MediaResponsesAndKeepsAsyncOptIn(t *testing.T) {
|
||||
defaultRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
defaultPlan := planTaskResponse("images.generations", true, map[string]any{}, defaultRequest)
|
||||
if defaultPlan.asyncMode || !defaultPlan.compatibleMode {
|
||||
t.Fatalf("canonical /api/v1 media endpoints should default to synchronous compatible responses, got %+v", defaultPlan)
|
||||
}
|
||||
if plan.compatibleMode {
|
||||
t.Fatal("non-compatible /api/v1 task endpoints should not return OpenAI-compatible payloads")
|
||||
|
||||
asyncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
asyncRequest.Header.Set("X-Async", "true")
|
||||
asyncPlan := planTaskResponse("images.generations", true, map[string]any{}, asyncRequest)
|
||||
if !asyncPlan.asyncMode || !asyncPlan.compatibleMode {
|
||||
t.Fatalf("canonical /api/v1 media endpoints should keep compatible mode when X-Async is enabled, got %+v", asyncPlan)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -504,7 +504,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
Result map[string]any `json:"result"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
|
||||
"model": defaultImageModel,
|
||||
"runMode": "simulation",
|
||||
"prompt": "a tiny gateway console",
|
||||
@@ -512,7 +512,9 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
"quality": "medium",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusAccepted, &imageResponse)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageResponse)
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageResponse.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageResponse.Task)
|
||||
if imageResponse.Task.Status != "succeeded" || imageResponse.Task.Result["id"] == "" {
|
||||
t.Fatalf("unexpected image generation task: %+v", imageResponse.Task)
|
||||
}
|
||||
@@ -524,7 +526,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
Result map[string]any `json:"result"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{
|
||||
"model": defaultImageModel,
|
||||
"runMode": "simulation",
|
||||
"prompt": "replace background with clean studio light",
|
||||
@@ -532,7 +534,9 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
"mask": "https://example.com/mask.png",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusAccepted, &imageEditResponse)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageEditResponse)
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageEditResponse.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageEditResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageEditResponse.Task)
|
||||
if imageEditResponse.Task.Status != "succeeded" || imageEditResponse.Task.Result["id"] == "" {
|
||||
t.Fatalf("unexpected image edit task: %+v", imageEditResponse.Task)
|
||||
}
|
||||
@@ -1196,17 +1200,20 @@ WHERE reference_type = 'gateway_task'
|
||||
}, http.StatusCreated, &videoRoutePlatformModel)
|
||||
var textToVideoTask struct {
|
||||
Task struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ModelType string `json:"modelType"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
"model": videoRouteModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"prompt": "text to video route",
|
||||
}, http.StatusAccepted, &textToVideoTask)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &textToVideoTask)
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, textToVideoTask.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+textToVideoTask.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &textToVideoTask.Task)
|
||||
if textToVideoTask.Task.Status != "succeeded" || textToVideoTask.Task.ModelType != "video_generate" {
|
||||
t.Fatalf("text-to-video request should use video_generate model_type: %+v", textToVideoTask.Task)
|
||||
}
|
||||
@@ -1218,14 +1225,16 @@ WHERE reference_type = 'gateway_task'
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
"model": videoRouteModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"prompt": "image to video route",
|
||||
"image": "https://example.com/source.png",
|
||||
}, http.StatusAccepted, &imageToVideoTask)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageToVideoTask)
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageToVideoTask.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageToVideoTask.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageToVideoTask.Task)
|
||||
if imageToVideoTask.Task.Status != "succeeded" || imageToVideoTask.Task.ModelType != "image_to_video" {
|
||||
t.Fatalf("image-to-video request should use image_to_video model_type: %+v", imageToVideoTask.Task)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ const maxGatewayUploadBytes = 256 << 20
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /api/v1/files/upload [post]
|
||||
// @Router /v1/files/upload [post]
|
||||
func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type gatewayTaskCreationStage string
|
||||
|
||||
const (
|
||||
gatewayTaskCreationPrepare gatewayTaskCreationStage = "prepare"
|
||||
gatewayTaskCreationStore gatewayTaskCreationStage = "store"
|
||||
)
|
||||
|
||||
type gatewayTaskCreationError struct {
|
||||
Stage gatewayTaskCreationStage
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *gatewayTaskCreationError) Error() string {
|
||||
if e == nil || e.Err == nil {
|
||||
return "gateway task creation failed"
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *gatewayTaskCreationError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (s *Server) prepareAndCreateGatewayTask(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
user *auth.User,
|
||||
kind string,
|
||||
model string,
|
||||
body map[string]any,
|
||||
async bool,
|
||||
) (store.GatewayTask, error) {
|
||||
prepared, err := s.prepareTaskRequest(ctx, r, user, body)
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, &gatewayTaskCreationError{Stage: gatewayTaskCreationPrepare, Err: err}
|
||||
}
|
||||
task, err := s.store.CreateTask(ctx, store.CreateTaskInput{
|
||||
Kind: kind,
|
||||
Model: model,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
Async: async,
|
||||
Request: prepared.Body,
|
||||
ConversationID: prepared.ConversationID,
|
||||
NewMessageCount: prepared.NewMessageCount,
|
||||
MessageRefs: prepared.MessageRefs,
|
||||
}, user)
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, &gatewayTaskCreationError{
|
||||
Stage: gatewayTaskCreationStore,
|
||||
Err: fmt.Errorf("create task: %w", err),
|
||||
}
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
@@ -40,9 +40,9 @@ type geminiUploadSession struct {
|
||||
}
|
||||
|
||||
var geminiGenerateContentRoutePrefixes = []string{
|
||||
"/api/v1/models/",
|
||||
"/v1beta/models/",
|
||||
"/v1/models/",
|
||||
"/models/",
|
||||
}
|
||||
|
||||
func (s *Server) registerGeminiGenerateContentRoutes(mux *http.ServeMux) {
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
)
|
||||
|
||||
func TestGeminiGenerateContentModelFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -25,9 +33,9 @@ func TestGeminiGenerateContentModelFromPath(t *testing.T) {
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "bare model path",
|
||||
prefix: "/models/",
|
||||
requestPath: "/models/gemini-image:generateContent",
|
||||
name: "gateway api v1 model",
|
||||
prefix: "/api/v1/models/",
|
||||
requestPath: "/api/v1/models/gemini-image:generateContent",
|
||||
wantModel: "gemini-image",
|
||||
wantOK: true,
|
||||
},
|
||||
@@ -61,6 +69,51 @@ func TestGeminiGenerateContentModelFromPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGeminiGenerateContentRoutes(t *testing.T) {
|
||||
server := &Server{
|
||||
auth: auth.New("test-secret", "", ""),
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/v1/models", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
server.registerGeminiGenerateContentRoutes(mux)
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
status int
|
||||
}{
|
||||
{method: http.MethodGet, path: "/api/v1/models", status: http.StatusNoContent},
|
||||
{method: http.MethodPost, path: "/api/v1/models/gemini-image:generateContent", status: http.StatusUnauthorized},
|
||||
{method: http.MethodPost, path: "/v1/models/gemini-image:generateContent", status: http.StatusUnauthorized},
|
||||
{method: http.MethodPost, path: "/v1beta/models/gemini-image:generateContent", status: http.StatusUnauthorized},
|
||||
{method: http.MethodPost, path: "/models/gemini-image:generateContent", status: http.StatusNotFound},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, httptest.NewRequest(tt.method, tt.path, nil))
|
||||
if response.Code != tt.status {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.Code, tt.status, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func 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())
|
||||
@@ -1214,8 +1198,6 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 429 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /chat/completions [post]
|
||||
// @Router /v1/chat/completions [post]
|
||||
func openAIChatCompletionsDoc() {}
|
||||
|
||||
// openAIResponsesDoc godoc
|
||||
@@ -1234,8 +1216,6 @@ func openAIChatCompletionsDoc() {}
|
||||
// @Failure 402 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
|
||||
// @Router /responses [post]
|
||||
// @Router /v1/responses [post]
|
||||
// @Router /api/v1/responses [post]
|
||||
func openAIResponsesDoc() {}
|
||||
|
||||
@@ -1660,7 +1640,6 @@ func matchedRateLimitRule(policy map[string]any, metric string) map[string]any {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks [get]
|
||||
// @Router /api/v1/tasks [get]
|
||||
// @Router /tasks [get]
|
||||
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -1769,7 +1748,6 @@ func boolValue(body map[string]any, key string) bool {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID} [get]
|
||||
// @Router /api/v1/tasks/{taskID} [get]
|
||||
// @Router /tasks/{taskID} [get]
|
||||
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
|
||||
if err == nil {
|
||||
@@ -1802,8 +1780,6 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID}/cancel [post]
|
||||
// @Router /api/v1/tasks/{taskID}/cancel [post]
|
||||
// @Router /v1/tasks/{taskID}/cancel [post]
|
||||
// @Router /tasks/{taskID}/cancel [post]
|
||||
func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -1840,7 +1816,6 @@ func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID}/param-preprocessing [get]
|
||||
// @Router /api/v1/tasks/{taskID}/param-preprocessing [get]
|
||||
// @Router /tasks/{taskID}/param-preprocessing [get]
|
||||
func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request) {
|
||||
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
|
||||
if err != nil {
|
||||
@@ -1874,7 +1849,6 @@ func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request)
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID}/events [get]
|
||||
// @Router /api/v1/tasks/{taskID}/events [get]
|
||||
// @Router /tasks/{taskID}/events [get]
|
||||
func (s *Server) taskEvents(w http.ResponseWriter, r *http.Request) {
|
||||
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,885 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const kelingOmniCompatibilityMarker = "keling_omni_v1"
|
||||
|
||||
type kelingCompatRequestIDKey struct{}
|
||||
|
||||
type KelingOmniVideoRequest struct {
|
||||
ModelName string `json:"model_name" example:"kling-v3-omni"`
|
||||
Prompt string `json:"prompt" example:"A quiet street in the rain with natural ambient sound"`
|
||||
MultiShot bool `json:"multi_shot" example:"false"`
|
||||
ShotType string `json:"shot_type,omitempty" example:"customize"`
|
||||
MultiPrompt []KelingOmniMultiPrompt `json:"multi_prompt,omitempty"`
|
||||
ImageList []KelingOmniImageInput `json:"image_list,omitempty"`
|
||||
ElementList []KelingOmniElementInput `json:"element_list,omitempty"`
|
||||
VideoList []KelingOmniVideoInput `json:"video_list,omitempty"`
|
||||
Sound string `json:"sound" enums:"on,off" example:"on"`
|
||||
Mode string `json:"mode" enums:"std,pro,4k" example:"pro"`
|
||||
AspectRatio string `json:"aspect_ratio" enums:"16:9,9:16,1:1" example:"9:16"`
|
||||
Duration any `json:"duration" swaggertype:"string" example:"5"`
|
||||
WatermarkInfo KelingOmniWatermarkInfo `json:"watermark_info,omitempty"`
|
||||
CallbackURL string `json:"callback_url,omitempty"`
|
||||
ExternalTask string `json:"external_task_id,omitempty"`
|
||||
}
|
||||
|
||||
type KelingOmniMultiPrompt struct {
|
||||
Index int `json:"index" example:"1"`
|
||||
Prompt string `json:"prompt" example:"A wide establishing shot"`
|
||||
Duration any `json:"duration" swaggertype:"string" example:"3"`
|
||||
}
|
||||
|
||||
type KelingOmniImageInput struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
Type string `json:"type,omitempty" enums:"first_frame,end_frame"`
|
||||
}
|
||||
|
||||
type KelingOmniElementInput struct {
|
||||
ElementID any `json:"element_id"`
|
||||
}
|
||||
|
||||
type KelingOmniVideoInput struct {
|
||||
VideoURL string `json:"video_url"`
|
||||
ReferType string `json:"refer_type,omitempty" enums:"base,feature"`
|
||||
KeepOriginalSound string `json:"keep_original_sound,omitempty" enums:"yes,no"`
|
||||
}
|
||||
|
||||
type KelingOmniWatermarkInfo struct {
|
||||
Enabled bool `json:"enabled" example:"false"`
|
||||
}
|
||||
|
||||
type KelingCompatibleEnvelope struct {
|
||||
Code int `json:"code" example:"0"`
|
||||
Message string `json:"message" example:"SUCCEED"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type kelingCompatError struct {
|
||||
HTTPStatus int
|
||||
Code int
|
||||
Message string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func (e *kelingCompatError) Error() string {
|
||||
if e == nil {
|
||||
return "keling compatibility error"
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func newKelingCompatError(status int, code int, message string) *kelingCompatError {
|
||||
return &kelingCompatError{HTTPStatus: status, Code: code, Message: message}
|
||||
}
|
||||
|
||||
func (s *Server) requireKelingAPIKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := newKelingCompatRequestID(r)
|
||||
r = r.WithContext(context.WithValue(r.Context(), kelingCompatRequestIDKey{}, requestID))
|
||||
user, err := s.auth.Authenticate(r)
|
||||
if err != nil {
|
||||
code := 1002
|
||||
message := "Authorization is invalid"
|
||||
if strings.TrimSpace(r.Header.Get("Authorization")) == "" {
|
||||
code = 1001
|
||||
message = "Authorization is required"
|
||||
}
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, code, message))
|
||||
return
|
||||
}
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "a Gateway API Key is required"))
|
||||
return
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusForbidden, 1103, "API Key scope does not allow video generation"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), user)))
|
||||
})
|
||||
}
|
||||
|
||||
// createKelingOmniVideo godoc
|
||||
// @Summary 创建 Kling Omni 视频任务
|
||||
// @Description 兼容 Kling 旧版 /v1/videos/omni-video;Bearer token 必须为 Gateway API Key。任务固定异步执行,返回的 task_id 为网关任务 ID。
|
||||
// @Tags kling-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param input body KelingOmniVideoRequest true "Kling Omni 官方兼容请求"
|
||||
// @Success 200 {object} KelingCompatibleEnvelope
|
||||
// @Failure 400 {object} KelingCompatibleEnvelope
|
||||
// @Failure 401 {object} KelingCompatibleEnvelope
|
||||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||||
// @Failure 429 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Failure 503 {object} KelingCompatibleEnvelope
|
||||
// @Router /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())
|
||||
if !ok || user == nil {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid"))
|
||||
return
|
||||
}
|
||||
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
|
||||
if err != nil {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, err.Error()))
|
||||
return
|
||||
}
|
||||
normalized, compatErr := normalizeKelingOmniRequest(body)
|
||||
if compatErr != nil {
|
||||
writeKelingCompatError(w, requestID, compatErr)
|
||||
return
|
||||
}
|
||||
model := strings.TrimSpace(stringFromKelingCompat(normalized["model"]))
|
||||
if normalized["resolution"] == "2160p" {
|
||||
candidates, candidateErr := s.store.ListModelCandidates(r.Context(), model, "omni_video", user)
|
||||
if candidateErr != nil {
|
||||
writeKelingCompatError(w, requestID, kelingCompatGatewayError(candidateErr))
|
||||
return
|
||||
}
|
||||
if !kelingCompatCandidatesSupport4K(candidates) {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, "mode=4k is not enabled by the selected model capabilities"))
|
||||
return
|
||||
}
|
||||
}
|
||||
task, createErr := s.prepareAndCreateGatewayTask(
|
||||
r.Context(),
|
||||
r,
|
||||
user,
|
||||
"videos.generations",
|
||||
model,
|
||||
normalized,
|
||||
true,
|
||||
)
|
||||
if createErr != nil {
|
||||
var staged *gatewayTaskCreationError
|
||||
if errors.As(createErr, &staged) && staged.Stage == gatewayTaskCreationPrepare {
|
||||
writeKelingCompatError(w, requestID, kelingCompatGatewayError(staged.Err))
|
||||
return
|
||||
}
|
||||
s.logger.Error("create Kling-compatible task failed", "error", createErr)
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "create task failed"))
|
||||
return
|
||||
}
|
||||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||||
s.logger.Error("enqueue Kling-compatible task failed", "taskId", task.ID, "error", err)
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusServiceUnavailable, 5001, "video task queue is unavailable"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
|
||||
Code: 0,
|
||||
Message: "SUCCEED",
|
||||
RequestID: requestID,
|
||||
Data: kelingCompatTaskData(task),
|
||||
})
|
||||
}
|
||||
|
||||
// getKelingOmniVideo godoc
|
||||
// @Summary 查询 Kling Omni 视频任务
|
||||
// @Description 按创建接口返回的网关 task_id 查询任务;仅允许创建任务的 Gateway 用户访问。
|
||||
// @Tags kling-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param taskID path string true "网关任务 ID"
|
||||
// @Success 200 {object} KelingCompatibleEnvelope
|
||||
// @Failure 401 {object} KelingCompatibleEnvelope
|
||||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||||
// @Failure 404 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Router /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())
|
||||
if !ok || user == nil {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusUnauthorized, 1002, "Authorization is invalid"))
|
||||
return
|
||||
}
|
||||
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
|
||||
return
|
||||
}
|
||||
s.logger.Error("get Kling-compatible task failed", "error", err)
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "query task failed"))
|
||||
return
|
||||
}
|
||||
if !kelingCompatTaskOwnedBy(task, user) || !isKelingCompatTask(task) {
|
||||
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
|
||||
Code: 0,
|
||||
Message: "SUCCEED",
|
||||
RequestID: requestID,
|
||||
Data: kelingCompatTaskData(task),
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeKelingOmniRequest(input map[string]any) (map[string]any, *kelingCompatError) {
|
||||
if input == nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
if callbackURL := strings.TrimSpace(stringFromKelingCompat(input["callback_url"])); callbackURL != "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "callback_url is not supported by this Gateway endpoint")
|
||||
}
|
||||
requestedModel := strings.TrimSpace(stringFromKelingCompat(input["model_name"]))
|
||||
if requestedModel == "" {
|
||||
requestedModel = "kling-video-o1"
|
||||
}
|
||||
model, maxDuration, ok := kelingCompatModel(requestedModel)
|
||||
if !ok {
|
||||
return nil, newKelingCompatError(http.StatusNotFound, 1203, "unsupported model_name: "+requestedModel)
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["mode"])))
|
||||
if mode == "" {
|
||||
mode = "pro"
|
||||
}
|
||||
resolutionByMode := map[string]string{"std": "720p", "pro": "1080p", "4k": "2160p"}
|
||||
resolution := resolutionByMode[mode]
|
||||
if resolution == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "mode must be std, pro, or 4k")
|
||||
}
|
||||
|
||||
sound := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["sound"])))
|
||||
if sound == "" {
|
||||
sound = "off"
|
||||
}
|
||||
if sound != "on" && sound != "off" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be on or off")
|
||||
}
|
||||
if model == "kling-o1" && sound == "on" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 does not support generated audio; sound must be off")
|
||||
}
|
||||
|
||||
content := make([]any, 0)
|
||||
prompt := strings.TrimSpace(stringFromKelingCompat(input["prompt"]))
|
||||
images, hasFirstFrame, imageErr := normalizeKelingImageList(input["image_list"])
|
||||
if imageErr != nil {
|
||||
return nil, imageErr
|
||||
}
|
||||
content = append(content, images...)
|
||||
elements, elementErr := normalizeKelingElementList(input["element_list"])
|
||||
if elementErr != nil {
|
||||
return nil, elementErr
|
||||
}
|
||||
content = append(content, elements...)
|
||||
videos, hasBaseVideo, hasVideo, videoErr := normalizeKelingVideoList(input["video_list"])
|
||||
if videoErr != nil {
|
||||
return nil, videoErr
|
||||
}
|
||||
content = append(content, videos...)
|
||||
if hasVideo && sound == "on" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "sound must be off when video_list is provided")
|
||||
}
|
||||
|
||||
multiShot, multiShotPresent, boolErr := kelingCompatOptionalBool(input, "multi_shot")
|
||||
if boolErr != nil {
|
||||
return nil, boolErr
|
||||
}
|
||||
shotType := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(input["shot_type"])))
|
||||
multiPrompts, shotDuration, multiPromptErr := normalizeKelingMultiPrompts(input["multi_prompt"])
|
||||
if multiPromptErr != nil {
|
||||
return nil, multiPromptErr
|
||||
}
|
||||
if !multiShotPresent {
|
||||
multiShot = false
|
||||
}
|
||||
if multiShot {
|
||||
if shotType != "customize" && shotType != "intelligence" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "shot_type must be customize or intelligence when multi_shot is true")
|
||||
}
|
||||
if shotType == "customize" && len(multiPrompts) == 0 {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt is required for customized multi-shot generation")
|
||||
}
|
||||
if shotType == "intelligence" && len(multiPrompts) > 0 {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt is only supported when shot_type is customize")
|
||||
}
|
||||
} else if len(multiPrompts) > 0 || shotType != "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "shot_type and multi_prompt require multi_shot=true")
|
||||
}
|
||||
if (len(multiPrompts) == 0 || shotType == "intelligence") && prompt == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "prompt is required")
|
||||
}
|
||||
if prompt != "" {
|
||||
content = append([]any{map[string]any{"type": "text", "text": prompt}}, content...)
|
||||
}
|
||||
content = append(content, multiPrompts...)
|
||||
|
||||
duration, durationProvided, durationErr := kelingCompatOptionalInt(input, "duration")
|
||||
if durationErr != nil {
|
||||
return nil, durationErr
|
||||
}
|
||||
if hasBaseVideo {
|
||||
if durationProvided {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "duration is not supported for base video editing")
|
||||
}
|
||||
} else {
|
||||
if len(multiPrompts) > 0 {
|
||||
if durationProvided && duration != shotDuration {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "duration must equal the sum of multi_prompt durations")
|
||||
}
|
||||
duration = shotDuration
|
||||
} else if !durationProvided {
|
||||
duration = 5
|
||||
}
|
||||
if duration < 3 || duration > maxDuration {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("duration for %s must be an integer between 3 and %d seconds", requestedModel, maxDuration))
|
||||
}
|
||||
if model == "kling-o1" && (len(images) == 0 || hasFirstFrame) && duration != 5 && duration != 10 {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "kling-video-o1 text-to-video and first-frame generation only support 5 or 10 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
aspectRatio := strings.TrimSpace(stringFromKelingCompat(input["aspect_ratio"]))
|
||||
if aspectRatio != "" && aspectRatio != "16:9" && aspectRatio != "9:16" && aspectRatio != "1:1" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio must be 16:9, 9:16, or 1:1")
|
||||
}
|
||||
if hasFirstFrame || hasBaseVideo {
|
||||
if aspectRatio != "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio is not supported with a first frame or base video")
|
||||
}
|
||||
} else if aspectRatio == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, "aspect_ratio is required when no first frame or base video is provided")
|
||||
}
|
||||
|
||||
watermarkEnabled, watermarkErr := kelingCompatWatermarkEnabled(input["watermark_info"])
|
||||
if watermarkErr != nil {
|
||||
return nil, watermarkErr
|
||||
}
|
||||
externalTaskID := strings.TrimSpace(stringFromKelingCompat(input["external_task_id"]))
|
||||
normalized := map[string]any{
|
||||
"model": model,
|
||||
"model_name": requestedModel,
|
||||
"modelType": "omni_video",
|
||||
"runMode": "real",
|
||||
"content": content,
|
||||
"resolution": resolution,
|
||||
"mode": mode,
|
||||
"sound": sound,
|
||||
"audio": sound == "on",
|
||||
"multi_shot": multiShot,
|
||||
"watermark": watermarkEnabled,
|
||||
"watermark_info": map[string]any{"enabled": watermarkEnabled},
|
||||
"external_task_id": externalTaskID,
|
||||
"_gateway_compatibility": kelingOmniCompatibilityMarker,
|
||||
}
|
||||
if prompt != "" {
|
||||
normalized["prompt"] = prompt
|
||||
}
|
||||
if shotType != "" {
|
||||
normalized["shot_type"] = shotType
|
||||
}
|
||||
if !hasBaseVideo {
|
||||
normalized["duration"] = duration
|
||||
}
|
||||
if aspectRatio != "" {
|
||||
normalized["aspect_ratio"] = aspectRatio
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeKelingImageList(value any) ([]any, bool, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "image_list")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
hasFirstFrame := false
|
||||
hasEndFrame := false
|
||||
for index, item := range items {
|
||||
url := strings.TrimSpace(stringFromKelingCompat(item["image_url"]))
|
||||
if url == "" {
|
||||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("image_list[%d].image_url is required", index))
|
||||
}
|
||||
frameType := strings.TrimSpace(stringFromKelingCompat(item["type"]))
|
||||
role := "reference_image"
|
||||
switch frameType {
|
||||
case "":
|
||||
case "first_frame":
|
||||
role = "first_frame"
|
||||
hasFirstFrame = true
|
||||
case "end_frame":
|
||||
role = "last_frame"
|
||||
hasEndFrame = true
|
||||
default:
|
||||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("image_list[%d].type must be first_frame or end_frame", index))
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"type": "image_url",
|
||||
"role": role,
|
||||
"image_url": map[string]any{"url": url},
|
||||
})
|
||||
}
|
||||
if hasEndFrame && !hasFirstFrame {
|
||||
return nil, false, newKelingCompatError(http.StatusBadRequest, 1201, "end_frame requires first_frame")
|
||||
}
|
||||
return out, hasFirstFrame, nil
|
||||
}
|
||||
|
||||
func normalizeKelingElementList(value any) ([]any, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "element_list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
id := item["element_id"]
|
||||
if strings.TrimSpace(stringFromKelingCompat(id)) == "" {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("element_list[%d].element_id is required", index))
|
||||
}
|
||||
out = append(out, map[string]any{"type": "element", "element": map[string]any{"element_id": id}})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeKelingVideoList(value any) ([]any, bool, bool, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "video_list")
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
if len(items) > 1 {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, "video_list supports at most one video")
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
hasBase := false
|
||||
for index, item := range items {
|
||||
url := strings.TrimSpace(stringFromKelingCompat(item["video_url"]))
|
||||
if url == "" {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].video_url is required", index))
|
||||
}
|
||||
referType := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(item["refer_type"])))
|
||||
if referType == "" {
|
||||
referType = "base"
|
||||
}
|
||||
if referType != "base" && referType != "feature" {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].refer_type must be base or feature", index))
|
||||
}
|
||||
keepSound := strings.ToLower(strings.TrimSpace(stringFromKelingCompat(item["keep_original_sound"])))
|
||||
if keepSound != "" && keepSound != "yes" && keepSound != "no" {
|
||||
return nil, false, false, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("video_list[%d].keep_original_sound must be yes or no", index))
|
||||
}
|
||||
nested := map[string]any{"url": url, "refer_type": referType}
|
||||
if keepSound != "" {
|
||||
nested["keep_original_sound"] = keepSound
|
||||
}
|
||||
role := "video_feature"
|
||||
if referType == "base" {
|
||||
role = "video_base"
|
||||
hasBase = true
|
||||
}
|
||||
out = append(out, map[string]any{"type": "video_url", "role": role, "video_url": nested})
|
||||
}
|
||||
return out, hasBase, len(items) > 0, nil
|
||||
}
|
||||
|
||||
func normalizeKelingMultiPrompts(value any) ([]any, int, *kelingCompatError) {
|
||||
items, err := kelingCompatObjectList(value, "multi_prompt")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(items) > 6 {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, "multi_prompt supports at most six shots")
|
||||
}
|
||||
out := make([]any, 0, len(items))
|
||||
seen := map[int]bool{}
|
||||
total := 0
|
||||
for index, item := range items {
|
||||
shotIndex, ok := kelingCompatInt(item["index"])
|
||||
if !ok || shotIndex < 1 || shotIndex > 6 || seen[shotIndex] {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].index must be a unique integer from 1 to 6", index))
|
||||
}
|
||||
seen[shotIndex] = true
|
||||
prompt := strings.TrimSpace(stringFromKelingCompat(item["prompt"]))
|
||||
if prompt == "" {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].prompt is required", index))
|
||||
}
|
||||
duration, ok := kelingCompatInt(item["duration"])
|
||||
if !ok || duration < 1 {
|
||||
return nil, 0, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("multi_prompt[%d].duration must be an integer of at least 1 second", index))
|
||||
}
|
||||
total += duration
|
||||
out = append(out, map[string]any{
|
||||
"type": "text",
|
||||
"role": "shot_prompt",
|
||||
"shot_index": shotIndex,
|
||||
"text": prompt,
|
||||
"duration": duration,
|
||||
})
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func kelingCompatModel(value string) (string, int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "kling-video-o1", "kling-o1":
|
||||
return "kling-o1", 10, true
|
||||
case "kling-v3-omni", "kling-3.0-omni":
|
||||
return "kling-3.0-omni", 15, true
|
||||
default:
|
||||
return "", 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatObjectList(value any, field string) ([]map[string]any, *kelingCompatError) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, field+" must be an array")
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for index, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, newKelingCompatError(http.StatusBadRequest, 1201, fmt.Sprintf("%s[%d] must be an object", field, index))
|
||||
}
|
||||
out = append(out, object)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func kelingCompatOptionalInt(body map[string]any, key string) (int, bool, *kelingCompatError) {
|
||||
value, present := body[key]
|
||||
if !present || value == nil || strings.TrimSpace(stringFromKelingCompat(value)) == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
parsed, ok := kelingCompatInt(value)
|
||||
if !ok {
|
||||
return 0, true, newKelingCompatError(http.StatusBadRequest, 1201, key+" must be an integer")
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func kelingCompatOptionalBool(body map[string]any, key string) (bool, bool, *kelingCompatError) {
|
||||
value, present := body[key]
|
||||
if !present || value == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
parsed, ok := value.(bool)
|
||||
if !ok {
|
||||
return false, true, newKelingCompatError(http.StatusBadRequest, 1201, key+" must be a boolean")
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func kelingCompatWatermarkEnabled(value any) (bool, *kelingCompatError) {
|
||||
if value == nil {
|
||||
return false, nil
|
||||
}
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false, newKelingCompatError(http.StatusBadRequest, 1201, "watermark_info must be an object")
|
||||
}
|
||||
enabled, present := object["enabled"]
|
||||
if !present {
|
||||
return false, nil
|
||||
}
|
||||
result, ok := enabled.(bool)
|
||||
if !ok {
|
||||
return false, newKelingCompatError(http.StatusBadRequest, 1201, "watermark_info.enabled must be a boolean")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func kelingCompatInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case int64:
|
||||
return int(typed), true
|
||||
case float64:
|
||||
if math.Abs(typed-math.Round(typed)) > 1e-9 {
|
||||
return 0, false
|
||||
}
|
||||
return int(math.Round(typed)), true
|
||||
case json.Number:
|
||||
parsed, err := strconv.Atoi(typed.String())
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func stringFromKelingCompat(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
if math.Abs(typed-math.Round(typed)) < 1e-9 {
|
||||
return strconv.FormatInt(int64(math.Round(typed)), 10)
|
||||
}
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatTaskData(task store.GatewayTask) map[string]any {
|
||||
data := map[string]any{
|
||||
"task_id": task.ID,
|
||||
"task_status": kelingCompatTaskStatus(task.Status),
|
||||
"task_info": map[string]any{
|
||||
"external_task_id": strings.TrimSpace(stringFromKelingCompat(task.Request["external_task_id"])),
|
||||
},
|
||||
"created_at": task.CreatedAt.UnixMilli(),
|
||||
"updated_at": task.UpdatedAt.UnixMilli(),
|
||||
"watermark_info": map[string]any{
|
||||
"enabled": kelingCompatTaskWatermark(task.Request),
|
||||
},
|
||||
}
|
||||
if message := kelingCompatTaskMessage(task); message != "" {
|
||||
data["task_status_msg"] = message
|
||||
}
|
||||
if kelingCompatTaskStatus(task.Status) == "failed" {
|
||||
data["task_status_code"] = kelingCompatBusinessCode(task.ErrorCode, kelingCompatTaskMessage(task))
|
||||
}
|
||||
if videos := kelingCompatTaskVideos(task.Result); len(videos) > 0 {
|
||||
data["task_result"] = map[string]any{"videos": videos}
|
||||
}
|
||||
if task.FinalChargeAmount > 0 {
|
||||
data["final_unit_deduction"] = strconv.FormatFloat(task.FinalChargeAmount, 'f', -1, 64)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func kelingCompatTaskStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "succeeded", "success", "completed":
|
||||
return "succeed"
|
||||
case "failed", "cancelled", "canceled":
|
||||
return "failed"
|
||||
case "running", "processing":
|
||||
return "processing"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatTaskVideos(result map[string]any) []any {
|
||||
raw, _ := result["data"].([]any)
|
||||
out := make([]any, 0, len(raw))
|
||||
for _, itemValue := range raw {
|
||||
item, _ := itemValue.(map[string]any)
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
url := strings.TrimSpace(stringFromKelingCompat(firstKelingCompatValue(item["url"], item["video_url"])))
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
video := map[string]any{"url": url}
|
||||
if id := strings.TrimSpace(stringFromKelingCompat(item["id"])); id != "" {
|
||||
video["id"] = id
|
||||
}
|
||||
if watermarkURL := strings.TrimSpace(stringFromKelingCompat(item["watermark_url"])); watermarkURL != "" {
|
||||
video["watermark_url"] = watermarkURL
|
||||
}
|
||||
if duration := strings.TrimSpace(stringFromKelingCompat(item["duration"])); duration != "" {
|
||||
video["duration"] = duration
|
||||
}
|
||||
out = append(out, video)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstKelingCompatValue(values ...any) any {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(stringFromKelingCompat(value)) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kelingCompatTaskMessage(task store.GatewayTask) string {
|
||||
return strings.TrimSpace(firstNonEmpty(task.ErrorMessage, task.Error, task.Message))
|
||||
}
|
||||
|
||||
func kelingCompatTaskWatermark(request map[string]any) bool {
|
||||
if value, ok := request["watermark"].(bool); ok {
|
||||
return value
|
||||
}
|
||||
if info, ok := request["watermark_info"].(map[string]any); ok {
|
||||
value, _ := info["enabled"].(bool)
|
||||
return value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kelingCompatCandidatesSupport4K(candidates []store.RuntimeModelCandidate) bool {
|
||||
for _, candidate := range candidates {
|
||||
if strings.ToLower(strings.TrimSpace(candidate.Provider)) != "keling" {
|
||||
continue
|
||||
}
|
||||
capability, _ := candidate.Capabilities["omni_video"].(map[string]any)
|
||||
if capability == nil {
|
||||
capability, _ = candidate.Capabilities["omni"].(map[string]any)
|
||||
}
|
||||
for _, resolution := range kelingCompatStringList(capability["output_resolutions"]) {
|
||||
switch strings.ToLower(strings.TrimSpace(resolution)) {
|
||||
case "2160p", "4k":
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kelingCompatStringList(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return typed
|
||||
case []any:
|
||||
result := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if text := strings.TrimSpace(stringFromKelingCompat(item)); text != "" {
|
||||
result = append(result, text)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func kelingCompatGatewayError(err error) *kelingCompatError {
|
||||
if err == nil {
|
||||
return newKelingCompatError(http.StatusInternalServerError, 5000, "unknown gateway error")
|
||||
}
|
||||
codeText := clients.ErrorCode(err)
|
||||
businessCode := kelingCompatBusinessCode(codeText, err.Error())
|
||||
status := http.StatusInternalServerError
|
||||
switch businessCode {
|
||||
case 1101:
|
||||
status = http.StatusPaymentRequired
|
||||
case 1103:
|
||||
status = http.StatusForbidden
|
||||
case 1201:
|
||||
status = http.StatusBadRequest
|
||||
case 1203:
|
||||
status = http.StatusNotFound
|
||||
case 1302, 1303:
|
||||
status = http.StatusTooManyRequests
|
||||
case 5001:
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
return newKelingCompatError(status, businessCode, err.Error())
|
||||
}
|
||||
|
||||
func kelingCompatBusinessCode(errorCode string, message string) int {
|
||||
combined := strings.ToLower(strings.TrimSpace(errorCode + " " + message))
|
||||
containsAny := func(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if strings.Contains(combined, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case containsAny("insufficient_balance", "insufficient balance", "balance_not_enough", "wallet balance", "余额不足", "欠费", "quota exceeded"):
|
||||
return 1101
|
||||
case containsAny("permission_denied", "permission denied", "forbidden", "access denied", "scope does not allow"):
|
||||
return 1103
|
||||
case containsAny("concurrent", "concurrency"):
|
||||
return 1303
|
||||
case containsAny("rate_limit", "rate limit", "too many requests", "rpm", "tpm"):
|
||||
return 1302
|
||||
case containsAny("no_model_candidate", "no model candidate", "model_not_found", "unsupported model", "resource not found"):
|
||||
return 1203
|
||||
case containsAny("invalid_parameter", "invalid parameter", "bad_request", "parameter_preprocessing", "duration", "aspect_ratio"):
|
||||
return 1201
|
||||
case containsAny("upload_", "request_asset_", "network", "timeout", "upstream", "service unavailable", "bad gateway"):
|
||||
return 5001
|
||||
default:
|
||||
return 5000
|
||||
}
|
||||
}
|
||||
|
||||
func isKelingCompatTask(task store.GatewayTask) bool {
|
||||
return task.Kind == "videos.generations" && strings.TrimSpace(stringFromKelingCompat(task.Request["_gateway_compatibility"])) == kelingOmniCompatibilityMarker
|
||||
}
|
||||
|
||||
func kelingCompatTaskOwnedBy(task store.GatewayTask, user *auth.User) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
taskOwner := strings.TrimSpace(firstNonEmpty(task.GatewayUserID, task.UserID))
|
||||
requestOwner := strings.TrimSpace(firstNonEmpty(user.GatewayUserID, user.ID))
|
||||
return taskOwner != "" && requestOwner != "" && taskOwner == requestOwner
|
||||
}
|
||||
|
||||
func newKelingCompatRequestID(r *http.Request) string {
|
||||
if r != nil {
|
||||
if value := strings.TrimSpace(firstNonEmpty(r.Header.Get("X-Request-ID"), r.Header.Get("X-Request-Id"))); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
random := make([]byte, 16)
|
||||
if _, err := rand.Read(random); err == nil {
|
||||
return hex.EncodeToString(random)
|
||||
}
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
|
||||
func kelingCompatRequestID(r *http.Request) string {
|
||||
if r != nil {
|
||||
if value, ok := r.Context().Value(kelingCompatRequestIDKey{}).(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return newKelingCompatRequestID(r)
|
||||
}
|
||||
|
||||
func writeKelingCompatError(w http.ResponseWriter, requestID string, err *kelingCompatError) {
|
||||
if err == nil {
|
||||
err = newKelingCompatError(http.StatusInternalServerError, 5000, "internal error")
|
||||
}
|
||||
if err.RequestID != "" {
|
||||
requestID = err.RequestID
|
||||
}
|
||||
if requestID == "" {
|
||||
requestID = newKelingCompatRequestID(nil)
|
||||
}
|
||||
status := err.HTTPStatus
|
||||
if status == 0 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
writeJSON(w, status, KelingCompatibleEnvelope{
|
||||
Code: err.Code,
|
||||
Message: err.Message,
|
||||
RequestID: requestID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestNormalizeKelingOmniRequestMapsOfficialFields(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "A rainy street with natural ambience",
|
||||
"mode": "pro",
|
||||
"sound": "on",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": "5",
|
||||
"external_task_id": "external-1",
|
||||
"watermark_info": map[string]any{"enabled": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize Kling request: %v", err)
|
||||
}
|
||||
if normalized["model"] != "kling-3.0-omni" ||
|
||||
normalized["modelType"] != "omni_video" ||
|
||||
normalized["resolution"] != "1080p" ||
|
||||
normalized["aspect_ratio"] != "9:16" ||
|
||||
normalized["duration"] != 5 ||
|
||||
normalized["audio"] != true ||
|
||||
normalized["sound"] != "on" ||
|
||||
normalized["watermark"] != true ||
|
||||
normalized["external_task_id"] != "external-1" ||
|
||||
normalized["_gateway_compatibility"] != kelingOmniCompatibilityMarker {
|
||||
t.Fatalf("unexpected normalized request: %+v", normalized)
|
||||
}
|
||||
content, _ := normalized["content"].([]any)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("unexpected content: %+v", normalized["content"])
|
||||
}
|
||||
text, _ := content[0].(map[string]any)
|
||||
if text["type"] != "text" || text["text"] != "A rainy street with natural ambience" {
|
||||
t.Fatalf("unexpected text content: %+v", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingOmniRequestBuildsMultiShotMedia(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-3.0-omni",
|
||||
"multi_shot": true,
|
||||
"shot_type": "customize",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 5,
|
||||
"multi_prompt": []any{
|
||||
map[string]any{"index": 1, "prompt": "First shot", "duration": "2"},
|
||||
map[string]any{"index": 2, "prompt": "Second shot", "duration": 3},
|
||||
},
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://example.com/reference.png"},
|
||||
},
|
||||
"element_list": []any{
|
||||
map[string]any{"element_id": float64(123)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize multi-shot request: %v", err)
|
||||
}
|
||||
if normalized["model"] != "kling-3.0-omni" || normalized["duration"] != 5 || normalized["multi_shot"] != true || normalized["shot_type"] != "customize" {
|
||||
t.Fatalf("unexpected multi-shot fields: %+v", normalized)
|
||||
}
|
||||
content, _ := normalized["content"].([]any)
|
||||
if len(content) != 4 {
|
||||
t.Fatalf("expected image, element and two shot prompts, got %+v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingOmniRequestRejectsUnsupportedCombinations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{
|
||||
name: "callback",
|
||||
body: map[string]any{"callback_url": "https://example.com/callback"},
|
||||
},
|
||||
{
|
||||
name: "unknown model",
|
||||
body: map[string]any{"model_name": "kling-unknown", "prompt": "x", "aspect_ratio": "16:9"},
|
||||
},
|
||||
{
|
||||
name: "o1 duration",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "aspect_ratio": "16:9", "duration": 11},
|
||||
},
|
||||
{
|
||||
name: "o1 text to video three seconds",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "aspect_ratio": "16:9", "duration": 3},
|
||||
},
|
||||
{
|
||||
name: "o1 generated audio",
|
||||
body: map[string]any{"model_name": "kling-video-o1", "prompt": "x", "sound": "on", "aspect_ratio": "16:9", "duration": 5},
|
||||
},
|
||||
{
|
||||
name: "video sound",
|
||||
body: map[string]any{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "edit",
|
||||
"sound": "on",
|
||||
"video_list": []any{map[string]any{"video_url": "https://example.com/base.mp4", "refer_type": "base"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "first frame ratio",
|
||||
body: map[string]any{
|
||||
"prompt": "animate",
|
||||
"aspect_ratio": "16:9",
|
||||
"image_list": []any{map[string]any{"image_url": "https://example.com/first.png", "type": "first_frame"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, item := range tests {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
_, err := normalizeKelingOmniRequest(item.body)
|
||||
if err == nil || err.Code != 1201 && err.Code != 1203 {
|
||||
t.Fatalf("expected official compatibility error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeKelingO1AllowsThreeSecondsWithReferenceImage(t *testing.T) {
|
||||
normalized, err := normalizeKelingOmniRequest(map[string]any{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "Use the landscape as a visual reference",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 3,
|
||||
"image_list": []any{
|
||||
map[string]any{"image_url": "https://placehold.co/1024x1024/png"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reference-image O1 request should allow three seconds: %v", err)
|
||||
}
|
||||
if normalized["duration"] != 3 {
|
||||
t.Fatalf("unexpected duration: %+v", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatTaskDataAndOwnership(t *testing.T) {
|
||||
createdAt := time.Unix(100, 0)
|
||||
task := store.GatewayTask{
|
||||
ID: "task-1",
|
||||
Kind: "videos.generations",
|
||||
GatewayUserID: "user-1",
|
||||
Status: "succeeded",
|
||||
FinalChargeAmount: 2.5,
|
||||
Request: map[string]any{
|
||||
"_gateway_compatibility": kelingOmniCompatibilityMarker,
|
||||
"external_task_id": "external-1",
|
||||
"watermark": true,
|
||||
},
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"id": "video-1",
|
||||
"url": "https://example.com/video.mp4",
|
||||
"watermark_url": "https://example.com/watermarked.mp4",
|
||||
"duration": "5",
|
||||
}}},
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(time.Second),
|
||||
}
|
||||
if !isKelingCompatTask(task) || !kelingCompatTaskOwnedBy(task, &auth.User{GatewayUserID: "user-1"}) {
|
||||
t.Fatalf("expected task ownership and compatibility marker")
|
||||
}
|
||||
if kelingCompatTaskOwnedBy(task, &auth.User{GatewayUserID: "user-2"}) {
|
||||
t.Fatalf("cross-user task access must be rejected")
|
||||
}
|
||||
data := kelingCompatTaskData(task)
|
||||
if data["task_status"] != "succeed" || data["final_unit_deduction"] != "2.5" {
|
||||
t.Fatalf("unexpected task data: %+v", data)
|
||||
}
|
||||
result, _ := data["task_result"].(map[string]any)
|
||||
videos, _ := result["videos"].([]any)
|
||||
video, _ := videos[0].(map[string]any)
|
||||
if video["id"] != "video-1" || video["watermark_url"] != "https://example.com/watermarked.mp4" || video["duration"] != "5" {
|
||||
t.Fatalf("unexpected compatible video: %+v", video)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireKelingAPIKeyWritesOfficialAuthEnvelope(t *testing.T) {
|
||||
server := &Server{auth: auth.New("secret", "", "")}
|
||||
server.auth.LocalAPIKeyVerifier = func(_ context.Context, key string) (*auth.User, error) {
|
||||
if key != "sk-gw-valid" {
|
||||
return nil, auth.ErrUnauthorized
|
||||
}
|
||||
return &auth.User{ID: "user-1", GatewayUserID: "user-1", APIKeyID: "key-1", APIKeyScopes: []string{"video"}}, nil
|
||||
}
|
||||
handler := server.requireKelingAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := auth.UserFromContext(r.Context()); !ok {
|
||||
t.Fatal("authenticated user is missing")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
missing := httptest.NewRecorder()
|
||||
handler.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil))
|
||||
if missing.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing auth status=%d body=%s", missing.Code, missing.Body.String())
|
||||
}
|
||||
var missingBody KelingCompatibleEnvelope
|
||||
if err := json.Unmarshal(missing.Body.Bytes(), &missingBody); err != nil || missingBody.Code != 1001 || missingBody.RequestID == "" {
|
||||
t.Fatalf("unexpected missing auth envelope: %+v err=%v", missingBody, err)
|
||||
}
|
||||
|
||||
invalid := httptest.NewRecorder()
|
||||
invalidRequest := httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil)
|
||||
invalidRequest.Header.Set("Authorization", "Bearer sk-gw-invalid")
|
||||
handler.ServeHTTP(invalid, invalidRequest)
|
||||
var invalidBody KelingCompatibleEnvelope
|
||||
if err := json.Unmarshal(invalid.Body.Bytes(), &invalidBody); err != nil || invalid.Code != http.StatusUnauthorized || invalidBody.Code != 1002 {
|
||||
t.Fatalf("unexpected invalid auth envelope: status=%d body=%+v err=%v", invalid.Code, invalidBody, err)
|
||||
}
|
||||
|
||||
valid := httptest.NewRecorder()
|
||||
validRequest := httptest.NewRequest(http.MethodPost, "/v1/videos/omni-video", nil)
|
||||
validRequest.Header.Set("Authorization", "Bearer sk-gw-valid")
|
||||
handler.ServeHTTP(valid, validRequest)
|
||||
if valid.Code != http.StatusNoContent {
|
||||
t.Fatalf("valid API Key status=%d body=%s", valid.Code, valid.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatErrorImplementsError(t *testing.T) {
|
||||
err := newKelingCompatError(http.StatusBadRequest, 1201, "invalid")
|
||||
if !errors.Is(err, err) || err.Error() != "invalid" {
|
||||
t.Fatalf("unexpected error behavior: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatBusinessCodeMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
code string
|
||||
message string
|
||||
want int
|
||||
}{
|
||||
{code: "insufficient_balance", want: 1101},
|
||||
{code: "permission_denied", want: 1103},
|
||||
{code: "invalid_parameter", want: 1201},
|
||||
{code: "no_model_candidate", want: 1203},
|
||||
{code: "rate_limit_exceeded", want: 1302},
|
||||
{code: "concurrency_limit", want: 1303},
|
||||
{code: "network", want: 5001},
|
||||
{code: "unknown", want: 5000},
|
||||
}
|
||||
for _, item := range tests {
|
||||
if got := kelingCompatBusinessCode(item.code, item.message); got != item.want {
|
||||
t.Fatalf("code=%q message=%q got=%d want=%d", item.code, item.message, got, item.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKelingCompatCandidatesSupport4K(t *testing.T) {
|
||||
candidates := []store.RuntimeModelCandidate{
|
||||
{Provider: "keling", Capabilities: map[string]any{"omni_video": map[string]any{"output_resolutions": []any{"720p", "1080p"}}}},
|
||||
{Provider: "keling", Capabilities: map[string]any{"omni_video": map[string]any{"output_resolutions": []any{"2160p"}}}},
|
||||
}
|
||||
if !kelingCompatCandidatesSupport4K(candidates) {
|
||||
t.Fatal("expected 2160p Keling capability to enable mode=4k")
|
||||
}
|
||||
if kelingCompatCandidatesSupport4K(candidates[:1]) {
|
||||
t.Fatal("mode=4k must stay disabled without an explicit capability")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling-compatible HTTP integration flow")
|
||||
}
|
||||
|
||||
var upstreamTaskSequence atomic.Int64
|
||||
var upstreamPayloadMu sync.Mutex
|
||||
var upstreamPayloads []map[string]any
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-keling-key" {
|
||||
t.Fatalf("unexpected upstream Authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/videos/omni-video":
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode upstream request: %v", err)
|
||||
}
|
||||
upstreamPayloadMu.Lock()
|
||||
upstreamPayloads = append(upstreamPayloads, payload)
|
||||
upstreamPayloadMu.Unlock()
|
||||
id := "upstream-" + strconv.FormatInt(upstreamTaskSequence.Add(1), 10)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "submit-" + id,
|
||||
"data": map[string]any{"task_id": id, "task_status": "submitted"},
|
||||
})
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/videos/omni-video/upstream-"):
|
||||
id := strings.TrimPrefix(r.URL.Path, "/videos/omni-video/")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0,
|
||||
"request_id": "poll-" + id,
|
||||
"data": map[string]any{
|
||||
"task_id": id,
|
||||
"task_status": "succeed",
|
||||
"created_at": time.Now().UnixMilli(),
|
||||
"task_result": map[string]any{"videos": []any{map[string]any{
|
||||
"id": "video-" + id,
|
||||
"url": "https://example.com/" + id + ".mp4",
|
||||
"watermark_url": "https://example.com/" + id + "-watermark.mp4",
|
||||
"duration": "3",
|
||||
}}},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected upstream request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
platform, err := db.CreatePlatform(ctx, store.CreatePlatformInput{
|
||||
Provider: "keling",
|
||||
PlatformKey: "keling-compatible-test-" + suffix,
|
||||
Name: "Kling Compatible Test",
|
||||
BaseURL: upstream.URL,
|
||||
AuthType: "APIKey",
|
||||
Credentials: map[string]any{"apiKey": "upstream-keling-key"},
|
||||
Config: map[string]any{
|
||||
"kelingPollIntervalMs": 100,
|
||||
"kelingPollTimeoutSeconds": 5,
|
||||
},
|
||||
Priority: 1,
|
||||
Status: "enabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create test platform: %v", err)
|
||||
}
|
||||
_, err = db.CreatePlatformModel(ctx, store.CreatePlatformModelInput{
|
||||
PlatformID: platform.ID,
|
||||
CanonicalModelKey: "keling:kling-video-o1",
|
||||
ModelName: "kling-o1",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
ModelAlias: "kling-o1",
|
||||
ModelType: store.StringList{"omni_video", "video_generate"},
|
||||
DisplayName: "Kling O1 Compatible Test",
|
||||
Capabilities: map[string]any{
|
||||
"omni_video": map[string]any{
|
||||
"supported_modes": []any{"text_to_video", "image_reference"},
|
||||
"output_resolutions": []any{"720p", "1080p"},
|
||||
"aspect_ratio_allowed": []any{"16:9", "9:16", "1:1"},
|
||||
"duration_options": []any{3, 4, 5, 6, 7, 8, 9, 10},
|
||||
"output_audio": false,
|
||||
"max_images": 7,
|
||||
},
|
||||
"video_generate": map[string]any{
|
||||
"supported_modes": []any{"text_to_video"},
|
||||
"output_resolutions": []any{"720p", "1080p"},
|
||||
"aspect_ratio_allowed": []any{"16:9", "9:16", "1:1"},
|
||||
"duration_options": []any{3, 4, 5, 6, 7, 8, 9, 10},
|
||||
"output_audio": true,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create test platform model: %v", err)
|
||||
}
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
gateway := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer gateway.Close()
|
||||
|
||||
firstUserToken, firstAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "first", suffix, true)
|
||||
_ = firstUserToken
|
||||
_, secondAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "second", suffix, false)
|
||||
|
||||
var created KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodPost, "/api/v1/videos/omni-video", firstAPIKey, map[string]any{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "A clean product reveal",
|
||||
"mode": "std",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": "3",
|
||||
"sound": "off",
|
||||
"image_list": []any{map[string]any{"image_url": "https://example.com/reference.png"}},
|
||||
"external_task_id": "compat-http-1",
|
||||
}, http.StatusOK, &created)
|
||||
createdData, _ := created.Data.(map[string]any)
|
||||
if created.Code != 0 || created.RequestID == "" || strings.TrimSpace(stringFromKelingCompat(createdData["task_id"])) == "" || createdData["task_status"] != "submitted" {
|
||||
t.Fatalf("unexpected compatible create response: %+v", created)
|
||||
}
|
||||
taskID := stringFromKelingCompat(createdData["task_id"])
|
||||
|
||||
var hidden KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/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)
|
||||
}
|
||||
|
||||
completed := waitForKelingCompatTask(t, gateway.URL, firstAPIKey, taskID, 5*time.Second)
|
||||
if completed.Code != 0 {
|
||||
t.Fatalf("compatible task failed: %+v", completed)
|
||||
}
|
||||
completedData, _ := completed.Data.(map[string]any)
|
||||
if completedData["task_status"] != "succeed" {
|
||||
t.Fatalf("compatible task did not succeed: %+v", completedData)
|
||||
}
|
||||
taskResult, _ := completedData["task_result"].(map[string]any)
|
||||
videos, _ := taskResult["videos"].([]any)
|
||||
video, _ := videos[0].(map[string]any)
|
||||
if video["id"] == "" || video["watermark_url"] == "" || video["duration"] != "3" {
|
||||
t.Fatalf("compatible result lost video metadata: %+v", video)
|
||||
}
|
||||
|
||||
var standard struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
doJSONWithHeaders(t, gateway.URL, http.MethodPost, "/api/v1/videos/generations", firstAPIKey, map[string]any{
|
||||
"model": "kling-o1",
|
||||
"prompt": "A second product reveal",
|
||||
"resolution": "720p",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 3,
|
||||
"audio": false,
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &standard)
|
||||
if standard.TaskID == "" {
|
||||
t.Fatal("standard video generation did not return taskId")
|
||||
}
|
||||
waitForTaskStatus(t, gateway.URL, firstAPIKey, standard.TaskID, []string{"succeeded"}, 5*time.Second)
|
||||
|
||||
var unsupportedAudio struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
doJSONWithHeaders(t, gateway.URL, http.MethodPost, "/api/v1/videos/generations", firstAPIKey, map[string]any{
|
||||
"model": "kling-o1",
|
||||
"prompt": "An O1 request that must not silently ignore audio",
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true,
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &unsupportedAudio)
|
||||
if unsupportedAudio.TaskID == "" {
|
||||
t.Fatal("unsupported O1 audio request did not return taskId")
|
||||
}
|
||||
waitForTaskStatus(t, gateway.URL, firstAPIKey, unsupportedAudio.TaskID, []string{"failed"}, 5*time.Second)
|
||||
var failedAudioTask store.GatewayTask
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/api/v1/tasks/"+unsupportedAudio.TaskID, firstAPIKey, nil, http.StatusOK, &failedAudioTask)
|
||||
if failedAudioTask.ErrorCode != "invalid_parameter" || !strings.Contains(failedAudioTask.ErrorMessage, "does not support generated audio") {
|
||||
t.Fatalf("O1 audio request must fail visibly before upstream submission: %+v", failedAudioTask)
|
||||
}
|
||||
|
||||
upstreamPayloadMu.Lock()
|
||||
defer upstreamPayloadMu.Unlock()
|
||||
if len(upstreamPayloads) != 2 {
|
||||
t.Fatalf("expected two upstream submissions, got %d", len(upstreamPayloads))
|
||||
}
|
||||
compatiblePayload := upstreamPayloads[0]
|
||||
if compatiblePayload["model_name"] != "kling-video-o1" || compatiblePayload["mode"] != "std" || compatiblePayload["sound"] != "off" || compatiblePayload["duration"] != "3" || compatiblePayload["aspect_ratio"] != "16:9" || compatiblePayload["external_task_id"] != "compat-http-1" {
|
||||
t.Fatalf("unexpected compatible upstream payload: %+v", compatiblePayload)
|
||||
}
|
||||
}
|
||||
|
||||
func createKelingCompatIntegrationUser(t *testing.T, ctx context.Context, db *store.Store, baseURL string, prefix string, suffix string, fund bool) (string, string) {
|
||||
t.Helper()
|
||||
username := fmt.Sprintf("kling_compat_%s_%s", prefix, suffix)
|
||||
password := "password123"
|
||||
var registered struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®istered)
|
||||
var apiKey struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/api-keys", registered.AccessToken, map[string]any{
|
||||
"name": "Kling compatible integration key",
|
||||
"scopes": []string{"video"},
|
||||
}, http.StatusCreated, &apiKey)
|
||||
if fund {
|
||||
if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
|
||||
t.Fatalf("promote integration user: %v", err)
|
||||
}
|
||||
var loggedIn struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username,
|
||||
"password": password,
|
||||
}, http.StatusOK, &loggedIn)
|
||||
var gatewayUserID string
|
||||
if err := db.Pool().QueryRow(ctx, `SELECT id::text FROM gateway_users WHERE username = $1`, username).Scan(&gatewayUserID); err != nil {
|
||||
t.Fatalf("read integration user id: %v", err)
|
||||
}
|
||||
doJSON(t, baseURL, http.MethodPatch, "/api/admin/users/"+gatewayUserID+"/wallet", loggedIn.AccessToken, map[string]any{
|
||||
"currency": "resource",
|
||||
"balance": 1000,
|
||||
"reason": "seed Kling compatible integration wallet",
|
||||
}, http.StatusOK, nil)
|
||||
}
|
||||
return registered.AccessToken, apiKey.Secret
|
||||
}
|
||||
|
||||
func waitForKelingCompatTask(t *testing.T, baseURL string, apiKey string, taskID string, timeout time.Duration) KelingCompatibleEnvelope {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
var response KelingCompatibleEnvelope
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response.Data.(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
return response
|
||||
case "failed":
|
||||
t.Fatalf("Kling-compatible task failed: %+v", response)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for Kling-compatible task %s", taskID)
|
||||
return KelingCompatibleEnvelope{}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,23 @@ func TestKlingCompatibilitySimulationFlow(t *testing.T) {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var upgradedBaseModels int
|
||||
if err := db.Pool().QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM base_model_catalog
|
||||
WHERE provider_key = 'keling'
|
||||
AND provider_model_name IN ('kling-video-o1', 'kling-v3-omni')
|
||||
AND model_type @> '["video_generate","image_to_video","omni_video"]'::jsonb
|
||||
AND capabilities ? 'video_generate'
|
||||
AND capabilities ? 'image_to_video'
|
||||
AND capabilities ? 'omni_video'
|
||||
AND metadata->'rawModel'->'types' @> '["video_generate","image_to_video","omni_video"]'::jsonb`).Scan(&upgradedBaseModels); err != nil {
|
||||
t.Fatalf("read upgraded Kling Omni base model capabilities: %v", err)
|
||||
}
|
||||
if upgradedBaseModels != 2 {
|
||||
t.Fatalf("expected both Kling Omni base models to expose base video capabilities, got %d", upgradedBaseModels)
|
||||
}
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
@@ -89,11 +106,66 @@ func TestKlingCompatibilitySimulationFlow(t *testing.T) {
|
||||
"displayName": model,
|
||||
}, http.StatusCreated, nil)
|
||||
}
|
||||
var upgradedPlatformModels int
|
||||
if err := db.Pool().QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM platform_models
|
||||
WHERE platform_id = $1::uuid
|
||||
AND model_type @> '["video_generate","image_to_video","omni_video"]'::jsonb
|
||||
AND capabilities ? 'video_generate'
|
||||
AND capabilities ? 'image_to_video'
|
||||
AND capabilities ? 'omni_video'`, platform.ID).Scan(&upgradedPlatformModels); err != nil {
|
||||
t.Fatalf("read upgraded Kling Omni platform model capabilities: %v", err)
|
||||
}
|
||||
if upgradedPlatformModels != 2 {
|
||||
t.Fatalf("expected both Kling Omni platform models to expose base video capabilities, got %d", upgradedPlatformModels)
|
||||
}
|
||||
|
||||
assertGenericVideoGeneration := func(name string, model string, image string, expectedModelType string) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := map[string]any{
|
||||
"model": model,
|
||||
"prompt": "通用视频接口模拟任务",
|
||||
"duration": 5,
|
||||
"resolution": "720p",
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}
|
||||
if image != "" {
|
||||
request["image"] = image
|
||||
}
|
||||
var response struct {
|
||||
Task struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ModelType string `json:"modelType"`
|
||||
ResolvedModel string `json:"resolvedModel"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, request, map[string]string{"X-Async": "true"}, http.StatusAccepted, &response)
|
||||
if response.Task.ID == "" {
|
||||
t.Fatal("async generic video response did not return a task id")
|
||||
}
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, response.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+response.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &response.Task)
|
||||
resolvedModel, resolved := klingV2ProviderModel(response.Task.ResolvedModel)
|
||||
if response.Task.Status != "succeeded" || response.Task.ModelType != expectedModelType || !resolved || resolvedModel != model {
|
||||
t.Fatalf("generic video request without modelType should use inferred capability: %+v", response.Task)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, model := range []string{klingO1Model, klingV3OmniModel} {
|
||||
assertGenericVideoGeneration(model+"-text-to-video", model, "", "video_generate")
|
||||
assertGenericVideoGeneration(model+"-image-to-video", model, "https://example.com/first.png", "image_to_video")
|
||||
}
|
||||
|
||||
createV1 := func(model string, duration int, externalID string) string {
|
||||
t.Helper()
|
||||
var response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": model,
|
||||
"prompt": "兼容接口模拟任务",
|
||||
"duration": duration,
|
||||
@@ -120,14 +192,14 @@ func TestKlingCompatibilitySimulationFlow(t *testing.T) {
|
||||
waitKlingV1SimulationTask(t, server.URL, apiKeyResponse.Secret, taskID)
|
||||
}
|
||||
var listResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodGet, "/kling/v1/videos/omni-video?pageNum=1&pageSize=10", apiKeyResponse.Secret, nil, http.StatusOK, &listResponse)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/kling/v1/videos/omni-video?pageNum=1&pageSize=10", apiKeyResponse.Secret, nil, http.StatusOK, &listResponse)
|
||||
items, _ := listResponse["data"].([]any)
|
||||
if len(items) < 2 {
|
||||
t.Fatalf("V1 task list did not return compatibility tasks: %#v", listResponse)
|
||||
}
|
||||
|
||||
var duplicateResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": klingO1Model, "prompt": "duplicate", "duration": 5,
|
||||
"external_task_id": "compat-o1-" + suffix,
|
||||
"runMode": "simulation", "simulation": true,
|
||||
@@ -137,7 +209,7 @@ func TestKlingCompatibilitySimulationFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
var v2Response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
|
||||
"contents": []any{map[string]any{"type": "prompt", "text": "API 2.0 模拟任务"}},
|
||||
"settings": map[string]any{"duration": 3, "resolution": "720p", "aspect_ratio": "16:9", "audio": "off"},
|
||||
"options": map[string]any{"external_task_id": "compat-v2-" + suffix},
|
||||
@@ -158,7 +230,7 @@ func waitKlingV1SimulationTask(t *testing.T, baseURL string, apiKey string, task
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response["data"].(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
@@ -176,7 +248,7 @@ func waitKlingV2SimulationTask(t *testing.T, baseURL string, apiKey string, task
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v2/tasks?task_ids="+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/kling/v2/tasks?task_ids="+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
items, _ := response["data"].([]any)
|
||||
if len(items) == 1 {
|
||||
data, _ := items[0].(map[string]any)
|
||||
|
||||
@@ -7,46 +7,57 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) platformModelResponse(ctx context.Context, model store.PlatformModel) store.PlatformModel {
|
||||
return s.platformModelResponseWithRuleSets(model, s.responsePricingRuleSetConfigs(ctx, []store.PlatformModel{model}))
|
||||
}
|
||||
|
||||
func (s *Server) platformModelResponseWithRuleSets(model store.PlatformModel, ruleSetConfigs map[string]map[string]any) store.PlatformModel {
|
||||
model.Capabilities = store.EffectivePlatformModelCapabilities(model.BaseCapabilities, model.Capabilities)
|
||||
model.Capabilities = enrichResponseCapabilities(model)
|
||||
model = s.withEffectiveResponseBillingConfig(ctx, model)
|
||||
model = withEffectiveResponseBillingConfig(model, ruleSetConfigs)
|
||||
return store.FilterPlatformModelBillingConfig(model)
|
||||
}
|
||||
|
||||
func (s *Server) platformModelResponses(ctx context.Context, models []store.PlatformModel) []store.PlatformModel {
|
||||
ruleSetConfigs := s.responsePricingRuleSetConfigs(ctx, models)
|
||||
items := make([]store.PlatformModel, len(models))
|
||||
for i, model := range models {
|
||||
items[i] = s.platformModelResponse(ctx, model)
|
||||
items[i] = s.platformModelResponseWithRuleSets(model, ruleSetConfigs)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *Server) withEffectiveResponseBillingConfig(ctx context.Context, model store.PlatformModel) store.PlatformModel {
|
||||
config := model.BillingConfig
|
||||
if model.PricingRuleSetID != "" {
|
||||
if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, model.PricingRuleSetID); err == nil && len(ruleSetConfig) > 0 {
|
||||
config = ruleSetConfig
|
||||
func (s *Server) responsePricingRuleSetConfigs(ctx context.Context, models []store.PlatformModel) map[string]map[string]any {
|
||||
configs := map[string]map[string]any{}
|
||||
if s.store == nil {
|
||||
return configs
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, model := range models {
|
||||
for _, id := range []string{firstNonEmpty(model.BasePricingRuleSetID, model.PlatformPricingRuleSetID), model.PricingRuleSetID} {
|
||||
if id != "" {
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(model.BillingConfigOverride) > 0 {
|
||||
config = mergeResponseBillingConfig(config, model.BillingConfigOverride)
|
||||
for id := range ids {
|
||||
if config, err := s.store.PricingRuleSetBillingConfig(ctx, id); err == nil && len(config) > 0 {
|
||||
configs[id] = config
|
||||
}
|
||||
}
|
||||
model.BillingConfig = config
|
||||
return model
|
||||
return configs
|
||||
}
|
||||
|
||||
func mergeResponseBillingConfig(base map[string]any, override map[string]any) map[string]any {
|
||||
if len(base) == 0 && len(override) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(base)+len(override))
|
||||
for key, value := range base {
|
||||
out[key] = value
|
||||
}
|
||||
for key, value := range override {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
func withEffectiveResponseBillingConfig(model store.PlatformModel, ruleSetConfigs map[string]map[string]any) store.PlatformModel {
|
||||
inheritedRuleSetConfig := ruleSetConfigs[firstNonEmpty(model.BasePricingRuleSetID, model.PlatformPricingRuleSetID)]
|
||||
modelRuleSetConfig := ruleSetConfigs[model.PricingRuleSetID]
|
||||
model.BillingConfig = store.ResolveEffectiveBillingConfig(store.EffectiveBillingConfigInput{
|
||||
BaseConfig: model.BaseBillingConfig,
|
||||
LegacyPlatformModelConfig: model.BillingConfig,
|
||||
InheritedRuleSetConfig: inheritedRuleSetConfig,
|
||||
ModelRuleSetConfig: modelRuleSetConfig,
|
||||
Override: model.BillingConfigOverride,
|
||||
})
|
||||
return model
|
||||
}
|
||||
|
||||
func enrichResponseCapabilities(model store.PlatformModel) map[string]any {
|
||||
|
||||
@@ -173,6 +173,41 @@ func TestPlatformModelResponsePreservesTextGenerateFieldsOverFallbacks(t *testin
|
||||
assertStringListValue(t, textGenerate["thinkingEffortLevels"], []string{"minimal", "low", "medium"})
|
||||
}
|
||||
|
||||
func TestPlatformModelResponseUsesBaseBillingConfigWithoutMaterializedSnapshot(t *testing.T) {
|
||||
model := store.PlatformModel{
|
||||
ModelName: "base-priced-model",
|
||||
ModelType: store.StringList{"video_generate"},
|
||||
BaseBillingConfig: map[string]any{
|
||||
"video": map[string]any{"basePrice": float64(416)},
|
||||
},
|
||||
}
|
||||
|
||||
response := (&Server{}).platformModelResponse(context.Background(), model)
|
||||
video, ok := response.BillingConfig["video"].(map[string]any)
|
||||
if !ok || video["basePrice"] != float64(416) {
|
||||
t.Fatalf("expected base billing price 416, got %#v", response.BillingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveResponseBillingConfigPrefersBaseRuleOverLegacySnapshot(t *testing.T) {
|
||||
model := store.PlatformModel{
|
||||
BasePricingRuleSetID: "seedance-pricing",
|
||||
BillingConfig: map[string]any{
|
||||
"video": map[string]any{"basePrice": float64(100)},
|
||||
},
|
||||
}
|
||||
response := withEffectiveResponseBillingConfig(model, map[string]map[string]any{
|
||||
"seedance-pricing": {
|
||||
"video": map[string]any{"basePrice": float64(416)},
|
||||
},
|
||||
})
|
||||
|
||||
video, ok := response.BillingConfig["video"].(map[string]any)
|
||||
if !ok || video["basePrice"] != float64(416) {
|
||||
t.Fatalf("expected base rule price 416, got %#v", response.BillingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func textGenerateCapabilities(t *testing.T, model store.PlatformModel) map[string]any {
|
||||
t.Helper()
|
||||
capabilities, ok := model.Capabilities["text_generate"].(map[string]any)
|
||||
|
||||
@@ -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 {
|
||||
@@ -229,6 +229,9 @@ type TaskRequest struct {
|
||||
Size string `json:"size,omitempty" example:"1024x1024"`
|
||||
Duration int `json:"duration,omitempty" example:"5"`
|
||||
Resolution string `json:"resolution,omitempty" example:"720p"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"`
|
||||
Audio *bool `json:"audio,omitempty" example:"false"`
|
||||
Watermark *bool `json:"watermark,omitempty" example:"false"`
|
||||
MakeInstrumental bool `json:"makeInstrumental,omitempty" example:"false"`
|
||||
CustomMode bool `json:"customMode,omitempty" example:"false"`
|
||||
Style string `json:"style,omitempty" example:"city pop, bright synth"`
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const seedancePortraitAssetCategory = "seedance_portrait_asset"
|
||||
|
||||
// getSeedancePortraitAssetCapability godoc
|
||||
// @Summary 查询 Seedance 真人资产能力
|
||||
// @Description 返回当前网关是否已配置可创建、同步和引用的火山 Seedance 真人资产平台。
|
||||
// @Tags portrait-assets
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} runner.PortraitAssetCapability
|
||||
// @Router /api/v1/resource/material/seedance-portrait-assets/capability [get]
|
||||
func (s *Server) getSeedancePortraitAssetCapability(w http.ResponseWriter, r *http.Request) {
|
||||
capability, err := s.runner.PortraitAssetCapability(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("get portrait asset capability failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "get portrait asset capability failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, capability)
|
||||
}
|
||||
|
||||
// listSeedancePortraitAssets godoc
|
||||
// @Summary 列出 Seedance 真人资产
|
||||
// @Description 返回当前用户的真人资产;兼容 server-main material 列表响应字段。
|
||||
// @Tags portrait-assets
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/resource/material/user/materials [get]
|
||||
func (s *Server) listSeedancePortraitAssets(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if category := strings.TrimSpace(r.URL.Query().Get("category")); category != seedancePortraitAssetCategory {
|
||||
writeError(w, http.StatusNotFound, "material category not found")
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListPortraitAssets(r.Context(), user, store.PortraitAssetListFilter{
|
||||
Keyword: r.URL.Query().Get("keyword"),
|
||||
SourceType: firstNonEmptyQuery(r, "fileType", "sourceType"),
|
||||
Page: portraitAssetQueryInt(r, "pageNumber", "page"),
|
||||
PageSize: portraitAssetQueryInt(r, "pageSize"),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list portrait assets failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list portrait assets failed")
|
||||
return
|
||||
}
|
||||
responseItems := make([]any, 0, len(items.Items))
|
||||
for _, item := range items.Items {
|
||||
responseItems = append(responseItems, s.portraitAssetResponse(r, item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": responseItems,
|
||||
"total": items.Total,
|
||||
"page": items.Page,
|
||||
"pageSize": items.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// createSeedancePortraitAsset godoc
|
||||
// @Summary 上传并创建 Seedance 真人资产
|
||||
// @Description 文件先写入网关文件存储;仅在 private_avatar_eligible=true 时登记到火山 Assets。创建后会立即触发一次状态同步。
|
||||
// @Tags portrait-assets
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param file formData file true "真人资产源文件(图片、视频或音频)"
|
||||
// @Param data formData string true "material JSON,category 必须是 seedance_portrait_asset"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/resource/material [post]
|
||||
func (s *Server) createSeedancePortraitAsset(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
|
||||
if err := r.ParseMultipartForm(multipartTaskMemoryBytes); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form-data body")
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(r.FormValue("data"))), &data); err != nil || data == nil {
|
||||
writeError(w, http.StatusBadRequest, "data must be a JSON object")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(portraitAssetString(data["category"])) != seedancePortraitAssetCategory {
|
||||
writeError(w, http.StatusBadRequest, "category must be seedance_portrait_asset")
|
||||
return
|
||||
}
|
||||
privateEligible, _ := data["private_avatar_eligible"].(bool)
|
||||
if !privateEligible {
|
||||
writeError(w, http.StatusBadRequest, "private_avatar_eligible must be true after the user confirms authorization", "portrait_asset_authorization_required")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
payload, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "read portrait asset file failed")
|
||||
return
|
||||
}
|
||||
contentType := strings.TrimSpace(header.Header.Get("Content-Type"))
|
||||
if contentType == "" && len(payload) > 0 {
|
||||
contentType = http.DetectContentType(payload)
|
||||
}
|
||||
sourceType := strings.ToLower(strings.TrimSpace(firstNonEmpty(portraitAssetString(data["fileType"]), portraitAssetString(data["sourceType"]))))
|
||||
if !portraitAssetSourceMatchesContentType(sourceType, contentType) {
|
||||
writeError(w, http.StatusBadRequest, "fileType must be image, video, or audio and match the uploaded file", "portrait_asset_unsupported_type")
|
||||
return
|
||||
}
|
||||
upload, err := s.runner.UploadFile(r.Context(), runner.FileUploadPayload{
|
||||
Bytes: payload, ContentType: contentType, FileName: header.Filename, Source: "seedance-portrait-asset", Scene: store.FileStorageSceneUpload,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("upload portrait asset failed", "error", err)
|
||||
writeError(w, http.StatusBadGateway, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(portraitAssetString(upload["url"]))
|
||||
if url == "" {
|
||||
writeError(w, http.StatusBadGateway, "portrait asset upload returned no URL", "portrait_asset_source_url_required")
|
||||
return
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
asset, reused, err := s.runner.CreatePortraitAsset(r.Context(), user, runner.PortraitAssetCreateInput{
|
||||
Name: strings.TrimSpace(portraitAssetString(data["name"])),
|
||||
Description: strings.TrimSpace(portraitAssetString(data["description"])),
|
||||
SourceType: sourceType,
|
||||
URL: url,
|
||||
Preview: firstNonEmpty(portraitAssetString(data["preview"]), url),
|
||||
MimeType: contentType,
|
||||
ByteSize: int64(len(payload)),
|
||||
SourceSHA256: hex.EncodeToString(digest[:]),
|
||||
PrivateAvatarEligible: privateEligible,
|
||||
Metadata: map[string]any{
|
||||
"tags": data["tags"],
|
||||
"materialGroupId": data["material_group_id"],
|
||||
"uploadedFileName": header.Filename,
|
||||
"uploadAssetStorage": upload["assetStorage"],
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
writePortraitAssetError(w, err)
|
||||
return
|
||||
}
|
||||
_, _ = s.runner.SyncPortraitAssets(r.Context(), user, []string{asset.ID})
|
||||
asset, _, err = s.refreshPortraitAssetForResponse(r, user, asset.ID, asset)
|
||||
if err != nil {
|
||||
s.logger.Error("refresh portrait asset after create failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "refresh portrait asset failed")
|
||||
return
|
||||
}
|
||||
response := map[string]any{"asset": s.portraitAssetResponse(r, asset)}
|
||||
if reused {
|
||||
response["dedupe"] = map[string]any{"reused": true, "code": "PORTRAIT_ASSET_REUSED", "reason": "same_source", "message": "已复用相同源文件的真人资产,并触发状态刷新。"}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// syncSeedancePortraitAssets godoc
|
||||
// @Summary 同步 Seedance 真人资产状态
|
||||
// @Description 调用火山 CreateAsset/GetAsset;多次调用可把 Processing 状态刷新为 Active 或 Failed。
|
||||
// @Tags portrait-assets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} runner.PortraitAssetSyncResponse
|
||||
// @Router /api/v1/resource/material/seedance-portrait-assets/sync [post]
|
||||
func (s *Server) syncSeedancePortraitAssets(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if len(request.IDs) == 0 {
|
||||
writeJSON(w, http.StatusOK, runner.PortraitAssetSyncResponse{SyncedIDs: []string{}, Skipped: []runner.PortraitAssetIssue{}, Failed: []runner.PortraitAssetIssue{}, Assets: []store.PortraitAsset{}})
|
||||
return
|
||||
}
|
||||
response, err := s.runner.SyncPortraitAssets(r.Context(), user, request.IDs)
|
||||
if err != nil {
|
||||
s.logger.Error("sync portrait assets failed", "error", err)
|
||||
writePortraitAssetError(w, err)
|
||||
return
|
||||
}
|
||||
assets := make([]any, 0, len(response.Assets))
|
||||
for _, asset := range response.Assets {
|
||||
assets = append(assets, s.portraitAssetResponse(r, asset))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"requested": response.Requested, "accepted": response.Accepted, "syncedIds": response.SyncedIDs,
|
||||
"skipped": response.Skipped, "failed": response.Failed, "assets": assets,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) refreshPortraitAssetForResponse(r *http.Request, user *auth.User, assetID string, fallback store.PortraitAsset) (store.PortraitAsset, bool, error) {
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(r.Context(), user, assetID)
|
||||
if err != nil || !found {
|
||||
return fallback, found, err
|
||||
}
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (s *Server) portraitAssetResponse(r *http.Request, asset store.PortraitAsset) map[string]any {
|
||||
active, total, lastError, updatedAt, err := s.store.PortraitAssetBindingSummary(r.Context(), asset.ID)
|
||||
if err != nil {
|
||||
active, total, lastError, updatedAt = 0, 0, asset.LastError, asset.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
summaryStatus := asset.Status
|
||||
if summaryStatus == "not_synced" && total == 0 {
|
||||
summaryStatus = "not_synced"
|
||||
}
|
||||
response := map[string]any{
|
||||
"id": asset.ID, "name": asset.Name, "description": asset.Description, "url": asset.URL, "preview": firstNonEmpty(asset.Preview, asset.URL),
|
||||
"type": "personal", "fileType": asset.SourceType, "sourceType": asset.SourceType, "size": asset.ByteSize,
|
||||
"privateAvatarEligible": asset.PrivateAvatarEligible,
|
||||
"createdAt": asset.CreatedAt.UTC().Format(time.RFC3339Nano), "updatedAt": asset.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"seedanceAssetSummary": map[string]any{
|
||||
"eligible": asset.PrivateAvatarEligible, "status": summaryStatus, "provider": "volces", "activePlatformCount": active,
|
||||
"totalPlatformCount": total, "sourceType": asset.SourceType, "updatedAt": updatedAt,
|
||||
},
|
||||
}
|
||||
if lastError != "" {
|
||||
response["seedanceAssetSummary"].(map[string]any)["lastError"] = lastError
|
||||
}
|
||||
if asset.SourceType == "image" {
|
||||
response["thumbnail"] = firstNonEmpty(asset.Preview, asset.URL)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func writePortraitAssetError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
if clientErr := clients.ErrorCode(err); clientErr != "client_error" {
|
||||
switch clientErr {
|
||||
case "portrait_asset_not_found":
|
||||
status = http.StatusNotFound
|
||||
case "portrait_asset_processing":
|
||||
status = http.StatusServiceUnavailable
|
||||
case "portrait_asset_authorization_required", "portrait_asset_unsupported_type", "portrait_asset_source_url_required", "portrait_asset_id_required", "portrait_asset_unsupported_model", "portrait_asset_audio_only":
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeError(w, status, err.Error(), clientErr)
|
||||
return
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
}
|
||||
|
||||
func portraitAssetSourceMatchesContentType(sourceType string, contentType string) bool {
|
||||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||
switch sourceType {
|
||||
case "image":
|
||||
return strings.HasPrefix(contentType, "image/")
|
||||
case "video":
|
||||
return strings.HasPrefix(contentType, "video/")
|
||||
case "audio":
|
||||
return strings.HasPrefix(contentType, "audio/")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetQueryInt(r *http.Request, keys ...string) int {
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(r.URL.Query().Get(key))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
var parsed int
|
||||
if _, err := fmt.Sscan(value, &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonEmptyQuery(r *http.Request, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func portraitAssetString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gatewaydocs "github.com/easyai/easyai-ai-gateway/apps/api/docs"
|
||||
)
|
||||
|
||||
func TestOpenAPIPublicRoutesUseCanonicalV1Prefix(t *testing.T) {
|
||||
var document struct {
|
||||
Paths map[string]any `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(gatewaydocs.SwaggerJSON, &document); err != nil {
|
||||
t.Fatalf("decode embedded OpenAPI document: %v", err)
|
||||
}
|
||||
|
||||
legacyPrefixes := []string{
|
||||
"/v1/",
|
||||
"/v1beta/",
|
||||
"/kling/",
|
||||
"/upload/",
|
||||
"/api/v3/",
|
||||
"/chat/",
|
||||
"/images/",
|
||||
"/song/",
|
||||
"/music/",
|
||||
"/speech/",
|
||||
"/voice_clone",
|
||||
"/tasks",
|
||||
}
|
||||
legacyExact := map[string]bool{
|
||||
"/healthz": true,
|
||||
"/readyz": true,
|
||||
"/api-docs-json": true,
|
||||
"/api-docs-yaml": true,
|
||||
"/responses": true,
|
||||
"/embeddings": true,
|
||||
"/reranks": true,
|
||||
}
|
||||
for route := range document.Paths {
|
||||
if legacyExact[route] {
|
||||
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
|
||||
}
|
||||
for _, prefix := range legacyPrefixes {
|
||||
if strings.HasPrefix(route, prefix) {
|
||||
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required := []string{
|
||||
"/api/v1/healthz",
|
||||
"/api/v1/readyz",
|
||||
"/api/v1/openapi.json",
|
||||
"/api/v1/chat/completions",
|
||||
"/api/v1/responses",
|
||||
"/api/v1/images/generations",
|
||||
"/api/v1/videos/generations",
|
||||
"/api/v1/models/{model}:generateContent",
|
||||
"/api/v1/videos/omni-video",
|
||||
"/api/v1/kling/v1/videos/omni-video",
|
||||
"/api/v1/kling/v2/omni-video/{model}",
|
||||
"/api/v1/contents/generations/tasks",
|
||||
}
|
||||
for _, route := range required {
|
||||
if _, ok := document.Paths[route]; !ok {
|
||||
t.Errorf("canonical public route missing from OpenAPI: %s", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,18 @@ func TestReceiveSecurityEventUsesPreparedReceiverBeforeFirstActivation(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiveSecurityEventReturnsNotFoundWithoutConfiguredReceiver(t *testing.T) {
|
||||
server := &Server{identityRuntime: identityruntime.NewManager(nil, &preparedReceiverBuilder{})}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.receiveSecurityEvent(response, request)
|
||||
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("unconfigured SSF status=%d, want %d", response.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventWriteAuditFailsClosedBeforeMutation(t *testing.T) {
|
||||
server := &Server{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/system/security-events/connection/verify", nil)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -182,6 +186,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
|
||||
mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
|
||||
mux.Handle("POST /api/v1/api-keys/access-rules/batch", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.batchAPIKeyAccessRules)))
|
||||
mux.Handle("GET /api/v1/api-keys/assignable-models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAssignableModels)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/scopes", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.updateAPIKeyScopes)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
|
||||
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
|
||||
@@ -251,12 +256,14 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("GET /api/admin/runtime/rate-limit-windows", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
|
||||
mux.Handle("GET /api/admin/runtime/model-rate-limits", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModelRateLimitStatuses)))
|
||||
mux.Handle("POST /api/v1/chat/completions", server.requireUser(auth.PermissionBasic, server.createAPIV1ChatCompletions()))
|
||||
mux.Handle("POST /api/v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", false)))
|
||||
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", false)))
|
||||
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", false)))
|
||||
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", false)))
|
||||
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", false)))
|
||||
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", false)))
|
||||
mux.Handle("POST /api/v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
|
||||
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
|
||||
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
|
||||
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
|
||||
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", true)))
|
||||
mux.Handle("POST /api/v1/video/generations", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createLegacyVolcesVideoGeneration)))
|
||||
mux.Handle("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getLegacyVolcesVideoResult)))
|
||||
mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /api/v1/music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
mux.Handle("POST /api/v1/speech/generations", server.requireUser(auth.PermissionBasic, server.createTask("speech.generations", true)))
|
||||
@@ -264,10 +271,24 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("GET /api/v1/voice_clone/voices", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listClonedVoices)))
|
||||
mux.Handle("DELETE /api/v1/voice_clone/voices/{voiceID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteClonedVoice)))
|
||||
mux.Handle("POST /api/v1/files/upload", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.uploadFile)))
|
||||
mux.Handle("GET /api/v1/resource/material/seedance-portrait-assets/capability", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getSeedancePortraitAssetCapability)))
|
||||
mux.Handle("GET /api/v1/resource/material/user/materials", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listSeedancePortraitAssets)))
|
||||
mux.Handle("POST /api/v1/resource/material", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createSeedancePortraitAsset)))
|
||||
mux.Handle("POST /api/v1/resource/material/seedance-portrait-assets/sync", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.syncSeedancePortraitAssets)))
|
||||
mux.Handle("POST /api/v3/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createVolcesContentsGenerationTask)))
|
||||
mux.Handle("GET /api/v3/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
|
||||
mux.Handle("GET /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
|
||||
mux.Handle("DELETE /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
|
||||
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)))
|
||||
@@ -290,6 +311,10 @@ 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 /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 {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const volcesContentsCompatibilityMarker = "volces_contents_generations_v3"
|
||||
|
||||
// createVolcesContentsGenerationTask godoc
|
||||
// @Summary 创建火山内容生成任务
|
||||
// @Description 统一公开入口兼容火山方舟内容生成任务。网关 task id 是查询与取消用的公开 id;上游 id 另以 upstream_task_id 保留。
|
||||
// @Tags volces-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @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 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
task, err := s.createVolcesCompatibleTask(r, user, body)
|
||||
if err != nil {
|
||||
writeVolcesCompatibleTaskError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, volcesCompatibleTask(task))
|
||||
}
|
||||
|
||||
// getVolcesContentsGenerationTask godoc
|
||||
// @Summary 查询火山内容生成任务
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/contents/generations/tasks/{taskID} [get]
|
||||
func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, volcesCompatibleTask(task))
|
||||
}
|
||||
|
||||
// listVolcesContentsGenerationTasks godoc
|
||||
// @Summary 列出火山内容生成任务
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/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 {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
page := portraitAssetQueryInt(r, "page_num", "pageNumber", "page")
|
||||
pageSize := portraitAssetQueryInt(r, "page_size", "pageSize")
|
||||
tasks, err := s.store.ListVolcesCompatibleTasks(r.Context(), user, store.VolcesCompatibleTaskListFilter{
|
||||
CompatibilityMarker: volcesContentsCompatibilityMarker,
|
||||
Status: r.URL.Query().Get("filter.status"),
|
||||
Model: r.URL.Query().Get("filter.model"),
|
||||
TaskIDs: r.URL.Query()["filter.task_ids"],
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("list Volces-compatible tasks failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list tasks failed")
|
||||
return
|
||||
}
|
||||
items := make([]any, 0)
|
||||
for _, task := range tasks.Items {
|
||||
items = append(items, volcesCompatibleTask(task))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "total": tasks.Total,
|
||||
"page_num": tasks.Page, "page_size": tasks.PageSize,
|
||||
// data/page are retained as additive gateway fields for existing callers.
|
||||
"data": items, "page": tasks.Page,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteVolcesContentsGenerationTask godoc
|
||||
// @Summary 取消火山内容生成任务
|
||||
// @Description 取消网关任务;对于已提交且保存了上游任务标识的 Volces 视频任务,同时调用火山 DELETE 接口并持久化取消状态。
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/contents/generations/tasks/{taskID} [delete]
|
||||
func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
result, err := s.runner.CancelVolcesVideoTask(r.Context(), task, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, runner.ErrTaskAccessDenied) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("cancel Volces-compatible task failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "cancel task failed")
|
||||
return
|
||||
}
|
||||
updated, err := s.store.GetTask(r.Context(), task.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "get cancelled task failed")
|
||||
return
|
||||
}
|
||||
response := volcesCompatibleTask(updated)
|
||||
response["cancelled"] = result.Cancelled
|
||||
response["cancellable"] = result.Cancellable
|
||||
response["message"] = result.Message
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// createLegacyVolcesVideoGeneration godoc
|
||||
// @Summary 创建 server-main 兼容视频任务
|
||||
// @Description 兼容 server-main 的 /api/v1/video/generations,返回 submitted 和 task_id;额外保留火山任务字段。
|
||||
// @Tags volces-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/video/generations [post]
|
||||
func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
body, err := s.decodeTaskRequestBody(r.Context(), w, r, "videos.generations")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
task, err := s.createVolcesCompatibleTask(r, user, body)
|
||||
if err != nil {
|
||||
writeVolcesCompatibleTaskError(w, err)
|
||||
return
|
||||
}
|
||||
response := volcesCompatibleTask(task)
|
||||
response["status"] = "submitted"
|
||||
response["task_id"] = task.ID
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// getLegacyVolcesVideoResult godoc
|
||||
// @Summary 查询 server-main 兼容视频结果
|
||||
// @Tags volces-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/ai/result/{taskID} [get]
|
||||
func (s *Server) getLegacyVolcesVideoResult(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
compat := volcesCompatibleTask(task)
|
||||
legacyStatus := "process"
|
||||
switch compat["status"] {
|
||||
case "succeeded":
|
||||
legacyStatus = "success"
|
||||
case "failed", "cancelled":
|
||||
legacyStatus = "failed"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": legacyStatus, "task_id": task.ID, "data": compat["content"], "result": compat,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createVolcesCompatibleTask(r *http.Request, user *auth.User, body map[string]any) (store.GatewayTask, error) {
|
||||
model := strings.TrimSpace(volcesCompatString(body["model"]))
|
||||
if model == "" {
|
||||
return store.GatewayTask{}, &clients.ClientError{Code: "invalid_parameter", Message: "model is required", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !apiKeyScopeAllowed(user, "videos.generations") {
|
||||
return store.GatewayTask{}, &clients.ClientError{Code: "forbidden", Message: "api key scope does not allow video generation", StatusCode: http.StatusForbidden, Retryable: false}
|
||||
}
|
||||
body["_gateway_compatibility"] = volcesContentsCompatibilityMarker
|
||||
task, err := s.prepareAndCreateGatewayTask(r.Context(), r, user, "videos.generations", model, body, true)
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, err
|
||||
}
|
||||
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
|
||||
return store.GatewayTask{}, &clients.ClientError{Code: "enqueue_failed", Message: err.Error(), StatusCode: http.StatusServiceUnavailable, Retryable: true}
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Server) volcesCompatibleTaskForUser(w http.ResponseWriter, r *http.Request) (store.GatewayTask, bool) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
s.logger.Error("get Volces-compatible task failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "get task failed")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
if !isVolcesCompatibleTask(task) || !kelingCompatTaskOwnedBy(task, user) {
|
||||
writeError(w, http.StatusNotFound, "task not found")
|
||||
return store.GatewayTask{}, false
|
||||
}
|
||||
return task, true
|
||||
}
|
||||
|
||||
func isVolcesCompatibleTask(task store.GatewayTask) bool {
|
||||
return task.Kind == "videos.generations" && strings.TrimSpace(volcesCompatString(task.Request["_gateway_compatibility"])) == volcesContentsCompatibilityMarker
|
||||
}
|
||||
|
||||
func volcesCompatibleTask(task store.GatewayTask) map[string]any {
|
||||
response := cloneVolcesCompatibleMap(task.Result)
|
||||
if len(response) == 0 {
|
||||
response = cloneVolcesCompatibleMap(task.RemoteTaskPayload)
|
||||
}
|
||||
if response == nil {
|
||||
response = map[string]any{}
|
||||
}
|
||||
response["id"] = task.ID
|
||||
response["model"] = firstNonEmpty(volcesCompatString(response["model"]), task.Model)
|
||||
response["status"] = volcesCompatibleTaskStatus(task.Status)
|
||||
response["created_at"] = task.CreatedAt.Unix()
|
||||
response["updated_at"] = task.UpdatedAt.Unix()
|
||||
if task.RemoteTaskID != "" {
|
||||
response["upstream_task_id"] = task.RemoteTaskID
|
||||
}
|
||||
for _, key := range []string{"content", "seed", "resolution", "ratio", "duration", "frames", "framespersecond"} {
|
||||
if response[key] == nil && task.Request[key] != nil {
|
||||
response[key] = task.Request[key]
|
||||
}
|
||||
}
|
||||
if len(task.Usage) > 0 && response["usage"] == nil {
|
||||
response["usage"] = task.Usage
|
||||
}
|
||||
if task.Status == "failed" || task.Status == "cancelled" {
|
||||
response["error"] = map[string]any{"code": firstNonEmpty(task.ErrorCode, strings.ToUpper(task.Status)), "message": firstNonEmpty(task.ErrorMessage, task.Error, task.Message)}
|
||||
}
|
||||
response["gateway_task_id"] = task.ID
|
||||
response["gateway_status"] = task.Status
|
||||
response["billings"] = task.Billings
|
||||
response["billing_summary"] = task.BillingSummary
|
||||
response["final_charge_amount"] = task.FinalChargeAmount
|
||||
return response
|
||||
}
|
||||
|
||||
func volcesCompatibleTaskStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "succeeded", "success", "completed":
|
||||
return "succeeded"
|
||||
case "failed":
|
||||
return "failed"
|
||||
case "cancelled", "canceled":
|
||||
return "cancelled"
|
||||
case "running", "processing":
|
||||
return "running"
|
||||
default:
|
||||
return "queued"
|
||||
}
|
||||
}
|
||||
|
||||
func cloneVolcesCompatibleMap(source map[string]any) map[string]any {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeVolcesCompatibleTaskError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
var staged *gatewayTaskCreationError
|
||||
if errors.As(err, &staged) {
|
||||
err = staged.Err
|
||||
}
|
||||
var clientErr *clients.ClientError
|
||||
if errors.As(err, &clientErr) && clientErr.StatusCode > 0 {
|
||||
status = clientErr.StatusCode
|
||||
} else if errors.As(err, &clientErr) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeError(w, status, err.Error(), clients.ErrorCode(err))
|
||||
}
|
||||
|
||||
func volcesCompatString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestVolcesCompatibleTaskPreservesOfficialFieldsAndGatewayBilling(t *testing.T) {
|
||||
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
|
||||
task := store.GatewayTask{
|
||||
ID: "gateway-task-1", Kind: "videos.generations", Status: "succeeded", Model: "doubao-seedance-2-0-mini-260615",
|
||||
RemoteTaskID: "cgt-upstream-1", CreatedAt: now, UpdatedAt: now.Add(time.Second),
|
||||
Result: map[string]any{
|
||||
"id": "cgt-upstream-1", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
|
||||
"content": map[string]any{"video_url": "https://example.com/out.mp4"}, "usage": map[string]any{"total_tokens": 9},
|
||||
},
|
||||
Billings: []any{map[string]any{"amount": 3}}, BillingSummary: map[string]any{"currency": "resource"}, FinalChargeAmount: 3,
|
||||
}
|
||||
got := volcesCompatibleTask(task)
|
||||
if got["id"] != task.ID || got["upstream_task_id"] != task.RemoteTaskID || got["status"] != "succeeded" {
|
||||
t.Fatalf("unexpected compatibility identity/status: %+v", got)
|
||||
}
|
||||
content, _ := got["content"].(map[string]any)
|
||||
if content["video_url"] != "https://example.com/out.mp4" || got["usage"] == nil || got["billings"] == nil {
|
||||
t.Fatalf("official or billing fields were lost: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -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,27 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestKelingO1GeneratedAudioIsRejectedInsteadOfSilentlyRemoved(t *testing.T) {
|
||||
result := preprocessRequestWithLog("videos.generations", map[string]any{
|
||||
"model": "kling-o1",
|
||||
"audio": true,
|
||||
}, store.RuntimeModelCandidate{
|
||||
Provider: "keling",
|
||||
ProviderModelName: "kling-video-o1",
|
||||
ModelType: "video_generate",
|
||||
Capabilities: map[string]any{
|
||||
"video_generate": map[string]any{"output_audio": false},
|
||||
},
|
||||
})
|
||||
if result.Err == nil {
|
||||
t.Fatal("Keling O1 audio=true must be rejected")
|
||||
}
|
||||
if len(result.Log.Changes) == 0 || result.Log.Changes[len(result.Log.Changes)-1].Action != "reject" {
|
||||
t.Fatalf("expected an auditable reject change, got %+v", result.Log.Changes)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type resolutionNormalizeProcessor struct{}
|
||||
@@ -691,6 +693,16 @@ func (audioProcessor) ShouldProcess(params map[string]any, modelType string, con
|
||||
}
|
||||
|
||||
func (audioProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
|
||||
if context != nil && kelingO1GeneratedAudioRequested(params, context.candidate) {
|
||||
return context.reject(
|
||||
"AudioProcessor",
|
||||
"audio",
|
||||
params["audio"],
|
||||
"kling-video-o1 does not support generated audio",
|
||||
capabilityPath(modelType, "output_audio"),
|
||||
capabilityValue(context.modelCapability, modelType, "output_audio"),
|
||||
)
|
||||
}
|
||||
capability := capabilityForType(context.modelCapability, modelType)
|
||||
if capability == nil || !boolFromAny(capability["output_audio"]) {
|
||||
for _, key := range []string{"audio", "output_audio"} {
|
||||
@@ -712,6 +724,17 @@ func (audioProcessor) Process(params map[string]any, modelType string, context *
|
||||
return true
|
||||
}
|
||||
|
||||
func kelingO1GeneratedAudioRequested(params map[string]any, candidate store.RuntimeModelCandidate) bool {
|
||||
if !strings.EqualFold(strings.TrimSpace(candidate.Provider), "keling") {
|
||||
return false
|
||||
}
|
||||
model := strings.ToLower(strings.TrimSpace(candidate.ProviderModelName))
|
||||
if model != "kling-o1" && model != "kling-video-o1" {
|
||||
return false
|
||||
}
|
||||
return boolFromAny(params["audio"]) || boolFromAny(params["output_audio"])
|
||||
}
|
||||
|
||||
type imageCountProcessor struct{}
|
||||
|
||||
func (imageCountProcessor) Name() string { return "ImageCountProcessor" }
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
var portraitAssetPlaceholderPattern = regexp.MustCompile(`(?i)<<<[[:space:]]*portrait[_-]?asset_([0-9]+)[[:space:]]*>>>|@portrait_asset([0-9]+)|@人像资产([0-9]+)`)
|
||||
|
||||
type PortraitAssetCapability struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CanUse bool `json:"canUse"`
|
||||
CanCreate bool `json:"canCreate"`
|
||||
CanUseAsPortraitAsset bool `json:"canUseAsPortraitAsset"`
|
||||
CanUseAsPlainMaterial bool `json:"canUseAsPlainMaterial"`
|
||||
AvailablePlatformIDs []string `json:"availablePlatformIds"`
|
||||
CreationPlatformIDs []string `json:"creationPlatformIds"`
|
||||
CanReferenceTencentAsset bool `json:"canReferenceTencentAssetUri"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type PortraitAssetCreateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
SourceType string
|
||||
URL string
|
||||
Preview string
|
||||
MimeType string
|
||||
ByteSize int64
|
||||
SourceSHA256 string
|
||||
PrivateAvatarEligible bool
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
type PortraitAssetSyncResponse struct {
|
||||
Requested int `json:"requested"`
|
||||
Accepted int `json:"accepted"`
|
||||
SyncedIDs []string `json:"syncedIds"`
|
||||
Skipped []PortraitAssetIssue `json:"skipped"`
|
||||
Failed []PortraitAssetIssue `json:"failed"`
|
||||
Assets []store.PortraitAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type PortraitAssetIssue struct {
|
||||
ID string `json:"id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type portraitAssetPlatformSettings struct {
|
||||
ProjectName string
|
||||
AssetGroupID string
|
||||
Credentials clients.VolcesAssetCredentials
|
||||
}
|
||||
|
||||
func (s *Service) PortraitAssetCapability(ctx context.Context) (PortraitAssetCapability, error) {
|
||||
platforms, err := s.store.ListPortraitAssetPlatforms(ctx)
|
||||
if err != nil {
|
||||
return PortraitAssetCapability{}, err
|
||||
}
|
||||
ids := make([]string, 0, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
if _, ok := portraitAssetSettings(platform); ok {
|
||||
ids = append(ids, platform.PlatformID)
|
||||
}
|
||||
}
|
||||
capability := PortraitAssetCapability{
|
||||
Enabled: len(ids) > 0,
|
||||
CanUse: len(ids) > 0,
|
||||
CanCreate: len(ids) > 0,
|
||||
CanUseAsPortraitAsset: len(ids) > 0,
|
||||
CanUseAsPlainMaterial: true,
|
||||
AvailablePlatformIDs: ids,
|
||||
CreationPlatformIDs: ids,
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
capability.Reason = "未配置可用的火山 Seedance 人像资产平台;请在 Volces 平台 config.seedancePrivateAsset 中配置 enabled、accessKey、secretKey、projectName、assetGroupId。"
|
||||
}
|
||||
return capability, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreatePortraitAsset(ctx context.Context, user *auth.User, input PortraitAssetCreateInput) (store.PortraitAsset, bool, error) {
|
||||
if s.store == nil {
|
||||
return store.PortraitAsset{}, false, fmt.Errorf("portrait asset store is unavailable")
|
||||
}
|
||||
if !validPortraitAssetSourceType(input.SourceType) {
|
||||
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_unsupported_type", Message: "source type must be image, video, or audio", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if strings.TrimSpace(input.URL) == "" {
|
||||
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_source_url_required", Message: "portrait asset source URL is required", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !input.PrivateAvatarEligible {
|
||||
return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_authorization_required", Message: "private_avatar_eligible must be true after the user confirms authorization", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if existing, found, err := s.store.FindPortraitAssetBySourceHash(ctx, user, input.SourceSHA256); err != nil {
|
||||
return store.PortraitAsset{}, false, err
|
||||
} else if found {
|
||||
return existing, true, nil
|
||||
}
|
||||
gatewayUserID, userID := portraitAssetUserKeys(user)
|
||||
if user == nil || userID == "" {
|
||||
return store.PortraitAsset{}, false, store.ErrLocalUserRequired
|
||||
}
|
||||
asset, err := s.store.CreatePortraitAsset(ctx, store.PortraitAssetInput{
|
||||
GatewayUserID: gatewayUserID,
|
||||
UserID: userID,
|
||||
GatewayTenantID: strings.TrimSpace(user.GatewayTenantID),
|
||||
TenantID: strings.TrimSpace(user.TenantID),
|
||||
TenantKey: strings.TrimSpace(user.TenantKey),
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
SourceType: strings.ToLower(strings.TrimSpace(input.SourceType)),
|
||||
URL: strings.TrimSpace(input.URL),
|
||||
Preview: firstNonEmptyString(strings.TrimSpace(input.Preview), strings.TrimSpace(input.URL)),
|
||||
MimeType: strings.TrimSpace(input.MimeType),
|
||||
ByteSize: input.ByteSize,
|
||||
SourceSHA256: strings.TrimSpace(input.SourceSHA256),
|
||||
PrivateAvatarEligible: input.PrivateAvatarEligible,
|
||||
Metadata: input.Metadata,
|
||||
})
|
||||
return asset, false, err
|
||||
}
|
||||
|
||||
func (s *Service) SyncPortraitAssets(ctx context.Context, user *auth.User, ids []string) (PortraitAssetSyncResponse, error) {
|
||||
response := PortraitAssetSyncResponse{
|
||||
Requested: len(ids), SyncedIDs: make([]string, 0), Skipped: make([]PortraitAssetIssue, 0), Failed: make([]PortraitAssetIssue, 0), Assets: make([]store.PortraitAsset, 0),
|
||||
}
|
||||
platforms, err := s.store.ListPortraitAssetPlatforms(ctx)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
configured := make([]store.PortraitAssetPlatform, 0, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
if _, ok := portraitAssetSettings(platform); ok {
|
||||
configured = append(configured, platform)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, value := range ids {
|
||||
assetID := strings.TrimSpace(value)
|
||||
if assetID == "" || seen[assetID] {
|
||||
continue
|
||||
}
|
||||
seen[assetID] = true
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
if !found {
|
||||
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: assetID, Reason: "portrait asset not found"})
|
||||
continue
|
||||
}
|
||||
if !asset.PrivateAvatarEligible {
|
||||
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: asset.ID, Reason: "portrait asset authorization is required"})
|
||||
continue
|
||||
}
|
||||
if len(configured) == 0 {
|
||||
_ = s.store.UpdatePortraitAssetStatus(ctx, asset.ID, "not_configured", "no configured Volces portrait asset platform")
|
||||
asset.Status = "not_configured"
|
||||
asset.LastError = "no configured Volces portrait asset platform"
|
||||
response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: asset.ID, Reason: asset.LastError})
|
||||
response.Assets = append(response.Assets, asset)
|
||||
continue
|
||||
}
|
||||
|
||||
response.Accepted++
|
||||
assetFailed := false
|
||||
for _, platform := range configured {
|
||||
if err := s.syncPortraitAssetToPlatform(ctx, asset, platform); err != nil {
|
||||
assetFailed = true
|
||||
response.Failed = append(response.Failed, PortraitAssetIssue{ID: asset.ID, Reason: platform.PlatformID + ": " + err.Error()})
|
||||
}
|
||||
}
|
||||
updated, _, err := s.refreshPortraitAssetStatus(ctx, user, asset.ID)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.Assets = append(response.Assets, updated)
|
||||
if !assetFailed {
|
||||
response.SyncedIDs = append(response.SyncedIDs, updated.ID)
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) syncPortraitAssetToPlatform(ctx context.Context, asset store.PortraitAsset, platform store.PortraitAssetPlatform) error {
|
||||
settings, ok := portraitAssetSettings(platform)
|
||||
if !ok {
|
||||
return &clients.ClientError{Code: "portrait_asset_not_configured", Message: "platform portrait asset configuration is incomplete", Retryable: false}
|
||||
}
|
||||
binding, found, err := s.store.GetPortraitAssetBinding(ctx, asset.ID, platform.PlatformID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
binding = store.PortraitAssetBinding{AssetID: asset.ID, PlatformID: platform.PlatformID, ProjectName: settings.ProjectName, AssetGroupID: settings.AssetGroupID, Status: "pending"}
|
||||
}
|
||||
if !portraitAssetHasPublicURL(asset.URL) {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, &clients.ClientError{
|
||||
Code: "portrait_asset_public_url_required",
|
||||
Message: "portrait asset URL must be an absolute http(s) URL reachable by Volces",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Retryable: false,
|
||||
})
|
||||
}
|
||||
client := clients.VolcesAssetClient{HTTPClient: s.portraitAssetHTTPClient()}
|
||||
remoteID := strings.TrimSpace(binding.RemoteAssetID)
|
||||
if remoteID == "" {
|
||||
created, _, createErr := client.CreateAsset(ctx, settings.Credentials, map[string]any{
|
||||
"GroupId": settings.AssetGroupID, "URL": asset.URL, "Name": asset.Name,
|
||||
"AssetType": volcesPortraitAssetType(asset.SourceType), "ProjectName": settings.ProjectName,
|
||||
})
|
||||
if createErr != nil {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, createErr)
|
||||
}
|
||||
remoteID = strings.TrimSpace(created.ID)
|
||||
if remoteID == "" {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, &clients.ClientError{Code: "invalid_response", Message: "volces CreateAsset returned no asset id", Retryable: false})
|
||||
}
|
||||
binding.RemoteAssetID = remoteID
|
||||
}
|
||||
remote, _, getErr := client.GetAsset(ctx, settings.Credentials, map[string]any{"Id": remoteID, "ProjectName": settings.ProjectName})
|
||||
if getErr != nil {
|
||||
return s.recordPortraitAssetBindingFailure(ctx, binding, settings, getErr)
|
||||
}
|
||||
binding.ProjectName = settings.ProjectName
|
||||
binding.AssetGroupID = settings.AssetGroupID
|
||||
binding.RemoteAssetID = firstNonEmptyString(remote.ID, remoteID)
|
||||
binding.RemoteAssetURI = "asset://" + binding.RemoteAssetID
|
||||
binding.Status = portraitAssetBindingStatus(remote.Status)
|
||||
binding.LastErrorCode = strings.TrimSpace(stringFromMap(remote.Error, "Code"))
|
||||
binding.LastErrorMessage = strings.TrimSpace(stringFromMap(remote.Error, "Message"))
|
||||
if binding.Status == "failed" && binding.LastErrorMessage == "" {
|
||||
binding.LastErrorMessage = "volces portrait asset processing failed"
|
||||
}
|
||||
_, err = s.store.UpsertPortraitAssetBinding(ctx, binding)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) recordPortraitAssetBindingFailure(ctx context.Context, binding store.PortraitAssetBinding, settings portraitAssetPlatformSettings, cause error) error {
|
||||
binding.ProjectName = settings.ProjectName
|
||||
binding.AssetGroupID = settings.AssetGroupID
|
||||
binding.Status = "failed"
|
||||
binding.LastErrorCode = clients.ErrorCode(cause)
|
||||
binding.LastErrorMessage = cause.Error()
|
||||
_, err := s.store.UpsertPortraitAssetBinding(ctx, binding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func (s *Service) refreshPortraitAssetStatus(ctx context.Context, user *auth.User, assetID string) (store.PortraitAsset, bool, error) {
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
|
||||
if err != nil || !found {
|
||||
return asset, found, err
|
||||
}
|
||||
active, total, lastError, _, err := s.store.PortraitAssetBindingSummary(ctx, asset.ID)
|
||||
if err != nil {
|
||||
return asset, true, err
|
||||
}
|
||||
status := "not_synced"
|
||||
if total == 0 {
|
||||
status = "not_synced"
|
||||
} else if active > 0 {
|
||||
status = "active"
|
||||
if active < total {
|
||||
status = "partial"
|
||||
}
|
||||
} else if lastError != "" {
|
||||
status = "failed"
|
||||
} else {
|
||||
status = "pending"
|
||||
}
|
||||
if err := s.store.UpdatePortraitAssetStatus(ctx, asset.ID, status, lastError); err != nil {
|
||||
return asset, true, err
|
||||
}
|
||||
asset.Status = status
|
||||
asset.LastError = lastError
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) compilePortraitAssetReferences(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) (map[string]any, error) {
|
||||
entries := portraitAssetList(body["portrait_asset_list"])
|
||||
if len(entries) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
if kind != "videos.generations" || !isVolcesPortraitAssetCandidate(candidate) {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_unsupported_model", Message: "portrait assets require a configured Volces Seedance omni video model", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
if !candidateSupportsPortraitAssets(candidate) {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_unsupported_model", Message: "selected model does not enable supports_portrait_asset_reference", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
|
||||
out := cloneMap(body)
|
||||
content := contentItems(out["content"])
|
||||
labels := make([]string, len(entries))
|
||||
nonAudioAssets := 0
|
||||
for index, entry := range entries {
|
||||
assetID := firstNonEmptyString(stringFromMap(entry, "id"), stringFromMap(entry, "easyai_portrait_asset_id"))
|
||||
if assetID == "" {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_id_required", Message: fmt.Sprintf("portrait_asset_list[%d].id is required", index), StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_not_found", Message: "portrait asset not found", StatusCode: http.StatusNotFound, Retryable: false}
|
||||
}
|
||||
if !asset.PrivateAvatarEligible {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_authorization_required", Message: "portrait asset authorization is required", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
binding, bound, err := s.store.GetPortraitAssetBinding(ctx, asset.ID, candidate.PlatformID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bound || binding.Status != "active" || strings.TrimSpace(binding.RemoteAssetURI) == "" {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_processing", Message: "portrait asset is not active for the selected Volces platform; sync it and retry", StatusCode: http.StatusServiceUnavailable, Retryable: true}
|
||||
}
|
||||
labels[index] = firstNonEmptyString(strings.TrimSpace(stringFromMap(entry, "name")), asset.Name, "portrait asset "+fmt.Sprint(index+1))
|
||||
if asset.SourceType != "audio" {
|
||||
nonAudioAssets++
|
||||
}
|
||||
content = append(content, portraitAssetContent(asset.SourceType, binding.RemoteAssetURI))
|
||||
}
|
||||
if nonAudioAssets == 0 {
|
||||
return nil, &clients.ClientError{Code: "portrait_asset_audio_only", Message: "portrait_asset_list cannot contain audio-only assets", StatusCode: http.StatusBadRequest, Retryable: false}
|
||||
}
|
||||
for index := range content {
|
||||
if strings.ToLower(strings.TrimSpace(stringFromAny(content[index]["type"]))) != "text" {
|
||||
continue
|
||||
}
|
||||
content[index]["text"] = replacePortraitAssetPlaceholders(stringFromAny(content[index]["text"]), labels)
|
||||
}
|
||||
out["content"] = mapsToAnySlice(content)
|
||||
delete(out, "portrait_asset_list")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) portraitAssetHTTPClient() *http.Client {
|
||||
if s.httpClients != nil && s.httpClients.none != nil {
|
||||
return s.httpClients.none
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func portraitAssetSettings(platform store.PortraitAssetPlatform) (portraitAssetPlatformSettings, bool) {
|
||||
config := portraitAssetNestedConfig(platform.Config)
|
||||
accessKey := firstNonEmptyString(portraitAssetValue(config, "accessKey", "access_key"), portraitAssetValue(platform.Credentials, "accessKey", "access_key"))
|
||||
secretKey := firstNonEmptyString(portraitAssetValue(config, "secretKey", "secret_key"), portraitAssetValue(platform.Credentials, "secretKey", "secret_key"))
|
||||
projectName := firstNonEmptyString(portraitAssetValue(config, "projectName", "project_name"), "default")
|
||||
assetGroupID := portraitAssetValue(config, "assetGroupId", "asset_group_id")
|
||||
endpoint := firstNonEmptyString(portraitAssetValue(config, "assetEndpoint", "asset_endpoint", "volcesAssetEndpoint", "volces_asset_endpoint"), clientsVolcesAssetDefaultEndpoint())
|
||||
if accessKey == "" || secretKey == "" || projectName == "" || assetGroupID == "" {
|
||||
return portraitAssetPlatformSettings{}, false
|
||||
}
|
||||
if enabled, present := portraitAssetBool(config, "enabled"); present && !enabled {
|
||||
return portraitAssetPlatformSettings{}, false
|
||||
}
|
||||
return portraitAssetPlatformSettings{ProjectName: projectName, AssetGroupID: assetGroupID, Credentials: clients.VolcesAssetCredentials{AccessKey: accessKey, SecretKey: secretKey, Endpoint: endpoint}}, true
|
||||
}
|
||||
|
||||
func portraitAssetNestedConfig(config map[string]any) map[string]any {
|
||||
for _, key := range []string{"seedancePrivateAsset", "seedance_private_asset", "portraitAsset", "portrait_asset"} {
|
||||
if nested, ok := config[key].(map[string]any); ok {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func portraitAssetValue(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(stringFromAny(values[key])); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func portraitAssetBool(values map[string]any, key string) (bool, bool) {
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed, true
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(typed), "true"), true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func validPortraitAssetSourceType(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "image", "video", "audio":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetHasPublicURL(value string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")
|
||||
}
|
||||
|
||||
func volcesPortraitAssetType(sourceType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(sourceType)) {
|
||||
case "video":
|
||||
return "Video"
|
||||
case "audio":
|
||||
return "Audio"
|
||||
default:
|
||||
return "Image"
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetBindingStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "active", "succeeded", "success":
|
||||
return "active"
|
||||
case "failed", "error":
|
||||
return "failed"
|
||||
default:
|
||||
return "processing"
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetList(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if object, ok := item.(map[string]any); ok {
|
||||
out = append(out, object)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []map[string]any:
|
||||
return typed
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func portraitAssetContent(sourceType string, assetURI string) map[string]any {
|
||||
switch strings.ToLower(strings.TrimSpace(sourceType)) {
|
||||
case "video":
|
||||
return map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": assetURI}}
|
||||
case "audio":
|
||||
return map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": assetURI}}
|
||||
default:
|
||||
return map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": assetURI}}
|
||||
}
|
||||
}
|
||||
|
||||
func replacePortraitAssetPlaceholders(value string, labels []string) string {
|
||||
return portraitAssetPlaceholderPattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||
parts := portraitAssetPlaceholderPattern.FindStringSubmatch(match)
|
||||
for index := 1; index < len(parts); index++ {
|
||||
if parts[index] == "" {
|
||||
continue
|
||||
}
|
||||
position := int(parts[index][0] - '0')
|
||||
if len(parts[index]) > 1 {
|
||||
position = 0
|
||||
for _, r := range parts[index] {
|
||||
position = position*10 + int(r-'0')
|
||||
}
|
||||
}
|
||||
if position > 0 && position <= len(labels) && strings.TrimSpace(labels[position-1]) != "" {
|
||||
return labels[position-1]
|
||||
}
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
func isVolcesPortraitAssetCandidate(candidate store.RuntimeModelCandidate) bool {
|
||||
provider := strings.ToLower(strings.TrimSpace(candidate.Provider))
|
||||
return provider == "volces" || provider == "volces-openai"
|
||||
}
|
||||
|
||||
func candidateSupportsPortraitAssets(candidate store.RuntimeModelCandidate) bool {
|
||||
capabilities := effectiveModelCapability(candidate)
|
||||
for _, key := range []string{candidate.ModelType, "omni_video", "omni", "video_generate"} {
|
||||
if capability, ok := capabilities[key].(map[string]any); ok {
|
||||
if enabled, present := portraitAssetBool(capability, "supports_portrait_asset_reference"); present {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func portraitAssetUserKeys(user *auth.User) (string, string) {
|
||||
if user == nil {
|
||||
return "", ""
|
||||
}
|
||||
gatewayUserID := strings.TrimSpace(user.GatewayUserID)
|
||||
if gatewayUserID == "" && user.Source == "gateway" {
|
||||
gatewayUserID = strings.TrimSpace(user.ID)
|
||||
}
|
||||
return gatewayUserID, strings.TrimSpace(user.ID)
|
||||
}
|
||||
|
||||
func portraitAssetSHA256(payload []byte) string {
|
||||
digest := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func clientsVolcesAssetDefaultEndpoint() string { return "https://ark.cn-beijing.volcengineapi.com" }
|
||||
@@ -0,0 +1,50 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestReplacePortraitAssetPlaceholders(t *testing.T) {
|
||||
got := replacePortraitAssetPlaceholders("让 <<<portrait_asset_1>>> 和 @portrait_asset2、@人像资产3 出镜", []string{"Alice", "Bob", "Carol"})
|
||||
want := "让 Alice 和 Bob、Carol 出镜"
|
||||
if got != want {
|
||||
t.Fatalf("placeholder replacement = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortraitAssetContentUsesAssetURI(t *testing.T) {
|
||||
item := portraitAssetContent("video", "asset://volces-video-1")
|
||||
video, _ := item["video_url"].(map[string]any)
|
||||
if item["type"] != "video_url" || item["role"] != "reference_video" || video["url"] != "asset://volces-video-1" {
|
||||
t.Fatalf("unexpected portrait asset content: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortraitAssetSettingsRequireConfiguredVolcesAssetGroup(t *testing.T) {
|
||||
settings, ok := portraitAssetSettings(store.PortraitAssetPlatform{Config: map[string]any{
|
||||
"seedancePrivateAsset": map[string]any{
|
||||
"enabled": true, "accessKey": "ak", "secretKey": "sk", "projectName": "project", "assetGroupId": "group",
|
||||
},
|
||||
}})
|
||||
if !ok || settings.ProjectName != "project" || settings.AssetGroupID != "group" || settings.Credentials.AccessKey != "ak" {
|
||||
t.Fatalf("unexpected configured portrait asset settings: %+v ok=%v", settings, ok)
|
||||
}
|
||||
if _, ok := portraitAssetSettings(store.PortraitAssetPlatform{Config: map[string]any{"seedancePrivateAsset": map[string]any{"enabled": true, "accessKey": "ak"}}}); ok {
|
||||
t.Fatal("incomplete platform config must not enable portrait assets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortraitAssetHasPublicURL(t *testing.T) {
|
||||
for _, value := range []string{"https://assets.example.com/portrait.png", "http://assets.example.com/portrait.mp4"} {
|
||||
if !portraitAssetHasPublicURL(value) {
|
||||
t.Fatalf("expected public URL: %q", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"/uploads/portrait.png", "file:///tmp/portrait.png", "asset://portrait-id"} {
|
||||
if portraitAssetHasPublicURL(value) {
|
||||
t.Fatalf("expected non-public URL: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,24 +194,25 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
||||
}
|
||||
|
||||
func (s *Service) effectiveBillingConfig(ctx context.Context, candidate store.RuntimeModelCandidate) map[string]any {
|
||||
base := candidate.BaseBillingConfig
|
||||
if ruleSetID := firstNonEmptyString(candidate.BasePricingRuleSetID, candidate.PlatformPricingRuleSetID); ruleSetID != "" {
|
||||
var inheritedRuleSetConfig map[string]any
|
||||
if ruleSetID := firstNonEmptyString(candidate.BasePricingRuleSetID, candidate.PlatformPricingRuleSetID); ruleSetID != "" && s.store != nil {
|
||||
if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, ruleSetID); err == nil && len(ruleSetConfig) > 0 {
|
||||
base = ruleSetConfig
|
||||
inheritedRuleSetConfig = ruleSetConfig
|
||||
}
|
||||
}
|
||||
if len(candidate.BillingConfig) > 0 {
|
||||
base = candidate.BillingConfig
|
||||
}
|
||||
if candidate.ModelPricingRuleSetID != "" {
|
||||
var modelRuleSetConfig map[string]any
|
||||
if candidate.ModelPricingRuleSetID != "" && s.store != nil {
|
||||
if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, candidate.ModelPricingRuleSetID); err == nil && len(ruleSetConfig) > 0 {
|
||||
base = ruleSetConfig
|
||||
modelRuleSetConfig = ruleSetConfig
|
||||
}
|
||||
}
|
||||
if len(candidate.BillingConfigOverride) > 0 {
|
||||
base = mergeMap(base, candidate.BillingConfigOverride)
|
||||
}
|
||||
return base
|
||||
return store.ResolveEffectiveBillingConfig(store.EffectiveBillingConfigInput{
|
||||
BaseConfig: candidate.BaseBillingConfig,
|
||||
LegacyPlatformModelConfig: candidate.BillingConfig,
|
||||
InheritedRuleSetConfig: inheritedRuleSetConfig,
|
||||
ModelRuleSetConfig: modelRuleSetConfig,
|
||||
Override: candidate.BillingConfigOverride,
|
||||
})
|
||||
}
|
||||
|
||||
func effectiveDiscount(ctx context.Context, db *store.Store, user *auth.User, candidate store.RuntimeModelCandidate) float64 {
|
||||
|
||||
@@ -526,6 +526,20 @@ candidatesLoop:
|
||||
candidateBody := preprocessing.Body
|
||||
candidatePricing := pricingByCandidate[pricingCandidateKey(candidate)]
|
||||
response, err := s.runCandidate(ctx, task, user, candidateBody, preprocessing.Log, candidate, candidatePricing, nextAttemptNo, onDelta, responseExecution, singleSourceProtected, runnerPolicy.CacheAffinityPolicy, cacheAffinityKeys.Record)
|
||||
if err != nil && isVolcesRemoteTaskCancellation(candidate, err) {
|
||||
cancelled, changed, cancelErr := s.store.CancelSubmittedTask(ctx, task.ID, task.ExecutionToken, "任务已由火山引擎取消")
|
||||
if cancelErr != nil {
|
||||
return Result{}, cancelErr
|
||||
}
|
||||
if changed {
|
||||
// CancelSubmittedTask atomically transfers any reservation to the release Outbox.
|
||||
walletReservationFinalized = true
|
||||
if emitErr := s.emit(ctx, task.ID, "task.cancelled", "cancelled", "cancelled", 1, "任务已由火山引擎取消", map[string]any{"taskId": task.ID, "reason": "upstream_cancelled"}, isSimulation(task, candidate)); emitErr != nil {
|
||||
return Result{}, emitErr
|
||||
}
|
||||
return Result{Task: cancelled, Output: cancelled.Result}, nil
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
attemptNo = nextAttemptNo
|
||||
var billings []any
|
||||
@@ -592,6 +606,13 @@ candidatesLoop:
|
||||
ResponseDurationMS: record.ResponseDurationMS,
|
||||
})
|
||||
if finishErr != nil {
|
||||
if errors.Is(finishErr, store.ErrTaskExecutionLeaseLost) {
|
||||
latest, latestErr := s.store.GetTask(ctx, task.ID)
|
||||
if latestErr == nil && latest.Status == "cancelled" {
|
||||
walletReservationFinalized = true
|
||||
return Result{Task: latest, Output: latest.Result}, nil
|
||||
}
|
||||
}
|
||||
return Result{}, finishErr
|
||||
}
|
||||
walletReservationFinalized = true
|
||||
@@ -916,7 +937,19 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
return clients.Response{}, fmt.Errorf("prepare http client: %w", err)
|
||||
}
|
||||
client := s.clientFor(candidate, simulated)
|
||||
providerBody, err := s.hydrateProviderRequestAssets(ctx, body, candidate)
|
||||
providerBody, err := s.compilePortraitAssetReferences(ctx, user, task.Kind, body, candidate)
|
||||
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, "trace": []any{failureTraceEntry(err, 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{
|
||||
AttemptID: attemptID,
|
||||
@@ -953,6 +986,12 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
}
|
||||
return s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, task.ExecutionToken, attemptID, remoteTaskID, payload)
|
||||
},
|
||||
OnRemoteTaskPolled: func(remoteTaskID string, payload map[string]any) error {
|
||||
if strings.TrimSpace(remoteTaskID) == "" {
|
||||
return nil
|
||||
}
|
||||
return s.store.SetTaskRemoteTask(context.WithoutCancel(ctx), task.ID, task.ExecutionToken, attemptID, remoteTaskID, payload)
|
||||
},
|
||||
Stream: boolFromMap(providerBody, "stream"),
|
||||
StreamDelta: onDelta,
|
||||
UpstreamProtocol: candidate.ResponseProtocol,
|
||||
@@ -1187,12 +1226,19 @@ func (s *Service) failTask(ctx context.Context, taskID string, executionToken st
|
||||
if err != nil {
|
||||
return store.GatewayTask{}, err
|
||||
}
|
||||
if failed.Status == "cancelled" {
|
||||
return failed, nil
|
||||
}
|
||||
if eventErr := s.emit(ctx, taskID, "task.failed", "failed", "failed", 1, message, map[string]any{"code": code, "requestId": requestID, "metrics": metrics}, simulated); eventErr != nil {
|
||||
return store.GatewayTask{}, eventErr
|
||||
}
|
||||
return failed, nil
|
||||
}
|
||||
|
||||
func isVolcesRemoteTaskCancellation(candidate store.RuntimeModelCandidate, err error) bool {
|
||||
return isVolcesCancellationCandidate(candidate) && strings.EqualFold(clients.ErrorCode(err), "volces_task_cancelled")
|
||||
}
|
||||
|
||||
type failedAttemptRecord struct {
|
||||
Task store.GatewayTask
|
||||
Body map[string]any
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/riverqueue/river/rivertype"
|
||||
)
|
||||
@@ -104,6 +105,61 @@ func (s *Service) CancelTask(ctx context.Context, taskID string, user *auth.User
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelVolcesVideoTask extends local queue cancellation with the official
|
||||
// Volces DELETE call once a video task has a persisted remote task id.
|
||||
func (s *Service) CancelVolcesVideoTask(ctx context.Context, task store.GatewayTask, user *auth.User) (TaskCancelResult, error) {
|
||||
local, err := s.CancelTask(ctx, task.ID, user)
|
||||
if err != nil || local.Cancelled || strings.TrimSpace(task.RemoteTaskID) == "" {
|
||||
return local, err
|
||||
}
|
||||
if taskCancelTerminalStatus(task.Status) {
|
||||
return local, nil
|
||||
}
|
||||
var latest store.TaskAttempt
|
||||
for _, attempt := range task.Attempts {
|
||||
if attempt.PlatformModelID != "" && (latest.AttemptNo == 0 || attempt.AttemptNo >= latest.AttemptNo) {
|
||||
latest = attempt
|
||||
}
|
||||
}
|
||||
candidate, found, err := s.store.GetRuntimeModelCandidateForRemoteTask(ctx, latest.PlatformModelID, latest.PlatformID)
|
||||
if err != nil {
|
||||
return TaskCancelResult{}, err
|
||||
}
|
||||
if !found || !isVolcesCancellationCandidate(candidate) {
|
||||
return local, nil
|
||||
}
|
||||
httpClient, err := s.httpClientForCandidate(candidate, false)
|
||||
if err != nil {
|
||||
return TaskCancelResult{}, err
|
||||
}
|
||||
_, _, err = (clients.VolcesClient{HTTPClient: httpClient}).DeleteVideoTask(ctx, clients.Request{
|
||||
Kind: "videos.generations", Candidate: candidate, HTTPClient: httpClient, RemoteTaskID: task.RemoteTaskID,
|
||||
})
|
||||
if err != nil {
|
||||
return TaskCancelResult{}, err
|
||||
}
|
||||
cancelledTask, cancelled, err := s.store.CancelSubmittedTask(ctx, task.ID, task.ExecutionToken, "任务已由火山引擎取消")
|
||||
if err != nil {
|
||||
return TaskCancelResult{}, err
|
||||
}
|
||||
if !cancelled {
|
||||
latestTask, latestErr := s.store.GetTask(ctx, task.ID)
|
||||
if latestErr == nil {
|
||||
return taskCancelUnavailable(latestTask, "任务状态已变化,未覆盖本地最终状态"), nil
|
||||
}
|
||||
return local, nil
|
||||
}
|
||||
if err := s.emit(ctx, cancelledTask.ID, "task.cancelled", "cancelled", "cancelled", 1, "任务已由火山引擎取消", map[string]any{"taskId": cancelledTask.ID, "reason": "upstream_cancel"}, cancelledTask.RunMode == "simulation"); err != nil {
|
||||
return TaskCancelResult{}, err
|
||||
}
|
||||
return TaskCancelResult{TaskID: cancelledTask.ID, Cancelled: true, Cancellable: true, Submitted: true, Message: "任务已由火山引擎取消"}, nil
|
||||
}
|
||||
|
||||
func isVolcesCancellationCandidate(candidate store.RuntimeModelCandidate) bool {
|
||||
provider := strings.ToLower(strings.TrimSpace(candidate.Provider))
|
||||
return provider == "volces" || provider == "volces-openai"
|
||||
}
|
||||
|
||||
func taskCancelUnavailable(task store.GatewayTask, message string) TaskCancelResult {
|
||||
return TaskCancelResult{
|
||||
TaskID: task.ID,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -283,6 +283,21 @@ func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.Us
|
||||
}
|
||||
|
||||
func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) {
|
||||
return s.listPlatformModelsForAccessRules(ctx, user, nil)
|
||||
}
|
||||
|
||||
// ListAPIKeyAssignablePlatformModels returns the enabled models that the
|
||||
// current user may delegate to their API keys. API-key rules are deliberately
|
||||
// excluded here: they restrict individual credentials and must not shrink the
|
||||
// resource pool that the owning user can manage.
|
||||
func (s *Store) ListAPIKeyAssignablePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) {
|
||||
if localGatewayUserID(user) == "" {
|
||||
return nil, ErrLocalUserRequired
|
||||
}
|
||||
return s.listPlatformModelsForAccessRules(ctx, user, map[string]bool{"api_key": true})
|
||||
}
|
||||
|
||||
func (s *Store) listPlatformModelsForAccessRules(ctx context.Context, user *auth.User, excludedSubjectTypes map[string]bool) ([]PlatformModel, error) {
|
||||
accessUser, err := s.resolveCurrentAccessUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -307,7 +322,7 @@ func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.Use
|
||||
enabled = append(enabled, model)
|
||||
}
|
||||
}
|
||||
return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled)
|
||||
return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled, excludedSubjectTypes)
|
||||
}
|
||||
|
||||
func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user *auth.User, resources []AccessRuleResourceInput) error {
|
||||
@@ -328,7 +343,7 @@ func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user
|
||||
}
|
||||
|
||||
func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User) (map[string]bool, error) {
|
||||
models, err := s.ListAccessiblePlatformModels(ctx, user)
|
||||
models, err := s.ListAPIKeyAssignablePlatformModels(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -383,7 +398,12 @@ WHERE id = $1::uuid
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) {
|
||||
func (s *Store) filterPlatformModelsByAccessRules(
|
||||
ctx context.Context,
|
||||
user *auth.User,
|
||||
models []PlatformModel,
|
||||
excludedSubjectTypes map[string]bool,
|
||||
) ([]PlatformModel, error) {
|
||||
if len(models) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
@@ -398,6 +418,12 @@ func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *aut
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
if len(excludedSubjectTypes) > 0 {
|
||||
rules = filterAccessRulesBySubjectType(rules, excludedSubjectTypes)
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
}
|
||||
subjects := accessRuleSubjects(user)
|
||||
level := 0
|
||||
if user != nil {
|
||||
@@ -412,6 +438,17 @@ func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *aut
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func filterAccessRulesBySubjectType(rules []AccessRule, excludedSubjectTypes map[string]bool) []AccessRule {
|
||||
filtered := make([]AccessRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if excludedSubjectTypes[rule.SubjectType] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *Store) listActiveAccessRulesForResources(ctx context.Context, resources []accessRuleResource) ([]AccessRule, error) {
|
||||
values := make([]string, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterAccessRulesBySubjectTypeExcludesAPIKeyRulesOnly(t *testing.T) {
|
||||
rules := []AccessRule{
|
||||
{ID: "api-key-allow", SubjectType: "api_key", Effect: "allow"},
|
||||
{ID: "api-key-deny", SubjectType: "api_key", Effect: "deny"},
|
||||
{ID: "user-group-allow", SubjectType: "user_group", Effect: "allow"},
|
||||
{ID: "user-deny", SubjectType: "user", Effect: "deny"},
|
||||
{ID: "tenant-allow", SubjectType: "tenant", Effect: "allow"},
|
||||
}
|
||||
|
||||
filtered := filterAccessRulesBySubjectType(rules, map[string]bool{"api_key": true})
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("filtered rule count = %d, want 3: %+v", len(filtered), filtered)
|
||||
}
|
||||
for _, rule := range filtered {
|
||||
if rule.SubjectType == "api_key" {
|
||||
t.Fatalf("api-key rule should not affect the owning user's assignable resources: %+v", rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -488,6 +488,8 @@ func modelTypeAliases(value string) []string {
|
||||
return []string{"image_edit"}
|
||||
case "video", "videos.generations":
|
||||
return []string{"video_generate"}
|
||||
case "omni_video":
|
||||
return []string{"video_generate", "image_to_video", "omni_video"}
|
||||
case "song", "music", "song.generations", "music.generations", "music_generate":
|
||||
return []string{"audio_generate"}
|
||||
case "speech", "speech.generations", "tts":
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package store
|
||||
|
||||
// EffectiveBillingConfigInput describes the billing layers used by runtime and
|
||||
// catalog responses. LegacyPlatformModelConfig is retained only as a fallback
|
||||
// for models that do not have an effective pricing rule set.
|
||||
type EffectiveBillingConfigInput struct {
|
||||
BaseConfig map[string]any
|
||||
LegacyPlatformModelConfig map[string]any
|
||||
InheritedRuleSetConfig map[string]any
|
||||
ModelRuleSetConfig map[string]any
|
||||
Override map[string]any
|
||||
}
|
||||
|
||||
// ResolveEffectiveBillingConfig keeps inherited pricing rules authoritative over
|
||||
// the legacy materialized snapshot. Explicit model rules and overrides retain
|
||||
// their higher-priority exception semantics.
|
||||
func ResolveEffectiveBillingConfig(input EffectiveBillingConfigInput) map[string]any {
|
||||
config := mergeObjects(input.BaseConfig, nil)
|
||||
if len(input.InheritedRuleSetConfig) > 0 {
|
||||
// Rule sets are allowed to cover only a subset of resource types. Keep
|
||||
// base-model prices for resources that the inherited rule set does not
|
||||
// define, while letting the rule set remain authoritative for matching
|
||||
// top-level keys.
|
||||
config = mergeObjects(config, input.InheritedRuleSetConfig)
|
||||
} else if len(input.LegacyPlatformModelConfig) > 0 {
|
||||
config = mergeObjects(config, input.LegacyPlatformModelConfig)
|
||||
}
|
||||
if len(input.ModelRuleSetConfig) > 0 {
|
||||
config = mergeObjects(config, input.ModelRuleSetConfig)
|
||||
}
|
||||
return mergeObjects(config, input.Override)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveEffectiveBillingConfigKeepsPricingRulesAuthoritative(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input EffectiveBillingConfigInput
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "inherited rule replaces stale platform snapshot",
|
||||
input: EffectiveBillingConfigInput{
|
||||
BaseConfig: videoBillingConfig(100),
|
||||
LegacyPlatformModelConfig: videoBillingConfig(100),
|
||||
InheritedRuleSetConfig: videoBillingConfig(416),
|
||||
},
|
||||
want: 416,
|
||||
},
|
||||
{
|
||||
name: "legacy snapshot remains a fallback without a rule",
|
||||
input: EffectiveBillingConfigInput{
|
||||
BaseConfig: videoBillingConfig(100),
|
||||
LegacyPlatformModelConfig: videoBillingConfig(125),
|
||||
},
|
||||
want: 125,
|
||||
},
|
||||
{
|
||||
name: "model rule remains an explicit pricing exception",
|
||||
input: EffectiveBillingConfigInput{
|
||||
BaseConfig: videoBillingConfig(100),
|
||||
LegacyPlatformModelConfig: videoBillingConfig(125),
|
||||
InheritedRuleSetConfig: videoBillingConfig(416),
|
||||
ModelRuleSetConfig: videoBillingConfig(500),
|
||||
},
|
||||
want: 500,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
config := ResolveEffectiveBillingConfig(test.input)
|
||||
video, ok := config["video"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected video billing config, got %#v", config)
|
||||
}
|
||||
if got := video["basePrice"]; got != test.want {
|
||||
t.Fatalf("video base price = %#v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveBillingConfigAppliesOverrideLast(t *testing.T) {
|
||||
config := ResolveEffectiveBillingConfig(EffectiveBillingConfigInput{
|
||||
InheritedRuleSetConfig: videoBillingConfig(416),
|
||||
Override: videoBillingConfig(600),
|
||||
})
|
||||
video, ok := config["video"].(map[string]any)
|
||||
if !ok || video["basePrice"] != float64(600) {
|
||||
t.Fatalf("expected override price 600, got %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveBillingConfigPreservesBaseResourcesMissingFromRuleSet(t *testing.T) {
|
||||
config := ResolveEffectiveBillingConfig(EffectiveBillingConfigInput{
|
||||
BaseConfig: map[string]any{
|
||||
"music": map[string]any{"basePrice": float64(20)},
|
||||
"audio": map[string]any{"basePrice": float64(1)},
|
||||
"video": map[string]any{"basePrice": float64(100)},
|
||||
},
|
||||
InheritedRuleSetConfig: map[string]any{
|
||||
"video": map[string]any{"basePrice": float64(416)},
|
||||
},
|
||||
})
|
||||
|
||||
assertBillingBasePrice(t, config, "music", 20)
|
||||
assertBillingBasePrice(t, config, "audio", 1)
|
||||
assertBillingBasePrice(t, config, "video", 416)
|
||||
}
|
||||
|
||||
func assertBillingBasePrice(t *testing.T, config map[string]any, resource string, want float64) {
|
||||
t.Helper()
|
||||
resourceConfig, ok := config[resource].(map[string]any)
|
||||
if !ok || resourceConfig["basePrice"] != want {
|
||||
t.Fatalf("%s base price = %#v, want %v", resource, config[resource], want)
|
||||
}
|
||||
}
|
||||
|
||||
func videoBillingConfig(basePrice float64) map[string]any {
|
||||
return map[string]any{
|
||||
"video": map[string]any{"basePrice": basePrice},
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,19 @@ func TestNormalizeModelMatchKeyRemovesWhitespace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeModelTypeListExpandsOmniVideoBaseCapabilities(t *testing.T) {
|
||||
got := normalizeModelTypeList([]string{"omni_video"})
|
||||
want := StringList{"video_generate", "image_to_video", "omni_video"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("omni_video should include text-to-video and image-to-video capabilities: got=%v want=%v", got, want)
|
||||
}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("omni_video capability mismatch at %d: got=%v want=%v", index, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskBillingModelIdentityKeepsRequestedModelPrimary(t *testing.T) {
|
||||
identity := taskBillingModelIdentity(GatewayTask{
|
||||
Model: "doubao-5.0 图像编辑",
|
||||
|
||||
@@ -23,6 +23,7 @@ type modelCatalogSnapshot struct {
|
||||
DisplayName string
|
||||
Capabilities map[string]any
|
||||
BaseBillingConfig map[string]any
|
||||
PricingRuleSetID string
|
||||
DefaultRateLimitPolicy map[string]any
|
||||
RuntimePolicySetID string
|
||||
RuntimePolicyOverride map[string]any
|
||||
@@ -121,10 +122,10 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier,
|
||||
if err := validateEnabledVolcesTextModelCapabilities(ctx, q, input, capabilities); err != nil {
|
||||
return PlatformModel{}, err
|
||||
}
|
||||
// billing_config is a legacy, explicitly supplied compatibility field. Do
|
||||
// not materialize base-model pricing into it: copied prices become stale as
|
||||
// soon as the base pricing rule changes and can mask the authoritative rule.
|
||||
billingConfig := input.BillingConfig
|
||||
if len(billingConfig) == 0 {
|
||||
billingConfig = mergeObjects(base.BaseBillingConfig, input.BillingConfigOverride)
|
||||
}
|
||||
explicitRuntimePolicySetID := strings.TrimSpace(input.RuntimePolicySetID)
|
||||
rateLimitPolicy := input.RateLimitPolicy
|
||||
if len(rateLimitPolicy) == 0 && explicitRuntimePolicySetID == "" {
|
||||
@@ -260,6 +261,8 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
|
||||
model.ModelType = decodeStringArray(modelTypeBytes)
|
||||
model.BillingConfigOverride = decodeObject(billingOverrideBytes)
|
||||
model.BillingConfig = decodeObject(billingBytes)
|
||||
model.BaseBillingConfig = base.BaseBillingConfig
|
||||
model.BasePricingRuleSetID = base.PricingRuleSetID
|
||||
model.PermissionConfig = decodeObject(permissionBytes)
|
||||
model.RetryPolicy = decodeObject(retryPolicyBytes)
|
||||
model.RateLimitPolicy = decodeObject(rateLimitPolicyBytes)
|
||||
@@ -368,7 +371,7 @@ func (s *Store) lookupBaseModel(ctx context.Context, q platformModelQuerier, id
|
||||
var modelTypeBytes []byte
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT id::text, provider_key, canonical_model_key, provider_model_name, model_type, display_name,
|
||||
capabilities, base_billing_config, default_rate_limit_policy,
|
||||
capabilities, base_billing_config, COALESCE(pricing_rule_set_id::text, ''), default_rate_limit_policy,
|
||||
COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override
|
||||
FROM base_model_catalog
|
||||
WHERE ($1 <> '' AND id = NULLIF($1, '')::uuid)
|
||||
@@ -384,6 +387,7 @@ LIMIT 1`, strings.TrimSpace(id), strings.TrimSpace(canonicalKey), strings.TrimSp
|
||||
&item.DisplayName,
|
||||
&capabilities,
|
||||
&billingConfig,
|
||||
&item.PricingRuleSetID,
|
||||
&rateLimitPolicy,
|
||||
&item.RuntimePolicySetID,
|
||||
&runtimePolicyOverride,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListModelsLoadsEffectiveBillingSources(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the platform-model billing source integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
models, err := db.ListModels(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list models with effective billing sources: %v", err)
|
||||
}
|
||||
for _, model := range models {
|
||||
if model.BaseModelID == "" {
|
||||
continue
|
||||
}
|
||||
if model.BaseBillingConfig == nil {
|
||||
t.Fatalf("platform model %s did not load base billing config", model.ID)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Skip("database has no base-model-backed platform model")
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
)
|
||||
|
||||
type PortraitAsset struct {
|
||||
ID string `json:"id"`
|
||||
GatewayUserID string `json:"gatewayUserId,omitempty"`
|
||||
UserID string `json:"userId"`
|
||||
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
TenantKey string `json:"tenantKey,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SourceType string `json:"sourceType"`
|
||||
URL string `json:"url"`
|
||||
Preview string `json:"preview,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
ByteSize int64 `json:"size,omitempty"`
|
||||
SourceSHA256 string `json:"sourceSha256,omitempty"`
|
||||
PrivateAvatarEligible bool `json:"privateAvatarEligible"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PortraitAssetBinding struct {
|
||||
ID string `json:"id"`
|
||||
AssetID string `json:"assetId"`
|
||||
PlatformID string `json:"platformId"`
|
||||
ProjectName string `json:"projectName,omitempty"`
|
||||
AssetGroupID string `json:"assetGroupId,omitempty"`
|
||||
RemoteAssetID string `json:"remoteAssetId,omitempty"`
|
||||
RemoteAssetURI string `json:"remoteAssetUri,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastErrorCode string `json:"lastErrorCode,omitempty"`
|
||||
LastErrorMessage string `json:"lastErrorMessage,omitempty"`
|
||||
LastSyncedAt string `json:"lastSyncedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PortraitAssetInput struct {
|
||||
GatewayUserID string
|
||||
UserID string
|
||||
GatewayTenantID string
|
||||
TenantID string
|
||||
TenantKey string
|
||||
Name string
|
||||
Description string
|
||||
SourceType string
|
||||
URL string
|
||||
Preview string
|
||||
MimeType string
|
||||
ByteSize int64
|
||||
SourceSHA256 string
|
||||
PrivateAvatarEligible bool
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
type PortraitAssetListFilter struct {
|
||||
Keyword string
|
||||
SourceType string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type PortraitAssetListResult struct {
|
||||
Items []PortraitAsset
|
||||
Total int
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type PortraitAssetPlatform struct {
|
||||
PlatformID string
|
||||
PlatformKey string
|
||||
Provider string
|
||||
Credentials map[string]any
|
||||
Config map[string]any
|
||||
}
|
||||
|
||||
const portraitAssetColumns = `
|
||||
a.id::text, COALESCE(a.gateway_user_id::text, ''), a.user_id,
|
||||
COALESCE(a.gateway_tenant_id::text, ''), COALESCE(a.tenant_id, ''), COALESCE(a.tenant_key, ''),
|
||||
a.name, a.description, a.source_type, a.url, a.preview, a.mime_type, a.byte_size,
|
||||
a.source_sha256, a.private_avatar_eligible, a.status, a.last_error, a.metadata, a.created_at, a.updated_at`
|
||||
|
||||
func (s *Store) CreatePortraitAsset(ctx context.Context, input PortraitAssetInput) (PortraitAsset, error) {
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanPortraitAsset(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_portrait_assets (
|
||||
gateway_user_id, user_id, gateway_tenant_id, tenant_id, tenant_key,
|
||||
name, description, source_type, url, preview, mime_type, byte_size, source_sha256,
|
||||
private_avatar_eligible, status, metadata
|
||||
)
|
||||
VALUES (
|
||||
NULLIF($1, '')::uuid, $2, NULLIF($3, '')::uuid, NULLIF($4, ''), NULLIF($5, ''),
|
||||
$6, $7, $8, $9, $10, $11, $12, $13, $14, 'not_synced', $15::jsonb
|
||||
)
|
||||
RETURNING `+portraitAssetColumns,
|
||||
input.GatewayUserID, input.UserID, input.GatewayTenantID, input.TenantID, input.TenantKey,
|
||||
strings.TrimSpace(input.Name), strings.TrimSpace(input.Description), strings.TrimSpace(input.SourceType),
|
||||
strings.TrimSpace(input.URL), strings.TrimSpace(input.Preview), strings.TrimSpace(input.MimeType), input.ByteSize,
|
||||
strings.TrimSpace(input.SourceSHA256), input.PrivateAvatarEligible, string(metadata),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) FindPortraitAssetBySourceHash(ctx context.Context, user *auth.User, sourceSHA256 string) (PortraitAsset, bool, error) {
|
||||
sourceSHA256 = strings.TrimSpace(sourceSHA256)
|
||||
if sourceSHA256 == "" {
|
||||
return PortraitAsset{}, false, nil
|
||||
}
|
||||
gatewayUserID, userID := portraitAssetUserKeys(user)
|
||||
asset, err := scanPortraitAsset(s.pool.QueryRow(ctx, `
|
||||
SELECT `+portraitAssetColumns+`
|
||||
FROM gateway_portrait_assets a
|
||||
WHERE ((NULLIF($1, '')::uuid IS NOT NULL AND a.gateway_user_id = NULLIF($1, '')::uuid)
|
||||
OR (NULLIF($2, '') IS NOT NULL AND a.user_id = $2))
|
||||
AND a.source_sha256 = $3
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 1`, gatewayUserID, userID, sourceSHA256))
|
||||
if IsNotFound(err) {
|
||||
return PortraitAsset{}, false, nil
|
||||
}
|
||||
return asset, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) FindPortraitAssetForUser(ctx context.Context, user *auth.User, assetID string) (PortraitAsset, bool, error) {
|
||||
assetID = strings.TrimSpace(assetID)
|
||||
if assetID == "" {
|
||||
return PortraitAsset{}, false, nil
|
||||
}
|
||||
gatewayUserID, userID := portraitAssetUserKeys(user)
|
||||
asset, err := scanPortraitAsset(s.pool.QueryRow(ctx, `
|
||||
SELECT `+portraitAssetColumns+`
|
||||
FROM gateway_portrait_assets a
|
||||
WHERE a.id = NULLIF($3, '')::uuid
|
||||
AND ((NULLIF($1, '')::uuid IS NOT NULL AND a.gateway_user_id = NULLIF($1, '')::uuid)
|
||||
OR (NULLIF($2, '') IS NOT NULL AND a.user_id = $2))`, gatewayUserID, userID, assetID))
|
||||
if IsNotFound(err) {
|
||||
return PortraitAsset{}, false, nil
|
||||
}
|
||||
return asset, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) ListPortraitAssets(ctx context.Context, user *auth.User, filter PortraitAssetListFilter) (PortraitAssetListResult, error) {
|
||||
page := filter.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := filter.PageSize
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
gatewayUserID, userID := portraitAssetUserKeys(user)
|
||||
keyword := strings.TrimSpace(filter.Keyword)
|
||||
if keyword != "" {
|
||||
keyword = "%" + keyword + "%"
|
||||
}
|
||||
where := `
|
||||
WHERE ((NULLIF($1, '')::uuid IS NOT NULL AND a.gateway_user_id = NULLIF($1, '')::uuid)
|
||||
OR (NULLIF($2, '') IS NOT NULL AND a.user_id = $2))
|
||||
AND (NULLIF($3, '') IS NULL OR a.source_type = $3)
|
||||
AND (NULLIF($4, '') IS NULL OR a.name ILIKE $4 OR a.description ILIKE $4)`
|
||||
args := []any{gatewayUserID, userID, strings.TrimSpace(filter.SourceType), keyword}
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_portrait_assets a `+where, args...).Scan(&total); err != nil {
|
||||
return PortraitAssetListResult{}, err
|
||||
}
|
||||
args = append(args, pageSize, (page-1)*pageSize)
|
||||
rows, err := s.pool.Query(ctx, `SELECT `+portraitAssetColumns+`
|
||||
FROM gateway_portrait_assets a `+where+`
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT $5 OFFSET $6`, args...)
|
||||
if err != nil {
|
||||
return PortraitAssetListResult{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]PortraitAsset, 0)
|
||||
for rows.Next() {
|
||||
asset, err := scanPortraitAsset(rows)
|
||||
if err != nil {
|
||||
return PortraitAssetListResult{}, err
|
||||
}
|
||||
items = append(items, asset)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return PortraitAssetListResult{}, err
|
||||
}
|
||||
return PortraitAssetListResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetPortraitAssetBinding(ctx context.Context, assetID string, platformID string) (PortraitAssetBinding, bool, error) {
|
||||
binding, err := scanPortraitAssetBinding(s.pool.QueryRow(ctx, `
|
||||
SELECT id::text, asset_id::text, platform_id::text, project_name, asset_group_id,
|
||||
remote_asset_id, remote_asset_uri, status, last_error_code, last_error_message,
|
||||
COALESCE(last_synced_at::text, ''), created_at, updated_at
|
||||
FROM gateway_portrait_asset_bindings
|
||||
WHERE asset_id = $1::uuid AND platform_id = $2::uuid`, assetID, platformID))
|
||||
if IsNotFound(err) {
|
||||
return PortraitAssetBinding{}, false, nil
|
||||
}
|
||||
return binding, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertPortraitAssetBinding(ctx context.Context, binding PortraitAssetBinding) (PortraitAssetBinding, error) {
|
||||
return scanPortraitAssetBinding(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_portrait_asset_bindings (
|
||||
asset_id, platform_id, project_name, asset_group_id, remote_asset_id, remote_asset_uri,
|
||||
status, last_error_code, last_error_message, last_synced_at
|
||||
)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8, $9, now())
|
||||
ON CONFLICT (asset_id, platform_id) DO UPDATE SET
|
||||
project_name = EXCLUDED.project_name,
|
||||
asset_group_id = EXCLUDED.asset_group_id,
|
||||
remote_asset_id = CASE WHEN EXCLUDED.remote_asset_id <> '' THEN EXCLUDED.remote_asset_id ELSE gateway_portrait_asset_bindings.remote_asset_id END,
|
||||
remote_asset_uri = CASE WHEN EXCLUDED.remote_asset_uri <> '' THEN EXCLUDED.remote_asset_uri ELSE gateway_portrait_asset_bindings.remote_asset_uri END,
|
||||
status = EXCLUDED.status,
|
||||
last_error_code = EXCLUDED.last_error_code,
|
||||
last_error_message = EXCLUDED.last_error_message,
|
||||
last_synced_at = now(),
|
||||
updated_at = now()
|
||||
RETURNING id::text, asset_id::text, platform_id::text, project_name, asset_group_id,
|
||||
remote_asset_id, remote_asset_uri, status, last_error_code, last_error_message,
|
||||
COALESCE(last_synced_at::text, ''), created_at, updated_at`,
|
||||
binding.AssetID, binding.PlatformID, strings.TrimSpace(binding.ProjectName), strings.TrimSpace(binding.AssetGroupID),
|
||||
strings.TrimSpace(binding.RemoteAssetID), strings.TrimSpace(binding.RemoteAssetURI), strings.TrimSpace(binding.Status),
|
||||
strings.TrimSpace(binding.LastErrorCode), strings.TrimSpace(binding.LastErrorMessage),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePortraitAssetStatus(ctx context.Context, assetID string, status string, lastError string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE gateway_portrait_assets
|
||||
SET status = $2, last_error = $3, updated_at = now()
|
||||
WHERE id = $1::uuid`, assetID, strings.TrimSpace(status), strings.TrimSpace(lastError))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) PortraitAssetBindingSummary(ctx context.Context, assetID string) (active int, total int, latestError string, updatedAt string, err error) {
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FILTER (WHERE status = 'active'), COUNT(*),
|
||||
COALESCE((ARRAY_AGG(NULLIF(last_error_message, '') ORDER BY updated_at DESC) FILTER (WHERE NULLIF(last_error_message, '') IS NOT NULL))[1], ''),
|
||||
COALESCE(MAX(updated_at)::text, '')
|
||||
FROM gateway_portrait_asset_bindings
|
||||
WHERE asset_id = $1::uuid`, assetID).Scan(&active, &total, &latestError, &updatedAt)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) ListPortraitAssetPlatforms(ctx context.Context) ([]PortraitAssetPlatform, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT p.id::text, p.platform_key, p.provider, p.credentials, p.config
|
||||
FROM integration_platforms p
|
||||
WHERE p.deleted_at IS NULL
|
||||
AND p.status = 'enabled'
|
||||
AND LOWER(p.provider) IN ('volces', 'volces-openai')
|
||||
ORDER BY COALESCE(p.dynamic_priority, p.priority), p.created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]PortraitAssetPlatform, 0)
|
||||
for rows.Next() {
|
||||
var item PortraitAssetPlatform
|
||||
var credentials, config []byte
|
||||
if err := rows.Scan(&item.PlatformID, &item.PlatformKey, &item.Provider, &credentials, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Credentials = decodeObject(credentials)
|
||||
item.Config = decodeObject(config)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func portraitAssetUserKeys(user *auth.User) (string, string) {
|
||||
if user == nil {
|
||||
return "", ""
|
||||
}
|
||||
gatewayUserID := strings.TrimSpace(user.GatewayUserID)
|
||||
if gatewayUserID == "" && user.Source == "gateway" {
|
||||
gatewayUserID = strings.TrimSpace(user.ID)
|
||||
}
|
||||
return gatewayUserID, strings.TrimSpace(user.ID)
|
||||
}
|
||||
|
||||
type portraitAssetScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanPortraitAsset(scanner portraitAssetScanner) (PortraitAsset, error) {
|
||||
var asset PortraitAsset
|
||||
var metadata []byte
|
||||
err := scanner.Scan(
|
||||
&asset.ID, &asset.GatewayUserID, &asset.UserID, &asset.GatewayTenantID, &asset.TenantID, &asset.TenantKey,
|
||||
&asset.Name, &asset.Description, &asset.SourceType, &asset.URL, &asset.Preview, &asset.MimeType, &asset.ByteSize,
|
||||
&asset.SourceSHA256, &asset.PrivateAvatarEligible, &asset.Status, &asset.LastError, &metadata, &asset.CreatedAt, &asset.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return PortraitAsset{}, err
|
||||
}
|
||||
asset.Metadata = decodeObject(metadata)
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func scanPortraitAssetBinding(scanner portraitAssetScanner) (PortraitAssetBinding, error) {
|
||||
var binding PortraitAssetBinding
|
||||
if err := scanner.Scan(
|
||||
&binding.ID, &binding.AssetID, &binding.PlatformID, &binding.ProjectName, &binding.AssetGroupID,
|
||||
&binding.RemoteAssetID, &binding.RemoteAssetURI, &binding.Status, &binding.LastErrorCode, &binding.LastErrorMessage,
|
||||
&binding.LastSyncedAt, &binding.CreatedAt, &binding.UpdatedAt,
|
||||
); err != nil {
|
||||
return PortraitAssetBinding{}, err
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
@@ -217,33 +217,36 @@ type CreatedAPIKey struct {
|
||||
}
|
||||
|
||||
type PlatformModel struct {
|
||||
ID string `json:"id"`
|
||||
PlatformID string `json:"platformId"`
|
||||
BaseModelID string `json:"baseModelId,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
PlatformName string `json:"platformName,omitempty"`
|
||||
ModelName string `json:"modelName"`
|
||||
ProviderModelName string `json:"providerModelName,omitempty"`
|
||||
ModelAlias string `json:"modelAlias,omitempty"`
|
||||
ModelType StringList `json:"modelType"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CapabilityOverride map[string]any `json:"capabilityOverride,omitempty"`
|
||||
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||
BaseCapabilities map[string]any `json:"-"`
|
||||
PricingMode string `json:"pricingMode"`
|
||||
DiscountFactor float64 `json:"discountFactor,omitempty"`
|
||||
PricingRuleSetID string `json:"pricingRuleSetId,omitempty"`
|
||||
BillingConfigOverride map[string]any `json:"billingConfigOverride,omitempty"`
|
||||
BillingConfig map[string]any `json:"billingConfig,omitempty"`
|
||||
PermissionConfig map[string]any `json:"permissionConfig,omitempty"`
|
||||
RetryPolicy map[string]any `json:"retryPolicy,omitempty"`
|
||||
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
|
||||
RuntimePolicySetID string `json:"runtimePolicySetId,omitempty"`
|
||||
RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride,omitempty"`
|
||||
CooldownUntil string `json:"cooldownUntil,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
PlatformID string `json:"platformId"`
|
||||
BaseModelID string `json:"baseModelId,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
PlatformName string `json:"platformName,omitempty"`
|
||||
ModelName string `json:"modelName"`
|
||||
ProviderModelName string `json:"providerModelName,omitempty"`
|
||||
ModelAlias string `json:"modelAlias,omitempty"`
|
||||
ModelType StringList `json:"modelType"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CapabilityOverride map[string]any `json:"capabilityOverride,omitempty"`
|
||||
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||
BaseCapabilities map[string]any `json:"-"`
|
||||
BaseBillingConfig map[string]any `json:"-"`
|
||||
BasePricingRuleSetID string `json:"-"`
|
||||
PlatformPricingRuleSetID string `json:"-"`
|
||||
PricingMode string `json:"pricingMode"`
|
||||
DiscountFactor float64 `json:"discountFactor,omitempty"`
|
||||
PricingRuleSetID string `json:"pricingRuleSetId,omitempty"`
|
||||
BillingConfigOverride map[string]any `json:"billingConfigOverride,omitempty"`
|
||||
BillingConfig map[string]any `json:"billingConfig,omitempty"`
|
||||
PermissionConfig map[string]any `json:"permissionConfig,omitempty"`
|
||||
RetryPolicy map[string]any `json:"retryPolicy,omitempty"`
|
||||
RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"`
|
||||
RuntimePolicySetID string `json:"runtimePolicySetId,omitempty"`
|
||||
RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride,omitempty"`
|
||||
CooldownUntil string `json:"cooldownUntil,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AccessRule struct {
|
||||
@@ -927,7 +930,9 @@ func (s *Store) listModels(ctx context.Context, platformID string) ([]PlatformMo
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name,
|
||||
m.model_name, COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), COALESCE(m.model_alias, ''), m.model_type, m.display_name,
|
||||
m.capability_override, m.capabilities, COALESCE(b.capabilities, '{}'::jsonb), m.pricing_mode, COALESCE(m.discount_factor, 0)::float8,
|
||||
m.capability_override, m.capabilities, COALESCE(b.capabilities, '{}'::jsonb),
|
||||
COALESCE(b.base_billing_config, '{}'::jsonb), COALESCE(b.pricing_rule_set_id::text, ''),
|
||||
COALESCE(p.pricing_rule_set_id::text, ''), m.pricing_mode, COALESCE(m.discount_factor, 0)::float8,
|
||||
COALESCE(m.pricing_rule_set_id::text, ''), m.billing_config_override, m.billing_config,
|
||||
m.permission_config, m.retry_policy, m.rate_limit_policy, COALESCE(m.runtime_policy_set_id::text, ''), m.runtime_policy_override,
|
||||
COALESCE(to_char(m.cooldown_until AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), ''),
|
||||
@@ -935,7 +940,7 @@ SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.p
|
||||
FROM platform_models m
|
||||
JOIN integration_platforms p ON p.id = m.platform_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT catalog.capabilities
|
||||
SELECT catalog.capabilities, catalog.base_billing_config, catalog.pricing_rule_set_id
|
||||
FROM base_model_catalog catalog
|
||||
WHERE (m.base_model_id IS NOT NULL AND catalog.id = m.base_model_id)
|
||||
OR (
|
||||
@@ -962,6 +967,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...)
|
||||
var capabilityOverride []byte
|
||||
var capabilities []byte
|
||||
var baseCapabilities []byte
|
||||
var baseBillingConfig []byte
|
||||
var billingConfigOverride []byte
|
||||
var billingConfig []byte
|
||||
var permissionConfig []byte
|
||||
@@ -983,6 +989,9 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...)
|
||||
&capabilityOverride,
|
||||
&capabilities,
|
||||
&baseCapabilities,
|
||||
&baseBillingConfig,
|
||||
&model.BasePricingRuleSetID,
|
||||
&model.PlatformPricingRuleSetID,
|
||||
&model.PricingMode,
|
||||
&model.DiscountFactor,
|
||||
&model.PricingRuleSetID,
|
||||
@@ -1003,6 +1012,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...)
|
||||
model.CapabilityOverride = decodeObject(capabilityOverride)
|
||||
model.Capabilities = decodeObject(capabilities)
|
||||
model.BaseCapabilities = decodeObject(baseCapabilities)
|
||||
model.BaseBillingConfig = decodeObject(baseBillingConfig)
|
||||
model.ModelType = decodeStringArray(modelTypeBytes)
|
||||
model.BillingConfigOverride = decodeObject(billingConfigOverride)
|
||||
model.BillingConfig = decodeObject(billingConfig)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetRuntimeModelCandidateForRemoteTask restores the exact platform model used
|
||||
// to submit an asynchronous provider task. It deliberately ignores enabled
|
||||
// state so a task can still be cancelled after its platform is disabled.
|
||||
func (s *Store) GetRuntimeModelCandidateForRemoteTask(ctx context.Context, platformModelID string, platformID string) (RuntimeModelCandidate, bool, error) {
|
||||
platformModelID = strings.TrimSpace(platformModelID)
|
||||
platformID = strings.TrimSpace(platformID)
|
||||
if platformModelID == "" || platformID == "" {
|
||||
return RuntimeModelCandidate{}, false, nil
|
||||
}
|
||||
var candidate RuntimeModelCandidate
|
||||
var credentials, config []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT p.id::text, p.platform_key, p.name, p.provider,
|
||||
COALESCE(NULLIF(p.config->>'specType', ''), p.provider), COALESCE(p.base_url, ''), p.auth_type,
|
||||
p.credentials, p.config, m.id::text, COALESCE(NULLIF(m.provider_model_name, ''), m.model_name),
|
||||
m.model_name, COALESCE(m.model_alias, ''),
|
||||
COALESCE((m.model_type->>0), 'video_generate')
|
||||
FROM platform_models m
|
||||
JOIN integration_platforms p ON p.id = m.platform_id
|
||||
WHERE m.id = $1::uuid AND p.id = $2::uuid AND p.deleted_at IS NULL`, platformModelID, platformID).Scan(
|
||||
&candidate.PlatformID, &candidate.PlatformKey, &candidate.PlatformName, &candidate.Provider,
|
||||
&candidate.SpecType, &candidate.BaseURL, &candidate.AuthType, &credentials, &config,
|
||||
&candidate.PlatformModelID, &candidate.ProviderModelName, &candidate.ModelName, &candidate.ModelAlias, &candidate.ModelType,
|
||||
)
|
||||
if IsNotFound(err) {
|
||||
return RuntimeModelCandidate{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RuntimeModelCandidate{}, false, err
|
||||
}
|
||||
candidate.Credentials = decodeObject(credentials)
|
||||
candidate.PlatformConfig = decodeObject(config)
|
||||
candidate.ClientID = candidate.PlatformKey + ":" + candidate.ModelType + ":" + firstNonEmpty(candidate.ProviderModelName, candidate.ModelName)
|
||||
candidate.QueueKey = candidate.ClientID
|
||||
return candidate, true, nil
|
||||
}
|
||||
@@ -530,7 +530,8 @@ WHERE id = $1::uuid
|
||||
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)
|
||||
WHERE id = $1::uuid`,
|
||||
WHERE id = $1::uuid
|
||||
AND status = 'running'`,
|
||||
attemptID,
|
||||
remoteTaskID,
|
||||
string(payloadJSON),
|
||||
@@ -585,6 +586,72 @@ WHERE id = $1::uuid
|
||||
return task, true, nil
|
||||
}
|
||||
|
||||
// CancelSubmittedTask records a confirmed upstream cancellation. Callers must
|
||||
// first complete the provider-side DELETE so local status never claims a remote
|
||||
// task was cancelled when the upstream request was not accepted.
|
||||
func (s *Store) CancelSubmittedTask(ctx context.Context, taskID string, executionToken string, message string) (GatewayTask, bool, error) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
message = "任务已由上游取消"
|
||||
}
|
||||
var task GatewayTask
|
||||
changed := false
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
var err error
|
||||
task, err = scanGatewayTask(tx.QueryRow(ctx, `
|
||||
UPDATE gateway_tasks
|
||||
SET status = 'cancelled',
|
||||
error = NULLIF($2, ''),
|
||||
error_code = 'task_cancelled',
|
||||
error_message = NULLIF($2, ''),
|
||||
billing_status = CASE
|
||||
WHEN run_mode <> 'production' OR gateway_user_id IS NULL THEN 'not_required'
|
||||
WHEN reservation_amount > 0 THEN 'pending'
|
||||
ELSE 'released'
|
||||
END,
|
||||
billing_updated_at = now(),
|
||||
locked_by = NULL,
|
||||
locked_at = NULL,
|
||||
heartbeat_at = NULL,
|
||||
execution_token = NULL,
|
||||
execution_lease_expires_at = NULL,
|
||||
finished_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND NULLIF(remote_task_id, '') IS NOT NULL
|
||||
AND (
|
||||
(status = 'running' AND execution_token = NULLIF($3, '')::uuid)
|
||||
OR status = 'queued'
|
||||
)
|
||||
RETURNING `+gatewayTaskColumns, taskID, message, strings.TrimSpace(executionToken)))
|
||||
if IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
payloadJSON, _ := json.Marshal(map[string]any{"taskId": taskID, "reason": "upstream_cancelled"})
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO settlement_outbox (
|
||||
task_id, event_type, action, amount, currency, pricing_snapshot, payload, status, next_attempt_at
|
||||
)
|
||||
SELECT id, 'task.billing.release', 'release', reservation_amount, billing_currency,
|
||||
pricing_snapshot, $2::jsonb, 'pending', now()
|
||||
FROM gateway_tasks
|
||||
WHERE id = $1::uuid
|
||||
AND run_mode = 'production'
|
||||
AND gateway_user_id IS NOT NULL
|
||||
AND reservation_amount > 0
|
||||
ON CONFLICT (task_id, event_type) DO NOTHING`, taskID, string(payloadJSON))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return GatewayTask{}, false, err
|
||||
}
|
||||
return task, changed, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRecoverableAsyncTasks(ctx context.Context, limit int) ([]AsyncTaskQueueItem, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
)
|
||||
|
||||
// VolcesCompatibleTaskListFilter mirrors the supported filters of Ark's
|
||||
// ListContentsGenerationsTasks API. Task IDs are the gateway's public task
|
||||
// IDs, which are the IDs returned by the compatibility create endpoint.
|
||||
type VolcesCompatibleTaskListFilter struct {
|
||||
CompatibilityMarker string
|
||||
Status string
|
||||
Model string
|
||||
TaskIDs []string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ListVolcesCompatibleTasks returns only video tasks created through a named
|
||||
// compatibility surface. Keeping this query separate from ListTasks avoids
|
||||
// broadening the ordinary task-list API's filtering semantics.
|
||||
func (s *Store) ListVolcesCompatibleTasks(ctx context.Context, user *auth.User, filter VolcesCompatibleTaskListFilter) (TaskListResult, error) {
|
||||
page := filter.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > 500 {
|
||||
page = 500
|
||||
}
|
||||
pageSize := filter.PageSize
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 500 {
|
||||
pageSize = 500
|
||||
}
|
||||
gatewayUserID := localGatewayUserID(user)
|
||||
userID, apiKeyID := "", ""
|
||||
if user != nil {
|
||||
userID = strings.TrimSpace(user.ID)
|
||||
apiKeyID = strings.TrimSpace(user.APIKeyID)
|
||||
}
|
||||
if gatewayUserID == "" && userID == "" {
|
||||
return TaskListResult{}, ErrLocalUserRequired
|
||||
}
|
||||
taskIDs := make([]string, 0, len(filter.TaskIDs))
|
||||
seen := make(map[string]bool, len(filter.TaskIDs))
|
||||
for _, taskID := range filter.TaskIDs {
|
||||
taskID = strings.TrimSpace(taskID)
|
||||
if taskID != "" && !seen[taskID] {
|
||||
seen[taskID] = true
|
||||
taskIDs = append(taskIDs, taskID)
|
||||
}
|
||||
}
|
||||
args := []any{
|
||||
gatewayUserID,
|
||||
userID,
|
||||
apiKeyID,
|
||||
strings.TrimSpace(filter.CompatibilityMarker),
|
||||
strings.ToLower(strings.TrimSpace(filter.Status)),
|
||||
strings.TrimSpace(filter.Model),
|
||||
taskIDs,
|
||||
}
|
||||
whereSQL := `
|
||||
WHERE (
|
||||
(
|
||||
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 kind = 'videos.generations'
|
||||
AND request->>'_gateway_compatibility' = $4
|
||||
AND (NULLIF($5, '') IS NULL OR LOWER(status) = $5)
|
||||
AND (NULLIF($6, '') IS NULL OR model = $6 OR resolved_model = $6)
|
||||
AND (COALESCE(array_length($7::text[], 1), 0) = 0 OR id::text = ANY($7::text[]))`
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks `+whereSQL, args...).Scan(&total); err != nil {
|
||||
return TaskListResult{}, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+gatewayTaskColumns+`
|
||||
FROM gateway_tasks
|
||||
`+whereSQL+`
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $8 OFFSET $9`, append(args, pageSize, (page-1)*pageSize)...)
|
||||
if err != nil {
|
||||
return TaskListResult{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]GatewayTask, 0)
|
||||
for rows.Next() {
|
||||
task, err := scanGatewayTask(rows)
|
||||
if err != nil {
|
||||
return TaskListResult{}, err
|
||||
}
|
||||
items = append(items, task)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return TaskListResult{}, err
|
||||
}
|
||||
return TaskListResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
-- GLM-5.2 is a text-only foundation model. The official model page lists text
|
||||
-- as both its input and output modality; vision belongs to the separate GLM-5V
|
||||
-- family. Keep the base catalog, snapshots, and already-created platform rows
|
||||
-- authoritative so stale/customized image_analysis metadata cannot leak back
|
||||
-- into model discovery.
|
||||
-- Source: https://docs.bigmodel.cn/cn/guide/models/text/glm-5.2
|
||||
|
||||
WITH glm52_contract AS (
|
||||
SELECT
|
||||
'["text_generate","tools_call"]'::jsonb AS model_type,
|
||||
'{
|
||||
"text_generate": {
|
||||
"supportedApiProtocols": ["openai_chat_completions"],
|
||||
"max_context_tokens": 1000000,
|
||||
"max_output_tokens": 131072,
|
||||
"supportTool": true,
|
||||
"supportThinking": true,
|
||||
"supportThinkingModeSwitch": true,
|
||||
"thinkingEffortLevels": ["none", "high", "max"],
|
||||
"supportStructuredOutput": true
|
||||
},
|
||||
"tools_call": {
|
||||
"supportedApiProtocols": ["openai_chat_completions"],
|
||||
"max_context_tokens": 1000000,
|
||||
"max_output_tokens": 131072,
|
||||
"supportTool": true,
|
||||
"supportThinking": true,
|
||||
"supportThinkingModeSwitch": true,
|
||||
"thinkingEffortLevels": ["none", "high", "max"],
|
||||
"supportStructuredOutput": true
|
||||
},
|
||||
"originalTypes": ["text_generate", "tools_call"]
|
||||
}'::jsonb AS capabilities,
|
||||
'旗舰 Coding 文本模型(不支持图像/视频理解),1M 上下文,最大输出 128K;支持思考及推理强度、流式输出/工具调用、结构化输出与隐式上下文缓存。'::text AS description
|
||||
),
|
||||
updated_base_models AS (
|
||||
UPDATE base_model_catalog base_model
|
||||
SET model_type = contract.model_type,
|
||||
capabilities = contract.capabilities,
|
||||
metadata = COALESCE(base_model.metadata, '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'originalTypes', contract.model_type,
|
||||
'description', contract.description,
|
||||
'rawModel', COALESCE(base_model.metadata->'rawModel', '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'types', contract.model_type,
|
||||
'description', contract.description,
|
||||
'capabilities', contract.capabilities - 'originalTypes'
|
||||
)
|
||||
),
|
||||
default_snapshot = CASE
|
||||
WHEN COALESCE(base_model.default_snapshot, '{}'::jsonb) = '{}'::jsonb THEN base_model.default_snapshot
|
||||
ELSE COALESCE(base_model.default_snapshot, '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'modelType', contract.model_type,
|
||||
'capabilities', contract.capabilities,
|
||||
'metadata', COALESCE(base_model.default_snapshot->'metadata', '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'originalTypes', contract.model_type,
|
||||
'description', contract.description,
|
||||
'rawModel', COALESCE(base_model.default_snapshot->'metadata'->'rawModel', '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'types', contract.model_type,
|
||||
'description', contract.description,
|
||||
'capabilities', contract.capabilities - 'originalTypes'
|
||||
)
|
||||
)
|
||||
)
|
||||
END,
|
||||
updated_at = now()
|
||||
FROM glm52_contract contract
|
||||
WHERE (
|
||||
base_model.canonical_model_key IN ('easyai:GLM-5.2', 'zhipu-openai:glm-5.2')
|
||||
OR (
|
||||
base_model.provider_key = 'easyai'
|
||||
AND lower(base_model.provider_model_name) = 'glm-5.2'
|
||||
)
|
||||
OR (
|
||||
base_model.provider_key = 'zhipu-openai'
|
||||
AND lower(base_model.provider_model_name) = 'glm-5.2'
|
||||
)
|
||||
)
|
||||
RETURNING base_model.id
|
||||
)
|
||||
UPDATE platform_models platform_model
|
||||
SET model_type = contract.model_type,
|
||||
capabilities = contract.capabilities,
|
||||
capability_override = (COALESCE(platform_model.capability_override, '{}'::jsonb) - 'image_analysis' - 'video_understanding' - 'originalTypes'),
|
||||
updated_at = now()
|
||||
FROM integration_platforms platform
|
||||
CROSS JOIN glm52_contract contract
|
||||
WHERE platform_model.platform_id = platform.id
|
||||
AND platform.deleted_at IS NULL
|
||||
AND (
|
||||
platform_model.base_model_id IN (
|
||||
SELECT id FROM updated_base_models
|
||||
)
|
||||
OR (
|
||||
platform.provider IN ('easyai', 'zhipu-openai')
|
||||
AND lower(COALESCE(NULLIF(platform_model.provider_model_name, ''), platform_model.model_name)) = 'glm-5.2'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
CREATE TABLE IF NOT EXISTS gateway_portrait_assets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL,
|
||||
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
|
||||
tenant_id text,
|
||||
tenant_key text,
|
||||
name text NOT NULL DEFAULT '',
|
||||
description text NOT NULL DEFAULT '',
|
||||
source_type text NOT NULL,
|
||||
url text NOT NULL,
|
||||
preview text NOT NULL DEFAULT '',
|
||||
mime_type text NOT NULL DEFAULT '',
|
||||
byte_size bigint NOT NULL DEFAULT 0,
|
||||
source_sha256 text NOT NULL DEFAULT '',
|
||||
private_avatar_eligible boolean NOT NULL DEFAULT false,
|
||||
status text NOT NULL DEFAULT 'not_synced',
|
||||
last_error text NOT NULL DEFAULT '',
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_portrait_asset_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
asset_id uuid NOT NULL REFERENCES gateway_portrait_assets(id) ON DELETE CASCADE,
|
||||
platform_id uuid NOT NULL REFERENCES integration_platforms(id) ON DELETE CASCADE,
|
||||
project_name text NOT NULL DEFAULT '',
|
||||
asset_group_id text NOT NULL DEFAULT '',
|
||||
remote_asset_id text NOT NULL DEFAULT '',
|
||||
remote_asset_uri text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
last_error_code text NOT NULL DEFAULT '',
|
||||
last_error_message text NOT NULL DEFAULT '',
|
||||
last_synced_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(asset_id, platform_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_portrait_assets_user_created
|
||||
ON gateway_portrait_assets(gateway_user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_portrait_assets_user_id_created
|
||||
ON gateway_portrait_assets(user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_portrait_assets_user_hash
|
||||
ON gateway_portrait_assets(gateway_user_id, source_sha256)
|
||||
WHERE source_sha256 <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_portrait_asset_bindings_asset_platform
|
||||
ON gateway_portrait_asset_bindings(asset_id, platform_id);
|
||||
|
||||
UPDATE base_model_catalog
|
||||
SET capabilities = jsonb_set(
|
||||
COALESCE(capabilities, '{}'::jsonb),
|
||||
'{omni_video,supports_portrait_asset_reference}',
|
||||
'true'::jsonb,
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE provider_key = 'volces'
|
||||
AND provider_model_name LIKE 'doubao-seedance-2-0%'
|
||||
AND model_type @> '["omni_video"]'::jsonb;
|
||||
|
||||
UPDATE platform_models m
|
||||
SET capabilities = jsonb_set(
|
||||
COALESCE(m.capabilities, '{}'::jsonb),
|
||||
'{omni_video,supports_portrait_asset_reference}',
|
||||
'true'::jsonb,
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
FROM integration_platforms p
|
||||
WHERE p.id = m.platform_id
|
||||
AND p.provider = 'volces'
|
||||
AND m.model_type @> '["omni_video"]'::jsonb
|
||||
AND COALESCE(NULLIF(m.provider_model_name, ''), m.model_name) LIKE 'doubao-seedance-2-0%';
|
||||
@@ -0,0 +1,182 @@
|
||||
WITH keling_omni_models(provider_model_name, model_type, capabilities) AS (
|
||||
VALUES
|
||||
(
|
||||
'kling-video-o1',
|
||||
'["video_generate","image_to_video","omni_video"]'::jsonb,
|
||||
'{
|
||||
"video_generate": {
|
||||
"output_resolutions": ["720p", "1080p"],
|
||||
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
|
||||
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10],
|
||||
"output_audio": false,
|
||||
"input_audio": false,
|
||||
"prompt_length_limit": {
|
||||
"max": 2500,
|
||||
"count_mode": "non_ascii_weighted",
|
||||
"label": "可灵口径"
|
||||
}
|
||||
},
|
||||
"image_to_video": {
|
||||
"output_resolutions": ["720p", "1080p"],
|
||||
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
|
||||
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10],
|
||||
"input_first_frame": true,
|
||||
"input_last_frame": false,
|
||||
"input_first_last_frame": true,
|
||||
"input_reference_generate_single": true,
|
||||
"input_reference_generate_multiple": true,
|
||||
"max_images": 7,
|
||||
"max_images_for_last_frame": 2,
|
||||
"support_video_effect_template": false,
|
||||
"output_audio": false,
|
||||
"input_audio": false,
|
||||
"prompt_length_limit": {
|
||||
"max": 2500,
|
||||
"count_mode": "non_ascii_weighted",
|
||||
"label": "可灵口径"
|
||||
}
|
||||
},
|
||||
"omni_video": {
|
||||
"supported_modes": ["text_to_video", "image_reference", "element_reference", "first_last_frame", "video_reference", "video_edit"],
|
||||
"output_resolutions": ["720p", "1080p"],
|
||||
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
|
||||
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10],
|
||||
"output_audio": false,
|
||||
"input_audio": false,
|
||||
"max_videos": 1,
|
||||
"max_audios": 0,
|
||||
"max_images": 7,
|
||||
"max_elements": 7,
|
||||
"max_images_and_elements": 7,
|
||||
"limits_with_video": {"max_images_and_elements": 4},
|
||||
"max_images_for_last_frame": 2,
|
||||
"support_instruction_edit": true,
|
||||
"prompt_length_limit": {
|
||||
"max": 2500,
|
||||
"count_mode": "non_ascii_weighted",
|
||||
"label": "可灵口径"
|
||||
}
|
||||
},
|
||||
"originalTypes": ["video_generate", "image_to_video", "omni_video"]
|
||||
}'::jsonb
|
||||
),
|
||||
(
|
||||
'kling-v3-omni',
|
||||
'["video_generate","image_to_video","omni_video"]'::jsonb,
|
||||
'{
|
||||
"video_generate": {
|
||||
"output_resolutions": ["720p", "1080p", "2160p"],
|
||||
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
|
||||
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
"output_audio": true,
|
||||
"input_audio": false,
|
||||
"prompt_length_limit": {
|
||||
"max": 2500,
|
||||
"count_mode": "non_ascii_weighted",
|
||||
"label": "可灵口径"
|
||||
}
|
||||
},
|
||||
"image_to_video": {
|
||||
"output_resolutions": ["720p", "1080p", "2160p"],
|
||||
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
|
||||
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
"input_first_frame": true,
|
||||
"input_last_frame": false,
|
||||
"input_first_last_frame": true,
|
||||
"input_reference_generate_single": true,
|
||||
"input_reference_generate_multiple": true,
|
||||
"max_images": 7,
|
||||
"max_images_for_last_frame": 2,
|
||||
"support_video_effect_template": false,
|
||||
"output_audio": true,
|
||||
"input_audio": false,
|
||||
"prompt_length_limit": {
|
||||
"max": 2500,
|
||||
"count_mode": "non_ascii_weighted",
|
||||
"label": "可灵口径"
|
||||
}
|
||||
},
|
||||
"omni_video": {
|
||||
"supported_modes": ["text_to_video", "image_reference", "element_reference", "first_last_frame", "video_reference", "video_edit", "multi_shot"],
|
||||
"output_resolutions": ["720p", "1080p", "2160p"],
|
||||
"aspect_ratio_allowed": ["16:9", "1:1", "9:16"],
|
||||
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
"output_audio": true,
|
||||
"input_audio": false,
|
||||
"max_videos": 1,
|
||||
"max_audios": 0,
|
||||
"max_images": 7,
|
||||
"max_elements": 7,
|
||||
"max_images_and_elements": 7,
|
||||
"limits_with_video": {
|
||||
"max_images_and_elements": 4,
|
||||
"duration_options": [3, 4, 5, 6, 7, 8, 9, 10]
|
||||
},
|
||||
"max_images_for_last_frame": 2,
|
||||
"support_instruction_edit": true,
|
||||
"prompt_length_limit": {
|
||||
"max": 2500,
|
||||
"count_mode": "non_ascii_weighted",
|
||||
"label": "可灵口径"
|
||||
}
|
||||
},
|
||||
"originalTypes": ["video_generate", "image_to_video", "omni_video"]
|
||||
}'::jsonb
|
||||
)
|
||||
)
|
||||
UPDATE base_model_catalog model
|
||||
SET model_type = defs.model_type,
|
||||
capabilities = defs.capabilities,
|
||||
metadata = COALESCE(model.metadata, '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'originalTypes', defs.model_type,
|
||||
'rawModel', COALESCE(model.metadata->'rawModel', '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'types', defs.model_type,
|
||||
'capabilities', defs.capabilities
|
||||
)
|
||||
),
|
||||
default_snapshot = CASE
|
||||
WHEN COALESCE(model.default_snapshot, '{}'::jsonb) = '{}'::jsonb THEN model.default_snapshot
|
||||
ELSE jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(model.default_snapshot, '{modelType}', defs.model_type, true),
|
||||
'{capabilities}',
|
||||
defs.capabilities,
|
||||
true
|
||||
),
|
||||
'{metadata}',
|
||||
COALESCE(model.default_snapshot->'metadata', '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'originalTypes', defs.model_type,
|
||||
'rawModel', COALESCE(model.default_snapshot->'metadata'->'rawModel', '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'types', defs.model_type,
|
||||
'capabilities', defs.capabilities
|
||||
)
|
||||
),
|
||||
true
|
||||
)
|
||||
END,
|
||||
updated_at = now()
|
||||
FROM keling_omni_models defs
|
||||
WHERE model.provider_model_name = defs.provider_model_name
|
||||
AND model.model_type @> '["omni_video"]'::jsonb;
|
||||
|
||||
WITH keling_omni_models(provider_model_name, model_type, capabilities) AS (
|
||||
SELECT DISTINCT ON (model.provider_model_name)
|
||||
model.provider_model_name,
|
||||
model.model_type,
|
||||
model.capabilities
|
||||
FROM base_model_catalog model
|
||||
WHERE model.provider_model_name IN ('kling-video-o1', 'kling-v3-omni')
|
||||
AND model.model_type @> '["omni_video"]'::jsonb
|
||||
ORDER BY model.provider_model_name, (model.provider_key = 'keling') DESC, model.created_at ASC
|
||||
)
|
||||
UPDATE platform_models model
|
||||
SET model_type = defs.model_type,
|
||||
capabilities = defs.capabilities,
|
||||
updated_at = now()
|
||||
FROM keling_omni_models defs
|
||||
WHERE COALESCE(NULLIF(model.provider_model_name, ''), model.model_name) = defs.provider_model_name
|
||||
AND model.model_type @> '["omni_video"]'::jsonb;
|
||||
@@ -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": {
|
||||
|
||||
+27
-19
@@ -69,6 +69,7 @@ import {
|
||||
listAccessRules,
|
||||
listAuditLogs,
|
||||
listApiKeyAccessRules,
|
||||
listApiKeyAssignableModels,
|
||||
listApiKeys,
|
||||
listBaseModels,
|
||||
listCatalogProviders,
|
||||
@@ -132,7 +133,7 @@ import {
|
||||
startOIDCLogin,
|
||||
startOIDCLogout,
|
||||
} from './lib/oidc';
|
||||
import { runTask } from './lib/run-task';
|
||||
import { runTask, type RunTaskOptions } from './lib/run-task';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { ApiDocsPage } from './pages/ApiDocsPage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
@@ -176,6 +177,7 @@ type DataKey =
|
||||
| 'publicCatalog'
|
||||
| 'playgroundApiKeys'
|
||||
| 'playgroundModels'
|
||||
| 'apiKeyPolicyModels'
|
||||
| 'modelCatalog'
|
||||
| 'networkProxyConfig'
|
||||
| 'clientCustomizationSettings'
|
||||
@@ -227,6 +229,7 @@ export function App() {
|
||||
summary: { modelCount: 0, sourceCount: 0 },
|
||||
});
|
||||
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
||||
const [apiKeyPolicyModels, setApiKeyPolicyModels] = useState<PlatformModel[]>([]);
|
||||
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
|
||||
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
|
||||
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
|
||||
@@ -530,6 +533,9 @@ export function App() {
|
||||
case 'playgroundModels':
|
||||
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
||||
return;
|
||||
case 'apiKeyPolicyModels':
|
||||
setApiKeyPolicyModels((await listApiKeyAssignableModels(nextToken)).items);
|
||||
return;
|
||||
case 'playgroundApiKeys': {
|
||||
const response = await listPlayableApiKeys(nextToken);
|
||||
setApiKeys(response.items);
|
||||
@@ -687,7 +693,7 @@ export function App() {
|
||||
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
|
||||
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
|
||||
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(input.platformId
|
||||
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
|
||||
@@ -707,7 +713,7 @@ export function App() {
|
||||
const updated = await updatePlatform(token, platform.id, input);
|
||||
const platformForState = withCredentialPreviewFallback(updated, input, platform);
|
||||
setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。');
|
||||
} catch (err) {
|
||||
@@ -739,7 +745,7 @@ export function App() {
|
||||
platformPriority: state.priority,
|
||||
}
|
||||
: status));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。');
|
||||
} catch (err) {
|
||||
@@ -783,7 +789,7 @@ export function App() {
|
||||
cooldownUntil: undefined,
|
||||
}
|
||||
: model));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('模型运行状态已恢复。');
|
||||
} catch (err) {
|
||||
@@ -800,7 +806,7 @@ export function App() {
|
||||
await deletePlatform(token, platformId);
|
||||
setPlatforms((current) => current.filter((item) => item.id !== platformId));
|
||||
setModels((current) => current.filter((item) => item.platformId !== platformId));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('平台已删除。');
|
||||
} catch (err) {
|
||||
@@ -816,6 +822,7 @@ export function App() {
|
||||
try {
|
||||
const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input);
|
||||
setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。');
|
||||
} catch (err) {
|
||||
@@ -831,6 +838,7 @@ export function App() {
|
||||
try {
|
||||
await deleteTenant(token, tenantId);
|
||||
setTenants((current) => current.filter((tenant) => tenant.id !== tenantId));
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('租户已删除。');
|
||||
} catch (err) {
|
||||
@@ -846,7 +854,7 @@ export function App() {
|
||||
try {
|
||||
const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input);
|
||||
setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(userId ? '用户已更新。' : '用户已创建。');
|
||||
} catch (err) {
|
||||
@@ -902,7 +910,7 @@ export function App() {
|
||||
try {
|
||||
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
|
||||
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
|
||||
invalidateDataKeys('modelCatalog');
|
||||
invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
|
||||
} catch (err) {
|
||||
@@ -920,8 +928,7 @@ export function App() {
|
||||
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
||||
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
||||
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
||||
invalidateDataKeys('modelCatalog');
|
||||
invalidateDataKeys('playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('用户组已删除。');
|
||||
} catch (err) {
|
||||
@@ -975,7 +982,7 @@ export function App() {
|
||||
try {
|
||||
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
||||
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
||||
} catch (err) {
|
||||
@@ -991,7 +998,7 @@ export function App() {
|
||||
try {
|
||||
await deleteAccessRule(token, ruleId);
|
||||
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('访问权限规则已删除。');
|
||||
} catch (err) {
|
||||
@@ -1007,7 +1014,7 @@ export function App() {
|
||||
try {
|
||||
const response = await batchAccessRules(token, input);
|
||||
setAccessRules(response.items);
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('访问权限已更新。');
|
||||
} catch (err) {
|
||||
@@ -1142,7 +1149,7 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTask(event: FormEvent<HTMLFormElement>) {
|
||||
async function submitTask(event: FormEvent<HTMLFormElement>, options: RunTaskOptions = {}) {
|
||||
event.preventDefault();
|
||||
const selectedApiKeySecret = selectedPlaygroundApiKeyId ? apiKeySecretsById[selectedPlaygroundApiKeyId] ?? '' : '';
|
||||
const fallbackApiKeySecret = apiKeys.find((item) => Boolean(apiKeySecretsById[item.id]))?.id;
|
||||
@@ -1153,11 +1160,12 @@ export function App() {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const response = await runTask(credential, taskForm);
|
||||
const response = await runTask(credential, taskForm, options);
|
||||
const completionMessage = response.submissionMode === 'simulation' ? '完成测试模式运行' : '完成真实提交';
|
||||
if (response.localOnly) {
|
||||
setTaskResult(response.task);
|
||||
setCoreState('ready');
|
||||
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`);
|
||||
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} ${completionMessage}。`);
|
||||
return;
|
||||
}
|
||||
const syncTask = (detail: GatewayTask) => {
|
||||
@@ -1169,7 +1177,7 @@ export function App() {
|
||||
setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]);
|
||||
invalidateDataKeys('tasks', 'wallet', 'walletTransactions');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`);
|
||||
setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} ${completionMessage}。`);
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '测试任务失败');
|
||||
@@ -1355,7 +1363,7 @@ export function App() {
|
||||
apiKeyForm={apiKeyForm}
|
||||
apiKeySecret={apiKeySecret}
|
||||
apiKeySecretsById={apiKeySecretsById}
|
||||
apiKeyPolicyModels={playgroundModels}
|
||||
apiKeyPolicyModels={apiKeyPolicyModels}
|
||||
data={data}
|
||||
message={coreMessage}
|
||||
section={workspaceSection}
|
||||
@@ -1622,7 +1630,7 @@ function dataKeysForRoute(
|
||||
if (activePage === 'workspace') {
|
||||
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
|
||||
if (workspaceSection === 'billing') return ['wallet'];
|
||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels'];
|
||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'apiKeyPolicyModels'];
|
||||
if (workspaceSection === 'tasks') return ['tasks'];
|
||||
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
|
||||
return [];
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
getCurrentUser,
|
||||
getOpsManagementSkillMetadata,
|
||||
loginLocalAccount,
|
||||
listApiKeyAssignableModels,
|
||||
OIDC_BROWSER_SESSION_CREDENTIAL,
|
||||
startIdentityPairing,
|
||||
retireIdentityPairingSecurityEventConflict,
|
||||
validateIdentityRevision,
|
||||
} from './api';
|
||||
import { applyTaskSubmissionMode, runTask } from './lib/run-task';
|
||||
|
||||
describe('local login transport', () => {
|
||||
afterEach(() => {
|
||||
@@ -231,6 +233,26 @@ describe('OIDC browser session transport', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key permission resources', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads the user-owned resource pool independently from playable models', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await listApiKeyAssignableModels('user-token');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain('/api/v1/api-keys/assignable-models');
|
||||
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer user-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public Agent resources', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -244,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,
|
||||
@@ -295,4 +317,46 @@ describe('API documentation runner transports', () => {
|
||||
expect(url).toContain('/api/v1/tasks/task-123');
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('removes every simulation switch from a real submission while preserving edited parameters', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'chatcmpl-real' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await runTask(
|
||||
'sk-test',
|
||||
{ kind: 'chat.completions', model: 'gpt-fallback', prompt: 'fallback' },
|
||||
{
|
||||
submissionMode: 'production',
|
||||
requestBody: {
|
||||
model: 'gpt-real',
|
||||
messages: [{ role: 'user', content: 'edited body' }],
|
||||
temperature: 0.25,
|
||||
runMode: 'simulation',
|
||||
run_mode: 'simulation',
|
||||
simulation: true,
|
||||
testMode: true,
|
||||
test_mode: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
model: 'gpt-real',
|
||||
messages: [{ role: 'user', content: 'edited body' }],
|
||||
temperature: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses canonical simulation parameters without removing a model-specific mode field', () => {
|
||||
expect(applyTaskSubmissionMode({ model: 'video-model', mode: 'pro', testMode: false }, 'simulation')).toEqual({
|
||||
model: 'video-model',
|
||||
mode: 'pro',
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -459,6 +459,10 @@ export async function listApiKeyAccessRules(token: string): Promise<ListResponse
|
||||
return request<ListResponse<GatewayAccessRule>>('/api/v1/api-keys/access-rules', { token });
|
||||
}
|
||||
|
||||
export async function listApiKeyAssignableModels(token: string): Promise<ListResponse<PlatformModel>> {
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/api-keys/assignable-models', { token });
|
||||
}
|
||||
|
||||
export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise<GatewayAccessRule> {
|
||||
return request<GatewayAccessRule>('/api/admin/access-rules', {
|
||||
body: input,
|
||||
|
||||
@@ -9,101 +9,97 @@ import {
|
||||
createVideoGenerationTask,
|
||||
getAPITask,
|
||||
} from '../api';
|
||||
import type { TaskForm } from '../types';
|
||||
import type { TaskForm, TaskSubmissionMode } from '../types';
|
||||
|
||||
export interface RunTaskResponse {
|
||||
localOnly?: boolean;
|
||||
next?: Record<string, string>;
|
||||
submissionMode: TaskSubmissionMode;
|
||||
task: GatewayTask;
|
||||
}
|
||||
|
||||
export async function runTask(token: string, task: TaskForm): Promise<RunTaskResponse> {
|
||||
export interface RunTaskOptions {
|
||||
requestBody?: Record<string, unknown>;
|
||||
submissionMode?: TaskSubmissionMode;
|
||||
}
|
||||
|
||||
const simulationParameterKeys = ['runMode', 'run_mode', 'simulation', 'testMode', 'test_mode'] as const;
|
||||
|
||||
export function applyTaskSubmissionMode(
|
||||
input: Record<string, unknown>,
|
||||
submissionMode: TaskSubmissionMode,
|
||||
): Record<string, unknown> {
|
||||
const body = { ...input };
|
||||
for (const key of simulationParameterKeys) delete body[key];
|
||||
if (submissionMode === 'simulation') {
|
||||
body.runMode = 'simulation';
|
||||
body.simulation = true;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function runTask(token: string, task: TaskForm, options: RunTaskOptions = {}): Promise<RunTaskResponse> {
|
||||
const submissionMode = options.submissionMode ?? 'simulation';
|
||||
const requestBody = task.kind === 'tasks.retrieve'
|
||||
? { taskId: task.taskId }
|
||||
: applyTaskSubmissionMode(options.requestBody ?? defaultRequestBody(task), submissionMode);
|
||||
|
||||
if (task.kind === 'chat.completions') {
|
||||
const result = await createCompatibleChatCompletion(token, {
|
||||
model: task.model,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: task.prompt }],
|
||||
});
|
||||
return { localOnly: true, task: compatibleTask(task, result) };
|
||||
const result = await createCompatibleChatCompletion(
|
||||
token,
|
||||
requestBody as Parameters<typeof createCompatibleChatCompletion>[1],
|
||||
);
|
||||
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||
}
|
||||
if (task.kind === 'responses') {
|
||||
const result = await createResponse(token, {
|
||||
model: task.model,
|
||||
input: task.prompt,
|
||||
instructions: task.instructions,
|
||||
previous_response_id: task.previousResponseId,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
store: true,
|
||||
stream: false,
|
||||
});
|
||||
return { localOnly: true, task: compatibleTask(task, result) };
|
||||
const result = await createResponse(token, requestBody as Parameters<typeof createResponse>[1]);
|
||||
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||
}
|
||||
if (task.kind === 'embeddings') {
|
||||
const result = await createEmbedding(token, {
|
||||
model: task.model,
|
||||
input: embeddingInput(task.prompt),
|
||||
dimensions: task.dimensions,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
});
|
||||
return { localOnly: true, task: compatibleTask(task, result) };
|
||||
const result = await createEmbedding(token, requestBody as Parameters<typeof createEmbedding>[1]);
|
||||
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||
}
|
||||
if (task.kind === 'reranks') {
|
||||
const result = await createRerank(token, {
|
||||
model: task.model,
|
||||
query: task.prompt,
|
||||
documents: rerankDocuments(task.documents),
|
||||
top_n: task.topN,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
});
|
||||
return { localOnly: true, task: compatibleTask(task, result) };
|
||||
const result = await createRerank(token, requestBody as Parameters<typeof createRerank>[1]);
|
||||
return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) };
|
||||
}
|
||||
if (task.kind === 'images.generations') {
|
||||
return createImageGenerationTask(token, {
|
||||
model: task.model,
|
||||
prompt: task.prompt,
|
||||
quality: 'medium',
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
size: '1024x1024',
|
||||
});
|
||||
const response = await createImageGenerationTask(
|
||||
token,
|
||||
requestBody as Parameters<typeof createImageGenerationTask>[1],
|
||||
);
|
||||
return { ...response, submissionMode };
|
||||
}
|
||||
if (task.kind === 'images.edits') {
|
||||
return createImageEditTask(token, {
|
||||
model: task.model,
|
||||
prompt: task.prompt,
|
||||
image: task.image,
|
||||
mask: task.mask,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
});
|
||||
const response = await createImageEditTask(token, requestBody as Parameters<typeof createImageEditTask>[1]);
|
||||
return { ...response, submissionMode };
|
||||
}
|
||||
if (task.kind === 'videos.generations') {
|
||||
return createVideoGenerationTask(token, {
|
||||
model: task.model,
|
||||
content: [{ type: 'text', text: task.prompt }],
|
||||
aspect_ratio: task.aspectRatio ?? '16:9',
|
||||
resolution: task.resolution ?? '720p',
|
||||
duration: task.duration ?? 5,
|
||||
audio: task.outputAudio ?? true,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
});
|
||||
const response = await createVideoGenerationTask(
|
||||
token,
|
||||
requestBody as unknown as Parameters<typeof createVideoGenerationTask>[1],
|
||||
);
|
||||
return { ...response, submissionMode };
|
||||
}
|
||||
if (task.kind === 'tasks.retrieve') {
|
||||
const taskId = task.taskId?.trim();
|
||||
if (!taskId) throw new Error('请输入要取回的 Task ID');
|
||||
const result = await getAPITask(token, taskId);
|
||||
return { localOnly: true, task: compatibleTask(task, result as unknown as Record<string, unknown>) };
|
||||
return {
|
||||
localOnly: true,
|
||||
submissionMode: 'production',
|
||||
task: compatibleTask(task, result as unknown as Record<string, unknown>, requestBody, 'production'),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported task kind: ${task.kind}`);
|
||||
}
|
||||
|
||||
function compatibleTask(task: TaskForm, result: Record<string, unknown>): GatewayTask {
|
||||
function compatibleTask(
|
||||
task: TaskForm,
|
||||
result: Record<string, unknown>,
|
||||
requestBody: Record<string, unknown>,
|
||||
submissionMode: TaskSubmissionMode,
|
||||
): GatewayTask {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `docs-${task.kind}-${Date.now()}`,
|
||||
@@ -111,26 +107,31 @@ function compatibleTask(task: TaskForm, result: Record<string, unknown>): Gatewa
|
||||
createdAt: now,
|
||||
finishedAt: now,
|
||||
kind: task.kind,
|
||||
model: task.model,
|
||||
model: typeof requestBody.model === 'string' ? requestBody.model : task.model,
|
||||
modelType: modelTypeForKind(task.kind),
|
||||
request: requestSnapshot(task),
|
||||
request: requestBody,
|
||||
result,
|
||||
runMode: 'simulation',
|
||||
runMode: submissionMode,
|
||||
status: 'succeeded',
|
||||
updatedAt: now,
|
||||
userId: 'docs-runner',
|
||||
};
|
||||
}
|
||||
|
||||
function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
||||
function defaultRequestBody(task: TaskForm): Record<string, unknown> {
|
||||
if (task.kind === 'chat.completions') {
|
||||
return {
|
||||
model: task.model,
|
||||
messages: [{ role: 'user', content: task.prompt }],
|
||||
stream: false,
|
||||
};
|
||||
}
|
||||
if (task.kind === 'responses') {
|
||||
return {
|
||||
model: task.model,
|
||||
input: task.prompt,
|
||||
instructions: task.instructions,
|
||||
previous_response_id: task.previousResponseId,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
store: true,
|
||||
stream: false,
|
||||
};
|
||||
@@ -140,8 +141,6 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
||||
model: task.model,
|
||||
input: embeddingInput(task.prompt),
|
||||
dimensions: task.dimensions,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
};
|
||||
}
|
||||
if (task.kind === 'reranks') {
|
||||
@@ -150,8 +149,22 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
||||
query: task.prompt,
|
||||
documents: rerankDocuments(task.documents),
|
||||
top_n: task.topN,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
};
|
||||
}
|
||||
if (task.kind === 'images.generations') {
|
||||
return {
|
||||
model: task.model,
|
||||
prompt: task.prompt,
|
||||
quality: 'medium',
|
||||
size: '1024x1024',
|
||||
};
|
||||
}
|
||||
if (task.kind === 'images.edits') {
|
||||
return {
|
||||
model: task.model,
|
||||
prompt: task.prompt,
|
||||
image: task.image,
|
||||
mask: task.mask,
|
||||
};
|
||||
}
|
||||
if (task.kind === 'videos.generations') {
|
||||
@@ -162,18 +175,10 @@ function requestSnapshot(task: TaskForm): Record<string, unknown> {
|
||||
resolution: task.resolution ?? '720p',
|
||||
duration: task.duration ?? 5,
|
||||
audio: task.outputAudio ?? true,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
};
|
||||
}
|
||||
if (task.kind === 'tasks.retrieve') return { taskId: task.taskId };
|
||||
return {
|
||||
model: task.model,
|
||||
messages: [{ role: 'user', content: task.prompt }],
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
stream: false,
|
||||
};
|
||||
return { model: task.model };
|
||||
}
|
||||
|
||||
function embeddingInput(prompt: string) {
|
||||
|
||||
@@ -36,6 +36,17 @@ describe('ApiDocsPage extended task documentation', () => {
|
||||
expect(html).toContain('任务取回接口');
|
||||
});
|
||||
|
||||
it('defaults the online runner to test mode and offers an explicit real submission mode', () => {
|
||||
const html = renderDocs('imageEdit', { kind: 'images.edits', model: 'gpt-image-1', prompt: '移除背景' });
|
||||
|
||||
expect(html).toContain('运行模式');
|
||||
expect(html).toContain('测试模式');
|
||||
expect(html).toContain('真实提交');
|
||||
expect(html).toContain('aria-pressed="true"');
|
||||
expect(html).toContain('"runMode": "simulation"');
|
||||
expect(html).toContain('"simulation": true');
|
||||
});
|
||||
|
||||
it('documents async mode as a body-independent capability', () => {
|
||||
const html = renderDocs('asyncMode', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' });
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Fragment, useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
|
||||
import { Fragment, useEffect, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
|
||||
import type { GatewayApiKey, GatewaySkillBundleMetadata, GatewayTask } from '@easyai-ai-gateway/contracts';
|
||||
import { BookOpen, Download, ExternalLink, FileJson, KeyRound, Play, Search, Send, Wrench } from 'lucide-react';
|
||||
import { Badge, Button, Input, Select, Textarea } from '../components/ui';
|
||||
import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api';
|
||||
import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types';
|
||||
import { applyTaskSubmissionMode, type RunTaskOptions } from '../lib/run-task';
|
||||
import type { ApiDocSection, LoadState, TaskForm, TaskKind, TaskSubmissionMode } from '../types';
|
||||
import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared';
|
||||
|
||||
interface ApiDocItem {
|
||||
@@ -34,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[] = [
|
||||
@@ -72,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: {
|
||||
@@ -90,7 +91,7 @@ export function ApiDocsPage(props: {
|
||||
onCreateApiKey: () => void;
|
||||
onLogin: () => void;
|
||||
onDocSectionChange: (value: ApiDocSection) => void;
|
||||
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitTask: (event: FormEvent<HTMLFormElement>, options?: RunTaskOptions) => void;
|
||||
onTaskFormChange: (value: TaskForm) => void;
|
||||
}) {
|
||||
const activeGuide = guideItems.find((item) => item.key === props.activeDocSection);
|
||||
@@ -101,12 +102,12 @@ export function ApiDocsPage(props: {
|
||||
const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve';
|
||||
const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode';
|
||||
const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path);
|
||||
const runnerModeEnabled = Boolean(runnerAvailable && currentApiDoc?.method !== 'GET');
|
||||
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
|
||||
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
|
||||
const bodyExample = useMemo(
|
||||
() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat'),
|
||||
[currentApiDoc?.key, props.taskForm],
|
||||
);
|
||||
const [submissionMode, setSubmissionMode] = useState<TaskSubmissionMode>('simulation');
|
||||
const [bodyDraft, setBodyDraft] = useState(() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation'));
|
||||
const [bodyError, setBodyError] = useState('');
|
||||
const runnerPath = currentApiDoc?.path
|
||||
? isTaskRetrieveDoc
|
||||
? currentApiDoc.path.replace('{taskID}', props.taskForm.taskId?.trim() || '{taskID}')
|
||||
@@ -119,6 +120,12 @@ export function ApiDocsPage(props: {
|
||||
}
|
||||
}, [currentApiDoc?.kind, props.taskForm.kind, props.taskResult?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSubmissionMode('simulation');
|
||||
setBodyDraft(requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation'));
|
||||
setBodyError('');
|
||||
}, [currentApiDoc?.key, props.taskForm]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getOpsManagementSkillMetadata()
|
||||
@@ -134,18 +141,55 @@ export function ApiDocsPage(props: {
|
||||
}, []);
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!runnerAvailable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!props.canRun) {
|
||||
event.preventDefault();
|
||||
props.onLogin();
|
||||
return;
|
||||
}
|
||||
if (runnerModeEnabled) {
|
||||
const parsed = parseEditableRequestBody(bodyDraft);
|
||||
if (parsed.error || !parsed.body) {
|
||||
setBodyError(parsed.error);
|
||||
return;
|
||||
}
|
||||
const requestBody = applyTaskSubmissionMode(parsed.body, submissionMode);
|
||||
setBodyDraft(JSON.stringify(requestBody, null, 2));
|
||||
setBodyError('');
|
||||
props.onSubmitTask(event, { requestBody, submissionMode });
|
||||
return;
|
||||
}
|
||||
props.onSubmitTask(event);
|
||||
}
|
||||
|
||||
function handleBodyChange(value: string) {
|
||||
setBodyDraft(value);
|
||||
setBodyError(parseEditableRequestBody(value).error);
|
||||
}
|
||||
|
||||
function handleBodyBlur() {
|
||||
const parsed = parseEditableRequestBody(bodyDraft);
|
||||
if (parsed.error || !parsed.body) {
|
||||
setBodyError(parsed.error);
|
||||
return;
|
||||
}
|
||||
setBodyDraft(JSON.stringify(applyTaskSubmissionMode(parsed.body, submissionMode), null, 2));
|
||||
setBodyError('');
|
||||
}
|
||||
|
||||
function handleSubmissionModeChange(nextMode: TaskSubmissionMode) {
|
||||
const parsed = parseEditableRequestBody(bodyDraft);
|
||||
if (parsed.error || !parsed.body) {
|
||||
setBodyError('请先修正请求 Body 的 JSON 格式,再切换运行模式。');
|
||||
return;
|
||||
}
|
||||
setSubmissionMode(nextMode);
|
||||
setBodyDraft(JSON.stringify(applyTaskSubmissionMode(parsed.body, nextMode), null, 2));
|
||||
setBodyError('');
|
||||
}
|
||||
|
||||
function handleDocClick(item: ApiDocItem) {
|
||||
if (item.kind) {
|
||||
props.onTaskFormChange(defaultTaskForDoc(item.kind, props.taskForm, props.taskResult));
|
||||
@@ -257,7 +301,7 @@ export function ApiDocsPage(props: {
|
||||
{currentApiDoc?.method && currentApiDoc.path && !isAsyncModeDoc ? <form onSubmit={handleSubmit}>
|
||||
<header>
|
||||
<strong>在线运行</strong>
|
||||
<Button type="submit" size="sm" disabled={!runnerAvailable || (props.canRun && props.coreState === 'loading')}>
|
||||
<Button type="submit" size="sm" disabled={!runnerAvailable || Boolean(bodyError) || (props.canRun && props.coreState === 'loading')}>
|
||||
<Send size={14} />
|
||||
{!runnerAvailable ? '暂不支持' : props.canRun ? '发送' : '登录'}
|
||||
</Button>
|
||||
@@ -286,6 +330,34 @@ export function ApiDocsPage(props: {
|
||||
)}
|
||||
{runnerAvailable ? (
|
||||
<>
|
||||
{runnerModeEnabled && (
|
||||
<div className="runnerModeField">
|
||||
<span className="runnerModeLabel">运行模式</span>
|
||||
<div className="runnerModeToggle" role="group" aria-label="运行模式">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={submissionMode === 'simulation'}
|
||||
data-active={submissionMode === 'simulation'}
|
||||
onClick={() => handleSubmissionModeChange('simulation')}
|
||||
>
|
||||
测试模式
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={submissionMode === 'production'}
|
||||
data-active={submissionMode === 'production'}
|
||||
onClick={() => handleSubmissionModeChange('production')}
|
||||
>
|
||||
真实提交
|
||||
</button>
|
||||
</div>
|
||||
<p className="runnerModeHint" data-mode={submissionMode}>
|
||||
{submissionMode === 'simulation'
|
||||
? '不触达真实供应商,不消耗上游额度。'
|
||||
: '请求将真实提交给供应商,可能产生费用。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<label className="shLabel">
|
||||
能力
|
||||
<Select value={props.taskForm.kind} onChange={(event) => handleKindChange(event.target.value as TaskKind)}>
|
||||
@@ -304,14 +376,29 @@ export function ApiDocsPage(props: {
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="shLabel">
|
||||
请求 Body
|
||||
<Textarea value={bodyExample} onChange={(event) => props.onTaskFormChange(parseBody(event.target.value, props.taskForm))} />
|
||||
</label>
|
||||
<div className="runnerBodyField">
|
||||
<label className="shLabel">
|
||||
请求 Body
|
||||
<Textarea
|
||||
aria-describedby={bodyError ? 'docs-runner-body-error' : undefined}
|
||||
aria-invalid={Boolean(bodyError)}
|
||||
value={bodyDraft}
|
||||
onBlur={handleBodyBlur}
|
||||
onChange={(event) => handleBodyChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{bodyError && <span className="runnerBodyError" id="docs-runner-body-error" role="alert">{bodyError}</span>}
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" disabled={props.canRun && props.coreState === 'loading'}>
|
||||
<Button type="submit" disabled={Boolean(bodyError) || (props.canRun && props.coreState === 'loading')}>
|
||||
<Play size={15} />
|
||||
{!props.canRun ? '登录后运行' : props.coreState === 'loading' ? '运行中' : '运行测试'}
|
||||
{!props.canRun
|
||||
? '登录后运行'
|
||||
: props.coreState === 'loading'
|
||||
? '运行中'
|
||||
: submissionMode === 'simulation' || !runnerModeEnabled
|
||||
? '运行测试'
|
||||
: '真实提交'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -458,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>
|
||||
@@ -767,9 +854,9 @@ function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm
|
||||
return { ...current, kind, model: 'task' };
|
||||
}
|
||||
|
||||
function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
||||
function requestBodyExample(task: TaskForm, section: ApiDocSection, submissionMode: TaskSubmissionMode) {
|
||||
const body = task.kind === 'chat.completions'
|
||||
? { model: task.model, messages: [{ role: 'user', content: task.prompt }], runMode: 'simulation', simulation: true, stream: false }
|
||||
? { model: task.model, messages: [{ role: 'user', content: task.prompt }], stream: false }
|
||||
: task.kind === 'responses'
|
||||
? {
|
||||
model: task.model,
|
||||
@@ -778,15 +865,13 @@ function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
||||
previous_response_id: task.previousResponseId || undefined,
|
||||
store: true,
|
||||
stream: false,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
}
|
||||
: task.kind === 'embeddings'
|
||||
? { model: task.model, input: embeddingInputExample(task.prompt), dimensions: task.dimensions ?? 4, runMode: 'simulation', simulation: true }
|
||||
? { model: task.model, input: embeddingInputExample(task.prompt), dimensions: task.dimensions ?? 4 }
|
||||
: task.kind === 'reranks'
|
||||
? { model: task.model, query: task.prompt, documents: rerankDocumentsExample(task.documents), top_n: task.topN ?? 2, runMode: 'simulation', simulation: true }
|
||||
? { model: task.model, query: task.prompt, documents: rerankDocumentsExample(task.documents), top_n: task.topN ?? 2 }
|
||||
: task.kind === 'images.edits'
|
||||
? { model: task.model, prompt: task.prompt, image: task.image, mask: task.mask, runMode: 'simulation', simulation: true }
|
||||
? { model: task.model, prompt: task.prompt, image: task.image, mask: task.mask }
|
||||
: task.kind === 'videos.generations'
|
||||
? {
|
||||
model: task.model,
|
||||
@@ -795,54 +880,23 @@ function requestBodyExample(task: TaskForm, section: ApiDocSection) {
|
||||
resolution: task.resolution ?? '720p',
|
||||
aspect_ratio: task.aspectRatio ?? '16:9',
|
||||
audio: task.outputAudio ?? true,
|
||||
runMode: 'simulation',
|
||||
simulation: true,
|
||||
}
|
||||
: section === 'pricing'
|
||||
? { kind: 'chat.completions', model: 'gpt-4o-mini', messages: [{ role: 'user', content: '你好' }], max_tokens: 512 }
|
||||
: { model: task.model, prompt: task.prompt, quality: 'medium', runMode: 'simulation', simulation: true, size: '1024x1024' };
|
||||
return JSON.stringify(body, null, 2);
|
||||
: { model: task.model, prompt: task.prompt, quality: 'medium', size: '1024x1024' };
|
||||
return JSON.stringify(section === 'pricing' ? body : applyTaskSubmissionMode(body, submissionMode), null, 2);
|
||||
}
|
||||
|
||||
function parseBody(value: string, current: TaskForm): TaskForm {
|
||||
function parseEditableRequestBody(value: string): { body: Record<string, unknown> | null; error: string } {
|
||||
try {
|
||||
const body = JSON.parse(value) as {
|
||||
image?: string;
|
||||
aspect_ratio?: string;
|
||||
audio?: boolean;
|
||||
content?: Array<{ text?: string; type?: string }>;
|
||||
duration?: number;
|
||||
instructions?: string;
|
||||
mask?: string;
|
||||
messages?: Array<{ content?: string }>;
|
||||
model?: string;
|
||||
previous_response_id?: string;
|
||||
prompt?: string;
|
||||
input?: unknown;
|
||||
query?: string;
|
||||
documents?: string[];
|
||||
resolution?: string;
|
||||
top_n?: number;
|
||||
dimensions?: number;
|
||||
};
|
||||
return {
|
||||
...current,
|
||||
aspectRatio: body.aspect_ratio ?? current.aspectRatio,
|
||||
dimensions: numberOrCurrent(body.dimensions, current.dimensions),
|
||||
documents: Array.isArray(body.documents) ? body.documents.join('\n') : current.documents,
|
||||
duration: numberOrCurrent(body.duration, current.duration),
|
||||
image: body.image ?? current.image,
|
||||
instructions: body.instructions ?? current.instructions,
|
||||
mask: body.mask ?? current.mask,
|
||||
model: body.model ?? current.model,
|
||||
outputAudio: typeof body.audio === 'boolean' ? body.audio : current.outputAudio,
|
||||
previousResponseId: body.previous_response_id ?? current.previousResponseId,
|
||||
prompt: body.prompt ?? body.query ?? inputText(body.input) ?? contentText(body.content) ?? body.messages?.[0]?.content ?? current.prompt,
|
||||
resolution: body.resolution ?? current.resolution,
|
||||
topN: numberOrCurrent(body.top_n, current.topN),
|
||||
};
|
||||
} catch {
|
||||
return current;
|
||||
const body = JSON.parse(value) as unknown;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { body: null, error: '请求 Body 必须是 JSON 对象。' };
|
||||
}
|
||||
return { body: body as Record<string, unknown>, error: '' };
|
||||
} catch (error) {
|
||||
const detail = error instanceof SyntaxError ? error.message : '无法解析 JSON';
|
||||
return { body: null, error: `JSON 格式有误:${detail}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1098,20 +1152,3 @@ function splitLines(value: string) {
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function inputText(value: unknown) {
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) {
|
||||
const texts = value.map((item) => typeof item === 'string' ? item : '').filter(Boolean);
|
||||
return texts.length ? texts.join('\n') : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function contentText(value?: Array<{ text?: string; type?: string }>) {
|
||||
return value?.find((item) => item.type === 'text' && item.text)?.text;
|
||||
}
|
||||
|
||||
function numberOrCurrent(value: unknown, current?: number) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : current;
|
||||
}
|
||||
|
||||
@@ -322,6 +322,84 @@
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.runnerModeField {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.runnerModeLabel {
|
||||
color: var(--text-normal);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.runnerModeToggle {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.runnerModeToggle button {
|
||||
min-height: 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.runnerModeToggle button:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.runnerModeToggle button[data-active='true'] {
|
||||
border-color: #d8dee8;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.runnerModeToggle button:last-child[data-active='true'] {
|
||||
border-color: #f59e0b;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.runnerModeHint {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runnerModeHint[data-mode='production'] {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.runnerBodyError {
|
||||
color: #b42318;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.runnerBodyField {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.docsRunner .shTextarea[aria-invalid='true'] {
|
||||
border-color: #f04438;
|
||||
box-shadow: 0 0 0 2px rgba(240, 68, 56, 0.1);
|
||||
}
|
||||
|
||||
.docsRunnerUnavailable {
|
||||
padding: 14px;
|
||||
border: 1px dashed var(--border);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type LoadState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
export type AuthMode = 'login' | 'register' | 'external';
|
||||
export type TaskSubmissionMode = 'simulation' | 'production';
|
||||
export type TaskKind =
|
||||
| 'chat.completions'
|
||||
| 'responses'
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"ok": true,
|
||||
"generatedAt": "2026-07-17T06:19:48.217Z",
|
||||
"baseURL": "https://ai.51easyai.com/gateway-api",
|
||||
"release": "2026.07.17-2d6c16f",
|
||||
"platform": {
|
||||
"id": "81fa89a4-67c3-448b-9ee6-b6189f794b63",
|
||||
"name": "漫路(火山兼容)",
|
||||
"priority": 200
|
||||
},
|
||||
"model": {
|
||||
"alias": "deyun-seedance-2.0-canary",
|
||||
"providerModelName": "doubao-seedance-2-0"
|
||||
},
|
||||
"authentication": {
|
||||
"type": "temporary_gateway_api_key",
|
||||
"keyId": "488fb516-235f-425d-b37a-d3d2bd071f98",
|
||||
"deletedAfterTest": true
|
||||
},
|
||||
"task": {
|
||||
"id": "cab496fc-5fc0-4f83-a365-5787d649f0fb",
|
||||
"status": "succeeded",
|
||||
"remoteTaskId": "cgt-20260717141718-blxpt",
|
||||
"requestId": "cgt-20260717141718-blxpt",
|
||||
"attemptCount": 1,
|
||||
"request": {
|
||||
"model": "deyun-seedance-2.0-canary",
|
||||
"prompt": "A calm ocean wave rolls toward a sandy beach at sunrise, locked camera, natural ambient sound.",
|
||||
"resolution": "720p",
|
||||
"ratio": "16:9",
|
||||
"duration": 6,
|
||||
"generate_audio": true,
|
||||
"seed": 72017,
|
||||
"watermark": false,
|
||||
"runMode": "real"
|
||||
},
|
||||
"finalChargeAmount": 200,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 200
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 200,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 200
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingRecordCount": 1
|
||||
},
|
||||
"media": {
|
||||
"byteSize": 3074928,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"duration": 6.041667,
|
||||
"ratio": 1.7777777777777777,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [
|
||||
{
|
||||
"codec": "aac",
|
||||
"channels": 2,
|
||||
"sampleRate": 44100,
|
||||
"duration": 6.06
|
||||
}
|
||||
]
|
||||
},
|
||||
"wallet": {
|
||||
"balanceBefore": 99789,
|
||||
"balanceAfter": 99589,
|
||||
"frozenBefore": 0,
|
||||
"frozenAfter": 0,
|
||||
"debit": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"ok": true,
|
||||
"generatedAt": "2026-07-17T06:28:51.689Z",
|
||||
"model": {
|
||||
"id": "118b4fac-d543-4344-ada0-71add153bfe6",
|
||||
"modelAlias": "豆包Seedance-2.0",
|
||||
"displayName": "豆包Seedance-2.0",
|
||||
"providerModelName": "doubao-seedance-2-0"
|
||||
},
|
||||
"platform": {
|
||||
"id": "81fa89a4-67c3-448b-9ee6-b6189f794b63",
|
||||
"name": "漫路(火山兼容)",
|
||||
"priority": 200
|
||||
},
|
||||
"task": {
|
||||
"id": "6b60099b-7275-4896-b4ee-db2c85bdbbad",
|
||||
"status": "succeeded",
|
||||
"remoteTaskId": "cgt-20260717142458-hmmdc",
|
||||
"requestId": "cgt-20260717142458-hmmdc",
|
||||
"attemptCount": 1,
|
||||
"request": {
|
||||
"duration": 4,
|
||||
"generate_audio": false,
|
||||
"model": "豆包Seedance-2.0",
|
||||
"prompt": "A red paper airplane glides slowly across a bright blue studio background, locked camera.",
|
||||
"ratio": "16:9",
|
||||
"resolution": "720p",
|
||||
"runMode": "real",
|
||||
"seed": 72018,
|
||||
"watermark": false
|
||||
},
|
||||
"finalChargeAmount": 100
|
||||
},
|
||||
"media": {
|
||||
"byteSize": 949084,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"duration": 4.041667,
|
||||
"ratio": 1.7777777777777777,
|
||||
"videoCodec": "h264",
|
||||
"audioStreamCount": 0
|
||||
},
|
||||
"taskWalletSettlement": {
|
||||
"reserved": 100,
|
||||
"released": 100,
|
||||
"billed": 100,
|
||||
"balanceBefore": 99589,
|
||||
"balanceAfter": 99489
|
||||
},
|
||||
"accountSnapshot": {
|
||||
"balance": 99339,
|
||||
"frozenBalance": 0,
|
||||
"note": "Current global frozen balance belongs to another concurrent task; this task reservation is fully released."
|
||||
},
|
||||
"temporaryAPIKeysRemaining": 0
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"ok": false,
|
||||
"generatedAt": "2026-07-17T16:29:53.750Z",
|
||||
"baseURL": "http://127.0.0.1:8088",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"authentication": {
|
||||
"type": "gateway_api_key",
|
||||
"source": "database",
|
||||
"keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8"
|
||||
},
|
||||
"selectedCases": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-video-o1"
|
||||
},
|
||||
{
|
||||
"name": "compatible-v3-1080p-9x16-5s-audio-on",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-v3-omni"
|
||||
},
|
||||
{
|
||||
"name": "standard-o1-1080p-9x16-5s-audio-on",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-o1"
|
||||
},
|
||||
{
|
||||
"name": "standard-v3-720p-16x9-3s-audio-off",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-3.0-omni"
|
||||
}
|
||||
],
|
||||
"submittedTasks": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"taskId": "7ec7bd46-e9be-4289-a3c7-ee1e662f71e8",
|
||||
"model": "kling-video-o1"
|
||||
}
|
||||
],
|
||||
"error": "compatible-o1-720p-16x9-3s-audio-off failed, code=5000, message={\"msg\":\"该能力暂不支持\"}"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"ok": true,
|
||||
"generatedAt": "2026-07-17T16:53:06.058Z",
|
||||
"baseURL": "http://127.0.0.1:8088",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"platformModels": [
|
||||
{
|
||||
"providerModelName": "kling-v3-omni",
|
||||
"modelName": "kling-3.0-omni",
|
||||
"modelAlias": "kling-3.0-omni",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"providerModelName": "kling-video-o1",
|
||||
"modelName": "kling-o1",
|
||||
"modelAlias": "kling-o1",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"authentication": {
|
||||
"type": "gateway_api_key",
|
||||
"source": "database",
|
||||
"keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8"
|
||||
},
|
||||
"wallet": {
|
||||
"balanceBefore": 6515.735312,
|
||||
"balanceAfter": 6435.735312,
|
||||
"frozenBalanceBefore": 0.14133,
|
||||
"frozenBalanceAfter": 0.14133,
|
||||
"debit": 80,
|
||||
"totalCharge": 80
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"name": "standard-v3-720p-16x9-3s-audio-off",
|
||||
"interface": "gateway-standard",
|
||||
"taskId": "b2300a36-0f43-4745-9d00-2dea6f9988fe",
|
||||
"remoteTaskId": "task_66af8f819c7d4b9b91e5b6d526e88d0e",
|
||||
"requestId": "eeec9ed2-d94e-4777-9ee0-c875c4efc418",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"requestedModel": "kling-3.0-omni",
|
||||
"providerModelName": "kling-v3-omni",
|
||||
"requested": {
|
||||
"resolution": "720p",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": 3,
|
||||
"audio": false
|
||||
},
|
||||
"observed": {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"shortEdge": 720,
|
||||
"duration": 3.041667,
|
||||
"ratio": 1.7777777777777777,
|
||||
"ratioErrorPercent": 0,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [],
|
||||
"volume": null
|
||||
},
|
||||
"byteSize": 1577848,
|
||||
"attemptCount": 1,
|
||||
"billingLineCount": 1,
|
||||
"finalChargeAmount": 80,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 80
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 80,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 80
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingChangeCount": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"ok": false,
|
||||
"generatedAt": "2026-07-17T16:35:04.858Z",
|
||||
"baseURL": "http://127.0.0.1:8088",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"authentication": {
|
||||
"type": "gateway_api_key",
|
||||
"source": "database",
|
||||
"keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8"
|
||||
},
|
||||
"selectedCases": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-video-o1"
|
||||
},
|
||||
{
|
||||
"name": "compatible-v3-1080p-9x16-5s-audio-on",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-v3-omni"
|
||||
},
|
||||
{
|
||||
"name": "standard-o1-1080p-9x16-5s-audio-on",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-o1"
|
||||
},
|
||||
{
|
||||
"name": "standard-v3-720p-16x9-3s-audio-off",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-3.0-omni"
|
||||
}
|
||||
],
|
||||
"submittedTasks": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"taskId": "abfadf1b-ad40-4eec-a38b-4a119b4861c7",
|
||||
"model": "kling-video-o1"
|
||||
}
|
||||
],
|
||||
"error": "compatible-o1-720p-16x9-3s-audio-off failed, code=1201, message=Duration only support 5 or 10 seconds when no refer image"
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"ok": false,
|
||||
"generatedAt": "2026-07-17T16:54:24.016Z",
|
||||
"baseURL": "http://127.0.0.1:8088",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"platformModels": [
|
||||
{
|
||||
"providerModelName": "kling-v3-omni",
|
||||
"modelName": "kling-3.0-omni",
|
||||
"modelAlias": "kling-3.0-omni",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"providerModelName": "kling-video-o1",
|
||||
"modelName": "kling-o1",
|
||||
"modelAlias": "kling-o1",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"authentication": {
|
||||
"type": "gateway_api_key",
|
||||
"source": "database",
|
||||
"keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8"
|
||||
},
|
||||
"wallet": {
|
||||
"balanceBefore": 6435.735312,
|
||||
"balanceAfter": 6435.735312,
|
||||
"frozenBalanceBefore": 0.14133,
|
||||
"frozenBalanceAfter": 0.14133,
|
||||
"debit": 0,
|
||||
"totalChargeThisRun": 0,
|
||||
"totalHistoricalCharge": 520
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"passed": true,
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"interface": "kling-compatible",
|
||||
"taskId": "84075b74-9c51-4ab4-b154-3280eb947bcb",
|
||||
"remoteTaskId": "task_7dc690f0a77649a3b6f8949b9d003371",
|
||||
"requestId": "67fd025f-0b53-4a56-8967-128f334b2bfb",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"requestedModel": "kling-video-o1",
|
||||
"providerModelName": "kling-video-o1",
|
||||
"reusedExistingTask": true,
|
||||
"requested": {
|
||||
"resolution": "720p",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": 3,
|
||||
"audio": false
|
||||
},
|
||||
"observed": {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"shortEdge": 720,
|
||||
"duration": 3.041667,
|
||||
"ratio": 1.7777777777777777,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [],
|
||||
"volume": null,
|
||||
"ratioErrorPercent": 0
|
||||
},
|
||||
"byteSize": 1275483,
|
||||
"attemptCount": 1,
|
||||
"billingLineCount": 1,
|
||||
"finalChargeAmount": 80,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 80
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 80,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 80
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingChangeCount": 0
|
||||
},
|
||||
{
|
||||
"passed": true,
|
||||
"name": "compatible-v3-1080p-9x16-5s-audio-on",
|
||||
"interface": "kling-compatible",
|
||||
"taskId": "71491b61-2b6f-47b5-a738-2d4ea3898b28",
|
||||
"remoteTaskId": "task_b4b622c374624c7e82cb34d6bc6c57af",
|
||||
"requestId": "da16b141-44cc-4379-b463-35d06f2f0870",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"requestedModel": "kling-v3-omni",
|
||||
"providerModelName": "kling-v3-omni",
|
||||
"reusedExistingTask": true,
|
||||
"requested": {
|
||||
"resolution": "1080p",
|
||||
"aspectRatio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true
|
||||
},
|
||||
"observed": {
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"shortEdge": 1080,
|
||||
"duration": 5.041667,
|
||||
"ratio": 0.5625,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [
|
||||
{
|
||||
"codec": "aac",
|
||||
"channels": 2,
|
||||
"sampleRate": 44100
|
||||
}
|
||||
],
|
||||
"volume": {
|
||||
"maxVolumeDb": -3.4,
|
||||
"meanVolumeDb": -33.8
|
||||
},
|
||||
"ratioErrorPercent": 0
|
||||
},
|
||||
"byteSize": 8132835,
|
||||
"attemptCount": 1,
|
||||
"billingLineCount": 1,
|
||||
"finalChargeAmount": 240,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 240
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 240,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 240
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingChangeCount": 0
|
||||
},
|
||||
{
|
||||
"passed": false,
|
||||
"validationError": "Task b8e996bb-d69a-4ede-a47f-f6243e478f45 requested audio but output has no audio stream",
|
||||
"name": "standard-o1-1080p-9x16-5s-audio-on",
|
||||
"interface": "gateway-standard",
|
||||
"taskId": "b8e996bb-d69a-4ede-a47f-f6243e478f45",
|
||||
"remoteTaskId": "task_c0e8271648104c72956ea7cd6da80dea",
|
||||
"requestId": "e4206343-6dbb-439b-94ba-ccc1f5a020a8",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"requestedModel": "kling-o1",
|
||||
"providerModelName": "kling-video-o1",
|
||||
"reusedExistingTask": true,
|
||||
"requested": {
|
||||
"resolution": "1080p",
|
||||
"aspectRatio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true
|
||||
},
|
||||
"observed": {
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
"shortEdge": 1080,
|
||||
"duration": 5.041667,
|
||||
"ratio": 0.5625,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [],
|
||||
"volume": null,
|
||||
"ratioErrorPercent": 0
|
||||
},
|
||||
"byteSize": 12728988,
|
||||
"attemptCount": 1,
|
||||
"billingLineCount": 1,
|
||||
"finalChargeAmount": 120,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 120
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 120,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 120
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingChangeCount": 1
|
||||
},
|
||||
{
|
||||
"passed": true,
|
||||
"name": "standard-v3-720p-16x9-3s-audio-off",
|
||||
"interface": "gateway-standard",
|
||||
"taskId": "b2300a36-0f43-4745-9d00-2dea6f9988fe",
|
||||
"remoteTaskId": "task_66af8f819c7d4b9b91e5b6d526e88d0e",
|
||||
"requestId": "eeec9ed2-d94e-4777-9ee0-c875c4efc418",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"requestedModel": "kling-3.0-omni",
|
||||
"providerModelName": "kling-v3-omni",
|
||||
"reusedExistingTask": true,
|
||||
"requested": {
|
||||
"resolution": "720p",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": 3,
|
||||
"audio": false
|
||||
},
|
||||
"observed": {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"shortEdge": 720,
|
||||
"duration": 3.041667,
|
||||
"ratio": 1.7777777777777777,
|
||||
"videoCodec": "h264",
|
||||
"audioStreams": [],
|
||||
"volume": null,
|
||||
"ratioErrorPercent": 0
|
||||
},
|
||||
"byteSize": 1577848,
|
||||
"attemptCount": 1,
|
||||
"billingLineCount": 1,
|
||||
"finalChargeAmount": 80,
|
||||
"billingSummary": {
|
||||
"amountByCurrency": {
|
||||
"resource": 80
|
||||
},
|
||||
"currency": "resource",
|
||||
"finalCharge": {
|
||||
"amount": 80,
|
||||
"currency": "resource",
|
||||
"simulated": false
|
||||
},
|
||||
"lineCount": 1,
|
||||
"simulated": false,
|
||||
"totalAmount": 80
|
||||
},
|
||||
"eventTypes": [
|
||||
"task.accepted",
|
||||
"task.running",
|
||||
"task.progress",
|
||||
"task.attempt.started",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.progress",
|
||||
"task.billing.settled",
|
||||
"task.completed"
|
||||
],
|
||||
"preprocessingChangeCount": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"ok": false,
|
||||
"generatedAt": "2026-07-17T16:49:49.164Z",
|
||||
"baseURL": "http://127.0.0.1:8088",
|
||||
"platform": {
|
||||
"key": "transtreams_keling",
|
||||
"name": "凌川 TranStreams(可灵)",
|
||||
"provider": "keling"
|
||||
},
|
||||
"authentication": {
|
||||
"type": "gateway_api_key",
|
||||
"source": "database",
|
||||
"keyId": "1146eed6-0999-48c4-b72c-c92872fad2b8"
|
||||
},
|
||||
"selectedCases": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-video-o1"
|
||||
},
|
||||
{
|
||||
"name": "compatible-v3-1080p-9x16-5s-audio-on",
|
||||
"interface": "kling-compatible",
|
||||
"model": "kling-v3-omni"
|
||||
},
|
||||
{
|
||||
"name": "standard-o1-1080p-9x16-5s-audio-on",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-o1"
|
||||
},
|
||||
{
|
||||
"name": "standard-v3-720p-16x9-3s-audio-off",
|
||||
"interface": "gateway-standard",
|
||||
"model": "kling-3.0-omni"
|
||||
}
|
||||
],
|
||||
"submittedTasks": [
|
||||
{
|
||||
"name": "compatible-o1-720p-16x9-3s-audio-off",
|
||||
"taskId": "84075b74-9c51-4ab4-b154-3280eb947bcb",
|
||||
"model": "kling-video-o1"
|
||||
},
|
||||
{
|
||||
"name": "compatible-v3-1080p-9x16-5s-audio-on",
|
||||
"taskId": "71491b61-2b6f-47b5-a738-2d4ea3898b28",
|
||||
"model": "kling-v3-omni"
|
||||
},
|
||||
{
|
||||
"name": "standard-o1-1080p-9x16-5s-audio-on",
|
||||
"taskId": "b8e996bb-d69a-4ede-a47f-f6243e478f45",
|
||||
"model": "kling-o1"
|
||||
}
|
||||
],
|
||||
"error": "Task b8e996bb-d69a-4ede-a47f-f6243e478f45 requested audio but output has no audio stream"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
2d6c16fec0bec9c0288e5cb142b458af982fff8f
|
||||
d95cecd0ebac48bda7ff0a2dcf453bb02b85ca7e
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Include inside the TLS server block for ai.51easyai.com.
|
||||
# The application owns routing below /api/v1; the edge proxy preserves the URI.
|
||||
|
||||
location = /api/v1/metrics {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ^~ /api/v1/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_pass http://127.0.0.1:8088;
|
||||
}
|
||||
+2
-2
@@ -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 旧版协议兼容)
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Kling Omni 兼容接口
|
||||
|
||||
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` | 时长范围 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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 秒 |
|
||||
|
||||
网关别名用于候选匹配,原生模型名用于发往 TranStreams 的 Kling Omni 请求;两类名称不会混用。
|
||||
|
||||
`kling-video-o1` 的纯文生视频和首帧生视频只接受 5 或 10 秒;3–10 秒中的其他整数需要使用普通参考图等支持该时长的 Omni 输入。`kling-v3-omni` 接受 3–15 秒。
|
||||
|
||||
真实上游结果表明 `kling-video-o1` 不生成音频,因此该模型的 `sound=on` 会返回 `1201`,标准接口的 `audio=true` 也会在参数预处理阶段失败,避免静默返回无声视频。`kling-v3-omni` 支持 `sound=on/off`。
|
||||
|
||||
`mode` 映射为网关分辨率:`std` = 720p,`pro` = 1080p,`4k` = 2160p。4K 只有在平台模型能力也声明支持时才能执行。`sound=on/off` 映射为 `audio=true/false`,`duration` 同时接受 JSON 字符串和整数。
|
||||
|
||||
兼容字段包括:`prompt`、`multi_shot`、`shot_type`、`multi_prompt`、`image_list`、`element_list`、`video_list`、`sound`、`mode`、`aspect_ratio`、`duration`、`watermark_info`、`external_task_id`。`callback_url` 可以省略或传空字符串;非空值会返回业务码 `1201`,本期不投递回调。
|
||||
|
||||
## 创建任务
|
||||
|
||||
`POST /api/v1/videos/omni-video` 固定异步受理,不需要 `X-Async`,成功返回 HTTP 200。
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$GATEWAY_ORIGIN/api/v1/videos/omni-video" \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model_name": "kling-v3-omni",
|
||||
"prompt": "A quiet street in the rain with natural ambient sound",
|
||||
"mode": "pro",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": "5",
|
||||
"sound": "on",
|
||||
"watermark_info": {"enabled": false},
|
||||
"external_task_id": "client-job-001"
|
||||
}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "SUCCEED",
|
||||
"request_id": "...",
|
||||
"data": {
|
||||
"task_id": "00000000-0000-0000-0000-000000000000",
|
||||
"task_info": {"external_task_id": "client-job-001"},
|
||||
"task_status": "submitted",
|
||||
"created_at": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 查询任务
|
||||
|
||||
使用创建任务时的同一个 Gateway API Key 轮询。跨用户查询与不存在的任务统一返回 HTTP 404 和业务码 `1203`。
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
"$GATEWAY_ORIGIN/api/v1/videos/omni-video/$TASK_ID"
|
||||
```
|
||||
|
||||
`task_status` 为 `submitted`、`processing`、`succeed` 或 `failed`。成功时结果位于 `data.task_result.videos`:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "SUCCEED",
|
||||
"request_id": "...",
|
||||
"data": {
|
||||
"task_id": "00000000-0000-0000-0000-000000000000",
|
||||
"task_status": "succeed",
|
||||
"task_result": {
|
||||
"videos": [
|
||||
{
|
||||
"id": "...",
|
||||
"url": "https://.../video.mp4",
|
||||
"watermark_url": "https://.../watermark.mp4",
|
||||
"duration": "5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 网关标准视频接口
|
||||
|
||||
标准接口仍为 `POST /api/v1/videos/generations`。异步调用需要 `X-Async: true`,再通过 `GET /api/v1/tasks/{taskId}` 轮询。
|
||||
|
||||
```bash
|
||||
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" \
|
||||
-d '{
|
||||
"model": "kling-o1",
|
||||
"prompt": "A product reveal in a daylight studio",
|
||||
"resolution": "1080p",
|
||||
"aspect_ratio": "9:16",
|
||||
"duration": 5,
|
||||
"audio": true,
|
||||
"watermark": false,
|
||||
"runMode": "real"
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer $GATEWAY_API_KEY" \
|
||||
"$GATEWAY_PUBLIC_API_BASE/tasks/$TASK_ID"
|
||||
```
|
||||
|
||||
## 错误格式
|
||||
|
||||
所有兼容接口错误都返回同一包络:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1201,
|
||||
"message": "duration must be between 3 and 10 seconds",
|
||||
"request_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
业务码分类:`1001/1002` 为鉴权错误,`1101/1103` 为余额或权限错误,`1201/1203` 为参数或资源错误,`1302/1303` 为限流错误,`5000/5001` 为网关或上游服务错误。HTTP 状态码仍反映错误类型。
|
||||
|
||||
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'
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Seedance 真人资产(Volces)
|
||||
|
||||
网关会把真人源文件保存到既有文件存储,并在 Volces 平台的火山 Assets 库创建绑定。生成时只能引用已经对当前用户、当前平台激活的资产,最终发送给火山的视频内容 URL 为 `asset://<remote_asset_id>`。
|
||||
|
||||
## 平台配置
|
||||
|
||||
在 `integration_platforms.config` 为对应的 `volces` 平台添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"seedancePrivateAsset": {
|
||||
"enabled": true,
|
||||
"accessKey": "AK...",
|
||||
"secretKey": "SK...",
|
||||
"projectName": "default",
|
||||
"assetGroupId": "asset-group-id",
|
||||
"assetEndpoint": "https://ark.cn-beijing.volcengineapi.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`assetEndpoint` 可省略。源文件 URL 必须是火山可访问的绝对 `http(s)` 地址;同步时会拒绝本地路径、`file://` 与相对 URL。因此生产环境应配置带公网 URL 的文件存储或 `PublicBaseURL`。
|
||||
|
||||
## Desktop / server-main 兼容路由
|
||||
|
||||
- `GET /api/v1/resource/material/seedance-portrait-assets/capability`
|
||||
- `GET /api/v1/resource/material/user/materials?category=seedance_portrait_asset`
|
||||
- `POST /api/v1/resource/material`(multipart:`file`、`data`)
|
||||
- `POST /api/v1/resource/material/seedance-portrait-assets/sync`
|
||||
- `POST /api/v1/video/generations` 与 `GET /api/v1/ai/result/{taskID}`
|
||||
|
||||
创建真人资产要求 `private_avatar_eligible: true`。同步接口可重复调用;资产在火山返回 `Active` 前,视频提交会返回 `portrait_asset_processing`,而不会把原始人像媒体当成普通参考图发送。
|
||||
|
||||
## 火山任务兼容路由
|
||||
|
||||
- `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` 是保留的网关附加字段。
|
||||
|
||||
公开 `id` 是网关任务 ID,原始火山任务 ID 保留在 `upstream_task_id`。响应保留火山的 `content`、`usage`、`seed`、`resolution` 等字段,并额外保留网关账单字段。
|
||||
@@ -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
+8
-7
@@ -6,17 +6,18 @@ settings:
|
||||
|
||||
overrides:
|
||||
'@babel/core@<=7.29.0': 7.29.6
|
||||
'@nx/js>picomatch': 4.0.5
|
||||
'@nx/vite>picomatch': 4.0.5
|
||||
'@nx/workspace>picomatch': 4.0.5
|
||||
axios@<1.18.0: 1.18.0
|
||||
brace-expansion@>=2.0.0 <2.1.2: 2.1.2
|
||||
dompurify@<=3.4.10: 3.4.11
|
||||
esbuild@>=0.27.3 <0.28.1: 0.28.1
|
||||
fast-uri@>=3.0.0 <3.1.4: 3.1.4
|
||||
form-data@>=4.0.0 <4.0.6: 4.0.6
|
||||
js-yaml@<4.3.0: 4.3.0
|
||||
mermaid@>=11.0.0-alpha.1 <=11.14.0: 11.15.0
|
||||
minimatch@>=9.0.0 <9.0.7: 9.0.7
|
||||
'@nx/js>picomatch': 4.0.5
|
||||
'@nx/vite>picomatch': 4.0.5
|
||||
'@nx/workspace>picomatch': 4.0.5
|
||||
tmp@<0.2.7: 0.2.7
|
||||
|
||||
importers:
|
||||
@@ -3034,8 +3035,8 @@ packages:
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-uri@3.1.2:
|
||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||
fast-uri@3.1.4:
|
||||
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
@@ -7181,7 +7182,7 @@ snapshots:
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.2
|
||||
fast-uri: 3.1.4
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
@@ -7850,7 +7851,7 @@ snapshots:
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-uri@3.1.2: {}
|
||||
fast-uri@3.1.4: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
|
||||
@@ -12,6 +12,7 @@ overrides:
|
||||
'brace-expansion@>=2.0.0 <2.1.2': 2.1.2
|
||||
'dompurify@<=3.4.10': 3.4.11
|
||||
'esbuild@>=0.27.3 <0.28.1': 0.28.1
|
||||
'fast-uri@>=3.0.0 <3.1.4': 3.1.4
|
||||
'form-data@>=4.0.0 <4.0.6': 4.0.6
|
||||
'js-yaml@<4.3.0': 4.3.0
|
||||
'mermaid@>=11.0.0-alpha.1 <=11.14.0': 11.15.0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user