forked from xiaohei/taiji-AI-PAD
== Heicode integration (~41 endpoints across 5 modules) ==
- §2 ResourceBinding (5 endpoints) — resources.py / resource_grants.py
- §4 NewAPI metadata proxy (4 endpoints) — heicode_proxy.py + heicode_client.py
- §5 Agnet platform stub (12 endpoints, in-memory mock) — agnet_stub.py
- §6 Task orchestration (5 endpoints + 3 extension endpoints) — heicode_tasks.py
6.1-6.5: intent / list / get / answer / messages
6.6-6.8: execution / delivery / audit?tab=... (Slice 8/9/10)
- §7 SSE single channel + approvals (4 endpoints + 5 event types) —
heicode_events.py + event_bus.py
- §7.8.1 internal billing-provider PUT endpoint — auth.py (routes)
== Schema changes ==
- migrations/026 heicode_tasks (orchestration state)
- migrations/027 users.billing_provider (litellm | newapi switch)
- migrations/028 heicode_approvals (high-risk approval queue)
== Register transaction hardening (P0 + P1 + P2) ==
routes/auth.py register():
- Pre-existing P0: failed register returned IntegrityError str verbatim
(leaking SQL params + ~50 plaintext LiteLLM keys per attempt).
Now logs exc_info, returns {code: REGISTER_FAILED, message: ...}.
- Pre-existing P0: model dedupe — two ModelProvider rows with overlapping
supported_models (e.g. taiji/gpt-4o-mini in both taiji and azure providers)
collide on uq_tenant_model. seen_models set deduplicates within the loop.
- New P1: track created_litellm_keys; on any failure call delete_key() for
each — prevents remote orphan keys when DB rollback fires.
- New P1: replace verify_code with peek_verification_code at the start;
only call verify_code (which consumes) after commit succeeds. Failed
registrations no longer burn the user's one-shot code.
- New P2: narrow inner `except (LiteLLMClientError, Exception)` to just
LiteLLMClientError so SQLAlchemy errors bubble to the outer rollback
instead of being silently swallowed into a half-allocated 200 response.
- New P2: same narrowing on outer `except (AgentManagerError, Exception)`.
== Auth middleware ==
- app/auth.py: allow /api/auth/internal/billing-provider and
/api/auth/internal/approvals to bypass user JWT (service-token auth
via HEICODE_INTERNAL_SERVICE_TOKEN, validated in-route).
== Docs ==
- Heicode-接口契约文档.md v2.2 (41 endpoints + SSE schema + 6.6-6.8)
- Heicode-对接进度与待办.md (through §7.14 SSE + 7.8.2 delivery回执)
- Heicode-完整调用流程图.md (sequence + routing diagrams)
- Agent-Manager-Heicode对接需求文档.md
- HEICODE_API_INTEGRATION.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 lines
2.5 KiB
Python
72 lines
2.5 KiB
Python
"""
|
||
Heicode SSE 事件总线(§7.8.3 配套)
|
||
|
||
进程内 pub/sub:每个用户的活跃 SSE 订阅者用一个 asyncio.Queue 排队接收事件;
|
||
其他业务路径(approvals/tasks/...)通过 emit() 发布事件。
|
||
|
||
⚠️ 单进程范围:mcp-server 多副本部署下,副本 A 上的订阅者**收不到**副本 B 上
|
||
emit 的事件。MVP 阶段可接受(cc-haha 单连接);生产化时换成 Redis pub/sub
|
||
或 NATS(mcp-server 已部署 NATS)即可,emit/subscribe 接口不变。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from typing import Any, Dict, List, Optional, Set
|
||
|
||
|
||
class _EventBus:
|
||
"""每用户独立队列的内存 pub/sub。"""
|
||
|
||
def __init__(self):
|
||
# user_id (str) → set of asyncio.Queue
|
||
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
|
||
self._lock = asyncio.Lock()
|
||
|
||
async def subscribe(self, user_id: str) -> asyncio.Queue:
|
||
"""订阅指定用户的事件流。返回一个新的 Queue。"""
|
||
queue: asyncio.Queue = asyncio.Queue(maxsize=256)
|
||
async with self._lock:
|
||
self._subscribers.setdefault(user_id, set()).add(queue)
|
||
return queue
|
||
|
||
async def unsubscribe(self, user_id: str, queue: asyncio.Queue) -> None:
|
||
async with self._lock:
|
||
subs = self._subscribers.get(user_id)
|
||
if subs:
|
||
subs.discard(queue)
|
||
if not subs:
|
||
self._subscribers.pop(user_id, None)
|
||
|
||
async def emit(self, user_id: str, event_type: str, data: Dict[str, Any]) -> int:
|
||
"""向指定用户的所有订阅者推送事件。返回送达的订阅者数。
|
||
|
||
队列已满时 drop(不阻塞 emit;客户端断重连后会重拉一次状态)。
|
||
"""
|
||
payload = {"event": event_type, "data": data}
|
||
delivered = 0
|
||
async with self._lock:
|
||
subs = list(self._subscribers.get(user_id, []))
|
||
for q in subs:
|
||
try:
|
||
q.put_nowait(payload)
|
||
delivered += 1
|
||
except asyncio.QueueFull:
|
||
pass
|
||
return delivered
|
||
|
||
def subscriber_count(self, user_id: Optional[str] = None) -> int:
|
||
if user_id is not None:
|
||
return len(self._subscribers.get(user_id, set()))
|
||
return sum(len(s) for s in self._subscribers.values())
|
||
|
||
|
||
# 单例
|
||
_default_bus: Optional[_EventBus] = None
|
||
|
||
|
||
def get_event_bus() -> _EventBus:
|
||
global _default_bus
|
||
if _default_bus is None:
|
||
_default_bus = _EventBus()
|
||
return _default_bus
|