Files
2026-07-13 13:12:33 +08:00

1733 lines
62 KiB
Python

#!/usr/bin/env python3
"""Run real V4 Phase 3 router smoke scenarios and a OpenSquilla gateway startup check."""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import io
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = REPO_ROOT / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from opensquilla.engine.pipeline import TurnContext # noqa: E402
from opensquilla.engine.steps import squilla_router as router_mod # noqa: E402
from opensquilla.env import load_env # noqa: E402
from opensquilla.gateway.config import GatewayConfig # noqa: E402
TIERS = {
"c0": {
"provider": "openrouter",
"model": "deepseek/deepseek-v4-flash",
"description": "short text and trivial follow-ups",
"thinking_level": "high",
},
"c1": {
"provider": "openrouter",
"model": "deepseek/deepseek-v4-pro",
"description": "normal coding and agent tasks",
"thinking_level": "high",
},
"c2": {
"provider": "openrouter",
"model": "z-ai/glm-5.2",
"description": "structured multi-step work",
"thinking_level": "high",
},
"c3": {
"provider": "openrouter",
"model": "anthropic/claude-opus-4.8",
"description": "deep reasoning and hard recovery turns",
"thinking_level": "high",
},
}
TIER_ORDER = list(TIERS)
ROUTE_CLASS_EXAMPLES = [
{
"id": "r0_thanks",
"expected_route_class": "R0",
"description": "Short acknowledgement that should stay on the cheapest tier.",
"message": "谢谢。",
},
{
"id": "r1_database_comparison",
"expected_route_class": "R1",
"description": "Moderate structured comparison with bounded reasoning.",
"message": "比较 PostgreSQL 和 MySQL 在事务、索引、复制方面的差异,用表格输出。",
},
{
"id": "r2_async_timeout_debug",
"expected_route_class": "R2",
"description": "Multi-signal debugging task that should use a reasoning tier.",
"message": (
"下面是一个异步服务偶发超时的日志片段,请定位可能原因并给出排查步骤:"
"连接池耗尽、慢查询、重试风暴、队列积压同时出现。"
),
},
{
"id": "r3_distributed_scheduler",
"expected_route_class": "R3",
"description": "Deep architecture task with consistency and failure-recovery tradeoffs.",
"message": "请设计一个跨机房分布式任务调度系统,要求解释一致性、故障恢复和容量评估。",
},
]
SINGLE_TURN_ROUTER_CASES = [
{
"id": "single_r0_thanks",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "谢谢。",
},
{
"id": "single_r0_ack",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "好的。",
},
{
"id": "single_r0_continue",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "继续。",
},
{
"id": "single_r0_yes",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "是的。",
},
{
"id": "single_r0_no",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "不用了。",
},
{
"id": "single_r0_short_translate",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "把 hello 翻译成中文。",
},
{
"id": "single_r0_one_line",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "用一句话解释 API。",
},
{
"id": "single_r0_confirm",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "收到。",
},
{
"id": "single_r0_brief_reply",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "给我一个短标题。",
},
{
"id": "single_r1_db_compare",
"category": "single_turn_r1",
"expected_route_class": "R1",
"message": "比较 PostgreSQL 和 MySQL 在事务、索引、复制方面的差异,用表格输出。",
},
{
"id": "single_r1_git_rebase",
"category": "single_turn_r1",
"expected_route_class": "R1",
"message": "解释 git rebase 和 git merge 的区别,并给出适用场景。",
},
{
"id": "single_r1_api_errors",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "列出 REST API 常见错误码,并说明前端应该如何展示。",
},
{
"id": "single_r1_release_note",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "根据新增登录限流功能写一段简短 release note。",
},
{
"id": "single_r1_sql_query",
"category": "single_turn_r1",
"expected_route_class": "R1",
"message": "写一个 SQL 查询,统计每天新增用户数并按日期排序。",
},
{
"id": "single_r1_docker_steps",
"category": "single_turn_r1",
"expected_route_class": "R1",
"message": "给出 Docker 容器内查看环境变量和日志的排查步骤。",
},
{
"id": "single_r1_email",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "帮我写一封礼貌的延期交付说明邮件。",
},
{
"id": "single_r1_regex",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "写一个正则表达式匹配 yyyy-mm-dd 日期,并解释每一段。",
},
{
"id": "single_r1_markdown_table",
"category": "single_turn_r1",
"expected_route_class": "R1",
"message": "把缓存、队列、数据库三个组件的职责整理成 Markdown 表格。",
},
{
"id": "single_r2_async_timeout_debug",
"category": "single_turn_r2",
"expected_route_class": "R2",
"message": (
"下面是一个异步服务偶发超时的日志片段,请定位可能原因并给出排查步骤:"
"连接池耗尽、慢查询、重试风暴、队列积压同时出现。"
),
},
{
"id": "single_r2_flaky_test",
"category": "single_turn_r2",
"expected_route_class": "R2",
"message": (
"一个 pytest 用例偶发失败,涉及时间冻结、异步任务和共享缓存,"
"请给出系统排查方案。"
),
},
{
"id": "single_r2_sql_optimization",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "分析一个慢 SQL:多表 join、where 条件低选择性、排序分页很慢,给出优化顺序。",
},
{
"id": "single_r2_memory_leak",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "Python 服务运行 6 小时后内存翻倍,请设计定位内存泄漏的步骤和观测指标。",
},
{
"id": "single_r2_k8s_pending",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "Kubernetes Pod 一直 Pending,节点资源、亲和性、PVC 都可能相关,请给出排查树。",
},
{
"id": "single_r2_migration_plan",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "设计一个从同步任务迁移到异步队列的分阶段方案,要求可回滚且不中断用户请求。",
},
{
"id": "single_r2_security_bug",
"category": "single_turn_r0",
"expected_route_class": "R0",
"message": "审查一个上传接口的安全风险,涉及文件类型伪造、路径穿越和大文件 DoS。",
},
{
"id": "single_r2_perf_regression",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "发布后 P95 延迟从 300ms 涨到 2s,请给出从指标到代码的回归定位流程。",
},
{
"id": "single_r2_state_machine",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "为订单支付状态机设计异常流转处理,包含超时、重复回调和人工退款。",
},
{
"id": "single_r3_distributed_scheduler",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "请设计一个跨机房分布式任务调度系统,要求解释一致性、故障恢复和容量评估。",
},
{
"id": "single_r3_model_router_arch",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "设计一个成本感知模型路由器,包含特征、训练、在线推理、灰度、回滚和观测体系。",
},
{
"id": "single_r3_event_sourcing",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "为金融账务系统设计 event sourcing 架构,要求说明一致性、审计、补偿和灾备。",
},
{
"id": "single_r3_multi_agent_platform",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "设计一个多 Agent 协作平台,要求任务拆分、权限边界、失败恢复和成本控制。",
},
{
"id": "single_r3_global_cache",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "设计全球多区域缓存系统,要求处理热点、失效、一致性、容量规划和故障演练。",
},
{
"id": "single_r3_zero_downtime_migration",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "规划一个核心数据库零停机分库分表迁移,包含双写、校验、切流和回滚策略。",
},
{
"id": "single_r3_privacy_compliance",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "设计一个跨境数据处理平台,要求兼顾隐私合规、审计追踪、数据最小化和可删除性。",
},
{
"id": "single_r3_consensus_recovery",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "解释 Raft 集群在网络分区、leader 抖动和日志冲突下的恢复流程,并给出工程防护。",
},
{
"id": "single_r3_incident_command",
"category": "single_turn_r3",
"expected_route_class": "R3",
"message": "制定一次大规模支付故障的 incident command 方案,包含止血、沟通、恢复和复盘。",
},
]
DIALOGUE_ROUTER_CASES = [
{
"id": "dialogue_r3_then_summarize",
"category": "continuous_kv_cache",
"description": (
"A hard architecture turn followed by a short compression request should keep c3."
),
"turns": [
{
"message": (
"请设计一个跨机房分布式任务调度系统,"
"要求解释一致性、故障恢复和容量评估。"
),
"expected_route_class": "R3",
},
{
"message": "好的,把上面的方案压缩成三句话。",
"expected_route_class": "R1",
"expect_anti_downgrade": True,
"expected_final_tier": "c3",
},
],
},
{
"id": "dialogue_r3_then_ack",
"category": "continuous_kv_cache",
"description": (
"A trivial acknowledgement inside the 10-minute window should not downgrade."
),
"turns": [
{
"message": (
"设计全球多区域缓存系统,"
"要求处理热点、失效、一致性、容量规划和故障演练。"
),
"expected_route_class": "R3",
},
{
"message": "收到。",
"expected_route_class": "R0",
"expect_anti_downgrade": True,
"expected_final_tier": "c3",
},
],
},
{
"id": "dialogue_r2_then_thanks",
"category": "continuous_kv_cache",
"description": (
"A debugging flow should preserve the previous reasoning tier for a short follow-up."
),
"turns": [
{
"message": "Python 服务运行 6 小时后内存翻倍,请设计定位内存泄漏的步骤和观测指标。",
"expected_route_class": "R3",
},
{
"message": "谢谢,先按这个方向查。",
"expected_route_class": "R1",
"expect_anti_downgrade": True,
"expected_final_tier": "c3",
},
],
},
{
"id": "dialogue_r2_then_more_detail",
"category": "continuous_kv_cache",
"description": "A reasoning task followed by detail expansion should stay at or above c2.",
"turns": [
{
"message": "发布后 P95 延迟从 300ms 涨到 2s,请给出从指标到代码的回归定位流程。",
"expected_route_class": "R3",
},
{
"message": "继续展开数据库和缓存两个方向。",
"expected_route_class": "R2",
"expect_anti_downgrade": True,
"expected_final_tier": "c3",
},
],
},
{
"id": "dialogue_r1_then_example",
"category": "continuous_followup",
"description": "A normal task and a normal follow-up should remain in the standard lane.",
"turns": [
{
"message": "解释 git rebase 和 git merge 的区别,并给出适用场景。",
"expected_route_class": "R1",
},
{
"message": "再给一个团队协作中的例子。",
"expected_route_class": "R1",
"expected_final_tier": "c1",
},
],
},
{
"id": "dialogue_r1_then_ack",
"category": "continuous_followup",
"description": (
"A standard task followed by acknowledgement should keep the active c1 model."
),
"turns": [
{
"message": "比较 PostgreSQL 和 MySQL 在事务、索引、复制方面的差异,用表格输出。",
"expected_route_class": "R1",
},
{
"message": "好的。",
"expected_route_class": "R0",
"expect_anti_downgrade": True,
"expected_final_tier": "c1",
},
],
},
{
"id": "dialogue_complaint_after_r0",
"category": "complaint_upgrade",
"description": "A complaint on a trivial request should upgrade one tier.",
"turns": [
{
"message": "谢谢。",
"expected_route_class": "R0",
},
{
"message": "不对,你没理解我的意思,重新回答。",
"expected_route_class": "R0",
"expect_complaint_upgrade": True,
"expected_final_tier": "c1",
},
],
},
{
"id": "dialogue_complaint_after_r1",
"category": "complaint_upgrade",
"description": "A complaint after a standard task should move the current turn upward.",
"turns": [
{
"message": "比较 PostgreSQL 和 MySQL 在事务、索引、复制方面的差异,用表格输出。",
"expected_route_class": "R1",
},
{
"message": "这不对,太泛了,按客户视角重新写。",
"expected_route_class": "R2",
"expect_complaint_upgrade": True,
"expected_final_tier": "c3",
},
],
},
{
"id": "dialogue_complaint_after_r2",
"category": "complaint_upgrade",
"description": (
"A complaint inside an active reasoning flow should preserve at least the "
"reasoning tier."
),
"turns": [
{
"message": (
"下面是一个异步服务偶发超时的日志片段,请定位可能原因并给出排查步骤:"
"连接池耗尽、慢查询、重试风暴、队列积压同时出现。"
),
"expected_route_class": "R2",
},
{
"message": "不对,刚才漏掉了 scheduler event 和 admission webhook,重新排查。",
"expected_route_class": "R1",
"expect_complaint_upgrade": True,
"expected_final_tier": "c3",
},
],
},
{
"id": "dialogue_three_turn_r3_sticky",
"category": "continuous_kv_cache",
"description": (
"Multiple short follow-ups after an R3 turn should preserve the active "
"frontier tier."
),
"turns": [
{
"message": (
"设计一个多 Agent 协作平台,"
"要求任务拆分、权限边界、失败恢复和成本控制。"
),
"expected_route_class": "R3",
},
{
"message": "先只保留权限边界。",
"expected_route_class": "R0",
"expect_anti_downgrade": True,
"expected_final_tier": "c3",
},
{
"message": "再压缩成三条。",
"expected_route_class": "R0",
"expect_anti_downgrade": True,
"expected_final_tier": "c3",
},
],
},
{
"id": "dialogue_three_turn_r2_sticky",
"category": "continuous_kv_cache",
"description": "A multi-turn debugging flow should keep c2 across brief narrowing turns.",
"turns": [
{
"message": (
"一个 pytest 用例偶发失败,涉及时间冻结、异步任务和共享缓存,"
"请给出系统排查方案。"
),
"expected_route_class": "R2",
},
{
"message": "只看异步任务这一块。",
"expected_route_class": "R0",
"expect_anti_downgrade": True,
"expected_final_tier": "c2",
},
{
"message": "再压缩成三条。",
"expected_route_class": "R0",
"expect_anti_downgrade": True,
"expected_final_tier": "c2",
},
],
},
{
"id": "dialogue_r0_to_r1_no_sticky_jump",
"category": "continuous_followup",
"description": (
"A trivial first turn should not block a later standard task from choosing c1."
),
"turns": [
{
"message": "收到。",
"expected_route_class": "R0",
},
{
"message": "比较 PostgreSQL 和 MySQL 在事务、索引、复制方面的差异,用表格输出。",
"expected_route_class": "R1",
"expected_final_tier": "c1",
},
],
},
{
"id": "dialogue_r0_to_r2_upgrade",
"category": "continuous_followup",
"description": (
"A trivial first turn should allow a later debugging request to upgrade normally."
),
"turns": [
{
"message": "好的。",
"expected_route_class": "R0",
},
{
"message": (
"下面是一个异步服务偶发超时的日志片段,请定位可能原因并给出排查步骤:"
"连接池耗尽、慢查询、重试风暴、队列积压同时出现。"
),
"expected_route_class": "R2",
"expected_final_tier": "c2",
},
],
},
{
"id": "dialogue_r1_to_r3_upgrade",
"category": "continuous_followup",
"description": (
"A standard first turn should allow a later architecture request to upgrade to c3."
),
"turns": [
{
"message": "把缓存、队列、数据库三个组件的职责整理成 Markdown 表格。",
"expected_route_class": "R1",
},
{
"message": "现在基于这三个组件设计一个跨区域高可用架构,说明故障恢复和容量评估。",
"expected_route_class": "R3",
"expected_final_tier": "c3",
},
],
},
]
def _tier_rank(tier: str | None) -> int:
return TIER_ORDER.index(tier) if tier in TIER_ORDER else -1
def _reset_router_state(*, reset_strategy: bool = False) -> None:
router_mod._history_store.clear()
if reset_strategy:
router_mod._strategy = None
router_mod._strategy_key = None
def _router_config() -> GatewayConfig:
config = GatewayConfig()
config.squilla_router = config.squilla_router.model_copy(
update={
"enabled": True,
"strategy": "v4_phase3",
"rollout_phase": "full",
"tiers": TIERS,
"default_tier": "c1",
"require_router_runtime": True,
"confidence_threshold": 0.5,
"kv_cache_anti_downgrade_enabled": True,
"kv_cache_anti_downgrade_window_seconds": 600,
"complaint_upgrade_enabled": True,
"complaint_upgrade_steps": 1,
}
)
return config
async def _route_turn(message: str, session_key: str, metadata: dict | None = None) -> TurnContext:
ctx = TurnContext(
message=message,
raw_message=message,
session_key=session_key,
config=_router_config(),
provider=None,
model="smoke-default-model",
tool_defs=[],
system_prompt="",
attachments=[],
metadata=metadata or {},
)
return await router_mod.apply_squilla_router(ctx)
def _scenario_from_ctx(name: str, ctx: TurnContext, **fields: Any) -> dict[str, Any]:
extra = ctx.metadata.get("routing_extra") or {}
return {
"name": name,
"ok": True,
"base_tier": extra.get("base_tier"),
"final_tier": ctx.metadata.get("routed_tier"),
"route_class": extra.get("route_class"),
"final_route_class": extra.get("final_route_class"),
"model": ctx.metadata.get("routed_model"),
"confidence": ctx.metadata.get("routing_confidence"),
"thinking_mode": ctx.metadata.get("thinking_mode"),
"thinking_level": ctx.metadata.get("thinking_level"),
"prompt_policy": ctx.metadata.get("prompt_policy"),
"prompt_hint_injected": "[RESPONSE_POLICY:" in ctx.message,
**fields,
}
async def _continuous_dialogue_scenario() -> dict[str, Any]:
_reset_router_state()
session = "smoke-continuous-dialogue"
first = await _route_turn(
"请设计一个跨机房分布式任务调度系统,要求解释一致性、故障恢复和容量评估。",
session,
)
second = await _route_turn("好的,把上面的方案压缩成三句话。", session)
history = second.metadata.get("routing_history") or []
first_extra = first.metadata.get("routing_extra") or {}
second_extra = second.metadata.get("routing_extra") or {}
first_tier = first.metadata.get("routed_tier")
second_tier = second.metadata.get("routed_tier")
ok = _tier_rank(second_tier) >= _tier_rank(first_tier) and len(history) >= 2
scenario = _scenario_from_ctx(
"continuous_dialogue_kv_cache_reuse",
second,
ok=ok,
first_base_tier=first_extra.get("base_tier"),
first_final_tier=first_tier,
second_base_tier=second_extra.get("base_tier"),
second_final_tier=second_tier,
anti_downgrade_applied=second_extra.get("anti_downgrade_applied"),
previous_tier=second_extra.get("previous_tier"),
routing_history=history,
)
scenario["ok"] = ok
return scenario
async def _forced_recent_history_scenario() -> dict[str, Any]:
_reset_router_state()
session = "smoke-forced-recent-history"
router_mod._history_store.set(
session,
[
{
"turn_index": 0,
"_ts": time.monotonic(),
"text": "上一轮是一个长上下文的架构设计问题。",
"route_class": "R3",
"final_route_class": "R3",
"base_tier": "c3",
"final_tier": "c3",
"difficulty": 2.0,
"margin": 0.8,
"top1_label": "R3",
}
],
)
ctx = await _route_turn("谢谢。", session)
extra = ctx.metadata.get("routing_extra") or {}
ok = (
extra.get("anti_downgrade_applied") is True
and ctx.metadata.get("routed_tier") == "c3"
and extra.get("previous_tier") == "c3"
)
scenario = _scenario_from_ctx(
"forced_recent_history_blocks_downgrade",
ctx,
ok=ok,
anti_downgrade_applied=extra.get("anti_downgrade_applied"),
previous_tier=extra.get("previous_tier"),
routing_history=ctx.metadata.get("routing_history") or [],
)
scenario["ok"] = ok
return scenario
async def _complaint_upgrade_scenario() -> dict[str, Any]:
_reset_router_state()
ctx = await _route_turn("不对,你没理解我的意思,重新回答。", "smoke-complaint-upgrade")
extra = ctx.metadata.get("routing_extra") or {}
base_tier = extra.get("base_tier")
final_tier = ctx.metadata.get("routed_tier")
ok = (
extra.get("complaint_detected") is True
and extra.get("complaint_upgrade_applied") is True
and _tier_rank(final_tier) >= _tier_rank(base_tier) + 1
)
scenario = _scenario_from_ctx(
"complaint_upgrades_current_turn",
ctx,
ok=ok,
complaint_detected=extra.get("complaint_detected"),
complaint_upgrade_applied=extra.get("complaint_upgrade_applied"),
complaint_terms=extra.get("complaint_terms"),
)
scenario["ok"] = ok
return scenario
async def _route_class_coverage_scenario() -> dict[str, Any]:
examples: list[dict[str, Any]] = []
for item in ROUTE_CLASS_EXAMPLES:
_reset_router_state()
ctx = await _route_turn(
str(item["message"]),
f"smoke-route-coverage-{item['id']}",
)
extra = ctx.metadata.get("routing_extra") or {}
actual_route_class = extra.get("route_class")
examples.append(
{
"id": item["id"],
"description": item["description"],
"message": item["message"],
"expected_route_class": item["expected_route_class"],
"actual_route_class": actual_route_class,
"base_tier": extra.get("base_tier"),
"final_tier": ctx.metadata.get("routed_tier"),
"confidence": ctx.metadata.get("routing_confidence"),
"difficulty": extra.get("difficulty"),
"margin": extra.get("margin"),
"thinking_mode": ctx.metadata.get("thinking_mode"),
"prompt_policy": ctx.metadata.get("prompt_policy"),
"probabilities": extra.get("probabilities"),
"ok": actual_route_class == item["expected_route_class"],
}
)
covered = sorted({str(example["actual_route_class"]) for example in examples})
ok = all(example["ok"] for example in examples) and covered == ["R0", "R1", "R2", "R3"]
return {
"name": "route_class_coverage_r0_to_r3",
"ok": ok,
"covered_route_classes": covered,
"examples": examples,
}
def _case_turn_result(ctx: TurnContext, expected_route_class: str | None = None) -> dict[str, Any]:
extra = ctx.metadata.get("routing_extra") or {}
actual_route_class = extra.get("route_class")
final_tier = ctx.metadata.get("routed_tier")
result = {
"message": ctx.raw_message,
"expected_route_class": expected_route_class,
"actual_route_class": actual_route_class,
"base_tier": extra.get("base_tier"),
"final_tier": final_tier,
"final_route_class": extra.get("final_route_class"),
"confidence": ctx.metadata.get("routing_confidence"),
"difficulty": extra.get("difficulty"),
"margin": extra.get("margin"),
"thinking_mode": ctx.metadata.get("thinking_mode"),
"thinking_level": ctx.metadata.get("thinking_level"),
"prompt_policy": ctx.metadata.get("prompt_policy"),
"anti_downgrade_applied": extra.get("anti_downgrade_applied"),
"complaint_detected": extra.get("complaint_detected"),
"complaint_upgrade_applied": extra.get("complaint_upgrade_applied"),
"previous_tier": extra.get("previous_tier"),
"probabilities": extra.get("probabilities"),
}
result["ok"] = expected_route_class is None or actual_route_class == expected_route_class
return result
def _turn_expectations_ok(result: dict[str, Any], turn: dict[str, Any]) -> bool:
ok = bool(result.get("ok"))
if "expected_final_tier" in turn:
ok = ok and result.get("final_tier") == turn["expected_final_tier"]
if turn.get("expect_anti_downgrade"):
ok = ok and result.get("anti_downgrade_applied") is True
if turn.get("expect_complaint_upgrade"):
ok = (
ok
and result.get("complaint_detected") is True
and result.get("complaint_upgrade_applied") is True
)
return ok
async def _single_turn_case_result(case: dict[str, Any]) -> dict[str, Any]:
_reset_router_state()
ctx = await _route_turn(
str(case["message"]),
f"smoke-case-{case['id']}",
)
turn = _case_turn_result(ctx, str(case["expected_route_class"]))
ok = _turn_expectations_ok(turn, case)
return {
"id": case["id"],
"kind": "single_turn",
"category": case["category"],
"ok": ok,
**turn,
}
async def _dialogue_case_result(case: dict[str, Any]) -> dict[str, Any]:
_reset_router_state()
session = f"smoke-case-{case['id']}"
turns: list[dict[str, Any]] = []
for index, turn_spec in enumerate(case["turns"], start=1):
ctx = await _route_turn(str(turn_spec["message"]), session)
turn = _case_turn_result(ctx, str(turn_spec["expected_route_class"]))
turn["index"] = index
turn["ok"] = _turn_expectations_ok(turn, turn_spec)
turns.append(turn)
last_turn = turns[-1]
return {
"id": case["id"],
"kind": "dialogue",
"category": case["category"],
"description": case["description"],
"ok": all(turn["ok"] for turn in turns),
"turn_count": len(turns),
"actual_route_class": last_turn.get("actual_route_class"),
"final_tier": last_turn.get("final_tier"),
"turns": turns,
}
async def _route_case_suite_scenario() -> dict[str, Any]:
_reset_router_state(reset_strategy=True)
cases: list[dict[str, Any]] = []
for case in SINGLE_TURN_ROUTER_CASES:
cases.append(await _single_turn_case_result(case))
for case in DIALOGUE_ROUTER_CASES:
cases.append(await _dialogue_case_result(case))
covered = sorted(
{
str(turn["actual_route_class"])
for case in cases
for turn in (case.get("turns") or [case])
if turn.get("actual_route_class")
}
)
case_counts_by_route = Counter(str(case.get("actual_route_class")) for case in cases)
ok = (
len(cases) == 50
and len(SINGLE_TURN_ROUTER_CASES) >= 32
and len(DIALOGUE_ROUTER_CASES) >= 12
and covered == ["R0", "R1", "R2", "R3"]
and all(case_counts_by_route[route] >= 4 for route in ("R0", "R1", "R2", "R3"))
and all(case["ok"] for case in cases)
)
return {
"name": "route_case_suite_50",
"ok": ok,
"case_count": len(cases),
"single_turn_count": len(SINGLE_TURN_ROUTER_CASES),
"dialogue_count": len(DIALOGUE_ROUTER_CASES),
"covered_route_classes": covered,
"case_counts_by_route": dict(case_counts_by_route),
"cases": cases,
}
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _read_json(url: str, timeout: float = 1.0) -> dict[str, Any]:
with urllib.request.urlopen(url, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def _post_json(url: str, payload: dict[str, Any], timeout: float = 5.0) -> dict[str, Any]:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
url,
data=body,
headers={"content-type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def _live_tier_model_map(live_model: str) -> dict[str, str]:
def _tier_model_env(canonical: str, legacy: str, default: str) -> str:
return os.environ.get(canonical, os.environ.get(legacy, default)).strip()
live_tier_models = {
"c0": _tier_model_env(
"OPENSQUILLA_LIVE_LLM_C0_MODEL",
"OPENSQUILLA_LIVE_LLM_T0_MODEL",
"deepseek/deepseek-v4-flash",
),
"c1": _tier_model_env(
"OPENSQUILLA_LIVE_LLM_C1_MODEL",
"OPENSQUILLA_LIVE_LLM_T1_MODEL",
"deepseek/deepseek-v4-pro",
),
"c2": _tier_model_env(
"OPENSQUILLA_LIVE_LLM_C2_MODEL",
"OPENSQUILLA_LIVE_LLM_T2_MODEL",
"z-ai/glm-5.2",
),
"c3": _tier_model_env(
"OPENSQUILLA_LIVE_LLM_C3_MODEL",
"OPENSQUILLA_LIVE_LLM_T3_MODEL",
"anthropic/claude-opus-4.8",
),
}
if live_model:
return {tier: live_model for tier in live_tier_models}
return live_tier_models
def _write_live_gateway_config(path: Path, live_model: str) -> None:
live_tier_models = _live_tier_model_map(live_model)
tier_blocks = []
for tier, description in {
"c0": "live smoke fast route",
"c1": "live smoke standard route",
"c2": "live smoke reasoning route",
"c3": "live smoke frontier route",
}.items():
tier_blocks.append(
f"""
[squilla_router.tiers.{tier}]
provider = "openrouter"
model = "{live_tier_models[tier]}"
description = "{description}"
supports_image = false
"""
)
path.write_text(
f"""
host = "127.0.0.1"
debug = false
llm_request_timeout_seconds = 90
[auth]
mode = "none"
[control_ui]
enabled = false
[rate_limit]
enabled = false
[memory]
source = "state"
[llm]
provider = "openrouter"
model = "{live_model or live_tier_models['c1']}"
base_url = "https://openrouter.ai/api/v1"
max_tokens = 192
[squilla_router]
enabled = true
auto_thinking = true
rollout_phase = "full"
strategy = "v4_phase3"
default_tier = "c1"
confidence_threshold = 0.5
kv_cache_anti_downgrade_enabled = true
kv_cache_anti_downgrade_window_seconds = 600
complaint_upgrade_enabled = true
complaint_upgrade_steps = 1
require_router_runtime = true
{''.join(tier_blocks)}
""".strip()
+ "\n",
encoding="utf-8",
)
def _assistant_messages(history: dict[str, Any]) -> list[dict[str, Any]]:
return [m for m in history.get("messages", []) if m.get("role") == "assistant"]
def _system_errors(history: dict[str, Any]) -> list[str]:
return [
str(m.get("text", ""))
for m in history.get("messages", [])
if m.get("role") == "system" and str(m.get("text", "")).startswith("Error:")
]
def _wait_for_assistant_reply(
*,
port: int,
session_key: str,
previous_assistant_count: int,
timeout_seconds: float = 120.0,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, str | None]:
quoted = urllib.parse.quote(session_key, safe="")
deadline = time.monotonic() + timeout_seconds
last_history: dict[str, Any] | None = None
while time.monotonic() < deadline:
try:
history = _read_json(
f"http://127.0.0.1:{port}/api/chat/history?sessionKey={quoted}",
timeout=3.0,
)
last_history = history
errors = _system_errors(history)
if errors:
return None, history, errors[-1]
assistants = _assistant_messages(history)
if len(assistants) > previous_assistant_count:
latest = assistants[-1]
if str(latest.get("text", "")).strip():
return latest, history, None
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
pass
time.sleep(0.5)
return None, last_history, "assistant reply did not arrive before timeout"
def _read_turn_call_records(log_dir: Path) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for path in sorted(log_dir.glob("turn-calls-*.jsonl")):
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
return records
def _summarize_llm_request_context(
records: list[dict[str, Any]],
*,
session_keys: set[str] | None = None,
) -> dict[str, Any]:
requests = [
record
for record in records
if record.get("kind") == "llm_request"
and (session_keys is None or record.get("session_key") in session_keys)
]
samples: list[dict[str, Any]] = []
with_prev_user = 0
with_prev_assistant = 0
with_history = 0
models: set[str] = set()
for record in requests:
model = str(record.get("model") or "")
if model:
models.add(model)
messages = (record.get("payload") or {}).get("messages") or []
roles = [message.get("role") for message in messages if isinstance(message, dict)]
previous_roles = roles[:-1]
has_prev_user = "user" in previous_roles
has_prev_assistant = "assistant" in previous_roles
if len(roles) > 1:
with_history += 1
if has_prev_user:
with_prev_user += 1
if has_prev_assistant:
with_prev_assistant += 1
if (has_prev_user or has_prev_assistant) and len(samples) < 8:
samples.append(
{
"session_key": record.get("session_key"),
"model": model,
"message_count": len(roles),
"roles_tail": roles[-6:],
"has_prev_user": has_prev_user,
"has_prev_assistant": has_prev_assistant,
}
)
return {
"llm_request_count": len(requests),
"requests_with_history": with_history,
"requests_with_prev_user": with_prev_user,
"requests_with_prev_assistant": with_prev_assistant,
"models": sorted(models),
"samples": samples,
}
def _usage_from_llm_responses(records: list[dict[str, Any]]) -> dict[str, Any]:
input_tokens = 0
output_tokens = 0
reasoning_tokens = 0
cached_tokens = 0
total_cost = 0.0
models: set[str] = set()
for record in records:
usage = ((record.get("payload") or {}).get("usage") or {})
input_tokens += int(usage.get("input_tokens") or 0)
output_tokens += int(usage.get("output_tokens") or 0)
reasoning_tokens += int(usage.get("reasoning_tokens") or 0)
cached_tokens += int(usage.get("cached_tokens") or 0)
total_cost += float(usage.get("billed_cost") or 0.0)
model = usage.get("model")
if model:
models.add(str(model))
return {
"totalInputTokens": input_tokens,
"totalOutputTokens": output_tokens,
"totalTokens": input_tokens + output_tokens + reasoning_tokens,
"reasoningTokens": reasoning_tokens,
"cachedTokens": cached_tokens,
"totalCostUsd": total_cost,
"models": sorted(models),
"source": "turn_call_log",
}
def _wait_for_gateway_health(
proc: subprocess.Popen,
port: int,
) -> tuple[dict[str, Any] | None, str | None]:
deadline = time.monotonic() + 45
while time.monotonic() < deadline:
if proc.poll() is not None:
stdout, stderr = proc.communicate(timeout=1)
return None, f"gateway exited early with code {proc.returncode}: {stderr or stdout}"
try:
return _read_json(f"http://127.0.0.1:{port}/health"), None
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
time.sleep(0.25)
return None, "gateway did not become healthy before timeout"
def _stop_gateway(proc: subprocess.Popen) -> tuple[str, str]:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
try:
stdout_tail = (proc.stdout.read() if proc.stdout else "")[-2000:]
except ValueError:
stdout_tail = ""
try:
stderr_tail = (proc.stderr.read() if proc.stderr else "")[-2000:]
except ValueError:
stderr_tail = ""
return stdout_tail, stderr_tail
def _gateway_startup_scenario() -> dict[str, Any]:
port = _free_port()
with tempfile.TemporaryDirectory(
prefix="opensquilla-router-smoke-",
ignore_cleanup_errors=True,
) as tmp:
tmp_path = Path(tmp)
env = os.environ.copy()
env["PYTHONPATH"] = str(SRC_DIR) + os.pathsep + env.get("PYTHONPATH", "")
env["OPENSQUILLA_GATEWAY_CONFIG_PATH"] = str(tmp_path / "no-config.toml")
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
env["OPENSQUILLA_AUTH_MODE"] = "none"
env["OPENSQUILLA_CONTROL_UI_ENABLED"] = "false"
env["OPENSQUILLA_RATE_ENABLED"] = "false"
env["OPENSQUILLA_MEMORY_SOURCE"] = "state"
env["OPENSQUILLA_MEMORY_DREAM_DISABLED"] = "1"
env["OPENSQUILLA_SANDBOX_SANDBOX"] = "false"
env["OPENSQUILLA_SANDBOX_SECURITY_GRADING"] = "false"
env["OPENSQUILLA_SQUILLA_ROUTER_ENABLED"] = "true"
env["OPENSQUILLA_SQUILLA_ROUTER_STRATEGY"] = "v4_phase3"
env["OPENSQUILLA_SQUILLA_ROUTER_ROLLOUT_PHASE"] = "full"
env["OPENSQUILLA_SQUILLA_ROUTER_REQUIRE_ROUTER_RUNTIME"] = "true"
env["OPENROUTER_API_KEY"] = ""
proc = subprocess.Popen(
[
sys.executable,
"-m",
"opensquilla.cli.main",
"gateway",
"run",
"--port",
str(port),
"--bind",
"127.0.0.1",
],
cwd=REPO_ROOT,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
)
health: dict[str, Any] | None = None
public_config: dict[str, Any] | None = None
error: str | None = None
try:
deadline = time.monotonic() + 45
while time.monotonic() < deadline:
if proc.poll() is not None:
stdout, stderr = proc.communicate(timeout=1)
error = f"gateway exited early with code {proc.returncode}: {stderr or stdout}"
break
try:
health = _read_json(f"http://127.0.0.1:{port}/health")
public_config = _read_json(f"http://127.0.0.1:{port}/api/config")
break
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
time.sleep(0.25)
if health is None and error is None:
error = "gateway did not become healthy before timeout"
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
stdout_tail = (proc.stdout.read() if proc.stdout else "")[-2000:]
stderr_tail = (proc.stderr.read() if proc.stderr else "")[-2000:]
squilla_router_config = (public_config or {}).get("squilla_router") or {}
ok = (
error is None
and health is not None
and health.get("ok") is True
and squilla_router_config.get("strategy") == "v4_phase3"
and squilla_router_config.get("enabled") is True
)
return {
"name": "opensquilla_gateway_startup",
"ok": ok,
"port": port,
"health": health or {},
"config": {"squilla_router": squilla_router_config},
"error": error,
"stdout_tail": stdout_tail,
"stderr_tail": stderr_tail,
}
def _live_llm_chat_scenario() -> dict[str, Any]:
api_key_set = bool(os.environ.get("OPENROUTER_API_KEY"))
if not api_key_set:
return {
"name": "opensquilla_gateway_live_llm_chat",
"ok": False,
"error": "OPENROUTER_API_KEY is required for --live-llm",
}
port = _free_port()
live_model = os.environ.get("OPENSQUILLA_LIVE_LLM_MODEL", "").strip()
session_key = f"live-e2e:{int(time.time() * 1000)}"
turns_spec = [
{
"message": "端到端验证第一轮:请用一句中文短句回复,包含 E2E_OK_1,不要调用工具。",
"intent": "new_chat",
},
{
"message": "继续上一轮,请用一句中文短句回复,包含 E2E_OK_2,不要调用工具。",
"intent": "continue",
},
{
"message": (
"不对,你没理解我的意思。"
"请重新回答一句中文短句,包含 E2E_OK_3,不要调用工具。"
),
"intent": "continue",
},
]
with tempfile.TemporaryDirectory(
prefix="opensquilla-router-live-e2e-",
ignore_cleanup_errors=True,
) as tmp:
tmp_path = Path(tmp)
config_path = tmp_path / "live-config.toml"
turn_log_dir = tmp_path / "turn-calls"
_write_live_gateway_config(config_path, live_model)
env = os.environ.copy()
env["PYTHONPATH"] = str(SRC_DIR) + os.pathsep + env.get("PYTHONPATH", "")
env["OPENSQUILLA_GATEWAY_CONFIG_PATH"] = str(config_path)
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
env["OPENSQUILLA_MEMORY_DREAM_DISABLED"] = "1"
env["OPENSQUILLA_SANDBOX_SANDBOX"] = "false"
env["OPENSQUILLA_SANDBOX_SECURITY_GRADING"] = "false"
env["OPENSQUILLA_TURN_CALL_LOG"] = "1"
env["OPENSQUILLA_TURN_CALL_LOG_DIR"] = str(turn_log_dir)
proc = subprocess.Popen(
[
sys.executable,
"-m",
"opensquilla.cli.main",
"gateway",
"run",
"--port",
str(port),
"--bind",
"127.0.0.1",
],
cwd=REPO_ROOT,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
)
turns: list[dict[str, Any]] = []
usage: dict[str, Any] = {}
health: dict[str, Any] | None = None
error: str | None = None
try:
deadline = time.monotonic() + 45
while time.monotonic() < deadline:
if proc.poll() is not None:
stdout, stderr = proc.communicate(timeout=1)
error = f"gateway exited early with code {proc.returncode}: {stderr or stdout}"
break
try:
health = _read_json(f"http://127.0.0.1:{port}/health")
break
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
time.sleep(0.25)
if health is None and error is None:
error = "gateway did not become healthy before timeout"
assistant_count = 0
if error is None:
for index, spec in enumerate(turns_spec, start=1):
accepted = _post_json(
f"http://127.0.0.1:{port}/api/chat",
{
"sessionKey": session_key,
"message": spec["message"],
"intent": spec["intent"],
},
timeout=10.0,
)
assistant, history, turn_error = _wait_for_assistant_reply(
port=port,
session_key=session_key,
previous_assistant_count=assistant_count,
)
if turn_error:
error = f"turn {index} failed: {turn_error}"
break
assistant_count += 1
turns.append(
{
"index": index,
"accepted": accepted,
"assistant_text": str((assistant or {}).get("text", "")).strip(),
"history_message_count": len((history or {}).get("messages", [])),
}
)
if error is None:
usage = _read_json(f"http://127.0.0.1:{port}/api/usage", timeout=5.0)
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
stdout_tail = (proc.stdout.read() if proc.stdout else "")[-2000:]
stderr_tail = (proc.stderr.read() if proc.stderr else "")[-2000:]
turn_call_records = _read_turn_call_records(turn_log_dir)
llm_responses = [
r
for r in turn_call_records
if r.get("session_key") == session_key and r.get("kind") == "llm_response"
]
context_summary = _summarize_llm_request_context(
turn_call_records,
session_keys={session_key},
)
ok = (
error is None
and len(turns) == len(turns_spec)
and all(turn.get("assistant_text") for turn in turns)
and int(usage.get("totalTokens", 0) or 0) > 0
and len(llm_responses) >= len(turns_spec)
)
return {
"name": "opensquilla_gateway_live_llm_chat",
"ok": ok,
"session_key": session_key,
"model": live_model,
"health": health or {},
"turns": turns,
"usage": usage,
"llm_response_count": len(llm_responses),
"llm_response_models": [
((record.get("payload") or {}).get("usage") or {}).get("model")
for record in llm_responses
],
"llm_request_context_summary": context_summary,
"model_override": live_model or None,
"tier_models": _live_tier_model_map(live_model),
"error": error,
"stdout_tail": stdout_tail,
"stderr_tail": stderr_tail,
}
def _live_case_turn_specs(case: dict[str, Any]) -> list[dict[str, str]]:
if "turns" not in case:
return [
{
"message": str(case["message"]),
"intent": "new_chat",
}
]
specs: list[dict[str, str]] = []
for index, turn in enumerate(case["turns"]):
specs.append(
{
"message": str(turn["message"]),
"intent": "new_chat" if index == 0 else "continue",
}
)
return specs
def _live_router_case_suite_scenario() -> dict[str, Any]:
api_key_set = bool(os.environ.get("OPENROUTER_API_KEY"))
if not api_key_set:
return {
"name": "opensquilla_gateway_live_router_case_suite_50",
"ok": False,
"error": "OPENROUTER_API_KEY is required for --live-llm",
}
port = _free_port()
live_model = os.environ.get("OPENSQUILLA_LIVE_LLM_MODEL", "").strip()
live_cases = [
{"kind": "single_turn", **case} for case in SINGLE_TURN_ROUTER_CASES
] + [{"kind": "dialogue", **case} for case in DIALOGUE_ROUTER_CASES]
with tempfile.TemporaryDirectory(
prefix="opensquilla-router-live-cases-",
ignore_cleanup_errors=True,
) as tmp:
tmp_path = Path(tmp)
config_path = tmp_path / "live-config.toml"
turn_log_dir = tmp_path / "turn-calls"
_write_live_gateway_config(config_path, live_model)
env = os.environ.copy()
env["PYTHONPATH"] = str(SRC_DIR) + os.pathsep + env.get("PYTHONPATH", "")
env["OPENSQUILLA_GATEWAY_CONFIG_PATH"] = str(config_path)
env["OPENSQUILLA_STATE_DIR"] = str(tmp_path / "state")
env["OPENSQUILLA_MEMORY_DREAM_DISABLED"] = "1"
env["OPENSQUILLA_SANDBOX_SANDBOX"] = "false"
env["OPENSQUILLA_SANDBOX_SECURITY_GRADING"] = "false"
env["OPENSQUILLA_TURN_CALL_LOG"] = "1"
env["OPENSQUILLA_TURN_CALL_LOG_DIR"] = str(turn_log_dir)
proc = subprocess.Popen(
[
sys.executable,
"-m",
"opensquilla.cli.main",
"gateway",
"run",
"--port",
str(port),
"--bind",
"127.0.0.1",
],
cwd=REPO_ROOT,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
)
health: dict[str, Any] | None = None
usage: dict[str, Any] = {}
cases: list[dict[str, Any]] = []
error: str | None = None
usage_error: str | None = None
try:
health, error = _wait_for_gateway_health(proc, port)
if error is None:
for case_index, case in enumerate(live_cases, start=1):
session_key = (
f"live-case:{case_index:02d}:{case['id']}:"
f"{int(time.time() * 1000)}"
)
assistant_count = 0
turns: list[dict[str, Any]] = []
case_error: str | None = None
for turn_index, turn_spec in enumerate(_live_case_turn_specs(case), start=1):
try:
accepted = _post_json(
f"http://127.0.0.1:{port}/api/chat",
{
"sessionKey": session_key,
"message": turn_spec["message"],
"intent": turn_spec["intent"],
},
timeout=10.0,
)
assistant, history, turn_error = _wait_for_assistant_reply(
port=port,
session_key=session_key,
previous_assistant_count=assistant_count,
)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
accepted = {}
assistant = None
history = None
turn_error = str(exc)
if turn_error:
case_error = f"turn {turn_index} failed: {turn_error}"
turns.append(
{
"index": turn_index,
"intent": turn_spec["intent"],
"accepted": accepted,
"assistant_text": "",
"history_message_count": len(
(history or {}).get("messages", [])
),
"ok": False,
"error": turn_error,
}
)
break
assistant_count += 1
assistant_text = str((assistant or {}).get("text", "")).strip()
turns.append(
{
"index": turn_index,
"intent": turn_spec["intent"],
"accepted": accepted,
"assistant_text": assistant_text,
"history_message_count": len(
(history or {}).get("messages", [])
),
"ok": bool(assistant_text),
}
)
case_ok = bool(turns) and case_error is None and all(
turn["ok"] for turn in turns
)
cases.append(
{
"id": case["id"],
"kind": case["kind"],
"category": case["category"],
"session_key": session_key,
"ok": case_ok,
"turn_count": len(_live_case_turn_specs(case)),
"assistant_reply_count": sum(1 for turn in turns if turn["ok"]),
"turns": turns,
"error": case_error,
}
)
if error is None:
try:
usage = _read_json(f"http://127.0.0.1:{port}/api/usage", timeout=30.0)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
usage_error = str(exc)
finally:
stdout_tail, stderr_tail = _stop_gateway(proc)
turn_call_records = _read_turn_call_records(turn_log_dir)
suite_session_keys = {case["session_key"] for case in cases}
llm_responses = [
record
for record in turn_call_records
if record.get("session_key") in suite_session_keys and record.get("kind") == "llm_response"
]
context_summary = _summarize_llm_request_context(
turn_call_records,
session_keys=suite_session_keys,
)
if int(usage.get("totalTokens", 0) or 0) <= 0:
usage = _usage_from_llm_responses(llm_responses)
turn_count = sum(case["turn_count"] for case in cases)
assistant_reply_count = sum(case["assistant_reply_count"] for case in cases)
ok = (
error is None
and len(cases) == 50
and turn_count >= 50
and assistant_reply_count == turn_count
and len(llm_responses) >= turn_count
and int(usage.get("totalTokens", 0) or 0) > 0
and all(case["ok"] for case in cases)
)
return {
"name": "opensquilla_gateway_live_router_case_suite_50",
"ok": ok,
"case_count": len(cases),
"single_turn_count": len(SINGLE_TURN_ROUTER_CASES),
"dialogue_count": len(DIALOGUE_ROUTER_CASES),
"turn_count": turn_count,
"assistant_reply_count": assistant_reply_count,
"llm_response_count": len(llm_responses),
"llm_request_context_summary": context_summary,
"model_override": live_model or None,
"tier_models": _live_tier_model_map(live_model),
"health": health or {},
"usage": usage,
"usage_error": usage_error,
"cases": cases,
"error": error,
"stdout_tail": stdout_tail,
"stderr_tail": stderr_tail,
}
async def _run_router_scenarios() -> list[dict[str, Any]]:
return [
await _route_case_suite_scenario(),
await _route_class_coverage_scenario(),
await _continuous_dialogue_scenario(),
await _forced_recent_history_scenario(),
await _complaint_upgrade_scenario(),
]
async def _run_all(skip_gateway: bool, live_llm: bool) -> dict[str, Any]:
scenarios = await _run_router_scenarios()
if skip_gateway:
scenarios.append({"name": "opensquilla_gateway_startup", "ok": True, "skipped": True})
else:
scenarios.append(_gateway_startup_scenario())
if live_llm:
scenarios.append(_live_llm_chat_scenario())
scenarios.append(_live_router_case_suite_scenario())
return {
"overall_ok": all(item.get("ok") is True for item in scenarios),
"scenarios": scenarios,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true", help="print a machine-readable report")
parser.add_argument(
"--skip-gateway",
action="store_true",
help="run only router scenarios without starting opensquilla gateway",
)
parser.add_argument(
"--live-llm",
action="store_true",
help="also call a real LLM through /api/chat; requires OPENROUTER_API_KEY",
)
args = parser.parse_args()
if args.json:
captured = io.StringIO()
with contextlib.redirect_stdout(captured):
load_env(REPO_ROOT)
report = asyncio.run(_run_all(skip_gateway=args.skip_gateway, live_llm=args.live_llm))
noise = captured.getvalue()
if noise:
print(noise, file=sys.stderr, end="")
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
load_env(REPO_ROOT)
report = asyncio.run(_run_all(skip_gateway=args.skip_gateway, live_llm=args.live_llm))
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["overall_ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())