feat: 完善模型请求适配与输出限制
This commit is contained in:
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, ®isterResponse)
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user