""" Provider Proxy Adapter — EwoooC AwoooP Envelope 注入 ===================================================== AwoooP Phase 6: ADR-115 D3 2026-05-04 ogt + Claude Sonnet 4.6 功能: EwoooC(或任何外部 tenant)的請求在進入 AwoooP 前, 必須注入完整的 platform envelope,確保: - project_id 正確(budget/audit/RLS 有效) - agent_id 存在(Gate 2 通過) - trace_id / run_id 有 W3C traceparent format - platform_subject_id 已建立(channel user 身份映射) 使用方式: from src.services.provider_proxy import ProviderProxy proxy = ProviderProxy(project_id="ewoooc", db=db) envelope = await proxy.build_envelope( agent_id="openclaw-biz", channel_type="telegram", channel_user_id="123456789", channel_chat_id="123456789", ) # envelope 可直接作為 GatewayContext 的初始化參數 設計原則(ADR-115 D3): - Proxy 只做 envelope 注入(<1ms),不做額外複雜 IO - platform_subject upsert 是唯一 DB write(auto-provisioning) - run_id 由 platform_runtime.create_run() 分配,Proxy 不自行生成 - 每個 tenant 有獨立的 budget partition 和 RLS 隔離 """ from __future__ import annotations import hashlib import os import re import struct import time import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any from uuid import UUID import structlog from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from src.db.awooop_models import AwoooPPlatformSubject, AwoooPProject logger = structlog.get_logger(__name__) # ───────────────────────────────────────────────────────────────────────────── # Platform Envelope # ───────────────────────────────────────────────────────────────────────────── @dataclass class PlatformEnvelope: """ AwoooP Platform Envelope — 每個 EwoooC 請求注入的 metadata。 下游(Gateway / Budget / Audit)都依賴這個 envelope。 """ project_id: str agent_id: str trace_id: str # W3C traceparent platform_subject_id: str # "{project_id}:{channel_type}:{channel_user_id}" channel_type: str channel_user_id: str channel_chat_id: str | None = None run_id: UUID | None = None # 由 create_run() 填入 policy_revision_id: str | None = None # active policy contract revision tags: dict[str, Any] = field(default_factory=dict) def as_dict(self) -> dict[str, Any]: return { "project_id": self.project_id, "agent_id": self.agent_id, "trace_id": self.trace_id, "platform_subject_id": self.platform_subject_id, "channel_type": self.channel_type, "channel_user_id": self.channel_user_id, "channel_chat_id": self.channel_chat_id, "run_id": str(self.run_id) if self.run_id else None, "policy_revision_id": self.policy_revision_id, } # ───────────────────────────────────────────────────────────────────────────── # W3C traceparent 生成 # ───────────────────────────────────────────────────────────────────────────── def _new_trace_id() -> str: """生成 W3C traceparent 格式 trace_id。格式:00-{32hex}-{16hex}-01""" trace_id = uuid.uuid4().hex # 32 hex chars = 128 bits span_id = uuid.uuid4().hex[:16] # 16 hex chars = 64 bits return f"00-{trace_id}-{span_id}-01" # ───────────────────────────────────────────────────────────────────────────── # platform_subject_id 格式 # ───────────────────────────────────────────────────────────────────────────── def build_platform_subject_id(project_id: str, channel_type: str, channel_user_id: str) -> str: """ 格式:{project_id}:{channel_type}:{channel_user_id} 例:ewoooc:telegram:123456789 """ return f"{project_id}:{channel_type}:{channel_user_id}" # ───────────────────────────────────────────────────────────────────────────── # ProviderProxy # ───────────────────────────────────────────────────────────────────────────── class ProviderProxy: """ AwoooP Provider Proxy Adapter(ADR-115 D3)。 職責: 1. 驗證 project 存在且不是 legacy mode 2. upsert platform_subject(auto-provisioning) 3. 生成 trace_id(W3C traceparent) 4. 返回 PlatformEnvelope 供下游使用 """ def __init__(self, project_id: str, db: AsyncSession) -> None: self.project_id = project_id self._db = db async def build_envelope( self, *, agent_id: str, channel_type: str, channel_user_id: str, channel_chat_id: str | None = None, display_name: str | None = None, extra_tags: dict[str, Any] | None = None, ) -> PlatformEnvelope: """ 建立 PlatformEnvelope: 1. 驗證 project_id(不是 legacy mode) 2. upsert platform_subject(auto-provisioning) 3. 生成 trace_id 4. 返回 envelope """ await self._validate_project() await self._upsert_platform_subject( channel_type=channel_type, channel_user_id=channel_user_id, channel_chat_id=channel_chat_id, display_name=display_name, ) platform_subject_id = build_platform_subject_id( self.project_id, channel_type, channel_user_id ) trace_id = _new_trace_id() logger.info( "provider_proxy_envelope_built", project_id=self.project_id, agent_id=agent_id, channel_type=channel_type, platform_subject_id=platform_subject_id, trace_id=trace_id[:32] + "...", # 只 log 前 32 字元 ) return PlatformEnvelope( project_id=self.project_id, agent_id=agent_id, trace_id=trace_id, platform_subject_id=platform_subject_id, channel_type=channel_type, channel_user_id=channel_user_id, channel_chat_id=channel_chat_id, tags=extra_tags or {}, ) async def _validate_project(self) -> None: """project 必須存在且不是 legacy_awoooi_default mode""" result = await self._db.execute( select(AwoooPProject).where( AwoooPProject.project_id == self.project_id, AwoooPProject.migration_mode != "legacy_awoooi_default", ) ) project = result.scalar_one_or_none() if project is None: raise ValueError( f"project '{self.project_id}' 不存在或 migration_mode=legacy_awoooi_default" "(EwoooC 接入需要至少 migration_mode='shadow')" ) async def _upsert_platform_subject( self, *, channel_type: str, channel_user_id: str, channel_chat_id: str | None, display_name: str | None, ) -> None: """ Auto-provisioning:第一次看到這個 channel user 就建立 platform_subject。 後續請求更新 last_seen_at。 """ platform_subject_id = build_platform_subject_id( self.project_id, channel_type, channel_user_id ) now = datetime.now(timezone.utc) await self._db.execute( text(""" INSERT INTO awooop_platform_subjects ( project_id, channel_type, channel_user_id, channel_chat_id, platform_subject_id, display_name, roles, first_seen_at, last_seen_at ) VALUES ( :project_id, :channel_type, :channel_user_id, :channel_chat_id, :platform_subject_id, :display_name, '["viewer"]'::jsonb, :now, :now ) ON CONFLICT (project_id, channel_type, channel_user_id) DO UPDATE SET last_seen_at = :now, channel_chat_id = COALESCE(EXCLUDED.channel_chat_id, awooop_platform_subjects.channel_chat_id), display_name = COALESCE(EXCLUDED.display_name, awooop_platform_subjects.display_name) """), { "project_id": self.project_id, "channel_type": channel_type, "channel_user_id": channel_user_id, "channel_chat_id": channel_chat_id, "platform_subject_id": platform_subject_id, "display_name": display_name, "now": now, }, )