feat: 完善模型请求适配与输出限制

This commit is contained in:
2026-07-17 13:52:00 +08:00
parent a24eb1aeb0
commit 5ee267ecbd
31 changed files with 3287 additions and 232 deletions
+7 -3
View File
@@ -9,7 +9,7 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const OpenAIReasoningEffortValidationMessage = "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh"
const OpenAIReasoningEffortValidationMessage = "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh, max"
var (
openAIReasoningEfforts = map[string]struct{}{
@@ -19,6 +19,7 @@ var (
"medium": {},
"high": {},
"xhigh": {},
"max": {},
}
volcesChatReasoningEfforts = map[string]struct{}{
"minimal": {},
@@ -212,13 +213,16 @@ func isZhipuReasoningEffortModel(model string) bool {
}
func highMaxReasoningEffort(effort string) string {
if effort == "xhigh" {
if effort == "xhigh" || effort == "max" {
return "max"
}
return "high"
}
func zhipuReasoningEffort(effort string) string {
if effort == "max" {
return "xhigh"
}
if _, ok := zhipuReasoningEfforts[effort]; ok {
return effort
}
@@ -229,7 +233,7 @@ func volcesChatReasoningEffort(effort string) string {
switch effort {
case "none":
return "minimal"
case "xhigh":
case "xhigh", "max":
return "high"
default:
if _, ok := volcesChatReasoningEfforts[effort]; ok {
@@ -0,0 +1,29 @@
package clients
import (
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestCurrentOpenAIReasoningEffortMaxProviderMapping(t *testing.T) {
tests := []struct {
name string
candidate store.RuntimeModelCandidate
expected string
}{
{name: "generic", candidate: store.RuntimeModelCandidate{Provider: "openai"}, expected: "max"},
{name: "deepseek", candidate: store.RuntimeModelCandidate{Provider: "deepseek-openai"}, expected: "max"},
{name: "zhipu", candidate: store.RuntimeModelCandidate{Provider: "zhipu-openai", ProviderModelName: "glm-5.2"}, expected: "xhigh"},
{name: "volces", candidate: store.RuntimeModelCandidate{Provider: "volces-openai", ProviderModelName: "doubao-seed-2-0-pro-260215"}, expected: "high"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
body := map[string]any{"model": test.candidate.ProviderModelName, "reasoning_effort": "max"}
applyOpenAIChatReasoningParams(body, test.candidate)
if body["reasoning_effort"] != test.expected {
t.Fatalf("expected reasoning_effort %q, got %#v", test.expected, body["reasoning_effort"])
}
})
}
}
+250
View File
@@ -2294,6 +2294,256 @@ func TestKelingClientVideoSubmitsAndPollsImageTask(t *testing.T) {
}
}
func TestKelingClient30TurboUsesModelEndpointAndTasksAPI(t *testing.T) {
var submitPath string
var pollPath string
var pollQuery string
var gotAuth string
var submittedPayload map[string]any
var submittedTaskPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
switch r.Method + " " + r.URL.Path {
case "POST /text-to-video/kling-3.0-turbo":
submitPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&submittedPayload); err != nil {
t.Fatalf("decode keling 3.0 turbo submit: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"request_id": "req-turbo-submit",
"data": map[string]any{
"id": "turbo-task-1",
"status": "submitted",
},
})
case "GET /tasks":
pollPath = r.URL.Path
pollQuery = r.URL.RawQuery
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"request_id": "req-turbo-poll",
"data": []any{
map[string]any{
"id": "turbo-task-1",
"status": "succeeded",
"create_time": 789,
"outputs": []any{
map[string]any{
"type": "video",
"url": "https://example.com/turbo.mp4",
"watermark_url": "https://example.com/turbo-watermark.mp4",
"duration": "8",
},
},
},
},
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "videos.generations",
ModelType: "video_generate",
Model: "可灵3.0 Turbo",
Body: map[string]any{
"prompt": "A cinematic city reveal",
"duration": 8,
"resolution": "1080p",
"aspect_ratio": "9:16",
"callback_url": "https://example.com/callback",
"external_task_id": "external-1",
},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL + "/v1",
Provider: "keling",
AuthType: "APIKey",
ModelName: "可灵3.0 Turbo",
ProviderModelName: "kling-3.0-turbo",
Credentials: map[string]any{"apiKey": "kling-api-key"},
PlatformConfig: map[string]any{
"kelingPollIntervalMs": 100,
"kelingPollTimeoutSeconds": 1,
},
},
OnRemoteTaskSubmitted: func(remoteTaskID string, payload map[string]any) error {
if remoteTaskID != "turbo-task-1" {
t.Fatalf("unexpected remote task id: %s", remoteTaskID)
}
submittedTaskPayload = payload
return nil
},
})
if err != nil {
t.Fatalf("run keling 3.0 turbo video: %v", err)
}
if submitPath != "/text-to-video/kling-3.0-turbo" ||
pollPath != "/tasks" ||
pollQuery != "task_ids=turbo-task-1" ||
gotAuth != "Bearer kling-api-key" {
t.Fatalf("unexpected keling 3.0 turbo paths/auth submit=%s poll=%s?%s auth=%s", submitPath, pollPath, pollQuery, gotAuth)
}
if submittedTaskPayload["endpoint"] != "/text-to-video/kling-3.0-turbo" ||
submittedTaskPayload["taskApi"] != "keling_tasks_v2" {
t.Fatalf("unexpected submitted task payload: %+v", submittedTaskPayload)
}
settings, _ := submittedPayload["settings"].(map[string]any)
options, _ := submittedPayload["options"].(map[string]any)
if submittedPayload["prompt"] != "A cinematic city reveal" ||
numericValue(settings["duration"], 0) != 8 ||
settings["resolution"] != "1080p" ||
settings["aspect_ratio"] != "9:16" ||
options["callback_url"] != "https://example.com/callback" ||
options["external_task_id"] != "external-1" {
t.Fatalf("unexpected keling 3.0 turbo payload: %+v", submittedPayload)
}
data, _ := response.Result["data"].([]any)
item, _ := data[0].(map[string]any)
if response.Result["upstream_task_id"] != "turbo-task-1" ||
item["url"] != "https://example.com/turbo.mp4" ||
item["watermark_url"] != "https://example.com/turbo-watermark.mp4" {
t.Fatalf("unexpected keling 3.0 turbo response: %+v", response.Result)
}
}
func TestKelingClient30TurboRejectsLegacyCredentials(t *testing.T) {
_, err := (KelingClient{}).Run(context.Background(), Request{
Kind: "videos.generations",
Body: map[string]any{"prompt": "A cinematic city reveal"},
Candidate: store.RuntimeModelCandidate{
Provider: "keling",
AuthType: "AccessKey-SecretKey",
ProviderModelName: "kling-3.0-turbo",
Credentials: map[string]any{"accessKey": "ak", "secretKey": "sk"},
},
})
if err == nil || !strings.Contains(err.Error(), "new API key") {
t.Fatalf("expected keling 3.0 turbo API key requirement, got %v", err)
}
}
func TestKelingClient30TurboResumePollsWithoutSubmitting(t *testing.T) {
var submitCalled bool
var pollQuery string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /text-to-video/kling-3.0-turbo":
submitCalled = true
t.Fatalf("resume should not submit a new keling 3.0 turbo task")
case "GET /tasks":
pollQuery = r.URL.RawQuery
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"request_id": "req-turbo-resume",
"data": []any{
map[string]any{
"id": "turbo-existing",
"status": "succeeded",
"outputs": []any{
map[string]any{"type": "video", "url": "https://example.com/resumed-turbo.mp4"},
},
},
},
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "videos.generations",
ModelType: "video_generate",
RemoteTaskID: "turbo-existing",
Body: map[string]any{},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL + "/v1",
Provider: "keling",
AuthType: "APIKey",
ProviderModelName: "kling-3.0-turbo",
Credentials: map[string]any{"apiKey": "kling-api-key"},
PlatformConfig: map[string]any{
"kelingPollIntervalMs": 100,
"kelingPollTimeoutSeconds": 1,
},
},
})
if err != nil {
t.Fatalf("resume keling 3.0 turbo video: %v", err)
}
if submitCalled || pollQuery != "task_ids=turbo-existing" {
t.Fatalf("resume should only poll existing task, submit=%v query=%s", submitCalled, pollQuery)
}
data, _ := response.Result["data"].([]any)
item, _ := data[0].(map[string]any)
if item["url"] != "https://example.com/resumed-turbo.mp4" {
t.Fatalf("unexpected resumed keling 3.0 turbo response: %+v", response.Result)
}
}
func TestKeling30TurboPayloadBuildsFirstFrameAndMultiShotRequests(t *testing.T) {
imagePayload, endpoint, err := keling30TurboPayload(Request{
Body: map[string]any{
"duration": 5,
"resolution": "720p",
"content": []any{
map[string]any{"type": "text", "text": "The subject looks toward the camera"},
map[string]any{
"type": "image_url",
"role": "first_frame",
"image_url": map[string]any{"url": "https://example.com/first.png"},
},
},
},
})
if err != nil {
t.Fatalf("build keling 3.0 turbo image payload: %v", err)
}
if endpoint != "/image-to-video/kling-3.0-turbo" {
t.Fatalf("unexpected image endpoint: %s", endpoint)
}
if _, ok := mapFromAny(imagePayload["settings"])["aspect_ratio"]; ok {
t.Fatalf("image-to-video settings should not contain aspect_ratio: %+v", imagePayload)
}
contents, _ := imagePayload["contents"].([]any)
frame := mapFromAny(contents[1])
if frame["type"] != "first_frame" || frame["url"] != "https://example.com/first.png" {
t.Fatalf("unexpected image contents: %+v", imagePayload["contents"])
}
shotPayload, _, err := keling30TurboPayload(Request{
Body: map[string]any{
"resolution": "720p",
"content": []any{
map[string]any{"type": "text", "role": "shot_prompt", "shot_index": 1, "duration": 4, "text": "A car enters the tunnel"},
map[string]any{"type": "text", "role": "shot_prompt", "shot_index": 2, "duration": 3, "text": "The headlights fill the frame"},
},
},
})
if err != nil {
t.Fatalf("build keling 3.0 turbo shot payload: %v", err)
}
if shotPayload["prompt"] != "shot 1, 4s, A car enters the tunnel; shot 2, 3s, The headlights fill the frame;" ||
numericValue(mapFromAny(shotPayload["settings"])["duration"], 0) != 7 {
t.Fatalf("unexpected shot payload: %+v", shotPayload)
}
}
func TestKeling30TurboPayloadRejectsLastFrame(t *testing.T) {
_, _, err := keling30TurboPayload(Request{
Body: map[string]any{
"prompt": "Move forward",
"last_frame": "https://example.com/last.png",
},
})
if err == nil || !strings.Contains(err.Error(), "last frame") {
t.Fatalf("expected unsupported last frame error, got %v", err)
}
}
func TestKelingOmniPayloadConvertsGatewayContent(t *testing.T) {
payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
Kind: "videos.generations",
+313 -3
View File
@@ -9,9 +9,11 @@ import (
"io"
"math"
"net/http"
"net/url"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/golang-jwt/jwt/v5"
@@ -32,14 +34,34 @@ func (c KelingClient) Run(ctx context.Context, request Request) (Response, error
if request.Kind != "videos.generations" {
return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported keling request kind", Retryable: false}
}
token, err := kelingAuthToken(request.Candidate)
token, err := kelingAuthTokenForRequest(request)
if err != nil {
return Response{}, err
}
return c.runVideo(ctx, request, token)
}
func kelingAuthTokenForRequest(request Request) (string, error) {
if kelingIs30TurboRequest(request) {
apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token")
if apiKey == "" {
return "", &ClientError{
Code: "missing_credentials",
Message: "keling 3.0 turbo requires the new API key; legacy accessKey/secretKey credentials do not support new models",
Retryable: false,
StatusCode: http.StatusBadRequest,
}
}
return apiKey, nil
}
return kelingAuthToken(request.Candidate)
}
func (c KelingClient) runVideo(ctx context.Context, request Request, token string) (Response, error) {
if kelingIs30TurboRequest(request) {
return c.runTaskAPIVideo(ctx, request, token)
}
submitStartedAt := time.Now()
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
@@ -143,6 +165,105 @@ func (c KelingClient) runVideo(ctx context.Context, request Request, token strin
}
}
func (c KelingClient) runTaskAPIVideo(ctx context.Context, request Request, token string) (Response, error) {
submitStartedAt := time.Now()
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
taskAPIBaseURL := kelingTaskAPIBaseURL(request.Candidate.BaseURL)
if upstreamTaskID == "" {
payload, endpoint, err := keling30TurboPayload(request)
if err != nil {
return Response{}, err
}
submitResult, requestID, err := c.postJSONAt(ctx, request, taskAPIBaseURL, endpoint, token, payload)
submitRequestID = requestID
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
}
upstreamTaskID = strings.TrimSpace(stringFromAny(kelingData(submitResult)["id"]))
if upstreamTaskID == "" {
return Response{}, &ClientError{Code: "invalid_response", Message: "keling 3.0 turbo task id is missing", RequestID: submitRequestID, Retryable: false}
}
if request.OnRemoteTaskSubmitted != nil {
if err := request.OnRemoteTaskSubmitted(upstreamTaskID, map[string]any{
"endpoint": endpoint,
"taskApi": "keling_tasks_v2",
"submit": submitResult,
}); err != nil {
return Response{}, err
}
}
}
interval := kelingPollInterval(request)
timeout := kelingPollTimeout(request)
deadline := time.NewTimer(timeout)
defer deadline.Stop()
ticker := time.NewTicker(interval)
defer ticker.Stop()
var lastStatus string
for {
select {
case <-ctx.Done():
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: submitRequestID, Retryable: true}
default:
}
pollStartedAt := time.Now()
pollResult, pollRequestID, err := c.getJSONAt(
ctx,
request,
taskAPIBaseURL,
"/tasks?task_ids="+url.QueryEscape(upstreamTaskID),
token,
)
pollFinishedAt := time.Now()
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
if err != nil {
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
}
task := kelingTaskAPITask(pollResult, upstreamTaskID)
lastStatus = strings.ToLower(strings.TrimSpace(stringFromAny(task["status"])))
switch lastStatus {
case "succeeded", "succeed":
return Response{
Result: kelingTaskAPIVideoSuccessResult(request, upstreamTaskID, task, pollResult),
RequestID: requestID,
Progress: kelingVideoProgress(request, upstreamTaskID),
ResponseStartedAt: submitStartedAt,
ResponseFinishedAt: pollFinishedAt,
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
}, nil
case "failed":
return Response{}, &ClientError{
Code: "keling_task_failed",
Message: kelingTaskAPIErrorMessage(request.Candidate, task, pollResult),
RequestID: requestID,
ResponseStartedAt: submitStartedAt,
ResponseFinishedAt: pollFinishedAt,
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
Retryable: false,
}
}
select {
case <-ctx.Done():
return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true}
case <-deadline.C:
return Response{}, &ClientError{
Code: "timeout",
Message: fmt.Sprintf("keling 3.0 turbo task %s did not finish before timeout; last status: %s", upstreamTaskID, lastStatus),
RequestID: requestID,
Retryable: true,
}
case <-ticker.C:
}
}
}
func (c KelingClient) prepareVideoTask(ctx context.Context, request Request, token string) (kelingPreparedTask, error) {
if kelingIsOmniRequest(request) {
payload, cleanupIDs, err := c.kelingOmniPayload(ctx, request, token)
@@ -400,8 +521,12 @@ func (c KelingClient) kelingOmniElementList(ctx context.Context, request Request
}
func (c KelingClient) postJSON(ctx context.Context, request Request, path string, token string, body map[string]any) (map[string]any, string, error) {
return c.postJSONAt(ctx, request, request.Candidate.BaseURL, path, token, body)
}
func (c KelingClient) postJSONAt(ctx context.Context, request Request, baseURL string, path string, token string, body map[string]any) (map[string]any, string, error) {
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, path), bytes.NewReader(raw))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
if err != nil {
return nil, "", err
}
@@ -423,7 +548,11 @@ func (c KelingClient) postJSON(ctx context.Context, request Request, path string
}
func (c KelingClient) getJSON(ctx context.Context, request Request, path string, token string) (map[string]any, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(request.Candidate.BaseURL, path), nil)
return c.getJSONAt(ctx, request, request.Candidate.BaseURL, path, token)
}
func (c KelingClient) getJSONAt(ctx context.Context, request Request, baseURL string, path string, token string) (map[string]any, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil)
if err != nil {
return nil, "", err
}
@@ -560,6 +689,127 @@ func kelingIsOmniRequest(request Request) bool {
request.Candidate.Capabilities["omni"] != nil
}
func kelingIs30TurboRequest(request Request) bool {
switch strings.ToLower(strings.TrimSpace(upstreamModelName(request.Candidate))) {
case "kling-3.0-turbo", "kling-v3-turbo", "kling-3-0-turbo":
return true
default:
return false
}
}
func kelingTaskAPIBaseURL(baseURL string) string {
trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if strings.HasSuffix(strings.ToLower(trimmed), "/v1") {
return trimmed[:len(trimmed)-len("/v1")]
}
return trimmed
}
func keling30TurboPayload(request Request) (map[string]any, string, error) {
body := cleanProviderBody(request.Body)
content := contentItems(body["content"])
if len(content) == 0 {
content = buildVolcesContentFromBody(body)
}
shots := kelingShotPrompts(content)
if len(shots) > 6 {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo supports at most 6 shots", StatusCode: 400, Retryable: false}
}
prompt := firstKelingPrompt(content)
duration := numericValue(body["duration"], 5)
if len(shots) > 0 {
var promptBuilder strings.Builder
duration = 0
for index, shot := range shots {
if shot.duration < 1 || math.Abs(shot.duration-math.Round(shot.duration)) > 1e-9 {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo shot duration must be an integer of at least 1 second", StatusCode: 400, Retryable: false}
}
duration += shot.duration
fmt.Fprintf(&promptBuilder, "shot %d, %ds, %s; ", index+1, int(math.Round(shot.duration)), shot.text)
}
prompt = strings.TrimSpace(promptBuilder.String())
}
if prompt == "" {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo video prompt is required", StatusCode: 400, Retryable: false}
}
if math.Abs(duration-math.Round(duration)) > 1e-9 || duration < 3 || duration > 15 {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo duration must be an integer between 3 and 15 seconds", StatusCode: 400, Retryable: false}
}
firstFrame, lastFrame, referenceImages := kelingImageInputs(content)
if lastFrame != "" {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo image-to-video supports first frame only; last frame is not supported", StatusCode: 400, Retryable: false}
}
imageCount := len(referenceImages)
if firstFrame != "" {
imageCount++
}
if imageCount > 1 {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo image-to-video supports exactly one first-frame image", StatusCode: 400, Retryable: false}
}
if firstFrame == "" && len(referenceImages) == 1 {
firstFrame = referenceImages[0]
}
isImageToVideo := firstFrame != ""
resolution := strings.TrimSpace(firstNonEmptyStringValue(body, "resolution", "size"))
if resolution == "" {
resolution = "720p"
}
if resolution != "720p" && resolution != "1080p" {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo resolution must be 720p or 1080p", StatusCode: 400, Retryable: false}
}
promptLimit := 3072
if isImageToVideo {
promptLimit = 2500
}
if utf8.RuneCountInString(prompt) > promptLimit {
return nil, "", &ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("keling 3.0 turbo prompt exceeds %d characters", promptLimit), StatusCode: 400, Retryable: false}
}
settings := map[string]any{
"duration": int(math.Round(duration)),
"resolution": resolution,
}
options := map[string]any{
"watermark_info": map[string]any{"enabled": boolValue(body, "watermark")},
}
if callbackURL := strings.TrimSpace(firstNonEmptyStringValue(body, "callback_url", "callbackUrl")); callbackURL != "" {
options["callback_url"] = callbackURL
}
if externalTaskID := strings.TrimSpace(firstNonEmptyStringValue(body, "external_task_id", "externalTaskId")); externalTaskID != "" {
options["external_task_id"] = externalTaskID
}
if isImageToVideo {
return map[string]any{
"contents": []any{
map[string]any{"type": "prompt", "text": prompt},
map[string]any{"type": "first_frame", "url": firstFrame},
},
"settings": settings,
"options": options,
}, "/image-to-video/kling-3.0-turbo", nil
}
aspectRatio := strings.TrimSpace(firstNonEmptyStringValue(body, "aspect_ratio", "aspectRatio", "ratio"))
if aspectRatio == "" || aspectRatio == "adaptive" || aspectRatio == "keep_ratio" {
aspectRatio = "16:9"
}
if aspectRatio != "16:9" && aspectRatio != "9:16" && aspectRatio != "1:1" {
return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo aspect_ratio must be 16:9, 9:16, or 1:1", StatusCode: 400, Retryable: false}
}
settings["aspect_ratio"] = aspectRatio
return map[string]any{
"prompt": prompt,
"settings": settings,
"options": options,
}, "/text-to-video/kling-3.0-turbo", nil
}
func firstKelingPrompt(content []map[string]any) string {
for _, item := range content {
if stringFromAny(item["type"]) == "text" && stringFromAny(item["role"]) != "shot_prompt" && item["shot_index"] == nil {
@@ -822,6 +1072,30 @@ func kelingTaskStatus(result map[string]any) string {
return strings.ToLower(strings.TrimSpace(stringFromAny(kelingData(result)["task_status"])))
}
func kelingTaskAPITask(result map[string]any, taskID string) map[string]any {
tasks := mapListFromAny(result["data"])
for _, task := range tasks {
if strings.TrimSpace(stringFromAny(task["id"])) == taskID {
return task
}
}
if len(tasks) > 0 {
return tasks[0]
}
return map[string]any{}
}
func kelingTaskAPIErrorMessage(candidate store.RuntimeModelCandidate, task map[string]any, result map[string]any) string {
message := strings.TrimSpace(stringFromAny(task["message"]))
if message == "" {
message = strings.TrimSpace(stringFromAny(result["message"]))
}
if message == "" {
message = "keling 3.0 turbo video task failed"
}
return fmt.Sprintf("Platform:%s,Code:%v,requestId:%s,message:%s", candidate.Provider, result["code"], stringFromAny(result["request_id"]), message)
}
func kelingTaskErrorCode(result map[string]any) string {
if code := intFromAny(result["code"]); code != 0 {
return fmt.Sprintf("keling_%d", code)
@@ -887,6 +1161,42 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
}
}
func kelingTaskAPIVideoSuccessResult(request Request, upstreamTaskID string, task map[string]any, raw map[string]any) map[string]any {
outputs := mapListFromAny(task["outputs"])
items := make([]any, 0, len(outputs))
for _, output := range outputs {
if strings.ToLower(strings.TrimSpace(stringFromAny(output["type"]))) != "video" {
continue
}
videoURL := strings.TrimSpace(stringFromAny(output["url"]))
if videoURL == "" {
continue
}
item := map[string]any{"url": videoURL, "video_url": videoURL, "type": "video"}
if duration := numericValue(output["duration"], 0); duration > 0 {
item["duration"] = duration
}
if watermarkURL := strings.TrimSpace(stringFromAny(output["watermark_url"])); watermarkURL != "" {
item["watermark_url"] = watermarkURL
}
items = append(items, item)
}
created := intFromAny(task["create_time"])
if created == 0 {
created = int(nowUnix())
}
return map[string]any{
"id": upstreamTaskID,
"object": "video.generation",
"created": created,
"model": upstreamModelName(request.Candidate),
"status": "succeeded",
"upstream_task_id": upstreamTaskID,
"data": items,
"raw": raw,
}
}
func kelingVideoProgress(request Request, upstreamTaskID string) []Progress {
progress := providerProgress(request)
progress = append(progress, Progress{
+7
View File
@@ -43,7 +43,14 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
if endpointKind == "chat.completions" {
body = NormalizeChatCompletionRequestBody(body)
applyOpenAIChatReasoningParams(body, request.Candidate)
body = FilterOpenAIChatRequestBody(body)
} else if request.Kind == "responses" {
body = FilterOpenAIResponsesRequestBody(body)
if _, hasInput := body["input"]; !hasInput {
if messages, hasMessages := request.Body["messages"]; hasMessages {
body["input"] = messages
}
}
delete(body, "messages")
if request.UpstreamPreviousResponseID != "" {
body["previous_response_id"] = request.UpstreamPreviousResponseID
@@ -0,0 +1,108 @@
package clients
import (
"fmt"
"net/http"
"sort"
)
// Keep these lists aligned with openai-node 6.47.0 and the public OpenAI API
// reference. The Gateway accepts a small, explicit set of routing extensions at
// ingress, but only protocol fields (plus controlled provider adaptations) are
// allowed across the upstream boundary.
var openAIChatRequestParameters = stringSet(
"messages", "model", "audio", "frequency_penalty", "function_call", "functions",
"logit_bias", "logprobs", "max_completion_tokens", "max_tokens", "metadata",
"modalities", "moderation", "n", "parallel_tool_calls", "prediction",
"presence_penalty", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention",
"reasoning_effort", "response_format", "safety_identifier", "seed", "service_tier",
"stop", "store", "stream", "stream_options", "temperature", "tool_choice", "tools",
"top_logprobs", "top_p", "user", "verbosity", "web_search_options",
)
var openAIResponsesRequestParameters = stringSet(
"background", "context_management", "conversation", "include", "input", "instructions",
"max_output_tokens", "max_tool_calls", "metadata", "model", "moderation",
"parallel_tool_calls", "previous_response_id", "prompt", "prompt_cache_key",
"prompt_cache_options", "prompt_cache_retention", "reasoning", "safety_identifier",
"service_tier", "store", "stream", "stream_options", "temperature", "text",
"tool_choice", "tools", "top_logprobs", "top_p", "truncation", "user",
)
var gatewayOpenAIRequestExtensions = stringSet(
"runMode", "run_mode", "conversationId", "conversation_id", "sessionId", "session_id",
"requestId", "request_id", "signal", "userMessage", "user_message", "platformId",
"platform_id", "options", "enable_thinking", "thinking_budget_tokens", "enable_web_search",
"modelType", "model_type", "capability", "capabilityType", "mode", "simulation", "testMode",
)
var gatewayResponsesRequestExtensions = stringSet("messages", "presence_penalty", "frequency_penalty")
var controlledOpenAIChatProviderParameters = stringSet(
"enable_thinking", "thinking_budget", "thinking", "enable_web_search",
)
var controlledOpenAIResponsesProviderParameters = stringSet("presence_penalty", "frequency_penalty")
func ValidateOpenAIRequestParameters(kind string, body map[string]any) error {
allowed := openAIChatRequestParameters
if kind == "responses" {
allowed = openAIResponsesRequestParameters
}
unknown := make([]string, 0)
for key := range body {
if _, ok := allowed[key]; ok {
continue
}
if _, ok := gatewayOpenAIRequestExtensions[key]; ok {
continue
}
if kind == "responses" {
if _, ok := gatewayResponsesRequestExtensions[key]; ok {
continue
}
}
unknown = append(unknown, key)
}
if len(unknown) == 0 {
return nil
}
sort.Strings(unknown)
return &ClientError{
Code: "invalid_parameter",
Message: fmt.Sprintf("Unknown parameter: %s", unknown[0]),
Param: unknown[0],
StatusCode: http.StatusBadRequest,
Retryable: false,
}
}
func FilterOpenAIChatRequestBody(body map[string]any) map[string]any {
return filterOpenAIRequestBody(body, openAIChatRequestParameters, controlledOpenAIChatProviderParameters)
}
func FilterOpenAIResponsesRequestBody(body map[string]any) map[string]any {
return filterOpenAIRequestBody(body, openAIResponsesRequestParameters, controlledOpenAIResponsesProviderParameters)
}
func filterOpenAIRequestBody(body map[string]any, allowed map[string]struct{}, extensions map[string]struct{}) map[string]any {
out := make(map[string]any, len(body))
for key, value := range body {
if _, ok := allowed[key]; ok {
out[key] = value
continue
}
if _, ok := extensions[key]; ok {
out[key] = value
}
}
return out
}
func stringSet(values ...string) map[string]struct{} {
out := make(map[string]struct{}, len(values))
for _, value := range values {
out[value] = struct{}{}
}
return out
}
@@ -0,0 +1,89 @@
package clients
import (
"strings"
"testing"
)
func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) {
body := map[string]any{}
for key := range openAIChatRequestParameters {
body[key] = "sentinel-" + key
}
body["conversationId"] = "internal"
body["unknown"] = "must-not-leak"
filtered := FilterOpenAIChatRequestBody(body)
for key := range openAIChatRequestParameters {
if _, ok := filtered[key]; !ok {
t.Fatalf("official Chat parameter %q was removed", key)
}
}
for _, key := range []string{"conversationId", "unknown"} {
if _, ok := filtered[key]; ok {
t.Fatalf("internal/unknown parameter %q leaked upstream", key)
}
}
}
func TestOpenAIResponsesOfficialParametersSurviveBoundary(t *testing.T) {
body := map[string]any{}
for key := range openAIResponsesRequestParameters {
body[key] = "sentinel-" + key
}
body["request_id"] = "internal"
body["unknown"] = "must-not-leak"
filtered := FilterOpenAIResponsesRequestBody(body)
for key := range openAIResponsesRequestParameters {
if _, ok := filtered[key]; !ok {
t.Fatalf("official Responses parameter %q was removed", key)
}
}
for _, key := range []string{"request_id", "unknown"} {
if _, ok := filtered[key]; ok {
t.Fatalf("internal/unknown parameter %q leaked upstream", key)
}
}
}
func TestValidateOpenAIRequestParametersRejectsUnknownTopLevelField(t *testing.T) {
err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "rogue": true})
if err == nil || ErrorCode(err) != "invalid_parameter" || !strings.Contains(err.Error(), "rogue") {
t.Fatalf("expected OpenAI-style invalid_parameter for rogue field, got %v", err)
}
if ErrorParam(err) != "rogue" {
t.Fatalf("expected rogue parameter attribution, got %q", ErrorParam(err))
}
if err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "messages": []any{}, "request_id": "internal"}); err != nil {
t.Fatalf("expected controlled Responses extensions to remain accepted, got %v", err)
}
}
func TestResponsesFallbackMapsEquivalentCurrentParameters(t *testing.T) {
body, err := ResponsesRequestToChat(map[string]any{
"input": "hello", "store": false, "metadata": map[string]any{"trace": "1"},
"request_id": "internal-request", "platform_id": "internal-platform",
"moderation": map[string]any{"type": "auto"}, "prompt_cache_key": "cache-key",
"prompt_cache_options": map[string]any{"type": "ephemeral"}, "prompt_cache_retention": "in_memory",
"safety_identifier": "safe", "service_tier": "priority", "top_logprobs": 3,
"stream_options": map[string]any{"include_usage": true},
"text": map[string]any{"format": map[string]any{"type": "text"}, "verbosity": "low"},
}, nil)
if err != nil {
t.Fatalf("convert Responses request: %v", err)
}
for _, key := range []string{"store", "metadata", "moderation", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention", "safety_identifier", "service_tier", "top_logprobs", "stream_options", "verbosity"} {
if _, ok := body[key]; !ok {
t.Fatalf("equivalent parameter %q was not mapped", key)
}
}
if body["logprobs"] != true {
t.Fatalf("top_logprobs fallback must enable Chat logprobs: %+v", body)
}
for _, key := range []string{"request_id", "platform_id"} {
if _, ok := body[key]; ok {
t.Fatalf("internal Responses parameter %q leaked into Chat fallback: %+v", key, body)
}
}
}
+29 -12
View File
@@ -21,11 +21,16 @@ var supportedResponseFallbackParameters = map[string]struct{}{
"model": {}, "input": {}, "messages": {}, "instructions": {}, "tools": {}, "tool_choice": {},
"parallel_tool_calls": {}, "max_output_tokens": {}, "temperature": {}, "top_p": {},
"presence_penalty": {}, "frequency_penalty": {}, "reasoning": {}, "text": {},
"stream": {}, "store": {}, "previous_response_id": {}, "metadata": {}, "user": {},
"stream": {}, "stream_options": {}, "store": {}, "previous_response_id": {}, "metadata": {}, "user": {},
"moderation": {}, "prompt_cache_key": {}, "prompt_cache_options": {}, "prompt_cache_retention": {},
"safety_identifier": {}, "service_tier": {}, "top_logprobs": {},
}
func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[string]any, error) {
for key := range body {
if _, internal := gatewayOpenAIRequestExtensions[key]; internal {
continue
}
if _, ok := supportedResponseFallbackParameters[key]; !ok {
return nil, unsupportedResponseParameter(key)
}
@@ -61,11 +66,19 @@ func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[st
return nil, &ClientError{Code: "invalid_parameter", Message: "input is required", StatusCode: http.StatusBadRequest}
}
out := map[string]any{"messages": messages}
for _, key := range []string{"temperature", "top_p", "presence_penalty", "frequency_penalty", "parallel_tool_calls", "stream", "user"} {
for _, key := range []string{
"temperature", "top_p", "presence_penalty", "frequency_penalty", "parallel_tool_calls",
"stream", "stream_options", "store", "metadata", "user", "moderation", "prompt_cache_key",
"prompt_cache_options", "prompt_cache_retention", "safety_identifier", "service_tier",
} {
if value, ok := body[key]; ok {
out[key] = value
}
}
if value, ok := body["top_logprobs"]; ok {
out["top_logprobs"] = value
out["logprobs"] = true
}
if value, ok := body["max_output_tokens"]; ok {
out["max_tokens"] = value
}
@@ -84,13 +97,16 @@ func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[st
}
}
if rawText, ok := body["text"]; ok {
responseFormat, err := responseTextFormat(rawText)
responseFormat, verbosity, err := responseTextParams(rawText)
if err != nil {
return nil, err
}
if responseFormat != nil {
out["response_format"] = responseFormat
}
if verbosity != nil {
out["verbosity"] = verbosity
}
}
if rawTools, ok := body["tools"]; ok {
tools, err := responseToolsToChat(rawTools)
@@ -212,31 +228,32 @@ func responseToolChoiceToChat(value any) (any, error) {
return map[string]any{"type": "function", "function": map[string]any{"name": choice["name"]}}, nil
}
func responseTextFormat(value any) (map[string]any, error) {
func responseTextParams(value any) (map[string]any, any, error) {
text, ok := value.(map[string]any)
if !ok {
return nil, unsupportedResponseParameter("text")
return nil, nil, unsupportedResponseParameter("text")
}
for key := range text {
if key != "format" {
return nil, unsupportedResponseParameter("text." + key)
if key != "format" && key != "verbosity" {
return nil, nil, unsupportedResponseParameter("text." + key)
}
}
verbosity := text["verbosity"]
format, ok := text["format"].(map[string]any)
if !ok || len(format) == 0 {
return nil, nil
return nil, verbosity, nil
}
switch stringFromAny(format["type"]) {
case "text":
return map[string]any{"type": "text"}, nil
return map[string]any{"type": "text"}, verbosity, nil
case "json_object":
return map[string]any{"type": "json_object"}, nil
return map[string]any{"type": "json_object"}, verbosity, nil
case "json_schema":
return map[string]any{"type": "json_schema", "json_schema": map[string]any{
"name": format["name"], "schema": format["schema"], "strict": format["strict"],
}}, nil
}}, verbosity, nil
default:
return nil, unsupportedResponseParameter("text.format.type")
return nil, nil, unsupportedResponseParameter("text.format.type")
}
}
@@ -23,6 +23,10 @@ func TestOpenAIResponsesNativeUsesResponsesEndpointAndPreservesVendorIDs(t *test
if body["messages"] != nil {
t.Fatalf("native Responses request must not contain messages: %+v", body)
}
input, _ := body["input"].([]any)
if len(input) != 1 {
t.Fatalf("native Responses request must translate controlled messages to input: %+v", body)
}
if body["previous_response_id"] != "resp_upstream_parent" {
t.Fatalf("expected translated upstream previous id, got %+v", body["previous_response_id"])
}
@@ -41,7 +45,7 @@ func TestOpenAIResponsesNativeUsesResponsesEndpointAndPreservesVendorIDs(t *test
response, err := (OpenAIClient{}).Run(context.Background(), Request{
Kind: "responses", Model: "Demo",
Body: map[string]any{"input": "hello", "messages": []any{map[string]any{"role": "user", "content": "illegal"}}},
Body: map[string]any{"messages": []any{map[string]any{"role": "user", "content": "hello"}}},
Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}},
UpstreamProtocol: ProtocolOpenAIResponses, PublicResponseID: "resp_12345678901234567890123456789012",
PublicPreviousResponseID: "resp_abcdefghijklmnopqrstuvwxyz123456", UpstreamPreviousResponseID: "resp_upstream_parent",
+9
View File
@@ -103,6 +103,7 @@ type VoiceCloneDeleter interface {
type ClientError struct {
Code string
Message string
Param string
StatusCode int
RequestID string
ResponseStartedAt time.Time
@@ -111,6 +112,14 @@ type ClientError struct {
Retryable bool
}
func ErrorParam(err error) string {
var clientErr *ClientError
if errors.As(err, &clientErr) {
return clientErr.Param
}
return ""
}
func (e *ClientError) Error() string {
if e.Message != "" {
return e.Message
+81 -4
View File
@@ -74,12 +74,13 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
submitStartedAt := time.Now()
submitRequestID := strings.TrimSpace(request.RemoteTaskID)
upstreamTaskID := strings.TrimSpace(request.RemoteTaskID)
taskPath := volcesVideoTaskPath(request)
if upstreamTaskID == "" {
body := volcesVideoBody(request)
if err := validateVolcesVideoTaskBody(body); err != nil {
return Response{}, err
}
submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body)
submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, taskPath, apiKey, body)
submitRequestID = requestID
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
@@ -112,7 +113,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
}
pollStartedAt := time.Now()
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks/"+upstreamTaskID, apiKey)
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
pollFinishedAt := time.Now()
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
if err != nil {
@@ -159,6 +160,21 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
}
}
func volcesVideoTaskPath(request Request) string {
path := firstNonEmptyStringValue(
request.Candidate.PlatformConfig,
"volcesVideoTaskPath",
"videoTaskPath",
)
if path == "" {
return "/contents/generations/tasks"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return strings.TrimRight(path, "/")
}
func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) {
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
@@ -173,7 +189,11 @@ func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL str
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
return result, requestID, err
if err != nil {
return result, requestID, err
}
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
return result, firstNonEmpty(requestID, envelopeRequestID), err
}
func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, error) {
@@ -188,7 +208,64 @@ func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL stri
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
return result, requestID, err
if err != nil {
return result, requestID, err
}
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
return result, firstNonEmpty(requestID, envelopeRequestID), err
}
func normalizeVolcesCompatibleResult(result map[string]any) (map[string]any, string, error) {
requestID := firstNonEmpty(
stringFromAny(result["request_id"]),
stringFromAny(result["requestId"]),
)
if errorObject, ok := result["error"].(map[string]any); ok {
code := firstNonEmpty(
stringFromAny(errorObject["code"]),
stringFromAny(errorObject["type"]),
"volces_compatible_error",
)
message := strings.TrimSpace(stringFromAny(errorObject["message"]))
if message == "" {
message = "volces compatible request failed"
}
return result, requestID, &ClientError{
Code: code,
Message: message,
RequestID: requestID,
Retryable: false,
}
}
rawCode, hasCode := result["code"]
if !hasCode {
return result, requestID, nil
}
code, validCode := volcesIntFromAny(rawCode)
if !validCode {
return result, requestID, nil
}
if code != 0 {
message := strings.TrimSpace(stringFromAny(result["message"]))
if message == "" {
message = fmt.Sprintf("volces compatible request failed with code %d", code)
}
return result, requestID, &ClientError{
Code: fmt.Sprintf("volces_%d", code),
Message: message,
RequestID: requestID,
Retryable: false,
}
}
data, ok := result["data"].(map[string]any)
if !ok {
return result, requestID, nil
}
normalized := cloneBody(data)
if requestID != "" && requestIDFromResult(normalized) == "" {
normalized["request_id"] = requestID
}
return normalized, requestID, nil
}
func volcesImageBody(request Request) map[string]any {
@@ -0,0 +1,129 @@
package clients
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestVolcesClientSupportsDeyunEnvelope(t *testing.T) {
var submitted bool
var polled bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer deyun-secret" {
t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization"))
}
switch r.Method + " " + r.URL.Path {
case "POST /c39/api/v3/video/tasks":
submitted = true
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"request_id": "deyun-submit-request",
"data": map[string]any{"id": "deyun-task-1"},
})
case "GET /c39/api/v3/video/tasks/deyun-task-1":
polled = true
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"request_id": "deyun-poll-request",
"data": map[string]any{
"id": "deyun-task-1",
"status": "succeeded",
"created_at": 123,
"content": map[string]any{
"video_url": "https://example.com/deyun.mp4",
},
},
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "videos.generations",
ModelType: "video_generate",
Model: "deyun-seedance-2.0-canary",
Body: map[string]any{
"prompt": "A red cube rotates on a white table",
"resolution": "480p",
"ratio": "16:9",
"duration": 4,
"generate_audio": false,
},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL + "/c39/api/v3",
ProviderModelName: "doubao-seedance-2-0",
Credentials: map[string]any{"apiKey": "deyun-secret"},
PlatformConfig: map[string]any{
"volcesPollIntervalMs": 100,
"volcesPollTimeoutSeconds": 1,
"volcesVideoTaskPath": "/video/tasks",
},
},
})
if err != nil {
t.Fatalf("run deyun-compatible video task: %v", err)
}
if !submitted || !polled {
t.Fatalf("expected submit and poll, submitted=%v polled=%v", submitted, polled)
}
if response.RequestID != "deyun-poll-request" {
t.Fatalf("unexpected request id: %s", response.RequestID)
}
data, _ := response.Result["data"].([]any)
item, _ := data[0].(map[string]any)
if response.Result["upstream_task_id"] != "deyun-task-1" || item["url"] != "https://example.com/deyun.mp4" {
t.Fatalf("unexpected response: %+v", response.Result)
}
}
func TestNormalizeVolcesCompatibleResultPreservesNativeResponse(t *testing.T) {
native := map[string]any{"id": "native-task", "status": "queued"}
got, requestID, err := normalizeVolcesCompatibleResult(native)
if err != nil {
t.Fatalf("normalize native response: %v", err)
}
if got["id"] != "native-task" || requestID != "" {
t.Fatalf("native response changed unexpectedly: %+v requestID=%q", got, requestID)
}
}
func TestNormalizeVolcesCompatibleResultRejectsBusinessError(t *testing.T) {
_, requestID, err := normalizeVolcesCompatibleResult(map[string]any{
"code": 1004,
"message": "Authorization is expired",
"request_id": "deyun-error-request",
})
if err == nil {
t.Fatal("expected business error")
}
if requestID != "deyun-error-request" || ErrorCode(err) != "volces_1004" {
t.Fatalf("unexpected error metadata requestID=%q code=%q err=%v", requestID, ErrorCode(err), err)
}
if !strings.Contains(err.Error(), "Authorization is expired") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestNormalizeVolcesCompatibleResultRejectsHTTP200ErrorObject(t *testing.T) {
_, _, err := normalizeVolcesCompatibleResult(map[string]any{
"error": map[string]any{
"code": "ModelNotOpen",
"message": "model service is not activated",
"type": "Not Found",
},
})
if err == nil {
t.Fatal("expected HTTP 200 error object to fail")
}
if ErrorCode(err) != "ModelNotOpen" || !strings.Contains(err.Error(), "not activated") {
t.Fatalf("unexpected error: code=%q err=%v", ErrorCode(err), err)
}
}