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)
}
}
@@ -142,7 +142,7 @@ func TestWriteCompatibleTaskResponseMapsInvalidParameterToBadRequest(t *testing.
executor := &fakeTaskExecutor{
runErr: &clients.ClientError{
Code: "invalid_parameter",
Message: "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh",
Message: "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh, max",
Retryable: false,
},
}
@@ -157,6 +157,9 @@ func TestWriteCompatibleTaskResponseMapsInvalidParameterToBadRequest(t *testing.
if !strings.Contains(recorder.Body.String(), "invalid_parameter") {
t.Fatalf("response should include invalid_parameter code: %s", recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"type":"invalid_request_error"`) || !strings.Contains(recorder.Body.String(), `"param":null`) {
t.Fatalf("response should use OpenAI error fields: %s", recorder.Body.String())
}
}
func TestWriteCompatibleTaskResponseReturnsSSEWhenStreamIsTrue(t *testing.T) {
+40 -4
View File
@@ -416,6 +416,10 @@ func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) {
}
model, err := s.store.CreatePlatformModel(r.Context(), input)
if err != nil {
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "base model not found")
return
@@ -460,6 +464,10 @@ func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) {
models, err := s.store.ReplacePlatformModels(r.Context(), platformID, input.Models)
if err != nil {
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "base model not found")
return
@@ -966,7 +974,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Failure 404 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/responses [post]
// @Router /api/v1/embeddings [post]
// @Router /api/v1/reranks [post]
// @Router /api/v1/images/generations [post]
@@ -976,8 +983,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 /chat/completions [post]
// @Router /v1/chat/completions [post]
// @Router /embeddings [post]
// @Router /v1/embeddings [post]
// @Router /reranks [post]
@@ -1011,6 +1016,12 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
writeError(w, status, err.Error(), clients.ErrorCode(err))
return
}
if kind == "chat.completions" || kind == "responses" {
if err := clients.ValidateOpenAIRequestParameters(kind, body); err != nil {
writeErrorWithDetails(w, http.StatusBadRequest, err.Error(), map[string]any{"param": clients.ErrorParam(err)}, clients.ErrorCode(err))
return
}
}
model := requestModelName(body)
if model == "" {
writeError(w, http.StatusBadRequest, "model is required")
@@ -1082,7 +1093,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
// @Produce text/event-stream
// @Security BearerAuth
// @Param X-Async header bool false "该接口忽略此参数"
// @Param input body TaskRequest true "Chat Completions 请求"
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
// @Success 200 {object} ChatCompletionCompatibleResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
@@ -1096,6 +1107,26 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
return s.createTask("chat.completions", false)
}
// openAIChatCompletionsDoc godoc
// @Summary 创建 OpenAI Chat Completions
// @Description OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter。
// @Tags chat
// @Accept json
// @Produce json
// @Produce text/event-stream
// @Security BearerAuth
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
// @Success 200 {object} ChatCompletionCompatibleResponse
// @Failure 400 {object} ErrorEnvelope "invalid_parameter"
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @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
// @Summary 创建 OpenAI Responses
// @Description 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 previous_response_id 时由调用方管理完整状态,Gateway 以本轮 input/messages 为准且不追加本地历史。
@@ -1114,6 +1145,7 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
// @Router /responses [post]
// @Router /v1/responses [post]
// @Router /api/v1/responses [post]
func openAIResponsesDoc() {}
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
@@ -1344,6 +1376,10 @@ func statusFromRunError(err error) int {
return http.StatusServiceUnavailable
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "invalid_parameter" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy":
return http.StatusBadRequest
case store.ModelCandidateErrorCode(err) == "invalid_parameter":
return http.StatusBadRequest
case store.ModelCandidateErrorCode(err) == "model_capability_configuration_error":
return http.StatusInternalServerError
case clients.ErrorCode(err) == "cloned_voice_not_found":
return http.StatusNotFound
case store.ModelCandidateErrorCode(err) == "platform_cooling_down" || store.ModelCandidateErrorCode(err) == "model_cooling_down":
@@ -0,0 +1,194 @@
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"
)
func TestKeling30TurboSimulationFlow(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 simulation 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()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
username := "kling_sim_" + suffix
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, &registerResponse)
var apiKeyResponse struct {
Secret string `json:"secret"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", registerResponse.AccessToken, map[string]any{
"name": "kling simulation key",
}, http.StatusCreated, &apiKeyResponse)
if _, err := db.Pool().Exec(ctx, `
UPDATE gateway_users
SET roles = '["admin"]'::jsonb
WHERE username = $1`, username); err != nil {
t.Fatalf("promote Kling simulation 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)
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 Kling simulation user id: %v", err)
}
doJSON(t, server.URL, http.MethodPatch, "/api/admin/users/"+gatewayUserID+"/wallet", loginResponse.AccessToken, map[string]any{
"currency": "resource",
"balance": 1000,
"reason": "seed Kling simulation wallet",
}, http.StatusOK, nil)
var platform struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
"provider": "keling",
"platformKey": "keling-simulation-" + suffix,
"name": "Kling Simulation",
"baseUrl": "https://api-beijing.klingai.com/v1",
"authType": "AccessKey-SecretKey",
"credentials": map[string]any{
"accessKey": "legacy-ak",
"secretKey": "legacy-sk",
},
}, http.StatusCreated, &platform)
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "keling:kling-3.0-turbo",
"modelName": "kling-3.0-turbo",
"providerModelName": "kling-3.0-turbo",
"modelAlias": "可灵3.0 Turbo",
"modelType": []string{"video_generate", "image_to_video"},
"displayName": "可灵3.0 Turbo",
}, http.StatusCreated, nil)
assertKeling30TurboSimulationTask := func(
name string,
request map[string]any,
expectedModelType string,
) {
t.Helper()
t.Run(name, func(t *testing.T) {
var response struct {
Task struct {
ID string `json:"id"`
Status string `json:"status"`
RunMode string `json:"runMode"`
ModelType string `json:"modelType"`
ResolvedModel string `json:"resolvedModel"`
Result map[string]any `json:"result"`
Metrics map[string]any `json:"metrics"`
BillingSummary map[string]any `json:"billingSummary"`
FinalChargeAmount float64 `json:"finalChargeAmount"`
ResponseDurationMS int64 `json:"responseDurationMs"`
} `json:"task"`
}
doJSON(
t,
server.URL,
http.MethodPost,
"/api/v1/videos/generations",
apiKeyResponse.Secret,
request,
http.StatusAccepted,
&response,
)
task := response.Task
if task.ID == "" ||
task.Status != "succeeded" ||
task.RunMode != "simulation" ||
task.ModelType != expectedModelType ||
task.ResolvedModel != "kling-3.0-turbo" {
t.Fatalf("unexpected Kling simulation task: %+v", task)
}
data, _ := task.Result["data"].([]any)
item, _ := data[0].(map[string]any)
if item["video_url"] != "/static/simulation/video.mp4" ||
item["assetSource"] != "simulation" {
t.Fatalf("unexpected Kling simulation result: %+v", task.Result)
}
if task.FinalChargeAmount <= 0 ||
task.BillingSummary["finalCharge"] == nil ||
task.Metrics["parameterPreprocessingSummary"] == nil ||
task.ResponseDurationMS <= 0 {
t.Fatalf("Kling simulation should preserve billing, preprocessing and timing: %+v", task)
}
})
}
assertKeling30TurboSimulationTask("text-to-video", map[string]any{
"model": "可灵3.0 Turbo",
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"prompt": "A cinematic city reveal",
"duration": 8,
"resolution": "1080p",
"aspect_ratio": "9:16",
"audio": true,
}, "video_generate")
assertKeling30TurboSimulationTask("image-to-video", map[string]any{
"model": "可灵3.0 Turbo",
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"prompt": "The subject looks toward the camera",
"image": "https://example.com/first.png",
"duration": 5,
"resolution": "720p",
"audio": true,
}, "image_to_video")
}
+86 -29
View File
@@ -35,6 +35,8 @@ type ErrorPayload struct {
Message string `json:"message" example:"invalid json body"`
Status int `json:"status" example:"400"`
Code string `json:"code,omitempty" example:"rate_limit"`
Type string `json:"type,omitempty" example:"invalid_request_error"`
Param any `json:"param,omitempty"`
}
type AuthResponse struct {
@@ -193,16 +195,19 @@ type PricingEstimateResponse struct {
type TaskRequest struct {
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages,omitempty"`
Input string `json:"input,omitempty" example:"Tell me a short story"`
Input interface{} `json:"input,omitempty"`
Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"`
Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."`
TextFileID string `json:"text_file_id,omitempty" example:""`
VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"`
Stream bool `json:"stream,omitempty" example:"false"`
Stream *bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
// MaxCompletionTokens includes visible output and reasoning tokens.
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"`
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium" enums:"none,minimal,low,medium,high,xhigh,max"`
Size string `json:"size,omitempty" example:"1024x1024"`
Duration int `json:"duration,omitempty" example:"5"`
Resolution string `json:"resolution,omitempty" example:"720p"`
@@ -223,36 +228,88 @@ type TaskRequest struct {
}
type ChatCompletionRequest struct {
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty" example:"0.7"`
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
Stream bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages"`
Audio map[string]interface{} `json:"audio,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" example:"0"`
FunctionCall interface{} `json:"function_call,omitempty"`
Functions []map[string]interface{} `json:"functions,omitempty"`
LogitBias map[string]interface{} `json:"logit_bias,omitempty"`
Logprobs *bool `json:"logprobs,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"`
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Modalities []string `json:"modalities,omitempty"`
Moderation interface{} `json:"moderation,omitempty"`
N *int `json:"n,omitempty" example:"1"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
Prediction interface{} `json:"prediction,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty" example:"0"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
PromptCacheOptions map[string]interface{} `json:"prompt_cache_options,omitempty"`
PromptCacheRetention string `json:"prompt_cache_retention,omitempty" enums:"in_memory,24h"`
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium" enums:"none,minimal,low,medium,high,xhigh,max"`
ResponseFormat interface{} `json:"response_format,omitempty"`
SafetyIdentifier string `json:"safety_identifier,omitempty"`
Seed *int `json:"seed,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Stop interface{} `json:"stop,omitempty"`
Store *bool `json:"store,omitempty"`
Stream *bool `json:"stream,omitempty" example:"false"`
StreamOptions map[string]interface{} `json:"stream_options,omitempty"`
Temperature *float64 `json:"temperature,omitempty" example:"0.7"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Tools []map[string]interface{} `json:"tools,omitempty"`
TopLogprobs *int `json:"top_logprobs,omitempty"`
TopP *float64 `json:"top_p,omitempty" example:"1"`
User string `json:"user,omitempty"`
Verbosity string `json:"verbosity,omitempty"`
WebSearchOptions map[string]interface{} `json:"web_search_options,omitempty"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
}
type ChatMessage struct {
Role string `json:"role" example:"user"`
Content string `json:"content" example:"Hello"`
Role string `json:"role" example:"user"`
Content interface{} `json:"content,omitempty"`
Name string `json:"name,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls interface{} `json:"tool_calls,omitempty"`
FunctionCall interface{} `json:"function_call,omitempty"`
}
type ResponsesRequest struct {
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Input interface{} `json:"input"`
Instructions string `json:"instructions,omitempty" example:"Answer concisely"`
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_0123456789abcdef0123456789abcdef"`
Tools []map[string]interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty" example:"true"`
MaxOutputTokens int `json:"max_output_tokens,omitempty" example:"512"`
Reasoning map[string]interface{} `json:"reasoning,omitempty"`
Text map[string]interface{} `json:"text,omitempty"`
Temperature float64 `json:"temperature,omitempty" example:"0.7"`
TopP float64 `json:"top_p,omitempty" example:"1"`
Store *bool `json:"store,omitempty"`
Stream bool `json:"stream,omitempty" example:"false"`
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Background *bool `json:"background,omitempty"`
ContextManagement []map[string]interface{} `json:"context_management,omitempty"`
Conversation interface{} `json:"conversation,omitempty"`
Include []string `json:"include,omitempty"`
Input interface{} `json:"input"`
Instructions string `json:"instructions,omitempty" example:"Answer concisely"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"`
MaxToolCalls *int `json:"max_tool_calls,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Moderation interface{} `json:"moderation,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty" example:"true"`
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_0123456789abcdef0123456789abcdef"`
Prompt interface{} `json:"prompt,omitempty"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
PromptCacheOptions map[string]interface{} `json:"prompt_cache_options,omitempty"`
PromptCacheRetention string `json:"prompt_cache_retention,omitempty" enums:"in_memory,24h"`
Reasoning map[string]interface{} `json:"reasoning,omitempty"`
SafetyIdentifier string `json:"safety_identifier,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store *bool `json:"store,omitempty"`
Stream *bool `json:"stream,omitempty" example:"false"`
StreamOptions map[string]interface{} `json:"stream_options,omitempty"`
Temperature *float64 `json:"temperature,omitempty" example:"0.7"`
Text map[string]interface{} `json:"text,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Tools []map[string]interface{} `json:"tools,omitempty"`
TopLogprobs *int `json:"top_logprobs,omitempty"`
TopP *float64 `json:"top_p,omitempty" example:"1"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
}
type ResponsesCompatibleResponse struct {
+6
View File
@@ -25,6 +25,12 @@ func writeErrorWithDetails(w http.ResponseWriter, status int, message string, de
if len(codes) > 0 {
if code := strings.TrimSpace(codes[0]); code != "" {
errorPayload["code"] = code
if code == "invalid_parameter" || code == "unsupported_response_parameter" {
errorPayload["type"] = "invalid_request_error"
if _, ok := details["param"]; !ok {
errorPayload["param"] = nil
}
}
}
}
for key, value := range details {
@@ -0,0 +1,232 @@
package runner
import (
"fmt"
"math"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const (
volcesOutputTokenThreshold = 10240
volcesOutputCapabilityErrorCode = "model_capability_configuration_error"
volcesOutputInvalidParameterCode = "invalid_parameter"
)
type outputTokenLimitProcessor struct{}
func (outputTokenLimitProcessor) Name() string { return "OutputTokenLimitProcessor" }
func (outputTokenLimitProcessor) ShouldProcess(_ map[string]any, modelType string, context *paramProcessContext) bool {
return context != nil && isOpenAITextGenerationKind(context.kind) && isTextOutputModelType(modelType) && isVolcesCandidate(context.candidate)
}
func (outputTokenLimitProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool {
modelMax, sourceType, capabilityValue, ok := candidateMaxOutputTokens(context.candidate, modelType)
path := capabilityPath(sourceType, "max_output_tokens")
if !ok {
return context.reject(
"OutputTokenLimitProcessor",
outputTokenParameter(context.kind),
nil,
"火山引擎文本候选未配置正整数 max_output_tokens,已禁止执行该候选。",
path,
capabilityValue,
)
}
if context.kind == "chat.completions" {
if value, explicit := nonNullParameter(params, "max_tokens"); explicit {
if parsed, valid := positiveInteger(value); !valid || parsed > modelMax {
return context.reject("OutputTokenLimitProcessor", "max_tokens", value, fmt.Sprintf("max_tokens must be a positive integer no greater than the selected Volcengine model limit (%d)", modelMax), path, capabilityValue)
}
return true
}
if _, explicit := nonNullParameter(params, "max_completion_tokens"); explicit {
return true
}
value := defaultVolcesOutputTokens(modelMax)
params["max_tokens"] = value
context.recordChange(
"OutputTokenLimitProcessor", "set", "max_tokens", nil, value,
fmt.Sprintf("火山候选未显式设置输出上限:floor(%d/3)=%d,阈值=%d,注入候选级默认值。", modelMax, modelMax/3, volcesOutputTokenThreshold),
path, capabilityValue,
)
return true
}
if value, explicit := nonNullParameter(params, "max_output_tokens"); explicit {
if parsed, valid := positiveInteger(value); !valid || parsed > modelMax {
return context.reject("OutputTokenLimitProcessor", "max_output_tokens", value, fmt.Sprintf("max_output_tokens must be a positive integer no greater than the selected Volcengine model limit (%d)", modelMax), path, capabilityValue)
}
return true
}
value := defaultVolcesOutputTokens(modelMax)
params["max_output_tokens"] = value
context.recordChange(
"OutputTokenLimitProcessor", "set", "max_output_tokens", nil, value,
fmt.Sprintf("火山候选未显式设置输出上限:floor(%d/3)=%d,阈值=%d,注入候选级默认值。", modelMax, modelMax/3, volcesOutputTokenThreshold),
path, capabilityValue,
)
return true
}
func filterRuntimeCandidatesByOutputTokens(kind string, requestedModel string, modelType string, body map[string]any, candidates []store.RuntimeModelCandidate) ([]store.RuntimeModelCandidate, map[string]any, error) {
if !isOpenAITextGenerationKind(kind) || !isTextOutputModelType(modelType) || len(candidates) == 0 {
return candidates, nil, nil
}
filtered := make([]store.RuntimeModelCandidate, 0, len(candidates))
rejected := make([]map[string]any, 0)
invalidExplicit := false
for _, candidate := range candidates {
if !isVolcesCandidate(candidate) {
filtered = append(filtered, candidate)
continue
}
modelMax, sourceType, raw, configured := candidateMaxOutputTokens(candidate, modelType)
detail := map[string]any{
"platformId": candidate.PlatformID, "platformKey": candidate.PlatformKey, "provider": candidate.Provider,
"platformModelId": candidate.PlatformModelID, "providerModelName": candidate.ProviderModelName,
"modelType": modelType, "capabilityPath": capabilityPath(sourceType, "max_output_tokens"), "capabilityValue": raw,
}
if !configured {
detail["reason"] = "max_output_tokens_missing"
rejected = append(rejected, detail)
continue
}
if parameter, value, explicit := explicitOutputTokenParameter(kind, body); explicit {
parsed, valid := positiveInteger(value)
if !valid || (parameter != "max_completion_tokens" && parsed > modelMax) {
detail["reason"] = "requested_output_tokens_exceed_capability"
detail["parameter"] = parameter
detail["requestedValue"] = value
invalidExplicit = true
rejected = append(rejected, detail)
continue
}
}
filtered = append(filtered, candidate)
}
if len(rejected) == 0 {
return filtered, nil, nil
}
summary := map[string]any{
"filter": "volces_output_token_limit", "kind": kind, "requestedModel": requestedModel, "modelType": modelType,
"candidateCount": len(candidates), "supportedCandidateCount": len(filtered), "filteredCandidateCount": len(rejected),
"threshold": volcesOutputTokenThreshold, "formula": "third=floor(modelMaxOutputTokens/3); default=third>=10240?third:modelMaxOutputTokens",
"rejectedCandidates": rejected,
}
if len(filtered) > 0 {
return filtered, summary, nil
}
code := volcesOutputCapabilityErrorCode
message := "所有火山引擎文本候选都缺少有效的 max_output_tokens 能力配置"
if invalidExplicit {
code = volcesOutputInvalidParameterCode
message = "请求的输出 token 上限超过所有可用火山引擎候选的模型能力"
}
summary["code"] = code
return nil, summary, &store.ModelCandidateUnavailableError{Code: code, Message: message, Details: summary}
}
func defaultVolcesOutputTokens(modelMax int) int {
third := modelMax / 3
if third >= volcesOutputTokenThreshold {
return third
}
return modelMax
}
func isVolcesCandidate(candidate store.RuntimeModelCandidate) bool {
provider := strings.ToLower(strings.TrimSpace(candidate.Provider))
baseURL := strings.ToLower(strings.TrimSpace(candidate.BaseURL))
return provider == "volces-openai" || strings.Contains(baseURL, "volces.com") || strings.Contains(baseURL, "byteplus.com")
}
func candidateMaxOutputTokens(candidate store.RuntimeModelCandidate, modelType string) (int, string, any, bool) {
capabilities := effectiveModelCapability(candidate)
seen := map[string]struct{}{}
for _, candidateType := range []string{candidate.ModelType, modelType, "text_generate"} {
candidateType = strings.TrimSpace(candidateType)
if candidateType == "" {
continue
}
if _, ok := seen[candidateType]; ok {
continue
}
seen[candidateType] = struct{}{}
capability := capabilityForType(capabilities, candidateType)
if capability == nil {
continue
}
raw, exists := capability["max_output_tokens"]
if !exists {
continue
}
value, ok := positiveInteger(raw)
return value, candidateType, raw, ok
}
return 0, firstNonEmptyString(candidate.ModelType, modelType, "text_generate"), nil, false
}
func positiveInteger(value any) (int, bool) {
number := floatFromAny(value)
if number <= 0 || math.Trunc(number) != number || number > float64(math.MaxInt) {
return 0, false
}
return int(number), true
}
func nonNullParameter(body map[string]any, key string) (any, bool) {
value, ok := body[key]
return value, ok && value != nil
}
func explicitOutputTokenParameter(kind string, body map[string]any) (string, any, bool) {
if kind == "responses" {
value, ok := nonNullParameter(body, "max_output_tokens")
return "max_output_tokens", value, ok
}
if value, ok := nonNullParameter(body, "max_tokens"); ok {
return "max_tokens", value, true
}
value, ok := nonNullParameter(body, "max_completion_tokens")
return "max_completion_tokens", value, ok
}
func outputTokenParameter(kind string) string {
if kind == "responses" {
return "max_output_tokens"
}
return "max_tokens"
}
func isOpenAITextGenerationKind(kind string) bool {
return kind == "chat.completions" || kind == "responses"
}
func isTextOutputModelType(modelType string) bool {
switch strings.TrimSpace(modelType) {
case "", "text_generate", "chat", "responses", "text":
return true
default:
return false
}
}
func mergeCandidateFilterSummaries(summaries ...map[string]any) map[string]any {
nonEmpty := make([]any, 0, len(summaries))
for _, summary := range summaries {
if len(summary) > 0 {
nonEmpty = append(nonEmpty, summary)
}
}
if len(nonEmpty) == 0 {
return nil
}
if len(nonEmpty) == 1 {
return nonEmpty[0].(map[string]any)
}
return map[string]any{"filters": nonEmpty}
}
@@ -0,0 +1,113 @@
package runner
import (
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func volcTextCandidate(maxOutput any) store.RuntimeModelCandidate {
capability := map[string]any{}
if maxOutput != nil {
capability["max_output_tokens"] = maxOutput
}
return store.RuntimeModelCandidate{
Provider: "volces-openai", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
ModelType: "text_generate", ProviderModelName: "demo",
Capabilities: map[string]any{"text_generate": capability},
}
}
func TestDefaultVolcesOutputTokens(t *testing.T) {
tests := []struct {
name string
max int
want int
}{
{name: "128K", max: 131072, want: 43690},
{name: "32K", max: 32768, want: 10922},
{name: "24K below threshold", max: 24576, want: 24576},
{name: "threshold exact", max: 30720, want: 10240},
{name: "threshold minus one", max: 30719, want: 30719},
{name: "floor", max: 131071, want: 43690},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := defaultVolcesOutputTokens(test.max); got != test.want {
t.Fatalf("defaultVolcesOutputTokens(%d)=%d, want %d", test.max, got, test.want)
}
})
}
}
func TestVolcesOutputProcessorInjectsCandidateSpecificDefaults(t *testing.T) {
chat := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}, "max_tokens": nil}, volcTextCandidate(131072))
if chat.Err != nil || chat.Body["max_tokens"] != 43690 {
t.Fatalf("unexpected Chat preprocessing: body=%+v err=%v", chat.Body, chat.Err)
}
if len(chat.Log.Changes) != 1 || chat.Log.Changes[0].CapabilityPath != "capabilities.text_generate.max_output_tokens" {
t.Fatalf("expected auditable capability source, got %+v", chat.Log.Changes)
}
responses := preprocessRequestWithLog("responses", map[string]any{"input": "hello", "max_output_tokens": nil}, volcTextCandidate(24576))
if responses.Err != nil || responses.Body["max_output_tokens"] != 24576 {
t.Fatalf("unexpected Responses preprocessing: body=%+v err=%v", responses.Body, responses.Err)
}
}
func TestVolcesOutputProcessorPreservesExplicitLimits(t *testing.T) {
for _, body := range []map[string]any{
{"messages": []any{}, "max_tokens": 1234},
{"messages": []any{}, "max_completion_tokens": 2345},
{"messages": []any{}, "max_tokens": 1234, "max_completion_tokens": 2345},
} {
result := preprocessRequestWithLog("chat.completions", body, volcTextCandidate(32768))
if result.Err != nil || len(result.Log.Changes) != 0 {
t.Fatalf("explicit Chat limit should remain unchanged: body=%+v err=%v changes=%+v", result.Body, result.Err, result.Log.Changes)
}
}
result := preprocessRequestWithLog("responses", map[string]any{"input": "hello", "max_output_tokens": 3456}, volcTextCandidate(32768))
if result.Err != nil || result.Body["max_output_tokens"] != 3456 || len(result.Log.Changes) != 0 {
t.Fatalf("explicit Responses limit should remain unchanged: %+v", result)
}
}
func TestVolcesOutputCandidateFilterSupportsFailover(t *testing.T) {
missing := volcTextCandidate(nil)
missing.PlatformID = "missing"
valid := volcTextCandidate(32768)
valid.PlatformID = "valid"
filtered, summary, err := filterRuntimeCandidatesByOutputTokens("chat.completions", "demo", "text_generate", map[string]any{"messages": []any{}}, []store.RuntimeModelCandidate{missing, valid})
if err != nil || len(filtered) != 1 || filtered[0].PlatformID != "valid" {
t.Fatalf("expected missing capability candidate to be skipped: filtered=%+v summary=%+v err=%v", filtered, summary, err)
}
result := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}}, filtered[0])
if result.Body["max_tokens"] != 10922 {
t.Fatalf("failover candidate must recalculate its own default, got %+v", result.Body)
}
}
func TestVolcesOutputCandidateFilterRejectsMissingAndExceededCapabilities(t *testing.T) {
_, _, err := filterRuntimeCandidatesByOutputTokens("responses", "demo", "text_generate", map[string]any{"input": "hello"}, []store.RuntimeModelCandidate{volcTextCandidate(nil)})
if store.ModelCandidateErrorCode(err) != volcesOutputCapabilityErrorCode {
t.Fatalf("expected capability configuration error, got %v", err)
}
_, _, err = filterRuntimeCandidatesByOutputTokens("chat.completions", "demo", "text_generate", map[string]any{"messages": []any{}, "max_tokens": 32769}, []store.RuntimeModelCandidate{volcTextCandidate(32768)})
if store.ModelCandidateErrorCode(err) != volcesOutputInvalidParameterCode {
t.Fatalf("expected invalid_parameter for explicit limit, got %v", err)
}
}
func TestNonVolcesOutputProcessorDoesNotInject(t *testing.T) {
candidate := volcTextCandidate(131072)
candidate.Provider = "openai"
candidate.BaseURL = "https://api.openai.com/v1"
chat := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}}, candidate)
if _, ok := chat.Body["max_tokens"]; ok {
t.Fatalf("non-Volcengine Chat candidate must remain unchanged: %+v", chat.Body)
}
responses := preprocessRequestWithLog("responses", map[string]any{"input": "hello"}, candidate)
if _, ok := responses.Body["max_output_tokens"]; ok {
t.Fatalf("non-Volcengine Responses candidate must remain unchanged: %+v", responses.Body)
}
}
@@ -55,6 +55,7 @@ type parameterPreprocessChange struct {
func NewParamProcessorChain() ParamProcessorChain {
return ParamProcessorChain{
processors: []paramProcessor{
outputTokenLimitProcessor{},
resolutionNormalizeProcessor{},
aspectRatioProcessor{},
imageSizeProcessor{},
+4
View File
@@ -28,6 +28,10 @@ func (s *Service) Estimate(ctx context.Context, kind string, model string, body
if err != nil {
return EstimateResult{}, err
}
candidates, _, err = filterRuntimeCandidatesByOutputTokens(kind, model, modelType, body, candidates)
if err != nil {
return EstimateResult{}, err
}
candidate := candidates[0]
body = preprocessRequest(kind, body, candidate)
items := s.estimatedBillings(ctx, user, kind, body, candidate)
+4 -1
View File
@@ -88,7 +88,10 @@ func (s *Service) startRiverQueue(ctx context.Context) error {
Queues: map[string]river.QueueConfig{
asyncTaskQueueName: {MaxWorkers: 32},
},
RescueStuckJobsAfter: 30 * time.Second,
// Provider-backed media jobs commonly poll for 10-20 minutes. River may
// execute a still-running job again once this window elapses, so keep the
// rescue horizon above the longest configured provider poll timeout.
RescueStuckJobsAfter: time.Hour,
TestOnly: s.cfg.AppEnv == "test",
Workers: workers,
})
+29 -1
View File
@@ -230,6 +230,22 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut
}
return Result{Task: failed, Output: failed.Result}, err
}
var outputTokenFilterSummary map[string]any
candidates, outputTokenFilterSummary, err = filterRuntimeCandidatesByOutputTokens(task.Kind, task.Model, modelType, body, candidates)
candidateFilterSummary = mergeCandidateFilterSummaries(candidateFilterSummary, outputTokenFilterSummary)
if err != nil {
candidateFilterMetrics := candidateCapabilityFilterMetrics(candidateFilterSummary)
s.recordFailedAttempt(ctx, failedAttemptRecord{
Task: task, Body: body, AttemptNo: task.AttemptCount + 1, Code: store.ModelCandidateErrorCode(err), Cause: err,
Simulated: task.RunMode == "simulation", Scope: "candidate_output_token_filter", Reason: store.ModelCandidateErrorCode(err),
ExtraMetrics: []map[string]any{candidateFilterMetrics}, ModelType: modelType,
})
failed, finishErr := s.failTask(ctx, task.ID, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err, candidateFilterMetrics)
if finishErr != nil {
return Result{}, finishErr
}
return Result{Task: failed, Output: failed.Result}, err
}
if task.Kind == "responses" {
candidates, err = prepareResponseCandidates(candidates, responseExecution)
if err != nil {
@@ -839,7 +855,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
}
func (s *Service) recordTaskParameterPreprocessing(ctx context.Context, taskID string, attemptID string, attemptNo int, candidate store.RuntimeModelCandidate, log parameterPreprocessingLog) error {
if skipTaskParameterPreprocessingLog(log.ModelType) {
if skipTaskParameterPreprocessingLog(log.ModelType) && !log.Changed {
return nil
}
_, err := s.store.CreateTaskParamPreprocessingLog(ctx, store.CreateTaskParamPreprocessingLogInput{
@@ -1348,10 +1364,22 @@ func validateRequest(kind string, body map[string]any) error {
if err := clients.ValidateOpenAIReasoningEffort(body["reasoning_effort"]); err != nil {
return err
}
for _, key := range []string{"max_tokens", "max_completion_tokens"} {
if value, explicit := nonNullParameter(body, key); explicit {
if _, ok := positiveInteger(value); !ok {
return &clients.ClientError{Code: "invalid_parameter", Message: key + " must be a positive integer", Param: key, StatusCode: 400, Retryable: false}
}
}
}
case "responses":
if body["input"] == nil && body["messages"] == nil {
return errors.New("input or messages is required")
}
if value, explicit := nonNullParameter(body, "max_output_tokens"); explicit {
if _, ok := positiveInteger(value); !ok {
return &clients.ClientError{Code: "invalid_parameter", Message: "max_output_tokens must be a positive integer", Param: "max_output_tokens", StatusCode: 400, Retryable: false}
}
}
case "embeddings":
if body["input"] == nil {
return errors.New("input is required")
+2 -2
View File
@@ -16,7 +16,7 @@ func (namedClient) Run(context.Context, clients.Request) (clients.Response, erro
}
func TestValidateRequestAcceptsOpenAIReasoningEffort(t *testing.T) {
for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh"} {
for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"} {
t.Run(effort, func(t *testing.T) {
err := validateRequest("chat.completions", map[string]any{
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
@@ -30,7 +30,7 @@ func TestValidateRequestAcceptsOpenAIReasoningEffort(t *testing.T) {
}
func TestValidateRequestRejectsNonOpenAIReasoningEffort(t *testing.T) {
for _, effort := range []string{"max", "auto"} {
for _, effort := range []string{"auto"} {
t.Run(effort, func(t *testing.T) {
err := validateRequest("chat.completions", map[string]any{
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
@@ -3,6 +3,8 @@ package store
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"github.com/jackc/pgx/v5"
@@ -116,6 +118,9 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier,
if len(capabilities) == 0 {
capabilities = EffectivePlatformModelCapabilities(base.Capabilities, input.CapabilityOverride)
}
if err := validateEnabledVolcesTextModelCapabilities(ctx, q, input, capabilities); err != nil {
return PlatformModel{}, err
}
billingConfig := input.BillingConfig
if len(billingConfig) == 0 {
billingConfig = mergeObjects(base.BaseBillingConfig, input.BillingConfigOverride)
@@ -262,6 +267,76 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
return model, nil
}
func validateEnabledVolcesTextModelCapabilities(ctx context.Context, q platformModelQuerier, input CreatePlatformModelInput, capabilities map[string]any) error {
// createPlatformModel enables/upserts models unconditionally, so every text
// model that reaches this path must already satisfy the Volcengine invariant.
if !containsTextOutputModelType(input.ModelType) {
return nil
}
var provider string
var baseURL string
if err := q.QueryRow(ctx, `SELECT provider, COALESCE(base_url, '') FROM integration_platforms WHERE id = $1::uuid`, input.PlatformID).Scan(&provider, &baseURL); err != nil {
return err
}
provider = strings.ToLower(strings.TrimSpace(provider))
baseURL = strings.ToLower(strings.TrimSpace(baseURL))
if provider != "volces-openai" && !strings.Contains(baseURL, "volces.com") && !strings.Contains(baseURL, "byteplus.com") {
return nil
}
seen := map[string]struct{}{}
for _, modelType := range append(append(StringList{}, input.ModelType...), "text_generate") {
modelType = strings.TrimSpace(modelType)
if modelType == "" {
continue
}
if _, ok := seen[modelType]; ok {
continue
}
seen[modelType] = struct{}{}
capability, _ := capabilities[modelType].(map[string]any)
if value, ok := positiveWholeNumber(capability["max_output_tokens"]); ok && value > 0 {
return nil
}
}
return fmt.Errorf("%w: enabled Volcengine text model %q requires a positive integer max_output_tokens capability for its text model type or text_generate fallback", ErrInvalidPlatformModelConfiguration, input.ProviderModelName)
}
func containsTextOutputModelType(values StringList) bool {
for _, value := range values {
switch strings.ToLower(strings.TrimSpace(value)) {
case "text_generate", "chat", "responses", "text":
return true
}
}
return false
}
func positiveWholeNumber(value any) (int64, bool) {
var number float64
switch typed := value.(type) {
case int:
number = float64(typed)
case int32:
number = float64(typed)
case int64:
number = float64(typed)
case float64:
number = typed
case json.Number:
parsed, err := typed.Float64()
if err != nil {
return 0, false
}
number = parsed
default:
return 0, false
}
if number <= 0 || math.Trunc(number) != number || number > math.MaxInt64 {
return 0, false
}
return int64(number), true
}
func (s *Store) DeletePlatformModel(ctx context.Context, id string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
+3 -2
View File
@@ -7,8 +7,9 @@ import (
)
var (
ErrNoModelCandidate = errors.New("no enabled platform model matches request")
ErrRateLimited = errors.New("rate limit exceeded")
ErrNoModelCandidate = errors.New("no enabled platform model matches request")
ErrRateLimited = errors.New("rate limit exceeded")
ErrInvalidPlatformModelConfiguration = errors.New("invalid platform model configuration")
)
type ModelCandidateUnavailableError struct {