✨ 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

+109 -8
View File
@@ -20,6 +20,22 @@ async def _create_conversation(client: AsyncClient, headers: dict) -> str:
return resp.json()["id"]
async def _wait_run_terminal(db, run_id: str, deadline_s: float = 10.0) -> str:
"""轮询等待 Run 终态(后台任务推进)。"""
import asyncio
from app.models import Run as RunModel
deadline = asyncio.get_event_loop().time() + deadline_s
while asyncio.get_event_loop().time() < deadline:
async with db() as session:
run = await session.get(RunModel, run_id)
if run is not None and run.status in {"completed", "failed", "cancelled"}:
return run.status
await asyncio.sleep(0.05)
raise AssertionError(f"Run {run_id} 未在 {deadline_s}s 内到达终态")
async def _post_message(client: AsyncClient, headers: dict, cid: str, key: str) -> object:
return await client.post(
f"/v1/conversations/{cid}/messages",
@@ -29,7 +45,7 @@ async def _post_message(client: AsyncClient, headers: dict, cid: str, key: str)
class TestMessagePipeline:
async def test_create_and_post_message(self, client: AsyncClient) -> None:
async def test_create_and_post_message(self, client: AsyncClient, db) -> None:
headers = await login(client, "alice@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-1")
@@ -37,7 +53,9 @@ class TestMessagePipeline:
body = resp.json()
assert body["message"]["id"].startswith("message_")
assert body["message"]["content"][0]["id"].startswith("block_")
assert body["run"]["status"] == "completed" # 空状态闭环
assert body["run"]["status"] == "queued" # 创建即返回;终态由后台执行推进
final_status = await _wait_run_terminal(db, body["run"]["id"])
assert final_status == "completed"
async def test_idempotent_replay_returns_original(self, client: AsyncClient) -> None:
headers = await login(client, "bob@example.com")
@@ -95,19 +113,25 @@ class TestMessagePipeline:
class TestRunSse:
async def test_sse_snapshot_and_terminal(self, client: AsyncClient) -> None:
async def test_sse_snapshot_and_terminal(self, client: AsyncClient, db) -> None:
headers = await login(client, "grace@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-sse")
run_id = resp.json()["run"]["id"]
await _wait_run_terminal(db, run_id)
sse = await client.get(f"/v1/runs/{run_id}/events")
assert sse.status_code == 200
assert sse.headers["content-type"].startswith("text/event-stream")
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
assert events[0]["type"] == "run.status.changed"
assert events[0]["data"]["status"] == "completed"
assert events[-1]["type"] == "message.completed"
assert events[0]["data"]["status"] == "running"
assert events[-1]["type"] == "run.status.changed"
assert events[-1]["data"]["status"] == "completed"
types = [e["type"] for e in events]
assert "content.block.started" in types
assert "content.block.delta" in types # 真实流产生
assert "message.completed" in types
async def test_cross_domain_run_returns_404(self, client: AsyncClient) -> None:
headers = await login(client, "heidi@example.com")
@@ -240,19 +264,24 @@ class TestContentBlocks:
@pytest.mark.usefixtures("client")
class TestSseReplay:
async def test_events_replayed_in_order_with_sequence(self, client: AsyncClient) -> None:
async def test_events_replayed_in_order_with_sequence(
self, client: AsyncClient, db
) -> None:
headers = await login(client, "sseq@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-seq")
run_id = resp.json()["run"]["id"]
await _wait_run_terminal(db, run_id)
sse = await client.get(f"/v1/runs/{run_id}/events")
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
sequences = [e["sequence"] for e in events]
assert sequences == sorted(sequences) == list(range(len(events)))
assert events[0]["type"] == "run.status.changed"
assert events[-1]["type"] == "message.completed"
# 用户消息两个 block:text 与(无 markdown 时)content.block.completed 数对应
assert events[-1]["type"] == "run.status.changed"
# delta 序号单调(契约 (block_id, delta_index) 去重基础)
deltas = [e for e in events if e["type"] == "content.block.delta"]
assert [d["data"]["delta_index"] for d in deltas] == list(range(len(deltas)))
assert sum(1 for e in events if e["type"] == "content.block.completed") >= 1
@@ -607,3 +636,75 @@ class TestCharacters:
await login(client, "char8-evil@example.com")
resp = await client.get(f"/v1/characters/{c['id']}")
assert resp.status_code in (401, 403, 404)
@pytest.mark.usefixtures("client")
class TestGatewayExecution:
async def test_model_call_usage_and_assistant_message(self, client, db) -> None:
"""端到端:mock 流 → ModelCall usage/TTFT + assistant 权威消息。"""
from sqlalchemy import select as sa
from app.models import Message as M
from app.models import ModelCall
headers = await login(client, "gw1@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-gw1")
run_id = resp.json()["run"]["id"]
assert await _wait_run_terminal(db, run_id) == "completed"
async with db() as session:
calls = list((await session.execute(sa(ModelCall))).scalars().all())
assert len(calls) == 1
assert calls[0].status == "succeeded"
assert calls[0].usage == {
"prompt_tokens": 12, "completion_tokens": 6, "total_tokens": 18,
}
assert calls[0].ttft_ms is not None
asst = list(
(
await session.execute(
sa(M).where(M.run_id == run_id, M.role == "assistant")
)
).scalars().all()
)
assert len(asst) == 1
assert asst[0].content[0]["type"] == "text"
assert asst[0].content[0]["status"] == "completed"
async def test_no_endpoint_fails_closed(self, client, db) -> None:
"""无可用端点:Run 显式失败并留 error 事件,不静默降级(ADR-016)。"""
from sqlalchemy import update
from app.models import ProviderEndpoint
async with db() as session:
await session.execute(update(ProviderEndpoint).values(enabled=False))
await session.commit()
try:
headers = await login(client, "gw2@example.com")
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, "idem-gw2")
run_id = resp.json()["run"]["id"]
assert await _wait_run_terminal(db, run_id) == "failed"
finally:
async with db() as session:
await session.execute(update(ProviderEndpoint).values(enabled=True))
await session.commit()
from sqlalchemy import select as sa_select
from app.models import RunEvent as RunEventModel
async with db() as session:
evs = list(
(
await session.execute(
sa_select(RunEventModel)
.where(RunEventModel.run_id == run_id)
.order_by(RunEventModel.sequence)
)
).scalars().all()
)
types = [e.type for e in evs]
assert "content.block.failed" in types
failed = next(e for e in evs if e.type == "content.block.failed")
assert failed.data["error"] == "no_endpoint_available"