✨ 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
+782 -49

No files matched your search

@@ -0,0 +1,94 @@
"""模型网关:逻辑模型、端点、Turn 与 ModelCall
Revision ID: 0fb7d605080a
Revises: 6292e21950cc
Create Date: 2026-09-11 09:22:23.431933
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0fb7d605080a'
down_revision: str | Sequence[str] | None = '6292e21950cc'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('logical_model',
sa.Column('id', sa.String(), nullable=False),
sa.Column('capabilities', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_logical_model'))
)
op.create_table('provider_endpoint',
sa.Column('id', sa.String(), nullable=False),
sa.Column('logical_model_id', sa.String(), nullable=False),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('base_url', sa.String(), nullable=False),
sa.Column('model_id', sa.String(), nullable=False),
sa.Column('api_key_env', sa.String(), nullable=False),
sa.Column('priority', sa.Integer(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("provider IN ('openai_compatible', 'anthropic')", name=op.f('ck_provider_endpoint_provider_values')),
sa.ForeignKeyConstraint(['logical_model_id'], ['logical_model.id'], name=op.f('fk_provider_endpoint_logical_model'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_provider_endpoint'))
)
op.create_index(op.f('ix_provider_endpoint_logical_model_id'), 'provider_endpoint', ['logical_model_id'], unique=False)
op.create_table('turn',
sa.Column('id', sa.String(), nullable=False),
sa.Column('run_id', sa.String(), nullable=False),
sa.Column('index', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['run_id'], ['run.id'], name=op.f('fk_turn_run'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_turn'))
)
op.create_index(op.f('ix_turn_run_id'), 'turn', ['run_id'], unique=False)
op.create_index('uq_turn_run_index', 'turn', ['run_id', 'index'], unique=True)
op.create_table('model_call',
sa.Column('id', sa.String(), nullable=False),
sa.Column('turn_id', sa.String(), nullable=False),
sa.Column('endpoint_id', sa.String(), nullable=True),
sa.Column('attempt', sa.Integer(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('request_summary', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('usage', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('ttft_ms', sa.Integer(), nullable=True),
sa.Column('error', sa.String(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('pending', 'streaming', 'succeeded', 'failed')", name=op.f('ck_model_call_status_values')),
sa.ForeignKeyConstraint(['endpoint_id'], ['provider_endpoint.id'], name=op.f('fk_model_call_provider_endpoint'), ondelete='set null'),
sa.ForeignKeyConstraint(['turn_id'], ['turn.id'], name=op.f('fk_model_call_turn'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_model_call'))
)
op.create_index(op.f('ix_model_call_turn_id'), 'model_call', ['turn_id'], unique=False)
op.create_index('uq_model_call_turn_attempt', 'model_call', ['turn_id', 'attempt'], unique=True)
op.create_foreign_key(op.f('fk_character_character_version'), 'character', 'character_version', ['current_version_id'], ['id'], ondelete='set null', use_alter=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('fk_character_character_version'), 'character', type_='foreignkey')
op.drop_index('uq_model_call_turn_attempt', table_name='model_call')
op.drop_index(op.f('ix_model_call_turn_id'), table_name='model_call')
op.drop_table('model_call')
op.drop_index('uq_turn_run_index', table_name='turn')
op.drop_index(op.f('ix_turn_run_id'), table_name='turn')
op.drop_table('turn')
op.drop_index(op.f('ix_provider_endpoint_logical_model_id'), table_name='provider_endpoint')
op.drop_table('provider_endpoint')
op.drop_table('logical_model')
# ### end Alembic commands ###
@@ -0,0 +1,36 @@
"""消息关联 Run
Revision ID: 1c1f7edb7cbe
Revises: 0fb7d605080a
Create Date: 2026-09-11 09:24:35.912343
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '1c1f7edb7cbe'
down_revision: str | Sequence[str] | None = '0fb7d605080a'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('message', sa.Column('run_id', sa.String(), nullable=True))
op.create_index(op.f('ix_message_run_id'), 'message', ['run_id'], unique=False)
op.create_foreign_key(op.f('fk_message_run'), 'message', 'run', ['run_id'], ['id'], ondelete='set null')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('fk_message_run'), 'message', type_='foreignkey')
op.drop_index(op.f('ix_message_run_id'), table_name='message')
op.drop_column('message', 'run_id')
# ### end Alembic commands ###
+35 -6
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 = (
terminal = {"completed", "failed", "cancelled"}
async def event_stream() -> AsyncIterator[str]:
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)
.where(RunEvent.run_id == run.id, RunEvent.sequence > last_seq)
.order_by(RunEvent.sequence.asc())
)
).scalars().all()
async def event_stream() -> AsyncIterator[str]:
for event in events:
)
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,
+6
View File
@@ -0,0 +1,6 @@
"""模型网关:路由与 Run 执行(WP-05)。"""
from .adapters import AdapterRequest, GatewayError, stream_chat
from .runner import execute_run
__all__ = ["AdapterRequest", "GatewayError", "stream_chat", "execute_run"]
+107
View File
@@ -0,0 +1,107 @@
"""OpenAI-compatible 流式适配器(WP-05)。
- 只消费逻辑请求(messages + 逻辑模型),输出标准化的流事件与 usage。
- 密钥从环境变量读取(endpoint.api_key_env),不落库、不进日志。
- Provider 未返回的 usage 字段保持 None(ADR-003:不估算伪装)。
- transport 可注入,供测试与故障注入。
"""
import json
import os
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
import httpx
class GatewayError(RuntimeError):
"""网关错误:稳定分类,供 ModelCall 失败记录与上层重试决策。"""
@dataclass
class StreamChunk:
kind: str # "delta" | "usage" | "done"
text: str | None = None
usage: dict[str, int] | None = None
finish_reason: str | None = None
@dataclass
class AdapterRequest:
messages: list[dict[str, str]] # {"role", "content"}:纯文本最小集
model: str # 上游模型 ID
def _parse_sse_line(line: str) -> dict[str, Any] | None:
if not line.startswith("data:"):
return None
payload = line.removeprefix("data:").strip()
if not payload or payload == "[DONE]":
return None
try:
parsed: dict[str, Any] = json.loads(payload)
return parsed
except json.JSONDecodeError:
return None
def _usage_from_final(chunk: dict[str, Any]) -> dict[str, int] | None:
usage = chunk.get("usage")
if not isinstance(usage, dict):
return None
result: dict[str, int] = {}
keys = (
"prompt_tokens",
"completion_tokens",
"total_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
)
for key in keys:
value = usage.get(key)
if isinstance(value, int):
result[key] = value
return result or None
async def stream_chat(
base_url: str,
model_id: str,
api_key_env: str,
request: AdapterRequest,
*,
request_timeout_s: float = 120.0,
transport: httpx.AsyncBaseTransport | None = None,
) -> AsyncIterator[StreamChunk]:
"""流式调用 OpenAI-compatible /chat/completions,产出标准化块。"""
api_key = os.environ.get(api_key_env, "")
if not api_key:
raise GatewayError(f"环境变量 {api_key_env} 未配置 Provider 密钥")
url = base_url.rstrip("/") + "/chat/completions"
body = {
"model": model_id,
"messages": request.messages,
"stream": True,
"stream_options": {"include_usage": True},
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=request_timeout_s, transport=transport) as client:
async with client.stream("POST", url, json=body, headers=headers) as resp:
if resp.status_code != 200:
detail = (await resp.aread()).decode("utf-8", "replace")[:500]
raise GatewayError(f"上游返回 {resp.status_code}: {detail}")
async for line in resp.aiter_lines():
chunk = _parse_sse_line(line)
if chunk is None:
continue
for choice in chunk.get("choices", []):
text = choice.get("delta", {}).get("content")
if text:
yield StreamChunk(kind="delta", text=text)
finish = choice.get("finish_reason")
if finish:
yield StreamChunk(kind="done", finish_reason=finish)
usage = _usage_from_final(chunk)
if usage is not None:
yield StreamChunk(kind="usage", usage=usage)
+250
View File
@@ -0,0 +1,250 @@
"""Run 执行器:调用模型、产生真实流事件与 assistant 消息(WP-05)。
- 后台任务执行:独立数据库会话(不复用请求会话)。
- 事件序列(契约第 5 节):run.status.changed(running) → content.block.started
→ content.block.delta* → content.block.completed → message.completed →
run.status.changed(completed/failed)。
- 首个用户可见 delta 前的失败允许显式 fail;无可用端点时 Run 失败并留 error 事件。
"""
import time
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.ids import new_id
from app.gateway.adapters import AdapterRequest, GatewayError, stream_chat
from app.models import (
AuditEvent,
Message,
ModelCall,
OutboxEvent,
ProviderEndpoint,
Run,
RunEvent,
Turn,
)
async def _next_sequence(db: AsyncSession, run_id: str) -> int:
last = await db.execute(
select(RunEvent.sequence)
.where(RunEvent.run_id == run_id)
.order_by(RunEvent.sequence.desc())
.limit(1)
)
row = last.scalar_one_or_none()
return (row + 1) if row is not None else 0
def _emit(db: AsyncSession, run_id: str, seq: int, event_type: str, data: dict[str, Any]) -> int:
db.add(RunEvent(run_id=run_id, sequence=seq, type=event_type, data=data))
return seq + 1
async def execute_run(
factory: async_sessionmaker[AsyncSession],
run_id: str,
transport: Any | None = None,
) -> None:
"""执行一个 Run 至终态。transport 注入仅供测试。"""
async with factory() as db:
try:
run = await db.get(Run, run_id)
if run is None:
return
seq = await _next_sequence(db, run_id)
# 状态事件:running
run.status = "running"
seq = _emit(
db, run_id, seq, "run.status.changed",
{"run_id": run_id, "status": "running"},
)
await db.commit()
# 路由:按逻辑模型选择启用端点(优先级升序)
endpoint = (
await db.execute(
select(ProviderEndpoint)
.where(
ProviderEndpoint.enabled.is_(True),
ProviderEndpoint.logical_model_id == "default-chat",
)
.order_by(ProviderEndpoint.priority.asc())
.limit(1)
)
).scalar_one_or_none()
turn = Turn(run_id=run_id, index=0)
db.add(turn)
await db.flush()
if endpoint is None:
# fail closed:无可用端点时 Run 显式失败,不静默降级
call = ModelCall(
turn_id=turn.id, status="failed",
request_summary={}, error="无可用 Provider 端点(default-chat)",
started_at=datetime.now(UTC),
completed_at=datetime.now(UTC),
)
db.add(call)
run.status = "failed"
run.completed_at = datetime.now(UTC)
seq = _emit(db, run_id, seq, "content.block.failed", {
"run_id": run_id, "message_id": None, "index": 0,
"block": {"id": new_id("block_"), "type": "text", "status": "failed"},
"error": "no_endpoint_available",
})
seq = _emit(db, run_id, seq, "message.completed",
{"run_id": run_id, "message_ids": [], "final_status": "failed"})
seq = _emit(db, run_id, seq, "run.status.changed",
{"run_id": run_id, "status": "failed"})
await db.commit()
return
# 对话历史(最小:当前分支文本消息)
messages = (
await db.execute(
select(Message)
.where(
Message.conversation_id == run.conversation_id,
Message.role.in_(["user", "assistant"]),
)
.order_by(Message.created_at.asc(), Message.id.asc())
.limit(50)
)
).scalars().all()
prompt = [
{
"role": m.role,
"content": "".join(
str(b.get("text", b.get("source", "")))
for b in m.content
if isinstance(b, dict)
),
}
for m in messages
]
call = ModelCall(
turn_id=turn.id, endpoint_id=endpoint.id, status="streaming",
request_summary={"messages": len(prompt), "model_id": endpoint.model_id},
started_at=datetime.now(UTC),
)
db.add(call)
await db.commit()
block_id = new_id("block_")
seq = _emit(db, run_id, seq, "content.block.started", {
"run_id": run_id, "index": 0,
"block": {"id": block_id, "type": "text", "status": "streaming"},
})
await db.commit()
# 流式消费:真实 delta 事件
started = time.monotonic()
ttft_ms: int | None = None
full_text: list[str] = []
usage: dict[str, int] | None = None
finish_reason: str | None = None
delta_index = 0
try:
adapter_req = AdapterRequest(messages=prompt, model=endpoint.model_id)
async for chunk in stream_chat(
endpoint.base_url, endpoint.model_id, endpoint.api_key_env,
adapter_req, transport=transport,
):
if chunk.kind == "delta" and chunk.text:
if ttft_ms is None:
ttft_ms = int((time.monotonic() - started) * 1000)
full_text.append(chunk.text)
seq = _emit(db, run_id, seq, "content.block.delta", {
"run_id": run_id, "block_id": block_id,
"delta_index": delta_index,
"delta": {"type": "text_append", "text": chunk.text},
})
delta_index += 1
await db.commit()
elif chunk.kind == "usage" and chunk.usage:
usage = chunk.usage
elif chunk.kind == "done" and chunk.finish_reason:
finish_reason = chunk.finish_reason
except GatewayError as exc:
failed_call = await db.get(ModelCall, call.id)
assert failed_call is not None
call.status = "failed"
call.error = str(exc)[:500]
call.completed_at = datetime.now(UTC)
run.status = "failed"
run.completed_at = datetime.now(UTC)
seq = _emit(db, run_id, seq, "content.block.failed", {
"run_id": run_id, "block_id": block_id, "index": 0,
"error": "upstream_error",
})
seq = _emit(db, run_id, seq, "message.completed",
{"run_id": run_id, "message_ids": [], "final_status": "failed"})
seq = _emit(db, run_id, seq, "run.status.changed",
{"run_id": run_id, "status": "failed"})
await db.commit()
return
text = "".join(full_text)
seq = _emit(db, run_id, seq, "content.block.completed", {
"run_id": run_id, "index": 0,
"block": {"id": block_id, "type": "text", "status": "completed", "text": text},
})
# assistant 消息(权威快照)
assistant = Message(
conversation_id=run.conversation_id,
branch_id=(
await db.execute(
select(Message.branch_id).where(Message.run_id == run_id).limit(1)
)
).scalar_one_or_none() or new_id("branch_"),
role="assistant",
realm="reality",
content=[{"id": block_id, "type": "text", "text": text, "status": "completed"}],
run_id=run_id,
)
db.add(assistant)
await db.flush()
seq = _emit(db, run_id, seq, "message.completed", {
"run_id": run_id, "message_ids": [assistant.id], "final_status": "completed",
})
run.status = "completed"
run.completed_at = datetime.now(UTC)
seq = _emit(db, run_id, seq, "run.status.changed",
{"run_id": run_id, "status": "completed"})
success_call = await db.get(ModelCall, call.id)
assert success_call is not None
success_call.status = "succeeded"
# Provider 未返回的 usage 保持 None:不估算伪装(ADR-003)
success_call.usage = usage
success_call.ttft_ms = ttft_ms
success_call.completed_at = datetime.now(UTC)
if finish_reason:
success_call.request_summary = {
**success_call.request_summary, "finish_reason": finish_reason
}
db.add(
AuditEvent(
actor_type="system", actor_id="gateway", action="model_call.completed",
aggregate_type="run", aggregate_id=run_id,
payload={"endpoint": endpoint.name, "usage": usage, "ttft_ms": ttft_ms},
)
)
db.add(
OutboxEvent(
aggregate_type="message", aggregate_id=assistant.id,
event_type="message.created",
payload={"message_id": assistant.id, "run_id": run_id, "role": "assistant"},
)
)
await db.commit()
except Exception:
await db.rollback()
raise
+98
View File
@@ -207,6 +207,9 @@ class Message(Base):
ForeignKey("message.id", ondelete="set null"), nullable=True
)
variant_kind: Mapped[str] = mapped_column(String, nullable=False, default="origin")
run_id: Mapped[str | None] = mapped_column(
ForeignKey("run.id", ondelete="set null"), nullable=True, index=True
)
content: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False)
parser_version: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
@@ -374,3 +377,98 @@ class Character(Base):
name="origin_values",
),
)
class LogicalModel(Base):
"""逻辑模型:业务只引用能力与逻辑名,不感知 Provider(契约第 5 节分面)。"""
__tablename__ = "logical_model"
id: Mapped[str] = mapped_column(String, primary_key=True) # kebab-case,如 default-chat
capabilities: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
description: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class ProviderEndpoint(Base):
"""Provider 端点:密钥只存环境变量名,永不落库明文。"""
__tablename__ = "provider_endpoint"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("endpoint_")
)
logical_model_id: Mapped[str] = mapped_column(
ForeignKey("logical_model.id", ondelete="cascade"), nullable=False, index=True
)
provider: Mapped[str] = mapped_column(String, nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
base_url: Mapped[str] = mapped_column(String, nullable=False)
model_id: Mapped[str] = mapped_column(String, nullable=False)
api_key_env: Mapped[str] = mapped_column(String, nullable=False)
priority: Mapped[int] = mapped_column(nullable=False, default=100)
enabled: Mapped[bool] = mapped_column(nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"provider IN ('openai_compatible', 'anthropic')", name="provider_values"
),
)
class Turn(Base):
"""Turn:Run 内一次模型决策及其工具交互阶段。"""
__tablename__ = "turn"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("turn_"))
run_id: Mapped[str] = mapped_column(
ForeignKey("run.id", ondelete="cascade"), nullable=False, index=True
)
index: Mapped[int] = mapped_column(nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("uq_turn_run_index", "run_id", "index", unique=True),
)
class ModelCall(Base):
"""每次上游 attempt 单独持久化:重试/故障转移不覆盖(ADR-003)。
Provider 未返回的 usage 字段保持 null,不估算伪装。
"""
__tablename__ = "model_call"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("model_call_")
)
turn_id: Mapped[str] = mapped_column(
ForeignKey("turn.id", ondelete="cascade"), nullable=False, index=True
)
endpoint_id: Mapped[str | None] = mapped_column(
ForeignKey("provider_endpoint.id", ondelete="set null"), nullable=True
)
attempt: Mapped[int] = mapped_column(nullable=False, default=1)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
request_summary: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
usage: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
ttft_ms: Mapped[int | None] = mapped_column(nullable=True)
error: Mapped[str | None] = mapped_column(String, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'streaming', 'succeeded', 'failed')", name="status_values"
),
Index("uq_model_call_turn_attempt", "turn_id", "attempt", unique=True),
)
+44
View File
@@ -46,6 +46,28 @@ async def client(db):
raise
app.dependency_overrides[db_session.get_db] = override
# 后台 Run 任务使用测试库会话工厂与 mock 流(生产为独立工厂与真实传输)
app.state.session_factory = db
app.state.gw_transport = _mock_openai_stream()
import os as _os
_os.environ.setdefault("HJ_MOCK_KEY", "test-key")
from app.models import LogicalModel, ProviderEndpoint
async with db() as session:
session.add(LogicalModel(id="default-chat", capabilities=["chat"]))
session.add(
ProviderEndpoint(
logical_model_id="default-chat",
provider="openai_compatible",
name="mock-endpoint",
base_url="http://mock.internal",
model_id="mock-chat",
api_key_env="HJ_MOCK_KEY",
priority=1,
)
)
await session.commit()
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="https://test") as c:
yield c
@@ -56,3 +78,25 @@ async def login(client, email: str) -> dict[str, str]:
resp = await client.post("/v1/auth/sessions", json={"email": email})
assert resp.status_code == 201, resp.text
return {"Hunjing-CSRF-Token": resp.json()["csrf_token"]}
def _mock_openai_stream():
"""OpenAI-compatible 流式 mock:真实 SSE 字节流(data: {...} 分块 + usage)。"""
import httpx
sse = (
b'data: {"choices":[{"delta":{"content":"\\u4f60\\u597d"}}]}\n\n'
b'data: {"choices":[{"delta":{"content":","}}]}\n\n'
b'data: {"choices":[{"delta":{"content":"\\u9b42\\u9559"}}]}\n\n'
b'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n'
b'data: {"choices":[],"usage":{"prompt_tokens":12,'
b'"completion_tokens":6,"total_tokens":18}}\n\n'
b'data: [DONE]\n\n'
)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=sse
)
return httpx.MockTransport(handler)
+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"