✨ feat: WP-05 模型网关核心——逻辑模型、流式适配与 Run 真实执行

- 迁移 7+8:logical_model(能力目录)、provider_endpoint(密钥只存环境变量名,永不落库)、turn、model_call(每 attempt 单独持久化:usage/TTFT/错误,Provider 未返回保持 null 不估算——ADR-003);message.run_id 关联
- OpenAI-compatible 流式适配器:httpx SSE 解析、标准化 StreamChunk(delta/usage/done)、transport 可注入(测试与故障注入);密钥经环境变量读取不进日志
- Run 执行器:后台执行完整生命周期——running → content.block.started → 真实 content.block.delta*(流式产生,delta_index 单调)→ content.block.completed → message.completed → 终态;assistant 消息为权威快照;ModelCall 记录 usage 与 TTFT;无可用端点时 Run 显式失败并留 error 事件(fail-closed)
- post_message:响应提交后由框架 BackgroundTasks 执行 Run(显式预提交保证 Run 落库);SSE 改为增量轮询(重放 + 等待新事件至终态,30s 上限)
- 测试 54 项全绿(+2):端到端 mock 流验证 ModelCall usage/TTFT/assistant 权威消息、无端点 Run failed + content.block.failed 事件
This commit is contained in:
chenxin committed 2026-09-11 09:44:54 +08:00
1 parent 40221170ae
commit b4242f3844
10 files changed
+789 -56

No files matched your search

+42 -13
View File
@@ -4,7 +4,7 @@ import json
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import select
@@ -51,6 +51,7 @@ async def post_message(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
background_tasks: BackgroundTasks,
body: MessageIn,
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
) -> MessageCreatedOut | JSONResponse:
@@ -90,6 +91,17 @@ async def post_message(
if replayed:
# 重放成功按契约返回 200 与原资源(避开装饰器固定 201)
return JSONResponse(status_code=200, content=json.loads(payload.model_dump_json()))
if run is not None:
# 先显式提交:确保 Run 在后台任务执行前已落库(与依赖清理顺序解耦)
await db.commit()
from app.gateway import execute_run
background_tasks.add_task(
execute_run,
request.app.state.session_factory,
run.id,
transport=getattr(request.app.state, "gw_transport", None),
)
return payload
@@ -111,23 +123,40 @@ async def run_events(
if run.personal_domain_id != domain.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
import asyncio as _asyncio
from app.models import RunEvent
events = (
await db.execute(
select(RunEvent)
.where(RunEvent.run_id == run.id)
.order_by(RunEvent.sequence.asc())
)
).scalars().all()
terminal = {"completed", "failed", "cancelled"}
async def event_stream() -> AsyncIterator[str]:
for event in events:
payload = json.dumps(
{"type": event.type, "data": event.data, "sequence": event.sequence},
ensure_ascii=False,
last_seq = -1
deadline = _asyncio.get_event_loop().time() + 30.0
while True:
rows = list(
(
await db.execute(
select(RunEvent)
.where(RunEvent.run_id == run.id, RunEvent.sequence > last_seq)
.order_by(RunEvent.sequence.asc())
)
).scalars().all()
)
yield f"data: {payload}\n\n"
for event in rows:
payload = json.dumps(
{"type": event.type, "data": event.data, "sequence": event.sequence},
ensure_ascii=False,
)
yield f"data: {payload}\n\n"
last_seq = event.sequence
if not rows:
await db.rollback() # 释放快照,下一轮可见新写入
await db.refresh(run)
if run.status in terminal:
break
if _asyncio.get_event_loop().time() > deadline:
break
await _asyncio.sleep(0.05)
return StreamingResponse(
event_stream(),
+3 -35
View File
@@ -24,7 +24,6 @@ from app.models import (
Message,
OutboxEvent,
Run,
RunEvent,
)
MESSAGE_ROUTE = "/v1/conversations/{conversation_id}/messages"
@@ -157,20 +156,19 @@ async def replay_or_execute_message(
run = None
if body.start_run:
# 垂直切片空状态闭环:不调用模型,Run 原子创建并直接进入终态 completed。
# Run 创建为 queued,由网关执行器在后台推进至终态(WP-05)
run = Run(
personal_domain_id=domain_id,
conversation_id=conversation.id,
status="completed",
completed_at=datetime.now(UTC),
status="queued",
)
db.add(run)
await db.flush()
message.run_id = run.id
resource_ids: dict[str, Any] = {"message_id": message.id}
if run is not None:
resource_ids["run_id"] = run.id
_record_run_events(db, run, message, blocks)
db.add(
AuditEvent(
actor_type="user",
@@ -203,36 +201,6 @@ async def replay_or_execute_message(
return message, run, False
def _record_run_events(
db: AsyncSession, run: Run, message: Message, blocks: list[dict[str, Any]]
) -> None:
"""空状态闭环的事件流:状态事件 + 每 block completed + message.completed。
sequence 在 Run 内从 0 单调;事件与权威状态同事务提交(契约第 5 节)。
delta/progress 事件在模型流式接入(WP-05)后由真实流产生,此处不伪造。
"""
seq = 0
events: list[tuple[str, dict[str, Any]]] = [
("run.status.changed", {"run_id": run.id, "status": run.status})
]
for index, block in enumerate(blocks):
events.append(
(
"content.block.completed",
{"run_id": run.id, "message_id": message.id, "index": index,
"block": {"id": block["id"], "type": block["type"], "status": "completed"}},
)
)
events.append(
(
"message.completed",
{"run_id": run.id, "message_ids": [message.id], "final_status": run.status},
)
)
for event_type, data in events:
db.add(RunEvent(run_id=run.id, sequence=seq, type=event_type, data=data))
seq += 1
async def list_messages(
db: AsyncSession,