Merge branch 'pat' into deploy/enterprise

This commit is contained in:
fatelei committed 2026-11-10 17:45:10 +08:00
commit 50cf8fbec5
12 files changed
+141 -42

No files matched your search

-2
View File
@@ -10,7 +10,6 @@ from typing import Final, Literal
from flask_restx import Resource
from constants.personal_access_token import PersonalAccessTokenPolicy
from controllers.common.rbac import RBACPermission
from controllers.openapi import openapi_ns
from controllers.openapi._contract import Example, Kind, endpoint
@@ -42,7 +41,6 @@ class WorkflowAppCreateApi(Resource):
kind=Kind.OBJECT,
summary="Create a blank workflow app; export its DSL to start building",
examples=(Example(title="Create a workflow app", input={"name": "Release notes"}),),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=_CREATE_GUARDS,
body=CreateAppPayload,
returns=(HTTPStatus.CREATED, CreatedAppResponse, "Created"),
-5
View File
@@ -8,7 +8,6 @@ from flask_restx import Resource
from werkzeug.exceptions import BadRequest, NotFound
from constants.oauth_bearer import Scope
from constants.personal_access_token import PersonalAccessTokenPolicy
from controllers.common.errors import ForbiddenError, NotFoundError
from controllers.common.rbac import RBACPermission
from controllers.openapi import openapi_ns
@@ -114,7 +113,6 @@ class AppDslImportApi(Resource):
@endpoint(
context="workspace",
op="import.console_app.dsl",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
effect=Effect.DESTRUCTIVE,
summary="Import an app from DSL text or URL",
@@ -215,7 +213,6 @@ class AppDslImportConfirmApi(Resource):
@endpoint(
context="workspace",
op="confirm.console_app.dsl_import",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
effect=Effect.DESTRUCTIVE,
summary="Confirm a pending DSL import",
@@ -252,7 +249,6 @@ class AppDslExportApi(Resource):
@endpoint(
context="app",
op="export.console_app.dsl",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Export app DSL as YAML text inside a JSON object",
examples=(
@@ -295,7 +291,6 @@ class AppDslCheckDependenciesApi(Resource):
@endpoint(
context="workspace",
op="check.console_app.dependency",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Check plugin dependencies of an app",
examples=(Example(title="Check which plugins an app needs", input={"app_id": "<app_id>"}),),
-3
View File
@@ -8,7 +8,6 @@ from typing import Final
from flask_restx import Resource
from constants.oauth_bearer import Scope
from constants.personal_access_token import PersonalAccessTokenPolicy
from controllers.common.rbac import AgentBehindApp, PlainApp, RBACCheck, RBACPermission
from controllers.openapi import _app_settings as settings
from controllers.openapi import openapi_ns
@@ -49,7 +48,6 @@ class WorkflowAppInfoApi(Resource):
kind=Kind.OBJECT,
summary="Name, description, icon and run cap of a workflow app",
examples=(_INFO_EXAMPLE,),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=account_settings_guards(_INFO_READ, scope=Scope.APPS_READ, modes=(AppMode.WORKFLOW,), roles=None),
returns=(HTTPStatus.OK, AppSettingsInfo, "App info"),
)
@@ -58,7 +56,6 @@ class WorkflowAppInfoApi(Resource):
@endpoint(
op="set.app_info.workflow",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Change a workflow app's info; only the fields passed change",
examples=(_RENAME_EXAMPLE,),
-2
View File
@@ -4,7 +4,6 @@ from http import HTTPStatus
from flask_restx import Resource
from constants.personal_access_token import PersonalAccessTokenPolicy
from controllers.openapi import openapi_ns
from controllers.openapi._contract import Example, Kind, endpoint, op_of
from controllers.openapi._models import (
@@ -54,7 +53,6 @@ class ReleaseCheckApi(Resource):
kind=Kind.OBJECT,
summary="Is the draft ready to publish: draft valid, last test passed on this draft, and what changed",
examples=(Example(title="Before publishing", input={"app_id": "<app_id>"}),),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=VERSION_READ_GUARDS,
returns=(HTTPStatus.OK, ReleaseCheckResponse, "Release check"),
)
-6
View File
@@ -12,7 +12,6 @@ from werkzeug.exceptions import BadRequest
from constants import HIDDEN_VALUE
from constants.oauth_bearer import Scope
from constants.personal_access_token import PersonalAccessTokenPolicy
from controllers.common.fields import SimpleResultResponse
from controllers.common.rbac import RBACPermission
from controllers.openapi import openapi_ns
@@ -114,7 +113,6 @@ class AppRunListApi(Resource):
input={"app_id": "<app_id>", "triggered_from": "debugging", "limit": 1},
),
),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=_RUN_READ_GUARDS,
query=RunListQuery,
returns=(HTTPStatus.OK, RunListResponse, "Run list"),
@@ -148,7 +146,6 @@ class AppRunDescribeApi(Resource):
kind=Kind.OBJECT,
summary="One run: inputs, outputs, error, tokens and timing",
examples=(Example(title="Read one run", input={"app_id": "<app_id>", "run_id": "<run_id>"}),),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=_RUN_READ_GUARDS,
returns=(HTTPStatus.OK, WorkflowRunDetailResponse, "Run detail"),
)
@@ -164,7 +161,6 @@ class AppRunNodeListApi(Resource):
kind=Kind.OBJECT,
summary="Each node step of a run, to find the one that failed",
examples=(Example(title="Node steps of one run", input={"app_id": "<app_id>", "run_id": "<run_id>"}),),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=_RUN_READ_GUARDS,
returns=(HTTPStatus.OK, WorkflowRunNodeExecutionListResponse, "Node steps"),
)
@@ -226,7 +222,6 @@ class AppVersionListApi(Resource):
Example(title="Latest versions", input={"app_id": "<app_id>"}),
Example(title="Only named versions", input={"app_id": "<app_id>", "named_only": True}),
),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=VERSION_READ_GUARDS,
query=VersionListQuery,
returns=(HTTPStatus.OK, VersionListResponse, "Version list"),
@@ -330,7 +325,6 @@ class AppEnvListApi(Resource):
summary="Environment variables of the draft. Each has an id, which set and delete take, "
"and a name, which nodes use. Secrets with a value are masked; empty secrets read as empty",
examples=(Example(title="List environment variables", input={"app_id": "<app_id>"}),),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=VERSION_READ_GUARDS,
returns=(HTTPStatus.OK, EnvVariableListResponse, "Environment variables"),
)
@@ -8,7 +8,6 @@ from typing import Any
from flask_restx import Resource
from constants.personal_access_token import PersonalAccessTokenPolicy
from controllers.openapi import openapi_ns
from controllers.openapi._contract import Example, Kind, endpoint
from controllers.openapi._models import (
@@ -43,7 +42,6 @@ class KnowledgeBasesApi(Resource):
kind=Kind.LIST,
summary="Knowledge bases you can use, each with the knowledge-retrieval node data to paste",
examples=(Example(title="Find product docs", input={"query": "product"}),),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=WORKSPACE_READ_GUARDS,
query=KnowledgeBaseListQuery,
returns=(HTTPStatus.OK, KnowledgeBaseListResponse, "Knowledge bases"),
+6 -1
View File
@@ -200,6 +200,7 @@ def model_rows(models: Iterable[ModelWithProviderEntity], *, words: str, languag
class ModelProviderApi(Resource):
@endpoint(
op="describe.model_provider",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="One model provider with its credential form and saved credentials (names only)",
examples=(Example(title="OpenAI", input={"provider": _PROVIDER_EXAMPLE}),),
@@ -264,6 +265,7 @@ class ModelProviderApi(Resource):
class ModelProviderCredentialsApi(Resource):
@endpoint(
op="create.model_provider.credential",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Save a credential for a model provider; the provider checks it first",
examples=(
@@ -289,6 +291,7 @@ class ModelProviderCredentialsApi(Resource):
class ModelProviderCredentialApi(Resource):
@endpoint(
op="set.model_provider.credential",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Replace a saved model provider credential",
examples=(
@@ -329,13 +332,13 @@ class ModelProviderCredentialApi(Resource):
class ModelsApi(Resource):
@endpoint(
op="get.model",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.LIST,
summary="Models in the workspace across providers, with whether each can be used now",
examples=(
Example(title="LLMs", input={"model_type": "llm"}),
Example(title="OpenAI models", input={"provider": _PROVIDER_EXAMPLE}),
),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=WORKSPACE_READ_GUARDS,
query=ModelListQuery,
returns=(HTTPStatus.OK, ModelListResponse, "Models"),
@@ -362,6 +365,7 @@ class ModelsApi(Resource):
class ModelCredentialsApi(Resource):
@endpoint(
op="create.model.credential",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Save a credential for one model you name, for providers such as OpenAI-compatible or Ollama; "
"the first credential of a model not yet set up becomes its active credential",
@@ -398,6 +402,7 @@ class ModelCredentialsApi(Resource):
class ModelCredentialApi(Resource):
@endpoint(
op="set.model.credential",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Replace a saved credential of one model",
examples=(
+5 -1
View File
@@ -149,6 +149,7 @@ def _provider_hints(workspace_id: str, plugin_ids: Sequence[str]) -> list[Hint]:
class MarketplacePluginsApi(Resource):
@endpoint(
op="get.marketplace.plugin",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.LIST,
summary="Search the marketplace for plugins to install; each hit says if it is installed here",
examples=(
@@ -194,10 +195,10 @@ class MarketplacePluginsApi(Resource):
class PluginsApi(Resource):
@endpoint(
op="get.plugin",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Plugins installed in the workspace, with the provider ids each one adds",
examples=(Example(title="Installed model plugins", input={"category": "model"}),),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=WORKSPACE_READ_GUARDS,
query=PluginListQuery,
returns=(HTTPStatus.OK, PluginListResponse, "Installed plugins"),
@@ -232,6 +233,7 @@ class PluginsApi(Resource):
class PluginInstallApi(Resource):
@endpoint(
op="install.plugin",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Start installing marketplace plugins; finishes in the background, check describe.plugin.task",
examples=(Example(title="Install one plugin", input={"identifiers": ["langgenius/openai:0.2.1@<sha256>"]}),),
@@ -253,6 +255,7 @@ class PluginInstallApi(Resource):
class PluginTaskApi(Resource):
@endpoint(
op="describe.plugin.task",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Progress of a plugin install or upgrade; check again while pending or running",
examples=(Example(title="Check an install", input={"task_id": "<task_id>"}),),
@@ -288,6 +291,7 @@ class PluginTaskApi(Resource):
class PluginApi(Resource):
@endpoint(
op="delete.plugin",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Uninstall a plugin from the workspace",
examples=(Example(title="Uninstall OpenAI", input={"plugin_id": "langgenius/openai"}),),
+4 -1
View File
@@ -192,13 +192,13 @@ def tool_rows(
class ToolsApi(Resource):
@endpoint(
op="get.tool",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.LIST,
summary="Tools a workflow can call here, each with a ready tool node data",
examples=(
Example(title="Find GitHub tools", input={"query": "github"}),
Example(title="Workflows published as tools", input={"provider_type": "workflow"}),
),
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
requirements=WORKSPACE_READ_GUARDS,
query=ToolListQuery,
returns=(HTTPStatus.OK, ToolListResponse, "Tools"),
@@ -224,6 +224,7 @@ class ToolsApi(Resource):
class ToolProviderApi(Resource):
@endpoint(
op="describe.tool_provider",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="One tool provider with its API-key form and saved credentials (names only)",
examples=(Example(title="Tavily", input={"provider": _PROVIDER_EXAMPLE}),),
@@ -276,6 +277,7 @@ class ToolProviderApi(Resource):
class ToolProviderCredentialsApi(Resource):
@endpoint(
op="create.tool_provider.credential",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Save an API-key credential for a tool provider; the provider checks it first",
examples=(
@@ -307,6 +309,7 @@ class ToolProviderCredentialsApi(Resource):
class ToolProviderCredentialApi(Resource):
@endpoint(
op="set.tool_provider.credential",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Replace a saved tool provider credential",
examples=(
-6
View File
@@ -101,7 +101,6 @@ class WorkspacesApi(Resource):
class WorkspaceByIdApi(Resource):
@endpoint(
op="describe.workspace",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Workspace detail",
examples=(
@@ -130,7 +129,6 @@ class WorkspaceSwitchApi(Resource):
@endpoint(
op="switch.workspace",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Server-side current workspace switch (shared with the web console)",
examples=(Example(title="Make a workspace current on the server", input={"workspace_id": "<workspace_id>"}),),
@@ -162,7 +160,6 @@ class WorkspaceMembersApi(Resource):
@endpoint(
op="get.workspace.member",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.LIST,
summary="List workspace members",
examples=(Example(title="List members of the pinned workspace, first page", input={"page": 1, "limit": 20}),),
@@ -187,7 +184,6 @@ class WorkspaceMembersApi(Resource):
@endpoint(
op="invite.workspace.member",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Invite a member by email",
examples=(
@@ -239,7 +235,6 @@ class WorkspaceMemberApi(Resource):
@endpoint(
op="delete.workspace.member",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Remove a member",
examples=(Example(title="Remove a member by account id", input={"member_id": "<member_id>"}),),
@@ -269,7 +264,6 @@ class WorkspaceMemberApi(Resource):
@endpoint(
op="set.workspace.member_role",
pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS,
kind=Kind.OBJECT,
summary="Change a member's role",
examples=(
@@ -23,7 +23,7 @@ from extensions.ext_redis import RedisClientWrapper
from libs import rate_limit
from libs.passport import PassportService
from libs.token import generate_csrf_token
from models import App
from models import App, AppMode
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
from tests.helpers.unit.config_override import apply_config_overrides
from tests.helpers.unit.model_factories import make_account, make_app, make_tenant
@@ -536,7 +536,98 @@ def test_app_scope_filters_discovery_and_workspace_scope_cannot_bypass_it(
assert listed.get_json()["total"] == 1
def test_selected_workspace_permits_member_reads_only_in_that_workspace(
@pytest.mark.parametrize(
("method", "path"),
[
("GET", "/workspaces/{id}"),
("POST", "/workspaces/{id}:switch"),
("GET", "/workspaces/{id}/members"),
("POST", "/workspaces/{id}/members"),
("PATCH", "/workspaces/{id}/members/{id}"),
("DELETE", "/workspaces/{id}/members/{id}"),
("POST", "/workspaces/{id}/apps/workflow"),
("POST", "/workspaces/{id}/apps/imports"),
("GET", "/workspaces/{id}/knowledge-bases"),
("PATCH", "/apps/{id}/app-info/workflow"),
("POST", "/apps/{id}:publish"),
("GET", "/apps/{id}/dsl"),
("GET", "/apps/{id}/versions"),
],
)
def test_pat_rejects_management_outside_phase_one(
client: FlaskClient, sqlite_session_factory: sessionmaker[Session], method: str, path: str
) -> None:
account = client.application.config["TEST_BROWSER_ACCOUNT"]
target_id = str(uuid4())
workspace = make_tenant(tenant_id=target_id)
app = make_app(app_id=target_id, tenant_id=target_id, mode=AppMode.WORKFLOW)
with sqlite_session_factory.begin() as session:
session.add_all(
[
workspace,
app,
TenantAccountJoin(
current=True, tenant_id=workspace.id, account_id=account.id, role=TenantAccountRole.OWNER
),
]
)
token = client.post(BASE, json={"name": "all scopes"}).get_json()
headers = {
"Authorization": f"Bearer {token['secret']}",
CATALOG_HEADER: client.get("/openapi/v1/_catalog").headers[CATALOG_HEADER],
}
response = client.open("/openapi/v1" + path.format(id=target_id), method=method, json={}, headers=headers)
assert response.status_code == 403, response.get_json()
assert response.get_json()["message"] == "personal_access_token_not_supported"
@pytest.mark.parametrize("rbac_enabled", [True], indirect=True)
@pytest.mark.parametrize("allowed", [True, False])
@pytest.mark.parametrize(
("method", "suffix"),
[
("POST", "model-providers/openai/credentials"),
("PATCH", "model-providers/openai/credentials/credential"),
("POST", "model-providers/openai/models/credentials"),
("PATCH", "model-providers/openai/models/credentials/credential"),
("POST", "tool-providers/custom/credentials"),
("PATCH", "tool-providers/custom/credentials/credential"),
],
)
def test_pat_credential_management_still_requires_rbac(
client: FlaskClient,
sqlite_session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
method: str,
suffix: str,
allowed: bool,
) -> None:
from tests.helpers.unit.rbac_fakes import build_rbac_domain
domain = build_rbac_domain(sqlite_session_factory, monkeypatch)
domain.transport.response = {"allowed": allowed}
account = client.application.config["TEST_BROWSER_ACCOUNT"]
workspace = make_tenant(tenant_id=str(uuid4()))
with sqlite_session_factory.begin() as session:
session.add_all(
[
workspace,
TenantAccountJoin(
current=True, tenant_id=workspace.id, account_id=account.id, role=TenantAccountRole.NORMAL
),
]
)
token = client.post(BASE, json={"name": "credentials", "resource_scope": {"mode": "none"}}).get_json()
headers = {
"Authorization": f"Bearer {token['secret']}",
CATALOG_HEADER: client.get("/openapi/v1/_catalog").headers[CATALOG_HEADER],
}
response = client.open(f"/openapi/v1/workspaces/{workspace.id}/{suffix}", method=method, json={}, headers=headers)
assert response.status_code == (422 if allowed else 403), response.get_json()
assert [request.endpoint for request in domain.transport.requests] == ["/rbac/check-access"]
def test_selected_workspace_permits_model_requests_only_in_that_workspace(
client: FlaskClient,
sqlite_session_factory: sessionmaker[Session],
) -> None:
@@ -571,16 +662,14 @@ def test_selected_workspace_permits_member_reads_only_in_that_workspace(
"Authorization": f"Bearer {secret}",
CATALOG_HEADER: client.get("/openapi/v1/_catalog").headers[CATALOG_HEADER],
}
members = client.get(f"/openapi/v1/workspaces/{first.id}/members", headers=headers)
assert members.status_code == 200, members.get_json()
assert client.get(f"/openapi/v1/workspaces/{second.id}/members", headers=headers).status_code == 403
models = client.get(f"/openapi/v1/workspaces/{first.id}/models?limit=0", headers=headers)
assert models.status_code == 422, models.get_json()
assert client.get(f"/openapi/v1/workspaces/{second.id}/models?limit=0", headers=headers).status_code == 403
listed = client.get("/openapi/v1/workspaces", headers=headers)
assert [row["id"] for row in listed.get_json()["data"]] == [first.id]
@pytest.mark.parametrize(
"operation", ["workflow:run", "chat:run", "advanced-chat:run", "completion:run", "files", "dsl-import"]
)
@pytest.mark.parametrize("operation", ["workflow:run", "chat:run", "advanced-chat:run", "completion:run", "files"])
def test_authorized_pat_reaches_existing_business_request_validation(
client: FlaskClient,
sqlite_session_factory: sessionmaker[Session],
@@ -608,11 +697,7 @@ def test_authorized_pat_reaches_existing_business_request_validation(
"Authorization": f"Bearer {secret}",
CATALOG_HEADER: client.get("/openapi/v1/_catalog").headers[CATALOG_HEADER],
}
url = (
f"/openapi/v1/workspaces/{workspace.id}/apps/imports"
if operation == "dsl-import"
else f"/openapi/v1/apps/{app.id}/{operation}"
)
url = f"/openapi/v1/apps/{app.id}/{operation}"
# Invalid input must be rejected by the existing contract after PAT admission,
# without executing workflows, invoking models or performing an upload/import.
response = client.post(url, json={}, headers=headers)
@@ -2515,6 +2515,34 @@ class TestAuthMatrixCases:
if expected.message is not None:
assert body.get("message") == expected.message
@staticmethod
def test_pat_workspace_management_is_limited_to_phase_one_routes(matrix_app: Flask) -> None:
from constants.personal_access_token import PersonalAccessTokenPolicy
supported = {
route.id
for route in ROUTES
if (spec := _spec_for(matrix_app, route)) is not None
and spec.pat_policy is PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS
}
assert supported == {
"plugins.marketplace",
"plugins.list",
"plugins.install",
"plugins.task",
"plugins.delete",
"model_providers.describe",
"model_providers.credential.create",
"model_providers.credential.set",
"models.list",
"models.credential.create",
"models.credential.set",
"tools.list",
"tool_providers.describe",
"tool_providers.credential.create",
"tool_providers.credential.set",
}
@staticmethod
def test_registered_openapi_routes_match_the_matrix(matrix_app: Flask) -> None:
"""Every guarded /openapi/v1 route is in the table, and nothing else is.