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;