✨ 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
@@ -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 ###
|
||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from typing import Annotated
|
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 fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ async def post_message(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
user: Annotated[User, Depends(require_state_change)],
|
user: Annotated[User, Depends(require_state_change)],
|
||||||
request: Request,
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
body: MessageIn,
|
body: MessageIn,
|
||||||
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
|
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
|
||||||
) -> MessageCreatedOut | JSONResponse:
|
) -> MessageCreatedOut | JSONResponse:
|
||||||
@@ -90,6 +91,17 @@ async def post_message(
|
|||||||
if replayed:
|
if replayed:
|
||||||
# 重放成功按契约返回 200 与原资源(避开装饰器固定 201)
|
# 重放成功按契约返回 200 与原资源(避开装饰器固定 201)
|
||||||
return JSONResponse(status_code=200, content=json.loads(payload.model_dump_json()))
|
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
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -111,23 +123,40 @@ async def run_events(
|
|||||||
if run.personal_domain_id != domain.id:
|
if run.personal_domain_id != domain.id:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
|
||||||
|
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
from app.models import RunEvent
|
from app.models import RunEvent
|
||||||
|
|
||||||
events = (
|
terminal = {"completed", "failed", "cancelled"}
|
||||||
await db.execute(
|
|
||||||
select(RunEvent)
|
|
||||||
.where(RunEvent.run_id == run.id)
|
|
||||||
.order_by(RunEvent.sequence.asc())
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
|
|
||||||
async def event_stream() -> AsyncIterator[str]:
|
async def event_stream() -> AsyncIterator[str]:
|
||||||
for event in events:
|
last_seq = -1
|
||||||
payload = json.dumps(
|
deadline = _asyncio.get_event_loop().time() + 30.0
|
||||||
{"type": event.type, "data": event.data, "sequence": event.sequence},
|
while True:
|
||||||
ensure_ascii=False,
|
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(
|
return StreamingResponse(
|
||||||
event_stream(),
|
event_stream(),
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from app.models import (
|
|||||||
Message,
|
Message,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
Run,
|
Run,
|
||||||
RunEvent,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
MESSAGE_ROUTE = "/v1/conversations/{conversation_id}/messages"
|
MESSAGE_ROUTE = "/v1/conversations/{conversation_id}/messages"
|
||||||
@@ -157,20 +156,19 @@ async def replay_or_execute_message(
|
|||||||
|
|
||||||
run = None
|
run = None
|
||||||
if body.start_run:
|
if body.start_run:
|
||||||
# 垂直切片空状态闭环:不调用模型,Run 原子创建并直接进入终态 completed。
|
# Run 创建为 queued,由网关执行器在后台推进至终态(WP-05)
|
||||||
run = Run(
|
run = Run(
|
||||||
personal_domain_id=domain_id,
|
personal_domain_id=domain_id,
|
||||||
conversation_id=conversation.id,
|
conversation_id=conversation.id,
|
||||||
status="completed",
|
status="queued",
|
||||||
completed_at=datetime.now(UTC),
|
|
||||||
)
|
)
|
||||||
db.add(run)
|
db.add(run)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
message.run_id = run.id
|
||||||
|
|
||||||
resource_ids: dict[str, Any] = {"message_id": message.id}
|
resource_ids: dict[str, Any] = {"message_id": message.id}
|
||||||
if run is not None:
|
if run is not None:
|
||||||
resource_ids["run_id"] = run.id
|
resource_ids["run_id"] = run.id
|
||||||
_record_run_events(db, run, message, blocks)
|
|
||||||
db.add(
|
db.add(
|
||||||
AuditEvent(
|
AuditEvent(
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
@@ -203,36 +201,6 @@ async def replay_or_execute_message(
|
|||||||
return message, run, False
|
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(
|
async def list_messages(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -207,6 +207,9 @@ class Message(Base):
|
|||||||
ForeignKey("message.id", ondelete="set null"), nullable=True
|
ForeignKey("message.id", ondelete="set null"), nullable=True
|
||||||
)
|
)
|
||||||
variant_kind: Mapped[str] = mapped_column(String, nullable=False, default="origin")
|
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)
|
content: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False)
|
||||||
parser_version: Mapped[str | None] = mapped_column(String, nullable=True)
|
parser_version: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
@@ -374,3 +377,98 @@ class Character(Base):
|
|||||||
name="origin_values",
|
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),
|
||||||
|
)
|
||||||
@@ -46,6 +46,28 @@ async def client(db):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
app.dependency_overrides[db_session.get_db] = override
|
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)
|
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||||
async with AsyncClient(transport=transport, base_url="https://test") as c:
|
async with AsyncClient(transport=transport, base_url="https://test") as c:
|
||||||
yield 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})
|
resp = await client.post("/v1/auth/sessions", json={"email": email})
|
||||||
assert resp.status_code == 201, resp.text
|
assert resp.status_code == 201, resp.text
|
||||||
return {"Hunjing-CSRF-Token": resp.json()["csrf_token"]}
|
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)
|
||||||
@@ -20,6 +20,22 @@ async def _create_conversation(client: AsyncClient, headers: dict) -> str:
|
|||||||
return resp.json()["id"]
|
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:
|
async def _post_message(client: AsyncClient, headers: dict, cid: str, key: str) -> object:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
f"/v1/conversations/{cid}/messages",
|
f"/v1/conversations/{cid}/messages",
|
||||||
@@ -29,7 +45,7 @@ async def _post_message(client: AsyncClient, headers: dict, cid: str, key: str)
|
|||||||
|
|
||||||
|
|
||||||
class TestMessagePipeline:
|
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")
|
headers = await login(client, "alice@example.com")
|
||||||
cid = await _create_conversation(client, headers)
|
cid = await _create_conversation(client, headers)
|
||||||
resp = await _post_message(client, headers, cid, "idem-1")
|
resp = await _post_message(client, headers, cid, "idem-1")
|
||||||
@@ -37,7 +53,9 @@ class TestMessagePipeline:
|
|||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["message"]["id"].startswith("message_")
|
assert body["message"]["id"].startswith("message_")
|
||||||
assert body["message"]["content"][0]["id"].startswith("block_")
|
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:
|
async def test_idempotent_replay_returns_original(self, client: AsyncClient) -> None:
|
||||||
headers = await login(client, "bob@example.com")
|
headers = await login(client, "bob@example.com")
|
||||||
@@ -95,19 +113,25 @@ class TestMessagePipeline:
|
|||||||
|
|
||||||
|
|
||||||
class TestRunSse:
|
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")
|
headers = await login(client, "grace@example.com")
|
||||||
cid = await _create_conversation(client, headers)
|
cid = await _create_conversation(client, headers)
|
||||||
resp = await _post_message(client, headers, cid, "idem-sse")
|
resp = await _post_message(client, headers, cid, "idem-sse")
|
||||||
run_id = resp.json()["run"]["id"]
|
run_id = resp.json()["run"]["id"]
|
||||||
|
await _wait_run_terminal(db, run_id)
|
||||||
sse = await client.get(f"/v1/runs/{run_id}/events")
|
sse = await client.get(f"/v1/runs/{run_id}/events")
|
||||||
assert sse.status_code == 200
|
assert sse.status_code == 200
|
||||||
assert sse.headers["content-type"].startswith("text/event-stream")
|
assert sse.headers["content-type"].startswith("text/event-stream")
|
||||||
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
|
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
|
||||||
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
|
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
|
||||||
assert events[0]["type"] == "run.status.changed"
|
assert events[0]["type"] == "run.status.changed"
|
||||||
assert events[0]["data"]["status"] == "completed"
|
assert events[0]["data"]["status"] == "running"
|
||||||
assert events[-1]["type"] == "message.completed"
|
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:
|
async def test_cross_domain_run_returns_404(self, client: AsyncClient) -> None:
|
||||||
headers = await login(client, "heidi@example.com")
|
headers = await login(client, "heidi@example.com")
|
||||||
@@ -240,19 +264,24 @@ class TestContentBlocks:
|
|||||||
|
|
||||||
@pytest.mark.usefixtures("client")
|
@pytest.mark.usefixtures("client")
|
||||||
class TestSseReplay:
|
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")
|
headers = await login(client, "sseq@example.com")
|
||||||
cid = await _create_conversation(client, headers)
|
cid = await _create_conversation(client, headers)
|
||||||
resp = await _post_message(client, headers, cid, "idem-seq")
|
resp = await _post_message(client, headers, cid, "idem-seq")
|
||||||
run_id = resp.json()["run"]["id"]
|
run_id = resp.json()["run"]["id"]
|
||||||
|
await _wait_run_terminal(db, run_id)
|
||||||
sse = await client.get(f"/v1/runs/{run_id}/events")
|
sse = await client.get(f"/v1/runs/{run_id}/events")
|
||||||
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
|
lines = [ln for ln in sse.text.splitlines() if ln.startswith("data: ")]
|
||||||
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
|
events = [json.loads(ln.removeprefix("data: ")) for ln in lines]
|
||||||
sequences = [e["sequence"] for e in events]
|
sequences = [e["sequence"] for e in events]
|
||||||
assert sequences == sorted(sequences) == list(range(len(events)))
|
assert sequences == sorted(sequences) == list(range(len(events)))
|
||||||
assert events[0]["type"] == "run.status.changed"
|
assert events[0]["type"] == "run.status.changed"
|
||||||
assert events[-1]["type"] == "message.completed"
|
assert events[-1]["type"] == "run.status.changed"
|
||||||
# 用户消息两个 block:text 与(无 markdown 时)content.block.completed 数对应
|
# 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
|
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")
|
await login(client, "char8-evil@example.com")
|
||||||
resp = await client.get(f"/v1/characters/{c['id']}")
|
resp = await client.get(f"/v1/characters/{c['id']}")
|
||||||
assert resp.status_code in (401, 403, 404)
|
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