From a4e1b2d63474ce2efaf955ffb9680466743e2de7 Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:21:12 +0800 Subject: [PATCH 01/11] Separate provider reachability and model probes --- .../v4/compatibility-manifest.generated.json | 8 +- ...arding-llm-profile-draft-probe.schema.json | 88 ++- .../onboarding-llm-profile-probe.schema.json | 24 +- .../onboarding-provider-probe.schema.json | 24 +- .../adapters/gateway/platformConfigV4.test.ts | 57 ++ .../gateway/privateTransports.test.ts | 9 + .../src/adapters/gateway/privateTransports.ts | 8 + .../src/adapters/gateway/setupWorkflowV4.ts | 48 +- .../src/adapters/gateway/transportTypes.ts | 1 + .../components/settings/SettingsDialog.vue | 4 + .../setup/SetupProviderCredentialCard.test.ts | 117 ++- .../setup/SetupProviderCredentialCard.vue | 145 +++- .../components/setup/SetupProviderMenu.vue | 2 + .../setup/SetupProviderPanel.test.ts | 207 ++++- .../components/setup/SetupProviderPanel.vue | 49 +- .../setup/useSetupCatalog.privacy.test.ts | 170 +++- .../src/composables/setup/useSetupCatalog.ts | 138 +++- .../setup/useSetupProviderForm.test.ts | 294 ++++++- .../composables/setup/useSetupProviderForm.ts | 150 +++- .../v4/onboardingLlmProfileDraftProbe.ts | 39 +- ...ardingLlmProfileDraftProbeValidators.d.mts | 2 +- ...boardingLlmProfileDraftProbeValidators.mjs | 4 +- .../generated/v4/onboardingLlmProfileProbe.ts | 14 +- .../onboardingLlmProfileProbeValidators.d.mts | 2 +- .../onboardingLlmProfileProbeValidators.mjs | 4 +- .../generated/v4/onboardingProviderProbe.ts | 14 +- .../onboardingProviderProbeValidators.d.mts | 2 +- .../v4/onboardingProviderProbeValidators.mjs | 4 +- opensquilla-webui/src/lib/rpc.test.ts | 53 ++ opensquilla-webui/src/lib/rpc.ts | 32 + opensquilla-webui/src/locales/de.json | 17 +- opensquilla-webui/src/locales/en.json | 17 +- opensquilla-webui/src/locales/es.json | 17 +- opensquilla-webui/src/locales/fr.json | 17 +- opensquilla-webui/src/locales/ja.json | 17 +- opensquilla-webui/src/locales/zh-Hans.json | 17 +- .../src/modules/setupWorkflow.ts | 15 +- src/opensquilla/application/provider_setup.py | 6 + .../generated/v4/gateway_contract_registry.py | 2 +- .../v4/onboarding_llm_profile_draft_probe.py | 71 +- ...arding_llm_profile_draft_probe_metadata.py | 2 +- .../v4/onboarding_llm_profile_probe.py | 32 +- .../onboarding_llm_profile_probe_metadata.py | 2 +- .../generated/v4/onboarding_provider_probe.py | 32 +- .../v4/onboarding_provider_probe_metadata.py | 2 +- src/opensquilla/engine/usage_accounting.py | 69 +- src/opensquilla/gateway/protocol.py | 2 + src/opensquilla/gateway/rpc_onboarding.py | 98 ++- src/opensquilla/gateway/websocket.py | 229 +++++- src/opensquilla/onboarding/probe.py | 391 +++++++++- src/opensquilla/provider/ollama.py | 11 + src/opensquilla/provider/openai.py | 24 +- src/opensquilla/provider/openai_responses.py | 19 +- src/opensquilla/provider/protocol.py | 9 + src/opensquilla/provider/selector.py | 10 +- .../test_provider_probe_wire.py | 88 +++ .../test_usage_event_accounting.py | 109 +++ .../test_provider_probe_stream_lifecycle.py | 166 ++++ tests/test_gateway/test_rpc_llm_profiles.py | 269 ++++++- .../test_websocket_frame_validation.py | 17 + .../test_websocket_request_concurrency.py | 538 +++++++++++++ tests/test_onboarding/test_models_discover.py | 26 + .../test_probe_model_listing.py | 25 +- .../test_provider_credential_rpc.py | 160 ++++ tests/test_onboarding/test_provider_probe.py | 729 ++++++++++++++++++ 65 files changed, 4677 insertions(+), 291 deletions(-) create mode 100644 tests/test_gateway/test_provider_probe_stream_lifecycle.py diff --git a/contracts/gateway/v4/compatibility-manifest.generated.json b/contracts/gateway/v4/compatibility-manifest.generated.json index d1a933dcae..4068409b24 100644 --- a/contracts/gateway/v4/compatibility-manifest.generated.json +++ b/contracts/gateway/v4/compatibility-manifest.generated.json @@ -842,7 +842,7 @@ "lifecycle": "stable", "name": "onboarding.llmProfile.draft.probe", "schema": "platform/onboarding-llm-profile-draft-probe.schema.json", - "schemaSha256": "b87c847bb7a4116bad627baafb3d17b205d59bc46b6cceaf2cafcfee1e0e4e06" + "schemaSha256": "e02caab5c5cc592ba57b0f2205912f9400b1d0eb31c322ceef7a72ab4350c51f" }, { "lifecycle": "stable", @@ -854,7 +854,7 @@ "lifecycle": "stable", "name": "onboarding.llmProfile.probe", "schema": "platform/onboarding-llm-profile-probe.schema.json", - "schemaSha256": "1d906e26d4631a43464c148552c8372555b91c6a51b8ddeb233db083c9c3a6f0" + "schemaSha256": "8e552abda066add5f5275b42a313746f80c7757d1f345d22762b039f6b8b007b" }, { "lifecycle": "stable", @@ -908,7 +908,7 @@ "lifecycle": "stable", "name": "onboarding.provider.probe", "schema": "platform/onboarding-provider-probe.schema.json", - "schemaSha256": "4bba8e8fbca95f2c8343a94dde1000b1b9724d447992d7b391317ca0469e565e" + "schemaSha256": "ffa4d9b4a0f662c2660de521b9bc7c27f9373c762c8cd4eafb2127b872c54e66" }, { "lifecycle": "stable", @@ -1460,7 +1460,7 @@ "generatorSha256": "0e0b513d7844d926e5f2c6065e64e4b523a1f8df2023317327ee6e05bfa29038", "methodCount": 214, "schemaCount": 224, - "schemaTreeSha256": "7479e55aa84897a526ea6a8c8b5dca3f995ffb7fdde4dc57bc84123e21cdf8fc" + "schemaTreeSha256": "1e2b22918decb19886eac6a25881c692c86409c38043dde5b48e9586eb0cc10d" }, "wireVersion": 4 } diff --git a/contracts/gateway/v4/platform/onboarding-llm-profile-draft-probe.schema.json b/contracts/gateway/v4/platform/onboarding-llm-profile-draft-probe.schema.json index 2b17e9f843..a59d125bef 100644 --- a/contracts/gateway/v4/platform/onboarding-llm-profile-draft-probe.schema.json +++ b/contracts/gateway/v4/platform/onboarding-llm-profile-draft-probe.schema.json @@ -70,6 +70,16 @@ "$defs": { "OnboardingLlmProfileDraftProbeParams": { "title": "onboarding.llmProfile.draft.probe params", + "oneOf": [ + { + "$ref": "#/$defs/OnboardingLlmProfileDraftModelProbeParams" + }, + { + "$ref": "#/$defs/OnboardingLlmProfileDraftReachabilityProbeParams" + } + ] + }, + "OnboardingLlmProfileDraftModelProbeParams": { "type": "object", "additionalProperties": true, "properties": { @@ -114,6 +124,12 @@ "string", "null" ] + }, + "mode": { + "enum": [ + "model", + null + ] } }, "required": [ @@ -121,6 +137,61 @@ "model" ] }, + "OnboardingLlmProfileDraftReachabilityProbeParams": { + "type": "object", + "additionalProperties": true, + "properties": { + "providerId": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "apiKey": { + "type": [ + "string", + "null" + ] + }, + "apiKeyEnv": { + "type": [ + "string", + "null" + ] + }, + "keepCurrentSecret": { + "type": [ + "boolean", + "null" + ] + }, + "baseUrl": { + "type": [ + "string", + "null" + ] + }, + "proxy": { + "type": [ + "string", + "null" + ] + }, + "mode": { + "const": "reachability" + } + }, + "required": [ + "providerId", + "mode" + ] + }, "OnboardingLlmProfileDraftProbeRequestFrame": { "type": "object", "additionalProperties": false, @@ -146,7 +217,22 @@ }, "OnboardingLlmProfileDraftProbeResult": { "type": "object", - "additionalProperties": true + "additionalProperties": true, + "properties": { + "ok": { "type": "boolean" }, + "providerId": { "type": "string" }, + "model": { "type": "string" }, + "failureKind": { "type": "string" }, + "message": { "type": "string" }, + "code": { "type": "string" }, + "latencyMs": { "type": "integer", "minimum": 0 }, + "firstResponseMs": { "type": ["integer", "null"], "minimum": 0 }, + "totalMs": { "type": "integer", "minimum": 0 }, + "verificationLevel": { + "enum": ["reachable", "model_verified", "none"] + }, + "failureStage": { "enum": ["reachability", "model"] } + } }, "RpcError": { "type": "object", diff --git a/contracts/gateway/v4/platform/onboarding-llm-profile-probe.schema.json b/contracts/gateway/v4/platform/onboarding-llm-profile-probe.schema.json index 439f0049ed..c60c761d89 100644 --- a/contracts/gateway/v4/platform/onboarding-llm-profile-probe.schema.json +++ b/contracts/gateway/v4/platform/onboarding-llm-profile-probe.schema.json @@ -84,6 +84,13 @@ "string", "null" ] + }, + "mode": { + "enum": [ + "reachability", + "model", + null + ] } }, "required": [ @@ -115,7 +122,22 @@ }, "OnboardingLlmProfileProbeResult": { "type": "object", - "additionalProperties": true + "additionalProperties": true, + "properties": { + "ok": { "type": "boolean" }, + "providerId": { "type": "string" }, + "model": { "type": "string" }, + "failureKind": { "type": "string" }, + "message": { "type": "string" }, + "code": { "type": "string" }, + "latencyMs": { "type": "integer", "minimum": 0 }, + "firstResponseMs": { "type": ["integer", "null"], "minimum": 0 }, + "totalMs": { "type": "integer", "minimum": 0 }, + "verificationLevel": { + "enum": ["reachable", "model_verified", "none"] + }, + "failureStage": { "enum": ["reachability", "model"] } + } }, "RpcError": { "type": "object", diff --git a/contracts/gateway/v4/platform/onboarding-provider-probe.schema.json b/contracts/gateway/v4/platform/onboarding-provider-probe.schema.json index 5915ef522c..1e54bf69e8 100644 --- a/contracts/gateway/v4/platform/onboarding-provider-probe.schema.json +++ b/contracts/gateway/v4/platform/onboarding-provider-probe.schema.json @@ -114,6 +114,13 @@ "string", "null" ] + }, + "mode": { + "enum": [ + "reachability", + "model", + null + ] } }, "required": [ @@ -145,7 +152,22 @@ }, "OnboardingProviderProbeResult": { "type": "object", - "additionalProperties": true + "additionalProperties": true, + "properties": { + "ok": { "type": "boolean" }, + "providerId": { "type": "string" }, + "model": { "type": "string" }, + "failureKind": { "type": "string" }, + "message": { "type": "string" }, + "code": { "type": "string" }, + "latencyMs": { "type": "integer", "minimum": 0 }, + "firstResponseMs": { "type": ["integer", "null"], "minimum": 0 }, + "totalMs": { "type": "integer", "minimum": 0 }, + "verificationLevel": { + "enum": ["reachable", "model_verified", "none"] + }, + "failureStage": { "enum": ["reachability", "model"] } + } }, "RpcError": { "type": "object", diff --git a/opensquilla-webui/src/adapters/gateway/platformConfigV4.test.ts b/opensquilla-webui/src/adapters/gateway/platformConfigV4.test.ts index da486458ff..d367f48f45 100644 --- a/opensquilla-webui/src/adapters/gateway/platformConfigV4.test.ts +++ b/opensquilla-webui/src/adapters/gateway/platformConfigV4.test.ts @@ -167,6 +167,63 @@ describe('Platform configuration adapters', () => { expect(source.request).toHaveBeenCalledWith('onboarding.router.configure', { mode: 'recommended' }, expect.any(Object)) }) + it('passes provider probe modes, cancellation, and caller-specific timeouts to transport', async () => { + const source = rpc() + const setup = createV4SetupWorkflow(source) + const controller = new AbortController() + + await setup.provider.probePrimary( + { providerId: 'openai', mode: 'reachability' }, + { signal: controller.signal, timeoutMs: 70_000 }, + ) + expect(source.request).toHaveBeenLastCalledWith( + 'onboarding.provider.probe', + { providerId: 'openai', mode: 'reachability' }, + { + timeoutMs: 70_000, + timeoutAction: 'reject', + abortAction: 'reject', + cancelOnAbort: true, + signal: controller.signal, + }, + ) + + await setup.provider.probePrimary( + { providerId: 'openai', model: 'gpt-4o', mode: 'model' }, + { timeoutMs: 65_000 }, + ) + expect(source.request).toHaveBeenLastCalledWith( + 'onboarding.provider.probe', + { providerId: 'openai', model: 'gpt-4o', mode: 'model' }, + expect.objectContaining({ timeoutMs: 65_000, cancelOnAbort: true }), + ) + + await setup.profile.probeProfile({ providerId: 'openai', mode: 'model' }) + expect(source.request).toHaveBeenLastCalledWith( + 'onboarding.llmProfile.probe', + { providerId: 'openai', mode: 'model' }, + expect.objectContaining({ cancelOnAbort: true }), + ) + + await setup.profile.probeDraftProfile({ providerId: 'openai', mode: 'reachability' }) + expect(source.request).toHaveBeenLastCalledWith( + 'onboarding.llmProfile.draft.probe', + { providerId: 'openai', mode: 'reachability' }, + expect.objectContaining({ cancelOnAbort: true }), + ) + + await setup.profile.probeProfile({ providerId: 'openai', model: 'gpt-4o' }) + expect(source.request).toHaveBeenLastCalledWith( + 'onboarding.llmProfile.probe', + { providerId: 'openai', model: 'gpt-4o' }, + { + timeoutMs: 20_000, + timeoutAction: 'reject', + abortAction: 'reject', + }, + ) + }) + it('keeps migration discovery and preview read-only behind the domain seam', async () => { const source = rpc() const migration = createV4MigrationOperations(source) diff --git a/opensquilla-webui/src/adapters/gateway/privateTransports.test.ts b/opensquilla-webui/src/adapters/gateway/privateTransports.test.ts index bd30197ac3..d87ad234ed 100644 --- a/opensquilla-webui/src/adapters/gateway/privateTransports.test.ts +++ b/opensquilla-webui/src/adapters/gateway/privateTransports.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcCallOptions, RpcEventHandler } from '@/lib/rpc' import { createPrivateGatewayTransports } from './privateTransports' +import { createV4SetupWorkflow } from './setupWorkflowV4' function source() { return { connectionGeneration: 7, + policy: { provider_probe_modes: ['model', 'reachability'] }, call: vi.fn(async () => ({ ok: true })) as ( method: string, params?: Record, @@ -68,6 +70,13 @@ describe('private Gateway transports', () => { expect(rpcSource.rememberUnsupportedMethod).toHaveBeenCalledWith('legacy.method') }) + it('projects the negotiated provider probe modes into the setup workflow capability', () => { + const transports = createPrivateGatewayTransports(source()) + const workflow = createV4SetupWorkflow(transports.rpc) + + expect(workflow.capabilities.providerProbeModes).toBe(true) + }) + it('owns idempotent event unsubscription', () => { const rpcSource = source() const unsubscribe = vi.fn() diff --git a/opensquilla-webui/src/adapters/gateway/privateTransports.ts b/opensquilla-webui/src/adapters/gateway/privateTransports.ts index 717ff43062..47ccc45adf 100644 --- a/opensquilla-webui/src/adapters/gateway/privateTransports.ts +++ b/opensquilla-webui/src/adapters/gateway/privateTransports.ts @@ -26,6 +26,7 @@ export interface RpcTransport { ): Promise ready(options?: TransportReadyOptions): Promise supports(method: string): boolean + supportsProviderProbeMode(mode: 'reachability' | 'model'): boolean markUnsupported(method: string): void acknowledgeDelivery?(receipt: TransportDeliveryReceipt): Promise | void resumeFlow?(receipt: TransportInstalledReceipt): Promise | void @@ -56,6 +57,7 @@ export interface GatewayTransports { } interface RpcStoreTransportSource { + readonly policy?: Record | null readonly connectionGeneration: number call( method: string, @@ -145,6 +147,12 @@ export function createPrivateGatewayTransports( supports(method) { return source.hasRpcMethod(method) }, + supportsProviderProbeMode(mode) { + const advertised = source.policy?.provider_probe_modes + return Array.isArray(advertised) + && advertised.every(value => typeof value === 'string') + && advertised.includes(mode) + }, markUnsupported(method) { source.rememberUnsupportedMethod(method) }, diff --git a/opensquilla-webui/src/adapters/gateway/setupWorkflowV4.ts b/opensquilla-webui/src/adapters/gateway/setupWorkflowV4.ts index 5d2bcbfd70..dc25275bb0 100644 --- a/opensquilla-webui/src/adapters/gateway/setupWorkflowV4.ts +++ b/opensquilla-webui/src/adapters/gateway/setupWorkflowV4.ts @@ -24,15 +24,24 @@ interface RpcTransport { request(method: string, params?: Record, options?: RpcCallOptions): Promise ready?(options?: { timeoutMs?: number; signal?: AbortSignal }): Promise supports?(method: string): boolean + supportsProviderProbeMode?(mode: 'reachability' | 'model'): boolean } -const options = (signal?: AbortSignal): RpcCallOptions => ({ - timeoutMs: 20_000, +const options = ( + request?: SetupRequestOptions, + cancelOnAbort = false, +): RpcCallOptions => ({ + timeoutMs: request?.timeoutMs ?? 20_000, timeoutAction: 'reject', abortAction: 'reject', - ...(signal ? { signal } : {}), + ...(cancelOnAbort ? { cancelOnAbort: true } : {}), + ...(request?.signal ? { signal: request.signal } : {}), }) +const hasExplicitProbeMode = (command: { mode?: unknown }): boolean => ( + command.mode === 'reachability' || command.mode === 'model' +) + const wireParams = (value: object): Record => ({ ...value }) const object = (result: unknown, method: string): Record => { @@ -88,10 +97,11 @@ async function requestContract( contract: SetupContractDescriptor, params: Record | undefined, request?: SetupRequestOptions, + cancelOnAbort = false, ): Promise> { let result: unknown try { - result = await rpc.request(contract.method, params, options(request?.signal)) + result = await rpc.request(contract.method, params, options(request, cancelOnAbort)) } catch (error) { throw mapSetupError(error) } @@ -111,7 +121,13 @@ export function createV4SetupWorkflow(rpc: RpcTransport): SetupWorkflow { return requestContract(rpc, setupContracts.providerConfigure, wireParams(command), request) }, probePrimary(command, request) { - return requestContract(rpc, setupContracts.providerProbe, wireParams(command), request) + return requestContract( + rpc, + setupContracts.providerProbe, + wireParams(command), + request, + hasExplicitProbeMode(command), + ) }, discoverPrimaryModels(command, request) { return requestContract(rpc, setupContracts.modelsDiscover, wireParams(command), request) as Promise @@ -142,10 +158,22 @@ export function createV4SetupWorkflow(rpc: RpcTransport): SetupWorkflow { return requestContract(rpc, setupContracts.profileActivate, wireParams(command), request) }, probeProfile(command, request) { - return requestContract(rpc, setupContracts.profileProbe, wireParams(command), request) + return requestContract( + rpc, + setupContracts.profileProbe, + wireParams(command), + request, + hasExplicitProbeMode(command), + ) }, probeDraftProfile(command, request) { - return requestContract(rpc, setupContracts.profileDraftProbe, wireParams(command), request) as Promise + return requestContract( + rpc, + setupContracts.profileDraftProbe, + wireParams(command), + request, + hasExplicitProbeMode(command), + ) as Promise }, async discoverProfileModels(command, request) { try { @@ -207,11 +235,15 @@ export function createV4SetupWorkflow(rpc: RpcTransport): SetupWorkflow { get imageModelDiscovery() { return rpc.supports?.(setupContracts.imageModelsDiscover.method) !== false }, + get providerProbeModes() { + return rpc.supportsProviderProbeMode?.('reachability') === true + && rpc.supportsProviderProbeMode('model') === true + }, }, async catalog(request) { let result: unknown try { - result = await rpc.request(ONBOARDING_CATALOG_METHOD, undefined, options(request?.signal)) + result = await rpc.request(ONBOARDING_CATALOG_METHOD, undefined, options(request)) } catch (error) { throw mapSetupError(error) } diff --git a/opensquilla-webui/src/adapters/gateway/transportTypes.ts b/opensquilla-webui/src/adapters/gateway/transportTypes.ts index 3aedd50b29..3a480c3dba 100644 --- a/opensquilla-webui/src/adapters/gateway/transportTypes.ts +++ b/opensquilla-webui/src/adapters/gateway/transportTypes.ts @@ -25,6 +25,7 @@ export interface TransportCallOptions { signal?: AbortSignal timeoutAction?: TransportTerminationAction abortAction?: TransportTerminationAction + cancelOnAbort?: boolean expectedGeneration?: number onSent?: (socketGeneration: number) => void } diff --git a/opensquilla-webui/src/components/settings/SettingsDialog.vue b/opensquilla-webui/src/components/settings/SettingsDialog.vue index 22a67ab8cd..cfde6418cf 100644 --- a/opensquilla-webui/src/components/settings/SettingsDialog.vue +++ b/opensquilla-webui/src/components/settings/SettingsDialog.vue @@ -134,7 +134,9 @@ @update-llm-timeout="updateLlmTimeout" @update-context-window="updateContextWindow" @probe-connection="probeProviderConnection" + @cancel-provider-probe="cancelProviderProbe" @refresh-models="refreshProviderModels" + @cancel-configured-provider-probe="cancelConfiguredProviderProbe" @save-provider="saveProvider" @save-provider-and-activate="saveProviderAndActivate" @cancel-provider-edit="cancelProviderEdit" @@ -317,8 +319,10 @@ const { updateLlmTimeout, updateContextWindow, probeProviderConnection, + cancelProviderProbe, refreshProviderModels, probeConfiguredProvider, + cancelConfiguredProviderProbe, activateProvider, removeProviderProfile, updateTierField, diff --git a/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.test.ts b/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.test.ts index b645b7c8ef..89f5d2b70e 100644 --- a/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.test.ts +++ b/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.test.ts @@ -25,7 +25,7 @@ function panel(overrides: Record = {}) { apiKeyEnvValue: 'DEEPSEEK_API_KEY', probeReady: true, probeDisabledReason: '', - probeButtonLabel: 'Verify current configuration', + probeButtonLabel: 'Test model', connection: { phase: 'unverified' }, ...overrides, } @@ -91,7 +91,7 @@ function discoveredModel(id: string) { function verifiedConnection(overrides: Record = {}) { return { - phase: 'verified', + phase: 'model_verified', failureKind: '', detail: '', firstResponseMs: 123, @@ -396,8 +396,8 @@ describe('SetupProviderCredentialCard', () => { expect(button).toBeTruthy() expect(button?.disabled).toBe(true) expect(button?.title).toBe('Add an API key before verifying this provider.') - expect(button?.getAttribute('aria-describedby')).toBe('setup-provider-probe-hint-deepseek') - expect(el.querySelector('#setup-provider-probe-hint-deepseek')?.textContent) + expect(button?.getAttribute('aria-describedby')).toBe('setup-provider-probe-hint-full-deepseek') + expect(el.querySelector('#setup-provider-probe-hint-full-deepseek')?.textContent) .toBe('Add an API key before verifying this provider.') expect(el.textContent).toContain('Add an API key before verifying this provider.') @@ -454,7 +454,7 @@ describe('SetupProviderCredentialCard', () => { const { app, el } = await mountCard({ probeReady: false, probeDisabledReason: reason }) const button = Array.from(el.querySelectorAll('button')) - .find(candidate => candidate.textContent?.includes('Verify current configuration')) + .find(candidate => candidate.textContent?.includes('Test model')) expect(button?.disabled).toBe(true) expect(button?.title).toBe(reason) expect(el.textContent).toContain(reason) @@ -547,18 +547,119 @@ describe('SetupProviderCredentialCard', () => { }) describe('SetupProviderCredentialCard — configuration verification verdict', () => { + it('offers separate connection and model checks with independent readiness', async () => { + const onCheckReachability = vi.fn() + const onTestModel = vi.fn() + const { app, el } = await mountCard({ + reachabilityReady: true, + probeReady: false, + probeDisabledReason: 'Choose a model before testing it.', + }, { onCheckReachability, onTestModel }) + const buttons = Array.from(el.querySelectorAll('button')) + const connection = buttons.find(button => button.textContent?.trim() === 'Check connection') + const model = buttons.find(button => button.textContent?.trim() === 'Test model') + + expect(connection?.disabled).toBe(false) + expect(model?.disabled).toBe(true) + connection?.click() + model?.click() + expect(onCheckReachability).toHaveBeenCalledOnce() + expect(onTestModel).not.toHaveBeenCalled() + + app.unmount() + }) + + it('distinguishes endpoint reachability from a successful model call', async () => { + const { app, el } = await mountCard({ + connection: verifiedConnection({ + phase: 'reachable', + verificationLevel: 'reachable', + firstResponseMs: null, + }), + }) + + expect(el.textContent).toContain('Endpoint reachable · model not tested') + expect(el.textContent).not.toContain('Model call succeeded') + expect(el.textContent).not.toContain('First model response') + + app.unmount() + }) + + it('reports model timeout as incomplete verification rather than unreachable', async () => { + const { app, el } = await mountCard({ + connection: verifiedConnection({ + phase: 'timed_out', + verificationLevel: 'none', + failureStage: 'model', + failureKind: 'probe_timeout', + detail: 'Model probe timed out after 60 seconds.', + models: [], + modelSource: 'none', + }), + }) + + expect(el.textContent).toContain('Model response timed out · verification incomplete') + expect(el.textContent).not.toContain("Couldn't connect") + + app.unmount() + }) + + it('reports a provider response separately from transport reachability failures', async () => { + const { app, el } = await mountCard({ + connection: verifiedConnection({ + phase: 'reachable_error', + verificationLevel: 'reachable', + failureStage: 'reachability', + failureKind: 'rate_limited', + detail: 'HTTP 429', + models: [], + modelSource: 'none', + }), + }) + + expect(el.textContent).toContain('Endpoint responded') + expect(el.textContent).toContain('rate-limiting requests') + expect(el.textContent).not.toContain("Couldn't connect") + + app.unmount() + }) + + it('does not describe a malformed reachability response as a streaming failure', async () => { + const { app, el } = await mountCard({ + connection: verifiedConnection({ + phase: 'reachable_error', + verificationLevel: 'reachable', + failureStage: 'reachability', + failureKind: 'malformed_response', + detail: 'Provider model catalog response could not be parsed', + models: [], + modelSource: 'none', + }), + }) + + expect(el.textContent).toContain('Endpoint responded') + expect(el.textContent).not.toContain('Streaming response incompatible') + app.unmount() + }) + it('announces the current-settings verdict and exposes probing as busy', async () => { const verified = await mountCard({ connection: verifiedConnection() }) const status = verified.el.querySelector('[role="status"]') expect(status?.getAttribute('aria-live')).toBe('polite') expect(status?.getAttribute('aria-atomic')).toBe('true') - expect(status?.textContent).toContain('Configuration verified') + expect(status?.textContent).toContain('Model call succeeded') verified.app.unmount() - const probing = await mountCard({ connection: { phase: 'probing' } }) + const onCancelProbe = vi.fn() + const probing = await mountCard( + { connection: { phase: 'probing', probeMode: 'model' } }, + { onCancelProbe }, + ) const button = Array.from(probing.el.querySelectorAll('button')) - .find(candidate => candidate.textContent?.includes('Verifying configuration')) + .find(candidate => candidate.textContent?.includes('Cancel test')) expect(button?.getAttribute('aria-busy')).toBe('true') + button?.click() + expect(onCancelProbe).toHaveBeenCalledOnce() probing.app.unmount() }) diff --git a/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.vue b/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.vue index 172a58be66..4cc9a3f0a0 100644 --- a/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.vue +++ b/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.vue @@ -2,7 +2,10 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import Icon from '@/components/Icon.vue' -import type { ConnectionState } from '@/composables/setup/useSetupProviderForm' +import { + isModelVerifiedConnection, + type ConnectionState, +} from '@/composables/setup/useSetupProviderForm' const { t } = useI18n() @@ -27,6 +30,9 @@ interface ProviderCredentialPanelContract { probeReady: boolean probeDisabledReason: string probeButtonLabel: string + reachabilityReady?: boolean + reachabilityDisabledReason?: string + probeModesSupported?: boolean connection: ConnectionState } @@ -42,7 +48,9 @@ const emit = defineEmits<{ replace: [] cancelReplace: [] removeCredential: [] - testConnection: [] + checkReachability: [] + testModel: [] + cancelProbe: [] updateField: [name: string, value: string] }>() @@ -151,8 +159,15 @@ watch(editingCredential, editing => { }) watch(() => props.panel.providerLabel, () => { showApiKey.value = false }) const probing = computed(() => props.panel.connection.phase === 'probing') +const checkingReachability = computed(() => ( + probing.value && props.panel.connection.probeMode === 'reachability' +)) +const reachabilityReady = computed(() => props.panel.reachabilityReady ?? props.panel.probeReady) +const reachabilityDisabledReason = computed(() => ( + props.panel.reachabilityDisabledReason ?? props.panel.probeDisabledReason +)) const probeHintId = computed(() => ( - `setup-provider-probe-hint-${props.panel.providerLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` + `setup-provider-probe-hint-${props.compact ? 'compact' : 'full'}-${props.panel.providerLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` )) const FAILURE_SENTENCE_KEYS: Record = { @@ -163,6 +178,7 @@ const FAILURE_SENTENCE_KEYS: Record = { model_not_found: 'setup.provider.failureModelNotFound', transport_transient: 'setup.provider.failureUnreachable', bad_request: 'setup.provider.failureBadRequest', + probe_timeout: 'setup.provider.failureProbeTimeout', } const PROTOCOL_FAILURE_KINDS = new Set([ @@ -183,10 +199,29 @@ const connectionPill = computed(() => { if (connection.phase === 'unverified') { return { tone: '', text: t('setup.provider.currentSettingsNotTested'), title: '' } } - if (connection.phase === 'verified') { - return { tone: 'control-pill--ok', text: t('setup.provider.endpointVerified'), title: '' } + if (connection.phase === 'reachable') { + return { tone: 'control-pill--ok', text: t('setup.provider.reachableNotModelTested'), title: '' } + } + if (connection.phase === 'model_verified' || connection.phase === 'verified') { + return { tone: 'control-pill--ok', text: t('setup.provider.modelVerified'), title: '' } + } + if (connection.phase === 'timed_out') { + return { + tone: 'control-pill--warn', + text: connection.failureStage === 'reachability' + ? t('setup.provider.reachabilityTimedOut') + : t('setup.provider.modelTimedOut'), + title: [connection.failureKind, connection.detail].filter(Boolean).join(' — '), + } } const title = [connection.failureKind, connection.detail].filter(Boolean).join(' — ') + if (connection.phase === 'reachable_error') { + return { + tone: 'control-pill--warn', + text: t('setup.provider.endpointResponded', { reason: failureSentence(connection) }), + title, + } + } if (PROTOCOL_FAILURE_KINDS.has(connection.failureKind)) { return { tone: 'control-pill--warn', @@ -232,12 +267,12 @@ const completeProbeText = computed(() => { const hasProbeTimings = computed(() => Boolean(firstResponseText.value || completeProbeText.value)) const failureProbeTimings = computed(() => { const phase = props.panel.connection.phase - return phase === 'key_invalid' || phase === 'unreachable' + return ['reachable_error', 'key_invalid', 'unreachable', 'timed_out'].includes(phase) }) const verdictModelsText = computed(() => { const connection = props.panel.connection - if (connection.phase !== 'verified' || connection.modelSource !== 'live') return '' + if (!['model_verified', 'verified'].includes(connection.phase) || connection.modelSource !== 'live') return '' if (connection.models.length === 0) return '' const joiner = t('setup.provider.verdictSampleJoiner') const samples = connection.models.slice(0, 3).map(model => model.id).join(joiner) @@ -362,26 +397,58 @@ const verdictModelsText = computed(() => {
+ + {{ connectionPill.text }} + > + {{ connectionPill.text }} + + + {{ panel.probeDisabledReason || reachabilityDisabledReason }}
@@ -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 701596e15c..a2800576b1 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' @@ -1311,6 +1312,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 bf04d02ff3..7a27827329 100644 --- a/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts +++ b/opensquilla-webui/src/composables/chat/useChatRpcEventHandlers.ts @@ -2395,6 +2395,7 @@ export function useChatRpcEventHandlers(options: UseChatRpcEventHandlersOptions) turnOutcome?.replaySafe === true, ), 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 d9f320e30f..853f3032fd 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts @@ -181,6 +181,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 }), @@ -6988,3 +6994,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 d2bd3aa9ad..1164ccfd8a 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 { @@ -484,6 +485,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 @@ -540,6 +544,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' @@ -841,6 +853,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 @@ -2281,6 +2294,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) @@ -2290,6 +2305,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 @@ -2384,6 +2401,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() } @@ -2462,6 +2481,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, @@ -2481,12 +2501,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) @@ -3704,6 +3728,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 @@ -3807,6 +3857,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 @@ -3842,6 +3894,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 @@ -3859,6 +3913,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 @@ -3990,7 +4053,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 @@ -4015,11 +4083,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) { @@ -4028,6 +4111,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({ @@ -4102,6 +4190,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) { @@ -4246,6 +4341,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 ce64875810..07cf415856 100644 --- a/opensquilla-webui/src/types/chat.ts +++ b/opensquilla-webui/src/types/chat.ts @@ -751,6 +751,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 { @@ -860,4 +861,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/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 c5bbce5586..c73f3c2e8e 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: 40f348cb685bc2449ebd75cad5eb3aacde0e0e3e8498939001c0eeed10dccc1b -# generator-sha256: 0e0b513d7844d926e5f2c6065e64e4b523a1f8df2023317327ee6e05bfa29038 +# sources-sha256: f138a6d4c9fc3b9cb4ef2d29c732c98d2048b13c67b4582b2b3ecd32e85d77db +# generator-sha256: 205494ea1368cd1e6e6bbbbd6217fea2b9d4a1b4408487905feffa1430de0f60 # 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 @@ -2312,6 +2316,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 a161cdb966..ac14975964 100644 --- a/src/opensquilla/engine/agent.py +++ b/src/opensquilla/engine/agent.py @@ -2678,6 +2678,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 == "memory_flush_timeout_before_compaction": @@ -2722,6 +2743,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( @@ -2731,6 +2753,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 101eb2b81c..fa22a3646d 100644 --- a/src/opensquilla/engine/turn_runner/harness.py +++ b/src/opensquilla/engine/turn_runner/harness.py @@ -552,6 +552,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 f98db97c94..f96d4e3c12 100644 --- a/src/opensquilla/engine/types.py +++ b/src/opensquilla/engine/types.py @@ -281,6 +281,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..6dcecab88e 100644 --- a/src/opensquilla/gateway/adapters/provider_configuration.py +++ b/src/opensquilla/gateway/adapters/provider_configuration.py @@ -20,9 +20,7 @@ from opensquilla.gateway.provider_runtime import resolve_provider_selector_config 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 - -_catalog = ProviderModelCatalog() +from opensquilla.provider.model_catalog import shared_catalog def _positive_int(value: object) -> int | None: @@ -71,7 +69,7 @@ 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 "") - entry = _catalog.resolve_entry(model_id, provider=provider_id) + entry = shared_catalog().resolve_entry(model_id, provider=provider_id) capabilities: list[str] = ["chat"] context_window = model.get("context_window", 0) max_output_tokens = model.get("max_output_tokens", 0) @@ -166,6 +164,33 @@ 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] + capacities = resolve_model_capacities(catalog, self._config, [ + {"provider": str(item["provider"]), "model": str(item["id"])} + for item in projected + ])["models"] + by_key = {(row["provider"], row["model"]): row for row in capacities} + for item in projected: + limits = by_key[(item["provider"].strip().lower(), item["id"].strip())] + 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 +218,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 +233,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 b95ab8b77f..f97f62ee8e 100644 --- a/src/opensquilla/gateway/boot.py +++ b/src/opensquilla/gateway/boot.py @@ -1942,6 +1942,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) @@ -1985,6 +1986,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). @@ -2839,6 +2843,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 1146fdffa7..0545e17522 100644 --- a/src/opensquilla/gateway/rpc_onboarding.py +++ b/src/opensquilla/gateway/rpc_onboarding.py @@ -1225,10 +1225,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/scopes.py b/src/opensquilla/gateway/scopes.py index b94380e9aa..38c9618c6c 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 a0f7542868..ae717f7ce8 100644 --- a/src/opensquilla/onboarding/probe.py +++ b/src/opensquilla/onboarding/probe.py @@ -375,19 +375,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"] @@ -556,25 +552,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) @@ -602,9 +631,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 "" @@ -640,9 +667,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, @@ -650,11 +675,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 0d4bd116c5..9e75c02cd4 100644 --- a/src/opensquilla/provider/anthropic.py +++ b/src/opensquilla/provider/anthropic.py @@ -1277,7 +1277,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 @@ -1285,14 +1285,61 @@ 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 + return [] 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 29222da401..2945ff7127 100644 --- a/src/opensquilla/provider/ensemble.py +++ b/src/opensquilla/provider/ensemble.py @@ -642,6 +642,7 @@ class _MemberRequestBudgetBinding: rederive: bool top_level_explicit_cap: int = 0 inherit_top_level_cap: bool = True + confirmed: bool = True @dataclass @@ -966,17 +967,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: return shared_catalog().resolve_max_tokens( cfg.model, - user_override=0, + user_override=max(0, member.max_tokens or 0), 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]: @@ -1664,6 +1663,7 @@ def _attachment_request_unavailability( if ( binding is None or not binding.rederive + or not binding.confirmed or context_window_tokens <= 0 ): return ( @@ -2622,6 +2622,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( @@ -7074,7 +7075,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 9ecc53ada6..ea91e8d0fb 100644 --- a/src/opensquilla/provider/model_catalog.py +++ b/src/opensquilla/provider/model_catalog.py @@ -439,6 +439,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. @@ -1448,7 +1449,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. @@ -1480,6 +1482,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; @@ -1538,6 +1544,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 a4cc440670..f8ea303257 100644 --- a/src/opensquilla/provider/openai.py +++ b/src/opensquilla/provider/openai.py @@ -6474,13 +6474,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/provider/protocol.py b/src/opensquilla/provider/protocol.py index ba08e7807f..083c1b2e78 100644 --- a/src/opensquilla/provider/protocol.py +++ b/src/opensquilla/provider/protocol.py @@ -28,6 +28,15 @@ "model or remove the image and try again." ) + +class ProviderModelListingResponseError(RuntimeError): + """A model-list response arrived but could not be parsed safely.""" + + def __init__(self, message: str, *, status_code: int) -> None: + super().__init__(message) + self.status_code = status_code + + if TYPE_CHECKING: from .selector import ProviderConfig, SelectorConfig diff --git a/src/opensquilla/session/terminal_reply.py b/src/opensquilla/session/terminal_reply.py index edd444d8b3..3c68f4ae1f 100644 --- a/src/opensquilla/session/terminal_reply.py +++ b/src/opensquilla/session/terminal_reply.py @@ -173,12 +173,21 @@ 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." + ) 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/test_ci/test_rpc_architecture_contracts.py b/tests/test_ci/test_rpc_architecture_contracts.py index 447819fa90..cc0fe388f5 100644 --- a/tests/test_ci/test_rpc_architecture_contracts.py +++ b/tests/test_ci/test_rpc_architecture_contracts.py @@ -123,8 +123,8 @@ # The retired 20-method RPC surface is absent; exact inventory remains pinned # so this does not authorize unrelated wire growth. # Primary-provider transitions add resetRecommended and upsertAndActivate. -RUNTIME_RPC_METHOD_BASELINE = 295 -RUNTIME_RPC_METHOD_DIGEST = "34c09211505ec1711fef3a294f254fdbc0ce1bf587d8cf1fc42b87e5943745b5" +RUNTIME_RPC_METHOD_BASELINE = 296 +RUNTIME_RPC_METHOD_DIGEST = "244e8ab10bf64f0f35f96e8d70f43393944a14100bf3e44e2366e13f9da4a97e" STATIC_RPC_DECORATOR_BASELINE = 77 # Physical lines in the sessions/runtime slice remain tracked for the final diff --git a/tests/test_gateway/test_model_capacity.py b/tests/test_gateway/test_model_capacity.py new file mode 100644 index 0000000000..2329b8573a --- /dev/null +++ b/tests/test_gateway/test_model_capacity.py @@ -0,0 +1,493 @@ +"""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 + + +def test_default_capacity_error_has_source_and_exact_model_target(configured): + from opensquilla.engine.agent import Agent + from opensquilla.engine.types import AgentConfig + from opensquilla.session.terminal_reply import 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 = "provider_request_budget_exhausted" + 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, "model_capacity": event.model_capacity}) + assert "system default of 8,192 tokens" in text + assert "Model settings" in text + + +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 + ) 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_provider_ensemble.py b/tests/test_provider_ensemble.py index 626f17ff79..c7f9d1bc22 100644 --- a/tests/test_provider_ensemble.py +++ b/tests/test_provider_ensemble.py @@ -3986,7 +3986,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() @@ -4014,7 +4014,7 @@ 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 + assert calls_by_model["kimi-k2.7-code"].provider_request_max_chars == 367_200 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 @@ -4024,7 +4024,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" From 39d28a200f1a6fcd97fae4568aff1f555ad898b9 Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:55:58 +0800 Subject: [PATCH 03/11] Preserve model listing and error compatibility in capacity settings --- .../tests/production_targets.test.mjs | 8 ++++- .../verify_gateway_validator_profiles.mjs | 2 +- .../adapters/provider_configuration.py | 7 +++- src/opensquilla/gateway/rpc_sessions.py | 2 ++ src/opensquilla/provider/anthropic.py | 5 ++- .../contracts/test_gateway_contract_runner.py | 11 +++++-- .../test_gateway_validator_profiles.py | 9 ++--- tests/contracts/test_r7_contract_freeze.py | 5 +-- tests/test_contracts/test_error_event_wire.py | 13 ++++++++ tests/test_gateway/test_model_capacity.py | 33 +++++++++++++++++++ 10 files changed, 82 insertions(+), 13 deletions(-) diff --git a/scripts/contracts/tests/production_targets.test.mjs b/scripts/contracts/tests/production_targets.test.mjs index 2a5f097630..aa5ab7701d 100644 --- a/scripts/contracts/tests/production_targets.test.mjs +++ b/scripts/contracts/tests/production_targets.test.mjs @@ -24,7 +24,13 @@ 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, 216) + assert.equal(result.targets.length, 218) + 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 d75e507736..dd72715255 100644 --- a/scripts/contracts/verify_gateway_validator_profiles.mjs +++ b/scripts/contracts/verify_gateway_validator_profiles.mjs @@ -107,7 +107,7 @@ export async function verifyProfiles({ baselineRoot, verificationRoot } = {}) { result.roles++ } } - assert.equal(result.roles, 866) + assert.equal(result.roles, 870) assert.equal(result.comparedRoles, baselineRoot ? 841 : selected.size) assert.deepEqual(result.rolesWithoutPositiveSeed, [], 'each role requires a positive seed') if (baselineRoot) assert.deepEqual(result.supplementalRoles, [ diff --git a/src/opensquilla/gateway/adapters/provider_configuration.py b/src/opensquilla/gateway/adapters/provider_configuration.py index 6dcecab88e..97b77d91c3 100644 --- a/src/opensquilla/gateway/adapters/provider_configuration.py +++ b/src/opensquilla/gateway/adapters/provider_configuration.py @@ -20,8 +20,11 @@ from opensquilla.gateway.provider_runtime import resolve_provider_selector_config 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() + def _positive_int(value: object) -> int | None: if isinstance(value, bool) or not isinstance(value, int) or value <= 0: @@ -69,7 +72,9 @@ 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 "") - entry = shared_catalog().resolve_entry(model_id, provider=provider_id) + # 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) max_output_tokens = model.get("max_output_tokens", 0) diff --git a/src/opensquilla/gateway/rpc_sessions.py b/src/opensquilla/gateway/rpc_sessions.py index 1067d26877..3f432ed1dd 100644 --- a/src/opensquilla/gateway/rpc_sessions.py +++ b/src/opensquilla/gateway/rpc_sessions.py @@ -1808,6 +1808,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/provider/anthropic.py b/src/opensquilla/provider/anthropic.py index 9e75c02cd4..f5b155c888 100644 --- a/src/opensquilla/provider/anthropic.py +++ b/src/opensquilla/provider/anthropic.py @@ -1336,7 +1336,10 @@ async def list_models(self, *, raise_on_error: bool = False) -> list[ModelInfo]: except (httpx.HTTPError, ValueError, TypeError, ProviderModelListingResponseError): if raise_on_error: raise - return [] + # 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 diff --git a/tests/contracts/test_gateway_contract_runner.py b/tests/contracts/test_gateway_contract_runner.py index 8c76aec5d5..951bab9a12 100644 --- a/tests/contracts/test_gateway_contract_runner.py +++ b/tests/contracts/test_gateway_contract_runner.py @@ -493,14 +493,14 @@ def test_compatibility_manifest_is_schema_derived_and_deterministic() -> None: assert manifest["protocol"] == runner.GATEWAY_PROTOCOL assert manifest["wireVersion"] == 4 assert manifest["source"] == { - "schemaCount": 224, - "methodCount": 214, + "schemaCount": 225, + "methodCount": 215, "eventFamilyCount": 10, "schemaTreeSha256": runner._schema_tree_digest(specs), "generatorSha256": runner._generator_digest(), } assert Counter(entry["lifecycle"] for entry in manifest["methods"]) == { - "stable": 211, + "stable": 212, "legacy": 3, } assert [ @@ -516,6 +516,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 90ffa1f34b..01c922c550 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) == 196 + assert len(targets) == 197 assert Counter(role for roles in targets.values() for role in roles) == { - "result": 186, - "params": 20, + "result": 187, + "params": 21, "payload": 9, "frame": 1, } - assert sum(len(spec.targets) for spec in specs) == 866 + assert sum(len(spec.targets) for spec in specs) == 870 + 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", "meta.list")] == ("result",) diff --git a/tests/contracts/test_r7_contract_freeze.py b/tests/contracts/test_r7_contract_freeze.py index 843a4215c2..d3038650ec 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) == 224 + assert len(specs) == 225 assert Counter(spec.contract_type for spec in specs) == { - "method": 214, + "method": 215, "event": 10, } assert EXPECTED_METHOD_METADATA.keys() <= {spec.wire_name for spec in specs} 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_model_capacity.py b/tests/test_gateway/test_model_capacity.py index fd47a03328..f3f5565e1a 100644 --- a/tests/test_gateway/test_model_capacity.py +++ b/tests/test_gateway/test_model_capacity.py @@ -506,3 +506,36 @@ def respond(request): 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) From f6987c885e733d1f49158c3eba593173ac081d12 Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:21:52 +0800 Subject: [PATCH 04/11] Preserve credential-scoped catalog snapshots when enriching custom limits --- .../gateway/adapters/provider_configuration.py | 8 +++++++- .../test_sandbox_runtime_contract_registration.py | 3 ++- tests/test_gateway/test_turn_admission_composition.py | 5 +++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/opensquilla/gateway/adapters/provider_configuration.py b/src/opensquilla/gateway/adapters/provider_configuration.py index 97b77d91c3..b75a236038 100644 --- a/src/opensquilla/gateway/adapters/provider_configuration.py +++ b/src/opensquilla/gateway/adapters/provider_configuration.py @@ -185,13 +185,19 @@ def project(items: list[dict[str, Any]]) -> list[dict[str, Any]]: 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[(item["provider"].strip().lower(), item["id"].strip())] + 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 diff --git a/tests/test_gateway/test_sandbox_runtime_contract_registration.py b/tests/test_gateway/test_sandbox_runtime_contract_registration.py index 24c26365b6..0294b13a2e 100644 --- a/tests/test_gateway/test_sandbox_runtime_contract_registration.py +++ b/tests/test_gateway/test_sandbox_runtime_contract_registration.py @@ -159,7 +159,8 @@ def register( def test_production_registry_uses_contract_wrappers_without_surface_drift() -> None: registry = get_dispatcher() - assert len(registry.list_methods()) == 295 + assert len(registry.list_methods()) == 296 + assert registry.get_entry("models.capacity.resolve") 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 845a8b320a..fab0e60c1b 100644 --- a/tests/test_gateway/test_turn_admission_composition.py +++ b/tests/test_gateway/test_turn_admission_composition.py @@ -147,8 +147,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 the product-activity and session execution-log methods. -assert len(registry.list_methods()) == 295 +# Includes product activity, execution logs, and model capacity resolution. +assert len(registry.list_methods()) == 296 +assert registry.get_entry("models.capacity.resolve") 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: From 91a8bcc02e1d93bde770eaa5b33e9d058e074a53 Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:48:59 +0800 Subject: [PATCH 05/11] Cover connection checks without a model ID --- .../setup/useSetupCatalog.privacy.test.ts | 21 ++++++- .../test_provider_credential_rpc.py | 61 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts index fc0a8c2ebd..160f5d0874 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts @@ -6114,7 +6114,7 @@ describe('useSetupCatalog optional provider credentials', () => { ['custom', 'Custom OpenAI-compatible endpoint', 'CUSTOM_LLM_API_KEY'], ['custom_anthropic', 'Custom Anthropic-compatible endpoint', 'CUSTOM_ANTHROPIC_API_KEY'], ])( - 'exposes an optional key and blocks %s probes until required fields exist', + 'allows %s connection checks without a model once required connection fields exist', async (providerId, label, envKey) => { rpcCall.mockImplementation(async (method: string) => { if (method === 'onboarding.catalog') { @@ -6165,8 +6165,25 @@ describe('useSetupCatalog optional provider credentials', () => { api.probeProviderConnection() expect(rpcCall.mock.calls.some(call => call[0] === 'onboarding.provider.probe')).toBe(false) - api.updateProviderField('model', 'test-model') api.updateProviderField('base_url', 'https://custom.example.test/v1') + expect(api.providerPanel.value.credentialPanel).toMatchObject({ + probeReady: false, + reachabilityReady: true, + reachabilityDisabledReason: '', + }) + await api.probeProviderConnection() + expect(rpcCall).toHaveBeenCalledWith('onboarding.provider.probe', { + providerId, + baseUrl: 'https://custom.example.test/v1', + mode: 'reachability', + }) + await api.probeProviderConnection('model') + expect(rpcCall.mock.calls.filter(call => call[0] === 'onboarding.provider.probe')).toHaveLength(1) + expect(rpcCall.mock.calls.some(call => ( + call[0] === 'onboarding.provider.configure' || call[0] === 'onboarding.llmProfile.upsert' + ))).toBe(false) + + api.updateProviderField('model', 'test-model') credential = api.providerPanel.value.credentialPanel expect(credential?.probeReady).toBe(true) expect(credential?.probeDisabledReason).toBe('') diff --git a/tests/test_onboarding/test_provider_credential_rpc.py b/tests/test_onboarding/test_provider_credential_rpc.py index 1759cc7992..0fbdc5a932 100644 --- a/tests/test_onboarding/test_provider_credential_rpc.py +++ b/tests/test_onboarding/test_provider_credential_rpc.py @@ -226,6 +226,67 @@ def build_provider(provider_id: str, model: str, **kwargs: Any) -> _Provider: assert calls == ["list", "chat"] +@pytest.mark.parametrize("provider_id", ["openai", "custom"]) +@pytest.mark.parametrize("model_fields", [{}, {"model": ""}], ids=["omitted", "empty"]) +@pytest.mark.parametrize( + ("status_code", "body", "expected_kind"), + [ + (200, {"data": [{"id": "synthetic-model"}]}, ""), + (200, {"data": []}, ""), + (401, {"error": {"message": "Invalid API key"}}, "auth_invalid"), + (404, {"error": {"message": "Not Found"}}, "unsupported_feature"), + ], + ids=["models", "empty-catalog", "invalid-key", "unsupported-listing"], +) +async def test_primary_reachability_without_model_uses_live_listing( + tmp_path, + monkeypatch: Any, + provider_id: str, + model_fields: dict[str, str], + status_code: int, + body: dict[str, Any], + expected_kind: str, +) -> None: + seen: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + seen.append(request) + assert request.method == "GET" + assert str(request.url) == "https://model-list.example.test/v1/models" + assert request.headers["authorization"] == "Bearer synthetic-probe-key" + return httpx.Response(status_code, json=body) + + real_client = httpx.AsyncClient + + def client(*args: Any, **kwargs: Any) -> httpx.AsyncClient: + kwargs["transport"] = httpx.MockTransport(respond) + return real_client(*args, **kwargs) + + monkeypatch.setattr("opensquilla.provider.openai.httpx.AsyncClient", client) + ctx = _ctx(tmp_path, is_owner=True, llm=LlmProviderConfig()) + before = ctx.config.model_dump(mode="python") + payload = await rpc_onboarding._provider_probe( + { + "providerId": provider_id, + "apiKey": "synthetic-probe-key", + "baseUrl": "https://model-list.example.test/v1", + "mode": "reachability", + **model_fields, + }, + ctx, + ) + + assert len(seen) == 1 + assert payload["ok"] is (status_code == 200) + assert payload["model"] == "" + assert payload["failureKind"] == expected_kind + assert payload["verificationLevel"] == "reachable" + assert payload["failureStage"] == "reachability" + assert payload["firstResponseMs"] is None + assert ctx.config.model_dump(mode="python") == before + assert not (tmp_path / "opensquilla.toml").exists() + + @pytest.mark.parametrize("mode_fields", [{}, {"mode": None}]) async def test_legacy_primary_probe_still_requires_an_explicit_model( tmp_path, From bea1c76acceefeb35cd23767bd46106c8e89c602 Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:24:35 +0000 Subject: [PATCH 06/11] Separate provider reachability and model verification (#1667) * Separate provider reachability and model probes * Cover connection checks without a model ID --- .../v4/compatibility-manifest.generated.json | 8 +- ...arding-llm-profile-draft-probe.schema.json | 88 ++- .../onboarding-llm-profile-probe.schema.json | 24 +- .../onboarding-provider-probe.schema.json | 24 +- .../adapters/gateway/platformConfigV4.test.ts | 57 ++ .../gateway/privateTransports.test.ts | 33 + .../src/adapters/gateway/privateTransports.ts | 3 + .../src/adapters/gateway/setupWorkflowV4.ts | 51 +- .../src/adapters/gateway/transportTypes.ts | 1 + .../components/settings/SettingsDialog.vue | 4 + .../setup/SetupProviderCredentialCard.test.ts | 117 ++- .../setup/SetupProviderCredentialCard.vue | 145 +++- .../components/setup/SetupProviderMenu.vue | 2 + .../setup/SetupProviderPanel.test.ts | 207 ++++- .../components/setup/SetupProviderPanel.vue | 49 +- .../setup/useSetupCatalog.privacy.test.ts | 191 ++++- .../src/composables/setup/useSetupCatalog.ts | 138 +++- .../setup/useSetupProviderForm.test.ts | 294 ++++++- .../composables/setup/useSetupProviderForm.ts | 150 +++- .../v4/onboardingLlmProfileDraftProbe.ts | 39 +- ...ardingLlmProfileDraftProbeValidators.d.mts | 2 +- ...boardingLlmProfileDraftProbeValidators.mjs | 4 +- .../generated/v4/onboardingLlmProfileProbe.ts | 14 +- .../onboardingLlmProfileProbeValidators.d.mts | 2 +- .../onboardingLlmProfileProbeValidators.mjs | 4 +- .../generated/v4/onboardingProviderProbe.ts | 14 +- .../onboardingProviderProbeValidators.d.mts | 2 +- .../v4/onboardingProviderProbeValidators.mjs | 4 +- opensquilla-webui/src/lib/rpc.test.ts | 53 ++ opensquilla-webui/src/lib/rpc.ts | 32 + opensquilla-webui/src/locales/de.json | 17 +- opensquilla-webui/src/locales/en.json | 17 +- opensquilla-webui/src/locales/es.json | 17 +- opensquilla-webui/src/locales/fr.json | 17 +- opensquilla-webui/src/locales/ja.json | 17 +- opensquilla-webui/src/locales/zh-Hans.json | 17 +- .../src/modules/setupWorkflow.ts | 15 +- src/opensquilla/application/provider_setup.py | 6 + .../generated/v4/gateway_contract_registry.py | 2 +- .../v4/onboarding_llm_profile_draft_probe.py | 71 +- ...arding_llm_profile_draft_probe_metadata.py | 2 +- .../v4/onboarding_llm_profile_probe.py | 32 +- .../onboarding_llm_profile_probe_metadata.py | 2 +- .../generated/v4/onboarding_provider_probe.py | 32 +- .../v4/onboarding_provider_probe_metadata.py | 2 +- src/opensquilla/engine/usage_accounting.py | 69 +- src/opensquilla/gateway/protocol.py | 2 + src/opensquilla/gateway/rpc_onboarding.py | 98 ++- src/opensquilla/gateway/websocket.py | 229 +++++- src/opensquilla/onboarding/probe.py | 391 +++++++++- src/opensquilla/provider/ollama.py | 11 + src/opensquilla/provider/openai.py | 24 +- src/opensquilla/provider/openai_responses.py | 19 +- src/opensquilla/provider/protocol.py | 9 + src/opensquilla/provider/selector.py | 10 +- .../test_provider_probe_wire.py | 88 +++ .../test_usage_event_accounting.py | 109 +++ .../test_provider_probe_stream_lifecycle.py | 166 ++++ tests/test_gateway/test_rpc_llm_profiles.py | 269 ++++++- .../test_websocket_frame_validation.py | 17 + .../test_websocket_request_concurrency.py | 538 +++++++++++++ tests/test_onboarding/test_models_discover.py | 26 + .../test_probe_model_listing.py | 25 +- .../test_provider_credential_rpc.py | 221 ++++++ tests/test_onboarding/test_provider_probe.py | 729 ++++++++++++++++++ 65 files changed, 4779 insertions(+), 293 deletions(-) create mode 100644 tests/test_gateway/test_provider_probe_stream_lifecycle.py diff --git a/contracts/gateway/v4/compatibility-manifest.generated.json b/contracts/gateway/v4/compatibility-manifest.generated.json index 6198b9609a..c5619c067f 100644 --- a/contracts/gateway/v4/compatibility-manifest.generated.json +++ b/contracts/gateway/v4/compatibility-manifest.generated.json @@ -842,7 +842,7 @@ "lifecycle": "stable", "name": "onboarding.llmProfile.draft.probe", "schema": "platform/onboarding-llm-profile-draft-probe.schema.json", - "schemaSha256": "b87c847bb7a4116bad627baafb3d17b205d59bc46b6cceaf2cafcfee1e0e4e06" + "schemaSha256": "e02caab5c5cc592ba57b0f2205912f9400b1d0eb31c322ceef7a72ab4350c51f" }, { "lifecycle": "stable", @@ -854,7 +854,7 @@ "lifecycle": "stable", "name": "onboarding.llmProfile.probe", "schema": "platform/onboarding-llm-profile-probe.schema.json", - "schemaSha256": "1d906e26d4631a43464c148552c8372555b91c6a51b8ddeb233db083c9c3a6f0" + "schemaSha256": "8e552abda066add5f5275b42a313746f80c7757d1f345d22762b039f6b8b007b" }, { "lifecycle": "stable", @@ -908,7 +908,7 @@ "lifecycle": "stable", "name": "onboarding.provider.probe", "schema": "platform/onboarding-provider-probe.schema.json", - "schemaSha256": "4bba8e8fbca95f2c8343a94dde1000b1b9724d447992d7b391317ca0469e565e" + "schemaSha256": "ffa4d9b4a0f662c2660de521b9bc7c27f9373c762c8cd4eafb2127b872c54e66" }, { "lifecycle": "stable", @@ -1466,7 +1466,7 @@ "generatorSha256": "f781c6d8e31b336c2d2e342784935b3d327c9947cb35e56de8e44a0a83bffae6", "methodCount": 215, "schemaCount": 225, - "schemaTreeSha256": "182cd4e1f6016ec223a6bdcf9e76cd6e734764b581e35a4f6c9efc21aeb173a6" + "schemaTreeSha256": "e667c15f23b95372ad843172a3f3581f9be8b6b1b4fc3693c07a5f317bc43eaa" }, "wireVersion": 4 } diff --git a/contracts/gateway/v4/platform/onboarding-llm-profile-draft-probe.schema.json b/contracts/gateway/v4/platform/onboarding-llm-profile-draft-probe.schema.json index 2b17e9f843..a59d125bef 100644 --- a/contracts/gateway/v4/platform/onboarding-llm-profile-draft-probe.schema.json +++ b/contracts/gateway/v4/platform/onboarding-llm-profile-draft-probe.schema.json @@ -70,6 +70,16 @@ "$defs": { "OnboardingLlmProfileDraftProbeParams": { "title": "onboarding.llmProfile.draft.probe params", + "oneOf": [ + { + "$ref": "#/$defs/OnboardingLlmProfileDraftModelProbeParams" + }, + { + "$ref": "#/$defs/OnboardingLlmProfileDraftReachabilityProbeParams" + } + ] + }, + "OnboardingLlmProfileDraftModelProbeParams": { "type": "object", "additionalProperties": true, "properties": { @@ -114,6 +124,12 @@ "string", "null" ] + }, + "mode": { + "enum": [ + "model", + null + ] } }, "required": [ @@ -121,6 +137,61 @@ "model" ] }, + "OnboardingLlmProfileDraftReachabilityProbeParams": { + "type": "object", + "additionalProperties": true, + "properties": { + "providerId": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "apiKey": { + "type": [ + "string", + "null" + ] + }, + "apiKeyEnv": { + "type": [ + "string", + "null" + ] + }, + "keepCurrentSecret": { + "type": [ + "boolean", + "null" + ] + }, + "baseUrl": { + "type": [ + "string", + "null" + ] + }, + "proxy": { + "type": [ + "string", + "null" + ] + }, + "mode": { + "const": "reachability" + } + }, + "required": [ + "providerId", + "mode" + ] + }, "OnboardingLlmProfileDraftProbeRequestFrame": { "type": "object", "additionalProperties": false, @@ -146,7 +217,22 @@ }, "OnboardingLlmProfileDraftProbeResult": { "type": "object", - "additionalProperties": true + "additionalProperties": true, + "properties": { + "ok": { "type": "boolean" }, + "providerId": { "type": "string" }, + "model": { "type": "string" }, + "failureKind": { "type": "string" }, + "message": { "type": "string" }, + "code": { "type": "string" }, + "latencyMs": { "type": "integer", "minimum": 0 }, + "firstResponseMs": { "type": ["integer", "null"], "minimum": 0 }, + "totalMs": { "type": "integer", "minimum": 0 }, + "verificationLevel": { + "enum": ["reachable", "model_verified", "none"] + }, + "failureStage": { "enum": ["reachability", "model"] } + } }, "RpcError": { "type": "object", diff --git a/contracts/gateway/v4/platform/onboarding-llm-profile-probe.schema.json b/contracts/gateway/v4/platform/onboarding-llm-profile-probe.schema.json index 439f0049ed..c60c761d89 100644 --- a/contracts/gateway/v4/platform/onboarding-llm-profile-probe.schema.json +++ b/contracts/gateway/v4/platform/onboarding-llm-profile-probe.schema.json @@ -84,6 +84,13 @@ "string", "null" ] + }, + "mode": { + "enum": [ + "reachability", + "model", + null + ] } }, "required": [ @@ -115,7 +122,22 @@ }, "OnboardingLlmProfileProbeResult": { "type": "object", - "additionalProperties": true + "additionalProperties": true, + "properties": { + "ok": { "type": "boolean" }, + "providerId": { "type": "string" }, + "model": { "type": "string" }, + "failureKind": { "type": "string" }, + "message": { "type": "string" }, + "code": { "type": "string" }, + "latencyMs": { "type": "integer", "minimum": 0 }, + "firstResponseMs": { "type": ["integer", "null"], "minimum": 0 }, + "totalMs": { "type": "integer", "minimum": 0 }, + "verificationLevel": { + "enum": ["reachable", "model_verified", "none"] + }, + "failureStage": { "enum": ["reachability", "model"] } + } }, "RpcError": { "type": "object", diff --git a/contracts/gateway/v4/platform/onboarding-provider-probe.schema.json b/contracts/gateway/v4/platform/onboarding-provider-probe.schema.json index 5915ef522c..1e54bf69e8 100644 --- a/contracts/gateway/v4/platform/onboarding-provider-probe.schema.json +++ b/contracts/gateway/v4/platform/onboarding-provider-probe.schema.json @@ -114,6 +114,13 @@ "string", "null" ] + }, + "mode": { + "enum": [ + "reachability", + "model", + null + ] } }, "required": [ @@ -145,7 +152,22 @@ }, "OnboardingProviderProbeResult": { "type": "object", - "additionalProperties": true + "additionalProperties": true, + "properties": { + "ok": { "type": "boolean" }, + "providerId": { "type": "string" }, + "model": { "type": "string" }, + "failureKind": { "type": "string" }, + "message": { "type": "string" }, + "code": { "type": "string" }, + "latencyMs": { "type": "integer", "minimum": 0 }, + "firstResponseMs": { "type": ["integer", "null"], "minimum": 0 }, + "totalMs": { "type": "integer", "minimum": 0 }, + "verificationLevel": { + "enum": ["reachable", "model_verified", "none"] + }, + "failureStage": { "enum": ["reachability", "model"] } + } }, "RpcError": { "type": "object", diff --git a/opensquilla-webui/src/adapters/gateway/platformConfigV4.test.ts b/opensquilla-webui/src/adapters/gateway/platformConfigV4.test.ts index da486458ff..d367f48f45 100644 --- a/opensquilla-webui/src/adapters/gateway/platformConfigV4.test.ts +++ b/opensquilla-webui/src/adapters/gateway/platformConfigV4.test.ts @@ -167,6 +167,63 @@ describe('Platform configuration adapters', () => { expect(source.request).toHaveBeenCalledWith('onboarding.router.configure', { mode: 'recommended' }, expect.any(Object)) }) + it('passes provider probe modes, cancellation, and caller-specific timeouts to transport', async () => { + const source = rpc() + const setup = createV4SetupWorkflow(source) + const controller = new AbortController() + + await setup.provider.probePrimary( + { providerId: 'openai', mode: 'reachability' }, + { signal: controller.signal, timeoutMs: 70_000 }, + ) + expect(source.request).toHaveBeenLastCalledWith( + 'onboarding.provider.probe', + { providerId: 'openai', mode: 'reachability' }, + { + timeoutMs: 70_000, + timeoutAction: 'reject', + abortAction: 'reject', + cancelOnAbort: true, + signal: controller.signal, + }, + ) + + await setup.provider.probePrimary( + { providerId: 'openai', model: 'gpt-4o', mode: 'model' }, + { timeoutMs: 65_000 }, + ) + expect(source.request).toHaveBeenLastCalledWith( + 'onboarding.provider.probe', + { providerId: 'openai', model: 'gpt-4o', mode: 'model' }, + expect.objectContaining({ timeoutMs: 65_000, cancelOnAbort: true }), + ) + + await setup.profile.probeProfile({ providerId: 'openai', mode: 'model' }) + expect(source.request).toHaveBeenLastCalledWith( + 'onboarding.llmProfile.probe', + { providerId: 'openai', mode: 'model' }, + expect.objectContaining({ cancelOnAbort: true }), + ) + + await setup.profile.probeDraftProfile({ providerId: 'openai', mode: 'reachability' }) + expect(source.request).toHaveBeenLastCalledWith( + 'onboarding.llmProfile.draft.probe', + { providerId: 'openai', mode: 'reachability' }, + expect.objectContaining({ cancelOnAbort: true }), + ) + + await setup.profile.probeProfile({ providerId: 'openai', model: 'gpt-4o' }) + expect(source.request).toHaveBeenLastCalledWith( + 'onboarding.llmProfile.probe', + { providerId: 'openai', model: 'gpt-4o' }, + { + timeoutMs: 20_000, + timeoutAction: 'reject', + abortAction: 'reject', + }, + ) + }) + it('keeps migration discovery and preview read-only behind the domain seam', async () => { const source = rpc() const migration = createV4MigrationOperations(source) diff --git a/opensquilla-webui/src/adapters/gateway/privateTransports.test.ts b/opensquilla-webui/src/adapters/gateway/privateTransports.test.ts index bd30197ac3..dc4ef7d9c6 100644 --- a/opensquilla-webui/src/adapters/gateway/privateTransports.test.ts +++ b/opensquilla-webui/src/adapters/gateway/privateTransports.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcCallOptions, RpcEventHandler } from '@/lib/rpc' import { createPrivateGatewayTransports } from './privateTransports' +import { createV4SetupWorkflow } from './setupWorkflowV4' function source() { return { connectionGeneration: 7, + policy: { provider_probe_modes: ['model', 'reachability'] }, call: vi.fn(async () => ({ ok: true })) as ( method: string, params?: Record, @@ -68,6 +70,37 @@ describe('private Gateway transports', () => { expect(rpcSource.rememberUnsupportedMethod).toHaveBeenCalledWith('legacy.method') }) + it('projects the negotiated provider probe modes into the setup workflow capability', () => { + const transports = createPrivateGatewayTransports(source()) + const workflow = createV4SetupWorkflow(transports.rpc) + + expect(workflow.capabilities.providerProbeModes).toBe(true) + }) + + it.each([ + undefined, + null, + {}, + { provider_probe_modes: 'model,reachability' }, + { provider_probe_modes: ['model'] }, + { provider_probe_modes: ['reachability'] }, + { provider_probe_modes: ['model', 'reachability', 1] }, + ])('keeps legacy probing for missing or incomplete policy %j', policy => { + const transports = createPrivateGatewayTransports({ ...source(), policy }) + expect(createV4SetupWorkflow(transports.rpc).capabilities.providerProbeModes).toBe(false) + }) + + it('reflects a replacement connection policy without rebuilding the workflow', () => { + const rpcSource = source() + const workflow = createV4SetupWorkflow(createPrivateGatewayTransports(rpcSource).rpc) + + expect(workflow.capabilities.providerProbeModes).toBe(true) + rpcSource.policy = { provider_probe_modes: ['model'] } + expect(workflow.capabilities.providerProbeModes).toBe(false) + rpcSource.policy = { provider_probe_modes: ['reachability', 'model'] } + expect(workflow.capabilities.providerProbeModes).toBe(true) + }) + it('owns idempotent event unsubscription', () => { const rpcSource = source() const unsubscribe = vi.fn() diff --git a/opensquilla-webui/src/adapters/gateway/privateTransports.ts b/opensquilla-webui/src/adapters/gateway/privateTransports.ts index 717ff43062..7bcc3a72ca 100644 --- a/opensquilla-webui/src/adapters/gateway/privateTransports.ts +++ b/opensquilla-webui/src/adapters/gateway/privateTransports.ts @@ -26,6 +26,7 @@ export interface RpcTransport { ): Promise ready(options?: TransportReadyOptions): Promise supports(method: string): boolean + readonly policy?: Readonly> | null markUnsupported(method: string): void acknowledgeDelivery?(receipt: TransportDeliveryReceipt): Promise | void resumeFlow?(receipt: TransportInstalledReceipt): Promise | void @@ -56,6 +57,7 @@ export interface GatewayTransports { } interface RpcStoreTransportSource { + readonly policy?: Record | null readonly connectionGeneration: number call( method: string, @@ -145,6 +147,7 @@ export function createPrivateGatewayTransports( supports(method) { return source.hasRpcMethod(method) }, + get policy() { return source.policy }, markUnsupported(method) { source.rememberUnsupportedMethod(method) }, diff --git a/opensquilla-webui/src/adapters/gateway/setupWorkflowV4.ts b/opensquilla-webui/src/adapters/gateway/setupWorkflowV4.ts index 5d2bcbfd70..b92476e7cd 100644 --- a/opensquilla-webui/src/adapters/gateway/setupWorkflowV4.ts +++ b/opensquilla-webui/src/adapters/gateway/setupWorkflowV4.ts @@ -24,15 +24,24 @@ interface RpcTransport { request(method: string, params?: Record, options?: RpcCallOptions): Promise ready?(options?: { timeoutMs?: number; signal?: AbortSignal }): Promise supports?(method: string): boolean + readonly policy?: Readonly> | null } -const options = (signal?: AbortSignal): RpcCallOptions => ({ - timeoutMs: 20_000, +const options = ( + request?: SetupRequestOptions, + cancelOnAbort = false, +): RpcCallOptions => ({ + timeoutMs: request?.timeoutMs ?? 20_000, timeoutAction: 'reject', abortAction: 'reject', - ...(signal ? { signal } : {}), + ...(cancelOnAbort ? { cancelOnAbort: true } : {}), + ...(request?.signal ? { signal: request.signal } : {}), }) +const hasExplicitProbeMode = (command: { mode?: unknown }): boolean => ( + command.mode === 'reachability' || command.mode === 'model' +) + const wireParams = (value: object): Record => ({ ...value }) const object = (result: unknown, method: string): Record => { @@ -88,10 +97,11 @@ async function requestContract( contract: SetupContractDescriptor, params: Record | undefined, request?: SetupRequestOptions, + cancelOnAbort = false, ): Promise> { let result: unknown try { - result = await rpc.request(contract.method, params, options(request?.signal)) + result = await rpc.request(contract.method, params, options(request, cancelOnAbort)) } catch (error) { throw mapSetupError(error) } @@ -111,7 +121,13 @@ export function createV4SetupWorkflow(rpc: RpcTransport): SetupWorkflow { return requestContract(rpc, setupContracts.providerConfigure, wireParams(command), request) }, probePrimary(command, request) { - return requestContract(rpc, setupContracts.providerProbe, wireParams(command), request) + return requestContract( + rpc, + setupContracts.providerProbe, + wireParams(command), + request, + hasExplicitProbeMode(command), + ) }, discoverPrimaryModels(command, request) { return requestContract(rpc, setupContracts.modelsDiscover, wireParams(command), request) as Promise @@ -142,10 +158,22 @@ export function createV4SetupWorkflow(rpc: RpcTransport): SetupWorkflow { return requestContract(rpc, setupContracts.profileActivate, wireParams(command), request) }, probeProfile(command, request) { - return requestContract(rpc, setupContracts.profileProbe, wireParams(command), request) + return requestContract( + rpc, + setupContracts.profileProbe, + wireParams(command), + request, + hasExplicitProbeMode(command), + ) }, probeDraftProfile(command, request) { - return requestContract(rpc, setupContracts.profileDraftProbe, wireParams(command), request) as Promise + return requestContract( + rpc, + setupContracts.profileDraftProbe, + wireParams(command), + request, + hasExplicitProbeMode(command), + ) as Promise }, async discoverProfileModels(command, request) { try { @@ -207,11 +235,18 @@ export function createV4SetupWorkflow(rpc: RpcTransport): SetupWorkflow { get imageModelDiscovery() { return rpc.supports?.(setupContracts.imageModelsDiscover.method) !== false }, + get providerProbeModes() { + const advertised = rpc.policy?.provider_probe_modes + return Array.isArray(advertised) + && advertised.every(value => typeof value === 'string') + && advertised.includes('reachability') + && advertised.includes('model') + }, }, async catalog(request) { let result: unknown try { - result = await rpc.request(ONBOARDING_CATALOG_METHOD, undefined, options(request?.signal)) + result = await rpc.request(ONBOARDING_CATALOG_METHOD, undefined, options(request)) } catch (error) { throw mapSetupError(error) } diff --git a/opensquilla-webui/src/adapters/gateway/transportTypes.ts b/opensquilla-webui/src/adapters/gateway/transportTypes.ts index 3aedd50b29..3a480c3dba 100644 --- a/opensquilla-webui/src/adapters/gateway/transportTypes.ts +++ b/opensquilla-webui/src/adapters/gateway/transportTypes.ts @@ -25,6 +25,7 @@ export interface TransportCallOptions { signal?: AbortSignal timeoutAction?: TransportTerminationAction abortAction?: TransportTerminationAction + cancelOnAbort?: boolean expectedGeneration?: number onSent?: (socketGeneration: number) => void } diff --git a/opensquilla-webui/src/components/settings/SettingsDialog.vue b/opensquilla-webui/src/components/settings/SettingsDialog.vue index 61eaa4c702..4d35bcc2f0 100644 --- a/opensquilla-webui/src/components/settings/SettingsDialog.vue +++ b/opensquilla-webui/src/components/settings/SettingsDialog.vue @@ -134,7 +134,9 @@ @update-llm-timeout="updateLlmTimeout" @update-context-window="updateContextWindow" @probe-connection="probeProviderConnection" + @cancel-provider-probe="cancelProviderProbe" @refresh-models="refreshProviderModels" + @cancel-configured-provider-probe="cancelConfiguredProviderProbe" @save-provider="saveProvider" @save-provider-and-activate="saveProviderAndActivate" @cancel-provider-edit="cancelProviderEdit" @@ -317,8 +319,10 @@ const { updateLlmTimeout, updateContextWindow, probeProviderConnection, + cancelProviderProbe, refreshProviderModels, probeConfiguredProvider, + cancelConfiguredProviderProbe, activateProvider, removeProviderProfile, updateTierField, diff --git a/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.test.ts b/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.test.ts index b645b7c8ef..89f5d2b70e 100644 --- a/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.test.ts +++ b/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.test.ts @@ -25,7 +25,7 @@ function panel(overrides: Record = {}) { apiKeyEnvValue: 'DEEPSEEK_API_KEY', probeReady: true, probeDisabledReason: '', - probeButtonLabel: 'Verify current configuration', + probeButtonLabel: 'Test model', connection: { phase: 'unverified' }, ...overrides, } @@ -91,7 +91,7 @@ function discoveredModel(id: string) { function verifiedConnection(overrides: Record = {}) { return { - phase: 'verified', + phase: 'model_verified', failureKind: '', detail: '', firstResponseMs: 123, @@ -396,8 +396,8 @@ describe('SetupProviderCredentialCard', () => { expect(button).toBeTruthy() expect(button?.disabled).toBe(true) expect(button?.title).toBe('Add an API key before verifying this provider.') - expect(button?.getAttribute('aria-describedby')).toBe('setup-provider-probe-hint-deepseek') - expect(el.querySelector('#setup-provider-probe-hint-deepseek')?.textContent) + expect(button?.getAttribute('aria-describedby')).toBe('setup-provider-probe-hint-full-deepseek') + expect(el.querySelector('#setup-provider-probe-hint-full-deepseek')?.textContent) .toBe('Add an API key before verifying this provider.') expect(el.textContent).toContain('Add an API key before verifying this provider.') @@ -454,7 +454,7 @@ describe('SetupProviderCredentialCard', () => { const { app, el } = await mountCard({ probeReady: false, probeDisabledReason: reason }) const button = Array.from(el.querySelectorAll('button')) - .find(candidate => candidate.textContent?.includes('Verify current configuration')) + .find(candidate => candidate.textContent?.includes('Test model')) expect(button?.disabled).toBe(true) expect(button?.title).toBe(reason) expect(el.textContent).toContain(reason) @@ -547,18 +547,119 @@ describe('SetupProviderCredentialCard', () => { }) describe('SetupProviderCredentialCard — configuration verification verdict', () => { + it('offers separate connection and model checks with independent readiness', async () => { + const onCheckReachability = vi.fn() + const onTestModel = vi.fn() + const { app, el } = await mountCard({ + reachabilityReady: true, + probeReady: false, + probeDisabledReason: 'Choose a model before testing it.', + }, { onCheckReachability, onTestModel }) + const buttons = Array.from(el.querySelectorAll('button')) + const connection = buttons.find(button => button.textContent?.trim() === 'Check connection') + const model = buttons.find(button => button.textContent?.trim() === 'Test model') + + expect(connection?.disabled).toBe(false) + expect(model?.disabled).toBe(true) + connection?.click() + model?.click() + expect(onCheckReachability).toHaveBeenCalledOnce() + expect(onTestModel).not.toHaveBeenCalled() + + app.unmount() + }) + + it('distinguishes endpoint reachability from a successful model call', async () => { + const { app, el } = await mountCard({ + connection: verifiedConnection({ + phase: 'reachable', + verificationLevel: 'reachable', + firstResponseMs: null, + }), + }) + + expect(el.textContent).toContain('Endpoint reachable · model not tested') + expect(el.textContent).not.toContain('Model call succeeded') + expect(el.textContent).not.toContain('First model response') + + app.unmount() + }) + + it('reports model timeout as incomplete verification rather than unreachable', async () => { + const { app, el } = await mountCard({ + connection: verifiedConnection({ + phase: 'timed_out', + verificationLevel: 'none', + failureStage: 'model', + failureKind: 'probe_timeout', + detail: 'Model probe timed out after 60 seconds.', + models: [], + modelSource: 'none', + }), + }) + + expect(el.textContent).toContain('Model response timed out · verification incomplete') + expect(el.textContent).not.toContain("Couldn't connect") + + app.unmount() + }) + + it('reports a provider response separately from transport reachability failures', async () => { + const { app, el } = await mountCard({ + connection: verifiedConnection({ + phase: 'reachable_error', + verificationLevel: 'reachable', + failureStage: 'reachability', + failureKind: 'rate_limited', + detail: 'HTTP 429', + models: [], + modelSource: 'none', + }), + }) + + expect(el.textContent).toContain('Endpoint responded') + expect(el.textContent).toContain('rate-limiting requests') + expect(el.textContent).not.toContain("Couldn't connect") + + app.unmount() + }) + + it('does not describe a malformed reachability response as a streaming failure', async () => { + const { app, el } = await mountCard({ + connection: verifiedConnection({ + phase: 'reachable_error', + verificationLevel: 'reachable', + failureStage: 'reachability', + failureKind: 'malformed_response', + detail: 'Provider model catalog response could not be parsed', + models: [], + modelSource: 'none', + }), + }) + + expect(el.textContent).toContain('Endpoint responded') + expect(el.textContent).not.toContain('Streaming response incompatible') + app.unmount() + }) + it('announces the current-settings verdict and exposes probing as busy', async () => { const verified = await mountCard({ connection: verifiedConnection() }) const status = verified.el.querySelector('[role="status"]') expect(status?.getAttribute('aria-live')).toBe('polite') expect(status?.getAttribute('aria-atomic')).toBe('true') - expect(status?.textContent).toContain('Configuration verified') + expect(status?.textContent).toContain('Model call succeeded') verified.app.unmount() - const probing = await mountCard({ connection: { phase: 'probing' } }) + const onCancelProbe = vi.fn() + const probing = await mountCard( + { connection: { phase: 'probing', probeMode: 'model' } }, + { onCancelProbe }, + ) const button = Array.from(probing.el.querySelectorAll('button')) - .find(candidate => candidate.textContent?.includes('Verifying configuration')) + .find(candidate => candidate.textContent?.includes('Cancel test')) expect(button?.getAttribute('aria-busy')).toBe('true') + button?.click() + expect(onCancelProbe).toHaveBeenCalledOnce() probing.app.unmount() }) diff --git a/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.vue b/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.vue index 172a58be66..4cc9a3f0a0 100644 --- a/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.vue +++ b/opensquilla-webui/src/components/setup/SetupProviderCredentialCard.vue @@ -2,7 +2,10 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import Icon from '@/components/Icon.vue' -import type { ConnectionState } from '@/composables/setup/useSetupProviderForm' +import { + isModelVerifiedConnection, + type ConnectionState, +} from '@/composables/setup/useSetupProviderForm' const { t } = useI18n() @@ -27,6 +30,9 @@ interface ProviderCredentialPanelContract { probeReady: boolean probeDisabledReason: string probeButtonLabel: string + reachabilityReady?: boolean + reachabilityDisabledReason?: string + probeModesSupported?: boolean connection: ConnectionState } @@ -42,7 +48,9 @@ const emit = defineEmits<{ replace: [] cancelReplace: [] removeCredential: [] - testConnection: [] + checkReachability: [] + testModel: [] + cancelProbe: [] updateField: [name: string, value: string] }>() @@ -151,8 +159,15 @@ watch(editingCredential, editing => { }) watch(() => props.panel.providerLabel, () => { showApiKey.value = false }) const probing = computed(() => props.panel.connection.phase === 'probing') +const checkingReachability = computed(() => ( + probing.value && props.panel.connection.probeMode === 'reachability' +)) +const reachabilityReady = computed(() => props.panel.reachabilityReady ?? props.panel.probeReady) +const reachabilityDisabledReason = computed(() => ( + props.panel.reachabilityDisabledReason ?? props.panel.probeDisabledReason +)) const probeHintId = computed(() => ( - `setup-provider-probe-hint-${props.panel.providerLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` + `setup-provider-probe-hint-${props.compact ? 'compact' : 'full'}-${props.panel.providerLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` )) const FAILURE_SENTENCE_KEYS: Record = { @@ -163,6 +178,7 @@ const FAILURE_SENTENCE_KEYS: Record = { model_not_found: 'setup.provider.failureModelNotFound', transport_transient: 'setup.provider.failureUnreachable', bad_request: 'setup.provider.failureBadRequest', + probe_timeout: 'setup.provider.failureProbeTimeout', } const PROTOCOL_FAILURE_KINDS = new Set([ @@ -183,10 +199,29 @@ const connectionPill = computed(() => { if (connection.phase === 'unverified') { return { tone: '', text: t('setup.provider.currentSettingsNotTested'), title: '' } } - if (connection.phase === 'verified') { - return { tone: 'control-pill--ok', text: t('setup.provider.endpointVerified'), title: '' } + if (connection.phase === 'reachable') { + return { tone: 'control-pill--ok', text: t('setup.provider.reachableNotModelTested'), title: '' } + } + if (connection.phase === 'model_verified' || connection.phase === 'verified') { + return { tone: 'control-pill--ok', text: t('setup.provider.modelVerified'), title: '' } + } + if (connection.phase === 'timed_out') { + return { + tone: 'control-pill--warn', + text: connection.failureStage === 'reachability' + ? t('setup.provider.reachabilityTimedOut') + : t('setup.provider.modelTimedOut'), + title: [connection.failureKind, connection.detail].filter(Boolean).join(' — '), + } } const title = [connection.failureKind, connection.detail].filter(Boolean).join(' — ') + if (connection.phase === 'reachable_error') { + return { + tone: 'control-pill--warn', + text: t('setup.provider.endpointResponded', { reason: failureSentence(connection) }), + title, + } + } if (PROTOCOL_FAILURE_KINDS.has(connection.failureKind)) { return { tone: 'control-pill--warn', @@ -232,12 +267,12 @@ const completeProbeText = computed(() => { const hasProbeTimings = computed(() => Boolean(firstResponseText.value || completeProbeText.value)) const failureProbeTimings = computed(() => { const phase = props.panel.connection.phase - return phase === 'key_invalid' || phase === 'unreachable' + return ['reachable_error', 'key_invalid', 'unreachable', 'timed_out'].includes(phase) }) const verdictModelsText = computed(() => { const connection = props.panel.connection - if (connection.phase !== 'verified' || connection.modelSource !== 'live') return '' + if (!['model_verified', 'verified'].includes(connection.phase) || connection.modelSource !== 'live') return '' if (connection.models.length === 0) return '' const joiner = t('setup.provider.verdictSampleJoiner') const samples = connection.models.slice(0, 3).map(model => model.id).join(joiner) @@ -362,26 +397,58 @@ const verdictModelsText = computed(() => {
+ + {{ connectionPill.text }} + > + {{ connectionPill.text }} + + + {{ panel.probeDisabledReason || reachabilityDisabledReason }}