diff --git a/.github/scripts/webui_browser_suites.py b/.github/scripts/webui_browser_suites.py index 8fe8f89c29..c9ac254bfb 100644 --- a/.github/scripts/webui_browser_suites.py +++ b/.github/scripts/webui_browser_suites.py @@ -41,6 +41,7 @@ "share.spec.ts", "session-chat-reliability.real.spec.ts", "session-delivery-feedback.spec.ts", + "router-provider-save.spec.ts", ) GENERAL_SHARDS = tuple(f"general-{index}" for index in range(1, 5)) VIRTUALIZATION_SPECS = ( diff --git a/.github/scripts/windows_test_assignments.json b/.github/scripts/windows_test_assignments.json index 6fe509762d..20a9cb752d 100644 --- a/.github/scripts/windows_test_assignments.json +++ b/.github/scripts/windows_test_assignments.json @@ -199,6 +199,7 @@ "tests/test_provider_tool_stream_accumulator.py", "tests/test_provider_trace_recorder.py", "tests/test_redaction.py", + "tests/test_router_explicit_provider_save.py", "tests/test_safety/test_run_sandboxed_proc_cleanup.py", "tests/test_sandbox/test_approval_runtime.py", "tests/test_sandbox/test_authority_paths.py", diff --git a/.github/scripts/windows_test_durations.json b/.github/scripts/windows_test_durations.json index 795df089a1..1a4e5593bb 100644 --- a/.github/scripts/windows_test_durations.json +++ b/.github/scripts/windows_test_durations.json @@ -1021,6 +1021,7 @@ "tests/test_release_oss_mirror_workflow.py": 7.527, "tests/test_request_proof_levers.py": 0.147, "tests/test_root_start_scripts.py": 0.01, + "tests/test_router_explicit_provider_save.py": 0.01, "tests/test_router_tier_contract.py": 0.423, "tests/test_runtime_packs/test_catalog.py": 0.089, "tests/test_runtime_packs/test_runtime_pack_manager.py": 6.264, diff --git a/contracts/gateway/v4/compatibility-manifest.generated.json b/contracts/gateway/v4/compatibility-manifest.generated.json index cde0553076..72069cf82c 100644 --- a/contracts/gateway/v4/compatibility-manifest.generated.json +++ b/contracts/gateway/v4/compatibility-manifest.generated.json @@ -789,7 +789,7 @@ "lifecycle": "stable", "name": "onboarding.router.configure", "schema": "platform/onboarding-router-configure.schema.json", - "schemaSha256": "fc1e17f9043e0ec5d86d8456f4dbee224ac93c80ecfef0f89f2bc8d07900797b" + "schemaSha256": "4245ff6e0ff14f92bda59a185a571dd6a4ca5c20d45ebf18d97f67ea32653cee" }, { "lifecycle": "stable", @@ -1455,7 +1455,7 @@ "generatorSha256": "829c5afc1ad4481e06e232cd30f7746db621efafcc9c29f6dcb01e7eb3b79cad", "methodCount": 213, "schemaCount": 223, - "schemaTreeSha256": "b3a598c652acefcf7ce67b5e706ca87f3b9901c64426e9dea3a79a1a3abdd856", + "schemaTreeSha256": "80c689d229c795f27e43c797468cd96a83eba981383b7e5e4bf4d7a4c9d0689c", "toolchains": { "legacyTypes": { "python": { diff --git a/contracts/gateway/v4/platform/onboarding-router-configure.schema.json b/contracts/gateway/v4/platform/onboarding-router-configure.schema.json index 629a80786a..2fcb0f1054 100644 --- a/contracts/gateway/v4/platform/onboarding-router-configure.schema.json +++ b/contracts/gateway/v4/platform/onboarding-router-configure.schema.json @@ -97,10 +97,9 @@ }, "crossProviderTiers": { "type": [ - "object", + "boolean", "null" - ], - "additionalProperties": true + ] }, "tierProviderMismatch": { "type": [ diff --git a/desktop/electron/package-lock.json b/desktop/electron/package-lock.json index 2da316c3eb..3bc418b48a 100644 --- a/desktop/electron/package-lock.json +++ b/desktop/electron/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@types/node": "^26.6.4", "@typescript/typescript6": "6.0.2", - "electron": "^42.11.12", + "electron": "^42.11.16", "electron-builder": "^26.15.3", "gsap": "^3.15.0", "lottie-web": "^5.13.0", @@ -1905,9 +1905,9 @@ } }, "node_modules/electron": { - "version": "42.11.12", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.11.12.tgz", - "integrity": "sha512-fHlCHh1uqfvWykQUq8eoSoB5q1gej7z/KKd3m7rqEEhHhihm7qz/EvBIkDat7vQoIq3kGTJcrskLbJ6UFkYGyg==", + "version": "42.11.16", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.11.16.tgz", + "integrity": "sha512-CHCEsZbWm9zspEnYsro+g8FFllh0e82A1Jtofi/XqPs/XqlrpDPdLP0OhzfhG/IkxNXQ8iHEoYms+wVepZNzZA==", "dev": true, "license": "MIT", "dependencies": { @@ -2280,9 +2280,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -3142,9 +3142,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.28.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", - "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "dev": true, "license": "MIT", "engines": { @@ -4034,9 +4034,9 @@ } }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "optional": true, diff --git a/desktop/electron/package.json b/desktop/electron/package.json index 0ee3a35961..be4b3ef10b 100644 --- a/desktop/electron/package.json +++ b/desktop/electron/package.json @@ -107,7 +107,7 @@ "devDependencies": { "@types/node": "^26.6.4", "@typescript/typescript6": "6.0.2", - "electron": "^42.11.12", + "electron": "^42.11.16", "electron-builder": "^26.15.3", "gsap": "^3.15.0", "lottie-web": "^5.13.0", diff --git a/docs/features/squilla-router.md b/docs/features/squilla-router.md index 64dcfd1408..9d350f4465 100644 --- a/docs/features/squilla-router.md +++ b/docs/features/squilla-router.md @@ -89,6 +89,17 @@ The reset button replaces the current ladder with that provider's recommendation including saved or unsaved custom tiers. Other primary providers do not show this shortcut. Restoring tiers keeps the current usage mode. +Saving explicit provider and model selections for executable text tiers enables +cross-provider routing automatically. Each selected provider uses its own configured +profile; no additional settings switch is needed. This also applies when saving an +existing mixed tier table after an upgrade. An explicit `crossProviderTiers: false` +API option remains authoritative. + +Loading saved settings, switching the primary provider, or re-enabling routing +without submitting tier selections does not enable cross-provider execution. +Existing primary-switch and legacy-router conflict checks still apply. Dormant +single-model settings under a static C3 fusion plan do not enable it either. + C3 can optionally use the shared multi-model fusion plan configured under `llm_ensemble`. Enabling fusion on C3 makes it use that plan instead of its single-model route. If the plan falls back to a single model, it uses the global diff --git a/opensquilla-webui/e2e/router-provider-save.spec.ts b/opensquilla-webui/e2e/router-provider-save.spec.ts new file mode 100644 index 0000000000..421d691be9 --- /dev/null +++ b/opensquilla-webui/e2e/router-provider-save.spec.ts @@ -0,0 +1,158 @@ +import { expect, test, type Page } from '@playwright/test' +import { helloOkResponse } from './support/gateway-fixture' + +const primaryModel = 'synthetic-primary-model' +const foreignModel = 'synthetic/selected-model' +type Tier = { provider: string; model: string; ensemble_enabled?: boolean } + +// Exercise the real Settings UI and wire decoder against an offline Gateway. +// Backend persistence and HTTP endpoint/key selection are covered separately +// by test_router_explicit_provider_save.py. +async function installGateway(page: Page, flag: boolean | undefined, legacy: boolean) { + const router = { + enabled: true, rollout_phase: 'full', preset_binding: 'custom', + default_tier: 'c1', visual_mode: 'real_candidates', + ...(flag === undefined ? {} : { cross_provider_tiers: flag }), + tiers: Object.fromEntries(['c0', 'c1', 'c2', 'c3'].map(name => [name, { + provider: legacy && name === 'c3' ? 'openrouter' : 'tokenrhythm', + model: legacy && name === 'c3' ? 'synthetic/old-model' : primaryModel, + ensemble_enabled: false, + }])) as Record, + } + const config = { + llm: { provider: 'tokenrhythm', model: primaryModel }, + llm_profiles: { openrouter: { model: foreignModel } }, + squilla_router: router, + llm_ensemble: { enabled: false, selection_mode: 'custom_b5', candidates: [] }, + memory: {}, models: {}, permissions: {}, skills: {}, + } + const writes: Record[] = [] + let rejectNextSave = false + const methods = [ + 'config.get', 'config.effective', 'onboarding.catalog', 'onboarding.status', + 'onboarding.models.discover', 'onboarding.llmProfile.models.discover', + 'onboarding.router.configure', 'models.list', 'models.routing.get', + 'providers.status', 'sessions.list', 'agents.list', 'commands.list_for_surface', + ] + await page.addInitScript(() => { + localStorage.setItem('opensquilla-locale', 'en') + localStorage.setItem('opensquilla-theme', 'light') + }) + await page.route('**/api/**', route => route.fulfill({ json: {} })) + await page.routeWebSocket(/\/ws$/, ws => { + ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} })) + ws.onMessage(message => { + const frame = JSON.parse(String(message)) + if (frame.type !== 'req') return + const method = frame.method as string + const respond = (payload: unknown, ok = true) => ws.send(JSON.stringify({ + type: 'res', id: frame.id, ok, ...(ok ? { payload } : { error: payload }), + })) + if (method === 'connect') { + ws.send(helloOkResponse({ features: { methods, events: [] } })) + return + } + if (method === 'onboarding.router.configure') { + writes.push(frame.params) + if (rejectNextSave) { + rejectNextSave = false + respond({ code: 'INTERNAL_ERROR', message: 'Synthetic save failure' }, false) + return + } + if (frame.params.crossProviderTiers !== true) { + respond({ code: 'ROUTER_PROVIDER_CONFLICT', message: 'Cross-provider execution was not selected' }, false) + return + } + router.cross_provider_tiers = true + router.tiers = structuredClone(frame.params.tiers) + respond({ changed: true, restartRequired: false, configPath: '/synthetic/config.toml', entry: router, warnings: [] }) + return + } + if (method.includes('discover')) { + const foreign = frame.params?.providerId === 'openrouter' + respond({ ok: true, source: 'live', models: (foreign ? [foreignModel, 'synthetic/old-model'] : [primaryModel]) + .map(id => ({ id, name: id, capabilities: ['chat'] })) }) + return + } + const payloads: Record = { + 'config.get': config, + 'config.effective': { fields: { 'llm.provider': { value: 'tokenrhythm', source: 'config' } } }, + 'onboarding.catalog': { providers: ['tokenrhythm', 'openrouter'].map(providerId => ({ + providerId, label: providerId === 'tokenrhythm' ? 'TokenRhythm' : 'OpenRouter', + runtimeSupported: true, requiresApiKey: true, routerSupported: true, + selectableModelCatalog: 'verified_live', fields: [{ name: 'model', label: 'Model', required: true }], + })) }, + 'onboarding.status': { + hasConfig: true, llmConfigured: true, + llmCredentialStatus: { provider: 'tokenrhythm', available: true, source: 'explicit' }, + llmProfileStatus: [{ provider: 'openrouter', ready: true, credentialSource: 'profile', primaryEligible: true }], + sectionDetails: { router: { enabled: true, routerBinding: 'custom' } }, + }, + 'models.routing.get': { mode: 'router' }, 'models.list': { models: [], errors: [] }, + 'agents.list': { agents: [] }, 'commands.list_for_surface': { commands: [] }, + 'sessions.list': { sessions: [], count: 0, ts: 1800000000, has_more: false }, + } + respond(payloads[method] ?? {}) + }) + }) + return { router, writes, rejectNext: () => { rejectNextSave = true } } +} + +async function selectForeignModel(page: Page, legacy: boolean) { + if (!legacy) { + await page.getByRole('combobox', { name: 'c3 request entry', exact: true }).click() + await page.getByRole('option', { name: 'OpenRouter', exact: true }).click() + } + const model = page.getByRole('combobox', { name: 'C3 processing mode or model', exact: true }) + await model.click() + await page.getByRole('option', { name: foreignModel, exact: true }).click() +} + +test.use({ viewport: { width: 1440, height: 1000 } }) + +for (const scenario of [ + { name: 'new installation with missing flag', flag: undefined, legacy: false }, + { name: 'upgraded disabled flag', flag: false, legacy: true }, + { name: 'upgraded enabled flag', flag: true, legacy: true }, +]) { + test(`${scenario.name} saves selected providers and retains them after reload`, async ({ page }, info) => { + const gateway = await installGateway(page, scenario.flag, scenario.legacy) + await page.goto('/control/settings/modelStrategy') + await expect(page.locator('.setup-model-strategy')).toBeVisible() + await expect(page.locator('.settings-dirtybar')).toHaveCount(0) + expect(gateway.writes).toHaveLength(0) + await selectForeignModel(page, scenario.legacy) + await page.locator('.settings-dirtybar').getByRole('button', { name: /^Save/ }).click() + await expect(page.locator('.settings-dirtybar')).toHaveCount(0) + expect(gateway.writes).toHaveLength(1) + expect(gateway.writes[0]).toMatchObject({ + crossProviderTiers: true, + tiers: { c0: { provider: 'tokenrhythm', model: primaryModel }, c3: { provider: 'openrouter', model: foreignModel } }, + }) + await page.reload() + await expect(page.getByTestId('routing-cross-provider-enabled')).toBeVisible() + await expect(page.getByRole('combobox', { name: 'c3 request entry', exact: true })).toContainText('OpenRouter') + await expect(page.getByRole('combobox', { name: 'C3 processing mode or model', exact: true })).toHaveValue(foreignModel) + await expect(page.locator('.settings-dirtybar')).toHaveCount(0) + expect(gateway.writes).toHaveLength(1) + await page.screenshot({ path: info.outputPath('saved-providers.png'), animations: 'disabled' }) + }) +} + +test('a rejected save keeps the selected providers available for retry', async ({ page }) => { + const gateway = await installGateway(page, false, false) + await page.goto('/control/settings/modelStrategy') + await expect(page.locator('.setup-model-strategy')).toBeVisible() + await selectForeignModel(page, false) + gateway.rejectNext() + const save = page.locator('.settings-dirtybar').getByRole('button', { name: /^Save/ }) + await save.click() + await expect.poll(() => gateway.writes.length).toBe(1) + await expect(save).toBeEnabled() + await expect(page.getByRole('combobox', { name: 'C3 processing mode or model', exact: true })).toHaveValue(foreignModel) + expect(gateway.router.cross_provider_tiers).toBe(false) + await save.click() + await expect(page.locator('.settings-dirtybar')).toHaveCount(0) + expect(gateway.writes).toHaveLength(2) + expect(gateway.router.cross_provider_tiers).toBe(true) +}) diff --git a/opensquilla-webui/package-lock.json b/opensquilla-webui/package-lock.json index 4d931acc05..8966854238 100644 --- a/opensquilla-webui/package-lock.json +++ b/opensquilla-webui/package-lock.json @@ -38,7 +38,7 @@ "@vitejs/plugin-vue": "^6.0.9", "ajv": "8.20.0", "fake-indexeddb": "6.2.5", - "happy-dom": "^20.14.5", + "happy-dom": "^21.0.4", "json-schema-to-typescript": "16.0.0", "json-schema-to-typescript-legacy": "npm:json-schema-to-typescript@15.0.4", "typescript": "7.0.2", @@ -2718,9 +2718,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -2816,9 +2816,9 @@ "license": "MIT" }, "node_modules/happy-dom": { - "version": "20.14.5", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz", - "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", + "version": "21.0.4", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.4.tgz", + "integrity": "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/opensquilla-webui/package.json b/opensquilla-webui/package.json index 542f91dffb..e16b5b3832 100644 --- a/opensquilla-webui/package.json +++ b/opensquilla-webui/package.json @@ -73,7 +73,7 @@ "@vitejs/plugin-vue": "^6.0.9", "ajv": "8.20.0", "fake-indexeddb": "6.2.5", - "happy-dom": "^20.14.5", + "happy-dom": "^21.0.4", "json-schema-to-typescript": "16.0.0", "json-schema-to-typescript-legacy": "npm:json-schema-to-typescript@15.0.4", "typescript": "7.0.2", diff --git a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts index cc06dfc577..c06a89255e 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts @@ -528,6 +528,137 @@ afterEach(() => { document.body.innerHTML = '' }) +describe('explicit cross-provider Router saves', () => { + async function scenario(permission?: boolean, existingForeign = false) { + let rejectNextSave = false + const result = await primaryTransitionScenario(false, (method, params) => { + if (method === 'onboarding.router.configure') { + if (rejectNextSave) { + rejectNextSave = false + throw new SetupWorkflowError('invalid', 'Synthetic deployment rejection', 'router-invalid') + } + Object.assign(result.saved.squilla_router, { + tiers: structuredClone(params?.tiers), + default_tier: params?.defaultTier, + cross_provider_tiers: params?.crossProviderTiers === true, + }) + } + return { changed: true } + }) + if (permission !== undefined) Object.assign(result.saved.squilla_router, { cross_provider_tiers: permission }) + if (existingForeign) result.saved.squilla_router.tiers.c1 = { provider: 'tokenrhythm', model: 'saved-foreign-model' } + await result.api.loadData() + rpcCall.mockClear() + return { ...result, rejectNextSave: () => { rejectNextSave = true } } + } + + it.each([ + ['new missing permission', undefined, false], + ['new disabled permission', false, false], + ['upgraded enabled permission', true, true], + ['upgraded disabled permission', false, true], + ] as const)('saves an explicitly selected foreign deployment for %s', async (_name, permission, existingForeign) => { + const { api, app } = await scenario(permission, existingForeign) + try { + expect(api.sectionDirty('modelStrategy')).toBe(false) + expect(rpcCall).not.toHaveBeenCalledWith('onboarding.router.configure', expect.anything()) + api.updateTierField('c1', 'provider', 'tokenrhythm') + api.updateTierField('c1', 'model', 'selected-foreign-model') + + expect(await api.saveModelStrategy()).toBe(true) + expect(rpcCall).toHaveBeenCalledWith('onboarding.router.configure', expect.objectContaining({ + crossProviderTiers: true, + tiers: expect.objectContaining({ c1: expect.objectContaining({ provider: 'tokenrhythm', model: 'selected-foreign-model' }) }), + })) + expect(api.modelStrategyPanel.value.routingSummary?.crossProviderEnabled).toBe(true) + expect(api.sectionDirty('modelStrategy')).toBe(false) + expect(confirmChoiceAction).not.toHaveBeenCalled() + } finally { app.unmount() } + }) + + it('submits the existing foreign table when saving a changed default tier', async () => { + const { api, app } = await scenario(false, true) + try { + api.setRouterDefaultTier('c0') + expect(await api.saveModelStrategy()).toBe(true) + expect(rpcCall).toHaveBeenCalledWith('onboarding.router.configure', expect.objectContaining({ + defaultTier: 'c0', crossProviderTiers: true, + tiers: expect.objectContaining({ c1: expect.objectContaining({ provider: 'tokenrhythm', model: 'saved-foreign-model' }) }), + })) + } finally { app.unmount() } + }) + + it('does not submit the tier table on load, a clean save, or a visual-only save', async () => { + const { api, app } = await scenario(false, true) + try { + expect(await api.saveModelStrategy()).toBe(true) + api.setRouterVisualMode('legacy_grid') + expect(await api.saveModelStrategy()).toBe(true) + + expect(rpcCall).not.toHaveBeenCalledWith('onboarding.router.configure', expect.anything()) + expect(rpcCall).toHaveBeenCalledWith('config.patch.safe', { + patches: { 'squilla_router.visual_mode': 'legacy_grid' }, + }) + expect(api.modelStrategyPanel.value.routingSummary?.crossProviderEnabled).toBe(false) + } finally { app.unmount() } + }) + + it('retains a rejected foreign deployment for an explicit retry', async () => { + const { api, app, rejectNextSave } = await scenario(false) + try { + api.updateTierField('c1', 'provider', 'tokenrhythm') + api.updateTierField('c1', 'model', 'retained-foreign-model') + rejectNextSave() + expect(await api.saveModelStrategy()).toBe(false) + expect(api.sectionDirty('modelStrategy')).toBe(true) + expect(api.routerPanel.value.tierRows.find(row => row.name === 'c1')).toMatchObject({ + provider: 'tokenrhythm', model: 'retained-foreign-model', + }) + expect(rpcCall.mock.calls.filter(([method]) => method === 'onboarding.router.configure')).toHaveLength(1) + + expect(await api.saveModelStrategy()).toBe(true) + expect(rpcCall.mock.calls.filter(([method]) => method === 'onboarding.router.configure')).toHaveLength(2) + expect(api.sectionDirty('modelStrategy')).toBe(false) + } finally { app.unmount() } + }) + + it('keeps primary activation on the conflict-protected path without submitting tiers', async () => { + const { api, app } = await primaryTransitionScenario(false, method => { + if (method === 'onboarding.llmProfile.activate') throw routerConflictForPrimary() + return { changed: true } + }) + try { + await api.activateProvider('tokenrhythm') + expect(confirmChoiceAction).toHaveBeenCalledOnce() + expect(rpcCall).toHaveBeenCalledWith('onboarding.llmProfile.activate', { + providerId: 'tokenrhythm', imageGenerationIntent: 'preserve', + }) + expect(rpcCall).not.toHaveBeenCalledWith('onboarding.router.configure', expect.anything()) + expect(api.modelStrategyPanel.value.routingSummary?.providerId).toBe('openrouter') + } finally { app.unmount() } + }) + + it('keeps mode-only activation on the conflict-protected path without submitting tiers', async () => { + const { api, app, saved } = await scenario(false, true) + const originalRpc = rpcCall.getMockImplementation()! + rpcCall.mockImplementation(async (method: string, params?: Record) => { + if (method === 'models.routing.set') throw routerConflictForPrimary() + return originalRpc(method, params) + }) + try { + saved.squilla_router.enabled = false + await api.loadData() + await api.setModelStrategy('router') + + expect(confirmChoiceAction).toHaveBeenCalledOnce() + expect(rpcCall).toHaveBeenCalledWith('models.routing.set', { mode: 'router' }) + expect(rpcCall).not.toHaveBeenCalledWith('onboarding.router.configure', expect.anything()) + expect(api.modelStrategyPanel.value.activeStrategy).toBe('single') + expect(api.modelStrategyPanel.value.routingSummary?.crossProviderEnabled).toBe(false) + } finally { app.unmount() } + }) +}) + describe('provider readiness indicator', () => { function mockReadiness(snapshot: Record, save?: () => void) { rpcCall.mockImplementation(async (method: string) => { diff --git a/opensquilla-webui/src/composables/setup/useSetupRouterForm.test.ts b/opensquilla-webui/src/composables/setup/useSetupRouterForm.test.ts index a27310d26e..87fd585ca7 100644 --- a/opensquilla-webui/src/composables/setup/useSetupRouterForm.test.ts +++ b/opensquilla-webui/src/composables/setup/useSetupRouterForm.test.ts @@ -480,7 +480,7 @@ describe('useSetupRouterForm - model strategy semantics', () => { expect(f.payload().mode).toBe('custom') }) - it('does not authorize cross-provider execution merely because tier providers differ', () => { + it('submits complete foreign deployments without making a loaded form dirty', () => { const f = useSetupRouterForm() f.initFromConfig({ enabled: true, @@ -494,7 +494,8 @@ describe('useSetupRouterForm - model strategy semantics', () => { }, {}, 'openai') expect(f.hasMixedTierProviders.value).toBe(true) - expect(f.payload()).not.toHaveProperty('crossProviderTiers') + expect(f.isDirty.value).toBe(false) + expect(f.payload()).toHaveProperty('crossProviderTiers', true) expect(f.tierTemplateState.value).toBe('custom') expect(f.payload()).toMatchObject({ mode: 'custom', @@ -547,9 +548,7 @@ describe('useSetupRouterForm - model strategy semantics', () => { expect(f.routerProviderRoles.value).toEqual({}) expect(f.hasMixedTierProviders.value).toBe(true) - expect(f.payload()).not.toHaveProperty('crossProviderTiers') - expect(f.payload()).toMatchObject({ - }) + expect(f.payload()).toHaveProperty('crossProviderTiers', true) expect(f.tierEnsembleStatus.value).toBeNull() }) @@ -620,7 +619,7 @@ describe('useSetupRouterForm - model strategy semantics', () => { expect(f.routerProviderRoles.value).toEqual({ c3: 'dynamic_member' }) expect(f.hasMixedTierProviders.value).toBe(true) - expect(f.payload()).not.toHaveProperty('crossProviderTiers') + expect(f.payload()).toHaveProperty('crossProviderTiers', true) expect(makePanel(f, true).value.routerProviderRoles).toEqual({ c3: 'dynamic_member' }) }) @@ -651,9 +650,7 @@ describe('useSetupRouterForm - model strategy semantics', () => { c3: 'direct', }) expect(f.hasMixedTierProviders.value).toBe(true) - expect(f.payload()).not.toHaveProperty('crossProviderTiers') - expect(f.payload()).toMatchObject({ - }) + expect(f.payload()).toHaveProperty('crossProviderTiers', true) }) it('keeps authoritative server roles when the local ensemble context did not change', () => { @@ -688,7 +685,7 @@ describe('useSetupRouterForm - model strategy semantics', () => { }, 'custom_b5', false) expect(f.hasMixedTierProviders.value).toBe(true) - expect(f.payload()).not.toHaveProperty('crossProviderTiers') + expect(f.payload()).toHaveProperty('crossProviderTiers', true) f.updateTierField('c3', 'ensembleEnabled', true) f.updateTierField('c3', 'ensembleSelectionMode', '') @@ -813,7 +810,7 @@ describe('useSetupRouterForm - model strategy semantics', () => { c3: 'direct', }) expect(f.hasMixedTierProviders.value).toBe(true) - expect(f.payload()).not.toHaveProperty('crossProviderTiers') + expect(f.payload()).toHaveProperty('crossProviderTiers', true) expect(f.payload()).toMatchObject({ tiers: { c3: { @@ -856,10 +853,8 @@ describe('useSetupRouterForm - model strategy semantics', () => { }, {}, 'openrouter') expect(f.hasMixedTierProviders.value).toBe(true) - expect(f.payload()).not.toHaveProperty('crossProviderTiers') + expect(f.payload()).toHaveProperty('crossProviderTiers', true) expect(makePanel(f, true).value.hasMixedTierProviders).toBe(true) - expect(f.payload()).toMatchObject({ - }) }) it('atomically clears provider-scoped model and ensemble when provider changes', () => { @@ -913,3 +908,73 @@ describe('saved cross-provider permission', () => { expect(form.payload()).toMatchObject({ crossProviderTiers: true, tierProviderMismatch: 'route' }) }) }) + +describe('explicit tier-table saves', () => { + it.each([undefined, false, true])('saves a selected foreign deployment with stored permission %s', permission => { + const form = useSetupRouterForm() + form.initFromConfig({ + enabled: true, + ...(permission === undefined ? {} : { cross_provider_tiers: permission }), + tiers: { c3: { provider: 'tokenrhythm', model: 'original-quality-model' } }, + }, {}, 'tokenrhythm') + + expect(form.isDirty.value).toBe(false) + form.updateTierField('c3', 'provider', ' OpenRouter ') + form.updateTierField('c3', 'model', 'moonshotai/kimi-k3.1-pro') + + expect(form.payload()).toMatchObject({ + mode: 'custom', + crossProviderTiers: true, + tiers: { c3: { provider: 'openrouter', model: 'moonshotai/kimi-k3.1-pro' } }, + }) + }) + + it('honors the complete legacy table when saving a changed default tier', () => { + const form = useSetupRouterForm() + form.initFromConfig({ + enabled: true, + cross_provider_tiers: false, + tiers: { c3: { provider: 'openrouter', model: 'saved-quality-model' } }, + }, {}, 'tokenrhythm') + form.setRouterDefaultTier('c3') + + expect(form.payload()).toMatchObject({ + defaultTier: 'c3', crossProviderTiers: true, + tiers: { c3: { provider: 'openrouter', model: 'saved-quality-model' } }, + }) + }) + + it('recomputes a dormant C3 after an entry change replaces fusion with one model', () => { + const form = useSetupRouterForm() + form.initFromConfig({ + enabled: true, + cross_provider_tiers: false, + tiers: { c3: { provider: 'tokenrhythm', model: 'old-draft', ensemble_enabled: true } }, + }, {}, 'tokenrhythm', 'custom', { c3: 'dormant_draft' }, 'custom_b5', false) + form.updateTierField('c3', 'provider', 'openrouter') + form.updateTierField('c3', 'model', 'moonshotai/kimi-k3.1-pro') + + expect(form.routerProviderRoles.value.c3).toBe('direct') + expect(form.payload()).toMatchObject({ + crossProviderTiers: true, + tiers: { c3: { provider: 'openrouter', model: 'moonshotai/kimi-k3.1-pro', ensembleEnabled: false } }, + }) + }) + + it.each([ + ['incomplete model', true, 'openrouter', '', 'direct'], + ['same primary', true, 'tokenrhythm', 'new-model', 'direct'], + ['disabled Router', false, 'openrouter', 'new-model', 'direct'], + ['dormant C3', true, 'openrouter', 'new-model', 'dormant_draft'], + ['blocked C3', true, 'openrouter', 'new-model', 'blocked'], + ] as const)('does not enable cross-provider execution for %s', (_label, enabled, provider, model, role) => { + const form = useSetupRouterForm() + form.initFromConfig({ + enabled, + cross_provider_tiers: false, + tiers: { c3: { provider, model } }, + }, {}, 'tokenrhythm', 'custom', { c3: role }) + + expect(form.payload()).not.toHaveProperty('crossProviderTiers') + }) +}) diff --git a/opensquilla-webui/src/composables/setup/useSetupRouterForm.ts b/opensquilla-webui/src/composables/setup/useSetupRouterForm.ts index 91c30e69ce..5c94298048 100644 --- a/opensquilla-webui/src/composables/setup/useSetupRouterForm.ts +++ b/opensquilla-webui/src/composables/setup/useSetupRouterForm.ts @@ -410,7 +410,7 @@ export function useSetupRouterForm() { currentEnsembleGloballyEnabled = false, currentTierEnsembleStatus?: unknown, ) { - activeProvider.value = provider.toLowerCase() + activeProvider.value = provider.trim().toLowerCase() crossProviderTiers.value = router.cross_provider_tiers === true tierProviderMismatch.value = router.tier_provider_mismatch === 'veto' ? 'veto' : 'route' refreshRuntimeMetadata(providerRoles, currentTierEnsembleStatus) @@ -495,6 +495,9 @@ export function useSetupRouterForm() { ensembleSelectionMode: dynamicMember ? tier.ensembleSelectionMode : '', }, } + if (!dynamicMember && (tier.ensembleEnabled === true || tier.ensembleSelectionMode)) { + providerRoleContextDirty.value = true + } return } if (key === 'model') { @@ -599,7 +602,18 @@ export function useSetupRouterForm() { ? 'recommended' : 'custom' const body = buildRouterPayload(mode, routerDefaultTier.value, tierValues.value) - if (crossProviderTiers.value) { + // This payload explicitly saves the tier table. Honor its complete foreign + // deployments without requiring a separate, hidden permission. Loading the + // form, changing presentation, and primary-provider activation do not submit it. + const submittedForeignDeployment = mode !== 'disabled' && Boolean(activeProvider.value) + && Object.entries(tierValues.value).some(([name, tier]) => { + if (!(TEXT_TIERS as readonly string[]).includes(name) + || !routerTierProviderParticipates(name, tier, routerProviderRoles.value)) return false + const provider = tier.provider.trim().toLowerCase() + const model = tier.model.trim() + return Boolean(provider && model && provider !== activeProvider.value) + }) + if (crossProviderTiers.value || submittedForeignDeployment) { body.crossProviderTiers = true body.tierProviderMismatch = tierProviderMismatch.value } diff --git a/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigure.ts b/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigure.ts index 59555e6664..7089788986 100644 --- a/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigure.ts +++ b/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigure.ts @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: 4e0bcb30bee3f84f5d13fef8203b26ad9133348fee9078771e07d30a14c55c1b +// source-sha256: db44eb45c51950b0194f2e05adb887ce2557cf77818c2d9c7cb9e4659890c128 /** * This interface was referenced by `OpenSquillaOnboardingRouterConfigureContract`'s JSON-Schema @@ -35,9 +35,7 @@ export interface OnboardingRouterConfigureParams { tiers?: { [k: string]: unknown; } | null; - crossProviderTiers?: { - [k: string]: unknown; - } | null; + crossProviderTiers?: boolean | null; tierProviderMismatch?: string | null; [k: string]: unknown; } diff --git a/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigureValidators.d.mts b/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigureValidators.d.mts index 7944117ac0..4d07a0de17 100644 --- a/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigureValidators.d.mts +++ b/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigureValidators.d.mts @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: 4e0bcb30bee3f84f5d13fef8203b26ad9133348fee9078771e07d30a14c55c1b +// source-sha256: db44eb45c51950b0194f2e05adb887ce2557cf77818c2d9c7cb9e4659890c128 export interface ContractValidator { (value: unknown): boolean diff --git a/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigureValidators.mjs b/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigureValidators.mjs index 60ca0ca0e2..4697264716 100644 --- a/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigureValidators.mjs +++ b/opensquilla-webui/src/contracts/generated/v4/onboardingRouterConfigureValidators.mjs @@ -1,4 +1,4 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: 4e0bcb30bee3f84f5d13fef8203b26ad9133348fee9078771e07d30a14c55c1b +// source-sha256: db44eb45c51950b0194f2e05adb887ce2557cf77818c2d9c7cb9e4659890c128 "use strict";export const validateOnboardingRouterConfigureResult = validate20;const schema31 = {"$id":"urn:opensquilla:contract:v4:OnboardingRouterConfigureResult","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/platform/onboarding-router-configure.schema.json#/$defs/OnboardingRouterConfigureResult"};const schema37 = {"type":"object","additionalProperties":true,"required":["changed","restartRequired","configPath","entry","warnings"],"properties":{"changed":{"type":"boolean"},"restartRequired":{"type":"boolean"},"configPath":{"type":"string"},"entry":{"type":"object","additionalProperties":true},"warnings":{"type":"array","items":{"type":"string"}}}};function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="urn:opensquilla:contract:v4:OnboardingRouterConfigureResult" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.changed === undefined){const err0 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/platform/onboarding-router-configure.schema.json#/$defs/OnboardingRouterConfigureResult/required",keyword:"required",params:{missingProperty: "changed"},message:"must have required property '"+"changed"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.restartRequired === undefined){const err1 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/platform/onboarding-router-configure.schema.json#/$defs/OnboardingRouterConfigureResult/required",keyword:"required",params:{missingProperty: "restartRequired"},message:"must have required property '"+"restartRequired"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.configPath === undefined){const err2 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/platform/onboarding-router-configure.schema.json#/$defs/OnboardingRouterConfigureResult/required",keyword:"required",params:{missingProperty: "configPath"},message:"must have required property '"+"configPath"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.entry === undefined){const err3 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/platform/onboarding-router-configure.schema.json#/$defs/OnboardingRouterConfigureResult/required",keyword:"required",params:{missingProperty: "entry"},message:"must have required property '"+"entry"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.warnings === undefined){const err4 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/platform/onboarding-router-configure.schema.json#/$defs/OnboardingRouterConfigureResult/required",keyword:"required",params:{missingProperty: "warnings"},message:"must have required property '"+"warnings"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(data.changed !== undefined){if(typeof data.changed !== "boolean"){const err5 = {instancePath:instancePath+"/changed",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/platform/onboarding-router-configure.schema.json#/$defs/OnboardingRouterConfigureResult/properties/changed/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.restartRequired !== undefined){if(typeof data.restartRequired !== "boolean"){const err6 = {instancePath:instancePath+"/restartRequired",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/platform/onboarding-router-configure.schema.json#/$defs/OnboardingRouterConfigureResult/properties/restartRequired/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.configPath !== undefined){if(typeof data.configPath !== "string"){const err7 = {instancePath:instancePath+"/configPath",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/platform/onboarding-router-configure.schema.json#/$defs/OnboardingRouterConfigureResult/properties/configPath/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.entry !== undefined){let data3 = data.entry;if(data3 && typeof data3 == "object" && !Array.isArray(data3)){}else {const err8 = {instancePath:instancePath+"/entry",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/platform/onboarding-router-configure.schema.json#/$defs/OnboardingRouterConfigureResult/properties/entry/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.warnings !== undefined){let data4 = data.warnings;if(Array.isArray(data4)){const len0 = data4.length;for(let i0=0; i0=6.9.1", "idna>=3.19", "urllib3>=2.7.0", - "brotli>=1.1", + "brotli>=1.2.1", "jinja2>=3.1", "structlog>=26.1.0", # The compact help renderer adapts Typer's private Rich panel hook. @@ -66,7 +66,7 @@ dependencies = [ "python-docx>=1.1.0", "python-pptx>=1.0.0", "openpyxl>=3.1.0", - "pypdf>=6.21.1", + "pypdf>=6.21.2", "reportlab>=4.0.0", "lark-oapi>=1.5.5,<1.6", "python-telegram-bot>=22.8", @@ -276,3 +276,10 @@ dev = [ [tool.uv] conflicts = [[{ group = "dev" }, { group = "legacy-contract-codegen" }]] +# Keep optional HTTP, model-cache, and packaging dependencies above patched +# versions without adding those packages to unrelated installation profiles. +constraint-dependencies = [ + "brotlicffi>=1.2.1.0", + "filelock>=4.2.1", + "setuptools>=84.1.0", +] diff --git a/scripts/contracts/tests/verification_loader.test.mjs b/scripts/contracts/tests/verification_loader.test.mjs index 0ed7d0281a..f3c861b9d5 100644 --- a/scripts/contracts/tests/verification_loader.test.mjs +++ b/scripts/contracts/tests/verification_loader.test.mjs @@ -24,6 +24,24 @@ test('sessions.list verification still exposes all four real validators', async assert.equal(validators.validateSessionsListResult({ sessions: [] }), false) }) +test('router configure accepts only an optional nullable boolean cross-provider flag', async () => { + const validators = await loadContractValidators('onboarding.router.configure') + for (const params of [{}, { crossProviderTiers: null }, { crossProviderTiers: true }, { crossProviderTiers: false }]) { + const wire = { type: 'req', id: 'synthetic', method: 'onboarding.router.configure', params } + const original = structuredClone(wire) + assert.equal(validators.validateOnboardingRouterConfigureParams(params), true) + assert.equal(validators.validateOnboardingRouterConfigureRequestFrame(wire), true) + assert.deepEqual(wire, original) + } + for (const crossProviderTiers of [{}, [], 'true', 'false', 0, 1]) { + const params = { crossProviderTiers } + assert.equal(validators.validateOnboardingRouterConfigureParams(params), false) + assert.equal(validators.validateOnboardingRouterConfigureRequestFrame({ + type: 'req', id: 'synthetic', method: 'onboarding.router.configure', params, + }), false) + } +}) + test('execution log fixtures validate frames and their params or results', async () => { const validators = await loadContractValidators('sessions.executionLog.read') for (const [file, frame, body, field] of [ diff --git a/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py b/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py index 70926f1205..f910c3adcd 100644 --- a/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py +++ b/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# sources-sha256: 972773fdf341340a1ef90050dfa9fc2962092ad1f3caef1468c69e90fd7a2334 +# sources-sha256: 26369c7901169a8c8a4780bade9add5606a40168003d67ca927b5a8d075f9edc # generator-sha256: 829c5afc1ad4481e06e232cd30f7746db621efafcc9c29f6dcb01e7eb3b79cad # ruff: noqa diff --git a/src/opensquilla/contracts/generated/v4/onboarding_router_configure.py b/src/opensquilla/contracts/generated/v4/onboarding_router_configure.py index b91a68f3ba..bad76cb0e8 100644 --- a/src/opensquilla/contracts/generated/v4/onboarding_router_configure.py +++ b/src/opensquilla/contracts/generated/v4/onboarding_router_configure.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# source-sha256: 4e0bcb30bee3f84f5d13fef8203b26ad9133348fee9078771e07d30a14c55c1b +# source-sha256: db44eb45c51950b0194f2e05adb887ce2557cf77818c2d9c7cb9e4659890c128 # ruff: noqa # generated by datamodel-codegen: @@ -50,7 +50,7 @@ class OnboardingRouterConfigureParams(BaseModel): mode: StrictStr | None = None defaultTier: StrictStr | None = None tiers: dict[str, Any] | None = None - crossProviderTiers: dict[str, Any] | None = None + crossProviderTiers: StrictBool | None = None tierProviderMismatch: StrictStr | None = None diff --git a/src/opensquilla/contracts/generated/v4/onboarding_router_configure_metadata.py b/src/opensquilla/contracts/generated/v4/onboarding_router_configure_metadata.py index 96d27fb979..018196d660 100644 --- a/src/opensquilla/contracts/generated/v4/onboarding_router_configure_metadata.py +++ b/src/opensquilla/contracts/generated/v4/onboarding_router_configure_metadata.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# source-sha256: 4e0bcb30bee3f84f5d13fef8203b26ad9133348fee9078771e07d30a14c55c1b +# source-sha256: db44eb45c51950b0194f2e05adb887ce2557cf77818c2d9c7cb9e4659890c128 # ruff: noqa from typing import Final diff --git a/src/opensquilla/onboarding/mutations.py b/src/opensquilla/onboarding/mutations.py index 309ee509a9..c19ed662ec 100644 --- a/src/opensquilla/onboarding/mutations.py +++ b/src/opensquilla/onboarding/mutations.py @@ -56,6 +56,7 @@ ) from opensquilla.onboarding.router_policy import ( LlmProfileActivationError, + explicitly_selects_cross_provider_tier, reconcile_recommended_router, validate_router_candidate, validate_router_reactivation, @@ -1176,33 +1177,11 @@ def upsert_router( router_payload["tiers"] = merged_tiers public_payload["mode"] = "recommended" if follows_managed_preset else "custom" public_payload.update({"enabled": True, "tier_profile": None}) - warnings: list[str] = [] if router_payload.get("enabled"): _validate_router_tiers( cast(dict[str, Any], router_payload.get("tiers") or {}), default_tier_clean, ) - warnings = _cross_provider_tier_warnings( - cast(dict[str, Any], router_payload.get("tiers") or {}), - provider, - shared_selection_mode=effective_ensemble_selection_mode(config), - ensemble_globally_enabled=bool( - getattr(getattr(config, "llm_ensemble", None), "enabled", False) - ), - cross_provider_enabled=bool(router_payload.get("cross_provider_tiers")), - tier_provider_mismatch=str( - router_payload.get("tier_provider_mismatch") or "route" - ), - llm_profiles=getattr(config, "llm_profiles", None), - ) - legacy_image_tier = (router_payload.get("tiers") or {}).get("image_model") - if isinstance(legacy_image_tier, dict) and legacy_image_tier.get("model"): - warnings.append( - "The legacy image_model setting is preserved for compatibility " - "but is not used for image input. Configure an image-capable " - "model in c0-c3; if none can process images, the turn continues " - "with an image-not-analyzed marker and keeps the original image." - ) new_cfg = _clone(config) new_cfg.squilla_router = SquillaRouterConfig(**router_payload) @@ -1251,12 +1230,39 @@ def upsert_router( new_cfg.mark_force_persist(f"llm_ensemble.{field_name}") if shared_tier_enabled: _materialize_c3_ensemble_policy_defaults(new_cfg) - # Otherwise this is ladder/settings maintenance on an already-enabled - # router (the common Web UI tier-table save and CLI default-tier path). - # Applying the mode patch here would silently escalate an operator's + if ( + cross_provider_tiers is None + and explicitly_selects_cross_provider_tier(new_cfg, tiers) + ): + # Saving explicit deployments means executing their selected providers. + # Preserve an explicit opt-out and keep implicit legacy reactivation + # and primary-provider transitions under their existing conflict policy. + new_cfg.squilla_router.cross_provider_tiers = True + # Ladder/settings maintenance on an already-enabled router preserves its + # strategy (the common Web UI tier-table save and CLI default-tier path). + # Applying a mode patch here would silently escalate an operator's # rollout_phase='observe'/'prompt_only' to live 'full' routing and turn # off a running ensemble, so the stored strategy fields are preserved. validate_router_reactivation(config, new_cfg) + warnings: list[str] = [] + if new_cfg.squilla_router.enabled: + warnings = _cross_provider_tier_warnings( + new_cfg.squilla_router.tiers, + provider, + shared_selection_mode=effective_ensemble_selection_mode(new_cfg), + ensemble_globally_enabled=new_cfg.llm_ensemble.enabled, + cross_provider_enabled=new_cfg.squilla_router.cross_provider_tiers, + tier_provider_mismatch=new_cfg.squilla_router.tier_provider_mismatch, + llm_profiles=new_cfg.llm_profiles, + ) + legacy_image_tier = new_cfg.squilla_router.tiers.get("image_model") + if isinstance(legacy_image_tier, dict) and legacy_image_tier.get("model"): + warnings.append( + "The legacy image_model setting is preserved for compatibility " + "but is not used for image input. Configure an image-capable " + "model in c0-c3; if none can process images, the turn continues " + "with an image-not-analyzed marker and keeps the original image." + ) public_payload["default_tier"] = new_cfg.squilla_router.default_tier public_payload["tiers"] = redact_router_tiers_payload(new_cfg.squilla_router.tiers) public_payload["cross_provider_tiers"] = bool(new_cfg.squilla_router.cross_provider_tiers) diff --git a/src/opensquilla/onboarding/router_policy.py b/src/opensquilla/onboarding/router_policy.py index 8b087a411c..2317942f6a 100644 --- a/src/opensquilla/onboarding/router_policy.py +++ b/src/opensquilla/onboarding/router_policy.py @@ -110,6 +110,31 @@ def router_provider_conflicts(config: Any, provider_id: str) -> tuple[str, ...]: ) +def explicitly_selects_cross_provider_tier( + candidate: Any, + submitted_tiers: Mapping[str, Any] | None, +) -> bool: + """A submitted provider/model pair selects that tier's execution endpoint. + + Only the dedicated tier-authoring mutation uses this intent. Loading a + legacy ladder, switching the primary or reactivating saved routes must not + infer it from the resulting configuration alone. Dormant fusion drafts and + the retired image tier do not select an executable deployment. + """ + submitted = normalize_tier_mapping(submitted_tiers) + primary = str(candidate.llm.provider).strip().lower() + for tier, provider, _ in executable_router_dependencies(candidate): + value = submitted.get(tier) + if ( + provider != primary + and isinstance(value, Mapping) + and str(value.get("provider") or "").strip().lower() == provider + and str(value.get("model") or "").strip() + ): + return True + return False + + def validate_router_candidate( candidate: Any, *, diff --git a/src/opensquilla/onboarding/setup_engine.py b/src/opensquilla/onboarding/setup_engine.py index 45327f9d33..5ce0a1e898 100644 --- a/src/opensquilla/onboarding/setup_engine.py +++ b/src/opensquilla/onboarding/setup_engine.py @@ -157,6 +157,12 @@ def apply(self, section: str, payload: dict[str, Any]) -> MutationResult: mode=str(payload.get("mode", "recommended")), default_tier=payload.get("defaultTier"), tiers=payload.get("tiers"), + cross_provider_tiers=( + _strict_bool(payload["crossProviderTiers"], field="crossProviderTiers") + if payload.get("crossProviderTiers") is not None + else None + ), + tier_provider_mismatch=_optional_str(payload.get("tierProviderMismatch")), ) elif normalized in ENSEMBLE_SECTION_ALIASES: # Keep-current semantics ride the mutation: keys absent from the diff --git a/tests/test_ci/test_webui_browser_suites.py b/tests/test_ci/test_webui_browser_suites.py index 8ed6c96bda..e613cb75d6 100644 --- a/tests/test_ci/test_webui_browser_suites.py +++ b/tests/test_ci/test_webui_browser_suites.py @@ -25,6 +25,7 @@ provider-error-experience.spec.ts retired-bgm-upgrade.spec.ts support-bundle.spec.ts console-clarity.spec.ts mobile-tabs.spec.ts header-responsive.spec.ts topbar-global-controls.spec.ts topbar-visual.spec.ts router-physical-model.spec.ts +router-provider-save.spec.ts user-message-newlines.spec.ts session-created-card.spec.ts session-chat-reliability.real.spec.ts session-delivery-feedback.spec.ts session-switch-transport.spec.ts @@ -47,7 +48,7 @@ def test_general_browser_shards_preserve_the_entire_existing_inventory_once() -> counts = Counter(spec for shard in shards for spec in shard) assert set(counts) == EXPECTED_GENERAL assert set(counts.values()) == {1} - assert [len(shard) for shard in shards] == [9, 9, 8, 8] + assert [len(shard) for shard in shards] == [9, 9, 9, 8] assert not set(counts).intersection(specs_for_suite("virtualization")) for spec in (*counts, *specs_for_suite("virtualization")): assert (Path("opensquilla-webui/e2e") / spec).is_file() diff --git a/tests/test_ci/test_workflows.py b/tests/test_ci/test_workflows.py index 5cf2998607..6841524431 100644 --- a/tests/test_ci/test_workflows.py +++ b/tests/test_ci/test_workflows.py @@ -2987,6 +2987,7 @@ def test_webui_chat_recovery_runs_the_verified_dist_through_gateway() -> None: "topbar-global-controls.spec.ts", "topbar-visual.spec.ts", "router-physical-model.spec.ts", + "router-provider-save.spec.ts", "queue-steer.spec.ts", "session-created-card.spec.ts", "session-chat-reliability.real.spec.ts", diff --git a/tests/test_desktop/test_electron_dependency_security.py b/tests/test_desktop/test_electron_dependency_security.py index 3acdd2b76b..1896840d8b 100644 --- a/tests/test_desktop/test_electron_dependency_security.py +++ b/tests/test_desktop/test_electron_dependency_security.py @@ -41,8 +41,8 @@ def test_electron_runtime_yaml_parser_includes_empty_merge_source_dos_fix() -> N [ ("js-yaml", (4, 3, 2)), ("@xmldom/xmldom", (0, 8, 15)), - ("fast-uri", (3, 1, 6)), - ("electron", (42, 5, 1)), + ("fast-uri", (3, 1, 9)), + ("electron", (42, 11, 16)), ("http-cache-semantics", (4, 3, 0)), ], ) @@ -68,9 +68,9 @@ def test_undici_security_floors_preserve_supported_parent_major_versions() -> No for version in versions: # node-gyp still uses 6.x; forcing it onto 7.x would bypass its contract. if version[0] == 6: - assert version >= (6, 28, 0) + assert version >= (6, 29, 1) else: - assert version >= (7, 29, 0) + assert version >= (7, 30, 1) def test_installer_downloader_removes_unpatched_formatter_dependency() -> None: diff --git a/tests/test_onboarding/test_setup_engine.py b/tests/test_onboarding/test_setup_engine.py index 12477d676f..e19498e332 100644 --- a/tests/test_onboarding/test_setup_engine.py +++ b/tests/test_onboarding/test_setup_engine.py @@ -8,11 +8,35 @@ from opensquilla.gateway.config import GatewayConfig from opensquilla.onboarding.config_store import load_config +from opensquilla.onboarding.router_policy import RouterProviderConflictError from opensquilla.onboarding.section_status import SectionStatus from opensquilla.onboarding.setup_engine import SetupEngine from opensquilla.onboarding.status import get_onboarding_status +def test_router_editor_enables_submitted_providers_and_preserves_explicit_opt_out(): + config = GatewayConfig(llm={"provider": "tokenrhythm", "model": "primary-model"}) + tiers = {"c3": {"provider": "openrouter", "model": "synthetic/foreign-model"}} + engine = SetupEngine(config=config) + with pytest.raises(RouterProviderConflictError): + engine.apply("router", { + "mode": "custom", "tiers": tiers, "crossProviderTiers": False, + }) + assert engine.config.squilla_router.cross_provider_tiers is False + result = engine.apply("router", { + "mode": "custom", "tiers": tiers, "tierProviderMismatch": "veto", + }) + assert result.config.squilla_router.cross_provider_tiers is True + assert result.config.squilla_router.tier_provider_mismatch == "veto" + + +@pytest.mark.parametrize("value", ["false", "true", 0, 1]) +def test_router_editor_rejects_non_boolean_cross_provider_flag(value): + engine = SetupEngine(config=GatewayConfig()) + with pytest.raises(ValueError, match="crossProviderTiers"): + engine.apply("router", {"crossProviderTiers": value}) + + def test_setup_engine_applies_provider_and_router_without_persisting_secret(tmp_path): target = tmp_path / "config.toml" engine = SetupEngine(path=target) diff --git a/tests/test_router_explicit_provider_save.py b/tests/test_router_explicit_provider_save.py new file mode 100644 index 0000000000..1ec45c25f0 --- /dev/null +++ b/tests/test_router_explicit_provider_save.py @@ -0,0 +1,276 @@ +"""Explicit Router deployments survive saves and execute on their own endpoint.""" + +from __future__ import annotations + +import copy +import json +import tomllib +from pathlib import Path +from typing import Any + +import httpx +import pytest +import tomli_w + +from opensquilla.engine.selector_override import ( + apply_model_override, + cross_provider_tier_config, +) +from opensquilla.gateway.config import GatewayConfig +from opensquilla.gateway.config_migration import LATEST_CONFIG_VERSION +from opensquilla.gateway.provider_runtime import resolve_provider_selector_config +from opensquilla.onboarding.config_store import load_config, persist_config +from opensquilla.onboarding.mutations import upsert_router +from opensquilla.onboarding.router_policy import RouterProviderConflictError +from opensquilla.provider.selector import ModelSelector, SelectorConfig +from opensquilla.provider.types import ChatConfig, DoneEvent, ErrorEvent, Message + +PRIMARY_KEY = "synthetic-primary-key" +SECONDARY_KEY = "synthetic-secondary-key" +PRIMARY_ENDPOINT = "https://primary-router.test/v1" +SECONDARY_ENDPOINT = "https://secondary-router.test/api/v1" +FOREIGN_MODEL = "moonshotai/kimi-k3.1-pro" + + +def _saved_config( + path: Path, + *, + cross_provider: bool | None, + foreign_c3: bool = False, + enabled: bool = True, + config_version: int = LATEST_CONFIG_VERSION, +) -> GatewayConfig: + router: dict[str, Any] = { + "enabled": enabled, + "preset_binding": "custom", + "rollout_phase": "full", + "tiers": { + f"c{index}": {"provider": "tokenrhythm", "model": f"primary-c{index}"} + for index in range(4) + }, + } + if cross_provider is not None: + router["cross_provider_tiers"] = cross_provider + if foreign_c3: + router["tiers"]["c3"] = {"provider": "openrouter", "model": FOREIGN_MODEL} + path.write_text( + tomli_w.dumps({ + "config_version": config_version, + "llm": { + "provider": "tokenrhythm", + "model": "primary-direct", + "api_key": PRIMARY_KEY, + "base_url": PRIMARY_ENDPOINT, + }, + "llm_profiles": { + "openrouter": {"api_key": SECONDARY_KEY, "base_url": SECONDARY_ENDPOINT}, + }, + "squilla_router": router, + "llm_ensemble": {"enabled": False, "selection_mode": "static_openrouter_b5"}, + }), + encoding="utf-8", + ) + return load_config(path) + + +def _foreign_tiers(config: GatewayConfig) -> dict[str, Any]: + tiers = copy.deepcopy(config.squilla_router.tiers) + tiers["c3"] = {"provider": "openrouter", "model": FOREIGN_MODEL} + return tiers + + +@pytest.mark.parametrize("saved_flag", [None, False, True], ids=["missing", "false", "true"]) +@pytest.mark.parametrize("was_enabled", [False, True], ids=["enable", "edit-enabled"]) +def test_explicit_foreign_deployment_save_persists_across_reload( + tmp_path: Path, saved_flag: bool | None, was_enabled: bool, +) -> None: + path = tmp_path / "config.toml" + config = _saved_config(path, cross_provider=saved_flag, enabled=was_enabled) + original_primary = config.llm.model_dump() + original_router = config.squilla_router.model_dump() + + result = upsert_router(config, mode="custom", tiers=_foreign_tiers(config)) + + assert result.config.squilla_router.cross_provider_tiers is True + assert result.public_payload["cross_provider_tiers"] is True + assert result.config.llm.model_dump() == original_primary + assert config.squilla_router.model_dump() == original_router + persist_config(result.config, path=path) + + raw = tomllib.loads(path.read_text(encoding="utf-8")) + assert raw["squilla_router"]["cross_provider_tiers"] is True + restored = load_config(path) + assert restored.squilla_router.cross_provider_tiers is True + assert restored.squilla_router.enabled is True + assert restored.squilla_router.preset_binding == "custom" + assert restored.squilla_router.tiers["c3"]["provider"] == "openrouter" + assert restored.squilla_router.tiers["c3"]["model"] == FOREIGN_MODEL + assert restored.llm.model_dump() == original_primary + + +@pytest.mark.parametrize("saved_flag", [None, False, True], ids=["missing", "false", "true"]) +def test_upgraded_config_enables_explicit_foreign_route_without_changing_load_semantics( + tmp_path: Path, saved_flag: bool | None, +) -> None: + path = tmp_path / "config.toml" + config = _saved_config(path, cross_provider=saved_flag, config_version=1) + assert config.squilla_router.cross_provider_tiers is (saved_flag is True) + migrated = tomllib.loads(path.read_text(encoding="utf-8")) + assert migrated["config_version"] == LATEST_CONFIG_VERSION + + result = upsert_router(config, mode="custom", tiers=_foreign_tiers(config)) + persist_config(result.config, path=path) + + restored = load_config(path) + assert restored.squilla_router.cross_provider_tiers is True + assert restored.squilla_router.tiers["c3"]["provider"] == "openrouter" + assert restored.squilla_router.tiers["c3"]["model"] == FOREIGN_MODEL + + +@pytest.mark.parametrize("change_model", [False, True], ids=["same-table", "new-model"]) +def test_explicit_legacy_foreign_table_adopts_its_selected_provider( + tmp_path: Path, change_model: bool, +) -> None: + config = _saved_config(tmp_path / "config.toml", cross_provider=False, foreign_c3=True) + tiers = copy.deepcopy(config.squilla_router.tiers) + if change_model: + tiers["c3"]["model"] = "openrouter/replacement-model" + + result = upsert_router(config, mode="custom", tiers=tiers) + + assert result.config.squilla_router.cross_provider_tiers is True + assert result.config.squilla_router.tiers["c3"]["model"] == tiers["c3"]["model"] + + +def test_unrelated_default_tier_save_preserves_legacy_execution(tmp_path: Path) -> None: + config = _saved_config(tmp_path / "config.toml", cross_provider=False, foreign_c3=True) + + result = upsert_router(config, mode="custom", default_tier="c2") + + assert result.config.squilla_router.cross_provider_tiers is False + for tier_name in ("c0", "c1", "c2", "c3"): + for field in ("provider", "model"): + assert ( + result.config.squilla_router.tiers[tier_name][field] + == config.squilla_router.tiers[tier_name][field] + ) + + +def test_enabling_legacy_foreign_router_without_explicit_tiers_still_conflicts( + tmp_path: Path, +) -> None: + config = _saved_config( + tmp_path / "config.toml", cross_provider=False, foreign_c3=True, enabled=False, + ) + + with pytest.raises(RouterProviderConflictError): + upsert_router(config, mode="custom") + + +def test_explicit_execution_opt_out_is_not_overridden(tmp_path: Path) -> None: + config = _saved_config(tmp_path / "config.toml", cross_provider=False) + + with pytest.raises(RouterProviderConflictError): + upsert_router( + config, mode="custom", tiers=_foreign_tiers(config), cross_provider_tiers=False, + ) + + +@pytest.mark.parametrize("selection_mode", ["static_openrouter_b5", "router_dynamic"]) +def test_shared_c3_only_enables_cross_provider_when_it_consumes_the_tier( + tmp_path: Path, selection_mode: str, +) -> None: + config = _saved_config(tmp_path / "config.toml", cross_provider=False) + config.llm_ensemble.selection_mode = selection_mode + tiers = _foreign_tiers(config) + tiers["c3"]["ensemble_enabled"] = True + + result = upsert_router(config, mode="custom", tiers=tiers) + + assert result.config.squilla_router.cross_provider_tiers is ( + selection_mode == "router_dynamic" + ) + + +def test_legacy_image_draft_does_not_enable_cross_provider_execution(tmp_path: Path) -> None: + config = _saved_config(tmp_path / "config.toml", cross_provider=False) + tiers = copy.deepcopy(config.squilla_router.tiers) + tiers["image_model"] = {"provider": "openrouter", "model": "unused-image-model"} + + result = upsert_router(config, mode="custom", tiers=tiers) + + assert result.config.squilla_router.cross_provider_tiers is False + + +@pytest.mark.parametrize("saved_flag", [None, False, True], ids=["missing", "false", "true"]) +async def test_reloaded_router_sends_each_tier_to_its_selected_endpoint_and_key( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, saved_flag: bool | None, +) -> None: + path = tmp_path / "config.toml" + config = _saved_config(path, cross_provider=saved_flag) + result = upsert_router(config, mode="custom", tiers=_foreign_tiers(config)) + persist_config(result.config, path=path) + restored = load_config(path) + captured: list[tuple[str, str, str]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + model = json.loads(request.content)["model"] + captured.append((str(request.url), request.headers["authorization"], model)) + chunks = [ + {"model": model, "choices": [{"delta": {"content": "ok"}, "finish_reason": None}]}, + { + "model": model, + "choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 2, "completion_tokens": 1}, + }, + ] + body = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, + content=body + "data: [DONE]\n\n", + ) + + transport = httpx.MockTransport(respond) + real_async_client = httpx.AsyncClient + + def mock_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient: + kwargs["transport"] = transport + kwargs["proxy"] = None + kwargs["trust_env"] = False + return real_async_client(*args, **kwargs) + + monkeypatch.setattr("opensquilla.provider.openai.httpx.AsyncClient", mock_client) + + for tier_name in ("c1", "c3"): + primary = resolve_provider_selector_config(restored) + assert primary is not None + selector = ModelSelector(SelectorConfig(primary=primary)) + tier = restored.squilla_router.tiers[tier_name] + metadata: dict[str, Any] = { + "routing_applied": True, + "routed_tier": tier_name, + "routed_provider": tier["provider"], + "routed_model": tier["model"], + } + deployment = cross_provider_tier_config( + restored, metadata, tier["model"], active_provider_id=primary.provider, + ) + provider = apply_model_override( + selector, tier["model"], turn_metadata=metadata, + realign_routed_model=False, tier_provider_config=deployment, + ) + events = [ + event async for event in provider.chat( + [Message(role="user", content="synthetic routing check")], + config=ChatConfig(max_tokens=16), + ) + ] + assert not any(isinstance(event, ErrorEvent) for event in events) + assert any(isinstance(event, DoneEvent) for event in events) + assert metadata["executed_provider"] == tier["provider"] + assert metadata["executed_model"] == tier["model"] + + assert captured == [ + (f"{PRIMARY_ENDPOINT}/chat/completions", f"Bearer {PRIMARY_KEY}", "primary-c1"), + (f"{SECONDARY_ENDPOINT}/chat/completions", f"Bearer {SECONDARY_KEY}", FOREIGN_MODEL), + ] diff --git a/tests/test_router_tier_contract.py b/tests/test_router_tier_contract.py index 0c3d76935b..c4545fa815 100644 --- a/tests/test_router_tier_contract.py +++ b/tests/test_router_tier_contract.py @@ -1771,8 +1771,8 @@ def test_cross_provider_warning_accepts_case_variant_profile_keys(monkeypatch) - def test_upsert_router_surfaces_existing_cross_provider_warning() -> None: - # Maintaining an already-executable legacy ladder still reports its - # mismatch; newly introducing a foreign dependency requires consent. + # An explicit opt-out preserves legacy execution and its mismatch warning. + # Ordinary submitted deployments instead select their own providers. tiers = {"c2": {"provider": "openai", "model": "gpt-5.5"}} cfg = GatewayConfig(llm={"provider": "openrouter"}) cfg.squilla_router.tiers.update(tiers) @@ -1780,6 +1780,7 @@ def test_upsert_router_surfaces_existing_cross_provider_warning() -> None: cfg, mode="recommended", tiers=tiers, + cross_provider_tiers=False, ) assert any("cross-provider" in w.lower() for w in res.warnings) diff --git a/uv.lock b/uv.lock index a20c7015a7..6f58d29aa1 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,13 @@ conflicts = [[ { package = "opensquilla", group = "legacy-contract-codegen" }, ]] +[manifest] +constraints = [ + { name = "brotlicffi", specifier = ">=1.2.1.0" }, + { name = "filelock", specifier = ">=4.2.1" }, + { name = "setuptools", specifier = ">=84.1.0" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -459,61 +466,92 @@ wheels = [ [[package]] name = "brotli" -version = "1.2.0" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, - { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, - { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, - { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, - { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, - { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, - { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, - { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, - { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, - { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, - { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, - { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, - { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, - { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, - { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cf/81/1546c983678a9b4e77cb4718eaedd1b2caf24a6cdbf1eb8775938d22aff7/brotli-1.2.1.tar.gz", hash = "sha256:02b2f7b5a83485efe448a44ddf7983def05c3bb1dcf917d4f7154070260e13de", size = 7389447, upload-time = "2026-10-22T14:00:12.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/5d/dcf82117cbb506cfe0b75a00c34f2f7e850e5d933d80c0e50e2a191f1fee/brotli-1.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3975fb11f63e212b87a087e4a910187b522c6969a4989ca362d6f85f9dc36acd", size = 861663, upload-time = "2026-10-22T13:57:54.196Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/59d6445882214a47eab34e763aacc8dce18c4e7f9c5e2d9c95df9a7f7a3d/brotli-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de499e2492c8106a5e163d3cf5b6726e2797f9234cfbc6055fbb5f9d8f198f39", size = 442271, upload-time = "2026-10-22T13:57:55.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/7b/55b0c895c17dbe747b63e442d2c3a163cc838d116bf5560414a13cc284c9/brotli-1.2.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1f65a91f3576949793b8720b12a47e7ea06debc970a2da6d54ff8458053e2109", size = 1242808, upload-time = "2026-10-22T13:57:56.675Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/d8f2a81748b60733e8b03afd4bc1b70d3fb74a235b3b13dff0b2978e7f87/brotli-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:875975f91870682cca4371260522ad3f22d636e845b5c5355c52bc045c0f75c8", size = 1402417, upload-time = "2026-10-22T13:57:57.827Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b2/7addcc2c4965d0946e38a416b60af89700d54141845447f03fe4bff0293c/brotli-1.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c3f667dc60a875b12a5ecd7828062ccd97c0fb79c4adc6c8699d23d5ff2af7e1", size = 1424422, upload-time = "2026-10-22T13:57:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cc/b0b5b6b1ff4544bec3a702d5ad2e0badf22db296d16277a6ee59dabaffdc/brotli-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b9dd0173ca5e01d1eb307d3c184fe8c09bdc168ede560889b1018d216d38c61", size = 1420478, upload-time = "2026-10-22T13:58:00.484Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/1d9bdb8aa35c14ba45ce208768eb977810446b7537d9d418c2bcaae2ed20/brotli-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9307679936e86d7773eadfa444a4a4ee80f6f45230e93379875f4a6897a09a2b", size = 1485966, upload-time = "2026-10-22T13:58:01.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8e/86e64fc403888244e090a7a3d9fb35f6d6bb2219e789fc88064065464a3c/brotli-1.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb5df75d1b1c42d997adbb39eac61f2cdb48efa26304a0cb9c0b757c9bc20732", size = 1279161, upload-time = "2026-10-22T13:58:02.933Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ba/cbfa7d264d4cec16d5951071f1d04028e7024119c65a7bd909e77cafc9e6/brotli-1.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d6ef0035b6ae338a46951c144081c61fb044f4c1eb6d20acc5ba6277f666a4c0", size = 1594070, upload-time = "2026-10-22T13:58:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/7abc13404dde1665d2fe3cde3385e06d5f81aed0242c23979acc3f552afd/brotli-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:338856ae9ff63af4fc3dbbd6184a7276a6ae83df2243562e86e5ee5cf980cce7", size = 1489507, upload-time = "2026-10-22T13:58:05.591Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f6/8eacbc45a0a158897752fae9055a4d4a0cfdb1c8e15680d1815af3936da9/brotli-1.2.1-cp312-cp312-win32.whl", hash = "sha256:96e6966edcfae74ffac2522d2dd1a304450d18807a9791f5ed0a3232192c9437", size = 333017, upload-time = "2026-10-22T13:58:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/3e/82/b7f6c56393f2224ff4108cf1cc255921be1c0ae5962163edbe367c8a55bc/brotli-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:f5342b8256476554b4045db8b7bf4dc4044d0405485f220f0cd80764bc0d7faf", size = 371081, upload-time = "2026-10-22T13:58:08.082Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6d/eff775fb2de444f224aa52f79ba400ff963377436495c3f01ed52c3071dd/brotli-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:b0c32f0660bb21e810ffc1d3d04b6ea682e118b8141c6e12522a3dacadeb70ee", size = 380894, upload-time = "2026-10-22T13:58:09.157Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/d74a573b92ba449960eb088ce87dcb61a9dc6a0c5a7ba8d104ae180e64c3/brotli-1.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a84e0ae14d1efe782a9f143021c4e01d5ed986a140fe5070505cc5978eaf0592", size = 861657, upload-time = "2026-10-22T13:58:10.392Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bd/848b55fbd96fa1ce9f534aa39c930f864dbf224282f4a3808c0d35ea2819/brotli-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5302747f969d5ec925810176f7697712a3fa2ea0fae0c7a85aa4ad9325247fd", size = 442252, upload-time = "2026-10-22T13:58:11.647Z" }, + { url = "https://files.pythonhosted.org/packages/40/41/95d5e6774cb037851001038484bcf98b3af203664fe78d29dd03ece317e7/brotli-1.2.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7ec678c52c3ef5e37482a094d109c04cf9e4a0e2923dd1e0fbddb4bf133a976e", size = 1242773, upload-time = "2026-10-22T13:58:13.182Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/0942abad71d8b2aabd21472efa5be31ab0be31f98271d58767b9797eec82/brotli-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5b458950312830913f837e179ce9221000ab7a20b4e42d7b48c1fcd79140de1", size = 1402440, upload-time = "2026-10-22T13:58:14.513Z" }, + { url = "https://files.pythonhosted.org/packages/a3/53/2f7e666e61a1419d527904b6018a711f10650a851e7767358abf2919625e/brotli-1.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f37ccd159dae35d4914651d57142c8077aa67ab7e3ffef690b00bcb7a7dbd8e6", size = 1424432, upload-time = "2026-10-22T13:58:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/65/85/4c57c3cd2eee42878677b4943086e6b36cefc50214a5447028268045ce63/brotli-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:264fd5b200f6f6170cb551a4b9fcaabf8aa846ddaefe72db4d5f49f9d3743df9", size = 1420480, upload-time = "2026-10-22T13:58:17.007Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f7/65e75560e20d522db1f0c75a1b2612c6ebd542ff9df17b8920cdbe4cde68/brotli-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1329c80daa60752f1d94f507479504921e358e71746c5fce9b55b3d51ac038bc", size = 1485998, upload-time = "2026-10-22T13:58:18.422Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2a/6cc0cfc4170bd72f6fc0aef11984e7c583ecf0a7fec563bb4292c0d50e15/brotli-1.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:77ef5882c5318ffc3dd94c6abc6a8f1ec2af75d66522319f1a250a07d53d0546", size = 1279199, upload-time = "2026-10-22T13:58:19.709Z" }, + { url = "https://files.pythonhosted.org/packages/66/36/97f3eefc9295ce8d87b568433d38e0ca3345b82571341196817c96b2ca50/brotli-1.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6bd29ab84bb8972370cacd310e8a91651f54476d01de1cd1a4cf0bece5eb25e1", size = 1594118, upload-time = "2026-10-22T13:58:20.857Z" }, + { url = "https://files.pythonhosted.org/packages/03/61/687b36bd6b714270ff3d7ff96df6199336abc26c1fa98da276d9776d75b7/brotli-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed8e452f5cb34f59f4861d50d029c6203b5f8f11c13d4186264392a64b4def8d", size = 1489530, upload-time = "2026-10-22T13:58:22Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b4/379b2e5584be70110b154021ffa61b84dc3b476d2c031317f5c3fbcfcde5/brotli-1.2.1-cp313-cp313-win32.whl", hash = "sha256:c788fdab2eeeab671f5ab375ad010ef743f6c95be67f60f878e8876a51f6c671", size = 333014, upload-time = "2026-10-22T13:58:23.112Z" }, + { url = "https://files.pythonhosted.org/packages/96/c2/0c7abb25013043d4bcf7dd768d343bf7dee6ce99ac8c172f7ee4aef1c772/brotli-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:e6c4fa55eab6b5684b33239597876a18a35ce1280b63ab960164032afbb8783f", size = 371072, upload-time = "2026-10-22T13:58:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/81/68/5f9d96203fdce3ff9ba1186ce10220e26483ffa2384cde5430dd980e9057/brotli-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:67f4dceb22ce3a742751cee849644449b169684699aa548e77d771de304e963c", size = 380893, upload-time = "2026-10-22T13:58:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/641dd6072ea8c5e0ef7d1f8dc02149b4b0b77ef68fe12fc08b08320980d5/brotli-1.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b8b2fc7901b31d5885f26521cf2d74feccdc126a272ba1d879b7545bb25468bc", size = 863184, upload-time = "2026-10-22T13:58:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b8/3c4a50dc10f098a022519042eab84f11ef8c29abb9270fca76086ac392c3/brotli-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:de1bf30d819809a964060356032e6670c9923644666afaaf3d1719d05ddb844e", size = 443276, upload-time = "2026-10-22T13:58:28.47Z" }, + { url = "https://files.pythonhosted.org/packages/b1/a1/5ef931a404de2c61b2cd775e70b1652ff3f4d434f0589da22efbee590f4a/brotli-1.2.1-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ed62c15ce5e8b975b2710a0f7c8bb953cbffb472fccb05055be6afcba9520c19", size = 1243026, upload-time = "2026-10-22T13:58:29.934Z" }, + { url = "https://files.pythonhosted.org/packages/11/95/54e7278ee3fc589a6ac3a9bc191b5412542949411f0b38c56614d0f51282/brotli-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3942e2d365f009e5e7839114fa315e1a2bcfea12b52064a28f1a7281862a2a7", size = 1402517, upload-time = "2026-10-22T13:58:31.231Z" }, + { url = "https://files.pythonhosted.org/packages/4d/db/e065253a23a09d97dc7bd8cb47194a85479d71be35a51b5c575ae5095ebe/brotli-1.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6532d2c58018121a9eb5882656cf862cdbbd853600cb5fbccac59bcff14f66b0", size = 1424572, upload-time = "2026-10-22T13:58:32.468Z" }, + { url = "https://files.pythonhosted.org/packages/c4/07/8948582ce173e8e43cea9fadb8c3c3ea94800fb9814119c9239c1ae6c797/brotli-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a998857af4fcd19920bdf5f30f0f1a23f38472a703d33e49168cd4fd32438377", size = 1420557, upload-time = "2026-10-22T13:58:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d4/65073a06d6356a8f60b4c6ce0c3ade206d1118b8b8705b548f7b72ed549a/brotli-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f352f797b1477c46a20e45f4cbc3a0e258e61963c8ae8f79f84a7bfb4195b37e", size = 1486209, upload-time = "2026-10-22T13:58:35.331Z" }, + { url = "https://files.pythonhosted.org/packages/5a/19/8663beee29665f6fdf520b9c7ea333e0e930952176092be24c4c8bd8f791/brotli-1.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:be371af72af54b6e508b68a7a87768e05b7e13023f6c82a3e3f5e4d0e829e186", size = 1279217, upload-time = "2026-10-22T13:58:36.719Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/3851ec19707a1610412ecf4f057d7c1b5acbb8f8b211494a70e1f451ccf6/brotli-1.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dca1bf90b3a6a7440904f128464eb95bf46ac9da7ad3c85912483dd83128fd95", size = 1594292, upload-time = "2026-10-22T13:58:38.115Z" }, + { url = "https://files.pythonhosted.org/packages/8d/13/6bc9d4cb30fb0a475cd149c2075b9f63b93257a84ff70d0783a403f4b015/brotli-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4a7be59d374c445766fa4ac066a04d19a757365ae00a950053d0ab7388d1039", size = 1489719, upload-time = "2026-10-22T13:58:39.365Z" }, + { url = "https://files.pythonhosted.org/packages/04/da/1ec2e9b4f500693e56e6391f128dd0d304d6d2b7f0d5090e80a802b722f1/brotli-1.2.1-cp314-cp314-win32.whl", hash = "sha256:0a30e5df5ba985b67f85e13dfd7e5a68e186d8d9b5cff7d9a8904832bb413e81", size = 338208, upload-time = "2026-10-22T13:58:40.927Z" }, + { url = "https://files.pythonhosted.org/packages/24/5e/80cd38322263da5e92ea4935423f1d3cfa2509201673dce77b772c47b997/brotli-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:972a2e19dbaab47d0aa77c1d07f71c930bdc53538f2b33decc905e92a4be5cbd", size = 376707, upload-time = "2026-10-22T13:58:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/c5/35/cb35c489fcd0a813bbb7500465af5a7bac3bee7d996f6a211bac9096ef2c/brotli-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a3dea7e1ac49e29306500a8627fa3eacabcc9e238d22495eea2d8e61de77065b", size = 390280, upload-time = "2026-10-22T13:58:43.256Z" }, + { url = "https://files.pythonhosted.org/packages/a2/72/85ea89c1088673a297e8b652cf0080668812144d5525db6512eadddf9d19/brotli-1.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eea17e3cdc6f9aecffe15fe9a9eeba8e8c049a5d347de1561eed2240cdf35a79", size = 863002, upload-time = "2026-10-22T13:58:44.325Z" }, + { url = "https://files.pythonhosted.org/packages/81/6a/bd4db4ef0c4d083d8a2e5a1d0b20239b186f261e72b14a9a0ec91c078e92/brotli-1.2.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:761db18dc32b24d00fbedca1013089eb3470053cefa8492bd9c07b11102d72bf", size = 443263, upload-time = "2026-10-22T13:58:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/79/1a/08635be453d523474fdfce81f588583a4e8049c75c04df849b5a278bf027/brotli-1.2.1-cp315-cp315-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:035f3bf861328d8b7fcb61f4a976f9913027158e5666116733d59ffc65f2745e", size = 1244379, upload-time = "2026-10-22T13:58:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/3b/94/52f2facc5ae118a68c4f0e715e03c9f854507a359608804d7de2dfe8d603/brotli-1.2.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:aa32b51702a0b6e9fddc0d1dc66cb6ed6af112f1bba0cac2be9039546a862b5c", size = 1404133, upload-time = "2026-10-22T13:58:47.756Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9b/768635c710b684574da55e12bcdbe36419fd8bf6cb97e01c72a4593f9747/brotli-1.2.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:810aea5958d6a2d92593fd27218df528644aeb72cc7f6d401e650752d6c98949", size = 1424875, upload-time = "2026-10-22T13:58:48.912Z" }, + { url = "https://files.pythonhosted.org/packages/46/39/7b7d53d7cea61f35f6b52ea149804523d01e532bf26017eed4351d82a7ad/brotli-1.2.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f00ff4b56caf151365c77988eb85a4a71eb26fe63047d4084d5614e06d65e3cf", size = 1425345, upload-time = "2026-10-22T13:58:50.253Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5e/652c788a4da5c6415c7ae31382626ba870020c9280e0f9be1a6dfec80261/brotli-1.2.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dafaeb38712f99e5c31ad7f02c366f1ddd3792921b94066015f8c49f197163b0", size = 1487925, upload-time = "2026-10-22T13:58:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/85/c9/1b7753d02c106ff70b3d88056586ed7c2fe329d2aa5707cdaebe90372942/brotli-1.2.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:972e34c5cbbfe6c90358bfae38edae1a29d3456e920da5a4664c98f221ee4df8", size = 1276526, upload-time = "2026-10-22T13:58:53.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5f/6ae3e85bfb7a3f4711cea5a1b92d4377fddf1319a666cda213cc83b6c040/brotli-1.2.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:59ca34c7a83d7a3b842247183c426f891a95a8f57db41b7aac92195c51e8a83a", size = 1594681, upload-time = "2026-10-22T13:58:54.571Z" }, + { url = "https://files.pythonhosted.org/packages/8a/52/2808406d83e5462c086d69dbebb892f4ed78d2eb4b6755ea4b9e58357e3b/brotli-1.2.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90bd49511341dd7c40bb3acaa97cd8b9e5df4821a5714b842117e7069c7cc03e", size = 1486968, upload-time = "2026-10-22T13:58:56.008Z" }, + { url = "https://files.pythonhosted.org/packages/a9/53/35042a907c309af8defb423e58692bdc5e3e1fd3d03388892b7614270626/brotli-1.2.1-cp315-cp315-win32.whl", hash = "sha256:1880db6241276e191d3e34a6e4eea708c6629103c396f0ea21e75b0ce63a9a2c", size = 338212, upload-time = "2026-10-22T13:58:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/38/6c/883a32c23603e278ae93d8df31f0573b67382de827e6e05a6e9ee0b51896/brotli-1.2.1-cp315-cp315-win_amd64.whl", hash = "sha256:09a2f112a638aca15a88f298571788795fc45d62f2bca7f3870c1152153ef399", size = 376707, upload-time = "2026-10-22T13:58:58.292Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d5/2c574da9faf130a59c4c685ba1378f2f470b57fb9fa5d3b0f8b4e90579a0/brotli-1.2.1-cp315-cp315-win_arm64.whl", hash = "sha256:91097d66ebb8ca8d23372081a33405a44e2239445614888e38929bd7f5879dae", size = 390278, upload-time = "2026-10-22T13:58:59.512Z" }, + { url = "https://files.pythonhosted.org/packages/f5/29/62f7de3d08c53b09fe67b24b024b13ce3ec088fe0dd6940064719e61eba5/brotli-1.2.1-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:5f3da70516b1bd1b2aa183ec4ab5c02dfc6ad5c1184d47bb65ade25e710439b6", size = 416714, upload-time = "2026-10-22T14:00:06.054Z" }, + { url = "https://files.pythonhosted.org/packages/e2/66/3da6c4e928c55fc85f4cdaf0cbd41ebb1639ae70c71f9f26183818460d54/brotli-1.2.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:11aec6ea5c20cad61a33302e06baa48b0243bfa4d173bc2985dabd047e367d08", size = 399657, upload-time = "2026-10-22T14:00:07.095Z" }, + { url = "https://files.pythonhosted.org/packages/85/9d/626bb730a70507151667d950c9214003427b962a3876142c07ba52d96391/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00a50d063aae2e69c2eb28818ccf6dc3e51f2ea5b74e3c2082a47943ee23f399", size = 404727, upload-time = "2026-10-22T14:00:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/bb/34/fb0bbe56d822ee4c75b2383e518bdd579327f18f1cfedf1d779a585df01b/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37de1818ce469b3c37324c89d4d4dbb5e64461d9dc01c16f8abb2a17c465b132", size = 398927, upload-time = "2026-10-22T14:00:09.739Z" }, + { url = "https://files.pythonhosted.org/packages/4d/3f/43ea0a58dcb50c820465a0d62ccbbf6464c2e2cc4ebf582e64225dd67b4b/brotli-1.2.1-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:06a22fcbcae059c0679098232779095fc50a735a3fea02db5c79cab10b84d877", size = 457800, upload-time = "2026-10-22T14:00:10.978Z" }, ] [[package]] name = "brotlicffi" -version = "1.2.0.1" +version = "1.2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/8b/10c6c4bac3a7d292707c9b879b8b3bb7f6c8ca5db5e6dff559bd9d59d45e/brotlicffi-1.2.1.0.tar.gz", hash = "sha256:5d0b89e959f643aab4c58c15c06e1e87b6f6605e5e88c3c1c28f5ce1c58c4e5f", size = 479661, upload-time = "2026-10-22T16:19:33.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, - { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, - { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/69/c2/970af780f5e72bec6beb0d1c63d5799d52d2008f5660925c57a313999785/brotlicffi-1.2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fea6f5450a6bd73ec222e92282d4db25030943946190697c1f2cd41087c59bc6", size = 439111, upload-time = "2026-10-22T16:19:00.566Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/72586e3eaba1e90b6ff5525db4171bcb0011d0fce23feb1a33ebdb89a060/brotlicffi-1.2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b77704eb33e345e00d0a2854abf7966b86e1709a1f89bcc143b6c8437256d7c7", size = 1542967, upload-time = "2026-10-22T16:19:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/54900777a94c65386e414eb74aa01f5cf871763f95a1e7288105d2f3e5e7/brotlicffi-1.2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cb591da62db91dddd701a28def82f39e0f7943321ac7d0814a1d2a1625095d1", size = 1543470, upload-time = "2026-10-22T16:19:04.865Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d4/d4c762118d5637371796d874245e98b63582c3ee631340b9ff9bda60e5d6/brotlicffi-1.2.1.0-cp314-cp314t-win32.whl", hash = "sha256:6f5ff7c8f62eaa3e6feb2b4d06a6001bb08cc346c9de7608786de99387255e94", size = 347155, upload-time = "2026-10-22T16:19:07.012Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2b/a1bb5da1278c57bb92c10b0c4ba1f589fdad8dadb133a46fa4fb5bd9fc55/brotlicffi-1.2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:421ba24ee6568c6baa3c83e0f584fe9e3941bf86401122942c5ed085f8114c09", size = 386423, upload-time = "2026-10-22T16:19:08.609Z" }, + { url = "https://files.pythonhosted.org/packages/6d/74/0149c52fef86523faf9768191e6b428a8aab2e60ef1ffeb6e6641d6d6bcd/brotlicffi-1.2.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:cc882e1ab3f81d262cc002132476dab7667f8f41ead3de8609b163cb64409f8a", size = 439185, upload-time = "2026-10-22T16:19:10.341Z" }, + { url = "https://files.pythonhosted.org/packages/23/3a/6ef07f52eb6ddef242eee7e3634ce21fa8467e7de9c4f4d288ef069375cb/brotlicffi-1.2.1.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed7c8311209853c9d78e2f6cff1f57e4df2541e4d67e3677dc1f4c88539db907", size = 1536298, upload-time = "2026-10-22T16:19:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/f1/93/e2735a441a0f071b8094a2b9baadad5fb9811708c088b294ccb0e90aeb46/brotlicffi-1.2.1.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a7281a39de91432e90c27ab9c4f9feb3a9cf33fda3873534c2a88270d9f60a2", size = 1538171, upload-time = "2026-10-22T16:19:14.394Z" }, + { url = "https://files.pythonhosted.org/packages/27/fe/fc7fbdc8d7d4a8e7585f9db0b84e0290f9da2f0b4199f4754e8750e2454a/brotlicffi-1.2.1.0-cp39-abi3-win32.whl", hash = "sha256:25ab19c47b88438b5d00a2074b86fe37ab5c176aef90de1ae0f5e1ae52300462", size = 342676, upload-time = "2026-10-22T16:19:16.086Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/b0a5afcbd30e4ca29629f71dbf9a9a7fa3032ae2a3c51b49c4ece968e527/brotlicffi-1.2.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:00a6e8df24c2f48744bbdca8694a2b50bb26d1d0b6360c3a7c5caeaf4ad83373", size = 380083, upload-time = "2026-10-22T16:19:17.669Z" }, + { url = "https://files.pythonhosted.org/packages/59/c4/99db05628cc7c258604476979b11414ab7c326a82042f4a9a4826acd9c92/brotlicffi-1.2.1.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:75e54388f06cd7edf5354f526d0386553db441b7574a22a2bcb19a2149e48eb7", size = 407820, upload-time = "2026-10-22T16:19:26.018Z" }, + { url = "https://files.pythonhosted.org/packages/09/3e/ad3da4967a107d9facff19c7117f10fd843e32ddd06545271b542157a8e4/brotlicffi-1.2.1.0-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30a52a620564ef3c6a34cd1e09d699b92ba68a3447a28915b77a17eb5d2c8f71", size = 407226, upload-time = "2026-10-22T16:19:27.736Z" }, + { url = "https://files.pythonhosted.org/packages/31/d4/245067918a5e378b995cbd3be2e5c4856d382f0b52bc72ac0d234b246d34/brotlicffi-1.2.1.0-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602e87ecc8fd2c72b13ba6dced2e0d5aefc18a262523e82795ed0f47f5a13903", size = 403284, upload-time = "2026-10-22T16:19:29.416Z" }, + { url = "https://files.pythonhosted.org/packages/42/da/f77571a1e13025991a6e1e265997ea4b5d18dd5092e9fb309531cfb2a305/brotlicffi-1.2.1.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:52b99120543ffa3b0667e922c6a7dc6b6e01350f7fd38f21aaca511bb9f8b4f2", size = 465300, upload-time = "2026-10-22T16:19:30.988Z" }, ] [[package]] @@ -917,11 +955,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.25.2" +version = "4.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/98/771d8ca889f220d7d7e352addf500666368f0fd081c6080a83767580c6e4/filelock-4.2.1.tar.gz", hash = "sha256:c4c937ccac2a813cee448e662a56366cfb374c0f8702e75f295c412cc1c5d5a3", size = 650997, upload-time = "2026-10-17T16:48:14.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/db89d339bc4ecdc8087fa7b8607ba22ffc27bf96286f340c9620bea428b3/filelock-4.2.1-py3-none-any.whl", hash = "sha256:7be2662e9e826b9bba28afc0f843a123280d799a2ea44bb5d35ccec67905ae44", size = 162455, upload-time = "2026-10-17T16:48:12.937Z" }, ] [[package]] @@ -2393,7 +2431,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.15.1" }, { name = "apscheduler", specifier = ">=3.11.3" }, { name = "beautifulsoup4", specifier = ">=4.15.0" }, - { name = "brotli", specifier = ">=1.1" }, + { name = "brotli", specifier = ">=1.2.1" }, { name = "cachetools", specifier = ">=5.3" }, { name = "certifi", specifier = ">=2026.10.19" }, { name = "click", specifier = ">=8.5.0,<9" }, @@ -2432,7 +2470,7 @@ requires-dist = [ { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'recommended'", specifier = ">=0.7,<1" }, { name = "pydantic", specifier = ">=2.13.5" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, - { name = "pypdf", specifier = ">=6.21.1" }, + { name = "pypdf", specifier = ">=6.21.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.1.1" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.4.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6" }, @@ -3059,11 +3097,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.21.1" +version = "6.21.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/e1/32a9c4dbd94618145f8bab5c623bdb0a528f23fc8719afd77ba0278fd377/pypdf-6.21.1.tar.gz", hash = "sha256:2d62c383acd6e0dff0a96322ba8b1135229343b084d73c6f6cfd947fd291afcb", size = 7088597, upload-time = "2026-10-17T10:29:52.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/28/081f1cbcca7042441dafc8de00f48ba60853f89e5bff92c5f26477346d81/pypdf-6.21.2.tar.gz", hash = "sha256:0a3e1c5c886a1be03646c16a04827cc166c2a0575c971b26a4bf89686551c2d4", size = 7144571, upload-time = "2026-10-21T16:44:55.272Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/e6/24c944e473c34e818cf1b98085796fcd780465ecfe17663f2bf8fee1958b/pypdf-6.21.1-py3-none-any.whl", hash = "sha256:553e2d42f5bb3bb2a785a7d3de8ef90bb7df74b5413eff5a7b4388007ea9254b", size = 405134, upload-time = "2026-10-17T10:29:50.687Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4d/c9d2d67f204eff463237a1a8f5e25a6923b7b1043982b91fcaf104c8ef60/pypdf-6.21.2-py3-none-any.whl", hash = "sha256:1d44d1a3a200f25062adff0085b02040e4646431ac2ccac22e42679e91466084", size = 407766, upload-time = "2026-10-21T16:44:53.626Z" }, ] [[package]] @@ -3785,11 +3823,11 @@ wheels = [ [[package]] name = "setuptools" -version = "84.0.0" +version = "84.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/eb/7e5f628ee2eb168fecbf8026de7856b2543d6d609cc85f086b9a91ac93d6/setuptools-84.1.0.tar.gz", hash = "sha256:23657205b63d4d31395ab917815247f694aafad28cd21316bf5fd45a51048ea0", size = 1171783, upload-time = "2026-10-19T20:11:43.489Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, + { url = "https://files.pythonhosted.org/packages/18/02/b4bbb90f8f29e334898ff45c6d9b1fffaa6f378017052b3023a06262b5f7/setuptools-84.1.0-py3-none-any.whl", hash = "sha256:5231689c6ac8156cd155cfb98c3fd6fb252c3e7e9bd3d78b64d3c55f132624c9", size = 819380, upload-time = "2026-10-19T20:11:41.684Z" }, ] [[package]]