chore(merge): 整合最新 main 与统一认证变更
ci / verify (pull_request) Successful in 12m18s
ci / verify (pull_request) Successful in 12m18s
合并远端生产 CI、依赖与 API 文档改动,保留统一认证和 SSF 能力。由于远端生产基线已占用 0062,将尚未发布的身份迁移整理为 0063 至 0068 的最终增量 Schema,移除仅用于开发期草稿升级的破坏性迁移步骤。 验证:go test ./...。其余生产门禁在合并提交后继续执行。
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
gatewaydocs "github.com/easyai/easyai-ai-gateway/apps/api/docs"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/skillbundle"
|
||||
)
|
||||
|
||||
const (
|
||||
opsManagementSkillDownloadPath = "/api/v1/public/skills/ai-gateway-ops-management/download"
|
||||
apiDocsJSONPath = "/api-docs-json"
|
||||
apiDocsYAMLPath = "/api-docs-yaml"
|
||||
)
|
||||
|
||||
// getOpsManagementSkillMetadata godoc
|
||||
// @Summary 获取 AI Gateway 运维管理 SKILL 元数据
|
||||
// @Description 返回公开运维管理 SKILL 的名称、版本、模块、下载文件名和机器可读接口文档路径。
|
||||
// @Tags agent-resources
|
||||
// @Produce json
|
||||
// @Success 200 {object} SkillBundleMetadataResponse
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/public/skills/ai-gateway-ops-management/metadata [get]
|
||||
func (s *Server) getOpsManagementSkillMetadata(w http.ResponseWriter, _ *http.Request) {
|
||||
metadata, err := skillbundle.LoadMetadata()
|
||||
if err != nil {
|
||||
s.logger.Error("load operations skill metadata failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "operations skill metadata unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, opsManagementSkillMetadataResponse(metadata))
|
||||
}
|
||||
|
||||
// downloadOpsManagementSkill godoc
|
||||
// @Summary 下载 AI Gateway 运维管理 SKILL
|
||||
// @Description 下载可交给 Agent 使用的 ai-gateway-ops-management ZIP 包。
|
||||
// @Tags agent-resources
|
||||
// @Produce application/zip
|
||||
// @Success 200 {file} binary
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/public/skills/ai-gateway-ops-management/download [get]
|
||||
func (s *Server) downloadOpsManagementSkill(w http.ResponseWriter, _ *http.Request) {
|
||||
metadata, err := skillbundle.LoadMetadata()
|
||||
if err != nil {
|
||||
s.logger.Error("load operations skill metadata failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "operations skill metadata unavailable")
|
||||
return
|
||||
}
|
||||
archive, err := skillbundle.BuildArchive()
|
||||
if err != nil {
|
||||
s.logger.Error("build operations skill archive failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "operations skill download unavailable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+skillbundle.FileName(metadata)+`"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(archive)
|
||||
}
|
||||
|
||||
// apiDocsJSON godoc
|
||||
// @Summary 获取 AI Gateway Swagger JSON
|
||||
// @Description 返回当前构建内嵌的完整机器可读 Swagger JSON,供 Agent 在 SKILL references 未覆盖接口时查询。
|
||||
// @Tags agent-resources
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api-docs-json [get]
|
||||
func (s *Server) apiDocsJSON(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(gatewaydocs.SwaggerJSON)
|
||||
}
|
||||
|
||||
// apiDocsYAML godoc
|
||||
// @Summary 获取 AI Gateway Swagger YAML
|
||||
// @Description 返回当前构建内嵌的完整机器可读 Swagger YAML。
|
||||
// @Tags agent-resources
|
||||
// @Produce application/yaml
|
||||
// @Success 200 {string} string
|
||||
// @Router /api-docs-yaml [get]
|
||||
func (s *Server) apiDocsYAML(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(gatewaydocs.SwaggerYAML)
|
||||
}
|
||||
|
||||
func opsManagementSkillMetadataResponse(metadata skillbundle.Metadata) SkillBundleMetadataResponse {
|
||||
return SkillBundleMetadataResponse{
|
||||
Name: metadata.Name,
|
||||
Version: metadata.Version,
|
||||
DisplayName: skillbundle.DisplayName,
|
||||
Modules: metadata.Modules,
|
||||
FileName: skillbundle.FileName(metadata),
|
||||
DownloadPath: opsManagementSkillDownloadPath,
|
||||
APIDocsJSONPath: apiDocsJSONPath,
|
||||
APIDocsYAMLPath: apiDocsYAMLPath,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetOpsManagementSkillMetadata(t *testing.T) {
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/public/skills/ai-gateway-ops-management/metadata", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.getOpsManagementSkillMetadata(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected metadata status 200, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var metadata SkillBundleMetadataResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &metadata); err != nil {
|
||||
t.Fatalf("decode metadata: %v", err)
|
||||
}
|
||||
if metadata.Name != "ai-gateway-ops-management" || metadata.Version != "1.0.2" {
|
||||
t.Fatalf("unexpected metadata: %+v", metadata)
|
||||
}
|
||||
if len(metadata.Modules) != 1 || metadata.Modules[0] != "model-runtime" {
|
||||
t.Fatalf("unexpected metadata modules: %+v", metadata.Modules)
|
||||
}
|
||||
if metadata.APIDocsJSONPath != "/api-docs-json" || metadata.APIDocsYAMLPath != "/api-docs-yaml" {
|
||||
t.Fatalf("unexpected API docs paths: %+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadOpsManagementSkill(t *testing.T) {
|
||||
server := &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/public/skills/ai-gateway-ops-management/download", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.downloadOpsManagementSkill(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected download status 200, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if response.Header().Get("Content-Type") != "application/zip" {
|
||||
t.Fatalf("unexpected content type: %q", response.Header().Get("Content-Type"))
|
||||
}
|
||||
if disposition := response.Header().Get("Content-Disposition"); !strings.Contains(disposition, "ai-gateway-ops-management-v1.0.2.zip") {
|
||||
t.Fatalf("unexpected content disposition: %q", disposition)
|
||||
}
|
||||
raw := response.Body.Bytes()
|
||||
archive, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("open downloaded archive: %v", err)
|
||||
}
|
||||
foundSkill := false
|
||||
for _, file := range archive.File {
|
||||
if file.Name == "SKILL.md" {
|
||||
foundSkill = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSkill {
|
||||
t.Fatalf("downloaded archive does not contain SKILL.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedAPIDocs(t *testing.T) {
|
||||
server := &Server{}
|
||||
|
||||
jsonResponse := httptest.NewRecorder()
|
||||
server.apiDocsJSON(jsonResponse, httptest.NewRequest(http.MethodGet, "/api-docs-json", nil))
|
||||
if jsonResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected JSON docs status 200, got %d", jsonResponse.Code)
|
||||
}
|
||||
if jsonResponse.Header().Get("Content-Type") != "application/json; charset=utf-8" {
|
||||
t.Fatalf("unexpected JSON docs content type: %q", jsonResponse.Header().Get("Content-Type"))
|
||||
}
|
||||
var document struct {
|
||||
Paths map[string]json.RawMessage `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonResponse.Body.Bytes(), &document); err != nil {
|
||||
t.Fatalf("decode embedded Swagger JSON: %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/api-docs-json",
|
||||
"/api/v1/public/skills/ai-gateway-ops-management/download",
|
||||
"/api/admin/catalog/providers",
|
||||
"/api/admin/catalog/base-models",
|
||||
"/api/admin/platforms",
|
||||
"/api/admin/runtime/policy-sets",
|
||||
"/api/admin/pricing/rule-sets",
|
||||
} {
|
||||
if document.Paths[path] == nil {
|
||||
t.Fatalf("embedded Swagger JSON missing %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
yamlResponse := httptest.NewRecorder()
|
||||
server.apiDocsYAML(yamlResponse, httptest.NewRequest(http.MethodGet, "/api-docs-yaml", nil))
|
||||
if yamlResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected YAML docs status 200, got %d", yamlResponse.Code)
|
||||
}
|
||||
if yamlResponse.Header().Get("Content-Type") != "application/yaml; charset=utf-8" {
|
||||
t.Fatalf("unexpected YAML docs content type: %q", yamlResponse.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(yamlResponse.Body.String(), "/api/v1/public/skills/ai-gateway-ops-management/metadata") {
|
||||
t.Fatalf("embedded Swagger YAML missing operations skill metadata path")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -429,6 +429,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
|
||||
@@ -473,6 +477,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
|
||||
@@ -979,7 +987,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]
|
||||
@@ -989,8 +996,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]
|
||||
@@ -1024,6 +1029,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")
|
||||
@@ -1095,7 +1106,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
|
||||
@@ -1109,6 +1120,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 为准且不追加本地历史。
|
||||
@@ -1127,6 +1158,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) {
|
||||
@@ -1357,6 +1389,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")
|
||||
}
|
||||
@@ -16,6 +16,17 @@ type ReadyResponse struct {
|
||||
OK bool `json:"ok" example:"true"`
|
||||
}
|
||||
|
||||
type SkillBundleMetadataResponse struct {
|
||||
Name string `json:"name" example:"ai-gateway-ops-management"`
|
||||
Version string `json:"version" example:"1.0.2"`
|
||||
DisplayName string `json:"displayName" example:"AI Gateway 运维管理"`
|
||||
Modules []string `json:"modules" example:"model-runtime"`
|
||||
FileName string `json:"fileName" example:"ai-gateway-ops-management-v1.0.2.zip"`
|
||||
DownloadPath string `json:"downloadPath" example:"/api/v1/public/skills/ai-gateway-ops-management/download"`
|
||||
APIDocsJSONPath string `json:"apiDocsJsonPath" example:"/api-docs-json"`
|
||||
APIDocsYAMLPath string `json:"apiDocsYamlPath" example:"/api-docs-yaml"`
|
||||
}
|
||||
|
||||
type ErrorEnvelope struct {
|
||||
Error ErrorPayload `json:"error"`
|
||||
}
|
||||
@@ -24,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 {
|
||||
@@ -182,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"`
|
||||
@@ -212,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 {
|
||||
|
||||
@@ -128,6 +128,10 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset)
|
||||
mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset)
|
||||
mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset)
|
||||
mux.HandleFunc("GET /api-docs-json", server.apiDocsJSON)
|
||||
mux.HandleFunc("GET /api-docs-yaml", server.apiDocsYAML)
|
||||
mux.HandleFunc("GET /api/v1/public/skills/ai-gateway-ops-management/metadata", server.getOpsManagementSkillMetadata)
|
||||
mux.HandleFunc("GET /api/v1/public/skills/ai-gateway-ops-management/download", server.downloadOpsManagementSkill)
|
||||
|
||||
mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register)))
|
||||
mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login)))
|
||||
|
||||
Reference in New Issue
Block a user