✨ 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:
1 parent
40221170ae
commit
b4242f3844
10 files changed
+789
-56
No files matched your search
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user