From a24eb1aeb0a0a04ca84a595deb270434e2104971 Mon Sep 17 00:00:00 2001 From: wangbo Date: Thu, 16 Jul 2026 23:57:14 +0800 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20=E8=87=AA=E6=89=98=E7=AE=A1=20AI?= =?UTF-8?q?=20Gateway=20=E8=BF=90=E7=BB=B4=E7=AE=A1=E7=90=86=20SKILL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/docs/embed.go | 9 ++ apps/api/docs/swagger.json | 135 +++++++++++++++++ apps/api/docs/swagger.yaml | 91 ++++++++++++ .../httpapi/agent_resources_handlers.go | 98 +++++++++++++ .../httpapi/agent_resources_handlers_test.go | 115 +++++++++++++++ apps/api/internal/httpapi/openapi_models.go | 11 ++ apps/api/internal/httpapi/server.go | 4 + apps/api/internal/skillbundle/bundle.go | 122 ++++++++++++++++ apps/api/internal/skillbundle/bundle_test.go | 80 +++++++++++ .../skills/ai-gateway-ops-management/SKILL.md | 53 +++++++ .../agents/openai.yaml | 4 + .../references/api-discovery-and-safety.md | 73 ++++++++++ .../references/model-acceptance-runbook.md | 82 +++++++++++ .../model-platforms-and-bindings.md | 123 ++++++++++++++++ .../references/model-pricing-and-policies.md | 136 ++++++++++++++++++ .../model-providers-and-base-models.md | 87 +++++++++++ .../references/model-universal-platforms.md | 92 ++++++++++++ .../ai-gateway-ops-management/skill.json | 7 + apps/web/src/api.test.ts | 31 ++++ apps/web/src/api.ts | 5 + apps/web/src/pages/ApiDocsPage.tsx | 93 +++++++++++- apps/web/src/styles/api-docs.css | 85 +++++++++++ packages/contracts/src/index.ts | 11 ++ 23 files changed, 1544 insertions(+), 3 deletions(-) create mode 100644 apps/api/docs/embed.go create mode 100644 apps/api/internal/httpapi/agent_resources_handlers.go create mode 100644 apps/api/internal/httpapi/agent_resources_handlers_test.go create mode 100644 apps/api/internal/skillbundle/bundle.go create mode 100644 apps/api/internal/skillbundle/bundle_test.go create mode 100644 apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/SKILL.md create mode 100644 apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/agents/openai.yaml create mode 100644 apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/api-discovery-and-safety.md create mode 100644 apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-acceptance-runbook.md create mode 100644 apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-platforms-and-bindings.md create mode 100644 apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-pricing-and-policies.md create mode 100644 apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-providers-and-base-models.md create mode 100644 apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-universal-platforms.md create mode 100644 apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/skill.json diff --git a/apps/api/docs/embed.go b/apps/api/docs/embed.go new file mode 100644 index 0000000..1de7ee1 --- /dev/null +++ b/apps/api/docs/embed.go @@ -0,0 +1,9 @@ +package docs + +import _ "embed" + +//go:embed swagger.json +var SwaggerJSON []byte + +//go:embed swagger.yaml +var SwaggerYAML []byte diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index bab1b40..ede6dfd 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -12,6 +12,47 @@ }, "basePath": "/", "paths": { + "/api-docs-json": { + "get": { + "description": "返回当前构建内嵌的完整机器可读 Swagger JSON,供 Agent 在 SKILL references 未覆盖接口时查询。", + "produces": [ + "application/json" + ], + "tags": [ + "agent-resources" + ], + "summary": "获取 AI Gateway Swagger JSON", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api-docs-yaml": { + "get": { + "description": "返回当前构建内嵌的完整机器可读 Swagger YAML。", + "produces": [ + "application/yaml" + ], + "tags": [ + "agent-resources" + ], + "summary": "获取 AI Gateway Swagger YAML", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, "/api/admin/access-rules": { "get": { "security": [ @@ -5320,6 +5361,58 @@ } } }, + "/api/v1/public/skills/ai-gateway-ops-management/download": { + "get": { + "description": "下载可交给 Agent 使用的 ai-gateway-ops-management ZIP 包。", + "produces": [ + "application/zip" + ], + "tags": [ + "agent-resources" + ], + "summary": "下载 AI Gateway 运维管理 SKILL", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/v1/public/skills/ai-gateway-ops-management/metadata": { + "get": { + "description": "返回公开运维管理 SKILL 的名称、版本、模块、下载文件名和机器可读接口文档路径。", + "produces": [ + "application/json" + ], + "tags": [ + "agent-resources" + ], + "summary": "获取 AI Gateway 运维管理 SKILL 元数据", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.SkillBundleMetadataResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/v1/reranks": { "post": { "security": [ @@ -10369,6 +10462,48 @@ } } }, + "httpapi.SkillBundleMetadataResponse": { + "type": "object", + "properties": { + "apiDocsJsonPath": { + "type": "string", + "example": "/api-docs-json" + }, + "apiDocsYamlPath": { + "type": "string", + "example": "/api-docs-yaml" + }, + "displayName": { + "type": "string", + "example": "AI Gateway 运维管理" + }, + "downloadPath": { + "type": "string", + "example": "/api/v1/public/skills/ai-gateway-ops-management/download" + }, + "fileName": { + "type": "string", + "example": "ai-gateway-ops-management-v1.0.1.zip" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "model-runtime" + ] + }, + "name": { + "type": "string", + "example": "ai-gateway-ops-management" + }, + "version": { + "type": "string", + "example": "1.0.1" + } + } + }, "httpapi.TaskAcceptedResponse": { "type": "object", "properties": { diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index ac0cb65..bd4092a 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -614,6 +614,36 @@ definitions: $ref: '#/definitions/store.RuntimePolicySet' type: array type: object + httpapi.SkillBundleMetadataResponse: + properties: + apiDocsJsonPath: + example: /api-docs-json + type: string + apiDocsYamlPath: + example: /api-docs-yaml + type: string + displayName: + example: AI Gateway 运维管理 + type: string + downloadPath: + example: /api/v1/public/skills/ai-gateway-ops-management/download + type: string + fileName: + example: ai-gateway-ops-management-v1.0.1.zip + type: string + modules: + example: + - model-runtime + items: + type: string + type: array + name: + example: ai-gateway-ops-management + type: string + version: + example: 1.0.1 + type: string + type: object httpapi.TaskAcceptedResponse: properties: next: @@ -2545,6 +2575,33 @@ info: title: EasyAI AI Gateway API version: 0.1.0 paths: + /api-docs-json: + get: + description: 返回当前构建内嵌的完整机器可读 Swagger JSON,供 Agent 在 SKILL references 未覆盖接口时查询。 + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: 获取 AI Gateway Swagger JSON + tags: + - agent-resources + /api-docs-yaml: + get: + description: 返回当前构建内嵌的完整机器可读 Swagger YAML。 + produces: + - application/yaml + responses: + "200": + description: OK + schema: + type: string + summary: 获取 AI Gateway Swagger YAML + tags: + - agent-resources /api/admin/access-rules: get: description: 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的访问规则。 @@ -5951,6 +6008,40 @@ paths: summary: 获取公开客户端自定义设置 tags: - system + /api/v1/public/skills/ai-gateway-ops-management/download: + get: + description: 下载可交给 Agent 使用的 ai-gateway-ops-management ZIP 包。 + produces: + - application/zip + responses: + "200": + description: OK + schema: + type: file + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + summary: 下载 AI Gateway 运维管理 SKILL + tags: + - agent-resources + /api/v1/public/skills/ai-gateway-ops-management/metadata: + get: + description: 返回公开运维管理 SKILL 的名称、版本、模块、下载文件名和机器可读接口文档路径。 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.SkillBundleMetadataResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + summary: 获取 AI Gateway 运维管理 SKILL 元数据 + tags: + - agent-resources /api/v1/reranks: post: consumes: diff --git a/apps/api/internal/httpapi/agent_resources_handlers.go b/apps/api/internal/httpapi/agent_resources_handlers.go new file mode 100644 index 0000000..9471763 --- /dev/null +++ b/apps/api/internal/httpapi/agent_resources_handlers.go @@ -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, + } +} diff --git a/apps/api/internal/httpapi/agent_resources_handlers_test.go b/apps/api/internal/httpapi/agent_resources_handlers_test.go new file mode 100644 index 0000000..28f685d --- /dev/null +++ b/apps/api/internal/httpapi/agent_resources_handlers_test.go @@ -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.1" { + 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.1.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") + } +} diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index f297a03..2d2363e 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -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.1"` + 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.1.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"` } diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 85c6cd4..be8deb4 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -121,6 +121,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))) diff --git a/apps/api/internal/skillbundle/bundle.go b/apps/api/internal/skillbundle/bundle.go new file mode 100644 index 0000000..e87587b --- /dev/null +++ b/apps/api/internal/skillbundle/bundle.go @@ -0,0 +1,122 @@ +package skillbundle + +import ( + "archive/zip" + "bytes" + "embed" + "encoding/json" + "fmt" + "io/fs" + "path" + "regexp" + "sort" + "strings" + "time" +) + +const ( + Name = "ai-gateway-ops-management" + DisplayName = "AI Gateway 运维管理" +) + +const bundleRoot = "content/skills/" + Name + +var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) + +//go:embed content/skills/ai-gateway-ops-management +var bundleFS embed.FS + +type Metadata struct { + Name string `json:"name"` + Version string `json:"version"` + Modules []string `json:"modules"` +} + +func LoadMetadata() (Metadata, error) { + raw, err := bundleFS.ReadFile(bundleRoot + "/skill.json") + if err != nil { + return Metadata{}, fmt.Errorf("read bundled skill metadata: %w", err) + } + var metadata Metadata + if err := json.Unmarshal(raw, &metadata); err != nil { + return Metadata{}, fmt.Errorf("parse bundled skill metadata: %w", err) + } + if metadata.Name != Name { + return Metadata{}, fmt.Errorf("bundled skill metadata name must be %q", Name) + } + if !semverPattern.MatchString(metadata.Version) { + return Metadata{}, fmt.Errorf("bundled skill metadata version %q is not semver", metadata.Version) + } + if len(metadata.Modules) == 0 { + return Metadata{}, fmt.Errorf("bundled skill metadata modules must not be empty") + } + seen := make(map[string]struct{}, len(metadata.Modules)) + for _, module := range metadata.Modules { + module = strings.TrimSpace(module) + if module == "" { + return Metadata{}, fmt.Errorf("bundled skill metadata module must not be empty") + } + if _, exists := seen[module]; exists { + return Metadata{}, fmt.Errorf("bundled skill metadata module %q is duplicated", module) + } + seen[module] = struct{}{} + } + if _, err := bundleFS.ReadFile(bundleRoot + "/SKILL.md"); err != nil { + return Metadata{}, fmt.Errorf("read bundled SKILL.md: %w", err) + } + return metadata, nil +} + +func FileName(metadata Metadata) string { + return fmt.Sprintf("%s-v%s.zip", metadata.Name, metadata.Version) +} + +func BuildArchive() ([]byte, error) { + if _, err := LoadMetadata(); err != nil { + return nil, err + } + files := make([]string, 0) + if err := fs.WalkDir(bundleFS, bundleRoot, func(filePath string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + relativePath := strings.TrimPrefix(filePath, bundleRoot+"/") + if relativePath == "" || path.IsAbs(relativePath) || path.Clean(relativePath) != relativePath || strings.HasPrefix(relativePath, "../") { + return fmt.Errorf("unsafe bundled skill path %q", relativePath) + } + files = append(files, relativePath) + return nil + }); err != nil { + return nil, fmt.Errorf("walk bundled skill: %w", err) + } + sort.Strings(files) + + var output bytes.Buffer + archive := zip.NewWriter(&output) + fixedTime := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC) + for _, relativePath := range files { + raw, err := bundleFS.ReadFile(bundleRoot + "/" + relativePath) + if err != nil { + _ = archive.Close() + return nil, fmt.Errorf("read bundled skill file %q: %w", relativePath, err) + } + header := &zip.FileHeader{Name: relativePath, Method: zip.Deflate} + header.SetModTime(fixedTime) + writer, err := archive.CreateHeader(header) + if err != nil { + _ = archive.Close() + return nil, fmt.Errorf("create bundled skill archive entry %q: %w", relativePath, err) + } + if _, err := writer.Write(raw); err != nil { + _ = archive.Close() + return nil, fmt.Errorf("write bundled skill archive entry %q: %w", relativePath, err) + } + } + if err := archive.Close(); err != nil { + return nil, fmt.Errorf("close bundled skill archive: %w", err) + } + return output.Bytes(), nil +} diff --git a/apps/api/internal/skillbundle/bundle_test.go b/apps/api/internal/skillbundle/bundle_test.go new file mode 100644 index 0000000..40f21fc --- /dev/null +++ b/apps/api/internal/skillbundle/bundle_test.go @@ -0,0 +1,80 @@ +package skillbundle + +import ( + "archive/zip" + "bytes" + "io" + "slices" + "strings" + "testing" +) + +func TestLoadMetadata(t *testing.T) { + metadata, err := LoadMetadata() + if err != nil { + t.Fatalf("load metadata: %v", err) + } + if metadata.Name != Name || metadata.Version != "1.0.1" { + t.Fatalf("unexpected metadata: %+v", metadata) + } + if !slices.Equal(metadata.Modules, []string{"model-runtime"}) { + t.Fatalf("unexpected modules: %+v", metadata.Modules) + } + if FileName(metadata) != "ai-gateway-ops-management-v1.0.1.zip" { + t.Fatalf("unexpected file name: %s", FileName(metadata)) + } +} + +func TestBuildArchiveContainsOperationsSkill(t *testing.T) { + raw, err := BuildArchive() + if err != nil { + t.Fatalf("build archive: %v", err) + } + archive, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatalf("open archive: %v", err) + } + files := make(map[string]*zip.File, len(archive.File)) + for _, file := range archive.File { + files[file.Name] = file + if strings.HasPrefix(file.Name, "/") || strings.Contains(file.Name, "..") { + t.Fatalf("unsafe archive path: %q", file.Name) + } + } + expected := []string{ + "SKILL.md", + "agents/openai.yaml", + "skill.json", + "references/api-discovery-and-safety.md", + "references/model-providers-and-base-models.md", + "references/model-pricing-and-policies.md", + "references/model-platforms-and-bindings.md", + "references/model-universal-platforms.md", + "references/model-acceptance-runbook.md", + } + for _, name := range expected { + if files[name] == nil { + t.Fatalf("archive missing %q; files=%v", name, archiveFileNames(archive.File)) + } + } + skillFile, err := files["SKILL.md"].Open() + if err != nil { + t.Fatalf("open SKILL.md: %v", err) + } + defer skillFile.Close() + skillContent, err := io.ReadAll(skillFile) + if err != nil { + t.Fatalf("read SKILL.md: %v", err) + } + if !strings.Contains(string(skillContent), "name: "+Name) { + t.Fatalf("SKILL.md frontmatter has unexpected name") + } +} + +func archiveFileNames(files []*zip.File) []string { + names := make([]string, 0, len(files)) + for _, file := range files { + names = append(names, file.Name) + } + return names +} diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/SKILL.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/SKILL.md new file mode 100644 index 0000000..c592b52 --- /dev/null +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/SKILL.md @@ -0,0 +1,53 @@ +--- +name: ai-gateway-ops-management +description: Operate and verify EasyAI AI Gateway administration capabilities. Use when Codex or an Agent needs to inspect, create, update, disable, restore, or troubleshoot AI Gateway providers, base models, pricing, runtime policies, runner policies, integration platforms, platform-model bindings, or universal custom-script platforms; also use this skill as the extensible entry point for future AI Gateway operations modules. +--- + +# AI Gateway Operations Management + +Use this skill to operate AI Gateway administration APIs through documented, evidence-first workflows. + +## Operating Rules + +- Obtain the Gateway API base URL and an administrator JWT before calling management APIs. Admin APIs reject `sk-*` API keys. +- Read `references/api-discovery-and-safety.md` before any write operation. +- Select only the references for the requested module. Do not load unrelated future operations modules. +- Read current state before changing it. Treat PATCH bodies as complete resource configurations unless the reference explicitly says otherwise. +- Never write credentials, tokens, script `authValues`, raw upstream responses, or other secrets into Skill files, logs, commands shown to users, or final summaries. +- Execute ordinary creates and updates only when the user requested the change. Before DELETE, full replacement, bulk reset, platform disablement, or credential clearing, show the current snapshot and impact and obtain explicit confirmation. +- 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 `/api-docs-json`; continue only when path, method, schema, authentication, permission, and side effects are unambiguous. + +## Module Routing + +### Model Runtime + +Use these references for the current v1 module: + +- `references/model-providers-and-base-models.md`: provider catalog and base-model lifecycle. +- `references/model-pricing-and-policies.md`: pricing rule sets, runtime policy sets, runner policy, priority, and recovery. +- `references/model-platforms-and-bindings.md`: integration platforms, credentials, platform-model upsert, replacement, and deletion. +- `references/model-universal-platforms.md`: `universal` custom platform triage, configuration, script contracts, and examples. +- `references/model-acceptance-runbook.md`: read-back, catalog, simulation, runtime, billing, and rollback verification. + +## Standard Workflow + +1. Read `references/api-discovery-and-safety.md` and confirm the API base URL, identity mode, administrator JWT, target documentation, and required credentials. +2. Read the current provider, base-model, pricing, policy, platform, and platform-model records relevant to the request. +3. Reuse an existing pricing rule when its base prices and calculators, combined with the effective platform or platform-model discount, produce the required price. Create a pricing rule only when that combination cannot represent the target. +4. Reuse an existing runtime policy set when its limits, scopes, retry, auto-disable, and degradation behavior match the requirement. Create a policy only for a real semantic difference. +5. Classify the upstream protocol against existing platforms and clients. If compatible, keep the existing `specType` and change only instance configuration such as `baseUrl`, credentials, and bindings. Use `universal` only for an unsupported protocol. +6. Reuse the base model across platforms. Put a platform-specific upstream invocation name in the platform-model `providerModelName`; do not duplicate the base model just because providers use different names. +7. Apply the minimum changes in dependency order: provider, base model, pricing/policy, platform, platform-model binding. +8. Read every changed resource back and verify the effective model catalog. +9. Run a simulation or approved real request, inspect task and preprocessing evidence, and confirm billing and recovery behavior. +10. Report reused and created resource IDs, effective pricing evidence, protocol-fit evidence, verification results, unresolved risks, and whether the Swagger fallback was used, without exposing secrets. + +## Extending This Skill + +- Add future modules as one-level files under `references/` with stable prefixes such as `storage-`, `runtime-`, `identity-`, or `network-`. +- Add the module to this routing section and to `skill.json`. +- Increment the `skill.json` version whenever downloadable contents change. +- Keep the same skill name and public download route. diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/agents/openai.yaml b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/agents/openai.yaml new file mode 100644 index 0000000..ae15f91 --- /dev/null +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "AI Gateway 运维管理" + short_description: "管理 AI Gateway 模型、平台、定价与运行策略" + default_prompt: "Use $ai-gateway-ops-management to inspect and safely operate the AI Gateway administration APIs." diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/api-discovery-and-safety.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/api-discovery-and-safety.md new file mode 100644 index 0000000..c033f39 --- /dev/null +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/api-discovery-and-safety.md @@ -0,0 +1,73 @@ +# API Discovery and Safety + +## Required Inputs + +- Gateway API base URL. When using the bundled Web deployment this commonly includes `/gateway-api`; direct API access commonly uses port `8088`. +- Administrator JWT with the `manager` or `admin` role. +- Target provider documentation and authorization material. +- Clear requested outcome and whether real upstream calls are allowed. + +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_ADMIN_TOKEN='' +``` + +## Authentication + +Management endpoints under `/api/admin/*` accept administrator user credentials only. A local or server-main `sk-*` API key is rejected even if it has broad model scopes. + +For standalone or hybrid deployments, local login can return a JWT: + +```bash +curl --fail-with-body \ + -H 'Content-Type: application/json' \ + -d '{"account":"","password":""}' \ + "$GATEWAY_BASE_URL/api/v1/auth/login" +``` + +Do not use local login when the deployment requires OIDC or server-main identity. Obtain the deployment's administrator access token instead. + +Verify identity and role before writes: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/v1/me" +``` + +## Request Pattern + +Use a temporary request file or a carefully quoted inline body without printing secrets: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -X POST \ + -d '' \ + "$GATEWAY_BASE_URL/api/admin/" +``` + +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. + +## High-impact Operations + +Obtain explicit confirmation after showing the current snapshot and impact before: + +- Any DELETE request. +- `POST /api/admin/catalog/base-models/reset-all`. +- `PUT /api/admin/platforms/{platformID}/models`. +- Changing a platform status to `disabled`. +- Sending an empty `credentials` object to clear stored credentials. +- Replacing pricing rules or policy contents in a way that removes existing entries. + +## Full API Fallback + +The live machine-readable documents are: + +- `/api-docs-json` +- `/api-docs-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. diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-acceptance-runbook.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-acceptance-runbook.md new file mode 100644 index 0000000..720a94b --- /dev/null +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-acceptance-runbook.md @@ -0,0 +1,82 @@ +# Model Runtime Acceptance Runbook + +## Before Changes + +- Record the target deployment and identity mode. +- Confirm administrator identity with `/api/v1/me`. +- Save current provider, base model, pricing rule set, policy set, platform, and platform-model JSON without secrets. +- Confirm whether real upstream calls are allowed; otherwise use simulation. +- Confirm upstream endpoint, auth, model name, capabilities, limits, pricing, and sync/async behavior from documentation. +- Record which existing pricing rule and runtime policy were reused, or the exact semantic mismatch that required a new one. +- Record the protocol compatibility decision and why the selected existing `specType` is sufficient, or the concrete gap that requires `universal`. +- When one base model has different upstream names across platforms, confirm each binding resolves the expected `providerModelName`. + +## After Configuration + +Read back: + +- `/api/admin/catalog/providers` +- `/api/admin/catalog/base-models` +- `/api/admin/pricing/rule-sets` +- `/api/admin/runtime/policy-sets` +- `/api/admin/runtime/runner-policy` +- `/api/admin/platforms` +- `/api/admin/models` + +Verify that IDs and bindings resolve as intended and no unrelated record was removed or reset. + +## Catalog Verification + +Use an authorized user JWT: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer " \ + "$GATEWAY_BASE_URL/api/v1/model-catalog" +``` + +Confirm model alias, model types, provider source, effective capabilities, pricing summary, rate limits, permissions, and enabled state. + +For pricing, verify the effective rule source and discount source rather than checking IDs only. Confirm whether the platform-model discount overrides the platform default, and compare the estimated or simulated amount with the intended price. + +## Execution Verification + +Start with simulation: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer " \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "", + "messages": [{"role":"user","content":"Reply with OK"}], + "runMode": "simulation", + "simulation": true, + "stream": false + }' \ + "$GATEWAY_BASE_URL/v1/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. + +For media or universal scripts, inspect: + +- `/api/workspace/tasks/{taskID}` +- `/api/workspace/tasks/{taskID}/events` +- `/api/workspace/tasks/{taskID}/param-preprocessing` +- `/api/admin/runtime/model-rate-limits` +- `/api/admin/runtime/rate-limit-windows` + +With explicit approval, run one real minimal request and verify upstream request ID, normalized output, task completion, billing, and failure handling. + +## Rollback + +- Restore the prior complete resource body with PATCH. +- Restore an individual platform-model binding through POST. +- Use full platform-model PUT only when the saved list is complete and replacement is intentional. +- Remove newly created resources in reverse dependency order only after explicit confirmation. +- Use runtime restore only after the upstream problem is resolved. + +## 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. diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-platforms-and-bindings.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-platforms-and-bindings.md new file mode 100644 index 0000000..c4f6b6e --- /dev/null +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-platforms-and-bindings.md @@ -0,0 +1,123 @@ +# Platforms and Platform-model Bindings + +## Read Current State + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/platforms" + +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/models" +``` + +Platform responses expose masked `credentialsPreview`, not stored secrets. + +## Reuse-compatible Platforms First + +Before creating a platform or selecting `universal`: + +1. Compare the upstream authentication, endpoint paths, content type, request schema, response schema, and sync/async lifecycle with the clients already represented by existing platform `config.specType` values. +2. If the upstream is compatible with an existing OpenAI, Gemini, or provider-specific client, keep that client type. For another compatible endpoint or account, the protocol layer normally needs only a different `baseUrl`; credentials and account-specific settings remain ordinary platform instance configuration. +3. Reuse the existing platform record only when it represents the same logical account/endpoint and changing it will not redirect unrelated models. Otherwise create another standard platform instance with the same compatible `specType`. +4. Use `universal` only when a documented request, authentication, task lifecycle, or response-mapping requirement cannot be expressed by a standard client. + +Do not convert a compatible platform to `universal` merely because the host name, account, or provider-side model name differs. + +## Create a Standard Platform + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "provider": "example-openai", + "platformKey": "example-openai-primary", + "name": "Example OpenAI Primary", + "internalName": "example-openai-primary", + "baseUrl": "https://api.example.com/v1", + "authType": "bearer", + "credentials": {"apiKey": ""}, + "config": {"specType": "openai"}, + "retryPolicy": {}, + "rateLimitPolicy": {}, + "defaultPricingMode": "inherit_discount", + "defaultDiscountFactor": 1, + "pricingRuleSetId": "", + "priority": 100, + "status": "enabled" + }' \ + "$GATEWAY_BASE_URL/api/admin/platforms" +``` + +Use `PATCH /api/admin/platforms/{platformID}` with the complete platform body. Omit `credentials` to preserve existing secrets. A non-empty credentials object is merged into stored credentials. An empty object clears credentials and requires explicit confirmation. + +## Upsert One Platform Model + +`POST /api/admin/platforms/{platformID}/models` inserts or updates the `(platformID, modelName)` binding: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "baseModelId": "", + "canonicalModelKey": "example-openai:example-chat", + "modelName": "example-chat", + "providerModelName": "example-chat", + "modelAlias": "example-chat", + "modelType": ["text_generate"], + "displayName": "Example Chat", + "capabilityOverride": {}, + "pricingMode": "inherit_discount", + "discountFactor": 1, + "pricingRuleSetId": "", + "billingConfigOverride": {}, + "permissionConfig": {}, + "retryPolicy": {}, + "rateLimitPolicy": {}, + "runtimePolicySetId": "", + "runtimePolicyOverride": {}, + "enabled": true + }' \ + "$GATEWAY_BASE_URL/api/admin/platforms//models" +``` + +When fields are omitted, defaults may be derived from the base model. For predictable updates, read the current binding and send the intended complete configuration. + +### Override the real upstream invocation name + +Use the base model as the stable system identity and `providerModelName` as the platform-specific name sent to the upstream API: + +- `modelName` is the platform binding key and participates in the `(platformId, modelName)` upsert identity. +- `providerModelName` is the real model/deployment name used by the selected runtime client. +- Bind the same `baseModelId` on multiple platforms and set a different `providerModelName` on each binding when providers expose different invocation names. +- Do not create duplicate base models solely for aliases such as a vendor deployment ID, endpoint ID, dated model ID, or regional model name. + +Example: + +```json +[ + { + "platform": "platform-a", + "baseModelId": "base-example-chat", + "modelName": "example-chat", + "providerModelName": "example-chat-2026-07" + }, + { + "platform": "platform-b", + "baseModelId": "base-example-chat", + "modelName": "example-chat", + "providerModelName": "deployment-prod-42" + } +] +``` + +The current create/upsert implementation stores the platform-model binding as enabled even when the request contains `"enabled": false`. Do not rely on that input field for canary isolation. Use an isolated test deployment or keep the whole platform disabled until configuration review is complete; enable the platform only for an approved protocol test. + +## Full Replacement and Deletion + +`PUT /api/admin/platforms/{platformID}/models` reconciles the platform to exactly the supplied `models` list. Omitted bindings are deleted together with their platform-model access rules. Never use it for a single-model update. + +Delete one binding with `DELETE /api/admin/platform-models/{modelID}` after confirmation. Delete a platform with `DELETE /api/admin/platforms/{platformID}` only after reviewing all bindings and access-rule impact. diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-pricing-and-policies.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-pricing-and-policies.md new file mode 100644 index 0000000..e1809a2 --- /dev/null +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-pricing-and-policies.md @@ -0,0 +1,136 @@ +# Model Pricing and Policies + +## Contents + +- Pricing Rule Sets +- Runtime Policy Sets +- Runner Policy and Runtime Recovery + +## Pricing Rule Sets + +Read rule sets and effective rule rows: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/pricing/rule-sets" + +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/pricing/rules" +``` + +### Reuse-first pricing decision + +Do not create a pricing rule until all existing active rule sets have been compared against the target resource types, units, calculator types, base prices, weights, and conditions. + +Evaluate the price that the runtime will actually use: + +1. Identify the inherited pricing source from the base model and platform, then check whether the platform-model has an explicit `pricingRuleSetId` or billing override. +2. Reuse the existing rule when it already describes the required raw unit prices and calculators. +3. Apply the effective discount: + - a positive platform-model `discountFactor` takes precedence; + - otherwise `pricingMode: "inherit_discount"` uses the platform `defaultDiscountFactor`; + - AI Gateway does not multiply the platform and platform-model discount factors together. +4. Verify the resulting amount with `/api/v1/pricing/estimate`, simulation billing lines, or an approved test request. +5. Create a new rule set only when no existing rule plus the available platform/platform-model discount can produce the required price or express the required unit/calculator structure. + +Do not clone an otherwise identical pricing rule merely to represent a provider or account discount. Keep the reusable base price in the rule and express the instance-specific adjustment with `defaultDiscountFactor` or `discountFactor`. + +Create an example text pricing rule set: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "ruleSetKey": "example-chat-pricing-v1", + "name": "Example Chat Pricing", + "description": "Input and output token pricing", + "category": "model", + "currency": "resource", + "status": "active", + "metadata": {}, + "rules": [ + { + "ruleKey": "input", + "displayName": "Input tokens", + "resourceType": "text_input", + "unit": "1k_tokens", + "basePrice": 0.001, + "calculatorType": "token_usage", + "priority": 10, + "status": "active" + }, + { + "ruleKey": "output", + "displayName": "Output tokens", + "resourceType": "text_output", + "unit": "1k_tokens", + "basePrice": 0.002, + "calculatorType": "token_usage", + "priority": 20, + "status": "active" + } + ] + }' \ + "$GATEWAY_BASE_URL/api/admin/pricing/rule-sets" +``` + +`PATCH /api/admin/pricing/rule-sets/{ruleSetID}` replaces the stored rules with the supplied list. Preserve every rule that should remain. Delete only non-default rule sets after checking base-model, platform, and platform-model bindings. + +## Runtime Policy Sets + +Read and create policies: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/runtime/policy-sets" +``` + +Compare the existing policy sets before creating one. Reuse a policy when the effective rate-limit metrics, limits, windows, concurrency lease TTL, retry count, auto-disable behavior, degradation behavior, and scope semantics match the target. A different platform name or model name alone is not a reason to duplicate a policy. + +Create a new policy only when at least one required behavior cannot be represented by an existing policy plus the supported platform-model override fields. Record the mismatch that justified the new policy. + +```json +{ + "policyKey": "example-balanced-v1", + "name": "Example Balanced", + "description": "Retry and concurrency policy", + "rateLimitPolicy": { + "rules": [ + {"metric": "rpm", "limit": 60, "windowSeconds": 60}, + {"metric": "concurrent", "limit": 5, "leaseTtlSeconds": 120} + ] + }, + "retryPolicy": {"maxRetries": 2}, + "autoDisablePolicy": {}, + "degradePolicy": {}, + "metadata": {}, + "status": "active" +} +``` + +POST the body to `/api/admin/runtime/policy-sets`. Use PATCH with a complete body. Default policy sets cannot be deleted. + +## Runner Policy and Runtime Recovery + +Read the current global runner policy before changing it: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/runtime/runner-policy" +``` + +PATCH the same endpoint with the complete current fields plus approved changes to `failoverPolicy`, `hardStopPolicy`, `singleSourcePolicy`, or `cacheAffinityPolicy`. + +Operational endpoints: + +- `PATCH /api/admin/platforms/{platformID}/dynamic-priority` +- `GET /api/admin/runtime/rate-limit-windows` +- `GET /api/admin/runtime/model-rate-limits` +- `POST /api/admin/runtime/model-rate-limits/{platformModelID}/restore` + +Use restore only after identifying whether the model, platform cooldown, or platform disablement was automatic and whether the upstream condition has recovered. diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-providers-and-base-models.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-providers-and-base-models.md new file mode 100644 index 0000000..384b3bd --- /dev/null +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-providers-and-base-models.md @@ -0,0 +1,87 @@ +# Model Providers and Base Models + +## Read Current State + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/catalog/providers" + +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/catalog/base-models" +``` + +Public read-only catalog endpoints also exist at `/api/v1/public/catalog/providers` and `/api/v1/public/catalog/base-models`. + +## Provider Catalog + +Create a provider: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "providerKey": "example-openai", + "code": "example-openai", + "displayName": "Example OpenAI Compatible", + "providerType": "openai", + "defaultBaseUrl": "https://api.example.com/v1", + "defaultAuthType": "APIKey", + "source": "gateway", + "capabilitySchema": {}, + "defaultRateLimitPolicy": {}, + "metadata": {"protocol": "openai-compatible"}, + "status": "active" + }' \ + "$GATEWAY_BASE_URL/api/admin/catalog/providers" +``` + +Use `PATCH /api/admin/catalog/providers/{providerID}` with the complete current provider body to update. `providerType` selects the runtime client through the platform candidate. Common supported values include `openai`, `gemini`, `volces`, `keling`, `minimax`, and `universal`. Unknown types may fall back to the OpenAI client, so never leave a custom integration type ambiguous. + +Delete with `DELETE /api/admin/catalog/providers/{providerID}` only after checking platform references and obtaining confirmation. + +## Base Model + +Create a base model after its provider exists: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "providerKey": "example-openai", + "canonicalModelKey": "example-openai:example-chat", + "providerModelName": "example-chat", + "modelType": ["text_generate"], + "modelAlias": "example-chat", + "displayName": "Example Chat", + "capabilities": { + "text_generate": { + "supportedApiProtocols": ["openai_chat_completions"] + } + }, + "baseBillingConfig": { + "textInputPer1k": 0.001, + "textOutputPer1k": 0.002 + }, + "defaultRateLimitPolicy": {}, + "runtimePolicyOverride": {}, + "metadata": {"description": "Example OpenAI-compatible chat model"}, + "catalogType": "custom", + "defaultSnapshot": {}, + "pricingVersion": 1, + "status": "active" + }' \ + "$GATEWAY_BASE_URL/api/admin/catalog/base-models" +``` + +Use `PATCH /api/admin/catalog/base-models/{baseModelID}` with a complete body to update. Do not omit capabilities, billing, policy bindings, metadata, or status unintentionally. + +Reset endpoints are destructive: + +- `POST /api/admin/catalog/base-models/{baseModelID}/reset` +- `POST /api/admin/catalog/base-models/reset-all` + +Only system-seeded models with default snapshots can be reset. Delete with `DELETE /api/admin/catalog/base-models/{baseModelID}` only after checking platform-model bindings. diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-universal-platforms.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-universal-platforms.md new file mode 100644 index 0000000..aa19ca5 --- /dev/null +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-universal-platforms.md @@ -0,0 +1,92 @@ +# Universal Custom Platforms + +## Selection Rule + +Use `universal` only when documented upstream requirements cannot be represented by an existing standard client. Prefer: + +1. `openai` for OpenAI-compatible chat, Responses, embeddings, reranks, image generation, or image editing. +2. `gemini` for Gemini `generateContent` contracts. +3. A provider-specific client such as `volces`, `keling`, or `minimax` when implemented. +4. `universal` for non-standard auth, submit/poll lifecycles, payload shaping, or response mapping. + +Set the provider catalog `providerType` or platform `config.specType` explicitly to `universal`. An unknown type may fall back to OpenAI behavior. + +Do not assume a new platform-model binding can be staged with `enabled: false`; the current upsert path enables it. Isolate tests with the platform status or a dedicated non-production deployment. + +## Platform Configuration + +Gateway directly recognizes these universal settings in the platform `config` object: + +- `specType` +- `submitPath` +- `getTaskURL` +- `pollIntervalMs` +- `pollTimeoutMs` or `timeoutMs` +- `skipParamNormalization` +- `customPreprocessScript` +- `customGetParamsScript` +- `customSubmitScript` +- `customPollScript` + +Do not invent additional enforcement fields. Other config keys are only exposed as data through `context.env` unless a custom script explicitly reads and enforces them. + +```json +{ + "specType": "universal", + "submitPath": "/video/generations", + "getTaskURL": "https://provider.example/tasks/{upstream_task_id}", + "pollIntervalMs": 2000, + "pollTimeoutMs": 600000, + "skipParamNormalization": false, + "customGetParamsScript": { + "video_generate": "function getGenerateParams(params, context) { return { model: context.options.providerModelName, prompt: params.prompt }; }" + }, + "customSubmitScript": { + "video_generate": "async function submitTask(payload, context) { const response = await got.post(context.createRequestURL('/video/generations'), { json: payload, headers: { Authorization: 'Bearer ' + context.authValues.apiKey } }).json(); const taskId = String(response.id || ''); if (!/^[A-Za-z0-9._:-]{1,200}$/.test(taskId)) return { status: 'failed', code: 'invalid_response', message: 'invalid upstream task id' }; return { status: 'submitted', task_id: taskId }; }" + }, + "customPollScript": { + "video_generate": "async function pollTask(taskId, context) { if (!/^[A-Za-z0-9._:-]{1,200}$/.test(taskId)) return { status: 'failed', code: 'invalid_response', message: 'invalid upstream task id' }; const response = await got.get(context.resolveGetTaskURL(taskId), { headers: { Authorization: 'Bearer ' + context.authValues.apiKey } }).json(); if (response.status === 'done') { const resultUrl = String(response.url || ''); if (!/^https:\\/\\/cdn\\.video\\.example(?:\\/|$)/.test(resultUrl)) return { status: 'failed', code: 'invalid_response', message: 'untrusted result URL' }; return { status: 'succeeded', data: [{ url: resultUrl }] }; } if (response.status === 'failed') return { status: 'failed', code: 'provider_failed', message: String(response.message || 'provider task failed').slice(0, 300) }; if (response.status === 'queued' || response.status === 'processing' || response.status === 'running') return { status: 'processing' }; return { status: 'failed', code: 'invalid_response', message: 'unknown upstream status' }; }" + } +} +``` + +Script values may be a string applied to all model types or an object keyed by model type with optional `common`. + +## Script Contracts + +- Preprocess: `(params, type, context)`; return an object merged into normalized parameters. Preferred names include `preprocessParams`, `preprocess`, `main`, and `handler`. +- Get params: `(params, context)`; return the upstream payload object. Preferred names include `getGenerateParams`, `getParams`, `main`, and `handler`. +- Submit: `(payload, context)`; return final success data or an asynchronous task ID. Preferred names include `submitTask`, `submitParams`, `submit`, `main`, and `handler`. +- Poll: `(upstreamTaskID, context)`; return processing, success, or failure state. Preferred names include `pollTask`, `poll`, `main`, and `handler`. + +The context includes `baseURL`, `getTaskURL`, `authValues`, `headers`, `payload`, `type`, `options`, `env`, `candidate`, `createRequestURL`, and `resolveGetTaskURL`. The runtime also exposes `fetch`, `got`, and `FormData`. + +If a result URL must be restricted, implement the HTTPS and host allowlist check inside the submit or poll script before returning `data`. Merely adding an `allowedResultUrlPrefixes`-style config field has no built-in effect. Do not convert HTTP failures into `processing` unless the upstream documentation proves that retrying the poll is safe. + +Do not assume Node.js or browser globals exist. The runtime does not install `URL`, `Buffer`, `process`, `require`, or Node modules. Use the provided context helpers, `fetch`, `got`, `FormData`, and standard ECMAScript string or regular-expression checks. + +## Result Shapes + +Synchronous success: + +```json +{"status":"succeeded","data":[{"url":"https://provider.example/result.png"}]} +``` + +Asynchronous submit: + +```json +{"status":"submitted","task_id":"remote-task-id"} +``` + +Poll success or failure: + +```json +{"status":"succeeded","data":[{"url":"https://provider.example/result.mp4"}]} +``` + +```json +{"status":"failed","code":"provider_failed","message":"upstream rejected request"} +``` + +Never log, return, or retain `authValues`, full raw responses, base64 media, Buffers, HTTP response objects, or entire request snapshots. Return only the fields required for routing and normalized output. diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/skill.json b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/skill.json new file mode 100644 index 0000000..4cfced4 --- /dev/null +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/skill.json @@ -0,0 +1,7 @@ +{ + "name": "ai-gateway-ops-management", + "version": "1.0.1", + "modules": [ + "model-runtime" + ] +} diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index 3e50a58..4d37917 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -4,6 +4,7 @@ import { GatewayApiError, gatewayErrorMessage, getCurrentUser, + getOpsManagementSkillMetadata, OIDC_BROWSER_SESSION_CREDENTIAL, } from './api'; @@ -65,3 +66,33 @@ describe('OIDC browser session transport', () => { expect(new Headers(init.headers).has('Authorization')).toBe(false); }); }); + +describe('Public Agent resources', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('loads operations skill metadata without authorization', async () => { + const metadata = { + name: 'ai-gateway-ops-management', + version: '1.0.1', + displayName: 'AI Gateway 运维管理', + modules: ['model-runtime'], + fileName: 'ai-gateway-ops-management-v1.0.1.zip', + downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download', + apiDocsJsonPath: '/api-docs-json', + apiDocsYamlPath: '/api-docs-yaml', + }; + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(metadata), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + + await expect(getOpsManagementSkillMetadata()).resolves.toEqual(metadata); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/v1/public/skills/ai-gateway-ops-management/metadata'); + expect(new Headers(init.headers).has('Authorization')).toBe(false); + }); +}); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index a548d99..eace7f8 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -24,6 +24,7 @@ import type { GatewayTenantUpsertRequest, GatewayNetworkProxyConfig, GatewayPricingEstimate, + GatewaySkillBundleMetadata, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayUser, @@ -90,6 +91,10 @@ export async function getHealth(): Promise { return request('/healthz', { auth: false }); } +export async function getOpsManagementSkillMetadata(): Promise { + return request('/api/v1/public/skills/ai-gateway-ops-management/metadata', { auth: false }); +} + export async function registerLocalAccount(input: { username: string; email?: string; diff --git a/apps/web/src/pages/ApiDocsPage.tsx b/apps/web/src/pages/ApiDocsPage.tsx index 73bc999..c65cfde 100644 --- a/apps/web/src/pages/ApiDocsPage.tsx +++ b/apps/web/src/pages/ApiDocsPage.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, type FormEvent } from 'react'; -import type { GatewayApiKey, GatewayTask } from '@easyai-ai-gateway/contracts'; -import { BookOpen, KeyRound, Play, Search, Send } from 'lucide-react'; +import { useEffect, useMemo, useState, type FormEvent } from 'react'; +import type { GatewayApiKey, GatewaySkillBundleMetadata, GatewayTask } from '@easyai-ai-gateway/contracts'; +import { BookOpen, Download, ExternalLink, FileJson, KeyRound, Play, Search, Send, Wrench } from 'lucide-react'; import { Badge, Button, Select, Textarea } from '../components/ui'; +import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api'; import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types'; import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared'; @@ -35,6 +36,17 @@ const taskKindOptions = [ ['images.edits', '图像编辑'], ] as const; +const defaultOpsSkillMetadata: GatewaySkillBundleMetadata = { + name: 'ai-gateway-ops-management', + version: '', + displayName: 'AI Gateway 运维管理', + modules: ['model-runtime'], + fileName: 'ai-gateway-ops-management.zip', + downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download', + apiDocsJsonPath: '/api-docs-json', + apiDocsYamlPath: '/api-docs-yaml', +}; + export function ApiDocsPage(props: { activeDocSection: ApiDocSection; apiKeySecretsById: Record; @@ -53,6 +65,7 @@ export function ApiDocsPage(props: { onTaskFormChange: (value: TaskForm) => void; }) { const current = docs.find((item) => item.key === props.activeDocSection) ?? docs[0]; + const [opsSkillMetadata, setOpsSkillMetadata] = useState(defaultOpsSkillMetadata); const isFileDoc = current.key === 'files'; const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById); const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId); @@ -64,6 +77,20 @@ export function ApiDocsPage(props: { } }, [current.kind, props.taskForm.kind]); + useEffect(() => { + let active = true; + getOpsManagementSkillMetadata() + .then((metadata) => { + if (active) setOpsSkillMetadata(metadata); + }) + .catch(() => { + // Keep stable public fallback paths visible when metadata is temporarily unavailable. + }); + return () => { + active = false; + }; + }, []); + function handleSubmit(event: FormEvent) { if (!props.canRun) { event.preventDefault(); @@ -123,6 +150,8 @@ export function ApiDocsPage(props: {

{current.lead}

+ +

Header 参数

@@ -215,6 +244,64 @@ export function ApiDocsPage(props: { ); } +function OpsManagementSkillCard(props: { metadata: GatewaySkillBundleMetadata }) { + const modules = props.metadata.modules.map(skillModuleLabel).join('、'); + return ( +
+
+ +
+
+
+
+

Agent 运维资源

+

{props.metadata.displayName}

+
+ {props.metadata.version ? `v${props.metadata.version}` : '最新版本'} +
+

用于让 Agent 安全操作厂商、基础模型、定价、运行策略、平台、平台模型和 universal 自定义平台。

+
+ 当前模块 + {modules || '模型运行时'} +
+ +
+
+ ); +} + +function skillModuleLabel(module: string) { + if (module === 'model-runtime') return '模型运行时'; + return module; +} + function DocsGroup(props: { items: Array<{ active?: boolean; method?: string; onClick?: () => void; title: string }>; title: string; diff --git a/apps/web/src/styles/api-docs.css b/apps/web/src/styles/api-docs.css index e2d047c..e20ddfb 100644 --- a/apps/web/src/styles/api-docs.css +++ b/apps/web/src/styles/api-docs.css @@ -120,6 +120,83 @@ line-height: 1.7; } +.agentResourceCard { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 16px; + margin: 0 0 24px; + padding: 18px; + border: 1px solid #bfdbfe; + border-radius: 12px; + background: linear-gradient(135deg, #eff6ff 0%, #ffffff 70%); +} + +.agentResourceIcon { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border-radius: 10px; + background: #dbeafe; + color: #1d4ed8; +} + +.agentResourceContent { + display: grid; + gap: 12px; + min-width: 0; +} + +.agentResourceContent p { + margin: 0; + color: var(--text-soft); + line-height: 1.6; +} + +.agentResourceTitle, +.agentResourceActions, +.agentResourceModules { + display: flex; + align-items: center; + gap: 10px; +} + +.agentResourceTitle { + justify-content: space-between; +} + +.agentResourceTitle h2 { + margin: 2px 0 0; + font-size: 1.0625rem; +} + +.agentResourceTitle .eyebrow { + color: #2563eb; +} + +.agentResourceModules { + color: var(--text-soft); + font-size: 0.8125rem; +} + +.agentResourceModules strong { + color: var(--text-strong); +} + +.agentResourceActions { + flex-wrap: wrap; +} + +.agentResourceMetadataLink { + color: #2563eb; + font-size: 0.8125rem; + text-decoration: none; +} + +.agentResourceMetadataLink:hover { + text-decoration: underline; +} + .paramCard { margin-top: 18px; overflow: hidden; @@ -201,4 +278,12 @@ .paramRow { grid-template-columns: 1fr; } + + .agentResourceCard { + grid-template-columns: 1fr; + } + + .agentResourceTitle { + align-items: flex-start; + } } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e4677e0..9fb5d40 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -303,6 +303,17 @@ export interface GatewayRunnerPolicyUpsertRequest { status?: 'active' | 'disabled' | string; } +export interface GatewaySkillBundleMetadata { + name: string; + version: string; + displayName: string; + modules: string[]; + fileName: string; + downloadPath: string; + apiDocsJsonPath: string; + apiDocsYamlPath: string; +} + export interface GatewayUser { id: string; userKey: string; From af13a0444e39bb30daf452df9bdcd68c76bdffd5 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:38:49 +0800 Subject: [PATCH 02/20] =?UTF-8?q?fix(build):=20=E4=BF=AE=E5=A4=8D=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=BE=9D=E8=B5=96=E5=AE=89=E5=85=A8=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步缺失的 Vitest 锁文件,并将 Vitest、Vite 及受影响的传递依赖限制到已修复版本,避免严格 frozen install 和高危依赖审计阻塞 CI。\n\n验证:pnpm frozen install、lint、26 个前端测试、Go 测试、生产构建及 high 级依赖审计通过。 --- apps/web/package.json | 2 +- package.json | 3 +- pnpm-lock.yaml | 1670 +++++++++++++++++++++-------------------- pnpm-workspace.yaml | 14 + 4 files changed, 855 insertions(+), 834 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 6b0937a..6b7143f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,6 +44,6 @@ "@types/react-dom": "^19.0.0", "tailwindcss": "^4.3.0", "typescript": "^5.8.0", - "vitest": "3.2.4" + "vitest": "3.2.6" } } diff --git a/package.json b/package.json index 48941e1..e4ac4da 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dotenv-cli": "^11.0.0", "nx": "^21.0.0", "typescript": "^5.8.0", - "vite": "^7.0.0" + "vite": "^7.3.5", + "vitest": "3.2.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f6eca5..0918eae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,16 +4,29 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@babel/core@<=7.29.0': 7.29.6 + dompurify@<=3.4.10: 3.4.11 + esbuild@>=0.27.3 <0.28.1: 0.28.1 + form-data@>=4.0.0 <4.0.6: 4.0.6 + js-yaml@<3.15.0: 4.1.1 + mermaid@>=11.0.0-alpha.1 <=11.14.0: 11.15.0 + minimatch@>=9.0.0 <9.0.7: 9.0.7 + '@nx/js>picomatch': 4.0.5 + '@nx/vite>picomatch': 4.0.5 + '@nx/workspace>picomatch': 4.0.5 + tmp@<0.2.7: 0.2.7 + importers: .: devDependencies: '@nx/vite': specifier: ^21.0.0 - version: 21.6.11(@babel/traverse@7.29.0)(nx@21.6.11)(typescript@5.9.3)(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))(vitest@3.2.4(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 21.6.11(@babel/traverse@7.29.0)(nx@21.6.11)(typescript@5.9.3)(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))(vitest@3.2.6(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@vitejs/plugin-react': specifier: ^5.0.0 - version: 5.2.0(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 5.2.0(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) dotenv-cli: specifier: ^11.0.0 version: 11.0.0 @@ -24,8 +37,11 @@ importers: specifier: ^5.8.0 version: 5.9.3 vite: - specifier: ^7.0.0 - version: 7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + specifier: ^7.3.5 + version: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vitest: + specifier: 3.2.6 + version: 3.2.6(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) apps/web: dependencies: @@ -67,7 +83,7 @@ importers: version: 1.0.2(react@19.2.6) '@vitejs/plugin-react': specifier: ^5.0.0 - version: 5.2.0(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 5.2.0(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) antd: specifier: ^5.29.3 version: 5.29.3(date-fns@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -106,11 +122,11 @@ importers: version: 3.5.0 vite: specifier: ^7.0.0 - version: 7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + version: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.0(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.3.0(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -123,6 +139,9 @@ importers: typescript: specifier: ^5.8.0 version: 5.9.3 + vitest: + specifier: 3.2.6 + version: 3.2.6(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) packages/contracts: devDependencies: @@ -248,18 +267,26 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.3': resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/core@7.29.6': + resolution: {integrity: sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -272,18 +299,18 @@ packages: resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/helper-create-regexp-features-plugin@7.28.5': resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/helper-define-polyfill-provider@0.6.8': resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': 7.29.6 '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} @@ -301,7 +328,7 @@ packages: resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/helper-optimise-call-expression@7.27.1': resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} @@ -315,13 +342,13 @@ packages: resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/helper-replace-supers@7.28.6': resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/helper-skip-transparent-expression-wrappers@7.27.1': resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} @@ -331,10 +358,18 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -343,8 +378,8 @@ packages: resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.3': @@ -352,436 +387,441 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1': resolution: {integrity: sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1': resolution: {integrity: sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3': resolution: {integrity: sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.13.0 + '@babel/core': 7.29.6 '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6': resolution: {integrity: sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/plugin-proposal-decorators@7.29.0': resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-syntax-decorators@7.28.6': resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-syntax-import-assertions@7.28.6': resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-syntax-import-attributes@7.28.6': resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-syntax-typescript@7.28.6': resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-syntax-unicode-sets-regex@7.18.6': resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/plugin-transform-arrow-functions@7.27.1': resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-async-generator-functions@7.29.0': resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-async-to-generator@7.28.6': resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-block-scoped-functions@7.27.1': resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-block-scoping@7.28.6': resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-class-properties@7.28.6': resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-class-static-block@7.28.6': resolution: {integrity: sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.12.0 + '@babel/core': 7.29.6 '@babel/plugin-transform-classes@7.28.6': resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-computed-properties@7.28.6': resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-destructuring@7.28.5': resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-dotall-regex@7.28.6': resolution: {integrity: sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-duplicate-keys@7.27.1': resolution: {integrity: sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0': resolution: {integrity: sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/plugin-transform-dynamic-import@7.27.1': resolution: {integrity: sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-explicit-resource-management@7.28.6': resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-exponentiation-operator@7.28.6': resolution: {integrity: sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-export-namespace-from@7.27.1': resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-for-of@7.27.1': resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-function-name@7.27.1': resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-json-strings@7.28.6': resolution: {integrity: sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-literals@7.27.1': resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-logical-assignment-operators@7.28.6': resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-member-expression-literals@7.27.1': resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-modules-amd@7.27.1': resolution: {integrity: sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-modules-commonjs@7.28.6': resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-modules-systemjs@7.29.4': resolution: {integrity: sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-modules-umd@7.27.1': resolution: {integrity: sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/plugin-transform-new-target@7.27.1': resolution: {integrity: sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-numeric-separator@7.28.6': resolution: {integrity: sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-object-rest-spread@7.28.6': resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-object-super@7.27.1': resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-optional-catch-binding@7.28.6': resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-optional-chaining@7.28.6': resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-parameters@7.27.7': resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-private-methods@7.28.6': resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-private-property-in-object@7.28.6': resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-property-literals@7.27.1': resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-react-jsx-source@7.27.1': resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-regenerator@7.29.0': resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-regexp-modifiers@7.28.6': resolution: {integrity: sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/plugin-transform-reserved-words@7.27.1': resolution: {integrity: sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-runtime@7.29.0': resolution: {integrity: sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-shorthand-properties@7.27.1': resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-spread@7.28.6': resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-sticky-regex@7.27.1': resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-template-literals@7.27.1': resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-typeof-symbol@7.27.1': resolution: {integrity: sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-typescript@7.28.6': resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-unicode-escapes@7.27.1': resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-unicode-property-regex@7.28.6': resolution: {integrity: sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-unicode-regex@7.27.1': resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/plugin-transform-unicode-sets-regex@7.28.6': resolution: {integrity: sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 '@babel/preset-env@7.29.5': resolution: {integrity: sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/preset-modules@0.1.6-no-external-plugins': resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} peerDependencies: - '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + '@babel/core': 7.29.6 '@babel/preset-typescript@7.28.5': resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} @@ -791,6 +831,10 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} @@ -799,23 +843,15 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} - '@chevrotain/cst-dts-gen@12.0.0': - resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} - - '@chevrotain/gast@12.0.0': - resolution: {integrity: sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==} - - '@chevrotain/regexp-to-ast@12.0.0': - resolution: {integrity: sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==} - - '@chevrotain/types@12.0.0': - resolution: {integrity: sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==} - - '@chevrotain/utils@12.0.0': - resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -835,158 +871,158 @@ packages: '@emotion/unitless@0.7.5': resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1040,8 +1076,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mermaid-js/parser@1.1.0': - resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} '@napi-rs/wasm-runtime@0.2.4': resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} @@ -1928,128 +1964,128 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rollup/rollup-android-arm-eabi@4.60.3': - resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.3': - resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.3': - resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.3': - resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.3': - resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.3': - resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.3': - resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.60.3': - resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.60.3': - resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.60.3': - resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.60.3': - resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.60.3': - resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.60.3': - resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.60.3': - resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.60.3': - resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.60.3': - resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.60.3': - resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.60.3': - resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.60.3': - resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.60.3': - resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.3': - resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.3': - resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.3': - resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.3': - resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.3': - resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} cpu: [x64] os: [win32] @@ -2310,9 +2346,6 @@ packages: '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -2363,11 +2396,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} peerDependencies: msw: ^2.4.9 vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 @@ -2377,20 +2410,23 @@ packages: vite: optional: true - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} + + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} @@ -2432,9 +2468,6 @@ packages: react: '>=16.9.0' react-dom: '>=16.9.0' - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2464,7 +2497,7 @@ packages: babel-plugin-const-enum@1.2.0: resolution: {integrity: sha512-o1m/6iyyFnp9MRsK1dHF3bneqyf3AlM2q3A/YbgQr2pCat6B6XJVDv2TXqzfY2RYUi4mak6WAksSBPlyYGx9dg==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} @@ -2473,27 +2506,27 @@ packages: babel-plugin-polyfill-corejs2@0.4.17: resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': 7.29.6 babel-plugin-polyfill-corejs3@0.13.0: resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': 7.29.6 babel-plugin-polyfill-corejs3@0.14.2: resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': 7.29.6 babel-plugin-polyfill-regenerator@0.6.8: resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': 7.29.6 babel-plugin-transform-typescript-metadata@0.3.2: resolution: {integrity: sha512-mWEvCQTgXQf48yDqgN7CH50waTyYBeP2Lpqx4nNWab9sxEpdXVeKgfj1qYI2/TgUPQtNFZ85i3PemRtnXVYYJg==} peerDependencies: - '@babel/core': ^7 + '@babel/core': 7.29.6 '@babel/traverse': ^7 peerDependenciesMeta: '@babel/traverse': @@ -2505,6 +2538,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2519,6 +2556,10 @@ packages: brace-expansion@2.1.0: resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2572,15 +2613,6 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} - chevrotain-allstar@0.4.3: - resolution: {integrity: sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==} - peerDependencies: - chevrotain: ^12.0.0 - - chevrotain@12.0.0: - resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} - engines: {node: '>=22.0.0'} - class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2873,8 +2905,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - dompurify@3.4.2: - resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} dotenv-cli@11.0.0: resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==} @@ -2948,8 +2980,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -2965,11 +3000,6 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -3030,8 +3060,8 @@ packages: debug: optional: true - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} front-matter@4.0.2: @@ -3098,6 +3128,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} @@ -3247,8 +3281,8 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsesc@3.1.0: @@ -3280,10 +3314,6 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - langium@4.2.3: - resolution: {integrity: sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==} - engines: {node: '>=20.10.0', npm: '>=10.2.3'} - layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -3462,8 +3492,8 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - mermaid@11.14.0: - resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} + mermaid@11.15.0: + resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -3597,8 +3627,8 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} - minimatch@9.0.3: - resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + minimatch@9.0.7: + resolution: {integrity: sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==} engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: @@ -3705,22 +3735,22 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} - engines: {node: '>=12'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} pretty-format@30.4.1: @@ -4152,8 +4182,8 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} - rollup@4.60.3: - resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -4218,9 +4248,6 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4306,6 +4333,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4318,8 +4349,8 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - tmp@0.2.5: - resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} toggle-selection@1.0.6: @@ -4479,8 +4510,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@7.3.3: - resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4519,16 +4550,16 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -4547,26 +4578,6 @@ packages: jsdom: optional: true - vscode-jsonrpc@8.2.0: - resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} - engines: {node: '>=14.0.0'} - - vscode-languageserver-protocol@3.17.5: - resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} - - vscode-languageserver-textdocument@1.0.12: - resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} - - vscode-languageserver-types@3.17.5: - resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} - - vscode-languageserver@9.0.1: - resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} - hasBin: true - - vscode-uri@3.1.0: - resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} - wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -4770,15 +4781,21 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.3': {} - '@babel/core@7.29.0': + '@babel/core@7.29.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 + '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) + '@babel/helpers': 7.29.7 '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 @@ -4800,6 +4817,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.29.0 @@ -4812,29 +4837,29 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/traverse': 7.29.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.0)': + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 debug: 4.4.3 @@ -4859,9 +4884,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 @@ -4874,18 +4899,18 @@ snapshots: '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 '@babel/traverse': 7.29.0 @@ -4901,8 +4926,12 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helper-wrap-function@7.28.6': @@ -4913,560 +4942,564 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helpers@7.29.2': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': + '@babel/parser@7.29.7': dependencies: - '@babel/core': 7.29.0 + '@babel/types': 7.29.7 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.6)': + dependencies: + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3(@babel/core@7.29.0)': + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.6) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.6) transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 - '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.6) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.6) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-globals': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/template': 7.28.6 - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.6) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.6) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.6) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.6) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.6) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.6) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 - '@babel/preset-env@7.29.5(@babel/core@7.29.0)': + '@babel/preset-env@7.29.5(@babel/core@7.29.6)': dependencies: '@babel/compat-data': 7.29.3 - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.3(@babel/core@7.29.0) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) - '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) - '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.0) - '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) - '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) - babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.0) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.6) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.3(@babel/core@7.29.6) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.6) + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.6) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.6) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6) + '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.6) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.6) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.6) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.6) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.6) + '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.6) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.6) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.6) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.6) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.6) core-js-compat: 3.49.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/types': 7.29.0 esutils: 2.0.3 - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.6) transitivePeerDependencies: - supports-color @@ -5478,6 +5511,12 @@ snapshots: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -5495,22 +5534,14 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@braintree/sanitize-url@7.1.2': {} - '@chevrotain/cst-dts-gen@12.0.0': - dependencies: - '@chevrotain/gast': 12.0.0 - '@chevrotain/types': 12.0.0 - - '@chevrotain/gast@12.0.0': - dependencies: - '@chevrotain/types': 12.0.0 - - '@chevrotain/regexp-to-ast@12.0.0': {} - - '@chevrotain/types@12.0.0': {} - - '@chevrotain/utils@12.0.0': {} + '@chevrotain/types@11.1.2': {} '@date-fns/tz@1.4.1': {} @@ -5531,82 +5562,82 @@ snapshots: '@emotion/unitless@0.7.5': {} - '@esbuild/aix-ppc64@0.27.7': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.7': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.27.7': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.27.7': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.7': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.7': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.7': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.7': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.7': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.27.7': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.27.7': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.27.7': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.27.7': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.27.7': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.27.7': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.27.7': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.27.7': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.7': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.27.7': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.7': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.27.7': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.7': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.27.7': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.27.7': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.27.7': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.27.7': + '@esbuild/win32-x64@0.28.1': optional: true '@floating-ui/core@1.7.5': @@ -5661,9 +5692,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mermaid-js/parser@1.1.0': + '@mermaid-js/parser@1.2.0': dependencies: - langium: 4.2.3 + '@chevrotain/types': 11.1.2 '@napi-rs/wasm-runtime@0.2.4': dependencies: @@ -5676,7 +5707,7 @@ snapshots: ejs: 3.1.10 enquirer: 2.3.6 ignore: 5.3.2 - minimatch: 9.0.3 + minimatch: 9.0.7 nx: 21.6.11 semver: 7.8.0 tslib: 2.8.1 @@ -5684,19 +5715,19 @@ snapshots: '@nx/js@21.6.11(@babel/traverse@7.29.0)(nx@21.6.11)': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) - '@babel/preset-env': 7.29.5(@babel/core@7.29.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.6) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.6) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.6) + '@babel/preset-env': 7.29.5(@babel/core@7.29.6) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.6) '@babel/runtime': 7.29.2 '@nx/devkit': 21.6.11(nx@21.6.11) '@nx/workspace': 21.6.11 '@zkochan/js-yaml': 0.0.7 - babel-plugin-const-enum: 1.2.0(@babel/core@7.29.0) + babel-plugin-const-enum: 1.2.0(@babel/core@7.29.6) babel-plugin-macros: 3.1.0 - babel-plugin-transform-typescript-metadata: 0.3.2(@babel/core@7.29.0)(@babel/traverse@7.29.0) + babel-plugin-transform-typescript-metadata: 0.3.2(@babel/core@7.29.6)(@babel/traverse@7.29.0) chalk: 4.1.2 columnify: 1.6.0 detect-port: 1.6.1 @@ -5708,7 +5739,7 @@ snapshots: npm-run-path: 4.0.1 ora: 5.3.0 picocolors: 1.1.1 - picomatch: 4.0.2 + picomatch: 4.0.5 semver: 7.8.0 source-map-support: 0.5.19 tinyglobby: 0.2.16 @@ -5751,19 +5782,19 @@ snapshots: '@nx/nx-win32-x64-msvc@21.6.11': optional: true - '@nx/vite@21.6.11(@babel/traverse@7.29.0)(nx@21.6.11)(typescript@5.9.3)(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))(vitest@3.2.4(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@nx/vite@21.6.11(@babel/traverse@7.29.0)(nx@21.6.11)(typescript@5.9.3)(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))(vitest@3.2.6(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@nx/devkit': 21.6.11(nx@21.6.11) '@nx/js': 21.6.11(@babel/traverse@7.29.0)(nx@21.6.11) '@phenomnomnominal/tsquery': 5.0.1(typescript@5.9.3) ajv: 8.20.0 enquirer: 2.3.6 - picomatch: 4.0.2 + picomatch: 4.0.5 semver: 7.8.0 tsconfig-paths: 4.2.0 tslib: 2.8.1 - vite: 7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) - vitest: 3.2.4(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vitest: 3.2.6(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -5781,7 +5812,7 @@ snapshots: chalk: 4.1.2 enquirer: 2.3.6 nx: 21.6.11 - picomatch: 4.0.2 + picomatch: 4.0.5 semver: 7.8.0 tslib: 2.8.1 yargs-parser: 21.1.1 @@ -6651,79 +6682,79 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.60.3': + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true - '@rollup/rollup-android-arm64@4.60.3': + '@rollup/rollup-android-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-arm64@4.60.3': + '@rollup/rollup-darwin-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-x64@4.60.3': + '@rollup/rollup-darwin-x64@4.62.2': optional: true - '@rollup/rollup-freebsd-arm64@4.60.3': + '@rollup/rollup-freebsd-arm64@4.62.2': optional: true - '@rollup/rollup-freebsd-x64@4.60.3': + '@rollup/rollup-freebsd-x64@4.62.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.3': + '@rollup/rollup-linux-arm-musleabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.3': + '@rollup/rollup-linux-arm64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.3': + '@rollup/rollup-linux-arm64-musl@4.62.2': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.3': + '@rollup/rollup-linux-loong64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.3': + '@rollup/rollup-linux-loong64-musl@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.3': + '@rollup/rollup-linux-ppc64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.3': + '@rollup/rollup-linux-ppc64-musl@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.3': + '@rollup/rollup-linux-riscv64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.3': + '@rollup/rollup-linux-riscv64-musl@4.62.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.3': + '@rollup/rollup-linux-s390x-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.3': + '@rollup/rollup-linux-x64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-musl@4.60.3': + '@rollup/rollup-linux-x64-musl@4.62.2': optional: true - '@rollup/rollup-openbsd-x64@4.60.3': + '@rollup/rollup-openbsd-x64@4.62.2': optional: true - '@rollup/rollup-openharmony-arm64@4.60.3': + '@rollup/rollup-openharmony-arm64@4.62.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.3': + '@rollup/rollup-win32-arm64-msvc@4.62.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.3': + '@rollup/rollup-win32-ia32-msvc@4.62.2': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.3': + '@rollup/rollup-win32-x64-gnu@4.62.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.3': + '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true '@shikijs/core@3.23.0': @@ -6791,7 +6822,7 @@ snapshots: '@streamdown/mermaid@1.0.2(react@19.2.6)': dependencies: - mermaid: 11.14.0 + mermaid: 11.15.0 react: 19.2.6 '@tailwindcss/node@4.3.0': @@ -6855,12 +6886,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@tailwindcss/vite@4.3.0(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) '@tybys/wasm-util@0.9.0': dependencies: @@ -7019,8 +7050,6 @@ snapshots: dependencies: '@types/estree': 1.0.9 - '@types/estree@1.0.8': {} - '@types/estree@1.0.9': {} '@types/geojson@7946.0.16': {} @@ -7061,57 +7090,61 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@5.2.0(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitejs/plugin-react@5.2.0(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.6) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.6) '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color - '@vitest/expect@3.2.4': + '@vitest/expect@3.2.6': dependencies: '@types/chai': 5.2.3 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitest/mocker@3.2.6(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@3.2.6': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.2.4': + '@vitest/pretty-format@3.2.7': dependencies: - '@vitest/utils': 3.2.4 + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.6': + dependencies: + '@vitest/utils': 3.2.6 pathe: 2.0.3 strip-literal: 3.1.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@3.2.6': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 3.2.6 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.2.4': + '@vitest/spy@3.2.6': dependencies: tinyspy: 4.0.4 - '@vitest/utils@3.2.4': + '@vitest/utils@3.2.6': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 3.2.6 loupe: 3.2.1 tinyrainbow: 2.0.0 @@ -7119,7 +7152,7 @@ snapshots: '@yarnpkg/parsers@3.0.2': dependencies: - js-yaml: 3.14.2 + js-yaml: 4.1.1 tslib: 2.8.1 '@zkochan/js-yaml@0.0.7': @@ -7203,10 +7236,6 @@ snapshots: - luxon - moment - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} aria-hidden@1.2.6: @@ -7232,16 +7261,16 @@ snapshots: axios@1.16.0: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 + form-data: 4.0.6 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug - babel-plugin-const-enum@1.2.0(@babel/core@7.29.0): + babel-plugin-const-enum@1.2.0(@babel/core@7.29.6): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.6) '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -7252,41 +7281,41 @@ snapshots: cosmiconfig: 7.1.0 resolve: 1.22.12 - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.6): dependencies: '@babel/compat-data': 7.29.3 - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.6) semver: 6.3.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.6): dependencies: - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.6) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.0): + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.6): dependencies: - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.6) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.0): + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.6): dependencies: - '@babel/core': 7.29.0 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.6) transitivePeerDependencies: - supports-color - babel-plugin-transform-typescript-metadata@0.3.2(@babel/core@7.29.0)(@babel/traverse@7.29.0): + babel-plugin-transform-typescript-metadata@0.3.2(@babel/core@7.29.6)(@babel/traverse@7.29.0): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 optionalDependencies: '@babel/traverse': 7.29.0 @@ -7295,6 +7324,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.28: {} @@ -7309,6 +7340,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.28 @@ -7360,19 +7395,6 @@ snapshots: check-error@2.1.3: {} - chevrotain-allstar@0.4.3(chevrotain@12.0.0): - dependencies: - chevrotain: 12.0.0 - lodash-es: 4.18.1 - - chevrotain@12.0.0: - dependencies: - '@chevrotain/cst-dts-gen': 12.0.0 - '@chevrotain/gast': 12.0.0 - '@chevrotain/regexp-to-ast': 12.0.0 - '@chevrotain/types': 12.0.0 - '@chevrotain/utils': 12.0.0 - class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -7679,7 +7701,7 @@ snapshots: dependencies: dequal: 2.0.3 - dompurify@3.4.2: + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -7750,36 +7772,38 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 - esbuild@0.27.7: + es-toolkit@1.49.0: {} + + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -7787,8 +7811,6 @@ snapshots: escape-string-regexp@5.0.0: {} - esprima@4.0.1: {} - esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -7815,6 +7837,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -7827,17 +7853,17 @@ snapshots: follow-redirects@1.16.0: {} - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.3 + hasown: 2.0.4 mime-types: 2.1.35 front-matter@4.0.2: dependencies: - js-yaml: 3.14.2 + js-yaml: 4.1.1 fs-constants@1.0.0: {} @@ -7862,7 +7888,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -7890,6 +7916,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-from-dom@5.0.1: dependencies: '@types/hast': 3.0.4 @@ -8099,10 +8129,9 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@3.14.2: + js-yaml@4.1.1: dependencies: - argparse: 1.0.10 - esprima: 4.0.1 + argparse: 2.0.1 jsesc@3.1.0: {} @@ -8124,15 +8153,6 @@ snapshots: khroma@2.1.0: {} - langium@4.2.3: - dependencies: - '@chevrotain/regexp-to-ast': 12.0.0 - chevrotain: 12.0.0 - chevrotain-allstar: 0.4.3(chevrotain@12.0.0) - vscode-languageserver: 9.0.1 - vscode-languageserver-textdocument: 1.0.12 - vscode-uri: 3.1.0 - layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -8390,11 +8410,11 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - mermaid@11.14.0: + mermaid@11.15.0: dependencies: '@braintree/sanitize-url': 7.1.2 '@iconify/utils': 3.1.3 - '@mermaid-js/parser': 1.1.0 + '@mermaid-js/parser': 1.2.0 '@types/d3': 7.4.3 '@upsetjs/venn.js': 2.0.0 cytoscape: 3.33.3 @@ -8404,10 +8424,10 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.20 - dompurify: 3.4.2 + dompurify: 3.4.11 + es-toolkit: 1.49.0 katex: 0.16.45 khroma: 2.1.0 - lodash-es: 4.18.1 marked: 16.4.2 roughjs: 4.6.6 stylis: 4.4.0 @@ -8659,9 +8679,9 @@ snapshots: dependencies: brace-expansion: 2.1.0 - minimatch@9.0.3: + minimatch@9.0.7: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 5.0.7 minimist@1.2.8: {} @@ -8707,7 +8727,7 @@ snapshots: jest-diff: 30.4.1 jsonc-parser: 3.2.0 lines-and-columns: 2.0.3 - minimatch: 9.0.3 + minimatch: 9.0.7 node-machine-id: 1.1.12 npm-run-path: 4.0.1 open: 8.4.2 @@ -8716,7 +8736,7 @@ snapshots: semver: 7.8.0 string-width: 4.2.3 tar-stream: 2.2.0 - tmp: 0.2.5 + tmp: 0.2.7 tree-kill: 1.2.2 tsconfig-paths: 4.2.0 tslib: 2.8.1 @@ -8811,10 +8831,10 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.2: {} - picomatch@4.0.4: {} + picomatch@4.0.5: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -8822,7 +8842,7 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - postcss@8.5.14: + postcss@8.5.19: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 @@ -9430,35 +9450,35 @@ snapshots: robust-predicates@3.0.3: {} - rollup@4.60.3: + rollup@4.62.2: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.3 - '@rollup/rollup-android-arm64': 4.60.3 - '@rollup/rollup-darwin-arm64': 4.60.3 - '@rollup/rollup-darwin-x64': 4.60.3 - '@rollup/rollup-freebsd-arm64': 4.60.3 - '@rollup/rollup-freebsd-x64': 4.60.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 - '@rollup/rollup-linux-arm-musleabihf': 4.60.3 - '@rollup/rollup-linux-arm64-gnu': 4.60.3 - '@rollup/rollup-linux-arm64-musl': 4.60.3 - '@rollup/rollup-linux-loong64-gnu': 4.60.3 - '@rollup/rollup-linux-loong64-musl': 4.60.3 - '@rollup/rollup-linux-ppc64-gnu': 4.60.3 - '@rollup/rollup-linux-ppc64-musl': 4.60.3 - '@rollup/rollup-linux-riscv64-gnu': 4.60.3 - '@rollup/rollup-linux-riscv64-musl': 4.60.3 - '@rollup/rollup-linux-s390x-gnu': 4.60.3 - '@rollup/rollup-linux-x64-gnu': 4.60.3 - '@rollup/rollup-linux-x64-musl': 4.60.3 - '@rollup/rollup-openbsd-x64': 4.60.3 - '@rollup/rollup-openharmony-arm64': 4.60.3 - '@rollup/rollup-win32-arm64-msvc': 4.60.3 - '@rollup/rollup-win32-ia32-msvc': 4.60.3 - '@rollup/rollup-win32-x64-gnu': 4.60.3 - '@rollup/rollup-win32-x64-msvc': 4.60.3 + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 roughjs@4.6.6: @@ -9518,8 +9538,6 @@ snapshots: space-separated-tokens@2.0.2: {} - sprintf-js@1.0.3: {} - stackback@0.0.2: {} std-env@3.10.0: {} @@ -9530,7 +9548,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 marked: 17.0.6 - mermaid: 11.14.0 + mermaid: 11.15.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) rehype-harden: 1.1.8 @@ -9614,8 +9632,13 @@ snapshots: tinyglobby@0.2.16: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@1.1.1: {} @@ -9623,7 +9646,7 @@ snapshots: tinyspy@4.0.4: {} - tmp@0.2.5: {} + tmp@0.2.7: {} toggle-selection@1.0.6: {} @@ -9774,7 +9797,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - '@types/node' - jiti @@ -9789,43 +9812,43 @@ snapshots: - tsx - yaml - vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): + vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: - esbuild: 0.27.7 + esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.14 - rollup: 4.60.3 - tinyglobby: 0.2.16 + postcss: 8.5.19 + rollup: 4.62.2 + tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 yaml: 2.8.4 - vitest@3.2.4(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): + vitest@3.2.6(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 chai: 5.3.3 debug: 4.4.3 expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) vite-node: 3.2.4(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: @@ -9844,23 +9867,6 @@ snapshots: - tsx - yaml - vscode-jsonrpc@8.2.0: {} - - vscode-languageserver-protocol@3.17.5: - dependencies: - vscode-jsonrpc: 8.2.0 - vscode-languageserver-types: 3.17.5 - - vscode-languageserver-textdocument@1.0.12: {} - - vscode-languageserver-types@3.17.5: {} - - vscode-languageserver@9.0.1: - dependencies: - vscode-languageserver-protocol: 3.17.5 - - vscode-uri@3.1.0: {} - wcwidth@1.0.1: dependencies: defaults: 1.0.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68399ab..fea2db6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,20 @@ packages: - apps/web - packages/* + allowBuilds: esbuild: true nx: true + +overrides: + '@babel/core@<=7.29.0': 7.29.6 + 'dompurify@<=3.4.10': 3.4.11 + 'esbuild@>=0.27.3 <0.28.1': 0.28.1 + 'form-data@>=4.0.0 <4.0.6': 4.0.6 + 'js-yaml@<3.15.0': 4.1.1 + 'mermaid@>=11.0.0-alpha.1 <=11.14.0': 11.15.0 + 'minimatch@>=9.0.0 <9.0.7': 9.0.7 + '@nx/js>picomatch': 4.0.5 + '@nx/vite>picomatch': 4.0.5 + '@nx/workspace>picomatch': 4.0.5 + 'tmp@<0.2.7': 0.2.7 From 745811cc6dd330dec2de3ea0e7c21a5c793062b3 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:39:07 +0800 Subject: [PATCH 03/20] =?UTF-8?q?ci:=20=E5=BB=BA=E7=AB=8B=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E9=95=9C=E5=83=8F=E4=B8=8E=20Tag=20=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增独立仓库级 Gitea Runner,main 和 PR 执行完整质量门禁与不可变 SHA 镜像构建;只有来自 main 历史的语义版本 Tag 才调用受限生产发布助手。专属 Buildx builder 将缓存限制为 2 GB。\n\n验证:流水线结构测试、ShellCheck、Go vet/test、govulncheck、前端 lint/test/build、Compose 渲染及高危依赖审计通过。 --- .gitea/workflows/ci.yml | 101 +++++++++++++++++ deploy/ci/act-runner-config.yaml | 35 ++++++ deploy/ci/easyai-gateway-act-runner.service | 31 ++++++ scripts/ci-build-images.sh | 61 +++++++++++ scripts/provision-ci-runner.sh | 113 ++++++++++++++++++++ tests/ci/pipeline-test.sh | 77 +++++++++++++ 6 files changed, 418 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 deploy/ci/act-runner-config.yaml create mode 100644 deploy/ci/easyai-gateway-act-runner.service create mode 100755 scripts/ci-build-images.sh create mode 100755 scripts/provision-ci-runner.sh create mode 100755 tests/ci/pipeline-test.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..394d0ba --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,101 @@ +name: ci + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + +jobs: + verify: + runs-on: easyai-gateway-linux + env: + IMAGE_TAG: ${{ github.sha }} + steps: + - name: Checkout without external Actions + env: + CI_REPOSITORY: ${{ github.repository }} + CI_SERVER_URL: ${{ github.server_url }} + CI_SHA: ${{ github.sha }} + CI_JOB_TOKEN: ${{ github.token }} + run: | + set -eu + test -n "$CI_JOB_TOKEN" + authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n') + git init . + git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \ + fetch --no-tags --depth=1 "$CI_SERVER_URL/$CI_REPOSITORY.git" "$CI_SHA" + unset authorization CI_JOB_TOKEN + git checkout --detach FETCH_HEAD + - name: Verify pinned host toolchains + run: | + go version + node --version + pnpm --version + docker version + docker compose version + docker buildx version + shellcheck --version + trivy --version + govulncheck -version + - name: Verify Production deployment prerequisites + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + run: | + grep -Eq '^NoNewPrivs:[[:space:]]+0$' /proc/self/status + test -x /usr/local/sbin/easyai-ai-gateway-release + sudo -n -l /usr/local/sbin/easyai-ai-gateway-release "$IMAGE_TAG" >/dev/null + - name: Verify release tag ancestry + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + env: + CI_REPOSITORY: ${{ github.repository }} + CI_SERVER_URL: ${{ github.server_url }} + CI_SHA: ${{ github.sha }} + CI_JOB_TOKEN: ${{ github.token }} + run: | + set -eu + tag_name=${GITHUB_REF#refs/tags/} + printf '%s' "$tag_name" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$' + authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n') + git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \ + fetch --no-tags --depth=200 "$CI_SERVER_URL/$CI_REPOSITORY.git" \ + main:refs/remotes/origin/main + unset authorization CI_JOB_TOKEN + test "$(git rev-parse HEAD)" = "$CI_SHA" + git merge-base --is-ancestor "$CI_SHA" refs/remotes/origin/main + - name: Verify Go formatting + run: | + unformatted=$(gofmt -l apps/api) + test -z "$unformatted" || { + printf 'Go files require gofmt:\n%s\n' "$unformatted" >&2 + exit 1 + } + - name: Verify Go code + working-directory: apps/api + run: | + go vet ./... + go test ./... + govulncheck ./... + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm test + - run: pnpm build + - name: Audit JavaScript dependencies + run: pnpm audit --audit-level high + - name: Validate deployment configuration + run: | + docker compose -f docker-compose.yml config --quiet + shellcheck scripts/ci-build-images.sh scripts/provision-ci-runner.sh tests/ci/pipeline-test.sh + ./tests/ci/pipeline-test.sh + - name: Scan repository + run: | + trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL \ + --ignore-unfixed --exit-code 1 --timeout 15m --skip-dirs .git \ + --skip-dirs node_modules . + - run: ./scripts/ci-build-images.sh + - name: Deploy verified release to Production + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + env: + CI_SHA: ${{ github.sha }} + run: | + test "$(git rev-parse HEAD)" = "$CI_SHA" + sudo -n /usr/local/sbin/easyai-ai-gateway-release "$CI_SHA" diff --git a/deploy/ci/act-runner-config.yaml b/deploy/ci/act-runner-config.yaml new file mode 100644 index 0000000..028f7cb --- /dev/null +++ b/deploy/ci/act-runner-config.yaml @@ -0,0 +1,35 @@ +log: + level: info + +runner: + file: /var/lib/easyai-gateway-runner/.runner + capacity: 1 + envs: + GOPROXY: "https://goproxy.cn,direct" + GOSUMDB: "sum.golang.google.cn" + PATH: "/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + AI_GATEWAY_GO_BUILD_IMAGE: "docker.m.daocloud.io/library/golang:1.26.5-alpine" + AI_GATEWAY_API_RUNTIME_IMAGE: "docker.m.daocloud.io/library/alpine:3.22" + AI_GATEWAY_NODE_BUILD_IMAGE: "docker.m.daocloud.io/library/node:24-alpine" + AI_GATEWAY_WEB_RUNTIME_IMAGE: "docker.m.daocloud.io/library/nginx:1.27-alpine" + timeout: 3h + shutdown_timeout: 5m + insecure: false + fetch_timeout: 5s + fetch_interval: 2s + labels: + - "easyai-gateway-linux:host" + +cache: + enabled: true + dir: /var/lib/easyai-gateway-runner/cache + +container: + docker_host: "-" + privileged: false + force_pull: false + force_rebuild: false + valid_volumes: [] + +host: + workdir_parent: /var/lib/easyai-gateway-runner/work diff --git a/deploy/ci/easyai-gateway-act-runner.service b/deploy/ci/easyai-gateway-act-runner.service new file mode 100644 index 0000000..bd207d6 --- /dev/null +++ b/deploy/ci/easyai-gateway-act-runner.service @@ -0,0 +1,31 @@ +[Unit] +Description=EasyAI AI Gateway Gitea Actions runner +After=network-online.target docker.service +Wants=network-online.target docker.service + +[Service] +Type=simple +User=easyai-gateway-runner +Group=easyai-gateway-runner +WorkingDirectory=/var/lib/easyai-gateway-runner +Environment=HOME=/var/lib/easyai-gateway-runner +Environment=PATH=/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# Gitea 1.22 can leave a long-running poll stale after a completed host job. +# Process exactly one job, then let Restart=always create a fresh poller. +ExecStart=/opt/easyai-gateway-ci/bin/gitea-runner --config /etc/easyai-gateway-runner/config.yaml daemon --once +Restart=always +RestartSec=5s +TimeoutStopSec=330s +# Tag jobs invoke one SHA-validating root helper. Docker access already gives +# this dedicated repository runner an equivalent host-root trust boundary. +NoNewPrivileges=false +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ReadWritePaths=/var/lib/easyai-gateway-runner + +[Install] +WantedBy=multi-user.target diff --git a/scripts/ci-build-images.sh b/scripts/ci-build-images.sh new file mode 100755 index 0000000..f34ba28 --- /dev/null +++ b/scripts/ci-build-images.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$root" + +tag=${IMAGE_TAG:-} +registry=${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc} +builder=${AI_GATEWAY_BUILDX_BUILDER:-easyai-gateway-ci} +cache_limit=${AI_GATEWAY_BUILDX_CACHE_LIMIT:-2gb} +platform=${AI_GATEWAY_PLATFORM:-linux/amd64} + +if [[ ! $tag =~ ^[0-9a-f]{40}$ ]]; then + echo 'IMAGE_TAG must be a full lowercase Git SHA' >&2 + exit 1 +fi +if [[ ! $registry =~ ^[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._/-]+)+$ ]]; then + echo 'AI_GATEWAY_IMAGE_REGISTRY has an invalid format' >&2 + exit 1 +fi + +if ! docker buildx inspect "$builder" >/dev/null 2>&1; then + docker buildx create --name "$builder" --driver docker-container --use >/dev/null +fi +docker buildx inspect "$builder" --bootstrap >/dev/null + +cleanup_cache() { + docker buildx prune --builder "$builder" --force --max-used-space "$cache_limit" >/dev/null || true +} +trap cleanup_cache EXIT HUP INT TERM + +api_image="$registry/ai-gateway:$tag" +web_image="$registry/ai-gateway-web:$tag" +common_args=( + --builder "$builder" + --platform "$platform" + --file Dockerfile + --pull + --load +) + +docker buildx build --builder "$builder" \ + "${common_args[@]:2}" \ + --target api \ + --build-arg "GO_BUILD_IMAGE=${AI_GATEWAY_GO_BUILD_IMAGE:-docker.m.daocloud.io/library/golang:1.26.5-alpine}" \ + --build-arg "API_RUNTIME_IMAGE=${AI_GATEWAY_API_RUNTIME_IMAGE:-docker.m.daocloud.io/library/alpine:3.22}" \ + --tag "$api_image" . + +docker buildx build --builder "$builder" \ + "${common_args[@]:2}" \ + --target web \ + --build-arg "NODE_BUILD_IMAGE=${AI_GATEWAY_NODE_BUILD_IMAGE:-docker.m.daocloud.io/library/node:24-alpine}" \ + --build-arg "WEB_RUNTIME_IMAGE=${AI_GATEWAY_WEB_RUNTIME_IMAGE:-docker.m.daocloud.io/library/nginx:1.27-alpine}" \ + --tag "$web_image" . + +for image in "$api_image" "$web_image"; do + trivy image --scanners vuln,secret --severity HIGH,CRITICAL --ignore-unfixed \ + --exit-code 1 --timeout 15m "$image" +done + +printf 'gateway_images=PASS git_sha=%s api=%s web=%s\n' "$tag" "$api_image" "$web_image" diff --git a/scripts/provision-ci-runner.sh b/scripts/provision-ci-runner.sh new file mode 100755 index 0000000..23a92a0 --- /dev/null +++ b/scripts/provision-ci-runner.sh @@ -0,0 +1,113 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +PREFIX=${CI_RUNNER_PREFIX:-/opt/easyai-gateway-ci} +RUNNER_HOME=${CI_RUNNER_HOME:-/var/lib/easyai-gateway-runner} +RUNNER_USER=${CI_RUNNER_USER:-easyai-gateway-runner} +GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL:-https://git.51easyai.com} +RUNNER_NAME=${RUNNER_NAME:-easyai-gateway-linux} +RUNNER_LABEL=${RUNNER_LABEL:-easyai-gateway-linux:host} + +GITEA_RUNNER_VERSION=2.0.0 +GITEA_RUNNER_SHA256=447156b33407ee045409f5552bd4a188a315cdd4085b4b498d8d4a9ad26c9f73 +GO_VERSION=1.26.5 +GO_SHA256=5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053 +NODE_VERSION=24.16.0 +NODE_SHA256=d804845d34eddc21dc1092b519d643ef40b1f58ec5dec5c22b1f4bd8fabde6c9 +TRIVY_VERSION=0.70.0 +TRIVY_SHA256=8b4376d5d6befe5c24d503f10ff136d9e0c49f9127a4279fd110b727929a5aa9 +PNPM_VERSION=10.18.1 + +[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; } +[ "$(uname -s)-$(uname -m)" = "Linux-x86_64" ] || { echo "only Linux x86_64 is supported" >&2; exit 1; } + +if ! command -v make >/dev/null 2>&1 || ! command -v gcc >/dev/null 2>&1 || \ + ! command -v jq >/dev/null 2>&1 || ! command -v shellcheck >/dev/null 2>&1 || \ + ! docker compose version >/dev/null 2>&1 || ! docker buildx version >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl docker-buildx-plugin docker-compose-plugin \ + git jq perl shellcheck unzip util-linux xz-utils +fi + +download_verified() { + url=$1 + output=$2 + checksum=$3 + if [ ! -f "$output" ] || ! printf '%s %s\n' "$checksum" "$output" | sha256sum -c - >/dev/null 2>&1; then + rm -f "$output" + curl -fL --retry 5 --retry-delay 2 "$url" -o "$output" + fi + printf '%s %s\n' "$checksum" "$output" | sha256sum -c - +} + +if ! id "$RUNNER_USER" >/dev/null 2>&1; then + useradd --system --home-dir "$RUNNER_HOME" --create-home --shell /usr/sbin/nologin "$RUNNER_USER" +fi +usermod -aG docker "$RUNNER_USER" + +install -d -m 0755 "$PREFIX/bin" "$PREFIX/downloads" "$PREFIX/toolchains" +install -d -o "$RUNNER_USER" -g "$RUNNER_USER" -m 0700 \ + "$RUNNER_HOME" "$RUNNER_HOME/cache" "$RUNNER_HOME/work" + +download_verified \ + "https://gitea.com/gitea/runner/releases/download/v${GITEA_RUNNER_VERSION}/gitea-runner-${GITEA_RUNNER_VERSION}-linux-amd64" \ + "$PREFIX/downloads/gitea-runner-${GITEA_RUNNER_VERSION}-linux-amd64" \ + "$GITEA_RUNNER_SHA256" +install -m 0755 "$PREFIX/downloads/gitea-runner-${GITEA_RUNNER_VERSION}-linux-amd64" "$PREFIX/bin/gitea-runner" + +download_verified \ + "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \ + "$PREFIX/downloads/go${GO_VERSION}.linux-amd64.tar.gz" \ + "$GO_SHA256" +rm -rf "$PREFIX/toolchains/go" +tar -xzf "$PREFIX/downloads/go${GO_VERSION}.linux-amd64.tar.gz" -C "$PREFIX/toolchains" + +download_verified \ + "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + "$PREFIX/downloads/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + "$NODE_SHA256" +rm -rf "$PREFIX/toolchains/node" +mkdir -p "$PREFIX/toolchains/node" +tar -xJf "$PREFIX/downloads/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + -C "$PREFIX/toolchains/node" --strip-components=1 +"$PREFIX/toolchains/node/bin/npm" install --global --prefix "$PREFIX/toolchains/node" \ + --ignore-scripts "pnpm@${PNPM_VERSION}" + +download_verified \ + "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \ + "$PREFIX/downloads/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \ + "$TRIVY_SHA256" +tar -xzf "$PREFIX/downloads/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" -C "$PREFIX/bin" trivy +chmod 0755 "$PREFIX/bin/trivy" +GOBIN="$PREFIX/bin" GOPROXY="${GO_MODULE_PROXY:-https://goproxy.cn,direct}" \ + "$PREFIX/toolchains/go/bin/go" install golang.org/x/vuln/cmd/govulncheck@v1.6.0 + +install -d -m 0755 /etc/easyai-gateway-runner +install -m 0644 "$ROOT/deploy/ci/act-runner-config.yaml" /etc/easyai-gateway-runner/config.yaml +install -m 0644 "$ROOT/deploy/ci/easyai-gateway-act-runner.service" /etc/systemd/system/easyai-gateway-act-runner.service + +if [ ! -s "$RUNNER_HOME/.runner" ]; then + [ -n "${RUNNER_REGISTRATION_TOKEN:-}" ] || { + echo "RUNNER_REGISTRATION_TOKEN is required for first registration" >&2 + exit 1 + } + runuser -u "$RUNNER_USER" -- env HOME="$RUNNER_HOME" \ + "$PREFIX/bin/gitea-runner" --config /etc/easyai-gateway-runner/config.yaml \ + register --no-interactive \ + --instance "$GITEA_INSTANCE_URL" \ + --token "$RUNNER_REGISTRATION_TOKEN" \ + --name "$RUNNER_NAME" \ + --labels "$RUNNER_LABEL" + chmod 0600 "$RUNNER_HOME/.runner" +fi + +chown -R "$RUNNER_USER:$RUNNER_USER" "$RUNNER_HOME" +systemctl daemon-reload +systemctl enable --now easyai-gateway-act-runner.service +systemctl --no-pager --full status easyai-gateway-act-runner.service + +env PATH="$PREFIX/bin:$PREFIX/toolchains/go/bin:$PREFIX/toolchains/node/bin:$PATH" \ + sh -c 'gitea-runner --version && go version && node --version && pnpm --version && trivy --version && govulncheck -version' diff --git a/tests/ci/pipeline-test.sh b/tests/ci/pipeline-test.sh new file mode 100755 index 0000000..3abc430 --- /dev/null +++ b/tests/ci/pipeline-test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # Assertions intentionally match literal workflow variables. +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +workflow=$root/.gitea/workflows/ci.yml +runner_service=$root/deploy/ci/easyai-gateway-act-runner.service +runner_config=$root/deploy/ci/act-runner-config.yaml +provision=$root/scripts/provision-ci-runner.sh +build_images=$root/scripts/ci-build-images.sh + +for file in "$workflow" "$runner_service" "$runner_config" "$provision" "$build_images"; do + [[ -f $file ]] || { + printf 'missing CI/CD file: %s\n' "$file" >&2 + exit 1 + } +done + +grep -q '^name: ci$' "$workflow" +grep -q '^ branches: \[main\]$' "$workflow" +grep -q "^ tags: \['v\*'\]$" "$workflow" +grep -q '^ pull_request:$' "$workflow" +grep -q '^ runs-on: easyai-gateway-linux$' "$workflow" +grep -q '^ - name: Checkout without external Actions$' "$workflow" +grep -q '^ - name: Verify release tag ancestry$' "$workflow" +grep -Fq "if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" "$workflow" +grep -Fq 'git merge-base --is-ancestor "$CI_SHA" refs/remotes/origin/main' "$workflow" +grep -q '^ - run: ./scripts/ci-build-images.sh$' "$workflow" +grep -q '^ - name: Deploy verified release to Production$' "$workflow" +grep -q '^ sudo -n /usr/local/sbin/easyai-ai-gateway-release "$CI_SHA"$' "$workflow" + +for gate in \ + 'gofmt -l' \ + 'go vet ./...' \ + 'go test ./...' \ + 'pnpm install --frozen-lockfile' \ + 'pnpm lint' \ + 'pnpm test' \ + 'pnpm build' \ + 'docker compose -f docker-compose.yml config --quiet' \ + 'govulncheck ./...' \ + 'trivy fs'; do + grep -Fq "$gate" "$workflow" || { + printf 'workflow is missing quality gate: %s\n' "$gate" >&2 + exit 1 + } +done + +grep -q '^User=easyai-gateway-runner$' "$runner_service" +grep -q '^Group=easyai-gateway-runner$' "$runner_service" +grep -q '^NoNewPrivileges=false$' "$runner_service" +grep -q '^ProtectSystem=strict$' "$runner_service" +grep -q '^ProtectHome=true$' "$runner_service" +grep -q '^ReadWritePaths=/var/lib/easyai-gateway-runner$' "$runner_service" +grep -q '^ExecStart=/opt/easyai-gateway-ci/bin/gitea-runner --config /etc/easyai-gateway-runner/config.yaml daemon --once$' "$runner_service" +grep -q 'easyai-gateway-linux:host' "$runner_config" +grep -q '^ capacity: 1$' "$runner_config" + +grep -q '^GITEA_RUNNER_VERSION=2.0.0$' "$provision" +grep -q '^GO_VERSION=1.26.5$' "$provision" +grep -q '^NODE_VERSION=24.16.0$' "$provision" +grep -q '^TRIVY_VERSION=0.70.0$' "$provision" +grep -Fq 'usermod -aG docker "$RUNNER_USER"' "$provision" +grep -Fq 'RUNNER_REGISTRATION_TOKEN is required for first registration' "$provision" + +grep -Fq 'docker buildx build --builder "$builder"' "$build_images" +grep -q -- '--target api' "$build_images" +grep -q -- '--target web' "$build_images" +grep -Fq 'docker buildx prune --builder "$builder" --force --max-used-space "$cache_limit"' "$build_images" +grep -Fq 'trivy image' "$build_images" + +if rg -n '(password|secret|token):[[:space:]]+[^$<{]' "$workflow"; then + echo 'workflow contains a literal credential' >&2 + exit 1 +fi + +echo 'gateway_ci_pipeline_tests=PASS' From db85487b733abea2a4a84c55cec32e0e76400fd5 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:41:47 +0800 Subject: [PATCH 04/20] =?UTF-8?q?docs(ci):=20=E8=AE=B0=E5=BD=95=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E6=B5=81=E6=B0=B4=E7=BA=BF=E5=86=B3=E7=AD=96=E4=B8=8E?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=89=8B=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固化 Tag 发布策略、Runner 信任边界、Secret 位置、数据库回滚限制,以及 Runner 安装、发布、验证和故障恢复步骤。 --- README.md | 4 ++ docs/decisions/001-production-cicd.md | 51 +++++++++++++++ docs/runbooks/production-ci-cd.md | 90 +++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 docs/decisions/001-production-cicd.md create mode 100644 docs/runbooks/production-ci-cd.md diff --git a/README.md b/README.md index 5f98881..50b94d6 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,10 @@ Web 容器的 Nginx 配置通过 bind mount 挂载自仓库文件 [docker/nginx. docker compose -f docker-compose.yml restart web ``` +## 生产 CI/CD + +Gitea Actions 会对 Pull Request 和 `main` Push 执行完整质量门禁,并以源码完整 Git SHA 构建 API/Web 镜像。`ai.51easyai.com` 只接受来自 `main` 历史的语义版本 Tag 发布,迁移前自动备份数据库,失败时恢复上一应用镜像。安装 Runner、发布、验证和回滚步骤见 [生产 CI/CD 运行手册](docs/runbooks/production-ci-cd.md),设计取舍见 [ADR-001](docs/decisions/001-production-cicd.md)。 + Compose 默认使用独立容器数据库 `postgres:18-alpine`,数据卷会保留在 `postgres_data` 和 `api_data`。为避免本地开发 `.env` 中的 `localhost` 数据库地址污染容器部署,compose 使用 `AI_GATEWAY_COMPOSE_*` 变量作为容器部署专用覆盖,例如: ```bash diff --git a/docs/decisions/001-production-cicd.md b/docs/decisions/001-production-cicd.md new file mode 100644 index 0000000..d9ae60f --- /dev/null +++ b/docs/decisions/001-production-cicd.md @@ -0,0 +1,51 @@ +# ADR-001: 使用仓库级 Runner 和不可变镜像发布生产环境 + +## 状态 + +Accepted + +## 日期 + +2026-07-17 + +## 背景 + +AI Gateway 已通过独立部署仓库和 Docker Compose 手工运行在 `110.42.51.33`,公共入口是 `https://ai.51easyai.com`。同一服务器还运行认证中心、K3s 和其他 EasyAI 服务,磁盘空间有限。Gitea 1.22 的 host Runner 已知不能可靠启动第二个依赖 Job,也不完整支持 GitHub Actions 的 `environment`、`permissions` 和 `concurrency` 语义。 + +生产发布必须满足:源码可追溯、数据库迁移前有备份、失败可恢复上一应用版本、Secret 不进入仓库或流水线日志,并且不能让普通分支自动修改生产环境。 + +## 决策 + +- 为 `BCAI/easyai-ai-gateway` 注册独立、仓库级 host Runner。它不与认证中心 Runner 共享身份、状态目录或任务标签。 +- Pull Request 和 `main` Push 执行相同质量门禁与镜像构建;只有符合 `vMAJOR.MINOR.PATCH` 形式、且目标提交属于 `main` 历史的 Tag 才发布生产。 +- API/Web 镜像使用完整 Git SHA 作为运行时 Tag。语义版本 Tag 只是发布授权信号,不作为可变镜像标识。 +- 验证和发布处于同一个 Runner Job。这样部署只使用已通过全部门禁的同一 checkout,并规避当前 Gitea 版本的依赖 Job 领取问题。 +- Runner 使用专属 Buildx builder,缓存上限为 2 GB。服务器 root 现有的阿里云 Registry 凭据只由固定发布助手使用,不复制到 Gitea Secret。 +- 发布助手在迁移前生成 PostgreSQL custom-format 备份并验证目录;保留最近 5 份。应用健康检查失败时恢复上一组 API/Web 镜像。 +- 数据库迁移必须保持向后兼容。自动回滚只覆盖应用镜像;数据库恢复需要人工确认,避免覆盖失败发布后产生的有效写入。 +- Gitea Runner 的 Docker 权限等价于宿主机 root 权限。因此只允许受信任的仓库维护者触发该 Runner,来自 Fork 的 Pull Request 必须先经维护者批准。 + +## 备选方案 + +### 复用认证中心 Runner + +拒绝。现有 Runner 是认证中心仓库级身份,扩大为组织级 Runner 会让更多仓库获得同一 Docker/root 信任边界。 + +### 由 Runner 保存 SSH 或 Registry Secret + +拒绝。Runner 已位于目标服务器,绕回 SSH 会增加长期凭据;复制 Registry Secret 也会扩大暴露面。固定 root helper 可以复用服务器现有登录状态。 + +### `main` 通过后直接发布生产 + +拒绝。`ai.51easyai.com` 是生产入口。语义版本 Tag 提供明确的发布授权点,同时仍保留自动化与可审计性。 + +### 只依赖 `latest` + +拒绝。`latest` 无法证明运行中的容器对应哪个源码提交,也不能可靠选择回滚版本。 + +## 影响 + +- 日常合并不会自动改变生产;创建并推送版本 Tag 才会部署。 +- 首次启用 CD 时需要在服务器捕获当前运行镜像作为 bootstrap 回滚基线。 +- 发布前必须确保数据库迁移对上一应用版本保持兼容。 +- 如果未来增加不受信任的贡献者,应把 CI 构建迁移到隔离的 rootless Runner,并保留独立的受保护部署 Runner。 diff --git a/docs/runbooks/production-ci-cd.md b/docs/runbooks/production-ci-cd.md new file mode 100644 index 0000000..e8d3eb5 --- /dev/null +++ b/docs/runbooks/production-ci-cd.md @@ -0,0 +1,90 @@ +# 生产 CI/CD 运行手册 + +## 流水线约定 + +- Runner:`easyai-gateway-act-runner.service` +- Runner 标签:`easyai-gateway-linux:host` +- Runner 状态:`/var/lib/easyai-gateway-runner` +- 工具链:`/opt/easyai-gateway-ci` +- Gitea:`https://git.51easyai.com/BCAI/easyai-ai-gateway` +- 生产入口:`https://ai.51easyai.com` +- 部署目录:`/root/easyai-ai-gateway-deploy` + +PR、`main` Push 和版本 Tag 都会执行格式、Go vet/test、govulncheck、前端 lint/test/build、依赖审计、Compose 校验、Trivy 扫描与 API/Web 镜像构建。只有版本 Tag 会执行最后的生产发布步骤。 + +## 首次安装或修复 Runner + +从 Gitea 仓库设置的 Actions Runner 页面取得一次性仓库注册 Token,在服务器可信 checkout 中执行: + +```bash +RUNNER_REGISTRATION_TOKEN='<一次性 Token>' ./scripts/provision-ci-runner.sh +``` + +Token 只通过进程环境传入。生成的 `/var/lib/easyai-gateway-runner/.runner` 必须保持 `0600`,不得复制到仓库、报告或聊天记录。 + +验证: + +```bash +systemctl is-active easyai-gateway-act-runner.service +systemctl is-enabled easyai-gateway-act-runner.service +journalctl -u easyai-gateway-act-runner.service --since '15 minutes ago' +``` + +Runner 使用 `daemon --once`。每个 Job 结束后进程自然退出,再由 systemd 启动新的长轮询进程。 + +## 发布生产版本 + +1. 合并到 `main`,等待 `ci / verify (push)` 成功。 +2. 在已验证的 `main` 提交上创建语义版本 Tag。 +3. 推送 Tag,等待同一 CI Job 完成镜像扫描、发布、数据库备份、迁移和公共健康检查。 + +```bash +git switch main +git pull --ff-only +git tag -a v0.1.1 -m 'release: v0.1.1' +git push origin v0.1.1 +``` + +流水线会部署 Tag 指向提交的完整 Git SHA,而不是 `latest` 或版本号镜像。 + +## 发布后验证 + +```bash +curl -fsS https://ai.51easyai.com/gateway-api/healthz +curl -fsS https://ai.51easyai.com/gateway-api/readyz +ssh root@110.42.51.33 'cd /root/easyai-ai-gateway-deploy && ./gateway-ops.sh ps' +``` + +同时检查服务器磁盘、API 日志和数据库容器健康状态。发布后的首小时如出现错误率翻倍、P95 延迟增加 50%、数据完整性或安全问题,应立即回滚。 + +## 应用回滚 + +发布失败时脚本会自动恢复 `.release.env` 中的上一组 API/Web 镜像并重复健康检查。人工回滚到一个仍存在于本机或 Registry 的历史 Git SHA: + +```bash +sudo /usr/local/sbin/easyai-ai-gateway-release <40 位历史 Git SHA> +``` + +该操作也会先创建数据库备份。不要把 `latest`、分支名或版本号传给发布助手。 + +## 数据库恢复 + +备份位于 `/var/backups/easyai-ai-gateway`,默认只保留最近 5 份。应用回滚不会自动恢复数据库。只有确认需要回退数据且已经停止写入后,才能由操作员选择备份执行 `pg_restore`;恢复前必须再保留当前数据库副本。 + +## Runner 故障恢复 + +重启前先确认没有构建、扫描或部署子进程: + +```bash +ps -u easyai-gateway-runner -o pid,ppid,etime,cmd --forest +systemctl status easyai-gateway-act-runner.service --no-pager +``` + +确认空闲后才执行: + +```bash +systemctl restart easyai-gateway-act-runner.service +journalctl -u easyai-gateway-act-runner.service -n 50 --no-pager +``` + +不要因为日志暂时没有新增就用定时器重启 Runner;镜像构建和扫描可长时间无输出。 From 5ee267ecbd54124a9ac3fb381b600488b8f55ff4 Mon Sep 17 00:00:00 2001 From: wangbo Date: Fri, 17 Jul 2026 13:52:00 +0800 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=AF=B7=E6=B1=82=E9=80=82=E9=85=8D=E4=B8=8E=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/docs/swagger.json | 377 +++++++++++---- apps/api/docs/swagger.yaml | 290 ++++++++--- apps/api/internal/clients/chat_reasoning.go | 10 +- .../clients/chat_reasoning_max_test.go | 29 ++ apps/api/internal/clients/clients_test.go | 250 ++++++++++ apps/api/internal/clients/keling.go | 316 +++++++++++- apps/api/internal/clients/openai.go | 7 + .../internal/clients/openai_request_params.go | 108 +++++ .../clients/openai_request_params_test.go | 89 ++++ apps/api/internal/clients/responses_compat.go | 41 +- .../internal/clients/responses_compat_test.go | 6 +- apps/api/internal/clients/types.go | 9 + apps/api/internal/clients/volces.go | 85 +++- .../api/internal/clients/volces_deyun_test.go | 129 +++++ .../httpapi/chat_completions_mode_test.go | 5 +- apps/api/internal/httpapi/handlers.go | 44 +- .../keling_simulation_integration_test.go | 194 ++++++++ apps/api/internal/httpapi/openapi_models.go | 115 +++-- apps/api/internal/httpapi/response.go | 6 + .../api/internal/runner/output_token_limit.go | 232 +++++++++ .../runner/output_token_limit_test.go | 113 +++++ apps/api/internal/runner/param_processor.go | 1 + apps/api/internal/runner/pricing.go | 4 + apps/api/internal/runner/queue_worker.go | 5 +- apps/api/internal/runner/service.go | 30 +- apps/api/internal/runner/service_test.go | 4 +- apps/api/internal/store/platform_models.go | 75 +++ apps/api/internal/store/runtime_types.go | 5 +- apps/api/migrations/0062_keling_30_turbo.sql | 216 +++++++++ scripts/deyun-video-e2e.mjs | 452 ++++++++++++++++++ scripts/output-token-limit-e2e.mjs | 272 +++++++++++ 31 files changed, 3287 insertions(+), 232 deletions(-) create mode 100644 apps/api/internal/clients/chat_reasoning_max_test.go create mode 100644 apps/api/internal/clients/openai_request_params.go create mode 100644 apps/api/internal/clients/openai_request_params_test.go create mode 100644 apps/api/internal/clients/volces_deyun_test.go create mode 100644 apps/api/internal/httpapi/keling_simulation_integration_test.go create mode 100644 apps/api/internal/runner/output_token_limit.go create mode 100644 apps/api/internal/runner/output_token_limit_test.go create mode 100644 apps/api/migrations/0062_keling_30_turbo.sql create mode 100755 scripts/deyun-video-e2e.mjs create mode 100644 scripts/output-token-limit-e2e.mjs diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index ede6dfd..81cd7ae 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -4521,7 +4521,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpapi.TaskRequest" + "$ref": "#/definitions/httpapi.ChatCompletionRequest" } } ], @@ -5513,31 +5513,26 @@ "BearerAuth": [] } ], - "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "description": "公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 previous_response_id 时由调用方管理完整状态,Gateway 以本轮 input/messages 为准且不追加本地历史。", "consumes": [ "application/json" ], "produces": [ - "application/json" + "application/json", + "text/event-stream" ], "tags": [ - "tasks" + "responses" ], - "summary": "创建或执行 AI 任务", + "summary": "创建 OpenAI Responses", "parameters": [ { - "type": "boolean", - "description": "true 时异步创建任务并返回 202", - "name": "X-Async", - "in": "header" - }, - { - "description": "AI 任务请求,字段随任务类型变化", + "description": "Responses 请求;Chat 回退只支持自定义 function tools", "name": "input", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpapi.TaskRequest" + "$ref": "#/definitions/httpapi.ResponsesRequest" } } ], @@ -5545,17 +5540,17 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpapi.CompatibleResponse" - } - }, - "202": { - "description": "Accepted", - "schema": { - "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + "$ref": "#/definitions/httpapi.ResponsesCompatibleResponse" + }, + "headers": { + "X-Gateway-Task-Id": { + "type": "string", + "description": "网关审计任务 ID" + } } }, "400": { - "description": "Bad Request", + "description": "invalid_previous_response_id / unsupported_response_tool / unsupported_response_parameter", "schema": { "$ref": "#/definitions/httpapi.ErrorEnvelope" } @@ -5578,20 +5573,8 @@ "$ref": "#/definitions/httpapi.ErrorEnvelope" } }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httpapi.ErrorEnvelope" - } - }, - "429": { - "description": "Too Many Requests", - "schema": { - "$ref": "#/definitions/httpapi.ErrorEnvelope" - } - }, - "502": { - "description": "Bad Gateway", + "503": { + "description": "response_chain_unavailable", "schema": { "$ref": "#/definitions/httpapi.ErrorEnvelope" } @@ -6938,31 +6921,26 @@ "BearerAuth": [] } ], - "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "description": "OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter。", "consumes": [ "application/json" ], "produces": [ - "application/json" + "application/json", + "text/event-stream" ], "tags": [ - "tasks" + "chat" ], - "summary": "创建或执行 AI 任务", + "summary": "创建 OpenAI Chat Completions", "parameters": [ { - "type": "boolean", - "description": "true 时异步创建任务并返回 202", - "name": "X-Async", - "in": "header" - }, - { - "description": "AI 任务请求,字段随任务类型变化", + "description": "Chat Completions 请求", "name": "input", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpapi.TaskRequest" + "$ref": "#/definitions/httpapi.ChatCompletionRequest" } } ], @@ -6970,17 +6948,11 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpapi.CompatibleResponse" - } - }, - "202": { - "description": "Accepted", - "schema": { - "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + "$ref": "#/definitions/httpapi.ChatCompletionCompatibleResponse" } }, "400": { - "description": "Bad Request", + "description": "invalid_parameter", "schema": { "$ref": "#/definitions/httpapi.ErrorEnvelope" } @@ -7003,12 +6975,6 @@ "$ref": "#/definitions/httpapi.ErrorEnvelope" } }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httpapi.ErrorEnvelope" - } - }, "429": { "description": "Too Many Requests", "schema": { @@ -8220,31 +8186,26 @@ "BearerAuth": [] } ], - "description": "网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible 路径同步返回兼容响应或 SSE 流。", + "description": "OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 400 invalid_parameter。", "consumes": [ "application/json" ], "produces": [ - "application/json" + "application/json", + "text/event-stream" ], "tags": [ - "tasks" + "chat" ], - "summary": "创建或执行 AI 任务", + "summary": "创建 OpenAI Chat Completions", "parameters": [ { - "type": "boolean", - "description": "true 时异步创建任务并返回 202", - "name": "X-Async", - "in": "header" - }, - { - "description": "AI 任务请求,字段随任务类型变化", + "description": "Chat Completions 请求", "name": "input", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/httpapi.TaskRequest" + "$ref": "#/definitions/httpapi.ChatCompletionRequest" } } ], @@ -8252,17 +8213,11 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/httpapi.CompatibleResponse" - } - }, - "202": { - "description": "Accepted", - "schema": { - "$ref": "#/definitions/httpapi.TaskAcceptedResponse" + "$ref": "#/definitions/httpapi.ChatCompletionCompatibleResponse" } }, "400": { - "description": "Bad Request", + "description": "invalid_parameter", "schema": { "$ref": "#/definitions/httpapi.ErrorEnvelope" } @@ -8285,12 +8240,6 @@ "$ref": "#/definitions/httpapi.ErrorEnvelope" } }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/httpapi.ErrorEnvelope" - } - }, "429": { "description": "Too Many Requests", "schema": { @@ -9752,6 +9701,158 @@ } } }, + "httpapi.ChatCompletionRequest": { + "type": "object", + "properties": { + "audio": { + "type": "object", + "additionalProperties": true + }, + "frequency_penalty": { + "type": "number", + "example": 0 + }, + "function_call": {}, + "functions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "logit_bias": { + "type": "object", + "additionalProperties": true + }, + "logprobs": { + "type": "boolean" + }, + "max_completion_tokens": { + "type": "integer", + "example": 512 + }, + "max_tokens": { + "type": "integer", + "example": 512 + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/httpapi.ChatMessage" + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "modalities": { + "type": "array", + "items": { + "type": "string" + } + }, + "model": { + "type": "string", + "example": "gpt-4o-mini" + }, + "moderation": {}, + "n": { + "type": "integer", + "example": 1 + }, + "parallel_tool_calls": { + "type": "boolean" + }, + "prediction": {}, + "presence_penalty": { + "type": "number", + "example": 0 + }, + "prompt_cache_key": { + "type": "string" + }, + "prompt_cache_options": { + "type": "object", + "additionalProperties": true + }, + "prompt_cache_retention": { + "type": "string", + "enum": [ + "in_memory", + "24h" + ] + }, + "reasoning_effort": { + "description": "ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。", + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "example": "medium" + }, + "response_format": {}, + "runMode": { + "type": "string", + "example": "simulation" + }, + "safety_identifier": { + "type": "string" + }, + "seed": { + "type": "integer" + }, + "service_tier": { + "type": "string" + }, + "stop": {}, + "store": { + "type": "boolean" + }, + "stream": { + "type": "boolean", + "example": false + }, + "stream_options": { + "type": "object", + "additionalProperties": true + }, + "temperature": { + "type": "number", + "example": 0.7 + }, + "tool_choice": {}, + "tools": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "top_logprobs": { + "type": "integer" + }, + "top_p": { + "type": "number", + "example": 1 + }, + "user": { + "type": "string" + }, + "verbosity": { + "type": "string" + }, + "web_search_options": { + "type": "object", + "additionalProperties": true + } + } + }, "httpapi.ChatCompletionUsage": { "type": "object", "properties": { @@ -9787,14 +9888,19 @@ "httpapi.ChatMessage": { "type": "object", "properties": { - "content": { - "type": "string", - "example": "Hello" + "content": {}, + "function_call": {}, + "name": { + "type": "string" }, "role": { "type": "string", "example": "user" - } + }, + "tool_call_id": { + "type": "string" + }, + "tool_calls": {} } }, "httpapi.ChatPromptTokensDetails": { @@ -9853,9 +9959,14 @@ "type": "string", "example": "invalid json body" }, + "param": {}, "status": { "type": "integer", "example": 400 + }, + "type": { + "type": "string", + "example": "invalid_request_error" } } }, @@ -10397,6 +10508,23 @@ "httpapi.ResponsesRequest": { "type": "object", "properties": { + "background": { + "type": "boolean" + }, + "context_management": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "conversation": {}, + "include": { + "type": "array", + "items": { + "type": "string" + } + }, "input": {}, "instructions": { "type": "string", @@ -10406,10 +10534,18 @@ "type": "integer", "example": 512 }, + "max_tool_calls": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, "model": { "type": "string", "example": "Doubao Seed 2.0 Pro" }, + "moderation": {}, "parallel_tool_calls": { "type": "boolean", "example": true @@ -10418,10 +10554,31 @@ "type": "string", "example": "resp_0123456789abcdef0123456789abcdef" }, + "prompt": {}, + "prompt_cache_key": { + "type": "string" + }, + "prompt_cache_options": { + "type": "object", + "additionalProperties": true + }, + "prompt_cache_retention": { + "type": "string", + "enum": [ + "in_memory", + "24h" + ] + }, "reasoning": { "type": "object", "additionalProperties": true }, + "safety_identifier": { + "type": "string" + }, + "service_tier": { + "type": "string" + }, "store": { "type": "boolean" }, @@ -10429,6 +10586,10 @@ "type": "boolean", "example": false }, + "stream_options": { + "type": "object", + "additionalProperties": true + }, "temperature": { "type": "number", "example": 0.7 @@ -10445,9 +10606,18 @@ "additionalProperties": true } }, + "top_logprobs": { + "type": "integer" + }, "top_p": { "type": "number", "example": 1 + }, + "truncation": { + "type": "string" + }, + "user": { + "type": "string" } } }, @@ -10610,14 +10780,20 @@ "type": "string", "example": "happy" }, - "input": { - "type": "string", - "example": "Tell me a short story" - }, + "input": {}, "makeInstrumental": { "type": "boolean", "example": false }, + "max_completion_tokens": { + "description": "MaxCompletionTokens includes visible output and reasoning tokens.", + "type": "integer", + "example": 512 + }, + "max_output_tokens": { + "type": "integer", + "example": 512 + }, "max_tokens": { "type": "integer", "example": 512 @@ -10645,8 +10821,17 @@ "example": "A watercolor robot reading a book" }, "reasoning_effort": { - "description": "ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。", + "description": "ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。", "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], "example": "medium" }, "resolution": { diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index bd4092a..21c5218 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -133,6 +133,118 @@ definitions: usage: $ref: '#/definitions/httpapi.ChatCompletionUsage' type: object + httpapi.ChatCompletionRequest: + properties: + audio: + additionalProperties: true + type: object + frequency_penalty: + example: 0 + type: number + function_call: {} + functions: + items: + additionalProperties: true + type: object + type: array + logit_bias: + additionalProperties: true + type: object + logprobs: + type: boolean + max_completion_tokens: + example: 512 + type: integer + max_tokens: + example: 512 + type: integer + messages: + items: + $ref: '#/definitions/httpapi.ChatMessage' + type: array + metadata: + additionalProperties: true + type: object + modalities: + items: + type: string + type: array + model: + example: gpt-4o-mini + type: string + moderation: {} + "n": + example: 1 + type: integer + parallel_tool_calls: + type: boolean + prediction: {} + presence_penalty: + example: 0 + type: number + prompt_cache_key: + type: string + prompt_cache_options: + additionalProperties: true + type: object + prompt_cache_retention: + enum: + - in_memory + - 24h + type: string + reasoning_effort: + description: ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。 + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - max + example: medium + type: string + response_format: {} + runMode: + example: simulation + type: string + safety_identifier: + type: string + seed: + type: integer + service_tier: + type: string + stop: {} + store: + type: boolean + stream: + example: false + type: boolean + stream_options: + additionalProperties: true + type: object + temperature: + example: 0.7 + type: number + tool_choice: {} + tools: + items: + additionalProperties: true + type: object + type: array + top_logprobs: + type: integer + top_p: + example: 1 + type: number + user: + type: string + verbosity: + type: string + web_search_options: + additionalProperties: true + type: object + type: object httpapi.ChatCompletionUsage: properties: completion_tokens: @@ -157,12 +269,16 @@ definitions: type: object httpapi.ChatMessage: properties: - content: - example: Hello + content: {} + function_call: {} + name: type: string role: example: user type: string + tool_call_id: + type: string + tool_calls: {} type: object httpapi.ChatPromptTokensDetails: properties: @@ -203,9 +319,13 @@ definitions: message: example: invalid json body type: string + param: {} status: example: 400 type: integer + type: + example: invalid_request_error + type: string type: object httpapi.FileStorageChannelListResponse: properties: @@ -567,6 +687,18 @@ definitions: type: object httpapi.ResponsesRequest: properties: + background: + type: boolean + context_management: + items: + additionalProperties: true + type: object + type: array + conversation: {} + include: + items: + type: string + type: array input: {} instructions: example: Answer concisely @@ -574,23 +706,47 @@ definitions: max_output_tokens: example: 512 type: integer + max_tool_calls: + type: integer + metadata: + additionalProperties: true + type: object model: example: Doubao Seed 2.0 Pro type: string + moderation: {} parallel_tool_calls: example: true type: boolean previous_response_id: example: resp_0123456789abcdef0123456789abcdef type: string + prompt: {} + prompt_cache_key: + type: string + prompt_cache_options: + additionalProperties: true + type: object + prompt_cache_retention: + enum: + - in_memory + - 24h + type: string reasoning: additionalProperties: true type: object + safety_identifier: + type: string + service_tier: + type: string store: type: boolean stream: example: false type: boolean + stream_options: + additionalProperties: true + type: object temperature: example: 0.7 type: number @@ -603,9 +759,15 @@ definitions: additionalProperties: true type: object type: array + top_logprobs: + type: integer top_p: example: 1 type: number + truncation: + type: string + user: + type: string type: object httpapi.RuntimePolicySetListResponse: properties: @@ -718,12 +880,17 @@ definitions: emotion: example: happy type: string - input: - example: Tell me a short story - type: string + input: {} makeInstrumental: example: false type: boolean + max_completion_tokens: + description: MaxCompletionTokens includes visible output and reasoning tokens. + example: 512 + type: integer + max_output_tokens: + example: 512 + type: integer max_tokens: example: 512 type: integer @@ -744,7 +911,15 @@ definitions: example: A watercolor robot reading a book type: string reasoning_effort: - description: ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。 + description: ReasoningEffort 推理强度,OpenAI-compatible 请求字段;支持 none、minimal、low、medium、high、xhigh、max。供应商自定义取值由网关按平台适配。 + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - max example: medium type: string resolution: @@ -5460,7 +5635,7 @@ paths: name: input required: true schema: - $ref: '#/definitions/httpapi.TaskRequest' + $ref: '#/definitions/httpapi.ChatCompletionRequest' produces: - application/json - text/event-stream @@ -6107,32 +6282,31 @@ paths: post: consumes: - application/json - description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible - 路径同步返回兼容响应或 SSE 流。 + description: 公开 OpenAI-compatible Responses 入口。模型声明 openai_responses 时原生转发,否则使用 + Chat Completions 转换;store 缺省为 true。previous_response_id 严格绑定首次成功的平台模型和上游协议,链路不可用时不跨平台续接。未提供 + previous_response_id 时由调用方管理完整状态,Gateway 以本轮 input/messages 为准且不追加本地历史。 parameters: - - description: true 时异步创建任务并返回 202 - in: header - name: X-Async - type: boolean - - description: AI 任务请求,字段随任务类型变化 + - description: Responses 请求;Chat 回退只支持自定义 function tools in: body name: input required: true schema: - $ref: '#/definitions/httpapi.TaskRequest' + $ref: '#/definitions/httpapi.ResponsesRequest' produces: - application/json + - text/event-stream responses: "200": description: OK + headers: + X-Gateway-Task-Id: + description: 网关审计任务 ID + type: string schema: - $ref: '#/definitions/httpapi.CompatibleResponse' - "202": - description: Accepted - schema: - $ref: '#/definitions/httpapi.TaskAcceptedResponse' + $ref: '#/definitions/httpapi.ResponsesCompatibleResponse' "400": - description: Bad Request + description: invalid_previous_response_id / unsupported_response_tool / + unsupported_response_parameter schema: $ref: '#/definitions/httpapi.ErrorEnvelope' "401": @@ -6147,23 +6321,15 @@ paths: description: Forbidden schema: $ref: '#/definitions/httpapi.ErrorEnvelope' - "404": - description: Not Found - schema: - $ref: '#/definitions/httpapi.ErrorEnvelope' - "429": - description: Too Many Requests - schema: - $ref: '#/definitions/httpapi.ErrorEnvelope' - "502": - description: Bad Gateway + "503": + description: response_chain_unavailable schema: $ref: '#/definitions/httpapi.ErrorEnvelope' security: - BearerAuth: [] - summary: 创建或执行 AI 任务 + summary: 创建 OpenAI Responses tags: - - tasks + - responses /api/v1/song/generations: post: consumes: @@ -7027,32 +7193,25 @@ paths: post: consumes: - application/json - description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible - 路径同步返回兼容响应或 SSE 流。 + description: OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 + 400 invalid_parameter。 parameters: - - description: true 时异步创建任务并返回 202 - in: header - name: X-Async - type: boolean - - description: AI 任务请求,字段随任务类型变化 + - description: Chat Completions 请求 in: body name: input required: true schema: - $ref: '#/definitions/httpapi.TaskRequest' + $ref: '#/definitions/httpapi.ChatCompletionRequest' produces: - application/json + - text/event-stream responses: "200": description: OK schema: - $ref: '#/definitions/httpapi.CompatibleResponse' - "202": - description: Accepted - schema: - $ref: '#/definitions/httpapi.TaskAcceptedResponse' + $ref: '#/definitions/httpapi.ChatCompletionCompatibleResponse' "400": - description: Bad Request + description: invalid_parameter schema: $ref: '#/definitions/httpapi.ErrorEnvelope' "401": @@ -7067,10 +7226,6 @@ paths: description: Forbidden schema: $ref: '#/definitions/httpapi.ErrorEnvelope' - "404": - description: Not Found - schema: - $ref: '#/definitions/httpapi.ErrorEnvelope' "429": description: Too Many Requests schema: @@ -7081,9 +7236,9 @@ paths: $ref: '#/definitions/httpapi.ErrorEnvelope' security: - BearerAuth: [] - summary: 创建或执行 AI 任务 + summary: 创建 OpenAI Chat Completions tags: - - tasks + - chat /embeddings: post: consumes: @@ -7866,32 +8021,25 @@ paths: post: consumes: - application/json - description: 网关任务接口按 model 选择平台模型;除 /api/v1/chat/completions 以外的 /api/v1 任务路径返回任务受理结果,OpenAI-compatible - 路径同步返回兼容响应或 SSE 流。 + description: OpenAI-compatible Chat Completions 入口;仅接受官方字段及文档声明的 EasyAI 路由扩展,未知顶层字段返回 + 400 invalid_parameter。 parameters: - - description: true 时异步创建任务并返回 202 - in: header - name: X-Async - type: boolean - - description: AI 任务请求,字段随任务类型变化 + - description: Chat Completions 请求 in: body name: input required: true schema: - $ref: '#/definitions/httpapi.TaskRequest' + $ref: '#/definitions/httpapi.ChatCompletionRequest' produces: - application/json + - text/event-stream responses: "200": description: OK schema: - $ref: '#/definitions/httpapi.CompatibleResponse' - "202": - description: Accepted - schema: - $ref: '#/definitions/httpapi.TaskAcceptedResponse' + $ref: '#/definitions/httpapi.ChatCompletionCompatibleResponse' "400": - description: Bad Request + description: invalid_parameter schema: $ref: '#/definitions/httpapi.ErrorEnvelope' "401": @@ -7906,10 +8054,6 @@ paths: description: Forbidden schema: $ref: '#/definitions/httpapi.ErrorEnvelope' - "404": - description: Not Found - schema: - $ref: '#/definitions/httpapi.ErrorEnvelope' "429": description: Too Many Requests schema: @@ -7920,9 +8064,9 @@ paths: $ref: '#/definitions/httpapi.ErrorEnvelope' security: - BearerAuth: [] - summary: 创建或执行 AI 任务 + summary: 创建 OpenAI Chat Completions tags: - - tasks + - chat /v1/embeddings: post: consumes: diff --git a/apps/api/internal/clients/chat_reasoning.go b/apps/api/internal/clients/chat_reasoning.go index abb2b53..5bb21bc 100644 --- a/apps/api/internal/clients/chat_reasoning.go +++ b/apps/api/internal/clients/chat_reasoning.go @@ -9,7 +9,7 @@ import ( "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) -const OpenAIReasoningEffortValidationMessage = "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh" +const OpenAIReasoningEffortValidationMessage = "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh, max" var ( openAIReasoningEfforts = map[string]struct{}{ @@ -19,6 +19,7 @@ var ( "medium": {}, "high": {}, "xhigh": {}, + "max": {}, } volcesChatReasoningEfforts = map[string]struct{}{ "minimal": {}, @@ -212,13 +213,16 @@ func isZhipuReasoningEffortModel(model string) bool { } func highMaxReasoningEffort(effort string) string { - if effort == "xhigh" { + if effort == "xhigh" || effort == "max" { return "max" } return "high" } func zhipuReasoningEffort(effort string) string { + if effort == "max" { + return "xhigh" + } if _, ok := zhipuReasoningEfforts[effort]; ok { return effort } @@ -229,7 +233,7 @@ func volcesChatReasoningEffort(effort string) string { switch effort { case "none": return "minimal" - case "xhigh": + case "xhigh", "max": return "high" default: if _, ok := volcesChatReasoningEfforts[effort]; ok { diff --git a/apps/api/internal/clients/chat_reasoning_max_test.go b/apps/api/internal/clients/chat_reasoning_max_test.go new file mode 100644 index 0000000..efd569d --- /dev/null +++ b/apps/api/internal/clients/chat_reasoning_max_test.go @@ -0,0 +1,29 @@ +package clients + +import ( + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestCurrentOpenAIReasoningEffortMaxProviderMapping(t *testing.T) { + tests := []struct { + name string + candidate store.RuntimeModelCandidate + expected string + }{ + {name: "generic", candidate: store.RuntimeModelCandidate{Provider: "openai"}, expected: "max"}, + {name: "deepseek", candidate: store.RuntimeModelCandidate{Provider: "deepseek-openai"}, expected: "max"}, + {name: "zhipu", candidate: store.RuntimeModelCandidate{Provider: "zhipu-openai", ProviderModelName: "glm-5.2"}, expected: "xhigh"}, + {name: "volces", candidate: store.RuntimeModelCandidate{Provider: "volces-openai", ProviderModelName: "doubao-seed-2-0-pro-260215"}, expected: "high"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := map[string]any{"model": test.candidate.ProviderModelName, "reasoning_effort": "max"} + applyOpenAIChatReasoningParams(body, test.candidate) + if body["reasoning_effort"] != test.expected { + t.Fatalf("expected reasoning_effort %q, got %#v", test.expected, body["reasoning_effort"]) + } + }) + } +} diff --git a/apps/api/internal/clients/clients_test.go b/apps/api/internal/clients/clients_test.go index dbd0d9f..0a4421e 100644 --- a/apps/api/internal/clients/clients_test.go +++ b/apps/api/internal/clients/clients_test.go @@ -2294,6 +2294,256 @@ func TestKelingClientVideoSubmitsAndPollsImageTask(t *testing.T) { } } +func TestKelingClient30TurboUsesModelEndpointAndTasksAPI(t *testing.T) { + var submitPath string + var pollPath string + var pollQuery string + var gotAuth string + var submittedPayload map[string]any + var submittedTaskPayload map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + switch r.Method + " " + r.URL.Path { + case "POST /text-to-video/kling-3.0-turbo": + submitPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&submittedPayload); err != nil { + t.Fatalf("decode keling 3.0 turbo submit: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "request_id": "req-turbo-submit", + "data": map[string]any{ + "id": "turbo-task-1", + "status": "submitted", + }, + }) + case "GET /tasks": + pollPath = r.URL.Path + pollQuery = r.URL.RawQuery + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "request_id": "req-turbo-poll", + "data": []any{ + map[string]any{ + "id": "turbo-task-1", + "status": "succeeded", + "create_time": 789, + "outputs": []any{ + map[string]any{ + "type": "video", + "url": "https://example.com/turbo.mp4", + "watermark_url": "https://example.com/turbo-watermark.mp4", + "duration": "8", + }, + }, + }, + }, + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{ + Kind: "videos.generations", + ModelType: "video_generate", + Model: "可灵3.0 Turbo", + Body: map[string]any{ + "prompt": "A cinematic city reveal", + "duration": 8, + "resolution": "1080p", + "aspect_ratio": "9:16", + "callback_url": "https://example.com/callback", + "external_task_id": "external-1", + }, + Candidate: store.RuntimeModelCandidate{ + BaseURL: server.URL + "/v1", + Provider: "keling", + AuthType: "APIKey", + ModelName: "可灵3.0 Turbo", + ProviderModelName: "kling-3.0-turbo", + Credentials: map[string]any{"apiKey": "kling-api-key"}, + PlatformConfig: map[string]any{ + "kelingPollIntervalMs": 100, + "kelingPollTimeoutSeconds": 1, + }, + }, + OnRemoteTaskSubmitted: func(remoteTaskID string, payload map[string]any) error { + if remoteTaskID != "turbo-task-1" { + t.Fatalf("unexpected remote task id: %s", remoteTaskID) + } + submittedTaskPayload = payload + return nil + }, + }) + if err != nil { + t.Fatalf("run keling 3.0 turbo video: %v", err) + } + if submitPath != "/text-to-video/kling-3.0-turbo" || + pollPath != "/tasks" || + pollQuery != "task_ids=turbo-task-1" || + gotAuth != "Bearer kling-api-key" { + t.Fatalf("unexpected keling 3.0 turbo paths/auth submit=%s poll=%s?%s auth=%s", submitPath, pollPath, pollQuery, gotAuth) + } + if submittedTaskPayload["endpoint"] != "/text-to-video/kling-3.0-turbo" || + submittedTaskPayload["taskApi"] != "keling_tasks_v2" { + t.Fatalf("unexpected submitted task payload: %+v", submittedTaskPayload) + } + settings, _ := submittedPayload["settings"].(map[string]any) + options, _ := submittedPayload["options"].(map[string]any) + if submittedPayload["prompt"] != "A cinematic city reveal" || + numericValue(settings["duration"], 0) != 8 || + settings["resolution"] != "1080p" || + settings["aspect_ratio"] != "9:16" || + options["callback_url"] != "https://example.com/callback" || + options["external_task_id"] != "external-1" { + t.Fatalf("unexpected keling 3.0 turbo payload: %+v", submittedPayload) + } + data, _ := response.Result["data"].([]any) + item, _ := data[0].(map[string]any) + if response.Result["upstream_task_id"] != "turbo-task-1" || + item["url"] != "https://example.com/turbo.mp4" || + item["watermark_url"] != "https://example.com/turbo-watermark.mp4" { + t.Fatalf("unexpected keling 3.0 turbo response: %+v", response.Result) + } +} + +func TestKelingClient30TurboRejectsLegacyCredentials(t *testing.T) { + _, err := (KelingClient{}).Run(context.Background(), Request{ + Kind: "videos.generations", + Body: map[string]any{"prompt": "A cinematic city reveal"}, + Candidate: store.RuntimeModelCandidate{ + Provider: "keling", + AuthType: "AccessKey-SecretKey", + ProviderModelName: "kling-3.0-turbo", + Credentials: map[string]any{"accessKey": "ak", "secretKey": "sk"}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "new API key") { + t.Fatalf("expected keling 3.0 turbo API key requirement, got %v", err) + } +} + +func TestKelingClient30TurboResumePollsWithoutSubmitting(t *testing.T) { + var submitCalled bool + var pollQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "POST /text-to-video/kling-3.0-turbo": + submitCalled = true + t.Fatalf("resume should not submit a new keling 3.0 turbo task") + case "GET /tasks": + pollQuery = r.URL.RawQuery + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "request_id": "req-turbo-resume", + "data": []any{ + map[string]any{ + "id": "turbo-existing", + "status": "succeeded", + "outputs": []any{ + map[string]any{"type": "video", "url": "https://example.com/resumed-turbo.mp4"}, + }, + }, + }, + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{ + Kind: "videos.generations", + ModelType: "video_generate", + RemoteTaskID: "turbo-existing", + Body: map[string]any{}, + Candidate: store.RuntimeModelCandidate{ + BaseURL: server.URL + "/v1", + Provider: "keling", + AuthType: "APIKey", + ProviderModelName: "kling-3.0-turbo", + Credentials: map[string]any{"apiKey": "kling-api-key"}, + PlatformConfig: map[string]any{ + "kelingPollIntervalMs": 100, + "kelingPollTimeoutSeconds": 1, + }, + }, + }) + if err != nil { + t.Fatalf("resume keling 3.0 turbo video: %v", err) + } + if submitCalled || pollQuery != "task_ids=turbo-existing" { + t.Fatalf("resume should only poll existing task, submit=%v query=%s", submitCalled, pollQuery) + } + data, _ := response.Result["data"].([]any) + item, _ := data[0].(map[string]any) + if item["url"] != "https://example.com/resumed-turbo.mp4" { + t.Fatalf("unexpected resumed keling 3.0 turbo response: %+v", response.Result) + } +} + +func TestKeling30TurboPayloadBuildsFirstFrameAndMultiShotRequests(t *testing.T) { + imagePayload, endpoint, err := keling30TurboPayload(Request{ + Body: map[string]any{ + "duration": 5, + "resolution": "720p", + "content": []any{ + map[string]any{"type": "text", "text": "The subject looks toward the camera"}, + map[string]any{ + "type": "image_url", + "role": "first_frame", + "image_url": map[string]any{"url": "https://example.com/first.png"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("build keling 3.0 turbo image payload: %v", err) + } + if endpoint != "/image-to-video/kling-3.0-turbo" { + t.Fatalf("unexpected image endpoint: %s", endpoint) + } + if _, ok := mapFromAny(imagePayload["settings"])["aspect_ratio"]; ok { + t.Fatalf("image-to-video settings should not contain aspect_ratio: %+v", imagePayload) + } + contents, _ := imagePayload["contents"].([]any) + frame := mapFromAny(contents[1]) + if frame["type"] != "first_frame" || frame["url"] != "https://example.com/first.png" { + t.Fatalf("unexpected image contents: %+v", imagePayload["contents"]) + } + + shotPayload, _, err := keling30TurboPayload(Request{ + Body: map[string]any{ + "resolution": "720p", + "content": []any{ + map[string]any{"type": "text", "role": "shot_prompt", "shot_index": 1, "duration": 4, "text": "A car enters the tunnel"}, + map[string]any{"type": "text", "role": "shot_prompt", "shot_index": 2, "duration": 3, "text": "The headlights fill the frame"}, + }, + }, + }) + if err != nil { + t.Fatalf("build keling 3.0 turbo shot payload: %v", err) + } + if shotPayload["prompt"] != "shot 1, 4s, A car enters the tunnel; shot 2, 3s, The headlights fill the frame;" || + numericValue(mapFromAny(shotPayload["settings"])["duration"], 0) != 7 { + t.Fatalf("unexpected shot payload: %+v", shotPayload) + } +} + +func TestKeling30TurboPayloadRejectsLastFrame(t *testing.T) { + _, _, err := keling30TurboPayload(Request{ + Body: map[string]any{ + "prompt": "Move forward", + "last_frame": "https://example.com/last.png", + }, + }) + if err == nil || !strings.Contains(err.Error(), "last frame") { + t.Fatalf("expected unsupported last frame error, got %v", err) + } +} + func TestKelingOmniPayloadConvertsGatewayContent(t *testing.T) { payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{ Kind: "videos.generations", diff --git a/apps/api/internal/clients/keling.go b/apps/api/internal/clients/keling.go index dfe1a09..310595b 100644 --- a/apps/api/internal/clients/keling.go +++ b/apps/api/internal/clients/keling.go @@ -9,9 +9,11 @@ import ( "io" "math" "net/http" + "net/url" "sort" "strings" "time" + "unicode/utf8" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" "github.com/golang-jwt/jwt/v5" @@ -32,14 +34,34 @@ func (c KelingClient) Run(ctx context.Context, request Request) (Response, error if request.Kind != "videos.generations" { return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported keling request kind", Retryable: false} } - token, err := kelingAuthToken(request.Candidate) + token, err := kelingAuthTokenForRequest(request) if err != nil { return Response{}, err } return c.runVideo(ctx, request, token) } +func kelingAuthTokenForRequest(request Request) (string, error) { + if kelingIs30TurboRequest(request) { + apiKey := credential(request.Candidate.Credentials, "apiKey", "api_key", "key", "token") + if apiKey == "" { + return "", &ClientError{ + Code: "missing_credentials", + Message: "keling 3.0 turbo requires the new API key; legacy accessKey/secretKey credentials do not support new models", + Retryable: false, + StatusCode: http.StatusBadRequest, + } + } + return apiKey, nil + } + return kelingAuthToken(request.Candidate) +} + func (c KelingClient) runVideo(ctx context.Context, request Request, token string) (Response, error) { + if kelingIs30TurboRequest(request) { + return c.runTaskAPIVideo(ctx, request, token) + } + submitStartedAt := time.Now() submitRequestID := strings.TrimSpace(request.RemoteTaskID) upstreamTaskID := strings.TrimSpace(request.RemoteTaskID) @@ -143,6 +165,105 @@ func (c KelingClient) runVideo(ctx context.Context, request Request, token strin } } +func (c KelingClient) runTaskAPIVideo(ctx context.Context, request Request, token string) (Response, error) { + submitStartedAt := time.Now() + submitRequestID := strings.TrimSpace(request.RemoteTaskID) + upstreamTaskID := strings.TrimSpace(request.RemoteTaskID) + taskAPIBaseURL := kelingTaskAPIBaseURL(request.Candidate.BaseURL) + + if upstreamTaskID == "" { + payload, endpoint, err := keling30TurboPayload(request) + if err != nil { + return Response{}, err + } + submitResult, requestID, err := c.postJSONAt(ctx, request, taskAPIBaseURL, endpoint, token, payload) + submitRequestID = requestID + if err != nil { + return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now()) + } + upstreamTaskID = strings.TrimSpace(stringFromAny(kelingData(submitResult)["id"])) + if upstreamTaskID == "" { + return Response{}, &ClientError{Code: "invalid_response", Message: "keling 3.0 turbo task id is missing", RequestID: submitRequestID, Retryable: false} + } + if request.OnRemoteTaskSubmitted != nil { + if err := request.OnRemoteTaskSubmitted(upstreamTaskID, map[string]any{ + "endpoint": endpoint, + "taskApi": "keling_tasks_v2", + "submit": submitResult, + }); err != nil { + return Response{}, err + } + } + } + + interval := kelingPollInterval(request) + timeout := kelingPollTimeout(request) + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + var lastStatus string + for { + select { + case <-ctx.Done(): + return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: submitRequestID, Retryable: true} + default: + } + + pollStartedAt := time.Now() + pollResult, pollRequestID, err := c.getJSONAt( + ctx, + request, + taskAPIBaseURL, + "/tasks?task_ids="+url.QueryEscape(upstreamTaskID), + token, + ) + pollFinishedAt := time.Now() + requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID) + if err != nil { + return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt) + } + + task := kelingTaskAPITask(pollResult, upstreamTaskID) + lastStatus = strings.ToLower(strings.TrimSpace(stringFromAny(task["status"]))) + switch lastStatus { + case "succeeded", "succeed": + return Response{ + Result: kelingTaskAPIVideoSuccessResult(request, upstreamTaskID, task, pollResult), + RequestID: requestID, + Progress: kelingVideoProgress(request, upstreamTaskID), + ResponseStartedAt: submitStartedAt, + ResponseFinishedAt: pollFinishedAt, + ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt), + }, nil + case "failed": + return Response{}, &ClientError{ + Code: "keling_task_failed", + Message: kelingTaskAPIErrorMessage(request.Candidate, task, pollResult), + RequestID: requestID, + ResponseStartedAt: submitStartedAt, + ResponseFinishedAt: pollFinishedAt, + ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt), + Retryable: false, + } + } + + select { + case <-ctx.Done(): + return Response{}, &ClientError{Code: "cancelled", Message: ctx.Err().Error(), RequestID: requestID, Retryable: true} + case <-deadline.C: + return Response{}, &ClientError{ + Code: "timeout", + Message: fmt.Sprintf("keling 3.0 turbo task %s did not finish before timeout; last status: %s", upstreamTaskID, lastStatus), + RequestID: requestID, + Retryable: true, + } + case <-ticker.C: + } + } +} + func (c KelingClient) prepareVideoTask(ctx context.Context, request Request, token string) (kelingPreparedTask, error) { if kelingIsOmniRequest(request) { payload, cleanupIDs, err := c.kelingOmniPayload(ctx, request, token) @@ -400,8 +521,12 @@ func (c KelingClient) kelingOmniElementList(ctx context.Context, request Request } func (c KelingClient) postJSON(ctx context.Context, request Request, path string, token string, body map[string]any) (map[string]any, string, error) { + return c.postJSONAt(ctx, request, request.Candidate.BaseURL, path, token, body) +} + +func (c KelingClient) postJSONAt(ctx context.Context, request Request, baseURL string, path string, token string, body map[string]any) (map[string]any, string, error) { raw, _ := json.Marshal(body) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, path), bytes.NewReader(raw)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw)) if err != nil { return nil, "", err } @@ -423,7 +548,11 @@ func (c KelingClient) postJSON(ctx context.Context, request Request, path string } func (c KelingClient) getJSON(ctx context.Context, request Request, path string, token string) (map[string]any, string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(request.Candidate.BaseURL, path), nil) + return c.getJSONAt(ctx, request, request.Candidate.BaseURL, path, token) +} + +func (c KelingClient) getJSONAt(ctx context.Context, request Request, baseURL string, path string, token string) (map[string]any, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil) if err != nil { return nil, "", err } @@ -560,6 +689,127 @@ func kelingIsOmniRequest(request Request) bool { request.Candidate.Capabilities["omni"] != nil } +func kelingIs30TurboRequest(request Request) bool { + switch strings.ToLower(strings.TrimSpace(upstreamModelName(request.Candidate))) { + case "kling-3.0-turbo", "kling-v3-turbo", "kling-3-0-turbo": + return true + default: + return false + } +} + +func kelingTaskAPIBaseURL(baseURL string) string { + trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if strings.HasSuffix(strings.ToLower(trimmed), "/v1") { + return trimmed[:len(trimmed)-len("/v1")] + } + return trimmed +} + +func keling30TurboPayload(request Request) (map[string]any, string, error) { + body := cleanProviderBody(request.Body) + content := contentItems(body["content"]) + if len(content) == 0 { + content = buildVolcesContentFromBody(body) + } + + shots := kelingShotPrompts(content) + if len(shots) > 6 { + return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo supports at most 6 shots", StatusCode: 400, Retryable: false} + } + prompt := firstKelingPrompt(content) + duration := numericValue(body["duration"], 5) + if len(shots) > 0 { + var promptBuilder strings.Builder + duration = 0 + for index, shot := range shots { + if shot.duration < 1 || math.Abs(shot.duration-math.Round(shot.duration)) > 1e-9 { + return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo shot duration must be an integer of at least 1 second", StatusCode: 400, Retryable: false} + } + duration += shot.duration + fmt.Fprintf(&promptBuilder, "shot %d, %ds, %s; ", index+1, int(math.Round(shot.duration)), shot.text) + } + prompt = strings.TrimSpace(promptBuilder.String()) + } + if prompt == "" { + return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo video prompt is required", StatusCode: 400, Retryable: false} + } + if math.Abs(duration-math.Round(duration)) > 1e-9 || duration < 3 || duration > 15 { + return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo duration must be an integer between 3 and 15 seconds", StatusCode: 400, Retryable: false} + } + + firstFrame, lastFrame, referenceImages := kelingImageInputs(content) + if lastFrame != "" { + return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo image-to-video supports first frame only; last frame is not supported", StatusCode: 400, Retryable: false} + } + imageCount := len(referenceImages) + if firstFrame != "" { + imageCount++ + } + if imageCount > 1 { + return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo image-to-video supports exactly one first-frame image", StatusCode: 400, Retryable: false} + } + if firstFrame == "" && len(referenceImages) == 1 { + firstFrame = referenceImages[0] + } + isImageToVideo := firstFrame != "" + + resolution := strings.TrimSpace(firstNonEmptyStringValue(body, "resolution", "size")) + if resolution == "" { + resolution = "720p" + } + if resolution != "720p" && resolution != "1080p" { + return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo resolution must be 720p or 1080p", StatusCode: 400, Retryable: false} + } + + promptLimit := 3072 + if isImageToVideo { + promptLimit = 2500 + } + if utf8.RuneCountInString(prompt) > promptLimit { + return nil, "", &ClientError{Code: "invalid_parameter", Message: fmt.Sprintf("keling 3.0 turbo prompt exceeds %d characters", promptLimit), StatusCode: 400, Retryable: false} + } + + settings := map[string]any{ + "duration": int(math.Round(duration)), + "resolution": resolution, + } + options := map[string]any{ + "watermark_info": map[string]any{"enabled": boolValue(body, "watermark")}, + } + if callbackURL := strings.TrimSpace(firstNonEmptyStringValue(body, "callback_url", "callbackUrl")); callbackURL != "" { + options["callback_url"] = callbackURL + } + if externalTaskID := strings.TrimSpace(firstNonEmptyStringValue(body, "external_task_id", "externalTaskId")); externalTaskID != "" { + options["external_task_id"] = externalTaskID + } + + if isImageToVideo { + return map[string]any{ + "contents": []any{ + map[string]any{"type": "prompt", "text": prompt}, + map[string]any{"type": "first_frame", "url": firstFrame}, + }, + "settings": settings, + "options": options, + }, "/image-to-video/kling-3.0-turbo", nil + } + + aspectRatio := strings.TrimSpace(firstNonEmptyStringValue(body, "aspect_ratio", "aspectRatio", "ratio")) + if aspectRatio == "" || aspectRatio == "adaptive" || aspectRatio == "keep_ratio" { + aspectRatio = "16:9" + } + if aspectRatio != "16:9" && aspectRatio != "9:16" && aspectRatio != "1:1" { + return nil, "", &ClientError{Code: "invalid_parameter", Message: "keling 3.0 turbo aspect_ratio must be 16:9, 9:16, or 1:1", StatusCode: 400, Retryable: false} + } + settings["aspect_ratio"] = aspectRatio + return map[string]any{ + "prompt": prompt, + "settings": settings, + "options": options, + }, "/text-to-video/kling-3.0-turbo", nil +} + func firstKelingPrompt(content []map[string]any) string { for _, item := range content { if stringFromAny(item["type"]) == "text" && stringFromAny(item["role"]) != "shot_prompt" && item["shot_index"] == nil { @@ -822,6 +1072,30 @@ func kelingTaskStatus(result map[string]any) string { return strings.ToLower(strings.TrimSpace(stringFromAny(kelingData(result)["task_status"]))) } +func kelingTaskAPITask(result map[string]any, taskID string) map[string]any { + tasks := mapListFromAny(result["data"]) + for _, task := range tasks { + if strings.TrimSpace(stringFromAny(task["id"])) == taskID { + return task + } + } + if len(tasks) > 0 { + return tasks[0] + } + return map[string]any{} +} + +func kelingTaskAPIErrorMessage(candidate store.RuntimeModelCandidate, task map[string]any, result map[string]any) string { + message := strings.TrimSpace(stringFromAny(task["message"])) + if message == "" { + message = strings.TrimSpace(stringFromAny(result["message"])) + } + if message == "" { + message = "keling 3.0 turbo video task failed" + } + return fmt.Sprintf("Platform:%s,Code:%v,requestId:%s,message:%s", candidate.Provider, result["code"], stringFromAny(result["request_id"]), message) +} + func kelingTaskErrorCode(result map[string]any) string { if code := intFromAny(result["code"]); code != 0 { return fmt.Sprintf("keling_%d", code) @@ -887,6 +1161,42 @@ func kelingVideoSuccessResult(request Request, upstreamTaskID string, raw map[st } } +func kelingTaskAPIVideoSuccessResult(request Request, upstreamTaskID string, task map[string]any, raw map[string]any) map[string]any { + outputs := mapListFromAny(task["outputs"]) + items := make([]any, 0, len(outputs)) + for _, output := range outputs { + if strings.ToLower(strings.TrimSpace(stringFromAny(output["type"]))) != "video" { + continue + } + videoURL := strings.TrimSpace(stringFromAny(output["url"])) + if videoURL == "" { + continue + } + item := map[string]any{"url": videoURL, "video_url": videoURL, "type": "video"} + if duration := numericValue(output["duration"], 0); duration > 0 { + item["duration"] = duration + } + if watermarkURL := strings.TrimSpace(stringFromAny(output["watermark_url"])); watermarkURL != "" { + item["watermark_url"] = watermarkURL + } + items = append(items, item) + } + created := intFromAny(task["create_time"]) + if created == 0 { + created = int(nowUnix()) + } + return map[string]any{ + "id": upstreamTaskID, + "object": "video.generation", + "created": created, + "model": upstreamModelName(request.Candidate), + "status": "succeeded", + "upstream_task_id": upstreamTaskID, + "data": items, + "raw": raw, + } +} + func kelingVideoProgress(request Request, upstreamTaskID string) []Progress { progress := providerProgress(request) progress = append(progress, Progress{ diff --git a/apps/api/internal/clients/openai.go b/apps/api/internal/clients/openai.go index be5c73b..8ee27f5 100644 --- a/apps/api/internal/clients/openai.go +++ b/apps/api/internal/clients/openai.go @@ -43,7 +43,14 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error if endpointKind == "chat.completions" { body = NormalizeChatCompletionRequestBody(body) applyOpenAIChatReasoningParams(body, request.Candidate) + body = FilterOpenAIChatRequestBody(body) } else if request.Kind == "responses" { + body = FilterOpenAIResponsesRequestBody(body) + if _, hasInput := body["input"]; !hasInput { + if messages, hasMessages := request.Body["messages"]; hasMessages { + body["input"] = messages + } + } delete(body, "messages") if request.UpstreamPreviousResponseID != "" { body["previous_response_id"] = request.UpstreamPreviousResponseID diff --git a/apps/api/internal/clients/openai_request_params.go b/apps/api/internal/clients/openai_request_params.go new file mode 100644 index 0000000..388a8cb --- /dev/null +++ b/apps/api/internal/clients/openai_request_params.go @@ -0,0 +1,108 @@ +package clients + +import ( + "fmt" + "net/http" + "sort" +) + +// Keep these lists aligned with openai-node 6.47.0 and the public OpenAI API +// reference. The Gateway accepts a small, explicit set of routing extensions at +// ingress, but only protocol fields (plus controlled provider adaptations) are +// allowed across the upstream boundary. +var openAIChatRequestParameters = stringSet( + "messages", "model", "audio", "frequency_penalty", "function_call", "functions", + "logit_bias", "logprobs", "max_completion_tokens", "max_tokens", "metadata", + "modalities", "moderation", "n", "parallel_tool_calls", "prediction", + "presence_penalty", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention", + "reasoning_effort", "response_format", "safety_identifier", "seed", "service_tier", + "stop", "store", "stream", "stream_options", "temperature", "tool_choice", "tools", + "top_logprobs", "top_p", "user", "verbosity", "web_search_options", +) + +var openAIResponsesRequestParameters = stringSet( + "background", "context_management", "conversation", "include", "input", "instructions", + "max_output_tokens", "max_tool_calls", "metadata", "model", "moderation", + "parallel_tool_calls", "previous_response_id", "prompt", "prompt_cache_key", + "prompt_cache_options", "prompt_cache_retention", "reasoning", "safety_identifier", + "service_tier", "store", "stream", "stream_options", "temperature", "text", + "tool_choice", "tools", "top_logprobs", "top_p", "truncation", "user", +) + +var gatewayOpenAIRequestExtensions = stringSet( + "runMode", "run_mode", "conversationId", "conversation_id", "sessionId", "session_id", + "requestId", "request_id", "signal", "userMessage", "user_message", "platformId", + "platform_id", "options", "enable_thinking", "thinking_budget_tokens", "enable_web_search", + "modelType", "model_type", "capability", "capabilityType", "mode", "simulation", "testMode", +) + +var gatewayResponsesRequestExtensions = stringSet("messages", "presence_penalty", "frequency_penalty") + +var controlledOpenAIChatProviderParameters = stringSet( + "enable_thinking", "thinking_budget", "thinking", "enable_web_search", +) + +var controlledOpenAIResponsesProviderParameters = stringSet("presence_penalty", "frequency_penalty") + +func ValidateOpenAIRequestParameters(kind string, body map[string]any) error { + allowed := openAIChatRequestParameters + if kind == "responses" { + allowed = openAIResponsesRequestParameters + } + unknown := make([]string, 0) + for key := range body { + if _, ok := allowed[key]; ok { + continue + } + if _, ok := gatewayOpenAIRequestExtensions[key]; ok { + continue + } + if kind == "responses" { + if _, ok := gatewayResponsesRequestExtensions[key]; ok { + continue + } + } + unknown = append(unknown, key) + } + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + return &ClientError{ + Code: "invalid_parameter", + Message: fmt.Sprintf("Unknown parameter: %s", unknown[0]), + Param: unknown[0], + StatusCode: http.StatusBadRequest, + Retryable: false, + } +} + +func FilterOpenAIChatRequestBody(body map[string]any) map[string]any { + return filterOpenAIRequestBody(body, openAIChatRequestParameters, controlledOpenAIChatProviderParameters) +} + +func FilterOpenAIResponsesRequestBody(body map[string]any) map[string]any { + return filterOpenAIRequestBody(body, openAIResponsesRequestParameters, controlledOpenAIResponsesProviderParameters) +} + +func filterOpenAIRequestBody(body map[string]any, allowed map[string]struct{}, extensions map[string]struct{}) map[string]any { + out := make(map[string]any, len(body)) + for key, value := range body { + if _, ok := allowed[key]; ok { + out[key] = value + continue + } + if _, ok := extensions[key]; ok { + out[key] = value + } + } + return out +} + +func stringSet(values ...string) map[string]struct{} { + out := make(map[string]struct{}, len(values)) + for _, value := range values { + out[value] = struct{}{} + } + return out +} diff --git a/apps/api/internal/clients/openai_request_params_test.go b/apps/api/internal/clients/openai_request_params_test.go new file mode 100644 index 0000000..df4de80 --- /dev/null +++ b/apps/api/internal/clients/openai_request_params_test.go @@ -0,0 +1,89 @@ +package clients + +import ( + "strings" + "testing" +) + +func TestOpenAIChatOfficialParametersSurviveBoundary(t *testing.T) { + body := map[string]any{} + for key := range openAIChatRequestParameters { + body[key] = "sentinel-" + key + } + body["conversationId"] = "internal" + body["unknown"] = "must-not-leak" + + filtered := FilterOpenAIChatRequestBody(body) + for key := range openAIChatRequestParameters { + if _, ok := filtered[key]; !ok { + t.Fatalf("official Chat parameter %q was removed", key) + } + } + for _, key := range []string{"conversationId", "unknown"} { + if _, ok := filtered[key]; ok { + t.Fatalf("internal/unknown parameter %q leaked upstream", key) + } + } +} + +func TestOpenAIResponsesOfficialParametersSurviveBoundary(t *testing.T) { + body := map[string]any{} + for key := range openAIResponsesRequestParameters { + body[key] = "sentinel-" + key + } + body["request_id"] = "internal" + body["unknown"] = "must-not-leak" + + filtered := FilterOpenAIResponsesRequestBody(body) + for key := range openAIResponsesRequestParameters { + if _, ok := filtered[key]; !ok { + t.Fatalf("official Responses parameter %q was removed", key) + } + } + for _, key := range []string{"request_id", "unknown"} { + if _, ok := filtered[key]; ok { + t.Fatalf("internal/unknown parameter %q leaked upstream", key) + } + } +} + +func TestValidateOpenAIRequestParametersRejectsUnknownTopLevelField(t *testing.T) { + err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "rogue": true}) + if err == nil || ErrorCode(err) != "invalid_parameter" || !strings.Contains(err.Error(), "rogue") { + t.Fatalf("expected OpenAI-style invalid_parameter for rogue field, got %v", err) + } + if ErrorParam(err) != "rogue" { + t.Fatalf("expected rogue parameter attribution, got %q", ErrorParam(err)) + } + if err := ValidateOpenAIRequestParameters("responses", map[string]any{"model": "demo", "input": "hello", "messages": []any{}, "request_id": "internal"}); err != nil { + t.Fatalf("expected controlled Responses extensions to remain accepted, got %v", err) + } +} + +func TestResponsesFallbackMapsEquivalentCurrentParameters(t *testing.T) { + body, err := ResponsesRequestToChat(map[string]any{ + "input": "hello", "store": false, "metadata": map[string]any{"trace": "1"}, + "request_id": "internal-request", "platform_id": "internal-platform", + "moderation": map[string]any{"type": "auto"}, "prompt_cache_key": "cache-key", + "prompt_cache_options": map[string]any{"type": "ephemeral"}, "prompt_cache_retention": "in_memory", + "safety_identifier": "safe", "service_tier": "priority", "top_logprobs": 3, + "stream_options": map[string]any{"include_usage": true}, + "text": map[string]any{"format": map[string]any{"type": "text"}, "verbosity": "low"}, + }, nil) + if err != nil { + t.Fatalf("convert Responses request: %v", err) + } + for _, key := range []string{"store", "metadata", "moderation", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention", "safety_identifier", "service_tier", "top_logprobs", "stream_options", "verbosity"} { + if _, ok := body[key]; !ok { + t.Fatalf("equivalent parameter %q was not mapped", key) + } + } + if body["logprobs"] != true { + t.Fatalf("top_logprobs fallback must enable Chat logprobs: %+v", body) + } + for _, key := range []string{"request_id", "platform_id"} { + if _, ok := body[key]; ok { + t.Fatalf("internal Responses parameter %q leaked into Chat fallback: %+v", key, body) + } + } +} diff --git a/apps/api/internal/clients/responses_compat.go b/apps/api/internal/clients/responses_compat.go index f0cd984..c361890 100644 --- a/apps/api/internal/clients/responses_compat.go +++ b/apps/api/internal/clients/responses_compat.go @@ -21,11 +21,16 @@ var supportedResponseFallbackParameters = map[string]struct{}{ "model": {}, "input": {}, "messages": {}, "instructions": {}, "tools": {}, "tool_choice": {}, "parallel_tool_calls": {}, "max_output_tokens": {}, "temperature": {}, "top_p": {}, "presence_penalty": {}, "frequency_penalty": {}, "reasoning": {}, "text": {}, - "stream": {}, "store": {}, "previous_response_id": {}, "metadata": {}, "user": {}, + "stream": {}, "stream_options": {}, "store": {}, "previous_response_id": {}, "metadata": {}, "user": {}, + "moderation": {}, "prompt_cache_key": {}, "prompt_cache_options": {}, "prompt_cache_retention": {}, + "safety_identifier": {}, "service_tier": {}, "top_logprobs": {}, } func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[string]any, error) { for key := range body { + if _, internal := gatewayOpenAIRequestExtensions[key]; internal { + continue + } if _, ok := supportedResponseFallbackParameters[key]; !ok { return nil, unsupportedResponseParameter(key) } @@ -61,11 +66,19 @@ func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[st return nil, &ClientError{Code: "invalid_parameter", Message: "input is required", StatusCode: http.StatusBadRequest} } out := map[string]any{"messages": messages} - for _, key := range []string{"temperature", "top_p", "presence_penalty", "frequency_penalty", "parallel_tool_calls", "stream", "user"} { + for _, key := range []string{ + "temperature", "top_p", "presence_penalty", "frequency_penalty", "parallel_tool_calls", + "stream", "stream_options", "store", "metadata", "user", "moderation", "prompt_cache_key", + "prompt_cache_options", "prompt_cache_retention", "safety_identifier", "service_tier", + } { if value, ok := body[key]; ok { out[key] = value } } + if value, ok := body["top_logprobs"]; ok { + out["top_logprobs"] = value + out["logprobs"] = true + } if value, ok := body["max_output_tokens"]; ok { out["max_tokens"] = value } @@ -84,13 +97,16 @@ func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[st } } if rawText, ok := body["text"]; ok { - responseFormat, err := responseTextFormat(rawText) + responseFormat, verbosity, err := responseTextParams(rawText) if err != nil { return nil, err } if responseFormat != nil { out["response_format"] = responseFormat } + if verbosity != nil { + out["verbosity"] = verbosity + } } if rawTools, ok := body["tools"]; ok { tools, err := responseToolsToChat(rawTools) @@ -212,31 +228,32 @@ func responseToolChoiceToChat(value any) (any, error) { return map[string]any{"type": "function", "function": map[string]any{"name": choice["name"]}}, nil } -func responseTextFormat(value any) (map[string]any, error) { +func responseTextParams(value any) (map[string]any, any, error) { text, ok := value.(map[string]any) if !ok { - return nil, unsupportedResponseParameter("text") + return nil, nil, unsupportedResponseParameter("text") } for key := range text { - if key != "format" { - return nil, unsupportedResponseParameter("text." + key) + if key != "format" && key != "verbosity" { + return nil, nil, unsupportedResponseParameter("text." + key) } } + verbosity := text["verbosity"] format, ok := text["format"].(map[string]any) if !ok || len(format) == 0 { - return nil, nil + return nil, verbosity, nil } switch stringFromAny(format["type"]) { case "text": - return map[string]any{"type": "text"}, nil + return map[string]any{"type": "text"}, verbosity, nil case "json_object": - return map[string]any{"type": "json_object"}, nil + return map[string]any{"type": "json_object"}, verbosity, nil case "json_schema": return map[string]any{"type": "json_schema", "json_schema": map[string]any{ "name": format["name"], "schema": format["schema"], "strict": format["strict"], - }}, nil + }}, verbosity, nil default: - return nil, unsupportedResponseParameter("text.format.type") + return nil, nil, unsupportedResponseParameter("text.format.type") } } diff --git a/apps/api/internal/clients/responses_compat_test.go b/apps/api/internal/clients/responses_compat_test.go index 8c6b4c4..0adb741 100644 --- a/apps/api/internal/clients/responses_compat_test.go +++ b/apps/api/internal/clients/responses_compat_test.go @@ -23,6 +23,10 @@ func TestOpenAIResponsesNativeUsesResponsesEndpointAndPreservesVendorIDs(t *test if body["messages"] != nil { t.Fatalf("native Responses request must not contain messages: %+v", body) } + input, _ := body["input"].([]any) + if len(input) != 1 { + t.Fatalf("native Responses request must translate controlled messages to input: %+v", body) + } if body["previous_response_id"] != "resp_upstream_parent" { t.Fatalf("expected translated upstream previous id, got %+v", body["previous_response_id"]) } @@ -41,7 +45,7 @@ func TestOpenAIResponsesNativeUsesResponsesEndpointAndPreservesVendorIDs(t *test response, err := (OpenAIClient{}).Run(context.Background(), Request{ Kind: "responses", Model: "Demo", - Body: map[string]any{"input": "hello", "messages": []any{map[string]any{"role": "user", "content": "illegal"}}}, + Body: map[string]any{"messages": []any{map[string]any{"role": "user", "content": "hello"}}}, Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "demo", Credentials: map[string]any{"apiKey": "secret"}}, UpstreamProtocol: ProtocolOpenAIResponses, PublicResponseID: "resp_12345678901234567890123456789012", PublicPreviousResponseID: "resp_abcdefghijklmnopqrstuvwxyz123456", UpstreamPreviousResponseID: "resp_upstream_parent", diff --git a/apps/api/internal/clients/types.go b/apps/api/internal/clients/types.go index 89c1ca4..5462f05 100644 --- a/apps/api/internal/clients/types.go +++ b/apps/api/internal/clients/types.go @@ -103,6 +103,7 @@ type VoiceCloneDeleter interface { type ClientError struct { Code string Message string + Param string StatusCode int RequestID string ResponseStartedAt time.Time @@ -111,6 +112,14 @@ type ClientError struct { Retryable bool } +func ErrorParam(err error) string { + var clientErr *ClientError + if errors.As(err, &clientErr) { + return clientErr.Param + } + return "" +} + func (e *ClientError) Error() string { if e.Message != "" { return e.Message diff --git a/apps/api/internal/clients/volces.go b/apps/api/internal/clients/volces.go index dd01b78..9eefa56 100644 --- a/apps/api/internal/clients/volces.go +++ b/apps/api/internal/clients/volces.go @@ -74,12 +74,13 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri submitStartedAt := time.Now() submitRequestID := strings.TrimSpace(request.RemoteTaskID) upstreamTaskID := strings.TrimSpace(request.RemoteTaskID) + taskPath := volcesVideoTaskPath(request) if upstreamTaskID == "" { body := volcesVideoBody(request) if err := validateVolcesVideoTaskBody(body); err != nil { return Response{}, err } - submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body) + submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, taskPath, apiKey, body) submitRequestID = requestID if err != nil { return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now()) @@ -112,7 +113,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri } pollStartedAt := time.Now() - pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks/"+upstreamTaskID, apiKey) + pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey) pollFinishedAt := time.Now() requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID) if err != nil { @@ -159,6 +160,21 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri } } +func volcesVideoTaskPath(request Request) string { + path := firstNonEmptyStringValue( + request.Candidate.PlatformConfig, + "volcesVideoTaskPath", + "videoTaskPath", + ) + if path == "" { + return "/contents/generations/tasks" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return strings.TrimRight(path, "/") +} + func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) { raw, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw)) @@ -173,7 +189,11 @@ func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL str } requestID := requestIDFromHTTPResponse(resp) result, err := decodeHTTPResponse(resp) - return result, requestID, err + if err != nil { + return result, requestID, err + } + result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result) + return result, firstNonEmpty(requestID, envelopeRequestID), err } func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, error) { @@ -188,7 +208,64 @@ func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL stri } requestID := requestIDFromHTTPResponse(resp) result, err := decodeHTTPResponse(resp) - return result, requestID, err + if err != nil { + return result, requestID, err + } + result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result) + return result, firstNonEmpty(requestID, envelopeRequestID), err +} + +func normalizeVolcesCompatibleResult(result map[string]any) (map[string]any, string, error) { + requestID := firstNonEmpty( + stringFromAny(result["request_id"]), + stringFromAny(result["requestId"]), + ) + if errorObject, ok := result["error"].(map[string]any); ok { + code := firstNonEmpty( + stringFromAny(errorObject["code"]), + stringFromAny(errorObject["type"]), + "volces_compatible_error", + ) + message := strings.TrimSpace(stringFromAny(errorObject["message"])) + if message == "" { + message = "volces compatible request failed" + } + return result, requestID, &ClientError{ + Code: code, + Message: message, + RequestID: requestID, + Retryable: false, + } + } + rawCode, hasCode := result["code"] + if !hasCode { + return result, requestID, nil + } + code, validCode := volcesIntFromAny(rawCode) + if !validCode { + return result, requestID, nil + } + if code != 0 { + message := strings.TrimSpace(stringFromAny(result["message"])) + if message == "" { + message = fmt.Sprintf("volces compatible request failed with code %d", code) + } + return result, requestID, &ClientError{ + Code: fmt.Sprintf("volces_%d", code), + Message: message, + RequestID: requestID, + Retryable: false, + } + } + data, ok := result["data"].(map[string]any) + if !ok { + return result, requestID, nil + } + normalized := cloneBody(data) + if requestID != "" && requestIDFromResult(normalized) == "" { + normalized["request_id"] = requestID + } + return normalized, requestID, nil } func volcesImageBody(request Request) map[string]any { diff --git a/apps/api/internal/clients/volces_deyun_test.go b/apps/api/internal/clients/volces_deyun_test.go new file mode 100644 index 0000000..04485a1 --- /dev/null +++ b/apps/api/internal/clients/volces_deyun_test.go @@ -0,0 +1,129 @@ +package clients + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestVolcesClientSupportsDeyunEnvelope(t *testing.T) { + var submitted bool + var polled bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer deyun-secret" { + t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization")) + } + switch r.Method + " " + r.URL.Path { + case "POST /c39/api/v3/video/tasks": + submitted = true + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "request_id": "deyun-submit-request", + "data": map[string]any{"id": "deyun-task-1"}, + }) + case "GET /c39/api/v3/video/tasks/deyun-task-1": + polled = true + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "request_id": "deyun-poll-request", + "data": map[string]any{ + "id": "deyun-task-1", + "status": "succeeded", + "created_at": 123, + "content": map[string]any{ + "video_url": "https://example.com/deyun.mp4", + }, + }, + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{ + Kind: "videos.generations", + ModelType: "video_generate", + Model: "deyun-seedance-2.0-canary", + Body: map[string]any{ + "prompt": "A red cube rotates on a white table", + "resolution": "480p", + "ratio": "16:9", + "duration": 4, + "generate_audio": false, + }, + Candidate: store.RuntimeModelCandidate{ + BaseURL: server.URL + "/c39/api/v3", + ProviderModelName: "doubao-seedance-2-0", + Credentials: map[string]any{"apiKey": "deyun-secret"}, + PlatformConfig: map[string]any{ + "volcesPollIntervalMs": 100, + "volcesPollTimeoutSeconds": 1, + "volcesVideoTaskPath": "/video/tasks", + }, + }, + }) + if err != nil { + t.Fatalf("run deyun-compatible video task: %v", err) + } + if !submitted || !polled { + t.Fatalf("expected submit and poll, submitted=%v polled=%v", submitted, polled) + } + if response.RequestID != "deyun-poll-request" { + t.Fatalf("unexpected request id: %s", response.RequestID) + } + data, _ := response.Result["data"].([]any) + item, _ := data[0].(map[string]any) + if response.Result["upstream_task_id"] != "deyun-task-1" || item["url"] != "https://example.com/deyun.mp4" { + t.Fatalf("unexpected response: %+v", response.Result) + } +} + +func TestNormalizeVolcesCompatibleResultPreservesNativeResponse(t *testing.T) { + native := map[string]any{"id": "native-task", "status": "queued"} + got, requestID, err := normalizeVolcesCompatibleResult(native) + if err != nil { + t.Fatalf("normalize native response: %v", err) + } + if got["id"] != "native-task" || requestID != "" { + t.Fatalf("native response changed unexpectedly: %+v requestID=%q", got, requestID) + } +} + +func TestNormalizeVolcesCompatibleResultRejectsBusinessError(t *testing.T) { + _, requestID, err := normalizeVolcesCompatibleResult(map[string]any{ + "code": 1004, + "message": "Authorization is expired", + "request_id": "deyun-error-request", + }) + if err == nil { + t.Fatal("expected business error") + } + if requestID != "deyun-error-request" || ErrorCode(err) != "volces_1004" { + t.Fatalf("unexpected error metadata requestID=%q code=%q err=%v", requestID, ErrorCode(err), err) + } + if !strings.Contains(err.Error(), "Authorization is expired") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestNormalizeVolcesCompatibleResultRejectsHTTP200ErrorObject(t *testing.T) { + _, _, err := normalizeVolcesCompatibleResult(map[string]any{ + "error": map[string]any{ + "code": "ModelNotOpen", + "message": "model service is not activated", + "type": "Not Found", + }, + }) + if err == nil { + t.Fatal("expected HTTP 200 error object to fail") + } + if ErrorCode(err) != "ModelNotOpen" || !strings.Contains(err.Error(), "not activated") { + t.Fatalf("unexpected error: code=%q err=%v", ErrorCode(err), err) + } +} diff --git a/apps/api/internal/httpapi/chat_completions_mode_test.go b/apps/api/internal/httpapi/chat_completions_mode_test.go index d28c873..df744fe 100644 --- a/apps/api/internal/httpapi/chat_completions_mode_test.go +++ b/apps/api/internal/httpapi/chat_completions_mode_test.go @@ -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) { diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 7c0ee71..b5c9767 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -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": diff --git a/apps/api/internal/httpapi/keling_simulation_integration_test.go b/apps/api/internal/httpapi/keling_simulation_integration_test.go new file mode 100644 index 0000000..421ce32 --- /dev/null +++ b/apps/api/internal/httpapi/keling_simulation_integration_test.go @@ -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") +} diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index 2d2363e..279b5d4 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -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 { diff --git a/apps/api/internal/httpapi/response.go b/apps/api/internal/httpapi/response.go index 27a144c..504cf57 100644 --- a/apps/api/internal/httpapi/response.go +++ b/apps/api/internal/httpapi/response.go @@ -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 { diff --git a/apps/api/internal/runner/output_token_limit.go b/apps/api/internal/runner/output_token_limit.go new file mode 100644 index 0000000..329e546 --- /dev/null +++ b/apps/api/internal/runner/output_token_limit.go @@ -0,0 +1,232 @@ +package runner + +import ( + "fmt" + "math" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +const ( + volcesOutputTokenThreshold = 10240 + volcesOutputCapabilityErrorCode = "model_capability_configuration_error" + volcesOutputInvalidParameterCode = "invalid_parameter" +) + +type outputTokenLimitProcessor struct{} + +func (outputTokenLimitProcessor) Name() string { return "OutputTokenLimitProcessor" } + +func (outputTokenLimitProcessor) ShouldProcess(_ map[string]any, modelType string, context *paramProcessContext) bool { + return context != nil && isOpenAITextGenerationKind(context.kind) && isTextOutputModelType(modelType) && isVolcesCandidate(context.candidate) +} + +func (outputTokenLimitProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool { + modelMax, sourceType, capabilityValue, ok := candidateMaxOutputTokens(context.candidate, modelType) + path := capabilityPath(sourceType, "max_output_tokens") + if !ok { + return context.reject( + "OutputTokenLimitProcessor", + outputTokenParameter(context.kind), + nil, + "火山引擎文本候选未配置正整数 max_output_tokens,已禁止执行该候选。", + path, + capabilityValue, + ) + } + + if context.kind == "chat.completions" { + if value, explicit := nonNullParameter(params, "max_tokens"); explicit { + if parsed, valid := positiveInteger(value); !valid || parsed > modelMax { + return context.reject("OutputTokenLimitProcessor", "max_tokens", value, fmt.Sprintf("max_tokens must be a positive integer no greater than the selected Volcengine model limit (%d)", modelMax), path, capabilityValue) + } + return true + } + if _, explicit := nonNullParameter(params, "max_completion_tokens"); explicit { + return true + } + value := defaultVolcesOutputTokens(modelMax) + params["max_tokens"] = value + context.recordChange( + "OutputTokenLimitProcessor", "set", "max_tokens", nil, value, + fmt.Sprintf("火山候选未显式设置输出上限:floor(%d/3)=%d,阈值=%d,注入候选级默认值。", modelMax, modelMax/3, volcesOutputTokenThreshold), + path, capabilityValue, + ) + return true + } + + if value, explicit := nonNullParameter(params, "max_output_tokens"); explicit { + if parsed, valid := positiveInteger(value); !valid || parsed > modelMax { + return context.reject("OutputTokenLimitProcessor", "max_output_tokens", value, fmt.Sprintf("max_output_tokens must be a positive integer no greater than the selected Volcengine model limit (%d)", modelMax), path, capabilityValue) + } + return true + } + value := defaultVolcesOutputTokens(modelMax) + params["max_output_tokens"] = value + context.recordChange( + "OutputTokenLimitProcessor", "set", "max_output_tokens", nil, value, + fmt.Sprintf("火山候选未显式设置输出上限:floor(%d/3)=%d,阈值=%d,注入候选级默认值。", modelMax, modelMax/3, volcesOutputTokenThreshold), + path, capabilityValue, + ) + return true +} + +func filterRuntimeCandidatesByOutputTokens(kind string, requestedModel string, modelType string, body map[string]any, candidates []store.RuntimeModelCandidate) ([]store.RuntimeModelCandidate, map[string]any, error) { + if !isOpenAITextGenerationKind(kind) || !isTextOutputModelType(modelType) || len(candidates) == 0 { + return candidates, nil, nil + } + filtered := make([]store.RuntimeModelCandidate, 0, len(candidates)) + rejected := make([]map[string]any, 0) + invalidExplicit := false + for _, candidate := range candidates { + if !isVolcesCandidate(candidate) { + filtered = append(filtered, candidate) + continue + } + modelMax, sourceType, raw, configured := candidateMaxOutputTokens(candidate, modelType) + detail := map[string]any{ + "platformId": candidate.PlatformID, "platformKey": candidate.PlatformKey, "provider": candidate.Provider, + "platformModelId": candidate.PlatformModelID, "providerModelName": candidate.ProviderModelName, + "modelType": modelType, "capabilityPath": capabilityPath(sourceType, "max_output_tokens"), "capabilityValue": raw, + } + if !configured { + detail["reason"] = "max_output_tokens_missing" + rejected = append(rejected, detail) + continue + } + if parameter, value, explicit := explicitOutputTokenParameter(kind, body); explicit { + parsed, valid := positiveInteger(value) + if !valid || (parameter != "max_completion_tokens" && parsed > modelMax) { + detail["reason"] = "requested_output_tokens_exceed_capability" + detail["parameter"] = parameter + detail["requestedValue"] = value + invalidExplicit = true + rejected = append(rejected, detail) + continue + } + } + filtered = append(filtered, candidate) + } + if len(rejected) == 0 { + return filtered, nil, nil + } + summary := map[string]any{ + "filter": "volces_output_token_limit", "kind": kind, "requestedModel": requestedModel, "modelType": modelType, + "candidateCount": len(candidates), "supportedCandidateCount": len(filtered), "filteredCandidateCount": len(rejected), + "threshold": volcesOutputTokenThreshold, "formula": "third=floor(modelMaxOutputTokens/3); default=third>=10240?third:modelMaxOutputTokens", + "rejectedCandidates": rejected, + } + if len(filtered) > 0 { + return filtered, summary, nil + } + code := volcesOutputCapabilityErrorCode + message := "所有火山引擎文本候选都缺少有效的 max_output_tokens 能力配置" + if invalidExplicit { + code = volcesOutputInvalidParameterCode + message = "请求的输出 token 上限超过所有可用火山引擎候选的模型能力" + } + summary["code"] = code + return nil, summary, &store.ModelCandidateUnavailableError{Code: code, Message: message, Details: summary} +} + +func defaultVolcesOutputTokens(modelMax int) int { + third := modelMax / 3 + if third >= volcesOutputTokenThreshold { + return third + } + return modelMax +} + +func isVolcesCandidate(candidate store.RuntimeModelCandidate) bool { + provider := strings.ToLower(strings.TrimSpace(candidate.Provider)) + baseURL := strings.ToLower(strings.TrimSpace(candidate.BaseURL)) + return provider == "volces-openai" || strings.Contains(baseURL, "volces.com") || strings.Contains(baseURL, "byteplus.com") +} + +func candidateMaxOutputTokens(candidate store.RuntimeModelCandidate, modelType string) (int, string, any, bool) { + capabilities := effectiveModelCapability(candidate) + seen := map[string]struct{}{} + for _, candidateType := range []string{candidate.ModelType, modelType, "text_generate"} { + candidateType = strings.TrimSpace(candidateType) + if candidateType == "" { + continue + } + if _, ok := seen[candidateType]; ok { + continue + } + seen[candidateType] = struct{}{} + capability := capabilityForType(capabilities, candidateType) + if capability == nil { + continue + } + raw, exists := capability["max_output_tokens"] + if !exists { + continue + } + value, ok := positiveInteger(raw) + return value, candidateType, raw, ok + } + return 0, firstNonEmptyString(candidate.ModelType, modelType, "text_generate"), nil, false +} + +func positiveInteger(value any) (int, bool) { + number := floatFromAny(value) + if number <= 0 || math.Trunc(number) != number || number > float64(math.MaxInt) { + return 0, false + } + return int(number), true +} + +func nonNullParameter(body map[string]any, key string) (any, bool) { + value, ok := body[key] + return value, ok && value != nil +} + +func explicitOutputTokenParameter(kind string, body map[string]any) (string, any, bool) { + if kind == "responses" { + value, ok := nonNullParameter(body, "max_output_tokens") + return "max_output_tokens", value, ok + } + if value, ok := nonNullParameter(body, "max_tokens"); ok { + return "max_tokens", value, true + } + value, ok := nonNullParameter(body, "max_completion_tokens") + return "max_completion_tokens", value, ok +} + +func outputTokenParameter(kind string) string { + if kind == "responses" { + return "max_output_tokens" + } + return "max_tokens" +} + +func isOpenAITextGenerationKind(kind string) bool { + return kind == "chat.completions" || kind == "responses" +} + +func isTextOutputModelType(modelType string) bool { + switch strings.TrimSpace(modelType) { + case "", "text_generate", "chat", "responses", "text": + return true + default: + return false + } +} + +func mergeCandidateFilterSummaries(summaries ...map[string]any) map[string]any { + nonEmpty := make([]any, 0, len(summaries)) + for _, summary := range summaries { + if len(summary) > 0 { + nonEmpty = append(nonEmpty, summary) + } + } + if len(nonEmpty) == 0 { + return nil + } + if len(nonEmpty) == 1 { + return nonEmpty[0].(map[string]any) + } + return map[string]any{"filters": nonEmpty} +} diff --git a/apps/api/internal/runner/output_token_limit_test.go b/apps/api/internal/runner/output_token_limit_test.go new file mode 100644 index 0000000..37262d4 --- /dev/null +++ b/apps/api/internal/runner/output_token_limit_test.go @@ -0,0 +1,113 @@ +package runner + +import ( + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func volcTextCandidate(maxOutput any) store.RuntimeModelCandidate { + capability := map[string]any{} + if maxOutput != nil { + capability["max_output_tokens"] = maxOutput + } + return store.RuntimeModelCandidate{ + Provider: "volces-openai", BaseURL: "https://ark.cn-beijing.volces.com/api/v3", + ModelType: "text_generate", ProviderModelName: "demo", + Capabilities: map[string]any{"text_generate": capability}, + } +} + +func TestDefaultVolcesOutputTokens(t *testing.T) { + tests := []struct { + name string + max int + want int + }{ + {name: "128K", max: 131072, want: 43690}, + {name: "32K", max: 32768, want: 10922}, + {name: "24K below threshold", max: 24576, want: 24576}, + {name: "threshold exact", max: 30720, want: 10240}, + {name: "threshold minus one", max: 30719, want: 30719}, + {name: "floor", max: 131071, want: 43690}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := defaultVolcesOutputTokens(test.max); got != test.want { + t.Fatalf("defaultVolcesOutputTokens(%d)=%d, want %d", test.max, got, test.want) + } + }) + } +} + +func TestVolcesOutputProcessorInjectsCandidateSpecificDefaults(t *testing.T) { + chat := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}, "max_tokens": nil}, volcTextCandidate(131072)) + if chat.Err != nil || chat.Body["max_tokens"] != 43690 { + t.Fatalf("unexpected Chat preprocessing: body=%+v err=%v", chat.Body, chat.Err) + } + if len(chat.Log.Changes) != 1 || chat.Log.Changes[0].CapabilityPath != "capabilities.text_generate.max_output_tokens" { + t.Fatalf("expected auditable capability source, got %+v", chat.Log.Changes) + } + + responses := preprocessRequestWithLog("responses", map[string]any{"input": "hello", "max_output_tokens": nil}, volcTextCandidate(24576)) + if responses.Err != nil || responses.Body["max_output_tokens"] != 24576 { + t.Fatalf("unexpected Responses preprocessing: body=%+v err=%v", responses.Body, responses.Err) + } +} + +func TestVolcesOutputProcessorPreservesExplicitLimits(t *testing.T) { + for _, body := range []map[string]any{ + {"messages": []any{}, "max_tokens": 1234}, + {"messages": []any{}, "max_completion_tokens": 2345}, + {"messages": []any{}, "max_tokens": 1234, "max_completion_tokens": 2345}, + } { + result := preprocessRequestWithLog("chat.completions", body, volcTextCandidate(32768)) + if result.Err != nil || len(result.Log.Changes) != 0 { + t.Fatalf("explicit Chat limit should remain unchanged: body=%+v err=%v changes=%+v", result.Body, result.Err, result.Log.Changes) + } + } + result := preprocessRequestWithLog("responses", map[string]any{"input": "hello", "max_output_tokens": 3456}, volcTextCandidate(32768)) + if result.Err != nil || result.Body["max_output_tokens"] != 3456 || len(result.Log.Changes) != 0 { + t.Fatalf("explicit Responses limit should remain unchanged: %+v", result) + } +} + +func TestVolcesOutputCandidateFilterSupportsFailover(t *testing.T) { + missing := volcTextCandidate(nil) + missing.PlatformID = "missing" + valid := volcTextCandidate(32768) + valid.PlatformID = "valid" + filtered, summary, err := filterRuntimeCandidatesByOutputTokens("chat.completions", "demo", "text_generate", map[string]any{"messages": []any{}}, []store.RuntimeModelCandidate{missing, valid}) + if err != nil || len(filtered) != 1 || filtered[0].PlatformID != "valid" { + t.Fatalf("expected missing capability candidate to be skipped: filtered=%+v summary=%+v err=%v", filtered, summary, err) + } + result := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}}, filtered[0]) + if result.Body["max_tokens"] != 10922 { + t.Fatalf("failover candidate must recalculate its own default, got %+v", result.Body) + } +} + +func TestVolcesOutputCandidateFilterRejectsMissingAndExceededCapabilities(t *testing.T) { + _, _, err := filterRuntimeCandidatesByOutputTokens("responses", "demo", "text_generate", map[string]any{"input": "hello"}, []store.RuntimeModelCandidate{volcTextCandidate(nil)}) + if store.ModelCandidateErrorCode(err) != volcesOutputCapabilityErrorCode { + t.Fatalf("expected capability configuration error, got %v", err) + } + _, _, err = filterRuntimeCandidatesByOutputTokens("chat.completions", "demo", "text_generate", map[string]any{"messages": []any{}, "max_tokens": 32769}, []store.RuntimeModelCandidate{volcTextCandidate(32768)}) + if store.ModelCandidateErrorCode(err) != volcesOutputInvalidParameterCode { + t.Fatalf("expected invalid_parameter for explicit limit, got %v", err) + } +} + +func TestNonVolcesOutputProcessorDoesNotInject(t *testing.T) { + candidate := volcTextCandidate(131072) + candidate.Provider = "openai" + candidate.BaseURL = "https://api.openai.com/v1" + chat := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}}, candidate) + if _, ok := chat.Body["max_tokens"]; ok { + t.Fatalf("non-Volcengine Chat candidate must remain unchanged: %+v", chat.Body) + } + responses := preprocessRequestWithLog("responses", map[string]any{"input": "hello"}, candidate) + if _, ok := responses.Body["max_output_tokens"]; ok { + t.Fatalf("non-Volcengine Responses candidate must remain unchanged: %+v", responses.Body) + } +} diff --git a/apps/api/internal/runner/param_processor.go b/apps/api/internal/runner/param_processor.go index f1216bf..18a42c1 100644 --- a/apps/api/internal/runner/param_processor.go +++ b/apps/api/internal/runner/param_processor.go @@ -55,6 +55,7 @@ type parameterPreprocessChange struct { func NewParamProcessorChain() ParamProcessorChain { return ParamProcessorChain{ processors: []paramProcessor{ + outputTokenLimitProcessor{}, resolutionNormalizeProcessor{}, aspectRatioProcessor{}, imageSizeProcessor{}, diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index f5adbd5..abda6fb 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -28,6 +28,10 @@ func (s *Service) Estimate(ctx context.Context, kind string, model string, body if err != nil { return EstimateResult{}, err } + candidates, _, err = filterRuntimeCandidatesByOutputTokens(kind, model, modelType, body, candidates) + if err != nil { + return EstimateResult{}, err + } candidate := candidates[0] body = preprocessRequest(kind, body, candidate) items := s.estimatedBillings(ctx, user, kind, body, candidate) diff --git a/apps/api/internal/runner/queue_worker.go b/apps/api/internal/runner/queue_worker.go index 040d3ba..2b3e8b7 100644 --- a/apps/api/internal/runner/queue_worker.go +++ b/apps/api/internal/runner/queue_worker.go @@ -88,7 +88,10 @@ func (s *Service) startRiverQueue(ctx context.Context) error { Queues: map[string]river.QueueConfig{ asyncTaskQueueName: {MaxWorkers: 32}, }, - RescueStuckJobsAfter: 30 * time.Second, + // Provider-backed media jobs commonly poll for 10-20 minutes. River may + // execute a still-running job again once this window elapses, so keep the + // rescue horizon above the longest configured provider poll timeout. + RescueStuckJobsAfter: time.Hour, TestOnly: s.cfg.AppEnv == "test", Workers: workers, }) diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index fb0409c..6461363 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -230,6 +230,22 @@ func (s *Service) execute(ctx context.Context, task store.GatewayTask, user *aut } return Result{Task: failed, Output: failed.Result}, err } + var outputTokenFilterSummary map[string]any + candidates, outputTokenFilterSummary, err = filterRuntimeCandidatesByOutputTokens(task.Kind, task.Model, modelType, body, candidates) + candidateFilterSummary = mergeCandidateFilterSummaries(candidateFilterSummary, outputTokenFilterSummary) + if err != nil { + candidateFilterMetrics := candidateCapabilityFilterMetrics(candidateFilterSummary) + s.recordFailedAttempt(ctx, failedAttemptRecord{ + Task: task, Body: body, AttemptNo: task.AttemptCount + 1, Code: store.ModelCandidateErrorCode(err), Cause: err, + Simulated: task.RunMode == "simulation", Scope: "candidate_output_token_filter", Reason: store.ModelCandidateErrorCode(err), + ExtraMetrics: []map[string]any{candidateFilterMetrics}, ModelType: modelType, + }) + failed, finishErr := s.failTask(ctx, task.ID, store.ModelCandidateErrorCode(err), err.Error(), task.RunMode == "simulation", err, candidateFilterMetrics) + if finishErr != nil { + return Result{}, finishErr + } + return Result{Task: failed, Output: failed.Result}, err + } if task.Kind == "responses" { candidates, err = prepareResponseCandidates(candidates, responseExecution) if err != nil { @@ -839,7 +855,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user } func (s *Service) recordTaskParameterPreprocessing(ctx context.Context, taskID string, attemptID string, attemptNo int, candidate store.RuntimeModelCandidate, log parameterPreprocessingLog) error { - if skipTaskParameterPreprocessingLog(log.ModelType) { + if skipTaskParameterPreprocessingLog(log.ModelType) && !log.Changed { return nil } _, err := s.store.CreateTaskParamPreprocessingLog(ctx, store.CreateTaskParamPreprocessingLogInput{ @@ -1348,10 +1364,22 @@ func validateRequest(kind string, body map[string]any) error { if err := clients.ValidateOpenAIReasoningEffort(body["reasoning_effort"]); err != nil { return err } + for _, key := range []string{"max_tokens", "max_completion_tokens"} { + if value, explicit := nonNullParameter(body, key); explicit { + if _, ok := positiveInteger(value); !ok { + return &clients.ClientError{Code: "invalid_parameter", Message: key + " must be a positive integer", Param: key, StatusCode: 400, Retryable: false} + } + } + } case "responses": if body["input"] == nil && body["messages"] == nil { return errors.New("input or messages is required") } + if value, explicit := nonNullParameter(body, "max_output_tokens"); explicit { + if _, ok := positiveInteger(value); !ok { + return &clients.ClientError{Code: "invalid_parameter", Message: "max_output_tokens must be a positive integer", Param: "max_output_tokens", StatusCode: 400, Retryable: false} + } + } case "embeddings": if body["input"] == nil { return errors.New("input is required") diff --git a/apps/api/internal/runner/service_test.go b/apps/api/internal/runner/service_test.go index db21854..ce99869 100644 --- a/apps/api/internal/runner/service_test.go +++ b/apps/api/internal/runner/service_test.go @@ -16,7 +16,7 @@ func (namedClient) Run(context.Context, clients.Request) (clients.Response, erro } func TestValidateRequestAcceptsOpenAIReasoningEffort(t *testing.T) { - for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh"} { + for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"} { t.Run(effort, func(t *testing.T) { err := validateRequest("chat.completions", map[string]any{ "messages": []any{map[string]any{"role": "user", "content": "ping"}}, @@ -30,7 +30,7 @@ func TestValidateRequestAcceptsOpenAIReasoningEffort(t *testing.T) { } func TestValidateRequestRejectsNonOpenAIReasoningEffort(t *testing.T) { - for _, effort := range []string{"max", "auto"} { + for _, effort := range []string{"auto"} { t.Run(effort, func(t *testing.T) { err := validateRequest("chat.completions", map[string]any{ "messages": []any{map[string]any{"role": "user", "content": "ping"}}, diff --git a/apps/api/internal/store/platform_models.go b/apps/api/internal/store/platform_models.go index 83c284a..6c5f7d4 100644 --- a/apps/api/internal/store/platform_models.go +++ b/apps/api/internal/store/platform_models.go @@ -3,6 +3,8 @@ package store import ( "context" "encoding/json" + "fmt" + "math" "strings" "github.com/jackc/pgx/v5" @@ -116,6 +118,9 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, if len(capabilities) == 0 { capabilities = EffectivePlatformModelCapabilities(base.Capabilities, input.CapabilityOverride) } + if err := validateEnabledVolcesTextModelCapabilities(ctx, q, input, capabilities); err != nil { + return PlatformModel{}, err + } billingConfig := input.BillingConfig if len(billingConfig) == 0 { billingConfig = mergeObjects(base.BaseBillingConfig, input.BillingConfigOverride) @@ -262,6 +267,76 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_ return model, nil } +func validateEnabledVolcesTextModelCapabilities(ctx context.Context, q platformModelQuerier, input CreatePlatformModelInput, capabilities map[string]any) error { + // createPlatformModel enables/upserts models unconditionally, so every text + // model that reaches this path must already satisfy the Volcengine invariant. + if !containsTextOutputModelType(input.ModelType) { + return nil + } + var provider string + var baseURL string + if err := q.QueryRow(ctx, `SELECT provider, COALESCE(base_url, '') FROM integration_platforms WHERE id = $1::uuid`, input.PlatformID).Scan(&provider, &baseURL); err != nil { + return err + } + provider = strings.ToLower(strings.TrimSpace(provider)) + baseURL = strings.ToLower(strings.TrimSpace(baseURL)) + if provider != "volces-openai" && !strings.Contains(baseURL, "volces.com") && !strings.Contains(baseURL, "byteplus.com") { + return nil + } + seen := map[string]struct{}{} + for _, modelType := range append(append(StringList{}, input.ModelType...), "text_generate") { + modelType = strings.TrimSpace(modelType) + if modelType == "" { + continue + } + if _, ok := seen[modelType]; ok { + continue + } + seen[modelType] = struct{}{} + capability, _ := capabilities[modelType].(map[string]any) + if value, ok := positiveWholeNumber(capability["max_output_tokens"]); ok && value > 0 { + return nil + } + } + return fmt.Errorf("%w: enabled Volcengine text model %q requires a positive integer max_output_tokens capability for its text model type or text_generate fallback", ErrInvalidPlatformModelConfiguration, input.ProviderModelName) +} + +func containsTextOutputModelType(values StringList) bool { + for _, value := range values { + switch strings.ToLower(strings.TrimSpace(value)) { + case "text_generate", "chat", "responses", "text": + return true + } + } + return false +} + +func positiveWholeNumber(value any) (int64, bool) { + var number float64 + switch typed := value.(type) { + case int: + number = float64(typed) + case int32: + number = float64(typed) + case int64: + number = float64(typed) + case float64: + number = typed + case json.Number: + parsed, err := typed.Float64() + if err != nil { + return 0, false + } + number = parsed + default: + return 0, false + } + if number <= 0 || math.Trunc(number) != number || number > math.MaxInt64 { + return 0, false + } + return int64(number), true +} + func (s *Store) DeletePlatformModel(ctx context.Context, id string) error { tx, err := s.pool.Begin(ctx) if err != nil { diff --git a/apps/api/internal/store/runtime_types.go b/apps/api/internal/store/runtime_types.go index 6819ff7..c4c4b5f 100644 --- a/apps/api/internal/store/runtime_types.go +++ b/apps/api/internal/store/runtime_types.go @@ -7,8 +7,9 @@ import ( ) var ( - ErrNoModelCandidate = errors.New("no enabled platform model matches request") - ErrRateLimited = errors.New("rate limit exceeded") + ErrNoModelCandidate = errors.New("no enabled platform model matches request") + ErrRateLimited = errors.New("rate limit exceeded") + ErrInvalidPlatformModelConfiguration = errors.New("invalid platform model configuration") ) type ModelCandidateUnavailableError struct { diff --git a/apps/api/migrations/0062_keling_30_turbo.sql b/apps/api/migrations/0062_keling_30_turbo.sql new file mode 100644 index 0000000..d554634 --- /dev/null +++ b/apps/api/migrations/0062_keling_30_turbo.sql @@ -0,0 +1,216 @@ +UPDATE model_catalog_providers +SET default_auth_type = 'APIKey', + updated_at = now() +WHERE provider_key = 'keling' + OR provider_code = 'keling'; + +UPDATE integration_platforms +SET auth_type = CASE + WHEN COALESCE(NULLIF(trim(credentials->>'accessKey'), ''), NULLIF(trim(credentials->>'secretKey'), '')) IS NULL + THEN 'APIKey' + ELSE auth_type + END, + credentials = CASE + WHEN credentials ? 'apiKey' THEN credentials + ELSE credentials || '{"apiKey": ""}'::jsonb + END, + updated_at = now() +WHERE provider = 'keling' + AND deleted_at IS NULL; + +WITH keling_30_turbo AS ( + SELECT + 'keling:kling-3.0-turbo' AS canonical_model_key, + 'kling-3.0-turbo' AS provider_model_name, + '可灵3.0 Turbo' AS display_name, + '["video_generate","image_to_video"]'::jsonb AS model_type, + '{ + "video_generate": { + "aspect_ratio_allowed": ["16:9", "1:1", "9:16"], + "output_resolutions": ["720p", "1080p"], + "duration_range": [3, 15], + "output_audio": true, + "prompt_length_limit": { + "max": 3072, + "label": "可灵3.0 Turbo" + } + }, + "image_to_video": { + "output_resolutions": ["720p", "1080p"], + "duration_range": [3, 15], + "input_first_frame": true, + "input_last_frame": false, + "input_first_last_frame": false, + "aspect_ratio_allowed": [], + "output_audio": true, + "input_reference_generate_single": false, + "input_reference_generate_multiple": false, + "support_video_effect_template": false, + "prompt_length_limit": { + "max": 2500, + "label": "可灵3.0 Turbo" + } + }, + "originalTypes": ["video_generate", "image_to_video"] + }'::jsonb AS capabilities, + COALESCE( + ( + SELECT base_billing_config + FROM base_model_catalog + WHERE canonical_model_key = 'keling:kling-v3' + LIMIT 1 + ), + '{ + "video": { + "basePrice": 100, + "baseWeight": 1, + "dynamicWeight": { + "720p": 1, + "1080p": 1.5, + "audio-true": 2, + "audio-false": 1 + } + }, + "currency": "resource" + }'::jsonb + ) AS base_billing_config, + COALESCE( + ( + SELECT default_rate_limit_policy + FROM base_model_catalog + WHERE canonical_model_key = 'keling:kling-v3' + LIMIT 1 + ), + '{"platformLimits":{"max_concurrent_requests":5}}'::jsonb + ) AS default_rate_limit_policy, + '{ + "source": "server-main.integration-platform", + "sourceProviderCode": "keling", + "sourceProviderName": "可灵AI", + "sourceSpecType": "keling", + "originalTypes": ["video_generate", "image_to_video"], + "alias": "可灵3.0 Turbo", + "description": "可灵新任务 API 的 3.0 Turbo 文生视频与首帧图生视频模型", + "iconPath": "https://static.51easyai.com/kling-color.webp", + "billingType": "external-api", + "billingMode": "", + "referenceModel": "", + "modelWeight": null, + "selectable": true, + "rawModel": { + "name": "kling-3.0-turbo", + "types": ["video_generate", "image_to_video"], + "alias": "可灵3.0 Turbo", + "icon_path": "https://static.51easyai.com/kling-color.webp" + } + }'::jsonb AS metadata +) +INSERT INTO base_model_catalog ( + provider_id, + provider_key, + canonical_model_key, + provider_model_name, + model_type, + display_name, + capabilities, + base_billing_config, + default_rate_limit_policy, + metadata, + catalog_type, + default_snapshot, + status +) +SELECT + ( + SELECT id + FROM model_catalog_providers + WHERE provider_key = 'keling' OR provider_code = 'keling' + LIMIT 1 + ), + 'keling', + model.canonical_model_key, + model.provider_model_name, + model.model_type, + model.display_name, + model.capabilities, + model.base_billing_config, + model.default_rate_limit_policy, + jsonb_set(model.metadata, '{rawModel,capabilities}', model.capabilities, true), + 'system', + jsonb_build_object( + 'providerKey', 'keling', + 'canonicalModelKey', model.canonical_model_key, + 'providerModelName', model.provider_model_name, + 'modelType', model.model_type, + 'modelAlias', model.display_name, + 'displayName', model.display_name, + 'capabilities', model.capabilities, + 'baseBillingConfig', model.base_billing_config, + 'defaultRateLimitPolicy', model.default_rate_limit_policy, + 'metadata', jsonb_set(model.metadata, '{rawModel,capabilities}', model.capabilities, true), + 'status', 'active' + ), + 'active' +FROM keling_30_turbo model +ON CONFLICT (canonical_model_key) DO UPDATE +SET provider_id = EXCLUDED.provider_id, + provider_key = EXCLUDED.provider_key, + provider_model_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.provider_model_name ELSE base_model_catalog.provider_model_name END, + model_type = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.model_type ELSE base_model_catalog.model_type END, + display_name = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.display_name ELSE base_model_catalog.display_name END, + capabilities = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.capabilities ELSE base_model_catalog.capabilities END, + base_billing_config = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.base_billing_config ELSE base_model_catalog.base_billing_config END, + default_rate_limit_policy = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.default_rate_limit_policy ELSE base_model_catalog.default_rate_limit_policy END, + metadata = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.metadata ELSE base_model_catalog.metadata END, + catalog_type = CASE WHEN base_model_catalog.customized_at IS NULL THEN 'system' ELSE base_model_catalog.catalog_type END, + default_snapshot = CASE WHEN base_model_catalog.customized_at IS NULL THEN EXCLUDED.default_snapshot ELSE base_model_catalog.default_snapshot END, + status = CASE WHEN base_model_catalog.customized_at IS NULL THEN 'active' ELSE base_model_catalog.status END, + updated_at = now(); + +INSERT INTO platform_models ( + platform_id, + base_model_id, + model_name, + provider_model_name, + model_alias, + model_type, + display_name, + capabilities, + pricing_mode, + billing_config, + retry_policy, + rate_limit_policy, + enabled +) +SELECT + platform.id, + base_model.id, + base_model.provider_model_name, + base_model.provider_model_name, + base_model.display_name, + base_model.model_type, + base_model.display_name, + base_model.capabilities, + 'inherit_discount', + base_model.base_billing_config, + '{"enabled":true,"maxAttempts":1}'::jsonb, + base_model.default_rate_limit_policy, + true +FROM integration_platforms platform +JOIN base_model_catalog base_model + ON base_model.canonical_model_key = 'keling:kling-3.0-turbo' +WHERE platform.provider = 'keling' + AND platform.deleted_at IS NULL +ON CONFLICT (platform_id, model_name) DO UPDATE +SET base_model_id = EXCLUDED.base_model_id, + provider_model_name = EXCLUDED.provider_model_name, + model_alias = EXCLUDED.model_alias, + display_name = EXCLUDED.display_name, + model_type = EXCLUDED.model_type, + capabilities = EXCLUDED.capabilities, + pricing_mode = EXCLUDED.pricing_mode, + billing_config = EXCLUDED.billing_config, + retry_policy = EXCLUDED.retry_policy, + rate_limit_policy = EXCLUDED.rate_limit_policy, + enabled = EXCLUDED.enabled, + updated_at = now(); diff --git a/scripts/deyun-video-e2e.mjs b/scripts/deyun-video-e2e.mjs new file mode 100755 index 0000000..44bad7e --- /dev/null +++ b/scripts/deyun-video-e2e.mjs @@ -0,0 +1,452 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const baseURL = (process.env.GATEWAY_BASE_URL || 'http://127.0.0.1:8088').replace(/\/+$/, ''); +const model = process.env.DEYUN_VIDEO_MODEL || 'deyun-seedance-2.0-canary'; +const expectedPlatform = process.env.DEYUN_VIDEO_PLATFORM || '漫路(火山兼容)'; +const expectedProviderModel = process.env.DEYUN_VIDEO_PROVIDER_MODEL || 'doubao-seedance-2-0'; +const pollIntervalMs = positiveInteger(process.env.DEYUN_VIDEO_POLL_INTERVAL_MS, 5000); +const taskTimeoutMs = positiveInteger(process.env.DEYUN_VIDEO_TASK_TIMEOUT_MS, 20 * 60 * 1000); +const outputPath = + process.env.DEYUN_VIDEO_E2E_OUTPUT || + `artifacts/deyun-video-e2e-${new Date().toISOString().replaceAll(/[:.]/g, '-')}.json`; +const database = { + container: process.env.GATEWAY_E2E_DB_CONTAINER || 'postgres', + name: process.env.GATEWAY_E2E_DB_NAME || 'easyai_ai_gateway', + user: process.env.GATEWAY_E2E_DB_USER || 'easyai', +}; + +const validationCases = [ + { + name: '480p-4s-audio-off', + request: { + model, + prompt: 'A glossy red cube rotates slowly on a clean white studio table, locked camera, soft daylight.', + resolution: '480p', + ratio: '16:9', + duration: 4, + generate_audio: false, + seed: 41001, + watermark: false, + runMode: 'real', + }, + expected: { + height: 480, + duration: 4, + audio: false, + ratio: 16 / 9, + }, + }, + { + name: '720p-6s-audio-on', + request: { + model, + prompt: 'A calm ocean wave rolls toward a sandy beach at sunrise, locked camera, natural ambient sound.', + resolution: '720p', + ratio: '16:9', + duration: 6, + generate_audio: true, + seed: 42002, + watermark: false, + runMode: 'real', + }, + expected: { + height: 720, + duration: 6, + audio: true, + ratio: 16 / 9, + }, + }, +]; +const requestedCases = new Set( + String(process.env.DEYUN_VIDEO_CASES || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), +); +if (requestedCases.size > 0) { + const selectedCases = validationCases.filter((validationCase) => requestedCases.has(validationCase.name)); + if (selectedCases.length !== requestedCases.size) { + const knownCases = validationCases.map((validationCase) => validationCase.name).join(', '); + throw new Error(`Unknown DEYUN_VIDEO_CASES value; known cases: ${knownCases}`); + } + validationCases.splice(0, validationCases.length, ...selectedCases); +} +const submittedTaskIds = []; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(String(value || ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function nearlyEqual(left, right, tolerance = 1e-6) { + return Math.abs(Number(left) - Number(right)) <= tolerance; +} + +function runPSQL(query) { + return execFileSync( + 'docker', + ['exec', database.container, 'psql', '-U', database.user, '-d', database.name, '-At', '-F', '\t', '-c', query], + { encoding: 'utf8' }, + ).trim(); +} + +function resolveGatewayAPIKey() { + if (process.env.GATEWAY_E2E_API_KEY) { + assert(process.env.GATEWAY_E2E_API_KEY_ID, 'Set GATEWAY_E2E_API_KEY_ID when GATEWAY_E2E_API_KEY is provided'); + const metadata = apiKeyMetadata(process.env.GATEWAY_E2E_API_KEY_ID); + return { + ...metadata, + secret: process.env.GATEWAY_E2E_API_KEY, + source: 'environment', + }; + } + const query = ` +SELECT key.id::text, + key.key_secret, + key.gateway_user_id::text, + wallet.balance::float8, + wallet.frozen_balance::float8 +FROM gateway_api_keys key +JOIN gateway_wallet_accounts wallet + ON wallet.gateway_user_id = key.gateway_user_id + AND wallet.currency = 'resource' + AND wallet.status = 'active' +WHERE key.deleted_at IS NULL + AND key.status = 'active' + AND COALESCE(key.key_secret, '') <> '' + AND (key.expires_at IS NULL OR key.expires_at > now()) + AND (key.scopes ? 'video' OR key.scopes ? '*' OR key.scopes ? 'all') + AND (wallet.balance - wallet.frozen_balance) > 0 +ORDER BY (wallet.balance - wallet.frozen_balance) DESC, key.created_at DESC +LIMIT 1`; + const fields = runPSQL(query).split('\t'); + assert(fields.length === 5 && fields[0] && fields[1], 'No recoverable video-scoped Gateway API Key with positive balance was found'); + return { + keyId: fields[0], + secret: fields[1], + userId: fields[2], + balance: Number(fields[3]), + frozenBalance: Number(fields[4]), + source: 'database', + }; +} + +function apiKeyMetadata(keyId) { + const escapedKeyId = String(keyId).replaceAll("'", "''"); + const output = runPSQL(` +SELECT key.id::text, + key.gateway_user_id::text, + wallet.balance::float8, + wallet.frozen_balance::float8 +FROM gateway_api_keys key +JOIN gateway_wallet_accounts wallet + ON wallet.gateway_user_id = key.gateway_user_id + AND wallet.currency = 'resource' +WHERE key.id = '${escapedKeyId}'::uuid +LIMIT 1`); + const fields = output.split('\t'); + assert(fields.length === 4 && fields[0], `Gateway API Key ${keyId} was not found`); + return { + keyId: fields[0], + userId: fields[1], + balance: Number(fields[2]), + frozenBalance: Number(fields[3]), + }; +} + +function walletState(userId) { + const escapedUserId = String(userId).replaceAll("'", "''"); + const fields = runPSQL(` +SELECT balance::float8, frozen_balance::float8 +FROM gateway_wallet_accounts +WHERE gateway_user_id = '${escapedUserId}'::uuid + AND currency = 'resource' +LIMIT 1`).split('\t'); + assert(fields.length === 2, `Wallet for Gateway user ${userId} was not found`); + return { + balance: Number(fields[0]), + frozenBalance: Number(fields[1]), + }; +} + +async function request(path, init = {}) { + const response = await fetch(`${baseURL}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${authentication.secret}`, + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + ...(init.headers || {}), + }, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${init.method || 'GET'} ${path} failed ${response.status}: ${text}`); + } + return text ? JSON.parse(text) : {}; +} + +async function createVideoTask(body) { + const accepted = await request('/api/v1/videos/generations', { + method: 'POST', + headers: { 'X-Async': 'true' }, + body: JSON.stringify(body), + }); + const taskId = accepted.taskId || accepted.task?.id; + assert(taskId, `Video task acceptance is missing taskId: ${JSON.stringify(accepted)}`); + submittedTaskIds.push(taskId); + return taskId; +} + +async function pollTask(taskId) { + const startedAt = Date.now(); + while (Date.now() - startedAt < taskTimeoutMs) { + const task = await request(`/api/v1/tasks/${taskId}`); + if (task.status === 'succeeded') return task; + if (task.status === 'failed' || task.status === 'cancelled') { + throw new Error(`Task ${taskId} ended as ${task.status}: ${task.errorMessage || task.error || JSON.stringify(task.result)}`); + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + throw new Error(`Timed out after ${taskTimeoutMs}ms waiting for task ${taskId}`); +} + +async function taskEvents(taskId) { + const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}/events`, { + headers: { Authorization: `Bearer ${authentication.secret}` }, + }); + const text = await response.text(); + if (!response.ok) throw new Error(`GET task events failed ${response.status}: ${text}`); + return text + .split(/\r?\n\r?\n/) + .flatMap((block) => { + const data = block + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trim()) + .join('\n'); + return data ? [JSON.parse(data)] : []; + }); +} + +async function taskPreprocessingLogs(taskId) { + return request(`/api/v1/tasks/${taskId}/param-preprocessing`); +} + +function videoURLFromTask(task) { + const data = Array.isArray(task.result?.data) ? task.result.data : []; + return data.find((item) => item?.type === 'video')?.url || data.find((item) => item?.url)?.url || ''; +} + +async function downloadVideo(url, filePath) { + const response = await fetch(url); + assert(response.ok, `Download ${url} failed ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + assert(bytes.length > 0, `Downloaded video ${url} is empty`); + await writeFile(filePath, bytes); + return bytes.length; +} + +function probeVideo(filePath) { + const output = execFileSync( + 'ffprobe', + ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', filePath], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ); + return JSON.parse(output); +} + +function validateRequestSnapshot(task, expectedRequest) { + const snapshot = task.request || {}; + for (const key of ['model', 'resolution', 'ratio', 'duration', 'generate_audio', 'seed']) { + assert( + snapshot[key] === expectedRequest[key], + `Task ${task.id} request.${key}=${JSON.stringify(snapshot[key])}, expected ${JSON.stringify(expectedRequest[key])}`, + ); + } +} + +function validateTaskAudit(task, events) { + const attempts = Array.isArray(task.attempts) ? task.attempts : []; + assert(attempts.length === 1, `Task ${task.id} has ${attempts.length} attempts, expected exactly one`); + const attempt = attempts[0]; + assert(attempt.platformName === expectedPlatform, `Task ${task.id} used platform ${attempt.platformName}, expected ${expectedPlatform}`); + assert(attempt.provider === 'volces', `Task ${task.id} used provider ${attempt.provider}, expected volces`); + assert( + attempt.providerModelName === expectedProviderModel, + `Task ${task.id} used provider model ${attempt.providerModelName}, expected ${expectedProviderModel}`, + ); + assert(task.remoteTaskId, `Task ${task.id} is missing remoteTaskId`); + assert(attempt.requestId, `Task ${task.id} attempt is missing requestId`); + const startedEvents = events.filter((event) => event.eventType === 'task.attempt.started'); + assert(startedEvents.length === 1, `Task ${task.id} emitted ${startedEvents.length} task.attempt.started events`); + assert(Number(task.finalChargeAmount) > 0, `Task ${task.id} finalChargeAmount must be positive`); + return attempt; +} + +function validateMedia(probe, expected, taskId) { + const streams = Array.isArray(probe.streams) ? probe.streams : []; + const video = streams.find((stream) => stream.codec_type === 'video'); + const audioStreams = streams.filter((stream) => stream.codec_type === 'audio'); + assert(video, `Task ${taskId} output has no video stream`); + const width = Number(video.width); + const height = Number(video.height); + const duration = Number(video.duration || probe.format?.duration); + assert(height === expected.height, `Task ${taskId} output height=${height}, expected ${expected.height}`); + const ratioError = Math.abs(width / height - expected.ratio) / expected.ratio; + assert(ratioError <= 0.02, `Task ${taskId} aspect ratio ${width}:${height} differs from 16:9 by ${(ratioError * 100).toFixed(2)}%`); + assert(Number.isFinite(duration), `Task ${taskId} output duration is unavailable`); + assert(Math.abs(duration - expected.duration) <= 1, `Task ${taskId} duration=${duration}s, expected ${expected.duration}s ±1s`); + if (expected.audio) { + assert(audioStreams.length > 0, `Task ${taskId} requested audio but output has no audio stream`); + const audioDuration = Number(audioStreams[0].duration || probe.format?.duration); + if (Number.isFinite(audioDuration)) { + assert(audioDuration >= duration - 1, `Task ${taskId} audio duration=${audioDuration}s is too short for video duration=${duration}s`); + } + } else { + assert(audioStreams.length === 0, `Task ${taskId} disabled audio but output has ${audioStreams.length} audio stream(s)`); + } + return { + width, + height, + duration, + ratio: width / height, + videoCodec: video.codec_name || null, + audioStreams: audioStreams.map((stream) => ({ + codec: stream.codec_name || null, + channels: Number(stream.channels || 0), + sampleRate: Number(stream.sample_rate || 0), + duration: Number(stream.duration || probe.format?.duration || 0), + })), + }; +} + +const authentication = resolveGatewayAPIKey(); + +function sanitizedFailureMessage(error) { + return String(error instanceof Error ? error.message : error) + .replace(/Request id:\s*[A-Za-z0-9_-]+/gi, 'Request id: [redacted]') + .replace(/\b\d{8,}\b/g, '[redacted-id]'); +} + +async function writeFailureReport(error) { + const report = { + ok: false, + generatedAt: new Date().toISOString(), + baseURL, + model, + expectedPlatform, + expectedProviderModel, + authentication: { + type: 'gateway_api_key', + source: authentication.source, + keyId: authentication.keyId, + }, + submittedTaskIds, + error: sanitizedFailureMessage(error), + }; + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + return report; +} + +async function main() { + const tempDirectory = await mkdtemp(join(tmpdir(), 'deyun-video-e2e-')); + const walletBefore = walletState(authentication.userId); + const results = []; + try { + for (const validationCase of validationCases) { + const taskId = await createVideoTask(validationCase.request); + const task = await pollTask(taskId); + const [events, preprocessing] = await Promise.all([ + taskEvents(taskId), + taskPreprocessingLogs(taskId), + ]); + validateRequestSnapshot(task, validationCase.request); + const attempt = validateTaskAudit(task, events); + assert( + Array.isArray(preprocessing.items) && preprocessing.items.length > 0, + `Task ${taskId} has no parameter preprocessing audit record`, + ); + const videoURL = videoURLFromTask(task); + assert(videoURL, `Task ${taskId} result is missing a video URL`); + const videoPath = join(tempDirectory, `${validationCase.name}.mp4`); + const byteSize = await downloadVideo(videoURL, videoPath); + const probe = probeVideo(videoPath); + const observed = validateMedia(probe, validationCase.expected, taskId); + results.push({ + name: validationCase.name, + taskId, + remoteTaskId: task.remoteTaskId, + requestId: attempt.requestId, + platform: attempt.platformName, + providerModelName: attempt.providerModelName, + requested: { + resolution: validationCase.request.resolution, + ratio: validationCase.request.ratio, + duration: validationCase.request.duration, + generateAudio: validationCase.request.generate_audio, + seed: validationCase.request.seed, + }, + observed, + byteSize, + finalChargeAmount: Number(task.finalChargeAmount), + billingSummary: task.billingSummary || {}, + eventTypes: events.map((event) => event.eventType), + preprocessingChangeCount: preprocessing.items.reduce((sum, item) => sum + Number(item.changeCount || 0), 0), + }); + } + } finally { + await rm(tempDirectory, { recursive: true, force: true }); + } + + const walletAfter = walletState(authentication.userId); + const totalCharge = results.reduce((sum, result) => sum + result.finalChargeAmount, 0); + const walletDebit = walletBefore.balance - walletAfter.balance; + assert(nearlyEqual(walletDebit, totalCharge, 1e-6), `Wallet debit=${walletDebit}, expected total charge=${totalCharge}`); + assert( + nearlyEqual(walletAfter.frozenBalance, walletBefore.frozenBalance, 1e-6), + `Wallet frozen balance changed from ${walletBefore.frozenBalance} to ${walletAfter.frozenBalance}`, + ); + + const report = { + ok: true, + generatedAt: new Date().toISOString(), + baseURL, + model, + expectedPlatform, + expectedProviderModel, + authentication: { + type: 'gateway_api_key', + source: authentication.source, + keyId: authentication.keyId, + }, + wallet: { + balanceBefore: walletBefore.balance, + balanceAfter: walletAfter.balance, + frozenBalanceBefore: walletBefore.frozenBalance, + frozenBalanceAfter: walletAfter.frozenBalance, + debit: walletDebit, + totalCharge, + }, + results, + }; + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + console.log(JSON.stringify({ ...report, outputPath }, null, 2)); +} + +main().catch(async (error) => { + const report = await writeFailureReport(error); + console.error(report.error); + console.error(`Failure report: ${outputPath}`); + process.exitCode = 1; +}); diff --git a/scripts/output-token-limit-e2e.mjs b/scripts/output-token-limit-e2e.mjs new file mode 100644 index 0000000..b66149d --- /dev/null +++ b/scripts/output-token-limit-e2e.mjs @@ -0,0 +1,272 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const baseURL = (process.env.GATEWAY_BASE_URL || 'http://127.0.0.1:8088').replace(/\/+$/, ''); +const authentication = resolveGatewayAPIKey(); +const timeoutMs = Number(process.env.GATEWAY_E2E_TIMEOUT_MS || 180_000); + +const scenarios = [ + { + name: '火山 128K Chat 未传上限', + platform: '火山引擎', + model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215', + path: '/v1/chat/completions', + body: chatBody(), + expected: { parameter: 'max_tokens', value: 43690, injected: true }, + }, + { + name: '火山 32K Chat 未传上限', + platform: '火山引擎', + model: process.env.GATEWAY_E2E_VOLCES_32K_MODEL || 'volces-openai:doubao-seed-1-8-251228', + path: '/v1/chat/completions', + body: chatBody(), + expected: { parameter: 'max_tokens', value: 10922, injected: true }, + }, + { + name: '火山 Chat max_tokens=null', + platform: '火山引擎', + model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215', + path: '/v1/chat/completions', + body: chatBody({ max_tokens: null }), + expected: { parameter: 'max_tokens', value: 43690, injected: true }, + }, + { + name: '火山 Chat 显式 max_tokens', + platform: '火山引擎', + model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215', + path: '/v1/chat/completions', + body: chatBody({ max_tokens: 64 }), + expected: { parameter: 'max_tokens', value: 64, injected: false }, + }, + { + name: '火山 Chat 显式 max_completion_tokens', + platform: '火山引擎', + model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215', + path: '/v1/chat/completions', + body: chatBody({ max_completion_tokens: 64 }), + expected: { parameter: 'max_tokens', absent: true, injected: false, preserved: { max_completion_tokens: 64 } }, + }, + { + name: '火山 Responses 未传上限', + platform: '火山引擎', + model: process.env.GATEWAY_E2E_VOLCES_128K_MODEL || 'volces-openai:doubao-seed-2-0-pro-260215', + path: '/v1/responses', + body: responsesBody(), + expected: { parameter: 'max_output_tokens', value: 43690, injected: true }, + }, + { + name: '阿里百炼 Qwen Chat 未传上限', + platform: '阿里云百炼', + model: process.env.GATEWAY_E2E_QWEN_MODEL || 'aliyun-bailian-openai:qwen3.7-plus', + path: '/v1/chat/completions', + body: chatBody(), + expected: { parameter: 'max_tokens', absent: true, injected: false }, + }, + { + name: '阿里百炼 Qwen Responses 未传上限', + platform: '阿里云百炼', + model: process.env.GATEWAY_E2E_QWEN_MODEL || 'aliyun-bailian-openai:qwen3.7-plus', + path: '/v1/responses', + body: responsesBody(), + expected: { parameter: 'max_output_tokens', absent: true, injected: false }, + }, + { + name: 'DeepSeek 官网 Chat 未传上限', + platform: 'DeepSeek 官网', + model: process.env.GATEWAY_E2E_DEEPSEEK_MODEL || 'deepseek-openai:deepseek-v4-pro', + path: '/v1/chat/completions', + body: chatBody(), + expected: { parameter: 'max_tokens', absent: true, injected: false }, + }, + { + name: 'DeepSeek 官网 Responses 回退未传上限', + platform: 'DeepSeek 官网', + model: process.env.GATEWAY_E2E_DEEPSEEK_MODEL || 'deepseek-openai:deepseek-v4-pro', + path: '/v1/responses', + body: responsesBody(), + expected: { parameter: 'max_output_tokens', absent: true, injected: false }, + }, + { + name: '智谱 GLM-5.2 Chat 未传上限', + platform: '智谱AI', + model: process.env.GATEWAY_E2E_ZHIPU_MODEL || 'zhipu-openai:glm-5.2', + path: '/v1/chat/completions', + body: chatBody(), + expected: { parameter: 'max_tokens', absent: true, injected: false }, + }, + { + name: 'OpenAI GPT-4o Chat 未传上限', + platform: 'OpenAI', + model: process.env.GATEWAY_E2E_OPENAI_MODEL || 'openai:gpt-4o', + path: '/v1/chat/completions', + body: chatBody(), + expected: { parameter: 'max_tokens', absent: true, injected: false }, + }, + { + name: 'Google Gemini Chat 未传上限', + platform: 'Google Gemini', + model: process.env.GATEWAY_E2E_GEMINI_MODEL || 'gemini:gemini-2.5-pro', + path: '/v1/chat/completions', + body: chatBody(), + expected: { parameter: 'max_tokens', absent: true, injected: false }, + }, +]; + +function chatBody(extra = {}) { + return { + messages: [{ role: 'user', content: '这是输出上限链路验证。只回复 OK。' }], + ...extra, + }; +} + +function responsesBody(extra = {}) { + return { input: '这是输出上限链路验证。只回复 OK。', ...extra }; +} + +function resolveGatewayAPIKey() { + if (process.env.GATEWAY_E2E_API_KEY) { + return { value: process.env.GATEWAY_E2E_API_KEY, source: 'environment', keyId: null }; + } + const container = process.env.GATEWAY_E2E_DB_CONTAINER || 'postgres'; + const database = process.env.GATEWAY_E2E_DB_NAME || 'easyai_ai_gateway'; + const user = process.env.GATEWAY_E2E_DB_USER || 'easyai'; + const query = `SELECT key.id::text || E'\\t' || key.key_secret FROM gateway_api_keys key JOIN gateway_wallet_accounts wallet ON wallet.gateway_user_id = key.gateway_user_id AND wallet.status = 'active' WHERE key.deleted_at IS NULL AND key.status = 'active' AND key.key_secret IS NOT NULL AND key.key_secret <> '' AND key.scopes ? 'chat' AND (key.expires_at IS NULL OR key.expires_at > now()) AND (wallet.balance - wallet.frozen_balance) > 0 ORDER BY (wallet.balance - wallet.frozen_balance) DESC, key.created_at DESC LIMIT 1`; + let output = ''; + try { + output = execFileSync('docker', ['exec', container, 'psql', '-U', user, '-d', database, '-At', '-c', query], { encoding: 'utf8' }).trim(); + } catch { + throw new Error('Unable to load an active chat-scoped Gateway API Key; set GATEWAY_E2E_API_KEY explicitly'); + } + const separator = output.indexOf('\t'); + if (separator <= 0 || separator === output.length - 1) { + throw new Error('No active chat-scoped Gateway API Key with a positive balance was found'); + } + return { keyId: output.slice(0, separator), value: output.slice(separator + 1), source: 'database' }; +} + +async function fetchText(path, options = {}) { + const response = await fetch(`${baseURL}${path}`, { + ...options, + signal: AbortSignal.timeout(timeoutMs), + headers: { Authorization: `Bearer ${authentication.value}`, ...(options.headers || {}) }, + }); + const text = await response.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = { raw: text.slice(0, 2_000) }; + } + return { response, body }; +} + +async function getTask(taskId) { + const { response, body } = await fetchText(`/api/v1/tasks/${taskId}`); + if (!response.ok) throw new Error(`GET task ${taskId} failed ${response.status}: ${JSON.stringify(body)}`); + return body; +} + +async function getPreprocessing(taskId) { + const { response, body } = await fetchText(`/api/v1/tasks/${taskId}/param-preprocessing`); + if (!response.ok) throw new Error(`GET preprocessing ${taskId} failed ${response.status}: ${JSON.stringify(body)}`); + return body?.items || []; +} + +function successfulAttempt(task) { + const attempts = Array.isArray(task.attempts) ? task.attempts : []; + return attempts.findLast((attempt) => attempt.status === 'succeeded') || attempts.at(-1); +} + +function hasOwn(value, key) { + return value != null && Object.prototype.hasOwnProperty.call(value, key); +} + +function validateAudit(scenario, task, preprocessing) { + const attempt = successfulAttempt(task); + if (!attempt) throw new Error('task does not contain an upstream attempt'); + if (task.simulated || attempt.simulated) throw new Error('request was served by a simulation candidate'); + const platformName = String(attempt.metrics?.platformName || task.metrics?.platformName || ''); + if (!platformName.includes(scenario.platform)) { + throw new Error(`unexpected platform ${platformName}; expected ${scenario.platform}`); + } + const snapshot = attempt.requestSnapshot || {}; + const expected = scenario.expected; + if (expected.absent) { + if (hasOwn(snapshot, expected.parameter)) { + throw new Error(`${expected.parameter} was unexpectedly sent upstream as ${JSON.stringify(snapshot[expected.parameter])}`); + } + } else if (snapshot[expected.parameter] !== expected.value) { + throw new Error(`${expected.parameter}=${JSON.stringify(snapshot[expected.parameter])}; expected ${expected.value}`); + } + for (const [key, value] of Object.entries(expected.preserved || {})) { + if (snapshot[key] !== value) throw new Error(`${key} was not preserved in the upstream snapshot`); + } + const changes = preprocessing.flatMap((item) => item.changes || []); + const outputLimitChanges = changes.filter((change) => change.processor === 'OutputTokenLimitProcessor'); + if (expected.injected) { + const matched = outputLimitChanges.some((change) => change.action === 'set' && change.path === expected.parameter && change.after === expected.value); + if (!matched) throw new Error(`missing OutputTokenLimitProcessor audit for ${expected.parameter}=${expected.value}`); + } else if (outputLimitChanges.length > 0) { + throw new Error(`unexpected OutputTokenLimitProcessor audit: ${JSON.stringify(outputLimitChanges)}`); + } + return { + taskId: task.id, + attemptId: attempt.id, + platform: platformName, + provider: attempt.metrics?.provider || task.metrics?.provider || null, + platformModelId: attempt.metrics?.platformModelId || task.metrics?.platformModelId || null, + upstreamProtocol: attempt.metrics?.upstreamProtocol || task.metrics?.upstreamProtocol || null, + upstreamEndpoint: attempt.metrics?.upstreamEndpoint || task.metrics?.upstreamEndpoint || null, + simulated: Boolean(task.simulated || attempt.simulated), + requestParameters: { + max_tokens: hasOwn(snapshot, 'max_tokens') ? snapshot.max_tokens : '(absent)', + max_completion_tokens: hasOwn(snapshot, 'max_completion_tokens') ? snapshot.max_completion_tokens : '(absent)', + max_output_tokens: hasOwn(snapshot, 'max_output_tokens') ? snapshot.max_output_tokens : '(absent)', + }, + outputLimitAudit: outputLimitChanges, + usage: task.usage || attempt.usage || null, + }; +} + +const results = []; +for (const scenario of scenarios) { + const startedAt = Date.now(); + let taskId = null; + try { + const requestBody = { model: scenario.model, ...scenario.body }; + const { response, body } = await fetchText(scenario.path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + taskId = response.headers.get('x-gateway-task-id'); + if (!response.ok) throw new Error(`POST ${scenario.path} failed ${response.status}: ${JSON.stringify(body)}`); + if (!taskId) throw new Error('response is missing X-Gateway-Task-Id'); + const [task, preprocessing] = await Promise.all([getTask(taskId), getPreprocessing(taskId)]); + const audit = validateAudit(scenario, task, preprocessing); + results.push({ name: scenario.name, model: scenario.model, path: scenario.path, passed: true, durationMs: Date.now() - startedAt, audit }); + process.stderr.write(`PASS ${scenario.name} (${Date.now() - startedAt} ms)\n`); + } catch (error) { + results.push({ name: scenario.name, model: scenario.model, path: scenario.path, passed: false, durationMs: Date.now() - startedAt, taskId, error: error instanceof Error ? error.message : String(error) }); + process.stderr.write(`FAIL ${scenario.name}: ${error instanceof Error ? error.message : String(error)}\n`); + } +} + +const report = { + ok: results.every((result) => result.passed), + generatedAt: new Date().toISOString(), + baseURL, + authentication: { type: 'gateway_api_key', source: authentication.source, keyId: authentication.keyId }, + summary: { total: results.length, passed: results.filter((result) => result.passed).length, failed: results.filter((result) => !result.passed).length }, + results, +}; +const outputPath = process.env.GATEWAY_E2E_OUTPUT; +if (outputPath) { + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); +} +console.log(JSON.stringify(report, null, 2)); +if (!report.ok) process.exitCode = 1; From bdcf9af5d6f97d6821190ee223141535c88a7a85 Mon Sep 17 00:00:00 2001 From: wangbo Date: Fri, 17 Jul 2026 13:54:32 +0800 Subject: [PATCH 06/20] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=9E=84=E5=BB=BA=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6ff9836..9354a47 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules/ *.log apps/api/bin/ +apps/api/gateway apps/api/tmp/ apps/api/data/ From 0d18df54f15b535b9a310de993d28fa5e987b561 Mon Sep 17 00:00:00 2001 From: wangbo Date: Fri, 17 Jul 2026 13:57:21 +0800 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E9=A2=84=E4=BC=B0=E6=A0=B8=E5=AF=B9=E8=BF=90=E7=BB=B4?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/docs/swagger.json | 4 +- apps/api/docs/swagger.yaml | 4 +- .../httpapi/agent_resources_handlers_test.go | 4 +- apps/api/internal/httpapi/openapi_models.go | 4 +- apps/api/internal/skillbundle/bundle_test.go | 19 +- .../skills/ai-gateway-ops-management/SKILL.md | 8 +- .../references/model-acceptance-runbook.md | 5 +- ...el-billing-configuration-and-estimation.md | 249 ++++++++++++++++++ .../references/model-pricing-and-policies.md | 3 + .../ai-gateway-ops-management/skill.json | 2 +- apps/web/src/api.test.ts | 4 +- 11 files changed, 289 insertions(+), 17 deletions(-) create mode 100644 apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-billing-configuration-and-estimation.md diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 81cd7ae..952041c 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -10653,7 +10653,7 @@ }, "fileName": { "type": "string", - "example": "ai-gateway-ops-management-v1.0.1.zip" + "example": "ai-gateway-ops-management-v1.0.2.zip" }, "modules": { "type": "array", @@ -10670,7 +10670,7 @@ }, "version": { "type": "string", - "example": "1.0.1" + "example": "1.0.2" } } }, diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 21c5218..36c20f9 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -791,7 +791,7 @@ definitions: example: /api/v1/public/skills/ai-gateway-ops-management/download type: string fileName: - example: ai-gateway-ops-management-v1.0.1.zip + example: ai-gateway-ops-management-v1.0.2.zip type: string modules: example: @@ -803,7 +803,7 @@ definitions: example: ai-gateway-ops-management type: string version: - example: 1.0.1 + example: 1.0.2 type: string type: object httpapi.TaskAcceptedResponse: diff --git a/apps/api/internal/httpapi/agent_resources_handlers_test.go b/apps/api/internal/httpapi/agent_resources_handlers_test.go index 28f685d..f4426cb 100644 --- a/apps/api/internal/httpapi/agent_resources_handlers_test.go +++ b/apps/api/internal/httpapi/agent_resources_handlers_test.go @@ -26,7 +26,7 @@ func TestGetOpsManagementSkillMetadata(t *testing.T) { 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.1" { + 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" { @@ -50,7 +50,7 @@ func TestDownloadOpsManagementSkill(t *testing.T) { 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.1.zip") { + 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() diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index 279b5d4..636b91f 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -18,10 +18,10 @@ type ReadyResponse struct { type SkillBundleMetadataResponse struct { Name string `json:"name" example:"ai-gateway-ops-management"` - Version string `json:"version" example:"1.0.1"` + 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.1.zip"` + 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"` diff --git a/apps/api/internal/skillbundle/bundle_test.go b/apps/api/internal/skillbundle/bundle_test.go index 40f21fc..9e773b4 100644 --- a/apps/api/internal/skillbundle/bundle_test.go +++ b/apps/api/internal/skillbundle/bundle_test.go @@ -14,13 +14,13 @@ func TestLoadMetadata(t *testing.T) { if err != nil { t.Fatalf("load metadata: %v", err) } - if metadata.Name != Name || metadata.Version != "1.0.1" { + if metadata.Name != Name || metadata.Version != "1.0.2" { t.Fatalf("unexpected metadata: %+v", metadata) } if !slices.Equal(metadata.Modules, []string{"model-runtime"}) { t.Fatalf("unexpected modules: %+v", metadata.Modules) } - if FileName(metadata) != "ai-gateway-ops-management-v1.0.1.zip" { + if FileName(metadata) != "ai-gateway-ops-management-v1.0.2.zip" { t.Fatalf("unexpected file name: %s", FileName(metadata)) } } @@ -48,6 +48,7 @@ func TestBuildArchiveContainsOperationsSkill(t *testing.T) { "references/api-discovery-and-safety.md", "references/model-providers-and-base-models.md", "references/model-pricing-and-policies.md", + "references/model-billing-configuration-and-estimation.md", "references/model-platforms-and-bindings.md", "references/model-universal-platforms.md", "references/model-acceptance-runbook.md", @@ -69,6 +70,20 @@ func TestBuildArchiveContainsOperationsSkill(t *testing.T) { if !strings.Contains(string(skillContent), "name: "+Name) { t.Fatalf("SKILL.md frontmatter has unexpected name") } + billingFile, err := files["references/model-billing-configuration-and-estimation.md"].Open() + if err != nil { + t.Fatalf("open billing reference: %v", err) + } + defer billingFile.Close() + billingContent, err := io.ReadAll(billingFile) + if err != nil { + t.Fatalf("read billing reference: %v", err) + } + for _, required := range []string{"/api/v1/pricing/estimate", "finalChargeAmount", "transactionType=task_billing"} { + if !strings.Contains(string(billingContent), required) { + t.Fatalf("billing reference missing %q", required) + } + } } func archiveFileNames(files []*zip.File) []string { diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/SKILL.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/SKILL.md index c592b52..169d4c0 100644 --- a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/SKILL.md +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/SKILL.md @@ -1,6 +1,6 @@ --- name: ai-gateway-ops-management -description: Operate and verify EasyAI AI Gateway administration capabilities. Use when Codex or an Agent needs to inspect, create, update, disable, restore, or troubleshoot AI Gateway providers, base models, pricing, runtime policies, runner policies, integration platforms, platform-model bindings, or universal custom-script platforms; also use this skill as the extensible entry point for future AI Gateway operations modules. +description: Operate and verify EasyAI AI Gateway administration capabilities. Use when Codex or an Agent needs to inspect, create, update, disable, restore, or troubleshoot AI Gateway providers, base models, pricing and billing configuration, price estimates and wallet deduction reconciliation, runtime policies, runner policies, integration platforms, platform-model bindings, or universal custom-script platforms; also use this skill as the extensible entry point for future AI Gateway operations modules. --- # AI Gateway Operations Management @@ -28,6 +28,7 @@ Use these references for the current v1 module: - `references/model-providers-and-base-models.md`: provider catalog and base-model lifecycle. - `references/model-pricing-and-policies.md`: pricing rule sets, runtime policy sets, runner policy, priority, and recovery. +- `references/model-billing-configuration-and-estimation.md`: effective billing configuration, price-estimate calls, simulation/real charge comparison, and wallet reconciliation. - `references/model-platforms-and-bindings.md`: integration platforms, credentials, platform-model upsert, replacement, and deletion. - `references/model-universal-platforms.md`: `universal` custom platform triage, configuration, script contracts, and examples. - `references/model-acceptance-runbook.md`: read-back, catalog, simulation, runtime, billing, and rollback verification. @@ -42,8 +43,9 @@ Use these references for the current v1 module: 6. Reuse the base model across platforms. Put a platform-specific upstream invocation name in the platform-model `providerModelName`; do not duplicate the base model just because providers use different names. 7. Apply the minimum changes in dependency order: provider, base model, pricing/policy, platform, platform-model binding. 8. Read every changed resource back and verify the effective model catalog. -9. Run a simulation or approved real request, inspect task and preprocessing evidence, and confirm billing and recovery behavior. -10. Report reused and created resource IDs, effective pricing evidence, protocol-fit evidence, verification results, unresolved risks, and whether the Swagger fallback was used, without exposing secrets. +9. For billing work, read `references/model-billing-configuration-and-estimation.md`, call `/api/v1/pricing/estimate` with the same user identity and request parameters intended for execution, and reconcile the returned candidate, line items, discounts, quantities, and total. +10. Run a simulation or approved real request only after the estimate is accepted. Inspect task billing and wallet transactions; do not assume simulation is free or side-effect-free. +11. Report reused and created resource IDs, effective pricing evidence, protocol-fit evidence, verification results, unresolved risks, and whether the Swagger fallback was used, without exposing secrets. ## Extending This Skill diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-acceptance-runbook.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-acceptance-runbook.md index 720a94b..741db4d 100644 --- a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-acceptance-runbook.md +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-acceptance-runbook.md @@ -8,6 +8,7 @@ - Confirm whether real upstream calls are allowed; otherwise use simulation. - Confirm upstream endpoint, auth, model name, capabilities, limits, pricing, and sync/async behavior from documentation. - Record which existing pricing rule and runtime policy were reused, or the exact semantic mismatch that required a new one. +- For billing changes, save the current effective rule bindings, discounts, user-group policy, wallet balance, and one representative estimate response as described in `model-billing-configuration-and-estimation.md`. - Record the protocol compatibility decision and why the selected existing `specType` is sufficient, or the concrete gap that requires `universal`. - When one base model has different upstream names across platforms, confirm each binding resolves the expected `providerModelName`. @@ -39,6 +40,8 @@ Confirm model alias, model types, provider source, effective capabilities, prici For pricing, verify the effective rule source and discount source rather than checking IDs only. Confirm whether the platform-model discount overrides the platform default, and compare the estimated or simulated amount with the intended price. +Use `/api/v1/pricing/estimate` for the first read-only calculation. A simulation request is a task execution path and may reserve and settle wallet billing; do not treat it as a read-only replacement for the estimate endpoint. + ## Execution Verification Start with simulation: @@ -67,7 +70,7 @@ For media or universal scripts, inspect: - `/api/admin/runtime/model-rate-limits` - `/api/admin/runtime/rate-limit-windows` -With explicit approval, run one real minimal request and verify upstream request ID, normalized output, task completion, billing, and failure handling. +With explicit approval, run one real minimal request and verify upstream request ID, normalized output, task completion, `billings`, `billingSummary.totalAmount`, `finalChargeAmount`, and the wallet `task_billing` transaction. Explain any expected estimate difference caused by actual token usage, cached input, generated media duration/audio, preprocessing, or failover to another platform. ## Rollback diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-billing-configuration-and-estimation.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-billing-configuration-and-estimation.md new file mode 100644 index 0000000..8a40fc6 --- /dev/null +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-billing-configuration-and-estimation.md @@ -0,0 +1,249 @@ +# Model Billing Configuration and Estimate Reconciliation + +## Contents + +- Safety and Identity +- Effective Billing Layers +- Billing Configuration Workflow +- Price Estimate Requests +- Estimate Reconciliation +- Task and Wallet Charge Verification +- Expected Differences and Troubleshooting + +## Safety and Identity + +- Use an administrator JWT with `manager` permission for pricing, base-model, platform, platform-model, or user-group writes. Do not use an `sk-*` API key for management APIs. +- Call `POST /api/v1/pricing/estimate` with the same user JWT or API key that will execute the real request. The subject controls access rules, candidate visibility, API-key scopes, and user-group billing discount. +- The estimate endpoint is read-only for tasks and wallets: it selects and preprocesses a candidate and returns simulated billing lines, but it does not create a task, freeze balance, or debit the wallet. +- A request with `runMode: "simulation"` is different: it enters the task execution and billing path and may create task records, reserve wallet balance, and settle a charge. Use the estimate endpoint first and obtain approval before any task request intended only for billing verification. +- Never expose API keys, administrator JWTs, wallet identifiers, or credentials in reports. + +## Effective Billing Layers + +Read every applicable layer before changing one: + +1. Base model: `pricingRuleSetId` and `baseBillingConfig`. +2. Platform: `pricingRuleSetId`, `defaultPricingMode`, and `defaultDiscountFactor`. +3. Platform model: `pricingRuleSetId`, `billingConfigOverride`, `pricingMode`, and `discountFactor`. +4. User group: `billingDiscountPolicy.discountFactor`. + +The runtime resolves an effective billing configuration, then applies discounts. An explicit platform-model rule and `billingConfigOverride` can replace or merge inherited values. Do not infer the final price from one ID; confirm it with an estimate. + +The current discount behavior is: + +```text +candidate discount = positive platform-model discountFactor + else platform defaultDiscountFactor +effective discount = candidate discount × user-group billingDiscountPolicy.discountFactor +``` + +The platform and platform-model discount factors do not multiply together. The user-group factor, when positive, multiplies the selected candidate discount. + +Pricing rule resource mappings include: + +| `resourceType` | Effective use | +| --- | --- | +| `text_input` | uncached input token price per 1,000 tokens | +| `text_cached_input` | cached input token price per 1,000 tokens | +| `text_output` | output token price per 1,000 tokens | +| `text_total` | text prices from `basePrice` and optional `formulaConfig` | +| `image` | image generation base price and dynamic weights | +| `image_edit` | image-edit price; image pricing is a fallback when absent | +| `video` | price per five-second unit plus dynamic weights | +| other types | generic resource base price and weights, such as music or audio | + +Common `dynamicWeight` dimensions are `qualityWeights`, `sizeWeights`, `resolutionWeights`, `audioWeights`, `referenceVideoWeights`, and `voiceSpecifiedWeights`. Configure only dimensions used by the runtime and proven by the target product pricing. + +## Billing Configuration Workflow + +### 1. Capture the current state + +```bash +curl --fail-with-body -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/pricing/rule-sets" +curl --fail-with-body -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/catalog/base-models" +curl --fail-with-body -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/platforms" +curl --fail-with-body -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/models" +curl --fail-with-body -H "Authorization: Bearer $GATEWAY_ADMIN_TOKEN" \ + "$GATEWAY_BASE_URL/api/admin/user-groups" +``` + +Use the runtime subject credential to read its effective group policy and wallet snapshot: + +```bash +curl --fail-with-body -H "Authorization: Bearer $RUNTIME_TOKEN" \ + "$GATEWAY_BASE_URL/api/workspace/user-groups" +curl --fail-with-body -H "Authorization: Bearer $RUNTIME_TOKEN" \ + "$GATEWAY_BASE_URL/api/workspace/wallet" +``` + +Save IDs and non-secret fields. Record the wallet balance and frozen balance only when a later task charge will be verified. + +### 2. Reuse or create the pricing rule + +Compare active rules by resource type, unit, base price, `dynamicWeight`, `formulaConfig`, calculator type, currency, and status. Reuse an existing rule when its effective amount can be adjusted with platform or platform-model discounts. Read `model-pricing-and-policies.md` for the rule-set request shape. + +Create a new rule only when no existing rule can express the required unit, formula, dimensions, or undiscounted price. `PATCH /api/admin/pricing/rule-sets/{ruleSetID}` replaces all rules in the set, so preserve every required row. + +### 3. Bind the minimum required layer + +- Put a reusable default rule on the base model with `pricingRuleSetId`. +- Put account-wide pricing on the platform with `pricingRuleSetId` and `defaultDiscountFactor`. +- Put a real per-model exception on the platform model with `pricingRuleSetId`, `discountFactor`, or `billingConfigOverride`. +- Use a user-group `billingDiscountPolicy.discountFactor` only for a subject-wide discount. Read the complete user-group record before PATCH and preserve unrelated rate-limit, quota, recharge, metadata, and status fields. + +Avoid copying the same prices into multiple layers. Read back every changed record before estimating. + +## Price Estimate Requests + +`POST /api/v1/pricing/estimate` accepts a normal request-shaped JSON object plus `kind`. `kind` defaults to `chat.completions`. An API key must have the scope required by the selected kind. + +### Chat estimate + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $RUNTIME_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "kind": "chat.completions", + "model": "", + "messages": [{"role": "user", "content": "Reply with OK"}], + "max_tokens": 256 + }' \ + "$GATEWAY_BASE_URL/api/v1/pricing/estimate" +``` + +Text input tokens are estimated from the request. Output tokens use `max_tokens`; when it is absent or zero, the estimate currently uses 64 output tokens. Use the intended output limit for a meaningful upper-bound comparison. + +### Image estimate + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $RUNTIME_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "kind": "images.generations", + "model": "", + "prompt": "A small orange cat", + "n": 2, + "size": "1024x1024", + "quality": "high" + }' \ + "$GATEWAY_BASE_URL/api/v1/pricing/estimate" +``` + +### Video estimate + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $RUNTIME_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "kind": "videos.generations", + "model": "", + "prompt": "A slow camera move over a lake", + "duration": 12, + "resolution": "1080p", + "audio": true, + "n": 1 + }' \ + "$GATEWAY_BASE_URL/api/v1/pricing/estimate" +``` + +The response has this shape: + +```json +{ + "items": [ + { + "model": "example-model", + "modelAlias": "example-model", + "provider": "example", + "platformId": "", + "platformModelId": "", + "resourceType": "video", + "unit": "5s_video", + "quantity": 3, + "amount": 12.5, + "currency": "resource", + "discountFactor": 0.8, + "simulated": true, + "durationSeconds": 12, + "durationUnitCount": 3 + } + ], + "resolver": "effective-pricing-v1", + "totalAmount": 12.5, + "currency": "resource" +} +``` + +## Estimate Reconciliation + +For every estimate, verify: + +1. `platformId` and `platformModelId` identify the intended first eligible candidate after permissions, capabilities, output-token limits, priority, and runtime availability are applied. +2. `resourceType`, `unit`, `quantity`, and dimension details match the normalized request. +3. `discountFactor` equals the selected platform/platform-model factor multiplied by the effective user-group factor. +4. Each `amount` matches the configured base price, weights, quantity, and discount. +5. `totalAmount` equals the rounded sum of `items[].amount`, and `currency` is expected. + +Useful calculation checks: + +```text +text input = input tokens / 1000 × input price × discount +text output = output tokens / 1000 × output price × discount +image = count × base price × quality/size/resolution weights × discount +video = count × ceil(duration seconds / 5) × base price × applicable weights × discount +speech = Unicode character count × audio price × discount +``` + +Cached input is normally known only after execution. When cached input exists but has no configured price, the runtime currently falls back to one tenth of the normal input price. + +If the estimate selects an unexpected platform, do not edit prices to hide the routing problem. Inspect access rules, enabled state, model type, capabilities, priority, cooldown/load, and output-token limits first. + +## Task and Wallet Charge Verification + +After the read-only estimate is accepted, obtain explicit approval before a simulation or real task if wallet mutation is possible. + +1. Record `GET /api/workspace/wallet` before the request. +2. Submit the exact same `kind`, model, and billing-relevant parameters through the corresponding runtime API. +3. Read `GET /api/workspace/tasks/{taskID}` and capture: + - `billings`; + - `billingSummary.totalAmount` and currency; + - `finalChargeAmount`; + - resolved model and candidate evidence from metrics. +4. Query the final wallet debit: + +```bash +curl --fail-with-body \ + -H "Authorization: Bearer $RUNTIME_TOKEN" \ + "$GATEWAY_BASE_URL/api/workspace/wallet/transactions?q=$TASK_ID&transactionType=task_billing&pageSize=20" +``` + +5. Read the wallet again and compare: + +```text +task finalChargeAmount == billingSummary.totalAmount +task finalChargeAmount == task_billing transaction amount +wallet balance before - wallet balance after == task_billing transaction amount +``` + +The task may also create `reserve` and `release` transactions. Reservation changes frozen balance, not the spend balance; `task_billing` is the final debit to reconcile. Use the task ID as the reference and never sum `reserve`, `release`, and `task_billing` as three charges. + +## Expected Differences and Troubleshooting + +An estimate and final charge may legitimately differ when: + +- actual text input/output or cached-input usage differs from the estimate; +- the request omitted `max_tokens`, so the estimate used the 64-token default; +- generated video duration or audio presence overrides the requested/preprocessed value; +- preprocessing normalizes duration, resolution, count, or model-specific fields; +- the first candidate fails and execution settles on another platform with different pricing; +- the estimate and task used different users, API keys, scopes, groups, or access rules; +- pricing or discount configuration changed between estimate and execution. + +When the difference is unexplained, compare the estimate line, task `billings`, task preprocessing log, candidate metrics, effective user group, wallet transaction metadata, and configuration timestamps. Do not change wallet balances to make a mismatch disappear. diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-pricing-and-policies.md b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-pricing-and-policies.md index e1809a2..8da4484 100644 --- a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-pricing-and-policies.md +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/references/model-pricing-and-policies.md @@ -3,6 +3,7 @@ ## Contents - Pricing Rule Sets +- Billing Configuration and Estimate Reconciliation - Runtime Policy Sets - Runner Policy and Runtime Recovery @@ -37,6 +38,8 @@ Evaluate the price that the runtime will actually use: Do not clone an otherwise identical pricing rule merely to represent a provider or account discount. Keep the reusable base price in the rule and express the instance-specific adjustment with `defaultDiscountFactor` or `discountFactor`. +For the complete binding, estimate, task-charge, and wallet-reconciliation workflow, read `model-billing-configuration-and-estimation.md` before changing billing settings. + Create an example text pricing rule set: ```bash diff --git a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/skill.json b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/skill.json index 4cfced4..27f2655 100644 --- a/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/skill.json +++ b/apps/api/internal/skillbundle/content/skills/ai-gateway-ops-management/skill.json @@ -1,6 +1,6 @@ { "name": "ai-gateway-ops-management", - "version": "1.0.1", + "version": "1.0.2", "modules": [ "model-runtime" ] diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index 4d37917..aedd9c7 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -75,10 +75,10 @@ describe('Public Agent resources', () => { it('loads operations skill metadata without authorization', async () => { const metadata = { name: 'ai-gateway-ops-management', - version: '1.0.1', + version: '1.0.2', displayName: 'AI Gateway 运维管理', modules: ['model-runtime'], - fileName: 'ai-gateway-ops-management-v1.0.1.zip', + fileName: 'ai-gateway-ops-management-v1.0.2.zip', downloadPath: '/api/v1/public/skills/ai-gateway-ops-management/download', apiDocsJsonPath: '/api-docs-json', apiDocsYamlPath: '/api-docs-yaml', From 2d6c16fec0bec9c0288e5cb142b458af982fff8f Mon Sep 17 00:00:00 2001 From: wangbo Date: Fri, 17 Jul 2026 13:57:37 +0800 Subject: [PATCH 08/20] =?UTF-8?q?chore(deps):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=8C=85=E7=89=88=E6=9C=AC=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0vitest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将typescript从5.8.0更新至5.9.3版本 - 新增vitest依赖包,版本为3.2.4 - 配置vitest的子依赖关系包括@types/debug、jiti、lightningcss和yaml --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f6eca5..2fa16b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: typescript: specifier: ^5.8.0 version: 5.9.3 + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/debug@4.1.13)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) packages/contracts: devDependencies: From 91ea0e6a2d629200df7f5a7801a14804c8c5a887 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 14:23:49 +0800 Subject: [PATCH 09/20] ci: harden production quality gates --- .gitea/workflows/ci.yml | 53 +-- .gitea/workflows/release-ci.yml | 86 +++++ README.md | 2 +- deploy/ci/act-runner-config.yaml | 35 -- deploy/ci/act-runner-v2-config.yaml | 36 ++ deploy/ci/easyai-gateway-act-runner.service | 31 -- deploy/ci/easyai-gateway-ci-v2-runner.service | 43 +++ docs/decisions/001-production-cicd.md | 63 ++-- docs/runbooks/production-ci-cd.md | 124 +++++-- scripts/ci-build-images.sh | 54 ++- scripts/ci-validate-semver.sh | 22 ++ scripts/provision-ci-runner.sh | 324 +++++++++++++++--- tests/ci/ci-build-images-test.sh | 107 ++++++ tests/ci/pipeline-test.sh | 243 +++++++++++-- tests/ci/semver-test.sh | 52 +++ 15 files changed, 1023 insertions(+), 252 deletions(-) create mode 100644 .gitea/workflows/release-ci.yml delete mode 100644 deploy/ci/act-runner-config.yaml create mode 100644 deploy/ci/act-runner-v2-config.yaml delete mode 100644 deploy/ci/easyai-gateway-act-runner.service create mode 100644 deploy/ci/easyai-gateway-ci-v2-runner.service create mode 100755 scripts/ci-validate-semver.sh create mode 100755 tests/ci/ci-build-images-test.sh create mode 100755 tests/ci/semver-test.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 394d0ba..c6db54b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -3,14 +3,12 @@ name: ci on: push: branches: [main] - tags: ['v*'] pull_request: + branches: [main] jobs: verify: - runs-on: easyai-gateway-linux - env: - IMAGE_TAG: ${{ github.sha }} + runs-on: easyai-gateway-ci-unprivileged-v2 steps: - name: Checkout without external Actions env: @@ -24,44 +22,19 @@ jobs: authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n') git init . git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \ - fetch --no-tags --depth=1 "$CI_SERVER_URL/$CI_REPOSITORY.git" "$CI_SHA" + fetch --no-tags "$CI_SERVER_URL/$CI_REPOSITORY.git" "$CI_SHA" unset authorization CI_JOB_TOKEN + test ! -f .git/shallow git checkout --detach FETCH_HEAD - name: Verify pinned host toolchains run: | go version node --version pnpm --version - docker version - docker compose version - docker buildx version + docker-compose version shellcheck --version trivy --version govulncheck -version - - name: Verify Production deployment prerequisites - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - run: | - grep -Eq '^NoNewPrivs:[[:space:]]+0$' /proc/self/status - test -x /usr/local/sbin/easyai-ai-gateway-release - sudo -n -l /usr/local/sbin/easyai-ai-gateway-release "$IMAGE_TAG" >/dev/null - - name: Verify release tag ancestry - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - env: - CI_REPOSITORY: ${{ github.repository }} - CI_SERVER_URL: ${{ github.server_url }} - CI_SHA: ${{ github.sha }} - CI_JOB_TOKEN: ${{ github.token }} - run: | - set -eu - tag_name=${GITHUB_REF#refs/tags/} - printf '%s' "$tag_name" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$' - authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n') - git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \ - fetch --no-tags --depth=200 "$CI_SERVER_URL/$CI_REPOSITORY.git" \ - main:refs/remotes/origin/main - unset authorization CI_JOB_TOKEN - test "$(git rev-parse HEAD)" = "$CI_SHA" - git merge-base --is-ancestor "$CI_SHA" refs/remotes/origin/main - name: Verify Go formatting run: | unformatted=$(gofmt -l apps/api) @@ -83,19 +56,15 @@ jobs: run: pnpm audit --audit-level high - name: Validate deployment configuration run: | - docker compose -f docker-compose.yml config --quiet - shellcheck scripts/ci-build-images.sh scripts/provision-ci-runner.sh tests/ci/pipeline-test.sh + docker-compose -f docker-compose.yml config --quiet + shellcheck scripts/ci-build-images.sh scripts/ci-validate-semver.sh \ + scripts/provision-ci-runner.sh tests/ci/ci-build-images-test.sh \ + tests/ci/pipeline-test.sh tests/ci/semver-test.sh + ./tests/ci/ci-build-images-test.sh ./tests/ci/pipeline-test.sh + ./tests/ci/semver-test.sh - name: Scan repository run: | trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL \ --ignore-unfixed --exit-code 1 --timeout 15m --skip-dirs .git \ --skip-dirs node_modules . - - run: ./scripts/ci-build-images.sh - - name: Deploy verified release to Production - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - env: - CI_SHA: ${{ github.sha }} - run: | - test "$(git rev-parse HEAD)" = "$CI_SHA" - sudo -n /usr/local/sbin/easyai-ai-gateway-release "$CI_SHA" diff --git a/.gitea/workflows/release-ci.yml b/.gitea/workflows/release-ci.yml new file mode 100644 index 0000000..6eb7b5c --- /dev/null +++ b/.gitea/workflows/release-ci.yml @@ -0,0 +1,86 @@ +name: release-ci + +on: + push: + tags: ['v*'] + +jobs: + verify-tag: + runs-on: easyai-gateway-ci-unprivileged-v2 + steps: + - name: Checkout without external Actions + env: + CI_REPOSITORY: ${{ github.repository }} + CI_SERVER_URL: ${{ github.server_url }} + CI_SHA: ${{ github.sha }} + CI_JOB_TOKEN: ${{ github.token }} + run: | + set -eu + test -n "$CI_JOB_TOKEN" + authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n') + git init . + git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \ + fetch --no-tags "$CI_SERVER_URL/$CI_REPOSITORY.git" "$CI_SHA" + unset authorization CI_JOB_TOKEN + test ! -f .git/shallow + git checkout --detach FETCH_HEAD + - name: Verify pinned host toolchains + run: | + go version + node --version + pnpm --version + docker-compose version + shellcheck --version + trivy --version + govulncheck -version + - name: Verify release tag ancestry + env: + CI_REPOSITORY: ${{ github.repository }} + CI_SERVER_URL: ${{ github.server_url }} + CI_SHA: ${{ github.sha }} + CI_JOB_TOKEN: ${{ github.token }} + run: | + set -eu + tag_name=${GITHUB_REF#refs/tags/} + ./scripts/ci-validate-semver.sh "$tag_name" + authorization=$(printf 'x-access-token:%s' "$CI_JOB_TOKEN" | base64 | tr -d '\n') + git -c "http.extraHeader=AUTHORIZATION: basic $authorization" \ + fetch --no-tags "$CI_SERVER_URL/$CI_REPOSITORY.git" \ + +refs/heads/main:refs/remotes/origin/main + unset authorization CI_JOB_TOKEN + test ! -f .git/shallow + test "$(git rev-parse HEAD)" = "$CI_SHA" + git merge-base --is-ancestor "$CI_SHA" refs/remotes/origin/main + - name: Verify Go formatting + run: | + unformatted=$(gofmt -l apps/api) + test -z "$unformatted" || { + printf 'Go files require gofmt:\n%s\n' "$unformatted" >&2 + exit 1 + } + - name: Verify Go code + working-directory: apps/api + run: | + go vet ./... + go test ./... + govulncheck ./... + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm test + - run: pnpm build + - name: Audit JavaScript dependencies + run: pnpm audit --audit-level high + - name: Validate deployment configuration + run: | + docker-compose -f docker-compose.yml config --quiet + shellcheck scripts/ci-build-images.sh scripts/ci-validate-semver.sh \ + scripts/provision-ci-runner.sh tests/ci/ci-build-images-test.sh \ + tests/ci/pipeline-test.sh tests/ci/semver-test.sh + ./tests/ci/ci-build-images-test.sh + ./tests/ci/pipeline-test.sh + ./tests/ci/semver-test.sh + - name: Scan repository + run: | + trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL \ + --ignore-unfixed --exit-code 1 --timeout 15m --skip-dirs .git \ + --skip-dirs node_modules . diff --git a/README.md b/README.md index 50b94d6..aa8464d 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ docker compose -f docker-compose.yml restart web ## 生产 CI/CD -Gitea Actions 会对 Pull Request 和 `main` Push 执行完整质量门禁,并以源码完整 Git SHA 构建 API/Web 镜像。`ai.51easyai.com` 只接受来自 `main` 历史的语义版本 Tag 发布,迁移前自动备份数据库,失败时恢复上一应用镜像。安装 Runner、发布、验证和回滚步骤见 [生产 CI/CD 运行手册](docs/runbooks/production-ci-cd.md),设计取舍见 [ADR-001](docs/decisions/001-production-cicd.md)。 +Gitea Actions 会在隔离的 rootless DinD Runner 中对 Pull Request、`main` Push 和版本 Tag 执行完整质量门禁;Tag 使用独立的 `release-ci / verify-tag (push)` context,不能复用旧的 `main` 成功状态。源码 Job 没有宿主 Docker、`sudo` 或生产部署权限。部署仓的 root-owned dispatcher 只在相同 SHA 的 `main` 与 Tag context 都成功后,用固定命令构建并扫描镜像,再以 Registry digest 发布 `ai.51easyai.com`。安装 Runner、Fork PR 审批、发布验证和回滚步骤见 [生产 CI/CD 运行手册](docs/runbooks/production-ci-cd.md),信任边界见 [ADR-001](docs/decisions/001-production-cicd.md)。 Compose 默认使用独立容器数据库 `postgres:18-alpine`,数据卷会保留在 `postgres_data` 和 `api_data`。为避免本地开发 `.env` 中的 `localhost` 数据库地址污染容器部署,compose 使用 `AI_GATEWAY_COMPOSE_*` 变量作为容器部署专用覆盖,例如: diff --git a/deploy/ci/act-runner-config.yaml b/deploy/ci/act-runner-config.yaml deleted file mode 100644 index 028f7cb..0000000 --- a/deploy/ci/act-runner-config.yaml +++ /dev/null @@ -1,35 +0,0 @@ -log: - level: info - -runner: - file: /var/lib/easyai-gateway-runner/.runner - capacity: 1 - envs: - GOPROXY: "https://goproxy.cn,direct" - GOSUMDB: "sum.golang.google.cn" - PATH: "/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - AI_GATEWAY_GO_BUILD_IMAGE: "docker.m.daocloud.io/library/golang:1.26.5-alpine" - AI_GATEWAY_API_RUNTIME_IMAGE: "docker.m.daocloud.io/library/alpine:3.22" - AI_GATEWAY_NODE_BUILD_IMAGE: "docker.m.daocloud.io/library/node:24-alpine" - AI_GATEWAY_WEB_RUNTIME_IMAGE: "docker.m.daocloud.io/library/nginx:1.27-alpine" - timeout: 3h - shutdown_timeout: 5m - insecure: false - fetch_timeout: 5s - fetch_interval: 2s - labels: - - "easyai-gateway-linux:host" - -cache: - enabled: true - dir: /var/lib/easyai-gateway-runner/cache - -container: - docker_host: "-" - privileged: false - force_pull: false - force_rebuild: false - valid_volumes: [] - -host: - workdir_parent: /var/lib/easyai-gateway-runner/work diff --git a/deploy/ci/act-runner-v2-config.yaml b/deploy/ci/act-runner-v2-config.yaml new file mode 100644 index 0000000..eb33e05 --- /dev/null +++ b/deploy/ci/act-runner-v2-config.yaml @@ -0,0 +1,36 @@ +log: + level: info + +runner: + file: /data/.runner + capacity: 1 + envs: + GOPROXY: "https://goproxy.cn,direct" + GOSUMDB: "sum.golang.google.cn" + PATH: "/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + timeout: 3h + shutdown_timeout: 5m + insecure: false + fetch_timeout: 5s + fetch_interval: 2s + labels: + - "easyai-gateway-ci-unprivileged-v2:docker://docker.io/library/node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7" + +cache: + enabled: false + +container: + docker_host: "-" + privileged: false + force_pull: false + force_rebuild: false + require_docker: true + docker_timeout: 1m + bind_workdir: false + workdir_parent: workspace + options: "--volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro" + valid_volumes: + - "/opt/easyai-gateway-ci" + +host: + workdir_parent: /data/host-work diff --git a/deploy/ci/easyai-gateway-act-runner.service b/deploy/ci/easyai-gateway-act-runner.service deleted file mode 100644 index bd207d6..0000000 --- a/deploy/ci/easyai-gateway-act-runner.service +++ /dev/null @@ -1,31 +0,0 @@ -[Unit] -Description=EasyAI AI Gateway Gitea Actions runner -After=network-online.target docker.service -Wants=network-online.target docker.service - -[Service] -Type=simple -User=easyai-gateway-runner -Group=easyai-gateway-runner -WorkingDirectory=/var/lib/easyai-gateway-runner -Environment=HOME=/var/lib/easyai-gateway-runner -Environment=PATH=/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -# Gitea 1.22 can leave a long-running poll stale after a completed host job. -# Process exactly one job, then let Restart=always create a fresh poller. -ExecStart=/opt/easyai-gateway-ci/bin/gitea-runner --config /etc/easyai-gateway-runner/config.yaml daemon --once -Restart=always -RestartSec=5s -TimeoutStopSec=330s -# Tag jobs invoke one SHA-validating root helper. Docker access already gives -# this dedicated repository runner an equivalent host-root trust boundary. -NoNewPrivileges=false -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectControlGroups=true -ReadWritePaths=/var/lib/easyai-gateway-runner - -[Install] -WantedBy=multi-user.target diff --git a/deploy/ci/easyai-gateway-ci-v2-runner.service b/deploy/ci/easyai-gateway-ci-v2-runner.service new file mode 100644 index 0000000..29c8874 --- /dev/null +++ b/deploy/ci/easyai-gateway-ci-v2-runner.service @@ -0,0 +1,43 @@ +[Unit] +Description=EasyAI AI Gateway rootless DinD Gitea Actions runner v2 +Requires=docker.service +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=root +Group=root +Environment=RUNNER_SECURITY_OPTIONS= +EnvironmentFile=-/etc/easyai-gateway-ci-v2/runner.env +ExecStartPre=-/usr/bin/docker rm --force easyai-gateway-ci-v2-runner +ExecStart=/usr/bin/docker run --rm --pull=never --name easyai-gateway-ci-v2-runner --privileged --pids-limit 4096 $RUNNER_SECURITY_OPTIONS --volume easyai-gateway-ci-v2-data:/data --volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro --volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro --env CONFIG_FILE=/config.yaml --env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns --env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 --env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns docker.io/gitea/runner:2.0.0-dind-rootless@sha256:5b7b625ff773d0ee761788c47582503ec1b241fa5b81edebad48a57e663f4f3a +ExecStop=-/usr/bin/docker stop --time 360 easyai-gateway-ci-v2-runner +Restart=always +RestartSec=5s +TimeoutStartSec=60s +TimeoutStopSec=390s + +# systemd only launches the host Docker client. Pull-request jobs run inside the +# nested rootless daemon and never receive the host or nested Docker socket. +NoNewPrivileges=true +CapabilityBoundingSet= +AmbientCapabilities= +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +LockPersonality=true +RestrictRealtime=true +RestrictSUIDSGID=true +SystemCallArchitectures=native +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/docs/decisions/001-production-cicd.md b/docs/decisions/001-production-cicd.md index d9ae60f..9d4d9b6 100644 --- a/docs/decisions/001-production-cicd.md +++ b/docs/decisions/001-production-cicd.md @@ -1,4 +1,4 @@ -# ADR-001: 使用仓库级 Runner 和不可变镜像发布生产环境 +# ADR-001: 隔离质量 CI 与 root-owned 生产发布 ## 状态 @@ -10,42 +10,59 @@ Accepted ## 背景 -AI Gateway 已通过独立部署仓库和 Docker Compose 手工运行在 `110.42.51.33`,公共入口是 `https://ai.51easyai.com`。同一服务器还运行认证中心、K3s 和其他 EasyAI 服务,磁盘空间有限。Gitea 1.22 的 host Runner 已知不能可靠启动第二个依赖 Job,也不完整支持 GitHub Actions 的 `environment`、`permissions` 和 `concurrency` 语义。 +AI Gateway 已通过独立部署仓库和 Docker Compose 运行在 `110.42.51.33`,公共入口是 `https://ai.51easyai.com`。同一服务器还运行认证中心、K3s 和其他 EasyAI 服务,磁盘与信任边界都很紧张。Gitea 1.22 对依赖 Job、`environment`、`permissions` 和 `concurrency` 的支持也不能作为生产发布的安全边界。 -生产发布必须满足:源码可追溯、数据库迁移前有备份、失败可恢复上一应用版本、Secret 不进入仓库或流水线日志,并且不能让普通分支自动修改生产环境。 +源码仓需要为 Pull Request、`main` 和版本 Tag 提供真实质量门禁;生产发布还必须满足源码可追溯、镜像不可变、数据库迁移前有备份、失败可恢复上一应用版本,以及 Tag 控制的脚本不能直接取得宿主 root 权限。 ## 决策 -- 为 `BCAI/easyai-ai-gateway` 注册独立、仓库级 host Runner。它不与认证中心 Runner 共享身份、状态目录或任务标签。 -- Pull Request 和 `main` Push 执行相同质量门禁与镜像构建;只有符合 `vMAJOR.MINOR.PATCH` 形式、且目标提交属于 `main` 历史的 Tag 才发布生产。 -- API/Web 镜像使用完整 Git SHA 作为运行时 Tag。语义版本 Tag 只是发布授权信号,不作为可变镜像标识。 -- 验证和发布处于同一个 Runner Job。这样部署只使用已通过全部门禁的同一 checkout,并规避当前 Gitea 版本的依赖 Job 领取问题。 -- Runner 使用专属 Buildx builder,缓存上限为 2 GB。服务器 root 现有的阿里云 Registry 凭据只由固定发布助手使用,不复制到 Gitea Secret。 -- 发布助手在迁移前生成 PostgreSQL custom-format 备份并验证目录;保留最近 5 份。应用健康检查失败时恢复上一组 API/Web 镜像。 -- 数据库迁移必须保持向后兼容。自动回滚只覆盖应用镜像;数据库恢复需要人工确认,避免覆盖失败发布后产生的有效写入。 -- Gitea Runner 的 Docker 权限等价于宿主机 root 权限。因此只允许受信任的仓库维护者触发该 Runner,来自 Fork 的 Pull Request 必须先经维护者批准。 +### 质量 CI + +- `BCAI/easyai-ai-gateway` 使用仓库级 Runner 标签 `easyai-gateway-ci-unprivileged-v2`。PR 与 `main` 由 `ci.yml` 执行,context 分别为 `ci / verify (pull_request)`、`ci / verify (push)`;`v*` Tag 必须由独立 `release-ci.yml` 执行,context 为 `release-ci / verify-tag (push)`。两份 workflow 执行相同质量门禁,但 Tag 不复用 `main` 的旧 context。 +- Runner 外层固定为 `gitea/runner:2.0.0-dind-rootless` 的指定 digest;Job 固定为 `node:24.16.0-bookworm` 的指定 digest。外层按官方 rootless DinD 要求使用 `--privileged`,但内层 Docker daemon 和 Runner 以 UID 1000 运行。 +- 不把宿主或内层 Docker socket 挂入 Job。Runner 配置使用 `docker_host: "-"`、`privileged: false`,工作流唯一允许的 bind source 是只读 `/opt/easyai-gateway-ci`,其中提供固定 Go、Node、pnpm、ShellCheck、Compose、Trivy 和 govulncheck 工具链。ShellCheck 与 Compose 使用官方静态发行物和固定 SHA-256,不复制宿主的可变插件或在 Job 中临时安装。RootlessKit 强制 `--pidns`,即使 PR 传入 `--pid=host`,Job 也不能进入外层 Runner 的 PID namespace;provision 会实测 namespace inode。 +- CI 只执行格式、静态检查、单元测试、依赖审计、Compose 校验、仓库扫描和 CI 自测;不构建发布镜像,不调用 `sudo`,不执行生产发布助手。 +- 版本 Tag 必须是完整 SemVer,且目标提交必须属于 `main` 历史。该检查是发布前置证据,但本身不授予生产权限。 +- Runner 注册状态保存在专用 Docker named volume,首次注册 Token 只传给短命注册容器;长期 systemd 服务不保存 Token,也不加入宿主 `docker`/`sudo` 用户组。 + +### 生产 CD + +- 生产发布由部署仓的 root-owned dispatcher 负责,和源码 CI Runner 分离。dispatcher 只接受受保护的 `main` 历史版本 Tag,并在对应源码 SHA 上同时等待 `ci / verify (push)` 与本次 Tag 新产生的 `release-ci / verify-tag (push)` 成功。 +- root 服务不执行 Tag 控制的 `pnpm`、测试脚本、源码仓 shell 脚本或应用程序。它只调用部署仓中由 root 固定和审核的命令,通过 BuildKit 构建目标镜像、用 Trivy 扫描镜像、发布 Registry digest,再进行备份、迁移、健康检查和回滚。 +- API/Web 镜像以完整 Git SHA 作为可追溯 Tag,但生产 Compose 使用 Registry digest。服务器为每个 Git SHA 保存 root-only digest 清单,并拒绝把未知的既有 SHA Tag 当作可信发布物。 +- 发布助手在迁移前生成并验证 PostgreSQL custom-format 备份,保留最近 5 份。应用健康检查失败时恢复上一组 digest-pinned 镜像;数据库恢复仍需人工确认。 + +### 残余风险与触发策略 + +rootless DinD 显著缩小了 Job 到宿主 Docker/root 的直接通路,但不是虚拟机级隔离。外层容器仍需 `--privileged`;Runner、rootless Docker、内核或容器运行时漏洞,以及 Gitea 对 `jobs..container.options` 的透传,都可能造成容器边界突破。Job 还保留依赖安装所需的出站网络,并可能消耗共享主机的 CPU、内存和磁盘。因此: + +- 来自同仓受信任分支的 PR 可自动运行;来自 Fork 的 PR 必须先由维护者检查工作流变更并在 Gitea 中批准,不能自动调度。 +- CI Job 不接收生产 Secret、宿主 Docker socket、部署目录或 Runner 状态目录。 +- Runner 容量固定为 1,外层容器设 4096 PID 上限;磁盘在安装前后硬门禁,但共享主机仍不具备完整的 CPU、内存和长期磁盘配额隔离。 +- 中长期应把不受信任 PR CI 迁移到独立主机或虚拟机;当前 rootless Runner 不能被描述为强多租户沙箱。 +- Ubuntu 22.04 没有受限 user namespace,生产主机缺少 `rootlesskit` AppArmor profile 时不硬编码该 profile。provision 会先探测;在启用了 `kernel.apparmor_restrict_unprivileged_userns=1` 的新系统上,如果 profile 缺失则失败,不会降级为 `apparmor=unconfined` 或关闭内核限制。 ## 备选方案 -### 复用认证中心 Runner +### 让源码 Runner 直接挂载宿主 Docker socket -拒绝。现有 Runner 是认证中心仓库级身份,扩大为组织级 Runner 会让更多仓库获得同一 Docker/root 信任边界。 +拒绝。Docker socket 等价于宿主 root,任何 PR 工作流都可能取得生产服务器控制权,Fork 审批也不能把它变成可靠的技术隔离。 -### 由 Runner 保存 SSH 或 Registry Secret +### 使用 host executor 但移除 Docker 组 -拒绝。Runner 已位于目标服务器,绕回 SSH 会增加长期凭据;复制 Registry Secret 也会扩大暴露面。固定 root helper 可以复用服务器现有登录状态。 +拒绝。host Job 仍可读取宿主可见文件、消耗宿主资源并攻击同一用户进程;Gitea 对容器选项的处理也无法为 host executor 提供隔离。 -### `main` 通过后直接发布生产 +### 让 Tag 工作流通过 `sudo` 调用发布脚本 -拒绝。`ai.51easyai.com` 是生产入口。语义版本 Tag 提供明确的发布授权点,同时仍保留自动化与可审计性。 +拒绝。Tag 可以控制 checkout 中的脚本和应用代码;root 服务不得执行这些内容。固定部署仓 dispatcher 是独立的受保护信任根。 -### 只依赖 `latest` +### `main` 通过后立即发布或只依赖 `latest` -拒绝。`latest` 无法证明运行中的容器对应哪个源码提交,也不能可靠选择回滚版本。 +拒绝。语义版本 Tag 是明确的发布授权点;digest 才能证明运行中的容器对应不可变产物并可靠选择回滚版本。 ## 影响 -- 日常合并不会自动改变生产;创建并推送版本 Tag 才会部署。 -- 首次启用 CD 时需要在服务器捕获当前运行镜像作为 bootstrap 回滚基线。 -- 发布前必须确保数据库迁移对上一应用版本保持兼容。 -- 如果未来增加不受信任的贡献者,应把 CI 构建迁移到隔离的 rootless Runner,并保留独立的受保护部署 Runner。 +- PR、`main` 和版本 Tag 都有自动质量证据,但源码仓 CI 成功不会单独获得生产权限。 +- 创建受保护版本 Tag 后,部署仓 dispatcher 必须同时取得同 SHA 的 `main` context 和独立 Tag context,不能把旧 `main` 成功状态误当作本次 Tag 验证。 +- 首次安装会清理本项目专属 Buildx 缓存并执行 8 GiB 前置、4 GiB 后置磁盘门禁,不会全局 prune 共享 Docker 状态。 +- 首次启用 CD 仍需捕获当前运行镜像作为 bootstrap 回滚基线,并保证数据库迁移对上一应用版本向后兼容。 diff --git a/docs/runbooks/production-ci-cd.md b/docs/runbooks/production-ci-cd.md index e8d3eb5..c2f3693 100644 --- a/docs/runbooks/production-ci-cd.md +++ b/docs/runbooks/production-ci-cd.md @@ -1,42 +1,102 @@ # 生产 CI/CD 运行手册 -## 流水线约定 +## 信任边界与固定资源 -- Runner:`easyai-gateway-act-runner.service` -- Runner 标签:`easyai-gateway-linux:host` -- Runner 状态:`/var/lib/easyai-gateway-runner` -- 工具链:`/opt/easyai-gateway-ci` -- Gitea:`https://git.51easyai.com/BCAI/easyai-ai-gateway` +- 源码仓:`https://git.51easyai.com/BCAI/easyai-ai-gateway` +- CI Runner:`easyai-gateway-ci-v2-runner.service` +- CI 标签:`easyai-gateway-ci-unprivileged-v2` +- Runner 状态:Docker named volume `easyai-gateway-ci-v2-data` 内的 `/data/.runner` +- 只读工具链:`/opt/easyai-gateway-ci` +- Runner 配置:`/etc/easyai-gateway-ci-v2/config.yaml` +- Runner 运行参数:`/etc/easyai-gateway-ci-v2/runner.env` - 生产入口:`https://ai.51easyai.com` - 部署目录:`/root/easyai-ai-gateway-deploy` -PR、`main` Push 和版本 Tag 都会执行格式、Go vet/test、govulncheck、前端 lint/test/build、依赖审计、Compose 校验、Trivy 扫描与 API/Web 镜像构建。只有版本 Tag 会执行最后的生产发布步骤。 +源码仓只执行质量 CI:PR context 是 `ci / verify (pull_request)`,`main` context 是 `ci / verify (push)`,版本 Tag 使用独立 workflow/context `release-ci / verify-tag (push)`。三者都执行 Go 格式/vet/test/govulncheck、前端 lint/test/build、依赖审计、Compose 校验、Trivy 仓库扫描和 CI 脚本自测。Job 没有 Docker socket、`sudo` 或部署目录权限,也不构建发布镜像。 -## 首次安装或修复 Runner +生产 CD 由部署仓的 root-owned dispatcher 执行。它在相同源码 SHA 上同时等待 `ci / verify (push)` 与本次新产生的 `release-ci / verify-tag (push)` 成功,然后只运行部署仓中固定的 BuildKit 构建、Trivy 镜像扫描、digest 发布、备份、迁移、健康检查和回滚命令;不得执行 Tag checkout 中的 `pnpm`、测试、shell 脚本或应用程序。 -从 Gitea 仓库设置的 Actions Runner 页面取得一次性仓库注册 Token,在服务器可信 checkout 中执行: +provision 安装 checksum-pinned 的官方静态 ShellCheck `0.11.0` 和 Docker Compose `5.3.1`,不会依赖宿主是否预装这些命令。所有下载都在使用前校验固定 SHA-256,并在精确 Node Job 镜像内实际执行版本检查。 + +## 首次安装或修复 CI Runner + +前置条件:Linux x86_64、可用 Docker Engine、root 权限,以及 Docker 文件系统至少 8 GiB 可用空间。脚本只压缩本项目专属 `easyai-gateway-ci` Buildx 缓存,不运行全局 `docker system prune`,因为该服务器与其他服务共享 Docker 状态。 + +从 Gitea 仓库设置的 Actions Runner 页面取得一次性仓库注册 Token,在服务器可信的源码 checkout 中执行: ```bash RUNNER_REGISTRATION_TOKEN='<一次性 Token>' ./scripts/provision-ci-runner.sh ``` -Token 只通过进程环境传入。生成的 `/var/lib/easyai-gateway-runner/.runner` 必须保持 `0600`,不得复制到仓库、报告或聊天记录。 - -验证: +已有有效 named volume 时可直接重跑,不再需要 Token: ```bash -systemctl is-active easyai-gateway-act-runner.service -systemctl is-enabled easyai-gateway-act-runner.service -journalctl -u easyai-gateway-act-runner.service --since '15 minutes ago' +./scripts/provision-ci-runner.sh ``` -Runner 使用 `daemon --once`。每个 Job 结束后进程自然退出,再由 systemd 启动新的长轮询进程。 +注册 Token 通过交互 stdin 送给短命注册容器,不出现在宿主或容器的命令行参数中;provision 随后立即从环境中清除它,长期 systemd unit 也不包含 Token。不要输出或复制 `/data/.runner` 内容。脚本会停用旧 host Runner,并确保旧 Runner 用户和 v2 遗留用户不属于 `docker`/`sudo` 组。 + +默认磁盘门禁可用环境变量提高,但生产环境不应降低: + +```bash +CI_RUNNER_MIN_FREE_GIB=8 \ +CI_RUNNER_MIN_POST_INSTALL_FREE_GIB=4 \ +RUNNER_REGISTRATION_TOKEN='<一次性 Token>' \ +./scripts/provision-ci-runner.sh +``` + +## AppArmor 分支 + +Ubuntu 22.04 默认没有 `kernel.apparmor_restrict_unprivileged_userns` 限制,当前服务器没有 `rootlesskit` profile 是受支持路径。provision 会实测 Docker 是否接受该 profile;不存在时写入: + +```text +RUNNER_SECURITY_OPTIONS="" +``` + +如果新系统的 `/proc/sys/kernel/apparmor_restrict_unprivileged_userns` 为 `1`,则必须先由宿主发行版或 Docker 安装包提供并加载 `rootlesskit` profile;provision 只探测和使用,不会自行复制一份 profile。缺失时 provision 会停止;不得手工关闭该 sysctl,也不得使用 `apparmor=unconfined` 绕过。 + +参考:[Docker Rootless Docker-in-Docker](https://docs.docker.com/engine/security/rootless/tips/#rootless-docker-in-docker)、[Ubuntu 安全特性版本矩阵](https://documentation.ubuntu.com/security/security-features/security-features-overview/)。 + +## Runner 真实验证 + +安装脚本会先启动一个无 Token、无持久 Runner 状态挂载、无宿主 bind mount 的短命 rootless DinD probe;随后启动长期服务,确认 Runner 已向 Gitea declare、通过内层 `docker info` 验证 `name=rootless`,再从内层 daemon 拉取固定 digest 的 Job 镜像,并在该镜像中实际运行全部工具。人工复核: + +```bash +systemctl is-active easyai-gateway-ci-v2-runner.service +systemctl is-enabled easyai-gateway-ci-v2-runner.service +systemctl cat easyai-gateway-ci-v2-runner.service +journalctl -u easyai-gateway-ci-v2-runner.service --since '15 minutes ago' --no-pager + +docker inspect -f 'user={{.Config.User}} privileged={{.HostConfig.Privileged}} apparmor={{.AppArmorProfile}}' \ + easyai-gateway-ci-v2-runner +docker inspect -f '{{range .Mounts}}{{println .Source " -> " .Destination .Mode}}{{end}}' \ + easyai-gateway-ci-v2-runner +docker exec easyai-gateway-ci-v2-runner docker info \ + --format '{{range .SecurityOptions}}{{println .}}{{end}}' +docker exec easyai-gateway-ci-v2-runner docker image inspect \ + 'docker.io/library/node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7' \ + --format '{{.Id}}' +``` + +预期:外层 image user 为 `rootless`、外层因 DinD 为 `privileged=true`、内层安全选项含 `name=rootless`;RootlessKit 使用独立 PID namespace,内层 `--pid=host` 也不能看到外层 Runner;挂载仅包含专用 `/data`、只读配置和只读工具链,不能出现 `/var/run/docker.sock` 或 `/run/docker.sock`。 + +在 Gitea 中分别验证: + +1. 同仓 PR 的 `ci / verify (pull_request)` 成功。 +2. `main` Push 的 `ci / verify (push)` 成功。 +3. 合法 SemVer Tag 新产生独立 `release-ci / verify-tag (push)`;非法或不属于 `main` 历史的 Tag 失败,且 Tag context 不能被旧 `main` context 替代。 +4. Fork PR 默认不自动调度,维护者先检查 `.gitea/workflows/**` 和容器选项后再批准。 + +## Fork PR 残余风险 + +rootless DinD 不是 VM 沙箱。外层仍按官方要求使用 `--privileged`,Gitea runner 也会解析 PR 控制的 `jobs..container.options`。配置已禁止 Job privileged、宿主/内层 Docker socket 和任意 bind source,但 Runner、rootless Docker、容器运行时或内核漏洞仍可能突破边界;Job 的出站网络和资源消耗也不是强租户隔离。因此 Fork PR 必须维护者批准,CI 不得注入生产 Secret;未来应迁移到独立 CI 主机或虚拟机。 ## 发布生产版本 -1. 合并到 `main`,等待 `ci / verify (push)` 成功。 -2. 在已验证的 `main` 提交上创建语义版本 Tag。 -3. 推送 Tag,等待同一 CI Job 完成镜像扫描、发布、数据库备份、迁移和公共健康检查。 +1. 合并到受保护 `main`,确认源码 CI 成功。 +2. 在已验证提交上创建完整 SemVer Tag 并推送。 +3. 确认同一 SHA 的 `ci / verify (push)` 和本次 `release-ci / verify-tag (push)` 都成功。 +4. 由部署仓 root-owned dispatcher 构建、扫描并发布 digest;源码仓工作流不会直接调用生产 helper。 ```bash git switch main @@ -45,7 +105,7 @@ git tag -a v0.1.1 -m 'release: v0.1.1' git push origin v0.1.1 ``` -流水线会部署 Tag 指向提交的完整 Git SHA,而不是 `latest` 或版本号镜像。 +dispatcher 以完整 Git SHA 发布 Registry Tag,并把 Registry 返回的 digest 写入服务器 root-only 发布清单。生产 Compose 使用 `repository@sha256:...`,不是 `latest`、版本号或可变 SHA Tag。 ## 发布后验证 @@ -57,34 +117,32 @@ ssh root@110.42.51.33 'cd /root/easyai-ai-gateway-deploy && ./gateway-ops.sh ps' 同时检查服务器磁盘、API 日志和数据库容器健康状态。发布后的首小时如出现错误率翻倍、P95 延迟增加 50%、数据完整性或安全问题,应立即回滚。 -## 应用回滚 +## 应用与数据库回滚 -发布失败时脚本会自动恢复 `.release.env` 中的上一组 API/Web 镜像并重复健康检查。人工回滚到一个仍存在于本机或 Registry 的历史 Git SHA: +发布失败时固定部署脚本会恢复上一组 API/Web digest 并重复健康检查。人工回滚只选择服务器已有 root-only 发布清单的历史 Git SHA: ```bash sudo /usr/local/sbin/easyai-ai-gateway-release <40 位历史 Git SHA> ``` -该操作也会先创建数据库备份。不要把 `latest`、分支名或版本号传给发布助手。 - -## 数据库恢复 - -备份位于 `/var/backups/easyai-ai-gateway`,默认只保留最近 5 份。应用回滚不会自动恢复数据库。只有确认需要回退数据且已经停止写入后,才能由操作员选择备份执行 `pg_restore`;恢复前必须再保留当前数据库副本。 +没有 `/root/easyai-ai-gateway-deploy/.releases/.env` 的远端 SHA Tag 不应被盲目信任。数据库备份位于 `/var/backups/easyai-ai-gateway`,只保留最近 5 份。应用回滚不会自动恢复数据库;只有停止写入并再次保留当前副本后,操作员才能选择备份执行 `pg_restore`。 ## Runner 故障恢复 -重启前先确认没有构建、扫描或部署子进程: +先在 Gitea 确认没有运行中的 Job,再检查服务与两个 daemon: ```bash -ps -u easyai-gateway-runner -o pid,ppid,etime,cmd --forest -systemctl status easyai-gateway-act-runner.service --no-pager +systemctl status easyai-gateway-ci-v2-runner.service --no-pager +docker logs --tail 100 easyai-gateway-ci-v2-runner +docker exec easyai-gateway-ci-v2-runner docker info +df -h /var/lib/docker ``` 确认空闲后才执行: ```bash -systemctl restart easyai-gateway-act-runner.service -journalctl -u easyai-gateway-act-runner.service -n 50 --no-pager +systemctl restart easyai-gateway-ci-v2-runner.service +journalctl -u easyai-gateway-ci-v2-runner.service -n 100 --no-pager ``` -不要因为日志暂时没有新增就用定时器重启 Runner;镜像构建和扫描可长时间无输出。 +不要删除 `easyai-gateway-ci-v2-data` volume;它包含 Runner 注册状态和内层 daemon 缓存。不得用全局 prune 处理磁盘压力。 diff --git a/scripts/ci-build-images.sh b/scripts/ci-build-images.sh index f34ba28..48ce1f0 100755 --- a/scripts/ci-build-images.sh +++ b/scripts/ci-build-images.sh @@ -9,6 +9,17 @@ registry=${AI_GATEWAY_IMAGE_REGISTRY:-registry.cn-shanghai.aliyuncs.com/easyaigc builder=${AI_GATEWAY_BUILDX_BUILDER:-easyai-gateway-ci} cache_limit=${AI_GATEWAY_BUILDX_CACHE_LIMIT:-2gb} platform=${AI_GATEWAY_PLATFORM:-linux/amd64} +keep_images=0 +build_complete=0 + +case ${1:-} in + '') ;; + --keep-images) keep_images=1 ;; + *) + echo 'usage: ci-build-images.sh [--keep-images]' >&2 + exit 64 + ;; +esac if [[ ! $tag =~ ^[0-9a-f]{40}$ ]]; then echo 'IMAGE_TAG must be a full lowercase Git SHA' >&2 @@ -18,19 +29,45 @@ if [[ ! $registry =~ ^[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._/-]+)+$ ]]; then echo 'AI_GATEWAY_IMAGE_REGISTRY has an invalid format' >&2 exit 1 fi +if [[ ! $cache_limit =~ ^[1-9][0-9]*(kb|mb|gb)$ ]]; then + echo 'AI_GATEWAY_BUILDX_CACHE_LIMIT has an invalid format' >&2 + exit 1 +fi + +api_image="$registry/ai-gateway:$tag" +web_image="$registry/ai-gateway-web:$tag" + +cleanup() { + original_status=$? + trap - EXIT HUP INT TERM + cleanup_status=0 + + docker buildx prune --builder "$builder" --force --max-used-space "$cache_limit" >/dev/null || { + cleanup_status=$? + echo 'failed to prune the CI BuildKit cache' >&2 + } + if [[ $keep_images -ne 1 || $build_complete -ne 1 ]]; then + docker image rm "$api_image" "$web_image" >/dev/null 2>&1 || { + image_rm_status=$? + [[ $cleanup_status -ne 0 ]] || cleanup_status=$image_rm_status + echo 'failed to remove CI image tags' >&2 + } + fi + + if [[ $original_status -ne 0 ]]; then + exit "$original_status" + fi + exit "$cleanup_status" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM if ! docker buildx inspect "$builder" >/dev/null 2>&1; then docker buildx create --name "$builder" --driver docker-container --use >/dev/null fi docker buildx inspect "$builder" --bootstrap >/dev/null - -cleanup_cache() { - docker buildx prune --builder "$builder" --force --max-used-space "$cache_limit" >/dev/null || true -} -trap cleanup_cache EXIT HUP INT TERM - -api_image="$registry/ai-gateway:$tag" -web_image="$registry/ai-gateway-web:$tag" common_args=( --builder "$builder" --platform "$platform" @@ -58,4 +95,5 @@ for image in "$api_image" "$web_image"; do --exit-code 1 --timeout 15m "$image" done +build_complete=1 printf 'gateway_images=PASS git_sha=%s api=%s web=%s\n' "$tag" "$api_image" "$web_image" diff --git a/scripts/ci-validate-semver.sh b/scripts/ci-validate-semver.sh new file mode 100755 index 0000000..167235f --- /dev/null +++ b/scripts/ci-validate-semver.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +tag=${1:-} + +die() { + printf 'invalid release tag: %s\n' "$tag" >&2 + exit 1 +} + +[[ $tag == v* ]] || die +version=${tag#v} + +# Bash arrays discard trailing empty fields, so parsing first would accept +# values such as v1.2.3. and v1.2.3-alpha. Validate the complete string with +# the SemVer 2.0.0 grammar before doing anything else with the tag. +semver_pattern='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' +LC_ALL=C +export LC_ALL +[[ $version =~ $semver_pattern ]] || die + +printf 'release_tag=PASS tag=%s\n' "$tag" diff --git a/scripts/provision-ci-runner.sh b/scripts/provision-ci-runner.sh index 23a92a0..4092fdd 100755 --- a/scripts/provision-ci-runner.sh +++ b/scripts/provision-ci-runner.sh @@ -2,36 +2,121 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) -PREFIX=${CI_RUNNER_PREFIX:-/opt/easyai-gateway-ci} -RUNNER_HOME=${CI_RUNNER_HOME:-/var/lib/easyai-gateway-runner} -RUNNER_USER=${CI_RUNNER_USER:-easyai-gateway-runner} +PREFIX=/opt/easyai-gateway-ci GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL:-https://git.51easyai.com} -RUNNER_NAME=${RUNNER_NAME:-easyai-gateway-linux} -RUNNER_LABEL=${RUNNER_LABEL:-easyai-gateway-linux:host} +RUNNER_NAME=${RUNNER_NAME:-easyai-gateway-ci-unprivileged-v2} +RUNNER_VOLUME=easyai-gateway-ci-v2-data +RUNNER_CONTAINER=easyai-gateway-ci-v2-runner +LEGACY_BUILDER=${AI_GATEWAY_BUILDX_BUILDER:-easyai-gateway-ci} +MIN_FREE_GIB=${CI_RUNNER_MIN_FREE_GIB:-8} +MIN_POST_INSTALL_FREE_GIB=${CI_RUNNER_MIN_POST_INSTALL_FREE_GIB:-4} GITEA_RUNNER_VERSION=2.0.0 -GITEA_RUNNER_SHA256=447156b33407ee045409f5552bd4a188a315cdd4085b4b498d8d4a9ad26c9f73 GO_VERSION=1.26.5 GO_SHA256=5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053 NODE_VERSION=24.16.0 NODE_SHA256=d804845d34eddc21dc1092b519d643ef40b1f58ec5dec5c22b1f4bd8fabde6c9 TRIVY_VERSION=0.70.0 TRIVY_SHA256=8b4376d5d6befe5c24d503f10ff136d9e0c49f9127a4279fd110b727929a5aa9 +SHELLCHECK_VERSION=0.11.0 +SHELLCHECK_SHA256=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 +COMPOSE_VERSION=5.3.1 +COMPOSE_SHA256=f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959 PNPM_VERSION=10.18.1 +RUNNER_IMAGE='docker.io/gitea/runner:2.0.0-dind-rootless@sha256:5b7b625ff773d0ee761788c47582503ec1b241fa5b81edebad48a57e663f4f3a' +JOB_IMAGE='docker.io/library/node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7' +RUNNER_LABEL="easyai-gateway-ci-unprivileged-v2:docker://$JOB_IMAGE" -[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; } -[ "$(uname -s)-$(uname -m)" = "Linux-x86_64" ] || { echo "only Linux x86_64 is supported" >&2; exit 1; } +fail() { + echo "$*" >&2 + exit 1 +} + +case $MIN_FREE_GIB:$MIN_POST_INSTALL_FREE_GIB in + *[!0-9:]* | :* | *:) + fail "CI runner free-space limits must be whole GiB values" + ;; +esac +[ "$MIN_FREE_GIB" -ge 8 ] || fail "CI_RUNNER_MIN_FREE_GIB cannot be lower than 8" +[ "$MIN_POST_INSTALL_FREE_GIB" -ge 4 ] || \ + fail "CI_RUNNER_MIN_POST_INSTALL_FREE_GIB cannot be lower than 4" + +[ "$(id -u)" -eq 0 ] || fail "run as root" +[ "$(uname -s)-$(uname -m)" = "Linux-x86_64" ] || fail "only Linux x86_64 is supported" +case $RUNNER_IMAGE in + *"/runner:${GITEA_RUNNER_VERSION}-dind-rootless@sha256:"*) ;; + *) fail "RUNNER_IMAGE does not match GITEA_RUNNER_VERSION" ;; +esac +command -v docker >/dev/null 2>&1 || fail "Docker Engine is required" +docker info >/dev/null 2>&1 || fail "Docker Engine is not reachable" +grep -Fq "\"$RUNNER_LABEL\"" "$ROOT/deploy/ci/act-runner-v2-config.yaml" || \ + fail "runner label does not match the pinned job image" + +# Fail closed before installing anything. Neither the legacy host runner nor an +# earlier v2 unit may poll for work while its replacement is being prepared. +systemctl disable --now easyai-gateway-act-runner.service >/dev/null 2>&1 || true +systemctl disable --now easyai-gateway-ci-v2-runner.service >/dev/null 2>&1 || true +docker rm --force "$RUNNER_CONTAINER" >/dev/null 2>&1 || true +rm -f /etc/systemd/system/easyai-gateway-act-runner.service + +if id easyai-gateway-runner >/dev/null 2>&1; then + if getent group docker >/dev/null 2>&1; then + gpasswd -d easyai-gateway-runner docker >/dev/null 2>&1 || true + fi + if getent group sudo >/dev/null 2>&1; then + gpasswd -d easyai-gateway-runner sudo >/dev/null 2>&1 || true + fi +fi +if id easyai-gateway-ci-v2 >/dev/null 2>&1; then + if getent group docker >/dev/null 2>&1; then + gpasswd -d easyai-gateway-ci-v2 docker >/dev/null 2>&1 || true + fi + if getent group sudo >/dev/null 2>&1; then + gpasswd -d easyai-gateway-ci-v2 sudo >/dev/null 2>&1 || true + fi +fi +for account in easyai-gateway-runner easyai-gateway-ci-v2; do + if id "$account" >/dev/null 2>&1 && \ + id -nG "$account" | tr ' ' '\n' | grep -Eq '^(docker|sudo)$'; then + fail "$account must not belong to a privileged group" + fi +done if ! command -v make >/dev/null 2>&1 || ! command -v gcc >/dev/null 2>&1 || \ - ! command -v jq >/dev/null 2>&1 || ! command -v shellcheck >/dev/null 2>&1 || \ - ! docker compose version >/dev/null 2>&1 || ! docker buildx version >/dev/null 2>&1; then + ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 || \ + ! command -v xz >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y --no-install-recommends \ - build-essential ca-certificates curl docker-buildx-plugin docker-compose-plugin \ - git jq perl shellcheck unzip util-linux xz-utils + build-essential ca-certificates curl git jq perl unzip util-linux xz-utils fi +docker_root=$(docker info --format '{{.DockerRootDir}}') +[ -n "$docker_root" ] || fail "Docker root directory could not be determined" + +require_free_space() { + minimum_gib=$1 + phase=$2 + available_kib=$(df -Pk "$docker_root" | awk 'NR == 2 { print $4 }') + case $available_kib in + '' | *[!0-9]*) fail "free space could not be determined for $docker_root" ;; + esac + minimum_kib=$((minimum_gib * 1024 * 1024)) + if [ "$available_kib" -lt "$minimum_kib" ]; then + echo "$phase requires at least ${minimum_gib} GiB free on the Docker filesystem" >&2 + return 1 + fi +} + +# This builder belonged only to the retired gateway image-building runner. Cap +# its unused cache before adding the nested daemon; never prune global images, +# volumes, or caches owned by another service on this shared production host. +if docker buildx version >/dev/null 2>&1 && \ + docker buildx inspect "$LEGACY_BUILDER" >/dev/null 2>&1; then + docker buildx prune --builder "$LEGACY_BUILDER" --force --max-used-space 512mb +fi +require_free_space "$MIN_FREE_GIB" "CI runner installation" + download_verified() { url=$1 output=$2 @@ -43,20 +128,22 @@ download_verified() { printf '%s %s\n' "$checksum" "$output" | sha256sum -c - } -if ! id "$RUNNER_USER" >/dev/null 2>&1; then - useradd --system --home-dir "$RUNNER_HOME" --create-home --shell /usr/sbin/nologin "$RUNNER_USER" -fi -usermod -aG docker "$RUNNER_USER" - install -d -m 0755 "$PREFIX/bin" "$PREFIX/downloads" "$PREFIX/toolchains" -install -d -o "$RUNNER_USER" -g "$RUNNER_USER" -m 0700 \ - "$RUNNER_HOME" "$RUNNER_HOME/cache" "$RUNNER_HOME/work" download_verified \ - "https://gitea.com/gitea/runner/releases/download/v${GITEA_RUNNER_VERSION}/gitea-runner-${GITEA_RUNNER_VERSION}-linux-amd64" \ - "$PREFIX/downloads/gitea-runner-${GITEA_RUNNER_VERSION}-linux-amd64" \ - "$GITEA_RUNNER_SHA256" -install -m 0755 "$PREFIX/downloads/gitea-runner-${GITEA_RUNNER_VERSION}-linux-amd64" "$PREFIX/bin/gitea-runner" + "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \ + "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \ + "$SHELLCHECK_SHA256" +rm -rf "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}" +tar -xJf "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \ + -C "$PREFIX/downloads" +install -m 0755 "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" "$PREFIX/bin/shellcheck" + +download_verified \ + "https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" \ + "$PREFIX/downloads/docker-compose-${COMPOSE_VERSION}-linux-x86_64" \ + "$COMPOSE_SHA256" +install -m 0755 "$PREFIX/downloads/docker-compose-${COMPOSE_VERSION}-linux-x86_64" "$PREFIX/bin/docker-compose" download_verified \ "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \ @@ -84,30 +171,177 @@ tar -xzf "$PREFIX/downloads/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" -C "$PREF chmod 0755 "$PREFIX/bin/trivy" GOBIN="$PREFIX/bin" GOPROXY="${GO_MODULE_PROXY:-https://goproxy.cn,direct}" \ "$PREFIX/toolchains/go/bin/go" install golang.org/x/vuln/cmd/govulncheck@v1.6.0 +chown -R root:root "$PREFIX" +chmod -R go-w "$PREFIX" -install -d -m 0755 /etc/easyai-gateway-runner -install -m 0644 "$ROOT/deploy/ci/act-runner-config.yaml" /etc/easyai-gateway-runner/config.yaml -install -m 0644 "$ROOT/deploy/ci/easyai-gateway-act-runner.service" /etc/systemd/system/easyai-gateway-act-runner.service +require_free_space "$MIN_FREE_GIB" "runner image pull" +docker pull "$RUNNER_IMAGE" +require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed runner image pull" +docker volume create "$RUNNER_VOLUME" >/dev/null -if [ ! -s "$RUNNER_HOME/.runner" ]; then - [ -n "${RUNNER_REGISTRATION_TOKEN:-}" ] || { - echo "RUNNER_REGISTRATION_TOKEN is required for first registration" >&2 - exit 1 - } - runuser -u "$RUNNER_USER" -- env HOME="$RUNNER_HOME" \ - "$PREFIX/bin/gitea-runner" --config /etc/easyai-gateway-runner/config.yaml \ - register --no-interactive \ - --instance "$GITEA_INSTANCE_URL" \ - --token "$RUNNER_REGISTRATION_TOKEN" \ - --name "$RUNNER_NAME" \ - --labels "$RUNNER_LABEL" - chmod 0600 "$RUNNER_HOME/.runner" +# Docker's official rootless DinD baseline is --privileged. Gitea's example +# additionally names an AppArmor profile, but Docker does not create that host +# profile. Enable it only when this daemon proves it can start the pinned image +# with the profile; otherwise leave the option empty. +RUNNER_SECURITY_OPTIONS= +if docker run --rm --pull=never --privileged \ + --security-opt apparmor=rootlesskit \ + --entrypoint /bin/true "$RUNNER_IMAGE" >/dev/null 2>&1; then + RUNNER_SECURITY_OPTIONS="--security-opt=apparmor=rootlesskit" +elif [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ] && \ + [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "1" ]; then + fail "restricted AppArmor user namespaces require the rootlesskit profile (install it with the host Docker packages)" fi -chown -R "$RUNNER_USER:$RUNNER_USER" "$RUNNER_HOME" -systemctl daemon-reload -systemctl enable --now easyai-gateway-act-runner.service -systemctl --no-pager --full status easyai-gateway-act-runner.service +# Exercise the actual daemon startup before registration. This probe has no +# token, state volume, host bind mount, or socket mount. +PROBE_CONTAINER="easyai-gateway-ci-v2-probe-$$" +cleanup_probe() { + docker rm --force "$PROBE_CONTAINER" >/dev/null 2>&1 || true +} +trap 'cleanup_probe' EXIT +trap 'cleanup_probe; exit 129' HUP +trap 'cleanup_probe; exit 130' INT +trap 'cleanup_probe; exit 143' TERM +if [ -n "$RUNNER_SECURITY_OPTIONS" ]; then + set -- "$RUNNER_SECURITY_OPTIONS" +else + set -- +fi +docker run --detach --rm --pull=never \ + --name "$PROBE_CONTAINER" --privileged --pids-limit 4096 "$@" \ + --env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns \ + --env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 \ + --env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns \ + --entrypoint dockerd-entrypoint.sh "$RUNNER_IMAGE" >/dev/null +attempt=0 +until docker exec "$PROBE_CONTAINER" docker info >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ] || \ + [ "$(docker inspect --format '{{.State.Running}}' "$PROBE_CONTAINER" 2>/dev/null || true)" != "true" ]; then + docker logs "$PROBE_CONTAINER" 2>&1 || true + fail "pinned rootless Docker-in-Docker probe did not become ready" + fi + sleep 2 +done +[ "$(docker exec "$PROBE_CONTAINER" id -u)" = "1000" ] || \ + fail "rootless Docker-in-Docker probe is not uid 1000" +docker exec "$PROBE_CONTAINER" docker info --format '{{range .SecurityOptions}}{{println .}}{{end}}' | \ + grep -Fq 'name=rootless' || fail "rootless Docker-in-Docker probe is not rootless" +cleanup_probe +trap - EXIT HUP INT TERM -env PATH="$PREFIX/bin:$PREFIX/toolchains/go/bin:$PREFIX/toolchains/node/bin:$PATH" \ - sh -c 'gitea-runner --version && go version && node --version && pnpm --version && trivy --version && govulncheck -version' +install -d -m 0755 /etc/easyai-gateway-ci-v2 +install -m 0644 "$ROOT/deploy/ci/act-runner-v2-config.yaml" /etc/easyai-gateway-ci-v2/config.yaml +umask 077 +printf 'RUNNER_SECURITY_OPTIONS="%s"\n' "$RUNNER_SECURITY_OPTIONS" \ + > /etc/easyai-gateway-ci-v2/runner.env +install -m 0644 "$ROOT/deploy/ci/easyai-gateway-ci-v2-runner.service" \ + /etc/systemd/system/easyai-gateway-ci-v2-runner.service + +# The image runs as uid 1000. Initialize only the dedicated named volume as +# root, then register and operate the runner as that image's rootless user. +docker run --rm --pull=never --user 0:0 \ + --volume "$RUNNER_VOLUME:/data" \ + --entrypoint /bin/sh "$RUNNER_IMAGE" \ + -ec 'chown 1000:1000 /data && chmod 0700 /data' + +runner_is_registered() { + docker run --rm --pull=never \ + --volume "$RUNNER_VOLUME:/data" \ + --entrypoint /bin/sh "$RUNNER_IMAGE" \ + -ec 'test -s /data/.runner' +} + +if ! runner_is_registered; then + [ -n "${RUNNER_REGISTRATION_TOKEN:-}" ] || \ + fail "RUNNER_REGISTRATION_TOKEN is required for first registration" + docker rm --force easyai-gateway-ci-v2-register >/dev/null 2>&1 || true + printf '%s\n%s\n%s\n' "$GITEA_INSTANCE_URL" "$RUNNER_REGISTRATION_TOKEN" "$RUNNER_NAME" | \ + docker run --rm --interactive --pull=never \ + --name easyai-gateway-ci-v2-register \ + --volume "$RUNNER_VOLUME:/data" \ + --volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro \ + --env HOME=/data \ + --entrypoint /usr/local/bin/gitea-runner \ + "$RUNNER_IMAGE" --config /config.yaml register +fi +unset RUNNER_REGISTRATION_TOKEN +docker run --rm --pull=never \ + --volume "$RUNNER_VOLUME:/data" \ + --entrypoint /bin/sh "$RUNNER_IMAGE" \ + -ec 'test -s /data/.runner && chmod 0600 /data/.runner' + +RUNNER_VALIDATED=0 +cleanup_unvalidated_runner() { + if [ "$RUNNER_VALIDATED" -ne 1 ]; then + systemctl disable --now easyai-gateway-ci-v2-runner.service >/dev/null 2>&1 || true + docker rm --force "$RUNNER_CONTAINER" >/dev/null 2>&1 || true + fi +} +trap 'cleanup_unvalidated_runner' EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +systemctl daemon-reload +systemctl enable --now easyai-gateway-ci-v2-runner.service + +attempt=0 +until docker exec "$RUNNER_CONTAINER" docker info >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ]; then + systemctl --no-pager --full status easyai-gateway-ci-v2-runner.service || true + docker logs "$RUNNER_CONTAINER" 2>&1 || true + fail "rootless Docker daemon did not become ready" + fi + sleep 2 +done + +attempt=0 +until docker logs "$RUNNER_CONTAINER" 2>&1 | grep -Fq 'declare successfully'; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ] || \ + [ "$(docker inspect --format '{{.State.Running}}' "$RUNNER_CONTAINER" 2>/dev/null || true)" != "true" ]; then + docker logs "$RUNNER_CONTAINER" 2>&1 || true + fail "Gitea runner did not declare itself successfully" + fi + sleep 2 +done + +[ "$(docker inspect --format '{{.Config.User}}' "$RUNNER_CONTAINER")" = "rootless" ] || \ + fail "outer runner container is not using the rootless image user" +[ "$(docker inspect --format '{{.HostConfig.Privileged}}' "$RUNNER_CONTAINER")" = "true" ] || \ + fail "outer rootless DinD container is not privileged" +if docker inspect --format '{{range .Mounts}}{{println .Source}}{{end}}' "$RUNNER_CONTAINER" | \ + grep -Eq '/var/run/docker\.sock|/run/docker\.sock'; then + fail "host Docker socket was mounted into the runner" +fi +docker exec "$RUNNER_CONTAINER" docker info --format '{{range .SecurityOptions}}{{println .}}{{end}}' | \ + grep -Fq 'name=rootless' || fail "nested Docker daemon is not rootless" + +# Pre-pull through the nested daemon to prove the digest is reachable and make +# the first real CI job independent of an additional large image pull. +docker exec "$RUNNER_CONTAINER" docker pull "$JOB_IMAGE" +outer_pidns=$(docker exec "$RUNNER_CONTAINER" readlink /proc/1/ns/pid) +inner_host_pidns=$(docker exec "$RUNNER_CONTAINER" docker run --rm --pull=never \ + --pid=host --entrypoint readlink "$JOB_IMAGE" /proc/1/ns/pid) +[ "$outer_pidns" != "$inner_host_pidns" ] || \ + fail "nested --pid=host can see the outer runner PID namespace" +docker exec "$RUNNER_CONTAINER" docker run --rm --pull=never \ + --volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro \ + --entrypoint /bin/sh "$JOB_IMAGE" -ec ' + export PATH=/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:$PATH + go version + node --version + pnpm --version + docker-compose version + shellcheck --version + trivy --version + govulncheck -version + ' +if ! require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed CI runner installation"; then + fail "CI runner was stopped because the post-install disk reserve was not met" +fi +systemctl --no-pager --full status easyai-gateway-ci-v2-runner.service +RUNNER_VALIDATED=1 +trap - EXIT HUP INT TERM diff --git a/tests/ci/ci-build-images-test.sh b/tests/ci/ci-build-images-test.sh new file mode 100755 index 0000000..ed1d781 --- /dev/null +++ b/tests/ci/ci-build-images-test.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +script=$root/scripts/ci-build-images.sh +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +mkdir -p "$tmp/bin" +cat >"$tmp/bin/docker" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf 'docker %s\n' "$*" >>"$MOCK_LOG" + +case "$*" in + 'buildx inspect '*|'buildx create '*) exit 0 ;; + 'buildx build '*) + if [[ ${MOCK_BUILD_FAIL:-0} == 1 && $* == *'--target web'* ]]; then + exit 21 + fi + ;; + 'buildx prune '*) + [[ ${MOCK_PRUNE_FAIL:-0} == 0 ]] || exit 22 + ;; + 'image rm '*) + [[ ${MOCK_IMAGE_RM_FAIL:-0} == 0 ]] || exit 23 + ;; + *) + printf 'unexpected docker invocation: %s\n' "$*" >&2 + exit 99 + ;; +esac +MOCK +cat >"$tmp/bin/trivy" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf 'trivy %s\n' "$*" >>"$MOCK_LOG" +MOCK +chmod +x "$tmp/bin/docker" "$tmp/bin/trivy" + +sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +registry=registry.example.com/easyai +api_image=$registry/ai-gateway:$sha +web_image=$registry/ai-gateway-web:$sha + +run_build() { + env \ + PATH="$tmp/bin:$PATH" \ + MOCK_LOG="$tmp/calls.log" \ + IMAGE_TAG="$sha" \ + AI_GATEWAY_IMAGE_REGISTRY="$registry" \ + AI_GATEWAY_BUILDX_BUILDER=gateway-test \ + AI_GATEWAY_BUILDX_CACHE_LIMIT=2gb \ + "$script" "$@" +} + +: >"$tmp/calls.log" +run_build +grep -Fq "docker buildx prune --builder gateway-test --force --max-used-space 2gb" "$tmp/calls.log" +grep -Fq "docker image rm $api_image $web_image" "$tmp/calls.log" + +: >"$tmp/calls.log" +run_build --keep-images +grep -Fq "docker buildx prune --builder gateway-test --force --max-used-space 2gb" "$tmp/calls.log" +if grep -Fq 'docker image rm ' "$tmp/calls.log"; then + echo '--keep-images must preserve fully built images' >&2 + exit 1 +fi + +: >"$tmp/calls.log" +if MOCK_PRUNE_FAIL=1 run_build; then + echo 'a prune failure must fail an otherwise successful build' >&2 + exit 1 +fi + +: >"$tmp/calls.log" +if MOCK_IMAGE_RM_FAIL=1 run_build; then + echo 'an image cleanup failure must fail an otherwise successful build' >&2 + exit 1 +fi + +: >"$tmp/calls.log" +if MOCK_BUILD_FAIL=1 run_build; then + echo 'a failed image build must fail the script' >&2 + exit 1 +fi +grep -Fq "docker image rm $api_image $web_image" "$tmp/calls.log" + +for invalid_limit in 0gb 2 2GB -1gb '2gb --all'; do + : >"$tmp/calls.log" + if env \ + PATH="$tmp/bin:$PATH" \ + MOCK_LOG="$tmp/calls.log" \ + IMAGE_TAG="$sha" \ + AI_GATEWAY_IMAGE_REGISTRY="$registry" \ + AI_GATEWAY_BUILDX_CACHE_LIMIT="$invalid_limit" \ + "$script" >/dev/null 2>&1; then + printf 'invalid cache limit was accepted: %s\n' "$invalid_limit" >&2 + exit 1 + fi + if [[ -s $tmp/calls.log ]]; then + printf 'invalid cache limit reached Docker: %s\n' "$invalid_limit" >&2 + exit 1 + fi +done + +echo 'ci_build_images_tests=PASS' diff --git a/tests/ci/pipeline-test.sh b/tests/ci/pipeline-test.sh index 3abc430..e713d45 100755 --- a/tests/ci/pipeline-test.sh +++ b/tests/ci/pipeline-test.sh @@ -4,12 +4,17 @@ set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) workflow=$root/.gitea/workflows/ci.yml -runner_service=$root/deploy/ci/easyai-gateway-act-runner.service -runner_config=$root/deploy/ci/act-runner-config.yaml +release_workflow=$root/.gitea/workflows/release-ci.yml +runner_service=$root/deploy/ci/easyai-gateway-ci-v2-runner.service +runner_config=$root/deploy/ci/act-runner-v2-config.yaml provision=$root/scripts/provision-ci-runner.sh build_images=$root/scripts/ci-build-images.sh +validate_semver=$root/scripts/ci-validate-semver.sh +build_images_test=$root/tests/ci/ci-build-images-test.sh +adr=$root/docs/decisions/001-production-cicd.md +runbook=$root/docs/runbooks/production-ci-cd.md -for file in "$workflow" "$runner_service" "$runner_config" "$provision" "$build_images"; do +for file in "$workflow" "$release_workflow" "$runner_service" "$runner_config" "$provision" "$build_images" "$validate_semver" "$build_images_test" "$adr" "$runbook"; do [[ -f $file ]] || { printf 'missing CI/CD file: %s\n' "$file" >&2 exit 1 @@ -17,59 +22,229 @@ for file in "$workflow" "$runner_service" "$runner_config" "$provision" "$build_ done grep -q '^name: ci$' "$workflow" +grep -q '^ push:$' "$workflow" grep -q '^ branches: \[main\]$' "$workflow" -grep -q "^ tags: \['v\*'\]$" "$workflow" grep -q '^ pull_request:$' "$workflow" -grep -q '^ runs-on: easyai-gateway-linux$' "$workflow" -grep -q '^ - name: Checkout without external Actions$' "$workflow" -grep -q '^ - name: Verify release tag ancestry$' "$workflow" -grep -Fq "if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" "$workflow" -grep -Fq 'git merge-base --is-ancestor "$CI_SHA" refs/remotes/origin/main' "$workflow" -grep -q '^ - run: ./scripts/ci-build-images.sh$' "$workflow" -grep -q '^ - name: Deploy verified release to Production$' "$workflow" -grep -q '^ sudo -n /usr/local/sbin/easyai-ai-gateway-release "$CI_SHA"$' "$workflow" +grep -q '^ verify:$' "$workflow" +if grep -Eq "^[[:space:]]+tags:|Verify release tag ancestry" "$workflow" || \ + grep -Fq './scripts/ci-validate-semver.sh "$tag_name"' "$workflow"; then + echo 'main/PR CI must not share the release-tag context' >&2 + exit 1 +fi -for gate in \ - 'gofmt -l' \ - 'go vet ./...' \ - 'go test ./...' \ - 'pnpm install --frozen-lockfile' \ - 'pnpm lint' \ - 'pnpm test' \ - 'pnpm build' \ - 'docker compose -f docker-compose.yml config --quiet' \ - 'govulncheck ./...' \ - 'trivy fs'; do - grep -Fq "$gate" "$workflow" || { - printf 'workflow is missing quality gate: %s\n' "$gate" >&2 +grep -q '^name: release-ci$' "$release_workflow" +grep -q '^ push:$' "$release_workflow" +grep -q "^ tags: \['v\*'\]$" "$release_workflow" +grep -q '^ verify-tag:$' "$release_workflow" +grep -q '^ - name: Verify release tag ancestry$' "$release_workflow" +grep -Fq './scripts/ci-validate-semver.sh "$tag_name"' "$release_workflow" +grep -Fq 'git merge-base --is-ancestor "$CI_SHA" refs/remotes/origin/main' "$release_workflow" +if grep -Eq 'branches:|pull_request:' "$release_workflow"; then + echo 'release CI must be a tag-only workflow/context' >&2 + exit 1 +fi + +for quality_workflow in "$workflow" "$release_workflow"; do + grep -q '^ runs-on: easyai-gateway-ci-unprivileged-v2$' "$quality_workflow" + grep -q '^ - name: Checkout without external Actions$' "$quality_workflow" + if grep -q -- '--depth=' "$quality_workflow"; then + echo 'quality verification must not use a shallow checkout' >&2 exit 1 - } + fi + grep -Fq 'test ! -f .git/shallow' "$quality_workflow" + grep -Fq './tests/ci/ci-build-images-test.sh' "$quality_workflow" + grep -Fq './tests/ci/pipeline-test.sh' "$quality_workflow" + grep -Fq './tests/ci/semver-test.sh' "$quality_workflow" + grep -Fq 'docker-compose version' "$quality_workflow" + grep -Fq 'docker-compose -f docker-compose.yml config --quiet' "$quality_workflow" + if grep -Fq 'docker compose' "$quality_workflow"; then + echo 'job container must use the read-only standalone Compose binary' >&2 + exit 1 + fi + + for forbidden in \ + 'sudo' \ + '/usr/local/sbin/easyai-ai-gateway-release' \ + 'docker version' \ + 'docker buildx' \ + './scripts/ci-build-images.sh --keep-images' \ + 'Deploy verified release to Production'; do + if grep -Fq "$forbidden" "$quality_workflow"; then + printf 'unprivileged workflow contains forbidden capability: %s\n' "$forbidden" >&2 + exit 1 + fi + done + + if grep -Eq '^[[:space:]]+\./scripts/ci-build-images\.sh([[:space:]]|$)' "$quality_workflow"; then + echo 'unprivileged workflow must not build container images' >&2 + exit 1 + fi + + for gate in \ + 'gofmt -l' \ + 'go vet ./...' \ + 'go test ./...' \ + 'pnpm install --frozen-lockfile' \ + 'pnpm lint' \ + 'pnpm test' \ + 'pnpm build' \ + 'pnpm audit --audit-level high' \ + 'docker-compose -f docker-compose.yml config --quiet' \ + 'govulncheck ./...' \ + 'trivy fs'; do + grep -Fq "$gate" "$quality_workflow" || { + printf '%s is missing quality gate: %s\n' "$quality_workflow" "$gate" >&2 + exit 1 + } + done done -grep -q '^User=easyai-gateway-runner$' "$runner_service" -grep -q '^Group=easyai-gateway-runner$' "$runner_service" -grep -q '^NoNewPrivileges=false$' "$runner_service" +if [[ $(sed -n '/^ - name: Verify Go formatting$/,$p' "$workflow") != \ + "$(sed -n '/^ - name: Verify Go formatting$/,$p' "$release_workflow")" ]]; then + echo 'main/PR and release-tag workflows must have identical quality gates' >&2 + exit 1 +fi + +runner_image='docker.io/gitea/runner:2.0.0-dind-rootless@sha256:5b7b625ff773d0ee761788c47582503ec1b241fa5b81edebad48a57e663f4f3a' +job_image='docker.io/library/node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7' + +grep -q '^User=root$' "$runner_service" +grep -q '^Group=root$' "$runner_service" +grep -q '^NoNewPrivileges=true$' "$runner_service" grep -q '^ProtectSystem=strict$' "$runner_service" grep -q '^ProtectHome=true$' "$runner_service" -grep -q '^ReadWritePaths=/var/lib/easyai-gateway-runner$' "$runner_service" -grep -q '^ExecStart=/opt/easyai-gateway-ci/bin/gitea-runner --config /etc/easyai-gateway-runner/config.yaml daemon --once$' "$runner_service" -grep -q 'easyai-gateway-linux:host' "$runner_config" +grep -q '^CapabilityBoundingSet=$' "$runner_service" +grep -Fq 'Requires=docker.service' "$runner_service" +grep -Fq -- "--pull=never" "$runner_service" +grep -Fq -- "--privileged" "$runner_service" +grep -Fq -- '--pids-limit 4096' "$runner_service" +grep -Fq -- '--env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns' "$runner_service" +grep -Fq 'EnvironmentFile=-/etc/easyai-gateway-ci-v2/runner.env' "$runner_service" +grep -Fq '$RUNNER_SECURITY_OPTIONS' "$runner_service" +grep -Fq -- "--volume easyai-gateway-ci-v2-data:/data" "$runner_service" +grep -Fq -- "--volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro" "$runner_service" +grep -Fq -- "--volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro" "$runner_service" +grep -Fq "$runner_image" "$runner_service" +if grep -Eq 'GITEA_RUNNER_REGISTRATION_TOKEN|/var/run/docker\.sock|/run/docker\.sock' "$runner_service"; then + echo 'persistent runner service exposes a registration token or host Docker socket' >&2 + exit 1 +fi +if grep -Fq 'apparmor=rootlesskit' "$runner_service"; then + echo 'runner service must not hard-code an AppArmor profile that may be absent' >&2 + exit 1 +fi + +grep -Fq "easyai-gateway-ci-unprivileged-v2:docker://$job_image" "$runner_config" +if grep -Fq ':host' "$runner_config"; then + echo 'CI runner must never use the host executor' >&2 + exit 1 +fi grep -q '^ capacity: 1$' "$runner_config" +grep -q '^ file: /data/.runner$' "$runner_config" +grep -q '^ docker_host: "-"$' "$runner_config" +grep -q '^ privileged: false$' "$runner_config" +grep -q '^ require_docker: true$' "$runner_config" +grep -q '^ bind_workdir: false$' "$runner_config" +grep -Fq ' options: "--volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro"' "$runner_config" +grep -q '^ valid_volumes:$' "$runner_config" +grep -q '^ - "/opt/easyai-gateway-ci"$' "$runner_config" +if awk ' + /^ valid_volumes:$/ { in_volumes=1; next } + in_volumes && /^ - / { count++; next } + in_volumes && /^ [A-Za-z_]+:/ { in_volumes=0 } + END { exit count == 1 ? 0 : 1 } +' "$runner_config"; then + : +else + echo 'runner must allow exactly one workflow volume source' >&2 + exit 1 +fi +if grep -Eq '/var/run/docker\.sock|/run/docker\.sock' "$runner_config"; then + echo 'job configuration must not expose a Docker socket' >&2 + exit 1 +fi +if grep -Eq 'apparmor=unconfined|sysctl .*(apparmor_restrict_unprivileged_userns)' "$runner_service" "$runner_config" "$provision"; then + echo 'runner must not disable AppArmor isolation or its user-namespace restriction' >&2 + exit 1 +fi grep -q '^GITEA_RUNNER_VERSION=2.0.0$' "$provision" grep -q '^GO_VERSION=1.26.5$' "$provision" grep -q '^NODE_VERSION=24.16.0$' "$provision" grep -q '^TRIVY_VERSION=0.70.0$' "$provision" -grep -Fq 'usermod -aG docker "$RUNNER_USER"' "$provision" +grep -q '^PREFIX=/opt/easyai-gateway-ci$' "$provision" +grep -q '^RUNNER_VOLUME=easyai-gateway-ci-v2-data$' "$provision" +grep -q '^SHELLCHECK_VERSION=0.11.0$' "$provision" +grep -q '^SHELLCHECK_SHA256=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198$' "$provision" +grep -q '^COMPOSE_VERSION=5.3.1$' "$provision" +grep -q '^COMPOSE_SHA256=f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959$' "$provision" +grep -Fq 'RUNNER_NAME=${RUNNER_NAME:-easyai-gateway-ci-unprivileged-v2}' "$provision" +grep -Fq "RUNNER_IMAGE='$runner_image'" "$provision" +grep -Fq "JOB_IMAGE='$job_image'" "$provision" +grep -Fq 'RUNNER_LABEL="easyai-gateway-ci-unprivileged-v2:docker://$JOB_IMAGE"' "$provision" +grep -Fq 'docker volume create "$RUNNER_VOLUME"' "$provision" +grep -Fq -- '--security-opt apparmor=rootlesskit' "$provision" +grep -Fq 'RUNNER_SECURITY_OPTIONS="--security-opt=apparmor=rootlesskit"' "$provision" +grep -Fq 'RUNNER_SECURITY_OPTIONS=' "$provision" +grep -Fq '/proc/sys/kernel/apparmor_restrict_unprivileged_userns' "$provision" +grep -Fq -- '--entrypoint dockerd-entrypoint.sh "$RUNNER_IMAGE"' "$provision" +grep -Fq 'PROBE_CONTAINER="easyai-gateway-ci-v2-probe-$$"' "$provision" +grep -Fq -- '--entrypoint /usr/local/bin/gitea-runner' "$provision" +grep -Fq 'shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz' "$provision" +grep -Fq 'docker-compose-linux-x86_64' "$provision" +grep -Fq 'install -m 0755 "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" "$PREFIX/bin/shellcheck"' "$provision" +grep -Fq 'install -m 0755 "$PREFIX/downloads/docker-compose-${COMPOSE_VERSION}-linux-x86_64" "$PREFIX/bin/docker-compose"' "$provision" +grep -Fq 'gpasswd -d easyai-gateway-ci-v2 docker' "$provision" +grep -Fq 'gpasswd -d easyai-gateway-ci-v2 sudo' "$provision" +if grep -Fq 'usermod -aG docker' "$provision"; then + echo 'unprivileged runner must never join the Docker group' >&2 + exit 1 +fi +grep -Fq 'systemctl disable --now easyai-gateway-act-runner.service' "$provision" +grep -Fq 'systemctl disable --now easyai-gateway-ci-v2-runner.service' "$provision" grep -Fq 'RUNNER_REGISTRATION_TOKEN is required for first registration' "$provision" +grep -Fq 'printf '\''%s\n%s\n%s\n'\'' "$GITEA_INSTANCE_URL" "$RUNNER_REGISTRATION_TOKEN" "$RUNNER_NAME"' "$provision" +if grep -Fq -- '--token "$RUNNER_REGISTRATION_TOKEN"' "$provision"; then + echo 'registration token must not be exposed in a host-visible process argv' >&2 + exit 1 +fi +grep -Fq "grep -Fq 'name=rootless'" "$provision" +grep -Fq "grep -Fq 'declare successfully'" "$provision" +grep -Fq 'docker buildx prune --builder "$LEGACY_BUILDER" --force --max-used-space 512mb' "$provision" +grep -Fq 'require_free_space "$MIN_FREE_GIB" "runner image pull"' "$provision" +grep -Fq 'require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed CI runner installation"' "$provision" +grep -Fq 'CI_RUNNER_MIN_FREE_GIB cannot be lower than 8' "$provision" +grep -Fq 'CI_RUNNER_MIN_POST_INSTALL_FREE_GIB cannot be lower than 4' "$provision" +grep -Fq 'docker exec "$RUNNER_CONTAINER" docker pull "$JOB_IMAGE"' "$provision" +grep -Fq 'docker exec "$RUNNER_CONTAINER" docker run --rm --pull=never' "$provision" +grep -Fq 'nested --pid=host can see the outer runner PID namespace' "$provision" +grep -Fq 'chown -R root:root "$PREFIX"' "$provision" +grep -Fq 'chmod -R go-w "$PREFIX"' "$provision" +if grep -Eq 'SHELLCHECK_BIN|COMPOSE_PLUGIN|command -v shellcheck|docker compose version' "$provision"; then + echo 'runner provisioning must use checksum-pinned static CI tools, not host copies' >&2 + exit 1 +fi +if grep -Eq 'docker (system|builder|image|volume) prune' "$provision"; then + echo 'runner provisioning must not globally prune shared Docker state' >&2 + exit 1 +fi + +grep -Fq 'root-owned dispatcher' "$adr" +grep -Fq 'release-ci / verify-tag (push)' "$adr" +grep -Fq '来自 Fork 的 PR 必须先由维护者检查工作流变更并在 Gitea 中批准' "$adr" +grep -Fq 'rootless DinD 不是 VM 沙箱' "$runbook" +grep -Fq '同时等待 `ci / verify (push)` 与本次新产生的 `release-ci / verify-tag (push)`' "$runbook" +grep -Fq '不得执行 Tag checkout 中的' "$runbook" grep -Fq 'docker buildx build --builder "$builder"' "$build_images" grep -q -- '--target api' "$build_images" grep -q -- '--target web' "$build_images" grep -Fq 'docker buildx prune --builder "$builder" --force --max-used-space "$cache_limit"' "$build_images" grep -Fq 'trivy image' "$build_images" +grep -Fq 'docker image rm "$api_image" "$web_image"' "$build_images" +grep -Fq 'AI_GATEWAY_BUILDX_CACHE_LIMIT has an invalid format' "$build_images" -if rg -n '(password|secret|token):[[:space:]]+[^$<{]' "$workflow"; then +if grep -En '(password|secret|token):[[:space:]]+[^$<{]' "$workflow" "$release_workflow"; then echo 'workflow contains a literal credential' >&2 exit 1 fi diff --git a/tests/ci/semver-test.sh b/tests/ci/semver-test.sh new file mode 100755 index 0000000..fde3ed3 --- /dev/null +++ b/tests/ci/semver-test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +validator=$root/scripts/ci-validate-semver.sh + +valid_tags=( + v0.0.0 + v1.2.3 + v1.2.3-alpha + v1.2.3-alpha.1 + v1.2.3-0.3.7 + v1.2.3-01a + v1.2.3-alpha-beta + v1.2.3-- + v1.2.3-x.7.z.92+build.5 + v1.2.3+build.11.e0f985a + v1.2.3+001 +) +invalid_tags=( + 1.2.3 + v01.2.3 + v1.02.3 + v1.2.03 + v1.2.3.foo + v1.2.3. + v1.2.3-. + v1.2.3-alpha. + v1.2.3-alpha..1 + v1.2.3-01 + v1.2.3-00.1 + v1.2.3+ + v1.2.3+build. + v1.2.3+build..1 + v1.2.3+build+1 + 'v1.2.3 ' + v1.2.3-alpha_1 + v1.2 +) + +for tag in "${valid_tags[@]}"; do + "$validator" "$tag" +done + +for tag in "${invalid_tags[@]}"; do + if "$validator" "$tag" >/dev/null 2>&1; then + printf 'invalid SemVer tag was accepted: %s\n' "$tag" >&2 + exit 1 + fi +done + +echo 'semver_tests=PASS' From 6ba46514c189d8dca76fc8c3d5c4375407a3c763 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 14:43:27 +0800 Subject: [PATCH 10/20] fix(ci): expose pinned node during runner setup --- scripts/provision-ci-runner.sh | 3 ++- tests/ci/pipeline-test.sh | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/provision-ci-runner.sh b/scripts/provision-ci-runner.sh index 4092fdd..093e2fc 100755 --- a/scripts/provision-ci-runner.sh +++ b/scripts/provision-ci-runner.sh @@ -160,7 +160,8 @@ rm -rf "$PREFIX/toolchains/node" mkdir -p "$PREFIX/toolchains/node" tar -xJf "$PREFIX/downloads/node-v${NODE_VERSION}-linux-x64.tar.xz" \ -C "$PREFIX/toolchains/node" --strip-components=1 -"$PREFIX/toolchains/node/bin/npm" install --global --prefix "$PREFIX/toolchains/node" \ +PATH="$PREFIX/toolchains/node/bin:$PATH" \ + "$PREFIX/toolchains/node/bin/npm" install --global --prefix "$PREFIX/toolchains/node" \ --ignore-scripts "pnpm@${PNPM_VERSION}" download_verified \ diff --git a/tests/ci/pipeline-test.sh b/tests/ci/pipeline-test.sh index e713d45..0f93558 100755 --- a/tests/ci/pipeline-test.sh +++ b/tests/ci/pipeline-test.sh @@ -194,6 +194,7 @@ grep -Fq 'shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz' "$provision" grep -Fq 'docker-compose-linux-x86_64' "$provision" grep -Fq 'install -m 0755 "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" "$PREFIX/bin/shellcheck"' "$provision" grep -Fq 'install -m 0755 "$PREFIX/downloads/docker-compose-${COMPOSE_VERSION}-linux-x86_64" "$PREFIX/bin/docker-compose"' "$provision" +grep -Fq 'PATH="$PREFIX/toolchains/node/bin:$PATH"' "$provision" grep -Fq 'gpasswd -d easyai-gateway-ci-v2 docker' "$provision" grep -Fq 'gpasswd -d easyai-gateway-ci-v2 sudo' "$provision" if grep -Fq 'usermod -aG docker' "$provision"; then From 1f8c5a3d03184c222f4e672e2de66932920d15da Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 14:50:38 +0800 Subject: [PATCH 11/20] fix(ci): support digest-verified runner mirrors --- deploy/ci/easyai-gateway-ci-v2-runner.service | 3 +- scripts/provision-ci-runner.sh | 43 +++++++++++++++---- tests/ci/pipeline-test.sh | 13 +++++- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/deploy/ci/easyai-gateway-ci-v2-runner.service b/deploy/ci/easyai-gateway-ci-v2-runner.service index 29c8874..99556ee 100644 --- a/deploy/ci/easyai-gateway-ci-v2-runner.service +++ b/deploy/ci/easyai-gateway-ci-v2-runner.service @@ -9,9 +9,10 @@ Type=simple User=root Group=root Environment=RUNNER_SECURITY_OPTIONS= +Environment=RUNNER_IMAGE_REF= EnvironmentFile=-/etc/easyai-gateway-ci-v2/runner.env ExecStartPre=-/usr/bin/docker rm --force easyai-gateway-ci-v2-runner -ExecStart=/usr/bin/docker run --rm --pull=never --name easyai-gateway-ci-v2-runner --privileged --pids-limit 4096 $RUNNER_SECURITY_OPTIONS --volume easyai-gateway-ci-v2-data:/data --volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro --volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro --env CONFIG_FILE=/config.yaml --env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns --env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 --env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns docker.io/gitea/runner:2.0.0-dind-rootless@sha256:5b7b625ff773d0ee761788c47582503ec1b241fa5b81edebad48a57e663f4f3a +ExecStart=/usr/bin/docker run --rm --pull=never --name easyai-gateway-ci-v2-runner --privileged --pids-limit 4096 $RUNNER_SECURITY_OPTIONS --volume easyai-gateway-ci-v2-data:/data --volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro --volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro --volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro --env CONFIG_FILE=/config.yaml --env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns --env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 --env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns $RUNNER_IMAGE_REF ExecStop=-/usr/bin/docker stop --time 360 easyai-gateway-ci-v2-runner Restart=always RestartSec=5s diff --git a/scripts/provision-ci-runner.sh b/scripts/provision-ci-runner.sh index 093e2fc..39ff207 100755 --- a/scripts/provision-ci-runner.sh +++ b/scripts/provision-ci-runner.sh @@ -25,6 +25,8 @@ COMPOSE_SHA256=f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959 PNPM_VERSION=10.18.1 RUNNER_IMAGE='docker.io/gitea/runner:2.0.0-dind-rootless@sha256:5b7b625ff773d0ee761788c47582503ec1b241fa5b81edebad48a57e663f4f3a' JOB_IMAGE='docker.io/library/node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7' +RUNNER_IMAGE_SOURCE=${CI_RUNNER_IMAGE_SOURCE:-$RUNNER_IMAGE} +NESTED_DOCKER_REGISTRY_MIRROR=${CI_NESTED_DOCKER_REGISTRY_MIRROR:-} RUNNER_LABEL="easyai-gateway-ci-unprivileged-v2:docker://$JOB_IMAGE" fail() { @@ -47,6 +49,16 @@ case $RUNNER_IMAGE in *"/runner:${GITEA_RUNNER_VERSION}-dind-rootless@sha256:"*) ;; *) fail "RUNNER_IMAGE does not match GITEA_RUNNER_VERSION" ;; esac +runner_manifest_digest=${RUNNER_IMAGE##*@} +case $RUNNER_IMAGE_SOURCE in + *@"$runner_manifest_digest") ;; + *) fail "CI_RUNNER_IMAGE_SOURCE must use the pinned runner manifest digest" ;; +esac +if [ -n "$NESTED_DOCKER_REGISTRY_MIRROR" ] && \ + ! printf '%s\n' "$NESTED_DOCKER_REGISTRY_MIRROR" | \ + grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?$'; then + fail "CI_NESTED_DOCKER_REGISTRY_MIRROR must be an HTTPS registry origin" +fi command -v docker >/dev/null 2>&1 || fail "Docker Engine is required" docker info >/dev/null 2>&1 || fail "Docker Engine is not reachable" grep -Fq "\"$RUNNER_LABEL\"" "$ROOT/deploy/ci/act-runner-v2-config.yaml" || \ @@ -176,10 +188,22 @@ chown -R root:root "$PREFIX" chmod -R go-w "$PREFIX" require_free_space "$MIN_FREE_GIB" "runner image pull" -docker pull "$RUNNER_IMAGE" +docker pull "$RUNNER_IMAGE_SOURCE" +runner_runtime_image=$(docker image inspect --format '{{.Id}}' "$RUNNER_IMAGE_SOURCE") +printf '%s\n' "$runner_runtime_image" | grep -Eq '^sha256:[0-9a-f]{64}$' || \ + fail "pinned runner image did not resolve to an immutable local image ID" require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed runner image pull" docker volume create "$RUNNER_VOLUME" >/dev/null +install -d -m 0755 /etc/easyai-gateway-ci-v2 +if [ -n "$NESTED_DOCKER_REGISTRY_MIRROR" ]; then + printf '{"registry-mirrors":["%s"]}\n' "$NESTED_DOCKER_REGISTRY_MIRROR" \ + > /etc/easyai-gateway-ci-v2/daemon.json +else + printf '{}\n' > /etc/easyai-gateway-ci-v2/daemon.json +fi +chmod 0644 /etc/easyai-gateway-ci-v2/daemon.json + # Docker's official rootless DinD baseline is --privileged. Gitea's example # additionally names an AppArmor profile, but Docker does not create that host # profile. Enable it only when this daemon proves it can start the pinned image @@ -187,7 +211,7 @@ docker volume create "$RUNNER_VOLUME" >/dev/null RUNNER_SECURITY_OPTIONS= if docker run --rm --pull=never --privileged \ --security-opt apparmor=rootlesskit \ - --entrypoint /bin/true "$RUNNER_IMAGE" >/dev/null 2>&1; then + --entrypoint /bin/true "$runner_runtime_image" >/dev/null 2>&1; then RUNNER_SECURITY_OPTIONS="--security-opt=apparmor=rootlesskit" elif [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ] && \ [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "1" ]; then @@ -211,10 +235,11 @@ else fi docker run --detach --rm --pull=never \ --name "$PROBE_CONTAINER" --privileged --pids-limit 4096 "$@" \ + --volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro \ --env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns \ --env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 \ --env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns \ - --entrypoint dockerd-entrypoint.sh "$RUNNER_IMAGE" >/dev/null + --entrypoint dockerd-entrypoint.sh "$runner_runtime_image" >/dev/null attempt=0 until docker exec "$PROBE_CONTAINER" docker info >/dev/null 2>&1; do attempt=$((attempt + 1)) @@ -232,10 +257,10 @@ docker exec "$PROBE_CONTAINER" docker info --format '{{range .SecurityOptions}}{ cleanup_probe trap - EXIT HUP INT TERM -install -d -m 0755 /etc/easyai-gateway-ci-v2 install -m 0644 "$ROOT/deploy/ci/act-runner-v2-config.yaml" /etc/easyai-gateway-ci-v2/config.yaml umask 077 -printf 'RUNNER_SECURITY_OPTIONS="%s"\n' "$RUNNER_SECURITY_OPTIONS" \ +printf 'RUNNER_SECURITY_OPTIONS="%s"\nRUNNER_IMAGE_REF="%s"\n' \ + "$RUNNER_SECURITY_OPTIONS" "$runner_runtime_image" \ > /etc/easyai-gateway-ci-v2/runner.env install -m 0644 "$ROOT/deploy/ci/easyai-gateway-ci-v2-runner.service" \ /etc/systemd/system/easyai-gateway-ci-v2-runner.service @@ -244,13 +269,13 @@ install -m 0644 "$ROOT/deploy/ci/easyai-gateway-ci-v2-runner.service" \ # root, then register and operate the runner as that image's rootless user. docker run --rm --pull=never --user 0:0 \ --volume "$RUNNER_VOLUME:/data" \ - --entrypoint /bin/sh "$RUNNER_IMAGE" \ + --entrypoint /bin/sh "$runner_runtime_image" \ -ec 'chown 1000:1000 /data && chmod 0700 /data' runner_is_registered() { docker run --rm --pull=never \ --volume "$RUNNER_VOLUME:/data" \ - --entrypoint /bin/sh "$RUNNER_IMAGE" \ + --entrypoint /bin/sh "$runner_runtime_image" \ -ec 'test -s /data/.runner' } @@ -265,12 +290,12 @@ if ! runner_is_registered; then --volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro \ --env HOME=/data \ --entrypoint /usr/local/bin/gitea-runner \ - "$RUNNER_IMAGE" --config /config.yaml register + "$runner_runtime_image" --config /config.yaml register fi unset RUNNER_REGISTRATION_TOKEN docker run --rm --pull=never \ --volume "$RUNNER_VOLUME:/data" \ - --entrypoint /bin/sh "$RUNNER_IMAGE" \ + --entrypoint /bin/sh "$runner_runtime_image" \ -ec 'test -s /data/.runner && chmod 0600 /data/.runner' RUNNER_VALIDATED=0 diff --git a/tests/ci/pipeline-test.sh b/tests/ci/pipeline-test.sh index 0f93558..bd433f6 100755 --- a/tests/ci/pipeline-test.sh +++ b/tests/ci/pipeline-test.sh @@ -123,8 +123,10 @@ grep -Fq 'EnvironmentFile=-/etc/easyai-gateway-ci-v2/runner.env' "$runner_servic grep -Fq '$RUNNER_SECURITY_OPTIONS' "$runner_service" grep -Fq -- "--volume easyai-gateway-ci-v2-data:/data" "$runner_service" grep -Fq -- "--volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro" "$runner_service" +grep -Fq -- "--volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro" "$runner_service" grep -Fq -- "--volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro" "$runner_service" -grep -Fq "$runner_image" "$runner_service" +grep -Fq 'Environment=RUNNER_IMAGE_REF=' "$runner_service" +grep -Fq '$RUNNER_IMAGE_REF' "$runner_service" if grep -Eq 'GITEA_RUNNER_REGISTRATION_TOKEN|/var/run/docker\.sock|/run/docker\.sock' "$runner_service"; then echo 'persistent runner service exposes a registration token or host Docker socket' >&2 exit 1 @@ -180,6 +182,11 @@ grep -q '^COMPOSE_VERSION=5.3.1$' "$provision" grep -q '^COMPOSE_SHA256=f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959$' "$provision" grep -Fq 'RUNNER_NAME=${RUNNER_NAME:-easyai-gateway-ci-unprivileged-v2}' "$provision" grep -Fq "RUNNER_IMAGE='$runner_image'" "$provision" +grep -Fq 'RUNNER_IMAGE_SOURCE=${CI_RUNNER_IMAGE_SOURCE:-$RUNNER_IMAGE}' "$provision" +grep -Fq 'runner_manifest_digest=${RUNNER_IMAGE##*@}' "$provision" +grep -Fq 'CI_RUNNER_IMAGE_SOURCE must use the pinned runner manifest digest' "$provision" +grep -Fq 'runner_runtime_image=$(docker image inspect' "$provision" +grep -Fq 'RUNNER_IMAGE_REF="%s"' "$provision" grep -Fq "JOB_IMAGE='$job_image'" "$provision" grep -Fq 'RUNNER_LABEL="easyai-gateway-ci-unprivileged-v2:docker://$JOB_IMAGE"' "$provision" grep -Fq 'docker volume create "$RUNNER_VOLUME"' "$provision" @@ -187,7 +194,9 @@ grep -Fq -- '--security-opt apparmor=rootlesskit' "$provision" grep -Fq 'RUNNER_SECURITY_OPTIONS="--security-opt=apparmor=rootlesskit"' "$provision" grep -Fq 'RUNNER_SECURITY_OPTIONS=' "$provision" grep -Fq '/proc/sys/kernel/apparmor_restrict_unprivileged_userns' "$provision" -grep -Fq -- '--entrypoint dockerd-entrypoint.sh "$RUNNER_IMAGE"' "$provision" +grep -Fq -- '--entrypoint dockerd-entrypoint.sh "$runner_runtime_image"' "$provision" +grep -Fq '/etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro' "$provision" +grep -Fq 'CI_NESTED_DOCKER_REGISTRY_MIRROR must be an HTTPS registry origin' "$provision" grep -Fq 'PROBE_CONTAINER="easyai-gateway-ci-v2-probe-$$"' "$provision" grep -Fq -- '--entrypoint /usr/local/bin/gitea-runner' "$provision" grep -Fq 'shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz' "$provision" From 06f95c5d8612b9c1bc730d876d96718b31439d13 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 15:08:37 +0800 Subject: [PATCH 12/20] ci: validate runner before registration --- scripts/provision-ci-runner.sh | 69 +++++++++++++++++++--------------- tests/ci/pipeline-test.sh | 13 ++++++- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/scripts/provision-ci-runner.sh b/scripts/provision-ci-runner.sh index 39ff207..4410203 100755 --- a/scripts/provision-ci-runner.sh +++ b/scripts/provision-ci-runner.sh @@ -195,12 +195,20 @@ printf '%s\n' "$runner_runtime_image" | grep -Eq '^sha256:[0-9a-f]{64}$' || \ require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed runner image pull" docker volume create "$RUNNER_VOLUME" >/dev/null +# The registered runner state and the nested rootless daemon cache share one +# dedicated volume. Persisting the pinned job image lets validation finish +# before registration and avoids racing the first queued workflow for layers. +docker run --rm --pull=never --user 0:0 \ + --volume "$RUNNER_VOLUME:/data" \ + --entrypoint /bin/sh "$runner_runtime_image" \ + -ec 'chown 1000:1000 /data && chmod 0700 /data' + install -d -m 0755 /etc/easyai-gateway-ci-v2 if [ -n "$NESTED_DOCKER_REGISTRY_MIRROR" ]; then - printf '{"registry-mirrors":["%s"]}\n' "$NESTED_DOCKER_REGISTRY_MIRROR" \ + printf '{"data-root":"/data/docker","registry-mirrors":["%s"]}\n' "$NESTED_DOCKER_REGISTRY_MIRROR" \ > /etc/easyai-gateway-ci-v2/daemon.json else - printf '{}\n' > /etc/easyai-gateway-ci-v2/daemon.json + printf '{"data-root":"/data/docker"}\n' > /etc/easyai-gateway-ci-v2/daemon.json fi chmod 0644 /etc/easyai-gateway-ci-v2/daemon.json @@ -235,7 +243,9 @@ else fi docker run --detach --rm --pull=never \ --name "$PROBE_CONTAINER" --privileged --pids-limit 4096 "$@" \ + --volume "$RUNNER_VOLUME:/data" \ --volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro \ + --volume "$PREFIX:$PREFIX:ro" \ --env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns \ --env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 \ --env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns \ @@ -254,6 +264,29 @@ done fail "rootless Docker-in-Docker probe is not uid 1000" docker exec "$PROBE_CONTAINER" docker info --format '{{range .SecurityOptions}}{{println .}}{{end}}' | \ grep -Fq 'name=rootless' || fail "rootless Docker-in-Docker probe is not rootless" +[ "$(docker exec "$PROBE_CONTAINER" docker info --format '{{.DockerRootDir}}')" = /data/docker ] || \ + fail "rootless Docker-in-Docker probe is not using its persistent dedicated data root" + +# Complete every slow and capability-sensitive validation before registration; +# otherwise a queued workflow can race the installer for the same image layers. +docker exec "$PROBE_CONTAINER" docker pull "$JOB_IMAGE" +outer_pidns=$(docker exec "$PROBE_CONTAINER" readlink /proc/1/ns/pid) +inner_host_pidns=$(docker exec "$PROBE_CONTAINER" docker run --rm --pull=never \ + --pid=host --entrypoint readlink "$JOB_IMAGE" /proc/1/ns/pid) +[ "$outer_pidns" != "$inner_host_pidns" ] || \ + fail "nested --pid=host can see the outer runner PID namespace" +docker exec "$PROBE_CONTAINER" docker run --rm --pull=never \ + --volume "$PREFIX:$PREFIX:ro" \ + --entrypoint /bin/sh "$JOB_IMAGE" -ec ' + export PATH=/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:$PATH + go version + node --version + pnpm --version + docker-compose version + shellcheck --version + trivy --version + govulncheck -version + ' cleanup_probe trap - EXIT HUP INT TERM @@ -265,13 +298,6 @@ printf 'RUNNER_SECURITY_OPTIONS="%s"\nRUNNER_IMAGE_REF="%s"\n' \ install -m 0644 "$ROOT/deploy/ci/easyai-gateway-ci-v2-runner.service" \ /etc/systemd/system/easyai-gateway-ci-v2-runner.service -# The image runs as uid 1000. Initialize only the dedicated named volume as -# root, then register and operate the runner as that image's rootless user. -docker run --rm --pull=never --user 0:0 \ - --volume "$RUNNER_VOLUME:/data" \ - --entrypoint /bin/sh "$runner_runtime_image" \ - -ec 'chown 1000:1000 /data && chmod 0700 /data' - runner_is_registered() { docker run --rm --pull=never \ --volume "$RUNNER_VOLUME:/data" \ @@ -344,27 +370,10 @@ if docker inspect --format '{{range .Mounts}}{{println .Source}}{{end}}' "$RUNNE fi docker exec "$RUNNER_CONTAINER" docker info --format '{{range .SecurityOptions}}{{println .}}{{end}}' | \ grep -Fq 'name=rootless' || fail "nested Docker daemon is not rootless" - -# Pre-pull through the nested daemon to prove the digest is reachable and make -# the first real CI job independent of an additional large image pull. -docker exec "$RUNNER_CONTAINER" docker pull "$JOB_IMAGE" -outer_pidns=$(docker exec "$RUNNER_CONTAINER" readlink /proc/1/ns/pid) -inner_host_pidns=$(docker exec "$RUNNER_CONTAINER" docker run --rm --pull=never \ - --pid=host --entrypoint readlink "$JOB_IMAGE" /proc/1/ns/pid) -[ "$outer_pidns" != "$inner_host_pidns" ] || \ - fail "nested --pid=host can see the outer runner PID namespace" -docker exec "$RUNNER_CONTAINER" docker run --rm --pull=never \ - --volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro \ - --entrypoint /bin/sh "$JOB_IMAGE" -ec ' - export PATH=/opt/easyai-gateway-ci/bin:/opt/easyai-gateway-ci/toolchains/go/bin:/opt/easyai-gateway-ci/toolchains/node/bin:$PATH - go version - node --version - pnpm --version - docker-compose version - shellcheck --version - trivy --version - govulncheck -version - ' +[ "$(docker exec "$RUNNER_CONTAINER" docker info --format '{{.DockerRootDir}}')" = /data/docker ] || \ + fail "nested Docker daemon is not using its persistent dedicated data root" +docker exec "$RUNNER_CONTAINER" docker image inspect "$JOB_IMAGE" >/dev/null || \ + fail "validated pinned job image is unavailable after runner activation" if ! require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed CI runner installation"; then fail "CI runner was stopped because the post-install disk reserve was not met" fi diff --git a/tests/ci/pipeline-test.sh b/tests/ci/pipeline-test.sh index bd433f6..3559ce9 100755 --- a/tests/ci/pipeline-test.sh +++ b/tests/ci/pipeline-test.sh @@ -190,6 +190,7 @@ grep -Fq 'RUNNER_IMAGE_REF="%s"' "$provision" grep -Fq "JOB_IMAGE='$job_image'" "$provision" grep -Fq 'RUNNER_LABEL="easyai-gateway-ci-unprivileged-v2:docker://$JOB_IMAGE"' "$provision" grep -Fq 'docker volume create "$RUNNER_VOLUME"' "$provision" +grep -Fq '"data-root":"/data/docker"' "$provision" grep -Fq -- '--security-opt apparmor=rootlesskit' "$provision" grep -Fq 'RUNNER_SECURITY_OPTIONS="--security-opt=apparmor=rootlesskit"' "$provision" grep -Fq 'RUNNER_SECURITY_OPTIONS=' "$provision" @@ -225,8 +226,16 @@ grep -Fq 'require_free_space "$MIN_FREE_GIB" "runner image pull"' "$provision" grep -Fq 'require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed CI runner installation"' "$provision" grep -Fq 'CI_RUNNER_MIN_FREE_GIB cannot be lower than 8' "$provision" grep -Fq 'CI_RUNNER_MIN_POST_INSTALL_FREE_GIB cannot be lower than 4' "$provision" -grep -Fq 'docker exec "$RUNNER_CONTAINER" docker pull "$JOB_IMAGE"' "$provision" -grep -Fq 'docker exec "$RUNNER_CONTAINER" docker run --rm --pull=never' "$provision" +grep -Fq 'docker exec "$PROBE_CONTAINER" docker pull "$JOB_IMAGE"' "$provision" +grep -Fq 'docker exec "$PROBE_CONTAINER" docker run --rm --pull=never' "$provision" +grep -Fq -- '--volume "$RUNNER_VOLUME:/data"' "$provision" +grep -Fq -- '--volume "$PREFIX:$PREFIX:ro"' "$provision" +probe_pull_line=$(grep -nF 'docker exec "$PROBE_CONTAINER" docker pull "$JOB_IMAGE"' "$provision" | cut -d: -f1) +registration_line=$(grep -nF 'if ! runner_is_registered; then' "$provision" | cut -d: -f1) +[[ -n $probe_pull_line && -n $registration_line && $probe_pull_line -lt $registration_line ]] || { + echo 'pinned job image and isolation probes must complete before runner registration' >&2 + exit 1 +} grep -Fq 'nested --pid=host can see the outer runner PID namespace' "$provision" grep -Fq 'chown -R root:root "$PREFIX"' "$provision" grep -Fq 'chmod -R go-w "$PREFIX"' "$provision" From 5e8fb4276fccc3d0c8665e9a10f4accbbc282aef Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 15:15:55 +0800 Subject: [PATCH 13/20] fix(ci): verify rootless pid namespace without proc links --- scripts/provision-ci-runner.sh | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/provision-ci-runner.sh b/scripts/provision-ci-runner.sh index 4410203..cff1918 100755 --- a/scripts/provision-ci-runner.sh +++ b/scripts/provision-ci-runner.sh @@ -270,11 +270,18 @@ docker exec "$PROBE_CONTAINER" docker info --format '{{range .SecurityOptions}}{ # Complete every slow and capability-sensitive validation before registration; # otherwise a queued workflow can race the installer for the same image layers. docker exec "$PROBE_CONTAINER" docker pull "$JOB_IMAGE" -outer_pidns=$(docker exec "$PROBE_CONTAINER" readlink /proc/1/ns/pid) -inner_host_pidns=$(docker exec "$PROBE_CONTAINER" docker run --rm --pull=never \ - --pid=host --entrypoint readlink "$JOB_IMAGE" /proc/1/ns/pid) -[ "$outer_pidns" != "$inner_host_pidns" ] || \ +outer_sentinel_pid=$(docker exec "$PROBE_CONTAINER" /bin/sh -c \ + 'sleep 300 >/dev/null 2>&1 & printf "%s\n" "$!"') +case $outer_sentinel_pid in + '' | *[!0-9]*) fail "could not create the outer PID namespace sentinel" ;; +esac +if ! docker exec "$PROBE_CONTAINER" docker run --rm --pull=never \ + --pid=host --entrypoint /bin/sh "$JOB_IMAGE" \ + -ec 'test ! -e "/proc/$1"' sh "$outer_sentinel_pid"; then + docker exec "$PROBE_CONTAINER" kill "$outer_sentinel_pid" >/dev/null 2>&1 || true fail "nested --pid=host can see the outer runner PID namespace" +fi +docker exec "$PROBE_CONTAINER" kill "$outer_sentinel_pid" >/dev/null 2>&1 || true docker exec "$PROBE_CONTAINER" docker run --rm --pull=never \ --volume "$PREFIX:$PREFIX:ro" \ --entrypoint /bin/sh "$JOB_IMAGE" -ec ' From e77ec9e84240ae0b24393a082e95c53cbc21f8a6 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 15:22:44 +0800 Subject: [PATCH 14/20] security(ci): bound runner resources --- deploy/ci/easyai-gateway-ci-v2-runner.service | 2 +- scripts/provision-ci-runner.sh | 14 +++++++++++++- tests/ci/pipeline-test.sh | 10 +++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/deploy/ci/easyai-gateway-ci-v2-runner.service b/deploy/ci/easyai-gateway-ci-v2-runner.service index 99556ee..3f4c9dc 100644 --- a/deploy/ci/easyai-gateway-ci-v2-runner.service +++ b/deploy/ci/easyai-gateway-ci-v2-runner.service @@ -12,7 +12,7 @@ Environment=RUNNER_SECURITY_OPTIONS= Environment=RUNNER_IMAGE_REF= EnvironmentFile=-/etc/easyai-gateway-ci-v2/runner.env ExecStartPre=-/usr/bin/docker rm --force easyai-gateway-ci-v2-runner -ExecStart=/usr/bin/docker run --rm --pull=never --name easyai-gateway-ci-v2-runner --privileged --pids-limit 4096 $RUNNER_SECURITY_OPTIONS --volume easyai-gateway-ci-v2-data:/data --volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro --volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro --volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro --env CONFIG_FILE=/config.yaml --env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns --env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 --env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns $RUNNER_IMAGE_REF +ExecStart=/usr/bin/docker run --rm --pull=never --name easyai-gateway-ci-v2-runner --privileged --cpus 2 --memory 2g --memory-swap 3g --pids-limit 1024 $RUNNER_SECURITY_OPTIONS --volume easyai-gateway-ci-v2-data:/data --volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro --volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro --volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro --env CONFIG_FILE=/config.yaml --env DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns --env DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 --env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns $RUNNER_IMAGE_REF ExecStop=-/usr/bin/docker stop --time 360 easyai-gateway-ci-v2-runner Restart=always RestartSec=5s diff --git a/scripts/provision-ci-runner.sh b/scripts/provision-ci-runner.sh index cff1918..6426a09 100755 --- a/scripts/provision-ci-runner.sh +++ b/scripts/provision-ci-runner.sh @@ -10,6 +10,10 @@ RUNNER_CONTAINER=easyai-gateway-ci-v2-runner LEGACY_BUILDER=${AI_GATEWAY_BUILDX_BUILDER:-easyai-gateway-ci} MIN_FREE_GIB=${CI_RUNNER_MIN_FREE_GIB:-8} MIN_POST_INSTALL_FREE_GIB=${CI_RUNNER_MIN_POST_INSTALL_FREE_GIB:-4} +RUNNER_CPUS=2 +RUNNER_MEMORY=2g +RUNNER_MEMORY_SWAP=3g +RUNNER_PIDS_LIMIT=1024 GITEA_RUNNER_VERSION=2.0.0 GO_VERSION=1.26.5 @@ -242,7 +246,9 @@ else set -- fi docker run --detach --rm --pull=never \ - --name "$PROBE_CONTAINER" --privileged --pids-limit 4096 "$@" \ + --name "$PROBE_CONTAINER" --privileged \ + --cpus "$RUNNER_CPUS" --memory "$RUNNER_MEMORY" \ + --memory-swap "$RUNNER_MEMORY_SWAP" --pids-limit "$RUNNER_PIDS_LIMIT" "$@" \ --volume "$RUNNER_VOLUME:/data" \ --volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro \ --volume "$PREFIX:$PREFIX:ro" \ @@ -262,6 +268,9 @@ until docker exec "$PROBE_CONTAINER" docker info >/dev/null 2>&1; do done [ "$(docker exec "$PROBE_CONTAINER" id -u)" = "1000" ] || \ fail "rootless Docker-in-Docker probe is not uid 1000" +[ "$(docker inspect --format '{{.HostConfig.Memory}}:{{.HostConfig.MemorySwap}}:{{.HostConfig.NanoCpus}}:{{.HostConfig.PidsLimit}}' "$PROBE_CONTAINER")" = \ + '2147483648:3221225472:2000000000:1024' ] || \ + fail "rootless Docker-in-Docker probe does not have the required aggregate resource limits" docker exec "$PROBE_CONTAINER" docker info --format '{{range .SecurityOptions}}{{println .}}{{end}}' | \ grep -Fq 'name=rootless' || fail "rootless Docker-in-Docker probe is not rootless" [ "$(docker exec "$PROBE_CONTAINER" docker info --format '{{.DockerRootDir}}')" = /data/docker ] || \ @@ -371,6 +380,9 @@ done fail "outer runner container is not using the rootless image user" [ "$(docker inspect --format '{{.HostConfig.Privileged}}' "$RUNNER_CONTAINER")" = "true" ] || \ fail "outer rootless DinD container is not privileged" +[ "$(docker inspect --format '{{.HostConfig.Memory}}:{{.HostConfig.MemorySwap}}:{{.HostConfig.NanoCpus}}:{{.HostConfig.PidsLimit}}' "$RUNNER_CONTAINER")" = \ + '2147483648:3221225472:2000000000:1024' ] || \ + fail "outer rootless DinD container does not have the required aggregate resource limits" if docker inspect --format '{{range .Mounts}}{{println .Source}}{{end}}' "$RUNNER_CONTAINER" | \ grep -Eq '/var/run/docker\.sock|/run/docker\.sock'; then fail "host Docker socket was mounted into the runner" diff --git a/tests/ci/pipeline-test.sh b/tests/ci/pipeline-test.sh index 3559ce9..d829728 100755 --- a/tests/ci/pipeline-test.sh +++ b/tests/ci/pipeline-test.sh @@ -117,7 +117,10 @@ grep -q '^CapabilityBoundingSet=$' "$runner_service" grep -Fq 'Requires=docker.service' "$runner_service" grep -Fq -- "--pull=never" "$runner_service" grep -Fq -- "--privileged" "$runner_service" -grep -Fq -- '--pids-limit 4096' "$runner_service" +grep -Fq -- '--pids-limit 1024' "$runner_service" +grep -Fq -- '--cpus 2' "$runner_service" +grep -Fq -- '--memory 2g' "$runner_service" +grep -Fq -- '--memory-swap 3g' "$runner_service" grep -Fq -- '--env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns' "$runner_service" grep -Fq 'EnvironmentFile=-/etc/easyai-gateway-ci-v2/runner.env' "$runner_service" grep -Fq '$RUNNER_SECURITY_OPTIONS' "$runner_service" @@ -199,6 +202,11 @@ grep -Fq -- '--entrypoint dockerd-entrypoint.sh "$runner_runtime_image"' "$provi grep -Fq '/etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro' "$provision" grep -Fq 'CI_NESTED_DOCKER_REGISTRY_MIRROR must be an HTTPS registry origin' "$provision" grep -Fq 'PROBE_CONTAINER="easyai-gateway-ci-v2-probe-$$"' "$provision" +grep -Fq -- '--pids-limit "$RUNNER_PIDS_LIMIT"' "$provision" +grep -Fq -- '--cpus "$RUNNER_CPUS"' "$provision" +grep -Fq -- '--memory "$RUNNER_MEMORY"' "$provision" +grep -Fq -- '--memory-swap "$RUNNER_MEMORY_SWAP"' "$provision" +grep -Fq "'2147483648:3221225472:2000000000:1024'" "$provision" grep -Fq -- '--entrypoint /usr/local/bin/gitea-runner' "$provision" grep -Fq 'shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz' "$provision" grep -Fq 'docker-compose-linux-x86_64' "$provision" From c67726ca6b8654b536eabdcb0121cb103af53629 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 15:31:41 +0800 Subject: [PATCH 15/20] security(api): upgrade vulnerable crypto dependency --- apps/api/go.mod | 4 ++-- apps/api/go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/api/go.mod b/apps/api/go.mod index 69c6e99..0bfd4ff 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -13,7 +13,7 @@ require ( github.com/riverqueue/river v0.24.0 github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0 github.com/riverqueue/river/rivertype v0.24.0 - golang.org/x/crypto v0.37.0 + golang.org/x/crypto v0.52.0 golang.org/x/oauth2 v0.36.0 ) @@ -36,6 +36,6 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect go.uber.org/goleak v1.3.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/api/go.sum b/apps/api/go.sum index 7db61cb..1fba1de 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -67,14 +67,14 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From a6e95be0c595aaca5ae5dbfbd898451a457683c0 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 15:38:59 +0800 Subject: [PATCH 16/20] fix(ci): use reachable vulnerability database mirror --- .gitea/workflows/ci.yml | 2 ++ .gitea/workflows/release-ci.yml | 2 ++ scripts/ci-build-images.sh | 4 +++- tests/ci/ci-build-images-test.sh | 2 ++ tests/ci/pipeline-test.sh | 6 ++++++ 5 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c6db54b..7d863f5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -9,6 +9,8 @@ on: jobs: verify: runs-on: easyai-gateway-ci-unprivileged-v2 + env: + TRIVY_DB_REPOSITORY: ghcr.m.daocloud.io/aquasecurity/trivy-db:2 steps: - name: Checkout without external Actions env: diff --git a/.gitea/workflows/release-ci.yml b/.gitea/workflows/release-ci.yml index 6eb7b5c..807049e 100644 --- a/.gitea/workflows/release-ci.yml +++ b/.gitea/workflows/release-ci.yml @@ -7,6 +7,8 @@ on: jobs: verify-tag: runs-on: easyai-gateway-ci-unprivileged-v2 + env: + TRIVY_DB_REPOSITORY: ghcr.m.daocloud.io/aquasecurity/trivy-db:2 steps: - name: Checkout without external Actions env: diff --git a/scripts/ci-build-images.sh b/scripts/ci-build-images.sh index 48ce1f0..7061ca9 100755 --- a/scripts/ci-build-images.sh +++ b/scripts/ci-build-images.sh @@ -11,6 +11,7 @@ cache_limit=${AI_GATEWAY_BUILDX_CACHE_LIMIT:-2gb} platform=${AI_GATEWAY_PLATFORM:-linux/amd64} keep_images=0 build_complete=0 +readonly trivy_db_repository=ghcr.m.daocloud.io/aquasecurity/trivy-db:2 case ${1:-} in '') ;; @@ -91,7 +92,8 @@ docker buildx build --builder "$builder" \ --tag "$web_image" . for image in "$api_image" "$web_image"; do - trivy image --scanners vuln,secret --severity HIGH,CRITICAL --ignore-unfixed \ + trivy image --db-repository "$trivy_db_repository" \ + --scanners vuln,secret --severity HIGH,CRITICAL --ignore-unfixed \ --exit-code 1 --timeout 15m "$image" done diff --git a/tests/ci/ci-build-images-test.sh b/tests/ci/ci-build-images-test.sh index ed1d781..bb12662 100755 --- a/tests/ci/ci-build-images-test.sh +++ b/tests/ci/ci-build-images-test.sh @@ -58,6 +58,8 @@ run_build() { run_build grep -Fq "docker buildx prune --builder gateway-test --force --max-used-space 2gb" "$tmp/calls.log" grep -Fq "docker image rm $api_image $web_image" "$tmp/calls.log" +grep -Fq 'trivy image --db-repository ghcr.m.daocloud.io/aquasecurity/trivy-db:2' \ + "$tmp/calls.log" : >"$tmp/calls.log" run_build --keep-images diff --git a/tests/ci/pipeline-test.sh b/tests/ci/pipeline-test.sh index d829728..f2738bd 100755 --- a/tests/ci/pipeline-test.sh +++ b/tests/ci/pipeline-test.sh @@ -97,6 +97,12 @@ for quality_workflow in "$workflow" "$release_workflow"; do exit 1 } done + grep -Fq 'TRIVY_DB_REPOSITORY: ghcr.m.daocloud.io/aquasecurity/trivy-db:2' \ + "$quality_workflow" || { + printf '%s does not pin the reachable Trivy vulnerability DB mirror\n' \ + "$quality_workflow" >&2 + exit 1 + } done if [[ $(sed -n '/^ - name: Verify Go formatting$/,$p' "$workflow") != \ From cc3bbeccc2bed32e8d0a6fcd536aef095e86bdb2 Mon Sep 17 00:00:00 2001 From: wangbo Date: Fri, 17 Jul 2026 15:42:46 +0800 Subject: [PATCH 17/20] =?UTF-8?q?feat(web):=20=E5=AE=8C=E5=96=84=20API=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=B8=8E=E5=BC=82=E6=AD=A5=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api.test.ts | 38 ++ apps/web/src/api.ts | 26 + apps/web/src/lib/run-task.ts | 69 +- apps/web/src/pages/ApiDocsPage.test.tsx | 107 +++ apps/web/src/pages/ApiDocsPage.tsx | 840 +++++++++++++++++++++--- apps/web/src/routing.test.ts | 27 + apps/web/src/routing.ts | 23 +- apps/web/src/styles/api-docs.css | 119 ++++ apps/web/src/types.ts | 34 +- 9 files changed, 1182 insertions(+), 101 deletions(-) create mode 100644 apps/web/src/pages/ApiDocsPage.test.tsx create mode 100644 apps/web/src/routing.test.ts diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index aedd9c7..0007300 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + createResponse, deleteOIDCBrowserSession, GatewayApiError, gatewayErrorMessage, + getAPITask, getCurrentUser, getOpsManagementSkillMetadata, OIDC_BROWSER_SESSION_CREDENTIAL, @@ -96,3 +98,39 @@ describe('Public Agent resources', () => { expect(new Headers(init.headers).has('Authorization')).toBe(false); }); }); + +describe('API documentation runner transports', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('runs Responses against the public OpenAI-compatible endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'resp-test', object: 'response' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + + await createResponse('sk-test', { model: 'gpt-test', input: 'hello', store: true, stream: false }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/v1/responses'); + expect(init.method).toBe('POST'); + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer sk-test'); + expect(JSON.parse(String(init.body))).toMatchObject({ model: 'gpt-test', input: 'hello', store: true, stream: false }); + }); + + it('retrieves a task from the documented API path', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'task-123', status: 'running' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + + await getAPITask('sk-test', 'task-123'); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/v1/tasks/task-123'); + expect(init.method).toBe('GET'); + }); +}); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index eace7f8..4707e42 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -605,6 +605,26 @@ export async function createCompatibleChatCompletion( }); } +export async function createResponse( + token: string, + input: { + model: string; + input: unknown; + instructions?: string; + previous_response_id?: string; + runMode?: string; + simulation?: boolean; + store?: boolean; + stream?: boolean; + }, +): Promise> { + return request>('/v1/responses', { + body: input, + method: 'POST', + token, + }); +} + export async function createEmbedding( token: string, input: { model: string; input: string | string[]; dimensions?: number; runMode?: string; simulation?: boolean }, @@ -818,6 +838,8 @@ export interface VideoGenerationParams { mode?: 'std' | 'pro'; negative_prompt?: string; cfg_scale?: number; + runMode?: string; + simulation?: boolean; } export async function createVideoGenerationTask( @@ -881,6 +903,10 @@ export async function getTask(token: string, taskId: string): Promise(`/api/workspace/tasks/${taskId}`, { token }); } +export async function getAPITask(token: string, taskId: string): Promise { + return request(`/api/v1/tasks/${taskId}`, { token }); +} + export async function listTaskParamPreprocessing( token: string, taskId: string, diff --git a/apps/web/src/lib/run-task.ts b/apps/web/src/lib/run-task.ts index 9cf43c3..a5fd6ea 100644 --- a/apps/web/src/lib/run-task.ts +++ b/apps/web/src/lib/run-task.ts @@ -1,5 +1,14 @@ import type { GatewayTask } from '@easyai-ai-gateway/contracts'; -import { createCompatibleChatCompletion, createEmbedding, createImageEditTask, createImageGenerationTask, createRerank } from '../api'; +import { + createCompatibleChatCompletion, + createEmbedding, + createImageEditTask, + createImageGenerationTask, + createRerank, + createResponse, + createVideoGenerationTask, + getAPITask, +} from '../api'; import type { TaskForm } from '../types'; export interface RunTaskResponse { @@ -19,6 +28,19 @@ export async function runTask(token: string, task: TaskForm): Promise) }; + } throw new Error(`Unsupported task kind: ${task.kind}`); } @@ -83,6 +123,18 @@ function compatibleTask(task: TaskForm, result: Record): Gatewa } function requestSnapshot(task: TaskForm): Record { + if (task.kind === 'responses') { + return { + model: task.model, + input: task.prompt, + instructions: task.instructions, + previous_response_id: task.previousResponseId, + runMode: 'simulation', + simulation: true, + store: true, + stream: false, + }; + } if (task.kind === 'embeddings') { return { model: task.model, @@ -102,6 +154,19 @@ function requestSnapshot(task: TaskForm): Record { simulation: true, }; } + if (task.kind === 'videos.generations') { + return { + model: task.model, + content: [{ type: 'text', text: task.prompt }], + aspect_ratio: task.aspectRatio ?? '16:9', + resolution: task.resolution ?? '720p', + duration: task.duration ?? 5, + audio: task.outputAudio ?? true, + runMode: 'simulation', + simulation: true, + }; + } + if (task.kind === 'tasks.retrieve') return { taskId: task.taskId }; return { model: task.model, messages: [{ role: 'user', content: task.prompt }], @@ -133,5 +198,7 @@ function modelTypeForKind(kind: TaskForm['kind']) { if (kind === 'reranks') return 'text_rerank'; if (kind === 'images.generations') return 'image_generate'; if (kind === 'images.edits') return 'image_edit'; + if (kind === 'videos.generations') return 'video_generate'; + if (kind === 'tasks.retrieve') return 'task'; return 'text_generate'; } diff --git a/apps/web/src/pages/ApiDocsPage.test.tsx b/apps/web/src/pages/ApiDocsPage.test.tsx new file mode 100644 index 0000000..f3550ce --- /dev/null +++ b/apps/web/src/pages/ApiDocsPage.test.tsx @@ -0,0 +1,107 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import type { ApiDocSection, TaskForm } from '../types'; +import { ApiDocsPage } from './ApiDocsPage'; + +describe('ApiDocsPage extended task documentation', () => { + it.each([ + ['guideBaseUrl', '前往创建 API Key'], + ['guideWebhook', 'TASK_PROGRESS_CALLBACK_ENABLED'], + ['guideErrors', '400 invalid_parameter'], + ['guideTesting', 'simulated=true'], + ] as const)('renders the clickable guide page %s', (section, expectedContent) => { + const html = renderDocs(section, { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' }); + + expect(html).toContain('data-active="true"'); + expect(html).toContain(expectedContent); + expect(html).toContain('指南导航'); + expect(html).not.toContain('/v1/chat/completions'); + }); + + it('documents the OpenAI-compatible Responses request and continuity fields', () => { + const html = renderDocs('responses', { kind: 'responses', model: 'gpt-4o-mini', prompt: '你好' }); + + expect(html).toContain('/v1/responses'); + expect(html).toContain('previous_response_id'); + expect(html).toContain('X-Async'); + expect(html).toContain('连续对话与状态归属'); + }); + + it('documents video generation parameters and async retrieval guidance', () => { + const html = renderDocs('videoGeneration', { kind: 'videos.generations', model: '豆包Seedance-2.0', prompt: '雪山日出' }); + + expect(html).toContain('/api/v1/videos/generations'); + expect(html).toContain('aspect_ratio'); + expect(html).toContain('reference_image'); + expect(html).toContain('任务取回接口'); + }); + + it('documents async mode as a body-independent capability', () => { + const html = renderDocs('asyncMode', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' }); + + expect(html).toContain('X-Async'); + expect(html).toContain('原请求 Header'); + expect(html).toContain('同步与异步的区别'); + expect(html).toContain('异步受理响应'); + expect(html).not.toContain('/api/v1/videos/generations'); + expect(html).not.toContain('Body 参数'); + expect(html).not.toContain('视频模型 ID'); + }); + + it.each(['chat', 'responses', 'embeddings', 'reranks', 'imageGeneration', 'imageEdit', 'videoGeneration'] as const)( + 'shows the shared X-Async switch on the %s task API', + (section) => { + const html = renderDocs(section, { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' }); + + expect(html).toContain('X-Async'); + }, + ); + + it('renders nested objects and object arrays recursively', () => { + const html = renderDocs('videoGeneration', { kind: 'videos.generations', model: '豆包Seedance-2.0', prompt: '雪山日出' }); + + expect(html).toContain('array<object>'); + expect(html).toContain('data-depth="1"'); + expect(html).toContain('data-depth="4"'); + expect(html).toContain('inline_element'); + expect(html).toContain('refer_images'); + expect(html).toContain('slot_key'); + }); + + it('renders task retrieval as GET with a path parameter instead of a request body', () => { + const html = renderDocs('taskRetrieve', { kind: 'tasks.retrieve', model: 'task', prompt: '', taskId: 'task-123' }); + + expect(html).toContain('GET'); + expect(html).toContain('/api/v1/tasks/task-123'); + expect(html).toContain('Path 参数'); + expect(html).toContain('响应 Body'); + expect(html).toContain('result'); + expect(html).toContain('billingSummary'); + expect(html).toContain('attempts'); + expect(html).toContain('成功响应示例'); + expect(html).toContain('Task ID'); + expect(html).not.toContain('请求 Body'); + }); +}); + +function renderDocs(activeDocSection: ApiDocSection, taskForm: TaskForm) { + return renderToStaticMarkup( + , + ); +} diff --git a/apps/web/src/pages/ApiDocsPage.tsx b/apps/web/src/pages/ApiDocsPage.tsx index c65cfde..9cb76bd 100644 --- a/apps/web/src/pages/ApiDocsPage.tsx +++ b/apps/web/src/pages/ApiDocsPage.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useState, type FormEvent } from 'react'; +import { Fragment, useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react'; import type { GatewayApiKey, GatewaySkillBundleMetadata, GatewayTask } from '@easyai-ai-gateway/contracts'; import { BookOpen, Download, ExternalLink, FileJson, KeyRound, Play, Search, Send, Wrench } from 'lucide-react'; -import { Badge, Button, Select, Textarea } from '../components/ui'; +import { Badge, Button, Input, Select, Textarea } from '../components/ui'; import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api'; import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types'; import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared'; @@ -11,29 +11,58 @@ interface ApiDocItem { key: ApiDocSection; kind?: TaskKind; lead: string; - method: string; - path: string; + method?: 'GET' | 'POST'; + path?: string; title: string; } -const docs: ApiDocItem[] = [ +interface ApiDocParam { + children?: ApiDocParam[]; + name: string; + required?: boolean; + type: string; + value: string; +} + +type ApiGuideSection = Extract; + +interface ApiGuideItem { + group: '指南'; + key: ApiGuideSection; + lead: string; + title: string; +} + +export const apiDocs: ApiDocItem[] = [ { key: 'chat', group: '文本', kind: 'chat.completions', method: 'POST', path: '/v1/chat/completions', title: 'Chat Completions', lead: 'OpenAI 兼容的对话接口,支持本地 API Key 授权、simulation 测试和非流式/流式响应。' }, + { key: 'responses', group: '文本', kind: 'responses', method: 'POST', path: '/v1/responses', title: 'Responses', lead: 'OpenAI 兼容的 Responses 接口,原生支持 input、previous_response_id、工具调用和流式输出;不支持原生 Responses 的模型会由网关转换到 Chat Completions。' }, { key: 'embeddings', group: '文本', kind: 'embeddings', method: 'POST', path: '/v1/embeddings', title: '文本向量 Embeddings', lead: 'OpenAI 兼容的文本向量接口,可直接用 input 数组或字符串生成 embedding,API Key 需要 embedding 权限。' }, { key: 'reranks', group: '文本', kind: 'reranks', method: 'POST', path: '/v1/reranks', title: '文本重排序 Reranks', lead: 'OpenAI 风格的重排序接口,传入 query 和 documents 后返回 relevance_score,API Key 需要 rerank 权限。' }, { key: 'imageGeneration', group: '图片', kind: 'images.generations', method: 'POST', path: '/v1/images/generations', title: '创建图片', lead: 'OpenAI 兼容的图片生成接口,支持 prompt、size、quality 和 simulation 测试。' }, { key: 'imageEdit', group: '图片', kind: 'images.edits', method: 'POST', path: '/v1/images/edits', title: '编辑图片', lead: 'OpenAI 兼容的图片编辑接口,支持 image、mask、prompt 和 simulation 测试。' }, + { key: 'videoGeneration', group: '视频', kind: 'videos.generations', method: 'POST', path: '/api/v1/videos/generations', title: '生成视频', lead: '视频生成任务接口,支持文生视频、首尾帧、图片/视频/音频参考,以及时长、分辨率、画幅和声音等模型能力参数。' }, + { key: 'asyncMode', group: '异步任务', title: '异步模式', lead: '所有 AI 任务创建接口使用同一种异步开启方式:保留原接口和原请求 Body,只需增加 X-Async: true。' }, + { key: 'taskRetrieve', group: '异步任务', kind: 'tasks.retrieve', method: 'GET', path: '/api/v1/tasks/{taskID}', title: '取回任务', lead: '使用异步提交返回的 taskId 查询任务状态、结果、错误、用量、计费和执行尝试;queued、running、submitting 为进行中状态。' }, { key: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估', lead: '按请求体估算输入输出 token、模型倍率和折扣后的预估费用。' }, { key: 'files', group: '文件', method: 'POST', path: '/v1/files/upload', title: '上传文件', lead: '上传在线测试所需的图片、音频或视频资源,后续请求可复用返回的文件 URL。' }, ]; -const guideItems = ['获取 Base URL 和 API Key', '通知设置-WebHook 参数介绍', '错误码', '测试模式']; +const guideItems: ApiGuideItem[] = [ + { key: 'guideBaseUrl', group: '指南', title: '获取 Base URL 和 API Key', lead: '确认当前部署环境的网关地址,创建 API Key,并使用 Bearer 鉴权调用公开接口。' }, + { key: 'guideWebhook', group: '指南', title: '通知设置-WebHook 参数介绍', lead: '了解任务进度 WebHook 的启用方式、回调结构、可靠投递机制和公开 API 的状态取回方式。' }, + { key: 'guideErrors', group: '指南', title: '错误码', lead: '根据 HTTP 状态、error.code、requestId 和异步任务错误字段快速定位请求失败原因。' }, + { key: 'guideTesting', group: '指南', title: '测试模式', lead: '使用 simulation 完整验证鉴权、路由、队列、限流、进度和结果归一,而不向真实供应商提交任务。' }, +]; const taskKindOptions = [ ['chat.completions', 'Chat'], + ['responses', 'Responses'], ['embeddings', '文本向量'], ['reranks', '重排序'], ['images.generations', '生图'], ['images.edits', '图像编辑'], + ['videos.generations', '视频生成'], + ['tasks.retrieve', '取回任务'], ] as const; const defaultOpsSkillMetadata: GatewaySkillBundleMetadata = { @@ -64,18 +93,31 @@ export function ApiDocsPage(props: { onSubmitTask: (event: FormEvent) => void; onTaskFormChange: (value: TaskForm) => void; }) { - const current = docs.find((item) => item.key === props.activeDocSection) ?? docs[0]; + const activeGuide = guideItems.find((item) => item.key === props.activeDocSection); + const currentApiDoc = apiDocs.find((item) => item.key === props.activeDocSection) ?? (activeGuide ? undefined : apiDocs[0]); + const current = activeGuide ?? currentApiDoc ?? apiDocs[0]; const [opsSkillMetadata, setOpsSkillMetadata] = useState(defaultOpsSkillMetadata); - const isFileDoc = current.key === 'files'; + const isFileDoc = currentApiDoc?.key === 'files'; + const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve'; + const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode'; + const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path); const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById); const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId); - const bodyExample = useMemo(() => requestBodyExample(props.taskForm), [props.taskForm]); + const bodyExample = useMemo( + () => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat'), + [currentApiDoc?.key, props.taskForm], + ); + const runnerPath = currentApiDoc?.path + ? isTaskRetrieveDoc + ? currentApiDoc.path.replace('{taskID}', props.taskForm.taskId?.trim() || '{taskID}') + : currentApiDoc.path + : ''; useEffect(() => { - if (current.kind && props.taskForm.kind !== current.kind) { - props.onTaskFormChange(defaultTaskForKind(current.kind, props.taskForm)); + if (currentApiDoc?.kind && props.taskForm.kind !== currentApiDoc.kind) { + props.onTaskFormChange(defaultTaskForDoc(currentApiDoc.kind, props.taskForm, props.taskResult)); } - }, [current.kind, props.taskForm.kind]); + }, [currentApiDoc?.kind, props.taskForm.kind, props.taskResult?.id]); useEffect(() => { let active = true; @@ -92,6 +134,10 @@ export function ApiDocsPage(props: { }, []); function handleSubmit(event: FormEvent) { + if (!runnerAvailable) { + event.preventDefault(); + return; + } if (!props.canRun) { event.preventDefault(); props.onLogin(); @@ -102,13 +148,17 @@ export function ApiDocsPage(props: { function handleDocClick(item: ApiDocItem) { if (item.kind) { - props.onTaskFormChange(defaultTaskForKind(item.kind, props.taskForm)); + props.onTaskFormChange(defaultTaskForDoc(item.kind, props.taskForm, props.taskResult)); } props.onDocSectionChange(item.key); } + function handleGuideClick(item: ApiGuideItem) { + props.onDocSectionChange(item.key); + } + function handleKindChange(kind: TaskKind) { - props.onTaskFormChange(defaultTaskForKind(kind, props.taskForm)); + props.onTaskFormChange(defaultTaskForDoc(kind, props.taskForm, props.taskResult)); const nextSection = docSectionForKind(kind); if (nextSection !== props.activeDocSection) { props.onDocSectionChange(nextSection); @@ -126,8 +176,15 @@ export function ApiDocsPage(props: { - ({ title }))} /> - {groupDocs(docs).map((group) => ( + ({ + active: item.key === props.activeDocSection, + title: item.title, + onClick: () => handleGuideClick(item), + }))} + /> + {groupDocs(apiDocs).map((group) => (

{current.group}

{current.title}

-
- {current.method} - {current.path} -
+ {currentApiDoc?.method && currentApiDoc.path && ( +
+ + {currentApiDoc.path} +
+ )}

{current.lead}

- + {isAsyncModeDoc ? ( + + ) : currentApiDoc ? ( + <> + {!isTaskRetrieveDoc && } -
-
-

Header 参数

- -
- - - -
+
+
+

Header 参数

+ +
+ {currentApiDoc.method !== 'GET' && } + + + {supportsAsyncMode(currentApiDoc.key) && ( + + )} +
-
-
-

Body 参数

- application/json -
- {isFileDoc ? ( - <> - - - - ) : ( - bodyParamRows(current.key).map((row) => ( - - )) - )} -
+
+
+

{isTaskRetrieveDoc ? 'Path 参数' : 'Body 参数'}

+ {!isTaskRetrieveDoc && {isFileDoc ? 'multipart/form-data' : 'application/json'}} +
+ {isTaskRetrieveDoc ? ( + + ) : isFileDoc ? ( + <> + + + + ) : ( + + )} +
+ + {isTaskRetrieveDoc && } + + + ) : activeGuide ? ( + + ) : null}