diff --git a/desktop/electron/src/main.ts b/desktop/electron/src/main.ts index b675a0ba62..ac92fec17b 100644 --- a/desktop/electron/src/main.ts +++ b/desktop/electron/src/main.ts @@ -1726,28 +1726,6 @@ function canonicalTierKey(name: string): string { } const ROUTER_PROFILE_IDS = new Set(['tokenrhythm', 'openrouter', 'dashscope', 'deepseek', 'gemini', 'volcengine', 'openai', 'zhipu', 'moonshot']) const TOKENRHYTHM_REGISTER_URL = 'https://tokenrhythm.studio/register' -const DESKTOP_ENSEMBLE_PROFILES: Record = { - static_tokenrhythm_b5: { - provider: 'tokenrhythm', - proposers: ['deepseek-v4-pro', 'glm-5.2', 'kimi-k2.7-code', 'qwen3.7-max'], - aggregator: 'glm-5.2', - }, - static_openrouter_b5: { - provider: 'openrouter', - proposers: [ - 'deepseek/deepseek-v4-pro', - 'z-ai/glm-5.2', - 'moonshotai/kimi-k2.7-code', - 'qwen/qwen3.7-max', - ], - aggregator: 'z-ai/glm-5.2', - }, -} - const PROVIDER_CATALOG: ProviderCatalogEntry[] = [ { id: 'tokenrhythm', @@ -2214,31 +2192,11 @@ function ensembleConfigTomlLines(credential: DesktopConnection): string[] { if (!selectionMode) { throw new Error(`LLM Ensemble is not supported for provider ${credential.provider}.`) } - const profile = DESKTOP_ENSEMBLE_PROFILES[selectionMode] - const candidates = profile.proposers.flatMap(model => [ - '', - '[[llm_ensemble.candidates]]', - `provider = ${tomlString(profile.provider)}`, - `model = ${tomlString(model)}`, - 'source = "custom"', - 'enabled = true', - 'role = "proposer"', - ]) - candidates.push( - '', - '[[llm_ensemble.candidates]]', - `provider = ${tomlString(profile.provider)}`, - `model = ${tomlString(profile.aggregator)}`, - 'source = "custom"', - 'enabled = true', - 'role = "aggregator"', - ) return [ '', '[llm_ensemble]', 'enabled = true', - 'selection_mode = "custom_b5"', - ...candidates, + `selection_mode = ${tomlString(selectionMode)}`, ] } diff --git a/docs/features/LLM-ensemble-design.md b/docs/features/LLM-ensemble-design.md index c0d5c1a2df..3efa53e808 100644 --- a/docs/features/LLM-ensemble-design.md +++ b/docs/features/LLM-ensemble-design.md @@ -95,15 +95,16 @@ Source: `_build_static_b5_members`, `STATIC_B5_PROFILES` Each preset is a `StaticB5Profile` — four fixed proposers plus one aggregator, all bound to a single provider: -| Profile | Provider | Proposers | Aggregator | -|---------|----------|-----------|------------| -| `static_openrouter_b5` | `openrouter` | `deepseek/deepseek-v4-pro`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `qwen/qwen3.7-max` | `z-ai/glm-5.2` | -| `static_tokenrhythm_b5` | `tokenrhythm` | `deepseek-v4-pro`, `glm-5.2`, `kimi-k2.7-code`, `qwen3.7-max` | `glm-5.2` | - -The TokenRhythm profile is a mirror of the OpenRouter one: same aggregation -shape and defaults, the same four models, only the provider and the model-id -naming differ (OpenRouter-style `vendor/model` slugs vs. TokenRhythm's bare -names). +| Profile | Provider | Proposers | Aggregator | Thinking | +|---------|----------|-----------|------------|----------| +| `static_openrouter_b5` | `openrouter` | `deepseek/deepseek-v4.1-flash`, `z-ai/glm-5.3-flash`, `qwen/qwen3.8-flash`, `qwen/qwen3.8-max-0902` | `deepseek/deepseek-v4.1-flash` | `high` for every member | +| `static_tokenrhythm_b5` | `tokenrhythm` | `deepseek-flash`, `glm-5.3-flash`, `qwen3.8-flash`, `qwen3.8-max` | `deepseek-flash` | `high` for every member | + +The OpenRouter profile uses the C5 lineup selected by the full DRACO evaluation. +The TokenRhythm profile maps the same C5 model families to model IDs published +by TokenRhythm. Both profiles explicitly set `high` thinking on all four +proposers and the aggregator and use the same aggregation runtime defaults, +while their model-ID conventions remain provider-specific. `_build_static_b5_members` simply materializes the profile: each proposer model becomes an `EnsembleMemberConfig` labeled `proposer_1..N`, the aggregator model diff --git a/docs/features/MULTI-MODEL-FUSION-C5-RELEASE-NOTE.md b/docs/features/MULTI-MODEL-FUSION-C5-RELEASE-NOTE.md new file mode 100644 index 0000000000..23b6796f17 --- /dev/null +++ b/docs/features/MULTI-MODEL-FUSION-C5-RELEASE-NOTE.md @@ -0,0 +1,74 @@ +# 多模型融合 C5 配置更新 + +本次版本将默认多模型融合配置升级为 C5。更新目标是:在提高复杂任务质量的同时,显著降低生成成本和响应时间。 + +## 1. 本次更新内容 + +旧配置使用 DeepSeek V4 Pro、GLM-5.2、Kimi K2.7 Code、Qwen3.7 Max 起草,由 GLM-5.2 汇总。本次升级后的配置为: + +| 供应商 | 4 个起草模型 | 汇总模型 | +| --- | --- | --- | +| OpenRouter | DeepSeek V4.1 Flash、GLM-5.3 Flash、Qwen3.8 Flash、Qwen3.8 Max 0902 | DeepSeek V4.1 Flash | +| TokenRhythm | `deepseek-flash`、`glm-5.3-flash`、`qwen3.8-flash`、`qwen3.8-max` | `deepseek-flash` | + +同时完成以下调整: + +- 所有起草模型和汇总模型均显式使用 `high` 思考档位。 +- 起草模型关闭工具调用,只负责生成独立候选答案。 +- 汇总模型结合原始问题和候选答案生成最终结果,并保留工具调用能力。 +- 起草和汇总超时分别为 120 秒和 180 秒;起草失败不额外重试,至少一个起草成功即可进入汇总。 +- OpenRouter、TokenRhythm、WebUI 和桌面端统一使用同一份静态配置,减少配置漂移。 + +## 2. DRACO 100 题实验结果 + +所有主指标均按同一套 DRACO 100 道任务计算。下表以旧配置 `old` 为基线;费用为平均每题生成费用,耗时不含判分时间。 + +| 组别 | AvgQ(较 old) | AvgPass | Avg Gen$/题(较 old) | p50(较 old) | p95(较 old) | +| --- | ---: | ---: | ---: | ---: | ---: | +| old | 59.188(基线) | 63.503 | $0.423032(基线) | 423.2s(基线) | 3241.7s(基线) | +| A | 60.787(+2.70%) | 64.312 | $0.328690(-22.30%) | 372.0s(-12.11%) | 3037.7s(-6.30%) | +| B | 62.575(+5.72%) | 65.936 | $0.132132(-68.77%) | 266.0s(-37.14%) | 2720.8s(-16.07%) | +| C1 | 63.523(+7.32%) | 67.201 | $0.119361(-71.78%) | 185.4s(-56.20%) | 1910.5s(-41.06%) | +| C2 | 63.807(+7.80%) | 67.624 | $0.073857(-82.54%) | 154.0s(-63.61%) | 934.6s(-71.17%) | +| C3 | 60.395(+2.04%) | 64.186 | $0.069649(-83.54%) | 259.2s(-38.76%) | 2251.6s(-30.54%) | +| C4 | 62.650(+5.85%) | 66.494 | $0.162033(-61.70%) | 193.6s(-54.26%) | 2329.4s(-28.14%) | +| **C5** | **64.190(+8.45%)** | **67.845** | **$0.137097(-67.59%)** | **195.7s(-53.75%)** | **1579.3s(-51.28%)** | +| C6 | 62.692(+5.92%) | 66.038 | $0.144662(-65.80%) | 215.8s(-49.00%) | 2256.7s(-30.39%) | +| C7 | 54.189(-8.45%) | 59.312 | $0.163359(-61.38%) | 157.7s(-62.75%) | 1847.5s(-43.01%) | + +选择 C5 的原因: + +- **质量最高:** C5 的 AvgQ 为 64.190,是全部实验组最高值;相对 old 提高 5.002 个百分点,增幅 8.45%。 +- **成本明显下降:** 平均每题生成费用下降 67.59%,100 题总生成费用从 $42.30 降至 $13.71。 +- **速度明显提升:** p50 缩短 53.75%,p95 缩短 51.28%。 +- **结果更完整:** C5 的 100 道任务全部完成原生评分;old 为 98/100,历史参照 B 为 97/100。 +- **符合质量优先的产品取向:** C2、C3 更便宜,但质量低于 C5。C5 在保留大幅降本的同时取得最高质量,因此作为本次默认配置。 + +相对第二轮历史参照 B,C5 的 AvgQ 再提高 1.615 个百分点,生成费用增加 3.76%,但 p50 和 p95 分别缩短 26.43% 和 41.96%。 + +> 上述结果是本轮 100 题的观测值。不同运行时段、搜索结果、服务负载和缓存状态可能影响结果;C5 相对 B 的质量区间仍跨过零点。 + +## 3. 三个代表性案例 + +### 案例一:印度 NCD 投资分析 + +任务要求识别 2025 年 12 月 8 日仍在开放的 NCD,比较评级、税后收益和发行人杠杆。 + +- **old:48.506 分**。漏掉 Muthoot Mercantile 和 KLM Axiva,并使用了不符合日期条件的发行人;对 EFSL 杠杆的判断也不准确。 +- **C5:76.782 分,提升 28.276 个百分点**。识别出有效发行人,给出评级、11.73% 收益率、发行前后债务权益比,并明确提示高杠杆风险。 + +### 案例二:巴黎萨克雷经济学硕士与 Charpak 奖学金 + +任务要求整理项目录取条件、奖学金资格、申请时间和材料。 + +- **old:39.894 分**。遗漏全日制限制、往届获奖者和博士生不可申请等关键条件,也没有覆盖 M2 申请及推荐信要求。 +- **C5:66.489 分,提升 26.596 个百分点**。补齐交换、实习、研究项目、博士生和往届获奖者等限制,并说明 M2、推荐信及英语证明要求。 + +### 案例三:澳大利亚半退休城市选择 + +任务要求比较 Toowoomba、Bundaberg 和 Cairns 的生活成本、会计岗位、房价及房颤专科医疗条件。 + +- **old:49.863 分**。错误判断 Cairns 缺少本地电生理和消融能力,对 Toowoomba 的心脏服务及城市间价格口径说明也不完整。 +- **C5:68.493 分,提升 18.630 个百分点**。确认 Cairns 本地电生理和消融服务,补齐 Toowoomba 的介入、起搏器、ICD 和消融能力,并解释不同房价数据的时间与统计口径差异。 + +本次配置更新及实验摘要见 [PR #1709](https://github.com/TokenRhythm/opensquilla/pull/1709)。完整实验报告及原始答案未包含在本仓库中。 diff --git a/opensquilla-webui/src/components/setup/SetupModelStrategyPanel.test.ts b/opensquilla-webui/src/components/setup/SetupModelStrategyPanel.test.ts index c430e7c148..acf9384d01 100644 --- a/opensquilla-webui/src/components/setup/SetupModelStrategyPanel.test.ts +++ b/opensquilla-webui/src/components/setup/SetupModelStrategyPanel.test.ts @@ -1587,19 +1587,19 @@ describe('SetupModelStrategyPanel', () => { fixedProfile: { providerLabel: 'OpenRouter', proposers: [ - { key: 'openrouter-fixed:proposer:openrouter:deepseek/deepseek-v4-pro', provider: 'openrouter', model: 'deepseek/deepseek-v4-pro', source: 'openrouter_fixed', enabled: true, role: '' }, - { key: 'openrouter-fixed:proposer:openrouter:z-ai/glm-5.2', provider: 'openrouter', model: 'z-ai/glm-5.2', source: 'openrouter_fixed', enabled: true, role: '' }, - { key: 'openrouter-fixed:proposer:openrouter:moonshotai/kimi-k2.7-code', provider: 'openrouter', model: 'moonshotai/kimi-k2.7-code', source: 'openrouter_fixed', enabled: true, role: '' }, - { key: 'openrouter-fixed:proposer:openrouter:qwen/qwen3.7-max', provider: 'openrouter', model: 'qwen/qwen3.7-max', source: 'openrouter_fixed', enabled: true, role: '' }, + { key: 'openrouter-fixed:proposer:openrouter:deepseek/deepseek-v4.1-flash', provider: 'openrouter', model: 'deepseek/deepseek-v4.1-flash', source: 'openrouter_fixed', enabled: true, role: '' }, + { key: 'openrouter-fixed:proposer:openrouter:z-ai/glm-5.3-flash', provider: 'openrouter', model: 'z-ai/glm-5.3-flash', source: 'openrouter_fixed', enabled: true, role: '' }, + { key: 'openrouter-fixed:proposer:openrouter:qwen/qwen3.8-flash', provider: 'openrouter', model: 'qwen/qwen3.8-flash', source: 'openrouter_fixed', enabled: true, role: '' }, + { key: 'openrouter-fixed:proposer:openrouter:qwen/qwen3.8-max-0902', provider: 'openrouter', model: 'qwen/qwen3.8-max-0902', source: 'openrouter_fixed', enabled: true, role: '' }, ], - aggregator: { key: 'openrouter-fixed:aggregator:openrouter:z-ai/glm-5.2', provider: 'openrouter', model: 'z-ai/glm-5.2', source: 'openrouter_fixed', enabled: true, role: 'aggregator' }, + aggregator: { key: 'openrouter-fixed:aggregator:openrouter:deepseek/deepseek-v4.1-flash', provider: 'openrouter', model: 'deepseek/deepseek-v4.1-flash', source: 'openrouter_fixed', enabled: true, role: 'aggregator' }, }, showCandidateEditor: false, }, }, { onUpdateEnsembleScheme }) - expect(el.textContent).toContain('deepseek/deepseek-v4-pro') - expect(el.textContent).toContain('moonshotai/kimi-k2.7-code') + expect(el.textContent).toContain('deepseek/deepseek-v4.1-flash') + expect(el.textContent).toContain('qwen/qwen3.8-max-0902') expect(el.textContent).toContain('Aggregator') expect(el.querySelector('.setup-model-strategy__ensemble > .control-section__head')).toBeNull() expect(el.textContent).not.toContain('Models draft in parallel') diff --git a/opensquilla-webui/src/composables/setup/useSetupEnsembleForm.test.ts b/opensquilla-webui/src/composables/setup/useSetupEnsembleForm.test.ts index cc70e01beb..4d40d2e01a 100644 --- a/opensquilla-webui/src/composables/setup/useSetupEnsembleForm.test.ts +++ b/opensquilla-webui/src/composables/setup/useSetupEnsembleForm.test.ts @@ -259,7 +259,11 @@ describe('useSetupEnsembleForm — scheme switching', () => { expect(aggregators[0]!.model).toBe(OPENROUTER_FIXED_ENSEMBLE_AGGREGATOR) const proposers = f.candidates.value.filter(c => c.role !== 'aggregator') expect(proposers.map(c => c.model)).toEqual([...OPENROUTER_FIXED_ENSEMBLE_PROPOSERS]) + expect(f.candidates.value.map(c => c.thinking_level)).toEqual(Array(5).fill('high')) expect(f.payload().selectionMode).toBe(CUSTOM_B5_SELECTION_MODE) + expect(f.payload().candidates).toEqual(expect.arrayContaining([ + expect.objectContaining({ thinking_level: 'high' }), + ])) }) it('switching to custom seeds from the TokenRhythm lineup for tokenrhythm', () => { @@ -276,6 +280,7 @@ describe('useSetupEnsembleForm — scheme switching', () => { expect(proposers.map(c => c.model)).toEqual([...TOKENRHYTHM_FIXED_ENSEMBLE_PROPOSERS]) expect(f.candidates.value.find(c => c.role === 'aggregator')!.model) .toBe(TOKENRHYTHM_FIXED_ENSEMBLE_AGGREGATOR) + expect(f.candidates.value.map(c => c.thinking_level)).toEqual(Array(5).fill('high')) }) it('switching back to preset restores the baseline candidate inputs', () => { @@ -294,15 +299,12 @@ describe('useSetupEnsembleForm — scheme switching', () => { expect(f.isDirty.value).toBe(false) }) - it('activateForProvider materializes a legacy preset-provider plan as custom', () => { + it('activateForProvider selects the provider preset for a legacy plan', () => { const f = useSetupEnsembleForm() f.initFromConfig({ selection_mode: 'router_dynamic' }) f.activateForProvider('tokenrhythm') - expect(f.selectionMode.value).toBe(CUSTOM_B5_SELECTION_MODE) - expect(f.candidates.value.filter(c => c.role !== 'aggregator').map(c => c.model)) - .toEqual([...TOKENRHYTHM_FIXED_ENSEMBLE_PROPOSERS]) - expect(f.candidates.value.find(c => c.role === 'aggregator')?.model) - .toBe(TOKENRHYTHM_FIXED_ENSEMBLE_AGGREGATOR) + expect(f.selectionMode.value).toBe('static_tokenrhythm_b5') + expect(f.candidates.value).toEqual([]) }) it('activateForProvider gives other providers an explicit custom lineup seeded from tiers', () => { diff --git a/opensquilla-webui/src/composables/setup/useSetupEnsembleForm.ts b/opensquilla-webui/src/composables/setup/useSetupEnsembleForm.ts index 41b924c7ec..dcade86c34 100644 --- a/opensquilla-webui/src/composables/setup/useSetupEnsembleForm.ts +++ b/opensquilla-webui/src/composables/setup/useSetupEnsembleForm.ts @@ -83,6 +83,7 @@ export interface EnsembleCandidateConfig { source?: 'custom' | 'legacy_model_options' enabled?: boolean role?: string + thinking_level?: string } export interface EnsembleRoutingModeState { @@ -267,6 +268,8 @@ function normalizeCandidates(value: unknown): EnsembleCandidateConfig[] { enabled: raw.enabled === false ? false : true, role, } + const thinkingLevel = String(raw.thinking_level ?? raw.thinkingLevel ?? '').trim() + if (thinkingLevel) normalized.thinking_level = thinkingLevel const existingIndex = seen.get(key) if (existingIndex === undefined) { seen.set(key, out.length) @@ -306,6 +309,7 @@ function customSeedFromProfile(profile: StaticB5Profile): EnsembleCandidateConfi source: 'custom', enabled: true, role: 'proposer', + ...(profile.thinkingLevel ? { thinking_level: profile.thinkingLevel } : {}), })) rows.push({ provider: profile.provider, @@ -313,6 +317,7 @@ function customSeedFromProfile(profile: StaticB5Profile): EnsembleCandidateConfi source: 'custom', enabled: true, role: 'aggregator', + ...(profile.thinkingLevel ? { thinking_level: profile.thinkingLevel } : {}), }) return normalizeCandidates(rows) } @@ -818,13 +823,14 @@ export function useSetupEnsembleForm() { && candidates.value.some(candidate => candidate.enabled !== false) ) return const presetMode = staticB5ModeForProvider(provider) - selectionMode.value = CUSTOM_B5_SELECTION_MODE - if (candidates.value.some(candidate => candidate.enabled !== false)) return - const profile = presetMode ? STATIC_B5_PROFILES[presetMode] : null - if (profile) { - candidates.value = customSeedFromProfile(profile) + if (presetMode) { + selectionMode.value = presetMode + modelOptions.value = [] + candidates.value = [] return } + selectionMode.value = CUSTOM_B5_SELECTION_MODE + if (candidates.value.some(candidate => candidate.enabled !== false)) return importTierCandidates(tierCandidates) } @@ -909,6 +915,7 @@ export function useSetupEnsembleForm() { source: candidate.source || 'custom', enabled: candidate.enabled !== false, role: normalizeCandidateRole(candidate.role), + ...(candidate.thinking_level ? { thinking_level: candidate.thinking_level } : {}), })) if (minSuccessfulDirty.value) params.minSuccessfulProposers = minSuccessfulProposers.value if (allFailedPolicyDirty.value) params.allFailedPolicy = allFailedPolicy.value diff --git a/opensquilla-webui/src/types/generated/router_tier_contract.test.ts b/opensquilla-webui/src/types/generated/router_tier_contract.test.ts index 2b05b7fa4a..29114da00d 100644 --- a/opensquilla-webui/src/types/generated/router_tier_contract.test.ts +++ b/opensquilla-webui/src/types/generated/router_tier_contract.test.ts @@ -15,12 +15,17 @@ describe('generated router-tier SelectionMode contract', () => { ]) expect(staticB5ModeForProvider('OpenRouter')).toBe('static_openrouter_b5') expect(staticB5ModeForProvider('tokenrhythm')).toBe('static_tokenrhythm_b5') + expect(STATIC_B5_PROFILES.static_openrouter_b5?.thinkingLevel).toBe('high') + expect(STATIC_B5_PROFILES.static_tokenrhythm_b5?.thinkingLevel).toBe('high') }) it('keeps provider recommendations and ownership sets in generated data', () => { expect(PROVIDER_RECOMMENDED_ENSEMBLE_SELECTION_MODES.tokenrhythm).toBe( 'static_tokenrhythm_b5', ) + expect(PROVIDER_RECOMMENDED_ENSEMBLE_SELECTION_MODES.openrouter).toBe( + 'static_openrouter_b5', + ) expect(DORMANT_SHARED_SELECTION_MODES).toEqual([ 'static_openrouter_b5', 'static_tokenrhythm_b5', diff --git a/opensquilla-webui/src/types/generated/router_tier_contract.ts b/opensquilla-webui/src/types/generated/router_tier_contract.ts index bfd49cc30a..0ea414fdb6 100644 --- a/opensquilla-webui/src/types/generated/router_tier_contract.ts +++ b/opensquilla-webui/src/types/generated/router_tier_contract.ts @@ -26,6 +26,7 @@ export interface StaticB5Profile { proposers: readonly string[] aggregator: string apiKeyEnv: string + thinkingLevel: string | null ownershipRole: string } @@ -33,17 +34,19 @@ export const STATIC_B5_PROFILES: Record = { "static_openrouter_b5": { provider: "openrouter", label: "OpenRouter", - proposers: ["deepseek/deepseek-v4-pro", "z-ai/glm-5.2", "moonshotai/kimi-k2.7-code", "qwen/qwen3.7-max"] as const, - aggregator: "z-ai/glm-5.2", + proposers: ["deepseek/deepseek-v4.1-flash", "z-ai/glm-5.3-flash", "qwen/qwen3.8-flash", "qwen/qwen3.8-max-0902"] as const, + aggregator: "deepseek/deepseek-v4.1-flash", apiKeyEnv: "OPENROUTER_API_KEY", + thinkingLevel: "high", ownershipRole: "static_profile", }, "static_tokenrhythm_b5": { provider: "tokenrhythm", label: "TokenRhythm", - proposers: ["deepseek-v4-pro", "glm-5.2", "kimi-k2.7-code", "qwen3.7-max"] as const, - aggregator: "glm-5.2", + proposers: ["deepseek-flash", "glm-5.3-flash", "qwen3.8-flash", "qwen3.8-max"] as const, + aggregator: "deepseek-flash", apiKeyEnv: "TOKENRHYTHM_API_KEY", + thinkingLevel: "high", ownershipRole: "static_profile", }, } @@ -51,7 +54,7 @@ export const STATIC_B5_PROFILES: Record = { export const STATIC_B5_SELECTION_MODE_PROVIDERS: Readonly> = {"static_openrouter_b5": "openrouter", "static_tokenrhythm_b5": "tokenrhythm"} export const PROVIDER_RECOMMENDED_ENSEMBLE_SELECTION_MODES: - Readonly> = {"tokenrhythm": "static_tokenrhythm_b5"} + Readonly> = {"openrouter": "static_openrouter_b5", "tokenrhythm": "static_tokenrhythm_b5"} export const SELECTION_MODE_OWNERSHIP_ROLES: Readonly> = {"static_openrouter_b5": "static_profile", "static_tokenrhythm_b5": "static_profile", "custom_b5": "custom_profile", "router_dynamic": "router_dynamic"} diff --git a/scripts/generate_router_tier_contract.py b/scripts/generate_router_tier_contract.py index b9c79bd1e6..c378fd3296 100644 --- a/scripts/generate_router_tier_contract.py +++ b/scripts/generate_router_tier_contract.py @@ -63,6 +63,7 @@ def _profile_block() -> str: + f" proposers: {_json(list(profile.proposer_models))} as const,\n" + f" aggregator: {_json(profile.aggregator_model)},\n" + f" apiKeyEnv: {_json(profile.api_key_env)},\n" + + f" thinkingLevel: {_json(profile.thinking_level)},\n" + f" ownershipRole: {_json(profile.ownership_role)},\n" + " }," ) @@ -111,6 +112,7 @@ def render() -> str: proposers: readonly string[] aggregator: string apiKeyEnv: string + thinkingLevel: string | null ownershipRole: string }} diff --git a/src/opensquilla/gateway/model_routing.py b/src/opensquilla/gateway/model_routing.py index fe21332138..7de5dcc5f6 100644 --- a/src/opensquilla/gateway/model_routing.py +++ b/src/opensquilla/gateway/model_routing.py @@ -235,6 +235,7 @@ def _custom_candidate( model: str, *, role: str = "proposer", + thinking_level: str | None = None, ) -> dict[str, Any]: return { "provider": provider, @@ -242,7 +243,7 @@ def _custom_candidate( "source": "custom", "enabled": True, "role": role, - "thinking_level": "", + "thinking_level": str(thinking_level or "").strip(), } @@ -271,7 +272,11 @@ def _provider_ensemble_candidates(config: Any) -> list[dict[str, Any]]: ) if static_profile is not None: static_candidates = [ - _custom_candidate(static_profile.provider_id, model) + _custom_candidate( + static_profile.provider_id, + model, + thinking_level=static_profile.thinking_level, + ) for model in static_profile.proposer_models ] static_candidates.append( @@ -279,6 +284,7 @@ def _provider_ensemble_candidates(config: Any) -> list[dict[str, Any]]: static_profile.provider_id, static_profile.aggregator_model, role="aggregator", + thinking_level=static_profile.thinking_level, ) ) return static_candidates diff --git a/src/opensquilla/provider/catalog_overrides.toml b/src/opensquilla/provider/catalog_overrides.toml index 4e81df08fa..f110dfc517 100644 --- a/src/opensquilla/provider/catalog_overrides.toml +++ b/src/opensquilla/provider/catalog_overrides.toml @@ -115,6 +115,55 @@ max_output_tokens = 16384 context_window = 256000 max_output_tokens = 16384 +# C5 ensemble models. Budgets, capabilities, and base prices mirror +# OpenRouter's public catalog; live catalog data remains authoritative. + +[openrouter."deepseek/deepseek-v4.1-flash"] +context_window = 1048576 +max_output_tokens = 384000 +supports_reasoning = true +supports_tools = true +supports_vision = true +reasoning_format = "openrouter" +input_cost_per_mtok = 0.15 +output_cost_per_mtok = 0.6 +cache_read_cost_per_mtok = 0.003 + +[openrouter."z-ai/glm-5.3-flash"] +context_window = 1310720 +max_output_tokens = 131072 +supports_reasoning = true +supports_tools = true +supports_vision = true +reasoning_format = "openrouter" +input_cost_per_mtok = 0.09 +output_cost_per_mtok = 0.3 +cache_read_cost_per_mtok = 0.018 + +[openrouter."qwen/qwen3.8-flash"] +context_window = 1000000 +max_output_tokens = 131072 +supports_reasoning = true +supports_tools = true +supports_vision = true +reasoning_format = "openrouter" +input_cost_per_mtok = 0.15 +output_cost_per_mtok = 0.47 +cache_read_cost_per_mtok = 0.016 +cache_write_cost_per_mtok = 0.2 + +[openrouter."qwen/qwen3.8-max-0902"] +context_window = 1000000 +max_output_tokens = 131072 +supports_reasoning = true +supports_tools = true +supports_vision = true +reasoning_format = "openrouter" +input_cost_per_mtok = 2.0 +output_cost_per_mtok = 6.0 +cache_read_cost_per_mtok = 0.25 +cache_write_cost_per_mtok = 2.5 + # Official https://openrouter.ai/api/v1/models, verified 2026-09-17. # Use the smaller catalog/top-provider context when both are published. [openrouter."deepseek/deepseek-v4-flash"] @@ -727,6 +776,42 @@ reasoning_format = "qwen_token_plan" # changing this neutral catalog shape. Vision is published for the kimi rows # and qwen3.8-max; explicit API declarations remain authoritative. +[tokenrhythm."deepseek-flash"] +context_window = 1000000 +max_output_tokens = 384000 +supports_reasoning = true +supports_tools = true +supports_vision = true +reasoning_format = "none" +status = "testing" +input_cost_per_mtok = 0.2867383512544803 +output_cost_per_mtok = 1.1469534050179212 +cache_read_cost_per_mtok = 0.005734767025089606 + +[tokenrhythm."glm-5.3-flash"] +context_window = 1048576 +max_output_tokens = 131072 +supports_reasoning = true +supports_tools = true +supports_vision = true +reasoning_format = "none" +status = "testing" +input_cost_per_mtok = 0.11469534050179211 +output_cost_per_mtok = 0.4014336917562724 +cache_read_cost_per_mtok = 0.03297491039426523 + +[tokenrhythm."qwen3.8-flash"] +context_window = 1000000 +max_output_tokens = 131072 +supports_reasoning = true +supports_tools = true +supports_vision = true +reasoning_format = "none" +status = "testing" +input_cost_per_mtok = 0.11469534050179211 +output_cost_per_mtok = 0.3870967741935484 +cache_read_cost_per_mtok = 0.014336917562724014 + [tokenrhythm."deepseek-v4-flash"] context_window = 1000000 max_output_tokens = 384000 diff --git a/src/opensquilla/provider/ensemble.py b/src/opensquilla/provider/ensemble.py index a298250080..a7a89f1f46 100644 --- a/src/opensquilla/provider/ensemble.py +++ b/src/opensquilla/provider/ensemble.py @@ -6356,13 +6356,14 @@ def _build_router_dynamic_members( return f"{ROUTER_DYNAMIC_SELECTION_MODE}/{routed_tier}", proposers, aggregator, plan -def _static_b5_ref(provider_id: str, model: str) -> _DynamicModelRef: +def _static_b5_ref(profile: StaticB5Profile, model: str) -> _DynamicModelRef: # Thinking belongs to the shared plan, not to the C3 tier that happened to - # activate it. OpenRouter's shipped B5 plan preserves the historical C3 - # high-reasoning default. TokenRhythm rejects thinking-toggle request - # fields, so leaving this unset preserves the provider's default payload. - thinking = "high" if provider_id == "openrouter" else None - return _DynamicModelRef(provider=provider_id, model=model, thinking=thinking) + # activate it. Each profile declares its own provider-compatible override. + return _DynamicModelRef( + provider=profile.provider_id, + model=model, + thinking=profile.thinking_level, + ) def _static_default_if_legacy( @@ -6387,7 +6388,7 @@ def _build_static_b5_members( ) -> tuple[str, list[EnsembleMemberConfig], EnsembleMemberConfig, dict[str, Any]]: proposers = [ _member_from_ref( - _static_b5_ref(profile.provider_id, model), + _static_b5_ref(profile, model), config=config, inherited=inherited_provider_config, label=f"proposer_{index + 1}", @@ -6397,7 +6398,7 @@ def _build_static_b5_members( for index, model in enumerate(profile.proposer_models) ] aggregator = _member_from_ref( - _static_b5_ref(profile.provider_id, profile.aggregator_model), + _static_b5_ref(profile, profile.aggregator_model), config=config, inherited=inherited_provider_config, label="aggregator", @@ -6671,7 +6672,7 @@ def static_b5_credential_available( profile.provider_id, model, inherited_provider_config=inherited, - overrides=_static_b5_ref(profile.provider_id, model), + overrides=_static_b5_ref(profile, model), credential_pool_acquirer=credential_pool_acquirer, session_key=session_key, ).ready diff --git a/src/opensquilla/router_tiers.py b/src/opensquilla/router_tiers.py index 8d78aa52f8..d6d8b0ae38 100644 --- a/src/opensquilla/router_tiers.py +++ b/src/opensquilla/router_tiers.py @@ -37,6 +37,7 @@ class StaticB5Profile: aggregator_model: str label: str api_key_env: str + thinking_level: str | None = None ownership_role: str = "static_profile" @@ -45,27 +46,29 @@ class StaticB5Profile: profile_name=STATIC_OPENROUTER_B5_SELECTION_MODE, provider_id="openrouter", proposer_models=( - "deepseek/deepseek-v4-pro", - "z-ai/glm-5.2", - "moonshotai/kimi-k2.7-code", - "qwen/qwen3.7-max", + "deepseek/deepseek-v4.1-flash", + "z-ai/glm-5.3-flash", + "qwen/qwen3.8-flash", + "qwen/qwen3.8-max-0902", ), - aggregator_model="z-ai/glm-5.2", + aggregator_model="deepseek/deepseek-v4.1-flash", label="OpenRouter", api_key_env="OPENROUTER_API_KEY", + thinking_level="high", ), STATIC_TOKENRHYTHM_B5_SELECTION_MODE: StaticB5Profile( profile_name=STATIC_TOKENRHYTHM_B5_SELECTION_MODE, provider_id="tokenrhythm", proposer_models=( - "deepseek-v4-pro", - "glm-5.2", - "kimi-k2.7-code", - "qwen3.7-max", + "deepseek-flash", + "glm-5.3-flash", + "qwen3.8-flash", + "qwen3.8-max", ), - aggregator_model="glm-5.2", + aggregator_model="deepseek-flash", label="TokenRhythm", api_key_env="TOKENRHYTHM_API_KEY", + thinking_level="high", ), } @@ -80,6 +83,7 @@ class StaticB5Profile: ROUTER_DYNAMIC_SELECTION_MODE: "router_dynamic", } PROVIDER_RECOMMENDED_ENSEMBLE_SELECTION_MODES: dict[str, str] = { + "openrouter": STATIC_OPENROUTER_B5_SELECTION_MODE, "tokenrhythm": STATIC_TOKENRHYTHM_B5_SELECTION_MODE, } ENSEMBLE_SELECTION_MODE_ORDER = ( diff --git a/tests/test_desktop/test_electron_startup_contract.py b/tests/test_desktop/test_electron_startup_contract.py index 19b245ce74..84fc60aee5 100644 --- a/tests/test_desktop/test_electron_startup_contract.py +++ b/tests/test_desktop/test_electron_startup_contract.py @@ -1561,9 +1561,9 @@ def test_desktop_tokenrhythm_single_page_onboarding_defaults_to_router() -> None assert "routerDefaultTier: 'c1'," in router_config assert "routerPresetBinding: 'follow_primary'," in router_config assert "[data-model-routing-mode]" not in onboarding_html - assert "'selection_mode = \"custom_b5\"'" in main_ts - assert "'[[llm_ensemble.candidates]]'" in main_ts - assert "DESKTOP_ENSEMBLE_PROFILES[selectionMode]" in main_ts + assert "`selection_mode = ${tomlString(selectionMode)}`" in main_ts + assert "'[[llm_ensemble.candidates]]'" not in main_ts + assert "DESKTOP_ENSEMBLE_PROFILES" not in main_ts expected_models = ( "deepseek-v4-flash-0731", diff --git a/tests/test_engine/test_agent_message_count_recovery.py b/tests/test_engine/test_agent_message_count_recovery.py index 90cd847b3b..24e51b33ec 100644 --- a/tests/test_engine/test_agent_message_count_recovery.py +++ b/tests/test_engine/test_agent_message_count_recovery.py @@ -464,10 +464,10 @@ async def list_models(self) -> list[Any]: class _EnsembleBudgetCatalog: _WINDOWS = { - "deepseek-v4-pro": 1_000_000, - "glm-5.2": 1_000_000, - "kimi-k2.7-code": 256_000, - "qwen3.7-max": 1_000_000, + "deepseek-flash": 1_000_000, + "glm-5.3-flash": 1_048_576, + "qwen3.8-flash": 1_000_000, + "qwen3.8-max": 1_000_000, } def resolve_context_window_with_source( @@ -1829,18 +1829,17 @@ def _build_member(config: ProviderConfig) -> _CountAwareEnsembleMemberProvider: assert sum(call["wire_messages"] > 100 for call in member_calls) == 4 assert sum(call["wire_messages"] <= 90 for call in member_calls) == 5 assert sum(call["aggregator"] for call in member_calls) == 1 - assert any( - call["model"] == "kimi-k2.7-code" - and call["max_tokens"] == 16_000 - and call["request_cap"] == 880_000 - for call in member_calls - ) - assert any( - call["model"] == "glm-5.2" - and call["max_tokens"] == 128_000 - and call["request_cap"] == 3_408_000 + # Failed drafts, compacted retries, and aggregation must retain C5 member + # budgets instead of inheriting the outer Kimi route's 256k context limit. + assert { + (call["model"], call["max_tokens"], call["request_cap"]) for call in member_calls - ) + } == { + ("deepseek-flash", 384_000, 2_384_000), + ("glm-5.3-flash", 131_072, 3_590_016), + ("qwen3.8-flash", 131_072, 3_395_712), + ("qwen3.8-max", 131_072, 3_395_712), + } assert any(getattr(event, "kind", None) == "done" for event in events) assert not any( isinstance(event, ErrorEvent) diff --git a/tests/test_gateway/test_rpc_model_routing.py b/tests/test_gateway/test_rpc_model_routing.py index 89b8eb6016..3f9d670cc5 100644 --- a/tests/test_gateway/test_rpc_model_routing.py +++ b/tests/test_gateway/test_rpc_model_routing.py @@ -63,7 +63,7 @@ def test_fresh_tokenrhythm_ensemble_activation_materializes_recommended_plan() - assert "llm_ensemble.candidates" not in changed.force_persist_paths() -def test_fresh_openrouter_ensemble_activation_materializes_custom_lineup() -> None: +def test_fresh_openrouter_ensemble_activation_uses_recommended_static_plan() -> None: cfg = GatewayConfig( llm={ "provider": "openrouter", @@ -73,13 +73,10 @@ def test_fresh_openrouter_ensemble_activation_materializes_custom_lineup() -> No changed = upsert_llm_ensemble(cfg, enabled=True).config - assert changed.llm_ensemble.selection_mode == "custom_b5" - assert len(changed.llm_ensemble.candidates) == 5 - assert { - candidate.provider for candidate in changed.llm_ensemble.candidates - } == {"openrouter"} - assert changed.llm_ensemble.candidates[-1].model == "z-ai/glm-5.2" - assert changed.llm_ensemble.candidates[-1].role == "aggregator" + assert changed.llm_ensemble.selection_mode == "static_openrouter_b5" + assert changed.llm_ensemble.candidates == [] + assert "llm_ensemble.selection_mode" in changed.force_persist_paths() + assert "llm_ensemble.candidates" not in changed.force_persist_paths() def test_explicit_cross_provider_ensemble_selection_is_preserved() -> None: diff --git a/tests/test_llm_ensemble_config.py b/tests/test_llm_ensemble_config.py index 5a4f6e5863..198f66e4a8 100644 --- a/tests/test_llm_ensemble_config.py +++ b/tests/test_llm_ensemble_config.py @@ -47,12 +47,14 @@ def test_llm_ensemble_defaults_to_disabled_for_model_router_first_install() -> N ) assert provider.profile_name == "static_openrouter_b5" assert [member.provider_config.model for member in provider.proposers] == [ - "deepseek/deepseek-v4-pro", - "z-ai/glm-5.2", - "moonshotai/kimi-k2.7-code", - "qwen/qwen3.7-max", + "deepseek/deepseek-v4.1-flash", + "z-ai/glm-5.3-flash", + "qwen/qwen3.8-flash", + "qwen/qwen3.8-max-0902", ] - assert provider.aggregator.provider_config.model == "z-ai/glm-5.2" + assert provider.aggregator.provider_config.model == "deepseek/deepseek-v4.1-flash" + assert {member.thinking for member in provider.proposers} == {"high"} + assert provider.aggregator.thinking == "high" # The fresh/default shared policy is a one-draft admission floor. assert provider.min_successful_proposers == 1 assert provider.target_successful_proposers == 1 @@ -85,15 +87,15 @@ def test_static_openrouter_b5_does_not_need_model_options() -> None: assert provider.profile_name == "static_openrouter_b5" assert [member.provider_config.model for member in provider.proposers] == [ - "deepseek/deepseek-v4-pro", - "z-ai/glm-5.2", - "moonshotai/kimi-k2.7-code", - "qwen/qwen3.7-max", + "deepseek/deepseek-v4.1-flash", + "z-ai/glm-5.3-flash", + "qwen/qwen3.8-flash", + "qwen/qwen3.8-max-0902", ] - assert provider.aggregator.provider_config.model == "z-ai/glm-5.2" + assert provider.aggregator.provider_config.model == "deepseek/deepseek-v4.1-flash" -def test_static_tokenrhythm_b5_mirrors_the_openrouter_lineup() -> None: +def test_static_tokenrhythm_b5_keeps_its_provider_specific_lineup() -> None: cfg = GatewayConfig( llm_ensemble={ "enabled": True, @@ -114,16 +116,18 @@ def test_static_tokenrhythm_b5_mirrors_the_openrouter_lineup() -> None: assert provider.profile_name == "static_tokenrhythm_b5" assert [member.provider_config.model for member in provider.proposers] == [ - "deepseek-v4-pro", - "glm-5.2", - "kimi-k2.7-code", - "qwen3.7-max", + "deepseek-flash", + "glm-5.3-flash", + "qwen3.8-flash", + "qwen3.8-max", ] assert all( member.provider_config.provider == "tokenrhythm" for member in provider.proposers ) + assert all(member.thinking == "high" for member in provider.proposers) assert provider.aggregator.provider_config.provider == "tokenrhythm" - assert provider.aggregator.provider_config.model == "glm-5.2" + assert provider.aggregator.provider_config.model == "deepseek-flash" + assert provider.aggregator.thinking == "high" # Same aggregation defaults as the static OpenRouter profile. assert provider.min_successful_proposers == 1 assert provider.proposer_timeout_seconds == 120.0 @@ -443,10 +447,10 @@ def test_static_openrouter_b5_ensemble_locks_members_across_routed_tiers() -> No provider_routing={"z-ai/glm-5.2": "z-ai"}, ) expected_proposers = [ - "deepseek/deepseek-v4-pro", - "z-ai/glm-5.2", - "moonshotai/kimi-k2.7-code", - "qwen/qwen3.7-max", + "deepseek/deepseek-v4.1-flash", + "z-ai/glm-5.3-flash", + "qwen/qwen3.8-flash", + "qwen/qwen3.8-max-0902", ] for tier in ("c0", "c1", "c2", "c3"): @@ -459,12 +463,12 @@ def test_static_openrouter_b5_ensemble_locks_members_across_routed_tiers() -> No assert provider.profile_name == "static_openrouter_b5" assert [member.provider_config.model for member in provider.proposers] == expected_proposers - assert provider.aggregator.provider_config.model == "z-ai/glm-5.2" + assert provider.aggregator.provider_config.model == "deepseek/deepseek-v4.1-flash" assert provider.selection_plan == { "strategy": "static_openrouter_b5", "profile": "static_openrouter_b5", "proposer_models": expected_proposers, - "aggregator_model": "z-ai/glm-5.2", + "aggregator_model": "deepseek/deepseek-v4.1-flash", "proposer_count": 4, "configured_min_successful_proposers": 9, "effective_min_successful_proposers": 4, diff --git a/tests/test_provider/test_catalog_layers.py b/tests/test_provider/test_catalog_layers.py index 5eaf965e13..5db3cbc570 100644 --- a/tests/test_provider/test_catalog_layers.py +++ b/tests/test_provider/test_catalog_layers.py @@ -321,6 +321,10 @@ def test_packaged_corrections_file_parses_with_expected_tables() -> None: assert set(payload["openrouter"]) == { "anthropic/claude-opus-4.8", "anthropic/claude-sonnet-4.6", + "deepseek/deepseek-v4.1-flash", + "qwen/qwen3.8-flash", + "qwen/qwen3.8-max-0902", + "z-ai/glm-5.3-flash", "x-ai/grok-4.3", "stepfun/step-3.5-flash", "deepseek/deepseek-v4-flash", diff --git a/tests/test_provider_ensemble.py b/tests/test_provider_ensemble.py index e7a00b3033..993968a462 100644 --- a/tests/test_provider_ensemble.py +++ b/tests/test_provider_ensemble.py @@ -401,10 +401,11 @@ def __init__( windows: dict[str, tuple[int, str] | Exception] | None = None, ) -> None: self.windows = windows or { - "deepseek-v4-pro": (1_000_000, "catalog"), - "glm-5.2": (1_000_000, "catalog"), + "deepseek-flash": (1_000_000, "catalog"), + "glm-5.3-flash": (1_048_576, "catalog"), + "qwen3.8-flash": (1_000_000, "catalog"), + "qwen3.8-max": (1_000_000, "catalog"), "kimi-k2.7-code": (256_000, "catalog"), - "qwen3.7-max": (1_000_000, "catalog"), } def _resolve(self, model_id: str) -> tuple[int, str]: @@ -475,7 +476,7 @@ def test_ensemble_budget_uses_each_tokenrhythm_authority_window( def _tokenrhythm_budget_registry() -> _FakeRegistry: - models = ("deepseek-v4-pro", "glm-5.2", "kimi-k2.7-code", "qwen3.7-max") + models = ("deepseek-flash", "glm-5.3-flash", "qwen3.8-flash", "qwen3.8-max") return _FakeRegistry( { model: _FakePlan( @@ -3288,28 +3289,35 @@ async def test_tokenrhythm_ensemble_rebinds_request_cap_per_member_context( ] calls_by_model = {call["model"]: call["config"] for call in registry.calls} - # Kimi's 256k window and 16k output yield 880,000 chars; GLM's 1m window yields - # 3,408,000. Parameterizing the inherited cap pins both widening and - # tightening instead of relying on the outer route's model. - assert calls_by_model["kimi-k2.7-code"].provider_request_max_chars == 880_000 - assert calls_by_model["glm-5.2"].provider_request_max_chars == 3_408_000 + # The models share roughly 1m context windows but reserve different output + # budgets plus the static profile's explicit high-thinking allowance. + # Parameterizing the inherited cap pins both widening and tightening + # instead of relying on the outer route's model. + assert calls_by_model["qwen3.8-flash"].provider_request_max_chars == 3_375_712 + assert calls_by_model["deepseek-flash"].provider_request_max_chars == 2_364_000 + assert all(call["config"].thinking is True for call in registry.calls) + assert all(call["config"].thinking_level == "high" for call in registry.calls) done = next(event for event in events if isinstance(event, DoneEvent)) assert done.ensemble_trace is not None - kimi_trace = next( + qwen_trace = next( candidate["execution"] for candidate in done.ensemble_trace["candidates"] - if candidate["model"] == "kimi-k2.7-code" - ) - assert kimi_trace["effective_context_window_tokens"] == 256_000 - assert kimi_trace["effective_context_window_source"] == "catalog" - assert kimi_trace["effective_provider_request_max_chars"] == 880_000 - assert kimi_trace["provider_request_max_chars_source"] == "member_context" + if candidate["model"] == "qwen3.8-flash" + ) + assert qwen_trace["effective_context_window_tokens"] == 1_000_000 + assert qwen_trace["effective_context_window_source"] == "catalog" + assert qwen_trace["effective_provider_request_max_chars"] == 3_375_712 + assert qwen_trace["provider_request_max_chars_source"] == "member_context" + assert qwen_trace["effective_thinking"] is True + assert qwen_trace["effective_thinking_level"] == "high" aggregator_trace = done.ensemble_trace["final_request"]["execution"] assert aggregator_trace["effective_context_window_tokens"] == 1_000_000 assert aggregator_trace["effective_context_window_source"] == "catalog" - assert aggregator_trace["effective_provider_request_max_chars"] == 3_408_000 + assert aggregator_trace["effective_provider_request_max_chars"] == 2_364_000 assert aggregator_trace["provider_request_max_chars_source"] == "member_context" + assert aggregator_trace["effective_thinking"] is True + assert aggregator_trace["effective_thinking_level"] == "high" @pytest.mark.parametrize( @@ -3409,10 +3417,10 @@ async def test_ensemble_member_context_precedence_is_override_then_global_then_c monkeypatch.setattr("opensquilla.provider.ensemble._build_provider", registry.provider_for) catalog = _BudgetCatalog( { - "deepseek-v4-pro": (1_000_000, "catalog"), - "glm-5.2": (1_000_000, "catalog"), - "kimi-k2.7-code": (300_000, "override"), - "qwen3.7-max": (1_000_000, "catalog"), + "deepseek-flash": (1_000_000, "catalog"), + "glm-5.3-flash": (1_048_576, "catalog"), + "qwen3.8-flash": (300_000, "override"), + "qwen3.8-max": (1_000_000, "catalog"), } ) provider = _build_tokenrhythm_budget_provider( @@ -3433,17 +3441,17 @@ async def test_ensemble_member_context_precedence_is_override_then_global_then_c ] calls_by_model = {call["model"]: call["config"] for call in registry.calls} - assert calls_by_model["kimi-k2.7-code"].provider_request_max_chars == 1_056_000 - assert calls_by_model["glm-5.2"].provider_request_max_chars == 1_408_000 + assert calls_by_model["qwen3.8-flash"].provider_request_max_chars == 575_712 + assert calls_by_model["deepseek-flash"].provider_request_max_chars == 364_000 done = next(event for event in events if isinstance(event, DoneEvent)) assert done.ensemble_trace is not None - kimi_trace = next( + qwen_trace = next( candidate["execution"] for candidate in done.ensemble_trace["candidates"] - if candidate["model"] == "kimi-k2.7-code" + if candidate["model"] == "qwen3.8-flash" ) - assert kimi_trace["effective_context_window_source"] == "override" - assert kimi_trace["effective_context_window_tokens"] == 300_000 + assert qwen_trace["effective_context_window_source"] == "override" + assert qwen_trace["effective_context_window_tokens"] == 300_000 aggregator_trace = done.ensemble_trace["final_request"]["execution"] assert aggregator_trace["effective_context_window_source"] == "config" assert aggregator_trace["effective_context_window_tokens"] == 500_000 @@ -4044,10 +4052,10 @@ async def test_ensemble_default_context_rebinds_but_catalog_failure_retains_oute monkeypatch.setattr("opensquilla.provider.ensemble._build_provider", registry.provider_for) catalog = _BudgetCatalog( { - "deepseek-v4-pro": (1_000_000, "catalog"), - "glm-5.2": RuntimeError("catalog unavailable"), - "kimi-k2.7-code": (256_000, "default"), - "qwen3.7-max": (1_000_000, "catalog"), + "deepseek-flash": RuntimeError("catalog unavailable"), + "glm-5.3-flash": (1_048_576, "catalog"), + "qwen3.8-flash": (1_000_000, "default"), + "qwen3.8-max": (1_000_000, "catalog"), } ) provider = _build_tokenrhythm_budget_provider(catalog=catalog) @@ -4065,20 +4073,20 @@ async def test_ensemble_default_context_rebinds_but_catalog_failure_retains_oute ] calls_by_model = {call["model"]: call["config"] for call in registry.calls} - # Automatic output follows this deployment's fallback, without borrowing - # a different authority's catalog. The member's context is still rebound. - assert calls_by_model["kimi-k2.7-code"].max_tokens == 16_000 - assert calls_by_model["kimi-k2.7-code"].provider_request_max_chars == 880_000 - assert calls_by_model["glm-5.2"].provider_request_max_chars == 555_555 + # The C5 member keeps its model-specific output ceiling while rebinding + # the default context; a catalog error still preserves the outer cap. + assert calls_by_model["qwen3.8-flash"].max_tokens == 131_072 + assert calls_by_model["qwen3.8-flash"].provider_request_max_chars == 3_375_712 + assert calls_by_model["deepseek-flash"].provider_request_max_chars == 555_555 done = next(event for event in events if isinstance(event, DoneEvent)) assert done.ensemble_trace is not None - kimi_trace = next( + qwen_trace = next( candidate["execution"] for candidate in done.ensemble_trace["candidates"] - if candidate["model"] == "kimi-k2.7-code" + if candidate["model"] == "qwen3.8-flash" ) - assert kimi_trace["effective_context_window_source"] == "default" - assert kimi_trace["provider_request_max_chars_source"] == "member_context" + assert qwen_trace["effective_context_window_source"] == "default" + assert qwen_trace["provider_request_max_chars_source"] == "member_context" aggregator_trace = done.ensemble_trace["final_request"]["execution"] assert aggregator_trace["effective_context_window_source"] == "error" assert aggregator_trace["provider_request_max_chars_source"] == "inherited" @@ -4088,7 +4096,7 @@ async def test_ensemble_default_context_rebinds_but_catalog_failure_retains_oute async def test_rebinding_rebinds_fallback_chat_config( monkeypatch: pytest.MonkeyPatch, ) -> None: - models = ("deepseek-v4-pro", "glm-5.2", "kimi-k2.7-code", "qwen3.7-max") + models = ("deepseek-flash", "glm-5.3-flash", "qwen3.8-flash", "qwen3.8-max") registry = _FakeRegistry( { model: _FakePlan([ErrorEvent(message="synthetic failure", code="500")]) diff --git a/tests/test_provider_model_catalog.py b/tests/test_provider_model_catalog.py index a9facb49b0..ed0f9baf68 100644 --- a/tests/test_provider_model_catalog.py +++ b/tests/test_provider_model_catalog.py @@ -63,6 +63,27 @@ def test_deepseek_direct_current_flash_alias_and_pro_limits() -> None: assert caps.reasoning_format == "deepseek" +def test_openrouter_c5_models_have_offline_budgets_and_capabilities() -> None: + catalog = ModelCatalog() + expected = { + "deepseek/deepseek-v4.1-flash": (1_048_576, 384_000), + "z-ai/glm-5.3-flash": (1_310_720, 131_072), + "qwen/qwen3.8-flash": (1_000_000, 131_072), + "qwen/qwen3.8-max-0902": (1_000_000, 131_072), + } + + for model, (context_window, max_tokens) in expected.items(): + assert catalog.resolve_context_window_with_source( + model, provider="openrouter" + ) == (context_window, "catalog") + assert catalog.resolve_max_tokens(model, provider="openrouter") == max_tokens + capabilities = catalog.get_capabilities(model, provider_name="openrouter") + assert capabilities.supports_reasoning is True + assert capabilities.supports_tools is True + assert capabilities.supports_vision is True + assert capabilities.reasoning_format == "openrouter" + + @pytest.mark.parametrize( ("model", "window", "output"), [ @@ -108,6 +129,21 @@ def test_provider_scoped_corrections_budget_outranks_snapshot_merge() -> None: them (see test_provider/test_live_catalog.py).""" catalog = ModelCatalog() + expected_c5_budgets = { + "deepseek-flash": (1_000_000, 384_000), + "glm-5.3-flash": (1_048_576, 131_072), + "qwen3.8-flash": (1_000_000, 131_072), + "qwen3.8-max": (1_000_000, 131_072), + } + for model, (context_window, max_tokens) in expected_c5_budgets.items(): + assert catalog.resolve_context_window_with_source( + model, provider="tokenrhythm" + ) == (context_window, "catalog") + assert ( + catalog.resolve_max_tokens(model, provider="tokenrhythm") + == max_tokens + ) + assert catalog.resolve_context_window_with_source( "deepseek-v4-flash", provider="tokenrhythm" ) == (1_000_000, "catalog") diff --git a/tests/test_router_tier_contract.py b/tests/test_router_tier_contract.py index 3ed331508b..0bbc7ece9f 100644 --- a/tests/test_router_tier_contract.py +++ b/tests/test_router_tier_contract.py @@ -58,6 +58,15 @@ def test_selection_mode_metadata_has_one_canonical_profile_owner() -> None: assert tokenrhythm is STATIC_B5_PROFILES["static_tokenrhythm_b5"] assert openrouter is not None and openrouter.provider_id == "openrouter" assert tokenrhythm is not None and tokenrhythm.provider_id == "tokenrhythm" + assert tokenrhythm.proposer_models == ( + "deepseek-flash", + "glm-5.3-flash", + "qwen3.8-flash", + "qwen3.8-max", + ) + assert tokenrhythm.aggregator_model == "deepseek-flash" + assert openrouter.thinking_level == "high" + assert tokenrhythm.thinking_level == "high" assert openrouter.ownership_role == "static_profile" assert tokenrhythm.api_key_env == "TOKENRHYTHM_API_KEY"