Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/scripts/windows_test_assignments.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"tests/test_engine/test_agent_provider_identity.py",
"tests/test_engine/test_agent_transactional_tool_publication.py",
"tests/test_engine/test_attachment_aware_routing.py",
"tests/test_engine/test_attachment_replay_ownership.py",
"tests/test_engine/test_auxiliary_usage_accounting.py",
"tests/test_engine/test_cancelled_turn_segments.py",
"tests/test_engine/test_coding_mode.py",
Expand All @@ -105,6 +106,7 @@
"tests/test_engine/test_resolve_turn_thinking.py",
"tests/test_engine/test_route_plan.py",
"tests/test_engine/test_router_calibration.py",
"tests/test_engine/test_router_configured_image_policy.py",
"tests/test_engine/test_router_decision_event.py",
"tests/test_engine/test_routing_policy_parity.py",
"tests/test_engine/test_runtime_agent_iteration_timeout.py",
Expand Down Expand Up @@ -158,6 +160,7 @@
"tests/test_provider/test_context_profile_parity.py",
"tests/test_provider/test_error_secret_boundary.py",
"tests/test_provider/test_failure_injection.py",
"tests/test_provider/test_image_projection.py",
"tests/test_provider/test_list_models_provider_identity.py",
"tests/test_provider/test_live_catalog.py",
"tests/test_provider/test_preset_registry.py",
Expand Down Expand Up @@ -857,6 +860,7 @@
"tests/test_session/test_agent_task_storage.py",
"tests/test_session/test_artifact_session_lifecycle.py",
"tests/test_session/test_assistant_message_identity.py",
"tests/test_session/test_attachment_manifest.py",
"tests/test_session/test_branch_media_copy.py",
"tests/test_session/test_cascade_pagination.py",
"tests/test_session/test_compaction.py",
Expand Down
4 changes: 4 additions & 0 deletions .github/scripts/windows_test_durations.json
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@
"tests/test_engine/test_artifact_routing_policy.py": 0.01,
"tests/test_engine/test_attachment_aware_routing.py": 9.013,
"tests/test_engine/test_attachment_messages.py": 1.073,
"tests/test_engine/test_attachment_replay_ownership.py": 0.01,
"tests/test_engine/test_auxiliary_usage_accounting.py": 0.028,
"tests/test_engine/test_bootstrap_snapshot_invalidation.py": 0.478,
"tests/test_engine/test_cache_break_monitor.py": 0.106,
Expand Down Expand Up @@ -400,6 +401,7 @@
"tests/test_engine/test_router_budget_gate.py": 0.043,
"tests/test_engine/test_router_calibration.py": 0.144,
"tests/test_engine/test_router_calibration_service.py": 3.359,
"tests/test_engine/test_router_configured_image_policy.py": 0.01,
"tests/test_engine/test_router_control.py": 0.062,
"tests/test_engine/test_router_decision_event.py": 0.07,
"tests/test_engine/test_router_decision_record.py": 0.786,
Expand Down Expand Up @@ -955,6 +957,7 @@
"tests/test_provider/test_error_secret_boundary.py": 0.194,
"tests/test_provider/test_failure_classification_parity.py": 0.353,
"tests/test_provider/test_failure_injection.py": 0.066,
"tests/test_provider/test_image_projection.py": 0.01,
"tests/test_provider/test_live_catalog.py": 0.089,
"tests/test_provider/test_list_models_provider_identity.py": 0.01,
"tests/test_provider/test_models_dev_snapshot_costs.py": 0.01,
Expand Down Expand Up @@ -1205,6 +1208,7 @@
"tests/test_session/test_agent_task_storage.py": 4.576,
"tests/test_session/test_artifact_session_lifecycle.py": 0.01,
"tests/test_session/test_assistant_message_identity.py": 0.062,
"tests/test_session/test_attachment_manifest.py": 0.01,
"tests/test_session/test_branch_media_copy.py": 0.88,
"tests/test_session/test_cascade_pagination.py": 0.078,
"tests/test_session/test_compaction.py": 0.282,
Expand Down
11 changes: 6 additions & 5 deletions desktop/electron/scripts/test-onboarding-flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1135,20 +1135,21 @@ try {
assert.equal(credential.routerTiers.c1.model, 'deepseek-v4-pro-0813')
assert.equal(credential.routerTiers.c2.model, 'kimi-k2.7-code')
assert.equal(credential.routerTiers.c3.model, 'glm-5.2')
assert.equal(credential.routerTiers.c0.supportsImage, false)
assert.equal(credential.routerTiers.c1.supportsImage, false)
assert.equal(credential.routerTiers.c2.supportsImage, false)
assert.equal(credential.routerTiers.c3.supportsImage, false)
assert.equal(Object.hasOwn(credential.routerTiers.c0, 'supportsImage'), false)
assert.equal(Object.hasOwn(credential.routerTiers.c1, 'supportsImage'), false)
assert.equal(Object.hasOwn(credential.routerTiers.c2, 'supportsImage'), false)
assert.equal(Object.hasOwn(credential.routerTiers.c3, 'supportsImage'), false)
assert.equal(credential.routerTiers.c3.ensembleEnabled, true)
assert.equal(credential.routerTiers.image_model.model, 'kimi-k2.6')
assert.equal(credential.routerTiers.image_model.supportsImage, true)
assert.equal(Object.hasOwn(credential.routerTiers.image_model, 'supportsImage'), false)
assert.match(config, /\[squilla_router\]\nenabled = true/)
assert.match(config, /\[llm\][\s\S]*?model = "deepseek-v4-pro-0813"/)
assert.match(config, /\[squilla_router\.tiers\.c0\]\nprovider = "tokenrhythm"\nmodel = "deepseek-v4-flash-0731"/)
assert.match(config, /\[squilla_router\.tiers\.c1\]\nprovider = "tokenrhythm"\nmodel = "deepseek-v4-pro-0813"/)
assert.match(config, /\[squilla_router\.tiers\.c2\]\nprovider = "tokenrhythm"\nmodel = "kimi-k2.7-code"/)
assert.match(config, /\[squilla_router\.tiers\.c3\][\s\S]*?model = "glm-5.2"[\s\S]*?ensemble_enabled = true/)
assert.doesNotMatch(config, /thinking_level\s*=/)
assert.doesNotMatch(config, /supports_image\s*=/)
assert.match(config, /\[llm_ensemble\]\nenabled = false/)
assert.equal(successfulProbeServer.requests.length, 1)
assert.equal(successfulProbeServer.requests[0].url, '/v1/chat/completions')
Expand Down
21 changes: 19 additions & 2 deletions desktop/electron/scripts/test-router-tier-normalization.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { strict as assert } from 'node:assert'
import { normalizeRouterTiers } from '../dist/router-tier-normalization.js'

const currentFallback = {
c0: { provider: 'tokenrhythm', model: 'deepseek-v4-flash-0731' },
c0: { provider: 'tokenrhythm', model: 'deepseek-v4-flash-0731', supportsImage: false },
c1: { provider: 'tokenrhythm', model: 'deepseek-v4-pro-0813' },
c2: { provider: 'tokenrhythm', model: 'kimi-k2.7-code' },
c3: { provider: 'tokenrhythm', model: 'glm-5.2', ensembleEnabled: true },
c3: { provider: 'tokenrhythm', model: 'glm-5.2', supportsImage: true, ensembleEnabled: true },
}

const legacyCredentialTiers = {
Expand All @@ -22,15 +22,32 @@ assert.deepEqual(
Object.fromEntries(Object.entries(legacyCredentialTiers).map(([name, tier]) => [name, tier.model])),
)
assert.equal(Object.hasOwn(loaded.c3, 'ensembleEnabled'), false)
assert.equal(Object.hasOwn(loaded.c0, 'supportsImage'), false)
assert.equal(Object.hasOwn(loaded.c3, 'supportsImage'), false)

// saveDesktopCredential normalizes the already-loaded same-provider ladder a
// second time. The missing legacy opt-in must remain missing on that pass.
const resaved = normalizeRouterTiers(loaded, currentFallback)
assert.deepEqual(resaved, loaded)
assert.equal(Object.hasOwn(resaved.c3, 'ensembleEnabled'), false)
assert.equal(Object.hasOwn(resaved.c0, 'supportsImage'), false)

for (const key of ['supports_image', 'supportsImage']) {
for (const value of [false, true]) {
const legacyCapability = normalizeRouterTiers(
{ ...legacyCredentialTiers, c0: { ...legacyCredentialTiers.c0, [key]: value } },
currentFallback,
)
assert.equal(Object.hasOwn(legacyCapability.c0, 'supportsImage'), false)
assert.equal(Object.hasOwn(legacyCapability.c0, 'supports_image'), false)
assert.equal(legacyCapability.c0.model, legacyCredentialTiers.c0.model)
}
}

const fresh = normalizeRouterTiers(undefined, currentFallback)
assert.equal(fresh.c3.ensembleEnabled, true)
assert.equal(Object.hasOwn(fresh.c0, 'supportsImage'), false)
assert.equal(Object.hasOwn(fresh.c3, 'supportsImage'), false)

const explicitSnakeCase = normalizeRouterTiers(
{ ...legacyCredentialTiers, c3: { ...legacyCredentialTiers.c3, ensemble_enabled: true } },
Expand Down
21 changes: 10 additions & 11 deletions desktop/electron/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1914,18 +1914,18 @@ function minimaxRouterProfile(provider: string): Record<string, RouterTier> {

const ROUTER_PROFILES: Record<string, Record<string, RouterTier>> = {
tokenrhythm: {
c0: { provider: 'tokenrhythm', model: 'deepseek-v4-flash-0731', description: 'Fast DeepSeek V4 Flash 0731 route for simple work', supportsImage: false },
c1: { provider: 'tokenrhythm', model: 'deepseek-v4-pro-0813', description: 'Default DeepSeek V4 Pro 0813 route for normal agent work', supportsImage: false },
c2: { provider: 'tokenrhythm', model: 'kimi-k2.7-code', description: 'Strong Kimi 2.7 Code route for harder coding and analysis', supportsImage: false },
c3: { provider: 'tokenrhythm', model: 'glm-5.2', description: 'Highest tier: shared B5 fusion; GLM 5.2 is retained for single-model C3 mode', supportsImage: false, ensembleEnabled: true },
image_model: { provider: 'tokenrhythm', model: 'kimi-k2.6', description: 'Vision route for image attachments', supportsImage: true, imageOnly: true },
c0: { provider: 'tokenrhythm', model: 'deepseek-v4-flash-0731', description: 'Fast DeepSeek V4 Flash 0731 route for simple work' },
c1: { provider: 'tokenrhythm', model: 'deepseek-v4-pro-0813', description: 'Default DeepSeek V4 Pro 0813 route for normal agent work' },
c2: { provider: 'tokenrhythm', model: 'kimi-k2.7-code', description: 'Strong Kimi 2.7 Code route for harder coding and analysis' },
c3: { provider: 'tokenrhythm', model: 'glm-5.2', description: 'Highest tier: shared B5 fusion; GLM 5.2 is retained for single-model C3 mode', ensembleEnabled: true },
image_model: { provider: 'tokenrhythm', model: 'kimi-k2.6', description: 'Vision route for image attachments', imageOnly: true },
},
openrouter: {
c0: { provider: 'openrouter', model: 'deepseek/deepseek-v4-flash', description: 'Fast everyday work', thinkingLevel: 'high' },
c1: { provider: 'openrouter', model: 'deepseek/deepseek-v4-pro', description: 'Balanced agent work', thinkingLevel: 'high' },
c2: { provider: 'openrouter', model: 'z-ai/glm-5.2', description: 'Complex reasoning', thinkingLevel: 'high' },
c3: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8', description: 'Highest quality review and planning', thinkingLevel: 'high' },
image_model: { provider: 'openrouter', model: 'moonshotai/kimi-k2.6', description: 'Vision route for image attachments', supportsImage: true, imageOnly: true, thinkingLevel: 'medium' },
image_model: { provider: 'openrouter', model: 'moonshotai/kimi-k2.6', description: 'Vision route for image attachments', imageOnly: true, thinkingLevel: 'medium' },
},
openai: {
c0: { provider: 'openai', model: 'gpt-5.4-nano', description: 'Fast simple work', thinkingLevel: 'none' },
Expand All @@ -1952,10 +1952,10 @@ const ROUTER_PROFILES: Record<string, Record<string, RouterTier>> = {
c3: { provider: 'gemini', model: 'gemini-3.1-pro-preview', description: 'Deep reasoning', thinkingLevel: 'high' },
},
moonshot: {
c0: { provider: 'moonshot', model: 'kimi-k2.6', description: 'Fast multimodal work', supportsImage: true, thinkingLevel: 'low' },
c1: { provider: 'moonshot', model: 'kimi-k2.6', description: 'Balanced multimodal work', supportsImage: true, thinkingLevel: 'medium' },
c2: { provider: 'moonshot', model: 'kimi-k2.6', description: 'Complex text and image work', supportsImage: true, thinkingLevel: 'medium' },
c3: { provider: 'moonshot', model: 'kimi-k2.7-code', description: 'Code-heavy deep reasoning', supportsImage: true, thinkingLevel: 'high' },
c0: { provider: 'moonshot', model: 'kimi-k2.6', description: 'Fast multimodal work', thinkingLevel: 'low' },
c1: { provider: 'moonshot', model: 'kimi-k2.6', description: 'Balanced multimodal work', thinkingLevel: 'medium' },
c2: { provider: 'moonshot', model: 'kimi-k2.6', description: 'Complex text and image work', thinkingLevel: 'medium' },
c3: { provider: 'moonshot', model: 'kimi-k2.7-code', description: 'Code-heavy deep reasoning', thinkingLevel: 'high' },
},
kimi_coding_openai: textRouterProfile(
'kimi_coding_openai',
Expand Down Expand Up @@ -2139,7 +2139,6 @@ function routerTierTomlLines(name: string, tier: RouterTier): string[] {
`model = ${tomlString(tier.model)}`,
]
if (tier.description) lines.push(`description = ${tomlString(tier.description)}`)
if (tier.supportsImage !== undefined) lines.push(`supports_image = ${tier.supportsImage ? 'true' : 'false'}`)
if (tier.imageOnly !== undefined) lines.push(`image_only = ${tier.imageOnly ? 'true' : 'false'}`)
if (tier.thinkingLevel) lines.push(`thinking_level = ${tomlString(tier.thinkingLevel)}`)
if (tier.ensembleEnabled !== undefined) lines.push(`ensemble_enabled = ${tier.ensembleEnabled ? 'true' : 'false'}`)
Expand Down
11 changes: 8 additions & 3 deletions desktop/electron/src/router-tier-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface RouterTier {
provider: string
model: string
description?: string
/** Read compatibility only; the gateway resolves model capability. */
supportsImage?: boolean
imageOnly?: boolean
thinkingLevel?: string
Expand All @@ -20,7 +21,11 @@ function canonicalTierKey(name: string): string {
}

function cloneRouterTiers(tiers: Record<string, RouterTier>): Record<string, RouterTier> {
return Object.fromEntries(Object.entries(tiers).map(([name, tier]) => [name, { ...tier }]))
return Object.fromEntries(Object.entries(tiers).map(([name, tier]) => {
const copy = { ...tier }
delete copy.supportsImage
return [name, copy]
}))
}

function normalizeBooleanSetting(raw: unknown, fallback: boolean): boolean {
Expand Down Expand Up @@ -58,18 +63,18 @@ export function normalizeRouterTiers(
const hasEnsembleEnabled = Object.prototype.hasOwnProperty.call(tier, 'ensembleEnabled')
|| Object.prototype.hasOwnProperty.call(tier, 'ensemble_enabled')
const ensembleEnabled = tier.ensembleEnabled ?? tier.ensemble_enabled
out[name] = {
const normalizedTier: RouterTier = {
...out[name],
provider,
model,
description: String(tier.description || out[name]?.description || ''),
supportsImage: Boolean(tier.supportsImage ?? tier.supports_image ?? out[name]?.supportsImage),
imageOnly: Boolean(tier.imageOnly ?? tier.image_only ?? out[name]?.imageOnly),
thinkingLevel: String(tier.thinkingLevel ?? tier.thinking_level ?? out[name]?.thinkingLevel ?? ''),
...(hasEnsembleEnabled
? { ensembleEnabled: normalizeBooleanSetting(ensembleEnabled, false) }
: {}),
}
out[name] = normalizedTier
}
return out
}
20 changes: 16 additions & 4 deletions docs/features/squilla-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,22 @@ Packaged static B5 lineups use a 120-second total budget per proposer and a
180-second aggregator idle budget. Operator-authored `custom_b5` lineups use
300 and 480 seconds respectively unless explicitly configured otherwise.

C3 fusion itself is excluded from image routing, but the dedicated
`image_model` tier remains eligible and is preferred for image requests. If it
is unavailable, another non-C3 tier with `supports_image = true` may handle the
request.
Image routing considers only the configured C0–C3 single-model deployments;
C3 fusion is excluded. Image input capability is resolved automatically from
the shared provider model catalog, including API-provided deployment metadata
and offline catalog fallback. The web and desktop clients do not expose a
manual image-capability switch. Legacy tier `supports_image` values remain
readable but do not override the deployment's capability, and `image_model`
is retained for compatibility without creating an executable fifth route.

When capability is unknown, the configured model may receive a native image
request. A recognized image-input rejection can advance through the remaining
configured tiers before falling back to explicit not-analyzed/analysis-failed
text markers. Direct retries only its configured model with markers; Ensemble
uses text markers for all members. These retries stop after visible output or
tool effects. Projection never deletes the canonical attachment, so switching
back to a vision-capable model can recover earlier images after history
compression.

Disable routing and use the configured provider/model directly:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,9 @@ describe('SetupModelStrategyPanel', () => {
expect(el.querySelector('[aria-label="c0 request entry"]')).toBeTruthy()
expect(el.querySelector('.setup-tier-table__row.is-head')?.textContent)
.toContain('Request entry')
expect(el.querySelector('[aria-label$="supports image"]')).toBeNull()
expect(el.querySelector('[data-testid="router-image-capability-hint"]')?.textContent)
.toContain('detected automatically from provider metadata and request results')

app.unmount()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const emit = defineEmits<{
updateFixedModel: [value: string]
updateRouterDefaultTier: [value: string]
updateRouterVisualMode: [value: string]
updateTierField: [name: string, key: 'provider' | 'model' | 'thinkingLevel' | 'supportsImage' | 'ensembleEnabled' | 'ensembleSelectionMode', value: string | boolean]
updateTierField: [name: string, key: 'provider' | 'model' | 'thinkingLevel' | 'ensembleEnabled' | 'ensembleSelectionMode', value: string | boolean]
updateEnsembleScheme: [value: 'preset' | 'custom']
addEnsembleCandidate: [provider: string, model: string, role: EnsembleCandidateRole]
removeEnsembleCandidate: [candidate: EnsembleCandidateView]
Expand Down Expand Up @@ -825,6 +825,9 @@ function credentialLabel(candidate: EnsembleCandidateView): string {
@update-tier-field="(name, key, value) => emit('updateTierField', name, key, value)"
@migrate-legacy-ensemble="emit('migrateEnsembleLegacy')"
/>
<p class="control-section__desc" data-testid="router-image-capability-hint">
{{ t('setup.router.imageCapabilityAutomatic') }}
</p>

<details
class="setup-model-strategy__runtime setup-model-strategy__advanced"
Expand Down
Loading
Loading