✨ 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

@@ -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 ###