✨ 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
@@ -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"
|
||||
Reference in new issue
Block a user