Bladeren bron

feat: connect platform services and web flows

Jax Docker 5 maanden geleden
bovenliggende
commit
7cdf0bb2e7
87 gewijzigde bestanden met toevoegingen van 7617 en 1320 verwijderingen
  1. 56 39
      docs/auth-service-design.md
  2. 46 0
      services/agent-service/app/api/routes.py
  3. 41 4
      services/agent-service/app/application/services.py
  4. 29 0
      services/agent-service/app/domain/repositories.py
  5. 49 3
      services/agent-service/app/schemas/agent.py
  6. 96 67
      services/api-gateway/app/api/routes.py
  7. 2 1
      services/api-gateway/app/infrastructure/proxy.py
  8. 22 13
      services/api-gateway/app/infrastructure/request_context.py
  9. 2 1
      services/auth-service/alembic/versions/20260427_0002_add_user_password_hash.py
  10. 81 0
      services/auth-service/alembic/versions/20260428_0004_add_identity_contract_tables.py
  11. 296 0
      services/auth-service/app/api/identity_routes.py
  12. 0 149
      services/auth-service/app/api/routes.py
  13. 160 84
      services/auth-service/app/application/services.py
  14. 4 2
      services/auth-service/app/bootstrap/app.py
  15. 62 0
      services/auth-service/app/bootstrap/demo_seed.py
  16. 5 0
      services/auth-service/app/bootstrap/settings.py
  17. 3 1
      services/auth-service/app/db/models/__init__.py
  18. 17 0
      services/auth-service/app/db/models/api_key.py
  19. 15 0
      services/auth-service/app/db/models/role_permission_binding.py
  20. 133 1
      services/auth-service/app/domain/repositories.py
  21. 18 0
      services/auth-service/app/infrastructure/api_keys.py
  22. 0 118
      services/auth-service/app/schemas/auth.py
  23. 222 0
      services/auth-service/app/schemas/identity.py
  24. 2 0
      services/knowledge-service/alembic/versions/20260425_0001_init_knowledge_models.py
  25. 1 0
      services/memory-service/alembic/versions/20260425_0001_init_memory_models.py
  26. 48 0
      services/model-gateway-service/alembic/versions/20260428_0002_add_model_provider_definitions.py
  27. 31 0
      services/model-gateway-service/alembic/versions/20260429_0003_add_model_provider_id.py
  28. 192 13
      services/model-gateway-service/app/api/routes.py
  29. 317 13
      services/model-gateway-service/app/application/services.py
  30. 13 0
      services/model-gateway-service/app/db/models.py
  31. 45 1
      services/model-gateway-service/app/domain/repositories.py
  32. 133 3
      services/model-gateway-service/app/infrastructure/provider.py
  33. 272 3
      services/model-gateway-service/app/schemas/model.py
  34. 225 0
      services/team-service/app/api/routes.py
  35. 195 21
      services/team-service/app/application/services.py
  36. 23 1
      services/team-service/app/bootstrap/app.py
  37. 5 2
      services/team-service/app/bootstrap/settings.py
  38. 44 4
      services/team-service/app/domain/repositories.py
  39. 36 0
      services/team-service/app/infrastructure/agent_client.py
  40. 253 1
      services/team-service/app/schemas/team.py
  41. 38 0
      services/team-service/app/worker.py
  42. 310 14
      services/tool-service/app/api/routes.py
  43. 272 1
      services/tool-service/app/application/services.py
  44. 41 1
      services/tool-service/app/domain/repositories.py
  45. 273 2
      services/tool-service/app/schemas/tool.py
  46. 0 3
      services/workflow-service/alembic/versions/20260422_0001_init_workflow_models.py
  47. 22 0
      tests/conftest.py
  48. 142 0
      tests/test_auth_identity_service.py
  49. 238 0
      tests/test_model_service.py
  50. 146 0
      tests/test_team_service.py
  51. 171 0
      tests/test_tool_service.py
  52. 0 3
      web/.claude/settings.local.json
  53. 1 1
      web/.env.example
  54. 39 16
      web/src/api/agents.ts
  55. 35 9
      web/src/api/auth.ts
  56. 16 6
      web/src/api/client.ts
  57. 397 53
      web/src/api/mock.ts
  58. 119 14
      web/src/api/model-providers.ts
  59. 92 9
      web/src/api/models.ts
  60. 254 23
      web/src/api/teams.ts
  61. 253 44
      web/src/api/tools.ts
  62. 12 2
      web/src/components/layout/AppLayout.tsx
  63. 0 190
      web/src/components/layout/Header.tsx
  64. 194 23
      web/src/components/layout/Sidebar.tsx
  65. 3 3
      web/src/hooks/useApps.ts
  66. 0 2
      web/src/lib/constants.ts
  67. 35 1
      web/src/locales/en.json
  68. 35 1
      web/src/locales/zh.json
  69. 152 75
      web/src/pages/agents/AgentListPage.tsx
  70. 242 88
      web/src/pages/agents/components/AgentRuns.tsx
  71. 27 36
      web/src/pages/agents/components/CreateAgentDialog.tsx
  72. 4 4
      web/src/pages/login/LoginPage.tsx
  73. 2 0
      web/src/pages/models/ModelProvidersPage.tsx
  74. 109 29
      web/src/pages/models/ModelsPage.tsx
  75. 52 7
      web/src/pages/teams/TeamsPage.tsx
  76. 195 6
      web/src/pages/teams/components/CreateTeamDialog.tsx
  77. 36 10
      web/src/pages/teams/components/TeamOverview.tsx
  78. 334 14
      web/src/pages/teams/components/TeamRuns.tsx
  79. 41 18
      web/src/pages/tools/components/ConnectMcpServerDialog.tsx
  80. 4 2
      web/src/pages/tools/components/ToolDetailSheet.tsx
  81. 1 7
      web/src/types/agent.ts
  82. 37 38
      web/src/types/auth.ts
  83. 15 0
      web/src/types/common.ts
  84. 1 1
      web/src/types/model-provider.ts
  85. 3 1
      web/src/types/model.ts
  86. 7 0
      web/src/types/team.ts
  87. 23 18
      web/vite.config.ts

+ 56 - 39
docs/auth-service-design.md

@@ -1,8 +1,8 @@
-# auth-service design
+# identity-service design
 
 ## Scope
 
-`auth-service` owns Web Studio account/password login, users, roles, role assignments, and permission checks.
+`identity-service` owns Web Studio account/password login, users, roles, role assignments, and permission checks.
 
 The auth domain is single-workspace. Auth API payloads and auth tables do not carry workspace partition fields.
 
@@ -10,17 +10,17 @@ The auth domain is single-workspace. Auth API payloads and auth tables do not ca
 
 Frontend requests go through `/gateway`:
 
-- `POST /gateway/auth/login`
-- `GET /gateway/auth/users`
-- `GET /gateway/auth/roles`
-- `POST /gateway/auth/permissions/check`
+- `POST /gateway/identity/auth/login`
+- `POST /gateway/identity/users/list`
+- `POST /gateway/identity/roles/list`
+- `POST /gateway/identity/permissions/check`
 
-Gateway proxies them to auth-service:
+Gateway proxies them to identity-service:
 
-- `POST /auth/login`
-- `GET /auth/users`
-- `GET /auth/roles`
-- `POST /auth/permissions/check`
+- `POST /identity/auth/login`
+- `POST /identity/users/list`
+- `POST /identity/roles/list`
+- `POST /identity/permissions/check`
 
 After login, frontend sends:
 
@@ -59,11 +59,19 @@ $env:AGENT_PLATFORM_DATABASE_URL="postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQ
 ### auth_role
 
 - `id`
-- `code`
 - `name`
 - `description`
 - `status`: `active | disabled`
-- `permissions_json`
+- audit fields
+- `version`
+
+### auth_role_permission_binding
+
+- `id`
+- `role_id`
+- `permission`
+- `scope_type`
+- `scope_id`
 - audit fields
 - `version`
 
@@ -81,7 +89,7 @@ $env:AGENT_PLATFORM_DATABASE_URL="postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQ
 
 ## Login
 
-`POST /auth/login`
+`POST /identity/auth/login`
 
 ```json
 {
@@ -94,18 +102,21 @@ Response:
 
 ```json
 {
-  "access_token": "apt_xxx",
-  "token_type": "bearer",
-  "expires_time": "2026-04-28T07:10:00Z",
-  "user": {
-    "id": "user-id",
-    "username": "demo-user",
-    "display_name": "Demo User",
-    "email": "demo@example.com",
-    "status": "active",
-    "metadata_json": {},
-    "last_login_time": "2026-04-27T23:10:00Z",
-    "created_time": "2026-04-27T23:00:00Z"
+  "success": true,
+  "data": {
+    "accessToken": "apt_xxx",
+    "tokenType": "bearer",
+    "expiresTime": "2026-04-28T07:10:00Z",
+    "user": {
+      "id": "user-id",
+      "username": "demo-user",
+      "displayName": "Demo User",
+      "email": "demo@example.com",
+      "metadata": {},
+      "lastLoginTime": "2026-04-27T23:10:00Z",
+      "createdTime": "2026-04-27T23:00:00Z",
+      "updatedTime": "2026-04-27T23:00:00Z"
+    }
   }
 }
 ```
@@ -114,11 +125,11 @@ Passwords are stored with salted `PBKDF2-HMAC-SHA256`. Access tokens are HMAC si
 
 ## Token Verification
 
-`POST /auth/tokens/verify`
+`POST /identity/auth/tokens/verify`
 
 ```json
 {
-  "access_token": "apt_xxx"
+  "accessToken": "apt_xxx"
 }
 ```
 
@@ -126,23 +137,26 @@ Response:
 
 ```json
 {
-  "active": true,
-  "user_id": "user-id",
-  "username": "demo-user",
-  "expires_time": "2026-04-28T07:10:00"
+  "success": true,
+  "data": {
+    "active": true,
+    "userId": "user-id",
+    "username": "demo-user",
+    "expiresTime": "2026-04-28T07:10:00"
+  }
 }
 ```
 
 ## Permission Check
 
-`POST /auth/permissions/check`
+`POST /identity/permissions/check`
 
 ```json
 {
-  "user_id": "user-id",
+  "userId": "user-id",
   "permission": "workflow:read",
-  "scope_type": null,
-  "scope_id": null
+  "scopeType": null,
+  "scopeId": null
 }
 ```
 
@@ -150,9 +164,12 @@ Response:
 
 ```json
 {
-  "allowed": true,
-  "reason": "matched",
-  "matched_role_ids": ["role-id"]
+  "success": true,
+  "data": {
+    "allowed": true,
+    "reason": "matched",
+    "matchedRoleIds": ["role-id"]
+  }
 }
 ```
 

+ 46 - 0
services/agent-service/app/api/routes.py

@@ -7,14 +7,19 @@ from app.application.services import AgentApplicationService, build_agent_applic
 from app.bootstrap.settings import AgentServiceSettings
 from app.db.session import get_db
 from app.schemas.agent import (
+    AgentConfigCreateRequest,
+    AgentConfigListRequest,
+    AgentConfigResponse,
     AgentCreateRequest,
     AgentResponse,
     AgentRunCreateRequest,
+    AgentRunDetailRequest,
     AgentRunExecuteRequest,
     AgentRunExecuteResponse,
     AgentRunResponse,
     AgentRunStatusUpdateRequest,
     AgentStatusUpdateRequest,
+    AgentUpdateRequest,
     AgentToolInvocationResponse,
     AgentVersionCreateRequest,
     AgentVersionResponse,
@@ -55,6 +60,16 @@ def list_agents(
     return [AgentResponse.from_entity(item) for item in service.list_agents()]
 
 
+@router.post("/update", response_model=AgentResponse)
+def update_agent(
+    payload: AgentUpdateRequest,
+    service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentResponse:
+    entity = service.update_agent(payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"agent not found: {payload.agent_id}")
+    return AgentResponse.from_entity(entity)
+
+
 @router.patch("/{agent_id}/status", response_model=AgentResponse)
 def update_agent_status(
     agent_id: str,
@@ -66,6 +81,27 @@ def update_agent_status(
     return AgentResponse.from_entity(entity)
 
 
+@router.post("/configs/create", response_model=AgentConfigResponse)
+def create_agent_config(
+    payload: AgentConfigCreateRequest,
+    service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentConfigResponse:
+    try:
+        entity = service.create_agent_config(payload)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+    return AgentConfigResponse.from_entity(entity)
+
+
+@router.post("/configs/list", response_model=list[AgentConfigResponse])
+def list_agent_configs(
+    payload: AgentConfigListRequest,
+    service: AgentApplicationService = Depends(get_agent_application_service)) -> list[AgentConfigResponse]:
+    return [
+        AgentConfigResponse.from_entity(item)
+        for item in service.list_agent_configs(payload)
+    ]
+
+
 @router.post("/versions", response_model=AgentVersionResponse)
 def create_agent_version(
     payload: AgentVersionCreateRequest,
@@ -111,6 +147,16 @@ def list_agent_runs(
     ]
 
 
+@router.post("/runs/detail", response_model=AgentRunResponse)
+def get_agent_run(
+    payload: AgentRunDetailRequest,
+    service: AgentApplicationService = Depends(get_agent_application_service)) -> AgentRunResponse:
+    entity = service.get_agent_run(payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"agent_run not found: {payload.agent_run_id}")
+    return AgentRunResponse.from_entity(entity)
+
+
 @router.get(
     "/runs/{agent_run_id}/tool-invocations",
     response_model=list[AgentToolInvocationResponse])

+ 41 - 4
services/agent-service/app/application/services.py

@@ -5,6 +5,8 @@ from typing import cast
 from sqlalchemy.orm import Session
 
 from core_events import EventPublishContract, EventServiceClient, EventServiceClientError
+from uuid import uuid4
+
 from core_domain import (
     AgentSkillRefContract,
     AgentToolRefContract,
@@ -31,13 +33,21 @@ from app.infrastructure.skill_client import SkillServiceClient, SkillServiceClie
 from app.infrastructure.tool_client import ToolServiceClient, ToolServiceClientError
 from app.schemas.agent import (
     AgentCreateRequest,
+    AgentConfigCreateRequest,
+    AgentConfigListRequest,
     AgentRunCreateRequest,
+    AgentRunDetailRequest,
     AgentRunExecuteRequest,
     AgentRunStatusUpdateRequest,
     AgentStatusUpdateRequest,
+    AgentUpdateRequest,
     AgentVersionCreateRequest)
 
 
+def generate_agent_code() -> str:
+    return f"agent_{uuid4().hex[:16]}"
+
+
 class AgentApplicationService:
     def __init__(
         self,
@@ -71,7 +81,7 @@ class AgentApplicationService:
 
     def create_agent(self, payload: AgentCreateRequest) -> AgentDefinition:
         return self.agent_repository.create(
-            code=payload.code,
+            code=payload.code or generate_agent_code(),
             name=payload.name,
             description=payload.description,
             agent_type=payload.agent_type,
@@ -81,6 +91,13 @@ class AgentApplicationService:
     def list_agents(self) -> list[AgentDefinition]:
         return self.agent_repository.list_all()
 
+    def update_agent(self, payload: AgentUpdateRequest) -> AgentDefinition | None:
+        return self.agent_repository.update(
+            agent_id=payload.agent_id,
+            name=payload.name,
+            description=payload.description,
+            metadata_json=payload.metadata_json)
+
     def update_agent_status(
         self,
         *,
@@ -90,6 +107,22 @@ class AgentApplicationService:
             agent_id=agent_id,
             status=payload.status)
 
+    def create_agent_config(self, payload: AgentConfigCreateRequest) -> AgentVersion:
+        return self.create_agent_version(
+            AgentVersionCreateRequest(
+                agent_id=payload.agent_id,
+                status="draft",
+                role=payload.role,
+                goal=payload.goal,
+                system_prompt=payload.system_prompt,
+                model_config=payload.model_config_data,
+                memory_policy=payload.memory_policy,
+                tool_refs=payload.tool_refs,
+                skill_refs=payload.skill_refs))
+
+    def list_agent_configs(self, payload: AgentConfigListRequest) -> list[AgentVersion]:
+        return self.list_agent_versions(agent_id=payload.agent_id)
+
     def create_agent_version(self, payload: AgentVersionCreateRequest) -> AgentVersion:
         agent = self.agent_repository.get_by_id(
             agent_id=payload.agent_id)
@@ -113,9 +146,9 @@ class AgentApplicationService:
     def create_agent_run(self, payload: AgentRunCreateRequest) -> AgentRun:
         agent_version = self._resolve_agent_version(
             agent_id=payload.agent_id,
-            agent_version_id=payload.agent_version_id)
+            agent_version_id=payload.agent_config_id or payload.agent_version_id)
         if agent_version is None:
-            raise ValueError("published agent version not found")
+            raise ValueError("agent config not found")
 
         agent_run = self.agent_run_repository.create(
             agent_id=payload.agent_id,
@@ -141,6 +174,10 @@ class AgentApplicationService:
             agent_id=agent_id,
             session_id=session_id)
 
+    def get_agent_run(self, payload: AgentRunDetailRequest) -> AgentRun | None:
+        return self.agent_run_repository.get_by_id(
+            agent_run_id=payload.agent_run_id)
+
     def list_agent_tool_invocations(
         self,
         *,
@@ -565,7 +602,7 @@ class AgentApplicationService:
         if agent_version_id is not None:
             return self.agent_version_repository.get_by_id(
                 agent_version_id=agent_version_id)
-        return self.agent_version_repository.get_latest_published(
+        return self.agent_version_repository.get_latest_by_agent(
             agent_id=agent_id)
 
     def _build_chat_messages(

+ 29 - 0
services/agent-service/app/domain/repositories.py

@@ -52,6 +52,26 @@ class AgentDefinitionRepository:
         )
         return self.db.scalar(stmt)
 
+    def update(
+        self,
+        *,
+        agent_id: str,
+        name: str | None,
+        description: str | None,
+        metadata_json: dict[str, JSONValue] | None) -> AgentDefinition | None:
+        entity = self.get_by_id(agent_id=agent_id)
+        if entity is None:
+            return None
+        if name is not None:
+            entity.name = name
+        if description is not None:
+            entity.description = description
+        if metadata_json is not None:
+            entity.metadata_json = metadata_json
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
     def update_status(
         self,
         *,
@@ -125,6 +145,15 @@ class AgentVersionRepository:
         )
         return self.db.scalar(stmt)
 
+    def get_latest_by_agent(self, *, agent_id: str) -> AgentVersion | None:
+        stmt = (
+            select(AgentVersion)
+            .where(AgentVersion.agent_id == agent_id)
+            .order_by(AgentVersion.version_no.desc())
+            .limit(1)
+        )
+        return self.db.scalar(stmt)
+
     def _next_version_no(self, agent_id: str) -> int:
         stmt = select(func.max(AgentVersion.version_no)).where(AgentVersion.agent_id == agent_id)
         current_max = self.db.scalar(stmt)

+ 49 - 3
services/agent-service/app/schemas/agent.py

@@ -22,7 +22,7 @@ if TYPE_CHECKING:
 
 
 class AgentCreateRequest(BaseModel):
-    code: str
+    code: str | None = None
     name: str
     description: str | None = None
     agent_type: str = "assistant"
@@ -30,6 +30,13 @@ class AgentCreateRequest(BaseModel):
     metadata_json: dict[str, JSONValue] = Field(default_factory=dict)
 
 
+class AgentUpdateRequest(BaseModel):
+    agent_id: str
+    name: str | None = None
+    description: str | None = None
+    metadata_json: dict[str, JSONValue] | None = None
+
+
 class AgentStatusUpdateRequest(BaseModel):
     status: AgentStatus
 
@@ -40,10 +47,13 @@ class AgentResponse(AgentDefinitionContract):
         return cls.model_validate(entity, from_attributes=True)
 
 
-class AgentVersionCreateRequest(BaseModel):
+class AgentConfigListRequest(BaseModel):
+    agent_id: str
+
+
+class AgentConfigCreateRequest(BaseModel):
     model_config = ConfigDict(populate_by_name=True)
     agent_id: str
-    status: AgentVersionStatus = "draft"
     role: str = "assistant"
     goal: str | None = None
     system_prompt: str
@@ -55,20 +65,56 @@ class AgentVersionCreateRequest(BaseModel):
     skill_refs: list[AgentSkillRefContract] = Field(default_factory=list)
 
 
+class AgentVersionCreateRequest(AgentConfigCreateRequest):
+    status: AgentVersionStatus = "draft"
+
+
 class AgentVersionResponse(AgentVersionContract):
     @classmethod
     def from_entity(cls, entity: "AgentVersion") -> "AgentVersionResponse":
         return cls.model_validate(entity, from_attributes=True)
 
 
+class AgentConfigResponse(BaseModel):
+    id: str
+    agent_id: str
+    role: str
+    goal: str | None = None
+    system_prompt: str
+    model_config_json: dict[str, JSONValue]
+    memory_policy_json: dict[str, JSONValue]
+    tool_refs_json: list[dict[str, JSONValue]]
+    skill_refs_json: list[dict[str, JSONValue]]
+    created_time: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "AgentVersion") -> "AgentConfigResponse":
+        return cls(
+            id=entity.id,
+            agent_id=entity.agent_id,
+            role=entity.role,
+            goal=entity.goal,
+            system_prompt=entity.system_prompt,
+            model_config_json=entity.model_config_json,
+            memory_policy_json=entity.memory_policy_json,
+            tool_refs_json=entity.tool_refs_json,
+            skill_refs_json=entity.skill_refs_json,
+            created_time=entity.created_time)
+
+
 class AgentRunCreateRequest(BaseModel):
     agent_id: str
     agent_version_id: str | None = None
+    agent_config_id: str | None = None
     session_id: str | None = None
     input_text: str | None = None
     input_json: dict[str, JSONValue] | None = None
 
 
+class AgentRunDetailRequest(BaseModel):
+    agent_run_id: str
+
+
 class AgentRunStatusUpdateRequest(BaseModel):
     status: AgentRunStatus
     worker_key: str | None = None

+ 96 - 67
services/api-gateway/app/api/routes.py

@@ -1,4 +1,5 @@
 import asyncio
+from typing import Annotated
 
 from core_domain import ServiceDescriptor, ServiceHealth
 from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@@ -22,16 +23,17 @@ from app.schemas.gateway import (
 )
 
 router = APIRouter()
+DbSession = Annotated[Session, Depends(get_db)]
 
 
 @router.get("/health", response_model=ServiceDescriptor)
-def health_check(db: Session = Depends(get_db)) -> ServiceDescriptor:
+def health_check(db: DbSession) -> ServiceDescriptor:
     db.execute(text("SELECT 1"))
     return ServiceDescriptor(name="api-gateway")
 
 
 @router.get("/ready", response_model=ServiceHealth)
-def readiness_check(db: Session = Depends(get_db)) -> ServiceHealth:
+def readiness_check(db: DbSession) -> ServiceHealth:
     db.execute(text("SELECT 1"))
     return ServiceHealth(service="api-gateway", status="ok", database="ok")
 
@@ -39,7 +41,7 @@ def readiness_check(db: Session = Depends(get_db)) -> ServiceHealth:
 @router.post("/gateway/api-keys", response_model=ApiKeyCreateResponse)
 def create_api_key(
     payload: ApiKeyCreateRequest,
-    db: Session = Depends(get_db)) -> ApiKeyCreateResponse:
+    db: DbSession) -> ApiKeyCreateResponse:
     api_key = generate_api_key()
     entity = ApiKeyRepository(db).create(
         name=payload.name,
@@ -60,7 +62,7 @@ def create_api_key(
 
 @router.get("/gateway/api-keys", response_model=list[ApiKeyResponse])
 def list_api_keys(
-    db: Session = Depends(get_db)) -> list[ApiKeyResponse]:
+    db: DbSession) -> list[ApiKeyResponse]:
     return [
         ApiKeyResponse.from_entity(item)
         for item in ApiKeyRepository(db).list_all()
@@ -71,7 +73,7 @@ def list_api_keys(
 def update_api_key_status(
     api_key_id: str,
     payload: ApiKeyStatusUpdateRequest,
-    db: Session = Depends(get_db)) -> ApiKeyResponse:
+    db: DbSession) -> ApiKeyResponse:
     entity = ApiKeyRepository(db).update_status(
         api_key_id=api_key_id,
         status=payload.status)
@@ -82,10 +84,10 @@ def update_api_key_status(
 
 @router.get("/gateway/audits", response_model=list[GatewayRequestAuditResponse])
 def list_gateway_audits(
-    request_id: str | None = Query(default=None),
-    target_service: str | None = Query(default=None),
-    limit: int = Query(default=100, ge=1, le=500),
-    db: Session = Depends(get_db)) -> list[GatewayRequestAuditResponse]:
+    db: DbSession,
+    request_id: Annotated[str | None, Query()] = None,
+    target_service: Annotated[str | None, Query()] = None,
+    limit: Annotated[int, Query(ge=1, le=500)] = 100) -> list[GatewayRequestAuditResponse]:
     items = GatewayRequestAuditRepository(db).list_by_scope(
         request_id=request_id,
         target_service=target_service,
@@ -95,7 +97,7 @@ def list_gateway_audits(
 
 @router.get("/gateway/audits/stats", response_model=GatewayAuditStatsResponse)
 def gateway_audit_stats(
-    db: Session = Depends(get_db)) -> GatewayAuditStatsResponse:
+    db: DbSession) -> GatewayAuditStatsResponse:
     rows = GatewayRequestAuditRepository(db).stats_by_service()
     services = [
         GatewayAuditServiceStats(
@@ -115,10 +117,15 @@ def get_gateway_settings() -> ApiGatewaySettings:
     return ApiGatewaySettings()
 
 
-def get_service_proxy(settings: ApiGatewaySettings = Depends(get_gateway_settings)) -> ServiceProxy:
+def get_service_proxy(
+    settings: Annotated[ApiGatewaySettings, Depends(get_gateway_settings)]) -> ServiceProxy:
     return ServiceProxy(settings=settings, timeout_seconds=settings.proxy_timeout_seconds)
 
 
+GatewaySettingsDep = Annotated[ApiGatewaySettings, Depends(get_gateway_settings)]
+ServiceProxyDep = Annotated[ServiceProxy, Depends(get_service_proxy)]
+
+
 def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName, ProxyTarget]:
     return {
         "workflow-service": ProxyTarget(
@@ -146,6 +153,11 @@ def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName,
             base_url=settings.model_gateway_service_url,
             path_prefix="/models",
             health_path="/models/health"),
+        "model-provider-service": ProxyTarget(
+            service_name="model-provider-service",
+            base_url=settings.model_gateway_service_url,
+            path_prefix="/models/providers",
+            health_path="/models/health"),
         "code-runner-service": ProxyTarget(
             service_name="code-runner-service",
             base_url=settings.code_runner_service_url,
@@ -186,11 +198,11 @@ def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName,
             base_url=settings.event_service_url,
             path_prefix="/events",
             health_path="/events/health"),
-        "auth-service": ProxyTarget(
-            service_name="auth-service",
+        "identity-service": ProxyTarget(
+            service_name="identity-service",
             base_url=settings.auth_service_url,
-            path_prefix="/auth",
-            health_path="/auth/health"),
+            path_prefix="/identity",
+            health_path="/identity/health"),
         "scheduler-service": ProxyTarget(
             service_name="scheduler-service",
             base_url=settings.scheduler_service_url,
@@ -201,7 +213,7 @@ def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName,
 
 @router.get("/gateway/services/health", response_model=GatewayServicesHealthResponse)
 async def downstream_health_check(
-    settings: ApiGatewaySettings = Depends(get_gateway_settings)) -> GatewayServicesHealthResponse:
+    settings: GatewaySettingsDep) -> GatewayServicesHealthResponse:
     targets = build_proxy_targets(settings)
     health_proxy = ServiceProxy(
         settings=settings,
@@ -223,9 +235,9 @@ async def downstream_health_check(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_workflow_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["workflow-service"],
@@ -240,9 +252,9 @@ async def proxy_workflow_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_session_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["session-service"],
@@ -257,9 +269,9 @@ async def proxy_session_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_runtime_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["runtime-service"],
@@ -274,9 +286,9 @@ async def proxy_runtime_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_agent_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["agent-service"],
@@ -291,9 +303,9 @@ async def proxy_agent_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_memory_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["memory-service"],
@@ -308,9 +320,9 @@ async def proxy_memory_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_team_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["team-service"],
@@ -325,9 +337,9 @@ async def proxy_team_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_skill_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["skill-service"],
@@ -342,9 +354,9 @@ async def proxy_skill_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_human_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["human-service"],
@@ -359,9 +371,9 @@ async def proxy_human_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_knowledge_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["knowledge-service"],
@@ -376,9 +388,9 @@ async def proxy_knowledge_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_event_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["event-service"],
@@ -386,19 +398,19 @@ async def proxy_event_service(
 
 
 @router.api_route(
-    "/gateway/auth",
-    methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
+    "/gateway/identity",
+    methods=["POST"])
 @router.api_route(
-    "/gateway/auth/{path:path}",
-    methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
-async def proxy_auth_service(
+    "/gateway/identity/{path:path}",
+    methods=["POST"])
+async def proxy_identity_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
-        target=build_proxy_targets(settings)["auth-service"],
+        target=build_proxy_targets(settings)["identity-service"],
         path=path)
 
 
@@ -410,9 +422,9 @@ async def proxy_auth_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_scheduler_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["scheduler-service"],
@@ -427,9 +439,9 @@ async def proxy_scheduler_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_tool_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["tool-service"],
@@ -444,15 +456,32 @@ async def proxy_tool_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_model_gateway_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["model-gateway-service"],
         path=path)
 
 
+@router.api_route(
+    "/gateway/model-providers",
+    methods=["POST"])
+@router.api_route(
+    "/gateway/model-providers/{path:path}",
+    methods=["POST"])
+async def proxy_model_provider_service(
+    request: Request,
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
+    return await proxy.forward(
+        request=request,
+        target=build_proxy_targets(settings)["model-provider-service"],
+        path=path)
+
+
 @router.api_route(
     "/gateway/code",
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
@@ -461,9 +490,9 @@ async def proxy_model_gateway_service(
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
 async def proxy_code_runner_service(
     request: Request,
-    path: str = "",
-    settings: ApiGatewaySettings = Depends(get_gateway_settings),
-    proxy: ServiceProxy = Depends(get_service_proxy)) -> Response:
+    settings: GatewaySettingsDep,
+    proxy: ServiceProxyDep,
+    path: str = "") -> Response:
     return await proxy.forward(
         request=request,
         target=build_proxy_targets(settings)["code-runner-service"],

+ 2 - 1
services/api-gateway/app/infrastructure/proxy.py

@@ -17,6 +17,7 @@ ProxyServiceName = Literal[
     "runtime-service",
     "tool-service",
     "model-gateway-service",
+    "model-provider-service",
     "code-runner-service",
     "agent-service",
     "memory-service",
@@ -25,7 +26,7 @@ ProxyServiceName = Literal[
     "human-service",
     "knowledge-service",
     "event-service",
-    "auth-service",
+    "identity-service",
     "scheduler-service",
 ]
 

+ 22 - 13
services/api-gateway/app/infrastructure/request_context.py

@@ -48,7 +48,6 @@ class GatewayRequestContextMiddleware(BaseHTTPMiddleware):
                 session_factory=request.app.state.session_factory,
                 status_code=auth_response.status_code,
                 error_message=None)
-            context = get_gateway_request_context(request)
             auth_response.headers[REQUEST_ID_HEADER] = request_id
             return auth_response
 
@@ -65,7 +64,6 @@ class GatewayRequestContextMiddleware(BaseHTTPMiddleware):
                 session_factory=request.app.state.session_factory,
                 status_code=rate_limit_response.status_code,
                 error_message="rate limit exceeded")
-            context = get_gateway_request_context(request)
             rate_limit_response.headers[REQUEST_ID_HEADER] = request_id
             return rate_limit_response
 
@@ -87,7 +85,6 @@ class GatewayRequestContextMiddleware(BaseHTTPMiddleware):
             request=request,
             session_factory=request.app.state.session_factory,
             status_code=response.status_code)
-        context = get_gateway_request_context(request)
         response.headers[REQUEST_ID_HEADER] = request_id
         apply_gateway_rate_limit_headers(
             response=response,
@@ -171,7 +168,7 @@ def authenticate_gateway_request(request: Request) -> Response | None:
 
 
 def is_auth_login_request(request: Request) -> bool:
-    return request.method.upper() == "POST" and request.url.path == "/gateway/auth/login"
+    return request.method.upper() == "POST" and request.url.path == "/gateway/identity/auth/login"
 
 
 def is_initial_api_key_bootstrap_request(request: Request) -> bool:
@@ -203,9 +200,9 @@ def authenticate_bearer_token(
     try:
         with httpx.Client(timeout=settings.authz_timeout_seconds) as client:
             response = client.post(
-                f"{settings.auth_service_url.rstrip('/')}/auth/tokens/verify",
+                f"{settings.auth_service_url.rstrip('/')}/identity/auth/tokens/verify",
                 headers=build_internal_service_headers(settings),
-                json={"access_token": token})
+                json={"accessToken": token})
             response.raise_for_status()
             payload = response.json()
     except (httpx.HTTPError, ValueError) as exc:
@@ -213,8 +210,14 @@ def authenticate_bearer_token(
             status_code=503,
             content={"detail": "auth token verification failed", "error": str(exc)})
 
-    if payload.get("active") is not True:
-        reason = payload.get("reason")
+    data = payload.get("data")
+    if not isinstance(data, dict):
+        return JSONResponse(
+            status_code=401,
+            content={"detail": "invalid token verification response"})
+
+    if data.get("active") is not True:
+        reason = data.get("reason")
         return JSONResponse(
             status_code=401,
             content={
@@ -222,7 +225,7 @@ def authenticate_bearer_token(
                 "reason": reason if isinstance(reason, str) else "inactive",
             })
 
-    user_id = payload.get("user_id")
+    user_id = data.get("userId")
     if not isinstance(user_id, str):
         return JSONResponse(
             status_code=401,
@@ -280,10 +283,10 @@ def check_auth_service_permission(
     try:
         with httpx.Client(timeout=settings.authz_timeout_seconds) as client:
             response = client.post(
-                f"{settings.auth_service_url.rstrip('/')}/auth/permissions/check",
+                f"{settings.auth_service_url.rstrip('/')}/identity/permissions/check",
                 headers=build_internal_service_headers(settings),
                 json={
-                    "user_id": user_id,
+                    "userId": user_id,
                     "permission": permission,
                 })
             response.raise_for_status()
@@ -293,10 +296,16 @@ def check_auth_service_permission(
             status_code=503,
             content={"detail": "auth service permission check failed", "error": str(exc)})
 
-    allowed = payload.get("allowed")
+    data = payload.get("data")
+    if not isinstance(data, dict):
+        return JSONResponse(
+            status_code=403,
+            content={"detail": "invalid permission check response"})
+
+    allowed = data.get("allowed")
     if allowed is True:
         return None
-    reason = payload.get("reason")
+    reason = data.get("reason")
     return JSONResponse(
         status_code=403,
         content={

+ 2 - 1
services/auth-service/alembic/versions/20260427_0002_add_user_password_hash.py

@@ -20,7 +20,8 @@ def upgrade() -> None:
     op.add_column(
         "auth_user",
         sa.Column("password_hash", sa.String(length=512), nullable=False, server_default=""))
-    op.alter_column("auth_user", "password_hash", server_default=None)
+    if op.get_bind().dialect.name != "sqlite":
+        op.alter_column("auth_user", "password_hash", server_default=None)
 
 
 def downgrade() -> None:

+ 81 - 0
services/auth-service/alembic/versions/20260428_0004_add_identity_contract_tables.py

@@ -0,0 +1,81 @@
+"""add identity contract tables
+
+Revision ID: 20260428_0004
+Revises: 20260427_0003
+Create Date: 2026-04-28 22:35:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260428_0004"
+down_revision: str | None = "20260427_0003"
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "auth_role_permission_binding",
+        sa.Column("role_id", sa.String(length=36), nullable=False),
+        sa.Column("permission", sa.String(length=256), nullable=False),
+        sa.Column("scope_type", sa.String(length=64), nullable=True),
+        sa.Column("scope_id", sa.String(length=64), nullable=True),
+        sa.Column("id", sa.String(length=36), nullable=False),
+        sa.Column("created_by", sa.String(length=36), nullable=True),
+        sa.Column("updated_by", sa.String(length=36), nullable=True),
+        sa.Column("created_time", sa.DateTime(), nullable=False),
+        sa.Column("updated_time", sa.DateTime(), nullable=False),
+        sa.Column("deleted_time", sa.DateTime(), nullable=True),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index(
+        "ix_auth_role_permission_binding_role_id",
+        "auth_role_permission_binding",
+        ["role_id"],
+    )
+    op.create_index(
+        "ix_auth_role_permission_binding_permission",
+        "auth_role_permission_binding",
+        ["permission"],
+    )
+    op.create_index(
+        "ix_auth_role_permission_binding_scope_type",
+        "auth_role_permission_binding",
+        ["scope_type"],
+    )
+    op.create_index(
+        "ix_auth_role_permission_binding_scope_id",
+        "auth_role_permission_binding",
+        ["scope_id"],
+    )
+
+    op.create_table(
+        "auth_api_key",
+        sa.Column("name", sa.String(length=128), nullable=False),
+        sa.Column("key_prefix", sa.String(length=16), nullable=False),
+        sa.Column("key_hash", sa.String(length=128), nullable=False),
+        sa.Column("scopes", sa.Text(), nullable=True),
+        sa.Column("expires_time", sa.DateTime(), nullable=True),
+        sa.Column("last_used_time", sa.DateTime(), nullable=True),
+        sa.Column("revoked_time", sa.DateTime(), nullable=True),
+        sa.Column("id", sa.String(length=36), nullable=False),
+        sa.Column("created_by", sa.String(length=36), nullable=True),
+        sa.Column("updated_by", sa.String(length=36), nullable=True),
+        sa.Column("created_time", sa.DateTime(), nullable=False),
+        sa.Column("updated_time", sa.DateTime(), nullable=False),
+        sa.Column("deleted_time", sa.DateTime(), nullable=True),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_auth_api_key_key_prefix", "auth_api_key", ["key_prefix"])
+    op.create_index("ix_auth_api_key_key_hash", "auth_api_key", ["key_hash"], unique=True)
+    op.create_index("ix_auth_api_key_revoked_time", "auth_api_key", ["revoked_time"])
+
+
+def downgrade() -> None:
+    op.drop_table("auth_api_key")
+    op.drop_table("auth_role_permission_binding")

+ 296 - 0
services/auth-service/app/api/identity_routes.py

@@ -0,0 +1,296 @@
+from datetime import datetime
+from typing import Annotated, TypeVar
+
+from core_domain import ServiceHealth
+from fastapi import APIRouter, Depends, Header, HTTPException, Request
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.application.services import AuthApplicationService
+from app.db.session import get_db
+from app.domain.repositories import (
+    ApiKeyRepository,
+    RoleAssignmentRepository,
+    RolePermissionBindingRepository,
+    RoleRepository,
+    UserRepository,
+)
+from app.schemas.identity import (
+    ApiKeyCreateData,
+    ApiKeyCreateRequestDto,
+    ApiKeyDto,
+    ApiKeyRevokeRequest,
+    ApiResponse,
+    AuthMeData,
+    BindingRemoveRequest,
+    DeleteData,
+    LoginData,
+    LoginRequestDto,
+    PageRequest,
+    PageResult,
+    PermissionCheckData,
+    PermissionCheckRequestDto,
+    RoleDto,
+    RolePermissionBindingAddRequest,
+    RolePermissionBindingDto,
+    RolePermissionBindingListRequest,
+    TokenVerifyData,
+    TokenVerifyRequestDto,
+    UserDto,
+)
+
+router = APIRouter()
+DbSession = Annotated[Session, Depends(get_db)]
+T = TypeVar("T")
+
+
+def get_identity_application_service(request: Request, db: DbSession) -> AuthApplicationService:
+    settings = request.app.state.settings
+    return AuthApplicationService(
+        user_repository=UserRepository(db),
+        role_repository=RoleRepository(db),
+        assignment_repository=RoleAssignmentRepository(db),
+        permission_binding_repository=RolePermissionBindingRepository(db),
+        api_key_repository=ApiKeyRepository(db),
+        token_secret=settings.credential_encryption_key)
+
+
+IdentityServiceDep = Annotated[AuthApplicationService, Depends(get_identity_application_service)]
+AuthorizationHeader = Annotated[str | None, Header(alias="Authorization")]
+
+
+def ok(request: Request, data: T) -> ApiResponse[T]:
+    return ApiResponse[T](
+        data=data,
+        requestId=request.headers.get("x-request-id", ""),
+        serverTime=datetime.utcnow())
+
+
+def get_bearer_token(authorization: str | None) -> str:
+    if not authorization:
+        raise HTTPException(status_code=401, detail="missing authorization header")
+    scheme, _, token = authorization.partition(" ")
+    if scheme.lower() != "bearer" or not token:
+        raise HTTPException(status_code=401, detail="invalid authorization header")
+    return token
+
+
+@router.get("/health", response_model=ServiceHealth)
+def health_check(db: DbSession) -> ServiceHealth:
+    db.execute(text("SELECT 1"))
+    return ServiceHealth(service="identity-service", status="ok", database="ok")
+
+
+@router.post("/auth/login", response_model=ApiResponse[LoginData])
+def login(
+    request: Request,
+    payload: LoginRequestDto,
+    service: IdentityServiceDep) -> ApiResponse[LoginData]:
+    result = service.login(username=payload.username, password=payload.password)
+    if result is None:
+        raise HTTPException(status_code=401, detail="invalid username or password")
+    return ok(
+        request,
+        LoginData(
+            accessToken=result.access_token,
+            expiresTime=result.expires_time,
+            user=UserDto.from_entity(result.user)))
+
+
+@router.post("/auth/logout", response_model=ApiResponse[dict[str, bool]])
+def logout(request: Request) -> ApiResponse[dict[str, bool]]:
+    return ok(request, {"ok": True})
+
+
+@router.post("/auth/tokens/verify", response_model=ApiResponse[TokenVerifyData])
+def verify_token(
+    request: Request,
+    payload: TokenVerifyRequestDto,
+    service: IdentityServiceDep) -> ApiResponse[TokenVerifyData]:
+    result = service.verify_token(access_token=payload.accessToken)
+    return ok(
+        request,
+        TokenVerifyData(
+            active=result.active,
+            userId=result.user_id,
+            username=result.username,
+            expiresTime=result.expires_time,
+            reason=result.reason))
+
+
+@router.post("/auth/me", response_model=ApiResponse[AuthMeData])
+def me(
+    request: Request,
+    service: IdentityServiceDep,
+    authorization: AuthorizationHeader = None) -> ApiResponse[AuthMeData]:
+    token = get_bearer_token(authorization)
+    verified = service.verify_token(access_token=token)
+    if not verified.active or verified.user_id is None:
+        raise HTTPException(status_code=401, detail=verified.reason or "invalid token")
+    user = service.user_repository.get_by_id(user_id=verified.user_id)
+    if user is None:
+        raise HTTPException(status_code=401, detail="user not found")
+
+    assignments = service.assignment_repository.list_by_user(user_id=user.id)
+    roles = []
+    permissions: set[str] = set()
+    for assignment in assignments:
+        role = service.role_repository.get_by_id(role_id=assignment.role_id)
+        if role is None:
+            continue
+        bindings = service.permission_binding_repository.list_all_by_role(role_id=role.id)
+        roles.append(RoleDto.from_entity(role, permission_binding_count=len(bindings)))
+        permissions.update(binding.permission for binding in bindings)
+    return ok(
+        request,
+        AuthMeData(
+            user=UserDto.from_entity(user),
+            roles=roles,
+            permissions=sorted(permissions)))
+
+
+@router.post("/users/list", response_model=ApiResponse[PageResult[UserDto]])
+def list_users(
+    request: Request,
+    payload: PageRequest,
+    service: IdentityServiceDep) -> ApiResponse[PageResult[UserDto]]:
+    items, total = service.list_users_page(
+        page=payload.page,
+        page_size=payload.pageSize,
+        keyword=payload.keyword)
+    return ok(
+        request,
+        PageResult[UserDto].from_items(
+            items=[UserDto.from_entity(item) for item in items],
+            total=total,
+            page=payload.page,
+            page_size=payload.pageSize))
+
+
+@router.post("/roles/list", response_model=ApiResponse[PageResult[RoleDto]])
+def list_roles(
+    request: Request,
+    payload: PageRequest,
+    service: IdentityServiceDep) -> ApiResponse[PageResult[RoleDto]]:
+    items, total = service.list_roles_page(
+        page=payload.page,
+        page_size=payload.pageSize,
+        keyword=payload.keyword)
+    binding_repo = service.permission_binding_repository
+    return ok(
+        request,
+        PageResult[RoleDto].from_items(
+            items=[
+                RoleDto.from_entity(
+                    item,
+                    permission_binding_count=len(binding_repo.list_all_by_role(role_id=item.id)))
+                for item in items
+            ],
+            total=total,
+            page=payload.page,
+            page_size=payload.pageSize))
+
+
+@router.post(
+    "/rolePermissionBindings/list",
+    response_model=ApiResponse[PageResult[RolePermissionBindingDto]])
+def list_role_permission_bindings(
+    request: Request,
+    payload: RolePermissionBindingListRequest,
+    service: IdentityServiceDep) -> ApiResponse[PageResult[RolePermissionBindingDto]]:
+    items, total = service.list_role_permission_bindings(
+        role_id=payload.roleId,
+        page=payload.page,
+        page_size=payload.pageSize)
+    return ok(
+        request,
+        PageResult[RolePermissionBindingDto].from_items(
+            items=[RolePermissionBindingDto.from_entity(item) for item in items],
+            total=total,
+            page=payload.page,
+            page_size=payload.pageSize))
+
+
+@router.post("/rolePermissionBindings/add", response_model=ApiResponse[RolePermissionBindingDto])
+def add_role_permission_binding(
+    request: Request,
+    payload: RolePermissionBindingAddRequest,
+    service: IdentityServiceDep) -> ApiResponse[RolePermissionBindingDto]:
+    entity = service.add_role_permission_binding(
+        role_id=payload.roleId,
+        permission=payload.permission,
+        scope_type=payload.scopeType,
+        scope_id=payload.scopeId)
+    return ok(request, RolePermissionBindingDto.from_entity(entity))
+
+
+@router.post("/rolePermissionBindings/remove", response_model=ApiResponse[DeleteData])
+def remove_role_permission_binding(
+    request: Request,
+    payload: BindingRemoveRequest,
+    service: IdentityServiceDep) -> ApiResponse[DeleteData]:
+    deleted = service.remove_role_permission_binding(binding_id=payload.bindingId)
+    return ok(request, DeleteData(deleted=deleted, bindingId=payload.bindingId))
+
+
+@router.post("/permissions/check", response_model=ApiResponse[PermissionCheckData])
+def check_permission(
+    request: Request,
+    payload: PermissionCheckRequestDto,
+    service: IdentityServiceDep) -> ApiResponse[PermissionCheckData]:
+    result = service.check_permission(
+        user_id=payload.userId,
+        permission=payload.permission,
+        scope_type=payload.scopeType,
+        scope_id=payload.scopeId)
+    return ok(
+        request,
+        PermissionCheckData(
+            allowed=result.allowed,
+            reason=result.reason,
+            matchedRoleIds=result.matched_role_ids))
+
+
+@router.post("/apiKeys/list", response_model=ApiResponse[PageResult[ApiKeyDto]])
+def list_api_keys(
+    request: Request,
+    payload: PageRequest,
+    service: IdentityServiceDep) -> ApiResponse[PageResult[ApiKeyDto]]:
+    items, total = service.list_api_keys_page(
+        page=payload.page,
+        page_size=payload.pageSize,
+        keyword=payload.keyword)
+    return ok(
+        request,
+        PageResult[ApiKeyDto].from_items(
+            items=[ApiKeyDto.from_entity(item) for item in items],
+            total=total,
+            page=payload.page,
+            page_size=payload.pageSize))
+
+
+@router.post("/apiKeys/create", response_model=ApiResponse[ApiKeyCreateData])
+def create_api_key(
+    request: Request,
+    payload: ApiKeyCreateRequestDto,
+    service: IdentityServiceDep) -> ApiResponse[ApiKeyCreateData]:
+    entity, secret = service.create_api_key(
+        name=payload.name,
+        scopes=payload.scopes,
+        expires_time=payload.expiresTime)
+    return ok(
+        request,
+        ApiKeyCreateData(
+            apiKey=ApiKeyDto.from_entity(entity),
+            secret=secret))
+
+
+@router.post("/apiKeys/revoke", response_model=ApiResponse[ApiKeyDto])
+def revoke_api_key(
+    request: Request,
+    payload: ApiKeyRevokeRequest,
+    service: IdentityServiceDep) -> ApiResponse[ApiKeyDto]:
+    entity = service.revoke_api_key(api_key_id=payload.apiKeyId)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"api key not found: {payload.apiKeyId}")
+    return ok(request, ApiKeyDto.from_entity(entity))

+ 0 - 149
services/auth-service/app/api/routes.py

@@ -1,149 +0,0 @@
-from typing import Annotated
-
-from core_domain import ServiceHealth
-from fastapi import APIRouter, Depends, HTTPException, Query, Request
-from sqlalchemy import text
-from sqlalchemy.orm import Session
-
-from app.application.services import AuthApplicationService
-from app.db.session import get_db
-from app.domain.repositories import RoleAssignmentRepository, RoleRepository, UserRepository
-from app.schemas.auth import (
-    LoginRequest,
-    LoginResponse,
-    PermissionCheckRequest,
-    PermissionCheckResponse,
-    RoleAssignmentCreateRequest,
-    RoleAssignmentResponse,
-    RoleAssignmentStatusUpdateRequest,
-    RoleCreateRequest,
-    RoleResponse,
-    RoleStatusUpdateRequest,
-    TokenVerifyRequest,
-    TokenVerifyResponse,
-    UserCreateRequest,
-    UserResponse,
-    UserStatusUpdateRequest,
-)
-
-router = APIRouter()
-DbSession = Annotated[Session, Depends(get_db)]
-UserIdQuery = Annotated[str, Query(...)]
-
-
-def get_auth_application_service(request: Request, db: DbSession) -> AuthApplicationService:
-    settings = request.app.state.settings
-    return AuthApplicationService(
-        user_repository=UserRepository(db),
-        role_repository=RoleRepository(db),
-        assignment_repository=RoleAssignmentRepository(db),
-        token_secret=settings.credential_encryption_key)
-
-
-AuthServiceDep = Annotated[AuthApplicationService, Depends(get_auth_application_service)]
-
-
-@router.get("/health", response_model=ServiceHealth)
-def health_check(db: DbSession) -> ServiceHealth:
-    db.execute(text("SELECT 1"))
-    return ServiceHealth(service="auth-service", status="ok", database="ok")
-
-
-@router.post("/login", response_model=LoginResponse)
-def login(
-    payload: LoginRequest,
-    service: AuthServiceDep) -> LoginResponse:
-    result = service.login(payload)
-    if result is None:
-        raise HTTPException(status_code=401, detail="invalid username or password")
-    return result
-
-
-@router.post("/tokens/verify", response_model=TokenVerifyResponse)
-def verify_token(
-    payload: TokenVerifyRequest,
-    service: AuthServiceDep) -> TokenVerifyResponse:
-    return service.verify_token(payload)
-
-
-@router.post("/users", response_model=UserResponse)
-def create_user(
-    payload: UserCreateRequest,
-    service: AuthServiceDep) -> UserResponse:
-    return UserResponse.from_entity(service.create_user(payload))
-
-
-@router.get("/users", response_model=list[UserResponse])
-def list_users(
-    service: AuthServiceDep) -> list[UserResponse]:
-    return [UserResponse.from_entity(item) for item in service.list_users()]
-
-
-@router.patch("/users/{user_id}/status", response_model=UserResponse)
-def update_user_status(
-    user_id: str,
-    payload: UserStatusUpdateRequest,
-    service: AuthServiceDep) -> UserResponse:
-    entity = service.update_user_status(user_id=user_id, payload=payload)
-    if entity is None:
-        raise HTTPException(status_code=404, detail=f"user not found: {user_id}")
-    return UserResponse.from_entity(entity)
-
-
-@router.post("/roles", response_model=RoleResponse)
-def create_role(
-    payload: RoleCreateRequest,
-    service: AuthServiceDep) -> RoleResponse:
-    return RoleResponse.from_entity(service.create_role(payload))
-
-
-@router.get("/roles", response_model=list[RoleResponse])
-def list_roles(
-    service: AuthServiceDep) -> list[RoleResponse]:
-    return [RoleResponse.from_entity(item) for item in service.list_roles()]
-
-
-@router.patch("/roles/{role_id}/status", response_model=RoleResponse)
-def update_role_status(
-    role_id: str,
-    payload: RoleStatusUpdateRequest,
-    service: AuthServiceDep) -> RoleResponse:
-    entity = service.update_role_status(role_id=role_id, payload=payload)
-    if entity is None:
-        raise HTTPException(status_code=404, detail=f"role not found: {role_id}")
-    return RoleResponse.from_entity(entity)
-
-
-@router.post("/assignments", response_model=RoleAssignmentResponse)
-def create_assignment(
-    payload: RoleAssignmentCreateRequest,
-    service: AuthServiceDep) -> RoleAssignmentResponse:
-    return RoleAssignmentResponse.from_entity(service.create_assignment(payload))
-
-
-@router.get("/assignments", response_model=list[RoleAssignmentResponse])
-def list_assignments(
-    user_id: UserIdQuery,
-    service: AuthServiceDep) -> list[RoleAssignmentResponse]:
-    return [
-        RoleAssignmentResponse.from_entity(item)
-        for item in service.list_assignments(user_id=user_id)
-    ]
-
-
-@router.patch("/assignments/{assignment_id}/status", response_model=RoleAssignmentResponse)
-def update_assignment_status(
-    assignment_id: str,
-    payload: RoleAssignmentStatusUpdateRequest,
-    service: AuthServiceDep) -> RoleAssignmentResponse:
-    entity = service.update_assignment_status(assignment_id=assignment_id, payload=payload)
-    if entity is None:
-        raise HTTPException(status_code=404, detail=f"assignment not found: {assignment_id}")
-    return RoleAssignmentResponse.from_entity(entity)
-
-
-@router.post("/permissions/check", response_model=PermissionCheckResponse)
-def check_permission(
-    payload: PermissionCheckRequest,
-    service: AuthServiceDep) -> PermissionCheckResponse:
-    return service.check_permission(payload)

+ 160 - 84
services/auth-service/app/application/services.py

@@ -1,24 +1,40 @@
+from dataclasses import dataclass
 from datetime import datetime
 
-from app.db.models import Role, RoleAssignment, User
-from app.domain.repositories import RoleAssignmentRepository, RoleRepository, UserRepository
-from app.infrastructure.passwords import hash_password, verify_password
-from app.infrastructure.tokens import TokenError, issue_access_token, verify_access_token
-from app.schemas.auth import (
-    LoginRequest,
-    LoginResponse,
-    LoginUserResponse,
-    PermissionCheckRequest,
-    PermissionCheckResponse,
-    RoleAssignmentCreateRequest,
-    RoleAssignmentStatusUpdateRequest,
-    RoleCreateRequest,
-    RoleStatusUpdateRequest,
-    TokenVerifyRequest,
-    TokenVerifyResponse,
-    UserCreateRequest,
-    UserStatusUpdateRequest,
+from app.db.models import ApiKey, Role, RoleAssignment, RolePermissionBinding, User
+from app.domain.repositories import (
+    ApiKeyRepository,
+    RoleAssignmentRepository,
+    RolePermissionBindingRepository,
+    RoleRepository,
+    UserRepository,
 )
+from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
+from app.infrastructure.passwords import verify_password
+from app.infrastructure.tokens import TokenError, issue_access_token, verify_access_token
+
+
+@dataclass(frozen=True)
+class LoginResult:
+    access_token: str
+    expires_time: datetime
+    user: User
+
+
+@dataclass(frozen=True)
+class TokenVerificationResult:
+    active: bool
+    user_id: str | None = None
+    username: str | None = None
+    expires_time: datetime | None = None
+    reason: str | None = None
+
+
+@dataclass(frozen=True)
+class PermissionCheckResult:
+    allowed: bool
+    reason: str
+    matched_role_ids: list[str]
 
 
 class AuthApplicationService:
@@ -28,52 +44,44 @@ class AuthApplicationService:
         user_repository: UserRepository,
         role_repository: RoleRepository,
         assignment_repository: RoleAssignmentRepository,
+        permission_binding_repository: RolePermissionBindingRepository,
+        api_key_repository: ApiKeyRepository,
         token_secret: str) -> None:
         self.user_repository = user_repository
         self.role_repository = role_repository
         self.assignment_repository = assignment_repository
+        self.permission_binding_repository = permission_binding_repository
+        self.api_key_repository = api_key_repository
         self.token_secret = token_secret
 
-    def create_user(self, payload: UserCreateRequest) -> User:
-        return self.user_repository.create(
-            username=payload.username,
-            password_hash=hash_password(payload.password) if payload.password else "",
-            display_name=payload.display_name,
-            email=payload.email,
-            metadata_json=payload.metadata_json)
-
-    def login(self, payload: LoginRequest) -> LoginResponse | None:
-        user = self.user_repository.get_by_username(
-            username=payload.username)
+    def login(self, *, username: str, password: str) -> LoginResult | None:
+        user = self.user_repository.get_by_username(username=username)
         if user is None or user.status != "active":
             return None
-        if not verify_password(payload.password, user.password_hash):
+        if not verify_password(password, user.password_hash):
             return None
 
         self.user_repository.touch_last_login_time(user_id=user.id)
         access_token, expires_time = issue_access_token(
             user_id=user.id,
             secret=self.token_secret)
-        return LoginResponse(
+        return LoginResult(
             access_token=access_token,
             expires_time=expires_time,
-            user=LoginUserResponse.from_entity(user))
+            user=user)
 
-    def verify_token(self, payload: TokenVerifyRequest) -> TokenVerifyResponse:
+    def verify_token(self, *, access_token: str) -> TokenVerificationResult:
         try:
-            token_payload = verify_access_token(
-                payload.access_token,
-                secret=self.token_secret)
+            token_payload = verify_access_token(access_token, secret=self.token_secret)
         except TokenError as exc:
-            return TokenVerifyResponse(active=False, reason=str(exc))
+            return TokenVerificationResult(active=False, reason=str(exc))
 
         expires_time_raw = token_payload["expires_time"]
-        user = self.user_repository.get_by_id(
-            user_id=token_payload["user_id"])
+        user = self.user_repository.get_by_id(user_id=token_payload["user_id"])
         if user is None or user.status != "active":
-            return TokenVerifyResponse(active=False, reason="user_not_active")
+            return TokenVerificationResult(active=False, reason="user_not_active")
 
-        return TokenVerifyResponse(
+        return TokenVerificationResult(
             active=True,
             user_id=user.id,
             username=user.username,
@@ -82,56 +90,105 @@ class AuthApplicationService:
     def list_users(self) -> list[User]:
         return self.user_repository.list_all()
 
-    def update_user_status(self, *, user_id: str, payload: UserStatusUpdateRequest) -> User | None:
-        return self.user_repository.update_status(
-            user_id=user_id,
-            status=payload.status)
-
-    def create_role(self, payload: RoleCreateRequest) -> Role:
-        return self.role_repository.create(
-            code=payload.code,
-            name=payload.name,
-            description=payload.description,
-            permissions_json=payload.permissions_json)
+    def list_users_page(
+        self,
+        *,
+        page: int,
+        page_size: int,
+        keyword: str | None) -> tuple[list[User], int]:
+        return self.user_repository.list_page(
+            offset=(page - 1) * page_size,
+            limit=page_size,
+            keyword=keyword)
 
     def list_roles(self) -> list[Role]:
         return self.role_repository.list_all()
 
-    def update_role_status(self, *, role_id: str, payload: RoleStatusUpdateRequest) -> Role | None:
-        return self.role_repository.update_status(
-            role_id=role_id,
-            status=payload.status)
-
-    def create_assignment(
+    def list_roles_page(
         self,
-        payload: RoleAssignmentCreateRequest) -> RoleAssignment:
-        return self.assignment_repository.create(
-            user_id=payload.user_id,
-            role_id=payload.role_id,
-            scope_type=payload.scope_type,
-            scope_id=payload.scope_id,
-            expires_time=payload.expires_time)
+        *,
+        page: int,
+        page_size: int,
+        keyword: str | None) -> tuple[list[Role], int]:
+        return self.role_repository.list_page(
+            offset=(page - 1) * page_size,
+            limit=page_size,
+            keyword=keyword)
 
     def list_assignments(self, *, user_id: str) -> list[RoleAssignment]:
         return self.assignment_repository.list_by_user(user_id=user_id)
 
-    def update_assignment_status(
+    def list_role_permission_bindings(
+        self,
+        *,
+        role_id: str,
+        page: int,
+        page_size: int) -> tuple[list[RolePermissionBinding], int]:
+        return self.permission_binding_repository.list_by_role(
+            role_id=role_id,
+            offset=(page - 1) * page_size,
+            limit=page_size)
+
+    def add_role_permission_binding(
         self,
         *,
-        assignment_id: str,
-        payload: RoleAssignmentStatusUpdateRequest) -> RoleAssignment | None:
-        return self.assignment_repository.update_status(
-            assignment_id=assignment_id,
-            status=payload.status)
-
-    def check_permission(self, payload: PermissionCheckRequest) -> PermissionCheckResponse:
-        user = self.user_repository.get_by_id(
-            user_id=payload.user_id)
+        role_id: str,
+        permission: str,
+        scope_type: str | None,
+        scope_id: str | None) -> RolePermissionBinding:
+        return self.permission_binding_repository.create(
+            role_id=role_id,
+            permission=permission,
+            scope_type=scope_type,
+            scope_id=scope_id)
+
+    def remove_role_permission_binding(self, *, binding_id: str) -> bool:
+        return self.permission_binding_repository.delete(binding_id=binding_id)
+
+    def list_api_keys_page(
+        self,
+        *,
+        page: int,
+        page_size: int,
+        keyword: str | None) -> tuple[list[ApiKey], int]:
+        return self.api_key_repository.list_page(
+            offset=(page - 1) * page_size,
+            limit=page_size,
+            keyword=keyword)
+
+    def create_api_key(
+        self,
+        *,
+        name: str,
+        scopes: str | None,
+        expires_time: datetime | None) -> tuple[ApiKey, str]:
+        secret = generate_api_key()
+        entity = self.api_key_repository.create(
+            name=name,
+            key_prefix=get_api_key_prefix(secret),
+            key_hash=hash_api_key(secret),
+            scopes=scopes,
+            expires_time=expires_time)
+        return entity, secret
+
+    def revoke_api_key(self, *, api_key_id: str) -> ApiKey | None:
+        return self.api_key_repository.revoke(api_key_id=api_key_id)
+
+    def check_permission(
+        self,
+        *,
+        user_id: str,
+        permission: str,
+        scope_type: str | None,
+        scope_id: str | None) -> PermissionCheckResult:
+        user = self.user_repository.get_by_id(user_id=user_id)
         if user is None or user.status != "active":
-            return PermissionCheckResponse(allowed=False, reason="user_not_active")
+            return PermissionCheckResult(
+                allowed=False,
+                reason="user_not_active",
+                matched_role_ids=[])
 
-        assignments = self.assignment_repository.list_by_user(
-            user_id=payload.user_id)
+        assignments = self.assignment_repository.list_by_user(user_id=user_id)
         matched_role_ids: list[str] = []
         now = datetime.utcnow()
         for assignment in assignments:
@@ -141,17 +198,20 @@ class AuthApplicationService:
                 continue
             if not self._scope_matches(
                 assignment=assignment,
-                scope_type=payload.scope_type,
-                scope_id=payload.scope_id):
+                scope_type=scope_type,
+                scope_id=scope_id):
                 continue
-            role = self.role_repository.get_by_id(
-                role_id=assignment.role_id)
+            role = self.role_repository.get_by_id(role_id=assignment.role_id)
             if role is None or role.status != "active":
                 continue
-            if self._permission_matches(role.permissions_json, payload.permission):
+            if self._role_has_permission(
+                role,
+                permission,
+                scope_type=scope_type,
+                scope_id=scope_id):
                 matched_role_ids.append(role.id)
 
-        return PermissionCheckResponse(
+        return PermissionCheckResult(
             allowed=bool(matched_role_ids),
             reason="matched" if matched_role_ids else "permission_not_found",
             matched_role_ids=matched_role_ids)
@@ -174,3 +234,19 @@ class AuthApplicationService:
         if assignment.scope_type is None and assignment.scope_id is None:
             return True
         return assignment.scope_type == scope_type and assignment.scope_id == scope_id
+
+    def _role_has_permission(
+        self,
+        role: Role,
+        requested_permission: str,
+        *,
+        scope_type: str | None,
+        scope_id: str | None) -> bool:
+        bindings = self.permission_binding_repository.list_all_by_role(role_id=role.id)
+        return any(
+            (binding.scope_type is None and binding.scope_id is None
+             or (binding.scope_type == scope_type and binding.scope_id == scope_id))
+            and
+            self._permission_matches([binding.permission], requested_permission)
+            for binding in bindings
+        )

+ 4 - 2
services/auth-service/app/bootstrap/app.py

@@ -2,7 +2,8 @@ from core_shared.observability import add_observability
 from core_shared.security import add_internal_service_auth
 from fastapi import FastAPI
 
-from app.api.routes import router
+from app.api.identity_routes import router as identity_router
+from app.bootstrap.demo_seed import bootstrap_demo_identity
 from app.bootstrap.settings import AuthServiceSettings
 from app.db.session import build_session_factory
 
@@ -14,7 +15,8 @@ def create_app() -> FastAPI:
         version="0.1.0")
     app.state.settings = settings
     app.state.session_factory = build_session_factory(settings)
+    bootstrap_demo_identity(settings=settings, session_factory=app.state.session_factory)
     add_observability(app, settings.service_name)
     add_internal_service_auth(app, settings)
-    app.include_router(router, prefix="/auth", tags=["auth"])
+    app.include_router(identity_router, prefix="/identity", tags=["identity"])
     return app

+ 62 - 0
services/auth-service/app/bootstrap/demo_seed.py

@@ -0,0 +1,62 @@
+import logging
+
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.orm import sessionmaker
+
+from app.bootstrap.settings import AuthServiceSettings
+from app.domain.repositories import (
+    RoleAssignmentRepository,
+    RolePermissionBindingRepository,
+    RoleRepository,
+    UserRepository,
+)
+from app.infrastructure.passwords import hash_password
+
+logger = logging.getLogger(__name__)
+
+
+def bootstrap_demo_identity(
+    *,
+    settings: AuthServiceSettings,
+    session_factory: sessionmaker) -> None:
+    if not settings.demo_user_bootstrap_enabled or settings.service_env != "local":
+        return
+
+    db = session_factory()
+    try:
+        users = UserRepository(db)
+        if users.has_any():
+            return
+
+        user = users.create(
+            username=settings.demo_user_username,
+            password_hash=hash_password(settings.demo_user_password),
+            display_name=settings.demo_user_display_name,
+            email=settings.demo_user_email,
+            metadata_json={"source": "local-bootstrap"})
+
+        roles = RoleRepository(db)
+        role = roles.get_by_name(name="Administrator")
+        if role is None:
+            role = roles.create(
+                code="administrator",
+                name="Administrator",
+                description="Local bootstrap administrator",
+                permissions_json=[])
+
+        RoleAssignmentRepository(db).create(
+            user_id=user.id,
+            role_id=role.id,
+            scope_type=None,
+            scope_id=None,
+            expires_time=None)
+        RolePermissionBindingRepository(db).create(
+            role_id=role.id,
+            permission="*",
+            scope_type=None,
+            scope_id=None)
+    except SQLAlchemyError as exc:
+        db.rollback()
+        logger.warning("Skipped demo identity bootstrap: %s", exc)
+    finally:
+        db.close()

+ 5 - 0
services/auth-service/app/bootstrap/settings.py

@@ -7,3 +7,8 @@ class AuthServiceSettings(ServiceSettings):
     database_url: str = (
         "postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb"
     )
+    demo_user_bootstrap_enabled: bool = True
+    demo_user_username: str = "demo-user"
+    demo_user_password: str = "demo-password"
+    demo_user_display_name: str = "Demo User"
+    demo_user_email: str = "demo@example.com"

+ 3 - 1
services/auth-service/app/db/models/__init__.py

@@ -1,7 +1,9 @@
 from core_db import Base
 
+from .api_key import ApiKey
 from .role import Role
 from .role_assignment import RoleAssignment
+from .role_permission_binding import RolePermissionBinding
 from .user import User
 
-__all__ = ["Base", "Role", "RoleAssignment", "User"]
+__all__ = ["ApiKey", "Base", "Role", "RoleAssignment", "RolePermissionBinding", "User"]

+ 17 - 0
services/auth-service/app/db/models/api_key.py

@@ -0,0 +1,17 @@
+from datetime import datetime
+
+from core_db import AuditMixin, Base, EntityMixin, VersionMixin
+from sqlalchemy import DateTime, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+
+class ApiKey(EntityMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "auth_api_key"
+
+    name: Mapped[str] = mapped_column(String(128))
+    key_prefix: Mapped[str] = mapped_column(String(16), index=True)
+    key_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
+    scopes: Mapped[str | None] = mapped_column(Text, nullable=True)
+    expires_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_used_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    revoked_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)

+ 15 - 0
services/auth-service/app/db/models/role_permission_binding.py

@@ -0,0 +1,15 @@
+from uuid import uuid4
+
+from core_db import AuditMixin, Base, VersionMixin
+from sqlalchemy import String
+from sqlalchemy.orm import Mapped, mapped_column
+
+
+class RolePermissionBinding(Base, AuditMixin, VersionMixin):
+    __tablename__ = "auth_role_permission_binding"
+
+    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
+    role_id: Mapped[str] = mapped_column(String(36), index=True)
+    permission: Mapped[str] = mapped_column(String(256), index=True)
+    scope_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+    scope_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)

+ 133 - 1
services/auth-service/app/domain/repositories.py

@@ -5,7 +5,7 @@ from core_shared import JSONValue
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 
-from app.db.models import Role, RoleAssignment, User
+from app.db.models import ApiKey, Role, RoleAssignment, RolePermissionBinding, User
 
 
 class UserRepository:
@@ -35,6 +35,23 @@ class UserRepository:
         stmt = select(User).order_by(User.created_time.desc())
         return list(self.db.scalars(stmt))
 
+    def has_any(self) -> bool:
+        stmt = select(User.id).limit(1)
+        return self.db.scalar(stmt) is not None
+
+    def list_page(self, *, offset: int, limit: int, keyword: str | None) -> tuple[list[User], int]:
+        stmt = select(User)
+        if keyword:
+            like = f"%{keyword}%"
+            stmt = stmt.where(
+                (User.username.like(like))
+                | (User.display_name.like(like))
+                | (User.email.like(like))
+            )
+        total = len(list(self.db.scalars(stmt)))
+        items_stmt = stmt.order_by(User.created_time.desc()).offset(offset).limit(limit)
+        return list(self.db.scalars(items_stmt)), total
+
     def get_by_id(self, *, user_id: str) -> User | None:
         return self.db.get(User, user_id)
 
@@ -85,6 +102,19 @@ class RoleRepository:
         stmt = select(Role).order_by(Role.created_time.desc())
         return list(self.db.scalars(stmt))
 
+    def get_by_name(self, *, name: str) -> Role | None:
+        stmt = select(Role).where(Role.name == name)
+        return self.db.scalar(stmt)
+
+    def list_page(self, *, offset: int, limit: int, keyword: str | None) -> tuple[list[Role], int]:
+        stmt = select(Role)
+        if keyword:
+            like = f"%{keyword}%"
+            stmt = stmt.where((Role.name.like(like)) | (Role.description.like(like)))
+        total = len(list(self.db.scalars(stmt)))
+        items_stmt = stmt.order_by(Role.created_time.desc()).offset(offset).limit(limit)
+        return list(self.db.scalars(items_stmt)), total
+
     def get_by_id(self, *, role_id: str) -> Role | None:
         return self.db.get(Role, role_id)
 
@@ -147,3 +177,105 @@ class RoleAssignmentRepository:
         self.db.commit()
         self.db.refresh(entity)
         return entity
+
+
+class RolePermissionBindingRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        role_id: str,
+        permission: str,
+        scope_type: str | None,
+        scope_id: str | None) -> RolePermissionBinding:
+        entity = RolePermissionBinding(
+            role_id=role_id,
+            permission=permission,
+            scope_type=scope_type,
+            scope_id=scope_id)
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_by_role(
+        self,
+        *,
+        role_id: str,
+        offset: int = 0,
+        limit: int = 100) -> tuple[list[RolePermissionBinding], int]:
+        stmt = select(RolePermissionBinding).where(RolePermissionBinding.role_id == role_id)
+        total = len(list(self.db.scalars(stmt)))
+        items_stmt = (
+            stmt.order_by(RolePermissionBinding.created_time.desc())
+            .offset(offset)
+            .limit(limit)
+        )
+        return list(self.db.scalars(items_stmt)), total
+
+    def list_all_by_role(self, *, role_id: str) -> list[RolePermissionBinding]:
+        stmt = (
+            select(RolePermissionBinding)
+            .where(RolePermissionBinding.role_id == role_id)
+            .order_by(RolePermissionBinding.created_time.desc())
+        )
+        return list(self.db.scalars(stmt))
+
+    def delete(self, *, binding_id: str) -> bool:
+        entity = self.db.get(RolePermissionBinding, binding_id)
+        if entity is None:
+            return False
+        self.db.delete(entity)
+        self.db.commit()
+        return True
+
+
+class ApiKeyRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        name: str,
+        key_prefix: str,
+        key_hash: str,
+        scopes: str | None,
+        expires_time: datetime | None) -> ApiKey:
+        entity = ApiKey(
+            name=name,
+            key_prefix=key_prefix,
+            key_hash=key_hash,
+            scopes=scopes,
+            expires_time=expires_time)
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_page(
+        self,
+        *,
+        offset: int,
+        limit: int,
+        keyword: str | None) -> tuple[list[ApiKey], int]:
+        stmt = select(ApiKey)
+        if keyword:
+            stmt = stmt.where(ApiKey.name.like(f"%{keyword}%"))
+        total = len(list(self.db.scalars(stmt)))
+        items_stmt = stmt.order_by(ApiKey.created_time.desc()).offset(offset).limit(limit)
+        return list(self.db.scalars(items_stmt)), total
+
+    def get_by_id(self, *, api_key_id: str) -> ApiKey | None:
+        return self.db.get(ApiKey, api_key_id)
+
+    def revoke(self, *, api_key_id: str) -> ApiKey | None:
+        entity = self.get_by_id(api_key_id=api_key_id)
+        if entity is None:
+            return None
+        entity.revoked_time = datetime.utcnow()
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity

+ 18 - 0
services/auth-service/app/infrastructure/api_keys.py

@@ -0,0 +1,18 @@
+import hashlib
+import secrets
+
+API_KEY_PREFIX = "agp"
+
+
+def generate_api_key() -> str:
+    return f"{API_KEY_PREFIX}_{secrets.token_urlsafe(32)}"
+
+
+def hash_api_key(api_key: str) -> str:
+    return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
+
+
+def get_api_key_prefix(api_key: str) -> str:
+    if len(api_key) <= 12:
+        return api_key
+    return api_key[:12]

+ 0 - 118
services/auth-service/app/schemas/auth.py

@@ -1,118 +0,0 @@
-from datetime import datetime
-from typing import TYPE_CHECKING
-
-from core_domain import (
-    PermissionCheckContract,
-    PermissionCheckResultContract,
-    RoleAssignmentContract,
-    RoleAssignmentStatus,
-    RoleContract,
-    RoleStatus,
-    UserContract,
-    UserStatus,
-)
-from core_shared import JSONValue
-from pydantic import BaseModel, Field
-
-if TYPE_CHECKING:
-    from app.db.models import Role, RoleAssignment, User
-
-
-class UserCreateRequest(BaseModel):
-    username: str
-    password: str | None = Field(default=None, min_length=8)
-    display_name: str | None = None
-    email: str | None = None
-    metadata_json: dict[str, JSONValue] = Field(default_factory=dict)
-
-
-class UserStatusUpdateRequest(BaseModel):
-    status: UserStatus
-
-
-class UserResponse(UserContract):
-    @classmethod
-    def from_entity(cls, entity: "User") -> "UserResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class LoginUserResponse(BaseModel):
-    id: str
-    username: str
-    display_name: str | None = None
-    email: str | None = None
-    status: UserStatus
-    metadata_json: dict[str, JSONValue] = Field(default_factory=dict)
-    last_login_time: datetime | None = None
-    created_time: datetime
-
-    @classmethod
-    def from_entity(cls, entity: "User") -> "LoginUserResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class LoginRequest(BaseModel):
-    username: str
-    password: str
-
-
-class LoginResponse(BaseModel):
-    access_token: str
-    token_type: str = "bearer"
-    expires_time: datetime
-    user: LoginUserResponse
-
-
-class TokenVerifyRequest(BaseModel):
-    access_token: str
-
-
-class TokenVerifyResponse(BaseModel):
-    active: bool
-    user_id: str | None = None
-    username: str | None = None
-    expires_time: datetime | None = None
-    reason: str | None = None
-
-
-class RoleCreateRequest(BaseModel):
-    code: str
-    name: str
-    description: str | None = None
-    permissions_json: list[str] = Field(default_factory=list)
-
-
-class RoleStatusUpdateRequest(BaseModel):
-    status: RoleStatus
-
-
-class RoleResponse(RoleContract):
-    @classmethod
-    def from_entity(cls, entity: "Role") -> "RoleResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class RoleAssignmentCreateRequest(BaseModel):
-    user_id: str
-    role_id: str
-    scope_type: str | None = None
-    scope_id: str | None = None
-    expires_time: datetime | None = None
-
-
-class RoleAssignmentStatusUpdateRequest(BaseModel):
-    status: RoleAssignmentStatus
-
-
-class RoleAssignmentResponse(RoleAssignmentContract):
-    @classmethod
-    def from_entity(cls, entity: "RoleAssignment") -> "RoleAssignmentResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class PermissionCheckRequest(PermissionCheckContract):
-    pass
-
-
-class PermissionCheckResponse(PermissionCheckResultContract):
-    pass

+ 222 - 0
services/auth-service/app/schemas/identity.py

@@ -0,0 +1,222 @@
+from datetime import datetime
+from typing import TYPE_CHECKING, Generic, TypeVar
+
+from core_shared import JSONValue
+from pydantic import BaseModel, Field
+
+if TYPE_CHECKING:
+    from app.db.models import ApiKey, Role, RolePermissionBinding, User
+
+T = TypeVar("T")
+
+
+class ApiErrorResponse(BaseModel):
+    errorType: str
+    message: str
+    details: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class ApiResponse(BaseModel, Generic[T]):
+    success: bool = True
+    data: T | None = None
+    error: ApiErrorResponse | None = None
+    requestId: str
+    serverTime: datetime
+
+
+class PageRequest(BaseModel):
+    page: int = Field(default=1, ge=1)
+    pageSize: int = Field(default=20, ge=1, le=200)
+    keyword: str | None = None
+    sortBy: str = "createdTime"
+    sortOrder: str = "desc"
+
+    @property
+    def offset(self) -> int:
+        return (self.page - 1) * self.pageSize
+
+
+class PageResult(BaseModel, Generic[T]):
+    items: list[T]
+    total: int
+    page: int
+    pageSize: int
+    hasMore: bool
+
+    @classmethod
+    def from_items(
+        cls,
+        *,
+        items: list[T],
+        total: int,
+        page: int,
+        page_size: int) -> "PageResult[T]":
+        return cls(
+            items=items,
+            total=total,
+            page=page,
+            pageSize=page_size,
+            hasMore=page * page_size < total)
+
+
+class UserDto(BaseModel):
+    id: str
+    username: str
+    displayName: str | None = None
+    email: str | None = None
+    metadata: dict[str, JSONValue] = Field(default_factory=dict)
+    lastLoginTime: datetime | None = None
+    createdTime: datetime
+    updatedTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "User") -> "UserDto":
+        return cls(
+            id=entity.id,
+            username=entity.username,
+            displayName=entity.display_name,
+            email=entity.email,
+            metadata=entity.metadata_json or {},
+            lastLoginTime=entity.last_login_time,
+            createdTime=entity.created_time,
+            updatedTime=entity.updated_time)
+
+
+class RoleDto(BaseModel):
+    id: str
+    name: str
+    description: str | None = None
+    permissionBindingCount: int
+    createdTime: datetime
+    updatedTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "Role", *, permission_binding_count: int = 0) -> "RoleDto":
+        return cls(
+            id=entity.id,
+            name=entity.name,
+            description=entity.description,
+            permissionBindingCount=permission_binding_count,
+            createdTime=entity.created_time,
+            updatedTime=entity.updated_time)
+
+
+class RolePermissionBindingDto(BaseModel):
+    id: str
+    roleId: str
+    permission: str
+    scopeType: str | None = None
+    scopeId: str | None = None
+    createdTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "RolePermissionBinding") -> "RolePermissionBindingDto":
+        return cls(
+            id=entity.id,
+            roleId=entity.role_id,
+            permission=entity.permission,
+            scopeType=entity.scope_type,
+            scopeId=entity.scope_id,
+            createdTime=entity.created_time)
+
+
+class ApiKeyDto(BaseModel):
+    id: str
+    name: str
+    keyPrefix: str
+    scopes: str | None = None
+    expiresTime: datetime | None = None
+    lastUsedTime: datetime | None = None
+    revokedTime: datetime | None = None
+    createdTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "ApiKey") -> "ApiKeyDto":
+        return cls(
+            id=entity.id,
+            name=entity.name,
+            keyPrefix=entity.key_prefix,
+            scopes=entity.scopes,
+            expiresTime=entity.expires_time,
+            lastUsedTime=entity.last_used_time,
+            revokedTime=entity.revoked_time,
+            createdTime=entity.created_time)
+
+
+class LoginRequestDto(BaseModel):
+    username: str
+    password: str
+
+
+class LoginData(BaseModel):
+    accessToken: str
+    tokenType: str = "bearer"
+    expiresTime: datetime
+    user: UserDto
+
+
+class TokenVerifyRequestDto(BaseModel):
+    accessToken: str
+
+
+class TokenVerifyData(BaseModel):
+    active: bool
+    userId: str | None = None
+    username: str | None = None
+    expiresTime: datetime | None = None
+    reason: str | None = None
+
+
+class AuthMeData(BaseModel):
+    user: UserDto
+    roles: list[RoleDto]
+    permissions: list[str]
+
+
+class RolePermissionBindingAddRequest(BaseModel):
+    roleId: str
+    permission: str
+    scopeType: str | None = None
+    scopeId: str | None = None
+
+
+class RolePermissionBindingListRequest(PageRequest):
+    roleId: str
+
+
+class BindingRemoveRequest(BaseModel):
+    bindingId: str
+
+
+class PermissionCheckRequestDto(BaseModel):
+    userId: str
+    permission: str
+    scopeType: str | None = None
+    scopeId: str | None = None
+
+
+class PermissionCheckData(BaseModel):
+    allowed: bool
+    reason: str
+    matchedRoleIds: list[str] = Field(default_factory=list)
+
+
+class ApiKeyCreateRequestDto(BaseModel):
+    name: str
+    scopes: str | None = None
+    expiresTime: datetime | None = None
+
+
+class ApiKeyCreateData(BaseModel):
+    apiKey: ApiKeyDto
+    secret: str
+
+
+class ApiKeyRevokeRequest(BaseModel):
+    apiKeyId: str
+
+
+class DeleteData(BaseModel):
+    deleted: bool
+    bindingId: str | None = None
+    apiKeyId: str | None = None

+ 2 - 0
services/knowledge-service/alembic/versions/20260425_0001_init_knowledge_models.py

@@ -71,7 +71,9 @@ def upgrade() -> None:
         ["content_hash"],
         unique=False)
     op.create_index(
+        "ix_knowledge_document_base_status",
         "knowledge_document",
+        ["knowledge_base_id", "status"],
         unique=False)
 
     op.create_table(

+ 1 - 0
services/memory-service/alembic/versions/20260425_0001_init_memory_models.py

@@ -51,6 +51,7 @@ def upgrade() -> None:
     op.create_index(
         "ix_memory_item_search_scope",
         "memory_item",
+        ["scope_type", "scope_id", "memory_type", "status"],
         unique=False)
 
 

+ 48 - 0
services/model-gateway-service/alembic/versions/20260428_0002_add_model_provider_definitions.py

@@ -0,0 +1,48 @@
+"""add model provider definitions
+
+Revision ID: 20260428_0002
+Revises: 20260428_0001
+Create Date: 2026-04-28 22:40:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260428_0002"
+down_revision: str | None = "20260428_0001"
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "model_provider_definition",
+        sa.Column("name", sa.String(length=128), nullable=False),
+        sa.Column("provider_type", sa.String(length=32), nullable=False),
+        sa.Column("base_url", sa.String(length=512), nullable=False),
+        sa.Column("api_key", sa.Text(), nullable=True),
+        sa.Column("models_json", sa.JSON(), nullable=False),
+        sa.Column("default_model", sa.String(length=128), nullable=True),
+        sa.Column("extra_config_json", sa.JSON(), nullable=True),
+        sa.Column("id", sa.String(length=36), nullable=False),
+        sa.Column("created_by", sa.String(length=36), nullable=True),
+        sa.Column("updated_by", sa.String(length=36), nullable=True),
+        sa.Column("created_time", sa.DateTime(), nullable=False),
+        sa.Column("updated_time", sa.DateTime(), nullable=False),
+        sa.Column("deleted_time", sa.DateTime(), nullable=True),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index(
+        "ix_model_provider_definition_provider_type",
+        "model_provider_definition",
+        ["provider_type"])
+
+
+def downgrade() -> None:
+    op.drop_index(
+        "ix_model_provider_definition_provider_type",
+        table_name="model_provider_definition")
+    op.drop_table("model_provider_definition")

+ 31 - 0
services/model-gateway-service/alembic/versions/20260429_0003_add_model_provider_id.py

@@ -0,0 +1,31 @@
+"""add model provider id
+
+Revision ID: 20260429_0003
+Revises: 20260428_0002
+Create Date: 2026-04-29 01:00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260429_0003"
+down_revision: str | None = "20260428_0002"
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column(
+        "model_definition",
+        sa.Column("provider_id", sa.String(length=36), nullable=True))
+    op.create_index(
+        "ix_model_definition_provider_id",
+        "model_definition",
+        ["provider_id"])
+
+
+def downgrade() -> None:
+    op.drop_index("ix_model_definition_provider_id", table_name="model_definition")
+    op.drop_column("model_definition", "provider_id")

+ 192 - 13
services/model-gateway-service/app/api/routes.py

@@ -1,4 +1,11 @@
-from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract, ServiceHealth
+from datetime import datetime
+from typing import Annotated, TypeVar
+
+from core_domain import (
+    ChatCompletionRequestContract,
+    ChatCompletionResponseContract,
+    ServiceHealth,
+)
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import text
 from sqlalchemy.orm import Session
@@ -6,18 +13,38 @@ from sqlalchemy.orm import Session
 from app.application.services import ModelGatewayApplicationService
 from app.bootstrap.settings import ModelGatewayServiceSettings
 from app.db.session import get_db
-from app.domain.repositories import ModelDefinitionRepository
+from app.domain.repositories import ModelDefinitionRepository, ModelProviderDefinitionRepository
 from app.infrastructure.provider import ModelProviderClient, ModelProviderClientError
 from app.schemas.model import (
+    ApiResponse,
+    DeleteData,
+    DiscoverModelsData,
+    DiscoverModelsRequestDto,
     ModelCreateRequest,
+    ModelCreateRequestDto,
+    ModelDeleteRequestDto,
+    ModelDto,
+    ModelProviderCreateRequestDto,
+    ModelProviderDeleteRequestDto,
+    ModelProviderDto,
+    ModelProviderTestData,
+    ModelProviderTestRequestDto,
+    ModelProviderUpdateRequestDto,
     ModelResponse,
     ModelStatusUpdateRequest,
+    ModelTestData,
     ModelTestRequest,
+    ModelTestRequestDto,
     ModelTestResponse,
     ModelUpdateRequest,
+    ModelUpdateRequestDto,
+    PageRequest,
+    PageResult,
 )
 
 router = APIRouter()
+DbSession = Annotated[Session, Depends(get_db)]
+T = TypeVar("T")
 
 
 def get_model_gateway_settings() -> ModelGatewayServiceSettings:
@@ -25,18 +52,38 @@ def get_model_gateway_settings() -> ModelGatewayServiceSettings:
 
 
 def get_model_gateway_application_service(
-    db: Session = Depends(get_db),
-    settings: ModelGatewayServiceSettings = Depends(get_model_gateway_settings)) -> ModelGatewayApplicationService:
+    db: DbSession,
+    settings: Annotated[
+        ModelGatewayServiceSettings,
+        Depends(get_model_gateway_settings),
+    ]) -> ModelGatewayApplicationService:
     return ModelGatewayApplicationService(
         model_repository=ModelDefinitionRepository(db),
+        provider_repository=ModelProviderDefinitionRepository(db),
         provider_client=ModelProviderClient(settings=settings),
         settings=settings)
 
 
+ModelServiceDep = Annotated[
+    ModelGatewayApplicationService,
+    Depends(get_model_gateway_application_service),
+]
+
+
+def ok(data: T) -> ApiResponse[T]:
+    return ApiResponse[T](
+        data=data,
+        requestId="",
+        serverTime=datetime.utcnow())
+
+
 @router.get("/health", response_model=ServiceHealth)
 def health_check(
-    db: Session = Depends(get_db),
-    settings: ModelGatewayServiceSettings = Depends(get_model_gateway_settings)) -> ServiceHealth:
+    db: DbSession,
+    settings: Annotated[
+        ModelGatewayServiceSettings,
+        Depends(get_model_gateway_settings),
+    ]) -> ServiceHealth:
     db.execute(text("SELECT 1"))
     provider_status = "configured" if settings.provider_base_url else "missing"
     return ServiceHealth(service="model-gateway-service", status="ok", database=provider_status)
@@ -45,7 +92,7 @@ def health_check(
 @router.post("", response_model=ModelResponse)
 def create_model(
     payload: ModelCreateRequest,
-    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+    service: ModelServiceDep,
 ) -> ModelResponse:
     try:
         entity = service.create_model(payload)
@@ -56,7 +103,7 @@ def create_model(
 
 @router.get("", response_model=list[ModelResponse])
 def list_models(
-    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+    service: ModelServiceDep,
 ) -> list[ModelResponse]:
     return [ModelResponse.from_entity(item) for item in service.list_models()]
 
@@ -65,7 +112,7 @@ def list_models(
 def update_model(
     model_id: str,
     payload: ModelUpdateRequest,
-    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+    service: ModelServiceDep,
 ) -> ModelResponse:
     try:
         entity = service.update_model(model_id=model_id, payload=payload)
@@ -80,7 +127,7 @@ def update_model(
 def update_model_status(
     model_id: str,
     payload: ModelStatusUpdateRequest,
-    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+    service: ModelServiceDep,
 ) -> ModelResponse:
     entity = service.update_model_status(model_id=model_id, payload=payload)
     if entity is None:
@@ -91,7 +138,7 @@ def update_model_status(
 @router.delete("/{model_id}", status_code=204)
 def delete_model(
     model_id: str,
-    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+    service: ModelServiceDep,
 ) -> None:
     if not service.delete_model(model_id):
         raise HTTPException(status_code=404, detail=f"model not found: {model_id}")
@@ -101,7 +148,7 @@ def delete_model(
 def test_model(
     model_id: str,
     payload: ModelTestRequest,
-    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+    service: ModelServiceDep,
 ) -> ModelTestResponse:
     try:
         result = service.test_model(model_id=model_id, payload=payload)
@@ -115,8 +162,140 @@ def test_model(
 @router.post("/chat-completions", response_model=ChatCompletionResponseContract)
 def create_chat_completion(
     payload: ChatCompletionRequestContract,
-    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service)) -> ChatCompletionResponseContract:
+    service: ModelServiceDep) -> ChatCompletionResponseContract:
     try:
         return service.create_chat_completion(payload)
     except ModelProviderClientError as exc:
         raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
+@router.post("/list", response_model=ApiResponse[PageResult[ModelDto]])
+def list_models_contract(
+    payload: PageRequest,
+    service: ModelServiceDep) -> ApiResponse[PageResult[ModelDto]]:
+    keyword = (payload.keyword or "").lower().strip()
+    items = [
+        item
+        for item in service.list_models()
+        if not keyword
+        or keyword in item.name.lower()
+        or keyword in item.model_name.lower()
+        or keyword in item.provider_type.lower()
+    ]
+    page_items = items[payload.offset:payload.offset + payload.pageSize]
+    return ok(
+        PageResult[ModelDto].from_items(
+            items=[ModelDto.from_entity(item) for item in page_items],
+            total=len(items),
+            page=payload.page,
+            page_size=payload.pageSize))
+
+
+@router.post("/create", response_model=ApiResponse[ModelDto])
+def create_model_contract(
+    payload: ModelCreateRequestDto,
+    service: ModelServiceDep) -> ApiResponse[ModelDto]:
+    try:
+        entity = service.create_model_from_contract(payload)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+    return ok(ModelDto.from_entity(entity))
+
+
+@router.post("/update", response_model=ApiResponse[ModelDto])
+def update_model_contract(
+    payload: ModelUpdateRequestDto,
+    service: ModelServiceDep) -> ApiResponse[ModelDto]:
+    try:
+        entity = service.update_model_from_contract(payload)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"model not found: {payload.modelId}")
+    return ok(ModelDto.from_entity(entity))
+
+
+@router.post("/delete", response_model=ApiResponse[DeleteData])
+def delete_model_contract(
+    payload: ModelDeleteRequestDto,
+    service: ModelServiceDep) -> ApiResponse[DeleteData]:
+    deleted = service.delete_model_from_contract(payload)
+    return ok(DeleteData(deleted=deleted, modelId=payload.modelId))
+
+
+@router.post("/test", response_model=ApiResponse[ModelTestData])
+def test_model_contract(
+    payload: ModelTestRequestDto,
+    service: ModelServiceDep) -> ApiResponse[ModelTestData]:
+    try:
+        result = service.test_model_from_contract(payload)
+    except ModelProviderClientError as exc:
+        raise HTTPException(status_code=502, detail=str(exc)) from exc
+    if result is None:
+        raise HTTPException(status_code=404, detail=f"model not found: {payload.modelId}")
+    return ok(result)
+
+
+@router.post("/providers/list", response_model=ApiResponse[PageResult[ModelProviderDto]])
+def list_model_providers_contract(
+    payload: PageRequest,
+    service: ModelServiceDep) -> ApiResponse[PageResult[ModelProviderDto]]:
+    keyword = (payload.keyword or "").lower().strip()
+    items = [
+        item
+        for item in service.list_providers()
+        if not keyword
+        or keyword in item.name.lower()
+        or keyword in item.provider_type.lower()
+        or keyword in item.base_url.lower()
+    ]
+    page_items = items[payload.offset:payload.offset + payload.pageSize]
+    return ok(
+        PageResult[ModelProviderDto].from_items(
+            items=[ModelProviderDto.from_entity(item) for item in page_items],
+            total=len(items),
+            page=payload.page,
+            page_size=payload.pageSize))
+
+
+@router.post("/providers/create", response_model=ApiResponse[ModelProviderDto])
+def create_model_provider_contract(
+    payload: ModelProviderCreateRequestDto,
+    service: ModelServiceDep) -> ApiResponse[ModelProviderDto]:
+    entity = service.create_provider(payload)
+    return ok(ModelProviderDto.from_entity(entity))
+
+
+@router.post("/providers/update", response_model=ApiResponse[ModelProviderDto])
+def update_model_provider_contract(
+    payload: ModelProviderUpdateRequestDto,
+    service: ModelServiceDep) -> ApiResponse[ModelProviderDto]:
+    entity = service.update_provider(payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"provider not found: {payload.providerId}")
+    return ok(ModelProviderDto.from_entity(entity))
+
+
+@router.post("/providers/delete", response_model=ApiResponse[DeleteData])
+def delete_model_provider_contract(
+    payload: ModelProviderDeleteRequestDto,
+    service: ModelServiceDep) -> ApiResponse[DeleteData]:
+    deleted = service.delete_provider(payload)
+    return ok(DeleteData(deleted=deleted, providerId=payload.providerId))
+
+
+@router.post("/providers/test", response_model=ApiResponse[ModelProviderTestData])
+def test_model_provider_contract(
+    payload: ModelProviderTestRequestDto,
+    service: ModelServiceDep) -> ApiResponse[ModelProviderTestData]:
+    result = service.test_provider(payload)
+    if result is None:
+        raise HTTPException(status_code=404, detail=f"provider not found: {payload.providerId}")
+    return ok(result)
+
+
+@router.post("/providers/discover", response_model=ApiResponse[DiscoverModelsData])
+def discover_models_contract(
+    payload: DiscoverModelsRequestDto,
+    service: ModelServiceDep) -> ApiResponse[DiscoverModelsData]:
+    return ok(service.discover_models(payload))

+ 317 - 13
services/model-gateway-service/app/application/services.py

@@ -1,15 +1,31 @@
 from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract
 
 from app.bootstrap.settings import ModelGatewayServiceSettings
-from app.db.models import ModelDefinition
-from app.domain.repositories import ModelDefinitionRepository
+from app.db.models import ModelDefinition, ModelProviderDefinition
+from app.domain.repositories import ModelDefinitionRepository, ModelProviderDefinitionRepository
 from app.infrastructure.provider import ModelProviderClient
 from app.schemas.model import (
+    DiscoverModelsData,
+    DiscoverModelsRequestDto,
     ModelCreateRequest,
+    ModelCreateRequestDto,
+    ModelDeleteRequestDto,
+    ModelDto,
+    ModelItemDto,
+    ModelProviderCreateRequestDto,
+    ModelProviderDeleteRequestDto,
+    ModelProviderDto,
+    ModelProviderTestData,
+    ModelProviderTestRequestDto,
+    ModelProviderUpdateRequestDto,
     ModelStatusUpdateRequest,
+    ModelTestData,
     ModelTestRequest,
+    ModelTestRequestDto,
     ModelTestResponse,
     ModelUpdateRequest,
+    ModelUpdateRequestDto,
+    _to_snake_model_item,
 )
 
 
@@ -18,22 +34,27 @@ class ModelGatewayApplicationService:
         self,
         *,
         model_repository: ModelDefinitionRepository,
+        provider_repository: ModelProviderDefinitionRepository,
         provider_client: ModelProviderClient,
         settings: ModelGatewayServiceSettings) -> None:
         self.model_repository = model_repository
+        self.provider_repository = provider_repository
         self.provider_client = provider_client
         self.settings = settings
 
     def create_model(self, payload: ModelCreateRequest) -> ModelDefinition:
-        if self.model_repository.get_by_code(payload.code) is not None:
-            raise ValueError(f"model code already exists: {payload.code}")
+        provider = self._get_provider_or_raise(payload.provider_id)
+        code = payload.code or self._build_model_code(payload.name, payload.model_name)
+        if self.model_repository.get_by_code(code) is not None:
+            raise ValueError(f"model code already exists: {code}")
         return self.model_repository.create(
             ModelDefinition(
-                code=payload.code,
+                code=code,
                 name=payload.name,
-                provider_type=payload.provider_type,
-                provider_base_url=str(payload.provider_base_url),
-                provider_api_key=payload.provider_api_key,
+                provider_id=provider.id if provider is not None else None,
+                provider_type=provider.provider_type if provider is not None else payload.provider_type,
+                provider_base_url=provider.base_url if provider is not None else str(payload.provider_base_url),
+                provider_api_key=provider.api_key if provider is not None else payload.provider_api_key,
                 model_name=payload.model_name,
                 status=payload.status,
                 description=payload.description,
@@ -49,6 +70,50 @@ class ModelGatewayApplicationService:
     def list_models(self) -> list[ModelDefinition]:
         return self.model_repository.list_all()
 
+    def create_model_from_contract(self, payload: ModelCreateRequestDto) -> ModelDefinition:
+        return self.create_model(
+            ModelCreateRequest(
+                name=payload.name,
+                provider_id=payload.providerId,
+                provider_type=payload.providerType,
+                provider_base_url=payload.providerBaseUrl or "",
+                provider_api_key=payload.providerApiKey,
+                model_name=payload.modelName,
+                description=payload.description,
+                capabilities_json=payload.capabilities,
+                context_window=payload.contextWindow,
+                max_output_tokens=payload.maxOutputTokens,
+                default_temperature=payload.defaultTemperature,
+                timeout_seconds=payload.timeoutSeconds,
+                metadata_json=payload.metadata))
+
+    def update_model_from_contract(self, payload: ModelUpdateRequestDto) -> ModelDefinition | None:
+        updates = payload.model_dump(exclude_unset=True)
+        updates.pop("modelId", None)
+        mapped_updates = {
+            "name": updates.get("name"),
+            "provider_id": updates.get("providerId"),
+            "provider_type": updates.get("providerType"),
+            "provider_base_url": updates.get("providerBaseUrl"),
+            "provider_api_key": updates.get("providerApiKey"),
+            "model_name": updates.get("modelName"),
+            "description": updates.get("description"),
+            "capabilities_json": updates.get("capabilities"),
+            "context_window": updates.get("contextWindow"),
+            "max_output_tokens": updates.get("maxOutputTokens"),
+            "default_temperature": updates.get("defaultTemperature"),
+            "timeout_seconds": updates.get("timeoutSeconds"),
+            "metadata_json": updates.get("metadata"),
+        }
+        return self.update_model(
+            model_id=payload.modelId,
+            payload=ModelUpdateRequest(
+                **{
+                    key: value
+                    for key, value in mapped_updates.items()
+                    if value is not None
+                }))
+
     def update_model(self, model_id: str, payload: ModelUpdateRequest) -> ModelDefinition | None:
         entity = self.model_repository.get_by_id(model_id)
         if entity is None:
@@ -58,6 +123,12 @@ class ModelGatewayApplicationService:
             existing = self.model_repository.get_by_code(str(updates["code"]))
             if existing is not None and existing.id != entity.id:
                 raise ValueError(f"model code already exists: {updates['code']}")
+        provider = self._get_provider_or_raise(updates.get("provider_id"))
+        if provider is not None:
+            updates["provider_id"] = provider.id
+            updates["provider_type"] = provider.provider_type
+            updates["provider_base_url"] = provider.base_url
+            updates["provider_api_key"] = provider.api_key
         for key, value in updates.items():
             if key == "provider_base_url" and value is not None:
                 value = str(value)
@@ -82,6 +153,9 @@ class ModelGatewayApplicationService:
         self.model_repository.delete(entity)
         return True
 
+    def delete_model_from_contract(self, payload: ModelDeleteRequestDto) -> bool:
+        return self.delete_model(payload.modelId)
+
     def create_chat_completion(
         self,
         payload: ChatCompletionRequestContract) -> ChatCompletionResponseContract:
@@ -90,6 +164,7 @@ class ModelGatewayApplicationService:
             configured_model = self.model_repository.get_active_for_request(payload.model)
 
         if configured_model is not None:
+            configured_provider = self._resolve_model_provider(configured_model)
             resolved_payload = payload.model_copy(
                 update={
                     "model": configured_model.model_name,
@@ -101,20 +176,24 @@ class ModelGatewayApplicationService:
             )
             return self.provider_client.create_chat_completion(
                 resolved_payload,
-                provider_base_url=configured_model.provider_base_url,
-                provider_api_key=configured_model.provider_api_key,
+                provider_type=configured_provider.provider_type,
+                provider_base_url=configured_provider.provider_base_url,
+                provider_api_key=configured_provider.provider_api_key,
                 timeout_seconds=configured_model.timeout_seconds,
             )
 
         resolved_payload = payload.model_copy(
             update={"model": payload.model or self.settings.default_model}
         )
-        return self.provider_client.create_chat_completion(resolved_payload)
+        return self.provider_client.create_chat_completion(
+            resolved_payload,
+            provider_type=self.settings.provider_type)
 
     def test_model(self, model_id: str, payload: ModelTestRequest) -> ModelTestResponse | None:
         entity = self.model_repository.get_by_id(model_id)
         if entity is None:
             return None
+        provider = self._resolve_model_provider(entity)
         messages = []
         if payload.system_prompt:
             messages.append({"role": "system", "content": payload.system_prompt})
@@ -128,10 +207,235 @@ class ModelGatewayApplicationService:
                 else entity.default_temperature,
                 max_tokens=payload.max_tokens or entity.max_output_tokens,
             ),
-            provider_base_url=entity.provider_base_url,
-            provider_api_key=entity.provider_api_key,
+            provider_type=provider.provider_type,
+            provider_base_url=provider.provider_base_url,
+            provider_api_key=provider.provider_api_key,
             timeout_seconds=entity.timeout_seconds,
         )
         from app.schemas.model import ModelResponse
 
         return ModelTestResponse(model=ModelResponse.from_entity(entity), response=response)
+
+    def test_model_from_contract(self, payload: ModelTestRequestDto) -> ModelTestData | None:
+        result = self.test_model(
+            model_id=payload.modelId,
+            payload=ModelTestRequest(
+                prompt=payload.prompt,
+                system_prompt=payload.systemPrompt,
+                temperature=payload.temperature,
+                max_tokens=payload.maxTokens))
+        if result is None:
+            return None
+        entity = self.model_repository.get_by_id(payload.modelId)
+        if entity is None:
+            return None
+        return ModelTestData(model=ModelDto.from_entity(entity), response=result.response)
+
+    def list_providers(self) -> list[ModelProviderDefinition]:
+        self._ensure_legacy_model_providers()
+        return self.provider_repository.list_all()
+
+    def create_provider(self, payload: ModelProviderCreateRequestDto) -> ModelProviderDefinition:
+        return self.provider_repository.create(
+            ModelProviderDefinition(
+                name=payload.name,
+                provider_type=payload.providerType,
+                base_url=str(payload.baseUrl),
+                api_key=payload.apiKey,
+                models_json=[_to_snake_model_item(item) for item in payload.models],
+                default_model=payload.defaultModel,
+                extra_config_json=payload.extraConfig))
+
+    def update_provider(
+        self,
+        payload: ModelProviderUpdateRequestDto) -> ModelProviderDefinition | None:
+        entity = self.provider_repository.get_by_id(payload.providerId)
+        if entity is None:
+            return None
+        updates = payload.model_dump(exclude_unset=True)
+        updates.pop("providerId", None)
+        for key, value in updates.items():
+            if key == "baseUrl":
+                entity.base_url = str(value) if value is not None else entity.base_url
+            elif key == "apiKey":
+                entity.api_key = value
+            elif key == "defaultModel":
+                entity.default_model = value
+            elif key == "extraConfig":
+                entity.extra_config_json = value
+            elif key == "models":
+                entity.models_json = [
+                    _to_snake_model_item(ModelItemDto(**item))
+                    if isinstance(item, dict)
+                    else _to_snake_model_item(item)
+                    for item in value or []
+                ]
+            elif key == "name":
+                entity.name = value
+        return self.provider_repository.update(entity)
+
+    def delete_provider(self, payload: ModelProviderDeleteRequestDto) -> bool:
+        entity = self.provider_repository.get_by_id(payload.providerId)
+        if entity is None:
+            return False
+        self.provider_repository.delete(entity)
+        return True
+
+    def test_provider(self, payload: ModelProviderTestRequestDto) -> ModelProviderTestData | None:
+        entity = self.provider_repository.get_by_id(payload.providerId)
+        if entity is None:
+            return None
+        return ModelProviderTestData(
+            success=True,
+            message="Connection configuration is available.",
+            latencyMs=0,
+            modelList=[
+                str(item.get("model_id") or item.get("modelId"))
+                for item in entity.models_json or []
+                if item.get("model_id") or item.get("modelId")
+            ])
+
+    def discover_models(self, payload: DiscoverModelsRequestDto) -> DiscoverModelsData:
+        provider_type = payload.providerType
+        if payload.providerId:
+            provider = self.provider_repository.get_by_id(payload.providerId)
+            if provider is not None:
+                return DiscoverModelsData(
+                    providerType=provider.provider_type,
+                    models=ModelProviderDto.from_entity(provider).models)
+        return DiscoverModelsData(
+            providerType=provider_type or self.settings.provider_type,
+            models=self._default_model_catalog(provider_type or self.settings.provider_type))
+
+    def _default_model_catalog(self, provider_type: str) -> list[ModelItemDto]:
+        catalogs = {
+            "openai": [
+                ModelItemDto(
+                    modelId="gpt-4.1-mini",
+                    displayName="GPT-4.1 Mini",
+                    modelType="chat",
+                    ownedBy="openai",
+                    contextWindow=1047576),
+                ModelItemDto(
+                    modelId="text-embedding-3-small",
+                    displayName="Text Embedding 3 Small",
+                    modelType="embedding",
+                    ownedBy="openai"),
+            ],
+            "openai_compatible": [
+                ModelItemDto(
+                    modelId="gpt-4.1-mini",
+                    displayName="OpenAI Compatible Chat",
+                    modelType="chat",
+                    contextWindow=128000),
+                ModelItemDto(
+                    modelId="text-embedding-3-small",
+                    displayName="OpenAI Compatible Embedding",
+                    modelType="embedding"),
+            ],
+            "ollama": [
+                ModelItemDto(
+                    modelId="llama3.1:8b",
+                    displayName="LLaMA 3.1 8B",
+                    modelType="chat",
+                    ownedBy="meta",
+                    contextWindow=131072),
+                ModelItemDto(
+                    modelId="nomic-embed-text",
+                    displayName="Nomic Embed Text",
+                    modelType="embedding",
+                    ownedBy="nomic"),
+            ],
+        }
+        return catalogs.get(provider_type, [])
+
+    def _build_model_code(self, name: str, model_name: str) -> str:
+        base = "".join(
+            char.lower() if char.isalnum() else "_"
+            for char in f"{name}_{model_name}"
+        ).strip("_") or "model"
+        candidate = base[:64]
+        suffix = 1
+        while self.model_repository.get_by_code(candidate) is not None:
+            suffix_text = f"_{suffix}"
+            candidate = f"{base[:64 - len(suffix_text)]}{suffix_text}"
+            suffix += 1
+        return candidate
+
+    def _get_provider_or_raise(self, provider_id: str | None) -> ModelProviderDefinition | None:
+        if provider_id is None:
+            return None
+        provider = self.provider_repository.get_by_id(provider_id)
+        if provider is None:
+            raise ValueError(f"model provider not found: {provider_id}")
+        return provider
+
+    def _resolve_model_provider(self, model: ModelDefinition) -> "_ResolvedModelProvider":
+        provider = self.provider_repository.get_by_id(model.provider_id) if model.provider_id else None
+        if provider is not None:
+            return _ResolvedModelProvider(
+                provider_type=provider.provider_type,
+                provider_base_url=provider.base_url,
+                provider_api_key=provider.api_key)
+        return _ResolvedModelProvider(
+            provider_type=model.provider_type,
+            provider_base_url=model.provider_base_url,
+            provider_api_key=model.provider_api_key)
+
+    def _ensure_legacy_model_providers(self) -> None:
+        legacy_models = [
+            model
+            for model in self.model_repository.list_all()
+            if model.provider_id is None and model.provider_base_url
+        ]
+        for model in legacy_models:
+            provider = self.provider_repository.get_by_connection(
+                provider_type=model.provider_type,
+                base_url=model.provider_base_url)
+            if provider is None:
+                provider = self.provider_repository.create(
+                    ModelProviderDefinition(
+                        name=self._build_provider_name(model.provider_type, model.provider_base_url),
+                        provider_type=model.provider_type,
+                        base_url=model.provider_base_url,
+                        api_key=model.provider_api_key,
+                        models_json=[],
+                        default_model=model.model_name,
+                        extra_config_json={"source": "legacy_model_backfill"}))
+            model.provider_id = provider.id
+            self._append_provider_model(provider=provider, model=model)
+            self.model_repository.update(model)
+            self.provider_repository.update(provider)
+
+    def _append_provider_model(
+        self,
+        *,
+        provider: ModelProviderDefinition,
+        model: ModelDefinition) -> None:
+        existing_items = list(provider.models_json or [])
+        if any(item.get("model_id") == model.model_name for item in existing_items):
+            return
+        existing_items.append(
+            {
+                "model_id": model.model_name,
+                "display_name": model.name,
+                "model_type": "chat",
+            })
+        provider.models_json = existing_items
+
+    def _build_provider_name(self, provider_type: str, base_url: str) -> str:
+        label = provider_type.replace("_", " ").title()
+        host = base_url.split("//")[-1].split("/")[0]
+        return f"{label} - {host}" if host else label
+
+
+class _ResolvedModelProvider:
+    def __init__(
+        self,
+        *,
+        provider_type: str,
+        provider_base_url: str,
+        provider_api_key: str | None) -> None:
+        self.provider_type = provider_type
+        self.provider_base_url = provider_base_url
+        self.provider_api_key = provider_api_key

+ 13 - 0
services/model-gateway-service/app/db/models.py

@@ -9,6 +9,7 @@ class ModelDefinition(EntityMixin, AuditMixin, VersionMixin, Base):
 
     code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
     name: Mapped[str] = mapped_column(String(128))
+    provider_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
     provider_type: Mapped[str] = mapped_column(String(32), default="openai_compatible", index=True)
     provider_base_url: Mapped[str] = mapped_column(String(512))
     provider_api_key: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -21,3 +22,15 @@ class ModelDefinition(EntityMixin, AuditMixin, VersionMixin, Base):
     default_temperature: Mapped[float | None] = mapped_column(Float, nullable=True)
     timeout_seconds: Mapped[float] = mapped_column(Float, default=60.0)
     metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
+
+class ModelProviderDefinition(EntityMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "model_provider_definition"
+
+    name: Mapped[str] = mapped_column(String(128))
+    provider_type: Mapped[str] = mapped_column(String(32), default="openai_compatible", index=True)
+    base_url: Mapped[str] = mapped_column(String(512))
+    api_key: Mapped[str | None] = mapped_column(Text, nullable=True)
+    models_json: Mapped[list[dict]] = mapped_column(JSON, default=list)
+    default_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    extra_config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)

+ 45 - 1
services/model-gateway-service/app/domain/repositories.py

@@ -3,7 +3,7 @@ from datetime import datetime
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 
-from app.db.models import ModelDefinition
+from app.db.models import ModelDefinition, ModelProviderDefinition
 
 
 class ModelDefinitionRepository:
@@ -51,3 +51,47 @@ class ModelDefinitionRepository:
     def delete(self, entity: ModelDefinition) -> None:
         self.db.delete(entity)
         self.db.commit()
+
+
+class ModelProviderDefinitionRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(self, entity: ModelProviderDefinition) -> ModelProviderDefinition:
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_all(self) -> list[ModelProviderDefinition]:
+        stmt = select(ModelProviderDefinition).order_by(
+            ModelProviderDefinition.updated_time.desc(),
+            ModelProviderDefinition.name.asc(),
+        )
+        return list(self.db.scalars(stmt))
+
+    def get_by_id(self, provider_id: str) -> ModelProviderDefinition | None:
+        return self.db.get(ModelProviderDefinition, provider_id)
+
+    def get_by_connection(
+        self,
+        *,
+        provider_type: str,
+        base_url: str) -> ModelProviderDefinition | None:
+        stmt = (
+            select(ModelProviderDefinition)
+            .where(ModelProviderDefinition.provider_type == provider_type)
+            .where(ModelProviderDefinition.base_url == base_url)
+            .limit(1)
+        )
+        return self.db.scalar(stmt)
+
+    def update(self, entity: ModelProviderDefinition) -> ModelProviderDefinition:
+        entity.updated_time = datetime.utcnow()
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def delete(self, entity: ModelProviderDefinition) -> None:
+        self.db.delete(entity)
+        self.db.commit()

+ 133 - 3
services/model-gateway-service/app/infrastructure/provider.py

@@ -17,6 +17,7 @@ class ModelProviderClient:
         self,
         payload: ChatCompletionRequestContract,
         *,
+        provider_type: str | None = None,
         provider_base_url: str | None = None,
         provider_api_key: str | None = None,
         timeout_seconds: float = 60.0,
@@ -24,8 +25,29 @@ class ModelProviderClient:
         if payload.model is None:
             raise ModelProviderClientError("model is required for chat completion")
 
-        request_payload = {
-            "model": payload.model,
+        resolved_provider_type = provider_type or self.settings.provider_type
+        if resolved_provider_type == "anthropic":
+            return self._create_anthropic_message(
+                payload,
+                provider_base_url=provider_base_url,
+                provider_api_key=provider_api_key,
+                timeout_seconds=timeout_seconds)
+
+        return self._create_openai_compatible_chat_completion(
+            payload,
+            provider_base_url=provider_base_url,
+            provider_api_key=provider_api_key,
+            timeout_seconds=timeout_seconds)
+
+    def _create_openai_compatible_chat_completion(
+        self,
+        payload: ChatCompletionRequestContract,
+        *,
+        provider_base_url: str | None,
+        provider_api_key: str | None,
+        timeout_seconds: float) -> ChatCompletionResponseContract:
+        request_payload: dict[str, JSONValue] = {
+            "model": payload.model or "",
             "messages": [item.model_dump(mode="json") for item in payload.messages],
         }
         if payload.temperature is not None:
@@ -50,10 +72,14 @@ class ModelProviderClient:
             base_url = provider_base_url or self.settings.provider_base_url
             with httpx.Client(timeout=timeout_seconds) as client:
                 response = client.post(
-                    f"{base_url.rstrip('/')}/chat/completions",
+                    _join_url(base_url, "chat/completions"),
                     json=request_payload,
                     headers=request_headers)
                 response.raise_for_status()
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:1000]
+            raise ModelProviderClientError(
+                f"model provider request failed: {exc.response.status_code} {detail}") from exc
         except httpx.HTTPError as exc:
             raise ModelProviderClientError(f"model provider request failed: {exc}") from exc
 
@@ -70,6 +96,62 @@ class ModelProviderClient:
             usage_json=usage_json,
             raw_response_json=response_json)
 
+    def _create_anthropic_message(
+        self,
+        payload: ChatCompletionRequestContract,
+        *,
+        provider_base_url: str | None,
+        provider_api_key: str | None,
+        timeout_seconds: float) -> ChatCompletionResponseContract:
+        api_key = (
+            provider_api_key
+            if provider_api_key is not None
+            else self.settings.provider_api_key
+        )
+        if not api_key:
+            raise ModelProviderClientError("anthropic api key is required")
+
+        system_prompt, messages = _to_anthropic_messages(payload)
+        request_payload: dict[str, JSONValue] = {
+            "model": payload.model or "",
+            "max_tokens": payload.max_tokens or 1024,
+            "messages": messages,
+        }
+        if system_prompt:
+            request_payload["system"] = system_prompt
+        if payload.temperature is not None:
+            request_payload["temperature"] = payload.temperature
+
+        request_headers = {
+            "content-type": "application/json",
+            "x-api-key": api_key,
+            "anthropic-version": "2023-06-01",
+        }
+
+        try:
+            base_url = provider_base_url or self.settings.provider_base_url
+            with httpx.Client(timeout=timeout_seconds) as client:
+                response = client.post(
+                    _join_url(base_url, "v1/messages"),
+                    json=request_payload,
+                    headers=request_headers)
+                response.raise_for_status()
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:1000]
+            raise ModelProviderClientError(
+                f"anthropic request failed: {exc.response.status_code} {detail}") from exc
+        except httpx.HTTPError as exc:
+            raise ModelProviderClientError(f"anthropic request failed: {exc}") from exc
+
+        response_json = _coerce_json_dict(response.json())
+        return ChatCompletionResponseContract(
+            model=_read_string(response_json, "model") or payload.model,
+            content=_extract_anthropic_content(response_json),
+            finish_reason=_read_string(response_json, "stop_reason"),
+            tool_calls_json=[],
+            usage_json=_extract_usage_json(response_json),
+            raw_response_json=response_json)
+
 
 def _coerce_json_dict(payload: JSONValue) -> dict[str, JSONValue]:
     if isinstance(payload, dict):
@@ -77,6 +159,54 @@ def _coerce_json_dict(payload: JSONValue) -> dict[str, JSONValue]:
     return {}
 
 
+def _join_url(base_url: str, path: str) -> str:
+    normalized_base = base_url.rstrip("/")
+    normalized_path = path.strip("/")
+    if normalized_path.startswith("v1/") and normalized_base.endswith("/v1"):
+        normalized_path = normalized_path.removeprefix("v1/")
+    return f"{normalized_base}/{normalized_path}"
+
+
+def _to_anthropic_messages(
+    payload: ChatCompletionRequestContract) -> tuple[str | None, list[dict[str, JSONValue]]]:
+    system_parts: list[str] = []
+    messages: list[dict[str, JSONValue]] = []
+    for message in payload.messages:
+        if message.role == "system":
+            system_parts.append(message.content)
+            continue
+        role = "assistant" if message.role == "assistant" else "user"
+        if messages and messages[-1].get("role") == role:
+            previous = messages[-1].get("content")
+            messages[-1]["content"] = f"{previous}\n\n{message.content}" if isinstance(previous, str) else message.content
+        else:
+            messages.append({"role": role, "content": message.content})
+    if not messages:
+        messages.append({"role": "user", "content": ""})
+    return ("\n\n".join(system_parts) if system_parts else None), messages
+
+
+def _extract_anthropic_content(payload: dict[str, JSONValue]) -> str:
+    content = payload.get("content")
+    if isinstance(content, str):
+        return content
+    if not isinstance(content, list):
+        return ""
+    parts: list[str] = []
+    for item in content:
+        if not isinstance(item, dict):
+            continue
+        text = item.get("text")
+        if isinstance(text, str):
+            parts.append(text)
+    return "\n".join(parts)
+
+
+def _read_string(payload: dict[str, JSONValue], key: str) -> str | None:
+    value = payload.get(key)
+    return value if isinstance(value, str) else None
+
+
 def _extract_response_content(payload: dict[str, JSONValue]) -> str:
     choices = payload.get("choices")
     if isinstance(choices, list) and choices:

+ 272 - 3
services/model-gateway-service/app/schemas/model.py

@@ -1,19 +1,68 @@
 from datetime import datetime
-from typing import TYPE_CHECKING, Literal
+from typing import TYPE_CHECKING, Generic, Literal, TypeVar
 
 from core_domain import ChatCompletionResponseContract
 from core_shared import JSONValue
 from pydantic import BaseModel, Field, HttpUrl
 
 if TYPE_CHECKING:
-    from app.db.models import ModelDefinition
+    from app.db.models import ModelDefinition, ModelProviderDefinition
 
 ModelStatus = Literal["active", "disabled"]
+T = TypeVar("T")
+
+
+class ApiErrorResponse(BaseModel):
+    errorType: str
+    message: str
+    details: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class ApiResponse(BaseModel, Generic[T]):
+    success: bool = True
+    data: T | None = None
+    error: ApiErrorResponse | None = None
+    requestId: str
+    serverTime: datetime
+
+
+class PageRequest(BaseModel):
+    page: int = Field(default=1, ge=1)
+    pageSize: int = Field(default=20, ge=1, le=200)
+    keyword: str | None = None
+
+    @property
+    def offset(self) -> int:
+        return (self.page - 1) * self.pageSize
+
+
+class PageResult(BaseModel, Generic[T]):
+    items: list[T]
+    total: int
+    page: int
+    pageSize: int
+    hasMore: bool
+
+    @classmethod
+    def from_items(
+        cls,
+        *,
+        items: list[T],
+        total: int,
+        page: int,
+        page_size: int) -> "PageResult[T]":
+        return cls(
+            items=items,
+            total=total,
+            page=page,
+            pageSize=page_size,
+            hasMore=page * page_size < total)
 
 
 class ModelCreateRequest(BaseModel):
-    code: str = Field(min_length=1, max_length=64)
+    code: str | None = Field(default=None, min_length=1, max_length=64)
     name: str = Field(min_length=1, max_length=128)
+    provider_id: str | None = None
     provider_type: str = "openai_compatible"
     provider_base_url: HttpUrl | str
     provider_api_key: str | None = None
@@ -31,6 +80,7 @@ class ModelCreateRequest(BaseModel):
 class ModelUpdateRequest(BaseModel):
     code: str | None = Field(default=None, min_length=1, max_length=64)
     name: str | None = Field(default=None, min_length=1, max_length=128)
+    provider_id: str | None = None
     provider_type: str | None = None
     provider_base_url: HttpUrl | str | None = None
     provider_api_key: str | None = None
@@ -53,6 +103,7 @@ class ModelResponse(BaseModel):
     id: str
     code: str
     name: str
+    provider_id: str | None = None
     provider_type: str
     provider_base_url: str
     has_provider_api_key: bool
@@ -74,6 +125,7 @@ class ModelResponse(BaseModel):
             id=entity.id,
             code=entity.code,
             name=entity.name,
+            provider_id=entity.provider_id,
             provider_type=entity.provider_type,
             provider_base_url=entity.provider_base_url,
             has_provider_api_key=bool(entity.provider_api_key),
@@ -91,6 +143,88 @@ class ModelResponse(BaseModel):
         )
 
 
+class ModelDto(BaseModel):
+    id: str
+    name: str
+    providerId: str | None = None
+    providerType: str
+    providerBaseUrl: str
+    hasProviderApiKey: bool
+    modelName: str
+    description: str | None = None
+    capabilities: list[str] = Field(default_factory=list)
+    contextWindow: int | None = None
+    maxOutputTokens: int | None = None
+    defaultTemperature: float | None = None
+    timeoutSeconds: float
+    metadata: dict[str, JSONValue] | None = None
+    createdTime: datetime
+    updatedTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "ModelDefinition") -> "ModelDto":
+        return cls(
+            id=entity.id,
+            name=entity.name,
+            providerId=entity.provider_id,
+            providerType=entity.provider_type,
+            providerBaseUrl=entity.provider_base_url,
+            hasProviderApiKey=bool(entity.provider_api_key),
+            modelName=entity.model_name,
+            description=entity.description,
+            capabilities=list(entity.capabilities_json or []),
+            contextWindow=entity.context_window,
+            maxOutputTokens=entity.max_output_tokens,
+            defaultTemperature=entity.default_temperature,
+            timeoutSeconds=entity.timeout_seconds,
+            metadata=entity.metadata_json,
+            createdTime=entity.created_time,
+            updatedTime=entity.updated_time)
+
+
+class ModelCreateRequestDto(BaseModel):
+    name: str = Field(min_length=1, max_length=128)
+    providerId: str | None = None
+    providerType: str = "openai_compatible"
+    providerBaseUrl: HttpUrl | str | None = None
+    providerApiKey: str | None = None
+    modelName: str = Field(min_length=1, max_length=128)
+    description: str | None = None
+    capabilities: list[str] = Field(default_factory=lambda: ["chat"])
+    contextWindow: int | None = Field(default=None, ge=1)
+    maxOutputTokens: int | None = Field(default=None, ge=1)
+    defaultTemperature: float | None = Field(default=None, ge=0, le=2)
+    timeoutSeconds: float = Field(default=60.0, ge=1, le=300)
+    metadata: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class ModelUpdateRequestDto(BaseModel):
+    modelId: str
+    name: str | None = Field(default=None, min_length=1, max_length=128)
+    providerId: str | None = None
+    providerType: str | None = None
+    providerBaseUrl: HttpUrl | str | None = None
+    providerApiKey: str | None = None
+    modelName: str | None = Field(default=None, min_length=1, max_length=128)
+    description: str | None = None
+    capabilities: list[str] | None = None
+    contextWindow: int | None = Field(default=None, ge=1)
+    maxOutputTokens: int | None = Field(default=None, ge=1)
+    defaultTemperature: float | None = Field(default=None, ge=0, le=2)
+    timeoutSeconds: float | None = Field(default=None, ge=1, le=300)
+    metadata: dict[str, JSONValue] | None = None
+
+
+class ModelDeleteRequestDto(BaseModel):
+    modelId: str
+
+
+class DeleteData(BaseModel):
+    deleted: bool
+    modelId: str | None = None
+    providerId: str | None = None
+
+
 class ModelTestRequest(BaseModel):
     prompt: str = Field(default="Reply with a short readiness check.", min_length=1)
     system_prompt: str | None = "You are a concise model connectivity checker."
@@ -101,3 +235,138 @@ class ModelTestRequest(BaseModel):
 class ModelTestResponse(BaseModel):
     model: ModelResponse
     response: ChatCompletionResponseContract
+
+
+class ModelTestRequestDto(BaseModel):
+    modelId: str
+    prompt: str = Field(default="Reply with a short readiness check.", min_length=1)
+    systemPrompt: str | None = "You are a concise model connectivity checker."
+    temperature: float | None = Field(default=None, ge=0, le=2)
+    maxTokens: int | None = Field(default=128, ge=1)
+
+
+class ModelTestData(BaseModel):
+    model: ModelDto
+    response: ChatCompletionResponseContract
+
+
+class ModelItemDto(BaseModel):
+    modelId: str
+    displayName: str
+    modelType: str
+    ownedBy: str | None = None
+    contextWindow: int | None = None
+
+
+class ModelProviderDto(BaseModel):
+    id: str
+    name: str
+    providerType: str
+    baseUrl: str
+    apiKeyRef: str
+    models: list[ModelItemDto]
+    defaultModel: str | None = None
+    extraConfig: dict[str, JSONValue] = Field(default_factory=dict)
+    createdTime: datetime
+    updatedTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "ModelProviderDefinition") -> "ModelProviderDto":
+        return cls(
+            id=entity.id,
+            name=entity.name,
+            providerType=entity.provider_type,
+            baseUrl=entity.base_url,
+            apiKeyRef=_mask_api_key(entity.api_key),
+            models=[
+                ModelItemDto(**_to_camel_model_item(item))
+                for item in entity.models_json or []
+            ],
+            defaultModel=entity.default_model,
+            extraConfig=entity.extra_config_json or {},
+            createdTime=entity.created_time,
+            updatedTime=entity.updated_time)
+
+
+class ModelProviderCreateRequestDto(BaseModel):
+    name: str = Field(min_length=1, max_length=128)
+    providerType: str = "openai_compatible"
+    baseUrl: HttpUrl | str
+    apiKey: str | None = None
+    models: list[ModelItemDto] = Field(default_factory=list)
+    defaultModel: str | None = None
+    extraConfig: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class ModelProviderUpdateRequestDto(BaseModel):
+    providerId: str
+    name: str | None = Field(default=None, min_length=1, max_length=128)
+    baseUrl: HttpUrl | str | None = None
+    apiKey: str | None = None
+    models: list[ModelItemDto] | None = None
+    defaultModel: str | None = None
+    extraConfig: dict[str, JSONValue] | None = None
+
+
+class ModelProviderDeleteRequestDto(BaseModel):
+    providerId: str
+
+
+class ModelProviderTestRequestDto(BaseModel):
+    providerId: str
+
+
+class ModelProviderTestData(BaseModel):
+    success: bool
+    message: str
+    latencyMs: int | None = None
+    modelList: list[str] = Field(default_factory=list)
+
+
+class DiscoverModelsRequestDto(BaseModel):
+    providerId: str | None = None
+    providerType: str | None = None
+    baseUrl: HttpUrl | str | None = None
+    apiKey: str | None = None
+
+
+class DiscoverModelsData(BaseModel):
+    providerType: str
+    models: list[ModelItemDto]
+
+
+def _mask_api_key(api_key: str | None) -> str:
+    if not api_key:
+        return ""
+    return f"{api_key[:3]}***masked"
+
+
+def _to_snake_model_item(item: ModelItemDto) -> dict[str, JSONValue]:
+    data: dict[str, JSONValue] = {
+        "model_id": item.modelId,
+        "display_name": item.displayName,
+        "model_type": item.modelType,
+    }
+    if item.ownedBy is not None:
+        data["owned_by"] = item.ownedBy
+    if item.contextWindow is not None:
+        data["context_window"] = item.contextWindow
+    return data
+
+
+def _to_camel_model_item(item: dict[str, JSONValue]) -> dict[str, JSONValue]:
+    return {
+        "modelId": str(item.get("model_id") or item.get("modelId") or ""),
+        "displayName": str(item.get("display_name") or item.get("displayName") or ""),
+        "modelType": str(item.get("model_type") or item.get("modelType") or "chat"),
+        "ownedBy": (
+            item.get("owned_by")
+            if isinstance(item.get("owned_by"), str)
+            else item.get("ownedBy")
+        ),
+        "contextWindow": (
+            item.get("context_window")
+            if isinstance(item.get("context_window"), int)
+            else item.get("contextWindow")
+        ),
+    }

+ 225 - 0
services/team-service/app/api/routes.py

@@ -1,3 +1,6 @@
+from datetime import datetime
+from typing import TypeVar
+
 from core_domain import ServiceHealth
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import text
@@ -12,14 +15,37 @@ from app.domain.repositories import (
     TeamVersionRepository,
 )
 from app.schemas.team import (
+    ApiResponse,
+    DeleteData,
+    PageResult,
+    TeamConfigCreateRequestDto,
+    TeamConfigDeleteRequestDto,
+    TeamConfigDetailRequestDto,
+    TeamConfigDto,
+    TeamConfigListRequestDto,
+    TeamConfigUpdateRequestDto,
     TeamCreateRequest,
+    TeamCreateRequestDto,
+    TeamDeleteRequestDto,
+    TeamDetailRequestDto,
+    TeamDto,
+    TeamListRequestDto,
     TeamResponse,
     TeamRunCreateRequest,
+    TeamRunCreateRequestDto,
+    TeamRunDeleteRequestDto,
+    TeamRunDetailRequestDto,
+    TeamRunDto,
     TeamRunExecuteRequest,
+    TeamRunExecuteData,
+    TeamRunExecuteRequestDto,
     TeamRunExecuteResponse,
+    TeamRunListRequestDto,
     TeamRunResponse,
     TeamRunStatusUpdateRequest,
+    TeamRunStatusUpdateRequestDto,
     TeamStatusUpdateRequest,
+    TeamUpdateRequestDto,
     TeamVersionCreateRequest,
     TeamVersionResponse,
     TeamWorkerExecuteNextRequest,
@@ -27,6 +53,14 @@ from app.schemas.team import (
 )
 
 router = APIRouter()
+T = TypeVar("T")
+
+
+def ok(data: T) -> ApiResponse[T]:
+    return ApiResponse[T](
+        data=data,
+        requestId="",
+        serverTime=datetime.utcnow())
 
 
 def get_team_settings() -> TeamServiceSettings:
@@ -63,6 +97,65 @@ def list_teams(
     return [TeamResponse.from_entity(item) for item in service.list_teams()]
 
 
+@router.post("/list", response_model=ApiResponse[PageResult[TeamDto]])
+def list_teams_contract(
+    payload: TeamListRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[PageResult[TeamDto]]:
+    keyword = (payload.keyword or "").lower().strip()
+    items = [
+        item
+        for item in service.list_teams()
+        if (payload.status is None or item.status == payload.status)
+        and (
+            not keyword
+            or keyword in item.name.lower()
+            or keyword in item.team_type.lower()
+            or keyword in (item.description or "").lower()
+        )
+    ]
+    page_items = items[payload.offset:payload.offset + payload.pageSize]
+    return ok(PageResult[TeamDto].from_items(
+        items=[TeamDto.from_entity(item) for item in page_items],
+        total=len(items),
+        page=payload.page,
+        page_size=payload.pageSize))
+
+
+@router.post("/create", response_model=ApiResponse[TeamDto])
+def create_team_contract(
+    payload: TeamCreateRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamDto]:
+    return ok(TeamDto.from_entity(service.create_team_from_contract(payload)))
+
+
+@router.post("/detail", response_model=ApiResponse[TeamDto])
+def get_team_contract(
+    payload: TeamDetailRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamDto]:
+    entity = service.get_team(team_id=payload.teamId)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"team not found: {payload.teamId}")
+    return ok(TeamDto.from_entity(entity))
+
+
+@router.post("/update", response_model=ApiResponse[TeamDto])
+def update_team_contract(
+    payload: TeamUpdateRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamDto]:
+    entity = service.update_team_from_contract(payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"team not found: {payload.teamId}")
+    return ok(TeamDto.from_entity(entity))
+
+
+@router.post("/delete", response_model=ApiResponse[DeleteData])
+def delete_team_contract(
+    payload: TeamDeleteRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[DeleteData]:
+    deleted = service.delete_team_from_contract(payload)
+    return ok(DeleteData(deleted=deleted, teamId=payload.teamId))
+
+
 @router.patch("/{team_id}/status", response_model=TeamResponse)
 def update_team_status(
     team_id: str,
@@ -95,6 +188,61 @@ def list_team_versions(
     ]
 
 
+@router.post("/configs/list", response_model=ApiResponse[PageResult[TeamConfigDto]])
+def list_team_configs_contract(
+    payload: TeamConfigListRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[PageResult[TeamConfigDto]]:
+    items = service.list_team_configs(team_id=payload.teamId)
+    page_items = items[payload.offset:payload.offset + payload.pageSize]
+    return ok(PageResult[TeamConfigDto].from_items(
+        items=[TeamConfigDto.from_entity(item) for item in page_items],
+        total=len(items),
+        page=payload.page,
+        page_size=payload.pageSize))
+
+
+@router.post("/configs/create", response_model=ApiResponse[TeamConfigDto])
+def create_team_config_contract(
+    payload: TeamConfigCreateRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamConfigDto]:
+    try:
+        entity = service.create_team_config_from_contract(payload)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+    return ok(TeamConfigDto.from_entity(entity))
+
+
+@router.post("/configs/detail", response_model=ApiResponse[TeamConfigDto])
+def get_team_config_contract(
+    payload: TeamConfigDetailRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamConfigDto]:
+    entity = service.get_team_config(config_id=payload.configId)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"team config not found: {payload.configId}")
+    return ok(TeamConfigDto.from_entity(entity))
+
+
+@router.post("/configs/update", response_model=ApiResponse[TeamConfigDto])
+def update_team_config_contract(
+    payload: TeamConfigUpdateRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamConfigDto]:
+    try:
+        entity = service.update_team_config_from_contract(payload)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"team config not found: {payload.configId}")
+    return ok(TeamConfigDto.from_entity(entity))
+
+
+@router.post("/configs/delete", response_model=ApiResponse[DeleteData])
+def delete_team_config_contract(
+    payload: TeamConfigDeleteRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[DeleteData]:
+    deleted = service.delete_team_config(config_id=payload.configId)
+    return ok(DeleteData(deleted=deleted, configId=payload.configId))
+
+
 @router.post("/runs", response_model=TeamRunResponse)
 def create_team_run(
     payload: TeamRunCreateRequest,
@@ -119,6 +267,82 @@ def list_team_runs(
     ]
 
 
+@router.post("/runs/list", response_model=ApiResponse[PageResult[TeamRunDto]])
+def list_team_runs_contract(
+    payload: TeamRunListRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[PageResult[TeamRunDto]]:
+    items = [
+        item
+        for item in service.list_team_runs(team_id=payload.teamId, session_id=payload.sessionId)
+        if payload.status is None or item.status == payload.status
+    ]
+    page_items = items[payload.offset:payload.offset + payload.pageSize]
+    return ok(PageResult[TeamRunDto].from_items(
+        items=[TeamRunDto.from_entity(item) for item in page_items],
+        total=len(items),
+        page=payload.page,
+        page_size=payload.pageSize))
+
+
+@router.post("/runs/create", response_model=ApiResponse[TeamRunDto])
+def create_team_run_contract(
+    payload: TeamRunCreateRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamRunDto]:
+    try:
+        entity = service.create_team_run_from_contract(payload)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+    return ok(TeamRunDto.from_entity(entity))
+
+
+@router.post("/runs/detail", response_model=ApiResponse[TeamRunDto])
+def get_team_run_contract(
+    payload: TeamRunDetailRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamRunDto]:
+    entity = service.get_team_run(team_run_id=payload.teamRunId)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"team_run not found: {payload.teamRunId}")
+    return ok(TeamRunDto.from_entity(entity))
+
+
+@router.post("/runs/status", response_model=ApiResponse[TeamRunDto])
+def update_team_run_status_contract(
+    payload: TeamRunStatusUpdateRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamRunDto]:
+    entity = service.update_team_run_status_from_contract(payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"team_run not found: {payload.teamRunId}")
+    return ok(TeamRunDto.from_entity(entity))
+
+
+@router.post("/runs/execute", response_model=ApiResponse[TeamRunExecuteData])
+def execute_team_run_contract(
+    payload: TeamRunExecuteRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[TeamRunExecuteData]:
+    entity = service.execute_team_run(
+        team_run_id=payload.teamRunId,
+        payload=TeamRunExecuteRequest(
+            worker_key=payload.workerKey,
+            dry_run=payload.dryRun))
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"team_run not found: {payload.teamRunId}")
+    output_json = entity.output_json or {}
+    member_run_count = output_json.get("member_run_count")
+    dry_run = output_json.get("dry_run")
+    return ok(TeamRunExecuteData(
+        run=TeamRunDto.from_entity(entity),
+        memberRunCount=member_run_count if isinstance(member_run_count, int) else 0,
+        dryRun=dry_run if isinstance(dry_run, bool) else payload.dryRun))
+
+
+@router.post("/runs/delete", response_model=ApiResponse[DeleteData])
+def delete_team_run_contract(
+    payload: TeamRunDeleteRequestDto,
+    service: TeamApplicationService = Depends(get_team_application_service)) -> ApiResponse[DeleteData]:
+    deleted = service.delete_team_run(team_run_id=payload.teamRunId)
+    return ok(DeleteData(deleted=deleted, teamRunId=payload.teamRunId))
+
+
 @router.post("/runs/{team_run_id}/status", response_model=TeamRunResponse)
 def update_team_run_status(
     team_run_id: str,
@@ -155,6 +379,7 @@ def execute_next_worker_task(
     result = service.execute_next_claimed_team_run(
         worker_key=payload.worker_key,
         lease_seconds=payload.lease_seconds or settings.worker_lease_seconds,
+        stale_running_seconds=settings.worker_stale_running_seconds,
         dry_run=payload.dry_run if payload.dry_run is not None else settings.worker_dry_run)
     if result is None:
         raise HTTPException(status_code=404, detail="queued team_run not found")

+ 195 - 21
services/team-service/app/application/services.py

@@ -1,3 +1,4 @@
+from dataclasses import dataclass
 from datetime import datetime, timedelta
 
 from core_events import EventPublishContract, EventServiceClient, EventServiceClientError
@@ -13,14 +14,27 @@ from app.domain.repositories import (
     TeamVersionRepository)
 from app.infrastructure.agent_client import AgentServiceClient, AgentServiceClientError
 from app.schemas.team import (
+    TeamConfigCreateRequestDto,
+    TeamConfigUpdateRequestDto,
     TeamCreateRequest,
+    TeamCreateRequestDto,
+    TeamDeleteRequestDto,
     TeamRunCreateRequest,
+    TeamRunCreateRequestDto,
     TeamRunExecuteRequest,
+    TeamRunStatusUpdateRequestDto,
     TeamRunStatusUpdateRequest,
     TeamStatusUpdateRequest,
+    TeamUpdateRequestDto,
     TeamVersionCreateRequest)
 
 
+@dataclass(frozen=True)
+class TeamMemberRunResult:
+    member: TeamMemberContract
+    run: AgentRunContract
+
+
 class TeamApplicationService:
     def __init__(
         self,
@@ -47,9 +61,48 @@ class TeamApplicationService:
             owner_user_id=payload.owner_user_id,
             metadata_json=payload.metadata_json)
 
+    def create_team_from_contract(self, payload: TeamCreateRequestDto) -> TeamDefinition:
+        return self.create_team(
+            TeamCreateRequest(
+                code=self._build_team_code(payload.name),
+                name=payload.name,
+                description=payload.description,
+                team_type=payload.teamType,
+                owner_user_id=payload.ownerUserId,
+                metadata_json=payload.metadata))
+
     def list_teams(self) -> list[TeamDefinition]:
         return self.team_repository.list_all()
 
+    def get_team(self, *, team_id: str) -> TeamDefinition | None:
+        return self.team_repository.get_by_id(team_id=team_id)
+
+    def update_team_from_contract(self, payload: TeamUpdateRequestDto) -> TeamDefinition | None:
+        entity = self.team_repository.get_by_id(team_id=payload.teamId)
+        if entity is None:
+            return None
+        if payload.name is not None:
+            entity.name = payload.name
+            entity.code = self._build_team_code(payload.name)
+        if payload.description is not None:
+            entity.description = payload.description
+        if payload.teamType is not None:
+            entity.team_type = payload.teamType
+        if payload.status is not None:
+            entity.status = payload.status
+        if payload.ownerUserId is not None:
+            entity.owner_user_id = payload.ownerUserId
+        if payload.metadata is not None:
+            entity.metadata_json = payload.metadata
+        return self.team_repository.save(entity)
+
+    def delete_team_from_contract(self, payload: TeamDeleteRequestDto) -> bool:
+        entity = self.team_repository.get_by_id(team_id=payload.teamId)
+        if entity is None:
+            return False
+        self.team_repository.delete(entity)
+        return True
+
     def update_team_status(
         self,
         *,
@@ -74,9 +127,54 @@ class TeamApplicationService:
             member_refs_json=[item.model_dump(mode="json") for item in payload.member_refs],
             policy_json=payload.policy_json)
 
+    def create_team_config_from_contract(self, payload: TeamConfigCreateRequestDto) -> TeamVersion:
+        return self.create_team_version(
+            TeamVersionCreateRequest(
+                team_id=payload.teamId,
+                status=payload.status,
+                coordination_mode=payload.coordinationMode,
+                objective=payload.objective,
+                member_refs=self._normalize_member_refs(payload.memberRefs),
+                policy_json=payload.policy))
+
     def list_team_versions(self, *, team_id: str) -> list[TeamVersion]:
         return self.team_version_repository.list_by_team(team_id=team_id)
 
+    def list_team_configs(self, *, team_id: str | None = None) -> list[TeamVersion]:
+        if team_id is not None:
+            return self.team_version_repository.list_by_team(team_id=team_id)
+        return self.team_version_repository.list_all()
+
+    def get_team_config(self, *, config_id: str) -> TeamVersion | None:
+        return self.team_version_repository.get_by_id(team_version_id=config_id)
+
+    def update_team_config_from_contract(self, payload: TeamConfigUpdateRequestDto) -> TeamVersion | None:
+        entity = self.team_version_repository.get_by_id(team_version_id=payload.configId)
+        if entity is None:
+            return None
+        if payload.status is not None:
+            entity.status = payload.status
+            entity.published_time = datetime.utcnow() if payload.status == "published" else entity.published_time
+        if payload.coordinationMode is not None:
+            entity.coordination_mode = payload.coordinationMode
+        if payload.objective is not None:
+            entity.objective = payload.objective
+        if payload.memberRefs is not None:
+            normalized = self._normalize_member_refs(payload.memberRefs)
+            if not normalized:
+                raise ValueError("team config requires at least one member")
+            entity.member_refs_json = [item.model_dump(mode="json") for item in normalized]
+        if payload.policy is not None:
+            entity.policy_json = payload.policy
+        return self.team_version_repository.save(entity)
+
+    def delete_team_config(self, *, config_id: str) -> bool:
+        entity = self.team_version_repository.get_by_id(team_version_id=config_id)
+        if entity is None:
+            return False
+        self.team_version_repository.delete(entity)
+        return True
+
     def create_team_run(self, payload: TeamRunCreateRequest) -> TeamRun:
         team_version = self._resolve_team_version(
             team_id=payload.team_id,
@@ -99,6 +197,15 @@ class TeamApplicationService:
                 team_run_id=team_run.id)
         return team_run
 
+    def create_team_run_from_contract(self, payload: TeamRunCreateRequestDto) -> TeamRun:
+        return self.create_team_run(
+            TeamRunCreateRequest(
+                team_id=payload.teamId,
+                team_version_id=payload.teamConfigId,
+                session_id=payload.sessionId,
+                input_text=payload.inputText,
+                input_json=payload.inputJson))
+
     def list_team_runs(
         self,
         *,
@@ -108,6 +215,16 @@ class TeamApplicationService:
             team_id=team_id,
             session_id=session_id)
 
+    def get_team_run(self, *, team_run_id: str) -> TeamRun | None:
+        return self.team_run_repository.get_by_id(team_run_id=team_run_id)
+
+    def delete_team_run(self, *, team_run_id: str) -> bool:
+        entity = self.team_run_repository.get_by_id(team_run_id=team_run_id)
+        if entity is None:
+            return False
+        self.team_run_repository.delete(entity)
+        return True
+
     def update_team_run_status(
         self,
         *,
@@ -126,6 +243,19 @@ class TeamApplicationService:
             error_code=payload.error_code,
             error_message=payload.error_message)
 
+    def update_team_run_status_from_contract(
+        self,
+        payload: TeamRunStatusUpdateRequestDto) -> TeamRun | None:
+        return self.update_team_run_status(
+            team_run_id=payload.teamRunId,
+            payload=TeamRunStatusUpdateRequest(
+                status=payload.status,
+                worker_key=payload.workerKey,
+                output_text=payload.outputText,
+                output_json=payload.outputJson,
+                error_code=payload.errorCode,
+                error_message=payload.errorMessage))
+
     def execute_team_run(
         self,
         *,
@@ -145,6 +275,16 @@ class TeamApplicationService:
                 worker_key=payload.worker_key,
                 error_code="team_version_missing",
                 error_message=f"team version not found: {team_run.team_version_id}")
+            if failed_run is not None:
+                self._publish_event(
+                    event_type="team.run.failed",
+                    team_run=failed_run,
+                    payload_json={
+                        "team_run_id": failed_run.id,
+                        "status": failed_run.status,
+                        "error_code": "team_version_missing",
+                    })
+            return failed_run
 
         running_run = self.team_run_repository.update_status(
             team_run_id=team_run.id,
@@ -177,7 +317,7 @@ class TeamApplicationService:
                 error_code="agent_service_error",
                 error_message=str(exc))
 
-        failed_results = [item for item in member_results if item.status != "completed"]
+        failed_results = [item for item in member_results if item.run.status != "completed"]
         output_text = self._build_team_output_text(
             team_version=team_version,
             member_results=member_results)
@@ -191,7 +331,7 @@ class TeamApplicationService:
             ],
         }
         if failed_results:
-            return self.team_run_repository.update_status(
+            failed_run = self.team_run_repository.update_status(
                 team_run_id=team_run.id,
                 status="failed",
                 worker_key=payload.worker_key,
@@ -232,9 +372,11 @@ class TeamApplicationService:
         *,
         worker_key: str,
         lease_seconds: int,
+        stale_running_seconds: int,
         dry_run: bool) -> tuple[TeamRun, int] | None:
         released_lease_count = self.team_run_repository.release_expired_leases(
-            now_time=datetime.utcnow())
+            now_time=datetime.utcnow(),
+            stale_running_seconds=stale_running_seconds)
         claimed_team_run = self.team_run_repository.claim_next_queued(
             worker_key=worker_key,
             lease_expire_time=datetime.utcnow() + timedelta(seconds=lease_seconds))
@@ -268,11 +410,11 @@ class TeamApplicationService:
         team_version: TeamVersion,
         members: list[TeamMemberContract],
         worker_key: str | None,
-        dry_run: bool) -> list[AgentRunContract]:
+        dry_run: bool) -> list[TeamMemberRunResult]:
         if self.agent_client is None:
             raise AgentServiceClientError("agent service client is not configured")
 
-        member_results: list[AgentRunContract] = []
+        member_results: list[TeamMemberRunResult] = []
         prior_outputs: list[dict[str, JSONValue]] = []
         for member in self._order_members(members):
             member_input_json = self._build_member_input_json(
@@ -293,11 +435,13 @@ class TeamApplicationService:
                 agent_run_id=created_run.id,
                 worker_key=worker_key,
                 dry_run=dry_run)
-            member_results.append(executed_run)
+            member_results.append(TeamMemberRunResult(member=member, run=executed_run))
             prior_outputs.append(
                 {
                     "member_key": member.member_key,
-                    "role": member.role,
+                    "member_role": member.role,
+                    "member_name": member.name,
+                    "agent_id": member.agent_id,
                     "agent_run_id": executed_run.id,
                     "status": executed_run.status,
                     "output_text": executed_run.output_text,
@@ -372,27 +516,34 @@ class TeamApplicationService:
         self,
         *,
         team_version: TeamVersion,
-        member_results: list[AgentRunContract]) -> str:
+        member_results: list[TeamMemberRunResult]) -> str:
         lines = [
             f"Team objective: {team_version.objective or 'No objective provided.'}",
             f"Coordination mode: {team_version.coordination_mode}",
-            "Member results:",
+            "Team conversation:",
         ]
-        for index, result in enumerate(member_results, start=1):
-            output_text = result.output_text or result.error_message or ""
-            lines.append(f"{index}. agent={result.agent_id} status={result.status}: {output_text}")
+        for index, item in enumerate(member_results, start=1):
+            output_text = item.run.output_text or item.run.error_message or ""
+            speaker = item.member.name or item.member.member_key
+            lines.append(
+                f"{index}. {speaker} ({item.member.role}) "
+                f"status={item.run.status}: {output_text}")
         return "\n".join(lines)
 
-    def _member_result_to_json(self, result: AgentRunContract) -> dict[str, JSONValue]:
+    def _member_result_to_json(self, result: TeamMemberRunResult) -> dict[str, JSONValue]:
         return {
-            "agent_run_id": result.id,
-            "agent_id": result.agent_id,
-            "agent_version_id": result.agent_version_id,
-            "status": result.status,
-            "output_text": result.output_text,
-            "output_json": result.output_json or {},
-            "error_code": result.error_code,
-            "error_message": result.error_message,
+            "member_key": result.member.member_key,
+            "member_role": result.member.role,
+            "member_name": result.member.name,
+            "member_responsibility": result.member.responsibility,
+            "agent_run_id": result.run.id,
+            "agent_id": result.run.agent_id,
+            "agent_version_id": result.run.agent_version_id,
+            "status": result.run.status,
+            "output_text": result.run.output_text,
+            "output_json": result.run.output_json or {},
+            "error_code": result.run.error_code,
+            "error_message": result.run.error_message,
         }
 
     def _publish_event(
@@ -420,6 +571,29 @@ class TeamApplicationService:
         except EventServiceClientError:
             return
 
+    def _build_team_code(self, name: str) -> str:
+        base = "".join(
+            char.lower() if char.isalnum() else "_"
+            for char in name
+        ).strip("_") or "team"
+        return base[:64]
+
+    def _normalize_member_refs(self, member_refs: list[dict[str, JSONValue]]) -> list[TeamMemberContract]:
+        members: list[TeamMemberContract] = []
+        for index, item in enumerate(member_refs, start=1):
+            role = item.get("role")
+            normalized_role = "executor" if role == "worker" else role
+            member = {
+                **item,
+                "member_key": item.get("member_key") or item.get("memberKey") or f"member_{index}",
+                "agent_id": item.get("agent_id") or item.get("agentId"),
+                "agent_version_id": item.get("agent_version_id") or item.get("agentVersionId"),
+                "role": normalized_role or "specialist",
+                "config_json": item.get("config_json") or item.get("configJson") or {},
+            }
+            members.append(TeamMemberContract.model_validate(member))
+        return members
+
 
 def build_team_application_service(
     *,

+ 23 - 1
services/team-service/app/bootstrap/app.py

@@ -1,3 +1,5 @@
+from contextlib import asynccontextmanager
+
 from core_shared.observability import add_observability
 from core_shared.security import add_internal_service_auth
 from fastapi import FastAPI
@@ -5,13 +7,33 @@ from fastapi import FastAPI
 from app.api.routes import router
 from app.bootstrap.settings import TeamServiceSettings
 from app.db.session import build_session_factory
+from app.worker import BackgroundTeamWorker, build_worker_key
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    settings: TeamServiceSettings = app.state.settings
+    worker: BackgroundTeamWorker | None = None
+    if settings.auto_worker_enabled:
+        worker = BackgroundTeamWorker(
+            settings=settings,
+            session_factory=app.state.session_factory,
+            worker_key=f"api-{build_worker_key()}")
+        app.state.background_team_worker = worker
+        worker.start()
+    try:
+        yield
+    finally:
+        if worker is not None:
+            worker.stop(timeout_seconds=settings.auto_worker_stop_timeout_seconds)
 
 
 def create_app() -> FastAPI:
     settings = TeamServiceSettings()
     app = FastAPI(
         title="agent-platform team-service",
-        version="0.1.0")
+        version="0.1.0",
+        lifespan=lifespan)
     app.state.settings = settings
     app.state.session_factory = build_session_factory(settings)
     add_observability(app, settings.service_name)

+ 5 - 2
services/team-service/app/bootstrap/settings.py

@@ -6,10 +6,13 @@ class TeamServiceSettings(ServiceSettings):
     service_port: int = 8009
     database_url: str = "sqlite:///./team_service.db"
     agent_service_url: str = "http://127.0.0.1:8007"
-    agent_service_timeout_seconds: float = 30.0
+    agent_service_timeout_seconds: float = 120.0
     event_service_url: str = "http://127.0.0.1:8013"
     event_service_timeout_seconds: float = 5.0
     worker_poll_interval_seconds: float = 1.0
     worker_lease_seconds: int = 300
+    worker_stale_running_seconds: int = 60
     worker_max_idle_cycles: int | None = None
-    worker_dry_run: bool = True
+    worker_dry_run: bool = False
+    auto_worker_enabled: bool = True
+    auto_worker_stop_timeout_seconds: float = 5.0

+ 44 - 4
services/team-service/app/domain/repositories.py

@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timedelta
 
 from sqlalchemy import func, select
 from sqlalchemy.orm import Session
@@ -48,6 +48,16 @@ class TeamDefinitionRepository:
         )
         return self.db.scalar(stmt)
 
+    def save(self, entity: TeamDefinition) -> TeamDefinition:
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def delete(self, entity: TeamDefinition) -> None:
+        self.db.delete(entity)
+        self.db.commit()
+
     def update_status(
         self,
         *,
@@ -98,6 +108,10 @@ class TeamVersionRepository:
         )
         return list(self.db.scalars(stmt))
 
+    def list_all(self) -> list[TeamVersion]:
+        stmt = select(TeamVersion).order_by(TeamVersion.created_time.desc())
+        return list(self.db.scalars(stmt))
+
     def get_by_id(self, *, team_version_id: str) -> TeamVersion | None:
         stmt = (
             select(TeamVersion)
@@ -105,6 +119,16 @@ class TeamVersionRepository:
         )
         return self.db.scalar(stmt)
 
+    def save(self, entity: TeamVersion) -> TeamVersion:
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def delete(self, entity: TeamVersion) -> None:
+        self.db.delete(entity)
+        self.db.commit()
+
     def get_latest_published(self, *, team_id: str) -> TeamVersion | None:
         stmt = (
             select(TeamVersion)
@@ -167,6 +191,10 @@ class TeamRunRepository:
         )
         return self.db.scalar(stmt)
 
+    def delete(self, entity: TeamRun) -> None:
+        self.db.delete(entity)
+        self.db.commit()
+
     def claim_next_queued(
         self,
         *,
@@ -192,12 +220,24 @@ class TeamRunRepository:
         self.db.refresh(entity)
         return entity
 
-    def release_expired_leases(self, *, now_time: datetime, max_items: int = 100) -> int:
+    def release_expired_leases(
+        self,
+        *,
+        now_time: datetime,
+        stale_running_seconds: int,
+        max_items: int = 100) -> int:
+        stale_started_before = now_time - timedelta(seconds=stale_running_seconds)
         stmt = (
             select(TeamRun)
             .where(TeamRun.status == "running")
-            .where(TeamRun.lease_expire_time.is_not(None))
-            .where(TeamRun.lease_expire_time <= now_time)
+            .where(
+                (TeamRun.lease_expire_time.is_not(None) & (TeamRun.lease_expire_time <= now_time))
+                | (
+                    TeamRun.lease_expire_time.is_(None)
+                    & TeamRun.started_time.is_not(None)
+                    & (TeamRun.started_time <= stale_started_before)
+                )
+            )
             .order_by(TeamRun.lease_expire_time.asc())
             .limit(max_items)
         )

+ 36 - 0
services/team-service/app/infrastructure/agent_client.py

@@ -37,9 +37,31 @@ class AgentServiceClient:
                 response = client.post(f"{self.base_url}/agents/runs", json=payload)
                 response.raise_for_status()
                 return AgentRunContract.model_validate(response.json())
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:500]
+            raise AgentServiceClientError(
+                f"agent-service create run failed: {exc.response.status_code} {detail}") from exc
         except httpx.HTTPError as exc:
             raise AgentServiceClientError(f"agent-service create run failed: {exc}") from exc
 
+    def get_agent_run(
+        self,
+        *,
+        agent_run_id: str) -> AgentRunContract:
+        try:
+            with httpx.Client(timeout=self.timeout_seconds) as client:
+                response = client.post(
+                    f"{self.base_url}/agents/runs/detail",
+                    json={"agent_run_id": agent_run_id})
+                response.raise_for_status()
+                return AgentRunContract.model_validate(response.json())
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:500]
+            raise AgentServiceClientError(
+                f"agent-service get run failed: {exc.response.status_code} {detail}") from exc
+        except httpx.HTTPError as exc:
+            raise AgentServiceClientError(f"agent-service get run failed: {exc}") from exc
+
     def execute_agent_run(
         self,
         *,
@@ -63,5 +85,19 @@ class AgentServiceClient:
                 if not isinstance(run_payload, dict):
                     raise AgentServiceClientError("agent-service execute response missing run")
                 return AgentRunContract.model_validate(run_payload)
+        except httpx.TimeoutException as exc:
+            try:
+                recovered_run = self.get_agent_run(agent_run_id=agent_run_id)
+            except AgentServiceClientError as recovery_exc:
+                raise AgentServiceClientError(
+                    f"agent-service execute run timed out and recovery failed: {recovery_exc}") from exc
+            if recovered_run.status in {"completed", "failed", "cancelled"}:
+                return recovered_run
+            raise AgentServiceClientError(
+                f"agent-service execute run timed out; recovered status={recovered_run.status}") from exc
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:500]
+            raise AgentServiceClientError(
+                f"agent-service execute run failed: {exc.response.status_code} {detail}") from exc
         except httpx.HTTPError as exc:
             raise AgentServiceClientError(f"agent-service execute run failed: {exc}") from exc

+ 253 - 1
services/team-service/app/schemas/team.py

@@ -1,4 +1,5 @@
-from typing import TYPE_CHECKING
+from datetime import datetime
+from typing import TYPE_CHECKING, Generic, TypeVar
 
 from core_domain import (
     TeamDefinitionContract,
@@ -15,6 +16,55 @@ from pydantic import BaseModel, Field
 if TYPE_CHECKING:
     from app.db.models import TeamDefinition, TeamRun, TeamVersion
 
+T = TypeVar("T")
+
+
+class ApiErrorResponse(BaseModel):
+    errorType: str
+    message: str
+    details: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class ApiResponse(BaseModel, Generic[T]):
+    success: bool = True
+    data: T | None = None
+    error: ApiErrorResponse | None = None
+    requestId: str
+    serverTime: datetime
+
+
+class PageRequest(BaseModel):
+    page: int = Field(default=1, ge=1)
+    pageSize: int = Field(default=20, ge=1, le=200)
+    keyword: str | None = None
+
+    @property
+    def offset(self) -> int:
+        return (self.page - 1) * self.pageSize
+
+
+class PageResult(BaseModel, Generic[T]):
+    items: list[T]
+    total: int
+    page: int
+    pageSize: int
+    hasMore: bool
+
+    @classmethod
+    def from_items(
+        cls,
+        *,
+        items: list[T],
+        total: int,
+        page: int,
+        page_size: int) -> "PageResult[T]":
+        return cls(
+            items=items,
+            total=total,
+            page=page,
+            pageSize=page_size,
+            hasMore=page * page_size < total)
+
 
 class TeamCreateRequest(BaseModel):
     code: str
@@ -95,3 +145,205 @@ class TeamWorkerExecuteNextResponse(BaseModel):
     member_run_count: int = 0
     dry_run: bool = True
     released_lease_count: int = 0
+
+
+class TeamDto(BaseModel):
+    id: str
+    name: str
+    description: str | None = None
+    teamType: str
+    status: TeamStatus
+    ownerUserId: str | None = None
+    metadata: dict[str, JSONValue] = Field(default_factory=dict)
+    createdTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "TeamDefinition") -> "TeamDto":
+        return cls(
+            id=entity.id,
+            name=entity.name,
+            description=entity.description,
+            teamType=entity.team_type,
+            status=entity.status,
+            ownerUserId=entity.owner_user_id,
+            metadata=entity.metadata_json or {},
+            createdTime=entity.created_time)
+
+
+class TeamListRequestDto(PageRequest):
+    status: TeamStatus | None = None
+
+
+class TeamCreateRequestDto(BaseModel):
+    name: str
+    description: str | None = None
+    teamType: str = "collaborative"
+    ownerUserId: str | None = None
+    metadata: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class TeamDetailRequestDto(BaseModel):
+    teamId: str
+
+
+class TeamUpdateRequestDto(BaseModel):
+    teamId: str
+    name: str | None = None
+    description: str | None = None
+    teamType: str | None = None
+    status: TeamStatus | None = None
+    ownerUserId: str | None = None
+    metadata: dict[str, JSONValue] | None = None
+
+
+class TeamDeleteRequestDto(BaseModel):
+    teamId: str
+
+
+class TeamConfigDto(BaseModel):
+    id: str
+    teamId: str
+    versionNo: int
+    status: TeamVersionStatus
+    coordinationMode: str
+    objective: str | None = None
+    memberRefs: list[dict[str, JSONValue]]
+    policy: dict[str, JSONValue]
+    publishedTime: datetime | None = None
+    createdTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "TeamVersion") -> "TeamConfigDto":
+        return cls(
+            id=entity.id,
+            teamId=entity.team_id,
+            versionNo=entity.version_no,
+            status=entity.status,
+            coordinationMode=entity.coordination_mode,
+            objective=entity.objective,
+            memberRefs=entity.member_refs_json,
+            policy=entity.policy_json,
+            publishedTime=entity.published_time,
+            createdTime=entity.created_time)
+
+
+class TeamConfigListRequestDto(PageRequest):
+    teamId: str | None = None
+
+
+class TeamConfigCreateRequestDto(BaseModel):
+    teamId: str
+    status: TeamVersionStatus = "draft"
+    coordinationMode: str = "supervisor"
+    objective: str | None = None
+    memberRefs: list[dict[str, JSONValue]] = Field(default_factory=list)
+    policy: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class TeamConfigDetailRequestDto(BaseModel):
+    configId: str
+
+
+class TeamConfigUpdateRequestDto(BaseModel):
+    configId: str
+    status: TeamVersionStatus | None = None
+    coordinationMode: str | None = None
+    objective: str | None = None
+    memberRefs: list[dict[str, JSONValue]] | None = None
+    policy: dict[str, JSONValue] | None = None
+
+
+class TeamConfigDeleteRequestDto(BaseModel):
+    configId: str
+
+
+class TeamRunDto(BaseModel):
+    id: str
+    teamId: str
+    teamConfigId: str
+    sessionId: str | None = None
+    inputText: str | None = None
+    inputJson: dict[str, JSONValue] | None = None
+    outputText: str | None = None
+    outputJson: dict[str, JSONValue] | None = None
+    status: TeamRunStatus
+    workerKey: str | None = None
+    queuedTime: datetime | None = None
+    leaseExpireTime: datetime | None = None
+    startedTime: datetime | None = None
+    finishedTime: datetime | None = None
+    errorCode: str | None = None
+    errorMessage: str | None = None
+    createdTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "TeamRun") -> "TeamRunDto":
+        return cls(
+            id=entity.id,
+            teamId=entity.team_id,
+            teamConfigId=entity.team_version_id,
+            sessionId=entity.session_id,
+            inputText=entity.input_text,
+            inputJson=entity.input_json,
+            outputText=entity.output_text,
+            outputJson=entity.output_json,
+            status=entity.status,
+            workerKey=entity.worker_key,
+            queuedTime=entity.queued_time,
+            leaseExpireTime=entity.lease_expire_time,
+            startedTime=entity.started_time,
+            finishedTime=entity.finished_time,
+            errorCode=entity.error_code,
+            errorMessage=entity.error_message,
+            createdTime=entity.created_time)
+
+
+class TeamRunListRequestDto(PageRequest):
+    teamId: str | None = None
+    sessionId: str | None = None
+    status: TeamRunStatus | None = None
+
+
+class TeamRunCreateRequestDto(BaseModel):
+    teamId: str
+    teamConfigId: str | None = None
+    sessionId: str | None = None
+    inputText: str | None = None
+    inputJson: dict[str, JSONValue] | None = None
+
+
+class TeamRunDetailRequestDto(BaseModel):
+    teamRunId: str
+
+
+class TeamRunStatusUpdateRequestDto(BaseModel):
+    teamRunId: str
+    status: TeamRunStatus
+    workerKey: str | None = None
+    outputText: str | None = None
+    outputJson: dict[str, JSONValue] | None = None
+    errorCode: str | None = None
+    errorMessage: str | None = None
+
+
+class TeamRunExecuteRequestDto(BaseModel):
+    teamRunId: str
+    workerKey: str | None = None
+    dryRun: bool = True
+
+
+class TeamRunExecuteData(BaseModel):
+    run: TeamRunDto
+    memberRunCount: int = 0
+    dryRun: bool = True
+
+
+class TeamRunDeleteRequestDto(BaseModel):
+    teamRunId: str
+
+
+class DeleteData(BaseModel):
+    deleted: bool
+    teamId: str | None = None
+    configId: str | None = None
+    teamRunId: str | None = None

+ 38 - 0
services/team-service/app/worker.py

@@ -6,6 +6,7 @@ import time
 import traceback
 from dataclasses import dataclass
 from math import ceil
+from threading import Event, Thread
 from uuid import uuid4
 
 from core_shared import try_build_redis_client
@@ -86,6 +87,7 @@ class TeamWorker:
             result = service.execute_next_claimed_team_run(
                 worker_key=self.worker_key,
                 lease_seconds=self.settings.worker_lease_seconds,
+                stale_running_seconds=self.settings.worker_stale_running_seconds,
                 dry_run=self.settings.worker_dry_run)
             return result is not None
         finally:
@@ -101,6 +103,42 @@ class TeamWorker:
             return
 
 
+class BackgroundTeamWorker:
+    def __init__(
+        self,
+        *,
+        settings: TeamServiceSettings,
+        session_factory: sessionmaker[Session],
+        worker_key: str) -> None:
+        self.settings = settings
+        self.worker = TeamWorker(
+            settings=settings,
+            session_factory=session_factory,
+            worker_key=worker_key)
+        self.stop_event = Event()
+        self.thread = Thread(
+            target=self._run,
+            name=f"team-auto-worker-{worker_key}",
+            daemon=True)
+
+    def start(self) -> None:
+        self.thread.start()
+
+    def stop(self, *, timeout_seconds: float) -> None:
+        self.stop_event.set()
+        self.thread.join(timeout=timeout_seconds)
+
+    def _run(self) -> None:
+        while not self.stop_event.is_set():
+            try:
+                executed = self.worker.run_once()
+            except Exception:
+                traceback.print_exc()
+                executed = False
+            if not executed:
+                self.stop_event.wait(self.settings.worker_poll_interval_seconds)
+
+
 def build_worker_key() -> str:
     configured_key = os.getenv("AGENT_PLATFORM_WORKER_KEY")
     if configured_key:

+ 310 - 14
services/tool-service/app/api/routes.py

@@ -1,3 +1,6 @@
+from datetime import datetime
+from typing import Annotated, TypeVar
+
 from core_domain import ServiceHealth
 from core_shared.secrets import SecretCipher
 from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -14,24 +17,56 @@ from app.domain.repositories import (
     ToolVersionRepository,
 )
 from app.schemas.tool import (
+    ApiResponse,
+    DeleteData,
+    McpConnectData,
+    McpConnectRequestDto,
+    PageRequest,
+    PageResult,
     ToolBindingCreateRequest,
+    ToolBindingCreateRequestDto,
+    ToolBindingDeleteRequestDto,
+    ToolBindingDetailRequestDto,
     ToolBindingDetailResponse,
+    ToolBindingDto,
+    ToolBindingListRequestDto,
     ToolBindingResponse,
+    ToolBindingUpdateRequestDto,
     ToolCreateRequest,
+    ToolCreateRequestDto,
     ToolCredentialCreateRequest,
+    ToolCredentialCreateRequestDto,
+    ToolCredentialDeleteRequestDto,
+    ToolCredentialDetailRequestDto,
+    ToolCredentialDto,
     ToolCredentialResponse,
+    ToolCredentialRevealDto,
+    ToolCredentialRevealRequestDto,
     ToolCredentialRevealResponse,
+    ToolCredentialUpdateRequestDto,
+    ToolDeleteRequestDto,
+    ToolDetailRequestDto,
+    ToolDto,
     ToolResponse,
+    ToolUpdateRequestDto,
     ToolVersionCreateRequest,
+    ToolVersionCreateRequestDto,
+    ToolVersionDeleteRequestDto,
+    ToolVersionDetailRequestDto,
+    ToolVersionDto,
+    ToolVersionListRequestDto,
     ToolVersionResponse,
+    ToolVersionUpdateRequestDto,
 )
 
 router = APIRouter()
+DbSession = Annotated[Session, Depends(get_db)]
+T = TypeVar("T")
 
 
 def get_tool_application_service(
     request: Request,
-    db: Session = Depends(get_db)) -> ToolApplicationService:
+    db: DbSession) -> ToolApplicationService:
     settings: ToolServiceSettings = request.app.state.settings
     return ToolApplicationService(
         tool_definition_repository=ToolDefinitionRepository(db),
@@ -41,8 +76,18 @@ def get_tool_application_service(
         secret_cipher=SecretCipher(key=settings.credential_encryption_key))
 
 
+ToolServiceDep = Annotated[ToolApplicationService, Depends(get_tool_application_service)]
+
+
+def ok(data: T) -> ApiResponse[T]:
+    return ApiResponse[T](
+        data=data,
+        requestId="",
+        serverTime=datetime.utcnow())
+
+
 @router.get("/health", response_model=ServiceHealth)
-def health_check(db: Session = Depends(get_db)) -> ServiceHealth:
+def health_check(db: DbSession) -> ServiceHealth:
     db.execute(text("SELECT 1"))
     return ServiceHealth(service="tool-service", status="ok", database="ok")
 
@@ -50,29 +95,29 @@ def health_check(db: Session = Depends(get_db)) -> ServiceHealth:
 @router.post("", response_model=ToolResponse)
 def create_tool_definition(
     payload: ToolCreateRequest,
-    service: ToolApplicationService = Depends(get_tool_application_service)) -> ToolResponse:
+    service: ToolServiceDep) -> ToolResponse:
     entity = service.create_tool_definition(payload)
     return ToolResponse.from_entity(entity)
 
 
 @router.get("", response_model=list[ToolResponse])
 def list_tool_definitions(
-    service: ToolApplicationService = Depends(get_tool_application_service)) -> list[ToolResponse]:
+    service: ToolServiceDep) -> list[ToolResponse]:
     return [ToolResponse.from_entity(item) for item in service.list_tool_definitions()]
 
 
 @router.post("/versions", response_model=ToolVersionResponse)
 def create_tool_version(
     payload: ToolVersionCreateRequest,
-    service: ToolApplicationService = Depends(get_tool_application_service)) -> ToolVersionResponse:
+    service: ToolServiceDep) -> ToolVersionResponse:
     entity = service.create_tool_version(payload)
     return ToolVersionResponse.from_entity(entity)
 
 
 @router.get("/versions", response_model=list[ToolVersionResponse])
 def list_tool_versions(
-    tool_id: str | None = Query(default=None),
-    service: ToolApplicationService = Depends(get_tool_application_service)) -> list[ToolVersionResponse]:
+    service: ToolServiceDep,
+    tool_id: str | None = Query(default=None)) -> list[ToolVersionResponse]:
     return [
         ToolVersionResponse.from_entity(item)
         for item in service.list_tool_versions(tool_id=tool_id)
@@ -82,7 +127,7 @@ def list_tool_versions(
 @router.post("/bindings", response_model=ToolBindingResponse)
 def create_tool_binding(
     payload: ToolBindingCreateRequest,
-    service: ToolApplicationService = Depends(get_tool_application_service)) -> ToolBindingResponse:
+    service: ToolServiceDep) -> ToolBindingResponse:
     try:
         entity = service.create_tool_binding(payload)
     except ValueError as exc:
@@ -92,8 +137,8 @@ def create_tool_binding(
 
 @router.get("/bindings", response_model=list[ToolBindingResponse])
 def list_tool_bindings(
-    app_id: str | None = Query(default=None),
-    service: ToolApplicationService = Depends(get_tool_application_service)) -> list[ToolBindingResponse]:
+    service: ToolServiceDep,
+    app_id: str | None = Query(default=None)) -> list[ToolBindingResponse]:
     return [
         ToolBindingResponse.from_entity(item)
         for item in service.list_tool_bindings(app_id=app_id)
@@ -103,7 +148,7 @@ def list_tool_bindings(
 @router.get("/bindings/{binding_id}", response_model=ToolBindingDetailResponse)
 def get_tool_binding_detail(
     binding_id: str,
-    service: ToolApplicationService = Depends(get_tool_application_service)) -> ToolBindingDetailResponse:
+    service: ToolServiceDep) -> ToolBindingDetailResponse:
     result = service.get_tool_binding_detail(binding_id=binding_id)
     if result is None:
         raise HTTPException(status_code=404, detail=f"tool_binding not found: {binding_id}")
@@ -118,14 +163,14 @@ def get_tool_binding_detail(
 @router.post("/credentials", response_model=ToolCredentialResponse)
 def create_tool_credential(
     payload: ToolCredentialCreateRequest,
-    service: ToolApplicationService = Depends(get_tool_application_service)) -> ToolCredentialResponse:
+    service: ToolServiceDep) -> ToolCredentialResponse:
     entity = service.create_tool_credential(payload)
     return ToolCredentialResponse.from_entity(entity)
 
 
 @router.get("/credentials", response_model=list[ToolCredentialResponse])
 def list_tool_credentials(
-    service: ToolApplicationService = Depends(get_tool_application_service)) -> list[ToolCredentialResponse]:
+    service: ToolServiceDep) -> list[ToolCredentialResponse]:
     return [
         ToolCredentialResponse.from_entity(item)
         for item in service.list_tool_credentials()
@@ -135,7 +180,7 @@ def list_tool_credentials(
 @router.post("/credentials/{credential_id}/reveal", response_model=ToolCredentialRevealResponse)
 def reveal_tool_credential(
     credential_id: str,
-    service: ToolApplicationService = Depends(get_tool_application_service)) -> ToolCredentialRevealResponse:
+    service: ToolServiceDep) -> ToolCredentialRevealResponse:
     result = service.reveal_tool_credential(
         credential_id=credential_id)
     if result is None:
@@ -144,3 +189,254 @@ def reveal_tool_credential(
     return ToolCredentialRevealResponse(
         credential=ToolCredentialResponse.from_entity(credential),
         secret_json=secret_json)
+
+
+@router.post("/list", response_model=ApiResponse[PageResult[ToolDto]])
+def list_tools_contract(
+    payload: PageRequest,
+    service: ToolServiceDep) -> ApiResponse[PageResult[ToolDto]]:
+    keyword = (payload.keyword or "").lower().strip()
+    items = [
+        item
+        for item in service.list_tool_definitions()
+        if not keyword
+        or keyword in item.name.lower()
+        or keyword in item.tool_type.lower()
+        or keyword in (item.description or "").lower()
+    ]
+    page_items = items[payload.offset:payload.offset + payload.pageSize]
+    return ok(
+        PageResult[ToolDto].from_items(
+            items=[ToolDto.from_entity(item) for item in page_items],
+            total=len(items),
+            page=payload.page,
+            page_size=payload.pageSize))
+
+
+@router.post("/create", response_model=ApiResponse[ToolDto])
+def create_tool_contract(
+    payload: ToolCreateRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolDto]:
+    entity = service.create_tool_definition_from_contract(payload)
+    return ok(ToolDto.from_entity(entity))
+
+
+@router.post("/detail", response_model=ApiResponse[ToolDto])
+def get_tool_contract(
+    payload: ToolDetailRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolDto]:
+    entity = service.get_tool_definition_from_contract(payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"tool not found: {payload.toolId}")
+    return ok(ToolDto.from_entity(entity))
+
+
+@router.post("/update", response_model=ApiResponse[ToolDto])
+def update_tool_contract(
+    payload: ToolUpdateRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolDto]:
+    entity = service.update_tool_definition_from_contract(payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"tool not found: {payload.toolId}")
+    return ok(ToolDto.from_entity(entity))
+
+
+@router.post("/delete", response_model=ApiResponse[DeleteData])
+def delete_tool_contract(
+    payload: ToolDeleteRequestDto,
+    service: ToolServiceDep) -> ApiResponse[DeleteData]:
+    deleted = service.delete_tool_definition_from_contract(payload)
+    return ok(DeleteData(deleted=deleted, toolId=payload.toolId))
+
+
+@router.post("/connections/list", response_model=ApiResponse[PageResult[ToolVersionDto]])
+def list_tool_connections_contract(
+    payload: ToolVersionListRequestDto,
+    service: ToolServiceDep) -> ApiResponse[PageResult[ToolVersionDto]]:
+    items = service.list_tool_versions(tool_id=payload.toolId)
+    page_items = items[payload.offset:payload.offset + payload.pageSize]
+    return ok(
+        PageResult[ToolVersionDto].from_items(
+            items=[ToolVersionDto.from_entity(item) for item in page_items],
+            total=len(items),
+            page=payload.page,
+            page_size=payload.pageSize))
+
+
+@router.post("/connections/create", response_model=ApiResponse[ToolVersionDto])
+def create_tool_connection_contract(
+    payload: ToolVersionCreateRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolVersionDto]:
+    entity = service.create_tool_version_from_contract(payload)
+    return ok(ToolVersionDto.from_entity(entity))
+
+
+@router.post("/connections/detail", response_model=ApiResponse[ToolVersionDto])
+def get_tool_connection_contract(
+    payload: ToolVersionDetailRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolVersionDto]:
+    entity = service.get_tool_version_from_contract(payload)
+    if entity is None:
+        raise HTTPException(
+            status_code=404,
+            detail=f"tool connection not found: {payload.connectionId}")
+    return ok(ToolVersionDto.from_entity(entity))
+
+
+@router.post("/connections/update", response_model=ApiResponse[ToolVersionDto])
+def update_tool_connection_contract(
+    payload: ToolVersionUpdateRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolVersionDto]:
+    entity = service.update_tool_version_from_contract(payload)
+    if entity is None:
+        raise HTTPException(
+            status_code=404,
+            detail=f"tool connection not found: {payload.connectionId}")
+    return ok(ToolVersionDto.from_entity(entity))
+
+
+@router.post("/connections/delete", response_model=ApiResponse[DeleteData])
+def delete_tool_connection_contract(
+    payload: ToolVersionDeleteRequestDto,
+    service: ToolServiceDep) -> ApiResponse[DeleteData]:
+    connection_id = payload.connectionId
+    deleted = service.delete_tool_version(connection_id=connection_id)
+    return ok(DeleteData(deleted=deleted, connectionId=connection_id))
+
+
+@router.post("/mcp/connect", response_model=ApiResponse[McpConnectData])
+def connect_mcp_server_contract(
+    payload: McpConnectRequestDto,
+    service: ToolServiceDep) -> ApiResponse[McpConnectData]:
+    return ok(service.connect_mcp_server(payload))
+
+
+@router.post("/bindings/list", response_model=ApiResponse[PageResult[ToolBindingDto]])
+def list_tool_bindings_contract(
+    payload: ToolBindingListRequestDto,
+    service: ToolServiceDep) -> ApiResponse[PageResult[ToolBindingDto]]:
+    items = service.list_tool_bindings(app_id=payload.appId)
+    page_items = items[payload.offset:payload.offset + payload.pageSize]
+    return ok(
+        PageResult[ToolBindingDto].from_items(
+            items=[ToolBindingDto.from_entity(item) for item in page_items],
+            total=len(items),
+            page=payload.page,
+            page_size=payload.pageSize))
+
+
+@router.post("/bindings/create", response_model=ApiResponse[ToolBindingDto])
+def create_tool_binding_contract(
+    payload: ToolBindingCreateRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolBindingDto]:
+    try:
+        entity = service.create_tool_binding_from_contract(payload)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+    return ok(ToolBindingDto.from_entity(entity))
+
+
+@router.post("/bindings/detail", response_model=ApiResponse[ToolBindingDto])
+def get_tool_binding_contract(
+    payload: ToolBindingDetailRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolBindingDto]:
+    entity = service.get_tool_binding_from_contract(payload)
+    if entity is None:
+        raise HTTPException(
+            status_code=404,
+            detail=f"tool binding not found: {payload.bindingId}")
+    return ok(ToolBindingDto.from_entity(entity))
+
+
+@router.post("/bindings/update", response_model=ApiResponse[ToolBindingDto])
+def update_tool_binding_contract(
+    payload: ToolBindingUpdateRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolBindingDto]:
+    entity = service.update_tool_binding_from_contract(payload)
+    if entity is None:
+        raise HTTPException(
+            status_code=404,
+            detail=f"tool binding not found: {payload.bindingId}")
+    return ok(ToolBindingDto.from_entity(entity))
+
+
+@router.post("/bindings/delete", response_model=ApiResponse[DeleteData])
+def delete_tool_binding_contract(
+    payload: ToolBindingDeleteRequestDto,
+    service: ToolServiceDep) -> ApiResponse[DeleteData]:
+    deleted = service.delete_tool_binding(payload)
+    return ok(DeleteData(deleted=deleted, bindingId=payload.bindingId))
+
+
+@router.post("/credentials/list", response_model=ApiResponse[PageResult[ToolCredentialDto]])
+def list_tool_credentials_contract(
+    payload: PageRequest,
+    service: ToolServiceDep) -> ApiResponse[PageResult[ToolCredentialDto]]:
+    keyword = (payload.keyword or "").lower().strip()
+    items = [
+        item
+        for item in service.list_tool_credentials()
+        if not keyword
+        or keyword in item.name.lower()
+        or keyword in item.credential_type.lower()
+    ]
+    page_items = items[payload.offset:payload.offset + payload.pageSize]
+    return ok(
+        PageResult[ToolCredentialDto].from_items(
+            items=[ToolCredentialDto.from_entity(item) for item in page_items],
+            total=len(items),
+            page=payload.page,
+            page_size=payload.pageSize))
+
+
+@router.post("/credentials/create", response_model=ApiResponse[ToolCredentialDto])
+def create_tool_credential_contract(
+    payload: ToolCredentialCreateRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolCredentialDto]:
+    entity = service.create_tool_credential_from_contract(payload)
+    return ok(ToolCredentialDto.from_entity(entity))
+
+
+@router.post("/credentials/detail", response_model=ApiResponse[ToolCredentialDto])
+def get_tool_credential_contract(
+    payload: ToolCredentialDetailRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolCredentialDto]:
+    entity = service.get_tool_credential_from_contract(payload)
+    if entity is None:
+        raise HTTPException(
+            status_code=404,
+            detail=f"tool credential not found: {payload.credentialId}")
+    return ok(ToolCredentialDto.from_entity(entity))
+
+
+@router.post("/credentials/update", response_model=ApiResponse[ToolCredentialDto])
+def update_tool_credential_contract(
+    payload: ToolCredentialUpdateRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolCredentialDto]:
+    entity = service.update_tool_credential_from_contract(payload)
+    if entity is None:
+        raise HTTPException(
+            status_code=404,
+            detail=f"tool credential not found: {payload.credentialId}")
+    return ok(ToolCredentialDto.from_entity(entity))
+
+
+@router.post("/credentials/delete", response_model=ApiResponse[DeleteData])
+def delete_tool_credential_contract(
+    payload: ToolCredentialDeleteRequestDto,
+    service: ToolServiceDep) -> ApiResponse[DeleteData]:
+    deleted = service.delete_tool_credential(payload)
+    return ok(DeleteData(deleted=deleted, credentialId=payload.credentialId))
+
+
+@router.post("/credentials/reveal", response_model=ApiResponse[ToolCredentialRevealDto])
+def reveal_tool_credential_contract(
+    payload: ToolCredentialRevealRequestDto,
+    service: ToolServiceDep) -> ApiResponse[ToolCredentialRevealDto]:
+    result = service.reveal_tool_credential_from_contract(
+        credential_id=payload.credentialId)
+    if result is None:
+        raise HTTPException(
+            status_code=404,
+            detail=f"tool credential not found: {payload.credentialId}")
+    return ok(result)

+ 272 - 1
services/tool-service/app/application/services.py

@@ -9,10 +9,32 @@ from app.domain.repositories import (
     ToolVersionRepository,
 )
 from app.schemas.tool import (
+    McpConnectData,
+    McpConnectRequestDto,
+    McpToolDto,
     ToolBindingCreateRequest,
+    ToolBindingCreateRequestDto,
+    ToolBindingDeleteRequestDto,
+    ToolBindingDetailRequestDto,
+    ToolBindingUpdateRequestDto,
     ToolCreateRequest,
+    ToolCreateRequestDto,
     ToolCredentialCreateRequest,
+    ToolCredentialCreateRequestDto,
+    ToolCredentialDeleteRequestDto,
+    ToolCredentialDetailRequestDto,
+    ToolCredentialDto,
+    ToolCredentialRevealDto,
+    ToolCredentialUpdateRequestDto,
+    ToolDeleteRequestDto,
+    ToolDetailRequestDto,
+    ToolDto,
+    ToolUpdateRequestDto,
     ToolVersionCreateRequest,
+    ToolVersionCreateRequestDto,
+    ToolVersionDetailRequestDto,
+    ToolVersionDto,
+    ToolVersionUpdateRequestDto,
 )
 
 
@@ -31,9 +53,10 @@ class ToolApplicationService:
         self.secret_cipher = secret_cipher
 
     def create_tool_definition(self, payload: ToolCreateRequest) -> ToolDefinition:
+        code = payload.code or self._build_tool_code(payload.name)
         return self.tool_definition_repository.create(
             plugin_id=payload.plugin_id,
-            code=payload.code,
+            code=code,
             name=payload.name,
             tool_type=payload.tool_type,
             description=payload.description)
@@ -41,6 +64,45 @@ class ToolApplicationService:
     def list_tool_definitions(self) -> list[ToolDefinition]:
         return self.tool_definition_repository.list_all()
 
+    def create_tool_definition_from_contract(
+        self,
+        payload: ToolCreateRequestDto) -> ToolDefinition:
+        return self.create_tool_definition(
+            ToolCreateRequest(
+                plugin_id=payload.pluginId,
+                name=payload.name,
+                tool_type=payload.toolType,
+                description=payload.description))
+
+    def delete_tool_definition_from_contract(self, payload: ToolDeleteRequestDto) -> bool:
+        entity = self.tool_definition_repository.get_by_id(tool_id=payload.toolId)
+        if entity is None:
+            return False
+        self.tool_definition_repository.delete(entity)
+        return True
+
+    def get_tool_definition_from_contract(
+        self,
+        payload: ToolDetailRequestDto) -> ToolDefinition | None:
+        return self.tool_definition_repository.get_by_id(tool_id=payload.toolId)
+
+    def update_tool_definition_from_contract(
+        self,
+        payload: ToolUpdateRequestDto) -> ToolDefinition | None:
+        entity = self.tool_definition_repository.get_by_id(tool_id=payload.toolId)
+        if entity is None:
+            return None
+        if payload.name is not None:
+            entity.name = payload.name
+            entity.code = self._build_tool_code(payload.name)
+        if payload.toolType is not None:
+            entity.tool_type = payload.toolType
+        if payload.description is not None:
+            entity.description = payload.description
+        if payload.pluginId is not None:
+            entity.plugin_id = payload.pluginId
+        return self.tool_definition_repository.save(entity)
+
     def create_tool_version(self, payload: ToolVersionCreateRequest) -> ToolVersion:
         return self.tool_version_repository.create(
             tool_id=payload.tool_id,
@@ -55,6 +117,76 @@ class ToolApplicationService:
             return self.tool_version_repository.list_all()
         return self.tool_version_repository.list_by_tool(tool_id=tool_id)
 
+    def create_tool_version_from_contract(
+        self,
+        payload: ToolVersionCreateRequestDto) -> ToolVersion:
+        return self.create_tool_version(
+            ToolVersionCreateRequest(
+                tool_id=payload.toolId,
+                input_schema_json=payload.inputSchema,
+                output_schema_json=payload.outputSchema,
+                invoke_config_json=payload.invokeConfig,
+                timeout_ms=payload.timeoutMs,
+                retry_policy_json=payload.retryPolicy))
+
+    def delete_tool_version(self, *, connection_id: str) -> bool:
+        entity = self.tool_version_repository.get_by_id(tool_version_id=connection_id)
+        if entity is None:
+            return False
+        self.tool_version_repository.delete(entity)
+        return True
+
+    def get_tool_version_from_contract(
+        self,
+        payload: ToolVersionDetailRequestDto) -> ToolVersion | None:
+        return self.tool_version_repository.get_by_id(
+            tool_version_id=payload.connectionId)
+
+    def update_tool_version_from_contract(
+        self,
+        payload: ToolVersionUpdateRequestDto) -> ToolVersion | None:
+        entity = self.tool_version_repository.get_by_id(
+            tool_version_id=payload.connectionId)
+        if entity is None:
+            return None
+        if payload.inputSchema is not None:
+            entity.input_schema_json = payload.inputSchema
+        if payload.outputSchema is not None:
+            entity.output_schema_json = payload.outputSchema
+        if payload.invokeConfig is not None:
+            entity.invoke_config_json = payload.invokeConfig
+        if payload.timeoutMs is not None:
+            entity.timeout_ms = payload.timeoutMs
+        if payload.retryPolicy is not None:
+            entity.retry_policy_json = payload.retryPolicy
+        return self.tool_version_repository.save(entity)
+
+    def connect_mcp_server(self, payload: McpConnectRequestDto) -> McpConnectData:
+        server_name, config = self._normalize_mcp_config(payload)
+        discovered_tools = self._extract_mcp_tools(config)
+        if discovered_tools:
+            config["mcp_tools"] = [
+                tool.model_dump(mode="json", by_alias=False)
+                for tool in discovered_tools
+            ]
+        tool = self.create_tool_definition(
+            ToolCreateRequest(
+                name=payload.name or server_name,
+                tool_type="mcp",
+                description=f"MCP SSE server: {config.get('url', '')}"))
+        version = self.create_tool_version(
+            ToolVersionCreateRequest(
+                tool_id=tool.id,
+                input_schema_json={},
+                output_schema_json={},
+                invoke_config_json=config,
+                timeout_ms=self._timeout_ms(config),
+                retry_policy_json={"max_attempts": 1}))
+        return McpConnectData(
+            tool=ToolDto.from_entity(tool),
+            connection=ToolVersionDto.from_entity(version),
+            discoveredTools=discovered_tools)
+
     def create_tool_binding(self, payload: ToolBindingCreateRequest) -> ToolBinding:
         if payload.credential_id is not None:
             credential = self.tool_credential_repository.get_by_id(
@@ -69,9 +201,47 @@ class ToolApplicationService:
             enabled=payload.enabled,
             config_json=payload.config_json)
 
+    def create_tool_binding_from_contract(
+        self,
+        payload: ToolBindingCreateRequestDto) -> ToolBinding:
+        return self.create_tool_binding(
+            ToolBindingCreateRequest(
+                app_id=payload.appId,
+                tool_version_id=payload.toolVersionId,
+                credential_id=payload.credentialId,
+                binding_scope=payload.bindingScope,
+                enabled=True,
+                config_json=payload.configJson))
+
     def list_tool_bindings(self, app_id: str | None = None) -> list[ToolBinding]:
         return self.tool_binding_repository.list_by_scope(app_id=app_id)
 
+    def delete_tool_binding(self, payload: ToolBindingDeleteRequestDto) -> bool:
+        entity = self.tool_binding_repository.get_by_id(binding_id=payload.bindingId)
+        if entity is None:
+            return False
+        self.tool_binding_repository.delete(entity)
+        return True
+
+    def get_tool_binding_from_contract(
+        self,
+        payload: ToolBindingDetailRequestDto) -> ToolBinding | None:
+        return self.tool_binding_repository.get_by_id(binding_id=payload.bindingId)
+
+    def update_tool_binding_from_contract(
+        self,
+        payload: ToolBindingUpdateRequestDto) -> ToolBinding | None:
+        entity = self.tool_binding_repository.get_by_id(binding_id=payload.bindingId)
+        if entity is None:
+            return None
+        if payload.credentialId is not None:
+            entity.credential_id = payload.credentialId
+        if payload.bindingScope is not None:
+            entity.binding_scope = payload.bindingScope
+        if payload.configJson is not None:
+            entity.config_json = payload.configJson
+        return self.tool_binding_repository.save(entity)
+
     def create_tool_credential(self, payload: ToolCredentialCreateRequest) -> ToolCredential:
         encrypted = self.secret_cipher.encrypt_json(payload.secret_json)
         return self.tool_credential_repository.create(
@@ -82,9 +252,46 @@ class ToolApplicationService:
             encryption_algorithm=encrypted.algorithm,
             metadata_json=payload.metadata_json)
 
+    def create_tool_credential_from_contract(
+        self,
+        payload: ToolCredentialCreateRequestDto) -> ToolCredential:
+        return self.create_tool_credential(
+            ToolCredentialCreateRequest(
+                name=payload.name,
+                credential_type=payload.credentialType,
+                secret_json=payload.secretJson,
+                metadata_json=payload.metadataJson))
+
     def list_tool_credentials(self) -> list[ToolCredential]:
         return self.tool_credential_repository.list_all()
 
+    def delete_tool_credential(self, payload: ToolCredentialDeleteRequestDto) -> bool:
+        entity = self.tool_credential_repository.get_by_id(
+            credential_id=payload.credentialId)
+        if entity is None:
+            return False
+        self.tool_credential_repository.delete(entity)
+        return True
+
+    def get_tool_credential_from_contract(
+        self,
+        payload: ToolCredentialDetailRequestDto) -> ToolCredential | None:
+        return self.tool_credential_repository.get_by_id(
+            credential_id=payload.credentialId)
+
+    def update_tool_credential_from_contract(
+        self,
+        payload: ToolCredentialUpdateRequestDto) -> ToolCredential | None:
+        entity = self.tool_credential_repository.get_by_id(
+            credential_id=payload.credentialId)
+        if entity is None:
+            return None
+        if payload.name is not None:
+            entity.name = payload.name
+        if payload.metadataJson is not None:
+            entity.metadata_json = payload.metadataJson
+        return self.tool_credential_repository.save(entity)
+
     def reveal_tool_credential(
         self,
         *,
@@ -101,6 +308,18 @@ class ToolApplicationService:
         )
         return credential, secret_json
 
+    def reveal_tool_credential_from_contract(
+        self,
+        *,
+        credential_id: str) -> ToolCredentialRevealDto | None:
+        result = self.reveal_tool_credential(credential_id=credential_id)
+        if result is None:
+            return None
+        credential, secret_json = result
+        return ToolCredentialRevealDto(
+            credential=ToolCredentialDto.from_entity(credential),
+            secretJson=secret_json)
+
     def get_tool_binding_detail(
         self,
         *,
@@ -120,3 +339,55 @@ class ToolApplicationService:
             return None
 
         return binding, tool_version, tool_definition
+
+    def _build_tool_code(self, name: str) -> str:
+        base = "".join(
+            char.lower() if char.isalnum() else "_"
+            for char in name
+        ).strip("_") or "tool"
+        return base[:64]
+
+    def _normalize_mcp_config(
+        self,
+        payload: McpConnectRequestDto) -> tuple[str, dict[str, JSONValue]]:
+        config = payload.config
+        if len(config) == 1:
+            server_name, value = next(iter(config.items()))
+            if isinstance(value, dict):
+                normalized = {str(key): item for key, item in value.items()}
+                normalized.setdefault("server_name", server_name)
+                normalized.setdefault("transport", "sse")
+                return payload.name or server_name, normalized
+
+        server_name_value = config.get("server_name") or config.get("serverName")
+        server_name = str(server_name_value or payload.name or "mcp_server")
+        normalized = {str(key): value for key, value in config.items()}
+        normalized.setdefault("server_name", server_name)
+        normalized.setdefault("transport", "sse")
+        return server_name, normalized
+
+    def _extract_mcp_tools(self, config: dict[str, JSONValue]) -> list[McpToolDto]:
+        raw_tools = config.get("mcp_tools") or config.get("tools")
+        if not isinstance(raw_tools, list):
+            return []
+        tools: list[McpToolDto] = []
+        for item in raw_tools:
+            if not isinstance(item, dict):
+                continue
+            name = item.get("name")
+            if not isinstance(name, str) or not name:
+                continue
+            description = item.get("description")
+            input_schema = item.get("inputSchema") or item.get("input_schema")
+            tools.append(
+                McpToolDto(
+                    name=name,
+                    description=description if isinstance(description, str) else None,
+                    inputSchema=input_schema if isinstance(input_schema, dict) else None))
+        return tools
+
+    def _timeout_ms(self, config: dict[str, JSONValue]) -> int | None:
+        timeout = config.get("timeout") or config.get("timeout_seconds")
+        if isinstance(timeout, int | float):
+            return int(timeout * 1000)
+        return None

+ 41 - 1
services/tool-service/app/domain/repositories.py

@@ -1,8 +1,8 @@
+from core_shared import JSONValue
 from sqlalchemy import func, select
 from sqlalchemy.orm import Session
 
 from app.db.models import ToolBinding, ToolCredential, ToolDefinition, ToolVersion
-from core_shared import JSONValue
 
 
 class ToolDefinitionRepository:
@@ -42,6 +42,16 @@ class ToolDefinitionRepository:
         )
         return self.db.scalar(stmt)
 
+    def save(self, entity: ToolDefinition) -> ToolDefinition:
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def delete(self, entity: ToolDefinition) -> None:
+        self.db.delete(entity)
+        self.db.commit()
+
 
 class ToolVersionRepository:
     def __init__(self, db: Session) -> None:
@@ -91,6 +101,16 @@ class ToolVersionRepository:
         )
         return self.db.scalar(stmt)
 
+    def save(self, entity: ToolVersion) -> ToolVersion:
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def delete(self, entity: ToolVersion) -> None:
+        self.db.delete(entity)
+        self.db.commit()
+
     def _next_version_no(self, tool_id: str) -> int:
         stmt = select(func.max(ToolVersion.version_no)).where(ToolVersion.tool_id == tool_id)
         current_max = self.db.scalar(stmt)
@@ -136,6 +156,16 @@ class ToolBindingRepository:
         )
         return self.db.scalar(stmt)
 
+    def save(self, entity: ToolBinding) -> ToolBinding:
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def delete(self, entity: ToolBinding) -> None:
+        self.db.delete(entity)
+        self.db.commit()
+
 
 class ToolCredentialRepository:
     def __init__(self, db: Session) -> None:
@@ -175,3 +205,13 @@ class ToolCredentialRepository:
             .where(ToolCredential.id == credential_id)
         )
         return self.db.scalar(stmt)
+
+    def save(self, entity: ToolCredential) -> ToolCredential:
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def delete(self, entity: ToolCredential) -> None:
+        self.db.delete(entity)
+        self.db.commit()

+ 273 - 2
services/tool-service/app/schemas/tool.py

@@ -1,4 +1,5 @@
-from typing import TYPE_CHECKING
+from datetime import datetime
+from typing import TYPE_CHECKING, Generic, TypeVar
 
 from core_domain import (
     ToolBindingContract,
@@ -14,10 +15,59 @@ from pydantic import BaseModel, Field
 if TYPE_CHECKING:
     from app.db.models import ToolBinding, ToolCredential, ToolDefinition, ToolVersion
 
+T = TypeVar("T")
+
+
+class ApiErrorResponse(BaseModel):
+    errorType: str
+    message: str
+    details: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class ApiResponse(BaseModel, Generic[T]):
+    success: bool = True
+    data: T | None = None
+    error: ApiErrorResponse | None = None
+    requestId: str
+    serverTime: datetime
+
+
+class PageRequest(BaseModel):
+    page: int = Field(default=1, ge=1)
+    pageSize: int = Field(default=20, ge=1, le=200)
+    keyword: str | None = None
+
+    @property
+    def offset(self) -> int:
+        return (self.page - 1) * self.pageSize
+
+
+class PageResult(BaseModel, Generic[T]):
+    items: list[T]
+    total: int
+    page: int
+    pageSize: int
+    hasMore: bool
+
+    @classmethod
+    def from_items(
+        cls,
+        *,
+        items: list[T],
+        total: int,
+        page: int,
+        page_size: int) -> "PageResult[T]":
+        return cls(
+            items=items,
+            total=total,
+            page=page,
+            pageSize=page_size,
+            hasMore=page * page_size < total)
+
 
 class ToolCreateRequest(BaseModel):
     plugin_id: str | None = None
-    code: str
+    code: str | None = None
     name: str
     tool_type: str
     description: str | None = None
@@ -84,3 +134,224 @@ class ToolCredentialResponse(ToolCredentialContract):
 
 class ToolCredentialRevealResponse(ToolCredentialRevealContract):
     credential: ToolCredentialResponse
+
+
+class ToolDto(BaseModel):
+    id: str
+    pluginId: str | None = None
+    name: str
+    toolType: str
+    description: str | None = None
+    createdTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "ToolDefinition") -> "ToolDto":
+        return cls(
+            id=entity.id,
+            pluginId=entity.plugin_id,
+            name=entity.name,
+            toolType=entity.tool_type,
+            description=entity.description,
+            createdTime=entity.created_time)
+
+
+class ToolCreateRequestDto(BaseModel):
+    pluginId: str | None = None
+    name: str
+    toolType: str = "mcp"
+    description: str | None = None
+
+
+class ToolDeleteRequestDto(BaseModel):
+    toolId: str
+
+
+class ToolDetailRequestDto(BaseModel):
+    toolId: str
+
+
+class ToolUpdateRequestDto(BaseModel):
+    toolId: str
+    name: str | None = None
+    toolType: str | None = None
+    description: str | None = None
+    pluginId: str | None = None
+
+
+class ToolVersionDto(BaseModel):
+    id: str
+    toolId: str
+    versionNo: int
+    inputSchema: dict[str, JSONValue] | None = None
+    outputSchema: dict[str, JSONValue] | None = None
+    invokeConfig: dict[str, JSONValue] | None = None
+    timeoutMs: int | None = None
+    retryPolicy: dict[str, JSONValue] | None = None
+    createdTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "ToolVersion") -> "ToolVersionDto":
+        return cls(
+            id=entity.id,
+            toolId=entity.tool_id,
+            versionNo=entity.version_no,
+            inputSchema=entity.input_schema_json,
+            outputSchema=entity.output_schema_json,
+            invokeConfig=entity.invoke_config_json,
+            timeoutMs=entity.timeout_ms,
+            retryPolicy=entity.retry_policy_json,
+            createdTime=entity.created_time)
+
+
+class ToolVersionListRequestDto(PageRequest):
+    toolId: str | None = None
+
+
+class ToolVersionCreateRequestDto(BaseModel):
+    toolId: str
+    inputSchema: dict[str, JSONValue] = Field(default_factory=dict)
+    outputSchema: dict[str, JSONValue] = Field(default_factory=dict)
+    invokeConfig: dict[str, JSONValue] = Field(default_factory=dict)
+    timeoutMs: int | None = None
+    retryPolicy: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class ToolVersionDeleteRequestDto(BaseModel):
+    connectionId: str
+
+
+class ToolVersionDetailRequestDto(BaseModel):
+    connectionId: str
+
+
+class ToolVersionUpdateRequestDto(BaseModel):
+    connectionId: str
+    inputSchema: dict[str, JSONValue] | None = None
+    outputSchema: dict[str, JSONValue] | None = None
+    invokeConfig: dict[str, JSONValue] | None = None
+    timeoutMs: int | None = None
+    retryPolicy: dict[str, JSONValue] | None = None
+
+
+class ToolBindingDto(BaseModel):
+    id: str
+    appId: str
+    toolVersionId: str
+    credentialId: str | None = None
+    bindingScope: str
+    configJson: dict[str, JSONValue] | None = None
+    createdTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "ToolBinding") -> "ToolBindingDto":
+        return cls(
+            id=entity.id,
+            appId=entity.app_id,
+            toolVersionId=entity.tool_version_id,
+            credentialId=entity.credential_id,
+            bindingScope=entity.binding_scope,
+            configJson=entity.config_json,
+            createdTime=entity.created_time)
+
+
+class ToolBindingListRequestDto(PageRequest):
+    appId: str | None = None
+
+
+class ToolBindingCreateRequestDto(BaseModel):
+    appId: str
+    toolVersionId: str
+    credentialId: str | None = None
+    bindingScope: str = "app"
+    configJson: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class ToolBindingDeleteRequestDto(BaseModel):
+    bindingId: str
+
+
+class ToolBindingDetailRequestDto(BaseModel):
+    bindingId: str
+
+
+class ToolBindingUpdateRequestDto(BaseModel):
+    bindingId: str
+    credentialId: str | None = None
+    bindingScope: str | None = None
+    configJson: dict[str, JSONValue] | None = None
+
+
+class ToolCredentialDto(BaseModel):
+    id: str
+    name: str
+    credentialType: str
+    secretFingerprint: str
+    encryptionAlgorithm: str
+    metadataJson: dict[str, JSONValue] = Field(default_factory=dict)
+    createdTime: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "ToolCredential") -> "ToolCredentialDto":
+        return cls(
+            id=entity.id,
+            name=entity.name,
+            credentialType=entity.credential_type,
+            secretFingerprint=entity.secret_fingerprint,
+            encryptionAlgorithm=entity.encryption_algorithm,
+            metadataJson=entity.metadata_json,
+            createdTime=entity.created_time)
+
+
+class ToolCredentialCreateRequestDto(BaseModel):
+    name: str
+    credentialType: str = "generic"
+    secretJson: dict[str, JSONValue] = Field(default_factory=dict)
+    metadataJson: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class ToolCredentialDeleteRequestDto(BaseModel):
+    credentialId: str
+
+
+class ToolCredentialDetailRequestDto(BaseModel):
+    credentialId: str
+
+
+class ToolCredentialUpdateRequestDto(BaseModel):
+    credentialId: str
+    name: str | None = None
+    metadataJson: dict[str, JSONValue] | None = None
+
+
+class ToolCredentialRevealRequestDto(BaseModel):
+    credentialId: str
+
+
+class ToolCredentialRevealDto(BaseModel):
+    credential: ToolCredentialDto
+    secretJson: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class McpToolDto(BaseModel):
+    name: str
+    description: str | None = None
+    inputSchema: dict[str, JSONValue] | None = None
+
+
+class McpConnectRequestDto(BaseModel):
+    name: str | None = None
+    config: dict[str, JSONValue]
+
+
+class McpConnectData(BaseModel):
+    tool: ToolDto
+    connection: ToolVersionDto
+    discoveredTools: list[McpToolDto] = Field(default_factory=list)
+
+
+class DeleteData(BaseModel):
+    deleted: bool
+    toolId: str | None = None
+    connectionId: str | None = None
+    bindingId: str | None = None
+    credentialId: str | None = None

+ 0 - 3
services/workflow-service/alembic/versions/20260422_0001_init_workflow_models.py

@@ -72,9 +72,6 @@ def upgrade() -> None:
         sa.PrimaryKeyConstraint("id"))
     op.create_index("ix_workflow_definition_app_id", "workflow_definition", ["app_id"], unique=False)
     op.create_index("ix_workflow_definition_code", "workflow_definition", ["code"], unique=False)
-    op.create_index(
-        "workflow_definition",
-        unique=False)
 
     op.create_table(
         "workflow_version",

+ 22 - 0
tests/conftest.py

@@ -18,12 +18,24 @@ SERVICE_IMPORT_CONFIGS: dict[str, ServiceImportConfig] = {
     "agent-service": ServiceImportConfig(
         service_name="agent-service",
         libs=("core-domain", "core-shared", "core-db", "core-events")),
+    "auth-service": ServiceImportConfig(
+        service_name="auth-service",
+        libs=("core-domain", "core-shared", "core-db")),
     "knowledge-service": ServiceImportConfig(
         service_name="knowledge-service",
         libs=("core-domain", "core-shared", "core-db")),
+    "model-gateway-service": ServiceImportConfig(
+        service_name="model-gateway-service",
+        libs=("core-domain", "core-shared", "core-db")),
     "runtime-service": ServiceImportConfig(
         service_name="runtime-service",
         libs=("core-domain", "core-shared", "core-db", "core-events", "core-dsl")),
+    "tool-service": ServiceImportConfig(
+        service_name="tool-service",
+        libs=("core-domain", "core-shared", "core-db")),
+    "team-service": ServiceImportConfig(
+        service_name="team-service",
+        libs=("core-domain", "core-shared", "core-db", "core-events")),
     "workflow-service": ServiceImportConfig(
         service_name="workflow-service",
         libs=("core-domain", "core-shared", "core-db", "core-dsl")),
@@ -37,6 +49,7 @@ def prepare_service_import(
     for module_name in list(sys.modules):
         if module_name == "app" or module_name.startswith("app."):
             del sys.modules[module_name]
+    _clear_shared_sqlalchemy_metadata()
 
     for lib_name in libs:
         lib_path = REPO_ROOT / "libs" / lib_name / "src"
@@ -60,6 +73,15 @@ def _prepend_sys_path(path: Path) -> None:
     sys.path.insert(0, path_text)
 
 
+def _clear_shared_sqlalchemy_metadata() -> None:
+    try:
+        from core_db import Base
+    except ImportError:
+        return
+    Base.registry.dispose()
+    Base.metadata.clear()
+
+
 def build_fastapi_test_client(app: Any) -> Any:
     _patch_httpx_testclient_compatibility()
     from fastapi.testclient import TestClient

+ 142 - 0
tests/test_auth_identity_service.py

@@ -0,0 +1,142 @@
+from pathlib import Path
+
+from tests.conftest import build_fastapi_test_client, prepare_known_service_import
+
+
+def test_identity_bootstraps_demo_user_for_frontend_login(
+    tmp_path: Path,
+    monkeypatch,
+) -> None:
+    prepare_known_service_import("auth-service")
+
+    from app.bootstrap.app import create_app
+    from app.db.models import Base
+    from sqlalchemy import create_engine
+
+    database_url = f"sqlite:///{tmp_path / 'demo-auth.db'}"
+    monkeypatch.setenv("AGENT_PLATFORM_DATABASE_URL", database_url)
+    engine = create_engine(database_url, connect_args={"check_same_thread": False})
+    Base.metadata.create_all(engine)
+
+    app = create_app()
+    client = build_fastapi_test_client(app)
+
+    response = client.post(
+        "/identity/auth/login",
+        json={"username": "demo-user", "password": "demo-password"},
+    )
+
+    assert response.status_code == 200
+    payload = response.json()
+    assert payload["data"]["accessToken"]
+    assert payload["data"]["user"]["username"] == "demo-user"
+
+
+def test_identity_post_contract_supports_login_permissions_and_api_keys(
+    tmp_path: Path,
+    monkeypatch,
+) -> None:
+    prepare_known_service_import("auth-service")
+
+    from app.bootstrap.app import create_app
+    from app.db.models import Base
+    from app.domain.repositories import RoleAssignmentRepository, RoleRepository, UserRepository
+    from app.infrastructure.passwords import hash_password
+    from core_db import create_session_factory
+    from sqlalchemy import create_engine
+
+    database_url = f"sqlite:///{tmp_path / 'auth.db'}"
+    monkeypatch.setenv("AGENT_PLATFORM_DATABASE_URL", database_url)
+    engine = create_engine(database_url, connect_args={"check_same_thread": False})
+    Base.metadata.create_all(engine)
+
+    app = create_app()
+    app.state.session_factory = create_session_factory(engine)
+    client = build_fastapi_test_client(app)
+
+    session_factory = create_session_factory(engine)
+    with session_factory() as db:
+        user = UserRepository(db).create(
+            username="demo",
+            password_hash=hash_password("super-secret"),
+            display_name="Demo User",
+            email="demo@example.com",
+            metadata_json={"source": "test"},
+        )
+        role = RoleRepository(db).create(
+            code="admin",
+            name="Admin",
+            description="Platform admin",
+            permissions_json=[],
+        )
+        RoleAssignmentRepository(db).create(
+            user_id=user.id,
+            role_id=role.id,
+            scope_type=None,
+            scope_id=None,
+            expires_time=None,
+        )
+        user_id = user.id
+        role_id = role.id
+
+    binding_response = client.post(
+        "/identity/rolePermissionBindings/add",
+        json={
+            "roleId": role_id,
+            "permission": "agents:*",
+        },
+    )
+
+    assert binding_response.status_code == 200
+    binding_payload = binding_response.json()
+    assert binding_payload["success"] is True
+    assert binding_payload["data"]["roleId"] == role_id
+
+    login_response = client.post(
+        "/identity/auth/login",
+        json={"username": "demo", "password": "super-secret"},
+    )
+
+    assert login_response.status_code == 200
+    login_payload = login_response.json()
+    assert login_payload["data"]["accessToken"]
+    assert login_payload["data"]["user"]["displayName"] == "Demo User"
+    assert "createdTime" in login_payload["data"]["user"]
+
+    token = login_payload["data"]["accessToken"]
+    me_response = client.post(
+        "/identity/auth/me",
+        headers={"Authorization": f"Bearer {token}"},
+        json={},
+    )
+
+    assert me_response.status_code == 200
+    me_payload = me_response.json()
+    assert me_payload["data"]["user"]["id"] == user_id
+    assert me_payload["data"]["permissions"] == ["agents:*"]
+
+    permission_response = client.post(
+        "/identity/permissions/check",
+        json={
+            "userId": user_id,
+            "permission": "agents:create",
+        },
+    )
+
+    assert permission_response.status_code == 200
+    assert permission_response.json()["data"]["allowed"] is True
+
+    api_key_response = client.post(
+        "/identity/apiKeys/create",
+        json={"name": "Gateway Test Key", "scopes": "agents:read"},
+    )
+
+    assert api_key_response.status_code == 200
+    api_key_payload = api_key_response.json()["data"]
+    assert api_key_payload["secret"].startswith("agp_")
+    assert api_key_payload["apiKey"]["keyPrefix"]
+
+    api_keys_response = client.post("/identity/apiKeys/list", json={"page": 1, "pageSize": 20})
+
+    assert api_keys_response.status_code == 200
+    assert api_keys_response.json()["data"]["total"] == 1

+ 238 - 0
tests/test_model_service.py

@@ -0,0 +1,238 @@
+from pathlib import Path
+
+from tests.conftest import build_fastapi_test_client, prepare_known_service_import
+
+
+def test_model_service_post_contract_supports_models_and_providers(
+    tmp_path: Path,
+    monkeypatch,
+) -> None:
+    prepare_known_service_import("model-gateway-service")
+
+    from app.bootstrap.app import create_app
+    from app.db.models import Base
+    from core_db import create_session_factory
+    from sqlalchemy import create_engine
+
+    database_url = f"sqlite:///{tmp_path / 'models.db'}"
+    monkeypatch.setenv("AGENT_PLATFORM_DATABASE_URL", database_url)
+    engine = create_engine(database_url, connect_args={"check_same_thread": False})
+    Base.metadata.create_all(engine)
+
+    app = create_app()
+    app.state.session_factory = create_session_factory(engine)
+    client = build_fastapi_test_client(app)
+
+    provider_response = client.post(
+        "/models/providers/create",
+        json={
+            "name": "Local OpenAI Compatible",
+            "providerType": "openai_compatible",
+            "baseUrl": "http://127.0.0.1:11434/v1",
+            "apiKey": "local-secret",
+            "models": [
+                {
+                    "modelId": "llama3.1",
+                    "displayName": "Llama 3.1",
+                    "modelType": "chat",
+                }
+            ],
+            "defaultModel": "llama3.1",
+        },
+    )
+
+    assert provider_response.status_code == 200
+    provider_payload = provider_response.json()["data"]
+    assert provider_payload["apiKeyRef"] == "loc***masked"
+    assert provider_payload["models"][0]["modelId"] == "llama3.1"
+
+    providers_response = client.post(
+        "/models/providers/list",
+        json={"page": 1, "pageSize": 20},
+    )
+    assert providers_response.status_code == 200
+    assert providers_response.json()["data"]["total"] == 1
+
+    discover_response = client.post(
+        "/models/providers/discover",
+        json={"providerId": provider_payload["id"]},
+    )
+    assert discover_response.status_code == 200
+    assert discover_response.json()["data"]["models"][0]["modelId"] == "llama3.1"
+
+    model_response = client.post(
+        "/models/create",
+        json={
+            "name": "Local Chat",
+            "providerId": provider_payload["id"],
+            "providerType": "openai_compatible",
+            "modelName": "llama3.1",
+            "capabilities": ["chat"],
+            "timeoutSeconds": 30,
+        },
+    )
+
+    assert model_response.status_code == 200
+    model_payload = model_response.json()["data"]
+    assert model_payload["modelName"] == "llama3.1"
+    assert model_payload["providerId"] == provider_payload["id"]
+    assert model_payload["providerBaseUrl"] == "http://127.0.0.1:11434/v1"
+    assert model_payload["hasProviderApiKey"] is True
+    assert "code" not in model_payload
+
+    models_response = client.post(
+        "/models/list",
+        json={"page": 1, "pageSize": 20, "keyword": "local"},
+    )
+    assert models_response.status_code == 200
+    assert models_response.json()["data"]["total"] == 1
+
+    update_response = client.post(
+        "/models/update",
+        json={
+            "modelId": model_payload["id"],
+            "name": "Local Chat Updated",
+            "defaultTemperature": 0.2,
+        },
+    )
+    assert update_response.status_code == 200
+    assert update_response.json()["data"]["defaultTemperature"] == 0.2
+
+    delete_response = client.post(
+        "/models/delete",
+        json={"modelId": model_payload["id"]},
+    )
+    assert delete_response.status_code == 200
+    assert delete_response.json()["data"]["deleted"] is True
+
+
+def test_model_provider_client_supports_anthropic_messages(monkeypatch) -> None:
+    prepare_known_service_import("model-gateway-service")
+
+    import app.infrastructure.provider as provider_module
+    from app.bootstrap.settings import ModelGatewayServiceSettings
+    from app.infrastructure.provider import ModelProviderClient
+    from core_domain import ChatCompletionRequestContract
+
+    captured: dict[str, object] = {}
+
+    class FakeResponse:
+        text = "{}"
+
+        def raise_for_status(self) -> None:
+            return None
+
+        def json(self) -> dict[str, object]:
+            return {
+                "model": "claude-3-5-sonnet-20241022",
+                "content": [{"type": "text", "text": "ready"}],
+                "stop_reason": "end_turn",
+                "usage": {"input_tokens": 12, "output_tokens": 3},
+            }
+
+    class FakeClient:
+        def __init__(self, *, timeout: float) -> None:
+            captured["timeout"] = timeout
+
+        def __enter__(self) -> "FakeClient":
+            return self
+
+        def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
+            return None
+
+        def post(
+            self,
+            url: str,
+            *,
+            json: dict[str, object],
+            headers: dict[str, str]) -> FakeResponse:
+            captured["url"] = url
+            captured["json"] = json
+            captured["headers"] = headers
+            return FakeResponse()
+
+    monkeypatch.setattr(provider_module.httpx, "Client", FakeClient)
+
+    client = ModelProviderClient(settings=ModelGatewayServiceSettings())
+    response = client.create_chat_completion(
+        ChatCompletionRequestContract(
+            model="claude-3-5-sonnet-20241022",
+            messages=[
+                {"role": "system", "content": "Be concise."},
+                {"role": "user", "content": "Ping"},
+            ],
+            max_tokens=128),
+        provider_type="anthropic",
+        provider_base_url="https://api.anthropic.com",
+        provider_api_key="sk-test",
+        timeout_seconds=15)
+
+    assert captured["url"] == "https://api.anthropic.com/v1/messages"
+    assert captured["headers"] == {
+        "content-type": "application/json",
+        "x-api-key": "sk-test",
+        "anthropic-version": "2023-06-01",
+    }
+    assert captured["json"] == {
+        "model": "claude-3-5-sonnet-20241022",
+        "max_tokens": 128,
+        "messages": [{"role": "user", "content": "Ping"}],
+        "system": "Be concise.",
+    }
+    assert response.content == "ready"
+    assert response.finish_reason == "end_turn"
+
+
+def test_model_service_backfills_legacy_model_connections_as_providers(
+    tmp_path: Path,
+    monkeypatch,
+) -> None:
+    prepare_known_service_import("model-gateway-service")
+
+    from app.bootstrap.app import create_app
+    from app.db.models import Base
+    from core_db import create_session_factory
+    from sqlalchemy import create_engine
+
+    database_url = f"sqlite:///{tmp_path / 'models.db'}"
+    monkeypatch.setenv("AGENT_PLATFORM_DATABASE_URL", database_url)
+    engine = create_engine(database_url, connect_args={"check_same_thread": False})
+    Base.metadata.create_all(engine)
+
+    app = create_app()
+    app.state.session_factory = create_session_factory(engine)
+    client = build_fastapi_test_client(app)
+
+    model_response = client.post(
+        "/models/create",
+        json={
+            "name": "Legacy Anthropic",
+            "providerType": "anthropic",
+            "providerBaseUrl": "https://api.anthropic.com",
+            "providerApiKey": "sk-legacy",
+            "modelName": "claude-3-5-sonnet-20241022",
+            "capabilities": ["chat"],
+        },
+    )
+    assert model_response.status_code == 200
+    assert model_response.json()["data"]["providerId"] is None
+
+    providers_response = client.post(
+        "/models/providers/list",
+        json={"page": 1, "pageSize": 20},
+    )
+
+    assert providers_response.status_code == 200
+    providers_payload = providers_response.json()["data"]
+    assert providers_payload["total"] == 1
+    provider_payload = providers_payload["items"][0]
+    assert provider_payload["providerType"] == "anthropic"
+    assert provider_payload["baseUrl"] == "https://api.anthropic.com"
+    assert provider_payload["models"][0]["modelId"] == "claude-3-5-sonnet-20241022"
+
+    models_response = client.post(
+        "/models/list",
+        json={"page": 1, "pageSize": 20},
+    )
+    assert models_response.status_code == 200
+    assert models_response.json()["data"]["items"][0]["providerId"] == provider_payload["id"]

+ 146 - 0
tests/test_team_service.py

@@ -0,0 +1,146 @@
+from pathlib import Path
+
+from tests.conftest import build_fastapi_test_client, prepare_known_service_import
+
+
+def test_team_service_post_contract_supports_team_configs_and_runs(
+    tmp_path: Path,
+    monkeypatch,
+) -> None:
+    prepare_known_service_import("team-service")
+
+    from app.bootstrap.app import create_app
+    from app.db.models import Base
+    from core_db import create_session_factory
+    from sqlalchemy import create_engine
+
+    database_url = f"sqlite:///{tmp_path / 'teams.db'}"
+    monkeypatch.setenv("AGENT_PLATFORM_DATABASE_URL", database_url)
+    monkeypatch.setenv("AGENT_PLATFORM_REDIS_URL", "")
+    monkeypatch.setenv("AGENT_PLATFORM_AUTO_WORKER_ENABLED", "false")
+    engine = create_engine(database_url, connect_args={"check_same_thread": False})
+    Base.metadata.create_all(engine)
+
+    app = create_app()
+    app.state.session_factory = create_session_factory(engine)
+    client = build_fastapi_test_client(app)
+
+    team_response = client.post(
+        "/teams/create",
+        json={
+            "name": "Support Team",
+            "description": "Handles support escalations",
+            "teamType": "collaborative",
+            "ownerUserId": "demo-user",
+        },
+    )
+    assert team_response.status_code == 200
+    team_payload = team_response.json()["data"]
+    assert team_payload["name"] == "Support Team"
+    assert team_payload["teamType"] == "collaborative"
+    assert "code" not in team_payload
+
+    config_response = client.post(
+        "/teams/configs/create",
+        json={
+            "teamId": team_payload["id"],
+            "coordinationMode": "supervisor",
+            "objective": "Resolve the customer request",
+            "memberRefs": [
+                {
+                    "role": "worker",
+                    "agentId": "agent_support",
+                    "responsibility": "Draft the answer",
+                }
+            ],
+            "policy": {
+                "max_rounds": 3,
+                "handoff": "supervisor",
+            },
+        },
+    )
+    assert config_response.status_code == 200
+    config_payload = config_response.json()["data"]
+    assert config_payload["teamId"] == team_payload["id"]
+    assert config_payload["memberRefs"][0]["role"] == "executor"
+    assert config_payload["memberRefs"][0]["member_key"] == "member_1"
+
+    list_response = client.post(
+        "/teams/list",
+        json={"page": 1, "pageSize": 20, "keyword": "support"},
+    )
+    assert list_response.status_code == 200
+    assert list_response.json()["data"]["total"] == 1
+
+    configs_response = client.post(
+        "/teams/configs/list",
+        json={"page": 1, "pageSize": 20, "teamId": team_payload["id"]},
+    )
+    assert configs_response.status_code == 200
+    assert configs_response.json()["data"]["total"] == 1
+
+    run_response = client.post(
+        "/teams/runs/create",
+        json={
+            "teamId": team_payload["id"],
+            "teamConfigId": config_payload["id"],
+            "inputText": "Help the customer reset MFA",
+        },
+    )
+    assert run_response.status_code == 200
+    run_payload = run_response.json()["data"]
+    assert run_payload["teamId"] == team_payload["id"]
+    assert run_payload["teamConfigId"] == config_payload["id"]
+    assert run_payload["status"] == "queued"
+
+    status_response = client.post(
+        "/teams/runs/status",
+        json={
+            "teamRunId": run_payload["id"],
+            "status": "completed",
+            "workerKey": "test-worker",
+            "outputText": "MFA reset steps prepared.",
+        },
+    )
+    assert status_response.status_code == 200
+    assert status_response.json()["data"]["status"] == "completed"
+
+    runs_response = client.post(
+        "/teams/runs/list",
+        json={"page": 1, "pageSize": 20, "teamId": team_payload["id"]},
+    )
+    assert runs_response.status_code == 200
+    assert runs_response.json()["data"]["total"] == 1
+
+    update_response = client.post(
+        "/teams/update",
+        json={
+            "teamId": team_payload["id"],
+            "name": "Support Team Updated",
+            "status": "active",
+        },
+    )
+    assert update_response.status_code == 200
+    assert update_response.json()["data"]["name"] == "Support Team Updated"
+    assert update_response.json()["data"]["status"] == "active"
+
+    delete_run_response = client.post(
+        "/teams/runs/delete",
+        json={"teamRunId": run_payload["id"]},
+    )
+    assert delete_run_response.status_code == 200
+    assert delete_run_response.json()["data"]["deleted"] is True
+
+    delete_config_response = client.post(
+        "/teams/configs/delete",
+        json={"configId": config_payload["id"]},
+    )
+    assert delete_config_response.status_code == 200
+    assert delete_config_response.json()["data"]["deleted"] is True
+
+    delete_team_response = client.post(
+        "/teams/delete",
+        json={"teamId": team_payload["id"]},
+    )
+    assert delete_team_response.status_code == 200
+    assert delete_team_response.json()["data"]["deleted"] is True

+ 171 - 0
tests/test_tool_service.py

@@ -0,0 +1,171 @@
+from pathlib import Path
+
+from tests.conftest import build_fastapi_test_client, prepare_known_service_import
+
+
+def test_tool_service_post_contract_supports_mcp_connections_and_secrets(
+    tmp_path: Path,
+    monkeypatch,
+) -> None:
+    prepare_known_service_import("tool-service")
+
+    from app.bootstrap.app import create_app
+    from app.db.models import Base
+    from core_db import create_session_factory
+    from sqlalchemy import create_engine
+
+    database_url = f"sqlite:///{tmp_path / 'tools.db'}"
+    monkeypatch.setenv("AGENT_PLATFORM_DATABASE_URL", database_url)
+    engine = create_engine(database_url, connect_args={"check_same_thread": False})
+    Base.metadata.create_all(engine)
+
+    app = create_app()
+    app.state.session_factory = create_session_factory(engine)
+    client = build_fastapi_test_client(app)
+
+    connect_response = client.post(
+        "/tools/mcp/connect",
+        json={
+            "config": {
+                "billing_mcp": {
+                    "url": "http://127.0.0.1:9090/sse",
+                    "headers": {"X-MCP-API-KEY": "secret"},
+                    "timeout": 50,
+                    "sse_read_timeout": 50,
+                    "mcp_tools": [
+                        {
+                            "name": "query_invoice",
+                            "description": "Query invoices",
+                            "inputSchema": {"invoiceId": "string"},
+                        }
+                    ],
+                }
+            }
+        },
+    )
+    assert connect_response.status_code == 200
+    connect_payload = connect_response.json()["data"]
+    assert connect_payload["tool"]["name"] == "billing_mcp"
+    assert connect_payload["tool"]["toolType"] == "mcp"
+    assert connect_payload["connection"]["invokeConfig"]["url"] == "http://127.0.0.1:9090/sse"
+    assert connect_payload["connection"]["timeoutMs"] == 50000
+    assert connect_payload["discoveredTools"][0]["name"] == "query_invoice"
+    assert "code" not in connect_payload["tool"]
+
+    tools_response = client.post(
+        "/tools/list",
+        json={"page": 1, "pageSize": 20, "keyword": "billing"},
+    )
+    assert tools_response.status_code == 200
+    assert tools_response.json()["data"]["total"] == 1
+
+    update_tool_response = client.post(
+        "/tools/update",
+        json={
+            "toolId": connect_payload["tool"]["id"],
+            "name": "Billing MCP Updated",
+        },
+    )
+    assert update_tool_response.status_code == 200
+    assert update_tool_response.json()["data"]["name"] == "Billing MCP Updated"
+
+    connections_response = client.post(
+        "/tools/connections/list",
+        json={"page": 1, "pageSize": 20, "toolId": connect_payload["tool"]["id"]},
+    )
+    assert connections_response.status_code == 200
+    assert (
+        connections_response.json()["data"]["items"][0]["id"]
+        == connect_payload["connection"]["id"]
+    )
+
+    update_connection_response = client.post(
+        "/tools/connections/update",
+        json={
+            "connectionId": connect_payload["connection"]["id"],
+            "timeoutMs": 60000,
+        },
+    )
+    assert update_connection_response.status_code == 200
+    assert update_connection_response.json()["data"]["timeoutMs"] == 60000
+
+    credential_response = client.post(
+        "/tools/credentials/create",
+        json={
+            "name": "Billing MCP Key",
+            "credentialType": "api_key",
+            "secretJson": {"apiKey": "secret"},
+        },
+    )
+    assert credential_response.status_code == 200
+    credential_payload = credential_response.json()["data"]
+    assert credential_payload["credentialType"] == "api_key"
+
+    reveal_response = client.post(
+        "/tools/credentials/reveal",
+        json={"credentialId": credential_payload["id"]},
+    )
+    assert reveal_response.status_code == 200
+    assert reveal_response.json()["data"]["secretJson"] == {"apiKey": "secret"}
+
+    update_credential_response = client.post(
+        "/tools/credentials/update",
+        json={
+            "credentialId": credential_payload["id"],
+            "metadataJson": {"owner": "billing"},
+        },
+    )
+    assert update_credential_response.status_code == 200
+    assert update_credential_response.json()["data"]["metadataJson"]["owner"] == "billing"
+
+    binding_response = client.post(
+        "/tools/bindings/create",
+        json={
+            "appId": "agent_support",
+            "toolVersionId": connect_payload["connection"]["id"],
+            "credentialId": credential_payload["id"],
+            "configJson": {"scope": "billing"},
+        },
+    )
+    assert binding_response.status_code == 200
+    binding_payload = binding_response.json()["data"]
+    assert binding_payload["toolVersionId"] == connect_payload["connection"]["id"]
+    assert "enabled" not in binding_payload
+
+    update_binding_response = client.post(
+        "/tools/bindings/update",
+        json={
+            "bindingId": binding_payload["id"],
+            "configJson": {"scope": "billing:read"},
+        },
+    )
+    assert update_binding_response.status_code == 200
+    assert update_binding_response.json()["data"]["configJson"]["scope"] == "billing:read"
+
+    bindings_response = client.post(
+        "/tools/bindings/list",
+        json={"page": 1, "pageSize": 20, "appId": "agent_support"},
+    )
+    assert bindings_response.status_code == 200
+    assert bindings_response.json()["data"]["total"] == 1
+
+    delete_binding_response = client.post(
+        "/tools/bindings/delete",
+        json={"bindingId": binding_payload["id"]},
+    )
+    assert delete_binding_response.status_code == 200
+    assert delete_binding_response.json()["data"]["deleted"] is True
+
+    delete_connection_response = client.post(
+        "/tools/connections/delete",
+        json={"connectionId": connect_payload["connection"]["id"]},
+    )
+    assert delete_connection_response.status_code == 200
+    assert delete_connection_response.json()["data"]["deleted"] is True
+
+    delete_tool_response = client.post(
+        "/tools/delete",
+        json={"toolId": connect_payload["tool"]["id"]},
+    )
+    assert delete_tool_response.status_code == 200
+    assert delete_tool_response.json()["data"]["deleted"] is True

+ 0 - 3
web/.claude/settings.local.json

@@ -7,9 +7,6 @@
       "Bash(for f:*)",
       "Bash(do sed:*)",
       "Bash(done)",
-      "Bash(sed -i '/const tenantId = useAuthStore/d' pages/agents/components/CreateAgentDialog.tsx pages/settings/SettingsPage.tsx pages/sessions/SessionChatPage.tsx pages/sessions/components/CreateSessionDialog.tsx pages/tools/ToolsPage.tsx pages/teams/TeamsPage.tsx pages/knowledge/KnowledgePage.tsx)",
-      "Bash(sed -i '/tenant_id: tenantId,/d' pages/agents/components/CreateAgentDialog.tsx pages/settings/SettingsPage.tsx pages/sessions/SessionChatPage.tsx pages/sessions/components/CreateSessionDialog.tsx pages/tools/ToolsPage.tsx pages/teams/TeamsPage.tsx pages/knowledge/KnowledgePage.tsx)",
-      "Bash(sed -i 's/tenant_id: tenantId, //' pages/teams/TeamsPage.tsx pages/sessions/SessionChatPage.tsx pages/knowledge/KnowledgePage.tsx)",
       "Bash(node -e \"const lr = require\\(''lucide-react''\\); console.log\\(''Wrench:'', !!lr.Wrench, ''Brain:'', !!lr.Brain\\)\")"
     ]
   }

+ 1 - 1
web/.env.example

@@ -1,2 +1,2 @@
 VITE_API_BASE_URL=http://127.0.0.1:8000
-VITE_USE_MOCKS=true
+VITE_USE_MOCKS=false

+ 39 - 16
web/src/api/agents.ts

@@ -1,5 +1,5 @@
 import { apiClient } from "./client";
-import type { AgentDefinition, AgentRun, AgentVersion, JSONObject } from "@/types";
+import type { AgentConfig, AgentDefinition, AgentRun, JSONObject } from "@/types";
 
 export async function listAgents() {
   const { data } = await apiClient.get<AgentDefinition[]>("/agents", { params: {} });
@@ -30,7 +30,10 @@ export async function updateAgent(
     metadata_json?: JSONObject;
   }
 ) {
-  const { data } = await apiClient.patch<AgentDefinition>(`/agents/${id}`, payload);
+  const { data } = await apiClient.post<AgentDefinition>("/agents/update", {
+    agent_id: id,
+    ...payload,
+  });
   return data;
 }
 
@@ -39,9 +42,7 @@ export async function deleteAgent(id: string) {
 }
 
 export async function listAgentConfigs(agentId?: string) {
-  const { data } = await apiClient.get<AgentVersion[]>("/agents/versions", {
-    params: { agent_id: agentId },
-  });
+  const { data } = await apiClient.post<AgentConfig[]>("/agents/configs/list", { agent_id: agentId });
   return data;
 }
 
@@ -55,18 +56,16 @@ export async function createAgentConfig(payload: {
   runtime_policy_json?: JSONObject;
   tool_refs_json?: JSONObject[];
   skill_refs_json?: JSONObject[];
-  status?: "draft" | "published" | "deprecated";
 }) {
-  const { data } = await apiClient.post<AgentVersion>("/agents/versions", {
-    status: "draft",
-    role: "assistant",
-    system_prompt: "",
-    model_config_json: {},
-    memory_policy_json: {},
-    runtime_policy_json: {},
-    tool_refs_json: [],
-    skill_refs_json: [],
-    ...payload,
+  const { data } = await apiClient.post<AgentConfig>("/agents/configs/create", {
+    agent_id: payload.agent_id,
+    goal: payload.goal,
+    model_config: payload.model_config_json ?? {},
+    memory_policy: payload.memory_policy_json ?? {},
+    tool_refs: payload.tool_refs_json ?? [],
+    skill_refs: payload.skill_refs_json ?? [],
+    role: payload.role ?? "assistant",
+    system_prompt: payload.system_prompt ?? "",
   });
   return data;
 }
@@ -76,3 +75,27 @@ export async function listAgentRuns(agentId?: string) {
   return data;
 }
 
+export async function createAgentRun(payload: {
+  agent_id: string;
+  agent_config_id?: string | null;
+  session_id?: string | null;
+  input_text?: string | null;
+  input_json?: JSONObject | null;
+}) {
+  const { data } = await apiClient.post<AgentRun>("/agents/runs", payload);
+  return data;
+}
+
+export async function executeAgentRun(agentRunId: string, payload: {
+  worker_key?: string | null;
+  dry_run?: boolean;
+}) {
+  const { data } = await apiClient.post<{ run: AgentRun; model?: string | null; dry_run: boolean }>(
+    `/agents/runs/${agentRunId}/execute`,
+    {
+      worker_key: payload.worker_key,
+      dry_run: payload.dry_run ?? false,
+    }
+  );
+  return data;
+}

+ 35 - 9
web/src/api/auth.ts

@@ -1,29 +1,55 @@
 import { apiClient } from "./client";
 import type {
+  ApiResponse,
+  AuthMeData,
+  LoginData,
   LoginRequest,
-  LoginResponse,
+  PaginatedResponse,
   PermissionCheckContract,
   PermissionCheckResultContract,
   RoleContract,
+  TokenVerifyData,
   UserContract,
 } from "@/types";
 
+function unwrapApiResponse<T>(response: ApiResponse<T>): T {
+  if (!response.success || response.data === null) {
+    throw new Error(response.error?.message ?? "Identity API request failed");
+  }
+  return response.data;
+}
+
 export async function login(payload: LoginRequest) {
-  const { data } = await apiClient.post<LoginResponse>("/auth/login", payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<LoginData>>("/identity/auth/login", payload);
+  return unwrapApiResponse(data);
+}
+
+export async function logout() {
+  const { data } = await apiClient.post<ApiResponse<{ ok: boolean }>>("/identity/auth/logout", {});
+  return unwrapApiResponse(data);
+}
+
+export async function getCurrentUser() {
+  const { data } = await apiClient.post<ApiResponse<AuthMeData>>("/identity/auth/me", {});
+  return unwrapApiResponse(data);
+}
+
+export async function verifyToken(accessToken: string) {
+  const { data } = await apiClient.post<ApiResponse<TokenVerifyData>>("/identity/auth/tokens/verify", { accessToken });
+  return unwrapApiResponse(data);
 }
 
 export async function listUsers() {
-  const { data } = await apiClient.get<UserContract[]>("/auth/users");
-  return data;
+  const { data } = await apiClient.post<ApiResponse<PaginatedResponse<UserContract>>>("/identity/users/list", { page: 1, pageSize: 200 });
+  return unwrapApiResponse(data).items;
 }
 
 export async function listRoles() {
-  const { data } = await apiClient.get<RoleContract[]>("/auth/roles");
-  return data;
+  const { data } = await apiClient.post<ApiResponse<PaginatedResponse<RoleContract>>>("/identity/roles/list", { page: 1, pageSize: 200 });
+  return unwrapApiResponse(data).items;
 }
 
 export async function checkPermission(payload: PermissionCheckContract) {
-  const { data } = await apiClient.post<PermissionCheckResultContract>("/auth/permissions/check", payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<PermissionCheckResultContract>>("/identity/permissions/check", payload);
+  return unwrapApiResponse(data);
 }

+ 16 - 6
web/src/api/client.ts

@@ -10,7 +10,7 @@ export const apiClient = axios.create({
 
 apiClient.interceptors.request.use((config) => {
   const { gatewayBasePath } = useUiStore.getState().siteSettings;
-  config.baseURL = gatewayBasePath || "/gateway";
+  config.baseURL = normalizeGatewayBasePath(gatewayBasePath);
   if (mockMode) {
     config.adapter = async (adapterConfig) => mockAxiosResponse(adapterConfig);
   }
@@ -23,9 +23,6 @@ apiClient.interceptors.request.use((config) => {
 apiClient.interceptors.response.use(
   (response) => response,
   (error) => {
-    if (error.config && (error.response?.status === 404 || !error.response)) {
-      return Promise.resolve(mockAxiosResponse(error.config));
-    }
     if (error.response?.status === 401) {
       useAuthStore.getState().logout();
       if (window.location.pathname !== "/login") {
@@ -36,6 +33,19 @@ apiClient.interceptors.response.use(
   },
 );
 
-export function tenantParams(extra?: Record<string, string | number | boolean | undefined | null>) {
-  return { ...extra };
+function normalizeGatewayBasePath(value: string | undefined) {
+  const basePath = value?.trim() || "/gateway";
+  if (basePath === "/" || basePath === "") return "/gateway";
+  if (basePath.startsWith("http://") || basePath.startsWith("https://")) {
+    try {
+      const url = new URL(basePath);
+      if (url.pathname === "/" || url.pathname === "") {
+        url.pathname = "/gateway";
+      }
+      return url.toString().replace(/\/$/, "");
+    } catch {
+      return basePath;
+    }
+  }
+  return basePath.replace(/\/$/, "");
 }

+ 397 - 53
web/src/api/mock.ts

@@ -1,11 +1,13 @@
 import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from "axios";
 import type {
   AgentDefinition,
+  AgentConfig,
   AgentRun,
-  AgentVersion,
+  ApiResponse,
   ApiKeyCreateResponse,
   ApiKeyResponse,
   AppResponse,
+  AuthMeData,
   DiscoverModelsResponse,
   DownstreamServiceHealth,
   ExecutionLog,
@@ -14,7 +16,7 @@ import type {
   KnowledgeBase,
   KnowledgeChunk,
   KnowledgeDocument,
-  LoginResponse,
+  LoginData,
   MemoryItem,
   MemorySearchResult,
   MemoryStatus,
@@ -40,12 +42,19 @@ import type {
   WorkflowRun,
 } from "@/types";
 
-export const mockMode = import.meta.env.VITE_USE_MOCKS !== "false";
+export const mockMode = import.meta.env.VITE_USE_MOCKS === "true";
 
 const now = new Date();
 const iso = (offsetMinutes = 0) => new Date(now.getTime() + offsetMinutes * 60_000).toISOString();
 const id = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
 const hashText = (value: string) => Array.from(value).reduce((hash, token, index) => ((hash << 5) - hash + token.length + index) | 0, 0);
+const identityResponse = <T>(data: T): ApiResponse<T> => ({
+  success: true,
+  data,
+  error: null,
+  requestId: id("req"),
+  serverTime: iso(),
+});
 
 const apps: AppResponse[] = [
   {
@@ -87,12 +96,10 @@ const agents: AgentDefinition[] = [
   },
 ];
 
-const agentVersions: AgentVersion[] = [
+const agentConfigs: AgentConfig[] = [
   {
     id: "agv_support_2",
     agent_id: "agent_support",
-    version_no: 2,
-    status: "published",
     role: "assistant",
     goal: "Resolve customer requests with context.",
     system_prompt: "You are a concise operations assistant.",
@@ -101,7 +108,6 @@ const agentVersions: AgentVersion[] = [
     runtime_policy_json: {},
     tool_refs_json: [{ tool_id: "tool_mcp_database" }],
     skill_refs_json: [],
-    published_time: iso(-1100),
     created_time: iso(-1500),
   },
 ];
@@ -839,6 +845,10 @@ const toolCredentials: ToolCredential[] = [
   },
 ];
 
+const mockCredentialSecrets = new Map<string, JSONObject>(
+  toolCredentials.map((item) => [item.id, { mocked: true, credentialId: item.id }]),
+);
+
 const knowledgeBases: KnowledgeBase[] = [
   {
     id: "kb_product",
@@ -980,12 +990,12 @@ const apiKeys: ApiKeyResponse[] = [
 const demoUser = {
   id: "demo-user",
   username: "demo-user",
-  display_name: "Demo User",
+  displayName: "Demo User",
   email: "demo@example.com",
-  status: "active" as const,
-  metadata_json: {},
-  last_login_time: iso(-5),
-  created_time: iso(-5000),
+  metadata: {},
+  lastLoginTime: iso(-5),
+  createdTime: iso(-5000),
+  updatedTime: iso(-10),
 };
 
 const services: DownstreamServiceHealth[] = [
@@ -996,7 +1006,7 @@ const services: DownstreamServiceHealth[] = [
   "tool-service",
   "knowledge-service",
   "team-service",
-  "auth-service",
+  "identity-service",
 ].map((service) => ({ service, status: "ok", url: `mock://${service}`, http_status: 200, error_message: null }));
 
 const modelProviders: ModelProvider[] = [
@@ -1088,16 +1098,84 @@ function route(config: AxiosRequestConfig): unknown {
 
   if (url === "/services/health") return { service: "api-gateway", status: "ok", downstream_services: services } satisfies GatewayServicesHealth;
 
-  if (url === "/auth/login" && method === "post") {
-    return {
-      access_token: "mock-access-token",
-      token_type: "bearer",
-      expires_time: iso(480),
+  if (url === "/identity/auth/login" && method === "post") {
+    return identityResponse({
+      accessToken: "mock-access-token",
+      tokenType: "bearer",
+      expiresTime: iso(480),
       user: {
         ...demoUser,
         username: String(payload.username ?? "demo-user"),
       },
-    } satisfies LoginResponse;
+    } satisfies LoginData);
+  }
+
+  if (url === "/identity/auth/logout" && method === "post") {
+    return identityResponse({ ok: true });
+  }
+
+  if (url === "/identity/auth/me" && method === "post") {
+    return identityResponse({
+      user: demoUser,
+      roles: [
+        {
+          id: "role_admin",
+          name: "Administrator",
+          description: "Full platform operator",
+          permissionBindingCount: 2,
+          createdTime: iso(-6000),
+          updatedTime: iso(-5000),
+        },
+      ],
+      permissions: ["agents:*", "knowledge:*"],
+    } satisfies AuthMeData);
+  }
+
+  if (url === "/identity/auth/tokens/verify" && method === "post") {
+    return identityResponse({
+      active: true,
+      userId: demoUser.id,
+      username: demoUser.username,
+      expiresTime: iso(480),
+      reason: null,
+    });
+  }
+
+  if (url === "/identity/users/list" && method === "post") {
+    return identityResponse({
+      items: [demoUser],
+      total: 1,
+      page: Number(payload.page ?? 1),
+      pageSize: Number(payload.pageSize ?? 20),
+      hasMore: false,
+    });
+  }
+
+  if (url === "/identity/roles/list" && method === "post") {
+    return identityResponse({
+      items: [
+        {
+          id: "role_admin",
+          name: "Administrator",
+          description: "Full platform operator",
+          permissionBindingCount: 2,
+          createdTime: iso(-6000),
+          updatedTime: iso(-5000),
+        },
+      ],
+      total: 1,
+      page: Number(payload.page ?? 1),
+      pageSize: Number(payload.pageSize ?? 20),
+      hasMore: false,
+    });
+  }
+
+  if (url === "/identity/permissions/check" && method === "post") {
+    return identityResponse({
+      allowed: true,
+      reason: "matched",
+      matchedRoleIds: ["role_admin"],
+    });
   }
 
   if (url === "/api-keys" && method === "get") return apiKeys;
@@ -1139,27 +1217,24 @@ function route(config: AxiosRequestConfig): unknown {
     agents.unshift(created);
     return created;
   }
-  if (url === "/agents/versions" && method === "post") {
-    const created: AgentVersion = {
+  if (url === "/agents/configs/create" && method === "post") {
+    const created: AgentConfig = {
       id: id("agv"),
       agent_id: String(payload.agent_id),
-      version_no: agentVersions.filter((v) => v.agent_id === payload.agent_id).length + 1,
-      status: (payload.status as AgentVersion["status"]) ?? "draft",
       role: String(payload.role ?? "assistant"),
       goal: (payload.goal as string | null) ?? null,
       system_prompt: String(payload.system_prompt ?? ""),
-      model_config_json: (payload.model_config_json as JSONObject) ?? {},
-      memory_policy_json: (payload.memory_policy_json as JSONObject) ?? {},
-      runtime_policy_json: (payload.runtime_policy_json as JSONObject) ?? {},
-      tool_refs_json: (payload.tool_refs_json as JSONObject[]) ?? [],
-      skill_refs_json: (payload.skill_refs_json as JSONObject[]) ?? [],
-      published_time: null,
+      model_config_json: (payload.model_config as JSONObject) ?? {},
+      memory_policy_json: (payload.memory_policy as JSONObject) ?? {},
+      runtime_policy_json: (payload.runtime_policy as JSONObject) ?? {},
+      tool_refs_json: (payload.tool_refs as JSONObject[]) ?? [],
+      skill_refs_json: (payload.skill_refs as JSONObject[]) ?? [],
       created_time: iso(),
     };
-    agentVersions.unshift(created);
+    agentConfigs.unshift(created);
     return created;
   }
-  if (url === "/agents/versions" && method === "get") return agentVersions.filter((item) => item.agent_id === params.agent_id);
+  if (url === "/agents/configs/list" && method === "post") return agentConfigs.filter((item) => item.agent_id === payload.agent_id);
   if (url === "/agents/runs") return params.agent_id ? agentRuns.filter((item) => item.agent_id === params.agent_id) : agentRuns;
 
   if (url === "/skills" && method === "get") return skills;
@@ -1390,51 +1465,233 @@ function route(config: AxiosRequestConfig): unknown {
     } satisfies ModelTestResponse;
   }
 
-  if (url === "/tools" && method === "get") return tools;
-  if (url === "/tools" && method === "post") {
+  if (url === "/tools/list" && method === "post") {
+    const keyword = String(payload.keyword ?? "").toLowerCase().trim();
+    const filtered = tools.filter((item) => {
+      const haystack = `${item.name} ${item.tool_type} ${item.description ?? ""}`.toLowerCase();
+      return !keyword || haystack.includes(keyword);
+    });
+    const page = Number(payload.page ?? 1);
+    const pageSize = Number(payload.pageSize ?? 200);
+    const start = (page - 1) * pageSize;
+    return identityResponse({
+      items: filtered.slice(start, start + pageSize).map(toToolDto),
+      total: filtered.length,
+      page,
+      pageSize,
+      hasMore: page * pageSize < filtered.length,
+    });
+  }
+  if (url === "/tools/detail" && method === "post") {
+    const target = tools.find((item) => item.id === payload.toolId);
+    return identityResponse(toToolDto(target ?? tools[0]));
+  }
+  if (url === "/tools/create" && method === "post") {
     const created: ToolDefinition = {
       id: id("tool"),
-      plugin_id: (payload.plugin_id as string | null) ?? null,
+      plugin_id: (payload.pluginId as string | null) ?? null,
       name: String(payload.name ?? "New Tool"),
-      tool_type: String(payload.tool_type ?? "http"),
+      tool_type: String(payload.toolType ?? "http"),
       description: (payload.description as string | null) ?? null,
       created_time: iso(),
     };
     tools.unshift(created);
-    return created;
+    return identityResponse(toToolDto(created));
+  }
+  if (url === "/tools/update" && method === "post") {
+    const target = tools.find((item) => item.id === payload.toolId);
+    if (target) {
+      if (typeof payload.name === "string") target.name = payload.name;
+      if (typeof payload.toolType === "string") target.tool_type = payload.toolType;
+      if ("description" in payload) target.description = (payload.description as string | null) ?? null;
+      if ("pluginId" in payload) target.plugin_id = (payload.pluginId as string | null) ?? null;
+    }
+    return identityResponse(toToolDto(target ?? tools[0]));
+  }
+  if (url === "/tools/delete" && method === "post") {
+    const index = tools.findIndex((item) => item.id === payload.toolId);
+    if (index >= 0) tools.splice(index, 1);
+    return identityResponse({ deleted: index >= 0, toolId: payload.toolId ?? null });
+  }
+  if (url === "/tools/connections/list" && method === "post") {
+    const filtered = payload.toolId
+      ? toolVersions.filter((item) => item.tool_id === payload.toolId)
+      : toolVersions;
+    const page = Number(payload.page ?? 1);
+    const pageSize = Number(payload.pageSize ?? 500);
+    const start = (page - 1) * pageSize;
+    return identityResponse({
+      items: filtered.slice(start, start + pageSize).map(toToolVersionDto),
+      total: filtered.length,
+      page,
+      pageSize,
+      hasMore: page * pageSize < filtered.length,
+    });
   }
-  if (url === "/tools/versions" && method === "get") return params.tool_id ? toolVersions.filter((item) => item.tool_id === params.tool_id) : toolVersions;
-  if (url === "/tools/versions" && method === "post") {
-    const versionNo = toolVersions.filter((item) => item.tool_id === payload.tool_id).length + 1;
+  if (url === "/tools/connections/detail" && method === "post") {
+    const target = toolVersions.find((item) => item.id === payload.connectionId);
+    return identityResponse(toToolVersionDto(target ?? toolVersions[0]));
+  }
+  if (url === "/tools/connections/create" && method === "post") {
+    const toolId = String(payload.toolId);
+    const versionNo = toolVersions.filter((item) => item.tool_id === toolId).length + 1;
     const created: ToolVersion = {
       id: id("tv"),
-      tool_id: String(payload.tool_id),
+      tool_id: toolId,
       version_no: versionNo,
-      input_schema_json: (payload.input_schema_json as Record<string, never>) ?? {},
-      output_schema_json: (payload.output_schema_json as Record<string, never>) ?? {},
-      invoke_config_json: (payload.invoke_config_json as Record<string, never>) ?? {},
-      timeout_ms: Number(payload.timeout_ms ?? 5000),
-      retry_policy_json: (payload.retry_policy_json as Record<string, never>) ?? {},
+      input_schema_json: (payload.inputSchema as Record<string, never>) ?? {},
+      output_schema_json: (payload.outputSchema as Record<string, never>) ?? {},
+      invoke_config_json: (payload.invokeConfig as Record<string, never>) ?? {},
+      timeout_ms: Number(payload.timeoutMs ?? 5000),
+      retry_policy_json: (payload.retryPolicy as Record<string, never>) ?? {},
       created_time: iso(),
     };
     toolVersions.unshift(created);
-    return created;
+    return identityResponse(toToolVersionDto(created));
+  }
+  if (url === "/tools/connections/update" && method === "post") {
+    const target = toolVersions.find((item) => item.id === payload.connectionId);
+    if (target) {
+      if ("inputSchema" in payload) target.input_schema_json = (payload.inputSchema as Record<string, never>) ?? {};
+      if ("outputSchema" in payload) target.output_schema_json = (payload.outputSchema as Record<string, never>) ?? {};
+      if ("invokeConfig" in payload) target.invoke_config_json = (payload.invokeConfig as Record<string, never>) ?? {};
+      if ("timeoutMs" in payload) target.timeout_ms = Number(payload.timeoutMs ?? 0) || null;
+      if ("retryPolicy" in payload) target.retry_policy_json = (payload.retryPolicy as Record<string, never>) ?? {};
+    }
+    return identityResponse(toToolVersionDto(target ?? toolVersions[0]));
+  }
+  if (url === "/tools/connections/delete" && method === "post") {
+    const index = toolVersions.findIndex((item) => item.id === payload.connectionId);
+    if (index >= 0) toolVersions.splice(index, 1);
+    return identityResponse({ deleted: index >= 0, connectionId: payload.connectionId ?? null });
+  }
+  if (url === "/tools/mcp/connect" && method === "post") {
+    const serverName = getMcpServerName((payload.config as JSONObject) ?? {}, payload.name);
+    const normalizedConfig = normalizeMcpConfig((payload.config as JSONObject) ?? {}, serverName);
+    const createdTool: ToolDefinition = {
+      id: id("tool"),
+      plugin_id: null,
+      name: String(payload.name ?? serverName),
+      tool_type: "mcp",
+      description: `MCP SSE server: ${String(normalizedConfig.url ?? "")}`,
+      created_time: iso(),
+    };
+    tools.unshift(createdTool);
+    const createdConnection: ToolVersion = {
+      id: id("tv"),
+      tool_id: createdTool.id,
+      version_no: 1,
+      input_schema_json: {},
+      output_schema_json: {},
+      invoke_config_json: normalizedConfig,
+      timeout_ms: Number(normalizedConfig.timeout ?? normalizedConfig.timeout_seconds ?? 0) * 1000 || null,
+      retry_policy_json: { max_attempts: 1 },
+      created_time: iso(),
+    };
+    toolVersions.unshift(createdConnection);
+    return identityResponse({
+      tool: toToolDto(createdTool),
+      connection: toToolVersionDto(createdConnection),
+      discoveredTools: extractMcpTools(normalizedConfig),
+    });
+  }
+  if (url === "/tools/bindings/list" && method === "post") {
+    const filtered = payload.appId
+      ? toolBindings.filter((item) => item.app_id === payload.appId)
+      : toolBindings;
+    const page = Number(payload.page ?? 1);
+    const pageSize = Number(payload.pageSize ?? 500);
+    const start = (page - 1) * pageSize;
+    return identityResponse({
+      items: filtered.slice(start, start + pageSize).map(toToolBindingDto),
+      total: filtered.length,
+      page,
+      pageSize,
+      hasMore: page * pageSize < filtered.length,
+    });
+  }
+  if (url === "/tools/bindings/detail" && method === "post") {
+    const target = toolBindings.find((item) => item.id === payload.bindingId);
+    return identityResponse(toToolBindingDto(target ?? toolBindings[0]));
+  }
+  if (url === "/tools/bindings/create" && method === "post") {
+    const created: ToolBinding = {
+      id: id("tb"),
+      app_id: String(payload.appId),
+      tool_version_id: String(payload.toolVersionId),
+      credential_id: (payload.credentialId as string | null) ?? null,
+      binding_scope: String(payload.bindingScope ?? "app"),
+      config_json: (payload.configJson as Record<string, never>) ?? {},
+      created_time: iso(),
+    };
+    toolBindings.unshift(created);
+    return identityResponse(toToolBindingDto(created));
+  }
+  if (url === "/tools/bindings/update" && method === "post") {
+    const target = toolBindings.find((item) => item.id === payload.bindingId);
+    if (target) {
+      if ("credentialId" in payload) target.credential_id = (payload.credentialId as string | null) ?? null;
+      if (typeof payload.bindingScope === "string") target.binding_scope = payload.bindingScope;
+      if ("configJson" in payload) target.config_json = (payload.configJson as Record<string, never>) ?? {};
+    }
+    return identityResponse(toToolBindingDto(target ?? toolBindings[0]));
+  }
+  if (url === "/tools/bindings/delete" && method === "post") {
+    const index = toolBindings.findIndex((item) => item.id === payload.bindingId);
+    if (index >= 0) toolBindings.splice(index, 1);
+    return identityResponse({ deleted: index >= 0, bindingId: payload.bindingId ?? null });
   }
-  if (url === "/tools/bindings") return params.app_id ? toolBindings.filter((item) => item.app_id === params.app_id) : toolBindings;
-  if (url === "/tools/credentials" && method === "get") return toolCredentials;
-  if (url === "/tools/credentials" && method === "post") {
-    const secret = JSON.stringify(payload.secret_json ?? {});
+  if (url === "/tools/credentials/list" && method === "post") {
+    const page = Number(payload.page ?? 1);
+    const pageSize = Number(payload.pageSize ?? 500);
+    const start = (page - 1) * pageSize;
+    return identityResponse({
+      items: toolCredentials.slice(start, start + pageSize).map(toToolCredentialDto),
+      total: toolCredentials.length,
+      page,
+      pageSize,
+      hasMore: page * pageSize < toolCredentials.length,
+    });
+  }
+  if (url === "/tools/credentials/detail" && method === "post") {
+    const target = toolCredentials.find((item) => item.id === payload.credentialId);
+    return identityResponse(toToolCredentialDto(target ?? toolCredentials[0]));
+  }
+  if (url === "/tools/credentials/create" && method === "post") {
+    const secret = JSON.stringify(payload.secretJson ?? {});
     const created: ToolCredential = {
       id: id("tc"),
       name: String(payload.name ?? "New Credential"),
-      credential_type: String(payload.credential_type ?? "api_key"),
+      credential_type: String(payload.credentialType ?? "api_key"),
       secret_fingerprint: `sha256:${Math.abs(hashText(secret)).toString(16).slice(0, 10)}`,
       encryption_algorithm: "mock",
-      metadata_json: (payload.metadata_json as Record<string, never>) ?? {},
+      metadata_json: (payload.metadataJson as Record<string, never>) ?? {},
       created_time: iso(),
     };
     toolCredentials.unshift(created);
-    return created;
+    mockCredentialSecrets.set(created.id, (payload.secretJson as JSONObject) ?? {});
+    return identityResponse(toToolCredentialDto(created));
+  }
+  if (url === "/tools/credentials/update" && method === "post") {
+    const target = toolCredentials.find((item) => item.id === payload.credentialId);
+    if (target) {
+      if (typeof payload.name === "string") target.name = payload.name;
+      if ("metadataJson" in payload) target.metadata_json = (payload.metadataJson as Record<string, never>) ?? {};
+    }
+    return identityResponse(toToolCredentialDto(target ?? toolCredentials[0]));
+  }
+  if (url === "/tools/credentials/delete" && method === "post") {
+    const index = toolCredentials.findIndex((item) => item.id === payload.credentialId);
+    if (index >= 0) toolCredentials.splice(index, 1);
+    mockCredentialSecrets.delete(String(payload.credentialId ?? ""));
+    return identityResponse({ deleted: index >= 0, credentialId: payload.credentialId ?? null });
+  }
+  if (url === "/tools/credentials/reveal" && method === "post") {
+    const target = toolCredentials.find((item) => item.id === payload.credentialId);
+    return identityResponse({
+      credential: toToolCredentialDto(target ?? toolCredentials[0]),
+      secretJson: mockCredentialSecrets.get(String(payload.credentialId ?? "")) ?? {},
+    });
   }
 
   if (url === "/knowledge/bases" && method === "get") return knowledgeBases;
@@ -1672,6 +1929,93 @@ function normalize(url?: string) {
   return path.startsWith("/gateway") ? path.slice("/gateway".length) || "/" : path;
 }
 
+function toToolDto(item?: ToolDefinition) {
+  return {
+    id: item?.id ?? "",
+    pluginId: item?.plugin_id ?? null,
+    name: item?.name ?? "",
+    toolType: item?.tool_type ?? "mcp",
+    description: item?.description ?? null,
+    createdTime: item?.created_time ?? iso(),
+  };
+}
+
+function toToolVersionDto(item?: ToolVersion) {
+  return {
+    id: item?.id ?? "",
+    toolId: item?.tool_id ?? "",
+    versionNo: item?.version_no ?? 1,
+    inputSchema: item?.input_schema_json ?? {},
+    outputSchema: item?.output_schema_json ?? {},
+    invokeConfig: item?.invoke_config_json ?? {},
+    timeoutMs: item?.timeout_ms ?? null,
+    retryPolicy: item?.retry_policy_json ?? {},
+    createdTime: item?.created_time ?? iso(),
+  };
+}
+
+function toToolBindingDto(item?: ToolBinding) {
+  return {
+    id: item?.id ?? "",
+    appId: item?.app_id ?? "",
+    toolVersionId: item?.tool_version_id ?? "",
+    credentialId: item?.credential_id ?? null,
+    bindingScope: item?.binding_scope ?? "app",
+    configJson: item?.config_json ?? {},
+    createdTime: item?.created_time ?? iso(),
+  };
+}
+
+function toToolCredentialDto(item?: ToolCredential) {
+  return {
+    id: item?.id ?? "",
+    name: item?.name ?? "",
+    credentialType: item?.credential_type ?? "api_key",
+    secretFingerprint: item?.secret_fingerprint ?? "",
+    encryptionAlgorithm: item?.encryption_algorithm ?? "mock",
+    metadataJson: item?.metadata_json ?? {},
+    createdTime: item?.created_time ?? iso(),
+  };
+}
+
+function getMcpServerName(config: JSONObject, fallback: unknown): string {
+  if (typeof fallback === "string" && fallback.trim()) return fallback.trim();
+  const keys = Object.keys(config);
+  const onlyKey = keys[0];
+  if (onlyKey && keys.length === 1 && typeof config[onlyKey] === "object") return onlyKey;
+  const serverName = config.server_name ?? config.serverName;
+  return typeof serverName === "string" && serverName.trim() ? serverName.trim() : "mcp_server";
+}
+
+function normalizeMcpConfig(config: JSONObject, serverName: string): JSONObject {
+  const nested = config[serverName];
+  const source = nested && typeof nested === "object" && !Array.isArray(nested)
+    ? nested as JSONObject
+    : config;
+  return {
+    ...source,
+    server_name: typeof source.server_name === "string" ? source.server_name : serverName,
+    transport: typeof source.transport === "string" ? source.transport : "sse",
+  };
+}
+
+function extractMcpTools(config: JSONObject) {
+  const rawTools = config.mcp_tools ?? config.tools;
+  if (!Array.isArray(rawTools)) return [];
+  return rawTools
+    .filter((item): item is JSONObject => Boolean(item) && typeof item === "object" && !Array.isArray(item))
+    .filter((item) => typeof item.name === "string")
+    .map((item) => ({
+      name: String(item.name),
+      description: typeof item.description === "string" ? item.description : null,
+      inputSchema: item.inputSchema && typeof item.inputSchema === "object" && !Array.isArray(item.inputSchema)
+        ? item.inputSchema as JSONObject
+        : item.input_schema && typeof item.input_schema === "object" && !Array.isArray(item.input_schema)
+          ? item.input_schema as JSONObject
+          : null,
+    }));
+}
+
 function mockSearch(knowledgeBaseId: string, query: string, topK: number): SearchResult[] {
   const baseDocuments = knowledgeDocuments.filter((item) => item.knowledge_base_id === knowledgeBaseId && item.status === "indexed");
   const fallbackDocument = baseDocuments[0] ?? knowledgeDocuments[0] ?? {

+ 119 - 14
web/src/api/model-providers.ts

@@ -1,43 +1,148 @@
-import { apiClient, tenantParams } from "./client";
+import { apiClient } from "./client";
 import type {
+  ApiResponse,
+  DiscoveredModel,
   ModelProvider,
   ModelProviderCreateRequest,
   ModelProviderTestResult,
   ModelProviderUpdateRequest,
   DiscoverModelsResponse,
+  PaginatedResponse,
 } from "@/types";
 
+interface ModelProviderDto {
+  id: string;
+  name: string;
+  providerType: ModelProvider["provider_type"];
+  baseUrl: string;
+  apiKeyRef: string;
+  models: Array<{
+    modelId: string;
+    displayName: string;
+    modelType: ModelProvider["models"][number]["model_type"];
+    ownedBy?: string | null;
+    contextWindow?: number | null;
+  }>;
+  defaultModel?: string | null;
+  extraConfig: Record<string, unknown>;
+  createdTime: string;
+  updatedTime?: string | null;
+}
+
+function unwrap<T>(response: ApiResponse<T>): T {
+  if (!response.success || response.data === null) throw new Error(response.error?.message ?? "Model provider API request failed");
+  return response.data;
+}
+
+function toLegacyProvider(provider: ModelProviderDto): ModelProvider {
+  return {
+    id: provider.id,
+    name: provider.name,
+    provider_type: provider.providerType,
+    base_url: provider.baseUrl,
+    api_key_ref: provider.apiKeyRef,
+    models: provider.models.map((model) => ({
+      model_id: model.modelId,
+      display_name: model.displayName,
+      model_type: model.modelType,
+    })),
+    default_model: provider.defaultModel,
+    extra_config_json: provider.extraConfig,
+    created_time: provider.createdTime,
+    updated_time: provider.updatedTime,
+  };
+}
+
+function toModelItems(models: ModelProviderCreateRequest["models"]) {
+  return models.map((model) => ({
+    modelId: model.model_id,
+    displayName: model.display_name,
+    modelType: model.model_type,
+  }));
+}
+
+function toCreateContract(payload: ModelProviderCreateRequest) {
+  return {
+    name: payload.name,
+    providerType: payload.provider_type,
+    baseUrl: payload.base_url,
+    apiKey: payload.api_key,
+    models: toModelItems(payload.models),
+    defaultModel: payload.default_model,
+    extraConfig: payload.extra_config_json,
+  };
+}
+
 export async function listModelProviders() {
-  const { data } = await apiClient.get<ModelProvider[]>("/model-providers", { params: tenantParams() });
-  return data;
+  const { data } = await apiClient.post<ApiResponse<PaginatedResponse<ModelProviderDto>>>("/model-providers/list", { page: 1, pageSize: 200 });
+  return unwrap(data).items.map(toLegacyProvider);
 }
 
 export async function createModelProvider(payload: ModelProviderCreateRequest) {
-  const { data } = await apiClient.post<ModelProvider>("/model-providers", payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ModelProviderDto>>("/model-providers/create", toCreateContract(payload));
+  return toLegacyProvider(unwrap(data));
 }
 
 export async function updateModelProvider(providerId: string, payload: ModelProviderUpdateRequest) {
-  const { data } = await apiClient.patch<ModelProvider>(`/model-providers/${providerId}`, {
-    ...payload,
+  const { data } = await apiClient.post<ApiResponse<ModelProviderDto>>("/model-providers/update", {
+    providerId,
+    name: payload.name,
+    baseUrl: payload.base_url,
+    apiKey: payload.api_key,
+    models: payload.models ? toModelItems(payload.models) : undefined,
+    defaultModel: payload.default_model,
+    extraConfig: payload.extra_config_json,
   });
-  return data;
+  return toLegacyProvider(unwrap(data));
 }
 
 export async function deleteModelProvider(providerId: string) {
-  const { data } = await apiClient.delete(`/model-providers/${providerId}`, { params: tenantParams() });
+  const { data } = await apiClient.post("/model-providers/delete", { providerId });
   return data;
 }
 
 export async function testModelProviderConnection(providerId: string) {
-  const { data } = await apiClient.post<ModelProviderTestResult>(`/model-providers/${providerId}/test`, tenantParams());
-  return data;
+  const { data } = await apiClient.post<ApiResponse<{
+    success: boolean;
+    message: string;
+    latencyMs?: number | null;
+    modelList: string[];
+  }>>("/model-providers/test", { providerId });
+  const result = unwrap(data);
+  return {
+    success: result.success,
+    message: result.message,
+    latency_ms: result.latencyMs ?? undefined,
+    model_list: result.modelList,
+  } satisfies ModelProviderTestResult;
 }
 
 /** Auto-discover available models from a provider (by ID) or by connection params. */
 export async function discoverModels(params: { providerId?: string; providerType?: string; baseUrl?: string; apiKey?: string }) {
-  const { data } = await apiClient.post<DiscoverModelsResponse>("/model-providers/discover", {
-    ...params,
+  const { data } = await apiClient.post<ApiResponse<{
+    providerType: DiscoverModelsResponse["provider_type"];
+    models: Array<{
+      modelId: string;
+      displayName: string;
+      modelType: DiscoveredModel["model_type"];
+      ownedBy?: string | null;
+      contextWindow?: number | null;
+    }>;
+  }>>("/model-providers/discover", {
+    providerId: params.providerId,
+    providerType: params.providerType,
+    baseUrl: params.baseUrl,
+    apiKey: params.apiKey,
   });
-  return data;
+  const result = unwrap(data);
+  return {
+    provider_type: result.providerType,
+    models: result.models.map((model) => ({
+      model_id: model.modelId,
+      display_name: model.displayName,
+      model_type: model.modelType,
+      owned_by: model.ownedBy ?? undefined,
+      context_window: model.contextWindow ?? undefined,
+    })),
+  } satisfies DiscoverModelsResponse;
 }

+ 92 - 9
web/src/api/models.ts

@@ -1,32 +1,115 @@
 import { apiClient } from "./client";
 import type {
+  ApiResponse,
   ModelCreateRequest,
   ModelDefinition,
+  JSONObject,
   ModelTestRequest,
   ModelTestResponse,
   ModelUpdateRequest,
+  PaginatedResponse,
 } from "@/types";
 
+interface ModelDto {
+  id: string;
+  name: string;
+  providerId?: string | null;
+  providerType: string;
+  providerBaseUrl: string;
+  hasProviderApiKey: boolean;
+  modelName: string;
+  description?: string | null;
+  capabilities: string[];
+  contextWindow?: number | null;
+  maxOutputTokens?: number | null;
+  defaultTemperature?: number | null;
+  timeoutSeconds: number;
+  metadata?: JSONObject | null;
+  createdTime: string;
+  updatedTime: string;
+}
+
+function unwrap<T>(response: ApiResponse<T>): T {
+  if (!response.success || response.data === null) throw new Error(response.error?.message ?? "Model API request failed");
+  return response.data;
+}
+
+function toLegacyModel(model: ModelDto): ModelDefinition {
+  return {
+    id: model.id,
+    name: model.name,
+    provider_id: model.providerId,
+    provider_type: model.providerType,
+    provider_base_url: model.providerBaseUrl,
+    has_provider_api_key: model.hasProviderApiKey,
+    model_name: model.modelName,
+    description: model.description,
+    capabilities_json: model.capabilities,
+    context_window: model.contextWindow,
+    max_output_tokens: model.maxOutputTokens,
+    default_temperature: model.defaultTemperature,
+    timeout_seconds: model.timeoutSeconds,
+    metadata_json: model.metadata ?? {},
+    created_time: model.createdTime,
+    updated_time: model.updatedTime,
+  };
+}
+
+function toCreateContract(payload: ModelCreateRequest) {
+  return {
+    name: payload.name,
+    providerId: payload.provider_id,
+    providerType: payload.provider_type,
+    providerBaseUrl: payload.provider_base_url,
+    providerApiKey: payload.provider_api_key,
+    modelName: payload.model_name,
+    description: payload.description,
+    capabilities: payload.capabilities_json,
+    contextWindow: payload.context_window,
+    maxOutputTokens: payload.max_output_tokens,
+    defaultTemperature: payload.default_temperature,
+    timeoutSeconds: payload.timeout_seconds,
+    metadata: payload.metadata_json,
+  };
+}
+
+function toUpdateContract(modelId: string, payload: ModelUpdateRequest) {
+  return {
+    modelId,
+    ...toCreateContract(payload as ModelCreateRequest),
+  };
+}
+
 export async function listModels() {
-  const { data } = await apiClient.get<ModelDefinition[]>("/models");
-  return data;
+  const { data } = await apiClient.post<ApiResponse<PaginatedResponse<ModelDto>>>("/models/list", { page: 1, pageSize: 200 });
+  return unwrap(data).items.map(toLegacyModel);
 }
 
 export async function createModel(payload: ModelCreateRequest) {
-  const { data } = await apiClient.post<ModelDefinition>("/models", payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ModelDto>>("/models/create", toCreateContract(payload));
+  return toLegacyModel(unwrap(data));
 }
 
 export async function updateModel(modelId: string, payload: ModelUpdateRequest) {
-  const { data } = await apiClient.patch<ModelDefinition>(`/models/${modelId}`, payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ModelDto>>("/models/update", toUpdateContract(modelId, payload));
+  return toLegacyModel(unwrap(data));
 }
 
 export async function deleteModel(modelId: string) {
-  await apiClient.delete(`/models/${modelId}`);
+  await apiClient.post("/models/delete", { modelId });
 }
 
 export async function testModel(modelId: string, payload: ModelTestRequest) {
-  const { data } = await apiClient.post<ModelTestResponse>(`/models/${modelId}/test`, payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<{ model: ModelDto; response: ModelTestResponse["response"] }>>("/models/test", {
+    modelId,
+    prompt: payload.prompt,
+    systemPrompt: payload.system_prompt,
+    temperature: payload.temperature,
+    maxTokens: payload.max_tokens,
+  });
+  const result = unwrap(data);
+  return {
+    model: toLegacyModel(result.model),
+    response: result.response,
+  };
 }

+ 254 - 23
web/src/api/teams.ts

@@ -1,14 +1,182 @@
 import { apiClient } from "./client";
-import type { JSONObject, TeamDefinition, TeamRun, TeamVersion } from "@/types";
+import type { ApiResponse, JSONObject, PaginatedResponse, TeamDefinition, TeamRun, TeamVersion } from "@/types";
+
+interface TeamDto {
+  id: string;
+  name: string;
+  description?: string | null;
+  teamType: string;
+  status: TeamDefinition["status"];
+  ownerUserId?: string | null;
+  metadata?: JSONObject | null;
+  createdTime: string;
+}
+
+interface TeamConfigDto {
+  id: string;
+  teamId: string;
+  versionNo: number;
+  status: TeamVersion["status"];
+  coordinationMode: string;
+  objective?: string | null;
+  memberRefs: JSONObject[];
+  policy: JSONObject;
+  publishedTime?: string | null;
+  createdTime: string;
+}
+
+interface TeamRunDto {
+  id: string;
+  teamId: string;
+  teamConfigId: string;
+  sessionId?: string | null;
+  inputText?: string | null;
+  inputJson?: JSONObject | null;
+  outputText?: string | null;
+  outputJson?: JSONObject | null;
+  status: TeamRun["status"];
+  workerKey?: string | null;
+  queuedTime?: string | null;
+  leaseExpireTime?: string | null;
+  startedTime?: string | null;
+  finishedTime?: string | null;
+  errorCode?: string | null;
+  errorMessage?: string | null;
+  createdTime: string;
+}
+
+function unwrap<T>(response: ApiResponse<T>): T {
+  if (!response.success || response.data === null) {
+    throw new Error(response.error?.message ?? "Team API request failed");
+  }
+  return response.data;
+}
+
+async function listAllPages<T>(path: string, payload: Record<string, unknown>): Promise<T[]> {
+  const pageSize = 200;
+  const items: T[] = [];
+  let page = 1;
+  let hasMore = true;
+  while (hasMore) {
+    const { data } = await apiClient.post<ApiResponse<PaginatedResponse<T>>>(path, {
+      ...compactObject(payload),
+      page,
+      pageSize,
+    });
+    const result = unwrap(data);
+    items.push(...result.items);
+    hasMore = result.hasMore ?? items.length < result.total;
+    page += 1;
+  }
+  return items;
+}
+
+function toLegacyTeam(team: TeamDto): TeamDefinition {
+  return {
+    id: team.id,
+    name: team.name,
+    description: team.description,
+    team_type: team.teamType,
+    status: team.status,
+    owner_user_id: team.ownerUserId,
+    created_time: team.createdTime,
+  };
+}
+
+function toLegacyConfig(config: TeamConfigDto): TeamVersion {
+  return {
+    id: config.id,
+    team_id: config.teamId,
+    version_no: config.versionNo,
+    status: config.status,
+    coordination_mode: config.coordinationMode,
+    objective: config.objective,
+    member_refs_json: config.memberRefs,
+    policy_json: config.policy,
+    published_time: config.publishedTime,
+    created_time: config.createdTime,
+  };
+}
+
+function toLegacyRun(run: TeamRunDto): TeamRun {
+  return {
+    id: run.id,
+    team_id: run.teamId,
+    team_version_id: run.teamConfigId,
+    session_id: run.sessionId,
+    input_text: run.inputText,
+    input_json: run.inputJson,
+    output_text: run.outputText,
+    output_json: run.outputJson,
+    status: run.status,
+    worker_key: run.workerKey,
+    queued_time: run.queuedTime,
+    lease_expire_time: run.leaseExpireTime,
+    started_time: run.startedTime,
+    finished_time: run.finishedTime,
+    error_code: run.errorCode,
+    error_message: run.errorMessage,
+    created_time: run.createdTime,
+  };
+}
 
 export async function listTeams() {
-  const { data } = await apiClient.get<TeamDefinition[]>("/teams", { params: {} });
-  return data;
+  const items = await listAllPages<TeamDto>("/teams/list", {});
+  return items.map(toLegacyTeam);
+}
+
+export async function getTeam(id: string) {
+  const { data } = await apiClient.post<ApiResponse<TeamDto>>("/teams/detail", { teamId: id });
+  return toLegacyTeam(unwrap(data));
+}
+
+export async function createTeam(payload: {
+  name: string;
+  description?: string | null;
+  team_type: string;
+  owner_user_id?: string | null;
+  metadata_json?: JSONObject;
+}) {
+  const { data } = await apiClient.post<ApiResponse<TeamDto>>("/teams/create", {
+    name: payload.name,
+    description: payload.description,
+    teamType: payload.team_type,
+    ownerUserId: payload.owner_user_id,
+    metadata: payload.metadata_json ?? {},
+  });
+  return toLegacyTeam(unwrap(data));
+}
+
+export async function updateTeam(
+  id: string,
+  payload: {
+    name?: string;
+    description?: string | null;
+    team_type?: string;
+    status?: TeamDefinition["status"];
+    owner_user_id?: string | null;
+    metadata_json?: JSONObject;
+  },
+) {
+  const { data } = await apiClient.post<ApiResponse<TeamDto>>("/teams/update", {
+    teamId: id,
+    name: payload.name,
+    description: payload.description,
+    teamType: payload.team_type,
+    status: payload.status,
+    ownerUserId: payload.owner_user_id,
+    metadata: payload.metadata_json,
+  });
+  return toLegacyTeam(unwrap(data));
+}
+
+export async function deleteTeam(id: string) {
+  await apiClient.post("/teams/delete", { teamId: id });
 }
 
 export async function listTeamConfigs(teamId?: string) {
-  const { data } = await apiClient.get<TeamVersion[]>("/teams/versions", { params: { team_id: teamId } });
-  return data;
+  const items = await listAllPages<TeamConfigDto>("/teams/configs/list", { teamId });
+  return items.map(toLegacyConfig);
 }
 
 export async function createTeamConfig(payload: {
@@ -19,18 +187,45 @@ export async function createTeamConfig(payload: {
   member_refs?: JSONObject[];
   policy_json?: JSONObject;
 }) {
-  const { data } = await apiClient.post<TeamVersion>("/teams/versions", {
-    status: "draft",
-    member_refs: [],
-    policy_json: {},
-    ...payload,
+  const { data } = await apiClient.post<ApiResponse<TeamConfigDto>>("/teams/configs/create", {
+    teamId: payload.team_id,
+    coordinationMode: payload.coordination_mode,
+    objective: payload.objective,
+    status: payload.status ?? "draft",
+    memberRefs: (payload.member_refs ?? []).map(normalizeMemberRef),
+    policy: payload.policy_json ?? {},
   });
-  return data;
+  return toLegacyConfig(unwrap(data));
+}
+
+export async function updateTeamConfig(
+  id: string,
+  payload: {
+    coordination_mode?: string;
+    objective?: string | null;
+    status?: "draft" | "published" | "deprecated";
+    member_refs?: JSONObject[];
+    policy_json?: JSONObject;
+  },
+) {
+  const { data } = await apiClient.post<ApiResponse<TeamConfigDto>>("/teams/configs/update", {
+    configId: id,
+    coordinationMode: payload.coordination_mode,
+    objective: payload.objective,
+    status: payload.status,
+    memberRefs: payload.member_refs?.map(normalizeMemberRef),
+    policy: payload.policy_json,
+  });
+  return toLegacyConfig(unwrap(data));
+}
+
+export async function deleteTeamConfig(id: string) {
+  await apiClient.post("/teams/configs/delete", { configId: id });
 }
 
 export async function listTeamRuns(teamId?: string) {
-  const { data } = await apiClient.get<TeamRun[]>("/teams/runs", { params: { team_id: teamId } });
-  return data;
+  const items = await listAllPages<TeamRunDto>("/teams/runs/list", { teamId });
+  return items.map(toLegacyRun);
 }
 
 export async function createTeamRun(payload: {
@@ -38,18 +233,54 @@ export async function createTeamRun(payload: {
   team_version_id?: string | null;
   input_text?: string | null;
   input_json?: JSONObject | null;
+  session_id?: string | null;
 }) {
-  const { data } = await apiClient.post<TeamRun>("/teams/runs", payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<TeamRunDto>>("/teams/runs/create", {
+    teamId: payload.team_id,
+    teamConfigId: payload.team_version_id,
+    sessionId: payload.session_id,
+    inputText: payload.input_text,
+    inputJson: payload.input_json,
+  });
+  return toLegacyRun(unwrap(data));
 }
 
-export async function createTeam(payload: {
-  name: string;
-  description?: string | null;
-  team_type: string;
-  owner_user_id?: string | null;
-  metadata_json?: JSONObject;
+export async function executeTeamRun(teamRunId: string, payload: {
+  worker_key?: string | null;
+  dry_run?: boolean;
 }) {
-  const { data } = await apiClient.post<TeamDefinition>("/teams", payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<{ run: TeamRunDto; memberRunCount: number; dryRun: boolean }>>(
+    "/teams/runs/execute",
+    {
+      teamRunId,
+      workerKey: payload.worker_key,
+      dryRun: payload.dry_run ?? true,
+    },
+  );
+  const result = unwrap(data);
+  return {
+    run: toLegacyRun(result.run),
+    member_run_count: result.memberRunCount,
+    dry_run: result.dryRun,
+  };
+}
+
+export async function deleteTeamRun(id: string) {
+  await apiClient.post("/teams/runs/delete", { teamRunId: id });
+}
+
+function normalizeMemberRef(member: JSONObject): JSONObject {
+  const rawRole = member.role;
+  const role = rawRole === "worker" ? "executor" : rawRole;
+  if (typeof role !== "string") return member;
+  return {
+    ...member,
+    role,
+  };
+}
+
+function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
+  return Object.fromEntries(
+    Object.entries(value).filter(([, item]) => item !== undefined && item !== null),
+  ) as Partial<T>;
 }

+ 253 - 44
web/src/api/tools.ts

@@ -1,14 +1,157 @@
 import { apiClient } from "./client";
-import type { JSONObject, ToolBinding, ToolCredential, ToolDefinition, ToolVersion } from "@/types";
+import type {
+  ApiResponse,
+  JSONObject,
+  PaginatedResponse,
+  ToolBinding,
+  ToolCredential,
+  ToolDefinition,
+  ToolVersion,
+} from "@/types";
+
+interface ToolDto {
+  id: string;
+  pluginId?: string | null;
+  name: string;
+  toolType: string;
+  description?: string | null;
+  createdTime: string;
+}
+
+interface ToolVersionDto {
+  id: string;
+  toolId: string;
+  versionNo: number;
+  inputSchema?: JSONObject | null;
+  outputSchema?: JSONObject | null;
+  invokeConfig?: JSONObject | null;
+  timeoutMs?: number | null;
+  retryPolicy?: JSONObject | null;
+  createdTime: string;
+}
+
+interface McpConnectData {
+  tool: ToolDto;
+  connection: ToolVersionDto;
+  discoveredTools: Array<{
+    name: string;
+    description?: string | null;
+    inputSchema?: JSONObject | null;
+  }>;
+}
+
+interface ToolBindingDto {
+  id: string;
+  appId: string;
+  toolVersionId: string;
+  credentialId?: string | null;
+  bindingScope: string;
+  configJson?: JSONObject | null;
+  createdTime: string;
+}
+
+interface ToolCredentialDto {
+  id: string;
+  name: string;
+  credentialType: string;
+  secretFingerprint: string;
+  encryptionAlgorithm: string;
+  metadataJson: JSONObject;
+  createdTime: string;
+}
+
+interface ToolCredentialRevealDto {
+  credential: ToolCredentialDto;
+  secretJson: JSONObject;
+}
+
+function unwrap<T>(response: ApiResponse<T>): T {
+  if (!response.success || response.data === null) {
+    throw new Error(response.error?.message ?? "Tool API request failed");
+  }
+  return response.data;
+}
+
+function toLegacyTool(tool: ToolDto): ToolDefinition {
+  return {
+    id: tool.id,
+    plugin_id: tool.pluginId,
+    name: tool.name,
+    tool_type: tool.toolType,
+    description: tool.description,
+    created_time: tool.createdTime,
+  };
+}
+
+function toLegacyConnection(connection: ToolVersionDto): ToolVersion {
+  return {
+    id: connection.id,
+    tool_id: connection.toolId,
+    version_no: connection.versionNo,
+    input_schema_json: connection.inputSchema,
+    output_schema_json: connection.outputSchema,
+    invoke_config_json: connection.invokeConfig,
+    timeout_ms: connection.timeoutMs,
+    retry_policy_json: connection.retryPolicy,
+    created_time: connection.createdTime,
+  };
+}
+
+function toLegacyBinding(binding: ToolBindingDto): ToolBinding {
+  return {
+    id: binding.id,
+    app_id: binding.appId,
+    tool_version_id: binding.toolVersionId,
+    credential_id: binding.credentialId,
+    binding_scope: binding.bindingScope,
+    config_json: binding.configJson,
+    created_time: binding.createdTime,
+  };
+}
+
+function toLegacyCredential(credential: ToolCredentialDto): ToolCredential {
+  return {
+    id: credential.id,
+    name: credential.name,
+    credential_type: credential.credentialType,
+    secret_fingerprint: credential.secretFingerprint,
+    encryption_algorithm: credential.encryptionAlgorithm,
+    metadata_json: credential.metadataJson,
+    created_time: credential.createdTime,
+  };
+}
+
+async function listAllPages<T>(
+  path: string,
+  payload: Record<string, unknown>,
+): Promise<T[]> {
+  const pageSize = 200;
+  const items: T[] = [];
+  let page = 1;
+  let hasMore = true;
+
+  while (hasMore) {
+    const { data } = await apiClient.post<ApiResponse<PaginatedResponse<T>>>(
+      path,
+      { ...payload, page, pageSize },
+    );
+    const result = unwrap(data);
+    items.push(...result.items);
+    hasMore = result.hasMore ?? items.length < result.total;
+    page += 1;
+  }
+
+  return items;
+}
 
 export async function listTools() {
-  const { data } = await apiClient.get<ToolDefinition[]>("/tools", { params: {} });
-  return data;
+  const items = await listAllPages<ToolDto>("/tools/list", {});
+  return items.map(toLegacyTool);
 }
 
 export async function getTool(id: string) {
-  const { data } = await apiClient.get<ToolDefinition>(`/tools/${id}`);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ToolDto>>("/tools/detail", { toolId: id });
+  return toLegacyTool(unwrap(data));
 }
 
 export async function createTool(payload: {
@@ -17,8 +160,13 @@ export async function createTool(payload: {
   tool_type: string;
   description?: string | null;
 }) {
-  const { data } = await apiClient.post<ToolDefinition>("/tools", payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ToolDto>>("/tools/create", {
+    pluginId: payload.plugin_id,
+    name: payload.name,
+    toolType: payload.tool_type,
+    description: payload.description,
+  });
+  return toLegacyTool(unwrap(data));
 }
 
 export async function updateTool(
@@ -28,17 +176,24 @@ export async function updateTool(
     description?: string | null;
   }
 ) {
-  const { data } = await apiClient.patch<ToolDefinition>(`/tools/${id}`, payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ToolDto>>("/tools/update", {
+    toolId: id,
+    name: payload.name,
+    description: payload.description,
+  });
+  return toLegacyTool(unwrap(data));
 }
 
 export async function deleteTool(id: string) {
-  await apiClient.delete(`/tools/${id}`);
+  await apiClient.post("/tools/delete", { toolId: id });
 }
 
 export async function listToolConnections(toolId?: string) {
-  const { data } = await apiClient.get<ToolVersion[]>("/tools/versions", { params: { tool_id: toolId } });
-  return data;
+  const items = await listAllPages<ToolVersionDto>(
+    "/tools/connections/list",
+    compactObject({ toolId }),
+  );
+  return items.map(toLegacyConnection);
 }
 
 export async function createToolConnection(payload: {
@@ -49,14 +204,15 @@ export async function createToolConnection(payload: {
   invoke_config_json?: JSONObject;
   retry_policy_json?: JSONObject;
 }) {
-  const { data } = await apiClient.post<ToolVersion>("/tools/versions", {
-    input_schema_json: {},
-    output_schema_json: {},
-    invoke_config_json: {},
-    retry_policy_json: {},
-    ...payload,
+  const { data } = await apiClient.post<ApiResponse<ToolVersionDto>>("/tools/connections/create", {
+    toolId: payload.tool_id,
+    timeoutMs: payload.timeout_ms,
+    inputSchema: payload.input_schema_json ?? {},
+    outputSchema: payload.output_schema_json ?? {},
+    invokeConfig: payload.invoke_config_json ?? {},
+    retryPolicy: payload.retry_policy_json ?? {},
   });
-  return data;
+  return toLegacyConnection(unwrap(data));
 }
 
 export async function updateToolConnection(
@@ -69,22 +225,45 @@ export async function updateToolConnection(
     retry_policy_json?: JSONObject;
   }
 ) {
-  const { data } = await apiClient.patch<ToolVersion>(`/tools/versions/${id}`, payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ToolVersionDto>>("/tools/connections/update", {
+    connectionId: id,
+    timeoutMs: payload.timeout_ms,
+    inputSchema: payload.input_schema_json,
+    outputSchema: payload.output_schema_json,
+    invokeConfig: payload.invoke_config_json,
+    retryPolicy: payload.retry_policy_json,
+  });
+  return toLegacyConnection(unwrap(data));
 }
 
 export async function deleteToolConnection(id: string) {
-  await apiClient.delete(`/tools/versions/${id}`);
+  await apiClient.post("/tools/connections/delete", { connectionId: id });
+}
+
+export async function connectMcpServer(payload: {
+  name?: string;
+  config: JSONObject;
+}) {
+  const { data } = await apiClient.post<ApiResponse<McpConnectData>>("/tools/mcp/connect", payload);
+  const result = unwrap(data);
+  return {
+    tool: toLegacyTool(result.tool),
+    connection: toLegacyConnection(result.connection),
+    discoveredTools: result.discoveredTools,
+  };
 }
 
 export async function listToolBindings(appId?: string) {
-  const { data } = await apiClient.get<ToolBinding[]>("/tools/bindings", { params: { app_id: appId } });
-  return data;
+  const items = await listAllPages<ToolBindingDto>(
+    "/tools/bindings/list",
+    compactObject({ appId }),
+  );
+  return items.map(toLegacyBinding);
 }
 
 export async function getToolBinding(id: string) {
-  const { data } = await apiClient.get<ToolBinding>(`/tools/bindings/${id}`);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ToolBindingDto>>("/tools/bindings/detail", { bindingId: id });
+  return toLegacyBinding(unwrap(data));
 }
 
 export async function createToolBinding(payload: {
@@ -94,11 +273,14 @@ export async function createToolBinding(payload: {
   binding_scope?: string;
   config_json?: JSONObject;
 }) {
-  const { data } = await apiClient.post<ToolBinding>("/tools/bindings", {
-    binding_scope: "app",
-    ...payload,
+  const { data } = await apiClient.post<ApiResponse<ToolBindingDto>>("/tools/bindings/create", {
+    appId: payload.app_id,
+    toolVersionId: payload.tool_version_id,
+    credentialId: payload.credential_id,
+    bindingScope: payload.binding_scope ?? "app",
+    configJson: payload.config_json ?? {},
   });
-  return data;
+  return toLegacyBinding(unwrap(data));
 }
 
 export async function updateToolBinding(
@@ -109,22 +291,30 @@ export async function updateToolBinding(
     config_json?: JSONObject;
   }
 ) {
-  const { data } = await apiClient.patch<ToolBinding>(`/tools/bindings/${id}`, payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ToolBindingDto>>("/tools/bindings/update", {
+    bindingId: id,
+    credentialId: payload.credential_id,
+    bindingScope: payload.binding_scope,
+    configJson: payload.config_json,
+  });
+  return toLegacyBinding(unwrap(data));
 }
 
 export async function deleteToolBinding(id: string) {
-  await apiClient.delete(`/tools/bindings/${id}`);
+  await apiClient.post("/tools/bindings/delete", { bindingId: id });
 }
 
 export async function listToolCredentials() {
-  const { data } = await apiClient.get<ToolCredential[]>("/tools/credentials", { params: {} });
-  return data;
+  const items = await listAllPages<ToolCredentialDto>("/tools/credentials/list", {});
+  return items.map(toLegacyCredential);
 }
 
 export async function getToolCredential(id: string) {
-  const { data } = await apiClient.get<ToolCredential>(`/tools/credentials/${id}`);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ToolCredentialDto>>(
+    "/tools/credentials/detail",
+    { credentialId: id },
+  );
+  return toLegacyCredential(unwrap(data));
 }
 
 export async function createToolCredential(payload: {
@@ -133,8 +323,13 @@ export async function createToolCredential(payload: {
   secret_json: JSONObject;
   metadata_json?: JSONObject;
 }) {
-  const { data } = await apiClient.post<ToolCredential>("/tools/credentials", payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ToolCredentialDto>>("/tools/credentials/create", {
+    name: payload.name,
+    credentialType: payload.credential_type,
+    secretJson: payload.secret_json,
+    metadataJson: payload.metadata_json ?? {},
+  });
+  return toLegacyCredential(unwrap(data));
 }
 
 export async function updateToolCredential(
@@ -144,16 +339,30 @@ export async function updateToolCredential(
     metadata_json?: JSONObject;
   }
 ) {
-  const { data } = await apiClient.patch<ToolCredential>(`/tools/credentials/${id}`, payload);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ToolCredentialDto>>("/tools/credentials/update", {
+    credentialId: id,
+    name: payload.name,
+    metadataJson: payload.metadata_json,
+  });
+  return toLegacyCredential(unwrap(data));
 }
 
 export async function deleteToolCredential(id: string) {
-  await apiClient.delete(`/tools/credentials/${id}`);
+  await apiClient.post("/tools/credentials/delete", { credentialId: id });
 }
 
 export async function revealToolCredential(id: string) {
-  const { data } = await apiClient.post<{ secret_json: JSONObject }>(`/tools/credentials/${id}/reveal`);
-  return data;
+  const { data } = await apiClient.post<ApiResponse<ToolCredentialRevealDto>>(
+    "/tools/credentials/reveal",
+    { credentialId: id },
+  );
+  const result = unwrap(data);
+  return { secret_json: result.secretJson };
+}
+
+function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
+  return Object.fromEntries(
+    Object.entries(value).filter(([, item]) => item !== undefined && item !== null),
+  ) as Partial<T>;
 }
 

+ 12 - 2
web/src/components/layout/AppLayout.tsx

@@ -1,13 +1,15 @@
 import { Outlet } from "react-router-dom";
 import { useTranslation } from "react-i18next";
-import { Header } from "./Header";
+import { Menu } from "lucide-react";
 import { MobileSidebar, Sidebar } from "./Sidebar";
+import { Button } from "@/components/ui/button";
 import { cn } from "@/lib/utils";
 import { useUiStore } from "@/stores/ui";
 
 export function AppLayout() {
   const { t } = useTranslation();
   const density = useUiStore((state) => state.siteSettings.density);
+  const toggleMobileSidebar = useUiStore((state) => state.toggleMobileSidebar);
   return (
     <div className="flex h-dvh overflow-hidden bg-surface-base">
       <a href="#main-content" className="skip-link">
@@ -16,7 +18,6 @@ export function AppLayout() {
       <Sidebar />
       <MobileSidebar />
       <div className="min-w-0 flex-1 overflow-y-auto">
-        <Header />
         <main
           id="main-content"
           className={cn(
@@ -27,6 +28,15 @@ export function AppLayout() {
           <Outlet />
         </main>
       </div>
+      <Button
+        className="fixed bottom-4 left-4 z-40 shadow-glow md:hidden"
+        variant="default"
+        size="icon"
+        onClick={toggleMobileSidebar}
+        aria-label={t("nav.openNavigation")}
+      >
+        <Menu className="h-4 w-4" />
+      </Button>
     </div>
   );
 }

+ 0 - 190
web/src/components/layout/Header.tsx

@@ -1,190 +0,0 @@
-import * as React from "react";
-import { Check, ChevronDown, Globe2, LogOut, Menu, Moon, Settings, Sun } from "lucide-react";
-import { useNavigate } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import { Button } from "@/components/ui/button";
-import { Breadcrumb } from "./Breadcrumb";
-import { useAuthStore } from "@/stores/auth";
-import { useUiStore } from "@/stores/ui";
-import { mockMode } from "@/api/mock";
-import { LANGUAGE_LABELS, SUPPORTED_LANGUAGES, type SupportedLanguage } from "@/i18n";
-import { cn } from "@/lib/utils";
-
-export function Header() {
-  const { t } = useTranslation();
-  const navigate = useNavigate();
-  const { displayName, username, userId, logout } = useAuthStore();
-  const { theme, toggleTheme, toggleMobileSidebar, siteSettings, language, setLanguage } = useUiStore();
-  const operatorName = displayName || username || userId || "user";
-
-  return (
-    <header className="glass sticky top-0 z-30 flex h-16 items-center justify-between gap-3 px-4 md:px-6">
-      <div className="flex min-w-0 items-center gap-3">
-        <Button className="md:hidden" variant="ghost" size="icon" onClick={toggleMobileSidebar} aria-label={t("nav.openNavigation")}>
-          <Menu className="h-4 w-4" />
-        </Button>
-        <Breadcrumb />
-      </div>
-      <div className="flex shrink-0 items-center gap-2">
-        <StatusCluster showMock={mockMode && siteSettings.showMockBanner} gatewayLabel={t("auth.gateway")} mockLabel={t("auth.mockData")} />
-        <Button
-          variant="ghost"
-          size="icon"
-          onClick={toggleTheme}
-          aria-label={theme === "dark" ? t("theme.switchToLight") : t("theme.switchToDark")}
-        >
-          {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
-        </Button>
-        <UserMenu
-          operatorName={operatorName}
-          username={username}
-          language={language}
-          onLanguageChange={setLanguage}
-          onSettings={() => navigate("/settings")}
-          onLogout={() => {
-            logout();
-            navigate("/login", { replace: true });
-          }}
-        />
-      </div>
-    </header>
-  );
-}
-
-function StatusCluster({ showMock, gatewayLabel, mockLabel }: { showMock: boolean; gatewayLabel: string; mockLabel: string }) {
-  return (
-    <div className="hidden items-center overflow-hidden rounded-md border border-border bg-muted/35 text-xs text-muted-foreground sm:flex">
-      <div className="flex h-9 items-center gap-2 px-3">
-        <span className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.8)]" />
-        <span className="hidden lg:inline">{gatewayLabel}</span>
-      </div>
-      {showMock ? (
-        <div className="flex h-9 items-center border-l border-border bg-amber-500/10 px-3 text-amber-700 dark:text-amber-200">
-          {mockLabel}
-        </div>
-      ) : null}
-    </div>
-  );
-}
-
-function UserMenu({
-  operatorName,
-  username,
-  language,
-  onLanguageChange,
-  onSettings,
-  onLogout,
-}: {
-  operatorName: string;
-  username: string;
-  language: SupportedLanguage;
-  onLanguageChange: (language: SupportedLanguage) => void;
-  onSettings: () => void;
-  onLogout: () => void;
-}) {
-  const { t } = useTranslation();
-  const [open, setOpen] = React.useState(false);
-  const containerRef = React.useRef<HTMLDivElement>(null);
-  const initials = operatorName.trim().slice(0, 1).toUpperCase() || "U";
-
-  React.useEffect(() => {
-    function handlePointerDown(event: PointerEvent) {
-      if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
-    }
-    function handleKeyDown(event: KeyboardEvent) {
-      if (event.key === "Escape") setOpen(false);
-    }
-    document.addEventListener("pointerdown", handlePointerDown);
-    document.addEventListener("keydown", handleKeyDown);
-    return () => {
-      document.removeEventListener("pointerdown", handlePointerDown);
-      document.removeEventListener("keydown", handleKeyDown);
-    };
-  }, []);
-
-  return (
-    <div ref={containerRef} className="relative">
-      <button
-        type="button"
-        aria-haspopup="menu"
-        aria-expanded={open}
-        onClick={() => setOpen((value) => !value)}
-        className="flex min-h-11 touch-manipulation items-center gap-2 rounded-md border border-border bg-muted/35 px-2.5 text-sm transition hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
-      >
-        <span className="grid h-7 w-7 place-items-center rounded-md bg-primary/15 text-xs font-semibold text-primary">{initials}</span>
-        <span className="hidden max-w-36 truncate text-left md:block">
-          <span className="block truncate font-medium">{operatorName}</span>
-          {username && username !== operatorName ? <span className="block truncate text-xs text-muted-foreground">{username}</span> : null}
-        </span>
-        <ChevronDown className={cn("h-4 w-4 text-muted-foreground transition", open && "rotate-180")} />
-      </button>
-
-      {open ? (
-        <div
-          role="menu"
-          className="absolute right-0 top-full z-40 mt-2 w-72 rounded-md border border-border bg-surface-elevated p-2 shadow-glow"
-        >
-          <div className="border-b border-border px-3 py-2">
-            <div className="flex items-center gap-3">
-              <span className="grid h-9 w-9 place-items-center rounded-md bg-primary/15 text-sm font-semibold text-primary">{initials}</span>
-              <div className="min-w-0">
-                <p className="truncate text-sm font-medium">{operatorName}</p>
-                {username ? <p className="truncate text-xs text-muted-foreground">{username}</p> : null}
-              </div>
-            </div>
-          </div>
-
-          <div className="py-2">
-            <p className="px-3 py-1 text-xs font-medium text-muted-foreground">{t("settings.language")}</p>
-            {SUPPORTED_LANGUAGES.map((item) => (
-              <button
-                key={item}
-                type="button"
-                role="menuitemradio"
-                aria-checked={language === item}
-                onClick={() => {
-                  onLanguageChange(item);
-                  setOpen(false);
-                }}
-                className="flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-sm hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
-              >
-                <span className="flex items-center gap-2">
-                  <Globe2 className="h-4 w-4 text-muted-foreground" />
-                  {LANGUAGE_LABELS[item]}
-                </span>
-                {language === item ? <Check className="h-4 w-4 text-primary" /> : null}
-              </button>
-            ))}
-          </div>
-
-          <div className="border-t border-border pt-2">
-            <button
-              type="button"
-              role="menuitem"
-              onClick={() => {
-                setOpen(false);
-                onSettings();
-              }}
-              className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
-            >
-              <Settings className="h-4 w-4 text-muted-foreground" />
-              {t("settings.title")}
-            </button>
-            <button
-              type="button"
-              role="menuitem"
-              onClick={() => {
-                setOpen(false);
-                onLogout();
-              }}
-              className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-700 hover:bg-red-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary dark:text-red-300"
-            >
-              <LogOut className="h-4 w-4" />
-              {t("auth.logout")}
-            </button>
-          </div>
-        </div>
-      ) : null}
-    </div>
-  );
-}

+ 194 - 23
web/src/components/layout/Sidebar.tsx

@@ -1,10 +1,11 @@
-import { NavLink } from "react-router-dom";
-import { PanelLeftClose, PanelLeftOpen, Workflow } from "lucide-react";
+import { NavLink, useNavigate } from "react-router-dom";
+import { Globe2, LogOut, Moon, PanelLeftClose, PanelLeftOpen, Settings, Sun, Workflow } from "lucide-react";
 import { useTranslation } from "react-i18next";
 import { Button } from "@/components/ui/button";
 import { Separator } from "@/components/ui/separator";
-import { NAV_ITEMS } from "@/lib/constants";
+import { NAV_ITEMS, ROUTE_PATHS } from "@/lib/constants";
 import { cn } from "@/lib/utils";
+import { useAuthStore } from "@/stores/auth";
 import { useUiStore } from "@/stores/ui";
 
 function NavItems({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
@@ -18,22 +19,173 @@ function NavItems({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?:
           onClick={onNavigate}
           className={({ isActive }) =>
             cn(
-              "flex min-h-11 items-center gap-3 rounded-md px-3 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
-              isActive && "bg-primary/15 text-primary",
-              item.path === "/settings" && "mt-auto",
-              collapsed && "justify-center px-0",
+              "grid min-h-10 grid-cols-[2rem_minmax(0,1fr)] items-center gap-2 rounded-md px-2 text-sm text-muted-foreground transition duration-150 hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
+              isActive && "bg-muted text-foreground",
+              item.path === ROUTE_PATHS.memories && "mt-2",
+              item.path === ROUTE_PATHS.teams && "mt-2",
+              collapsed && "grid-cols-1 justify-items-center px-0",
             )
           }
           title={t(item.labelKey)}
         >
-          <item.icon className="h-4 w-4 shrink-0" />
-          {!collapsed ? <span>{t(item.labelKey)}</span> : null}
+          {({ isActive }) => (
+            <>
+              <span
+                className={cn(
+                  "grid h-8 w-8 shrink-0 place-items-center rounded-md transition",
+                  isActive ? "text-foreground" : "text-muted-foreground",
+                )}
+              >
+                <item.icon className="h-4 w-4" />
+              </span>
+              {!collapsed ? <span className="truncate">{t(item.labelKey)}</span> : null}
+            </>
+          )}
         </NavLink>
       ))}
     </>
   );
 }
 
+function SidebarUtilities({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
+  const { t } = useTranslation();
+  const { language, setLanguage, theme, toggleTheme } = useUiStore();
+  const themeLabel = theme === "dark" ? t("theme.switchToLight") : t("theme.switchToDark");
+  const nextLanguage = language === "en" ? "zh" : "en";
+  const languageLabel = t("settings.language");
+
+  if (collapsed) {
+    return (
+      <div className="mb-2 flex flex-col items-center gap-1 px-3">
+        <Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={themeLabel} title={themeLabel}>
+          {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
+        </Button>
+        <Button
+          variant="ghost"
+          size="icon"
+          onClick={() => setLanguage(nextLanguage)}
+          aria-label={languageLabel}
+          title={languageLabel}
+        >
+          <Globe2 className="h-4 w-4" />
+        </Button>
+        <NavLink
+          to={ROUTE_PATHS.settings}
+          onClick={onNavigate}
+          className={({ isActive }) =>
+            cn(
+              "grid h-11 w-11 place-items-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary sm:h-10 sm:w-10",
+              isActive && "bg-muted text-foreground",
+            )
+          }
+          aria-label={t("settings.title")}
+          title={t("settings.title")}
+        >
+          <Settings className="h-4 w-4" />
+        </NavLink>
+      </div>
+    );
+  }
+
+  return (
+    <div className="mb-2 flex items-center gap-1 px-3">
+      <Button
+        variant="ghost"
+        size="icon"
+        className="h-10 flex-1 px-0 text-muted-foreground"
+        onClick={toggleTheme}
+        aria-label={themeLabel}
+        title={themeLabel}
+      >
+        {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
+      </Button>
+      <Button
+        variant="ghost"
+        size="icon"
+        className="h-10 flex-1 px-0 text-muted-foreground"
+        onClick={() => setLanguage(nextLanguage)}
+        aria-label={languageLabel}
+        title={languageLabel}
+      >
+        <Globe2 className="h-4 w-4" />
+      </Button>
+      <NavLink
+        to={ROUTE_PATHS.settings}
+        onClick={onNavigate}
+        className={({ isActive }) =>
+          cn(
+            "grid h-10 flex-1 place-items-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
+            isActive && "bg-muted text-foreground",
+          )
+        }
+        aria-label={t("settings.title")}
+        title={t("settings.title")}
+      >
+        <Settings className="h-4 w-4" />
+      </NavLink>
+    </div>
+  );
+}
+
+function SidebarAccountFooter({ collapsed }: { collapsed?: boolean }) {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+  const { displayName, username, userId, logout } = useAuthStore();
+  const operatorName = displayName || username || userId || "user";
+  const operatorInitial = operatorName.trim().slice(0, 1).toUpperCase() || "U";
+
+  if (collapsed) {
+    return (
+      <div className="flex flex-col items-center gap-2">
+        <div
+          className="grid h-9 w-9 place-items-center rounded-md bg-muted text-sm font-semibold text-foreground"
+          title={operatorName}
+        >
+          {operatorInitial}
+        </div>
+        <Button
+          variant="ghost"
+          size="icon"
+          className="hover:bg-red-500/10 hover:text-red-700 dark:hover:text-red-300"
+          onClick={() => {
+            logout();
+            navigate("/login", { replace: true });
+          }}
+          aria-label={t("auth.logout")}
+          title={t("auth.logout")}
+        >
+          <LogOut className="h-4 w-4" />
+        </Button>
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex min-h-11 items-center gap-3 rounded-md px-3 py-2 transition hover:bg-muted">
+      <div className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-muted text-xs font-semibold text-foreground">
+        {operatorInitial}
+      </div>
+      <div className="min-w-0 flex-1">
+        <p className="truncate text-sm font-medium text-foreground">{operatorName}</p>
+        {username && username !== operatorName ? <p className="truncate text-xs text-muted-foreground">{username}</p> : null}
+      </div>
+      <Button
+        variant="ghost"
+        size="icon"
+        className="shrink-0 hover:bg-red-500/10 hover:text-red-700 dark:hover:text-red-300"
+        onClick={() => {
+          logout();
+          navigate("/login", { replace: true });
+        }}
+        aria-label={t("auth.logout")}
+        title={t("auth.logout")}
+      >
+        <LogOut className="h-4 w-4" />
+      </Button>
+    </div>
+  );
+}
+
 export function Sidebar() {
   const { t } = useTranslation();
   const { sidebarCollapsed, toggleSidebar, siteSettings } = useUiStore();
@@ -41,24 +193,36 @@ export function Sidebar() {
     <aside
       className={cn(
         "hidden h-dvh shrink-0 border-r border-border bg-surface-deep md:flex md:flex-col",
-        sidebarCollapsed ? "w-16" : "w-60")}
+        sidebarCollapsed ? "w-16" : "w-60",
+      )}
     >
-      <div className="flex h-16 items-center gap-3 px-4">
-        <div className="grid h-9 w-9 place-items-center rounded-md bg-primary/15 text-primary">
-          <Workflow className="h-5 w-5" />
-        </div>
-        {!sidebarCollapsed ? <span className="truncate text-sm font-semibold">{siteSettings.workspaceName || t("app.name")}</span> : null}
+      <div className={cn("flex h-16 items-center px-3", sidebarCollapsed ? "justify-center" : "gap-3")}>
+        {sidebarCollapsed ? (
+          <Button variant="ghost" size="icon" onClick={toggleSidebar} aria-label={t("common.expand")} title={t("common.expand")}>
+            <PanelLeftOpen className="h-4 w-4" />
+          </Button>
+        ) : (
+          <>
+            <div className="grid h-9 w-9 place-items-center rounded-md bg-muted text-foreground">
+              <Workflow className="h-5 w-5" />
+            </div>
+            <div className="min-w-0 flex-1">
+              <span className="block truncate text-sm font-semibold">{siteSettings.workspaceName || t("app.name")}</span>
+            </div>
+            <Button variant="ghost" size="icon" onClick={toggleSidebar} aria-label={t("nav.collapse")} title={t("nav.collapse")}>
+              <PanelLeftClose className="h-4 w-4" />
+            </Button>
+          </>
+        )}
       </div>
+      <SidebarUtilities collapsed={sidebarCollapsed} />
       <Separator />
-      <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-3">
+      <nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-3 py-2">
         <NavItems collapsed={sidebarCollapsed} />
       </nav>
       <Separator />
       <div className="p-3">
-        <Button variant="ghost" size={sidebarCollapsed ? "icon" : "md"} className="w-full" onClick={toggleSidebar}>
-          {sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
-          {!sidebarCollapsed ? t("nav.collapse") : null}
-        </Button>
+        <SidebarAccountFooter collapsed={sidebarCollapsed} />
       </div>
     </aside>
   );
@@ -77,15 +241,22 @@ export function MobileSidebar() {
       />
       <aside className="relative flex h-full w-[min(82vw,320px)] flex-col border-r border-border bg-surface-deep shadow-glow">
         <div className="flex h-16 items-center gap-3 px-4">
-          <div className="grid h-9 w-9 place-items-center rounded-md bg-primary/15 text-primary">
+          <div className="grid h-9 w-9 place-items-center rounded-md bg-muted text-foreground">
             <Workflow className="h-5 w-5" />
           </div>
-          <span className="truncate text-sm font-semibold">{siteSettings.workspaceName || t("app.name")}</span>
+          <div className="min-w-0">
+            <span className="block truncate text-sm font-semibold">{siteSettings.workspaceName || t("app.name")}</span>
+          </div>
         </div>
+        <SidebarUtilities onNavigate={() => setMobileSidebarOpen(false)} />
         <Separator />
-        <nav className="flex flex-1 flex-col gap-1 p-3">
+        <nav className="flex flex-1 flex-col gap-0.5 px-3 py-2">
           <NavItems onNavigate={() => setMobileSidebarOpen(false)} />
         </nav>
+        <Separator />
+        <div className="p-3">
+          <SidebarAccountFooter />
+        </div>
       </aside>
     </div>
   );

+ 3 - 3
web/src/hooks/useApps.ts

@@ -1,12 +1,12 @@
 import { useApi } from "./useApi";
-import { apiClient, tenantParams } from "@/api/client";
+import { apiClient } from "@/api/client";
 import type { AppResponse } from "@/types";
 
 async function listApps() {
-  const { data } = await apiClient.get<AppResponse[]>("/workflows/apps", { params: tenantParams() });
+  const { data } = await apiClient.get<AppResponse[]>("/workflows/apps");
   return data;
 }
 
 export function useApps() {
   return useApi(listApps, []);
-}
+}

+ 0 - 2
web/src/lib/constants.ts

@@ -6,7 +6,6 @@ import {
   LayoutDashboard,
   MessageSquare,
   Puzzle,
-  Settings,
   Users,
   Wrench,
   type LucideIcon,
@@ -36,7 +35,6 @@ export const NAV_ITEMS: Array<{ labelKey: string; path: string; icon: LucideIcon
   { labelKey: "nav.teams", path: ROUTE_PATHS.teams, icon: Users },
   { labelKey: "nav.skills", path: ROUTE_PATHS.skills, icon: Puzzle },
   { labelKey: "nav.models", path: ROUTE_PATHS.models, icon: BrainCircuit },
-  { labelKey: "nav.settings", path: ROUTE_PATHS.settings, icon: Settings },
 ];
 
 export const NODE_TYPE_CONFIG = {

+ 35 - 1
web/src/locales/en.json

@@ -386,6 +386,29 @@
     "skillsBadge": "{{count}} skills",
     "testAgent": "Test Agent",
     "testAgentDescription": "Validate {{name}} and inspect recent execution history.",
+    "defaultTestInput": "Please introduce what you can do.",
+    "debugConsole": "Test Console",
+    "debugConsoleRealRunHint": "By default this calls the selected model and executes a real agent run.",
+    "debugConsoleDryRunHint": "Link-debug mode is on: it creates and executes a run, but does not call the model.",
+    "dryRun": "Link debug",
+    "dryRunHint": "When enabled, this verifies prompt, memory, skills, and tool selection without calling the model.",
+    "dryRunMode": "Link debug, no model call",
+    "realRunMode": "Real execution, model call",
+    "runRealTest": "Test with model",
+    "runDryTest": "Debug link",
+    "testing": "Testing",
+    "testCompleted": "Test completed",
+    "testFailed": "Test failed",
+    "testInputPlaceholder": "Enter the test message you want to send to this agent",
+    "noRunnableConfig": "Save the agent model configuration before testing.",
+    "configReady": "Configuration ready",
+    "configRequired": "Save configuration first",
+    "noTestResult": "No test result yet",
+    "runTestToInspect": "Run a test to inspect input, output, status, and debug details.",
+    "notRecorded": "Not recorded",
+    "duration": "Duration",
+    "input": "Input",
+    "error": "Error",
     "deleteAgent": "Delete Agent",
     "deleteConfirm": "Are you sure you want to delete \"{{name}}\"? This will also delete its configuration and runs.",
     "valueLabels": {
@@ -756,7 +779,6 @@
     "humanRelevanceFeedback": "Human relevance feedback",
     "regressionComparison": "Regression comparison",
     "archiveRestoreBase": "Archive / restore base",
-    "tenantIsolation": "Tenant isolation",
     "documentAclPii": "Document ACL / PII rules",
     "agentBinding": "Agent binding",
     "workflowRetrievalNode": "Workflow retrieval node",
@@ -925,6 +947,8 @@
     "failureMode": "Failure Mode",
     "supervisor": "Supervisor",
     "worker": "Worker",
+    "specialist": "Specialist",
+    "executor": "Executor",
     "reviewer": "Reviewer",
     "planner": "Planner",
     "roundRobin": "Round Robin",
@@ -947,12 +971,16 @@
     "parallel": "Parallel",
     "teamCreated": "Team created",
     "failedCreateTeam": "Failed to create team",
+    "editTeam": "Edit Team",
+    "teamUpdated": "Team updated",
+    "failedUpdateTeam": "Failed to update team",
     "createTeam": "Create Team",
     "startRun": "Start Run",
     "starting": "Starting...",
     "startRunPreview": "Start a run to preview the newest result for this team.",
     "teamRunStarted": "Team run started",
     "failedStartTeamRun": "Failed to start team run",
+    "continueRun": "Continue run",
     "startTeamRunTitle": "Start Team Run",
     "runInput": "Run input",
     "runConsole": "Run Console",
@@ -973,6 +1001,9 @@
     "notSet": "Not set",
     "latest": "Latest",
     "latestResult": "Latest Result",
+    "memberRuns": "{{count}} member runs",
+    "teamConversation": "Team conversation",
+    "memberSaid": "Said",
     "none": "None",
     "lastRun": "Last Run",
     "currentObjective": "Current Objective",
@@ -1222,6 +1253,9 @@
     "providerAnthropic": "Anthropic",
     "providerDeepSeek": "DeepSeek",
     "providerCustom": "Custom",
+    "providerConnection": "Provider connection",
+    "manualConnection": "Manual connection",
+    "usingSharedProvider": "Using shared provider connection",
     "capability": {
       "chat": "Chat",
       "tools": "Tools",

+ 35 - 1
web/src/locales/zh.json

@@ -386,6 +386,29 @@
     "skillsBadge": "{{count}} 个技能",
     "testAgent": "测试智能体",
     "testAgentDescription": "验证 {{name}} 并查看最近执行记录。",
+    "defaultTestInput": "请介绍一下你能做什么。",
+    "debugConsole": "测试控制台",
+    "debugConsoleRealRunHint": "默认会真实调用已选择模型,创建并执行一次智能体运行。",
+    "debugConsoleDryRunHint": "当前是链路调试模式:会创建并执行运行,但不会调用模型。",
+    "dryRun": "链路调试",
+    "dryRunHint": "开启后只验证 Prompt、记忆、技能和工具选择链路,不调用模型。",
+    "dryRunMode": "链路调试,不调用模型",
+    "realRunMode": "真实执行,调用模型",
+    "runRealTest": "调用模型测试",
+    "runDryTest": "调试链路",
+    "testing": "测试中",
+    "testCompleted": "测试完成",
+    "testFailed": "测试失败",
+    "testInputPlaceholder": "输入你想发送给智能体的测试消息",
+    "noRunnableConfig": "请先保存智能体模型配置后再测试。",
+    "configReady": "配置已就绪",
+    "configRequired": "需要先保存配置",
+    "noTestResult": "还没有测试结果",
+    "runTestToInspect": "运行一次测试后,这里会显示输入、输出、状态和调试信息。",
+    "notRecorded": "未记录",
+    "duration": "耗时",
+    "input": "输入",
+    "error": "错误",
     "deleteAgent": "删除智能体",
     "deleteConfirm": "确定要删除“{{name}}”吗?这也会删除它的配置和运行记录。",
     "valueLabels": {
@@ -756,7 +779,6 @@
     "humanRelevanceFeedback": "人工相关性反馈",
     "regressionComparison": "回归对比",
     "archiveRestoreBase": "归档 / 恢复知识库",
-    "tenantIsolation": "租户隔离",
     "documentAclPii": "文档 ACL / PII 规则",
     "agentBinding": "智能体绑定",
     "workflowRetrievalNode": "工作流检索节点",
@@ -925,6 +947,8 @@
     "failureMode": "失败处理",
     "supervisor": "主管",
     "worker": "执行者",
+    "specialist": "专家",
+    "executor": "执行者",
     "reviewer": "审核者",
     "planner": "规划者",
     "roundRobin": "轮询",
@@ -947,12 +971,16 @@
     "parallel": "并行",
     "teamCreated": "团队已创建",
     "failedCreateTeam": "创建团队失败",
+    "editTeam": "编辑团队",
+    "teamUpdated": "团队已更新",
+    "failedUpdateTeam": "更新团队失败",
     "createTeam": "创建团队",
     "startRun": "开始运行",
     "starting": "启动中...",
     "startRunPreview": "启动一次运行以预览该团队的最新结果。",
     "teamRunStarted": "团队运行已启动",
     "failedStartTeamRun": "启动团队运行失败",
+    "continueRun": "继续执行",
     "startTeamRunTitle": "启动团队运行",
     "runInput": "运行输入",
     "runConsole": "运行控制台",
@@ -973,6 +1001,9 @@
     "notSet": "未设置",
     "latest": "最新",
     "latestResult": "最新结果",
+    "memberRuns": "{{count}} 个成员运行",
+    "teamConversation": "团队对话",
+    "memberSaid": "发言",
     "none": "无",
     "lastRun": "最近运行",
     "currentObjective": "当前目标",
@@ -1222,6 +1253,9 @@
     "providerAnthropic": "Anthropic",
     "providerDeepSeek": "DeepSeek",
     "providerCustom": "自定义",
+    "providerConnection": "供应商连接",
+    "manualConnection": "手动配置连接",
+    "usingSharedProvider": "正在使用共享供应商连接",
     "capability": {
       "chat": "对话",
       "tools": "工具调用",

+ 152 - 75
web/src/pages/agents/AgentListPage.tsx

@@ -2,13 +2,12 @@ import * as React from "react";
 import { useTranslation } from "react-i18next";
 import {
   Bot,
-  Play,
   RefreshCw,
   Search,
   Sparkles,
   Trash2,
 } from "lucide-react";
-import { createAgentConfig, deleteAgent, listAgentConfigs, listAgentRuns, listModelProviders, listSkills, updateAgent } from "@/api";
+import { createAgentConfig, deleteAgent, listAgentConfigs, listAgentRuns, listModels, listSkills, updateAgent } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
 import { EmptyState } from "@/components/shared/EmptyState";
@@ -24,7 +23,7 @@ import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
 import { demoText } from "@/lib/demo-text";
 import { useAgentList } from "@/hooks";
-import type { AgentConfig, AgentDefinition, AgentRun, JSONObject, ModelProvider, SkillDefinition } from "@/types";
+import type { AgentConfig, AgentDefinition, AgentRun, JSONObject, ModelDefinition, SkillDefinition } from "@/types";
 import { AgentOverview } from "./components/AgentOverview";
 import { AgentRuns } from "./components/AgentRuns";
 import { CreateAgentDialog } from "./components/CreateAgentDialog";
@@ -37,9 +36,10 @@ export function AgentListPage() {
   const [runs, setRuns] = React.useState<AgentRun[]>([]);
   const [skills, setSkills] = React.useState<SkillDefinition[]>([]);
   const [relatedLoading, setRelatedLoading] = React.useState(true);
+  const [directoryLoading, setDirectoryLoading] = React.useState(false);
   const [editOpen, setEditOpen] = React.useState(false);
   const [deleteOpen, setDeleteOpen] = React.useState(false);
-  const [runsOpen, setRunsOpen] = React.useState(false);
+  const [detailTab, setDetailTab] = React.useState<"overview" | "test">("overview");
   const agents = useAgentList();
 
   const agentList = agents.data ?? [];
@@ -48,7 +48,7 @@ export function AgentListPage() {
   const selectedRuns = runs
     .filter((r) => r.agent_id === selectedAgent?.id)
     .sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime());
-  const activeConfig = [...selectedConfigs].sort((a, b) => b.version_no - a.version_no)[0];
+  const activeConfig = [...selectedConfigs].sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime())[0];
   const configByAgent = React.useMemo(() => {
     const grouped = new Map<string, AgentConfig[]>();
     configs.forEach((config) => {
@@ -56,11 +56,18 @@ export function AgentListPage() {
     });
     const result = new Map<string, AgentConfig>();
     grouped.forEach((items, agentId) => {
-      const config = [...items].sort((a, b) => b.version_no - a.version_no)[0];
+      const config = [...items].sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime())[0];
       if (config) result.set(agentId, config);
     });
     return result;
   }, [configs]);
+  const runsByAgent = React.useMemo(() => {
+    const result = new Map<string, AgentRun[]>();
+    runs.forEach((run) => {
+      result.set(run.agent_id, [...(result.get(run.agent_id) ?? []), run]);
+    });
+    return result;
+  }, [runs]);
 
   const filtered = agentList.filter((agent) => {
     const text = `${agent.name} ${agent.description ?? ""}`.toLowerCase();
@@ -75,14 +82,14 @@ export function AgentListPage() {
     }
   }, []);
 
-  const loadRelated = React.useCallback(async (agentId?: string) => {
+  const loadRelated = React.useCallback(async (agentId?: string, options?: { silent?: boolean }) => {
     if (!agentId) {
       setConfigs([]);
       setRuns([]);
       setRelatedLoading(false);
       return;
     }
-    setRelatedLoading(true);
+    if (!options?.silent) setRelatedLoading(true);
     try {
       const [configData, runData] = await Promise.all([listAgentConfigs(agentId), listAgentRuns(agentId)]);
       setConfigs((current) => [...current.filter((config) => config.agent_id !== agentId), ...configData]);
@@ -90,13 +97,38 @@ export function AgentListPage() {
     } catch {
       toast.error(t("errors.failedToLoad"));
     } finally {
-      setRelatedLoading(false);
+      if (!options?.silent) setRelatedLoading(false);
     }
   }, [t]);
 
+  const loadDirectorySummaries = React.useCallback(async (agentIds: string[]) => {
+    if (!agentIds.length) return;
+    setDirectoryLoading(true);
+    try {
+      const [configResults, runResults] = await Promise.all([
+        Promise.allSettled(agentIds.map((agentId) => listAgentConfigs(agentId))),
+        Promise.allSettled(agentIds.map((agentId) => listAgentRuns(agentId))),
+      ]);
+      const nextConfigs = configResults.flatMap((result) => result.status === "fulfilled" ? result.value : []);
+      const nextRuns = runResults.flatMap((result) => result.status === "fulfilled" ? result.value : []);
+      setConfigs((current) => [
+        ...current.filter((config) => !agentIds.includes(config.agent_id)),
+        ...nextConfigs,
+      ]);
+      setRuns((current) => [
+        ...current.filter((run) => !agentIds.includes(run.agent_id)),
+        ...nextRuns,
+      ]);
+    } finally {
+      setDirectoryLoading(false);
+    }
+  }, []);
+
   React.useEffect(() => { void loadSkills(); }, [loadSkills]);
+  React.useEffect(() => { void loadDirectorySummaries(agentList.map((agent) => agent.id)); }, [agentList, loadDirectorySummaries]);
   React.useEffect(() => { void loadRelated(selectedAgent?.id); }, [loadRelated, selectedAgent?.id]);
   React.useEffect(() => { if (!selectedAgentId && agentList[0]) setSelectedAgentId(agentList[0].id); }, [agentList, selectedAgentId]);
+  React.useEffect(() => { setDetailTab("overview"); }, [selectedAgent?.id]);
 
   async function handleDelete() {
     if (!selectedAgent) return;
@@ -135,7 +167,9 @@ export function AgentListPage() {
             <div className="flex items-start justify-between gap-3">
               <div>
                 <CardTitle>{t("agents.agentDirectory")}</CardTitle>
-                <CardDescription>{t("agents.shown", { shown: filtered.length, total: agentList.length })}</CardDescription>
+                <CardDescription>
+                  {directoryLoading ? t("common.loading") : t("agents.shown", { shown: filtered.length, total: agentList.length })}
+                </CardDescription>
               </div>
               <Badge className="border-primary/20 bg-primary/10 text-primary">{agentList.length}</Badge>
             </div>
@@ -150,7 +184,7 @@ export function AgentListPage() {
                     active={agent.id === selectedAgent?.id}
                     agent={agent}
                     config={configByAgent.get(agent.id)}
-                    runCount={runs.filter((item) => item.agent_id === agent.id).length}
+                    runs={runsByAgent.get(agent.id) ?? []}
                     onClick={() => setSelectedAgentId(agent.id)}
                   />
                 ))}
@@ -175,11 +209,6 @@ export function AgentListPage() {
               </div>
               {selectedAgent && (
                 <div className="flex shrink-0 items-center gap-2">
-                  {activeConfig && (
-                    <Button size="sm" variant="outline" onClick={() => setRunsOpen(true)}>
-                      <Play className="h-3 w-3 mr-1" /> {t("agents.test")}
-                    </Button>
-                  )}
                   <Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
                     {t("common.edit")}
                   </Button>
@@ -190,15 +219,50 @@ export function AgentListPage() {
               )}
             </div>
           </CardHeader>
-          <CardContent className="pt-4">
+          <CardContent className="min-w-0 pt-4">
             {selectedAgent ? (
-              <AgentOverview
-                agent={selectedAgent}
-                activeConfig={activeConfig}
-                skills={skills}
-                runCount={selectedRuns.length}
-                failedRunCount={selectedRuns.filter((r) => r.status === "failed").length}
-              />
+              <div className="min-w-0 space-y-4">
+                <div className="grid rounded-lg border border-border bg-muted/25 p-1 sm:w-fit sm:grid-cols-2">
+                  <button
+                    type="button"
+                    onClick={() => setDetailTab("overview")}
+                    className={[
+                      "rounded-md px-4 py-2 text-sm font-medium transition",
+                      detailTab === "overview" ? "bg-surface text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
+                    ].join(" ")}
+                  >
+                    {t("agents.overview")}
+                  </button>
+                  <button
+                    type="button"
+                    onClick={() => setDetailTab("test")}
+                    className={[
+                      "rounded-md px-4 py-2 text-sm font-medium transition",
+                      detailTab === "test" ? "bg-surface text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
+                    ].join(" ")}
+                  >
+                    {t("agents.test")}
+                  </button>
+                </div>
+
+                {detailTab === "overview" ? (
+                  <AgentOverview
+                    agent={selectedAgent}
+                    activeConfig={activeConfig}
+                    skills={skills}
+                    runCount={selectedRuns.length}
+                    failedRunCount={selectedRuns.filter((r) => r.status === "failed").length}
+                  />
+                ) : (
+                  <AgentRuns
+                    agentId={selectedAgent.id}
+                    agentConfigId={activeConfig?.id}
+                    runs={selectedRuns}
+                    loading={relatedLoading}
+                    onRunCompleted={() => void loadRelated(selectedAgent.id, { silent: true })}
+                  />
+                )}
+              </div>
             ) : (
               <EmptyState icon={Bot} title={t("agents.noAgents")} description={t("agents.createAgentStart")} />
             )}
@@ -215,16 +279,6 @@ export function AgentListPage() {
           onSaved={() => { void agents.refetch(); void loadRelated(selectedAgent.id); }}
         />
       )}
-      {selectedAgent && (
-        <Dialog open={runsOpen} onOpenChange={setRunsOpen} title={t("agents.testAgent")} className="max-w-5xl">
-          <div className="space-y-4">
-            <p className="text-sm text-muted-foreground">
-              {t("agents.testAgentDescription", { name: demoText(selectedAgent.name, t) })}
-            </p>
-            <AgentRuns agentId={selectedAgent.id} runs={selectedRuns} loading={relatedLoading} />
-          </div>
-        </Dialog>
-      )}
       <ConfirmDialog
         open={deleteOpen}
         onOpenChange={setDeleteOpen}
@@ -240,18 +294,21 @@ function AgentDirectoryRow({
   agent,
   config,
   active,
-  runCount,
+  runs,
   onClick,
 }: {
   agent: AgentDefinition;
   config?: AgentConfig;
   active: boolean;
-  runCount: number;
+  runs: AgentRun[];
   onClick: () => void;
 }) {
   const { t } = useTranslation();
   const model = config ? formatAgentModel(config, t) : t("agents.noModelSelected");
+  const provider = config ? formatAgentProvider(config, t) : t("agents.providerNotSet");
   const skillCount = config?.skill_refs_json.length ?? 0;
+  const failedCount = runs.filter((run) => run.status === "failed").length;
+  const latestRun = [...runs].sort((a, b) => new Date(b.created_time).getTime() - new Date(a.created_time).getTime())[0];
   return (
     <button
       type="button"
@@ -263,11 +320,26 @@ function AgentDirectoryRow({
     >
       <span className="min-w-0">
         <span className="block truncate text-sm font-medium">{demoText(agent.name, t)}</span>
-        <span className="mt-1 block truncate text-xs text-muted-foreground">{model}</span>
+        <span className="mt-1 block truncate text-xs text-muted-foreground">
+          {model}
+          {provider ? ` · ${provider}` : ""}
+        </span>
         <span className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
-          <span>{t("agents.runsBadge", { count: runCount })}</span>
+          <span>{t("agents.runsBadge", { count: runs.length })}</span>
           <span className="text-border">|</span>
           <span>{t("agents.skillsBadge", { count: skillCount })}</span>
+          {failedCount ? (
+            <>
+              <span className="text-border">|</span>
+              <span>{t("agents.failedRuns")}: {failedCount}</span>
+            </>
+          ) : null}
+          {latestRun ? (
+            <>
+              <span className="text-border">|</span>
+              <span>{t("agents.latest")}: {readableAgentValue(latestRun.status, t)}</span>
+            </>
+          ) : null}
         </span>
       </span>
     </button>
@@ -279,6 +351,16 @@ function formatAgentModel(config: AgentConfig, t: ReturnType<typeof useTranslati
   return typeof value === "string" && value ? value : t("agents.noModelSelected");
 }
 
+function formatAgentProvider(config: AgentConfig, t: ReturnType<typeof useTranslation>["t"]) {
+  const value = config.model_config_json.provider;
+  return typeof value === "string" && value ? readableAgentValue(value, t) : t("agents.providerNotSet");
+}
+
+function readableAgentValue(value: string, t: ReturnType<typeof useTranslation>["t"]) {
+  const fallback = value.split(/[_-]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
+  return t(`agents.valueLabels.${value}`, fallback);
+}
+
 function EditAgentDialog({
   agent,
   activeConfig,
@@ -295,9 +377,8 @@ function EditAgentDialog({
   const { t } = useTranslation();
   const [name, setName] = React.useState(agent.name);
   const [systemPrompt, setSystemPrompt] = React.useState(activeConfig?.system_prompt ?? "");
-  const [modelProviders, setModelProviders] = React.useState<ModelProvider[]>([]);
-  const [selectedProviderId, setSelectedProviderId] = React.useState("");
-  const [selectedModel, setSelectedModel] = React.useState("");
+  const [models, setModels] = React.useState<ModelDefinition[]>([]);
+  const [selectedModelId, setSelectedModelId] = React.useState("");
   const [availableSkills, setAvailableSkills] = React.useState<SkillDefinition[]>([]);
   const [selectedSkillIds, setSelectedSkillIds] = React.useState<string[]>([]);
   const [skillsLoading, setSkillsLoading] = React.useState(false);
@@ -314,25 +395,26 @@ function EditAgentDialog({
   const [humanApprovalPolicy, setHumanApprovalPolicy] = React.useState("never");
   const [submitting, setSubmitting] = React.useState(false);
 
-  const currentProvider = modelProviders.find((provider) => provider.id === selectedProviderId);
+  const currentModel = models.find((model) => model.id === selectedModelId);
   const modelOptions = React.useMemo(() => {
-    return modelProviders.flatMap((provider) =>
-      provider.models
-        .filter((model) => model.model_type === "chat" || model.model_type === "reasoning")
-        .map((model) => ({
-          value: `${provider.id}:${model.model_id}`,
-          label: `${model.display_name} - ${provider.name}`,
-        }))
-    );
-  }, [modelProviders]);
+    return models
+      .filter((model) => {
+        const capabilities = model.capabilities_json ?? [];
+        return capabilities.length === 0 || capabilities.includes("chat") || capabilities.includes("reasoning");
+      })
+      .map((model) => ({
+        value: model.id,
+        label: `${model.name} - ${model.model_name}`,
+      }));
+  }, [models]);
 
   React.useEffect(() => {
     if (!open) return;
     setName(agent.name);
     hydrateFromConfig(activeConfig);
-    void listModelProviders().then((providers) => {
-      setModelProviders(providers);
-      hydrateModelSelection(providers, activeConfig);
+    void listModels().then((items) => {
+      setModels(items);
+      hydrateModelSelection(items, activeConfig);
     });
     setSkillsLoading(true);
     setSkillsError(null);
@@ -368,22 +450,23 @@ function EditAgentDialog({
     setHumanApprovalPolicy(getString(runtimePolicy, "human_approval_policy") ?? "never");
   }
 
-  function hydrateModelSelection(providers: ModelProvider[], config?: AgentConfig) {
+  function hydrateModelSelection(items: ModelDefinition[], config?: AgentConfig) {
     const modelConfig = config?.model_config_json ?? {};
+    const modelDefinitionId = getString(modelConfig, "model_id");
     const providerType = getString(modelConfig, "provider");
-    const modelId = getString(modelConfig, "model");
-    const matchedProvider = providers.find((provider) =>
-      provider.provider_type === providerType && provider.models.some((model) => model.model_id === modelId)
+    const modelName = getString(modelConfig, "model");
+    const matchedModel = items.find((model) =>
+      model.id === modelDefinitionId || (model.provider_type === providerType && model.model_name === modelName)
     );
-    if (matchedProvider && modelId) {
-      setSelectedProviderId(matchedProvider.id);
-      setSelectedModel(`${matchedProvider.id}:${modelId}`);
+    if (matchedModel) {
+      setSelectedModelId(matchedModel.id);
       return;
     }
-    const firstProvider = providers[0];
-    const firstModel = firstProvider?.models.find((model) => model.model_type === "chat" || model.model_type === "reasoning");
-    setSelectedProviderId(firstProvider?.id ?? "");
-    setSelectedModel(firstProvider && firstModel ? `${firstProvider.id}:${firstModel.model_id}` : "");
+    const firstModel = items.find((model) => {
+      const capabilities = model.capabilities_json ?? [];
+      return capabilities.length === 0 || capabilities.includes("chat") || capabilities.includes("reasoning");
+    });
+    setSelectedModelId(firstModel?.id ?? "");
   }
 
   function toggleSkill(skillId: string) {
@@ -392,12 +475,6 @@ function EditAgentDialog({
     );
   }
 
-  function selectModel(value: string) {
-    const [providerId] = value.split(":");
-    setSelectedProviderId(providerId ?? "");
-    setSelectedModel(value);
-  }
-
   async function submit(event: React.FormEvent) {
     event.preventDefault();
     setSubmitting(true);
@@ -408,8 +485,9 @@ function EditAgentDialog({
         role: "assistant",
         system_prompt: systemPrompt,
         model_config_json: {
-          provider: currentProvider?.provider_type ?? getString(activeConfig?.model_config_json ?? {}, "provider") ?? "openai",
-          model: selectedModel.split(":").slice(1).join(":") || selectedModel,
+          model_id: currentModel?.id ?? null,
+          provider: currentModel?.provider_type ?? getString(activeConfig?.model_config_json ?? {}, "provider") ?? "openai",
+          model: currentModel?.model_name ?? getString(activeConfig?.model_config_json ?? {}, "model") ?? "",
           temperature: parseOptionalFloat(temperature) ?? 0.7,
           max_tokens: parseOptionalInteger(maxTokens) ?? 4096,
           timeout_seconds: parseOptionalInteger(timeoutSeconds) ?? 60,
@@ -429,7 +507,6 @@ function EditAgentDialog({
         },
         tool_refs_json: activeConfig?.tool_refs_json ?? [],
         skill_refs_json: selectedSkillIds.map((skillId) => ({ skill_id: skillId })),
-        status: "draft",
       });
       toast.success(t("agents.agentUpdated"));
       onOpenChange(false);
@@ -504,7 +581,7 @@ function EditAgentDialog({
 
             <div className="space-y-4">
               <Field label={t("agents.model")}>
-                <Select value={selectedModel} onChange={(event) => selectModel(event.target.value)} options={modelOptions} />
+                <Select value={selectedModelId} onChange={(event) => setSelectedModelId(event.target.value)} options={modelOptions} />
               </Field>
               <div className="grid gap-3 sm:grid-cols-2">
                 <Field label={t("agents.temperature")}>

+ 242 - 88
web/src/pages/agents/components/AgentRuns.tsx

@@ -1,132 +1,286 @@
 import * as React from "react";
 import { useTranslation } from "react-i18next";
-import { Activity, Clock, Play } from "lucide-react";
+import { Activity, AlertCircle, CheckCircle2, Clock, Loader2, Play, TerminalSquare } from "lucide-react";
+import { createAgentRun, executeAgentRun } from "@/api";
 import { EmptyState } from "@/components/shared/EmptyState";
-import { JsonViewer } from "@/components/shared/JsonViewer";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/input";
 import { Select } from "@/components/ui/select";
+import { toast } from "@/components/ui/toaster";
 import type { AgentRun, AgentRunStatus } from "@/types";
-import { relativeTime, truncateMiddle } from "@/lib/utils";
+import { formatDateTime, relativeTime, truncateMiddle } from "@/lib/utils";
 
 type RunStatusFilter = "all" | AgentRunStatus;
 
 export function AgentRuns({
   agentId,
+  agentConfigId,
   runs,
   loading,
+  onRunCompleted,
 }: {
   agentId: string;
+  agentConfigId?: string;
   runs: AgentRun[];
   loading: boolean;
+  onRunCompleted?: () => void;
 }) {
   const { t } = useTranslation();
-  const [testInput, setTestInput] = React.useState("");
+  const [testInput, setTestInput] = React.useState(() => t("agents.defaultTestInput"));
+  const [dryRun, setDryRun] = React.useState(false);
+  const [executing, setExecuting] = React.useState(false);
   const [testResult, setTestResult] = React.useState<AgentRun | undefined>();
+  const [executionMeta, setExecutionMeta] = React.useState<{ model?: string | null; dryRun?: boolean; durationMs?: number }>();
   const [statusFilter, setStatusFilter] = React.useState<RunStatusFilter>("all");
 
-  function simulateRun() {
-    const timestamp = new Date().toISOString();
-    setTestResult({
-      id: `local_${Date.now().toString(36)}`,
-      agent_id: agentId,
-      agent_version_id: "",
-      session_id: null,
-      input_text: testInput,
-      input_json: null,
-      output_text: `Simulated response. Input: ${testInput.slice(0, 100)}`,
-      output_json: { simulated: true },
-      status: "completed",
-      worker_key: "frontend-simulator",
-      queued_time: timestamp,
-      lease_expire_time: null,
-      started_time: timestamp,
-      finished_time: timestamp,
-      error_message: null,
-      created_time: timestamp,
-    });
+  async function runTest() {
+    if (!testInput.trim()) return;
+    if (!agentConfigId) {
+      toast.error(t("agents.noRunnableConfig"));
+      return;
+    }
+
+    setExecuting(true);
+    setExecutionMeta(undefined);
+    const startedAt = performance.now();
+    try {
+      const run = await createAgentRun({
+        agent_id: agentId,
+        agent_config_id: agentConfigId,
+        input_text: testInput.trim(),
+      });
+      setTestResult(run);
+
+      const executed = await executeAgentRun(run.id, {
+        worker_key: "web-debugger",
+        dry_run: dryRun,
+      });
+      setTestResult(executed.run);
+      setExecutionMeta({
+        model: executed.model ?? readString(executed.run.output_json, "model"),
+        dryRun: executed.dry_run,
+        durationMs: Math.round(performance.now() - startedAt),
+      });
+      onRunCompleted?.();
+      toast.success(t("agents.testCompleted"));
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : t("agents.testFailed"));
+    } finally {
+      setExecuting(false);
+    }
   }
 
-  if (loading) return <LoadingSpinner label={t("common.loading")} />;
+  const visibleRuns = React.useMemo(() => {
+    if (!testResult) return runs;
+    const hasResult = runs.some((run) => run.id === testResult.id);
+    return hasResult ? runs.map((run) => (run.id === testResult.id ? testResult : run)) : [testResult, ...runs];
+  }, [runs, testResult]);
+  const showInitialLoading = loading && visibleRuns.length === 0 && !testResult;
 
-  const filteredRuns = runs.filter((run) => statusFilter === "all" || run.status === statusFilter);
+  if (showInitialLoading) return <LoadingSpinner label={t("common.loading")} />;
+
+  const filteredRuns = visibleRuns.filter((run) => statusFilter === "all" || run.status === statusFilter);
+  const currentResult = testResult ?? filteredRuns[0];
 
   return (
-    <div className="space-y-6">
-      <div className="grid gap-4 lg:grid-cols-[1fr_360px]">
-        <div className="space-y-3">
-          <label className="block space-y-2 text-sm">
+    <div className="grid min-w-0 gap-5 2xl:grid-cols-[minmax(0,1fr)_340px]">
+      <section className="min-w-0 space-y-4">
+        <div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
+          <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
+            <div>
+              <div className="flex items-center gap-2">
+                <TerminalSquare className="h-4 w-4 text-primary" />
+                <h3 className="text-sm font-semibold">{t("agents.debugConsole")}</h3>
+              </div>
+              <p className="mt-1 text-sm text-muted-foreground">
+                {dryRun ? t("agents.debugConsoleDryRunHint") : t("agents.debugConsoleRealRunHint")}
+              </p>
+            </div>
+            <label
+              className="flex min-h-11 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 text-sm"
+              title={t("agents.dryRunHint")}
+            >
+              <input
+                type="checkbox"
+                checked={dryRun}
+                onChange={(event) => setDryRun(event.target.checked)}
+                className="h-4 w-4 accent-primary"
+              />
+              <span>{t("agents.dryRun")}</span>
+            </label>
+          </div>
+
+          <label className="mt-4 block space-y-2 text-sm">
             <span className="text-muted-foreground">{t("agents.testInput")}</span>
-            <textarea
-              className="min-h-32 w-full rounded-md border border-border bg-surface-elevated px-3 py-2 text-base text-foreground outline-none focus:border-primary/70 focus:ring-2 focus:ring-primary/20 sm:text-sm"
+            <Textarea
+              className="min-h-36 resize-y bg-surface-elevated"
               value={testInput}
               onChange={(event) => setTestInput(event.target.value)}
+              placeholder={t("agents.testInputPlaceholder")}
             />
           </label>
-          <Button size="sm" onClick={simulateRun} disabled={!testInput.trim()}>
-            <Play className="h-4 w-4" /> {t("agents.simulateRun")}
-          </Button>
+
+          <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
+            <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
+              <Badge className={dryRun ? "border-amber-500/25 bg-amber-500/10 text-amber-700" : "border-emerald-500/25 bg-emerald-500/10 text-emerald-700"}>
+                {dryRun ? t("agents.dryRunMode") : t("agents.realRunMode")}
+              </Badge>
+              {agentConfigId ? (
+                <span>{t("agents.configReady")}</span>
+              ) : (
+                <span className="text-amber-600">{t("agents.configRequired")}</span>
+              )}
+            </div>
+            <Button onClick={() => void runTest()} disabled={executing || !testInput.trim() || !agentConfigId}>
+              {executing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
+              {executing ? t("agents.testing") : dryRun ? t("agents.runDryTest") : t("agents.runRealTest")}
+            </Button>
+          </div>
         </div>
-        <div className="rounded-md border border-border bg-muted/30 p-4">
-          <div className="mb-3 flex items-center gap-2">
-            <Clock className="h-4 w-4 text-muted-foreground" />
-            <h3 className="text-sm font-semibold">{t("agents.resultPreview")}</h3>
+
+        <ResultPanel run={currentResult} meta={executionMeta} />
+      </section>
+
+      <aside className="min-w-0 space-y-3">
+        <div className="rounded-xl border border-border bg-surface p-4">
+          <div className="flex items-center justify-between gap-3">
+            <div className="flex items-center gap-2">
+              <h3 className="text-sm font-semibold">{t("common.runs")} ({filteredRuns.length})</h3>
+              {loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" /> : null}
+            </div>
+            <Select
+              className="w-36"
+              aria-label={t("common.filterRunsByStatus")}
+              value={statusFilter}
+              onChange={(event) => setStatusFilter(event.target.value as RunStatusFilter)}
+              options={[
+                { value: "all", label: t("common.all") },
+                { value: "queued", label: t("common.queued") },
+                { value: "running", label: t("common.running") },
+                { value: "completed", label: t("common.completed") },
+                { value: "failed", label: t("common.failed") },
+                { value: "cancelled", label: t("common.cancelled") },
+              ]}
+            />
+          </div>
+
+          <div className="mt-3 max-h-[520px] space-y-2 overflow-auto pr-1">
+            {filteredRuns.length ? (
+              filteredRuns.map((run) => (
+                <button
+                  key={run.id}
+                  type="button"
+                  onClick={() => {
+                    setTestResult(run);
+                    setExecutionMeta(undefined);
+                  }}
+                  className="w-full rounded-md border border-border bg-muted/25 p-3 text-left transition hover:bg-muted/50"
+                >
+                  <div className="flex items-center justify-between gap-2">
+                    <span className="truncate font-mono text-xs">{truncateMiddle(run.id, 22)}</span>
+                    <StatusBadge status={run.status} />
+                  </div>
+                  <p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
+                    {run.input_text ?? t("agents.structuredInput")}
+                  </p>
+                  <p className="mt-2 text-xs text-muted-foreground">{relativeTime(run.created_time)}</p>
+                </button>
+              ))
+            ) : (
+              <EmptyState icon={Activity} title={t("agents.noRuns")} description={t("agents.noRunRecords")} />
+            )}
           </div>
-          {testResult ? <JsonViewer value={testResult} collapsed={false} /> : <p className="text-sm text-muted-foreground">{t("agents.runSimulation")}</p>}
         </div>
+      </aside>
+    </div>
+  );
+}
+
+function ResultPanel({ run, meta }: { run?: AgentRun; meta?: { model?: string | null; dryRun?: boolean; durationMs?: number } }) {
+  const { t } = useTranslation();
+  if (!run) {
+    return (
+      <div className="rounded-xl border border-dashed border-border bg-muted/20 p-6">
+        <EmptyState icon={Play} title={t("agents.noTestResult")} description={t("agents.runTestToInspect")} />
       </div>
+    );
+  }
 
-      <div className="space-y-3">
-        <div className="flex items-center justify-between gap-3">
-          <h3 className="text-sm font-semibold">{t("common.runs")} ({filteredRuns.length})</h3>
-          <Select
-            className="w-48"
-            aria-label={t("common.filterRunsByStatus")}
-            value={statusFilter}
-            onChange={(event) => setStatusFilter(event.target.value as RunStatusFilter)}
-            options={[
-              { value: "all", label: t("common.all") },
-              { value: "queued", label: t("common.queued") },
-              { value: "running", label: t("common.running") },
-              { value: "completed", label: t("common.completed") },
-              { value: "failed", label: t("common.failed") },
-              { value: "cancelled", label: t("common.cancelled") },
-            ]}
-          />
-        </div>
-        {filteredRuns.length ? (
-          <div className="space-y-2">
-            {filteredRuns.map((run) => (
-              <div key={run.id} className="rounded-md border border-border bg-muted/30 p-4">
-                <div className="flex flex-wrap items-center justify-between gap-3">
-                  <div className="min-w-0">
-                    <p className="truncate font-mono text-xs">{truncateMiddle(run.id, 28)}</p>
-                    <p className="mt-1 truncate text-sm text-muted-foreground">{run.input_text ?? t("agents.structuredInput")}</p>
-                  </div>
-                  <StatusBadge status={run.status} />
-                </div>
-                <div className="mt-3 grid gap-3 text-sm md:grid-cols-3">
-                  <div className="min-w-0">
-                    <p className="text-muted-foreground">{t("common.created")}</p>
-                    <p className="mt-1">{relativeTime(run.created_time)}</p>
-                  </div>
-                  <div className="min-w-0">
-                    <p className="text-muted-foreground">{t("agents.worker")}</p>
-                    <p className="mt-1 truncate font-mono text-xs">{run.worker_key ?? t("agents.unassigned")}</p>
-                  </div>
-                  <div className="min-w-0">
-                    <p className="text-muted-foreground">{t("agents.output")}</p>
-                    <p className="mt-1 truncate">{run.output_text ?? run.error_message ?? t("agents.noOutputYet")}</p>
-                  </div>
-                </div>
-              </div>
-            ))}
+  const dryRunValue = meta?.dryRun ?? readBool(run.output_json, "dry_run");
+  const model = meta?.model ?? readString(run.output_json, "model");
+
+  return (
+    <div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
+      <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
+        <div>
+          <div className="flex items-center gap-2">
+            {run.status === "completed" ? (
+              <CheckCircle2 className="h-4 w-4 text-emerald-600" />
+            ) : run.status === "failed" ? (
+              <AlertCircle className="h-4 w-4 text-destructive" />
+            ) : (
+              <Clock className="h-4 w-4 text-muted-foreground" />
+            )}
+            <h3 className="text-sm font-semibold">{t("agents.resultPreview")}</h3>
           </div>
-        ) : (
-          <EmptyState icon={Activity} title={t("agents.noRuns")} description={t("agents.noRunRecords")} />
-        )}
+          <p className="mt-1 font-mono text-xs text-muted-foreground">{truncateMiddle(run.id, 36)}</p>
+        </div>
+        <div className="flex flex-wrap items-center gap-2">
+          <StatusBadge status={run.status} />
+          {dryRunValue ? <Badge className="border-amber-500/25 bg-amber-500/10 text-amber-700">{t("agents.dryRunMode")}</Badge> : null}
+        </div>
+      </div>
+
+      <div className="mt-4 grid gap-3 sm:grid-cols-3">
+        <Info label={t("common.created")} value={formatDateTime(run.created_time)} />
+        <Info label={t("agents.model")} value={model || t("agents.notRecorded")} />
+        <Info label={t("agents.duration")} value={meta?.durationMs ? `${meta.durationMs} ms` : t("agents.notRecorded")} />
+      </div>
+
+      <div className="mt-4 grid min-w-0 gap-4 lg:grid-cols-2">
+        <TextBlock title={t("agents.input")} value={run.input_text ?? JSON.stringify(run.input_json ?? {}, null, 2)} muted={!run.input_text} />
+        <TextBlock
+          title={run.status === "failed" ? t("agents.error") : t("agents.output")}
+          value={run.output_text ?? run.error_message ?? t("agents.noOutputYet")}
+          danger={run.status === "failed"}
+          muted={!run.output_text && !run.error_message}
+        />
       </div>
     </div>
   );
 }
+
+function Info({ label, value }: { label: string; value: React.ReactNode }) {
+  return (
+    <div className="rounded-md border border-border bg-muted/25 p-3">
+      <p className="text-xs text-muted-foreground">{label}</p>
+      <p className="mt-1 truncate text-sm font-medium">{value}</p>
+    </div>
+  );
+}
+
+function TextBlock({ title, value, danger, muted }: { title: string; value: string; danger?: boolean; muted?: boolean }) {
+  return (
+    <div className="min-w-0 rounded-md border border-border bg-muted/20 p-3">
+      <p className={danger ? "text-sm font-semibold text-destructive" : "text-sm font-semibold"}>{title}</p>
+      <pre className={["mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words text-sm leading-6", muted ? "text-muted-foreground" : "text-foreground"].join(" ")}>
+        {value}
+      </pre>
+    </div>
+  );
+}
+
+function readString(value: unknown, key: string) {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
+  const item = (value as Record<string, unknown>)[key];
+  return typeof item === "string" ? item : undefined;
+}
+
+function readBool(value: unknown, key: string) {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+  const item = (value as Record<string, unknown>)[key];
+  return typeof item === "boolean" ? item : false;
+}

+ 27 - 36
web/src/pages/agents/components/CreateAgentDialog.tsx

@@ -1,14 +1,14 @@
 import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { Plus, Sparkles } from "lucide-react";
-import { createAgent, createAgentConfig, listModelProviders, listSkills } from "@/api";
+import { createAgent, createAgentConfig, listModels, listSkills } from "@/api";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Input, Textarea } from "@/components/ui/input";
 import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
 import { useAuthStore } from "@/stores/auth";
-import type { ModelProvider, SkillDefinition } from "@/types";
+import type { ModelDefinition, SkillDefinition } from "@/types";
 
 export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
   const { t } = useTranslation();
@@ -19,9 +19,8 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
   const [name, setName] = React.useState("");
   const [systemPrompt, setSystemPrompt] = React.useState("");
 
-  const [modelProviders, setModelProviders] = React.useState<ModelProvider[]>([]);
-  const [selectedProviderId, setSelectedProviderId] = React.useState("");
-  const [selectedModel, setSelectedModel] = React.useState("");
+  const [models, setModels] = React.useState<ModelDefinition[]>([]);
+  const [selectedModelId, setSelectedModelId] = React.useState("");
 
   const [availableSkills, setAvailableSkills] = React.useState<SkillDefinition[]>([]);
   const [selectedSkillIds, setSelectedSkillIds] = React.useState<string[]>([]);
@@ -38,29 +37,28 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
   const [outputFormat, setOutputFormat] = React.useState("text");
   const [humanApprovalPolicy, setHumanApprovalPolicy] = React.useState("never");
 
-  const currentProvider = modelProviders.find((p) => p.id === selectedProviderId);
+  const currentModel = models.find((model) => model.id === selectedModelId);
   const modelOptions = React.useMemo(() => {
-    return modelProviders.flatMap((provider) =>
-      provider.models
-        .filter((model) => model.model_type === "chat" || model.model_type === "reasoning")
-        .map((model) => ({
-          value: `${provider.id}:${model.model_id}`,
-          label: `${model.display_name} - ${provider.name}`,
-        }))
-    );
-  }, [modelProviders]);
+    return models
+      .filter((model) => {
+        const capabilities = model.capabilities_json ?? [];
+        return capabilities.length === 0 || capabilities.includes("chat") || capabilities.includes("reasoning");
+      })
+      .map((model) => ({
+        value: model.id,
+        label: `${model.name} - ${model.model_name}`,
+      }));
+  }, [models]);
 
   React.useEffect(() => {
     if (!open) return;
-    void listModelProviders().then((providers) => {
-      setModelProviders(providers);
-      if (providers[0]) {
-        const chatModels = providers[0].models.filter((m) => m.model_type === "chat" || m.model_type === "reasoning");
-        if (chatModels[0]) {
-          setSelectedProviderId(providers[0].id);
-          setSelectedModel(`${providers[0].id}:${chatModels[0].model_id}`);
-        }
-      }
+    void listModels().then((items) => {
+      setModels(items);
+      const firstModel = items.find((model) => {
+        const capabilities = model.capabilities_json ?? [];
+        return capabilities.length === 0 || capabilities.includes("chat") || capabilities.includes("reasoning");
+      });
+      setSelectedModelId(firstModel?.id ?? "");
     });
     setSkillsLoading(true);
     setSkillsError(null);
@@ -76,8 +74,7 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
   function reset() {
     setName("");
     setSystemPrompt("");
-    setSelectedProviderId("");
-    setSelectedModel("");
+    setSelectedModelId("");
     setSelectedSkillIds([]);
     setMemoryScope("session");
     setTemperature("0.7");
@@ -97,12 +94,6 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
     );
   }
 
-  function selectModel(value: string) {
-    const [providerId] = value.split(":");
-    setSelectedProviderId(providerId ?? "");
-    setSelectedModel(value);
-  }
-
   async function submit(event: React.FormEvent) {
     event.preventDefault();
     setSubmitting(true);
@@ -118,8 +109,9 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
         role: "assistant",
         system_prompt: systemPrompt,
         model_config_json: {
-          provider: currentProvider?.provider_type ?? "openai",
-          model: selectedModel.split(":").slice(1).join(":") || selectedModel,
+          model_id: currentModel?.id ?? null,
+          provider: currentModel?.provider_type ?? "openai",
+          model: currentModel?.model_name ?? "",
           temperature: parseOptionalFloat(temperature) ?? 0.7,
           max_tokens: parseOptionalInteger(maxTokens) ?? 4096,
           timeout_seconds: parseOptionalInteger(timeoutSeconds) ?? 60,
@@ -139,7 +131,6 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
         },
         tool_refs_json: [],
         skill_refs_json: selectedSkillIds.map((skillId) => ({ skill_id: skillId })),
-        status: "draft",
       });
 
       toast.success(t("agents.agentCreated"));
@@ -224,7 +215,7 @@ export function CreateAgentDialog({ onCreated }: { onCreated: () => void }) {
 
               <div className="space-y-4">
                 <Field label={t("agents.model")}>
-                  <Select value={selectedModel} onChange={(e) => selectModel(e.target.value)} options={modelOptions} />
+                  <Select value={selectedModelId} onChange={(e) => setSelectedModelId(e.target.value)} options={modelOptions} />
                 </Field>
                 <div className="grid gap-3 sm:grid-cols-2">
                   <Field label={t("agents.temperature")}>

+ 4 - 4
web/src/pages/login/LoginPage.tsx

@@ -25,12 +25,12 @@ export function LoginPage() {
     try {
       const result = await loginWithPassword({ username, password });
       login({
-        accessToken: result.access_token,
-        tokenType: result.token_type,
-        tokenExpiresTime: result.expires_time,
+        accessToken: result.accessToken,
+        tokenType: result.tokenType,
+        tokenExpiresTime: result.expiresTime,
         userId: result.user.id,
         username: result.user.username,
-        displayName: result.user.display_name,
+        displayName: result.user.displayName,
       });
       toast.success(t("login.connected"));
       const redirectTo = (location.state as { from?: string } | null)?.from ?? "/dashboard";

+ 2 - 0
web/src/pages/models/ModelProvidersPage.tsx

@@ -38,6 +38,7 @@ import {
 } from "@/types";
 
 const PROVIDER_TYPE_OPTIONS = [
+  { value: "openai_compatible", label: "OpenAI Compatible" },
   { value: "openai", label: "OpenAI" },
   { value: "anthropic", label: "Anthropic" },
   { value: "deepseek", label: "DeepSeek" },
@@ -47,6 +48,7 @@ const PROVIDER_TYPE_OPTIONS = [
 ];
 
 const DEFAULT_URLS = {
+  openai_compatible: "http://127.0.0.1:11434/v1",
   openai: "https://api.openai.com/v1",
   anthropic: "https://api.anthropic.com",
   deepseek: "https://api.deepseek.com/v1",

+ 109 - 29
web/src/pages/models/ModelsPage.tsx

@@ -15,6 +15,7 @@ import {
 import {
   createModel,
   deleteModel,
+  listModelProviders,
   listModels,
   testModel,
   updateModel,
@@ -34,7 +35,7 @@ import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
 import { demoText } from "@/lib/demo-text";
 import { formatDateTime } from "@/lib/utils";
-import type { ModelCreateRequest, ModelDefinition } from "@/types";
+import type { ModelCreateRequest, ModelDefinition, ModelProvider } from "@/types";
 
 const providerPresets = [
   { value: "openai_compatible", labelKey: "models.providerOpenaiCompatible", defaultLabel: "OpenAI compatible", baseUrl: "http://127.0.0.1:11434/v1" },
@@ -47,6 +48,7 @@ const providerPresets = [
 
 type ModelFormState = {
   name: string;
+  provider_id: string;
   provider_type: string;
   provider_base_url: string;
   provider_api_key: string;
@@ -61,6 +63,7 @@ type ModelFormState = {
 
 const emptyForm: ModelFormState = {
   name: "",
+  provider_id: "",
   provider_type: "openai_compatible",
   provider_base_url: "http://127.0.0.1:11434/v1",
   provider_api_key: "",
@@ -76,6 +79,7 @@ const emptyForm: ModelFormState = {
 export function ModelsPage() {
   const { t } = useTranslation();
   const [models, setModels] = React.useState<ModelDefinition[]>([]);
+  const [modelProviders, setModelProviders] = React.useState<ModelProvider[]>([]);
   const [search, setSearch] = React.useState("");
   const [providerFilter, setProviderFilter] = React.useState("all");
   const [loading, setLoading] = React.useState(true);
@@ -108,6 +112,7 @@ export function ModelsPage() {
     setError(undefined);
     try {
       setModels(await listModels());
+      setModelProviders(await listModelProviders().catch(() => []));
     } catch (err) {
       setError(err instanceof Error ? err.message : t("models.failedToLoad"));
     } finally {
@@ -264,13 +269,14 @@ export function ModelsPage() {
         </CardContent>
       </Card>
 
-      <CreateModelDialog open={createOpen} onOpenChange={setCreateOpen} onCreate={createFromDialog} providers={providers} />
+      <CreateModelDialog open={createOpen} onOpenChange={setCreateOpen} onCreate={createFromDialog} providers={providers} modelProviders={modelProviders} />
 
       <EditModelDialog
         open={editOpen}
         onOpenChange={setEditOpen}
         form={form}
         providers={providers}
+        modelProviders={modelProviders}
         activeModelName={demoText(activeModel?.name, t)}
         saving={saving}
         onChange={setForm}
@@ -351,11 +357,13 @@ function CreateModelDialog({
   onOpenChange,
   onCreate,
   providers,
+  modelProviders,
 }: {
   open: boolean;
   onOpenChange: (open: boolean) => void;
   onCreate: (form: ModelFormState) => Promise<void>;
   providers: string[];
+  modelProviders: ModelProvider[];
 }) {
   const { t } = useTranslation();
   const [form, setForm] = React.useState<ModelFormState>(emptyForm);
@@ -384,16 +392,29 @@ function CreateModelDialog({
     setForm((current) => ({ ...current, [key]: value }));
   }
 
+  function setProviderConnection(providerId: string) {
+    const provider = modelProviders.find((item) => item.id === providerId);
+    setForm((current) => ({
+      ...current,
+      provider_id: providerId,
+      provider_type: provider?.provider_type ?? current.provider_type,
+      provider_base_url: provider?.base_url ?? current.provider_base_url,
+      provider_api_key: providerId ? "" : current.provider_api_key,
+    }));
+  }
+
   function setProvider(value: string) {
     const preset = providerPresets.find((item) => item.value === value);
     setForm((current) => ({
       ...current,
+      provider_id: "",
       provider_type: value,
       provider_base_url: preset?.baseUrl ?? current.provider_base_url,
     }));
   }
 
   const canCreate = Boolean(form.model_name.trim() && form.provider_base_url.trim());
+  const usingProviderConnection = Boolean(form.provider_id);
   const providerOptions = buildProviderOptions(providers, t);
 
   return (
@@ -405,9 +426,25 @@ function CreateModelDialog({
       className="max-w-xl"
     >
       <div className="space-y-5">
+        {modelProviders.length ? (
+          <Field label={t("models.providerConnection")}>
+            <Select
+              value={form.provider_id}
+              onChange={(event) => setProviderConnection(event.target.value)}
+              options={[
+                { value: "", label: t("models.manualConnection") },
+                ...modelProviders.map((provider) => ({
+                  value: provider.id,
+                  label: `${provider.name} - ${providerLabel(provider.provider_type, t)}`,
+                })),
+              ]}
+            />
+          </Field>
+        ) : null}
+
         <div className="grid gap-4 sm:grid-cols-2">
           <Field label={t("models.provider")}>
-            <Select value={form.provider_type} onChange={(event) => setProvider(event.target.value)} options={providerOptions} />
+            <Select value={form.provider_type} onChange={(event) => setProvider(event.target.value)} options={providerOptions} disabled={usingProviderConnection} />
           </Field>
           <Field label={t("models.modelName")}>
             <Input
@@ -420,14 +457,16 @@ function CreateModelDialog({
           </Field>
         </div>
 
-        <Field label={t("models.apiKey")}>
-          <Input
-            type="password"
-            value={form.provider_api_key}
-            onChange={(event) => setField("provider_api_key", event.target.value)}
-            placeholder={t("models.optionalApiKey")}
-          />
-        </Field>
+        {!usingProviderConnection ? (
+          <Field label={t("models.apiKey")}>
+            <Input
+              type="password"
+              value={form.provider_api_key}
+              onChange={(event) => setField("provider_api_key", event.target.value)}
+              placeholder={t("models.optionalApiKey")}
+            />
+          </Field>
+        ) : null}
 
         <div className="rounded-md border border-border bg-muted/25 p-3">
           <div className="flex items-start gap-3">
@@ -435,7 +474,7 @@ function CreateModelDialog({
             <div className="min-w-0 text-sm">
               <p className="font-medium">{form.model_name.trim() || t("models.modelNamePreview")}</p>
               <p className="mt-1 break-words text-xs text-muted-foreground">
-                {form.provider_base_url || t("models.baseUrlNeeded")}
+                {usingProviderConnection ? t("models.usingSharedProvider") : form.provider_base_url || t("models.baseUrlNeeded")}
               </p>
             </div>
           </div>
@@ -454,9 +493,11 @@ function CreateModelDialog({
             <Field label={t("common.name")}>
               <Input value={form.name} onChange={(event) => setField("name", event.target.value)} placeholder={form.model_name || t("models.localChatPlaceholder")} />
             </Field>
-            <Field label={t("models.baseUrl")}>
-              <Input value={form.provider_base_url} onChange={(event) => setField("provider_base_url", event.target.value)} />
-            </Field>
+            {!usingProviderConnection ? (
+              <Field label={t("models.baseUrl")}>
+                <Input value={form.provider_base_url} onChange={(event) => setField("provider_base_url", event.target.value)} />
+              </Field>
+            ) : null}
             <Field label={t("models.capabilities")}>
               <Input value={form.capabilities} onChange={(event) => setField("capabilities", event.target.value)} placeholder="chat, tools" />
             </Field>
@@ -487,6 +528,7 @@ function EditModelDialog({
   onOpenChange,
   form,
   providers,
+  modelProviders,
   activeModelName,
   saving,
   onChange,
@@ -496,6 +538,7 @@ function EditModelDialog({
   onOpenChange: (open: boolean) => void;
   form: ModelFormState;
   providers: string[];
+  modelProviders: ModelProvider[];
   activeModelName?: string;
   saving: boolean;
   onChange: (form: ModelFormState) => void;
@@ -512,10 +555,22 @@ function EditModelDialog({
     onChange({ ...form, [key]: value });
   }
 
+  function setProviderConnection(providerId: string) {
+    const provider = modelProviders.find((item) => item.id === providerId);
+    onChange({
+      ...form,
+      provider_id: providerId,
+      provider_type: provider?.provider_type ?? form.provider_type,
+      provider_base_url: provider?.base_url ?? form.provider_base_url,
+      provider_api_key: providerId ? "" : form.provider_api_key,
+    });
+  }
+
   function setProvider(value: string) {
     const preset = providerPresets.find((item) => item.value === value);
     onChange({
       ...form,
+      provider_id: "",
       provider_type: value,
       provider_base_url: preset?.baseUrl ?? form.provider_base_url,
     });
@@ -523,6 +578,7 @@ function EditModelDialog({
 
   const providerOptions = buildProviderOptions(providers, t);
   const canSave = Boolean(form.model_name.trim() && form.provider_base_url.trim());
+  const usingProviderConnection = Boolean(form.provider_id);
 
   return (
     <Dialog
@@ -533,30 +589,50 @@ function EditModelDialog({
       className="max-w-xl"
     >
       <div className="space-y-5">
+        {modelProviders.length ? (
+          <Field label={t("models.providerConnection")}>
+            <Select
+              value={form.provider_id}
+              onChange={(event) => setProviderConnection(event.target.value)}
+              options={[
+                { value: "", label: t("models.manualConnection") },
+                ...modelProviders.map((provider) => ({
+                  value: provider.id,
+                  label: `${provider.name} - ${providerLabel(provider.provider_type, t)}`,
+                })),
+              ]}
+            />
+          </Field>
+        ) : null}
+
         <div className="grid gap-4 sm:grid-cols-2">
           <Field label={t("models.provider")}>
-            <Select value={form.provider_type} onChange={(event) => setProvider(event.target.value)} options={providerOptions} />
+            <Select value={form.provider_type} onChange={(event) => setProvider(event.target.value)} options={providerOptions} disabled={usingProviderConnection} />
           </Field>
           <Field label={t("models.modelName")}>
             <Input value={form.model_name} onChange={(event) => setField("model_name", event.target.value)} />
           </Field>
         </div>
 
-        <Field label={t("models.apiKey")}>
-          <Input
-            type="password"
-            value={form.provider_api_key}
-            onChange={(event) => setField("provider_api_key", event.target.value)}
-            placeholder={t("models.keepSecretPlaceholder")}
-          />
-        </Field>
+        {!usingProviderConnection ? (
+          <Field label={t("models.apiKey")}>
+            <Input
+              type="password"
+              value={form.provider_api_key}
+              onChange={(event) => setField("provider_api_key", event.target.value)}
+              placeholder={t("models.keepSecretPlaceholder")}
+            />
+          </Field>
+        ) : null}
 
         <div className="rounded-md border border-border bg-muted/25 p-3">
           <div className="flex items-start gap-3">
             <BrainCircuit className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
             <div className="min-w-0 text-sm">
               <p className="font-medium">{form.name.trim() || form.model_name.trim() || t("models.modelNamePreview")}</p>
-              <p className="mt-1 break-words text-xs text-muted-foreground">{form.provider_base_url}</p>
+              <p className="mt-1 break-words text-xs text-muted-foreground">
+                {usingProviderConnection ? t("models.usingSharedProvider") : form.provider_base_url}
+              </p>
             </div>
           </div>
         </div>
@@ -574,9 +650,11 @@ function EditModelDialog({
             <Field label={t("common.name")}>
               <Input value={form.name} onChange={(event) => setField("name", event.target.value)} />
             </Field>
-            <Field label={t("models.baseUrl")}>
-              <Input value={form.provider_base_url} onChange={(event) => setField("provider_base_url", event.target.value)} />
-            </Field>
+            {!usingProviderConnection ? (
+              <Field label={t("models.baseUrl")}>
+                <Input value={form.provider_base_url} onChange={(event) => setField("provider_base_url", event.target.value)} />
+              </Field>
+            ) : null}
             <Field label={t("models.capabilities")}>
               <Input value={form.capabilities} onChange={(event) => setField("capabilities", event.target.value)} />
             </Field>
@@ -623,6 +701,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
 function fromModel(model: ModelDefinition): ModelFormState {
   return {
     name: model.name,
+    provider_id: model.provider_id ?? "",
     provider_type: model.provider_type,
     provider_base_url: model.provider_base_url,
     provider_api_key: "",
@@ -640,8 +719,9 @@ function toPayload(form: ModelFormState): ModelCreateRequest {
   const modelName = form.model_name.trim();
   return {
     name: form.name.trim() || modelName || "New Model",
+    provider_id: form.provider_id || null,
     provider_type: form.provider_type.trim() || "openai_compatible",
-    provider_base_url: form.provider_base_url.trim(),
+    provider_base_url: form.provider_base_url.trim() || null,
     provider_api_key: form.provider_api_key.trim() || null,
     model_name: modelName,
     description: form.description.trim() || null,

+ 52 - 7
web/src/pages/teams/TeamsPage.tsx

@@ -5,11 +5,13 @@ import {
   Bot,
   CheckCircle2,
   Clock,
+  Pencil,
   RefreshCw,
   Search,
   SlidersHorizontal,
   Users,
 } from "lucide-react";
+import { listAgents } from "@/api/agents";
 import { listTeamConfigs, listTeamRuns, listTeams } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { EmptyState } from "@/components/shared/EmptyState";
@@ -24,8 +26,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
 import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
 import { cn, relativeTime } from "@/lib/utils";
-import type { TeamConfig, TeamDefinition, TeamRun, TeamStatus } from "@/types";
-import { CreateTeamDialog } from "./components/CreateTeamDialog";
+import type { AgentDefinition, TeamConfig, TeamDefinition, TeamRun, TeamStatus } from "@/types";
+import { CreateTeamDialog, EditTeamDialog } from "./components/CreateTeamDialog";
 import { TeamOverview } from "./components/TeamOverview";
 import { TeamRuns } from "./components/TeamRuns";
 
@@ -37,6 +39,7 @@ export function TeamsPage() {
   const [teams, setTeams] = React.useState<TeamDefinition[]>([]);
   const [configs, setConfigs] = React.useState<TeamConfig[]>([]);
   const [runs, setRuns] = React.useState<TeamRun[]>([]);
+  const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
   const [selectedTeamId, setSelectedTeamId] = React.useState<string>();
   const [search, setSearch] = React.useState("");
   const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");
@@ -45,6 +48,7 @@ export function TeamsPage() {
   const [error, setError] = React.useState<string>();
   const [relatedLoading, setRelatedLoading] = React.useState(false);
   const [createOpen, setCreateOpen] = React.useState(false);
+  const [editOpen, setEditOpen] = React.useState(false);
 
   const selectedTeam = teams.find((team) => team.id === selectedTeamId);
   const sortedConfigs = React.useMemo(
@@ -59,6 +63,11 @@ export function TeamsPage() {
   const failedRunCount = sortedRuns.filter((run) => run.status === "failed").length;
   const activeTeams = teams.filter((team) => team.status === "active").length;
   const draftTeams = teams.filter((team) => team.status === "draft").length;
+  const agentNameById = React.useMemo(() => {
+    const result = new Map<string, string>();
+    agents.forEach((agent) => result.set(agent.id, agent.name));
+    return result;
+  }, [agents]);
 
   const filtered = teams
     .filter((team) => {
@@ -79,8 +88,12 @@ export function TeamsPage() {
     setLoading(true);
     setError(undefined);
     try {
-      const data = await listTeams();
+      const [data, agentData] = await Promise.all([
+        listTeams(),
+        listAgents().catch(() => [] as AgentDefinition[]),
+      ]);
       setTeams(data);
+      setAgents(agentData);
       setSelectedTeamId((current) => current ?? data[0]?.id);
     } catch (err) {
       setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
@@ -213,7 +226,7 @@ export function TeamsPage() {
                   {selectedTeam ? <StatusBadge status={selectedTeam.status} /> : null}
                 </div>
                 <CardDescription className="mt-1">
-                  {selectedTeam ? readableLabel(selectedTeam.team_type) : t("teams.selectTeamInspect")}
+                  {selectedTeam ? teamValueLabel(t, selectedTeam.team_type) : t("teams.selectTeamInspect")}
                 </CardDescription>
               </div>
               <div className="flex flex-wrap items-center gap-2">
@@ -222,6 +235,11 @@ export function TeamsPage() {
                     {sortedRuns.length} {t("common.runs")}
                   </Badge>
                 ) : null}
+                {selectedTeam ? (
+                  <Button type="button" size="sm" variant="outline" onClick={() => setEditOpen(true)}>
+                    <Pencil className="h-4 w-4" /> {t("common.edit")}
+                  </Button>
+                ) : null}
               </div>
             </div>
           </CardHeader>
@@ -234,13 +252,17 @@ export function TeamsPage() {
                   runCount={sortedRuns.length}
                   failedRunCount={failedRunCount}
                   latestRun={sortedRuns[0]}
+                  agentNameById={agentNameById}
                 />
                 <TeamRuns
                   teamId={selectedTeam.id}
                   configs={sortedConfigs}
                   runs={sortedRuns}
                   loading={relatedLoading}
-                  onRunCreated={(run) => { setRuns((current) => [run, ...current]); void reloadDetails(); }}
+                  agentNameById={agentNameById}
+                  onRunCreated={(run) => {
+                    setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)]);
+                  }}
                 />
               </div>
             ) : (
@@ -259,6 +281,16 @@ export function TeamsPage() {
           void load();
         }}
       />
+      <EditTeamDialog
+        open={editOpen}
+        onOpenChange={setEditOpen}
+        team={selectedTeam}
+        activeConfig={activeConfig}
+        onUpdated={(team) => {
+          setTeams((current) => current.map((item) => (item.id === team.id ? team : item)));
+          void reloadDetails();
+        }}
+      />
     </div>
   );
 }
@@ -289,7 +321,7 @@ function TeamDirectoryItem({
             <Users className="h-4 w-4 text-primary" />
             <p className="truncate text-sm font-semibold">{team.name}</p>
           </div>
-          <p className="mt-1 truncate text-xs text-muted-foreground">{readableLabel(team.team_type)}</p>
+          <p className="mt-1 truncate text-xs text-muted-foreground">{teamValueLabel(t, team.team_type)}</p>
         </div>
         <StatusBadge status={team.status} />
       </div>
@@ -297,7 +329,7 @@ function TeamDirectoryItem({
         {team.description ?? t("teams.noDescription")}
       </p>
       <div className="mt-3 flex flex-wrap items-center gap-2">
-        <Badge className="border-border bg-surface-elevated text-muted-foreground">{readableLabel(team.team_type)}</Badge>
+        <Badge className="border-border bg-surface-elevated text-muted-foreground">{teamValueLabel(t, team.team_type)}</Badge>
         <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
           <Clock className="h-3.5 w-3.5" /> {relativeTime(team.created_time)}
         </span>
@@ -309,3 +341,16 @@ function TeamDirectoryItem({
 function readableLabel(value: string) {
   return value.split(/[_-]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
 }
+
+function teamValueLabel(t: (key: string, options?: Record<string, unknown>) => string, value: string) {
+  const translationKey = TEAM_VALUE_LABEL_KEYS[value] ?? value;
+  return t(`teams.${translationKey}`, { defaultValue: readableLabel(value) });
+}
+
+const TEAM_VALUE_LABEL_KEYS: Record<string, string> = {
+  collaborative: "collaborative",
+  debate: "debate",
+  parallel: "parallel",
+  pipeline: "pipeline",
+  supervisor: "supervisor",
+};

+ 195 - 6
web/src/pages/teams/components/CreateTeamDialog.tsx

@@ -2,14 +2,14 @@ import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { GitBranch, ListPlus, Minus, Plus, Settings2, Users } from "lucide-react";
 import { listAgents } from "@/api/agents";
-import { createTeam, createTeamConfig } from "@/api";
+import { createTeam, createTeamConfig, updateTeam, updateTeamConfig } from "@/api";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Input, Textarea } from "@/components/ui/input";
 import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
 import { useAuthStore } from "@/stores/auth";
-import type { AgentDefinition, JSONObject, TeamDefinition } from "@/types";
+import type { AgentDefinition, JSONObject, TeamConfig, TeamDefinition } from "@/types";
 
 type MemberDraft = {
   role: string;
@@ -30,7 +30,7 @@ const DEFAULT_POLICY: PolicyDraft = {
 };
 
 function createDefaultMember(): MemberDraft {
-  return { role: "worker", agent_id: "", responsibility: "" };
+  return { role: "executor", agent_id: "", responsibility: "" };
 }
 
 export function CreateTeamDialog({
@@ -67,7 +67,7 @@ export function CreateTeamDialog({
     event.preventDefault();
     if (!form.name.trim()) return;
 
-    const configPayload = buildTeamConfig(members, policy, t);
+    const configPayload = buildTeamConfig(members, agents, policy, t);
     if (!configPayload.ok) {
       setFormError(configPayload.message);
       return;
@@ -214,7 +214,7 @@ function MemberEditor({ members, onChange, agents }: { members: MemberDraft[]; o
                     onChange={(event) => update(index, { role: event.target.value })}
                     options={[
                       { value: "supervisor", label: t("teams.supervisor") },
-                      { value: "worker", label: t("teams.worker") },
+                      { value: "executor", label: t("teams.executor") },
                       { value: "reviewer", label: t("teams.reviewer") },
                       { value: "planner", label: t("teams.planner") },
                     ]}
@@ -298,6 +298,155 @@ function SectionHeader({ icon, title, action }: { icon: React.ReactNode; title:
   );
 }
 
+export function EditTeamDialog({
+  open,
+  onOpenChange,
+  team,
+  activeConfig,
+  onUpdated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  team?: TeamDefinition;
+  activeConfig?: TeamConfig;
+  onUpdated: (team: TeamDefinition) => void;
+}) {
+  const { t } = useTranslation();
+  const [form, setForm] = React.useState({ name: "", description: "", coordination_mode: "supervisor", objective: "" });
+  const [members, setMembers] = React.useState<MemberDraft[]>([createDefaultMember()]);
+  const [policy, setPolicy] = React.useState<PolicyDraft>(DEFAULT_POLICY);
+  const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
+  const [formError, setFormError] = React.useState<string>();
+  const [submitting, setSubmitting] = React.useState(false);
+
+  React.useEffect(() => {
+    if (!open) return;
+    void listAgents().then(setAgents).catch(() => {});
+  }, [open]);
+
+  React.useEffect(() => {
+    if (!open || !team) return;
+    setForm({
+      name: team.name,
+      description: team.description ?? "",
+      coordination_mode: activeConfig?.coordination_mode ?? "supervisor",
+      objective: activeConfig?.objective ?? "",
+    });
+    setMembers(readMemberDrafts(activeConfig));
+    setPolicy(readPolicyDraft(activeConfig));
+    setFormError(undefined);
+  }, [activeConfig, open, team]);
+
+  async function submit(event: React.FormEvent) {
+    event.preventDefault();
+    if (!team || !form.name.trim()) return;
+
+    const configPayload = buildTeamConfig(members, agents, policy, t);
+    if (!configPayload.ok) {
+      setFormError(configPayload.message);
+      return;
+    }
+
+    setSubmitting(true);
+    setFormError(undefined);
+    try {
+      const updatedTeam = await updateTeam(team.id, {
+        name: form.name,
+        description: form.description || null,
+        team_type: team.team_type,
+        owner_user_id: team.owner_user_id,
+      });
+      if (activeConfig) {
+        await updateTeamConfig(activeConfig.id, {
+          coordination_mode: form.coordination_mode,
+          objective: form.objective || null,
+          member_refs: configPayload.memberRefs,
+          policy_json: configPayload.policyJson,
+        });
+      } else {
+        await createTeamConfig({
+          team_id: team.id,
+          coordination_mode: form.coordination_mode,
+          objective: form.objective || undefined,
+          member_refs: configPayload.memberRefs,
+          policy_json: configPayload.policyJson,
+          status: "draft",
+        });
+      }
+      toast.success(t("teams.teamUpdated"));
+      onOpenChange(false);
+      onUpdated(updatedTeam);
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : t("teams.failedUpdateTeam"));
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <Dialog
+      open={open}
+      onOpenChange={onOpenChange}
+      title={t("teams.editTeam")}
+      description={team?.name ?? t("teams.teamDetails")}
+      className="max-w-6xl"
+    >
+      <form className="space-y-4" onSubmit={submit}>
+        <div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 sm:grid-cols-3">
+          <SummaryPill icon={<GitBranch className="h-4 w-4" />} label={t("teams.coordination")} value={t(`teams.${form.coordination_mode}`)} />
+          <SummaryPill icon={<Users className="h-4 w-4" />} label={t("teams.members")} value={String(members.length)} />
+          <SummaryPill icon={<Settings2 className="h-4 w-4" />} label={t("teams.maxRoundsField")} value={policy.max_rounds} />
+        </div>
+
+        <div className="grid gap-4 xl:grid-cols-[0.95fr_1.25fr]">
+          <div className="space-y-4">
+            <section className="rounded-md border border-border bg-muted/10 p-4">
+              <SectionHeader icon={<ListPlus className="h-4 w-4" />} title={t("teams.basicInfo")} />
+              <div className="mt-4 space-y-4">
+                <div className="grid gap-4 sm:grid-cols-[1fr_190px]">
+                  <Field label={t("common.name")}>
+                    <Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
+                  </Field>
+                  <Field label={t("teams.coordinationMode")}>
+                    <Select
+                      value={form.coordination_mode}
+                      onChange={(event) => setForm({ ...form, coordination_mode: event.target.value })}
+                      options={[
+                        { value: "supervisor", label: t("teams.supervisor") },
+                        { value: "pipeline", label: t("teams.pipeline") },
+                        { value: "parallel", label: t("teams.parallel") },
+                        { value: "debate", label: t("teams.debate") },
+                      ]}
+                    />
+                  </Field>
+                </div>
+                <Field label={t("common.description")}>
+                  <Textarea className="min-h-20" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
+                </Field>
+                <Field label={t("teams.objective")}>
+                  <Textarea className="min-h-24" value={form.objective} placeholder={t("teams.describeObjective")} onChange={(event) => setForm({ ...form, objective: event.target.value })} />
+                </Field>
+              </div>
+            </section>
+
+            <PolicyEditor policy={policy} onChange={setPolicy} />
+          </div>
+
+          <MemberEditor members={members} onChange={setMembers} agents={agents} />
+        </div>
+
+        {formError ? <p className="rounded-md border border-red-500/25 bg-red-500/5 p-3 text-sm text-foreground">{formError}</p> : null}
+        <div className="sticky bottom-0 -mx-4 -mb-4 flex justify-end gap-2 border-t border-border bg-surface-elevated/95 px-4 py-4 backdrop-blur sm:-mx-5 sm:-mb-5 sm:px-5">
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
+            {t("common.cancel")}
+          </Button>
+          <Button disabled={submitting || !form.name.trim()}>{submitting ? t("common.saving") : t("common.save")}</Button>
+        </div>
+      </form>
+    </Dialog>
+  );
+}
+
 function SummaryPill({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
   return (
     <div className="flex items-center gap-3 rounded-md border border-border bg-surface-elevated px-3 py-2">
@@ -321,13 +470,28 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
 
 function buildTeamConfig(
   members: MemberDraft[],
+  agents: AgentDefinition[],
   policy: PolicyDraft,
   t: (key: string) => string,
 ):
   | { ok: true; memberRefs: JSONObject[]; policyJson: JSONObject }
   | { ok: false; message: string } {
+  const agentNameById = new Map(agents.map((agent) => [agent.id, agent.name]));
   const normalizedMembers = members
-    .map((m) => ({ role: m.role.trim(), agent_id: m.agent_id.trim(), responsibility: m.responsibility.trim() }))
+    .map((m) => {
+      const agentId = m.agent_id.trim();
+      const agentName = agentNameById.get(agentId);
+      const member: JSONObject = {
+        role: m.role.trim(),
+        agent_id: agentId,
+        responsibility: m.responsibility.trim(),
+      };
+      if (agentName) {
+        member.name = agentName;
+        member.agent_name = agentName;
+      }
+      return member;
+    })
     .filter((m) => m.role || m.agent_id || m.responsibility);
   if (!normalizedMembers.length) return { ok: false, message: t("teams.addAtLeastOneMember") };
   if (normalizedMembers.some((m) => !m.agent_id)) return { ok: false, message: t("teams.eachMemberNeedsAgentId") };
@@ -339,3 +503,28 @@ function buildTeamConfig(
     policyJson: { max_rounds: maxRounds, handoff: policy.handoff, failure_mode: policy.failure_mode },
   };
 }
+
+function readMemberDrafts(activeConfig?: TeamConfig): MemberDraft[] {
+  if (!activeConfig?.member_refs_json.length) return [createDefaultMember()];
+  return activeConfig.member_refs_json.map((member) => ({
+    role: readString(member, "role") ?? "executor",
+    agent_id: readString(member, "agent_id") ?? readString(member, "agentId") ?? "",
+    responsibility: readString(member, "responsibility") ?? readString(member, "description") ?? "",
+  }));
+}
+
+function readPolicyDraft(activeConfig?: TeamConfig): PolicyDraft {
+  if (!activeConfig) return DEFAULT_POLICY;
+  return {
+    max_rounds: readString(activeConfig.policy_json, "max_rounds") ?? DEFAULT_POLICY.max_rounds,
+    handoff: readString(activeConfig.policy_json, "handoff") ?? DEFAULT_POLICY.handoff,
+    failure_mode: readString(activeConfig.policy_json, "failure_mode") ?? DEFAULT_POLICY.failure_mode,
+  };
+}
+
+function readString(value: JSONObject, key: string) {
+  const item = value[key];
+  if (typeof item === "string" && item.trim()) return item;
+  if (typeof item === "number" || typeof item === "boolean") return String(item);
+  return undefined;
+}

+ 36 - 10
web/src/pages/teams/components/TeamOverview.tsx

@@ -11,12 +11,14 @@ export function TeamOverview({
   runCount,
   failedRunCount,
   latestRun,
+  agentNameById,
 }: {
   team: TeamDefinition;
   activeConfig?: TeamConfig;
   runCount: number;
   failedRunCount: number;
   latestRun?: TeamRun;
+  agentNameById?: Map<string, string>;
 }) {
   const { t } = useTranslation();
   const memberCount = activeConfig?.member_refs_json.length ?? 0;
@@ -37,7 +39,7 @@ export function TeamOverview({
           <div className="min-w-0 flex-1">
             <div className="flex min-w-0 flex-wrap items-center gap-2">
               <h3 className="truncate text-sm font-semibold">{t("teams.currentObjective")}</h3>
-              <Badge className="border-border bg-muted/40 text-muted-foreground">{readableLabel(activeConfig?.coordination_mode ?? team.team_type)}</Badge>
+              <Badge className="border-border bg-muted/40 text-muted-foreground">{teamValueLabel(t, activeConfig?.coordination_mode ?? team.team_type)}</Badge>
             </div>
             <p className="mt-2 break-words text-sm leading-6 text-muted-foreground">
               {activeConfig?.objective || team.description || t("teams.noObjectiveProvided")}
@@ -53,7 +55,7 @@ export function TeamOverview({
             {activeConfig.member_refs_json.length ? (
               <div className="mt-3 grid min-w-0 gap-2">
                 {activeConfig.member_refs_json.map((member, index) => (
-                  <MemberCard key={index} member={member} index={index} />
+                  <MemberCard key={index} member={member} index={index} agentNameById={agentNameById} />
                 ))}
               </div>
             ) : (
@@ -63,9 +65,9 @@ export function TeamOverview({
 
           <section className="min-w-0 space-y-3 rounded-md border border-border bg-muted/20 p-4">
             <SectionHeader icon={<GitBranch className="h-4 w-4" />} title={t("teams.policy")} />
-            <PolicyRow icon={<GitBranch className="h-4 w-4" />} label={t("teams.coordination")} value={readableLabel(activeConfig.coordination_mode)} />
+            <PolicyRow icon={<GitBranch className="h-4 w-4" />} label={t("teams.coordination")} value={teamValueLabel(t, activeConfig.coordination_mode)} />
             <PolicyRow icon={<Activity className="h-4 w-4" />} label={t("teams.maxRoundsField")} value={getJsonString(activeConfig.policy_json, "max_rounds") ?? t("teams.none")} />
-            <PolicyRow icon={<ShieldCheck className="h-4 w-4" />} label={t("teams.handoff")} value={readableLabel(getJsonString(activeConfig.policy_json, "handoff") ?? t("teams.none"))} />
+            <PolicyRow icon={<ShieldCheck className="h-4 w-4" />} label={t("teams.handoff")} value={teamValueLabel(t, getJsonString(activeConfig.policy_json, "handoff") ?? "none")} />
             <div className="rounded-md border border-border bg-surface-elevated p-3">
               <p className="text-xs text-muted-foreground">{t("common.created")}</p>
               <p className="mt-1 truncate text-sm font-medium">{formatDateTime(activeConfig.created_time)}</p>
@@ -93,9 +95,11 @@ function Signal({ icon, label, value }: { icon: ReactNode; label: string; value:
   );
 }
 
-function MemberCard({ member, index }: { member: JSONObject; index: number }) {
+function MemberCard({ member, index, agentNameById }: { member: JSONObject; index: number; agentNameById?: Map<string, string> }) {
   const { t } = useTranslation();
-  const name = getJsonString(member, "name") ?? getJsonString(member, "agent_name") ?? `${t("teams.member")} ${index + 1}`;
+  const agentId = getJsonString(member, "agent_id") ?? getJsonString(member, "agentId") ?? getJsonString(member, "id");
+  const agentName = agentId ? agentNameById?.get(agentId) : undefined;
+  const name = agentName ?? getJsonString(member, "name") ?? getJsonString(member, "agent_name") ?? `${t("teams.member")} ${index + 1}`;
   const role = getJsonString(member, "role") ?? t("teams.member");
   const responsibility = getJsonString(member, "description") ?? getJsonString(member, "responsibility") ?? t("teams.noResponsibilitySummary");
   return (
@@ -107,12 +111,10 @@ function MemberCard({ member, index }: { member: JSONObject; index: number }) {
           </div>
           <div className="min-w-0">
             <p className="truncate text-sm font-semibold">{name}</p>
-            <p className="mt-0.5 truncate text-xs text-muted-foreground">
-              {getJsonString(member, "agent_id") ?? getJsonString(member, "id") ?? t("teams.unboundAgent")}
-            </p>
+            {!agentId ? <p className="mt-0.5 truncate text-xs text-muted-foreground">{t("teams.unboundAgent")}</p> : null}
           </div>
         </div>
-        <Badge className="shrink-0 border-border bg-surface-elevated text-muted-foreground">{readableLabel(role)}</Badge>
+        <Badge className="shrink-0 border-border bg-surface-elevated text-muted-foreground">{teamValueLabel(t, role)}</Badge>
       </div>
       <p className="mt-2 line-clamp-2 break-words text-sm leading-6 text-muted-foreground">{responsibility}</p>
     </div>
@@ -144,6 +146,30 @@ function readableLabel(value: string) {
   return value.split(/[_-]+/).filter(Boolean).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
 }
 
+function teamValueLabel(t: ReturnType<typeof useTranslation>["t"], value: string) {
+  const translationKey = TEAM_VALUE_LABEL_KEYS[value] ?? value;
+  return t(`teams.${translationKey}`, { defaultValue: readableLabel(value) });
+}
+
+const TEAM_VALUE_LABEL_KEYS: Record<string, string> = {
+  collaborative: "collaborative",
+  debate: "debate",
+  executor: "executor",
+  parallel: "parallel",
+  pipeline: "pipeline",
+  planner: "planner",
+  reviewer: "reviewer",
+  round_robin: "roundRobin",
+  parallel_merge: "parallelMerge",
+  specialist: "executor",
+  stop_on_critical: "stopOnCritical",
+  continue_with_warning: "continueWithWarning",
+  retry_once: "retryOnce",
+  supervisor: "supervisor",
+  worker: "executor",
+  none: "none",
+};
+
 function getJsonString(value: JSONObject, key: string) {
   const item = value[key];
   if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") return String(item);

+ 334 - 14
web/src/pages/teams/components/TeamRuns.tsx

@@ -1,57 +1,138 @@
 import * as React from "react";
 import { useTranslation } from "react-i18next";
-import { Activity, Play } from "lucide-react";
-import { createTeamRun } from "@/api";
+import { Activity, MessageSquareText, Play } from "lucide-react";
+import { createTeamRun, executeTeamRun, listTeamRuns } from "@/api";
 import { EmptyState } from "@/components/shared/EmptyState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Select } from "@/components/ui/select";
 import { Textarea } from "@/components/ui/input";
 import { toast } from "@/components/ui/toaster";
-import type { TeamConfig, TeamRun, TeamRunStatus } from "@/types";
+import type { JSONObject, JSONValue, TeamConfig, TeamRun, TeamRunStatus } from "@/types";
 import { relativeTime, truncateMiddle } from "@/lib/utils";
 
 type RunStatusFilter = "all" | TeamRunStatus;
+const RESULT_PREVIEW_LIMIT = 900;
+const RUN_LIST_PREVIEW_LIMIT = 180;
+const INPUT_PREVIEW_LIMIT = 500;
+
+type TeamSpeech = {
+  key: string;
+  agentId?: string;
+  speaker: string;
+  role: string;
+  status?: string;
+  output?: string;
+  error?: string;
+};
 
 export function TeamRuns({
   teamId,
   configs,
   runs,
   loading,
+  agentNameById,
   onRunCreated,
 }: {
   teamId: string;
   configs: TeamConfig[];
   runs: TeamRun[];
   loading: boolean;
+  agentNameById?: Map<string, string>;
   onRunCreated: (run: TeamRun) => void;
 }) {
   const { t } = useTranslation();
   const [statusFilter, setStatusFilter] = React.useState<RunStatusFilter>("all");
   const [inputText, setInputText] = React.useState("");
   const [submitting, setSubmitting] = React.useState(false);
+  const [executingRunId, setExecutingRunId] = React.useState<string>();
+  const [activeRunId, setActiveRunId] = React.useState<string>();
+  const [expandedSpeechKey, setExpandedSpeechKey] = React.useState<string>();
+  const [expandedFallbackOutput, setExpandedFallbackOutput] = React.useState(false);
+  const [executionMeta, setExecutionMeta] = React.useState<{
+    memberRunCount: number;
+    dryRun: boolean;
+    durationMs: number;
+  }>();
 
   const activeConfig = configs[0];
+  const activeRun = runs.find((run) => run.id === activeRunId) ?? runs[0];
+  const activeConversation = activeRun ? getTeamConversation(activeRun, agentNameById) : [];
   const filteredRuns = runs.filter((run) => statusFilter === "all" || run.status === statusFilter);
 
+  React.useEffect(() => {
+    setExpandedSpeechKey(undefined);
+    setExpandedFallbackOutput(false);
+  }, [activeRun?.id]);
+
+  async function pollRunUntilSettled(runId: string) {
+    for (let attempt = 0; attempt < 45; attempt += 1) {
+      await wait(2000);
+      const latestRuns = await listTeamRuns(teamId);
+      const latestRun = latestRuns.find((item) => item.id === runId);
+      if (!latestRun) continue;
+      onRunCreated(latestRun);
+      if (!["queued", "running"].includes(latestRun.status)) {
+        if (latestRun.status === "failed") {
+          toast.error(latestRun.error_message ?? t("teams.failedStartTeamRun"));
+        }
+        return;
+      }
+    }
+  }
+
+  async function continueRun(run: TeamRun) {
+    const startedAt = performance.now();
+    const runningRun: TeamRun = { ...run, status: "running" };
+    setActiveRunId(run.id);
+    setExecutionMeta(undefined);
+    setExecutingRunId(run.id);
+    onRunCreated(runningRun);
+    try {
+      const executed = await executeTeamRun(run.id, {
+        worker_key: "web-team-console",
+        dry_run: false,
+      });
+      setExecutionMeta({
+        memberRunCount: executed.member_run_count,
+        dryRun: executed.dry_run,
+        durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
+      });
+      onRunCreated(executed.run);
+      if (executed.run.status === "failed") {
+        toast.error(executed.run.error_message ?? t("teams.failedStartTeamRun"));
+      } else {
+        toast.success(t("teams.teamRunStarted"));
+      }
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : t("teams.failedStartTeamRun"));
+      await pollRunUntilSettled(run.id);
+    } finally {
+      setExecutingRunId(undefined);
+    }
+  }
+
   async function startRun(event: React.FormEvent) {
     event.preventDefault();
     if (!activeConfig?.id || !inputText.trim()) return;
     setSubmitting(true);
     try {
-      const run = await createTeamRun({ team_id: teamId, team_version_id: activeConfig.id, input_text: inputText });
-      toast.success(t("teams.teamRunStarted"));
+      const runInput = inputText.trim();
+      const createdRun = await createTeamRun({ team_id: teamId, team_version_id: activeConfig.id, input_text: runInput });
       setInputText("");
-      onRunCreated(run);
-    } catch (err) {
-      toast.error(err instanceof Error ? err.message : t("teams.failedStartTeamRun"));
+      setActiveRunId(createdRun.id);
+      setExecutionMeta(undefined);
+      onRunCreated(createdRun);
+      toast.success(t("teams.teamRunStarted"));
+      void pollRunUntilSettled(createdRun.id);
     } finally {
       setSubmitting(false);
     }
   }
 
-  if (loading) return <LoadingSpinner label={t("common.loading")} />;
+  if (loading && !runs.length) return <LoadingSpinner label={t("common.loading")} />;
 
   if (!activeConfig) {
     return <EmptyState icon={Play} title="No team configuration" description="Create or save this team before starting a run." />;
@@ -71,11 +152,90 @@ export function TeamRuns({
           placeholder={t("teams.runInput")}
           onChange={(event) => setInputText(event.target.value)}
         />
-        <Button className="w-full" size="sm" disabled={submitting || !inputText.trim()}>
-          <Play className="h-4 w-4" /> {submitting ? t("teams.starting") : t("teams.startRun")}
+        <Button className="w-full" size="sm" disabled={submitting || Boolean(executingRunId) || !inputText.trim()}>
+          <Play className="h-4 w-4" /> {submitting || executingRunId ? t("teams.starting") : t("teams.startRun")}
         </Button>
       </form>
 
+      {activeRun ? (
+        <section className="min-w-0 space-y-3 rounded-md border border-border bg-surface-base p-4">
+          <div className="flex min-w-0 items-start justify-between gap-3">
+            <div className="min-w-0">
+              <h3 className="truncate text-sm font-semibold">{t("teams.latestResult")}</h3>
+              <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{truncateMiddle(activeRun.id, 32)}</p>
+            </div>
+            <StatusBadge status={executingRunId === activeRun.id ? "running" : activeRun.status} />
+          </div>
+          {activeRun.status === "queued" || activeRun.status === "failed" ? (
+            <Button
+              type="button"
+              className="w-full"
+              size="sm"
+              variant="outline"
+              disabled={executingRunId === activeRun.id}
+              onClick={() => void continueRun(activeRun)}
+            >
+              <Play className="h-4 w-4" />
+              {executingRunId === activeRun.id ? t("teams.starting") : t("teams.continueRun")}
+            </Button>
+          ) : null}
+          <div className="rounded-md border border-border bg-muted/25 p-3">
+            <p className="text-xs font-medium text-muted-foreground">{t("teams.input")}</p>
+            <BoundedText
+              value={activeRun.input_text ?? t("teams.structuredInput")}
+              previewLimit={INPUT_PREVIEW_LIMIT}
+              className="mt-1 text-sm leading-6"
+              t={t}
+            />
+          </div>
+          {activeConversation.length ? (
+            <div className="rounded-md border border-border bg-muted/25">
+              <div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
+                <div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
+                  <MessageSquareText className="h-4 w-4 text-primary" />
+                  {t("teams.teamConversation")}
+                </div>
+                <span className="rounded-md bg-background px-2 py-1 text-xs text-muted-foreground">
+                  {activeConversation.length}
+                </span>
+              </div>
+              <div className="max-h-[68vh] space-y-2 overflow-auto p-3 pr-2">
+                {activeConversation.map((speech) => (
+                  <TeamSpeechCard
+                    key={speech.key}
+                    speech={speech}
+                    expanded={expandedSpeechKey === speech.key}
+                    onToggle={() => setExpandedSpeechKey((current) => (current === speech.key ? undefined : speech.key))}
+                    t={t}
+                  />
+                ))}
+              </div>
+            </div>
+          ) : (
+            <div className="rounded-md border border-border bg-muted/25 p-3">
+              <p className="text-xs font-medium text-muted-foreground">{t("common.output")}</p>
+              <BoundedText
+                value={activeRun.output_text ?? activeRun.error_message ?? t("teams.noOutputYet")}
+                previewLimit={RESULT_PREVIEW_LIMIT}
+                expanded={expandedFallbackOutput}
+                onToggle={() => setExpandedFallbackOutput((current) => !current)}
+                className="mt-1 text-sm leading-6 text-muted-foreground"
+                t={t}
+              />
+            </div>
+          )}
+          {executionMeta ? (
+            <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
+              <span className="rounded-md bg-muted px-2 py-1">
+                {t("teams.memberRuns", { count: executionMeta.memberRunCount })}
+              </span>
+              <span className="rounded-md bg-muted px-2 py-1">{(executionMeta.durationMs / 1000).toFixed(1)}s</span>
+              {executionMeta.dryRun ? <span className="rounded-md bg-muted px-2 py-1">{t("agents.dryRunMode")}</span> : null}
+            </div>
+          ) : null}
+        </section>
+      ) : null}
+
       <div className="min-w-0 space-y-3 rounded-md border border-border bg-muted/20 p-4">
         <div className="flex min-w-0 items-center justify-between gap-3">
           <h3 className="truncate text-sm font-semibold">{t("common.runs")}</h3>
@@ -94,6 +254,7 @@ export function TeamRuns({
             ]}
           />
         </div>
+        {loading ? <LoadingSpinner label={t("common.loading")} /> : null}
         {filteredRuns.length ? (
           <div className="max-h-[520px] space-y-2 overflow-auto pr-1">
             {filteredRuns.map((run) => (
@@ -101,13 +262,31 @@ export function TeamRuns({
                 <div className="flex min-w-0 items-start justify-between gap-3">
                   <div className="min-w-0">
                     <p className="truncate font-mono text-xs">{truncateMiddle(run.id, 32)}</p>
-                    <p className="mt-1 line-clamp-2 break-words text-sm leading-5 text-muted-foreground">{run.input_text ?? t("teams.structuredInput")}</p>
+                    <p className="mt-1 line-clamp-2 break-words text-sm leading-5 text-muted-foreground">
+                      {previewText(run.input_text ?? t("teams.structuredInput"), RUN_LIST_PREVIEW_LIMIT)}
+                    </p>
+                  </div>
+                  <div className="flex shrink-0 flex-col items-end gap-2">
+                    <StatusBadge status={executingRunId === run.id ? "running" : run.status} />
+                    {run.status === "queued" || run.status === "failed" ? (
+                      <Button
+                        type="button"
+                        size="sm"
+                        variant="outline"
+                        disabled={executingRunId === run.id}
+                        onClick={() => void continueRun(run)}
+                      >
+                        <Play className="h-3.5 w-3.5" />
+                        {executingRunId === run.id ? t("teams.starting") : t("teams.continueRun")}
+                      </Button>
+                    ) : null}
                   </div>
-                  <StatusBadge status={run.status} />
                 </div>
                 <div className="mt-3 min-w-0 border-t border-border pt-3 text-sm">
                   <p className="text-xs text-muted-foreground">{relativeTime(run.created_time)}</p>
-                  <p className="mt-1 line-clamp-2 break-words text-muted-foreground">{run.output_text ?? t("teams.noOutputYet")}</p>
+                  <p className="mt-1 line-clamp-2 break-words text-muted-foreground">
+                    {previewText(run.output_text ?? run.error_message ?? t("teams.noOutputYet"), RUN_LIST_PREVIEW_LIMIT)}
+                  </p>
                 </div>
               </div>
             ))}
@@ -120,3 +299,144 @@ export function TeamRuns({
   );
 }
 
+function TeamSpeechCard({
+  speech,
+  expanded,
+  onToggle,
+  t,
+}: {
+  speech: TeamSpeech;
+  expanded: boolean;
+  onToggle: () => void;
+  t: (key: string, options?: Record<string, unknown>) => string;
+}) {
+  const content = speech.output ?? speech.error ?? t("teams.noOutputYet");
+  return (
+    <article className="rounded-md border border-border bg-surface-elevated p-3">
+      <div className="flex min-w-0 items-start justify-between gap-3">
+        <div className="min-w-0">
+          <div className="flex min-w-0 flex-wrap items-center gap-2">
+            <p className="truncate text-sm font-semibold">{speech.speaker}</p>
+            <Badge className="border-border bg-muted/45 text-muted-foreground">
+              {teamValueLabel(t, speech.role)}
+            </Badge>
+          </div>
+          {speech.agentId ? (
+            <p className="mt-0.5 text-xs text-muted-foreground">{t("teams.memberSaid")}</p>
+          ) : null}
+        </div>
+        <StatusBadge status={speech.status} />
+      </div>
+      <BoundedText
+        value={content}
+        previewLimit={RESULT_PREVIEW_LIMIT}
+        expanded={expanded}
+        onToggle={onToggle}
+        className="mt-3 text-sm leading-6 text-muted-foreground"
+        t={t}
+      />
+    </article>
+  );
+}
+
+function BoundedText({
+  value,
+  previewLimit,
+  expanded,
+  onToggle,
+  className,
+  t,
+}: {
+  value: string;
+  previewLimit: number;
+  expanded?: boolean;
+  onToggle?: () => void;
+  className?: string;
+  t: (key: string, options?: Record<string, unknown>) => string;
+}) {
+  const isLong = value.length > previewLimit;
+  const visibleValue = expanded || !isLong ? value : previewText(value, previewLimit);
+  return (
+    <div className={className}>
+      <div className={expanded && isLong ? "max-h-[420px] overflow-auto rounded-md bg-background/70 p-3" : ""}>
+        <p className="whitespace-pre-wrap break-words">{visibleValue}</p>
+      </div>
+      {isLong && onToggle ? (
+        <div className="mt-2 flex items-center justify-between gap-3">
+          <span className="text-xs text-muted-foreground">
+            {value.length.toLocaleString()}
+          </span>
+          <Button type="button" size="sm" variant="ghost" className="h-8 px-2 text-xs" onClick={onToggle}>
+            {expanded ? t("common.collapse") : t("common.expand")}
+          </Button>
+        </div>
+      ) : null}
+    </div>
+  );
+}
+
+function getTeamConversation(run: TeamRun, agentNameById?: Map<string, string>): TeamSpeech[] {
+  const outputJson = run.output_json;
+  if (!isRecord(outputJson)) return [];
+  const memberResults = outputJson.member_results;
+  if (!Array.isArray(memberResults)) return [];
+  return memberResults.flatMap((item, index) => {
+    if (!isRecord(item)) return [];
+    const agentId = readString(item.agent_id) ?? readString(item.agentId);
+    const memberKey = readString(item.member_key) ?? readString(item.memberKey) ?? `member_${index + 1}`;
+    const role = readString(item.member_role) ?? readString(item.memberRole) ?? readString(item.role) ?? "executor";
+    const speaker =
+      readString(item.member_name)
+      ?? readString(item.memberName)
+      ?? readString(item.name)
+      ?? (agentId ? agentNameById?.get(agentId) : undefined)
+      ?? memberKey;
+    return [{
+      key: readString(item.agent_run_id) ?? `${memberKey}_${index}`,
+      agentId,
+      speaker,
+      role,
+      status: readString(item.status),
+      output: readString(item.output_text) ?? readString(item.outputText),
+      error: readString(item.error_message) ?? readString(item.errorMessage),
+    }];
+  });
+}
+
+function isRecord(value: JSONValue | undefined): value is JSONObject {
+  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function readString(value: JSONValue | undefined) {
+  if (typeof value === "string" && value.trim()) return value;
+  if (typeof value === "number" || typeof value === "boolean") return String(value);
+  return undefined;
+}
+
+function previewText(value: string, maxLength: number) {
+  if (value.length <= maxLength) return value;
+  return `${value.slice(0, maxLength).trimEnd()}...`;
+}
+
+function readableLabel(value: string) {
+  return value.split(/[_-]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
+}
+
+function teamValueLabel(t: (key: string, options?: Record<string, unknown>) => string, value: string) {
+  const translationKey = TEAM_VALUE_LABEL_KEYS[value] ?? value;
+  return t(`teams.${translationKey}`, { defaultValue: readableLabel(value) });
+}
+
+const TEAM_VALUE_LABEL_KEYS: Record<string, string> = {
+  executor: "executor",
+  planner: "planner",
+  reviewer: "reviewer",
+  specialist: "executor",
+  supervisor: "supervisor",
+  worker: "executor",
+};
+
+function wait(ms: number) {
+  return new Promise<void>((resolve) => window.setTimeout(resolve, ms));
+}
+

+ 41 - 18
web/src/pages/tools/components/ConnectMcpServerDialog.tsx

@@ -1,11 +1,12 @@
 import * as React from "react";
-import { Plus, Trash2 } from "lucide-react";
+import { Braces, Plus, Trash2 } from "lucide-react";
 import { useTranslation } from "react-i18next";
-import { createTool, createToolConnection } from "@/api";
+import { connectMcpServer } from "@/api";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Input } from "@/components/ui/input";
 import { toast } from "@/components/ui/toaster";
+import type { JSONObject } from "@/types";
 
 interface ConnectMcpServerDialogProps {
   open: boolean;
@@ -17,6 +18,7 @@ export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: Connec
   const { t } = useTranslation();
   const [serverName, setServerName] = React.useState("");
   const [url, setUrl] = React.useState("");
+  const [rawConfig, setRawConfig] = React.useState("");
   const [headers, setHeaders] = React.useState<Array<{ key: string; value: string }>>([]);
   const [timeout, setTimeout] = React.useState("30");
   const [submitting, setSubmitting] = React.useState(false);
@@ -24,6 +26,7 @@ export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: Connec
   function reset() {
     setServerName("");
     setUrl("");
+    setRawConfig("");
     setHeaders([]);
     setTimeout("30");
   }
@@ -47,7 +50,12 @@ export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: Connec
 
   async function submit(event: React.FormEvent) {
     event.preventDefault();
-    if (!serverName.trim() || !url.trim()) return;
+    const pastedConfig = parseConfig(rawConfig);
+    if (rawConfig.trim() && !pastedConfig) {
+      toast.error(t("tools.invalidMcpConfig"));
+      return;
+    }
+    if (!pastedConfig && (!serverName.trim() || !url.trim())) return;
 
     const headersObj: Record<string, string> = {};
     for (const h of headers) {
@@ -58,24 +66,15 @@ export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: Connec
 
     setSubmitting(true);
     try {
-      const tool = await createTool({
-        name: serverName.trim(),
-        tool_type: "mcp",
-        description: `MCP SSE server: ${url.trim()}`,
-      });
-      await createToolConnection({
-        tool_id: tool.id,
-        timeout_ms: Number(timeout) * 1000 || null,
-        input_schema_json: {},
-        output_schema_json: {},
-        invoke_config_json: {
+      await connectMcpServer({
+        name: serverName.trim() || undefined,
+        config: pastedConfig ?? {
           transport: "sse",
           server_name: serverName.trim(),
           url: url.trim(),
           headers: headersObj,
           timeout_seconds: Number(timeout) || null,
         },
-        retry_policy_json: { max_attempts: 1 },
       });
       toast.success(t("tools.mcpConnected"));
       onOpenChange(false);
@@ -91,12 +90,24 @@ export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: Connec
   return (
     <Dialog open={open} onOpenChange={onOpenChange} title={t("tools.connectMcpServer")} className="max-w-lg">
       <form className="space-y-4" onSubmit={submit}>
+        <Field label={t("tools.pasteMcpConfig")}>
+          <textarea
+            className="min-h-28 w-full rounded-md border border-border bg-surface-elevated px-3 py-2 font-mono text-sm outline-none focus:border-primary/70 focus:ring-2 focus:ring-primary/20"
+            value={rawConfig}
+            onChange={(e) => setRawConfig(e.target.value)}
+            placeholder={'{ "server_name": { "url": "http://host:9090/sse", "headers": { "X-MCP-API-KEY": "..." }, "timeout": 50 } }'}
+          />
+          <p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
+            <Braces className="h-3 w-3" />
+            {t("tools.pasteMcpConfigHint")}
+          </p>
+        </Field>
         <Field label={t("tools.serverName")} required>
           <Input
             value={serverName}
             onChange={(e) => setServerName(e.target.value)}
             placeholder={t("tools.serverNamePlaceholder")}
-            required
+            required={!rawConfig.trim()}
           />
         </Field>
         <Field label={t("tools.sseEndpointUrl")} required>
@@ -104,7 +115,7 @@ export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: Connec
             value={url}
             onChange={(e) => setUrl(e.target.value)}
             placeholder="http://host:9090/sse"
-            required
+            required={!rawConfig.trim()}
           />
         </Field>
         <div className="space-y-2">
@@ -146,7 +157,7 @@ export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: Connec
           <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
             {t("common.cancel")}
           </Button>
-          <Button disabled={submitting || !serverName.trim() || !url.trim()}>
+          <Button disabled={submitting || (!rawConfig.trim() && (!serverName.trim() || !url.trim()))}>
             {submitting ? t("tools.connecting") : t("tools.connect")}
           </Button>
         </div>
@@ -155,6 +166,18 @@ export function ConnectMcpServerDialog({ open, onOpenChange, onCreated }: Connec
   );
 }
 
+function parseConfig(value: string): JSONObject | null {
+  if (!value.trim()) return null;
+  try {
+    const parsed = JSON.parse(value);
+    return parsed && typeof parsed === "object" && !Array.isArray(parsed)
+      ? parsed as JSONObject
+      : null;
+  } catch {
+    return null;
+  }
+}
+
 function Field({ label, children, required }: { label: string; children: React.ReactNode; required?: boolean }) {
   return (
     <label className="block space-y-2 text-sm">

+ 4 - 2
web/src/pages/tools/components/ToolDetailSheet.tsx

@@ -104,8 +104,10 @@ function getMcpToolsFromConfig(config?: Record<string, unknown> | null): McpExpo
     result.push({
       name: record.name as string,
       description: typeof record.description === "string" ? record.description : undefined,
-      inputSchema: record.input_schema && typeof record.input_schema === "object"
-        ? record.input_schema as Record<string, unknown>
+      inputSchema: record.inputSchema && typeof record.inputSchema === "object"
+        ? record.inputSchema as Record<string, unknown>
+        : record.input_schema && typeof record.input_schema === "object"
+          ? record.input_schema as Record<string, unknown>
         : undefined,
     });
   }

+ 1 - 7
web/src/types/agent.ts

@@ -1,7 +1,6 @@
 import type { JSONObject } from "./common";
 
 export type AgentStatus = "draft" | "active" | "archived";
-export type AgentVersionStatus = "draft" | "published" | "deprecated";
 export type AgentRunStatus = "queued" | "running" | "completed" | "failed" | "cancelled";
 
 export interface AgentDefinition {
@@ -14,11 +13,9 @@ export interface AgentDefinition {
   created_time: string;
 }
 
-export interface AgentVersion {
+export interface AgentConfig {
   id: string;
   agent_id: string;
-  version_no: number;
-  status: AgentVersionStatus;
   role: string;
   goal?: string | null;
   system_prompt: string;
@@ -27,12 +24,9 @@ export interface AgentVersion {
   runtime_policy_json?: JSONObject;
   tool_refs_json: JSONObject[];
   skill_refs_json: JSONObject[];
-  published_time?: string | null;
   created_time: string;
 }
 
-export type AgentConfig = AgentVersion;
-
 export interface AgentRun {
   id: string;
   agent_id: string;

+ 37 - 38
web/src/types/auth.ts

@@ -1,51 +1,45 @@
 import type { JSONObject } from "./common";
 
-export type UserStatus = "active" | "disabled" | "deleted";
-export type RoleStatus = "active" | "disabled";
-export type RoleAssignmentStatus = "active" | "revoked";
-
 export interface UserContract {
   id: string;
   username: string;
-  display_name?: string | null;
+  displayName?: string | null;
   email?: string | null;
-  status: UserStatus;
-  metadata_json: JSONObject;
-  last_login_time?: string | null;
-  created_time: string;
+  metadata: JSONObject;
+  lastLoginTime?: string | null;
+  createdTime: string;
+  updatedTime: string;
 }
 
 export interface RoleContract {
   id: string;
   name: string;
   description?: string | null;
-  status: RoleStatus;
-  permissions_json: string[];
-  created_time: string;
+  permissionBindingCount: number;
+  createdTime: string;
+  updatedTime: string;
 }
 
-export interface RoleAssignmentContract {
+export interface RolePermissionBindingContract {
   id: string;
-  user_id: string;
-  role_id: string;
-  status: RoleAssignmentStatus;
-  scope_type?: string | null;
-  scope_id?: string | null;
-  expires_time?: string | null;
-  created_time: string;
+  roleId: string;
+  permission: string;
+  scopeType?: string | null;
+  scopeId?: string | null;
+  createdTime: string;
 }
 
 export interface PermissionCheckContract {
-  user_id: string;
+  userId: string;
   permission: string;
-  scope_type?: string | null;
-  scope_id?: string | null;
+  scopeType?: string | null;
+  scopeId?: string | null;
 }
 
 export interface PermissionCheckResultContract {
   allowed: boolean;
   reason: string;
-  matched_role_ids: string[];
+  matchedRoleIds: string[];
 }
 
 export interface LoginRequest {
@@ -53,18 +47,23 @@ export interface LoginRequest {
   password: string;
 }
 
-export interface LoginResponse {
-  access_token: string;
-  token_type: "bearer";
-  expires_time: string;
-  user: {
-    id: string;
-    username: string;
-    display_name?: string | null;
-    email?: string | null;
-    status: UserStatus;
-    metadata_json: JSONObject;
-    last_login_time?: string | null;
-    created_time: string;
-  };
+export interface LoginData {
+  accessToken: string;
+  tokenType: "bearer";
+  expiresTime: string;
+  user: UserContract;
+}
+
+export interface AuthMeData {
+  user: UserContract;
+  roles: RoleContract[];
+  permissions: string[];
+}
+
+export interface TokenVerifyData {
+  active: boolean;
+  userId?: string | null;
+  username?: string | null;
+  expiresTime?: string | null;
+  reason?: string | null;
 }

+ 15 - 0
web/src/types/common.ts

@@ -7,6 +7,21 @@ export interface PaginatedResponse<T> {
   total: number;
   page: number;
   pageSize: number;
+  hasMore?: boolean;
+}
+
+export interface ApiErrorResponse {
+  errorType: string;
+  message: string;
+  details?: JSONObject;
+}
+
+export interface ApiResponse<T> {
+  success: boolean;
+  data: T | null;
+  error?: ApiErrorResponse | null;
+  requestId: string;
+  serverTime: string;
 }
 
 export interface ServiceHealth {

+ 1 - 1
web/src/types/model-provider.ts

@@ -1,4 +1,4 @@
-export type ModelProviderType = "openai" | "anthropic" | "deepseek" | "azure_openai" | "ollama" | "custom";
+export type ModelProviderType = "openai_compatible" | "openai" | "anthropic" | "deepseek" | "azure_openai" | "ollama" | "custom";
 export type ModelType = "chat" | "reasoning" | "embedding" | "image" | "audio" | "video" | "rerank" | "moderation" | "other";
 
 export interface ModelProvider {

+ 3 - 1
web/src/types/model.ts

@@ -3,6 +3,7 @@ import type { JSONObject } from "./common";
 export interface ModelDefinition {
   id: string;
   name: string;
+  provider_id?: string | null;
   provider_type: string;
   provider_base_url: string;
   has_provider_api_key: boolean;
@@ -20,8 +21,9 @@ export interface ModelDefinition {
 
 export interface ModelCreateRequest {
   name: string;
+  provider_id?: string | null;
   provider_type: string;
-  provider_base_url: string;
+  provider_base_url?: string | null;
   provider_api_key?: string | null;
   model_name: string;
   description?: string | null;

+ 7 - 0
web/src/types/team.ts

@@ -38,5 +38,12 @@ export interface TeamRun {
   output_text?: string | null;
   output_json?: JSONObject | null;
   status: TeamRunStatus;
+  worker_key?: string | null;
+  queued_time?: string | null;
+  lease_expire_time?: string | null;
+  started_time?: string | null;
+  finished_time?: string | null;
+  error_code?: string | null;
+  error_message?: string | null;
   created_time: string;
 }

+ 23 - 18
web/vite.config.ts

@@ -1,25 +1,30 @@
-import { defineConfig } from "vite";
+import { defineConfig, loadEnv } from "vite";
 import react from "@vitejs/plugin-react";
 import path from "path";
 
-export default defineConfig({
-  plugins: [react()],
-  resolve: {
-    alias: {
-      "@": path.resolve(__dirname, "./src"),
-    },
-  },
-  server: {
-    port: 5173,
-    proxy: {
-      "/gateway": {
-        target: "http://127.0.0.1:8000",
-        changeOrigin: true,
+export default defineConfig(({ mode }) => {
+  const env = loadEnv(mode, process.cwd(), "");
+  const apiTarget = env.VITE_API_BASE_URL || "http://127.0.0.1:8100";
+
+  return {
+    plugins: [react()],
+    resolve: {
+      alias: {
+        "@": path.resolve(__dirname, "./src"),
       },
-      "/health": {
-        target: "http://127.0.0.1:8000",
-        changeOrigin: true,
+    },
+    server: {
+      port: 5173,
+      proxy: {
+        "/gateway": {
+          target: apiTarget,
+          changeOrigin: true,
+        },
+        "/health": {
+          target: apiTarget,
+          changeOrigin: true,
+        },
       },
     },
-  },
+  };
 });