feat(api): 统一公开接口为 /api/v1 前缀
将通用生成、Gemini、可灵、火山、健康检查与 OpenAPI 的推荐入口统一到 /api/v1,并保留历史路径作为兼容别名。同步更新代理配置、接入文档、接口清单和前缀回归测试。\n\n验证:go vet ./...;go test ./...;pnpm openapi;pnpm lint;pnpm test;pnpm build;公开 OpenAPI 71 个方法与接口清单机器比对一致。
This commit is contained in:
+1110
-3032
File diff suppressed because it is too large
Load Diff
+716
-1993
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
const (
|
||||
opsManagementSkillDownloadPath = "/api/v1/public/skills/ai-gateway-ops-management/download"
|
||||
apiDocsJSONPath = "/api-docs-json"
|
||||
apiDocsYAMLPath = "/api-docs-yaml"
|
||||
apiDocsJSONPath = "/api/v1/openapi.json"
|
||||
apiDocsYAMLPath = "/api/v1/openapi.yaml"
|
||||
)
|
||||
|
||||
// getOpsManagementSkillMetadata godoc
|
||||
@@ -64,7 +64,7 @@ func (s *Server) downloadOpsManagementSkill(w http.ResponseWriter, _ *http.Reque
|
||||
// @Tags agent-resources
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api-docs-json [get]
|
||||
// @Router /api/v1/openapi.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)
|
||||
@@ -77,7 +77,7 @@ func (s *Server) apiDocsJSON(w http.ResponseWriter, _ *http.Request) {
|
||||
// @Tags agent-resources
|
||||
// @Produce application/yaml
|
||||
// @Success 200 {string} string
|
||||
// @Router /api-docs-yaml [get]
|
||||
// @Router /api/v1/openapi.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)
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestGetOpsManagementSkillMetadata(t *testing.T) {
|
||||
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" {
|
||||
if metadata.APIDocsJSONPath != "/api/v1/openapi.json" || metadata.APIDocsYAMLPath != "/api/v1/openapi.yaml" {
|
||||
t.Fatalf("unexpected API docs paths: %+v", metadata)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
|
||||
server := &Server{}
|
||||
|
||||
jsonResponse := httptest.NewRecorder()
|
||||
server.apiDocsJSON(jsonResponse, httptest.NewRequest(http.MethodGet, "/api-docs-json", nil))
|
||||
server.apiDocsJSON(jsonResponse, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil))
|
||||
if jsonResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected JSON docs status 200, got %d", jsonResponse.Code)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
|
||||
t.Fatalf("decode embedded Swagger JSON: %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/api-docs-json",
|
||||
"/api/v1/openapi.json",
|
||||
"/api/v1/public/skills/ai-gateway-ops-management/download",
|
||||
"/api/admin/catalog/providers",
|
||||
"/api/admin/catalog/base-models",
|
||||
@@ -102,7 +102,7 @@ func TestEmbeddedAPIDocs(t *testing.T) {
|
||||
}
|
||||
|
||||
yamlResponse := httptest.NewRecorder()
|
||||
server.apiDocsYAML(yamlResponse, httptest.NewRequest(http.MethodGet, "/api-docs-yaml", nil))
|
||||
server.apiDocsYAML(yamlResponse, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil))
|
||||
if yamlResponse.Code != http.StatusOK {
|
||||
t.Fatalf("expected YAML docs status 200, got %d", yamlResponse.Code)
|
||||
}
|
||||
|
||||
@@ -58,17 +58,18 @@ func TestPlanTaskResponseTreatsAPIV1EmbeddingAndRerankAsSynchronousCompatibleRes
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTaskResponseKeepsAsyncTaskModeForOtherAPIV1Tasks(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
req.Header.Set("X-Async", "true")
|
||||
|
||||
plan := planTaskResponse("images.generations", false, map[string]any{"stream": true}, req)
|
||||
|
||||
if !plan.asyncMode {
|
||||
t.Fatal("non-chat /api/v1 task endpoints should keep X-Async task mode")
|
||||
func TestPlanTaskResponseUsesCompatibleAPIV1MediaResponsesAndKeepsAsyncOptIn(t *testing.T) {
|
||||
defaultRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
defaultPlan := planTaskResponse("images.generations", true, map[string]any{}, defaultRequest)
|
||||
if defaultPlan.asyncMode || !defaultPlan.compatibleMode {
|
||||
t.Fatalf("canonical /api/v1 media endpoints should default to synchronous compatible responses, got %+v", defaultPlan)
|
||||
}
|
||||
if plan.compatibleMode {
|
||||
t.Fatal("non-compatible /api/v1 task endpoints should not return OpenAI-compatible payloads")
|
||||
|
||||
asyncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
asyncRequest.Header.Set("X-Async", "true")
|
||||
asyncPlan := planTaskResponse("images.generations", true, map[string]any{}, asyncRequest)
|
||||
if !asyncPlan.asyncMode || !asyncPlan.compatibleMode {
|
||||
t.Fatalf("canonical /api/v1 media endpoints should keep compatible mode when X-Async is enabled, got %+v", asyncPlan)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -504,7 +504,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
Result map[string]any `json:"result"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
|
||||
"model": defaultImageModel,
|
||||
"runMode": "simulation",
|
||||
"prompt": "a tiny gateway console",
|
||||
@@ -512,7 +512,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
"quality": "medium",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusAccepted, &imageResponse)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageResponse)
|
||||
if imageResponse.Task.Status != "succeeded" || imageResponse.Task.Result["id"] == "" {
|
||||
t.Fatalf("unexpected image generation task: %+v", imageResponse.Task)
|
||||
}
|
||||
@@ -524,7 +524,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
Result map[string]any `json:"result"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{
|
||||
"model": defaultImageModel,
|
||||
"runMode": "simulation",
|
||||
"prompt": "replace background with clean studio light",
|
||||
@@ -532,7 +532,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
"mask": "https://example.com/mask.png",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, http.StatusAccepted, &imageEditResponse)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageEditResponse)
|
||||
if imageEditResponse.Task.Status != "succeeded" || imageEditResponse.Task.Result["id"] == "" {
|
||||
t.Fatalf("unexpected image edit task: %+v", imageEditResponse.Task)
|
||||
}
|
||||
@@ -1200,13 +1200,13 @@ WHERE reference_type = 'gateway_task'
|
||||
ModelType string `json:"modelType"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
"model": videoRouteModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"prompt": "text to video route",
|
||||
}, http.StatusAccepted, &textToVideoTask)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &textToVideoTask)
|
||||
if textToVideoTask.Task.Status != "succeeded" || textToVideoTask.Task.ModelType != "video_generate" {
|
||||
t.Fatalf("text-to-video request should use video_generate model_type: %+v", textToVideoTask.Task)
|
||||
}
|
||||
@@ -1218,14 +1218,14 @@ WHERE reference_type = 'gateway_task'
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, map[string]any{
|
||||
"model": videoRouteModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"prompt": "image to video route",
|
||||
"image": "https://example.com/source.png",
|
||||
}, http.StatusAccepted, &imageToVideoTask)
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageToVideoTask)
|
||||
if imageToVideoTask.Task.Status != "succeeded" || imageToVideoTask.Task.ModelType != "image_to_video" {
|
||||
t.Fatalf("image-to-video request should use image_to_video model_type: %+v", imageToVideoTask.Task)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ const maxGatewayUploadBytes = 256 << 20
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /api/v1/files/upload [post]
|
||||
// @Router /v1/files/upload [post]
|
||||
func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxGatewayUploadBytes)
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
|
||||
@@ -75,6 +75,21 @@ func geminiGenerateContentModelFromPath(prefix string, requestPath string) (stri
|
||||
return model, true
|
||||
}
|
||||
|
||||
// geminiGenerateContent godoc
|
||||
// @Summary Gemini generateContent 兼容接口
|
||||
// @Description 使用统一 /api/v1 前缀接收 Gemini generateContent 请求;旧 /v1 和 /v1beta 路径保留兼容。
|
||||
// @Tags gemini-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param model path string true "模型名称"
|
||||
// @Param input body map[string]interface{} true "Gemini generateContent 请求"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /api/v1/models/{model}:generateContent [post]
|
||||
func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -395,6 +410,18 @@ func geminiUsageMetadataFromOutput(output map[string]any) map[string]any {
|
||||
return meta
|
||||
}
|
||||
|
||||
// geminiFilesUpload godoc
|
||||
// @Summary Gemini Files 上传接口
|
||||
// @Description 使用统一 /api/v1 前缀启动或直接完成 Gemini Files 上传。
|
||||
// @Tags gemini-compatible
|
||||
// @Accept octet-stream
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param version path string true "Gemini 版本(v1 或 v1beta)"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /api/v1/gemini/upload/{version}/files [post]
|
||||
func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
@@ -412,6 +439,18 @@ func (s *Server) geminiFilesUpload(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// geminiFilesUploadFinalize godoc
|
||||
// @Summary 完成 Gemini Files 分段上传
|
||||
// @Tags gemini-compatible
|
||||
// @Accept octet-stream
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param version path string true "Gemini 版本(v1 或 v1beta)"
|
||||
// @Param uploadID path string true "上传会话 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /api/v1/gemini/upload/{version}/files/{uploadID} [post]
|
||||
func (s *Server) geminiFilesUploadFinalize(w http.ResponseWriter, r *http.Request) {
|
||||
if err := validateGeminiFilesVersion(r.PathValue("version")); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
|
||||
@@ -440,11 +479,18 @@ func (s *Server) startGeminiFilesUpload(w http.ResponseWriter, r *http.Request)
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
s.geminiUploadSessions.Store(uploadID, session)
|
||||
w.Header().Set("X-Goog-Upload-URL", absoluteRequestURL(r, "/upload/"+session.Version+"/files/"+uploadID))
|
||||
w.Header().Set("X-Goog-Upload-URL", absoluteRequestURL(r, geminiUploadPath(r, session.Version, uploadID)))
|
||||
w.Header().Set("X-Goog-Upload-Status", "active")
|
||||
writeJSON(w, http.StatusOK, map[string]any{})
|
||||
}
|
||||
|
||||
func geminiUploadPath(r *http.Request, version string, uploadID string) string {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v1/gemini/upload/") {
|
||||
return "/api/v1/gemini/upload/" + version + "/files/" + uploadID
|
||||
}
|
||||
return "/upload/" + version + "/files/" + uploadID
|
||||
}
|
||||
|
||||
func (s *Server) finalizeGeminiFilesUpload(w http.ResponseWriter, r *http.Request, uploadID string, session geminiUploadSession) {
|
||||
if uploadID == "" {
|
||||
uploadID = newGeminiUploadID()
|
||||
|
||||
@@ -102,6 +102,18 @@ func TestRegisterGeminiGenerateContentRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiUploadPathKeepsCanonicalV1Prefix(t *testing.T) {
|
||||
canonical := httptest.NewRequest(http.MethodPost, "/api/v1/gemini/upload/v1beta/files", nil)
|
||||
if got := geminiUploadPath(canonical, "v1beta", "upload-1"); got != "/api/v1/gemini/upload/v1beta/files/upload-1" {
|
||||
t.Fatalf("canonical upload path = %q", got)
|
||||
}
|
||||
|
||||
legacy := httptest.NewRequest(http.MethodPost, "/upload/v1beta/files", nil)
|
||||
if got := geminiUploadPath(legacy, "v1beta", "upload-1"); got != "/upload/v1beta/files/upload-1" {
|
||||
t.Fatalf("legacy upload path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiImageTaskBodyMapsTextOnlyToImageGenerate(t *testing.T) {
|
||||
mapping, err := geminiImageTaskBody("gemini-image", map[string]any{
|
||||
"contents": []any{
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Success 200 {object} HealthResponse
|
||||
// @Router /healthz [get]
|
||||
// @Router /api/v1/healthz [get]
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
@@ -48,7 +48,7 @@ func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
// @Produce json
|
||||
// @Success 200 {object} ReadyResponse
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Router /readyz [get]
|
||||
// @Router /api/v1/readyz [get]
|
||||
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), postgresReadinessTimeout)
|
||||
defer cancel()
|
||||
@@ -1021,7 +1021,7 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// createTask godoc
|
||||
// @Summary 创建或执行 AI 任务
|
||||
// @Description 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。
|
||||
// @Description 统一公开入口按 model 选择平台模型并默认同步返回兼容响应;设置 X-Async=true 时异步创建任务并返回 202。
|
||||
// @Tags tasks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -1047,22 +1047,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 /embeddings [post]
|
||||
// @Router /v1/embeddings [post]
|
||||
// @Router /reranks [post]
|
||||
// @Router /v1/reranks [post]
|
||||
// @Router /images/generations [post]
|
||||
// @Router /v1/images/generations [post]
|
||||
// @Router /images/edits [post]
|
||||
// @Router /v1/images/edits [post]
|
||||
// @Router /song/generations [post]
|
||||
// @Router /v1/song/generations [post]
|
||||
// @Router /music/generations [post]
|
||||
// @Router /v1/music/generations [post]
|
||||
// @Router /speech/generations [post]
|
||||
// @Router /v1/speech/generations [post]
|
||||
// @Router /voice_clone [post]
|
||||
// @Router /v1/voice_clone [post]
|
||||
func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
@@ -1214,8 +1198,6 @@ func (s *Server) createAPIV1ChatCompletions() http.Handler {
|
||||
// @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
|
||||
@@ -1234,8 +1216,6 @@ func openAIChatCompletionsDoc() {}
|
||||
// @Failure 402 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope "response_chain_unavailable"
|
||||
// @Router /responses [post]
|
||||
// @Router /v1/responses [post]
|
||||
// @Router /api/v1/responses [post]
|
||||
func openAIResponsesDoc() {}
|
||||
|
||||
@@ -1660,7 +1640,6 @@ func matchedRateLimitRule(policy map[string]any, metric string) map[string]any {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks [get]
|
||||
// @Router /api/v1/tasks [get]
|
||||
// @Router /tasks [get]
|
||||
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -1769,7 +1748,6 @@ func boolValue(body map[string]any, key string) bool {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID} [get]
|
||||
// @Router /api/v1/tasks/{taskID} [get]
|
||||
// @Router /tasks/{taskID} [get]
|
||||
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
|
||||
if err == nil {
|
||||
@@ -1802,8 +1780,6 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID}/cancel [post]
|
||||
// @Router /api/v1/tasks/{taskID}/cancel [post]
|
||||
// @Router /v1/tasks/{taskID}/cancel [post]
|
||||
// @Router /tasks/{taskID}/cancel [post]
|
||||
func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -1840,7 +1816,6 @@ func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID}/param-preprocessing [get]
|
||||
// @Router /api/v1/tasks/{taskID}/param-preprocessing [get]
|
||||
// @Router /tasks/{taskID}/param-preprocessing [get]
|
||||
func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request) {
|
||||
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
|
||||
if err != nil {
|
||||
@@ -1874,7 +1849,6 @@ func (s *Server) taskParamPreprocessing(w http.ResponseWriter, r *http.Request)
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/workspace/tasks/{taskID}/events [get]
|
||||
// @Router /api/v1/tasks/{taskID}/events [get]
|
||||
// @Router /tasks/{taskID}/events [get]
|
||||
func (s *Server) taskEvents(w http.ResponseWriter, r *http.Request) {
|
||||
task, err := s.store.GetTask(r.Context(), r.PathValue("taskID"))
|
||||
if err != nil {
|
||||
|
||||
@@ -132,7 +132,7 @@ func (s *Server) requireKelingAPIKey(next http.Handler) http.Handler {
|
||||
// @Failure 429 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Failure 503 {object} KelingCompatibleEnvelope
|
||||
// @Router /v1/videos/omni-video [post]
|
||||
// @Router /api/v1/videos/omni-video [post]
|
||||
func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := kelingCompatRequestID(r)
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
@@ -206,7 +206,7 @@ func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 403 {object} KelingCompatibleEnvelope
|
||||
// @Failure 404 {object} KelingCompatibleEnvelope
|
||||
// @Failure 500 {object} KelingCompatibleEnvelope
|
||||
// @Router /v1/videos/omni-video/{taskID} [get]
|
||||
// @Router /api/v1/videos/omni-video/{taskID} [get]
|
||||
func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := kelingCompatRequestID(r)
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
|
||||
@@ -145,7 +145,7 @@ func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
|
||||
_, secondAPIKey := createKelingCompatIntegrationUser(t, ctx, db, gateway.URL, "second", suffix, false)
|
||||
|
||||
var created KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodPost, "/v1/videos/omni-video", firstAPIKey, map[string]any{
|
||||
doJSON(t, gateway.URL, http.MethodPost, "/api/v1/videos/omni-video", firstAPIKey, map[string]any{
|
||||
"model_name": "kling-video-o1",
|
||||
"prompt": "A clean product reveal",
|
||||
"mode": "std",
|
||||
@@ -162,7 +162,7 @@ func TestKelingOmniCompatibleHTTPFlow(t *testing.T) {
|
||||
taskID := stringFromKelingCompat(createdData["task_id"])
|
||||
|
||||
var hidden KelingCompatibleEnvelope
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/v1/videos/omni-video/"+taskID, secondAPIKey, nil, http.StatusNotFound, &hidden)
|
||||
doJSON(t, gateway.URL, http.MethodGet, "/api/v1/videos/omni-video/"+taskID, secondAPIKey, nil, http.StatusNotFound, &hidden)
|
||||
if hidden.Code != 1203 {
|
||||
t.Fatalf("cross-user task must be hidden: %+v", hidden)
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func waitForKelingCompatTask(t *testing.T, baseURL string, apiKey string, taskID
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
var response KelingCompatibleEnvelope
|
||||
doJSON(t, baseURL, http.MethodGet, "/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response.Data.(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
|
||||
@@ -134,13 +134,14 @@ WHERE username = $1`, username); err != nil {
|
||||
ResponseDurationMS int64 `json:"responseDurationMs"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(
|
||||
doJSONWithHeaders(
|
||||
t,
|
||||
server.URL,
|
||||
http.MethodPost,
|
||||
"/api/v1/videos/generations",
|
||||
apiKeyResponse.Secret,
|
||||
request,
|
||||
map[string]string{"X-Async": "true"},
|
||||
http.StatusAccepted,
|
||||
&response,
|
||||
)
|
||||
|
||||
@@ -27,6 +27,15 @@ func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
|
||||
handler := func(next http.HandlerFunc) http.Handler {
|
||||
return s.requireUser(auth.PermissionBasic, http.HandlerFunc(next))
|
||||
}
|
||||
// /api/v1 is the canonical public prefix. The historical /kling paths
|
||||
// remain registered below so existing clients can migrate without downtime.
|
||||
mux.Handle("POST /api/v1/kling/v1/videos/omni-video", handler(s.klingV1CreateOmniVideo))
|
||||
mux.Handle("GET /api/v1/kling/v1/videos/omni-video", handler(s.klingV1ListOmniVideos))
|
||||
mux.Handle("GET /api/v1/kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo))
|
||||
mux.Handle("POST /api/v1/kling/v2/omni-video/{model}", handler(s.klingV2CreateOmniVideo))
|
||||
mux.Handle("GET /api/v1/kling/v2/tasks", handler(s.klingV2GetTasks))
|
||||
mux.Handle("POST /api/v1/kling/v2/tasks", handler(s.klingV2ListTasks))
|
||||
|
||||
mux.Handle("POST /kling/v1/videos/omni-video", handler(s.klingV1CreateOmniVideo))
|
||||
mux.Handle("GET /kling/v1/videos/omni-video", handler(s.klingV1ListOmniVideos))
|
||||
mux.Handle("GET /kling/v1/videos/omni-video/{taskID}", handler(s.klingV1GetOmniVideo))
|
||||
@@ -52,7 +61,7 @@ func (s *Server) registerKlingCompatibilityRoutes(mux *http.ServeMux) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /kling/v1/videos/omni-video [post]
|
||||
// @Router /api/v1/kling/v1/videos/omni-video [post]
|
||||
func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
var native map[string]any
|
||||
if err := decodeKlingJSON(r, &native); err != nil {
|
||||
@@ -78,7 +87,7 @@ func (s *Server) klingV1CreateOmniVideo(w http.ResponseWriter, r *http.Request)
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /kling/omni-video/{model} [post]
|
||||
// @Router /api/v1/kling/v2/omni-video/{model} [post]
|
||||
func (s *Server) klingV2CreateOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
model, ok := klingV2ProviderModel(r.PathValue("model"))
|
||||
if !ok {
|
||||
@@ -433,7 +442,7 @@ func validateKlingCompatBody(model string, body map[string]any) error {
|
||||
// @Param taskID path string true "任务 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]interface{}
|
||||
// @Router /kling/v1/videos/omni-video/{taskID} [get]
|
||||
// @Router /api/v1/kling/v1/videos/omni-video/{taskID} [get]
|
||||
func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
task, err := s.store.GetCompatTask(r.Context(), user, klingCompatProvider, "v1", r.PathValue("taskID"))
|
||||
@@ -456,7 +465,7 @@ func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
|
||||
// @Param pageNum query int false "页码" default(1)
|
||||
// @Param pageSize query int false "每页数量" default(30)
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/v1/videos/omni-video [get]
|
||||
// @Router /api/v1/kling/v1/videos/omni-video [get]
|
||||
func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
page, err := positiveQueryInt(r.URL.Query().Get("pageNum"), 1)
|
||||
@@ -489,7 +498,7 @@ func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
|
||||
// @Param task_ids query string false "逗号分隔的任务 ID"
|
||||
// @Param external_task_ids query string false "逗号分隔的外部任务 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/tasks [get]
|
||||
// @Router /api/v1/kling/v2/tasks [get]
|
||||
func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
taskIDs := splitKlingIDs(r.URL.Query().Get("task_ids"))
|
||||
@@ -525,7 +534,7 @@ func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
|
||||
// @Security BearerAuth
|
||||
// @Param input body map[string]interface{} true "游标、数量、时间范围和筛选条件"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /kling/tasks [post]
|
||||
// @Router /api/v1/kling/v2/tasks [post]
|
||||
func (s *Server) klingV2ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
var body map[string]any
|
||||
|
||||
@@ -143,7 +143,7 @@ WHERE platform_id = $1::uuid
|
||||
ResolvedModel string `json:"resolvedModel"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, request, http.StatusAccepted, &response)
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/videos/generations", apiKeyResponse.Secret, request, map[string]string{"X-Async": "true"}, http.StatusAccepted, &response)
|
||||
resolvedModel, resolved := klingV2ProviderModel(response.Task.ResolvedModel)
|
||||
if response.Task.Status != "succeeded" || response.Task.ModelType != expectedModelType || !resolved || resolvedModel != model {
|
||||
t.Fatalf("generic video request without modelType should use inferred capability: %+v", response.Task)
|
||||
@@ -159,7 +159,7 @@ WHERE platform_id = $1::uuid
|
||||
createV1 := func(model string, duration int, externalID string) string {
|
||||
t.Helper()
|
||||
var response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": model,
|
||||
"prompt": "兼容接口模拟任务",
|
||||
"duration": duration,
|
||||
@@ -186,14 +186,14 @@ WHERE platform_id = $1::uuid
|
||||
waitKlingV1SimulationTask(t, server.URL, apiKeyResponse.Secret, taskID)
|
||||
}
|
||||
var listResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodGet, "/kling/v1/videos/omni-video?pageNum=1&pageSize=10", apiKeyResponse.Secret, nil, http.StatusOK, &listResponse)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/kling/v1/videos/omni-video?pageNum=1&pageSize=10", apiKeyResponse.Secret, nil, http.StatusOK, &listResponse)
|
||||
items, _ := listResponse["data"].([]any)
|
||||
if len(items) < 2 {
|
||||
t.Fatalf("V1 task list did not return compatibility tasks: %#v", listResponse)
|
||||
}
|
||||
|
||||
var duplicateResponse map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/kling/v1/videos/omni-video", apiKeyResponse.Secret, map[string]any{
|
||||
"model_name": klingO1Model, "prompt": "duplicate", "duration": 5,
|
||||
"external_task_id": "compat-o1-" + suffix,
|
||||
"runMode": "simulation", "simulation": true,
|
||||
@@ -203,7 +203,7 @@ WHERE platform_id = $1::uuid
|
||||
}
|
||||
|
||||
var v2Response map[string]any
|
||||
doJSON(t, server.URL, http.MethodPost, "/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/kling/v2/omni-video/kling-v3-omni", apiKeyResponse.Secret, map[string]any{
|
||||
"contents": []any{map[string]any{"type": "prompt", "text": "API 2.0 模拟任务"}},
|
||||
"settings": map[string]any{"duration": 3, "resolution": "720p", "aspect_ratio": "16:9", "audio": "off"},
|
||||
"options": map[string]any{"external_task_id": "compat-v2-" + suffix},
|
||||
@@ -224,7 +224,7 @@ func waitKlingV1SimulationTask(t *testing.T, baseURL string, apiKey string, task
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/kling/v1/videos/omni-video/"+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
data, _ := response["data"].(map[string]any)
|
||||
switch data["task_status"] {
|
||||
case "succeed":
|
||||
@@ -242,7 +242,7 @@ func waitKlingV2SimulationTask(t *testing.T, baseURL string, apiKey string, task
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var response map[string]any
|
||||
doJSON(t, baseURL, http.MethodGet, "/kling/v2/tasks?task_ids="+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/kling/v2/tasks?task_ids="+taskID, apiKey, nil, http.StatusOK, &response)
|
||||
items, _ := response["data"].([]any)
|
||||
if len(items) == 1 {
|
||||
data, _ := items[0].(map[string]any)
|
||||
|
||||
@@ -23,8 +23,8 @@ type SkillBundleMetadataResponse struct {
|
||||
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"`
|
||||
APIDocsJSONPath string `json:"apiDocsJsonPath" example:"/api/v1/openapi.json"`
|
||||
APIDocsYAMLPath string `json:"apiDocsYamlPath" example:"/api/v1/openapi.yaml"`
|
||||
}
|
||||
|
||||
type ErrorEnvelope struct {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gatewaydocs "github.com/easyai/easyai-ai-gateway/apps/api/docs"
|
||||
)
|
||||
|
||||
func TestOpenAPIPublicRoutesUseCanonicalV1Prefix(t *testing.T) {
|
||||
var document struct {
|
||||
Paths map[string]any `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(gatewaydocs.SwaggerJSON, &document); err != nil {
|
||||
t.Fatalf("decode embedded OpenAPI document: %v", err)
|
||||
}
|
||||
|
||||
legacyPrefixes := []string{
|
||||
"/v1/",
|
||||
"/v1beta/",
|
||||
"/kling/",
|
||||
"/upload/",
|
||||
"/api/v3/",
|
||||
"/chat/",
|
||||
"/images/",
|
||||
"/song/",
|
||||
"/music/",
|
||||
"/speech/",
|
||||
"/voice_clone",
|
||||
"/tasks",
|
||||
}
|
||||
legacyExact := map[string]bool{
|
||||
"/healthz": true,
|
||||
"/readyz": true,
|
||||
"/api-docs-json": true,
|
||||
"/api-docs-yaml": true,
|
||||
"/responses": true,
|
||||
"/embeddings": true,
|
||||
"/reranks": true,
|
||||
}
|
||||
for route := range document.Paths {
|
||||
if legacyExact[route] {
|
||||
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
|
||||
}
|
||||
for _, prefix := range legacyPrefixes {
|
||||
if strings.HasPrefix(route, prefix) {
|
||||
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required := []string{
|
||||
"/api/v1/healthz",
|
||||
"/api/v1/readyz",
|
||||
"/api/v1/openapi.json",
|
||||
"/api/v1/chat/completions",
|
||||
"/api/v1/responses",
|
||||
"/api/v1/images/generations",
|
||||
"/api/v1/videos/generations",
|
||||
"/api/v1/models/{model}:generateContent",
|
||||
"/api/v1/videos/omni-video",
|
||||
"/api/v1/kling/v1/videos/omni-video",
|
||||
"/api/v1/kling/v2/omni-video/{model}",
|
||||
"/api/v1/contents/generations/tasks",
|
||||
}
|
||||
for _, route := range required {
|
||||
if _, ok := document.Paths[route]; !ok {
|
||||
t.Errorf("canonical public route missing from OpenAPI: %s", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,12 +127,16 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", server.health)
|
||||
mux.HandleFunc("GET /readyz", server.ready)
|
||||
mux.HandleFunc("GET /api/v1/healthz", server.health)
|
||||
mux.HandleFunc("GET /api/v1/readyz", server.ready)
|
||||
mux.Handle("GET /metrics", securityEventMetrics.DynamicHandler(db))
|
||||
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/openapi.json", server.apiDocsJSON)
|
||||
mux.HandleFunc("GET /api/v1/openapi.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)
|
||||
|
||||
@@ -252,12 +256,12 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("GET /api/admin/runtime/rate-limit-windows", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
|
||||
mux.Handle("GET /api/admin/runtime/model-rate-limits", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModelRateLimitStatuses)))
|
||||
mux.Handle("POST /api/v1/chat/completions", server.requireUser(auth.PermissionBasic, server.createAPIV1ChatCompletions()))
|
||||
mux.Handle("POST /api/v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", false)))
|
||||
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", false)))
|
||||
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", false)))
|
||||
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", false)))
|
||||
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", false)))
|
||||
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", false)))
|
||||
mux.Handle("POST /api/v1/responses", server.requireUser(auth.PermissionBasic, server.createTask("responses", true)))
|
||||
mux.Handle("POST /api/v1/embeddings", server.requireUser(auth.PermissionBasic, server.createTask("embeddings", true)))
|
||||
mux.Handle("POST /api/v1/reranks", server.requireUser(auth.PermissionBasic, server.createTask("reranks", true)))
|
||||
mux.Handle("POST /api/v1/images/generations", server.requireUser(auth.PermissionBasic, server.createTask("images.generations", true)))
|
||||
mux.Handle("POST /api/v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /api/v1/videos/generations", server.requireUser(auth.PermissionBasic, server.createTask("videos.generations", true)))
|
||||
mux.Handle("POST /api/v1/video/generations", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createLegacyVolcesVideoGeneration)))
|
||||
mux.Handle("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getLegacyVolcesVideoResult)))
|
||||
mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
@@ -275,10 +279,16 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("GET /api/v3/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
|
||||
mux.Handle("GET /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
|
||||
mux.Handle("DELETE /api/v3/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
|
||||
mux.Handle("POST /api/v1/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createVolcesContentsGenerationTask)))
|
||||
mux.Handle("GET /api/v1/contents/generations/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listVolcesContentsGenerationTasks)))
|
||||
mux.Handle("GET /api/v1/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getVolcesContentsGenerationTask)))
|
||||
mux.Handle("DELETE /api/v1/contents/generations/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteVolcesContentsGenerationTask)))
|
||||
server.registerGeminiGenerateContentRoutes(mux)
|
||||
server.registerKlingCompatibilityRoutes(mux)
|
||||
mux.Handle("POST /upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
|
||||
mux.Handle("POST /upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
|
||||
mux.Handle("POST /api/v1/gemini/upload/{version}/files", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUpload)))
|
||||
mux.Handle("POST /api/v1/gemini/upload/{version}/files/{uploadID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.geminiFilesUploadFinalize)))
|
||||
mux.Handle("GET /api/v1/tasks", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
mux.Handle("GET /api/v1/tasks/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||
mux.Handle("POST /api/v1/tasks/{taskID}/cancel", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.cancelTask)))
|
||||
@@ -303,6 +313,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /v1/images/edits", server.requireUser(auth.PermissionBasic, server.createTask("images.edits", true)))
|
||||
mux.Handle("POST /v1/videos/omni-video", server.requireKelingAPIKey(http.HandlerFunc(server.createKelingOmniVideo)))
|
||||
mux.Handle("GET /v1/videos/omni-video/{taskID}", server.requireKelingAPIKey(http.HandlerFunc(server.getKelingOmniVideo)))
|
||||
mux.Handle("POST /api/v1/videos/omni-video", server.requireKelingAPIKey(http.HandlerFunc(server.createKelingOmniVideo)))
|
||||
mux.Handle("GET /api/v1/videos/omni-video/{taskID}", server.requireKelingAPIKey(http.HandlerFunc(server.getKelingOmniVideo)))
|
||||
mux.Handle("POST /song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/voice_clone/voices [get]
|
||||
// @Router /v1/voice_clone/voices [get]
|
||||
// @Router /voice_clone/voices [get]
|
||||
func (s *Server) listClonedVoices(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -51,8 +49,6 @@ func (s *Server) listClonedVoices(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /api/v1/voice_clone/voices/{voiceID} [delete]
|
||||
// @Router /v1/voice_clone/voices/{voiceID} [delete]
|
||||
// @Router /voice_clone/voices/{voiceID} [delete]
|
||||
func (s *Server) deleteClonedVoice(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
|
||||
@@ -17,13 +17,13 @@ const volcesContentsCompatibilityMarker = "volces_contents_generations_v3"
|
||||
|
||||
// createVolcesContentsGenerationTask godoc
|
||||
// @Summary 创建火山内容生成任务
|
||||
// @Description 兼容火山方舟 POST /api/v3/contents/generations/tasks。网关 task id 是查询与取消用的公开 id;上游 id 另以 upstream_task_id 保留。
|
||||
// @Description 统一公开入口兼容火山方舟内容生成任务。网关 task id 是查询与取消用的公开 id;上游 id 另以 upstream_task_id 保留。
|
||||
// @Tags volces-compatible
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks [post]
|
||||
// @Router /api/v1/contents/generations/tasks [post]
|
||||
func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
@@ -49,7 +49,7 @@ func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks/{taskID} [get]
|
||||
// @Router /api/v1/contents/generations/tasks/{taskID} [get]
|
||||
func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
@@ -64,7 +64,7 @@ func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks [get]
|
||||
// @Router /api/v1/contents/generations/tasks [get]
|
||||
func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
@@ -105,7 +105,7 @@ func (s *Server) listVolcesContentsGenerationTasks(w http.ResponseWriter, r *htt
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v3/contents/generations/tasks/{taskID} [delete]
|
||||
// @Router /api/v1/contents/generations/tasks/{taskID} [delete]
|
||||
func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
|
||||
@@ -18,7 +18,7 @@ Use this skill to operate AI Gateway administration APIs through documented, evi
|
||||
- Reuse existing pricing rules, runtime policy sets, providers, protocol clients, base models, and platforms whenever their effective behavior satisfies the target. Do not create a near-duplicate resource merely because the upstream account, base URL, or provider-side model name differs.
|
||||
- Prefer a supported standard client before using `universal` scripts. Use custom scripts only when the upstream contract cannot be represented by the existing OpenAI, Gemini, or provider-specific clients.
|
||||
- Do not invent platform config fields or assume an arbitrary config key is enforced. For `universal`, use only the recognized keys documented in `references/model-universal-platforms.md`; treat any extra key as script-owned data available through `context.env`.
|
||||
- Use the module references as the primary API source. Only when the required API is absent, inspect `<gateway-api-base-url>/api-docs-json`; continue only when path, method, schema, authentication, permission, and side effects are unambiguous.
|
||||
- Use the module references as the primary API source. Only when the required API is absent, inspect `<gateway-origin>/api/v1/openapi.json`; continue only when path, method, schema, authentication, permission, and side effects are unambiguous.
|
||||
|
||||
## Module Routing
|
||||
|
||||
|
||||
+8
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
## Required Inputs
|
||||
|
||||
- Gateway API base URL. When using the bundled Web deployment this commonly includes `/gateway-api`; direct API access commonly uses port `8088`.
|
||||
- Gateway origin and public API base URL. Public API access always ends with `/api/v1`; direct local access commonly uses `http://127.0.0.1:8088/api/v1`.
|
||||
- Administrator JWT with the `manager` or `admin` role.
|
||||
- Target provider documentation and authorization material.
|
||||
- Clear requested outcome and whether real upstream calls are allowed.
|
||||
@@ -10,7 +10,8 @@
|
||||
Do not place credentials in files or reusable commands. Use shell environment variables:
|
||||
|
||||
```bash
|
||||
export GATEWAY_BASE_URL='https://gateway.example.com/gateway-api'
|
||||
export GATEWAY_ORIGIN='https://gateway.example.com'
|
||||
export GATEWAY_PUBLIC_API_BASE="$GATEWAY_ORIGIN/api/v1"
|
||||
export GATEWAY_ADMIN_TOKEN='<administrator-jwt>'
|
||||
```
|
||||
|
||||
@@ -24,7 +25,7 @@ For standalone or hybrid deployments, local login can return a JWT:
|
||||
curl --fail-with-body \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"account":"<admin-account>","password":"<admin-password>"}' \
|
||||
"$GATEWAY_BASE_URL/api/v1/auth/login"
|
||||
"$GATEWAY_PUBLIC_API_BASE/auth/login"
|
||||
```
|
||||
|
||||
Do not use local login when the deployment requires OIDC or server-main identity. Obtain the deployment's administrator access token instead.
|
||||
@@ -34,7 +35,7 @@ Verify identity and role before writes:
|
||||
```bash
|
||||
curl --fail-with-body \
|
||||
-H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \
|
||||
"$GATEWAY_BASE_URL/api/v1/me"
|
||||
"$GATEWAY_PUBLIC_API_BASE/me"
|
||||
```
|
||||
|
||||
## Request Pattern
|
||||
@@ -47,7 +48,7 @@ curl --fail-with-body \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
-d '<json-body>' \
|
||||
"$GATEWAY_BASE_URL/api/admin/<resource>"
|
||||
"$GATEWAY_ORIGIN/api/admin/<resource>"
|
||||
```
|
||||
|
||||
Always read current state before PATCH, DELETE, reset, disable, or full replacement. PATCH handlers for providers, base models, pricing rule sets, runtime policy sets, runner policy, and platforms write complete resource shapes rather than merging every omitted field.
|
||||
@@ -67,7 +68,7 @@ Obtain explicit confirmation after showing the current snapshot and impact befor
|
||||
|
||||
The live machine-readable documents are:
|
||||
|
||||
- `<gateway-api-base-url>/api-docs-json`
|
||||
- `<gateway-api-base-url>/api-docs-yaml`
|
||||
- `<gateway-origin>/api/v1/openapi.json`
|
||||
- `<gateway-origin>/api/v1/openapi.yaml`
|
||||
|
||||
Use them only when this Skill does not document the required operation. Before acting, confirm the exact path, method, body, authentication, permission, response, and side effect. Do not infer a write operation from a similarly named endpoint.
|
||||
|
||||
+3
-3
@@ -33,7 +33,7 @@ Use an authorized user JWT:
|
||||
```bash
|
||||
curl --fail-with-body \
|
||||
-H "Authorization: Bearer <user-jwt>" \
|
||||
"$GATEWAY_BASE_URL/api/v1/model-catalog"
|
||||
"$GATEWAY_PUBLIC_API_BASE/model-catalog"
|
||||
```
|
||||
|
||||
Confirm model alias, model types, provider source, effective capabilities, pricing summary, rate limits, permissions, and enabled state.
|
||||
@@ -57,7 +57,7 @@ curl --fail-with-body \
|
||||
"simulation": true,
|
||||
"stream": false
|
||||
}' \
|
||||
"$GATEWAY_BASE_URL/v1/chat/completions"
|
||||
"$GATEWAY_PUBLIC_API_BASE/chat/completions"
|
||||
```
|
||||
|
||||
Simulation verifies Gateway routing, permissions, parameter normalization, pricing, and task behavior, but it does not execute the real universal submit or poll scripts. Validate universal scripts separately against a local mock or approved provider test environment before enabling production traffic.
|
||||
@@ -82,4 +82,4 @@ With explicit approval, run one real minimal request and verify upstream request
|
||||
|
||||
## Final Report
|
||||
|
||||
Report resource IDs, before/after behavior, requests used for verification, simulation or real mode, billing evidence, remaining risks, rollback readiness, and whether `/api-docs-json` was used. Never include credentials or raw secret-bearing payloads.
|
||||
Report resource IDs, before/after behavior, requests used for verification, simulation or real mode, billing evidence, remaining risks, rollback readiness, and whether `/api/v1/openapi.json` was used. Never include credentials or raw secret-bearing payloads.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"outputs": ["{projectRoot}/docs/swagger.json", "{projectRoot}/docs/swagger.yaml"],
|
||||
"options": {
|
||||
"cwd": "apps/api",
|
||||
"command": "go run github.com/swaggo/swag/cmd/swag@v1.16.4 init --parseInternal -d ./cmd/gateway,./internal/httpapi,./internal/store,./internal/auth,./internal/identity -g main.go -o docs --outputTypes json,yaml"
|
||||
"command": "go run github.com/swaggo/swag/cmd/swag@v1.16.4 init --parseInternal -d ./cmd/gateway,./internal/httpapi,./internal/store,./internal/auth,./internal/identity,./internal/runner -g main.go -o docs --outputTypes json,yaml"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
|
||||
Reference in New Issue
Block a user