diff --git a/contracts/gateway/v4/compatibility-manifest.generated.json b/contracts/gateway/v4/compatibility-manifest.generated.json index adf3dbda7d..d959bba5d8 100644 --- a/contracts/gateway/v4/compatibility-manifest.generated.json +++ b/contracts/gateway/v4/compatibility-manifest.generated.json @@ -29,7 +29,7 @@ "delivery": "live-only-best-effort", "family": "conversation.events", "schema": "conversation/conversation-events.schema.json", - "schemaSha256": "3a853963ea7b33c87edef960338dd1e181f6e5140a49762a9e345fea8b6bc37c", + "schemaSha256": "00a95e7de26a01838bfb2db882f092b9e499de19052aa1010e8a03b1adbdca8a", "schemaVersion": 1, "wireNames": [ "chat.done", @@ -724,6 +724,12 @@ "schema": "platform/migration-sources-preview.schema.json", "schemaSha256": "7d89ea10bff333c3a5c92fb157ea887fbc5823685808e6531fab1cb9426597ff" }, + { + "lifecycle": "stable", + "name": "models.capacity.resolve", + "schema": "platform/models-capacity-resolve.schema.json", + "schemaSha256": "ad2449d34174321d078ee87779b13457c998056897de6008ac9a41e8829851e0" + }, { "lifecycle": "stable", "name": "models.list", @@ -1463,10 +1469,10 @@ "protocol": "opensquilla-websocket-json", "source": { "eventFamilyCount": 10, - "generatorSha256": "f781c6d8e31b336c2d2e342784935b3d327c9947cb35e56de8e44a0a83bffae6", - "methodCount": 215, - "schemaCount": 225, - "schemaTreeSha256": "840fa1aabc2cf9ec62fb500bbe7771c49a00a5f54e367cabc3865fa57fb63579" + "generatorSha256": "d2ba9f83594c975b279a95386cd247d50b6704d4b6feabd516c8d870ad827013", + "methodCount": 216, + "schemaCount": 226, + "schemaTreeSha256": "acfc84425d8709c6c39362c0cce19ae186462d430a1205ef43042778b6ff983b" }, "wireVersion": 4 } diff --git a/contracts/gateway/v4/conversation/conversation-events.schema.json b/contracts/gateway/v4/conversation/conversation-events.schema.json index b8af0d95b8..1e64f3d327 100644 --- a/contracts/gateway/v4/conversation/conversation-events.schema.json +++ b/contracts/gateway/v4/conversation/conversation-events.schema.json @@ -147,6 +147,43 @@ "type": "object", "additionalProperties": true, "properties": { + "model_capacity": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "required": [ + "provider", + "model", + "contextWindow", + "source" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "contextWindow": { + "type": "integer", + "minimum": 1 + }, + "source": { + "enum": [ + "default", + "catalog", + "config", + "override" + ] + } + } + }, "key": { "type": ["string", "null"], "minLength": 1 diff --git a/contracts/gateway/v4/platform/models-capacity-resolve.schema.json b/contracts/gateway/v4/platform/models-capacity-resolve.schema.json new file mode 100644 index 0000000000..5497e1a9ea --- /dev/null +++ b/contracts/gateway/v4/platform/models-capacity-resolve.schema.json @@ -0,0 +1,256 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opensquilla.dev/contracts/gateway/v4/platform/models-capacity-resolve.schema.json", + "title": "OpenSquilla models.capacity.resolve Contract", + "type": "object", + "additionalProperties": false, + "properties": { + "request": { + "$ref": "#/$defs/RequestFrame" + }, + "response": { + "$ref": "#/$defs/ResponseFrame" + } + }, + "x-opensquilla-wire": { + "protocol": "opensquilla-websocket-json", + "version": 4, + "compatibility": "exact-json-tree" + }, + "x-opensquilla-codegen": { + "python": { + "tool": "datamodel-code-generator", + "version": "0.75.1", + "target": "pydantic_v2.BaseModel" + }, + "typescript": { + "tool": "json-schema-to-typescript", + "version": "15.0.4" + }, + "runtimeValidation": { + "tool": "ajv", + "version": "8.17.1", + "mode": "standalone-adapter-only" + } + }, + "x-opensquilla-method": { + "name": "models.capacity.resolve", + "kind": "query", + "scope": "operator.read", + "guestAllowed": false, + "idempotency": "read-only", + "timeout": { + "policy": "transport" + }, + "capability": { + "kind": "method-availability", + "name": "models.capacity.resolve" + }, + "request": "#/$defs/RequestFrame", + "params": "#/$defs/Params", + "response": "#/$defs/ResponseFrame", + "result": "#/$defs/Result", + "errors": [ + { + "code": "INVALID_REQUEST" + }, + { + "code": "UNAUTHORIZED" + }, + { + "code": "UNAVAILABLE", + "retryable": true + }, + { + "code": "INTERNAL_ERROR" + } + ] + }, + "$defs": { + "Params": { + "type": "object", + "additionalProperties": false, + "required": [ + "models" + ], + "properties": { + "models": { + "type": "array", + "maxItems": 128, + "items": { + "$ref": "#/$defs/Target" + } + } + } + }, + "Result": { + "type": "object", + "additionalProperties": false, + "required": [ + "models" + ], + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/$defs/Capacity" + } + } + } + }, + "RpcError": { + "type": "object", + "additionalProperties": true + }, + "RequestFrame": { + "type": "object", + "additionalProperties": true, + "required": [ + "type", + "id", + "method" + ], + "properties": { + "type": { + "const": "req" + }, + "id": { + "type": "string" + }, + "method": { + "const": "models.capacity.resolve" + } + } + }, + "ResponseFrame": { + "type": "object", + "additionalProperties": true, + "required": [ + "type", + "id", + "ok" + ], + "properties": { + "type": { + "const": "res" + }, + "id": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "payload": { + "$ref": "#/$defs/Result" + }, + "error": { + "$ref": "#/$defs/RpcError" + } + } + }, + "Target": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "model" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "\\S" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "\\S" + } + } + }, + "Limit": { + "type": "object", + "additionalProperties": false, + "required": [ + "automatic", + "automaticSource", + "override", + "value", + "source", + "editable" + ], + "properties": { + "automatic": { + "type": "integer", + "minimum": 1 + }, + "automaticSource": { + "type": "string", + "enum": [ + "catalog", + "default", + "override", + "config" + ] + }, + "override": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "value": { + "type": "integer", + "minimum": 1 + }, + "source": { + "type": "string", + "enum": [ + "catalog", + "default", + "override", + "config" + ] + }, + "editable": { + "type": "boolean" + } + } + }, + "Capacity": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "model", + "contextWindow", + "maxOutputTokens", + "localRuntime" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "contextWindow": { + "$ref": "#/$defs/Limit" + }, + "maxOutputTokens": { + "$ref": "#/$defs/Limit" + }, + "localRuntime": { + "type": "boolean" + } + } + } + } +} diff --git a/contracts/gateway/v4/production-targets.json b/contracts/gateway/v4/production-targets.json index eae75a17a7..5ef1afdb07 100644 --- a/contracts/gateway/v4/production-targets.json +++ b/contracts/gateway/v4/production-targets.json @@ -4,27 +4,40 @@ { "kind": "method", "wireName": "telemetry.product_active.record", - "roles": ["result"] + "roles": [ + "result" + ] }, { "kind": "method", "wireName": "sessions.executionLog.read", - "roles": ["params", "result"] + "roles": [ + "params", + "result" + ] }, { "kind": "event", "wireName": "transport.flow.dirty", - "roles": ["payload"] + "roles": [ + "payload" + ] }, { "kind": "method", "wireName": "transport.flow.update", - "roles": ["params", "result"] + "roles": [ + "params", + "result" + ] }, { "kind": "method", "wireName": "sessions.messages.snapshot.read", - "roles": ["params", "result"] + "roles": [ + "params", + "result" + ] }, { "kind": "event", @@ -678,6 +691,14 @@ "result" ] }, + { + "kind": "method", + "wireName": "models.capacity.resolve", + "roles": [ + "params", + "result" + ] + }, { "kind": "method", "wireName": "models.list", diff --git a/opensquilla-webui/e2e/model-capacity.spec.ts b/opensquilla-webui/e2e/model-capacity.spec.ts new file mode 100644 index 0000000000..c015d26873 --- /dev/null +++ b/opensquilla-webui/e2e/model-capacity.spec.ts @@ -0,0 +1,288 @@ +import { expect, test, type Page } from '@playwright/test'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { helloOkResponse } from './support/gateway-fixture'; +const MODEL = 'example.vendor/unknown-model.v1:latest'; +const KNOWN = 'example.vendor/catalog-model.v2:latest'; +const OUTPUT = process.env.CAPACITY_SCREENSHOTS; +const BASELINE = process.env.CAPACITY_BASELINE === '1'; +const methods = ['config.get', 'config.effective', 'config.patch', 'config.patch.safe', 'onboarding.catalog', 'onboarding.status', 'onboarding.models.discover', 'onboarding.llmProfile.models.discover', 'onboarding.llmProfile.draft.models.discover', 'onboarding.llmProfile.upsert', 'onboarding.llmProfile.upsertAndActivate', 'onboarding.llmProfile.activate', 'models.list', 'models.capacity.resolve', 'providers.status', 'models.routing.get', 'models.routing.set', 'onboarding.router.configure', 'onboarding.ensemble.configure', 'sessions.list', 'agents.list', 'commands.list_for_surface']; +async function fixture(page: Page, mode: string, locale: string, theme: string) { + const config: Record = { + llm: { provider: 'custom', model: MODEL, base_url: 'https://capacity.example.invalid/v1', api_key_env: 'SYNTHETIC_MODEL_KEY' }, + llm_profiles: { custom_anthropic: { model: MODEL, base_url: 'https://anthropic.example.invalid/v1', api_key_env: 'SYNTHETIC_ANTHROPIC_KEY' } }, + squilla_router: { enabled: mode === 'router', rollout_phase: 'enforce', preset_binding: 'custom', default_tier: 'c1', visual_mode: 'real_candidates', tiers: Object.fromEntries(['c0', 'c1', 'c2', 'c3'].map((name, i) => [name, { provider: i === 2 ? 'custom_anthropic' : 'custom', model: i === 1 ? KNOWN : MODEL, thinking_level: 'off', ensemble_enabled: i === 3, ensemble_selection_mode: i === 3 ? 'custom_b5' : '' }])) }, + llm_ensemble: { enabled: mode === 'ensemble', selection_mode: 'custom_b5', min_successful_proposers: 2, all_failed_policy: 'fixed', candidates: [['custom', MODEL], ['custom_anthropic', MODEL], ['custom', KNOWN]].map(([provider, model]) => ({ provider, model, role: 'proposer', enabled: true, source: 'custom' })) }, models: {}, permissions: {}, skills: {}, + }; + const writes: any[] = [], calls: string[] = []; + const state = { reject: false, delayed: false, release: () => { } }; + await page.addInitScript(({ locale, theme }) => { localStorage.setItem('opensquilla-locale', locale); localStorage.setItem('opensquilla-theme', theme); }, { locale, theme }); + await page.route('**/api/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })); + await page.routeWebSocket(/\/ws$/, ws => { + ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} })); + ws.onMessage(async (message) => { + const frame = JSON.parse(String(message)); + if (frame.type !== 'req') + return; + const method = frame.method as string; + calls.push(method); + 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: [] }, auth: { scopes: ['operator.read', 'operator.write', 'operator.admin'], runModePolicy: { allowedRunModes: ['safe', 'full'], defaultRunMode: 'full' } } })); + return; + } + if (method === 'models.capacity.resolve') { + respond({ models: frame.params.models.map((item: any) => { + const override = config.models[item.provider]?.[item.model] || {}, known = item.model === KNOWN; + const limit = (field: string, automatic: number) => ({ automatic, automaticSource: known ? 'catalog' : 'default', override: override[field] ?? null, value: override[field] ?? automatic, source: override[field] ? 'override' : known ? 'catalog' : 'default', editable: true }); + return { ...item, contextWindow: limit('context_window', known ? 131072 : 8192), maxOutputTokens: limit('max_output_tokens', 16384), localRuntime: false }; + }) }); + return; + } + if (method === 'config.patch') { + writes.push(frame.params); + if (state.delayed) + await new Promise(resolve => { state.release = resolve; }); + if (state.reject) { + respond({ code: 'INTERNAL_ERROR', message: 'Synthetic save failure' }, false); + return; + } + for (const [provider, models] of Object.entries(frame.params.patch?.models || {}) as any) { + config.models[provider] ||= {}; + for (const [model, fields] of Object.entries(models) as any) { + config.models[provider][model] ||= {}; + for (const [field, value] of Object.entries(fields)) + if (value === null) + delete config.models[provider][model][field]; + else + config.models[provider][model][field] = value; + } + } + respond({ changed: true, restartRequired: false, patched: ['models'] }); + return; + } + if (method.includes('discover')) { + respond({ ok: true, failureKind: '', detail: '', source: 'live', models: [MODEL, KNOWN].map(id => ({ id, name: id, contextWindow: id === KNOWN ? 131072 : 8192, maxOutputTokens: 16384, capabilities: ['chat'], pricing: null, capabilitySource: id === KNOWN ? 'catalog' : 'synthesized' })) }); + return; + } + const payloads: Record = { + 'config.get': config, 'config.effective': { fields: { 'llm.provider': { value: 'custom', source: 'config' } } }, + 'onboarding.catalog': { providers: ['custom', 'custom_anthropic'].map(providerId => ({ providerId, label: providerId === 'custom' ? 'Custom (OpenAI)' : 'Custom (Anthropic)', runtimeSupported: true, acceptsApiKey: true, requiresApiKey: true, requiresBaseUrl: true, routerSupported: true, selectableModelCatalog: 'verified_live', fields: [{ name: 'model', label: 'Model ID', required: true }, { name: 'baseUrl', label: 'Base URL', required: true }] })) }, + 'onboarding.status': { hasConfig: true, llmConfigured: true, audioConfigured: false, llmCredentialStatus: { provider: 'custom', available: true, source: 'env', envKey: 'SYNTHETIC_MODEL_KEY' }, llmProfileStatus: [{ provider: 'custom_anthropic', ready: true, primaryEligible: true, primaryBlockReason: '', credentialSource: 'profile' }], sectionDetails: { router: { routerBinding: 'custom', enabled: mode === 'router' }, ensemble: { configuredAllFailedPolicy: 'fixed', effectiveAllFailedPolicy: 'fixed' } } }, + 'models.list': { models: [], errors: [] }, 'models.routing.get': { mode: mode === 'single' ? 'direct' : mode }, 'agents.list': { agents: [] }, 'commands.list_for_surface': { commands: [] }, 'sessions.list': { sessions: [], count: 0, ts: 1800000000, has_more: false }, 'usage.status': { sessions: [] }, + }; + respond(payloads[method] ?? {}); + }); + }); + return { config, writes, calls, state }; +} +async function shot(page: Page, name: string) { if (OUTPUT) { + mkdirSync(OUTPUT, { recursive: true }); + await page.screenshot({ animations: 'disabled', path: join(OUTPUT, `${BASELINE ? 'before' : 'after'}-${name}.png`) }); +} } +async function ready(page: Page) { await page.goto('/control/settings/modelStrategy'); await expect(page.locator('.setup-model-strategy')).toBeVisible({ timeout: 20000 }); } +for (const mode of ['single', 'router', 'ensemble']) + for (const [width, height] of [[1440, 900], [1024, 768], [390, 844]]) + for (const [locale, theme] of [['en', 'light'], ['en', 'dark'], ['zh-Hans', 'light'], ['zh-Hans', 'dark']]) { + test(`layout ${mode} ${width} ${locale} ${theme}`, async ({ page }) => { + await page.setViewportSize({ width: width!, height: height! }); + const { calls } = await fixture(page, mode, locale!, theme!); + await ready(page); + if (!BASELINE) + await expect(page.locator('.model-capacity-warning')).toHaveCount(0); + await shot(page, `${mode}-${width}-${locale}-${theme}`); + if (BASELINE) + return; // Baseline captures are evidence; current-code checks run below. + const overflow = await page.locator('.settings-panel').evaluate(el => el.scrollWidth - el.clientWidth); + if (overflow > 1) + console.log(await page.locator('.settings-panel').evaluate(el => Array.from(el.querySelectorAll('*')).filter(node => node.getBoundingClientRect().right > el.getBoundingClientRect().right + 1).slice(0, 12).map(node => ({ class: node.className, width: node.clientWidth, scroll: node.scrollWidth })))); + expect(overflow).toBeLessThanOrEqual(1); + if (mode === 'router') + expect(await page.locator('.setup-tier-table__row.is-head').innerText()).not.toMatch(/capacity|容量/i); + const gear = page.locator('.model-capacity-trigger').first(); + await gear.scrollIntoViewIfNeeded(); + await gear.focus(); + await page.keyboard.press('Enter'); + const dialog = page.locator('.model-capacity-dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('input').first()).toBeVisible(); + for (const control of await dialog.locator('.model-capacity-fields__control').all()) { + const inputBounds = await control.locator('input').boundingBox(); + const unitBounds = await control.locator('.model-capacity-fields__unit').boundingBox(); + expect(unitBounds!.x).toBeGreaterThan(inputBounds!.x); + expect(unitBounds!.x + unitBounds!.width).toBeLessThan(inputBounds!.x + inputBounds!.width); + } + const bounds = await dialog.boundingBox(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width! + 1); + await shot(page, `editor-${mode}-${width}-${locale}-${theme}`); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(gear).toBeFocused(); + expect(calls.filter(method => method === 'models.capacity.resolve').length).toBeLessThanOrEqual(4); + }); + } +test('draft errors cancel save failure retry restore and keyboard', async ({ page }) => { + test.skip(BASELINE); + const { config, writes, calls, state } = await fixture(page, 'single', 'en', 'light'); + await page.setViewportSize({ width: 1440, height: 900 }); + await ready(page); + const gear = page.locator('.model-capacity-trigger').first(); + await gear.click(); + const dialog = page.locator('.model-capacity-dialog'); + const context = dialog.getByLabel('Context window', { exact: true }), output = dialog.getByLabel('Maximum output length', { exact: true }), done = dialog.getByRole('button', { name: 'Done', exact: true }); + await context.fill('-1'); + await expect(done).toBeDisabled(); + await shot(page, 'invalid-input'); + await context.fill('262144'); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await gear.click(); + await expect(context).toHaveValue(''); + await context.fill('262144'); + await output.fill('65536'); + await done.click(); + await shot(page, 'unsaved'); + state.reject = true; + state.delayed = true; + const save = page.locator('.settings-dirtybar .btn--primary'); + await save.click(); + await expect(save).toBeDisabled(); + expect(writes).toHaveLength(1); + state.release(); + state.delayed = false; + await expect(save).toBeEnabled(); + await gear.click(); + await expect(context).toHaveValue('262144'); + await shot(page, 'save-failed-retained'); + await done.click(); + state.reject = false; + await save.click(); + await expect(save).toBeHidden(); + expect(config.models.custom[MODEL]).toEqual({ context_window: 262144, max_output_tokens: 65536 }); + await gear.click(); + await expect(context).toHaveValue('262144'); + await shot(page, 'manual-saved'); + await dialog.getByRole('button', { name: 'Restore automatic values', exact: true }).click(); + await done.click(); + await save.click(); + await expect(save).toBeHidden(); + expect(config.models.custom[MODEL]).toEqual({}); + expect(calls.some(method => method.includes('configure') || method.includes('activate'))).toBe(false); +}); +test('C3 opens shared lineup without changing routing mode', async ({ page }) => { + test.skip(BASELINE); + const { calls } = await fixture(page, 'router', 'en', 'light'); + await ready(page); + const entry = page.getByTestId('tier-edit-shared-ensemble'); + await entry.click(); + await expect(page.getByTestId('ensemble-panel')).toBeVisible(); + await shot(page, 'router-shared-lineup'); + await page.getByRole('button', { name: 'Back to smart routing' }).click(); + await expect(entry).toBeFocused(); + expect(calls.some(method => method.includes('configure') || method === 'models.routing.set')).toBe(false); +}); +for (const width of [1440, 390]) + for (const provider of ['custom', 'custom_anthropic']) { + test(`provider capacity ${provider} ${width}`, async ({ page }) => { + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); + const { config, writes, calls } = await fixture(page, 'single', 'en', 'light'); + await page.goto('/control/settings/provider'); + const entry = page.locator(`[data-provider-id="${provider}"] .setup-provider-card__select`); + await expect(entry).toBeVisible(); + await entry.click(); + const modal = page.locator('.setup-provider-modal'); + await expect(modal).toBeVisible(); + await shot(page, `provider-${provider}-${width}-collapsed`); + if (BASELINE) + return; + const disclosure = modal.locator('.model-capacity-disclosure'); + await expect(disclosure).not.toHaveAttribute('open'); + await disclosure.locator('summary').click(); + const context = disclosure.getByLabel('Context window', { exact: true }); + await context.scrollIntoViewIfNeeded(); + await expect(context).toBeVisible(); + await shot(page, `provider-${provider}-${width}-expanded`); + await context.fill('262144'); + const save = modal.locator('.setup-provider-modal__footer .btn--primary'); + await expect(save).toBeEnabled(); + await save.click(); + await expect.poll(() => writes.length).toBe(1); + await expect.poll(() => config.models[provider]?.[MODEL]?.context_window).toBe(262144); + expect(config.llm.provider).toBe('custom'); + expect(calls.some(method => method.includes('activate') || method.includes('configure'))).toBe(false); + }); + } +test('capacity error target opens exact provider model editor and catalog is automatic', async ({ page }) => { + test.skip(BASELINE); + const { calls } = await fixture(page, 'router', 'en', 'light'); + await page.goto(`/control/settings/modelStrategy?capacityProvider=custom_anthropic&capacityModel=${encodeURIComponent(MODEL)}`); + const dialog = page.locator('.model-capacity-dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText(`custom_anthropic · ${MODEL}`); + await expect(dialog).toContainText('System default'); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.locator('#settings-section-modelStrategy > .model-capacity-trigger')).toHaveCount(0); + const known = page.locator('.setup-tier-table__row').filter({ has: page.locator(`input[value="${KNOWN}"]`) }); + await known.locator('.model-capacity-trigger').click(); + await expect(dialog).toContainText('Model catalog'); + await shot(page, 'automatic-catalog'); + expect(calls.some(method => method.includes('configure') || method.includes('activate'))).toBe(false); +}); +test('fallback stays compact and configuration file lives in Advanced', async ({ page }) => { + test.skip(BASELINE); + await fixture(page, 'ensemble', 'en', 'light'); + await ready(page); + const fallback = page.getByTestId('setup-model-strategy-fixed-section'); + const input = fallback.locator('input[name="setup_provider_model_strategy_fixed_model"]'); + await expect(fallback).not.toHaveAttribute('open'); + await expect(input).toBeHidden(); + const summary = fallback.locator('summary').first(); + await summary.scrollIntoViewIfNeeded(); + await summary.focus(); + await page.keyboard.press('Enter'); + await expect(input).toBeVisible(); + await shot(page, 'fallback-expanded'); + await summary.click(); + await expect(input).toBeHidden(); + await expect(page.locator('.settings-foot')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Copy config path', exact: true })).toHaveCount(0); + await page.getByRole('tab', { name: /^Advanced/ }).click(); + const file = page.getByTestId('advanced-config-file'); + await expect(file).toBeVisible(); + await expect(file.locator('code')).toContainText('config.toml'); + await file.scrollIntoViewIfNeeded(); + await shot(page, 'advanced-config-file'); +}); + +test('ensemble menu keyboard entry edits capacity without changing the lineup', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + const { config, writes } = await fixture(page, 'ensemble', 'en', 'light'); + await ready(page); + const lineup = JSON.stringify(config.llm_ensemble); + const actions = page.locator('.setup-model-strategy__candidate-actions').first(); + await actions.evaluate(el => el.scrollIntoView({ block: 'center' })); + await shot(page, 'ensemble-lineup'); + if (BASELINE) return; + const summary = actions.locator('summary'); + await summary.focus(); + await page.keyboard.press('Enter'); + await page.keyboard.press('Tab'); + const entry = actions.locator('.model-capacity-menu'); + await expect(entry).toBeFocused(); + await shot(page, 'ensemble-capacity-menu'); + await page.keyboard.press('Enter'); + const dialog = page.locator('.model-capacity-dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByLabel('Context window', { exact: true }).fill('131072'); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + await expect(entry).toBeFocused(); + const inherited = page.getByTestId('ensemble-custom-aggregator-inherited').locator('.model-capacity-trigger'); + await inherited.click(); + await expect(dialog.getByLabel('Context window', { exact: true })).toHaveValue('131072'); + await expect(dialog).toContainText(`custom · ${MODEL}`); + await shot(page, 'ensemble-inherited-editor'); + await page.keyboard.press('Escape'); + await expect(inherited).toBeFocused(); + expect(JSON.stringify(config.llm_ensemble)).toBe(lineup); + expect(writes).toHaveLength(0); +}); diff --git a/opensquilla-webui/e2e/settings-modal.spec.ts b/opensquilla-webui/e2e/settings-modal.spec.ts index cbb7af234a..8245d0daf9 100644 --- a/opensquilla-webui/e2e/settings-modal.spec.ts +++ b/opensquilla-webui/e2e/settings-modal.spec.ts @@ -73,15 +73,14 @@ test.describe('Settings modal', () => { await expect(dialog(page).locator('textarea#cfg-yaml-area')).toHaveCount(0) await expect(dialog(page).getByText('Guided setup')).toHaveCount(0) - // Footer keeps the config.toml escape hatch with a copy affordance. - const foot = dialog(page).locator('.settings-foot') - await expect(foot).toContainText('More options live in') - // Honest restart copy: most edits apply live, only some need a restart. - await expect(foot).toContainText('Most changes apply live; some need a gateway restart') - // The old blanket "always restart" leak is gone. - await expect(foot).not.toContainText('Restart the gateway after manual edits') - await expect(foot.locator('.settings-foot__path')).toContainText(/config.*\.toml/) - await foot.getByRole('button', { name: 'Copy config path' }).click() + // Config details live in Advanced; routine settings keep the footer clear. + await expect(dialog(page).locator('.settings-foot')).toHaveCount(0) + await expect(dialog(page).getByRole('button', { name: 'Copy config path' })).toHaveCount(0) + await railTab(page, 'Advanced').click() + const file = dialog(page).getByTestId('advanced-config-file') + await expect(file).toContainText('Configuration file') + await expect(file.locator('code')).toContainText(/config.*\.toml/) + await file.getByRole('button', { name: 'Copy config path' }).click() await expect(page.locator('.toast', { hasText: /Copied/ }).first()).toBeVisible() }) @@ -98,6 +97,7 @@ test.describe('Settings modal', () => { }) await openFromSidebar(page) + await railTab(page, 'Advanced').click() await dialog(page).getByRole('button', { name: 'Copy config path' }).click() const toast = page.locator('.toast.toast--danger', { hasText: /Copy failed/ }).first() await expect(toast).toBeVisible() diff --git a/opensquilla-webui/src/adapters/gateway/conversationContentV4.ts b/opensquilla-webui/src/adapters/gateway/conversationContentV4.ts index 86f5a1b856..5fd8c49ab6 100644 --- a/opensquilla-webui/src/adapters/gateway/conversationContentV4.ts +++ b/opensquilla-webui/src/adapters/gateway/conversationContentV4.ts @@ -303,6 +303,14 @@ export function projectConversationContent(payload: unknown, kind?: Conversation if (typeof turnId === 'string') result.completedTurnId = turnId.trim() } if (kind === 'turn-failed' || kind === 'task-failed' || kind === 'task-timed-out' || kind === 'task-abandoned') { + const capacity = object(source.model_capacity) + if (typeof capacity.provider === 'string' && capacity.provider.trim() && capacity.provider.length <= 1024 + && typeof capacity.model === 'string' && capacity.model.trim() && capacity.model.length <= 1024 + && Number.isSafeInteger(capacity.contextWindow) && Number(capacity.contextWindow) > 0 + && ['default', 'catalog', 'config', 'override'].includes(String(capacity.source))) { + result.modelCapacity = { provider: capacity.provider, model: capacity.model, + contextWindow: Number(capacity.contextWindow), source: capacity.source as import('@/modules/providerConfiguration').ModelCapacitySource } + } result.terminalOutcome = normalizeTurnOutcome({ ...source, turn_id: source.turn_id ?? source.turnId ?? taskId, diff --git a/opensquilla-webui/src/adapters/gateway/conversationEventsV4.test.ts b/opensquilla-webui/src/adapters/gateway/conversationEventsV4.test.ts index 510e334495..be6b9730c2 100644 --- a/opensquilla-webui/src/adapters/gateway/conversationEventsV4.test.ts +++ b/opensquilla-webui/src/adapters/gateway/conversationEventsV4.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { projectConversationContent } from './conversationContentV4' import { canonicalConversationEventName, conversationSemanticEventKind, @@ -26,6 +27,23 @@ function fixture(name: string): FixtureDocument { } describe('conversation event v4 Adapter', () => { + it('projects validated capacity provenance on failures only and rejects malformed targets', () => { + const model_capacity = { provider: 'custom', model: 'example.vendor/model.v1:latest', contextWindow: 8192, source: 'default' } + expect(projectConversationContent({ model_capacity }, 'turn-failed').modelCapacity).toEqual(model_capacity) + expect(projectConversationContent({ model_capacity }, 'text-delta').modelCapacity).toBeUndefined() + expect(projectConversationContent({ model_capacity: { ...model_capacity, model: ' ' } }, 'turn-failed').modelCapacity).toBeUndefined() + expect(projectConversationContent({ model_capacity: { ...model_capacity, contextWindow: -1 } }, 'turn-failed').modelCapacity).toBeUndefined() + }) + it('preserves task timeout identity and capacity provenance together', () => { + const model_capacity = { provider: 'custom', model: 'example/model.v1:latest', contextWindow: 8192, source: 'default' } + const projected = projectConversationContent({ + task_id: 'capacity-task', turn_id: 'capacity-turn', model_capacity, + }, 'task-timed-out') + expect(projected.modelCapacity).toEqual(model_capacity) + expect(projected.terminalOutcome).toMatchObject({ + turnId: 'capacity-turn', status: 'timeout', statusSource: 'task', + }) + }) it('decodes every valid canonical, legacy, and future event fixture', () => { for (const testCase of fixture('events.json').cases) { const wire = testCase.wire as Record diff --git a/opensquilla-webui/src/adapters/gateway/providerConfigurationV4.ts b/opensquilla-webui/src/adapters/gateway/providerConfigurationV4.ts index 260741a663..7ecfa08781 100644 --- a/opensquilla-webui/src/adapters/gateway/providerConfigurationV4.ts +++ b/opensquilla-webui/src/adapters/gateway/providerConfigurationV4.ts @@ -1,5 +1,7 @@ import type { TransportCallOptions as RpcCallOptions } from './transportTypes' import { readTransportFailure } from './transportTypes' +import { MODELS_CAPACITY_RESOLVE_METHOD } from '@/contracts/generated/v4/modelsCapacityResolve' +import { validateParams as validateCapacityParams, validateResult as validateCapacityResult } from '@/contracts/generated/v4/modelsCapacityResolveValidators.mjs' import type { ModelCatalogResult, ModelDescriptor, @@ -211,6 +213,19 @@ export function createV4ProviderConfiguration( events: EventTransport, ): ProviderConfiguration { return { + get capacitySupported() { + return rpc.supports?.(MODELS_CAPACITY_RESOLVE_METHOD) === true + }, + async resolveCapacity(models) { + if (rpc.supports?.(MODELS_CAPACITY_RESOLVE_METHOD) !== true) { + throw new ProviderConfigurationError('unsupported', 'Model capacity requires a newer Gateway.') + } + const params = { models: models.map(({ provider, model }) => ({ provider, model })) } + if (!validateCapacityParams(params)) throw new ProviderConfigurationError('invalid', 'Invalid model capacity targets') + const result = await requestProvider(rpc, MODELS_CAPACITY_RESOLVE_METHOD, params, options()) + if (!validateCapacityResult(result)) throw new Error('Invalid model capacity response') + return result as { models: import('@/modules/providerConfiguration').ModelCapacity[] } + }, get resetRecommendedSupported() { return rpc.supports?.(MODELS_ROUTING_RESET_RECOMMENDED_METHOD) === true }, diff --git a/opensquilla-webui/src/components/chat/SystemMessage.resume.test.ts b/opensquilla-webui/src/components/chat/SystemMessage.resume.test.ts index 43b3c14101..3d8f63b29d 100644 --- a/opensquilla-webui/src/components/chat/SystemMessage.resume.test.ts +++ b/opensquilla-webui/src/components/chat/SystemMessage.resume.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' import { createApp, nextTick } from 'vue' +import { createMemoryHistory, createRouter } from 'vue-router' import i18n from '@/i18n' import type { ChatRenderedMessage } from '@/types/chat' import { normalizeTurnOutcome } from '@/utils/chat/turnOutcome' @@ -42,6 +43,10 @@ async function mountMsg( retryAvailable, }) app.use(i18n) + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: { render: () => null } }] }) + app.use(router) + await router.push('/') + await router.isReady() app.mount(el) await nextTick() return { app, el } @@ -53,6 +58,36 @@ beforeEach(() => { }) describe('SystemMessage sandbox resume', () => { + it('links a capacity failure to the exact provider and punctuation-containing model without retrying', async () => { + const onRetry = vi.fn() + const target = { provider: 'custom_anthropic', model: 'example.vendor/model.v1:latest', contextWindow: 8192, source: 'default' as const } + const { app, el } = await mountMsg(errorMessage({ errorCode: 'provider_request_too_large', modelCapacity: target }), undefined, onRetry, true) + const href = el.querySelector('.msg-error__capacity')?.getAttribute('href') + const url = new URL(href!, 'https://capacity.invalid') + expect(url.pathname).toBe('/settings/modelStrategy') + expect(url.searchParams.get('capacityProvider')).toBe(target.provider) + expect(url.searchParams.get('capacityModel')).toBe(target.model) + expect(el.querySelector('button')).toBeNull() + expect(onRetry).not.toHaveBeenCalled() + app.unmount() + }) + + it('preserves capacity guidance alongside a diagnostic action without provider replay', async () => { + const onRetry = vi.fn() + const text = 'The request exceeds the system default of 8,192 tokens.' + const { app, el } = await mountMsg(errorMessage({ + text, errorCode: 'provider_request_budget_exhausted', turnId: 'capacity-turn', + modelCapacity: { provider: 'custom', model: 'example/model.v1:latest', contextWindow: 8192, source: 'default' }, + turnOutcome: { turnId: 'capacity-turn', status: 'failed', failureKind: 'context_overflow', errorId: 'abcdef01', retryable: true }, + }), undefined, onRetry, true) + expect(el.querySelector('.msg-error__text')?.textContent).toBe(text) + expect(el.querySelector('.msg-error__capacity')).not.toBeNull() + expect(el.querySelector('.msg-error__copy')).not.toBeNull() + expect(el.querySelector('.msg-error__resume')).toBeNull() + expect(onRetry).not.toHaveBeenCalled() + app.unmount() + }) + it('keeps lifecycle timeout text ahead of a preserved provider classification', async () => { const { app, el } = await mountMsg(errorMessage({ text: 'The task timed out before it could finish.', errorCode: '429', turnId: 't', diff --git a/opensquilla-webui/src/components/chat/SystemMessage.vue b/opensquilla-webui/src/components/chat/SystemMessage.vue index f8f8375c9e..2630bc72df 100644 --- a/opensquilla-webui/src/components/chat/SystemMessage.vue +++ b/opensquilla-webui/src/components/chat/SystemMessage.vue @@ -3,7 +3,11 @@ @@ -1796,6 +1842,8 @@ function credentialLabel(candidate: EnsembleCandidateView): string { .setup-model-strategy__page-head .control-section__desc { flex: none; + max-width: 100%; + overflow-wrap: anywhere; } .setup-model-strategy__page-meta { @@ -1949,6 +1997,20 @@ function credentialLabel(candidate: EnsembleCandidateView): string { margin-top: var(--sp-2); } +.setup-model-strategy__detail--secondary { border-top: 1px solid var(--border); } +.setup-model-strategy__fallback-summary { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) 0; list-style: none; cursor: pointer; min-width: 0; } +.setup-model-strategy__fallback-summary::-webkit-details-marker { display: none; } +.setup-model-strategy__fallback-label { color: var(--text); font-size: var(--fs-sm); flex-shrink: 0; } +.setup-model-strategy__fallback-target { color: var(--text-muted); font-size: var(--fs-xs); min-width: 0; flex: 1; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; } +.setup-model-strategy__fallback-chevron { color: var(--text-muted); flex-shrink: 0; } +.setup-model-strategy__detail--secondary[open] > summary .setup-model-strategy__fallback-chevron { transform: rotate(180deg); } +.setup-model-strategy__fallback-summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.setup-model-strategy__fixed-fields { min-width: 0; } +@container model-strategy-panel (max-width: 520px) { + .setup-model-strategy__fallback-summary { flex-wrap: wrap; } + .setup-model-strategy__fallback-target { flex-basis: calc(100% - 120px); } +} + .setup-model-strategy__single-provider { align-items: center; display: flex; @@ -2231,20 +2293,8 @@ function credentialLabel(candidate: EnsembleCandidateView): string { border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); } -.setup-model-strategy__candidate-main { - display: grid; - gap: 2px; - min-width: 0; -} -.setup-model-strategy__candidate-label { - overflow-wrap: anywhere; -} -.setup-model-strategy__candidate-source { - color: var(--text-muted); - font-size: var(--fs-xs); -} .setup-model-strategy__credential { color: var(--text-muted); @@ -2533,9 +2583,9 @@ function credentialLabel(candidate: EnsembleCandidateView): string { .setup-model-strategy__step-number { align-items: center; - background: var(--accent); + background: var(--bg-hover); border-radius: var(--radius-full); - color: var(--accent-foreground); + color: var(--text-muted); display: inline-flex; flex: 0 0 auto; font-size: var(--fs-xs); @@ -2609,19 +2659,13 @@ function credentialLabel(candidate: EnsembleCandidateView): string { display: block; } -.setup-model-strategy__candidate-list--aggregator { - background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface-2)); - border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); - box-shadow: inset 3px 0 0 var(--accent); -} - .setup-model-strategy__candidate { background: transparent; border: 0; border-bottom: 1px solid var(--border); border-radius: 0; display: flex; - gap: var(--sp-3); + gap: var(--sp-2); min-height: 2.85rem; padding: 8px var(--sp-3); position: relative; @@ -2635,16 +2679,6 @@ function credentialLabel(candidate: EnsembleCandidateView): string { border-style: none; } -.setup-model-strategy__candidate-label, -.setup-model-strategy__candidate-main { - min-width: 0; -} - -.setup-model-strategy__candidate-label { - flex: 1 1 auto; - font-size: var(--fs-sm); - line-height: 1.35; -} .setup-model-strategy__credential { align-items: center; @@ -2728,7 +2762,7 @@ function credentialLabel(candidate: EnsembleCandidateView): string { .setup-model-strategy__add-trigger { align-items: center; background: transparent; - border: 1px dashed var(--border-strong); + border: 1px solid transparent; border-radius: var(--radius-md); color: var(--text-muted); cursor: pointer; @@ -2738,7 +2772,8 @@ function credentialLabel(candidate: EnsembleCandidateView): string { gap: var(--sp-2); justify-content: center; min-height: 2.3rem; - width: 100%; + justify-self: start; + padding: 0 var(--sp-2); } .setup-model-strategy__add-trigger:not(:disabled):hover { @@ -2809,7 +2844,7 @@ function credentialLabel(candidate: EnsembleCandidateView): string { .setup-model-strategy__replace-aggregator { background: transparent; border: 0; - color: var(--accent-hover); + color: var(--text-muted); cursor: pointer; flex: 0 0 auto; font: inherit; @@ -2861,12 +2896,34 @@ function credentialLabel(candidate: EnsembleCandidateView): string { .setup-model-strategy__facts { align-items: center; - background: var(--bg-surface-2); - border: 1px solid transparent; + background: transparent; + border: 0; display: flex; gap: var(--sp-2); - min-height: 2.5rem; - padding: 8px var(--sp-3); + margin: 0; + padding: var(--sp-1) 0; +} + +.setup-model-strategy__hint { + color: var(--text-muted); + font-size: var(--fs-xs); + line-height: 1.5; + margin: 0; +} + +.setup-model-strategy__card-badge { + color: var(--text-muted); + font-size: var(--fs-xs); + font-weight: 400; + white-space: nowrap; +} + +.setup-model-strategy__credential.is-ready { font-size: 0; margin-left: 0; } + + +@container model-strategy-panel (max-width: 520px) { + .setup-model-strategy__candidate { flex-wrap: wrap; justify-content: flex-end; } + .setup-model-strategy__candidate > :deep(.setup-model-identity) { flex-basis: 100%; } } .setup-model-strategy__runtime { diff --git a/opensquilla-webui/src/components/setup/SetupProviderPanel.vue b/opensquilla-webui/src/components/setup/SetupProviderPanel.vue index 9afa57be87..043abb157e 100644 --- a/opensquilla-webui/src/components/setup/SetupProviderPanel.vue +++ b/opensquilla-webui/src/components/setup/SetupProviderPanel.vue @@ -8,6 +8,7 @@ import SetupNeedList from '@/components/SetupNeedList.vue' import SetupCommandBlock from '@/components/setup/SetupCommandBlock.vue' import SetupProviderCredentialCard from '@/components/setup/SetupProviderCredentialCard.vue' import SetupProviderRecommendation from '@/components/setup/SetupProviderRecommendation.vue' +import SetupModelCapacity from '@/components/setup/SetupModelCapacity.vue' import SetupModelCombobox from '@/components/setup/SetupModelCombobox.vue' import SetupProviderCatalogDialog from '@/components/setup/SetupProviderCatalogDialog.vue' import SetupProviderMenu, { type ProviderMenuItem } from '@/components/setup/SetupProviderMenu.vue' @@ -1335,6 +1336,12 @@ const tokenRhythmCredentialReplacementRequired = computed(() => ( @update="(name, val) => emit('updateProviderField', name, val)" /> + diff --git a/opensquilla-webui/src/components/setup/SetupTierTable.vue b/opensquilla-webui/src/components/setup/SetupTierTable.vue index fb3919ee58..1f864dd91a 100644 --- a/opensquilla-webui/src/components/setup/SetupTierTable.vue +++ b/opensquilla-webui/src/components/setup/SetupTierTable.vue @@ -11,6 +11,7 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import Icon from '@/components/Icon.vue' +import SetupModelCapacity from '@/components/setup/SetupModelCapacity.vue' import SetupModelCombobox from '@/components/setup/SetupModelCombobox.vue' import type { RouterProviderRoles, @@ -62,6 +63,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ updateTierField: [name: string, key: 'provider' | 'model' | 'thinkingLevel' | 'ensembleEnabled' | 'ensembleSelectionMode', value: string | boolean] migrateLegacyEnsemble: [] + editEnsemble: [] }>() const THINKING_LEVELS = ['', 'off', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh'] @@ -760,6 +762,10 @@ const allowsFloatingContent = computed(() => ( } : undefined" @update="(val) => updateModelChoice(tier, val)" /> + + ( } @media (max-width: 760px) { + .setup-tier-table--open { + overflow-x: auto; + } .setup-tier-table--without-provider .setup-tier-table__row { min-width: 460px; } diff --git a/opensquilla-webui/src/composables/chat/useChatRenderedMessages.ts b/opensquilla-webui/src/composables/chat/useChatRenderedMessages.ts index 43c234970b..6aeb9b9409 100644 --- a/opensquilla-webui/src/composables/chat/useChatRenderedMessages.ts +++ b/opensquilla-webui/src/composables/chat/useChatRenderedMessages.ts @@ -647,6 +647,7 @@ export function useChatRenderedMessages(options: UseChatRenderedMessagesOptions) provenanceSourceTool: msg.provenanceSourceTool, stopNotice: msg.stopNotice, errorCode: msg.errorCode, + modelCapacity: msg.modelCapacity, } // Additive: derive discriminated parts from the finished rendered // object so they cannot drift from the fields the components read. Only diff --git a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts index 192939cd21..3e2cf64534 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -2441,6 +2441,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) turnOutcome?.status, ), errorCode, + modelCapacity: rawPayload.modelCapacity, turnId: terminalTurnId || undefined, turnOutcome, terminalNotice: true, diff --git a/opensquilla-webui/src/composables/setup/useModelCapacityForm.test.ts b/opensquilla-webui/src/composables/setup/useModelCapacityForm.test.ts new file mode 100644 index 0000000000..cd33a4e9bb --- /dev/null +++ b/opensquilla-webui/src/composables/setup/useModelCapacityForm.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import type { ModelCapacity, ModelCapacityTarget, ProviderConfiguration } from '@/modules/providerConfiguration' +import { capacityKey, parseCapacity, useModelCapacityForm } from './useModelCapacityForm' + +export const target = { provider: 'custom', model: 'example.vendor/unknown.v1:latest' } +export function capacityRow(item: ModelCapacityTarget = target): ModelCapacity { + return { ...item, localRuntime: false, + contextWindow: { automatic: 8192, automaticSource: 'default', override: null, value: 8192, source: 'default', editable: true }, + maxOutputTokens: { automatic: 8192, automaticSource: 'default', override: null, value: 8192, source: 'default', editable: true }, + } +} +const flush = async () => { await Promise.resolve(); await Promise.resolve(); await nextTick() } +function form() { + const resolveCapacity = vi.fn(async (items: readonly ModelCapacityTarget[]) => ({ models: items.map(capacityRow) })) + const api = useModelCapacityForm({ capacitySupported: true, resolveCapacity } as unknown as ProviderConfiguration) + return { api, resolveCapacity } +} + +describe('model capacity drafts', () => { + it('batches and deduplicates reads while keeping providers isolated', async () => { + const { api, resolveCapacity } = form() + api.ensure(target); api.ensure({ ...target }); api.ensure({ ...target, provider: 'custom_anthropic' }) + await flush() + expect(resolveCapacity).toHaveBeenCalledTimes(1) + expect(resolveCapacity.mock.calls[0]![0]).toHaveLength(2) + expect(api.rows.size).toBe(2) + }) + it('does not probe old gateways and retries failed reads explicitly', async () => { + const { api, resolveCapacity } = form() + resolveCapacity.mockRejectedValueOnce(new Error('offline')) + api.ensure(target); await flush() + expect(api.failed.has(capacityKey(target))).toBe(true) + api.ensure(target); await flush() + expect(api.rows.size).toBe(1) + const old = useModelCapacityForm({ resolveCapacity } as unknown as ProviderConfiguration) + old.ensure(target); await flush() + expect(resolveCapacity).toHaveBeenCalledTimes(2) + }) + it('shows a retryable error when a partial response omits a requested model', async () => { + const { api, resolveCapacity } = form() + resolveCapacity.mockResolvedValueOnce({ models: [] }) + api.ensure(target); await flush() + expect(api.pending.size).toBe(0) + expect(api.failed.has(capacityKey(target))).toBe(true) + api.ensure(target); await flush() + expect(api.rows.has(capacityKey(target))).toBe(true) + }) + it('shares drafts, retains other models and persists punctuation as literal keys', async () => { + const { api } = form() + const other = { ...target, model: '__proto__' } + api.ensure(target); api.ensure(other); await flush() + api.update(target, { contextWindow: '262144', maxOutputTokens: '65536' }, 'modelStrategy') + api.update(other, { contextWindow: '32000', maxOutputTokens: '' }, 'modelStrategy') + expect(api.values({ ...target }).contextWindow).toBe('262144') + const patch = api.patch('modelStrategy')! + expect(JSON.parse(JSON.stringify(patch))).toEqual({ models: { custom: { + [target.model]: { context_window: 262144, max_output_tokens: 65536 }, + ['__proto__']: { context_window: 32000 }, + } } }) + expect(Object.getPrototypeOf({})).not.toHaveProperty('context_window') + }) + it('retains failed scopes and acknowledges only successful writes', async () => { + const { api } = form() + const other = { ...target, provider: 'custom_anthropic' } + api.ensure(target); api.ensure(other); await flush() + api.update(target, { contextWindow: '32000', maxOutputTokens: '' }, 'provider:custom') + api.update(other, { contextWindow: '64000', maxOutputTokens: '' }, 'modelStrategy') + await api.save('provider:custom', vi.fn(async () => true)) + await expect(api.save('modelStrategy', vi.fn(async () => { throw new Error('disk full') }))).rejects.toThrow('disk full') + expect(api.dirty('provider:custom')).toBe(false) + expect(api.values(other).contextWindow).toBe('64000') + expect(api.dirty('modelStrategy')).toBe(true) + }) + it('restores only capacity fields and retains invalid inputs until corrected', async () => { + const { api } = form() + api.rows.set(capacityKey(target), { ...capacityRow(), contextWindow: { ...capacityRow().contextWindow, override: 32000, value: 32000, source: 'override' } }) + api.update(target, { contextWindow: '-5', maxOutputTokens: '' }, 'modelStrategy') + expect(api.valid('modelStrategy')).toBe(false) + expect(api.values(target).contextWindow).toBe('-5') + expect(() => api.patch('modelStrategy')).toThrow() + api.update(target, { contextWindow: '', maxOutputTokens: '' }, 'modelStrategy') + expect(api.patch('modelStrategy')).toEqual({ models: { custom: { [target.model]: { context_window: null } } } }) + api.discard('modelStrategy') + expect(api.values(target).contextWindow).toBe('32000') + }) + it('cannot override a server-controlled output limit', async () => { + const { api } = form() + const row = capacityRow({ provider: 'openai_codex', model: 'gpt-example' }) + row.maxOutputTokens.editable = false + api.rows.set(capacityKey(row), row) + api.update(row, { contextWindow: '131072', maxOutputTokens: '1000' }, 'modelStrategy') + expect(api.patch('modelStrategy')).toEqual({ models: { openai_codex: { 'gpt-example': { context_window: 131072 } } } }) + }) + it('rejects stale reads after endpoint or save invalidation', async () => { + const { api, resolveCapacity } = form() + let finish!: (value: { models: ModelCapacity[] }) => void + resolveCapacity.mockReturnValueOnce(new Promise(resolve => { finish = resolve })) + api.ensure(target); await Promise.resolve() + api.invalidate(); finish({ models: [capacityRow()] }); await flush() + expect(api.rows.size).toBe(0) + api.ensure(target); await flush() + expect(api.rows.size).toBe(1) + }) + it.each(['0', '-1', '1.5', '2e5', 'Infinity', 'NaN', '9007199254740992'])('rejects invalid value %s', value => { + expect(() => parseCapacity(value)).toThrow() + }) +}) diff --git a/opensquilla-webui/src/composables/setup/useModelCapacityForm.ts b/opensquilla-webui/src/composables/setup/useModelCapacityForm.ts new file mode 100644 index 0000000000..6b12b2a6c6 --- /dev/null +++ b/opensquilla-webui/src/composables/setup/useModelCapacityForm.ts @@ -0,0 +1,128 @@ +import { computed, reactive, ref, type InjectionKey } from 'vue' +import type { ModelCapacity, ModelCapacityTarget, ProviderConfiguration } from '@/modules/providerConfiguration' + +export type CapacityField = 'contextWindow' | 'maxOutputTokens' +export interface CapacityValues { contextWindow: string; maxOutputTokens: string } +interface Draft { target: ModelCapacityTarget; values: CapacityValues; baseline: CapacityValues; scope: string } +export const capacityKey = (target: ModelCapacityTarget) => JSON.stringify([target.provider.trim().toLowerCase(), target.model.trim()]) +export function parseCapacity(value: string): number | null { + if (!value.trim()) return null + if (!/^\d+$/.test(value.trim())) throw new Error('positiveInteger') + const number = Number(value) + if (!Number.isSafeInteger(number) || number < 1) throw new Error('positiveInteger') + return number +} +const fields: CapacityField[] = ['contextWindow', 'maxOutputTokens'] +const wire = { contextWindow: 'context_window', maxOutputTokens: 'max_output_tokens' } as const +const same = (a: CapacityValues, b: CapacityValues) => fields.every(field => a[field] === b[field]) + +export function useModelCapacityForm(provider: ProviderConfiguration) { + const rows = reactive(new Map()) + const drafts = reactive(new Map()) + const pending = reactive(new Set()) + const failed = reactive(new Set()) + const supported = computed(() => provider.capacitySupported === true && Boolean(provider.resolveCapacity)) + const generation = ref(0) + const queued = new Map() + let scheduled = false + + function ensure(target: ModelCapacityTarget) { + const normalized = { provider: target.provider.trim().toLowerCase(), model: target.model.trim() } + const key = capacityKey(normalized) + if (!supported.value || !normalized.provider || !normalized.model || rows.has(key) || pending.has(key)) return + pending.add(key) + failed.delete(key) + queued.set(key, normalized) + if (scheduled) return + scheduled = true + queueMicrotask(async () => { + scheduled = false + const batch = [...queued.entries()] + queued.clear() + const epoch = generation.value + try { + for (let offset = 0; offset < batch.length; offset += 128) { + const result = await provider.resolveCapacity!(batch.slice(offset, offset + 128).map(([, item]) => item)) + if (epoch !== generation.value) return + const requested = new Set(batch.slice(offset, offset + 128).map(([key]) => key)) + for (const row of result.models) { + const key = capacityKey(row) + if (requested.delete(key)) rows.set(key, row) + } + for (const key of requested) failed.add(key) + } + } catch { + if (epoch === generation.value) for (const [key] of batch) failed.add(key) + } finally { + if (epoch === generation.value) for (const [key] of batch) pending.delete(key) + } + }) + } + function values(target: ModelCapacityTarget): CapacityValues { + const key = capacityKey(target) + const draft = drafts.get(key) + if (draft) return { ...draft.values } + const row = rows.get(key) + return { + contextWindow: row?.contextWindow.override == null ? '' : String(row.contextWindow.override), + maxOutputTokens: row?.maxOutputTokens.override == null ? '' : String(row.maxOutputTokens.override), + } + } + function update(target: ModelCapacityTarget, next: CapacityValues, scope: string) { + const key = capacityKey(target) + const row = rows.get(key) + if (!row) return + const safe = { ...next } + for (const field of fields) if (!row[field].editable) safe[field] = values(target)[field] + const baseline = drafts.get(key)?.baseline || values(target) + if (same(safe, baseline)) drafts.delete(key) + else drafts.set(key, { target: { provider: target.provider.trim().toLowerCase(), model: target.model.trim() }, values: safe, baseline, scope }) + } + const dirty = (scope: string) => [...drafts.values()].some(draft => draft.scope === scope) + function valid(scope: string) { + try { + for (const draft of drafts.values()) if (draft.scope === scope) fields.forEach(field => parseCapacity(draft.values[field])) + return true + } catch { return false } + } + function discard(scope: string) { + for (const [key, draft] of drafts) if (draft.scope === scope) drafts.delete(key) + } + function captureProviderDrafts(providerId: string): () => void { + const provider = providerId.trim().toLowerCase() + const snapshot = new Map([...drafts].filter(([, draft]) => draft.target.provider === provider)) + return () => { + for (const [key, draft] of drafts) if (draft.target.provider === provider) drafts.delete(key) + for (const [key, draft] of snapshot) drafts.set(key, draft) + } + } + function patch(scope: string): Record | null { + const models: Record>> = Object.create(null) + for (const draft of drafts.values()) { + if (draft.scope !== scope) continue + const changes: Record = {} + for (const field of fields) if (draft.values[field] !== draft.baseline[field]) changes[wire[field]] = parseCapacity(draft.values[field]) + ;(models[draft.target.provider] ||= Object.create(null))[draft.target.model] = changes + } + return Object.keys(models).length ? { models } : null + } + async function save(scope: string, persist: (patch: Record) => Promise) { + const changes = patch(scope) + if (!changes) return false + const snapshot = [...drafts.entries()].filter(([, draft]) => draft.scope === scope) + await persist(changes) + for (const [key, draft] of snapshot) { + if (drafts.get(key) === draft) drafts.delete(key) + rows.delete(key) + ensure(draft.target) + } + return true + } + function invalidate() { + generation.value++ + rows.clear(); pending.clear(); failed.clear(); queued.clear() + } + return { rows, drafts, pending, failed, supported, generation, ensure, values, update, dirty, valid, discard, captureProviderDrafts, patch, save, invalidate } +} +export type ModelCapacityForm = ReturnType +export const MODEL_CAPACITY_KEY: InjectionKey = Symbol('ModelCapacityForm') diff --git a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts index 771ce10c88..f8c615814a 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts @@ -192,6 +192,12 @@ async function mountCatalog() { }, } as unknown as import('@/modules/setupWorkflow').SetupWorkflow) app.provide(PROVIDER_CONFIGURATION_KEY, { + capacitySupported: true, + resolveCapacity: async (models: readonly {provider: string; model: string}[]) => ({models: models.map(target => ({ + ...target, localRuntime: false, + contextWindow: { automatic: 8192, automaticSource: 'default', override: null, value: 8192, source: 'default', editable: true }, + maxOutputTokens: { automatic: 16384, automaticSource: 'default', override: null, value: 16384, source: 'default', editable: true }, + }))}), catalog: async () => [], list: async () => ({ models: [], errors: [] }), status: async () => ({ activeProvider: null, providerResolution: {}, providers: [], count: 0 }), @@ -7128,3 +7134,126 @@ describe('recommended Router reset and activation safety', () => { app.unmount() }) }) + + +describe('capacity save integration', () => { + it.each(['131072', ''])('restores the prior routing draft when provider editing is cancelled after entering %s', async value => { + const { api, app } = await primaryTransitionScenario() + try { + const target = { provider: 'openrouter', model: 'example.vendor/model.v1:latest' } + api.modelCapacity.ensure(target) + await vi.waitFor(() => expect(api.modelCapacity.rows.size).toBeGreaterThan(0)) + api.modelCapacity.update(target, { contextWindow: '65536', maxOutputTokens: '' }, 'modelStrategy') + await api.requestSelectConfiguredProvider('openrouter') + api.modelCapacity.update(target, { contextWindow: value, maxOutputTokens: '' }, 'provider:openrouter') + api.cancelProviderEdit() + expect(api.modelCapacity.values(target).contextWindow).toBe('65536') + expect(api.modelCapacity.dirty('modelStrategy')).toBe(true) + expect(api.modelCapacity.dirty('provider:openrouter')).toBe(false) + } finally { app.unmount() } + }) + it('does not restore a routing draft after its provider edit was saved', async () => { + const { api, app } = await primaryTransitionScenario() + try { + const target = { provider: 'openrouter', model: 'example.vendor/model.v1:latest' } + api.modelCapacity.ensure(target) + await vi.waitFor(() => expect(api.modelCapacity.rows.size).toBeGreaterThan(0)) + api.modelCapacity.update(target, { contextWindow: '65536', maxOutputTokens: '' }, 'modelStrategy') + await api.requestSelectConfiguredProvider('openrouter') + api.modelCapacity.update(target, { contextWindow: '131072', maxOutputTokens: '' }, 'provider:openrouter') + expect(await api.saveProvider({ reload: false })).toBe(true) + api.cancelProviderEdit() + expect(api.modelCapacity.dirty('modelStrategy')).toBe(false) + } finally { app.unmount() } + }) + it('identifies an unsaved routing panel style separately from saved model tiers', async () => { + const scenario = await primaryTransitionScenario(false, (method, params) => { + if (method === 'onboarding.router.configure') { + scenario.saved.squilla_router.tiers.c0.model = String((params?.tiers as Record).c0!.model) + return { changed: true } + } + if (method === 'config.patch.safe') throw new Error('synthetic style save failure') + return { changed: true } + }) + const { api, app } = scenario + try { + const target = { provider: 'openrouter', model: 'example.vendor/model.v1:latest' } + api.modelCapacity.ensure(target) + await vi.waitFor(() => expect(api.modelCapacity.rows.size).toBeGreaterThan(0)) + api.updateTierField('c0', 'model', 'saved-new-route') + api.setRouterVisualMode('legacy_grid') + api.modelCapacity.update(target, { contextWindow: '65536', maxOutputTokens: '' }, 'modelStrategy') + expect(await api.saveModelStrategy()).toBe(false) + expect(pushToast).toHaveBeenCalledWith(expect.stringContaining('Saved: Intelligent model routing. Not saved: Routing decision panel style, Model settings.'), { tone: 'danger' }) + expect(api.modelStrategyPanel.value.router.routerVisualMode).toBe('legacy_grid') + expect(api.modelCapacity.values(target).contextWindow).toBe('65536') + rpcCall.mockClear() + await api.saveModelStrategy({ reload: false }) + expect(rpcCall.mock.calls.some(([method]) => method === 'onboarding.router.configure')).toBe(false) + expect(rpcCall.mock.calls.some(([method]) => method === 'config.patch.safe')).toBe(true) + } finally { app.unmount() } + }) + it('rebases acknowledged routing while retaining a rejected capacity draft', async () => { + const scenario = await primaryTransitionScenario(false, (method, params) => { + if (method === 'onboarding.router.configure') { + scenario.saved.squilla_router.tiers.c0.model = String((params?.tiers as Record).c0!.model) + return { changed: true } + } + if (method === 'config.patch' && params?.patch) throw new Error('synthetic capacity save failure') + return { changed: true } + }) + const { api, app } = scenario + try { + const target = { provider: 'openrouter', model: 'example.vendor/model.v1:latest' } + api.modelCapacity.ensure(target) + await vi.waitFor(() => expect(api.modelCapacity.rows.size).toBeGreaterThan(0)) + api.updateTierField('c0', 'model', 'saved-new-route') + api.modelCapacity.update(target, { contextWindow: '65536', maxOutputTokens: '' }, 'modelStrategy') + expect(await api.saveModelStrategy()).toBe(false) + expect(api.modelStrategyPanel.value.router.tierRows[0]?.model).toBe('saved-new-route') + expect(api.modelCapacity.values(target).contextWindow).toBe('65536') + expect(pushToast).toHaveBeenCalledWith(expect.stringContaining('Saved: Intelligent model routing. Not saved: Model settings.'), { tone: 'danger' }) + rpcCall.mockClear() + await api.saveModelStrategy({ reload: false }) + expect(rpcCall.mock.calls.some(([method]) => method === 'onboarding.router.configure')).toBe(false) + } finally { app.unmount() } + }) + it.each(['provider', 'profile', 'modelStrategy'] as const)('saves capacity-only work through %s without activation or connection validation', async location => { + const { api, app } = await primaryTransitionScenario() + try { + if (location === 'profile') await api.selectConfiguredProvider('tokenrhythm') + const provider = location === 'profile' ? 'tokenrhythm' : 'openrouter' + const target = { provider, model: 'example.vendor/model.v1:latest' } + api.modelCapacity.ensure(target) + await Promise.resolve(); await Promise.resolve(); await nextTick() + const scope = location === 'modelStrategy' ? 'modelStrategy' : `provider:${provider}` + api.modelCapacity.update(target, { contextWindow: '262144', maxOutputTokens: '65536' }, scope) + rpcCall.mockClear() + const saved = location === 'modelStrategy' ? await api.saveModelStrategy({ reload: false }) : await api.saveProvider({ reload: false }) + expect(saved).toBe(true) + expect(rpcCall.mock.calls.filter(([method]) => method === 'config.patch')).toEqual([['config.patch', { + patch: { models: { [provider]: { [target.model]: { context_window: 262144, max_output_tokens: 65536 } } } }, + }]]) + expect(rpcCall.mock.calls.some(([method]) => /configure|activate|probe/.test(method))).toBe(false) + expect(api.modelCapacity.dirty(scope)).toBe(false) + } finally { app.unmount() } + }) + it('retains a rejected capacity patch and submits no second write for invalid values', async () => { + const { api, app } = await primaryTransitionScenario(false, method => { + if (method === 'config.patch') throw new Error('synthetic disk failure') + return { changed: true } + }) + try { + const target = { provider: 'openrouter', model: 'example.v1/model:latest' } + api.modelCapacity.ensure(target) + await Promise.resolve(); await Promise.resolve(); await nextTick() + api.modelCapacity.update(target, { contextWindow: '65536', maxOutputTokens: '' }, 'modelStrategy') + expect(await api.saveModelStrategy({ reload: false })).toBe(false) + expect(api.modelCapacity.values(target).contextWindow).toBe('65536') + api.modelCapacity.update(target, { contextWindow: '-1', maxOutputTokens: '' }, 'modelStrategy') + rpcCall.mockClear() + expect(await api.saveModelStrategy({ reload: false })).toBe(false) + expect(rpcCall).not.toHaveBeenCalled() + } finally { app.unmount() } + }) +}) diff --git a/opensquilla-webui/src/composables/setup/useSetupCatalog.ts b/opensquilla-webui/src/composables/setup/useSetupCatalog.ts index 81d0cc8ab8..7ec97a69cb 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.ts @@ -1,5 +1,6 @@ -import { inject, ref, computed, onMounted, onUnmounted, watch } from 'vue' +import { inject, provide, ref, computed, onMounted, onUnmounted, watch } from 'vue' import i18n from '@/i18n' +import { MODEL_CAPACITY_KEY, useModelCapacityForm } from './useModelCapacityForm' import { useSetupCapabilitiesForm } from '@/composables/setup/useSetupCapabilitiesForm' import { useSetupBehaviorForm } from '@/composables/setup/useSetupBehaviorForm' import { @@ -486,6 +487,9 @@ const setupWorkflow: SetupWorkflow = injectedSetupWorkflow const injectedProviderConfiguration = inject(PROVIDER_CONFIGURATION_KEY) if (!injectedProviderConfiguration) throw new Error('ProviderConfiguration was not provided') const providerConfiguration: ProviderConfiguration = injectedProviderConfiguration +const modelCapacity = useModelCapacityForm(providerConfiguration) +provide(MODEL_CAPACITY_KEY, modelCapacity) +let restoreProviderCapacityDrafts: (() => void) | null = null const { pushToast } = useToasts() const { confirm } = useConfirm() const t = i18n.global.t @@ -546,6 +550,14 @@ const capabilitiesForm = useSetupCapabilitiesForm() const promotedForm = useSettingsPromotedForm() const tierModelCatalogs = ref({}) +// Discovery may populate the runtime catalog after the first capacity read. +// Re-read visible targets only; this watch never initiates discovery itself. +watch(() => providerForm.connection.value.models, () => { + if (providerForm.connection.value.modelSource === 'live') modelCapacity.invalidate() +}) +watch(tierModelCatalogs, catalogs => { + if (Object.values(catalogs).some(catalog => catalog.source === 'live')) modelCapacity.invalidate() +}) const tierModelDiscoveries = new Map>() let tierModelDiscoveryEpoch = 0 type ImageModelCatalogSource = 'live' | 'catalog' | 'none' @@ -850,6 +862,7 @@ async function loadData(options: { catalog.value = (cat || {}) as OnboardingCatalog status.value = (st || {}) as OnboardingStatus config.value = (cfg || {}) as ConfigData + modelCapacity.invalidate() effectiveConfig.value = (effective || {}) as EffectiveConfigData // A probe result describes one exact saved deployment. Any successful // reload may follow a key, endpoint, model, activation, or deletion @@ -2322,6 +2335,8 @@ function sectionForDetailName(name: string): SettingsSectionId | null { // --------------------------------------------------------------------------- const providerDirty = computed(() => ( + modelCapacity.dirty(`provider:${normalizeProviderId(providerForm.selectedProvider.value)}`) + || providerForm.isDirty.value || (providerOwnsFixedModelDraft.value && modelStrategyForm.fixedModelDirty.value) || (editingPrimaryProvider.value && promotedForm.timeoutDirty.value) @@ -2331,6 +2346,8 @@ const behaviorDirty = computed(() => behaviorForm.isDirty.value) const securityPrivacyDirty = computed(() => privacyDirty.value) const memorySettingsDirty = computed(() => promotedForm.captureDirty.value) const modelStrategyDirty = computed(() => ( + modelCapacity.dirty('modelStrategy') + || routerForm.isDirty.value || ensembleForm.isDirty.value || modelStrategyForm.fixedProviderDirty.value @@ -2425,6 +2442,8 @@ async function saveDirtySections() { async function discardChanges() { if (saveAllRequestPending || modelStrategyRoutingBusy.value) return if (providerInteractionLocked()) return + restoreProviderCapacityDrafts = null + for (const draft of [...modelCapacity.drafts.values()]) modelCapacity.discard(draft.scope) await loadData() } @@ -2503,6 +2522,7 @@ async function requestSelectConfiguredProvider(value: string) { if (providerInteractionLocked()) return const next = normalizeProviderId(value) if (!next) return + restoreProviderCapacityDrafts ??= modelCapacity.captureProviderDrafts(next) if (providerFixedModelDraftSnapshot.value == null) { providerFixedModelDraftSnapshot.value = { provider: modelStrategyForm.fixedProvider.value, @@ -2522,12 +2542,16 @@ async function requestAddProvider(value: string) { if (providerInteractionLocked()) return const next = normalizeProviderId(value) if (!next || !(await confirmProviderDraftDiscard())) return + restoreProviderCapacityDrafts ??= modelCapacity.captureProviderDrafts(next) providerForm.selectProvider(next) onProviderChange() } function cancelProviderEdit() { if (providerInteractionLocked()) return + if (restoreProviderCapacityDrafts) restoreProviderCapacityDrafts() + else modelCapacity.discard(`provider:${normalizeProviderId(providerForm.selectedProvider.value)}`) + restoreProviderCapacityDrafts = null if (providerOwnsFixedModelDraft.value && providerFixedModelDraftSnapshot.value != null) { modelStrategyForm.setFixedProvider(providerFixedModelDraftSnapshot.value.provider) modelStrategyForm.setFixedModel(providerFixedModelDraftSnapshot.value.model) @@ -3809,6 +3833,32 @@ async function saveProvider(options: SaveOptions = {}): Promise { pushToast(t('setup.toast.chooseProvider'), { tone: 'danger' }) return false } + const capacityScope = `provider:${normalizeProviderId(providerForm.selectedProvider.value)}` + if (!modelCapacity.valid(capacityScope)) { + pushToast(t('setup.capacity.invalid'), { tone: 'danger' }) + return false + } + const capacityOnly = modelCapacity.dirty(capacityScope) + && !providerForm.isDirty.value + && !(providerOwnsFixedModelDraft.value && modelStrategyForm.fixedModelDirty.value) + && !promotedForm.timeoutDirty.value && !promotedForm.contextWindowDirty.value + && !options.activate + if (capacityOnly) { + providerSavePending.value = true + primaryMutationPending.value = true + try { + await modelCapacity.save(capacityScope, deepPatchConfig) + restoreProviderCapacityDrafts = null + pushToast(t('setup.capacity.saved')) + return true + } catch { + pushToast(t('setup.capacity.saveFailed'), { tone: 'danger' }) + return false + } finally { + providerSavePending.value = false + primaryMutationPending.value = false + } + } // A saved identity with an unavailable primary is still a first usable // configuration. The primary action is labelled “Save and start using” in // that state, so route it through the same atomic upsert+activate RPC as the @@ -3905,6 +3955,8 @@ async function saveProvider(options: SaveOptions = {}): Promise { await setupWorkflow.profile.upsertProfile(payload) } primaryAcknowledged = true + await modelCapacity.save(capacityScope, deepPatchConfig) + restoreProviderCapacityDrafts = null if (options.reload !== false) { // Saving a routing-only profile refreshes its persisted status without // discarding drafts in any other Settings section. Provider-owned @@ -3940,6 +3992,8 @@ async function saveProvider(options: SaveOptions = {}): Promise { // deliberately preserves the saved primary model until Model Routing is // saved), and skip the patch entirely when no model is selected. if (contextPatch) await deepPatchConfig(contextPatch) + await modelCapacity.save(capacityScope, deepPatchConfig) + restoreProviderCapacityDrafts = null if (options.reload !== false) { // Replacing the primary deployment on a legacy Gateway changes the // identity that Router and the fixed fallback are based on. Rebuild that @@ -3957,6 +4011,15 @@ async function saveProvider(options: SaveOptions = {}): Promise { return true } catch (err) { if (primaryAcknowledged) { + if (!refreshStarted && modelCapacity.dirty(capacityScope)) { + try { + const selected = normalizeProviderId(providerForm.selectedProvider.value) + await reloadProviderData(true) + if (selected !== normalizeProviderId(currentProvider.value)) applyConfiguredProviderSelection(selected) + } catch { /* Keep drafts when the read-back itself is unavailable. */ } + pushToast(t('setup.capacity.providerPartialSaved'), { tone: 'danger' }) + return false + } pushToast(t(refreshStarted ? 'setup.modelStrategy.savedRefreshFailed' : 'setup.provider.primarySavedAdditionalFailed'), { tone: 'danger' }) } else await reportPrimaryMutationFailure(err) return false @@ -4088,7 +4151,12 @@ async function saveModelStrategy(options: SaveOptions & { const hasRouterWork = Boolean(routerRoutingPayload) || Object.keys(routerVisualPatches).length > 0 const hasFixedModelWork = fixedProviderChanged || Object.keys(fixedModelPatches).length > 0 const hasEnsembleWork = Object.keys(ensemblePayload).length > 0 - if (!hasRouterWork && !hasFixedModelWork && !hasEnsembleWork) return true + const hasCapacityWork = modelCapacity.dirty('modelStrategy') + if (!modelCapacity.valid('modelStrategy')) { + pushToast(t('setup.capacity.invalid'), { tone: 'danger' }) + return false + } + if (!hasRouterWork && !hasFixedModelWork && !hasEnsembleWork && !hasCapacityWork) return true if (hasFixedModelWork && !fixedModel) { pushToast(t('setup.toast.chooseFixedModel'), { tone: 'danger' }) return false @@ -4113,11 +4181,26 @@ async function saveModelStrategy(options: SaveOptions & { let routerSaved = false let visualSaved = false let ensembleSaved = false + let fixedSaved = false let primaryActivationAttempted = false const refreshPartialSave = async (unknownPrimaryResult = false) => { + const savedSections: string[] = [] + const pendingSections: string[] = [] + for (const [changed, acknowledged, label] of [ + [Boolean(routerRoutingPayload), routerSaved, 'setup.modelStrategy.cards.router.title'], + [Object.keys(routerVisualPatches).length > 0, visualSaved, 'setup.modelStrategy.visualModeLabel'], + [hasEnsembleWork, ensembleSaved, 'setup.modelStrategy.cards.ensemble.title'], + [hasFixedModelWork, fixedSaved, 'setup.modelStrategy.cards.single.title'], + [hasCapacityWork, !modelCapacity.dirty('modelStrategy'), 'setup.capacity.title'], + ] as const) { + if (changed) (acknowledged ? savedSections : pendingSections).push(t(label)) + } const message = t(unknownPrimaryResult ? 'setup.toast.modelStrategyPartialSaveUncertain' - : 'setup.toast.modelStrategyPartialSaved') + : hasCapacityWork && modelCapacity.dirty('modelStrategy') + ? 'setup.capacity.partialSaved' : 'setup.toast.modelStrategyPartialSaved', { + saved: savedSections.join(', '), pending: pendingSections.join(', '), + }) try { await loadData({ preserveFormDrafts: true, throwOnError: true }) } catch (refreshError) { @@ -4126,6 +4209,11 @@ async function saveModelStrategy(options: SaveOptions & { pushToast(message, { tone: 'danger' }) return } + if (fixedSaved) { + modelStrategyForm.initFixedModel(config.value.llm?.model || '', config.value.llm?.provider || '') + providerOwnsFixedModelDraft.value = false + providerFixedModelDraftSnapshot.value = null + } if (ensembleSaved) { const detail = (status.value.sectionDetails || {}).ensemble || {} ensembleForm.initFromConfig({ @@ -4200,6 +4288,13 @@ async function saveModelStrategy(options: SaveOptions & { savedAny = true } + fixedSaved = hasFixedModelWork + + if (hasCapacityWork) { + await modelCapacity.save('modelStrategy', deepPatchConfig) + savedAny = true + pushToast(t('setup.capacity.saved')) + } if (savedAny && options.reload !== false) await loadData({ preserveProviderDraft: true, preserveDirtySectionDrafts: true, forceResetModelStrategy: true, throwOnError: true }) return savedAny } catch (err) { @@ -4344,6 +4439,7 @@ async function copyConfigPath() { } return { + modelCapacity, status, config, section, diff --git a/opensquilla-webui/src/contracts/generated/v4/conversationEvents.ts b/opensquilla-webui/src/contracts/generated/v4/conversationEvents.ts index 9acc049350..0754a85b6d 100644 --- a/opensquilla-webui/src/contracts/generated/v4/conversationEvents.ts +++ b/opensquilla-webui/src/contracts/generated/v4/conversationEvents.ts @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: b48e13df3b46c5f9d4594d02f5534a9aebf307c587ab9f7b9ffe99f0601b09ca +// source-sha256: ba10d11fd34519ad9801b388f5f3f64b2e2482abc84e19a80d674948db6dae2e /** * Versioned conversation payload. Event-specific fields remain open, but canonical producers must carry schema_version=1. @@ -69,6 +69,12 @@ export interface V4ConversationEventFrame { * via the `definition` "ConversationEventIdentityPayload". */ export interface CommonConversationEventIdentityPayload { + model_capacity?: { + provider: string; + model: string; + contextWindow: number; + source: "default" | "catalog" | "config" | "override"; + } | null; key?: string | null; session_key?: string | null; sessionKey?: string | null; diff --git a/opensquilla-webui/src/contracts/generated/v4/conversationEventsValidators.d.mts b/opensquilla-webui/src/contracts/generated/v4/conversationEventsValidators.d.mts index 8eacd1a303..f763c0e56a 100644 --- a/opensquilla-webui/src/contracts/generated/v4/conversationEventsValidators.d.mts +++ b/opensquilla-webui/src/contracts/generated/v4/conversationEventsValidators.d.mts @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: b48e13df3b46c5f9d4594d02f5534a9aebf307c587ab9f7b9ffe99f0601b09ca +// source-sha256: ba10d11fd34519ad9801b388f5f3f64b2e2482abc84e19a80d674948db6dae2e export interface ContractValidator { (value: unknown): boolean diff --git a/opensquilla-webui/src/contracts/generated/v4/conversationEventsValidators.mjs b/opensquilla-webui/src/contracts/generated/v4/conversationEventsValidators.mjs index 4a0496dba8..9173b33785 100644 --- a/opensquilla-webui/src/contracts/generated/v4/conversationEventsValidators.mjs +++ b/opensquilla-webui/src/contracts/generated/v4/conversationEventsValidators.mjs @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: b48e13df3b46c5f9d4594d02f5534a9aebf307c587ab9f7b9ffe99f0601b09ca +// source-sha256: ba10d11fd34519ad9801b388f5f3f64b2e2482abc84e19a80d674948db6dae2e function __opensquillaAjvUcs2Length(str) { const len = str.length @@ -16,4 +16,4 @@ function __opensquillaAjvUcs2Length(str) { } return length } -"use strict";export const validateConversationEventFrame = validate20;const schema31 = {"$id":"urn:opensquilla:contract:v4:ConversationEventFrame","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/conversation/conversation-events.schema.json#/$defs/ConversationEventFrame"};const schema33 = {"title":"v4 conversation event frame","description":"The decoder accepts known and future session/task event names. Unknown additive event names remain observable as an unknown event instead of being delivered to a typed handler.","type":"object","additionalProperties":true,"required":["event"],"properties":{"type":{"const":"event"},"event":{"type":"string","pattern":"^(?:chat\\.done|session\\.(?:epoch_changed|event(?:\\.[a-z0-9_]+)+)|task\\.[a-z0-9_]+)$"},"payload":{"anyOf":[{"$ref":"#/$defs/ConversationEventCanonicalPayload"},{"$ref":"#/$defs/ConversationEventLegacyPayload"},{"not":{"type":"object"}}]},"meta":{"type":["object","null"],"additionalProperties":true},"seq":{"type":["integer","null"],"minimum":0},"state_version":{"type":["object","null"],"additionalProperties":true}}};const pattern4 = new RegExp("^(?:chat\\.done|session\\.(?:epoch_changed|event(?:\\.[a-z0-9_]+)+)|task\\.[a-z0-9_]+)$", "u");const schema34 = {"title":"canonical conversation event payload","description":"Versioned conversation payload. Event-specific fields remain open, but canonical producers must carry schema_version=1.","allOf":[{"$ref":"#/$defs/ConversationEventPayload"},{"type":"object","required":["schema_version"],"properties":{"schema_version":{"const":1}}}]};const schema35 = {"title":"common conversation event payload","description":"Common identity, replay and version fields are documented here; event-specific fields remain open so old and additive producers keep their exact payload.","allOf":[{"$ref":"#/$defs/ConversationEventIdentityPayload"},{"type":"object","additionalProperties":true,"properties":{"schema_version":{"type":["integer","null"],"minimum":1}}}]};const schema36 = {"title":"common conversation event identity payload","description":"Common identity and replay fields are documented here; event-specific fields remain open so old and additive producers keep their exact payload.","type":"object","additionalProperties":true,"properties":{"key":{"type":["string","null"],"minLength":1},"session_key":{"type":["string","null"],"minLength":1},"sessionKey":{"type":["string","null"],"minLength":1},"stream_generation":{"type":["string","null"],"minLength":1},"streamGeneration":{"type":["string","null"],"minLength":1},"stream_seq":{"type":["integer","null"],"minimum":0},"streamSeq":{"type":["integer","null"],"minimum":0},"generation_epoch":{"type":["integer","null"],"minimum":0},"generationEpoch":{"type":["integer","null"],"minimum":0},"task_id":{"type":["string","null"]},"taskId":{"type":["string","null"]},"turn_id":{"type":["string","null"]},"turnId":{"type":["string","null"]},"emitted_at":{"type":["integer","null"],"minimum":0},"emittedAt":{"type":["integer","null"],"minimum":0}}};const func1 = __opensquillaAjvUcs2Length;function validate24(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate24.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.key !== undefined){let data0 = data.key;if((typeof data0 !== "string") && (data0 !== null)){const err0 = {instancePath:instancePath+"/key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/key/type",keyword:"type",params:{type: schema36.properties.key.type},message:"must be string,null"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(typeof data0 === "string"){if(func1(data0) < 1){const err1 = {instancePath:instancePath+"/key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/key/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}if(data.session_key !== undefined){let data1 = data.session_key;if((typeof data1 !== "string") && (data1 !== null)){const err2 = {instancePath:instancePath+"/session_key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/session_key/type",keyword:"type",params:{type: schema36.properties.session_key.type},message:"must be string,null"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(typeof data1 === "string"){if(func1(data1) < 1){const err3 = {instancePath:instancePath+"/session_key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/session_key/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}}if(data.sessionKey !== undefined){let data2 = data.sessionKey;if((typeof data2 !== "string") && (data2 !== null)){const err4 = {instancePath:instancePath+"/sessionKey",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/sessionKey/type",keyword:"type",params:{type: schema36.properties.sessionKey.type},message:"must be string,null"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(typeof data2 === "string"){if(func1(data2) < 1){const err5 = {instancePath:instancePath+"/sessionKey",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/sessionKey/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}}if(data.stream_generation !== undefined){let data3 = data.stream_generation;if((typeof data3 !== "string") && (data3 !== null)){const err6 = {instancePath:instancePath+"/stream_generation",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_generation/type",keyword:"type",params:{type: schema36.properties.stream_generation.type},message:"must be string,null"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(typeof data3 === "string"){if(func1(data3) < 1){const err7 = {instancePath:instancePath+"/stream_generation",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_generation/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}}if(data.streamGeneration !== undefined){let data4 = data.streamGeneration;if((typeof data4 !== "string") && (data4 !== null)){const err8 = {instancePath:instancePath+"/streamGeneration",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamGeneration/type",keyword:"type",params:{type: schema36.properties.streamGeneration.type},message:"must be string,null"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(typeof data4 === "string"){if(func1(data4) < 1){const err9 = {instancePath:instancePath+"/streamGeneration",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamGeneration/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}}if(data.stream_seq !== undefined){let data5 = data.stream_seq;if((!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))) && (data5 !== null)){const err10 = {instancePath:instancePath+"/stream_seq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_seq/type",keyword:"type",params:{type: schema36.properties.stream_seq.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if((typeof data5 == "number") && (isFinite(data5))){if(data5 < 0 || isNaN(data5)){const err11 = {instancePath:instancePath+"/stream_seq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_seq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}}if(data.streamSeq !== undefined){let data6 = data.streamSeq;if((!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))) && (data6 !== null)){const err12 = {instancePath:instancePath+"/streamSeq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamSeq/type",keyword:"type",params:{type: schema36.properties.streamSeq.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 0 || isNaN(data6)){const err13 = {instancePath:instancePath+"/streamSeq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}if(data.generation_epoch !== undefined){let data7 = data.generation_epoch;if((!(((typeof data7 == "number") && (!(data7 % 1) && !isNaN(data7))) && (isFinite(data7)))) && (data7 !== null)){const err14 = {instancePath:instancePath+"/generation_epoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generation_epoch/type",keyword:"type",params:{type: schema36.properties.generation_epoch.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if((typeof data7 == "number") && (isFinite(data7))){if(data7 < 0 || isNaN(data7)){const err15 = {instancePath:instancePath+"/generation_epoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generation_epoch/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}if(data.generationEpoch !== undefined){let data8 = data.generationEpoch;if((!(((typeof data8 == "number") && (!(data8 % 1) && !isNaN(data8))) && (isFinite(data8)))) && (data8 !== null)){const err16 = {instancePath:instancePath+"/generationEpoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generationEpoch/type",keyword:"type",params:{type: schema36.properties.generationEpoch.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if((typeof data8 == "number") && (isFinite(data8))){if(data8 < 0 || isNaN(data8)){const err17 = {instancePath:instancePath+"/generationEpoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generationEpoch/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}if(data.task_id !== undefined){let data9 = data.task_id;if((typeof data9 !== "string") && (data9 !== null)){const err18 = {instancePath:instancePath+"/task_id",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/task_id/type",keyword:"type",params:{type: schema36.properties.task_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.taskId !== undefined){let data10 = data.taskId;if((typeof data10 !== "string") && (data10 !== null)){const err19 = {instancePath:instancePath+"/taskId",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/taskId/type",keyword:"type",params:{type: schema36.properties.taskId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.turn_id !== undefined){let data11 = data.turn_id;if((typeof data11 !== "string") && (data11 !== null)){const err20 = {instancePath:instancePath+"/turn_id",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/turn_id/type",keyword:"type",params:{type: schema36.properties.turn_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.turnId !== undefined){let data12 = data.turnId;if((typeof data12 !== "string") && (data12 !== null)){const err21 = {instancePath:instancePath+"/turnId",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/turnId/type",keyword:"type",params:{type: schema36.properties.turnId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.emitted_at !== undefined){let data13 = data.emitted_at;if((!(((typeof data13 == "number") && (!(data13 % 1) && !isNaN(data13))) && (isFinite(data13)))) && (data13 !== null)){const err22 = {instancePath:instancePath+"/emitted_at",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emitted_at/type",keyword:"type",params:{type: schema36.properties.emitted_at.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if((typeof data13 == "number") && (isFinite(data13))){if(data13 < 0 || isNaN(data13)){const err23 = {instancePath:instancePath+"/emitted_at",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emitted_at/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}if(data.emittedAt !== undefined){let data14 = data.emittedAt;if((!(((typeof data14 == "number") && (!(data14 % 1) && !isNaN(data14))) && (isFinite(data14)))) && (data14 !== null)){const err24 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emittedAt/type",keyword:"type",params:{type: schema36.properties.emittedAt.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if((typeof data14 == "number") && (isFinite(data14))){if(data14 < 0 || isNaN(data14)){const err25 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emittedAt/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}}}else {const err26 = {instancePath,schemaPath:"#/$defs/ConversationEventIdentityPayload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema_version !== undefined){let data15 = data.schema_version;if((!(((typeof data15 == "number") && (!(data15 % 1) && !isNaN(data15))) && (isFinite(data15)))) && (data15 !== null)){const err27 = {instancePath:instancePath+"/schema_version",schemaPath:"#/allOf/1/properties/schema_version/type",keyword:"type",params:{type: schema35.allOf[1].properties.schema_version.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if((typeof data15 == "number") && (isFinite(data15))){if(data15 < 1 || isNaN(data15)){const err28 = {instancePath:instancePath+"/schema_version",schemaPath:"#/allOf/1/properties/schema_version/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}}else {const err29 = {instancePath,schemaPath:"#/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}validate24.errors = vErrors;return errors === 0;}validate24.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate23(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate23.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(!(validate24(data, {instancePath,parentData,parentDataProperty,rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate24.errors : vErrors.concat(validate24.errors);errors = vErrors.length;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema_version === undefined){const err0 = {instancePath,schemaPath:"#/allOf/1/required",keyword:"required",params:{missingProperty: "schema_version"},message:"must have required property '"+"schema_version"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.schema_version !== undefined){if(1 !== data.schema_version){const err1 = {instancePath:instancePath+"/schema_version",schemaPath:"#/allOf/1/properties/schema_version/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}else {const err2 = {instancePath,schemaPath:"#/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}validate23.errors = vErrors;return errors === 0;}validate23.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema37 = {"title":"legacy conversation event payload","description":"Older v4 producers omit schema_version. The decoder also retains primitive and null payloads as legacy values for exact wire compatibility.","allOf":[{"$ref":"#/$defs/ConversationEventIdentityPayload"},{"type":"object","additionalProperties":true,"properties":{"schema_version":{"type":"null"}}}]};function validate27(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate27.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.key !== undefined){let data0 = data.key;if((typeof data0 !== "string") && (data0 !== null)){const err0 = {instancePath:instancePath+"/key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/key/type",keyword:"type",params:{type: schema36.properties.key.type},message:"must be string,null"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(typeof data0 === "string"){if(func1(data0) < 1){const err1 = {instancePath:instancePath+"/key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/key/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}if(data.session_key !== undefined){let data1 = data.session_key;if((typeof data1 !== "string") && (data1 !== null)){const err2 = {instancePath:instancePath+"/session_key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/session_key/type",keyword:"type",params:{type: schema36.properties.session_key.type},message:"must be string,null"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(typeof data1 === "string"){if(func1(data1) < 1){const err3 = {instancePath:instancePath+"/session_key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/session_key/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}}if(data.sessionKey !== undefined){let data2 = data.sessionKey;if((typeof data2 !== "string") && (data2 !== null)){const err4 = {instancePath:instancePath+"/sessionKey",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/sessionKey/type",keyword:"type",params:{type: schema36.properties.sessionKey.type},message:"must be string,null"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}if(typeof data2 === "string"){if(func1(data2) < 1){const err5 = {instancePath:instancePath+"/sessionKey",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/sessionKey/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}}if(data.stream_generation !== undefined){let data3 = data.stream_generation;if((typeof data3 !== "string") && (data3 !== null)){const err6 = {instancePath:instancePath+"/stream_generation",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_generation/type",keyword:"type",params:{type: schema36.properties.stream_generation.type},message:"must be string,null"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(typeof data3 === "string"){if(func1(data3) < 1){const err7 = {instancePath:instancePath+"/stream_generation",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_generation/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}}if(data.streamGeneration !== undefined){let data4 = data.streamGeneration;if((typeof data4 !== "string") && (data4 !== null)){const err8 = {instancePath:instancePath+"/streamGeneration",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamGeneration/type",keyword:"type",params:{type: schema36.properties.streamGeneration.type},message:"must be string,null"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(typeof data4 === "string"){if(func1(data4) < 1){const err9 = {instancePath:instancePath+"/streamGeneration",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamGeneration/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}}if(data.stream_seq !== undefined){let data5 = data.stream_seq;if((!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))) && (data5 !== null)){const err10 = {instancePath:instancePath+"/stream_seq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_seq/type",keyword:"type",params:{type: schema36.properties.stream_seq.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if((typeof data5 == "number") && (isFinite(data5))){if(data5 < 0 || isNaN(data5)){const err11 = {instancePath:instancePath+"/stream_seq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_seq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}}if(data.streamSeq !== undefined){let data6 = data.streamSeq;if((!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))) && (data6 !== null)){const err12 = {instancePath:instancePath+"/streamSeq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamSeq/type",keyword:"type",params:{type: schema36.properties.streamSeq.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 0 || isNaN(data6)){const err13 = {instancePath:instancePath+"/streamSeq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}if(data.generation_epoch !== undefined){let data7 = data.generation_epoch;if((!(((typeof data7 == "number") && (!(data7 % 1) && !isNaN(data7))) && (isFinite(data7)))) && (data7 !== null)){const err14 = {instancePath:instancePath+"/generation_epoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generation_epoch/type",keyword:"type",params:{type: schema36.properties.generation_epoch.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if((typeof data7 == "number") && (isFinite(data7))){if(data7 < 0 || isNaN(data7)){const err15 = {instancePath:instancePath+"/generation_epoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generation_epoch/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}if(data.generationEpoch !== undefined){let data8 = data.generationEpoch;if((!(((typeof data8 == "number") && (!(data8 % 1) && !isNaN(data8))) && (isFinite(data8)))) && (data8 !== null)){const err16 = {instancePath:instancePath+"/generationEpoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generationEpoch/type",keyword:"type",params:{type: schema36.properties.generationEpoch.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if((typeof data8 == "number") && (isFinite(data8))){if(data8 < 0 || isNaN(data8)){const err17 = {instancePath:instancePath+"/generationEpoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generationEpoch/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}if(data.task_id !== undefined){let data9 = data.task_id;if((typeof data9 !== "string") && (data9 !== null)){const err18 = {instancePath:instancePath+"/task_id",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/task_id/type",keyword:"type",params:{type: schema36.properties.task_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.taskId !== undefined){let data10 = data.taskId;if((typeof data10 !== "string") && (data10 !== null)){const err19 = {instancePath:instancePath+"/taskId",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/taskId/type",keyword:"type",params:{type: schema36.properties.taskId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.turn_id !== undefined){let data11 = data.turn_id;if((typeof data11 !== "string") && (data11 !== null)){const err20 = {instancePath:instancePath+"/turn_id",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/turn_id/type",keyword:"type",params:{type: schema36.properties.turn_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}if(data.turnId !== undefined){let data12 = data.turnId;if((typeof data12 !== "string") && (data12 !== null)){const err21 = {instancePath:instancePath+"/turnId",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/turnId/type",keyword:"type",params:{type: schema36.properties.turnId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.emitted_at !== undefined){let data13 = data.emitted_at;if((!(((typeof data13 == "number") && (!(data13 % 1) && !isNaN(data13))) && (isFinite(data13)))) && (data13 !== null)){const err22 = {instancePath:instancePath+"/emitted_at",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emitted_at/type",keyword:"type",params:{type: schema36.properties.emitted_at.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if((typeof data13 == "number") && (isFinite(data13))){if(data13 < 0 || isNaN(data13)){const err23 = {instancePath:instancePath+"/emitted_at",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emitted_at/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}if(data.emittedAt !== undefined){let data14 = data.emittedAt;if((!(((typeof data14 == "number") && (!(data14 % 1) && !isNaN(data14))) && (isFinite(data14)))) && (data14 !== null)){const err24 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emittedAt/type",keyword:"type",params:{type: schema36.properties.emittedAt.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}if((typeof data14 == "number") && (isFinite(data14))){if(data14 < 0 || isNaN(data14)){const err25 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emittedAt/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}}}else {const err26 = {instancePath,schemaPath:"#/$defs/ConversationEventIdentityPayload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema_version !== undefined){if(data.schema_version !== null){const err27 = {instancePath:instancePath+"/schema_version",schemaPath:"#/allOf/1/properties/schema_version/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}}}else {const err28 = {instancePath,schemaPath:"#/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}validate27.errors = vErrors;return errors === 0;}validate27.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate30(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate30.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.event === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "event"},message:"must have required property '"+"event"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.type !== undefined){if("event" !== data.type){const err1 = {instancePath:instancePath+"/type",schemaPath:"#/properties/type/const",keyword:"const",params:{allowedValue: "event"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.event !== undefined){let data1 = data.event;if(typeof data1 === "string"){if(!pattern4.test(data1)){const err2 = {instancePath:instancePath+"/event",schemaPath:"#/properties/event/pattern",keyword:"pattern",params:{pattern: "^(?:chat\\.done|session\\.(?:epoch_changed|event(?:\\.[a-z0-9_]+)+)|task\\.[a-z0-9_]+)$"},message:"must match pattern \""+"^(?:chat\\.done|session\\.(?:epoch_changed|event(?:\\.[a-z0-9_]+)+)|task\\.[a-z0-9_]+)$"+"\""};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}else {const err3 = {instancePath:instancePath+"/event",schemaPath:"#/properties/event/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.payload !== undefined){let data2 = data.payload;const _errs6 = errors;let valid1 = false;const _errs7 = errors;if(!(validate23(data2, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate23.errors : vErrors.concat(validate23.errors);errors = vErrors.length;}var _valid0 = _errs7 === errors;valid1 = valid1 || _valid0;if(_valid0){var props0 = true;}const _errs8 = errors;if(!(validate27(data2, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate27.errors : vErrors.concat(validate27.errors);errors = vErrors.length;}var _valid0 = _errs8 === errors;valid1 = valid1 || _valid0;if(_valid0){if(props0 !== true){props0 = true;}}const _errs9 = errors;const _errs10 = errors;const _errs11 = errors;if(!(data2 && typeof data2 == "object" && !Array.isArray(data2))){const err4 = {};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}var valid2 = _errs11 === errors;if(valid2){const err5 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/anyOf/2/not",keyword:"not",params:{},message:"must NOT be valid"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}else {errors = _errs10;if(vErrors !== null){if(_errs10){vErrors.length = _errs10;}else {vErrors = null;}}}var _valid0 = _errs9 === errors;valid1 = valid1 || _valid0;if(!valid1){const err6 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}else {errors = _errs6;if(vErrors !== null){if(_errs6){vErrors.length = _errs6;}else {vErrors = null;}}}}if(data.meta !== undefined){let data3 = data.meta;if((!(data3 && typeof data3 == "object" && !Array.isArray(data3))) && (data3 !== null)){const err7 = {instancePath:instancePath+"/meta",schemaPath:"#/properties/meta/type",keyword:"type",params:{type: schema33.properties.meta.type},message:"must be object,null"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.seq !== undefined){let data4 = data.seq;if((!(((typeof data4 == "number") && (!(data4 % 1) && !isNaN(data4))) && (isFinite(data4)))) && (data4 !== null)){const err8 = {instancePath:instancePath+"/seq",schemaPath:"#/properties/seq/type",keyword:"type",params:{type: schema33.properties.seq.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if((typeof data4 == "number") && (isFinite(data4))){if(data4 < 0 || isNaN(data4)){const err9 = {instancePath:instancePath+"/seq",schemaPath:"#/properties/seq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}}if(data.state_version !== undefined){let data5 = data.state_version;if((!(data5 && typeof data5 == "object" && !Array.isArray(data5))) && (data5 !== null)){const err10 = {instancePath:instancePath+"/state_version",schemaPath:"#/properties/state_version/type",keyword:"type",params:{type: schema33.properties.state_version.type},message:"must be object,null"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}}else {const err11 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}validate30.errors = vErrors;return errors === 0;}validate30.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="urn:opensquilla:contract:v4:ConversationEventFrame" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(!(validate30(data, {instancePath,parentData,parentDataProperty,rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate30.errors : vErrors.concat(validate30.errors);errors = vErrors.length;}validate20.errors = vErrors;return errors === 0;}validate20.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; +"use strict";export const validateConversationEventFrame = validate20;const schema31 = {"$id":"urn:opensquilla:contract:v4:ConversationEventFrame","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/conversation/conversation-events.schema.json#/$defs/ConversationEventFrame"};const schema33 = {"title":"v4 conversation event frame","description":"The decoder accepts known and future session/task event names. Unknown additive event names remain observable as an unknown event instead of being delivered to a typed handler.","type":"object","additionalProperties":true,"required":["event"],"properties":{"type":{"const":"event"},"event":{"type":"string","pattern":"^(?:chat\\.done|session\\.(?:epoch_changed|event(?:\\.[a-z0-9_]+)+)|task\\.[a-z0-9_]+)$"},"payload":{"anyOf":[{"$ref":"#/$defs/ConversationEventCanonicalPayload"},{"$ref":"#/$defs/ConversationEventLegacyPayload"},{"not":{"type":"object"}}]},"meta":{"type":["object","null"],"additionalProperties":true},"seq":{"type":["integer","null"],"minimum":0},"state_version":{"type":["object","null"],"additionalProperties":true}}};const pattern4 = new RegExp("^(?:chat\\.done|session\\.(?:epoch_changed|event(?:\\.[a-z0-9_]+)+)|task\\.[a-z0-9_]+)$", "u");const schema34 = {"title":"canonical conversation event payload","description":"Versioned conversation payload. Event-specific fields remain open, but canonical producers must carry schema_version=1.","allOf":[{"$ref":"#/$defs/ConversationEventPayload"},{"type":"object","required":["schema_version"],"properties":{"schema_version":{"const":1}}}]};const schema35 = {"title":"common conversation event payload","description":"Common identity, replay and version fields are documented here; event-specific fields remain open so old and additive producers keep their exact payload.","allOf":[{"$ref":"#/$defs/ConversationEventIdentityPayload"},{"type":"object","additionalProperties":true,"properties":{"schema_version":{"type":["integer","null"],"minimum":1}}}]};const schema36 = {"title":"common conversation event identity payload","description":"Common identity and replay fields are documented here; event-specific fields remain open so old and additive producers keep their exact payload.","type":"object","additionalProperties":true,"properties":{"model_capacity":{"type":["object","null"],"additionalProperties":false,"required":["provider","model","contextWindow","source"],"properties":{"provider":{"type":"string","minLength":1,"maxLength":1024},"model":{"type":"string","minLength":1,"maxLength":1024},"contextWindow":{"type":"integer","minimum":1},"source":{"enum":["default","catalog","config","override"]}}},"key":{"type":["string","null"],"minLength":1},"session_key":{"type":["string","null"],"minLength":1},"sessionKey":{"type":["string","null"],"minLength":1},"stream_generation":{"type":["string","null"],"minLength":1},"streamGeneration":{"type":["string","null"],"minLength":1},"stream_seq":{"type":["integer","null"],"minimum":0},"streamSeq":{"type":["integer","null"],"minimum":0},"generation_epoch":{"type":["integer","null"],"minimum":0},"generationEpoch":{"type":["integer","null"],"minimum":0},"task_id":{"type":["string","null"]},"taskId":{"type":["string","null"]},"turn_id":{"type":["string","null"]},"turnId":{"type":["string","null"]},"emitted_at":{"type":["integer","null"],"minimum":0},"emittedAt":{"type":["integer","null"],"minimum":0}}};const func1 = __opensquillaAjvUcs2Length;function validate24(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate24.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.model_capacity !== undefined){let data0 = data.model_capacity;if((!(data0 && typeof data0 == "object" && !Array.isArray(data0))) && (data0 !== null)){const err0 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/type",keyword:"type",params:{type: schema36.properties.model_capacity.type},message:"must be object,null"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data0 && typeof data0 == "object" && !Array.isArray(data0)){if(data0.provider === undefined){const err1 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data0.model === undefined){const err2 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/required",keyword:"required",params:{missingProperty: "model"},message:"must have required property '"+"model"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data0.contextWindow === undefined){const err3 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/required",keyword:"required",params:{missingProperty: "contextWindow"},message:"must have required property '"+"contextWindow"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data0.source === undefined){const err4 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/required",keyword:"required",params:{missingProperty: "source"},message:"must have required property '"+"source"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data0){if(!((((key0 === "provider") || (key0 === "model")) || (key0 === "contextWindow")) || (key0 === "source"))){const err5 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data0.provider !== undefined){let data1 = data0.provider;if(typeof data1 === "string"){if(func1(data1) > 1024){const err6 = {instancePath:instancePath+"/model_capacity/provider",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/provider/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(func1(data1) < 1){const err7 = {instancePath:instancePath+"/model_capacity/provider",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}else {const err8 = {instancePath:instancePath+"/model_capacity/provider",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data0.model !== undefined){let data2 = data0.model;if(typeof data2 === "string"){if(func1(data2) > 1024){const err9 = {instancePath:instancePath+"/model_capacity/model",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/model/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data2) < 1){const err10 = {instancePath:instancePath+"/model_capacity/model",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/model/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/model_capacity/model",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/model/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data0.contextWindow !== undefined){let data3 = data0.contextWindow;if(!(((typeof data3 == "number") && (!(data3 % 1) && !isNaN(data3))) && (isFinite(data3)))){const err12 = {instancePath:instancePath+"/model_capacity/contextWindow",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/contextWindow/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if((typeof data3 == "number") && (isFinite(data3))){if(data3 < 1 || isNaN(data3)){const err13 = {instancePath:instancePath+"/model_capacity/contextWindow",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/contextWindow/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}if(data0.source !== undefined){let data4 = data0.source;if(!((((data4 === "default") || (data4 === "catalog")) || (data4 === "config")) || (data4 === "override"))){const err14 = {instancePath:instancePath+"/model_capacity/source",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/source/enum",keyword:"enum",params:{allowedValues: schema36.properties.model_capacity.properties.source.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}}if(data.key !== undefined){let data5 = data.key;if((typeof data5 !== "string") && (data5 !== null)){const err15 = {instancePath:instancePath+"/key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/key/type",keyword:"type",params:{type: schema36.properties.key.type},message:"must be string,null"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(typeof data5 === "string"){if(func1(data5) < 1){const err16 = {instancePath:instancePath+"/key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/key/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}if(data.session_key !== undefined){let data6 = data.session_key;if((typeof data6 !== "string") && (data6 !== null)){const err17 = {instancePath:instancePath+"/session_key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/session_key/type",keyword:"type",params:{type: schema36.properties.session_key.type},message:"must be string,null"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(typeof data6 === "string"){if(func1(data6) < 1){const err18 = {instancePath:instancePath+"/session_key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/session_key/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}}if(data.sessionKey !== undefined){let data7 = data.sessionKey;if((typeof data7 !== "string") && (data7 !== null)){const err19 = {instancePath:instancePath+"/sessionKey",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/sessionKey/type",keyword:"type",params:{type: schema36.properties.sessionKey.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(typeof data7 === "string"){if(func1(data7) < 1){const err20 = {instancePath:instancePath+"/sessionKey",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/sessionKey/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}if(data.stream_generation !== undefined){let data8 = data.stream_generation;if((typeof data8 !== "string") && (data8 !== null)){const err21 = {instancePath:instancePath+"/stream_generation",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_generation/type",keyword:"type",params:{type: schema36.properties.stream_generation.type},message:"must be string,null"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(typeof data8 === "string"){if(func1(data8) < 1){const err22 = {instancePath:instancePath+"/stream_generation",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_generation/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}}if(data.streamGeneration !== undefined){let data9 = data.streamGeneration;if((typeof data9 !== "string") && (data9 !== null)){const err23 = {instancePath:instancePath+"/streamGeneration",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamGeneration/type",keyword:"type",params:{type: schema36.properties.streamGeneration.type},message:"must be string,null"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(typeof data9 === "string"){if(func1(data9) < 1){const err24 = {instancePath:instancePath+"/streamGeneration",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamGeneration/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}}if(data.stream_seq !== undefined){let data10 = data.stream_seq;if((!(((typeof data10 == "number") && (!(data10 % 1) && !isNaN(data10))) && (isFinite(data10)))) && (data10 !== null)){const err25 = {instancePath:instancePath+"/stream_seq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_seq/type",keyword:"type",params:{type: schema36.properties.stream_seq.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if((typeof data10 == "number") && (isFinite(data10))){if(data10 < 0 || isNaN(data10)){const err26 = {instancePath:instancePath+"/stream_seq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_seq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}if(data.streamSeq !== undefined){let data11 = data.streamSeq;if((!(((typeof data11 == "number") && (!(data11 % 1) && !isNaN(data11))) && (isFinite(data11)))) && (data11 !== null)){const err27 = {instancePath:instancePath+"/streamSeq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamSeq/type",keyword:"type",params:{type: schema36.properties.streamSeq.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if((typeof data11 == "number") && (isFinite(data11))){if(data11 < 0 || isNaN(data11)){const err28 = {instancePath:instancePath+"/streamSeq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}if(data.generation_epoch !== undefined){let data12 = data.generation_epoch;if((!(((typeof data12 == "number") && (!(data12 % 1) && !isNaN(data12))) && (isFinite(data12)))) && (data12 !== null)){const err29 = {instancePath:instancePath+"/generation_epoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generation_epoch/type",keyword:"type",params:{type: schema36.properties.generation_epoch.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if((typeof data12 == "number") && (isFinite(data12))){if(data12 < 0 || isNaN(data12)){const err30 = {instancePath:instancePath+"/generation_epoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generation_epoch/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}}if(data.generationEpoch !== undefined){let data13 = data.generationEpoch;if((!(((typeof data13 == "number") && (!(data13 % 1) && !isNaN(data13))) && (isFinite(data13)))) && (data13 !== null)){const err31 = {instancePath:instancePath+"/generationEpoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generationEpoch/type",keyword:"type",params:{type: schema36.properties.generationEpoch.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if((typeof data13 == "number") && (isFinite(data13))){if(data13 < 0 || isNaN(data13)){const err32 = {instancePath:instancePath+"/generationEpoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generationEpoch/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}}if(data.task_id !== undefined){let data14 = data.task_id;if((typeof data14 !== "string") && (data14 !== null)){const err33 = {instancePath:instancePath+"/task_id",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/task_id/type",keyword:"type",params:{type: schema36.properties.task_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}if(data.taskId !== undefined){let data15 = data.taskId;if((typeof data15 !== "string") && (data15 !== null)){const err34 = {instancePath:instancePath+"/taskId",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/taskId/type",keyword:"type",params:{type: schema36.properties.taskId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data.turn_id !== undefined){let data16 = data.turn_id;if((typeof data16 !== "string") && (data16 !== null)){const err35 = {instancePath:instancePath+"/turn_id",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/turn_id/type",keyword:"type",params:{type: schema36.properties.turn_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}if(data.turnId !== undefined){let data17 = data.turnId;if((typeof data17 !== "string") && (data17 !== null)){const err36 = {instancePath:instancePath+"/turnId",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/turnId/type",keyword:"type",params:{type: schema36.properties.turnId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data.emitted_at !== undefined){let data18 = data.emitted_at;if((!(((typeof data18 == "number") && (!(data18 % 1) && !isNaN(data18))) && (isFinite(data18)))) && (data18 !== null)){const err37 = {instancePath:instancePath+"/emitted_at",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emitted_at/type",keyword:"type",params:{type: schema36.properties.emitted_at.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if((typeof data18 == "number") && (isFinite(data18))){if(data18 < 0 || isNaN(data18)){const err38 = {instancePath:instancePath+"/emitted_at",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emitted_at/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}}if(data.emittedAt !== undefined){let data19 = data.emittedAt;if((!(((typeof data19 == "number") && (!(data19 % 1) && !isNaN(data19))) && (isFinite(data19)))) && (data19 !== null)){const err39 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emittedAt/type",keyword:"type",params:{type: schema36.properties.emittedAt.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}if((typeof data19 == "number") && (isFinite(data19))){if(data19 < 0 || isNaN(data19)){const err40 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emittedAt/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}}}else {const err41 = {instancePath,schemaPath:"#/$defs/ConversationEventIdentityPayload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema_version !== undefined){let data20 = data.schema_version;if((!(((typeof data20 == "number") && (!(data20 % 1) && !isNaN(data20))) && (isFinite(data20)))) && (data20 !== null)){const err42 = {instancePath:instancePath+"/schema_version",schemaPath:"#/allOf/1/properties/schema_version/type",keyword:"type",params:{type: schema35.allOf[1].properties.schema_version.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}if((typeof data20 == "number") && (isFinite(data20))){if(data20 < 1 || isNaN(data20)){const err43 = {instancePath:instancePath+"/schema_version",schemaPath:"#/allOf/1/properties/schema_version/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}}}else {const err44 = {instancePath,schemaPath:"#/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}validate24.errors = vErrors;return errors === 0;}validate24.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate23(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate23.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(!(validate24(data, {instancePath,parentData,parentDataProperty,rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate24.errors : vErrors.concat(validate24.errors);errors = vErrors.length;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema_version === undefined){const err0 = {instancePath,schemaPath:"#/allOf/1/required",keyword:"required",params:{missingProperty: "schema_version"},message:"must have required property '"+"schema_version"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.schema_version !== undefined){if(1 !== data.schema_version){const err1 = {instancePath:instancePath+"/schema_version",schemaPath:"#/allOf/1/properties/schema_version/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}}else {const err2 = {instancePath,schemaPath:"#/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}validate23.errors = vErrors;return errors === 0;}validate23.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};const schema37 = {"title":"legacy conversation event payload","description":"Older v4 producers omit schema_version. The decoder also retains primitive and null payloads as legacy values for exact wire compatibility.","allOf":[{"$ref":"#/$defs/ConversationEventIdentityPayload"},{"type":"object","additionalProperties":true,"properties":{"schema_version":{"type":"null"}}}]};function validate27(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate27.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.model_capacity !== undefined){let data0 = data.model_capacity;if((!(data0 && typeof data0 == "object" && !Array.isArray(data0))) && (data0 !== null)){const err0 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/type",keyword:"type",params:{type: schema36.properties.model_capacity.type},message:"must be object,null"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data0 && typeof data0 == "object" && !Array.isArray(data0)){if(data0.provider === undefined){const err1 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data0.model === undefined){const err2 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/required",keyword:"required",params:{missingProperty: "model"},message:"must have required property '"+"model"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data0.contextWindow === undefined){const err3 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/required",keyword:"required",params:{missingProperty: "contextWindow"},message:"must have required property '"+"contextWindow"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data0.source === undefined){const err4 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/required",keyword:"required",params:{missingProperty: "source"},message:"must have required property '"+"source"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data0){if(!((((key0 === "provider") || (key0 === "model")) || (key0 === "contextWindow")) || (key0 === "source"))){const err5 = {instancePath:instancePath+"/model_capacity",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data0.provider !== undefined){let data1 = data0.provider;if(typeof data1 === "string"){if(func1(data1) > 1024){const err6 = {instancePath:instancePath+"/model_capacity/provider",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/provider/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(func1(data1) < 1){const err7 = {instancePath:instancePath+"/model_capacity/provider",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}else {const err8 = {instancePath:instancePath+"/model_capacity/provider",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data0.model !== undefined){let data2 = data0.model;if(typeof data2 === "string"){if(func1(data2) > 1024){const err9 = {instancePath:instancePath+"/model_capacity/model",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/model/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data2) < 1){const err10 = {instancePath:instancePath+"/model_capacity/model",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/model/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/model_capacity/model",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/model/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data0.contextWindow !== undefined){let data3 = data0.contextWindow;if(!(((typeof data3 == "number") && (!(data3 % 1) && !isNaN(data3))) && (isFinite(data3)))){const err12 = {instancePath:instancePath+"/model_capacity/contextWindow",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/contextWindow/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if((typeof data3 == "number") && (isFinite(data3))){if(data3 < 1 || isNaN(data3)){const err13 = {instancePath:instancePath+"/model_capacity/contextWindow",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/contextWindow/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}if(data0.source !== undefined){let data4 = data0.source;if(!((((data4 === "default") || (data4 === "catalog")) || (data4 === "config")) || (data4 === "override"))){const err14 = {instancePath:instancePath+"/model_capacity/source",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/model_capacity/properties/source/enum",keyword:"enum",params:{allowedValues: schema36.properties.model_capacity.properties.source.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}}if(data.key !== undefined){let data5 = data.key;if((typeof data5 !== "string") && (data5 !== null)){const err15 = {instancePath:instancePath+"/key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/key/type",keyword:"type",params:{type: schema36.properties.key.type},message:"must be string,null"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(typeof data5 === "string"){if(func1(data5) < 1){const err16 = {instancePath:instancePath+"/key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/key/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}}if(data.session_key !== undefined){let data6 = data.session_key;if((typeof data6 !== "string") && (data6 !== null)){const err17 = {instancePath:instancePath+"/session_key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/session_key/type",keyword:"type",params:{type: schema36.properties.session_key.type},message:"must be string,null"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(typeof data6 === "string"){if(func1(data6) < 1){const err18 = {instancePath:instancePath+"/session_key",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/session_key/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}}if(data.sessionKey !== undefined){let data7 = data.sessionKey;if((typeof data7 !== "string") && (data7 !== null)){const err19 = {instancePath:instancePath+"/sessionKey",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/sessionKey/type",keyword:"type",params:{type: schema36.properties.sessionKey.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(typeof data7 === "string"){if(func1(data7) < 1){const err20 = {instancePath:instancePath+"/sessionKey",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/sessionKey/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}if(data.stream_generation !== undefined){let data8 = data.stream_generation;if((typeof data8 !== "string") && (data8 !== null)){const err21 = {instancePath:instancePath+"/stream_generation",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_generation/type",keyword:"type",params:{type: schema36.properties.stream_generation.type},message:"must be string,null"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(typeof data8 === "string"){if(func1(data8) < 1){const err22 = {instancePath:instancePath+"/stream_generation",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_generation/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}}if(data.streamGeneration !== undefined){let data9 = data.streamGeneration;if((typeof data9 !== "string") && (data9 !== null)){const err23 = {instancePath:instancePath+"/streamGeneration",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamGeneration/type",keyword:"type",params:{type: schema36.properties.streamGeneration.type},message:"must be string,null"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if(typeof data9 === "string"){if(func1(data9) < 1){const err24 = {instancePath:instancePath+"/streamGeneration",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamGeneration/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}}if(data.stream_seq !== undefined){let data10 = data.stream_seq;if((!(((typeof data10 == "number") && (!(data10 % 1) && !isNaN(data10))) && (isFinite(data10)))) && (data10 !== null)){const err25 = {instancePath:instancePath+"/stream_seq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_seq/type",keyword:"type",params:{type: schema36.properties.stream_seq.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if((typeof data10 == "number") && (isFinite(data10))){if(data10 < 0 || isNaN(data10)){const err26 = {instancePath:instancePath+"/stream_seq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/stream_seq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}if(data.streamSeq !== undefined){let data11 = data.streamSeq;if((!(((typeof data11 == "number") && (!(data11 % 1) && !isNaN(data11))) && (isFinite(data11)))) && (data11 !== null)){const err27 = {instancePath:instancePath+"/streamSeq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamSeq/type",keyword:"type",params:{type: schema36.properties.streamSeq.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if((typeof data11 == "number") && (isFinite(data11))){if(data11 < 0 || isNaN(data11)){const err28 = {instancePath:instancePath+"/streamSeq",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/streamSeq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}}if(data.generation_epoch !== undefined){let data12 = data.generation_epoch;if((!(((typeof data12 == "number") && (!(data12 % 1) && !isNaN(data12))) && (isFinite(data12)))) && (data12 !== null)){const err29 = {instancePath:instancePath+"/generation_epoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generation_epoch/type",keyword:"type",params:{type: schema36.properties.generation_epoch.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}if((typeof data12 == "number") && (isFinite(data12))){if(data12 < 0 || isNaN(data12)){const err30 = {instancePath:instancePath+"/generation_epoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generation_epoch/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}}if(data.generationEpoch !== undefined){let data13 = data.generationEpoch;if((!(((typeof data13 == "number") && (!(data13 % 1) && !isNaN(data13))) && (isFinite(data13)))) && (data13 !== null)){const err31 = {instancePath:instancePath+"/generationEpoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generationEpoch/type",keyword:"type",params:{type: schema36.properties.generationEpoch.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if((typeof data13 == "number") && (isFinite(data13))){if(data13 < 0 || isNaN(data13)){const err32 = {instancePath:instancePath+"/generationEpoch",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/generationEpoch/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}}}if(data.task_id !== undefined){let data14 = data.task_id;if((typeof data14 !== "string") && (data14 !== null)){const err33 = {instancePath:instancePath+"/task_id",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/task_id/type",keyword:"type",params:{type: schema36.properties.task_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}}if(data.taskId !== undefined){let data15 = data.taskId;if((typeof data15 !== "string") && (data15 !== null)){const err34 = {instancePath:instancePath+"/taskId",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/taskId/type",keyword:"type",params:{type: schema36.properties.taskId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}}if(data.turn_id !== undefined){let data16 = data.turn_id;if((typeof data16 !== "string") && (data16 !== null)){const err35 = {instancePath:instancePath+"/turn_id",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/turn_id/type",keyword:"type",params:{type: schema36.properties.turn_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}}if(data.turnId !== undefined){let data17 = data.turnId;if((typeof data17 !== "string") && (data17 !== null)){const err36 = {instancePath:instancePath+"/turnId",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/turnId/type",keyword:"type",params:{type: schema36.properties.turnId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}}if(data.emitted_at !== undefined){let data18 = data.emitted_at;if((!(((typeof data18 == "number") && (!(data18 % 1) && !isNaN(data18))) && (isFinite(data18)))) && (data18 !== null)){const err37 = {instancePath:instancePath+"/emitted_at",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emitted_at/type",keyword:"type",params:{type: schema36.properties.emitted_at.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}if((typeof data18 == "number") && (isFinite(data18))){if(data18 < 0 || isNaN(data18)){const err38 = {instancePath:instancePath+"/emitted_at",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emitted_at/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}}}if(data.emittedAt !== undefined){let data19 = data.emittedAt;if((!(((typeof data19 == "number") && (!(data19 % 1) && !isNaN(data19))) && (isFinite(data19)))) && (data19 !== null)){const err39 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emittedAt/type",keyword:"type",params:{type: schema36.properties.emittedAt.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}if((typeof data19 == "number") && (isFinite(data19))){if(data19 < 0 || isNaN(data19)){const err40 = {instancePath:instancePath+"/emittedAt",schemaPath:"#/$defs/ConversationEventIdentityPayload/properties/emittedAt/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}}}}else {const err41 = {instancePath,schemaPath:"#/$defs/ConversationEventIdentityPayload/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema_version !== undefined){if(data.schema_version !== null){const err42 = {instancePath:instancePath+"/schema_version",schemaPath:"#/allOf/1/properties/schema_version/type",keyword:"type",params:{type: "null"},message:"must be null"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}}}else {const err43 = {instancePath,schemaPath:"#/allOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}validate27.errors = vErrors;return errors === 0;}validate27.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate30(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate30.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.event === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "event"},message:"must have required property '"+"event"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.type !== undefined){if("event" !== data.type){const err1 = {instancePath:instancePath+"/type",schemaPath:"#/properties/type/const",keyword:"const",params:{allowedValue: "event"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.event !== undefined){let data1 = data.event;if(typeof data1 === "string"){if(!pattern4.test(data1)){const err2 = {instancePath:instancePath+"/event",schemaPath:"#/properties/event/pattern",keyword:"pattern",params:{pattern: "^(?:chat\\.done|session\\.(?:epoch_changed|event(?:\\.[a-z0-9_]+)+)|task\\.[a-z0-9_]+)$"},message:"must match pattern \""+"^(?:chat\\.done|session\\.(?:epoch_changed|event(?:\\.[a-z0-9_]+)+)|task\\.[a-z0-9_]+)$"+"\""};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}else {const err3 = {instancePath:instancePath+"/event",schemaPath:"#/properties/event/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.payload !== undefined){let data2 = data.payload;const _errs6 = errors;let valid1 = false;const _errs7 = errors;if(!(validate23(data2, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate23.errors : vErrors.concat(validate23.errors);errors = vErrors.length;}var _valid0 = _errs7 === errors;valid1 = valid1 || _valid0;if(_valid0){var props0 = true;}const _errs8 = errors;if(!(validate27(data2, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate27.errors : vErrors.concat(validate27.errors);errors = vErrors.length;}var _valid0 = _errs8 === errors;valid1 = valid1 || _valid0;if(_valid0){if(props0 !== true){props0 = true;}}const _errs9 = errors;const _errs10 = errors;const _errs11 = errors;if(!(data2 && typeof data2 == "object" && !Array.isArray(data2))){const err4 = {};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}var valid2 = _errs11 === errors;if(valid2){const err5 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/anyOf/2/not",keyword:"not",params:{},message:"must NOT be valid"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}else {errors = _errs10;if(vErrors !== null){if(_errs10){vErrors.length = _errs10;}else {vErrors = null;}}}var _valid0 = _errs9 === errors;valid1 = valid1 || _valid0;if(!valid1){const err6 = {instancePath:instancePath+"/payload",schemaPath:"#/properties/payload/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}else {errors = _errs6;if(vErrors !== null){if(_errs6){vErrors.length = _errs6;}else {vErrors = null;}}}}if(data.meta !== undefined){let data3 = data.meta;if((!(data3 && typeof data3 == "object" && !Array.isArray(data3))) && (data3 !== null)){const err7 = {instancePath:instancePath+"/meta",schemaPath:"#/properties/meta/type",keyword:"type",params:{type: schema33.properties.meta.type},message:"must be object,null"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.seq !== undefined){let data4 = data.seq;if((!(((typeof data4 == "number") && (!(data4 % 1) && !isNaN(data4))) && (isFinite(data4)))) && (data4 !== null)){const err8 = {instancePath:instancePath+"/seq",schemaPath:"#/properties/seq/type",keyword:"type",params:{type: schema33.properties.seq.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if((typeof data4 == "number") && (isFinite(data4))){if(data4 < 0 || isNaN(data4)){const err9 = {instancePath:instancePath+"/seq",schemaPath:"#/properties/seq/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}}if(data.state_version !== undefined){let data5 = data.state_version;if((!(data5 && typeof data5 == "object" && !Array.isArray(data5))) && (data5 !== null)){const err10 = {instancePath:instancePath+"/state_version",schemaPath:"#/properties/state_version/type",keyword:"type",params:{type: schema33.properties.state_version.type},message:"must be object,null"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}}else {const err11 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}validate30.errors = vErrors;return errors === 0;}validate30.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="urn:opensquilla:contract:v4:ConversationEventFrame" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(!(validate30(data, {instancePath,parentData,parentDataProperty,rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate30.errors : vErrors.concat(validate30.errors);errors = vErrors.length;}validate20.errors = vErrors;return errors === 0;}validate20.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; diff --git a/opensquilla-webui/src/contracts/generated/v4/modelsCapacityResolve.ts b/opensquilla-webui/src/contracts/generated/v4/modelsCapacityResolve.ts new file mode 100644 index 0000000000..dedfab1f52 --- /dev/null +++ b/opensquilla-webui/src/contracts/generated/v4/modelsCapacityResolve.ts @@ -0,0 +1,92 @@ +// @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +// source-sha256: c11092c6291e788817b45b8f9dbfb9f363230872c8b0509d554f558258b9fa61 + +export interface OpenSquillaModelsCapacityResolveContract { + request?: RequestFrame; + response?: ResponseFrame; +} +/** + * This interface was referenced by `OpenSquillaModelsCapacityResolveContract`'s JSON-Schema + * via the `definition` "RequestFrame". + */ +export interface RequestFrame { + type: "req"; + id: string; + method: "models.capacity.resolve"; + [k: string]: unknown; +} +/** + * This interface was referenced by `OpenSquillaModelsCapacityResolveContract`'s JSON-Schema + * via the `definition` "ResponseFrame". + */ +export interface ResponseFrame { + type: "res"; + id: string; + ok: boolean; + payload?: Result; + error?: RpcError; + [k: string]: unknown; +} +/** + * This interface was referenced by `OpenSquillaModelsCapacityResolveContract`'s JSON-Schema + * via the `definition` "Result". + */ +export interface Result { + models: Capacity[]; +} +/** + * This interface was referenced by `OpenSquillaModelsCapacityResolveContract`'s JSON-Schema + * via the `definition` "Capacity". + */ +export interface Capacity { + provider: string; + model: string; + contextWindow: Limit; + maxOutputTokens: Limit; + localRuntime: boolean; +} +/** + * This interface was referenced by `OpenSquillaModelsCapacityResolveContract`'s JSON-Schema + * via the `definition` "Limit". + */ +export interface Limit { + automatic: number; + automaticSource: "catalog" | "default" | "override" | "config"; + override: number | null; + value: number; + source: "catalog" | "default" | "override" | "config"; + editable: boolean; +} +/** + * This interface was referenced by `OpenSquillaModelsCapacityResolveContract`'s JSON-Schema + * via the `definition` "RpcError". + */ +export interface RpcError { + [k: string]: unknown; +} +/** + * This interface was referenced by `OpenSquillaModelsCapacityResolveContract`'s JSON-Schema + * via the `definition` "Params". + */ +export interface Params { + /** + * @maxItems 128 + */ + models: Target[]; +} +/** + * This interface was referenced by `OpenSquillaModelsCapacityResolveContract`'s JSON-Schema + * via the `definition` "Target". + */ +export interface Target { + provider: string; + model: string; +} + +export const MODELS_CAPACITY_RESOLVE_CONTRACT_KIND = "query" as const +export const MODELS_CAPACITY_RESOLVE_METHOD = "models.capacity.resolve" as const +export const MODELS_CAPACITY_RESOLVE_SCOPE = "operator.read" as const +export const MODELS_CAPACITY_RESOLVE_IDEMPOTENCY = "read-only" as const +export const MODELS_CAPACITY_RESOLVE_TIMEOUT = {"policy":"transport"} as const +export const MODELS_CAPACITY_RESOLVE_CAPABILITY = {"kind":"method-availability","name":"models.capacity.resolve"} as const +export const MODELS_CAPACITY_RESOLVE_ERRORS = [{"code":"INVALID_REQUEST"},{"code":"UNAUTHORIZED"},{"code":"UNAVAILABLE","retryable":true},{"code":"INTERNAL_ERROR"}] as const diff --git a/opensquilla-webui/src/contracts/generated/v4/modelsCapacityResolveValidators.d.mts b/opensquilla-webui/src/contracts/generated/v4/modelsCapacityResolveValidators.d.mts new file mode 100644 index 0000000000..6fb8a8dbed --- /dev/null +++ b/opensquilla-webui/src/contracts/generated/v4/modelsCapacityResolveValidators.d.mts @@ -0,0 +1,10 @@ +// @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +// source-sha256: c11092c6291e788817b45b8f9dbfb9f363230872c8b0509d554f558258b9fa61 + +export interface ContractValidator { + (value: unknown): boolean + errors?: readonly unknown[] | null +} + +export const validateParams: ContractValidator +export const validateResult: ContractValidator diff --git a/opensquilla-webui/src/contracts/generated/v4/modelsCapacityResolveValidators.mjs b/opensquilla-webui/src/contracts/generated/v4/modelsCapacityResolveValidators.mjs new file mode 100644 index 0000000000..04c64b321f --- /dev/null +++ b/opensquilla-webui/src/contracts/generated/v4/modelsCapacityResolveValidators.mjs @@ -0,0 +1,19 @@ +// @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +// source-sha256: c11092c6291e788817b45b8f9dbfb9f363230872c8b0509d554f558258b9fa61 + +function __opensquillaAjvUcs2Length(str) { + const len = str.length + let length = 0 + let pos = 0 + let value + while (pos < len) { + length++ + value = str.charCodeAt(pos++) + if (value >= 0xd800 && value <= 0xdbff && pos < len) { + value = str.charCodeAt(pos) + if ((value & 0xfc00) === 0xdc00) pos++ + } + } + return length +} +"use strict";export const validateParams = validate20;const schema31 = {"$id":"urn:opensquilla:contract:v4:Params","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/platform/models-capacity-resolve.schema.json#/$defs/Params"};const schema40 = {"type":"object","additionalProperties":false,"required":["models"],"properties":{"models":{"type":"array","maxItems":128,"items":{"$ref":"#/$defs/Target"}}}};const schema41 = {"type":"object","additionalProperties":false,"required":["provider","model"],"properties":{"provider":{"type":"string","minLength":1,"maxLength":1024,"pattern":"\\S"},"model":{"type":"string","minLength":1,"maxLength":1024,"pattern":"\\S"}}};const func1 = __opensquillaAjvUcs2Length;const pattern4 = new RegExp("\\S", "u");function validate28(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate28.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.models === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "models"},message:"must have required property '"+"models"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}for(const key0 in data){if(!(key0 === "models")){const err1 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.models !== undefined){let data0 = data.models;if(Array.isArray(data0)){if(data0.length > 128){const err2 = {instancePath:instancePath+"/models",schemaPath:"#/properties/models/maxItems",keyword:"maxItems",params:{limit: 128},message:"must NOT have more than 128 items"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}const len0 = data0.length;for(let i0=0; i0 1024){const err6 = {instancePath:instancePath+"/models/" + i0+"/provider",schemaPath:"#/$defs/Target/properties/provider/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(func1(data2) < 1){const err7 = {instancePath:instancePath+"/models/" + i0+"/provider",schemaPath:"#/$defs/Target/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(!pattern4.test(data2)){const err8 = {instancePath:instancePath+"/models/" + i0+"/provider",schemaPath:"#/$defs/Target/properties/provider/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}else {const err9 = {instancePath:instancePath+"/models/" + i0+"/provider",schemaPath:"#/$defs/Target/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data1.model !== undefined){let data3 = data1.model;if(typeof data3 === "string"){if(func1(data3) > 1024){const err10 = {instancePath:instancePath+"/models/" + i0+"/model",schemaPath:"#/$defs/Target/properties/model/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(func1(data3) < 1){const err11 = {instancePath:instancePath+"/models/" + i0+"/model",schemaPath:"#/$defs/Target/properties/model/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(!pattern4.test(data3)){const err12 = {instancePath:instancePath+"/models/" + i0+"/model",schemaPath:"#/$defs/Target/properties/model/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}else {const err13 = {instancePath:instancePath+"/models/" + i0+"/model",schemaPath:"#/$defs/Target/properties/model/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}}else {const err14 = {instancePath:instancePath+"/models/" + i0,schemaPath:"#/$defs/Target/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}else {const err15 = {instancePath:instancePath+"/models",schemaPath:"#/properties/models/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}}else {const err16 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}validate28.errors = vErrors;return errors === 0;}validate28.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="urn:opensquilla:contract:v4:Params" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(!(validate28(data, {instancePath,parentData,parentDataProperty,rootData,dynamicAnchors}))){vErrors = vErrors === null ? validate28.errors : vErrors.concat(validate28.errors);errors = vErrors.length;}validate20.errors = vErrors;return errors === 0;}validate20.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};export const validateResult = validate30;const schema42 = {"$id":"urn:opensquilla:contract:v4:Result","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/platform/models-capacity-resolve.schema.json#/$defs/Result"};const schema35 = {"type":"object","additionalProperties":false,"required":["models"],"properties":{"models":{"type":"array","items":{"$ref":"#/$defs/Capacity"}}}};const schema36 = {"type":"object","additionalProperties":false,"required":["provider","model","contextWindow","maxOutputTokens","localRuntime"],"properties":{"provider":{"type":"string","minLength":1,"maxLength":1024},"model":{"type":"string","minLength":1,"maxLength":1024},"contextWindow":{"$ref":"#/$defs/Limit"},"maxOutputTokens":{"$ref":"#/$defs/Limit"},"localRuntime":{"type":"boolean"}}};const schema37 = {"type":"object","additionalProperties":false,"required":["automatic","automaticSource","override","value","source","editable"],"properties":{"automatic":{"type":"integer","minimum":1},"automaticSource":{"type":"string","enum":["catalog","default","override","config"]},"override":{"type":["integer","null"],"minimum":1},"value":{"type":"integer","minimum":1},"source":{"type":"string","enum":["catalog","default","override","config"]},"editable":{"type":"boolean"}}};function validate24(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate24.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.provider === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.model === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "model"},message:"must have required property '"+"model"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.contextWindow === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "contextWindow"},message:"must have required property '"+"contextWindow"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.maxOutputTokens === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "maxOutputTokens"},message:"must have required property '"+"maxOutputTokens"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.localRuntime === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "localRuntime"},message:"must have required property '"+"localRuntime"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "provider") || (key0 === "model")) || (key0 === "contextWindow")) || (key0 === "maxOutputTokens")) || (key0 === "localRuntime"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.provider !== undefined){let data0 = data.provider;if(typeof data0 === "string"){if(func1(data0) > 1024){const err6 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(func1(data0) < 1){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}else {const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.model !== undefined){let data1 = data.model;if(typeof data1 === "string"){if(func1(data1) > 1024){const err9 = {instancePath:instancePath+"/model",schemaPath:"#/properties/model/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(func1(data1) < 1){const err10 = {instancePath:instancePath+"/model",schemaPath:"#/properties/model/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}else {const err11 = {instancePath:instancePath+"/model",schemaPath:"#/properties/model/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.contextWindow !== undefined){let data2 = data.contextWindow;if(data2 && typeof data2 == "object" && !Array.isArray(data2)){if(data2.automatic === undefined){const err12 = {instancePath:instancePath+"/contextWindow",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "automatic"},message:"must have required property '"+"automatic"+"'"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}if(data2.automaticSource === undefined){const err13 = {instancePath:instancePath+"/contextWindow",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "automaticSource"},message:"must have required property '"+"automaticSource"+"'"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}if(data2.override === undefined){const err14 = {instancePath:instancePath+"/contextWindow",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "override"},message:"must have required property '"+"override"+"'"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}if(data2.value === undefined){const err15 = {instancePath:instancePath+"/contextWindow",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "value"},message:"must have required property '"+"value"+"'"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}if(data2.source === undefined){const err16 = {instancePath:instancePath+"/contextWindow",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "source"},message:"must have required property '"+"source"+"'"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(data2.editable === undefined){const err17 = {instancePath:instancePath+"/contextWindow",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "editable"},message:"must have required property '"+"editable"+"'"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}for(const key1 in data2){if(!((((((key1 === "automatic") || (key1 === "automaticSource")) || (key1 === "override")) || (key1 === "value")) || (key1 === "source")) || (key1 === "editable"))){const err18 = {instancePath:instancePath+"/contextWindow",schemaPath:"#/$defs/Limit/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data2.automatic !== undefined){let data3 = data2.automatic;if(!(((typeof data3 == "number") && (!(data3 % 1) && !isNaN(data3))) && (isFinite(data3)))){const err19 = {instancePath:instancePath+"/contextWindow/automatic",schemaPath:"#/$defs/Limit/properties/automatic/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if((typeof data3 == "number") && (isFinite(data3))){if(data3 < 1 || isNaN(data3)){const err20 = {instancePath:instancePath+"/contextWindow/automatic",schemaPath:"#/$defs/Limit/properties/automatic/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}if(data2.automaticSource !== undefined){let data4 = data2.automaticSource;if(typeof data4 !== "string"){const err21 = {instancePath:instancePath+"/contextWindow/automaticSource",schemaPath:"#/$defs/Limit/properties/automaticSource/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}if(!((((data4 === "catalog") || (data4 === "default")) || (data4 === "override")) || (data4 === "config"))){const err22 = {instancePath:instancePath+"/contextWindow/automaticSource",schemaPath:"#/$defs/Limit/properties/automaticSource/enum",keyword:"enum",params:{allowedValues: schema37.properties.automaticSource.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data2.override !== undefined){let data5 = data2.override;if((!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))) && (data5 !== null)){const err23 = {instancePath:instancePath+"/contextWindow/override",schemaPath:"#/$defs/Limit/properties/override/type",keyword:"type",params:{type: schema37.properties.override.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}if((typeof data5 == "number") && (isFinite(data5))){if(data5 < 1 || isNaN(data5)){const err24 = {instancePath:instancePath+"/contextWindow/override",schemaPath:"#/$defs/Limit/properties/override/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}}if(data2.value !== undefined){let data6 = data2.value;if(!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))){const err25 = {instancePath:instancePath+"/contextWindow/value",schemaPath:"#/$defs/Limit/properties/value/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}if((typeof data6 == "number") && (isFinite(data6))){if(data6 < 1 || isNaN(data6)){const err26 = {instancePath:instancePath+"/contextWindow/value",schemaPath:"#/$defs/Limit/properties/value/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}}}if(data2.source !== undefined){let data7 = data2.source;if(typeof data7 !== "string"){const err27 = {instancePath:instancePath+"/contextWindow/source",schemaPath:"#/$defs/Limit/properties/source/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err27];}else {vErrors.push(err27);}errors++;}if(!((((data7 === "catalog") || (data7 === "default")) || (data7 === "override")) || (data7 === "config"))){const err28 = {instancePath:instancePath+"/contextWindow/source",schemaPath:"#/$defs/Limit/properties/source/enum",keyword:"enum",params:{allowedValues: schema37.properties.source.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err28];}else {vErrors.push(err28);}errors++;}}if(data2.editable !== undefined){if(typeof data2.editable !== "boolean"){const err29 = {instancePath:instancePath+"/contextWindow/editable",schemaPath:"#/$defs/Limit/properties/editable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err29];}else {vErrors.push(err29);}errors++;}}}else {const err30 = {instancePath:instancePath+"/contextWindow",schemaPath:"#/$defs/Limit/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err30];}else {vErrors.push(err30);}errors++;}}if(data.maxOutputTokens !== undefined){let data9 = data.maxOutputTokens;if(data9 && typeof data9 == "object" && !Array.isArray(data9)){if(data9.automatic === undefined){const err31 = {instancePath:instancePath+"/maxOutputTokens",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "automatic"},message:"must have required property '"+"automatic"+"'"};if(vErrors === null){vErrors = [err31];}else {vErrors.push(err31);}errors++;}if(data9.automaticSource === undefined){const err32 = {instancePath:instancePath+"/maxOutputTokens",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "automaticSource"},message:"must have required property '"+"automaticSource"+"'"};if(vErrors === null){vErrors = [err32];}else {vErrors.push(err32);}errors++;}if(data9.override === undefined){const err33 = {instancePath:instancePath+"/maxOutputTokens",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "override"},message:"must have required property '"+"override"+"'"};if(vErrors === null){vErrors = [err33];}else {vErrors.push(err33);}errors++;}if(data9.value === undefined){const err34 = {instancePath:instancePath+"/maxOutputTokens",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "value"},message:"must have required property '"+"value"+"'"};if(vErrors === null){vErrors = [err34];}else {vErrors.push(err34);}errors++;}if(data9.source === undefined){const err35 = {instancePath:instancePath+"/maxOutputTokens",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "source"},message:"must have required property '"+"source"+"'"};if(vErrors === null){vErrors = [err35];}else {vErrors.push(err35);}errors++;}if(data9.editable === undefined){const err36 = {instancePath:instancePath+"/maxOutputTokens",schemaPath:"#/$defs/Limit/required",keyword:"required",params:{missingProperty: "editable"},message:"must have required property '"+"editable"+"'"};if(vErrors === null){vErrors = [err36];}else {vErrors.push(err36);}errors++;}for(const key2 in data9){if(!((((((key2 === "automatic") || (key2 === "automaticSource")) || (key2 === "override")) || (key2 === "value")) || (key2 === "source")) || (key2 === "editable"))){const err37 = {instancePath:instancePath+"/maxOutputTokens",schemaPath:"#/$defs/Limit/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err37];}else {vErrors.push(err37);}errors++;}}if(data9.automatic !== undefined){let data10 = data9.automatic;if(!(((typeof data10 == "number") && (!(data10 % 1) && !isNaN(data10))) && (isFinite(data10)))){const err38 = {instancePath:instancePath+"/maxOutputTokens/automatic",schemaPath:"#/$defs/Limit/properties/automatic/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err38];}else {vErrors.push(err38);}errors++;}if((typeof data10 == "number") && (isFinite(data10))){if(data10 < 1 || isNaN(data10)){const err39 = {instancePath:instancePath+"/maxOutputTokens/automatic",schemaPath:"#/$defs/Limit/properties/automatic/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err39];}else {vErrors.push(err39);}errors++;}}}if(data9.automaticSource !== undefined){let data11 = data9.automaticSource;if(typeof data11 !== "string"){const err40 = {instancePath:instancePath+"/maxOutputTokens/automaticSource",schemaPath:"#/$defs/Limit/properties/automaticSource/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err40];}else {vErrors.push(err40);}errors++;}if(!((((data11 === "catalog") || (data11 === "default")) || (data11 === "override")) || (data11 === "config"))){const err41 = {instancePath:instancePath+"/maxOutputTokens/automaticSource",schemaPath:"#/$defs/Limit/properties/automaticSource/enum",keyword:"enum",params:{allowedValues: schema37.properties.automaticSource.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err41];}else {vErrors.push(err41);}errors++;}}if(data9.override !== undefined){let data12 = data9.override;if((!(((typeof data12 == "number") && (!(data12 % 1) && !isNaN(data12))) && (isFinite(data12)))) && (data12 !== null)){const err42 = {instancePath:instancePath+"/maxOutputTokens/override",schemaPath:"#/$defs/Limit/properties/override/type",keyword:"type",params:{type: schema37.properties.override.type},message:"must be integer,null"};if(vErrors === null){vErrors = [err42];}else {vErrors.push(err42);}errors++;}if((typeof data12 == "number") && (isFinite(data12))){if(data12 < 1 || isNaN(data12)){const err43 = {instancePath:instancePath+"/maxOutputTokens/override",schemaPath:"#/$defs/Limit/properties/override/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err43];}else {vErrors.push(err43);}errors++;}}}if(data9.value !== undefined){let data13 = data9.value;if(!(((typeof data13 == "number") && (!(data13 % 1) && !isNaN(data13))) && (isFinite(data13)))){const err44 = {instancePath:instancePath+"/maxOutputTokens/value",schemaPath:"#/$defs/Limit/properties/value/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err44];}else {vErrors.push(err44);}errors++;}if((typeof data13 == "number") && (isFinite(data13))){if(data13 < 1 || isNaN(data13)){const err45 = {instancePath:instancePath+"/maxOutputTokens/value",schemaPath:"#/$defs/Limit/properties/value/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err45];}else {vErrors.push(err45);}errors++;}}}if(data9.source !== undefined){let data14 = data9.source;if(typeof data14 !== "string"){const err46 = {instancePath:instancePath+"/maxOutputTokens/source",schemaPath:"#/$defs/Limit/properties/source/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err46];}else {vErrors.push(err46);}errors++;}if(!((((data14 === "catalog") || (data14 === "default")) || (data14 === "override")) || (data14 === "config"))){const err47 = {instancePath:instancePath+"/maxOutputTokens/source",schemaPath:"#/$defs/Limit/properties/source/enum",keyword:"enum",params:{allowedValues: schema37.properties.source.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err47];}else {vErrors.push(err47);}errors++;}}if(data9.editable !== undefined){if(typeof data9.editable !== "boolean"){const err48 = {instancePath:instancePath+"/maxOutputTokens/editable",schemaPath:"#/$defs/Limit/properties/editable/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err48];}else {vErrors.push(err48);}errors++;}}}else {const err49 = {instancePath:instancePath+"/maxOutputTokens",schemaPath:"#/$defs/Limit/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err49];}else {vErrors.push(err49);}errors++;}}if(data.localRuntime !== undefined){if(typeof data.localRuntime !== "boolean"){const err50 = {instancePath:instancePath+"/localRuntime",schemaPath:"#/properties/localRuntime/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err50];}else {vErrors.push(err50);}errors++;}}}else {const err51 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err51];}else {vErrors.push(err51);}errors++;}validate24.errors = vErrors;return errors === 0;}validate24.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate31(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate31.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.models === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "models"},message:"must have required property '"+"models"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}for(const key0 in data){if(!(key0 === "models")){const err1 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.models !== undefined){let data0 = data.models;if(Array.isArray(data0)){const len0 = data0.length;for(let i0=0; i0 export type ConversationAnswerReset = WithIdentity<'old_generation_epoch' | 'new_generation_epoch' | 'preserve_completed_tools' | 'authoritative_text_snapshot' | 'authoritative_reasoning_snapshot' | 'sequence' | 'terminal' | 'terminal_text_snapshot'> export type ConversationSubagentCompletion = WithIdentity<'type' | 'parent_session_key' | 'child_session_key' | 'status' | 'terminal_reason' | 'message_id'> & { result?: { text?: string; [key: string]: unknown } } -export type ConversationLifecycle = WithIdentity<'reason' | 'status' | 'run_status' | 'terminal_message' | 'terminal_reason' | 'message' | 'code' | 'error_class' | 'group_id' | 'to_state' | 'active_task' | 'last_task' | 'changed_task' | 'terminalOutcome'> +export type ConversationLifecycle = WithIdentity<'reason' | 'status' | 'run_status' | 'terminal_message' | 'terminal_reason' | 'message' | 'code' | 'error_class' | 'group_id' | 'to_state' | 'active_task' | 'last_task' | 'changed_task' | 'terminalOutcome' | 'modelCapacity'> export type ConversationTurnCompletion = ConversationLifecycle & WithIdentity<'finalText' | 'completedTurnId' | 'usage' | 'text' | 'reasoning_content' | 'delivery' | 'suppression_reason' | 'input_mode' | 'run_kind' | 'model_call_segments'> export type ConversationCommittedTurn = WithIdentity<'status' | 'terminal_reason' | 'finished_at' | 'session_id' | 'client_message_id' | 'user_message_id' | 'surface_id'> export type ConversationInputDisposition = WithIdentity<'target_turn_id' | 'client_request_id' | 'client_message_id' | 'user_message_id' | 'disposition' | 'promoted_from_turn_id' | 'promoted_turn_id' | 'applied_iteration' | 'model_call_id' | 'failure_code' | 'retryable' | 'recovery' | 'fallback_safe' | 'revision' | 'intent'> diff --git a/opensquilla-webui/src/modules/providerConfiguration.ts b/opensquilla-webui/src/modules/providerConfiguration.ts index adf3f1befd..77f35f27c4 100644 --- a/opensquilla-webui/src/modules/providerConfiguration.ts +++ b/opensquilla-webui/src/modules/providerConfiguration.ts @@ -131,6 +131,30 @@ export interface ProviderCatalog { export interface ModelCatalog { list(options?: { signal?: AbortSignal }): Promise + readonly capacitySupported?: boolean + resolveCapacity?(models: readonly ModelCapacityTarget[]): Promise<{ models: ModelCapacity[] }> +} + +export interface ModelCapacityFailure { + provider: string + model: string + contextWindow: number + source: ModelCapacitySource +} +export interface ModelCapacityTarget { provider: string; model: string } +export type ModelCapacitySource = 'catalog' | 'default' | 'override' | 'config' +export interface ModelCapacityLimit { + automatic: number + automaticSource: ModelCapacitySource + override: number | null + value: number + source: ModelCapacitySource + editable: boolean +} +export interface ModelCapacity extends ModelCapacityTarget { + contextWindow: ModelCapacityLimit + maxOutputTokens: ModelCapacityLimit + localRuntime: boolean } export interface ProviderStatus { diff --git a/opensquilla-webui/src/types/chat.ts b/opensquilla-webui/src/types/chat.ts index 7900a98b2d..9842e8cee0 100644 --- a/opensquilla-webui/src/types/chat.ts +++ b/opensquilla-webui/src/types/chat.ts @@ -756,6 +756,7 @@ export interface ChatMessage { /** Typed terminal error code (e.g. 'sandbox_threshold_exceeded') carried on * role:'error' messages so the renderer can offer a recovery action. */ errorCode?: string + modelCapacity?: import('@/modules/providerConfiguration').ModelCapacityFailure } export interface ChatMessageMeta { @@ -865,4 +866,5 @@ export interface ChatRenderedMessage { /** Typed terminal error code, propagated from the raw message so the error * card can render a recovery action (e.g. resume after a sandbox pause). */ errorCode?: string + modelCapacity?: import('@/modules/providerConfiguration').ModelCapacityFailure } diff --git a/scripts/contracts/tests/production_targets.test.mjs b/scripts/contracts/tests/production_targets.test.mjs index c021c29f42..8f3f6af06b 100644 --- a/scripts/contracts/tests/production_targets.test.mjs +++ b/scripts/contracts/tests/production_targets.test.mjs @@ -24,8 +24,14 @@ test('production cannot import or re-export the verification compiler', () => { test('production references exactly match the reviewed target policy', () => { const result = evaluateProductionTargets() assert.deepEqual(result.failures, []) - assert.equal(result.targets.length, 217) assert.ok(result.targets.includes('method:skills.install.status:result')) + assert.equal(result.targets.length, 219) + assert.deepEqual(result.targets.filter(target => ( + target.startsWith('method:models.capacity.resolve:') + )), [ + 'method:models.capacity.resolve:params', + 'method:models.capacity.resolve:result', + ]) assert.deepEqual(result.targets.filter(target => ( target.startsWith('method:telemetry.product_active.record:') )), ['method:telemetry.product_active.record:result']) diff --git a/scripts/contracts/verify_gateway_validator_profiles.mjs b/scripts/contracts/verify_gateway_validator_profiles.mjs index 0d0393b4b1..6966a46aa2 100644 --- a/scripts/contracts/verify_gateway_validator_profiles.mjs +++ b/scripts/contracts/verify_gateway_validator_profiles.mjs @@ -107,8 +107,8 @@ export async function verifyProfiles({ baselineRoot, verificationRoot } = {}) { result.roles++ } } - assert.equal(result.roles, 870) - assert.equal(result.comparedRoles, baselineRoot ? 869 : selected.size) + assert.equal(result.roles, 874) + assert.equal(result.comparedRoles, baselineRoot ? 873 : selected.size) assert.deepEqual(result.rolesWithoutPositiveSeed, [], 'each role requires a positive seed') if (baselineRoot) assert.deepEqual(result.supplementalRoles, [ 'method:sessions.list:params', diff --git a/src/opensquilla/contracts/generated/v4/conversation_events.py b/src/opensquilla/contracts/generated/v4/conversation_events.py index 698c355908..ded55b79ac 100644 --- a/src/opensquilla/contracts/generated/v4/conversation_events.py +++ b/src/opensquilla/contracts/generated/v4/conversation_events.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# source-sha256: b48e13df3b46c5f9d4594d02f5534a9aebf307c587ab9f7b9ffe99f0601b09ca +# source-sha256: ba10d11fd34519ad9801b388f5f3f64b2e2482abc84e19a80d674948db6dae2e # ruff: noqa # generated by datamodel-codegen: @@ -7,6 +7,7 @@ from __future__ import annotations +from enum import Enum from typing import Annotated, Any, Literal from pydantic import ( @@ -41,6 +42,23 @@ def _validate_json_integer(value: Any) -> int | float: WithJsonSchema({'type': 'integer'}), ] +class Source(Enum): + default = 'default' + catalog = 'catalog' + config = 'config' + override = 'override' + + +class ModelCapacity(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + provider: StrictStr = Field(..., max_length=1024, min_length=1) + model: StrictStr = Field(..., max_length=1024, min_length=1) + contextWindow: _JsonInteger = Field(..., ge=1) + source: Source + + class ConversationEventIdentityPayload(BaseModel): """ Common identity and replay fields are documented here; event-specific fields remain open so old and additive producers keep their exact payload. @@ -49,6 +67,7 @@ class ConversationEventIdentityPayload(BaseModel): model_config = ConfigDict( extra='allow', ) + model_capacity: ModelCapacity | None = None key: StrictStr | None = Field(None, min_length=1) session_key: StrictStr | None = Field(None, min_length=1) sessionKey: StrictStr | None = Field(None, min_length=1) diff --git a/src/opensquilla/contracts/generated/v4/conversation_events_metadata.py b/src/opensquilla/contracts/generated/v4/conversation_events_metadata.py index b29ecc01ca..b27580c0a3 100644 --- a/src/opensquilla/contracts/generated/v4/conversation_events_metadata.py +++ b/src/opensquilla/contracts/generated/v4/conversation_events_metadata.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# source-sha256: b48e13df3b46c5f9d4594d02f5534a9aebf307c587ab9f7b9ffe99f0601b09ca +# source-sha256: ba10d11fd34519ad9801b388f5f3f64b2e2482abc84e19a80d674948db6dae2e # ruff: noqa from typing import Final diff --git a/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py b/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py index efca9c31a7..279a5224ce 100644 --- a/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py +++ b/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py @@ -1,6 +1,6 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# sources-sha256: c15d55f8c49a9f57db0492a403288aefdaa12fa1a3ef03a10684a408ce059cc6 -# generator-sha256: f781c6d8e31b336c2d2e342784935b3d327c9947cb35e56de8e44a0a83bffae6 +# sources-sha256: 55e5ff389393186fc3b3b67867518c87d23ab76e20c2e71bf380702e9ad1d5f4 +# generator-sha256: d2ba9f83594c975b279a95386cd247d50b6704d4b6feabd516c8d870ad827013 # ruff: noqa from dataclasses import dataclass @@ -383,6 +383,10 @@ from opensquilla.contracts.generated.v4.migration_sources_preview import RequestFrame as _migration_sources_preview_request_model from opensquilla.contracts.generated.v4.migration_sources_preview import ResponseFrame as _migration_sources_preview_response_model from opensquilla.contracts.generated.v4.migration_sources_preview import Result as _migration_sources_preview_result_model +from opensquilla.contracts.generated.v4.models_capacity_resolve import Params as _models_capacity_resolve_params_model +from opensquilla.contracts.generated.v4.models_capacity_resolve import RequestFrame as _models_capacity_resolve_request_model +from opensquilla.contracts.generated.v4.models_capacity_resolve import ResponseFrame as _models_capacity_resolve_response_model +from opensquilla.contracts.generated.v4.models_capacity_resolve import Result as _models_capacity_resolve_result_model from opensquilla.contracts.generated.v4.models_list import Params as _models_list_params_model from opensquilla.contracts.generated.v4.models_list import RequestFrame as _models_list_request_model from opensquilla.contracts.generated.v4.models_list import ResponseFrame as _models_list_response_model @@ -2316,6 +2320,22 @@ class GatewayEventContract: response_model=_migration_sources_preview_response_model, result_model=_migration_sources_preview_result_model, ), + 'models.capacity.resolve': GatewayMethodContract( + name='models.capacity.resolve', + kind='query', + scope='operator.read', + guest_allowed=False, + idempotency='read-only', + timeout={'policy': 'transport'}, + capability={'kind': 'method-availability', 'name': 'models.capacity.resolve'}, + errors=({'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'INTERNAL_ERROR'}), + protocol='opensquilla-websocket-json', + wire_version=4, + request_model=_models_capacity_resolve_request_model, + params_model=_models_capacity_resolve_params_model, + response_model=_models_capacity_resolve_response_model, + result_model=_models_capacity_resolve_result_model, + ), 'models.list': GatewayMethodContract( name='models.list', kind='query', diff --git a/src/opensquilla/contracts/generated/v4/models_capacity_resolve.py b/src/opensquilla/contracts/generated/v4/models_capacity_resolve.py new file mode 100644 index 0000000000..177139f7c1 --- /dev/null +++ b/src/opensquilla/contracts/generated/v4/models_capacity_resolve.py @@ -0,0 +1,136 @@ +# @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +# source-sha256: c11092c6291e788817b45b8f9dbfb9f363230872c8b0509d554f558258b9fa61 +# ruff: noqa + +# generated by datamodel-codegen: +# filename: models-capacity-resolve.schema.json + +from __future__ import annotations + +from enum import StrEnum +from typing import Any, Annotated, Literal + +from pydantic import ( + BeforeValidator, + WithJsonSchema, + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictStr, +) + + +def _validate_json_number(value: Any) -> int | float: + if type(value) is int: + return value + if type(value) is float and value == value and abs(value) != float('inf'): + return value + raise ValueError('expected a finite JSON number') + + +def _validate_json_integer(value: Any) -> int | float: + if type(value) is int: + return value + if type(value) is float and value.is_integer(): + return value + raise ValueError('expected an integral JSON number') + + +_JsonInteger = Annotated[ + int | float, + BeforeValidator(_validate_json_integer), + WithJsonSchema({'type': 'integer'}), +] + +class RpcError(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + + +class RequestFrame(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + type: Literal['req'] + id: StrictStr + method: Literal['models.capacity.resolve'] + + +class Target(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + provider: StrictStr = Field(..., max_length=1024, min_length=1, pattern='\\S') + model: StrictStr = Field(..., max_length=1024, min_length=1, pattern='\\S') + + +class AutomaticSource(StrEnum): + catalog = 'catalog' + default = 'default' + override = 'override' + config = 'config' + + +class Source(StrEnum): + catalog = 'catalog' + default = 'default' + override = 'override' + config = 'config' + + +class Limit(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + automatic: _JsonInteger = Field(..., ge=1) + automaticSource: AutomaticSource + override: _JsonInteger | None = Field(..., ge=1) + value: _JsonInteger = Field(..., ge=1) + source: Source + editable: StrictBool + + +class Capacity(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + provider: StrictStr = Field(..., max_length=1024, min_length=1) + model: StrictStr = Field(..., max_length=1024, min_length=1) + contextWindow: Limit + maxOutputTokens: Limit + localRuntime: StrictBool + + +class Params(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + models: list[Target] = Field(..., max_length=128) + + +class Result(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + models: list[Capacity] + + +class ResponseFrame(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + type: Literal['res'] + id: StrictStr + ok: StrictBool + payload: Result | None = None + error: RpcError | None = None + + +class OpensquillaModelsCapacityResolveContract(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + request: RequestFrame | None = None + response: ResponseFrame | None = None diff --git a/src/opensquilla/contracts/generated/v4/models_capacity_resolve_metadata.py b/src/opensquilla/contracts/generated/v4/models_capacity_resolve_metadata.py new file mode 100644 index 0000000000..d414519a03 --- /dev/null +++ b/src/opensquilla/contracts/generated/v4/models_capacity_resolve_metadata.py @@ -0,0 +1,13 @@ +# @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. +# source-sha256: c11092c6291e788817b45b8f9dbfb9f363230872c8b0509d554f558258b9fa61 +# ruff: noqa + +from typing import Final + +MODELS_CAPACITY_RESOLVE_CONTRACT_KIND: Final = 'query' +MODELS_CAPACITY_RESOLVE_METHOD: Final = 'models.capacity.resolve' +MODELS_CAPACITY_RESOLVE_SCOPE: Final = 'operator.read' +MODELS_CAPACITY_RESOLVE_IDEMPOTENCY: Final = 'read-only' +MODELS_CAPACITY_RESOLVE_TIMEOUT: Final = {'policy': 'transport'} +MODELS_CAPACITY_RESOLVE_CAPABILITY: Final = {'kind': 'method-availability', 'name': 'models.capacity.resolve'} +MODELS_CAPACITY_RESOLVE_ERRORS: Final = [{'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'INTERNAL_ERROR'}] diff --git a/src/opensquilla/engine/agent.py b/src/opensquilla/engine/agent.py index 00ce501072..6eded69f66 100644 --- a/src/opensquilla/engine/agent.py +++ b/src/opensquilla/engine/agent.py @@ -2682,6 +2682,27 @@ def _publish_tool_reliability( error_type=type(exc).__name__, ) + def _context_capacity_details(self) -> dict[str, Any] | None: + from opensquilla.provider.model_catalog import ( + resolve_effective_context_window, + shared_catalog, + ) + + provider = str(self.config.provider_id or "").strip() + model = str(self.config.model_id or "").strip() + if not provider or not model: + return None + window, source = resolve_effective_context_window( + shared_catalog(), model, provider, + self.config.context_window_tokens_global_override, + ) + # Only attach a model setting when its resolved window is the actual + # window that rejected this request. A wrapper's different physical + # member must not be misidentified as the outer model. + if window != self.config.context_window_tokens: + return None + return {"provider": provider, "model": model, "contextWindow": window, "source": source} + def _context_overflow_error(self) -> ErrorEvent: reason = self._last_compaction_refusal_reason if reason == "empty_summary_rejected": @@ -2703,6 +2724,7 @@ def _context_overflow_error(self) -> ErrorEvent: return ErrorEvent( message=CONTEXT_PAYLOAD_TOO_LARGE_MESSAGES[reason], code="provider_request_too_large", + model_capacity=self._context_capacity_details(), ) if reason in { "provider_native_overflow_after_admission", @@ -2715,6 +2737,7 @@ def _context_overflow_error(self) -> ErrorEvent: "a narrower current request or a larger-context model." ), code="provider_request_too_large", + model_capacity=self._context_capacity_details(), ) if reason == "provider_request_budget_exhausted": return ErrorEvent( @@ -2724,6 +2747,7 @@ def _context_overflow_error(self) -> ErrorEvent: "tools, or choose a larger-context model." ), code="provider_request_too_large", + model_capacity=self._context_capacity_details(), ) return ErrorEvent( message="Context overflow persists after compaction", diff --git a/src/opensquilla/engine/turn_runner/harness.py b/src/opensquilla/engine/turn_runner/harness.py index acd9879280..d3f4c65e0b 100644 --- a/src/opensquilla/engine/turn_runner/harness.py +++ b/src/opensquilla/engine/turn_runner/harness.py @@ -548,6 +548,12 @@ def lookup(self, model_id: str, provider: str = "") -> _ResolvedCatalog: user_context_window = _positive_int_or_zero( getattr(llm_cfg, "context_window_tokens", 0) ) + if provider and provider.strip().lower() != str( + getattr(llm_cfg, "provider", "") or "" + ).strip().lower(): + # The global context declaration belongs to the primary deployment; + # a routed model at another provider must use its own capacity. + user_context_window = 0 # Explicit provider-request proof budget (chars). Positive values bypass # the derived context-budget ladder in ContextBudgetGovernor.from_values. user_proof_max_chars = _positive_int_or_zero( diff --git a/src/opensquilla/engine/types.py b/src/opensquilla/engine/types.py index 1874c29e88..6a14a9ca25 100644 --- a/src/opensquilla/engine/types.py +++ b/src/opensquilla/engine/types.py @@ -277,6 +277,7 @@ class ErrorEvent: usage_call_index: int | None = None no_prior_provider_dispatch: bool | None = None replay_safe: bool | None = None + model_capacity: dict[str, Any] | None = None @dataclass diff --git a/src/opensquilla/gateway/adapters/platform_configuration_contract.py b/src/opensquilla/gateway/adapters/platform_configuration_contract.py index f71d08b66c..ff8ea0c6dc 100644 --- a/src/opensquilla/gateway/adapters/platform_configuration_contract.py +++ b/src/opensquilla/gateway/adapters/platform_configuration_contract.py @@ -22,6 +22,7 @@ "config.patch", "config.patch.safe", "models.list", + "models.capacity.resolve", "providers.status", "models.routing.get", "models.routing.set", diff --git a/src/opensquilla/gateway/adapters/provider_configuration.py b/src/opensquilla/gateway/adapters/provider_configuration.py index 9e181a9776..b75a236038 100644 --- a/src/opensquilla/gateway/adapters/provider_configuration.py +++ b/src/opensquilla/gateway/adapters/provider_configuration.py @@ -21,6 +21,7 @@ from opensquilla.gateway.provider_status_runtime import read_provider_status from opensquilla.gateway.setup_config_runtime import sync_media_runtime from opensquilla.provider.model_catalog import ModelCatalog as ProviderModelCatalog +from opensquilla.provider.model_catalog import shared_catalog _catalog = ProviderModelCatalog() @@ -71,6 +72,8 @@ def model_info_to_projection(model: dict[str, Any]) -> dict[str, Any]: provider_id = str(model.get("provider", "") or "") model_id = str(model.get("model_id", "") or "") + # Capacity is enriched separately from the shared runtime resolver. Keep + # capability/source projection independent of mutable session overrides. entry = _catalog.resolve_entry(model_id, provider=provider_id) capabilities: list[str] = ["chat"] context_window = model.get("context_window", 0) @@ -166,6 +169,39 @@ def __init__(self, provider_selector: Any, config: Any) -> None: self._config = config async def load_model_catalog(self) -> ModelCatalogResult: + from opensquilla.provider.model_capacity import ( + custom_capacity_identity, + install_custom_capacity, + resolve_model_capacities, + ) + + catalog = shared_catalog() + identities = {provider: custom_capacity_identity(self._config, provider) + for provider in ("custom", "custom_anthropic")} + + def project(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + for provider, identity in identities.items(): + selected = [item for item in items if item.get("provider") == provider] + if selected: + install_custom_capacity(catalog, identity, provider, selected) + projected = [model_info_to_projection(item) for item in items] + # Only custom endpoint rows need the shared capacity enrichment. + # Other providers can carry credential-scoped snapshot limits that + # must not be replaced by a different deployment's catalog entry. + capacities = resolve_model_capacities(catalog, self._config, [ + {"provider": str(item["provider"]), "model": str(item["id"])} + for item in projected + if item["provider"].strip().lower() in identities + ])["models"] + by_key = {(row["provider"], row["model"]): row for row in capacities} + for item in projected: + limits = by_key.get((item["provider"].strip().lower(), item["id"].strip())) + if limits is None: + continue + item["contextWindow"] = limits["contextWindow"]["value"] + item["maxOutputTokens"] = limits["maxOutputTokens"]["value"] + return projected + models: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = [] selector = self._provider_selector @@ -193,7 +229,7 @@ def snapshot_resolver(config: Any) -> Any: return cached_tokenrhythm_models(_snapshot_config_for_selector_leg(config)) detailed = await list_models_detailed(snapshot_resolver=snapshot_resolver) - models = [model_info_to_projection(item) for item in detailed.models] + models = project(detailed.models) errors = [model_list_error_to_projection(item) for item in detailed.errors] else: current = getattr(selector, "current_config", None) @@ -208,10 +244,10 @@ def snapshot_resolver(config: Any) -> Any: ) cached = cached_tokenrhythm_models(self._config) - models = [model_info_to_projection(item.model_dump()) for item in cached] + models = project([item.model_dump() for item in cached]) else: detailed = await list_models_detailed() - models = [model_info_to_projection(item) for item in detailed.models] + models = project(detailed.models) errors = [model_list_error_to_projection(item) for item in detailed.errors] except Exception: pass diff --git a/src/opensquilla/gateway/boot.py b/src/opensquilla/gateway/boot.py index 556191e181..e531758dbb 100644 --- a/src/opensquilla/gateway/boot.py +++ b/src/opensquilla/gateway/boot.py @@ -1958,6 +1958,7 @@ async def _emit_task_runtime_stream_events( raw_usage_call_index = event_dict.pop("usage_call_index", None) raw_no_prior_provider_dispatch = event_dict.pop("no_prior_provider_dispatch", None) raw_replay_safe = event_dict.pop("replay_safe", None) + model_capacity = event_dict.pop("model_capacity", None) # Keep the normalized provider classification internal to the # durable task outcome; it is not part of the public stream event. raw_failure_kind = event_dict.pop("failure_kind", None) @@ -2004,6 +2005,9 @@ async def _emit_task_runtime_stream_events( replay_safe=raw_replay_safe, ) terminal_payload.update(replay_proof) + if model_capacity is not None: + event_dict["model_capacity"] = model_capacity + terminal_payload["model_capacity"] = model_capacity terminal_message = build_terminal_reply(terminal_payload) # Additive ref suffix joining the reply to its durable turn_errors # row; absent when no record was written (error_id empty). @@ -2660,6 +2664,9 @@ def apply_model_catalog_overrides(catalog: ModelCatalog, config: GatewayConfig) value rather than dropping it silently. """ try: + from opensquilla.provider.model_capacity import sync_custom_capacity_endpoints + + sync_custom_capacity_endpoints(catalog, config) catalog.set_user_overrides(model_override_entries(config)) except ValueError as exc: log.warning("model_catalog.user_override_rejected", error=str(exc)) diff --git a/src/opensquilla/gateway/rpc_models.py b/src/opensquilla/gateway/rpc_models.py index 8ce4efb48d..39328313de 100644 --- a/src/opensquilla/gateway/rpc_models.py +++ b/src/opensquilla/gateway/rpc_models.py @@ -14,6 +14,15 @@ _d = get_dispatcher() +async def _handle_models_capacity_resolve(params: dict | None, ctx: RpcContext) -> dict[str, Any]: + from opensquilla.provider.model_capacity import resolve_model_capacities + from opensquilla.provider.model_catalog import shared_catalog + + if not isinstance(params, dict): + raise ValueError("params must be an object") + return resolve_model_capacities(shared_catalog(), ctx.config, params["models"]) + + async def _handle_models_list(params: dict | None, ctx: RpcContext) -> dict[str, Any]: from opensquilla.application.provider_configuration import ModelCatalog from opensquilla.gateway.adapters.provider_configuration import ( @@ -107,6 +116,7 @@ async def _handle_models_routing_reset_recommended( from opensquilla.gateway.rpc import RpcHandlerError # noqa: E402 _PLATFORM_CONFIGURATION_IMPLEMENTATIONS = { + "models.capacity.resolve": _handle_models_capacity_resolve, "models.list": _handle_models_list, "models.routing.get": _handle_models_routing_get, "models.routing.set": _handle_models_routing_set, diff --git a/src/opensquilla/gateway/rpc_onboarding.py b/src/opensquilla/gateway/rpc_onboarding.py index df2c504ff9..5319eb749c 100644 --- a/src/opensquilla/gateway/rpc_onboarding.py +++ b/src/opensquilla/gateway/rpc_onboarding.py @@ -1293,10 +1293,10 @@ async def _discover_primary_models( candidate credentials, so it must not be reachable at the read/write tiers even though it changes no state. - Selector discovery is fail-closed: only registry-verified providers on - their official hosts are queried. Self-hosted and arbitrary endpoints - remain manual-entry surfaces; raw CLI diagnostics retain their broader - endpoint-probing behavior. + Official-provider catalogs require registry-verified hosts. Explicit + custom providers can list their configured endpoint's models. Other + self-hosted endpoints retain manual entry; raw CLI diagnostics keep their + broader endpoint-probing behavior. Blank credentials fall back to the stored config's only while a supplied candidate Base URL remains same-origin; omitted Base URLs reuse the stored diff --git a/src/opensquilla/gateway/rpc_sessions.py b/src/opensquilla/gateway/rpc_sessions.py index 46017a643b..17c98e0a45 100644 --- a/src/opensquilla/gateway/rpc_sessions.py +++ b/src/opensquilla/gateway/rpc_sessions.py @@ -1824,6 +1824,8 @@ def _normalize_terminal_event_payload(event_name: str, payload: dict[str, Any]) safe_payload = { key: value for key, value in payload.items() if key not in sensitive_provider_fields } + if safe_payload.get("model_capacity") is None: + safe_payload.pop("model_capacity", None) return { **safe_payload, "code": code, diff --git a/src/opensquilla/gateway/scopes.py b/src/opensquilla/gateway/scopes.py index fae8b3fb5b..7df19cd106 100644 --- a/src/opensquilla/gateway/scopes.py +++ b/src/opensquilla/gateway/scopes.py @@ -179,6 +179,7 @@ "logs.tail": READ_SCOPE, "logs.trace": READ_SCOPE, "models.list": READ_SCOPE, + "models.capacity.resolve": READ_SCOPE, "models.routing.get": READ_SCOPE, "providers.status": READ_SCOPE, # OpenSquilla-only; non-consuming peek at a session's router-control hold diff --git a/src/opensquilla/onboarding/probe.py b/src/opensquilla/onboarding/probe.py index 40e46d7c5a..ab91ad28fc 100644 --- a/src/opensquilla/onboarding/probe.py +++ b/src/opensquilla/onboarding/probe.py @@ -701,19 +701,15 @@ def _discover_model_row(info: ModelInfo, provider_id: str) -> dict[str, object]: elif info.context_window > 0: context_window = info.context_window else: - context_window = entry.context_window - max_output = ( - info.max_output_tokens if info.max_output_tokens > 0 else entry.max_output_tokens - ) + context_window = catalog.resolve_context_window(info.model_id, provider_id) + max_output = info.max_output_tokens if info.max_output_tokens > 0 else entry.max_output_tokens tools = _metadata_capability(metadata, "tools") reasoning = _metadata_capability(metadata, "reasoning") vision = _metadata_capability(metadata, "vision") safe_tools = info.supports_tools or entry.supports_tools tools_enabled = False if tools is False else safe_tools safe_reasoning = info.supports_reasoning or entry.supports_reasoning - reasoning_enabled = ( - False if reasoning is False else safe_reasoning - ) + reasoning_enabled = False if reasoning is False else safe_reasoning safe_vision = info.supports_vision or entry.supports_vision vision_enabled = False if vision is False else safe_vision capabilities: list[str] = ["chat"] @@ -885,25 +881,58 @@ async def discover_selectable_provider_models( persist_catalog: bool = False, catalog_config: object | None = None, ) -> ProviderModelsDiscoverResult: - """Return only verified live catalogs suitable for a model picker. + """Return endpoint-declared custom models or verified official catalogs. This is the selector-facing policy boundary. Unknown and unsupported provider ids remain validation errors, matching raw discovery. All other - providers default to an empty, successful catalog *before* credential + non-custom providers default to an empty, successful catalog *before* credential resolution or provider construction, preserving the manual model-id escape hatch without presenting guessed data as authoritative. A trusted provider id is not enough on its own: an operator-supplied OpenAI-compatible re-host can serve a completely different model set. - Live selection is therefore allowed only when the effective base URL uses + Official-provider selection is allowed only when the effective base URL uses HTTPS and the provider's allowlisted official host (or one of its - subdomains). + subdomains). Explicit custom providers query their configured endpoint; + only declared capacity fields enter that provider's runtime metadata. """ provider_id = (provider_id or "").strip() spec = get_provider_spec(provider_id) # raises UnknownProviderError(ValueError) if not spec.runtime_supported: raise ValueError(f"Provider '{provider_id}' has no runtime support to discover.") + if provider_id in {"custom", "custom_anthropic"}: + from opensquilla.provider.model_capacity import ( + custom_capacity_identity, + install_custom_capacity, + resolve_model_capacities, + ) + from opensquilla.provider.model_catalog import shared_catalog + + identity = custom_capacity_identity(catalog_config, provider_id) + result = await discover_provider_models( + provider_id=provider_id, + api_key=api_key, + api_key_env=api_key_env, + base_url=base_url, + proxy=proxy, + allow_default_api_key_env=allow_default_api_key_env, + ) + if persist_catalog and result.ok and catalog_config is not None: + catalog = shared_catalog() + install_custom_capacity(catalog, identity, provider_id, result.models) + capacities = resolve_model_capacities( + catalog, + catalog_config, + [{"provider": provider_id, "model": str(row["id"])} for row in result.models], + )["models"] + by_model = {capacity["model"]: capacity for capacity in capacities} + for row in result.models: + capacity = by_model[str(row["id"])] + row["contextWindow"] = capacity["contextWindow"]["value"] + row["maxOutputTokens"] = capacity["maxOutputTokens"]["value"] + return result + if spec.selectable_model_catalog != "verified_live": return ProviderModelsDiscoverResult(ok=True, provider_id=provider_id) @@ -931,9 +960,7 @@ async def discover_selectable_provider_models( is_official_tokenrhythm_endpoint, ) - tokenrhythm_production_catalog = is_official_tokenrhythm_endpoint( - effective_base_url - ) + tokenrhythm_production_catalog = is_official_tokenrhythm_endpoint(effective_base_url) if tokenrhythm_production_catalog: default_env_key = spec.env_key if allow_default_api_key_env else "" @@ -969,9 +996,7 @@ async def discover_selectable_provider_models( # foreign hosts, and lookalike suffixes. Treat an admitted TokenRhythm # non-production origin as its own declared catalog authority. - discovery_provider_id = ( - spec.selectable_model_discovery_provider_id or provider_id - ) + discovery_provider_id = spec.selectable_model_discovery_provider_id or provider_id discover_kwargs: dict[str, Any] = { "provider_id": discovery_provider_id, "api_key": api_key, @@ -979,11 +1004,7 @@ async def discover_selectable_provider_models( # A sibling discovery provider owns a different protocol path. Its # registry default is the only trusted listing endpoint; never pass # the configured chat base path across protocols. - "base_url": ( - base_url.strip() - if discovery_provider_id == provider_id - else "" - ), + "base_url": (base_url.strip() if discovery_provider_id == provider_id else ""), "proxy": proxy, } if not allow_default_api_key_env: diff --git a/src/opensquilla/provider/anthropic.py b/src/opensquilla/provider/anthropic.py index 10b22ee4b8..05dab64d13 100644 --- a/src/opensquilla/provider/anthropic.py +++ b/src/opensquilla/provider/anthropic.py @@ -1282,7 +1282,7 @@ def _trace_tool_call(end_event: ToolUseEndEvent, raw: str) -> None: code="provider_internal", ) - async def list_models(self) -> list[ModelInfo]: + async def list_models(self, *, raise_on_error: bool = False) -> list[ModelInfo]: """Build listing rows for this provider identity from the shared catalog. The catalog's canonical costs are USD per million tokens; the @@ -1290,14 +1290,64 @@ async def list_models(self) -> list[ModelInfo]: renders per-1k), so entry costs are converted back (÷1000). Capability flags stay at ``ModelInfo`` defaults — the listing has only ever advertised identity, windows, and pricing. Native Anthropic - uses its built-in SKU list; compatibility endpoints receive an exact - registry list or the configured model from the selector. + uses its built-in SKU list. Explicit custom Anthropic endpoints read + their own model list, including declared capacity fields. """ + if self.provider_id == "custom_anthropic": + # Only an explicit model-list call reads this endpoint. Capacity + # resolution itself is offline and never performs discovery. + from .model_capacity import custom_listing_capacity + from .protocol import ProviderModelListingResponseError + + headers = {"anthropic-version": _ANTHROPIC_VERSION} + if self._api_key: + if self._auth_header_style == "bearer": + headers["Authorization"] = f"Bearer {self._api_key}" + else: + headers["x-api-key"] = self._api_key + try: + async with httpx.AsyncClient( + timeout=15.0, + trust_env=_trust_env(), + proxy=self._proxy, + ) as client: + response = await client.get(self._api_url("/v1/models"), headers=headers) + response.raise_for_status() + try: + data = response.json() + except ValueError: + raise ProviderModelListingResponseError( + "Provider model catalog returned invalid JSON", + status_code=response.status_code, + ) from None + models = data.get("data") if isinstance(data, dict) else None + if not isinstance(models, list): + raise ProviderModelListingResponseError( + "Provider model catalog response must contain a model list", + status_code=response.status_code, + ) + return [ + ModelInfo( + provider=self.provider_id, + model_id=row["id"], + display_name=str(row.get("display_name") or row["id"]), + context_window=custom_listing_capacity(row).get("context_window", 0), + max_output_tokens=custom_listing_capacity(row).get("max_output_tokens", 0), + metadata={"capacity": custom_listing_capacity(row)}, + ) + for row in models + if isinstance(row, dict) and isinstance(row.get("id"), str) + ] + except (httpx.HTTPError, ValueError, TypeError, ProviderModelListingResponseError): + if raise_on_error: + raise + # Some compatible servers implement messages but no model list. + # Preserve their configured model without inventing capacity + # metadata or treating it as a successful discovery result. + return [ModelInfo(provider=self.provider_id, model_id=self._model)] rows: list[ModelInfo] = [] model_ids = ( - _LISTING_MODEL_IDS - if self._listing_model_ids is None - else self._listing_model_ids + _LISTING_MODEL_IDS if self._listing_model_ids is None else self._listing_model_ids ) for model_id in model_ids: entry = shared_catalog().resolve_entry(model_id, provider=self.provider_id) diff --git a/src/opensquilla/provider/ensemble.py b/src/opensquilla/provider/ensemble.py index 462799c442..a298250080 100644 --- a/src/opensquilla/provider/ensemble.py +++ b/src/opensquilla/provider/ensemble.py @@ -644,6 +644,7 @@ class _MemberRequestBudgetBinding: rederive: bool top_level_explicit_cap: int = 0 inherit_top_level_cap: bool = True + confirmed: bool = True @dataclass @@ -968,11 +969,15 @@ def _member_tools_capability_is_verified( def _member_max_tokens(member: EnsembleMemberConfig) -> int: - if member.max_tokens and member.max_tokens > 0: - return member.max_tokens cfg = member.provider_config try: catalog = shared_catalog() + if member.max_tokens and member.max_tokens > 0: + # An explicit request budget remains separate from automatic + # deployment capacity and retains the catalog's provider caps. + return catalog.resolve_max_tokens( + cfg.model, user_override=member.max_tokens, provider=cfg.provider, + ) deployment_limits = getattr(catalog, "resolve_deployment_limits", None) if callable(deployment_limits): return int(deployment_limits( @@ -985,7 +990,7 @@ def _member_max_tokens(member: EnsembleMemberConfig) -> int: provider=cfg.provider, ) except Exception: - return ChatConfig().max_tokens + return member.max_tokens or ChatConfig().max_tokens def _member_budget_key(member: EnsembleMemberConfig) -> tuple[str, str, str]: @@ -1685,6 +1690,7 @@ def _attachment_request_unavailability( if ( binding is None or not binding.rederive + or not binding.confirmed or context_window_tokens <= 0 ): return ( @@ -2646,6 +2652,7 @@ async def _chat_unbounded( if self._require_attachment_capacity_proof and ( aggregator_binding is None or not aggregator_binding.rederive + or not aggregator_binding.confirmed or int(aggregator_binding.context_window_tokens or 0) <= 0 ): async for event in self._fallback_or_error( @@ -7111,7 +7118,10 @@ def _runtime_member_request_budget_bindings( if same_top_level_provider else "unavailable" ), - rederive=reliable_context, + # Defaults remain usable for ordinary calls, but each member must + # use its own default instead of inheriting a larger outer window. + rederive=context_window is not None and context_window > 0, + confirmed=reliable_context, top_level_explicit_cap=member_explicit_cap, inherit_top_level_cap=same_top_level_provider, ) diff --git a/src/opensquilla/provider/model_capacity.py b/src/opensquilla/provider/model_capacity.py new file mode 100644 index 0000000000..c6c45f01f1 --- /dev/null +++ b/src/opensquilla/provider/model_capacity.py @@ -0,0 +1,178 @@ +"""Read-only capacity projection using the runtime catalog's own resolvers.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from copy import copy +from typing import Any + +from .model_catalog import ModelCatalog, resolve_effective_context_window +from .registry import LOCAL_RUNTIME_PROVIDERS + + +def custom_listing_capacity(row: Any) -> dict[str, int]: + """Normalize only explicitly declared limits; never promote a fallback.""" + if not isinstance(row, dict): + return {} + top = row.get("top_provider") + top = top if isinstance(top, dict) else {} + candidates = { + "context_window": [ + row.get(name) for name in ("context_length", "context_window", "contextWindow") + ] + + [top.get("context_length")], + "max_output_tokens": [ + row.get("max_output_tokens"), + row.get("maxOutputTokens"), + top.get("max_completion_tokens"), + ], + } + result = {} + for field, values in candidates.items(): + valid = [ + value + for value in values + if isinstance(value, int) and not isinstance(value, bool) and value > 0 + ] + if valid: + result[field] = min(valid) + return result + + +def _endpoint_identity(config: Any, provider: str) -> str: + llm = getattr(config, "llm", None) + deployment = ( + llm + if getattr(llm, "provider", "") == provider + else (getattr(config, "llm_profiles", {}) or {}).get(provider) + ) + fields = [ + getattr(deployment, name, None) + for name in ("base_url", "proxy", "api_key", "api_key_env", "api_key_env_pool") + ] + return hashlib.sha256(json.dumps(fields, sort_keys=True).encode()).hexdigest() + + +def sync_custom_capacity_endpoints(catalog: ModelCatalog, config: Any) -> None: + if not callable(getattr(catalog, "set_live_provider_entries", None)): + return + identities = dict(getattr(catalog, "_capacity_endpoint_identities", {})) + for provider in ("custom", "custom_anthropic"): + identity = _endpoint_identity(config, provider) + if identities.get(provider) != identity: + catalog.set_live_provider_entries(provider, {}) + identities[provider] = identity + catalog._capacity_endpoint_identities = identities + + +def custom_capacity_identity(config: Any, provider: str) -> str: + """Capture a deployment identity before an asynchronous listing starts.""" + return _endpoint_identity(config, provider) + + +def install_custom_capacity( + catalog: ModelCatalog, + identity: str, + provider: str, + rows: Sequence[Mapping[str, Any]], +) -> None: + if provider not in {"custom", "custom_anthropic"}: + return + identities = getattr(catalog, "_capacity_endpoint_identities", {}) + if identities.get(provider) != identity: + return # A listing for an old endpoint must not overwrite a new deployment. + entries: dict[str, dict[str, int]] = {} + for row in rows: + metadata = row.get("metadata") + declared = metadata.get("capacity") if isinstance(metadata, dict) else None + model_id = row.get("id", row.get("model_id")) + if model_id and isinstance(declared, dict): + values = entries.setdefault(str(model_id), {}) + for field, value in custom_listing_capacity(declared).items(): + values[field] = min(values.get(field, value), value) + catalog.set_live_provider_entries(provider, entries) + + +def resolve_model_capacities( + catalog: ModelCatalog, + config: Any, + models: list[dict[str, str]], +) -> dict[str, Any]: + llm = getattr(config, "llm", None) + active_provider = str(getattr(llm, "provider", "") or "").strip().lower() + result: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for target in models: + provider, model = target["provider"].strip().lower(), target["model"].strip() + if (provider, model) in seen: + continue + seen.add((provider, model)) + # Restore only this exact model's two fields. Unqualified overrides + # and other settings still participate in the canonical resolver. + override_key = f"{provider}/{model}".lower() + overrides = catalog._user_overrides.get(override_key, {}) + automatic = copy(catalog) + automatic.set_user_overrides( + { + **catalog._user_overrides, + override_key: { + name: value + for name, value in overrides.items() + if name not in {"context_window", "max_output_tokens"} + }, + } + ) + global_context = ( + int(getattr(llm, "context_window_tokens", 0) or 0) if provider == active_provider else 0 + ) + auto_context, auto_context_source = resolve_effective_context_window( + automatic, + model, + provider, + global_context, + ) + context, context_source = resolve_effective_context_window( + catalog, + model, + provider, + global_context, + ) + auto_output, auto_output_source = automatic.resolve_max_tokens_with_source( + model, + 0, + provider, + capacity_only=True, + ) + output, output_source = catalog.resolve_max_tokens_with_source( + model, + 0, + provider, + capacity_only=True, + ) + result.append( + { + "provider": provider, + "model": model, + "contextWindow": { + "automatic": auto_context, + "automaticSource": auto_context_source, + "override": overrides.get("context_window"), + "value": context, + "source": context_source, + "editable": True, + }, + "maxOutputTokens": { + "automatic": auto_output, + "automaticSource": auto_output_source, + "override": overrides.get("max_output_tokens"), + "value": output, + "source": output_source, + "editable": provider != "openai_codex", + }, + "localRuntime": provider + in LOCAL_RUNTIME_PROVIDERS - {"custom", "custom_anthropic"}, + } + ) + return {"models": result} diff --git a/src/opensquilla/provider/model_catalog.py b/src/opensquilla/provider/model_catalog.py index 6e31626978..d4cc76cc89 100644 --- a/src/opensquilla/provider/model_catalog.py +++ b/src/opensquilla/provider/model_catalog.py @@ -440,6 +440,7 @@ def __init__(self) -> None: # User-override layer for resolve_entry; keys are lowercased # "provider/model" or bare model ids (see set_user_overrides). self._user_overrides: dict[str, dict[str, Any]] = {} + self._capacity_endpoint_identities: dict[str, str] = {} # Provider-scoped live layer: boot-time ingest of a provider's own # public model listing (see provider/live_catalog.py). Keyed # provider -> lowercased model id -> validated entry fields. @@ -1463,7 +1464,8 @@ def resolve_max_tokens( return self.resolve_max_tokens_with_source(model_id, user_override, provider)[0] def resolve_max_tokens_with_source( - self, model_id: str, user_override: int = 0, provider: str = "" + self, model_id: str, user_override: int = 0, provider: str = "", + *, capacity_only: bool = False, ) -> tuple[int, MaxTokensSource]: """Resolve max_tokens and name the layer that decided the value. @@ -1495,6 +1497,10 @@ def resolve_max_tokens_with_source( source: MaxTokensSource if using_user_override: effective = user_override + if isinstance(override_max, int) and override_max > 0: + # A request/member output budget cannot enlarge an explicitly + # configured model capability. Smaller request limits survive. + effective = min(effective, override_max) source = "override" elif isinstance(override_max, int) and override_max > 0: # A [models.*] operator override is authoritative for budgeting; @@ -1553,6 +1559,11 @@ def resolve_max_tokens_with_source( published_max_tokens=published_max, ) + if capacity_only: + # Model configuration displays a capability, not the request's + # output reservation. Keep the same selection and source chain. + return effective, source + # Clamp to context window. Some provider catalogs report a model's # max_completion_tokens as almost the entire context window; using that # value as max_tokens leaves no room for ordinary prompt/tool/image input diff --git a/src/opensquilla/provider/openai.py b/src/opensquilla/provider/openai.py index 67c29aa6c9..6231c93671 100644 --- a/src/opensquilla/provider/openai.py +++ b/src/opensquilla/provider/openai.py @@ -6483,13 +6483,28 @@ async def list_models(self, *, raise_on_error: bool = False) -> list[ModelInfo]: ) models = result else: + from .model_capacity import custom_listing_capacity + models = [ ModelInfo( provider=self.provider_id, model_id=m["id"], display_name=m.get("name", m.get("id", "")), - context_window=m.get("context_length", 0), - max_output_tokens=_model_listing_max_output(m), + context_window=( + custom_listing_capacity(m).get("context_window", 0) + if self.provider_id == "custom" + else m.get("context_length", 0) + ), + max_output_tokens=( + custom_listing_capacity(m).get("max_output_tokens", 0) + if self.provider_id == "custom" + else _model_listing_max_output(m) + ), + metadata=( + {"capacity": custom_listing_capacity(m)} + if self.provider_id == "custom" + else None + ), supports_vision=_model_listing_supports_vision(m), ) for m in rows diff --git a/src/opensquilla/session/terminal_reply.py b/src/opensquilla/session/terminal_reply.py index 3cab7a5765..7fc38f4d5e 100644 --- a/src/opensquilla/session/terminal_reply.py +++ b/src/opensquilla/session/terminal_reply.py @@ -207,18 +207,27 @@ def build_terminal_reply( if is_context_payload_too_large(record_or_payload) or ( isinstance(existing, str) and _contains_context_payload_marker(existing) ): + capacity = _read_value(record_or_payload, "model_capacity") + capacity_hint = "" + if isinstance(capacity, Mapping) and capacity.get("source") == "default": + window = capacity.get("contextWindow") + if isinstance(window, int) and not isinstance(window, bool) and window > 0: + capacity_hint = ( + f" The context window uses a system default of {window:,} tokens; " + "verify this model's limits in Settings > Model Routing > Model settings." + ) # Only our complete, fixed diagnostics may survive this boundary. # Upstream prose (including text appended to a known message) stays # behind the generic context-error projection below. for message in CONTEXT_PAYLOAD_TOO_LARGE_MESSAGES.values(): if error_message == _normalize(message): - return message + return message + capacity_hint return ( "The request is too large for the provider context window after " "automatic context compaction and payload reduction. OpenSquilla " "preserved the recoverable state; retry with a narrower request " "or a larger-context model." - ) + ) + capacity_hint if ( error_class == "empty_response" and error_message == _REASONING_ONLY_OUTPUT_BUDGET_ERROR_MESSAGE diff --git a/tests/contracts/test_gateway_contract_runner.py b/tests/contracts/test_gateway_contract_runner.py index 7bc921c48e..64dcbdf3b6 100644 --- a/tests/contracts/test_gateway_contract_runner.py +++ b/tests/contracts/test_gateway_contract_runner.py @@ -493,15 +493,15 @@ def test_compatibility_manifest_is_schema_derived_and_deterministic() -> None: assert manifest["protocol"] == runner.GATEWAY_PROTOCOL assert manifest["wireVersion"] == 4 assert manifest["source"] == { - "schemaCount": 225, - "methodCount": 215, + "schemaCount": 226, + "methodCount": 216, "eventFamilyCount": 10, "schemaTreeSha256": runner._schema_tree_digest(specs), "generatorSha256": runner._generator_digest(), } assert any(entry["name"] == "skills.install.status" for entry in manifest["methods"]) assert Counter(entry["lifecycle"] for entry in manifest["methods"]) == { - "stable": 212, + "stable": 213, "legacy": 3, } assert [ @@ -517,6 +517,11 @@ def test_compatibility_manifest_is_schema_derived_and_deterministic() -> None: assert profile_save_activate["schema"] == ( "platform/onboarding-llm-profile-upsert-and-activate.schema.json" ) + capacity_resolve = next( + entry for entry in manifest["methods"] if entry["name"] == "models.capacity.resolve" + ) + assert capacity_resolve["lifecycle"] == "stable" + assert capacity_resolve["schema"] == "platform/models-capacity-resolve.schema.json" assert { entry["name"]: entry["canonicalName"] for entry in manifest["methods"] diff --git a/tests/contracts/test_gateway_validator_profiles.py b/tests/contracts/test_gateway_validator_profiles.py index 722a085dd5..1a5f1fef46 100644 --- a/tests/contracts/test_gateway_validator_profiles.py +++ b/tests/contracts/test_gateway_validator_profiles.py @@ -22,14 +22,15 @@ def test_production_targets_preserve_every_approved_validator_role() -> None: specs = runner.discover_contracts() targets = runner.load_production_targets(specs) - assert len(targets) == 197 + assert len(targets) == 198 assert Counter(role for roles in targets.values() for role in roles) == { - "result": 187, - "params": 20, + "result": 188, + "params": 21, "payload": 9, "frame": 1, } - assert sum(len(spec.targets) for spec in specs) == 870 + assert sum(len(spec.targets) for spec in specs) == 874 + assert targets[("method", "models.capacity.resolve")] == ("params", "result") assert targets[("method", "sessions.executionLog.read")] == ("params", "result") assert targets[("method", "sessions.list")] == ("result",) assert targets[("method", "skills.install.status")] == ("result",) diff --git a/tests/contracts/test_r7_contract_freeze.py b/tests/contracts/test_r7_contract_freeze.py index 5e0fe7ca75..e2dc4a44e6 100644 --- a/tests/contracts/test_r7_contract_freeze.py +++ b/tests/contracts/test_r7_contract_freeze.py @@ -13,6 +13,7 @@ from scripts.contracts.generate_gateway_contracts import discover_contracts EXPECTED_METHOD_METADATA = { + "models.capacity.resolve": ("operator.read", "query", "read-only"), "memory.import.info": ("operator.read", "query", "read-only"), "memory.import.start": ("operator.admin", "command", "idempotent"), "memory.import.status": ("operator.admin", "query", "read-only"), @@ -234,9 +235,9 @@ def _specs_by_wire_name(): def test_contract_inventory_freezes_all_webui_reachable_wire_names() -> None: specs = discover_contracts() - assert len(specs) == 225 + assert len(specs) == 226 assert Counter(spec.contract_type for spec in specs) == { - "method": 215, + "method": 216, "event": 10, } assert EXPECTED_METHOD_METADATA.keys() <= {spec.wire_name for spec in specs} diff --git a/tests/test_ci/test_rpc_architecture_contracts.py b/tests/test_ci/test_rpc_architecture_contracts.py index 4b6372a275..3169dac7d0 100644 --- a/tests/test_ci/test_rpc_architecture_contracts.py +++ b/tests/test_ci/test_rpc_architecture_contracts.py @@ -124,8 +124,8 @@ # so this does not authorize unrelated wire growth. # Primary-provider transitions add resetRecommended and upsertAndActivate. # Retire the five legacy memory raw-fallback and repair methods. -RUNTIME_RPC_METHOD_BASELINE = 291 -RUNTIME_RPC_METHOD_DIGEST = "af6b515f5959431237e706b83d403f6ef379709d5abb90315336d88131268097" +RUNTIME_RPC_METHOD_BASELINE = 292 +RUNTIME_RPC_METHOD_DIGEST = "a6423344892156a71b72fd178b95093eb7608c1d2a59180397966cfc2d756148" STATIC_RPC_DECORATOR_BASELINE = 72 # Physical lines in the sessions/runtime slice remain tracked for the final diff --git a/tests/test_contracts/test_error_event_wire.py b/tests/test_contracts/test_error_event_wire.py index 9444d2a9a1..e5126787f6 100644 --- a/tests/test_contracts/test_error_event_wire.py +++ b/tests/test_contracts/test_error_event_wire.py @@ -61,6 +61,19 @@ def test_normalized_error_payload_keys_are_frozen() -> None: assert set(normalized) == NORMALIZED_ERROR_KEYS +def test_normalized_error_payload_preserves_explicit_model_capacity() -> None: + capacity = { + "provider": "custom", + "model": "synthetic-model", + "contextWindow": 8192, + "source": "default", + } + payload = {**_synthetic_error_payload(), "model_capacity": capacity} + normalized = _normalize_terminal_event_payload("session.event.error", payload) + assert set(normalized) == NORMALIZED_ERROR_KEYS | {"model_capacity"} + assert normalized["model_capacity"] == capacity + + def test_normalized_error_payload_message_carries_ref() -> None: normalized = _normalize_terminal_event_payload( "session.event.error", _synthetic_error_payload() diff --git a/tests/test_gateway/test_compaction_target.py b/tests/test_gateway/test_compaction_target.py index c1fc6c52ca..86634be4bb 100644 --- a/tests/test_gateway/test_compaction_target.py +++ b/tests/test_gateway/test_compaction_target.py @@ -258,7 +258,9 @@ def test_manual_compaction_uses_exact_credential_limits( @pytest.mark.parametrize("writer_provider,configured_output,known_window,expected_output", [ - ("openai", 8192, True, 8192), + # A request budget cannot enlarge the writer's configured model limit; + # a smaller request budget still applies independently of the body target. + ("openai", 8192, True, 3072), ("openai", 512, True, 512), ("openrouter", 8192, True, 3072), ("openrouter", 8192, False, 3072), diff --git a/tests/test_gateway/test_model_capacity.py b/tests/test_gateway/test_model_capacity.py new file mode 100644 index 0000000000..f3f5565e1a --- /dev/null +++ b/tests/test_gateway/test_model_capacity.py @@ -0,0 +1,541 @@ +"""Offline capacity contract, discovery and runtime-catalog regression coverage.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import httpx +import pytest + +from opensquilla.gateway.boot import apply_model_catalog_overrides +from opensquilla.gateway.config import GatewayConfig +from opensquilla.onboarding.probe import discover_selectable_provider_models +from opensquilla.provider.model_capacity import ( + custom_capacity_identity, + custom_listing_capacity, + install_custom_capacity, + resolve_model_capacities, +) +from opensquilla.provider.model_catalog import ModelCatalog + +MODEL = "example.vendor/unknown.v1:latest" + + +@pytest.fixture +def configured(monkeypatch): + config = GatewayConfig.model_validate( + { + "llm": { + "provider": "custom", + "model": MODEL, + "base_url": "https://capacity.invalid/v1", + "api_key": "synthetic-test-key", + } + } + ) + catalog = ModelCatalog() + apply_model_catalog_overrides(catalog, config) + monkeypatch.setattr("opensquilla.provider.model_catalog._shared_catalog", catalog) + return config, catalog + + +def resolve(config, catalog, provider="custom", model=MODEL): + return resolve_model_capacities(catalog, config, [{"provider": provider, "model": model}])[ + "models" + ][0] + + +def test_unknown_keeps_local_default_and_separates_cloud_default(configured): + config, catalog = configured + row = resolve(config, catalog) + assert row["contextWindow"] == { + "automatic": 8192, + "automaticSource": "default", + "override": None, + "value": 8192, + "source": "default", + "editable": True, + } + assert resolve(config, catalog, "openai")["contextWindow"]["value"] == 200000 + assert resolve(config, catalog, "custom_anthropic")["contextWindow"]["value"] == 8192 + assert not row["localRuntime"] + assert resolve(config, catalog, "ollama")["localRuntime"] + assert not resolve(config, catalog, "openai_codex")["maxOutputTokens"]["editable"] + + +def test_override_global_and_automatic_sources_preserve_catalog(configured): + config, catalog = configured + config.llm.context_window_tokens = 65536 + values = config.model_dump() + values["models"] = {"custom": {MODEL: {"context_window": 131072, "max_output_tokens": 32000}}} + # Parse the same structure accepted by config.patch, including punctuation in the id. + config = GatewayConfig.model_validate(values) + apply_model_catalog_overrides(catalog, config) + before = dict(catalog._user_overrides) + row = resolve(config, catalog) + assert row["contextWindow"] == { + "automatic": 65536, + "automaticSource": "config", + "override": 131072, + "value": 131072, + "source": "override", + "editable": True, + } + assert row["maxOutputTokens"]["value"] == 32000 + assert catalog._user_overrides == before + assert resolve(config, catalog, "custom_anthropic")["contextWindow"]["source"] == "default" + + +def test_capacity_is_not_the_request_output_reservation(configured): + config, catalog = configured + catalog.set_live_provider_entries( + "custom", + { + MODEL: { + "context_window": 128000, + "max_output_tokens": 128000, + } + }, + ) + assert resolve(config, catalog)["maxOutputTokens"]["value"] == 128000 + assert catalog.resolve_max_tokens(MODEL, provider="custom") < 128000 + + +def test_restore_automatic_retains_existing_unqualified_override(configured): + config, catalog = configured + catalog.set_user_overrides( + { + MODEL: {"context_window": 65536}, + f"custom/{MODEL}": {"context_window": 131072, "supports_tools": True}, + } + ) + context = resolve(config, catalog)["contextWindow"] + assert context["override"] == 131072 + assert context["automatic"] == 65536 + assert context["automaticSource"] == "override" + assert context["value"] == 131072 + assert catalog._user_override_fields(MODEL, "custom")["supports_tools"] is True + + +def test_explicit_metadata_only_and_conflicting_declarations_take_minimum(): + assert custom_listing_capacity({"context_window": True, "max_output_tokens": "32000"}) == {} + assert custom_listing_capacity({"contextWindow": 0, "max_output_tokens": -1}) == {} + assert custom_listing_capacity( + { + "context_length": 262144, + "contextWindow": 131072, + "max_output_tokens": 65536, + "top_provider": {"max_completion_tokens": 32000}, + } + ) == {"context_window": 131072, "max_output_tokens": 32000} + + +def test_endpoint_change_invalidates_and_rejects_stale_listing(configured): + config, catalog = configured + identity = custom_capacity_identity(config, "custom") + rows = [{"id": MODEL, "metadata": {"capacity": {"context_window": 131072}}}] + install_custom_capacity(catalog, identity, "custom", rows) + assert resolve(config, catalog)["contextWindow"]["source"] == "catalog" + config.llm.base_url = "https://different.invalid/v1" + apply_model_catalog_overrides(catalog, config) + install_custom_capacity(catalog, identity, "custom", rows) + assert resolve(config, catalog)["contextWindow"]["source"] == "default" + + +@pytest.mark.parametrize("provider", ["custom", "custom_anthropic"]) +@pytest.mark.parametrize("persist", [True, False]) +async def test_synthetic_discovery_metadata_enters_only_saved_runtime( + configured, monkeypatch, provider, persist +): + config, catalog = configured + config.llm.provider = provider + apply_model_catalog_overrides(catalog, config) + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response( + 200, + json={ + "data": [ + {"id": MODEL, "context_length": 262144, "max_output_tokens": 65536}, + {"id": "another.unknown/model:v2"}, + ] + }, + ) + + original = httpx.AsyncClient + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kwargs: original( + **kwargs, + transport=httpx.MockTransport(respond), + ), + ) + result = await discover_selectable_provider_models( + provider_id=provider, + api_key="synthetic-test-key", + base_url=config.llm.base_url, + persist_catalog=persist, + catalog_config=config, + ) + assert result.ok and len(requests) == 1 + assert requests[0].url.path == "/v1/models" + assert result.models[0]["contextWindow"] == 262144 + row = resolve(config, catalog, provider) + assert row["contextWindow"]["value"] == (262144 if persist else 8192) + assert ( + resolve(config, catalog, provider, "another.unknown/model:v2")["contextWindow"]["source"] + == "default" + ) + + +async def test_models_list_and_readonly_capacity_share_runtime_metadata(configured): + from opensquilla.gateway.adapters.provider_configuration import GatewayModelCatalogPort + + config, catalog = configured + + async def listing(): + return SimpleNamespace( + models=[ + { + "provider": "custom", + "model_id": MODEL, + "context_window": 131072, + "max_output_tokens": 32000, + "metadata": { + "capacity": {"context_window": 131072, "max_output_tokens": 32000} + }, + } + ], + errors=[], + ) + + selector = SimpleNamespace(is_configured=True, list_models_detailed=listing) + listed = await GatewayModelCatalogPort(selector, config).load_model_catalog() + assert listed["models"][0]["contextWindow"] == 131072 + assert resolve(config, catalog)["contextWindow"]["value"] == 131072 + assert catalog.resolve_context_window(MODEL, "custom") == 131072 + assert catalog.resolve_max_tokens(MODEL, provider="custom") == 32000 + + +async def test_capacity_rpc_is_batched_offline_and_rejects_invalid_targets(configured): + from opensquilla.gateway.rpc_models import _PLATFORM_CONFIGURATION_CONTRACT_HANDLERS + + config, _ = configured + handler = _PLATFORM_CONFIGURATION_CONTRACT_HANDLERS["models.capacity.resolve"] + ctx = SimpleNamespace(config=config) + result = await handler( + { + "models": [ + {"provider": "custom", "model": MODEL}, + {"provider": "custom", "model": MODEL}, + {"provider": "custom_anthropic", "model": MODEL}, + ] + }, + ctx, + ) + assert len(result["models"]) == 2 + for invalid in ("", " "): + with pytest.raises(Exception): + await handler({"models": [{"provider": "custom", "model": invalid}]}, ctx) + + +@pytest.mark.parametrize("metadata", [True, False]) +def test_issue_1561_large_synthetic_request_after_capacity_correction(configured, metadata): + from opensquilla.context_budget import ContextBudgetGovernor + from opensquilla.provider.request_proof import ( + ProviderRequestBudgetExceededError, + prove_provider_payload, + ) + + config, catalog = configured + payload = {"model": MODEL, "messages": [{"role": "system", "content": "synthetic " * 15000}]} + + def prove(): + budget = ContextBudgetGovernor.from_values( + context_window_tokens=catalog.resolve_context_window(MODEL, "custom"), + max_output_tokens=catalog.resolve_max_tokens(MODEL, provider="custom"), + thinking_budget_tokens=0, + context_overflow_threshold=0.85, + ).snapshot() + return prove_provider_payload( + payload, + projection_adapter="openai", + proof_budget=budget.provider_request_max_chars, + ) + + with pytest.raises(ProviderRequestBudgetExceededError): + prove() + if metadata: + install_custom_capacity( + catalog, + custom_capacity_identity(config, "custom"), + "custom", + [ + { + "id": MODEL, + "metadata": { + "capacity": { + "context_window": 262144, + "max_output_tokens": 65536, + } + }, + } + ], + ) + else: + data = config.model_dump() + data["models"] = { + "custom": { + MODEL: { + "context_window": 262144, + "max_output_tokens": 65536, + } + } + } + apply_model_catalog_overrides(catalog, GatewayConfig.model_validate(data)) + assert prove()["fits"] + + +def test_fixed_router_and_ensemble_roles_use_provider_scoped_capacity(configured): + from opensquilla.engine.turn_runner.harness import _TurnRunnerModelCatalogAdapter + from opensquilla.provider import ChatConfig + from opensquilla.provider.ensemble import ( + EnsembleMemberConfig, + _member_budget_key, + _member_chat_config, + _runtime_member_request_budget_bindings, + ) + from opensquilla.provider.selector import ProviderConfig + + config, catalog = configured + config.llm.context_window_tokens = 131072 + adapter = _TurnRunnerModelCatalogAdapter( + SimpleNamespace(_model_catalog=catalog, _config=config) + ) + assert adapter.lookup(MODEL, "custom").context_window == 131072 + assert adapter.lookup(MODEL, "custom_anthropic").context_window == 8192 + catalog.set_user_overrides( + { + f"custom/{MODEL}": {"context_window": 262144, "max_output_tokens": 65536}, + f"custom_anthropic/{MODEL}": {"context_window": 65536, "max_output_tokens": 8192}, + } + ) + adapter = _TurnRunnerModelCatalogAdapter( + SimpleNamespace(_model_catalog=catalog, _config=config) + ) + assert adapter.lookup(MODEL, "custom").context_window == 262144 + assert adapter.lookup(MODEL, "custom_anthropic").context_window == 65536 + members = [ + EnsembleMemberConfig( + provider_config=ProviderConfig(provider=provider, model=MODEL), + max_tokens=limit, + ) + for provider, limit in [("custom", 4000), ("custom_anthropic", 16000)] + ] + bindings = _runtime_member_request_budget_bindings( + config=config, + members=members, + model_catalog=catalog, + context_overflow_threshold=0.85, + ) + # The same binding functions serve candidates, aggregator and fixed takeover. + for role in ["proposer", "aggregator", "fallback"]: + configs = [ + _member_chat_config( + ChatConfig(thinking=False, provider_request_max_chars=999999), + member, + request_budget_binding=bindings[_member_budget_key(member)], + role=role, + ) + for member in members + ] + assert [item.max_tokens for item in configs] == [4000, 8192] + assert configs[0].provider_request_max_chars > configs[1].provider_request_max_chars > 0 + + +@pytest.mark.parametrize("reason", [ + "provider_request_budget_exhausted", + "provider_system_prompt_too_large", + "provider_tool_schema_too_large", + "provider_protected_context_too_large", +]) +def test_default_capacity_error_has_source_and_exact_model_target(configured, reason): + from opensquilla.engine.agent import Agent + from opensquilla.engine.types import AgentConfig + from opensquilla.session.terminal_reply import ( + CONTEXT_PAYLOAD_TOO_LARGE_MESSAGES, + build_terminal_reply, + ) + + _, _catalog = configured + agent = object.__new__(Agent) + agent.config = AgentConfig(provider_id="custom", model_id=MODEL, context_window_tokens=8192) + agent._last_compaction_refusal_reason = reason + event = agent._context_overflow_error() + assert event.model_capacity == { + "provider": "custom", + "model": MODEL, + "contextWindow": 8192, + "source": "default", + } + text = build_terminal_reply({ + "error_class": event.code, + "error_message": event.message, + "model_capacity": event.model_capacity, + }) + assert "system default of 8,192 tokens" in text + assert "Model settings" in text + if reason in CONTEXT_PAYLOAD_TOO_LARGE_MESSAGES: + assert text.startswith(CONTEXT_PAYLOAD_TOO_LARGE_MESSAGES[reason]) + + +def test_conflicting_listing_rows_take_smaller_limits(configured): + config, catalog = configured + install_custom_capacity( + catalog, + custom_capacity_identity(config, "custom"), + "custom", + [ + {"id": MODEL, "metadata": {"capacity": {"context_window": value}}} + for value in (65536, 262144) + ], + ) + assert resolve(config, catalog)["contextWindow"]["value"] == 65536 + + +async def test_capacity_patch_roundtrip_and_restore_preserve_other_model_fields( + tmp_path, configured +): + import tomllib + + import tomli_w + + from opensquilla.gateway.rpc import RpcContext + from opensquilla.gateway.rpc_config import _handle_config_patch + + path = tmp_path / "capacity.toml" + path.write_text( + tomli_w.dumps( + { + "llm": { + "provider": "custom", + "model": MODEL, + "base_url": "https://capacity.invalid/v1", + }, + "models": {"custom": {MODEL: {"supports_vision": False, "context_window": 8192}}}, + } + ) + ) + config = GatewayConfig.load(str(path)) + ctx = RpcContext(conn_id="capacity-test", config=config) + await _handle_config_patch( + { + "patch": { + "models": { + "custom": { + MODEL: { + "context_window": 262144, + "max_output_tokens": 65536, + } + } + } + } + }, + ctx, + ) + data = tomllib.loads(path.read_text()) + assert data["models"]["custom"][MODEL] == { + "supports_vision": False, + "context_window": 262144, + "max_output_tokens": 65536, + } + await _handle_config_patch( + { + "patch": { + "models": { + "custom": { + MODEL: { + "context_window": None, + "max_output_tokens": None, + } + } + } + } + }, + ctx, + ) + data = tomllib.loads(path.read_text()) + assert data["models"]["custom"][MODEL] == {"supports_vision": False} + assert config.llm.provider == "custom" and config.llm.model == MODEL + + +@pytest.mark.parametrize("auth_style", ["bearer", "x-api-key"]) +async def test_custom_anthropic_listing_auth_and_malformed_response(monkeypatch, auth_style): + from opensquilla.provider.anthropic import AnthropicProvider + from opensquilla.provider.protocol import ProviderModelListingResponseError + + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(200, json={"unexpected": "synthetic-private-response"}) + + original = httpx.AsyncClient + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kwargs: original( + **kwargs, + transport=httpx.MockTransport(respond), + ), + ) + provider = AnthropicProvider( + "synthetic-api-key", + provider_id="custom_anthropic", + base_url="https://capacity.invalid/v1", + auth_header_style=auth_style, + ) + with pytest.raises(ProviderModelListingResponseError) as error: + await provider.list_models(raise_on_error=True) + assert "synthetic-private-response" not in str(error.value) + assert requests[0].url.path == "/v1/models" + expected = "Bearer synthetic-api-key" if auth_style == "bearer" else "synthetic-api-key" + assert ( + requests[0].headers["Authorization" if auth_style == "bearer" else "x-api-key"] == expected + ) + + +@pytest.mark.parametrize( + "failure", ["missing_endpoint", "unauthorized", "unreachable", "malformed"], +) +async def test_custom_anthropic_listing_fallback_keeps_identity_without_declared_limits( + monkeypatch, failure, +): + from opensquilla.provider.anthropic import AnthropicProvider + from opensquilla.provider.protocol import ProviderModelListingResponseError + + def respond(request): + assert request.method == "GET" + assert request.url.path == "/v1/models" + if failure == "unreachable": + raise httpx.ConnectError("synthetic connection failure", request=request) + status = {"missing_endpoint": 404, "unauthorized": 401, "malformed": 200}[failure] + return httpx.Response(status, json={"unexpected": "synthetic response"}) + + original = httpx.AsyncClient + monkeypatch.setattr( + httpx, "AsyncClient", + lambda **kwargs: original(**kwargs, transport=httpx.MockTransport(respond)), + ) + provider = AnthropicProvider( + "", model=MODEL, provider_id="custom_anthropic", base_url="https://capacity.invalid/v1", + ) + rows = await provider.list_models() + assert [(row.provider, row.model_id) for row in rows] == [("custom_anthropic", MODEL)] + assert rows[0].context_window == rows[0].max_output_tokens == 0 + assert rows[0].metadata is None + with pytest.raises((httpx.HTTPError, ProviderModelListingResponseError)): + await provider.list_models(raise_on_error=True) diff --git a/tests/test_gateway/test_platform_configuration_contract.py b/tests/test_gateway/test_platform_configuration_contract.py index 027f74719f..4d401d1645 100644 --- a/tests/test_gateway/test_platform_configuration_contract.py +++ b/tests/test_gateway/test_platform_configuration_contract.py @@ -23,6 +23,7 @@ "config.patch", "config.patch.safe", "models.list", + "models.capacity.resolve", "providers.status", "models.routing.get", "models.routing.set", diff --git a/tests/test_gateway/test_sandbox_runtime_contract_registration.py b/tests/test_gateway/test_sandbox_runtime_contract_registration.py index a9ea1659f7..a057de5fb6 100644 --- a/tests/test_gateway/test_sandbox_runtime_contract_registration.py +++ b/tests/test_gateway/test_sandbox_runtime_contract_registration.py @@ -159,7 +159,9 @@ def register( def test_production_registry_uses_contract_wrappers_without_surface_drift() -> None: registry = get_dispatcher() - assert len(registry.list_methods()) == 291 + assert len(registry.list_methods()) == 292 + assert registry.get_entry("models.capacity.resolve") is not None + assert registry.get_entry("skills.install.status") is not None assert registry.get_entry("telemetry.product_active.record") is not None assert registry.get_entry("sessions.executionLog.read") is not None assert tuple(PRODUCTION_HANDLER_NAMES) == SANDBOX_RUNTIME_CONTRACT_METHODS diff --git a/tests/test_gateway/test_turn_admission_composition.py b/tests/test_gateway/test_turn_admission_composition.py index bc43716f3f..6e7f579ae4 100644 --- a/tests/test_gateway/test_turn_admission_composition.py +++ b/tests/test_gateway/test_turn_admission_composition.py @@ -148,7 +148,9 @@ def test_fresh_rpc_import_preserves_contract_entries_without_an_import_cycle() - assert rpc_chat._turn_admission_adapter_factory is rpc_sessions.build_gateway_turn_admission_adapter registry = get_dispatcher() # Includes product activity and execution logs; excludes retired memory flush/repair methods. -assert len(registry.list_methods()) == 291 +assert len(registry.list_methods()) == 292 +assert registry.get_entry("models.capacity.resolve") is not None +assert registry.get_entry("skills.install.status") is not None assert registry.get_entry("telemetry.product_active.record") is not None assert registry.get_entry("sessions.executionLog.read") is not None for method in methods: diff --git a/tests/test_provider_ensemble.py b/tests/test_provider_ensemble.py index 2e740d1261..e7a00b3033 100644 --- a/tests/test_provider_ensemble.py +++ b/tests/test_provider_ensemble.py @@ -4037,7 +4037,7 @@ async def test_ensemble_request_cap_rebinding_preserves_explicit_zero_and_unboun @pytest.mark.asyncio -async def test_ensemble_request_cap_rebinding_requires_reliable_member_context( +async def test_ensemble_default_context_rebinds_but_catalog_failure_retains_outer_cap( monkeypatch: pytest.MonkeyPatch, ) -> None: registry = _tokenrhythm_budget_registry() @@ -4065,7 +4065,10 @@ async def test_ensemble_request_cap_rebinding_requires_reliable_member_context( ] calls_by_model = {call["model"]: call["config"] for call in registry.calls} - assert calls_by_model["kimi-k2.7-code"].provider_request_max_chars == 555_555 + # Automatic output follows this deployment's fallback, without borrowing + # a different authority's catalog. The member's context is still rebound. + assert calls_by_model["kimi-k2.7-code"].max_tokens == 16_000 + assert calls_by_model["kimi-k2.7-code"].provider_request_max_chars == 880_000 assert calls_by_model["glm-5.2"].provider_request_max_chars == 555_555 done = next(event for event in events if isinstance(event, DoneEvent)) assert done.ensemble_trace is not None @@ -4075,7 +4078,7 @@ async def test_ensemble_request_cap_rebinding_requires_reliable_member_context( if candidate["model"] == "kimi-k2.7-code" ) assert kimi_trace["effective_context_window_source"] == "default" - assert kimi_trace["provider_request_max_chars_source"] == "inherited" + assert kimi_trace["provider_request_max_chars_source"] == "member_context" aggregator_trace = done.ensemble_trace["final_request"]["execution"] assert aggregator_trace["effective_context_window_source"] == "error" assert aggregator_trace["provider_request_max_chars_source"] == "inherited"