feat(agent): add roster service api access (#37759)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-06-22 20:37:27 +08:00 committed by GitHub
parent e3d0320826
commit 7d2f25df8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 878 additions and 76 deletions

View File

@ -3,10 +3,12 @@ from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import func, select
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
from controllers.console.app.app import (
AppDetailWithSite as GenericAppDetailWithSite,
)
@ -25,9 +27,13 @@ from controllers.console.app.app import (
UpdateAppPayload as GenericUpdateAppPayload,
)
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
enterprise_license_required,
is_admin_or_owner_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
@ -49,7 +55,8 @@ from libs.datetime_utils import parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.model import IconType
from models.enums import ApiTokenType
from models.model import ApiToken, App, IconType
from services.agent.errors import AgentNotFoundError
from services.agent.observability_service import (
AgentLogQueryParams,
@ -103,6 +110,27 @@ class AgentAppUpdatePayload(GenericUpdateAppPayload):
return role
class AgentApiStatusPayload(BaseModel):
enable_api: bool = Field(..., description="Enable or disable Agent service API")
class AgentApiAccessResponse(BaseModel):
enabled: bool
service_api_base_url: str
streaming_only: bool = True
chat_endpoint: str
stop_endpoint: str
conversations_endpoint: str
messages_endpoint: str
files_upload_endpoint: str
parameters_endpoint: str
info_endpoint: str
meta_endpoint: str
api_rpm: int
api_rph: int
api_key_count: int
class AgentAppPublishedReferenceResponse(BaseModel):
app_id: str
app_name: str
@ -210,6 +238,7 @@ register_schema_models(
console_ns,
AgentAppCreatePayload,
AgentAppUpdatePayload,
AgentApiStatusPayload,
CopyAppPayload,
AgentInviteOptionsQuery,
AgentLogsQuery,
@ -221,6 +250,7 @@ register_schema_models(
register_response_schema_models(
console_ns,
AgentAppPagination,
AgentApiAccessResponse,
AgentAppPublishedReferenceResponse,
AgentAppDetailWithSite,
AgentAppPartial,
@ -329,6 +359,38 @@ def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def _agent_api_key_count(app_id: str) -> int:
return (
db.session.scalar(
select(func.count(ApiToken.id)).where(
ApiToken.type == ApiTokenType.APP,
ApiToken.app_id == app_id,
)
)
or 0
)
def _serialize_agent_api_access(app_model: App) -> dict:
base_url = app_model.api_base_url
response = AgentApiAccessResponse(
enabled=bool(app_model.enable_api),
service_api_base_url=base_url,
chat_endpoint=f"{base_url}/chat-messages",
stop_endpoint=f"{base_url}/chat-messages/{{task_id}}/stop",
conversations_endpoint=f"{base_url}/conversations",
messages_endpoint=f"{base_url}/messages",
files_upload_endpoint=f"{base_url}/files/upload",
parameters_endpoint=f"{base_url}/parameters",
info_endpoint=f"{base_url}/info",
meta_endpoint=f"{base_url}/meta",
api_rpm=app_model.api_rpm or 0,
api_rph=app_model.api_rph or 0,
api_key_count=_agent_api_key_count(str(app_model.id)),
)
return response.model_dump(mode="json")
def _agent_observability_service() -> AgentObservabilityService:
return AgentObservabilityService(db.session)
@ -485,6 +547,75 @@ class AgentAppCopyApi(Resource):
return _serialize_agent_app_detail(copied_app), 201
@console_ns.route("/agent/<uuid:agent_id>/api-access")
class AgentApiAccessApi(Resource):
@console_ns.response(200, "Agent service API access", console_ns.models[AgentApiAccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _serialize_agent_api_access(app_model)
@console_ns.route("/agent/<uuid:agent_id>/api-enable")
class AgentApiStatusApi(Resource):
@console_ns.expect(console_ns.models[AgentApiStatusPayload.__name__])
@console_ns.response(200, "Agent service API status updated", console_ns.models[AgentApiAccessResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
args = AgentApiStatusPayload.model_validate(console_ns.payload)
app_model = AppService().update_app_api_status(app_model, args.enable_api)
return _serialize_agent_api_access(app_model)
@console_ns.route("/agent/<uuid:agent_id>/api-keys")
class AgentApiKeyListApi(BaseApiKeyListResource):
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
token_prefix = "app-"
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID) -> dict[str, object]:
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(ApiKeyList, self._get_api_key_list(str(app_model.id), tenant_id))
@console_ns.response(201, "Agent service API key created", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
def post(self, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(ApiKeyItem, self._create_api_key(str(app_model.id), tenant_id)), 201
@console_ns.route("/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>")
class AgentApiKeyApi(BaseApiKeyResource):
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
@console_ns.response(204, "Agent service API key deleted")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, api_key_id: UUID) -> tuple[str, int]:
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
self._delete_api_key(str(app_model.id), str(api_key_id), tenant_id, current_user)
return "", 204
@console_ns.route("/agent/invite-options")
class AgentInviteOptionsApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))

View File

@ -2,6 +2,7 @@ from typing import Any, cast
from flask_restx import Resource
from pydantic import Field
from sqlalchemy import select
from controllers.common.fields import Parameters
from controllers.common.schema import register_response_schema_models
@ -9,7 +10,11 @@ from controllers.service_api import service_api_ns
from controllers.service_api.app.error import AppUnavailableError
from controllers.service_api.wraps import validate_app_token
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from extensions.ext_database import db
from fields.base import ResponseModel
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode
from services.app_service import AppService
@ -29,6 +34,40 @@ class AppMetaResponse(ResponseModel):
register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, AppInfoResponse)
def _get_agent_app_feature_dict_and_user_input_form(app_model: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
app_model_config = app_model.app_model_config
features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {}
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None or not agent.active_config_snapshot_id:
raise AppUnavailableError()
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AppUnavailableError()
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
@service_api_ns.route("/parameters")
class AppParameterApi(Resource):
"""Resource for app variables."""
@ -61,12 +100,16 @@ class AppParameterApi(Resource):
Returns the input form parameters and configuration for the application.
"""
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
features_dict: dict[str, Any]
user_input_form: list[dict[str, Any]]
if app_model.mode == AppMode.AGENT:
features_dict, user_input_form = _get_agent_app_feature_dict_and_user_input_form(app_model)
elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppUnavailableError()
features_dict: dict[str, Any] = workflow.features_dict
features_dict = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config

View File

@ -21,6 +21,7 @@ from core.app.app_config.entities import (
EasyUIBasedAppModelConfigFrom,
PromptTemplateEntity,
)
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, Conversation
@ -98,8 +99,7 @@ class AgentAppConfigManager(BaseAppConfigManager):
# pipeline's bookkeeping (token counting, persistence).
base["prompt_type"] = PromptTemplateEntity.PromptType.SIMPLE.value
base["pre_prompt"] = agent_soul.prompt.system_prompt or ""
# Agent App takes the user message directly; no completion-style inputs form.
base.setdefault("user_input_form", [])
base["user_input_form"] = agent_app_variables_to_user_input_form(agent_soul.app_variables)
return base

View File

@ -0,0 +1,37 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from models.agent_config_entities import AppVariableConfig
def agent_app_variables_to_user_input_form(app_variables: Sequence[AppVariableConfig]) -> list[dict[str, Any]]:
"""Project Agent Soul app variables into the legacy service-API parameter form."""
user_input_form: list[dict[str, Any]] = []
for variable in app_variables:
form_type = _form_type_for_agent_variable(variable.type)
form_item: dict[str, Any] = {
"label": variable.name,
"variable": variable.name,
"required": variable.required,
}
if variable.default is not None:
form_item["default"] = variable.default
user_input_form.append({form_type: form_item})
return user_input_form
def _form_type_for_agent_variable(variable_type: str) -> str:
normalized = variable_type.strip().lower()
if normalized in {"number", "integer", "float"}:
return "number"
if normalized in {"boolean", "bool"}:
return "checkbox"
if normalized in {"paragraph", "long_text", "multiline"}:
return "paragraph"
return "text-input"
__all__ = ["agent_app_variables_to_user_input_form"]

View File

@ -391,6 +391,80 @@ Check if activation token is valid
| 400 | Invalid request parameters | |
| 403 | Insufficient permissions | |
### [GET] /agent/{agent_id}/api-access
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent service API access | **application/json**: [AgentApiAccessResponse](#agentapiaccessresponse)<br> |
### [POST] /agent/{agent_id}/api-enable
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentApiStatusPayload](#agentapistatuspayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent service API status updated | **application/json**: [AgentApiAccessResponse](#agentapiaccessresponse)<br> |
| 403 | Insufficient permissions | |
### [GET] /agent/{agent_id}/api-keys
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent service API keys | **application/json**: [ApiKeyList](#apikeylist)<br> |
### [POST] /agent/{agent_id}/api-keys
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Agent service API key created | **application/json**: [ApiKeyItem](#apikeyitem)<br> |
| 400 | Maximum keys exceeded | |
### [DELETE] /agent/{agent_id}/api-keys/{api_key_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
| api_key_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description |
| ---- | ----------- |
| 204 | Agent service API key deleted |
### [GET] /agent/{agent_id}/chat-messages
Get Agent App chat messages for a conversation with pagination
@ -12033,6 +12107,31 @@ Default namespace
| chat_prompt_config | object | | No |
| completion_prompt_config | object | | No |
#### AgentApiAccessResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| api_key_count | integer | | Yes |
| api_rph | integer | | Yes |
| api_rpm | integer | | Yes |
| chat_endpoint | string | | Yes |
| conversations_endpoint | string | | Yes |
| enabled | boolean | | Yes |
| files_upload_endpoint | string | | Yes |
| info_endpoint | string | | Yes |
| messages_endpoint | string | | Yes |
| meta_endpoint | string | | Yes |
| parameters_endpoint | string | | Yes |
| service_api_base_url | string | | Yes |
| stop_endpoint | string | | Yes |
| streaming_only | boolean, <br>**Default:** true | | No |
#### AgentApiStatusPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enable_api | boolean | Enable or disable Agent service API | Yes |
#### AgentAppComposerResponse
| Name | Type | Description | Required |

View File

@ -20,6 +20,10 @@ from controllers.console.agent.composer import (
WorkflowAgentComposerValidateApi,
)
from controllers.console.agent.roster import (
AgentApiAccessApi,
AgentApiKeyApi,
AgentApiKeyListApi,
AgentApiStatusApi,
AgentAppApi,
AgentAppCopyApi,
AgentAppListApi,
@ -150,6 +154,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/agent/<uuid:agent_id>/sandbox/files",
"/agent/<uuid:agent_id>/skills/upload",
"/agent/<uuid:agent_id>/files",
"/agent/<uuid:agent_id>/api-access",
"/agent/<uuid:agent_id>/api-enable",
"/agent/<uuid:agent_id>/api-keys",
"/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>",
"/agent/<uuid:agent_id>/chat-messages",
"/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop",
"/agent/<uuid:agent_id>/feedbacks",
@ -177,6 +185,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/apps/<uuid:app_id>/agent-features",
"/apps/<uuid:app_id>/agent-referencing-workflows",
"/apps/<uuid:app_id>/agent-sandbox/files",
"/apps/<uuid:agent_id>/api-access",
):
assert route not in paths
@ -449,6 +458,127 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
}
def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = SimpleNamespace(
id="app-1",
enable_api=True,
api_base_url="https://api.example.test/v1",
api_rpm=60,
api_rph=600,
)
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda app_id: 2)
response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), "tenant-1", agent_id)
assert response == {
"enabled": True,
"service_api_base_url": "https://api.example.test/v1",
"streaming_only": True,
"chat_endpoint": "https://api.example.test/v1/chat-messages",
"stop_endpoint": "https://api.example.test/v1/chat-messages/{task_id}/stop",
"conversations_endpoint": "https://api.example.test/v1/conversations",
"messages_endpoint": "https://api.example.test/v1/messages",
"files_upload_endpoint": "https://api.example.test/v1/files/upload",
"parameters_endpoint": "https://api.example.test/v1/parameters",
"info_endpoint": "https://api.example.test/v1/info",
"meta_endpoint": "https://api.example.test/v1/meta",
"api_rpm": 60,
"api_rph": 600,
"api_key_count": 2,
}
def test_agent_api_status_and_key_routes_resolve_backing_app(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
api_key_id = "00000000-0000-0000-0000-000000000002"
app_model = SimpleNamespace(
id="app-1",
enable_api=False,
api_base_url="https://api.example.test/v1",
api_rpm=0,
api_rph=0,
)
captured: dict[str, object] = {}
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda app_id: 1)
class FakeAppService:
def update_app_api_status(self, app_obj: object, enable_api: bool) -> object:
captured["enable"] = {"app": app_obj, "enable_api": enable_api}
app_model.enable_api = enable_api
return app_model
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
def fake_get_api_key_list(self, resource_id: str, tenant_id: str):
captured["list_keys"] = {"resource_id": resource_id, "tenant_id": tenant_id}
return roster_controller.ApiKeyList(data=[])
def fake_create_api_key(self, resource_id: str, tenant_id: str):
captured["create_key"] = {"resource_id": resource_id, "tenant_id": tenant_id}
return SimpleNamespace(
id=api_key_id,
type="app",
token="app-test-token",
last_used_at=None,
created_at=None,
)
def fake_delete_api_key(self, resource_id: str, key_id: str, tenant_id: str, current_user: object) -> None:
captured["delete_key"] = {
"resource_id": resource_id,
"api_key_id": key_id,
"tenant_id": tenant_id,
"current_user": current_user,
}
monkeypatch.setattr(AgentApiKeyListApi, "_get_api_key_list", fake_get_api_key_list)
monkeypatch.setattr(AgentApiKeyListApi, "_create_api_key", fake_create_api_key)
monkeypatch.setattr(AgentApiKeyApi, "_delete_api_key", fake_delete_api_key)
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/api-enable",
json={"enable_api": True},
):
enabled = unwrap(AgentApiStatusApi.post)(AgentApiStatusApi(), "tenant-1", agent_id)
assert enabled["enabled"] is True
assert captured["enable"] == {"app": app_model, "enable_api": True}
keys = unwrap(AgentApiKeyListApi.get)(AgentApiKeyListApi(), "tenant-1", agent_id)
assert keys == {"data": []}
assert captured["list_keys"] == {"resource_id": "app-1", "tenant_id": "tenant-1"}
created, status = unwrap(AgentApiKeyListApi.post)(AgentApiKeyListApi(), "tenant-1", agent_id)
assert status == 201
assert created["id"] == api_key_id
assert created["token"] == "app-test-token"
assert captured["create_key"] == {"resource_id": "app-1", "tenant_id": "tenant-1"}
current_user = SimpleNamespace(id="account-1", is_admin_or_owner=True)
deleted, delete_status = unwrap(AgentApiKeyApi.delete)(
AgentApiKeyApi(),
"tenant-1",
current_user,
agent_id,
api_key_id,
)
assert (deleted, delete_status) == ("", 204)
assert captured["delete_key"] == {
"resource_id": "app-1",
"api_key_id": api_key_id,
"tenant_id": "tenant-1",
"current_user": current_user,
}
def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id)

View File

@ -136,6 +136,55 @@ class TestAppParameterApi:
assert "user_input_form" in response
assert "opening_statement" in response
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.current_app")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@patch("controllers.service_api.wraps.db")
@patch("controllers.service_api.app.app._get_agent_app_feature_dict_and_user_input_form")
def test_get_parameters_for_agent_app(
self,
mock_get_agent_parameters,
mock_db,
mock_validate_token,
mock_current_app,
mock_user_logged_in,
app: Flask,
mock_app_model,
):
"""Test retrieving parameters for an Agent App from Agent Soul app variables."""
_configure_current_app_mock(mock_current_app)
mock_app_model.mode = AppMode.AGENT
mock_app_model.app_model_config = None
mock_app_model.workflow = None
mock_get_agent_parameters.return_value = (
{"opening_statement": "Hi from Agent"},
[{"text-input": {"label": "topic", "variable": "topic", "required": True}}],
)
mock_api_token = Mock()
mock_api_token.app_id = mock_app_model.id
mock_api_token.tenant_id = mock_app_model.tenant_id
mock_validate_token.return_value = mock_api_token
mock_tenant = Mock()
mock_tenant.status = TenantStatus.NORMAL
mock_db.session.get.side_effect = [mock_app_model, mock_tenant]
mock_account = Mock()
mock_account.current_tenant = mock_tenant
setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account)
with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}):
api = AppParameterApi()
response = api.get()
assert response["opening_statement"] == "Hi from Agent"
assert response["user_input_form"] == [
{"text-input": {"label": "topic", "variable": "topic", "required": True}}
]
mock_get_agent_parameters.assert_called_once_with(mock_app_model)
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.current_app")
@patch("controllers.service_api.wraps.validate_and_get_api_token")

View File

@ -19,6 +19,10 @@ def _soul() -> AgentSoulConfig:
"model_settings": {"temperature": 0.2},
},
"prompt": {"system_prompt": "You are Iris."},
"app_variables": [
{"name": "topic", "type": "string", "required": True},
{"name": "count", "type": "number", "default": 3},
],
}
)
@ -32,7 +36,10 @@ def test_model_and_prompt_come_from_soul():
"completion_params": {"temperature": 0.2},
}
assert d["pre_prompt"] == "You are Iris."
assert d["user_input_form"] == []
assert d["user_input_form"] == [
{"text-input": {"label": "topic", "variable": "topic", "required": True}},
{"number": {"label": "count", "variable": "count", "required": False, "default": 3}},
]
def test_feature_flags_come_from_app_model_config_when_present():

View File

@ -4,6 +4,8 @@ import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zDeleteAgentByAgentIdApiKeysByApiKeyIdPath,
zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse,
zDeleteAgentByAgentIdFilesPath,
zDeleteAgentByAgentIdFilesQuery,
zDeleteAgentByAgentIdFilesResponse,
@ -11,6 +13,10 @@ import {
zDeleteAgentByAgentIdResponse,
zDeleteAgentByAgentIdSkillsBySlugPath,
zDeleteAgentByAgentIdSkillsBySlugResponse,
zGetAgentByAgentIdApiAccessPath,
zGetAgentByAgentIdApiAccessResponse,
zGetAgentByAgentIdApiKeysPath,
zGetAgentByAgentIdApiKeysResponse,
zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath,
zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse,
zGetAgentByAgentIdChatMessagesPath,
@ -65,6 +71,11 @@ import {
zGetAgentQuery,
zGetAgentResponse,
zPostAgentBody,
zPostAgentByAgentIdApiEnableBody,
zPostAgentByAgentIdApiEnablePath,
zPostAgentByAgentIdApiEnableResponse,
zPostAgentByAgentIdApiKeysPath,
zPostAgentByAgentIdApiKeysResponse,
zPostAgentByAgentIdChatMessagesByTaskIdStopPath,
zPostAgentByAgentIdChatMessagesByTaskIdStopResponse,
zPostAgentByAgentIdComposerValidateBody,
@ -116,10 +127,87 @@ export const inviteOptions = {
get,
}
export const get2 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentByAgentIdApiAccess',
path: '/agent/{agent_id}/api-access',
tags: ['console'],
})
.input(z.object({ params: zGetAgentByAgentIdApiAccessPath }))
.output(zGetAgentByAgentIdApiAccessResponse)
export const apiAccess = {
get: get2,
}
export const post = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAgentByAgentIdApiEnable',
path: '/agent/{agent_id}/api-enable',
tags: ['console'],
})
.input(
z.object({ body: zPostAgentByAgentIdApiEnableBody, params: zPostAgentByAgentIdApiEnablePath }),
)
.output(zPostAgentByAgentIdApiEnableResponse)
export const apiEnable = {
post,
}
export const delete_ = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'deleteAgentByAgentIdApiKeysByApiKeyId',
path: '/agent/{agent_id}/api-keys/{api_key_id}',
successStatus: 204,
tags: ['console'],
})
.input(z.object({ params: zDeleteAgentByAgentIdApiKeysByApiKeyIdPath }))
.output(zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse)
export const byApiKeyId = {
delete: delete_,
}
export const get3 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentByAgentIdApiKeys',
path: '/agent/{agent_id}/api-keys',
tags: ['console'],
})
.input(z.object({ params: zGetAgentByAgentIdApiKeysPath }))
.output(zGetAgentByAgentIdApiKeysResponse)
export const post2 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAgentByAgentIdApiKeys',
path: '/agent/{agent_id}/api-keys',
successStatus: 201,
tags: ['console'],
})
.input(z.object({ params: zPostAgentByAgentIdApiKeysPath }))
.output(zPostAgentByAgentIdApiKeysResponse)
export const apiKeys = {
get: get3,
post: post2,
byApiKeyId,
}
/**
* Get suggested questions for an Agent App message
*/
export const get2 = oc
export const get4 = oc
.route({
description: 'Get suggested questions for an Agent App message',
inputStructure: 'detailed',
@ -132,7 +220,7 @@ export const get2 = oc
.output(zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse)
export const suggestedQuestions = {
get: get2,
get: get4,
}
export const byMessageId = {
@ -142,7 +230,7 @@ export const byMessageId = {
/**
* Stop a running Agent App chat message generation
*/
export const post = oc
export const post3 = oc
.route({
description: 'Stop a running Agent App chat message generation',
inputStructure: 'detailed',
@ -155,7 +243,7 @@ export const post = oc
.output(zPostAgentByAgentIdChatMessagesByTaskIdStopResponse)
export const stop = {
post,
post: post3,
}
export const byTaskId = {
@ -165,7 +253,7 @@ export const byTaskId = {
/**
* Get Agent App chat messages for a conversation with pagination
*/
export const get3 = oc
export const get5 = oc
.route({
description: 'Get Agent App chat messages for a conversation with pagination',
inputStructure: 'detailed',
@ -183,12 +271,12 @@ export const get3 = oc
.output(zGetAgentByAgentIdChatMessagesResponse)
export const chatMessages = {
get: get3,
get: get5,
byMessageId,
byTaskId,
}
export const get4 = oc
export const get6 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -200,10 +288,10 @@ export const get4 = oc
.output(zGetAgentByAgentIdComposerCandidatesResponse)
export const candidates = {
get: get4,
get: get6,
}
export const post2 = oc
export const post4 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -220,10 +308,10 @@ export const post2 = oc
.output(zPostAgentByAgentIdComposerValidateResponse)
export const validate = {
post: post2,
post: post4,
}
export const get5 = oc
export const get7 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -246,13 +334,13 @@ export const put = oc
.output(zPutAgentByAgentIdComposerResponse)
export const composer = {
get: get5,
get: get7,
put,
candidates,
validate,
}
export const post3 = oc
export const post5 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -265,13 +353,13 @@ export const post3 = oc
.output(zPostAgentByAgentIdCopyResponse)
export const copy = {
post: post3,
post: post5,
}
/**
* Time-limited external signed URL for one Agent App drive value
*/
export const get6 = oc
export const get8 = oc
.route({
description: 'Time-limited external signed URL for one Agent App drive value',
inputStructure: 'detailed',
@ -289,13 +377,13 @@ export const get6 = oc
.output(zGetAgentByAgentIdDriveFilesDownloadResponse)
export const download = {
get: get6,
get: get8,
}
/**
* Truncated text preview of one Agent App drive value
*/
export const get7 = oc
export const get9 = oc
.route({
description: 'Truncated text preview of one Agent App drive value',
inputStructure: 'detailed',
@ -313,13 +401,13 @@ export const get7 = oc
.output(zGetAgentByAgentIdDriveFilesPreviewResponse)
export const preview = {
get: get7,
get: get9,
}
/**
* List agent drive entries for an Agent App
*/
export const get8 = oc
export const get10 = oc
.route({
description: 'List agent drive entries for an Agent App',
inputStructure: 'detailed',
@ -337,7 +425,7 @@ export const get8 = oc
.output(zGetAgentByAgentIdDriveFilesResponse)
export const files = {
get: get8,
get: get10,
download,
preview,
}
@ -345,7 +433,7 @@ export const files = {
/**
* Inspect one drive-backed skill for slash-menu hover/detail UI
*/
export const get9 = oc
export const get11 = oc
.route({
description: 'Inspect one drive-backed skill for slash-menu hover/detail UI',
inputStructure: 'detailed',
@ -358,7 +446,7 @@ export const get9 = oc
.output(zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse)
export const inspect = {
get: get9,
get: get11,
}
export const bySkillPath = {
@ -368,7 +456,7 @@ export const bySkillPath = {
/**
* List drive-backed skills for an Agent App
*/
export const get10 = oc
export const get12 = oc
.route({
description: 'List drive-backed skills for an Agent App',
inputStructure: 'detailed',
@ -381,7 +469,7 @@ export const get10 = oc
.output(zGetAgentByAgentIdDriveSkillsResponse)
export const skills = {
get: get10,
get: get12,
bySkillPath,
}
@ -393,7 +481,7 @@ export const drive = {
/**
* Update an Agent App's presentation features (opener, follow-up, citations, ...)
*/
export const post4 = oc
export const post6 = oc
.route({
description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)',
inputStructure: 'detailed',
@ -408,13 +496,13 @@ export const post4 = oc
.output(zPostAgentByAgentIdFeaturesResponse)
export const features = {
post: post4,
post: post6,
}
/**
* Create or update Agent App message feedback
*/
export const post5 = oc
export const post7 = oc
.route({
description: 'Create or update Agent App message feedback',
inputStructure: 'detailed',
@ -429,13 +517,13 @@ export const post5 = oc
.output(zPostAgentByAgentIdFeedbacksResponse)
export const feedbacks = {
post: post5,
post: post7,
}
/**
* Delete one Agent App drive file by key
*/
export const delete_ = oc
export const delete2 = oc
.route({
description: 'Delete one Agent App drive file by key',
inputStructure: 'detailed',
@ -452,7 +540,7 @@ export const delete_ = oc
/**
* Commit an uploaded file into the Agent App drive under files/<name>
*/
export const post6 = oc
export const post8 = oc
.route({
description: 'Commit an uploaded file into the Agent App drive under files/<name>',
inputStructure: 'detailed',
@ -466,11 +554,11 @@ export const post6 = oc
.output(zPostAgentByAgentIdFilesResponse)
export const files2 = {
delete: delete_,
post: post6,
delete: delete2,
post: post8,
}
export const get11 = oc
export const get13 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -482,10 +570,10 @@ export const get11 = oc
.output(zGetAgentByAgentIdLogSourcesResponse)
export const logSources = {
get: get11,
get: get13,
}
export const get12 = oc
export const get14 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -502,14 +590,14 @@ export const get12 = oc
.output(zGetAgentByAgentIdLogsByConversationIdMessagesResponse)
export const messages = {
get: get12,
get: get14,
}
export const byConversationId = {
messages,
}
export const get13 = oc
export const get15 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -523,14 +611,14 @@ export const get13 = oc
.output(zGetAgentByAgentIdLogsResponse)
export const logs = {
get: get13,
get: get15,
byConversationId,
}
/**
* Get Agent App message details by ID
*/
export const get14 = oc
export const get16 = oc
.route({
description: 'Get Agent App message details by ID',
inputStructure: 'detailed',
@ -543,7 +631,7 @@ export const get14 = oc
.output(zGetAgentByAgentIdMessagesByMessageIdResponse)
export const byMessageId2 = {
get: get14,
get: get16,
}
export const messages2 = {
@ -553,7 +641,7 @@ export const messages2 = {
/**
* List workflow apps that reference this Agent App's bound Agent (read-only)
*/
export const get15 = oc
export const get17 = oc
.route({
description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)',
inputStructure: 'detailed',
@ -566,13 +654,13 @@ export const get15 = oc
.output(zGetAgentByAgentIdReferencingWorkflowsResponse)
export const referencingWorkflows = {
get: get15,
get: get17,
}
/**
* Read a text/binary preview file in an Agent App conversation sandbox
*/
export const get16 = oc
export const get18 = oc
.route({
description: 'Read a text/binary preview file in an Agent App conversation sandbox',
inputStructure: 'detailed',
@ -590,13 +678,13 @@ export const get16 = oc
.output(zGetAgentByAgentIdSandboxFilesReadResponse)
export const read = {
get: get16,
get: get18,
}
/**
* Upload one Agent App sandbox file as a Dify ToolFile mapping
*/
export const post7 = oc
export const post9 = oc
.route({
description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping',
inputStructure: 'detailed',
@ -614,13 +702,13 @@ export const post7 = oc
.output(zPostAgentByAgentIdSandboxFilesUploadResponse)
export const upload = {
post: post7,
post: post9,
}
/**
* List a directory in an Agent App conversation sandbox
*/
export const get17 = oc
export const get19 = oc
.route({
description: 'List a directory in an Agent App conversation sandbox',
inputStructure: 'detailed',
@ -638,7 +726,7 @@ export const get17 = oc
.output(zGetAgentByAgentIdSandboxFilesResponse)
export const files3 = {
get: get17,
get: get19,
read,
upload,
}
@ -650,7 +738,7 @@ export const sandbox = {
/**
* Upload + standardize a Skill into an Agent App drive
*/
export const post8 = oc
export const post10 = oc
.route({
description: 'Upload + standardize a Skill into an Agent App drive',
inputStructure: 'detailed',
@ -669,13 +757,13 @@ export const post8 = oc
.output(zPostAgentByAgentIdSkillsUploadResponse)
export const upload2 = {
post: post8,
post: post10,
}
/**
* Infer CLI tool + ENV suggestions from a standardized Agent App skill
*/
export const post9 = oc
export const post11 = oc
.route({
description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill',
inputStructure: 'detailed',
@ -688,13 +776,13 @@ export const post9 = oc
.output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse)
export const inferTools = {
post: post9,
post: post11,
}
/**
* Delete a standardized skill from an Agent App drive
*/
export const delete2 = oc
export const delete3 = oc
.route({
description: 'Delete a standardized skill from an Agent App drive',
inputStructure: 'detailed',
@ -707,7 +795,7 @@ export const delete2 = oc
.output(zDeleteAgentByAgentIdSkillsBySlugResponse)
export const bySlug = {
delete: delete2,
delete: delete3,
inferTools,
}
@ -716,7 +804,7 @@ export const skills2 = {
bySlug,
}
export const get18 = oc
export const get20 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -733,14 +821,14 @@ export const get18 = oc
.output(zGetAgentByAgentIdStatisticsSummaryResponse)
export const summary = {
get: get18,
get: get20,
}
export const statistics = {
summary,
}
export const post10 = oc
export const post12 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -752,10 +840,10 @@ export const post10 = oc
.output(zPostAgentByAgentIdVersionsByVersionIdRestoreResponse)
export const restore = {
post: post10,
post: post12,
}
export const get19 = oc
export const get21 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -767,11 +855,11 @@ export const get19 = oc
.output(zGetAgentByAgentIdVersionsByVersionIdResponse)
export const byVersionId = {
get: get19,
get: get21,
restore,
}
export const get20 = oc
export const get22 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -783,11 +871,11 @@ export const get20 = oc
.output(zGetAgentByAgentIdVersionsResponse)
export const versions = {
get: get20,
get: get22,
byVersionId,
}
export const delete3 = oc
export const delete4 = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
@ -799,7 +887,7 @@ export const delete3 = oc
.input(z.object({ params: zDeleteAgentByAgentIdPath }))
.output(zDeleteAgentByAgentIdResponse)
export const get21 = oc
export const get23 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -822,9 +910,12 @@ export const put2 = oc
.output(zPutAgentByAgentIdResponse)
export const byAgentId = {
delete: delete3,
get: get21,
delete: delete4,
get: get23,
put: put2,
apiAccess,
apiEnable,
apiKeys,
chatMessages,
composer,
copy,
@ -842,7 +933,7 @@ export const byAgentId = {
versions,
}
export const get22 = oc
export const get24 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -853,7 +944,7 @@ export const get22 = oc
.input(z.object({ query: zGetAgentQuery.optional() }))
.output(zGetAgentResponse)
export const post11 = oc
export const post13 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -866,8 +957,8 @@ export const post11 = oc
.output(zPostAgentResponse)
export const agent = {
get: get22,
post: post11,
get: get24,
post: post13,
inviteOptions,
byAgentId,
}

View File

@ -74,6 +74,39 @@ export type AgentAppUpdatePayload = {
use_icon_as_answer_icon?: boolean | null
}
export type AgentApiAccessResponse = {
api_key_count: number
api_rph: number
api_rpm: number
chat_endpoint: string
conversations_endpoint: string
enabled: boolean
files_upload_endpoint: string
info_endpoint: string
messages_endpoint: string
meta_endpoint: string
parameters_endpoint: string
service_api_base_url: string
stop_endpoint: string
streaming_only?: boolean
}
export type AgentApiStatusPayload = {
enable_api: boolean
}
export type ApiKeyList = {
data: Array<ApiKeyItem>
}
export type ApiKeyItem = {
created_at?: number | null
id: string
last_used_at?: number | null
token: string
type: string
}
export type MessageInfiniteScrollPaginationResponse = {
data: Array<MessageDetailResponse>
has_more: boolean
@ -1699,6 +1732,95 @@ export type PutAgentByAgentIdResponses = {
export type PutAgentByAgentIdResponse = PutAgentByAgentIdResponses[keyof PutAgentByAgentIdResponses]
export type GetAgentByAgentIdApiAccessData = {
body?: never
path: {
agent_id: string
}
query?: never
url: '/agent/{agent_id}/api-access'
}
export type GetAgentByAgentIdApiAccessResponses = {
200: AgentApiAccessResponse
}
export type GetAgentByAgentIdApiAccessResponse
= GetAgentByAgentIdApiAccessResponses[keyof GetAgentByAgentIdApiAccessResponses]
export type PostAgentByAgentIdApiEnableData = {
body: AgentApiStatusPayload
path: {
agent_id: string
}
query?: never
url: '/agent/{agent_id}/api-enable'
}
export type PostAgentByAgentIdApiEnableErrors = {
403: unknown
}
export type PostAgentByAgentIdApiEnableResponses = {
200: AgentApiAccessResponse
}
export type PostAgentByAgentIdApiEnableResponse
= PostAgentByAgentIdApiEnableResponses[keyof PostAgentByAgentIdApiEnableResponses]
export type GetAgentByAgentIdApiKeysData = {
body?: never
path: {
agent_id: string
}
query?: never
url: '/agent/{agent_id}/api-keys'
}
export type GetAgentByAgentIdApiKeysResponses = {
200: ApiKeyList
}
export type GetAgentByAgentIdApiKeysResponse
= GetAgentByAgentIdApiKeysResponses[keyof GetAgentByAgentIdApiKeysResponses]
export type PostAgentByAgentIdApiKeysData = {
body?: never
path: {
agent_id: string
}
query?: never
url: '/agent/{agent_id}/api-keys'
}
export type PostAgentByAgentIdApiKeysErrors = {
400: unknown
}
export type PostAgentByAgentIdApiKeysResponses = {
201: ApiKeyItem
}
export type PostAgentByAgentIdApiKeysResponse
= PostAgentByAgentIdApiKeysResponses[keyof PostAgentByAgentIdApiKeysResponses]
export type DeleteAgentByAgentIdApiKeysByApiKeyIdData = {
body?: never
path: {
agent_id: string
api_key_id: string
}
query?: never
url: '/agent/{agent_id}/api-keys/{api_key_id}'
}
export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponses = {
204: void
}
export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponse
= DeleteAgentByAgentIdApiKeysByApiKeyIdResponses[keyof DeleteAgentByAgentIdApiKeysByApiKeyIdResponses]
export type GetAgentByAgentIdChatMessagesData = {
body?: never
path: {

View File

@ -2,6 +2,51 @@
import * as z from 'zod'
/**
* AgentApiAccessResponse
*/
export const zAgentApiAccessResponse = z.object({
api_key_count: z.int(),
api_rph: z.int(),
api_rpm: z.int(),
chat_endpoint: z.string(),
conversations_endpoint: z.string(),
enabled: z.boolean(),
files_upload_endpoint: z.string(),
info_endpoint: z.string(),
messages_endpoint: z.string(),
meta_endpoint: z.string(),
parameters_endpoint: z.string(),
service_api_base_url: z.string(),
stop_endpoint: z.string(),
streaming_only: z.boolean().optional().default(true),
})
/**
* AgentApiStatusPayload
*/
export const zAgentApiStatusPayload = z.object({
enable_api: z.boolean(),
})
/**
* ApiKeyItem
*/
export const zApiKeyItem = z.object({
created_at: z.int().nullish(),
id: z.string(),
last_used_at: z.int().nullish(),
token: z.string(),
type: z.string(),
})
/**
* ApiKeyList
*/
export const zApiKeyList = z.object({
data: z.array(zApiKeyItem),
})
/**
* SuggestedQuestionsResponse
*/
@ -2257,6 +2302,54 @@ export const zPutAgentByAgentIdPath = z.object({
*/
export const zPutAgentByAgentIdResponse = zAgentAppDetailWithSite
export const zGetAgentByAgentIdApiAccessPath = z.object({
agent_id: z.uuid(),
})
/**
* Agent service API access
*/
export const zGetAgentByAgentIdApiAccessResponse = zAgentApiAccessResponse
export const zPostAgentByAgentIdApiEnableBody = zAgentApiStatusPayload
export const zPostAgentByAgentIdApiEnablePath = z.object({
agent_id: z.uuid(),
})
/**
* Agent service API status updated
*/
export const zPostAgentByAgentIdApiEnableResponse = zAgentApiAccessResponse
export const zGetAgentByAgentIdApiKeysPath = z.object({
agent_id: z.uuid(),
})
/**
* Agent service API keys
*/
export const zGetAgentByAgentIdApiKeysResponse = zApiKeyList
export const zPostAgentByAgentIdApiKeysPath = z.object({
agent_id: z.uuid(),
})
/**
* Agent service API key created
*/
export const zPostAgentByAgentIdApiKeysResponse = zApiKeyItem
export const zDeleteAgentByAgentIdApiKeysByApiKeyIdPath = z.object({
agent_id: z.uuid(),
api_key_id: z.uuid(),
})
/**
* Agent service API key deleted
*/
export const zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse = z.void()
export const zGetAgentByAgentIdChatMessagesPath = z.object({
agent_id: z.uuid(),
})