From d818e7947aa20be4e5f73f558d5400cbd1b68886 Mon Sep 17 00:00:00 2001 From: wangbo Date: Fri, 17 Jul 2026 22:39:11 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E8=B0=83=E6=95=B4=20Gemini=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=8E=A5=E5=8F=A3=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/internal/httpapi/gemini_compat.go | 2 +- .../internal/httpapi/gemini_compat_test.go | 49 +++++++++++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/api/internal/httpapi/gemini_compat.go b/apps/api/internal/httpapi/gemini_compat.go index 16366fc..3e3b93e 100644 --- a/apps/api/internal/httpapi/gemini_compat.go +++ b/apps/api/internal/httpapi/gemini_compat.go @@ -40,9 +40,9 @@ type geminiUploadSession struct { } var geminiGenerateContentRoutePrefixes = []string{ + "/api/v1/models/", "/v1beta/models/", "/v1/models/", - "/models/", } func (s *Server) registerGeminiGenerateContentRoutes(mux *http.ServeMux) { diff --git a/apps/api/internal/httpapi/gemini_compat_test.go b/apps/api/internal/httpapi/gemini_compat_test.go index d7e5c79..fcf20ec 100644 --- a/apps/api/internal/httpapi/gemini_compat_test.go +++ b/apps/api/internal/httpapi/gemini_compat_test.go @@ -1,6 +1,14 @@ package httpapi -import "testing" +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" +) func TestGeminiGenerateContentModelFromPath(t *testing.T) { tests := []struct { @@ -25,9 +33,9 @@ func TestGeminiGenerateContentModelFromPath(t *testing.T) { wantOK: true, }, { - name: "bare model path", - prefix: "/models/", - requestPath: "/models/gemini-image:generateContent", + name: "gateway api v1 model", + prefix: "/api/v1/models/", + requestPath: "/api/v1/models/gemini-image:generateContent", wantModel: "gemini-image", wantOK: true, }, @@ -61,6 +69,39 @@ func TestGeminiGenerateContentModelFromPath(t *testing.T) { } } +func TestRegisterGeminiGenerateContentRoutes(t *testing.T) { + server := &Server{ + auth: auth.New("test-secret", "", ""), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/models", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + server.registerGeminiGenerateContentRoutes(mux) + + tests := []struct { + method string + path string + status int + }{ + {method: http.MethodGet, path: "/api/v1/models", status: http.StatusNoContent}, + {method: http.MethodPost, path: "/api/v1/models/gemini-image:generateContent", status: http.StatusUnauthorized}, + {method: http.MethodPost, path: "/v1/models/gemini-image:generateContent", status: http.StatusUnauthorized}, + {method: http.MethodPost, path: "/v1beta/models/gemini-image:generateContent", status: http.StatusUnauthorized}, + {method: http.MethodPost, path: "/models/gemini-image:generateContent", status: http.StatusNotFound}, + } + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + response := httptest.NewRecorder() + mux.ServeHTTP(response, httptest.NewRequest(tt.method, tt.path, nil)) + if response.Code != tt.status { + t.Fatalf("status = %d, want %d; body=%s", response.Code, tt.status, response.Body.String()) + } + }) + } +} + func TestGeminiImageTaskBodyMapsTextOnlyToImageGenerate(t *testing.T) { mapping, err := geminiImageTaskBody("gemini-image", map[string]any{ "contents": []any{ From 276c0612d8da6a11e29a12d2193844f73e5dd3a7 Mon Sep 17 00:00:00 2001 From: wangbo Date: Tue, 21 Jul 2026 23:51:09 +0800 Subject: [PATCH 02/12] =?UTF-8?q?refactor(task):=20=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E7=BD=91=E5=85=B3=E4=BB=BB=E5=8A=A1=E5=88=9B=E5=BB=BA=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/httpapi/gateway_task_creation.go | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 apps/api/internal/httpapi/gateway_task_creation.go diff --git a/apps/api/internal/httpapi/gateway_task_creation.go b/apps/api/internal/httpapi/gateway_task_creation.go new file mode 100644 index 0000000..76b3c7e --- /dev/null +++ b/apps/api/internal/httpapi/gateway_task_creation.go @@ -0,0 +1,68 @@ +package httpapi + +import ( + "context" + "fmt" + "net/http" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type gatewayTaskCreationStage string + +const ( + gatewayTaskCreationPrepare gatewayTaskCreationStage = "prepare" + gatewayTaskCreationStore gatewayTaskCreationStage = "store" +) + +type gatewayTaskCreationError struct { + Stage gatewayTaskCreationStage + Err error +} + +func (e *gatewayTaskCreationError) Error() string { + if e == nil || e.Err == nil { + return "gateway task creation failed" + } + return e.Err.Error() +} + +func (e *gatewayTaskCreationError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func (s *Server) prepareAndCreateGatewayTask( + ctx context.Context, + r *http.Request, + user *auth.User, + kind string, + model string, + body map[string]any, + async bool, +) (store.GatewayTask, error) { + prepared, err := s.prepareTaskRequest(ctx, r, user, body) + if err != nil { + return store.GatewayTask{}, &gatewayTaskCreationError{Stage: gatewayTaskCreationPrepare, Err: err} + } + task, err := s.store.CreateTask(ctx, store.CreateTaskInput{ + Kind: kind, + Model: model, + RunMode: runModeFromRequest(prepared.Body), + Async: async, + Request: prepared.Body, + ConversationID: prepared.ConversationID, + NewMessageCount: prepared.NewMessageCount, + MessageRefs: prepared.MessageRefs, + }, user) + if err != nil { + return store.GatewayTask{}, &gatewayTaskCreationError{ + Stage: gatewayTaskCreationStore, + Err: fmt.Errorf("create task: %w", err), + } + } + return task, nil +} From 56d4a3a6b72d4e2f6d583671df5d9f2cb3b4d327 Mon Sep 17 00:00:00 2001 From: wangbo Date: Tue, 21 Jul 2026 23:52:19 +0800 Subject: [PATCH 03/12] =?UTF-8?q?fix(access):=20=E4=BF=AE=E6=AD=A3=20API?= =?UTF-8?q?=20Key=20=E5=8F=AF=E5=88=86=E9=85=8D=E6=A8=A1=E5=9E=8B=E8=8C=83?= =?UTF-8?q?=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/httpapi/access_rule_handlers.go | 26 +++ ..._key_assignable_models_integration_test.go | 150 ++++++++++++++++++ apps/api/internal/httpapi/server.go | 1 + apps/api/internal/store/access_rules.go | 43 ++++- apps/api/internal/store/access_rules_test.go | 23 +++ apps/web/src/App.tsx | 35 ++-- apps/web/src/api.test.ts | 21 +++ apps/web/src/api.ts | 4 + 8 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 apps/api/internal/httpapi/api_key_assignable_models_integration_test.go create mode 100644 apps/api/internal/store/access_rules_test.go diff --git a/apps/api/internal/httpapi/access_rule_handlers.go b/apps/api/internal/httpapi/access_rule_handlers.go index 9601608..7cd20ef 100644 --- a/apps/api/internal/httpapi/access_rule_handlers.go +++ b/apps/api/internal/httpapi/access_rule_handlers.go @@ -58,6 +58,32 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +// listAPIKeyAssignableModels godoc +// @Summary 列出 API Key 可分配模型 +// @Description 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。 +// @Tags api-keys +// @Produce json +// @Security BearerAuth +// @Success 200 {object} PlatformModelListResponse +// @Failure 401 {object} ErrorEnvelope +// @Failure 503 {object} ErrorEnvelope +// @Failure 500 {object} ErrorEnvelope +// @Router /api/v1/api-keys/assignable-models [get] +func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Request) { + user, _ := auth.UserFromContext(r.Context()) + models, err := s.store.ListAPIKeyAssignablePlatformModels(r.Context(), user) + if err != nil { + if errors.Is(err, store.ErrLocalUserRequired) { + writeLocalUserRequired(w) + return + } + s.logger.Error("list api key assignable models failed", "error", err) + writeError(w, http.StatusInternalServerError, "list api key assignable models failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)}) +} + // createAccessRule godoc // @Summary 创建访问规则 // @Description 管理端创建一条访问控制规则。 diff --git a/apps/api/internal/httpapi/api_key_assignable_models_integration_test.go b/apps/api/internal/httpapi/api_key_assignable_models_integration_test.go new file mode 100644 index 0000000..0edf1b6 --- /dev/null +++ b/apps/api/internal/httpapi/api_key_assignable_models_integration_test.go @@ -0,0 +1,150 @@ +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" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestAPIKeyAssignableModelsIgnoreAPIKeyRules(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 API key assignable-model 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() + + suffixText := strconv.FormatInt(time.Now().UnixNano(), 10) + username := "api_key_assignable_" + suffixText + 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) + + testPool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatalf("connect test pool: %v", err) + } + defer testPool.Close() + if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil { + t.Fatalf("promote test 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) + + createAPIKey := func(name string) string { + t.Helper() + var response struct { + APIKey struct { + ID string `json:"id"` + } `json:"apiKey"` + } + doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", loginResponse.AccessToken, map[string]any{ + "name": name, + }, http.StatusCreated, &response) + return response.APIKey.ID + } + firstAPIKeyID := createAPIKey("first assignable key") + secondAPIKeyID := createAPIKey("second assignable key") + + var platform struct { + ID string `json:"id"` + } + doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{ + "provider": "openai", + "platformKey": "api-key-assignable-" + suffixText, + "name": "API Key Assignable Test", + "baseUrl": "https://api.openai.com/v1", + "authType": "bearer", + "credentials": map[string]any{"mode": "simulation"}, + "config": map[string]any{"testMode": true}, + }, http.StatusCreated, &platform) + + var model struct { + ID string `json:"id"` + } + modelName := "api-key-assignable-model-" + suffixText + doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{ + "canonicalModelKey": "openai:gpt-4o-mini", + "modelName": modelName, + "modelAlias": modelName, + "modelType": []string{"text_generate"}, + "displayName": "API Key Assignable Model", + }, http.StatusCreated, &model) + + assertAssignable := func() { + t.Helper() + var response struct { + Items []struct { + ID string `json:"id"` + ModelName string `json:"modelName"` + } `json:"items"` + } + doJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys/assignable-models", loginResponse.AccessToken, nil, http.StatusOK, &response) + if !modelListContains(response.Items, model.ID) { + t.Fatalf("user-owned model should remain assignable regardless of API key rules: %+v", response.Items) + } + } + assignModel := func(apiKeyID string) { + t.Helper() + doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys/access-rules/batch", loginResponse.AccessToken, map[string]any{ + "subjectType": "api_key", + "subjectId": apiKeyID, + "effect": "allow", + "upsertResources": []map[string]any{{ + "resourceType": "platform_model", + "resourceId": model.ID, + "priority": 100, + "minPermissionLevel": 0, + "status": "active", + }}, + "deleteResources": []map[string]any{}, + }, http.StatusOK, nil) + } + + assertAssignable() + assignModel(firstAPIKeyID) + assertAssignable() + assignModel(secondAPIKeyID) + assertAssignable() +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 7834444..6c9f010 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -182,6 +182,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.Handle("POST /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey))) mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules))) mux.Handle("POST /api/v1/api-keys/access-rules/batch", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.batchAPIKeyAccessRules))) + mux.Handle("GET /api/v1/api-keys/assignable-models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAssignableModels))) mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/scopes", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.updateAPIKeyScopes))) mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey))) mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey))) diff --git a/apps/api/internal/store/access_rules.go b/apps/api/internal/store/access_rules.go index f30a23c..3eaf1ba 100644 --- a/apps/api/internal/store/access_rules.go +++ b/apps/api/internal/store/access_rules.go @@ -283,6 +283,21 @@ func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.Us } func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) { + return s.listPlatformModelsForAccessRules(ctx, user, nil) +} + +// ListAPIKeyAssignablePlatformModels returns the enabled models that the +// current user may delegate to their API keys. API-key rules are deliberately +// excluded here: they restrict individual credentials and must not shrink the +// resource pool that the owning user can manage. +func (s *Store) ListAPIKeyAssignablePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) { + if localGatewayUserID(user) == "" { + return nil, ErrLocalUserRequired + } + return s.listPlatformModelsForAccessRules(ctx, user, map[string]bool{"api_key": true}) +} + +func (s *Store) listPlatformModelsForAccessRules(ctx context.Context, user *auth.User, excludedSubjectTypes map[string]bool) ([]PlatformModel, error) { accessUser, err := s.resolveCurrentAccessUser(ctx, user) if err != nil { return nil, err @@ -307,7 +322,7 @@ func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.Use enabled = append(enabled, model) } } - return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled) + return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled, excludedSubjectTypes) } func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user *auth.User, resources []AccessRuleResourceInput) error { @@ -328,7 +343,7 @@ func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user } func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User) (map[string]bool, error) { - models, err := s.ListAccessiblePlatformModels(ctx, user) + models, err := s.ListAPIKeyAssignablePlatformModels(ctx, user) if err != nil { return nil, err } @@ -383,7 +398,12 @@ WHERE id = $1::uuid return &next, nil } -func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) { +func (s *Store) filterPlatformModelsByAccessRules( + ctx context.Context, + user *auth.User, + models []PlatformModel, + excludedSubjectTypes map[string]bool, +) ([]PlatformModel, error) { if len(models) == 0 { return models, nil } @@ -398,6 +418,12 @@ func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *aut if len(rules) == 0 { return models, nil } + if len(excludedSubjectTypes) > 0 { + rules = filterAccessRulesBySubjectType(rules, excludedSubjectTypes) + if len(rules) == 0 { + return models, nil + } + } subjects := accessRuleSubjects(user) level := 0 if user != nil { @@ -412,6 +438,17 @@ func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *aut return filtered, nil } +func filterAccessRulesBySubjectType(rules []AccessRule, excludedSubjectTypes map[string]bool) []AccessRule { + filtered := make([]AccessRule, 0, len(rules)) + for _, rule := range rules { + if excludedSubjectTypes[rule.SubjectType] { + continue + } + filtered = append(filtered, rule) + } + return filtered +} + func (s *Store) listActiveAccessRulesForResources(ctx context.Context, resources []accessRuleResource) ([]AccessRule, error) { values := make([]string, 0, len(resources)) for _, resource := range resources { diff --git a/apps/api/internal/store/access_rules_test.go b/apps/api/internal/store/access_rules_test.go new file mode 100644 index 0000000..6458c82 --- /dev/null +++ b/apps/api/internal/store/access_rules_test.go @@ -0,0 +1,23 @@ +package store + +import "testing" + +func TestFilterAccessRulesBySubjectTypeExcludesAPIKeyRulesOnly(t *testing.T) { + rules := []AccessRule{ + {ID: "api-key-allow", SubjectType: "api_key", Effect: "allow"}, + {ID: "api-key-deny", SubjectType: "api_key", Effect: "deny"}, + {ID: "user-group-allow", SubjectType: "user_group", Effect: "allow"}, + {ID: "user-deny", SubjectType: "user", Effect: "deny"}, + {ID: "tenant-allow", SubjectType: "tenant", Effect: "allow"}, + } + + filtered := filterAccessRulesBySubjectType(rules, map[string]bool{"api_key": true}) + if len(filtered) != 3 { + t.Fatalf("filtered rule count = %d, want 3: %+v", len(filtered), filtered) + } + for _, rule := range filtered { + if rule.SubjectType == "api_key" { + t.Fatalf("api-key rule should not affect the owning user's assignable resources: %+v", rule) + } + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6870008..0877d36 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -69,6 +69,7 @@ import { listAccessRules, listAuditLogs, listApiKeyAccessRules, + listApiKeyAssignableModels, listApiKeys, listBaseModels, listCatalogProviders, @@ -176,6 +177,7 @@ type DataKey = | 'publicCatalog' | 'playgroundApiKeys' | 'playgroundModels' + | 'apiKeyPolicyModels' | 'modelCatalog' | 'networkProxyConfig' | 'clientCustomizationSettings' @@ -227,6 +229,7 @@ export function App() { summary: { modelCount: 0, sourceCount: 0 }, }); const [playgroundModels, setPlaygroundModels] = useState([]); + const [apiKeyPolicyModels, setApiKeyPolicyModels] = useState([]); const [networkProxyConfig, setNetworkProxyConfig] = useState(null); const [clientCustomizationSettings, setClientCustomizationSettings] = useState(null); const [fileStorageChannels, setFileStorageChannels] = useState([]); @@ -530,6 +533,9 @@ export function App() { case 'playgroundModels': setPlaygroundModels((await listPlayableModels(nextToken)).items); return; + case 'apiKeyPolicyModels': + setApiKeyPolicyModels((await listApiKeyAssignableModels(nextToken)).items); + return; case 'playgroundApiKeys': { const response = await listPlayableApiKeys(nextToken); setApiKeys(response.items); @@ -687,7 +693,7 @@ export function App() { const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings); setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]); setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]); - invalidateDataKeys('modelCatalog', 'modelRateLimits'); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels'); setCoreState('ready'); setCoreMessage(input.platformId ? `平台已更新,当前绑定 ${input.models.length} 个模型。` @@ -707,7 +713,7 @@ export function App() { const updated = await updatePlatform(token, platform.id, input); const platformForState = withCredentialPreviewFallback(updated, input, platform); setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item)); - invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels'); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels'); setCoreState('ready'); setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。'); } catch (err) { @@ -739,7 +745,7 @@ export function App() { platformPriority: state.priority, } : status)); - invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels'); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels', 'apiKeyPolicyModels'); setCoreState('ready'); setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。'); } catch (err) { @@ -783,7 +789,7 @@ export function App() { cooldownUntil: undefined, } : model)); - invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels'); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels', 'apiKeyPolicyModels'); setCoreState('ready'); setCoreMessage('模型运行状态已恢复。'); } catch (err) { @@ -800,7 +806,7 @@ export function App() { await deletePlatform(token, platformId); setPlatforms((current) => current.filter((item) => item.id !== platformId)); setModels((current) => current.filter((item) => item.platformId !== platformId)); - invalidateDataKeys('modelCatalog', 'modelRateLimits'); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels'); setCoreState('ready'); setCoreMessage('平台已删除。'); } catch (err) { @@ -816,6 +822,7 @@ export function App() { try { const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input); setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]); + invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels'); setCoreState('ready'); setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。'); } catch (err) { @@ -831,6 +838,7 @@ export function App() { try { await deleteTenant(token, tenantId); setTenants((current) => current.filter((tenant) => tenant.id !== tenantId)); + invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels'); setCoreState('ready'); setCoreMessage('租户已删除。'); } catch (err) { @@ -846,7 +854,7 @@ export function App() { try { const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input); setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]); - invalidateDataKeys('playgroundModels'); + invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels'); setCoreState('ready'); setCoreMessage(userId ? '用户已更新。' : '用户已创建。'); } catch (err) { @@ -902,7 +910,7 @@ export function App() { try { const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input); setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]); - invalidateDataKeys('modelCatalog'); + invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels'); setCoreState('ready'); setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。'); } catch (err) { @@ -920,8 +928,7 @@ export function App() { setUserGroups((current) => current.filter((group) => group.id !== groupId)); setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant)); setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user)); - invalidateDataKeys('modelCatalog'); - invalidateDataKeys('playgroundModels'); + invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels'); setCoreState('ready'); setCoreMessage('用户组已删除。'); } catch (err) { @@ -975,7 +982,7 @@ export function App() { try { const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input); setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]); - invalidateDataKeys('playgroundModels', 'modelCatalog'); + invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。'); } catch (err) { @@ -991,7 +998,7 @@ export function App() { try { await deleteAccessRule(token, ruleId); setAccessRules((current) => current.filter((rule) => rule.id !== ruleId)); - invalidateDataKeys('playgroundModels', 'modelCatalog'); + invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage('访问权限规则已删除。'); } catch (err) { @@ -1007,7 +1014,7 @@ export function App() { try { const response = await batchAccessRules(token, input); setAccessRules(response.items); - invalidateDataKeys('playgroundModels', 'modelCatalog'); + invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage('访问权限已更新。'); } catch (err) { @@ -1355,7 +1362,7 @@ export function App() { apiKeyForm={apiKeyForm} apiKeySecret={apiKeySecret} apiKeySecretsById={apiKeySecretsById} - apiKeyPolicyModels={playgroundModels} + apiKeyPolicyModels={apiKeyPolicyModels} data={data} message={coreMessage} section={workspaceSection} @@ -1622,7 +1629,7 @@ function dataKeysForRoute( if (activePage === 'workspace') { if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys']; if (workspaceSection === 'billing') return ['wallet']; - if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels']; + if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'apiKeyPolicyModels']; if (workspaceSection === 'tasks') return ['tasks']; if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions']; return []; diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index 470cc3b..0aa6b2e 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -10,6 +10,7 @@ import { getCurrentUser, getOpsManagementSkillMetadata, loginLocalAccount, + listApiKeyAssignableModels, OIDC_BROWSER_SESSION_CREDENTIAL, startIdentityPairing, retireIdentityPairingSecurityEventConflict, @@ -231,6 +232,26 @@ describe('OIDC browser session transport', () => { }); }); +describe('API Key permission resources', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('loads the user-owned resource pool independently from playable models', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + + await listApiKeyAssignableModels('user-token'); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/v1/api-keys/assignable-models'); + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer user-token'); + }); +}); + describe('Public Agent resources', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index aa79f36..30ba323 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -459,6 +459,10 @@ export async function listApiKeyAccessRules(token: string): Promise>('/api/v1/api-keys/access-rules', { token }); } +export async function listApiKeyAssignableModels(token: string): Promise> { + return request>('/api/v1/api-keys/assignable-models', { token }); +} + export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise { return request('/api/admin/access-rules', { body: input, From 6b675c406e2ef77573a3f07e720a0927cbf0a504 Mon Sep 17 00:00:00 2001 From: wangbo Date: Tue, 21 Jul 2026 23:52:53 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix(billing):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=AE=A1=E8=B4=B9=E9=85=8D=E7=BD=AE=E7=BB=A7?= =?UTF-8?q?=E6=89=BF=E4=BC=98=E5=85=88=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/internal/httpapi/model_response.go | 57 ++++++++------- .../internal/httpapi/model_response_test.go | 35 ++++++++++ apps/api/internal/runner/pricing.go | 25 +++---- apps/api/internal/store/billing_config.go | 28 ++++++++ .../api/internal/store/billing_config_test.go | 69 +++++++++++++++++++ apps/api/internal/store/platform_models.go | 12 ++-- .../store/platform_models_integration_test.go | 37 ++++++++++ apps/api/internal/store/postgres.go | 68 ++++++++++-------- 8 files changed, 263 insertions(+), 68 deletions(-) create mode 100644 apps/api/internal/store/billing_config.go create mode 100644 apps/api/internal/store/billing_config_test.go create mode 100644 apps/api/internal/store/platform_models_integration_test.go diff --git a/apps/api/internal/httpapi/model_response.go b/apps/api/internal/httpapi/model_response.go index ded775b..0aa90dc 100644 --- a/apps/api/internal/httpapi/model_response.go +++ b/apps/api/internal/httpapi/model_response.go @@ -7,46 +7,57 @@ import ( ) func (s *Server) platformModelResponse(ctx context.Context, model store.PlatformModel) store.PlatformModel { + return s.platformModelResponseWithRuleSets(model, s.responsePricingRuleSetConfigs(ctx, []store.PlatformModel{model})) +} + +func (s *Server) platformModelResponseWithRuleSets(model store.PlatformModel, ruleSetConfigs map[string]map[string]any) store.PlatformModel { model.Capabilities = store.EffectivePlatformModelCapabilities(model.BaseCapabilities, model.Capabilities) model.Capabilities = enrichResponseCapabilities(model) - model = s.withEffectiveResponseBillingConfig(ctx, model) + model = withEffectiveResponseBillingConfig(model, ruleSetConfigs) return store.FilterPlatformModelBillingConfig(model) } func (s *Server) platformModelResponses(ctx context.Context, models []store.PlatformModel) []store.PlatformModel { + ruleSetConfigs := s.responsePricingRuleSetConfigs(ctx, models) items := make([]store.PlatformModel, len(models)) for i, model := range models { - items[i] = s.platformModelResponse(ctx, model) + items[i] = s.platformModelResponseWithRuleSets(model, ruleSetConfigs) } return items } -func (s *Server) withEffectiveResponseBillingConfig(ctx context.Context, model store.PlatformModel) store.PlatformModel { - config := model.BillingConfig - if model.PricingRuleSetID != "" { - if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, model.PricingRuleSetID); err == nil && len(ruleSetConfig) > 0 { - config = ruleSetConfig +func (s *Server) responsePricingRuleSetConfigs(ctx context.Context, models []store.PlatformModel) map[string]map[string]any { + configs := map[string]map[string]any{} + if s.store == nil { + return configs + } + ids := map[string]bool{} + for _, model := range models { + for _, id := range []string{firstNonEmpty(model.BasePricingRuleSetID, model.PlatformPricingRuleSetID), model.PricingRuleSetID} { + if id != "" { + ids[id] = true + } } } - if len(model.BillingConfigOverride) > 0 { - config = mergeResponseBillingConfig(config, model.BillingConfigOverride) + for id := range ids { + if config, err := s.store.PricingRuleSetBillingConfig(ctx, id); err == nil && len(config) > 0 { + configs[id] = config + } } - model.BillingConfig = config - return model + return configs } -func mergeResponseBillingConfig(base map[string]any, override map[string]any) map[string]any { - if len(base) == 0 && len(override) == 0 { - return nil - } - out := make(map[string]any, len(base)+len(override)) - for key, value := range base { - out[key] = value - } - for key, value := range override { - out[key] = value - } - return out +func withEffectiveResponseBillingConfig(model store.PlatformModel, ruleSetConfigs map[string]map[string]any) store.PlatformModel { + inheritedRuleSetConfig := ruleSetConfigs[firstNonEmpty(model.BasePricingRuleSetID, model.PlatformPricingRuleSetID)] + modelRuleSetConfig := ruleSetConfigs[model.PricingRuleSetID] + model.BillingConfig = store.ResolveEffectiveBillingConfig(store.EffectiveBillingConfigInput{ + BaseConfig: model.BaseBillingConfig, + LegacyPlatformModelConfig: model.BillingConfig, + InheritedRuleSetConfig: inheritedRuleSetConfig, + ModelRuleSetConfig: modelRuleSetConfig, + Override: model.BillingConfigOverride, + }) + return model } func enrichResponseCapabilities(model store.PlatformModel) map[string]any { diff --git a/apps/api/internal/httpapi/model_response_test.go b/apps/api/internal/httpapi/model_response_test.go index e3fd661..e4c5fae 100644 --- a/apps/api/internal/httpapi/model_response_test.go +++ b/apps/api/internal/httpapi/model_response_test.go @@ -173,6 +173,41 @@ func TestPlatformModelResponsePreservesTextGenerateFieldsOverFallbacks(t *testin assertStringListValue(t, textGenerate["thinkingEffortLevels"], []string{"minimal", "low", "medium"}) } +func TestPlatformModelResponseUsesBaseBillingConfigWithoutMaterializedSnapshot(t *testing.T) { + model := store.PlatformModel{ + ModelName: "base-priced-model", + ModelType: store.StringList{"video_generate"}, + BaseBillingConfig: map[string]any{ + "video": map[string]any{"basePrice": float64(416)}, + }, + } + + response := (&Server{}).platformModelResponse(context.Background(), model) + video, ok := response.BillingConfig["video"].(map[string]any) + if !ok || video["basePrice"] != float64(416) { + t.Fatalf("expected base billing price 416, got %#v", response.BillingConfig) + } +} + +func TestEffectiveResponseBillingConfigPrefersBaseRuleOverLegacySnapshot(t *testing.T) { + model := store.PlatformModel{ + BasePricingRuleSetID: "seedance-pricing", + BillingConfig: map[string]any{ + "video": map[string]any{"basePrice": float64(100)}, + }, + } + response := withEffectiveResponseBillingConfig(model, map[string]map[string]any{ + "seedance-pricing": { + "video": map[string]any{"basePrice": float64(416)}, + }, + }) + + video, ok := response.BillingConfig["video"].(map[string]any) + if !ok || video["basePrice"] != float64(416) { + t.Fatalf("expected base rule price 416, got %#v", response.BillingConfig) + } +} + func textGenerateCapabilities(t *testing.T, model store.PlatformModel) map[string]any { t.Helper() capabilities, ok := model.Capabilities["text_generate"].(map[string]any) diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index f149c21..7026ac9 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -194,24 +194,25 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo } func (s *Service) effectiveBillingConfig(ctx context.Context, candidate store.RuntimeModelCandidate) map[string]any { - base := candidate.BaseBillingConfig - if ruleSetID := firstNonEmptyString(candidate.BasePricingRuleSetID, candidate.PlatformPricingRuleSetID); ruleSetID != "" { + var inheritedRuleSetConfig map[string]any + if ruleSetID := firstNonEmptyString(candidate.BasePricingRuleSetID, candidate.PlatformPricingRuleSetID); ruleSetID != "" && s.store != nil { if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, ruleSetID); err == nil && len(ruleSetConfig) > 0 { - base = ruleSetConfig + inheritedRuleSetConfig = ruleSetConfig } } - if len(candidate.BillingConfig) > 0 { - base = candidate.BillingConfig - } - if candidate.ModelPricingRuleSetID != "" { + var modelRuleSetConfig map[string]any + if candidate.ModelPricingRuleSetID != "" && s.store != nil { if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, candidate.ModelPricingRuleSetID); err == nil && len(ruleSetConfig) > 0 { - base = ruleSetConfig + modelRuleSetConfig = ruleSetConfig } } - if len(candidate.BillingConfigOverride) > 0 { - base = mergeMap(base, candidate.BillingConfigOverride) - } - return base + return store.ResolveEffectiveBillingConfig(store.EffectiveBillingConfigInput{ + BaseConfig: candidate.BaseBillingConfig, + LegacyPlatformModelConfig: candidate.BillingConfig, + InheritedRuleSetConfig: inheritedRuleSetConfig, + ModelRuleSetConfig: modelRuleSetConfig, + Override: candidate.BillingConfigOverride, + }) } func effectiveDiscount(ctx context.Context, db *store.Store, user *auth.User, candidate store.RuntimeModelCandidate) float64 { diff --git a/apps/api/internal/store/billing_config.go b/apps/api/internal/store/billing_config.go new file mode 100644 index 0000000..9cb1114 --- /dev/null +++ b/apps/api/internal/store/billing_config.go @@ -0,0 +1,28 @@ +package store + +// EffectiveBillingConfigInput describes the billing layers used by runtime and +// catalog responses. LegacyPlatformModelConfig is retained only as a fallback +// for models that do not have an effective pricing rule set. +type EffectiveBillingConfigInput struct { + BaseConfig map[string]any + LegacyPlatformModelConfig map[string]any + InheritedRuleSetConfig map[string]any + ModelRuleSetConfig map[string]any + Override map[string]any +} + +// ResolveEffectiveBillingConfig keeps inherited pricing rules authoritative over +// the legacy materialized snapshot. Explicit model rules and overrides retain +// their higher-priority exception semantics. +func ResolveEffectiveBillingConfig(input EffectiveBillingConfigInput) map[string]any { + config := mergeObjects(input.BaseConfig, nil) + if len(input.InheritedRuleSetConfig) > 0 { + config = mergeObjects(input.InheritedRuleSetConfig, nil) + } else if len(input.LegacyPlatformModelConfig) > 0 { + config = mergeObjects(input.LegacyPlatformModelConfig, nil) + } + if len(input.ModelRuleSetConfig) > 0 { + config = mergeObjects(input.ModelRuleSetConfig, nil) + } + return mergeObjects(config, input.Override) +} diff --git a/apps/api/internal/store/billing_config_test.go b/apps/api/internal/store/billing_config_test.go new file mode 100644 index 0000000..67be617 --- /dev/null +++ b/apps/api/internal/store/billing_config_test.go @@ -0,0 +1,69 @@ +package store + +import "testing" + +func TestResolveEffectiveBillingConfigKeepsPricingRulesAuthoritative(t *testing.T) { + tests := []struct { + name string + input EffectiveBillingConfigInput + want float64 + }{ + { + name: "inherited rule replaces stale platform snapshot", + input: EffectiveBillingConfigInput{ + BaseConfig: videoBillingConfig(100), + LegacyPlatformModelConfig: videoBillingConfig(100), + InheritedRuleSetConfig: videoBillingConfig(416), + }, + want: 416, + }, + { + name: "legacy snapshot remains a fallback without a rule", + input: EffectiveBillingConfigInput{ + BaseConfig: videoBillingConfig(100), + LegacyPlatformModelConfig: videoBillingConfig(125), + }, + want: 125, + }, + { + name: "model rule remains an explicit pricing exception", + input: EffectiveBillingConfigInput{ + BaseConfig: videoBillingConfig(100), + LegacyPlatformModelConfig: videoBillingConfig(125), + InheritedRuleSetConfig: videoBillingConfig(416), + ModelRuleSetConfig: videoBillingConfig(500), + }, + want: 500, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config := ResolveEffectiveBillingConfig(test.input) + video, ok := config["video"].(map[string]any) + if !ok { + t.Fatalf("expected video billing config, got %#v", config) + } + if got := video["basePrice"]; got != test.want { + t.Fatalf("video base price = %#v, want %v", got, test.want) + } + }) + } +} + +func TestResolveEffectiveBillingConfigAppliesOverrideLast(t *testing.T) { + config := ResolveEffectiveBillingConfig(EffectiveBillingConfigInput{ + InheritedRuleSetConfig: videoBillingConfig(416), + Override: videoBillingConfig(600), + }) + video, ok := config["video"].(map[string]any) + if !ok || video["basePrice"] != float64(600) { + t.Fatalf("expected override price 600, got %#v", config) + } +} + +func videoBillingConfig(basePrice float64) map[string]any { + return map[string]any{ + "video": map[string]any{"basePrice": basePrice}, + } +} diff --git a/apps/api/internal/store/platform_models.go b/apps/api/internal/store/platform_models.go index 6c5f7d4..9eb70ca 100644 --- a/apps/api/internal/store/platform_models.go +++ b/apps/api/internal/store/platform_models.go @@ -23,6 +23,7 @@ type modelCatalogSnapshot struct { DisplayName string Capabilities map[string]any BaseBillingConfig map[string]any + PricingRuleSetID string DefaultRateLimitPolicy map[string]any RuntimePolicySetID string RuntimePolicyOverride map[string]any @@ -121,10 +122,10 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, if err := validateEnabledVolcesTextModelCapabilities(ctx, q, input, capabilities); err != nil { return PlatformModel{}, err } + // billing_config is a legacy, explicitly supplied compatibility field. Do + // not materialize base-model pricing into it: copied prices become stale as + // soon as the base pricing rule changes and can mask the authoritative rule. billingConfig := input.BillingConfig - if len(billingConfig) == 0 { - billingConfig = mergeObjects(base.BaseBillingConfig, input.BillingConfigOverride) - } explicitRuntimePolicySetID := strings.TrimSpace(input.RuntimePolicySetID) rateLimitPolicy := input.RateLimitPolicy if len(rateLimitPolicy) == 0 && explicitRuntimePolicySetID == "" { @@ -260,6 +261,8 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_ model.ModelType = decodeStringArray(modelTypeBytes) model.BillingConfigOverride = decodeObject(billingOverrideBytes) model.BillingConfig = decodeObject(billingBytes) + model.BaseBillingConfig = base.BaseBillingConfig + model.BasePricingRuleSetID = base.PricingRuleSetID model.PermissionConfig = decodeObject(permissionBytes) model.RetryPolicy = decodeObject(retryPolicyBytes) model.RateLimitPolicy = decodeObject(rateLimitPolicyBytes) @@ -368,7 +371,7 @@ func (s *Store) lookupBaseModel(ctx context.Context, q platformModelQuerier, id var modelTypeBytes []byte err := q.QueryRow(ctx, ` SELECT id::text, provider_key, canonical_model_key, provider_model_name, model_type, display_name, - capabilities, base_billing_config, default_rate_limit_policy, + capabilities, base_billing_config, COALESCE(pricing_rule_set_id::text, ''), default_rate_limit_policy, COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override FROM base_model_catalog WHERE ($1 <> '' AND id = NULLIF($1, '')::uuid) @@ -384,6 +387,7 @@ LIMIT 1`, strings.TrimSpace(id), strings.TrimSpace(canonicalKey), strings.TrimSp &item.DisplayName, &capabilities, &billingConfig, + &item.PricingRuleSetID, &rateLimitPolicy, &item.RuntimePolicySetID, &runtimePolicyOverride, diff --git a/apps/api/internal/store/platform_models_integration_test.go b/apps/api/internal/store/platform_models_integration_test.go new file mode 100644 index 0000000..a59f3d9 --- /dev/null +++ b/apps/api/internal/store/platform_models_integration_test.go @@ -0,0 +1,37 @@ +package store + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestListModelsLoadsEffectiveBillingSources(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 platform-model billing source integration test") + } + + ctx := context.Background() + db, err := Connect(ctx, databaseURL) + if err != nil { + t.Fatalf("connect store: %v", err) + } + defer db.Close() + + models, err := db.ListModels(ctx) + if err != nil { + t.Fatalf("list models with effective billing sources: %v", err) + } + for _, model := range models { + if model.BaseModelID == "" { + continue + } + if model.BaseBillingConfig == nil { + t.Fatalf("platform model %s did not load base billing config", model.ID) + } + return + } + t.Skip("database has no base-model-backed platform model") +} diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index 036793f..4840668 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -217,33 +217,36 @@ type CreatedAPIKey struct { } type PlatformModel struct { - ID string `json:"id"` - PlatformID string `json:"platformId"` - BaseModelID string `json:"baseModelId,omitempty"` - Provider string `json:"provider,omitempty"` - PlatformName string `json:"platformName,omitempty"` - ModelName string `json:"modelName"` - ProviderModelName string `json:"providerModelName,omitempty"` - ModelAlias string `json:"modelAlias,omitempty"` - ModelType StringList `json:"modelType"` - DisplayName string `json:"displayName"` - CapabilityOverride map[string]any `json:"capabilityOverride,omitempty"` - Capabilities map[string]any `json:"capabilities,omitempty"` - BaseCapabilities map[string]any `json:"-"` - PricingMode string `json:"pricingMode"` - DiscountFactor float64 `json:"discountFactor,omitempty"` - PricingRuleSetID string `json:"pricingRuleSetId,omitempty"` - BillingConfigOverride map[string]any `json:"billingConfigOverride,omitempty"` - BillingConfig map[string]any `json:"billingConfig,omitempty"` - PermissionConfig map[string]any `json:"permissionConfig,omitempty"` - RetryPolicy map[string]any `json:"retryPolicy,omitempty"` - RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"` - RuntimePolicySetID string `json:"runtimePolicySetId,omitempty"` - RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride,omitempty"` - CooldownUntil string `json:"cooldownUntil,omitempty"` - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + PlatformID string `json:"platformId"` + BaseModelID string `json:"baseModelId,omitempty"` + Provider string `json:"provider,omitempty"` + PlatformName string `json:"platformName,omitempty"` + ModelName string `json:"modelName"` + ProviderModelName string `json:"providerModelName,omitempty"` + ModelAlias string `json:"modelAlias,omitempty"` + ModelType StringList `json:"modelType"` + DisplayName string `json:"displayName"` + CapabilityOverride map[string]any `json:"capabilityOverride,omitempty"` + Capabilities map[string]any `json:"capabilities,omitempty"` + BaseCapabilities map[string]any `json:"-"` + BaseBillingConfig map[string]any `json:"-"` + BasePricingRuleSetID string `json:"-"` + PlatformPricingRuleSetID string `json:"-"` + PricingMode string `json:"pricingMode"` + DiscountFactor float64 `json:"discountFactor,omitempty"` + PricingRuleSetID string `json:"pricingRuleSetId,omitempty"` + BillingConfigOverride map[string]any `json:"billingConfigOverride,omitempty"` + BillingConfig map[string]any `json:"billingConfig,omitempty"` + PermissionConfig map[string]any `json:"permissionConfig,omitempty"` + RetryPolicy map[string]any `json:"retryPolicy,omitempty"` + RateLimitPolicy map[string]any `json:"rateLimitPolicy,omitempty"` + RuntimePolicySetID string `json:"runtimePolicySetId,omitempty"` + RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride,omitempty"` + CooldownUntil string `json:"cooldownUntil,omitempty"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type AccessRule struct { @@ -927,7 +930,9 @@ func (s *Store) listModels(ctx context.Context, platformID string) ([]PlatformMo rows, err := s.pool.Query(ctx, ` SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name, m.model_name, COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), COALESCE(m.model_alias, ''), m.model_type, m.display_name, - m.capability_override, m.capabilities, COALESCE(b.capabilities, '{}'::jsonb), m.pricing_mode, COALESCE(m.discount_factor, 0)::float8, + m.capability_override, m.capabilities, COALESCE(b.capabilities, '{}'::jsonb), + COALESCE(b.base_billing_config, '{}'::jsonb), COALESCE(b.pricing_rule_set_id::text, ''), + COALESCE(p.pricing_rule_set_id::text, ''), m.pricing_mode, COALESCE(m.discount_factor, 0)::float8, COALESCE(m.pricing_rule_set_id::text, ''), m.billing_config_override, m.billing_config, m.permission_config, m.retry_policy, m.rate_limit_policy, COALESCE(m.runtime_policy_set_id::text, ''), m.runtime_policy_override, COALESCE(to_char(m.cooldown_until AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), ''), @@ -935,7 +940,7 @@ SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.p FROM platform_models m JOIN integration_platforms p ON p.id = m.platform_id LEFT JOIN LATERAL ( - SELECT catalog.capabilities + SELECT catalog.capabilities, catalog.base_billing_config, catalog.pricing_rule_set_id FROM base_model_catalog catalog WHERE (m.base_model_id IS NOT NULL AND catalog.id = m.base_model_id) OR ( @@ -962,6 +967,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...) var capabilityOverride []byte var capabilities []byte var baseCapabilities []byte + var baseBillingConfig []byte var billingConfigOverride []byte var billingConfig []byte var permissionConfig []byte @@ -983,6 +989,9 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...) &capabilityOverride, &capabilities, &baseCapabilities, + &baseBillingConfig, + &model.BasePricingRuleSetID, + &model.PlatformPricingRuleSetID, &model.PricingMode, &model.DiscountFactor, &model.PricingRuleSetID, @@ -1003,6 +1012,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...) model.CapabilityOverride = decodeObject(capabilityOverride) model.Capabilities = decodeObject(capabilities) model.BaseCapabilities = decodeObject(baseCapabilities) + model.BaseBillingConfig = decodeObject(baseBillingConfig) model.ModelType = decodeStringArray(modelTypeBytes) model.BillingConfigOverride = decodeObject(billingConfigOverride) model.BillingConfig = decodeObject(billingConfig) From b04a7d9d3d7cc05c65b37a97aef08ef92f32b3cc Mon Sep 17 00:00:00 2001 From: wangbo Date: Tue, 21 Jul 2026 23:52:58 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat(web):=20=E6=94=AF=E6=8C=81=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E9=A1=B5=E7=9C=9F=E5=AE=9E=E6=8F=90=E4=BA=A4=E8=AF=B7?= =?UTF-8?q?=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/App.tsx | 11 +- apps/web/src/api.test.ts | 43 +++++ apps/web/src/lib/run-task.ts | 175 +++++++++++---------- apps/web/src/pages/ApiDocsPage.test.tsx | 11 ++ apps/web/src/pages/ApiDocsPage.tsx | 201 ++++++++++++++---------- apps/web/src/styles/api-docs.css | 78 +++++++++ apps/web/src/types.ts | 1 + 7 files changed, 348 insertions(+), 172 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0877d36..9740241 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -133,7 +133,7 @@ import { startOIDCLogin, startOIDCLogout, } from './lib/oidc'; -import { runTask } from './lib/run-task'; +import { runTask, type RunTaskOptions } from './lib/run-task'; import { AdminPage } from './pages/AdminPage'; import { ApiDocsPage } from './pages/ApiDocsPage'; import { HomePage } from './pages/HomePage'; @@ -1149,7 +1149,7 @@ export function App() { } } - async function submitTask(event: FormEvent) { + async function submitTask(event: FormEvent, options: RunTaskOptions = {}) { event.preventDefault(); const selectedApiKeySecret = selectedPlaygroundApiKeyId ? apiKeySecretsById[selectedPlaygroundApiKeyId] ?? '' : ''; const fallbackApiKeySecret = apiKeys.find((item) => Boolean(apiKeySecretsById[item.id]))?.id; @@ -1160,11 +1160,12 @@ export function App() { setCoreState('loading'); setCoreMessage(''); try { - const response = await runTask(credential, taskForm); + const response = await runTask(credential, taskForm, options); + const completionMessage = response.submissionMode === 'simulation' ? '完成测试模式运行' : '完成真实提交'; if (response.localOnly) { setTaskResult(response.task); setCoreState('ready'); - setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`); + setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} ${completionMessage}。`); return; } const syncTask = (detail: GatewayTask) => { @@ -1176,7 +1177,7 @@ export function App() { setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]); invalidateDataKeys('tasks', 'wallet', 'walletTransactions'); setCoreState('ready'); - setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} 完成 simulation。`); + setCoreMessage(`${taskForm.kind} 已通过 ${credentialLabel} ${completionMessage}。`); } catch (err) { setCoreState('error'); setCoreMessage(err instanceof Error ? err.message : '测试任务失败'); diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index 0aa6b2e..8e4c220 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -16,6 +16,7 @@ import { retireIdentityPairingSecurityEventConflict, validateIdentityRevision, } from './api'; +import { applyTaskSubmissionMode, runTask } from './lib/run-task'; describe('local login transport', () => { afterEach(() => { @@ -316,4 +317,46 @@ describe('API documentation runner transports', () => { expect(url).toContain('/api/v1/tasks/task-123'); expect(init.method).toBe('GET'); }); + + it('removes every simulation switch from a real submission while preserving edited parameters', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'chatcmpl-real' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + + await runTask( + 'sk-test', + { kind: 'chat.completions', model: 'gpt-fallback', prompt: 'fallback' }, + { + submissionMode: 'production', + requestBody: { + model: 'gpt-real', + messages: [{ role: 'user', content: 'edited body' }], + temperature: 0.25, + runMode: 'simulation', + run_mode: 'simulation', + simulation: true, + testMode: true, + test_mode: true, + }, + }, + ); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ + model: 'gpt-real', + messages: [{ role: 'user', content: 'edited body' }], + temperature: 0.25, + }); + }); + + it('uses canonical simulation parameters without removing a model-specific mode field', () => { + expect(applyTaskSubmissionMode({ model: 'video-model', mode: 'pro', testMode: false }, 'simulation')).toEqual({ + model: 'video-model', + mode: 'pro', + runMode: 'simulation', + simulation: true, + }); + }); }); diff --git a/apps/web/src/lib/run-task.ts b/apps/web/src/lib/run-task.ts index a5fd6ea..6efa599 100644 --- a/apps/web/src/lib/run-task.ts +++ b/apps/web/src/lib/run-task.ts @@ -9,101 +9,97 @@ import { createVideoGenerationTask, getAPITask, } from '../api'; -import type { TaskForm } from '../types'; +import type { TaskForm, TaskSubmissionMode } from '../types'; export interface RunTaskResponse { localOnly?: boolean; next?: Record; + submissionMode: TaskSubmissionMode; task: GatewayTask; } -export async function runTask(token: string, task: TaskForm): Promise { +export interface RunTaskOptions { + requestBody?: Record; + submissionMode?: TaskSubmissionMode; +} + +const simulationParameterKeys = ['runMode', 'run_mode', 'simulation', 'testMode', 'test_mode'] as const; + +export function applyTaskSubmissionMode( + input: Record, + submissionMode: TaskSubmissionMode, +): Record { + const body = { ...input }; + for (const key of simulationParameterKeys) delete body[key]; + if (submissionMode === 'simulation') { + body.runMode = 'simulation'; + body.simulation = true; + } + return body; +} + +export async function runTask(token: string, task: TaskForm, options: RunTaskOptions = {}): Promise { + const submissionMode = options.submissionMode ?? 'simulation'; + const requestBody = task.kind === 'tasks.retrieve' + ? { taskId: task.taskId } + : applyTaskSubmissionMode(options.requestBody ?? defaultRequestBody(task), submissionMode); + if (task.kind === 'chat.completions') { - const result = await createCompatibleChatCompletion(token, { - model: task.model, - runMode: 'simulation', - simulation: true, - stream: false, - messages: [{ role: 'user', content: task.prompt }], - }); - return { localOnly: true, task: compatibleTask(task, result) }; + const result = await createCompatibleChatCompletion( + token, + requestBody as Parameters[1], + ); + return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) }; } if (task.kind === 'responses') { - const result = await createResponse(token, { - model: task.model, - input: task.prompt, - instructions: task.instructions, - previous_response_id: task.previousResponseId, - runMode: 'simulation', - simulation: true, - store: true, - stream: false, - }); - return { localOnly: true, task: compatibleTask(task, result) }; + const result = await createResponse(token, requestBody as Parameters[1]); + return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) }; } if (task.kind === 'embeddings') { - const result = await createEmbedding(token, { - model: task.model, - input: embeddingInput(task.prompt), - dimensions: task.dimensions, - runMode: 'simulation', - simulation: true, - }); - return { localOnly: true, task: compatibleTask(task, result) }; + const result = await createEmbedding(token, requestBody as Parameters[1]); + return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) }; } if (task.kind === 'reranks') { - const result = await createRerank(token, { - model: task.model, - query: task.prompt, - documents: rerankDocuments(task.documents), - top_n: task.topN, - runMode: 'simulation', - simulation: true, - }); - return { localOnly: true, task: compatibleTask(task, result) }; + const result = await createRerank(token, requestBody as Parameters[1]); + return { localOnly: true, submissionMode, task: compatibleTask(task, result, requestBody, submissionMode) }; } if (task.kind === 'images.generations') { - return createImageGenerationTask(token, { - model: task.model, - prompt: task.prompt, - quality: 'medium', - runMode: 'simulation', - simulation: true, - size: '1024x1024', - }); + const response = await createImageGenerationTask( + token, + requestBody as Parameters[1], + ); + return { ...response, submissionMode }; } if (task.kind === 'images.edits') { - return createImageEditTask(token, { - model: task.model, - prompt: task.prompt, - image: task.image, - mask: task.mask, - runMode: 'simulation', - simulation: true, - }); + const response = await createImageEditTask(token, requestBody as Parameters[1]); + return { ...response, submissionMode }; } if (task.kind === 'videos.generations') { - return createVideoGenerationTask(token, { - 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, - }); + const response = await createVideoGenerationTask( + token, + requestBody as unknown as Parameters[1], + ); + return { ...response, submissionMode }; } if (task.kind === 'tasks.retrieve') { const taskId = task.taskId?.trim(); if (!taskId) throw new Error('请输入要取回的 Task ID'); const result = await getAPITask(token, taskId); - return { localOnly: true, task: compatibleTask(task, result as unknown as Record) }; + return { + localOnly: true, + submissionMode: 'production', + task: compatibleTask(task, result as unknown as Record, requestBody, 'production'), + }; } throw new Error(`Unsupported task kind: ${task.kind}`); } -function compatibleTask(task: TaskForm, result: Record): GatewayTask { +function compatibleTask( + task: TaskForm, + result: Record, + requestBody: Record, + submissionMode: TaskSubmissionMode, +): GatewayTask { const now = new Date().toISOString(); return { id: `docs-${task.kind}-${Date.now()}`, @@ -111,26 +107,31 @@ function compatibleTask(task: TaskForm, result: Record): Gatewa createdAt: now, finishedAt: now, kind: task.kind, - model: task.model, + model: typeof requestBody.model === 'string' ? requestBody.model : task.model, modelType: modelTypeForKind(task.kind), - request: requestSnapshot(task), + request: requestBody, result, - runMode: 'simulation', + runMode: submissionMode, status: 'succeeded', updatedAt: now, userId: 'docs-runner', }; } -function requestSnapshot(task: TaskForm): Record { +function defaultRequestBody(task: TaskForm): Record { + if (task.kind === 'chat.completions') { + return { + model: task.model, + messages: [{ role: 'user', content: task.prompt }], + stream: false, + }; + } 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, }; @@ -140,8 +141,6 @@ function requestSnapshot(task: TaskForm): Record { model: task.model, input: embeddingInput(task.prompt), dimensions: task.dimensions, - runMode: 'simulation', - simulation: true, }; } if (task.kind === 'reranks') { @@ -150,8 +149,22 @@ function requestSnapshot(task: TaskForm): Record { query: task.prompt, documents: rerankDocuments(task.documents), top_n: task.topN, - runMode: 'simulation', - simulation: true, + }; + } + if (task.kind === 'images.generations') { + return { + model: task.model, + prompt: task.prompt, + quality: 'medium', + size: '1024x1024', + }; + } + if (task.kind === 'images.edits') { + return { + model: task.model, + prompt: task.prompt, + image: task.image, + mask: task.mask, }; } if (task.kind === 'videos.generations') { @@ -162,18 +175,10 @@ function requestSnapshot(task: TaskForm): Record { 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 }], - runMode: 'simulation', - simulation: true, - stream: false, - }; + return { model: task.model }; } function embeddingInput(prompt: string) { diff --git a/apps/web/src/pages/ApiDocsPage.test.tsx b/apps/web/src/pages/ApiDocsPage.test.tsx index f3550ce..521fc4f 100644 --- a/apps/web/src/pages/ApiDocsPage.test.tsx +++ b/apps/web/src/pages/ApiDocsPage.test.tsx @@ -36,6 +36,17 @@ describe('ApiDocsPage extended task documentation', () => { expect(html).toContain('任务取回接口'); }); + it('defaults the online runner to test mode and offers an explicit real submission mode', () => { + const html = renderDocs('imageEdit', { kind: 'images.edits', model: 'gpt-image-1', prompt: '移除背景' }); + + expect(html).toContain('运行模式'); + expect(html).toContain('测试模式'); + expect(html).toContain('真实提交'); + expect(html).toContain('aria-pressed="true"'); + expect(html).toContain('"runMode": "simulation"'); + expect(html).toContain('"simulation": true'); + }); + it('documents async mode as a body-independent capability', () => { const html = renderDocs('asyncMode', { kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '你好' }); diff --git a/apps/web/src/pages/ApiDocsPage.tsx b/apps/web/src/pages/ApiDocsPage.tsx index 9cb76bd..72f0aa0 100644 --- a/apps/web/src/pages/ApiDocsPage.tsx +++ b/apps/web/src/pages/ApiDocsPage.tsx @@ -1,9 +1,10 @@ -import { Fragment, useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react'; +import { Fragment, useEffect, 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, Input, Select, Textarea } from '../components/ui'; import { getOpsManagementSkillMetadata, resolveApiAssetUrl } from '../api'; -import type { ApiDocSection, LoadState, TaskForm, TaskKind } from '../types'; +import { applyTaskSubmissionMode, type RunTaskOptions } from '../lib/run-task'; +import type { ApiDocSection, LoadState, TaskForm, TaskKind, TaskSubmissionMode } from '../types'; import { ApiKeySelect, apiKeyNoticeText, resolveSelectedApiKeyId } from './playground-shared'; interface ApiDocItem { @@ -90,7 +91,7 @@ export function ApiDocsPage(props: { onCreateApiKey: () => void; onLogin: () => void; onDocSectionChange: (value: ApiDocSection) => void; - onSubmitTask: (event: FormEvent) => void; + onSubmitTask: (event: FormEvent, options?: RunTaskOptions) => void; onTaskFormChange: (value: TaskForm) => void; }) { const activeGuide = guideItems.find((item) => item.key === props.activeDocSection); @@ -101,12 +102,12 @@ export function ApiDocsPage(props: { const isTaskRetrieveDoc = currentApiDoc?.key === 'taskRetrieve'; const isAsyncModeDoc = currentApiDoc?.key === 'asyncMode'; const runnerAvailable = Boolean(currentApiDoc?.kind && currentApiDoc.method && currentApiDoc.path); + const runnerModeEnabled = Boolean(runnerAvailable && currentApiDoc?.method !== 'GET'); const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById); const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId); - const bodyExample = useMemo( - () => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat'), - [currentApiDoc?.key, props.taskForm], - ); + const [submissionMode, setSubmissionMode] = useState('simulation'); + const [bodyDraft, setBodyDraft] = useState(() => requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation')); + const [bodyError, setBodyError] = useState(''); const runnerPath = currentApiDoc?.path ? isTaskRetrieveDoc ? currentApiDoc.path.replace('{taskID}', props.taskForm.taskId?.trim() || '{taskID}') @@ -119,6 +120,12 @@ export function ApiDocsPage(props: { } }, [currentApiDoc?.kind, props.taskForm.kind, props.taskResult?.id]); + useEffect(() => { + setSubmissionMode('simulation'); + setBodyDraft(requestBodyExample(props.taskForm, currentApiDoc?.key ?? 'chat', 'simulation')); + setBodyError(''); + }, [currentApiDoc?.key, props.taskForm]); + useEffect(() => { let active = true; getOpsManagementSkillMetadata() @@ -134,18 +141,55 @@ export function ApiDocsPage(props: { }, []); function handleSubmit(event: FormEvent) { + event.preventDefault(); if (!runnerAvailable) { - event.preventDefault(); return; } if (!props.canRun) { - event.preventDefault(); props.onLogin(); return; } + if (runnerModeEnabled) { + const parsed = parseEditableRequestBody(bodyDraft); + if (parsed.error || !parsed.body) { + setBodyError(parsed.error); + return; + } + const requestBody = applyTaskSubmissionMode(parsed.body, submissionMode); + setBodyDraft(JSON.stringify(requestBody, null, 2)); + setBodyError(''); + props.onSubmitTask(event, { requestBody, submissionMode }); + return; + } props.onSubmitTask(event); } + function handleBodyChange(value: string) { + setBodyDraft(value); + setBodyError(parseEditableRequestBody(value).error); + } + + function handleBodyBlur() { + const parsed = parseEditableRequestBody(bodyDraft); + if (parsed.error || !parsed.body) { + setBodyError(parsed.error); + return; + } + setBodyDraft(JSON.stringify(applyTaskSubmissionMode(parsed.body, submissionMode), null, 2)); + setBodyError(''); + } + + function handleSubmissionModeChange(nextMode: TaskSubmissionMode) { + const parsed = parseEditableRequestBody(bodyDraft); + if (parsed.error || !parsed.body) { + setBodyError('请先修正请求 Body 的 JSON 格式,再切换运行模式。'); + return; + } + setSubmissionMode(nextMode); + setBodyDraft(JSON.stringify(applyTaskSubmissionMode(parsed.body, nextMode), null, 2)); + setBodyError(''); + } + function handleDocClick(item: ApiDocItem) { if (item.kind) { props.onTaskFormChange(defaultTaskForDoc(item.kind, props.taskForm, props.taskResult)); @@ -257,7 +301,7 @@ export function ApiDocsPage(props: { {currentApiDoc?.method && currentApiDoc.path && !isAsyncModeDoc ?
在线运行 - @@ -286,6 +330,34 @@ export function ApiDocsPage(props: { )} {runnerAvailable ? ( <> + {runnerModeEnabled && ( +
+ 运行模式 +
+ + +
+

+ {submissionMode === 'simulation' + ? '不触达真实供应商,不消耗上游额度。' + : '请求将真实提交给供应商,可能产生费用。'} +

+
+ )}