## Phase 1-3: Control Plane + Contract System - awooop_phase1_control_plane_2026-05-04.sql: 12 張核心表 + RLS - awooop_phase1_batch1_rls_2026-05-04.sql: 全部 FORCE RLS + GRANT - packages/awooop-contracts/: 六合約 JSON Schema + golden fixtures - src/models/awooop_contracts.py: Pydantic v2 contract models(extra=forbid) - src/repositories/contract_repository.py: contract lifecycle(draft→published→active) - src/services/contract_service.py: HMAC publish sig + Redis multi-sig activate - src/services/schema_validator.py: LLM output validator(retry×3, E-SCHEMA-001) ## Phase 2: Tenant Isolation - awooop_phase2_budget_ledger_2026-05-04.sql: budget_ledger + RLS - src/services/budget_service.py: Token Budget Hard Kill 三層防線 - src/core/context.py: PROJECT_ID ContextVar(31 background loop 自動繼承) - src/db/base.py + models.py: project_id 欄位 + RLS set_config 注入 - src/hermes/nl_gateway.py: project_id Redis key 前綴(Phase A 雙寫) - src/services/anomaly_counter.py: per-project 改造(Phase A fallback) ## Phase 4: Platform Shell in Shadow Mode - awooop_phase4_run_state_2026-05-04.sql: run_state + step_journal + idempotency - src/services/run_state_machine.py: 8-state FSM + SKIP LOCKED + stale reaper - src/services/platform_runtime.py: UUID v7 + W3C trace_id + shadow_execute - src/services/audit_sink.py: PII/secret redaction 9 patterns - src/api/v1/platform/runs.py: POST/GET /v1/platform/runs(Router→Service 架構) - src/workers/platform_worker.py: SKIP LOCKED worker + heartbeat + reaper loop - src/main.py: platform router + lifespan worker start/stop ## Phase 5: MCP Gateway 五閘門 - awooop_phase5_mcp_gateway_2026-05-04.sql: 4 表 + RLS - src/plugins/mcp/gateway.py: McpGateway(Gate 1~5, E-MCP-GATE-001~009) - src/plugins/mcp/redaction_middleware.py: 雙層 redaction + 16K 截斷 - src/plugins/mcp/registry.py: __provider name mangling(ADR-116) - src/plugins/mcp/credential_resolver.py: k8s secret ref 解析 - tests/test_mcp_credential_isolation.py: 10 個迴歸測試(secret leak 防再現) ## Phase 6-8: EwoooC + Channel Hub + Approval Token - awooop_phase6_ewoooc_onboarding_2026-05-04.sql: ewoooc tenant + 4 read-only MCP tools - awooop_phase7_channel_hub_2026-05-04.sql: conversation_event + outbound_message - src/services/provider_proxy.py: ProviderProxy + PlatformEnvelope(ADR-115) - src/services/channel_hub.py: Telegram inbound mirror + Progressive Feedback(30s) - src/services/awooop_approval_token.py: HS256 + jti NX replay 防護 + suggest mode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
438 lines
16 KiB
Python
438 lines
16 KiB
Python
"""
|
||
AwoooP Contract Pydantic Models
|
||
================================
|
||
Phase 3: 六合約家族 Pydantic v2 驗證模型(ADR-112)
|
||
2026-05-04 ogt + Claude Sonnet 4.6
|
||
|
||
六合約家族:
|
||
1. ProjectTenantContract — 租戶/專案能力邊界
|
||
2. AgentContract — Agent 模型、工具、治理
|
||
3. MCPGatewayContract — MCP 工具閘道
|
||
4. PolicyRoutingContract — LLM 路由規則
|
||
5. RuntimeRunStateContract — Run FSM 狀態
|
||
6. ChannelEventContract — Channel 事件(冪等)
|
||
|
||
所有含 artifact ref 的欄位都附 sha256(ADR-112 artifact integrity)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import datetime
|
||
from enum import Enum
|
||
from typing import Any
|
||
from uuid import UUID
|
||
|
||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 共用型別
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||
_PROJECT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,63}$")
|
||
_AGENT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,127}$")
|
||
_UUID_RE = re.compile(
|
||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||
)
|
||
|
||
|
||
def _validate_sha256(v: str | None, field_name: str = "sha256") -> str | None:
|
||
if v is None:
|
||
return v
|
||
if not _SHA256_RE.match(v):
|
||
raise ValueError(f"{field_name} 必須為 64 位 hex 字串")
|
||
return v
|
||
|
||
|
||
class MigrationMode(str, Enum):
|
||
LEGACY = "legacy_awoooi_default"
|
||
SHADOW = "shadow"
|
||
CANARY = "canary"
|
||
ACTIVE = "active"
|
||
|
||
|
||
class ChannelType(str, Enum):
|
||
TELEGRAM = "telegram"
|
||
SLACK = "slack"
|
||
WEBHOOK = "webhook"
|
||
API = "api"
|
||
|
||
|
||
class Provider(str, Enum):
|
||
ANTHROPIC = "anthropic"
|
||
OPENAI = "openai"
|
||
OLLAMA = "ollama"
|
||
GEMINI = "gemini"
|
||
NVIDIA = "nvidia"
|
||
OPENROUTER = "openrouter"
|
||
|
||
|
||
class RunState(str, Enum):
|
||
PENDING = "pending"
|
||
RUNNING = "running"
|
||
WAITING_APPROVAL = "waiting_approval"
|
||
WAITING_TOOL = "waiting_tool"
|
||
COMPLETED = "completed"
|
||
FAILED = "failed"
|
||
CANCELLED = "cancelled"
|
||
TIMEOUT = "timeout"
|
||
|
||
|
||
class AuthScheme(str, Enum):
|
||
NONE = "none"
|
||
BEARER = "bearer"
|
||
HMAC = "hmac"
|
||
|
||
|
||
class Transport(str, Enum):
|
||
STDIO = "stdio"
|
||
HTTP = "http"
|
||
SSE = "sse"
|
||
|
||
|
||
class EventType(str, Enum):
|
||
MESSAGE_RECEIVED = "message_received"
|
||
CALLBACK_QUERY = "callback_query"
|
||
COMMAND_INVOKED = "command_invoked"
|
||
WEBHOOK_POST = "webhook_post"
|
||
API_REQUEST = "api_request"
|
||
APPROVAL_RESPONSE = "approval_response"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 1. Project Tenant Contract
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class ProjectTenantContract(BaseModel):
|
||
"""租戶/專案合約(ADR-111/115)"""
|
||
|
||
model_config = {"extra": "forbid"}
|
||
|
||
project_id: str = Field(..., description="全局唯一租戶識別符")
|
||
display_name: str = Field(..., min_length=1, max_length=256)
|
||
migration_mode: MigrationMode = MigrationMode.LEGACY
|
||
budget_limit_usd: float | None = Field(None, ge=0)
|
||
allowed_channels: list[ChannelType] = Field(default_factory=list)
|
||
is_active: bool = True
|
||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
@field_validator("project_id")
|
||
@classmethod
|
||
def validate_project_id(cls, v: str) -> str:
|
||
if not _PROJECT_ID_RE.match(v):
|
||
raise ValueError("project_id 只允許 a-z, 0-9, _, -,長度 2-64")
|
||
return v
|
||
|
||
@field_validator("allowed_channels")
|
||
@classmethod
|
||
def validate_unique_channels(cls, v: list[ChannelType]) -> list[ChannelType]:
|
||
if len(v) != len(set(v)):
|
||
raise ValueError("allowed_channels 不可包含重複項目")
|
||
return v
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 2. Agent Contract
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class ArtifactRef(BaseModel):
|
||
"""含 SHA-256 的 artifact 參照(ADR-112 artifact integrity)"""
|
||
|
||
model_config = {"extra": "forbid"}
|
||
|
||
artifact_id: str
|
||
sha256: str = Field(..., description="SHA-256 hex digest(64 位)")
|
||
|
||
@field_validator("sha256")
|
||
@classmethod
|
||
def validate_sha256(cls, v: str) -> str:
|
||
return _validate_sha256(v, "sha256") # type: ignore[return-value]
|
||
|
||
|
||
class ToolRef(BaseModel):
|
||
"""Agent 工具參照"""
|
||
|
||
model_config = {"extra": "allow"}
|
||
|
||
tool_name: str
|
||
mcp_gateway_id: str | None = None
|
||
sha256: str | None = None
|
||
|
||
@field_validator("sha256")
|
||
@classmethod
|
||
def validate_sha256(cls, v: str | None) -> str | None:
|
||
return _validate_sha256(v, "tool sha256")
|
||
|
||
|
||
class AgentContract(BaseModel):
|
||
"""Agent 合約(ADR-112)"""
|
||
|
||
model_config = {"extra": "forbid"}
|
||
|
||
agent_id: str = Field(..., description="Agent 識別符")
|
||
agent_name: str = Field(..., min_length=1, max_length=256)
|
||
model: str = Field(..., min_length=1, max_length=128)
|
||
provider: Provider
|
||
max_tokens: int | None = Field(None, ge=1, le=200000)
|
||
temperature: float | None = Field(None, ge=0.0, le=2.0)
|
||
system_prompt_ref: ArtifactRef | None = None
|
||
tools: list[ToolRef] = Field(default_factory=list)
|
||
budget_limit_usd_per_run: float | None = Field(None, ge=0)
|
||
require_approval: bool = False
|
||
approval_timeout_seconds: int | None = Field(None, ge=60, le=86400)
|
||
max_parallel_runs: int = Field(1, ge=1, le=100)
|
||
tags: list[str] = Field(default_factory=list)
|
||
|
||
@field_validator("agent_id")
|
||
@classmethod
|
||
def validate_agent_id(cls, v: str) -> str:
|
||
if not _AGENT_ID_RE.match(v):
|
||
raise ValueError("agent_id 只允許 a-z, 0-9, _, -,長度 2-128")
|
||
return v
|
||
|
||
@model_validator(mode="after")
|
||
def validate_approval_config(self) -> AgentContract:
|
||
if self.require_approval and self.approval_timeout_seconds is None:
|
||
self.approval_timeout_seconds = 300
|
||
return self
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 3. MCP Gateway Contract
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class ToolExposed(BaseModel):
|
||
"""Gateway 暴露的工具定義"""
|
||
|
||
model_config = {"extra": "forbid"}
|
||
|
||
tool_name: str
|
||
description: str | None = None
|
||
schema_sha256: str = Field(..., description="工具 input schema SHA-256")
|
||
is_destructive: bool = False
|
||
|
||
@field_validator("schema_sha256")
|
||
@classmethod
|
||
def validate_schema_sha256(cls, v: str) -> str:
|
||
return _validate_sha256(v, "schema_sha256") # type: ignore[return-value]
|
||
|
||
|
||
class MCPGatewayContract(BaseModel):
|
||
"""MCP Gateway 合約(ADR-113)"""
|
||
|
||
model_config = {"extra": "forbid"}
|
||
|
||
gateway_id: str
|
||
gateway_name: str = Field(..., min_length=1, max_length=256)
|
||
transport: Transport
|
||
endpoint: str | None = None
|
||
auth_scheme: AuthScheme = AuthScheme.NONE
|
||
hmac_secret_ref: str | None = None
|
||
tools_exposed: list[ToolExposed] = Field(default_factory=list)
|
||
rate_limit_rpm: int | None = Field(None, ge=1)
|
||
timeout_seconds: int = Field(30, ge=1, le=300)
|
||
is_enabled: bool = True
|
||
|
||
@model_validator(mode="after")
|
||
def validate_http_endpoint(self) -> MCPGatewayContract:
|
||
if self.transport in (Transport.HTTP, Transport.SSE) and not self.endpoint:
|
||
raise ValueError(f"transport={self.transport} 時 endpoint 為必填")
|
||
return self
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 4. Policy Routing Contract
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class TimeRange(BaseModel):
|
||
model_config = {"extra": "forbid"}
|
||
|
||
start_utc: str = Field(..., pattern=r"^[0-2][0-9]:[0-5][0-9]$")
|
||
end_utc: str = Field(..., pattern=r"^[0-2][0-9]:[0-5][0-9]$")
|
||
|
||
|
||
class RoutingCondition(BaseModel):
|
||
model_config = {"extra": "forbid"}
|
||
|
||
task_types: list[str] = Field(default_factory=list)
|
||
max_prompt_tokens: int | None = Field(None, ge=1)
|
||
time_range: TimeRange | None = None
|
||
|
||
|
||
class RoutingRule(BaseModel):
|
||
model_config = {"extra": "forbid"}
|
||
|
||
rule_id: str
|
||
priority: int = Field(..., ge=0, le=9999)
|
||
provider: Provider
|
||
model: str
|
||
condition: RoutingCondition | None = None
|
||
weight: int = Field(100, ge=1, le=100)
|
||
|
||
|
||
class RetryPolicy(BaseModel):
|
||
model_config = {"extra": "forbid"}
|
||
|
||
max_retries: int = Field(3, ge=0, le=10)
|
||
backoff_base_seconds: float = Field(1.0, ge=0.1, le=60)
|
||
retry_on_provider_errors: bool = True
|
||
|
||
|
||
class PolicyRoutingContract(BaseModel):
|
||
"""路由/政策合約"""
|
||
|
||
model_config = {"extra": "forbid"}
|
||
|
||
policy_id: str
|
||
policy_name: str = Field(..., min_length=1, max_length=256)
|
||
routing_rules: list[RoutingRule] = Field(..., min_length=1)
|
||
fallback_provider: Provider | None = None
|
||
fallback_model: str | None = None
|
||
max_cost_per_run_usd: float | None = Field(None, ge=0)
|
||
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
||
effective_from: datetime | None = None
|
||
effective_to: datetime | None = None
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 5. Runtime Run State Contract
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class RunTrigger(BaseModel):
|
||
model_config = {"extra": "forbid"}
|
||
|
||
trigger_type: str = Field(
|
||
..., pattern="^(channel_event|schedule|api|sub_agent|retry)$"
|
||
)
|
||
channel_event_id: str | None = None
|
||
schedule_id: str | None = None
|
||
triggered_by: str | None = None
|
||
|
||
|
||
class RuntimeRunStateContract(BaseModel):
|
||
"""Run 狀態機合約(ADR-106 Phase 3)"""
|
||
|
||
model_config = {"extra": "forbid"}
|
||
|
||
run_id: str = Field(..., description="UUID v7")
|
||
project_id: str
|
||
agent_id: str
|
||
state: RunState
|
||
trace_id: str | None = None
|
||
parent_run_id: str | None = None
|
||
trigger: RunTrigger | None = None
|
||
input_sha256: str | None = None
|
||
output_sha256: str | None = None
|
||
started_at: datetime | None = None
|
||
completed_at: datetime | None = None
|
||
timeout_at: datetime | None = None
|
||
error_code: str | None = None
|
||
cost_usd: float | None = Field(None, ge=0)
|
||
step_count: int = Field(0, ge=0)
|
||
|
||
@field_validator("run_id", "parent_run_id")
|
||
@classmethod
|
||
def validate_uuid(cls, v: str | None) -> str | None:
|
||
if v is None:
|
||
return v
|
||
if not _UUID_RE.match(v):
|
||
raise ValueError("必須為標準 UUID 格式")
|
||
return v
|
||
|
||
@field_validator("input_sha256", "output_sha256")
|
||
@classmethod
|
||
def validate_sha256_fields(cls, v: str | None) -> str | None:
|
||
return _validate_sha256(v)
|
||
|
||
@field_validator("project_id")
|
||
@classmethod
|
||
def validate_project_id(cls, v: str) -> str:
|
||
if not _PROJECT_ID_RE.match(v):
|
||
raise ValueError("project_id 格式不合法")
|
||
return v
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 6. Channel Event Contract
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class AttachmentRef(BaseModel):
|
||
model_config = {"extra": "forbid"}
|
||
|
||
attachment_type: str = Field(..., pattern="^(photo|document|audio|video)$")
|
||
file_id: str
|
||
sha256: str | None = None
|
||
|
||
@field_validator("sha256")
|
||
@classmethod
|
||
def validate_sha256(cls, v: str | None) -> str | None:
|
||
return _validate_sha256(v, "attachment sha256")
|
||
|
||
|
||
class ChannelEventContract(BaseModel):
|
||
"""Channel Event 合約(ADR-114 冪等去重)"""
|
||
|
||
model_config = {"extra": "forbid"}
|
||
|
||
event_id: str = Field(..., description="Platform 生成的 UUID")
|
||
project_id: str
|
||
channel_type: ChannelType
|
||
event_type: EventType
|
||
provider_event_id: str | None = Field(None, max_length=256)
|
||
user_id: str | None = None
|
||
chat_id: str | None = None
|
||
payload: dict[str, Any] = Field(..., min_length=1)
|
||
text: str | None = Field(None, max_length=4096)
|
||
attachments: list[AttachmentRef] = Field(default_factory=list)
|
||
run_id: str | None = None
|
||
is_duplicate: bool = False
|
||
received_at: datetime
|
||
|
||
@field_validator("event_id", "run_id")
|
||
@classmethod
|
||
def validate_uuid(cls, v: str | None) -> str | None:
|
||
if v is None:
|
||
return v
|
||
if not _UUID_RE.match(v):
|
||
raise ValueError("必須為標準 UUID 格式")
|
||
return v
|
||
|
||
@field_validator("project_id")
|
||
@classmethod
|
||
def validate_project_id(cls, v: str) -> str:
|
||
if not _PROJECT_ID_RE.match(v):
|
||
raise ValueError("project_id 格式不合法")
|
||
return v
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Contract family dispatcher
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
CONTRACT_FAMILY_MODELS: dict[str, type[BaseModel]] = {
|
||
"project_tenant": ProjectTenantContract,
|
||
"agent": AgentContract,
|
||
"mcp_gateway": MCPGatewayContract,
|
||
"policy_routing": PolicyRoutingContract,
|
||
"runtime_run_state": RuntimeRunStateContract,
|
||
"channel_event": ChannelEventContract,
|
||
}
|
||
|
||
VALID_CONTRACT_FAMILIES = frozenset(CONTRACT_FAMILY_MODELS.keys())
|
||
|
||
|
||
def validate_contract_body(family: str, body: dict[str, Any]) -> BaseModel:
|
||
"""
|
||
依 contract_family 驗證 body_json。
|
||
驗證失敗拋出 pydantic.ValidationError。
|
||
"""
|
||
model_cls = CONTRACT_FAMILY_MODELS.get(family)
|
||
if model_cls is None:
|
||
raise ValueError(
|
||
f"未知 contract_family: {family!r}。"
|
||
f"合法值:{sorted(VALID_CONTRACT_FAMILIES)}"
|
||
)
|
||
return model_cls.model_validate(body)
|