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

This commit is contained in:
2026-07-17 13:52:00 +08:00
parent a24eb1aeb0
commit 5ee267ecbd
31 changed files with 3287 additions and 232 deletions
@@ -142,7 +142,7 @@ func TestWriteCompatibleTaskResponseMapsInvalidParameterToBadRequest(t *testing.
executor := &fakeTaskExecutor{
runErr: &clients.ClientError{
Code: "invalid_parameter",
Message: "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh",
Message: "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh, max",
Retryable: false,
},
}
@@ -157,6 +157,9 @@ func TestWriteCompatibleTaskResponseMapsInvalidParameterToBadRequest(t *testing.
if !strings.Contains(recorder.Body.String(), "invalid_parameter") {
t.Fatalf("response should include invalid_parameter code: %s", recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"type":"invalid_request_error"`) || !strings.Contains(recorder.Body.String(), `"param":null`) {
t.Fatalf("response should use OpenAI error fields: %s", recorder.Body.String())
}
}
func TestWriteCompatibleTaskResponseReturnsSSEWhenStreamIsTrue(t *testing.T) {
+40 -4
View File
@@ -416,6 +416,10 @@ func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) {
}
model, err := s.store.CreatePlatformModel(r.Context(), input)
if err != nil {
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "base model not found")
return
@@ -460,6 +464,10 @@ func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) {
models, err := s.store.ReplacePlatformModels(r.Context(), platformID, input.Models)
if err != nil {
if errors.Is(err, store.ErrInvalidPlatformModelConfiguration) {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "base model not found")
return
@@ -966,7 +974,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Failure 404 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/responses [post]
// @Router /api/v1/embeddings [post]
// @Router /api/v1/reranks [post]
// @Router /api/v1/images/generations [post]
@@ -976,8 +983,6 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Router /api/v1/music/generations [post]
// @Router /api/v1/speech/generations [post]
// @Router /api/v1/voice_clone [post]
// @Router /chat/completions [post]
// @Router /v1/chat/completions [post]
// @Router /embeddings [post]
// @Router /v1/embeddings [post]
// @Router /reranks [post]
@@ -1011,6 +1016,12 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
writeError(w, status, err.Error(), clients.ErrorCode(err))
return
}
if kind == "chat.completions" || kind == "responses" {
if err := clients.ValidateOpenAIRequestParameters(kind, body); err != nil {
writeErrorWithDetails(w, http.StatusBadRequest, err.Error(), map[string]any{"param": clients.ErrorParam(err)}, clients.ErrorCode(err))
return
}
}
model := requestModelName(body)
if model == "" {
writeError(w, http.StatusBadRequest, "model is required")
@@ -1082,7 +1093,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
// @Produce text/event-stream
// @Security BearerAuth
// @Param X-Async header bool false "该接口忽略此参数"
// @Param input body TaskRequest true "Chat Completions 请求"
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
// @Success 200 {object} ChatCompletionCompatibleResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
@@ -1096,6 +1107,26 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
return s.createTask("chat.completions", false)
}
// openAIChatCompletionsDoc godoc
// @Summary 创建 OpenAI Chat Completions
// @Description OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter。
// @Tags chat
// @Accept json
// @Produce json
// @Produce text/event-stream
// @Security BearerAuth
// @Param input body ChatCompletionRequest true "Chat Completions 请求"
// @Success 200 {object} ChatCompletionCompatibleResponse
// @Failure 400 {object} ErrorEnvelope "invalid_parameter"
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /chat/completions [post]
// @Router /v1/chat/completions [post]
func openAIChatCompletionsDoc() {}
// openAIResponsesDoc godoc
// @Summary 创建 OpenAI Responses
// @Description 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 previous_response_id 时由调用方管理完整状态,Gateway 以本轮 input/messages 为准且不追加本地历史。
@@ -1114,6 +1145,7 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
// @Router /responses [post]
// @Router /v1/responses [post]
// @Router /api/v1/responses [post]
func openAIResponsesDoc() {}
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
@@ -1344,6 +1376,10 @@ func statusFromRunError(err error) int {
return http.StatusServiceUnavailable
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "invalid_parameter" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy":
return http.StatusBadRequest
case store.ModelCandidateErrorCode(err) == "invalid_parameter":
return http.StatusBadRequest
case store.ModelCandidateErrorCode(err) == "model_capability_configuration_error":
return http.StatusInternalServerError
case clients.ErrorCode(err) == "cloned_voice_not_found":
return http.StatusNotFound
case store.ModelCandidateErrorCode(err) == "platform_cooling_down" || store.ModelCandidateErrorCode(err) == "model_cooling_down":
@@ -0,0 +1,194 @@
package httpapi
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestKeling30TurboSimulationFlow(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the Kling simulation integration flow")
}
ctx := context.Background()
applyMigration(t, ctx, databaseURL)
db, err := store.Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect store: %v", err)
}
defer db.Close()
serverCtx, cancelServer := context.WithCancel(ctx)
defer cancelServer()
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
AppEnv: "test",
HTTPAddr: ":0",
DatabaseURL: databaseURL,
IdentityMode: "hybrid",
JWTSecret: "test-secret",
CORSAllowedOrigin: "*",
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
username := "kling_sim_" + suffix
password := "password123"
var registerResponse struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
"username": username,
"email": username + "@example.com",
"password": password,
}, http.StatusCreated, &registerResponse)
var apiKeyResponse struct {
Secret string `json:"secret"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", registerResponse.AccessToken, map[string]any{
"name": "kling simulation key",
}, http.StatusCreated, &apiKeyResponse)
if _, err := db.Pool().Exec(ctx, `
UPDATE gateway_users
SET roles = '["admin"]'::jsonb
WHERE username = $1`, username); err != nil {
t.Fatalf("promote Kling simulation user: %v", err)
}
var loginResponse struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": username,
"password": password,
}, http.StatusOK, &loginResponse)
var gatewayUserID string
if err := db.Pool().QueryRow(
ctx,
`SELECT id::text FROM gateway_users WHERE username = $1`,
username,
).Scan(&gatewayUserID); err != nil {
t.Fatalf("read Kling simulation user id: %v", err)
}
doJSON(t, server.URL, http.MethodPatch, "/api/admin/users/"+gatewayUserID+"/wallet", loginResponse.AccessToken, map[string]any{
"currency": "resource",
"balance": 1000,
"reason": "seed Kling simulation wallet",
}, http.StatusOK, nil)
var platform struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
"provider": "keling",
"platformKey": "keling-simulation-" + suffix,
"name": "Kling Simulation",
"baseUrl": "https://api-beijing.klingai.com/v1",
"authType": "AccessKey-SecretKey",
"credentials": map[string]any{
"accessKey": "legacy-ak",
"secretKey": "legacy-sk",
},
}, http.StatusCreated, &platform)
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "keling:kling-3.0-turbo",
"modelName": "kling-3.0-turbo",
"providerModelName": "kling-3.0-turbo",
"modelAlias": "可灵3.0 Turbo",
"modelType": []string{"video_generate", "image_to_video"},
"displayName": "可灵3.0 Turbo",
}, http.StatusCreated, nil)
assertKeling30TurboSimulationTask := func(
name string,
request map[string]any,
expectedModelType string,
) {
t.Helper()
t.Run(name, func(t *testing.T) {
var response struct {
Task struct {
ID string `json:"id"`
Status string `json:"status"`
RunMode string `json:"runMode"`
ModelType string `json:"modelType"`
ResolvedModel string `json:"resolvedModel"`
Result map[string]any `json:"result"`
Metrics map[string]any `json:"metrics"`
BillingSummary map[string]any `json:"billingSummary"`
FinalChargeAmount float64 `json:"finalChargeAmount"`
ResponseDurationMS int64 `json:"responseDurationMs"`
} `json:"task"`
}
doJSON(
t,
server.URL,
http.MethodPost,
"/api/v1/videos/generations",
apiKeyResponse.Secret,
request,
http.StatusAccepted,
&response,
)
task := response.Task
if task.ID == "" ||
task.Status != "succeeded" ||
task.RunMode != "simulation" ||
task.ModelType != expectedModelType ||
task.ResolvedModel != "kling-3.0-turbo" {
t.Fatalf("unexpected Kling simulation task: %+v", task)
}
data, _ := task.Result["data"].([]any)
item, _ := data[0].(map[string]any)
if item["video_url"] != "/static/simulation/video.mp4" ||
item["assetSource"] != "simulation" {
t.Fatalf("unexpected Kling simulation result: %+v", task.Result)
}
if task.FinalChargeAmount <= 0 ||
task.BillingSummary["finalCharge"] == nil ||
task.Metrics["parameterPreprocessingSummary"] == nil ||
task.ResponseDurationMS <= 0 {
t.Fatalf("Kling simulation should preserve billing, preprocessing and timing: %+v", task)
}
})
}
assertKeling30TurboSimulationTask("text-to-video", map[string]any{
"model": "可灵3.0 Turbo",
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"prompt": "A cinematic city reveal",
"duration": 8,
"resolution": "1080p",
"aspect_ratio": "9:16",
"audio": true,
}, "video_generate")
assertKeling30TurboSimulationTask("image-to-video", map[string]any{
"model": "可灵3.0 Turbo",
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"prompt": "The subject looks toward the camera",
"image": "https://example.com/first.png",
"duration": 5,
"resolution": "720p",
"audio": true,
}, "image_to_video")
}
+86 -29
View File
@@ -35,6 +35,8 @@ type ErrorPayload struct {
Message string `json:"message" example:"invalid json body"`
Status int `json:"status" example:"400"`
Code string `json:"code,omitempty" example:"rate_limit"`
Type string `json:"type,omitempty" example:"invalid_request_error"`
Param any `json:"param,omitempty"`
}
type AuthResponse struct {
@@ -193,16 +195,19 @@ type PricingEstimateResponse struct {
type TaskRequest struct {
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages,omitempty"`
Input string `json:"input,omitempty" example:"Tell me a short story"`
Input interface{} `json:"input,omitempty"`
Prompt string `json:"prompt,omitempty" example:"A watercolor robot reading a book"`
Text string `json:"text,omitempty" example:"Hello from EasyAI audio synthesis."`
TextFileID string `json:"text_file_id,omitempty" example:""`
VoiceID string `json:"voice_id,omitempty" example:"female-shaonv"`
Stream bool `json:"stream,omitempty" example:"false"`
Stream *bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
// MaxCompletionTokens includes visible output and reasoning tokens.
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"`
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium" enums:"none,minimal,low,medium,high,xhigh,max"`
Size string `json:"size,omitempty" example:"1024x1024"`
Duration int `json:"duration,omitempty" example:"5"`
Resolution string `json:"resolution,omitempty" example:"720p"`
@@ -223,36 +228,88 @@ type TaskRequest struct {
}
type ChatCompletionRequest struct {
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty" example:"0.7"`
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
Stream bool `json:"stream,omitempty" example:"false"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
Model string `json:"model" example:"gpt-4o-mini"`
Messages []ChatMessage `json:"messages"`
Audio map[string]interface{} `json:"audio,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" example:"0"`
FunctionCall interface{} `json:"function_call,omitempty"`
Functions []map[string]interface{} `json:"functions,omitempty"`
LogitBias map[string]interface{} `json:"logit_bias,omitempty"`
Logprobs *bool `json:"logprobs,omitempty"`
MaxCompletionTokens *int `json:"max_completion_tokens,omitempty" example:"512"`
MaxTokens *int `json:"max_tokens,omitempty" example:"512"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Modalities []string `json:"modalities,omitempty"`
Moderation interface{} `json:"moderation,omitempty"`
N *int `json:"n,omitempty" example:"1"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
Prediction interface{} `json:"prediction,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty" example:"0"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
PromptCacheOptions map[string]interface{} `json:"prompt_cache_options,omitempty"`
PromptCacheRetention string `json:"prompt_cache_retention,omitempty" enums:"in_memory,24h"`
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium" enums:"none,minimal,low,medium,high,xhigh,max"`
ResponseFormat interface{} `json:"response_format,omitempty"`
SafetyIdentifier string `json:"safety_identifier,omitempty"`
Seed *int `json:"seed,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Stop interface{} `json:"stop,omitempty"`
Store *bool `json:"store,omitempty"`
Stream *bool `json:"stream,omitempty" example:"false"`
StreamOptions map[string]interface{} `json:"stream_options,omitempty"`
Temperature *float64 `json:"temperature,omitempty" example:"0.7"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Tools []map[string]interface{} `json:"tools,omitempty"`
TopLogprobs *int `json:"top_logprobs,omitempty"`
TopP *float64 `json:"top_p,omitempty" example:"1"`
User string `json:"user,omitempty"`
Verbosity string `json:"verbosity,omitempty"`
WebSearchOptions map[string]interface{} `json:"web_search_options,omitempty"`
RunMode string `json:"runMode,omitempty" example:"simulation"`
}
type ChatMessage struct {
Role string `json:"role" example:"user"`
Content string `json:"content" example:"Hello"`
Role string `json:"role" example:"user"`
Content interface{} `json:"content,omitempty"`
Name string `json:"name,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls interface{} `json:"tool_calls,omitempty"`
FunctionCall interface{} `json:"function_call,omitempty"`
}
type ResponsesRequest struct {
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Input interface{} `json:"input"`
Instructions string `json:"instructions,omitempty" example:"Answer concisely"`
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_0123456789abcdef0123456789abcdef"`
Tools []map[string]interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty" example:"true"`
MaxOutputTokens int `json:"max_output_tokens,omitempty" example:"512"`
Reasoning map[string]interface{} `json:"reasoning,omitempty"`
Text map[string]interface{} `json:"text,omitempty"`
Temperature float64 `json:"temperature,omitempty" example:"0.7"`
TopP float64 `json:"top_p,omitempty" example:"1"`
Store *bool `json:"store,omitempty"`
Stream bool `json:"stream,omitempty" example:"false"`
Model string `json:"model" example:"Doubao Seed 2.0 Pro"`
Background *bool `json:"background,omitempty"`
ContextManagement []map[string]interface{} `json:"context_management,omitempty"`
Conversation interface{} `json:"conversation,omitempty"`
Include []string `json:"include,omitempty"`
Input interface{} `json:"input"`
Instructions string `json:"instructions,omitempty" example:"Answer concisely"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty" example:"512"`
MaxToolCalls *int `json:"max_tool_calls,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Moderation interface{} `json:"moderation,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty" example:"true"`
PreviousResponseID string `json:"previous_response_id,omitempty" example:"resp_0123456789abcdef0123456789abcdef"`
Prompt interface{} `json:"prompt,omitempty"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
PromptCacheOptions map[string]interface{} `json:"prompt_cache_options,omitempty"`
PromptCacheRetention string `json:"prompt_cache_retention,omitempty" enums:"in_memory,24h"`
Reasoning map[string]interface{} `json:"reasoning,omitempty"`
SafetyIdentifier string `json:"safety_identifier,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store *bool `json:"store,omitempty"`
Stream *bool `json:"stream,omitempty" example:"false"`
StreamOptions map[string]interface{} `json:"stream_options,omitempty"`
Temperature *float64 `json:"temperature,omitempty" example:"0.7"`
Text map[string]interface{} `json:"text,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Tools []map[string]interface{} `json:"tools,omitempty"`
TopLogprobs *int `json:"top_logprobs,omitempty"`
TopP *float64 `json:"top_p,omitempty" example:"1"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
}
type ResponsesCompatibleResponse struct {
+6
View File
@@ -25,6 +25,12 @@ func writeErrorWithDetails(w http.ResponseWriter, status int, message string, de
if len(codes) > 0 {
if code := strings.TrimSpace(codes[0]); code != "" {
errorPayload["code"] = code
if code == "invalid_parameter" || code == "unsupported_response_parameter" {
errorPayload["type"] = "invalid_request_error"
if _, ok := details["param"]; !ok {
errorPayload["param"] = nil
}
}
}
}
for key, value := range details {