mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
{
|
||||
"name": "@qinglong/ai",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 optional bounded model gateway and remote providers",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./model": {
|
||||
"types": "./dist/model-gateway/model.d.ts",
|
||||
"require": "./dist/model-gateway/model.js",
|
||||
"default": "./dist/model-gateway/model.js"
|
||||
},
|
||||
"./gateway": {
|
||||
"types": "./dist/model-gateway/gateway.d.ts",
|
||||
"require": "./dist/model-gateway/gateway.js",
|
||||
"default": "./dist/model-gateway/gateway.js"
|
||||
},
|
||||
"./model-invocation": {
|
||||
"types": "./dist/model-invocation/modelInvocation.d.ts",
|
||||
"require": "./dist/model-invocation/modelInvocation.js",
|
||||
"default": "./dist/model-invocation/modelInvocation.js"
|
||||
},
|
||||
"./model-invocation-resolution": {
|
||||
"types": "./dist/model-invocation/modelInvocationResolution.d.ts",
|
||||
"require": "./dist/model-invocation/modelInvocationResolution.js",
|
||||
"default": "./dist/model-invocation/modelInvocationResolution.js"
|
||||
},
|
||||
"./durable-model-invocation": {
|
||||
"types": "./dist/model-invocation/durableModelInvocationCoordinator.d.ts",
|
||||
"require": "./dist/model-invocation/durableModelInvocationCoordinator.js",
|
||||
"default": "./dist/model-invocation/durableModelInvocationCoordinator.js"
|
||||
},
|
||||
"./profile": {
|
||||
"types": "./dist/profile/profileComposition.d.ts",
|
||||
"require": "./dist/profile/profileComposition.js",
|
||||
"default": "./dist/profile/profileComposition.js"
|
||||
},
|
||||
"./provider-credential": {
|
||||
"types": "./dist/model-provider-credential/providerCredential.d.ts",
|
||||
"require": "./dist/model-provider-credential/providerCredential.js",
|
||||
"default": "./dist/model-provider-credential/providerCredential.js"
|
||||
},
|
||||
"./model-provider-credential-administration": {
|
||||
"types": "./dist/model-provider-credential/modelProviderCredentialAdministration.d.ts",
|
||||
"require": "./dist/model-provider-credential/modelProviderCredentialAdministration.js",
|
||||
"default": "./dist/model-provider-credential/modelProviderCredentialAdministration.js"
|
||||
},
|
||||
"./model-provider-credential-catalog": {
|
||||
"types": "./dist/model-provider-credential/modelProviderCredentialCatalog.d.ts",
|
||||
"require": "./dist/model-provider-credential/modelProviderCredentialCatalog.js",
|
||||
"default": "./dist/model-provider-credential/modelProviderCredentialCatalog.js"
|
||||
},
|
||||
"./postgres-model-provider-credential-storage": {
|
||||
"types": "./dist/model-provider-credential/postgresModelProviderCredentialRepository.d.ts",
|
||||
"require": "./dist/model-provider-credential/postgresModelProviderCredentialRepository.js",
|
||||
"default": "./dist/model-provider-credential/postgresModelProviderCredentialRepository.js"
|
||||
},
|
||||
"./postgres-model-provider-credential-management-identity-ledger": {
|
||||
"types": "./dist/model-provider-credential/postgresModelProviderCredentialManagementIdentityLedger.d.ts",
|
||||
"require": "./dist/model-provider-credential/postgresModelProviderCredentialManagementIdentityLedger.js",
|
||||
"default": "./dist/model-provider-credential/postgresModelProviderCredentialManagementIdentityLedger.js"
|
||||
},
|
||||
"./postgres-model-provider-credential-management-audit-query": {
|
||||
"types": "./dist/model-provider-credential/postgresModelProviderCredentialManagementAuditQuery.d.ts",
|
||||
"require": "./dist/model-provider-credential/postgresModelProviderCredentialManagementAuditQuery.js",
|
||||
"default": "./dist/model-provider-credential/postgresModelProviderCredentialManagementAuditQuery.js"
|
||||
},
|
||||
"./model-provider-credential-test-connection": {
|
||||
"types": "./dist/model-provider-credential/modelProviderCredentialTestConnection.d.ts",
|
||||
"require": "./dist/model-provider-credential/modelProviderCredentialTestConnection.js",
|
||||
"default": "./dist/model-provider-credential/modelProviderCredentialTestConnection.js"
|
||||
},
|
||||
"./postgres-model-provider-credential-test-connection": {
|
||||
"types": "./dist/model-provider-credential/postgresModelProviderCredentialTestConnection.d.ts",
|
||||
"require": "./dist/model-provider-credential/postgresModelProviderCredentialTestConnection.js",
|
||||
"default": "./dist/model-provider-credential/postgresModelProviderCredentialTestConnection.js"
|
||||
},
|
||||
"./projected-model-provider-secret-material": {
|
||||
"types": "./dist/model-provider-credential/projectedModelProviderSecretMaterial.d.ts",
|
||||
"require": "./dist/model-provider-credential/projectedModelProviderSecretMaterial.js",
|
||||
"default": "./dist/model-provider-credential/projectedModelProviderSecretMaterial.js"
|
||||
},
|
||||
"./projected-model-gateway-authority": {
|
||||
"types": "./dist/model-gateway/projectedModelGatewayAuthority.d.ts",
|
||||
"require": "./dist/model-gateway/projectedModelGatewayAuthority.js",
|
||||
"default": "./dist/model-gateway/projectedModelGatewayAuthority.js"
|
||||
},
|
||||
"./usage-ledger": {
|
||||
"types": "./dist/usage/usageLedger.d.ts",
|
||||
"require": "./dist/usage/usageLedger.js",
|
||||
"default": "./dist/usage/usageLedger.js"
|
||||
},
|
||||
"./usage-quota": {
|
||||
"types": "./dist/usage/usageQuota.d.ts",
|
||||
"require": "./dist/usage/usageQuota.js",
|
||||
"default": "./dist/usage/usageQuota.js"
|
||||
},
|
||||
"./pricing": {
|
||||
"types": "./dist/pricing/pricing.d.ts",
|
||||
"require": "./dist/pricing/pricing.js",
|
||||
"default": "./dist/pricing/pricing.js"
|
||||
},
|
||||
"./plugin-package-prompt-execution": {
|
||||
"types": "./dist/prompt/pluginPackagePromptExecution.d.ts",
|
||||
"require": "./dist/prompt/pluginPackagePromptExecution.js",
|
||||
"default": "./dist/prompt/pluginPackagePromptExecution.js"
|
||||
},
|
||||
"./plugin-package-prompt-catalog": {
|
||||
"types": "./dist/prompt/pluginPackagePromptCatalog.d.ts",
|
||||
"require": "./dist/prompt/pluginPackagePromptCatalog.js",
|
||||
"default": "./dist/prompt/pluginPackagePromptCatalog.js"
|
||||
},
|
||||
"./plugin-package-prompt-execution-inspection": {
|
||||
"types": "./dist/prompt/pluginPackagePromptExecutionInspection.d.ts",
|
||||
"require": "./dist/prompt/pluginPackagePromptExecutionInspection.js",
|
||||
"default": "./dist/prompt/pluginPackagePromptExecutionInspection.js"
|
||||
},
|
||||
"./local-plugin-package-prompt-execution-inspection": {
|
||||
"types": "./dist/prompt/localPluginPackagePromptExecutionInspectionRepository.d.ts",
|
||||
"require": "./dist/prompt/localPluginPackagePromptExecutionInspectionRepository.js",
|
||||
"default": "./dist/prompt/localPluginPackagePromptExecutionInspectionRepository.js"
|
||||
},
|
||||
"./plugin-package-prompt-executor": {
|
||||
"types": "./dist/prompt/pluginPackagePromptExecutor.d.ts",
|
||||
"require": "./dist/prompt/pluginPackagePromptExecutor.js",
|
||||
"default": "./dist/prompt/pluginPackagePromptExecutor.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-artifact": {
|
||||
"types": "./dist/prompt-output/pluginPackagePromptOutputArtifact.d.ts",
|
||||
"require": "./dist/prompt-output/pluginPackagePromptOutputArtifact.js",
|
||||
"default": "./dist/prompt-output/pluginPackagePromptOutputArtifact.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-completion": {
|
||||
"types": "./dist/prompt-output/pluginPackagePromptOutputCompletion.d.ts",
|
||||
"require": "./dist/prompt-output/pluginPackagePromptOutputCompletion.js",
|
||||
"default": "./dist/prompt-output/pluginPackagePromptOutputCompletion.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-read": {
|
||||
"types": "./dist/prompt-output/pluginPackagePromptOutputRead.d.ts",
|
||||
"require": "./dist/prompt-output/pluginPackagePromptOutputRead.js",
|
||||
"default": "./dist/prompt-output/pluginPackagePromptOutputRead.js"
|
||||
},
|
||||
"./plugin-package-prompt-execution-output-read": {
|
||||
"types": "./dist/prompt-output/pluginPackagePromptExecutionOutputRead.d.ts",
|
||||
"require": "./dist/prompt-output/pluginPackagePromptExecutionOutputRead.js",
|
||||
"default": "./dist/prompt-output/pluginPackagePromptExecutionOutputRead.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-retention": {
|
||||
"types": "./dist/prompt-output/pluginPackagePromptOutputRetention.d.ts",
|
||||
"require": "./dist/prompt-output/pluginPackagePromptOutputRetention.js",
|
||||
"default": "./dist/prompt-output/pluginPackagePromptOutputRetention.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-key-retirement": {
|
||||
"types": "./dist/prompt-output/key-management/pluginPackagePromptOutputKeyRetirement.d.ts",
|
||||
"require": "./dist/prompt-output/key-management/pluginPackagePromptOutputKeyRetirement.js",
|
||||
"default": "./dist/prompt-output/key-management/pluginPackagePromptOutputKeyRetirement.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-key-rotation": {
|
||||
"types": "./dist/prompt-output/key-management/pluginPackagePromptOutputKeyRotation.d.ts",
|
||||
"require": "./dist/prompt-output/key-management/pluginPackagePromptOutputKeyRotation.js",
|
||||
"default": "./dist/prompt-output/key-management/pluginPackagePromptOutputKeyRotation.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-file-keyring": {
|
||||
"types": "./dist/prompt-output/key-management/pluginPackagePromptOutputFileKeyring.d.ts",
|
||||
"require": "./dist/prompt-output/key-management/pluginPackagePromptOutputFileKeyring.js",
|
||||
"default": "./dist/prompt-output/key-management/pluginPackagePromptOutputFileKeyring.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-keyring-manifest": {
|
||||
"types": "./dist/prompt-output/key-management/pluginPackagePromptOutputKeyringManifest.d.ts",
|
||||
"require": "./dist/prompt-output/key-management/pluginPackagePromptOutputKeyringManifest.js",
|
||||
"default": "./dist/prompt-output/key-management/pluginPackagePromptOutputKeyringManifest.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-external-custody": {
|
||||
"types": "./dist/prompt-output/custody/pluginPackagePromptOutputExternalCustody.d.ts",
|
||||
"require": "./dist/prompt-output/custody/pluginPackagePromptOutputExternalCustody.js",
|
||||
"default": "./dist/prompt-output/custody/pluginPackagePromptOutputExternalCustody.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-external-custody-bundle": {
|
||||
"types": "./dist/prompt-output/custody/pluginPackagePromptOutputExternalCustodyBundle.d.ts",
|
||||
"require": "./dist/prompt-output/custody/pluginPackagePromptOutputExternalCustodyBundle.js",
|
||||
"default": "./dist/prompt-output/custody/pluginPackagePromptOutputExternalCustodyBundle.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-external-recovery-authorization": {
|
||||
"types": "./dist/prompt-output/custody/pluginPackagePromptOutputExternalRecoveryAuthorization.d.ts",
|
||||
"require": "./dist/prompt-output/custody/pluginPackagePromptOutputExternalRecoveryAuthorization.js",
|
||||
"default": "./dist/prompt-output/custody/pluginPackagePromptOutputExternalRecoveryAuthorization.js"
|
||||
},
|
||||
"./plugin-package-prompt-output-projected-keyring": {
|
||||
"types": "./dist/prompt-output/key-management/pluginPackagePromptOutputProjectedKeyring.d.ts",
|
||||
"require": "./dist/prompt-output/key-management/pluginPackagePromptOutputProjectedKeyring.js",
|
||||
"default": "./dist/prompt-output/key-management/pluginPackagePromptOutputProjectedKeyring.js"
|
||||
},
|
||||
"./price-catalog": {
|
||||
"types": "./dist/pricing/modelPriceCatalog.d.ts",
|
||||
"require": "./dist/pricing/modelPriceCatalog.js",
|
||||
"default": "./dist/pricing/modelPriceCatalog.js"
|
||||
},
|
||||
"./price-catalog-management": {
|
||||
"types": "./dist/pricing/modelPriceCatalogManagement.d.ts",
|
||||
"require": "./dist/pricing/modelPriceCatalogManagement.js",
|
||||
"default": "./dist/pricing/modelPriceCatalogManagement.js"
|
||||
},
|
||||
"./local-price-catalog-storage": {
|
||||
"types": "./dist/pricing/storage/localModelPriceCatalogRepository.d.ts",
|
||||
"require": "./dist/pricing/storage/localModelPriceCatalogRepository.js",
|
||||
"default": "./dist/pricing/storage/localModelPriceCatalogRepository.js"
|
||||
},
|
||||
"./postgres-price-catalog-storage": {
|
||||
"types": "./dist/pricing/storage/postgresModelPriceCatalogRepository.d.ts",
|
||||
"require": "./dist/pricing/storage/postgresModelPriceCatalogRepository.js",
|
||||
"default": "./dist/pricing/storage/postgresModelPriceCatalogRepository.js"
|
||||
},
|
||||
"./model-invocation-migration": {
|
||||
"types": "./dist/migration/modelInvocationMigration.d.ts",
|
||||
"require": "./dist/migration/modelInvocationMigration.js",
|
||||
"default": "./dist/migration/modelInvocationMigration.js"
|
||||
},
|
||||
"./local-feature-activation": {
|
||||
"types": "./dist/feature-activation/localModelInvocationFeatureActivation.d.ts",
|
||||
"require": "./dist/feature-activation/localModelInvocationFeatureActivation.js",
|
||||
"default": "./dist/feature-activation/localModelInvocationFeatureActivation.js"
|
||||
},
|
||||
"./local-model-invocation-storage": {
|
||||
"types": "./dist/model-invocation/localModelInvocationRepository.d.ts",
|
||||
"require": "./dist/model-invocation/localModelInvocationRepository.js",
|
||||
"default": "./dist/model-invocation/localModelInvocationRepository.js"
|
||||
},
|
||||
"./local-model-provider-credential-storage": {
|
||||
"types": "./dist/model-provider-credential/localModelProviderCredentialRepository.d.ts",
|
||||
"require": "./dist/model-provider-credential/localModelProviderCredentialRepository.js",
|
||||
"default": "./dist/model-provider-credential/localModelProviderCredentialRepository.js"
|
||||
},
|
||||
"./local-plugin-package-prompt-admission-storage": {
|
||||
"types": "./dist/prompt/localPluginPackagePromptAdmissionRepository.d.ts",
|
||||
"require": "./dist/prompt/localPluginPackagePromptAdmissionRepository.js",
|
||||
"default": "./dist/prompt/localPluginPackagePromptAdmissionRepository.js"
|
||||
},
|
||||
"./local-plugin-package-prompt-output-artifact-storage": {
|
||||
"types": "./dist/prompt-output/storage/localPluginPackagePromptOutputArtifactRepository.d.ts",
|
||||
"require": "./dist/prompt-output/storage/localPluginPackagePromptOutputArtifactRepository.js",
|
||||
"default": "./dist/prompt-output/storage/localPluginPackagePromptOutputArtifactRepository.js"
|
||||
},
|
||||
"./local-plugin-package-prompt-execution-output-reference-storage": {
|
||||
"types": "./dist/prompt-output/storage/localPluginPackagePromptExecutionOutputReferenceRepository.d.ts",
|
||||
"require": "./dist/prompt-output/storage/localPluginPackagePromptExecutionOutputReferenceRepository.js",
|
||||
"default": "./dist/prompt-output/storage/localPluginPackagePromptExecutionOutputReferenceRepository.js"
|
||||
},
|
||||
"./local-plugin-package-prompt-output-retention-storage": {
|
||||
"types": "./dist/prompt-output/storage/localPluginPackagePromptOutputRetentionRepository.d.ts",
|
||||
"require": "./dist/prompt-output/storage/localPluginPackagePromptOutputRetentionRepository.js",
|
||||
"default": "./dist/prompt-output/storage/localPluginPackagePromptOutputRetentionRepository.js"
|
||||
},
|
||||
"./local-plugin-package-prompt-output-key-retirement-storage": {
|
||||
"types": "./dist/prompt-output/storage/localPluginPackagePromptOutputKeyRetirementRepository.d.ts",
|
||||
"require": "./dist/prompt-output/storage/localPluginPackagePromptOutputKeyRetirementRepository.js",
|
||||
"default": "./dist/prompt-output/storage/localPluginPackagePromptOutputKeyRetirementRepository.js"
|
||||
},
|
||||
"./postgres-model-invocation-storage": {
|
||||
"types": "./dist/model-invocation/postgresModelInvocationRepository.d.ts",
|
||||
"require": "./dist/model-invocation/postgresModelInvocationRepository.js",
|
||||
"default": "./dist/model-invocation/postgresModelInvocationRepository.js"
|
||||
},
|
||||
"./postgres-plugin-package-prompt-admission-storage": {
|
||||
"types": "./dist/prompt/postgresPluginPackagePromptAdmissionRepository.d.ts",
|
||||
"require": "./dist/prompt/postgresPluginPackagePromptAdmissionRepository.js",
|
||||
"default": "./dist/prompt/postgresPluginPackagePromptAdmissionRepository.js"
|
||||
},
|
||||
"./postgres-plugin-package-prompt-output-artifact-storage": {
|
||||
"types": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputArtifactRepository.d.ts",
|
||||
"require": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputArtifactRepository.js",
|
||||
"default": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputArtifactRepository.js"
|
||||
},
|
||||
"./postgres-plugin-package-prompt-execution-output-reference-storage": {
|
||||
"types": "./dist/prompt-output/storage/postgresPluginPackagePromptExecutionOutputReferenceRepository.d.ts",
|
||||
"require": "./dist/prompt-output/storage/postgresPluginPackagePromptExecutionOutputReferenceRepository.js",
|
||||
"default": "./dist/prompt-output/storage/postgresPluginPackagePromptExecutionOutputReferenceRepository.js"
|
||||
},
|
||||
"./postgres-plugin-package-prompt-output-retention-storage": {
|
||||
"types": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputRetentionRepository.d.ts",
|
||||
"require": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputRetentionRepository.js",
|
||||
"default": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputRetentionRepository.js"
|
||||
},
|
||||
"./postgres-plugin-package-prompt-output-key-retirement-storage": {
|
||||
"types": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputKeyRetirementRepository.d.ts",
|
||||
"require": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputKeyRetirementRepository.js",
|
||||
"default": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputKeyRetirementRepository.js"
|
||||
},
|
||||
"./postgres-plugin-package-prompt-output-key-rotation-storage": {
|
||||
"types": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputKeyRotationRepository.d.ts",
|
||||
"require": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputKeyRotationRepository.js",
|
||||
"default": "./dist/prompt-output/storage/postgresPluginPackagePromptOutputKeyRotationRepository.js"
|
||||
},
|
||||
"./postgres-plugin-package-prompt-application": {
|
||||
"types": "./dist/prompt/postgresPluginPackagePromptApplication.d.ts",
|
||||
"require": "./dist/prompt/postgresPluginPackagePromptApplication.js",
|
||||
"default": "./dist/prompt/postgresPluginPackagePromptApplication.js"
|
||||
},
|
||||
"./openai-compatible": {
|
||||
"types": "./dist/model-gateway/openAiCompatibleProvider.d.ts",
|
||||
"require": "./dist/model-gateway/openAiCompatibleProvider.js",
|
||||
"default": "./dist/model-gateway/openAiCompatibleProvider.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "node ../../scripts/ql3-build-package-closure.cjs && tsc -p tsconfig.json --noEmit",
|
||||
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qinglong/runtime-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@qinglong/local-sqlite": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
|
||||
import {
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST,
|
||||
assertLocalModelInvocationFeatureReady,
|
||||
} from '../migration/modelInvocationMigration';
|
||||
|
||||
export const LOCAL_MODEL_INVOCATION_FEATURE_ID = 'model-invocation' as const;
|
||||
export const LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_SCHEMA =
|
||||
'qinglong/model-invocation-feature-transition@v1' as const;
|
||||
export const LOCAL_MODEL_INVOCATION_FEATURE_STATES = [
|
||||
'active',
|
||||
'inactive',
|
||||
] as const;
|
||||
export const LOCAL_MODEL_INVOCATION_FEATURE_SAFETY_MODES = [
|
||||
'fresh_database',
|
||||
'backup_verified',
|
||||
'preserve_existing',
|
||||
] as const;
|
||||
|
||||
export type LocalModelInvocationFeatureState =
|
||||
(typeof LOCAL_MODEL_INVOCATION_FEATURE_STATES)[number];
|
||||
export type LocalModelInvocationFeatureSafetyMode =
|
||||
(typeof LOCAL_MODEL_INVOCATION_FEATURE_SAFETY_MODES)[number];
|
||||
|
||||
export interface LocalModelInvocationFeatureTransitionCommand {
|
||||
readonly featureId: typeof LOCAL_MODEL_INVOCATION_FEATURE_ID;
|
||||
readonly expectedGeneration: number;
|
||||
readonly expectedState: LocalModelInvocationFeatureState | null;
|
||||
readonly state: LocalModelInvocationFeatureState;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly expectedMigrationDigest: string;
|
||||
readonly safety: Readonly<{
|
||||
readonly mode: LocalModelInvocationFeatureSafetyMode;
|
||||
readonly backupEvidenceDigest: string | null;
|
||||
}>;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalModelInvocationFeatureTransition {
|
||||
readonly schema: typeof LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_SCHEMA;
|
||||
readonly featureId: typeof LOCAL_MODEL_INVOCATION_FEATURE_ID;
|
||||
readonly generation: number;
|
||||
readonly previousGeneration: number | null;
|
||||
readonly state: LocalModelInvocationFeatureState;
|
||||
readonly mutationId: string;
|
||||
readonly requestId: string;
|
||||
readonly expectedMigrationDigest: string;
|
||||
readonly safety: Readonly<{
|
||||
readonly mode: LocalModelInvocationFeatureSafetyMode;
|
||||
readonly backupEvidenceDigest: string | null;
|
||||
}>;
|
||||
readonly changedByUserId: string;
|
||||
readonly authenticationId: string;
|
||||
readonly assurance: 'local_console';
|
||||
readonly commandDigest: string;
|
||||
readonly committedAtMs: number;
|
||||
readonly transitionDigest: string;
|
||||
}
|
||||
|
||||
export interface CommitLocalModelInvocationFeatureTransitionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly transition: Readonly<LocalModelInvocationFeatureTransition>;
|
||||
}
|
||||
|
||||
export interface LocalModelInvocationFeatureActivationRepositoryOptions {
|
||||
readonly beforeMutation?: (
|
||||
client: DatabaseSync,
|
||||
command: Readonly<LocalModelInvocationFeatureTransitionCommand>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export class InvalidLocalModelInvocationFeatureTransitionError extends TypeError {
|
||||
readonly code = 'LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Local ModelInvocation feature transition is invalid: ${message}`);
|
||||
this.name = 'InvalidLocalModelInvocationFeatureTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalModelInvocationFeatureTransitionConflictError extends Error {
|
||||
readonly code = 'LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Local ModelInvocation feature transition conflicts with durable state',
|
||||
);
|
||||
this.name = 'LocalModelInvocationFeatureTransitionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalModelInvocationFeatureTransitionUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Local ModelInvocation feature transition is unavailable', options);
|
||||
this.name = 'LocalModelInvocationFeatureTransitionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/local-model-invocation-feature-command@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const TRANSITION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/local-model-invocation-feature-transition@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidLocalModelInvocationFeatureTransitionError(message);
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function generation(value: unknown, label: string, minimum: number): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > 2_147_483_647
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function nonnegativeInteger(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function state(value: unknown): LocalModelInvocationFeatureState {
|
||||
if (value !== 'active' && value !== 'inactive') {
|
||||
return invalid('state is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hash(domain: Buffer, value: object): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function principalIdentity(principal: Readonly<SecurityPrincipal>): Readonly<{
|
||||
changedByUserId: string;
|
||||
authenticationId: string;
|
||||
assurance: 'local_console';
|
||||
}> {
|
||||
if (
|
||||
!principal ||
|
||||
typeof principal !== 'object' ||
|
||||
Array.isArray(principal) ||
|
||||
principal.subject?.type !== 'user' ||
|
||||
typeof principal.subject.id !== 'string' ||
|
||||
principal.subject.id.length < 1 ||
|
||||
principal.subject.id.length > 255 ||
|
||||
!IDENTITY_PATTERN.test(principal.authenticationId) ||
|
||||
principal.assurance !== 'local_console'
|
||||
) {
|
||||
return invalid('principal must be a local-console User');
|
||||
}
|
||||
return Object.freeze({
|
||||
changedByUserId: principal.subject.id,
|
||||
authenticationId: principal.authenticationId,
|
||||
assurance: principal.assurance,
|
||||
});
|
||||
}
|
||||
|
||||
function safety(
|
||||
value: LocalModelInvocationFeatureTransitionCommand['safety'],
|
||||
targetState: LocalModelInvocationFeatureState,
|
||||
): Readonly<LocalModelInvocationFeatureTransitionCommand['safety']> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid('safety is invalid');
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
if (
|
||||
actual.length !== 2 ||
|
||||
actual[0] !== 'backupEvidenceDigest' ||
|
||||
actual[1] !== 'mode'
|
||||
) {
|
||||
return invalid('safety shape is invalid');
|
||||
}
|
||||
const backupEvidenceDigest =
|
||||
value.backupEvidenceDigest === null
|
||||
? null
|
||||
: digest(value.backupEvidenceDigest, 'backup evidence digest');
|
||||
if (
|
||||
(targetState === 'active' &&
|
||||
!(
|
||||
(value.mode === 'fresh_database' && backupEvidenceDigest === null) ||
|
||||
(value.mode === 'backup_verified' && backupEvidenceDigest !== null)
|
||||
)) ||
|
||||
(targetState === 'inactive' &&
|
||||
(value.mode !== 'preserve_existing' || backupEvidenceDigest !== null))
|
||||
) {
|
||||
return invalid('safety mode does not match the target state');
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: value.mode,
|
||||
backupEvidenceDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function commandDigestValue(
|
||||
value: Omit<LocalModelInvocationFeatureTransitionCommand, 'commandDigest'>,
|
||||
): object {
|
||||
const principal = principalIdentity(value.principal);
|
||||
return {
|
||||
featureId: value.featureId,
|
||||
expectedGeneration: value.expectedGeneration,
|
||||
expectedState: value.expectedState,
|
||||
state: value.state,
|
||||
mutationId: value.mutationId,
|
||||
requestId: value.requestId,
|
||||
expectedMigrationDigest: value.expectedMigrationDigest,
|
||||
safety: value.safety,
|
||||
changedByUserId: principal.changedByUserId,
|
||||
authenticationId: principal.authenticationId,
|
||||
assurance: principal.assurance,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocalModelInvocationFeatureTransitionCommand(
|
||||
value: Omit<LocalModelInvocationFeatureTransitionCommand, 'commandDigest'>,
|
||||
): Readonly<LocalModelInvocationFeatureTransitionCommand> {
|
||||
if (
|
||||
value.featureId !== LOCAL_MODEL_INVOCATION_FEATURE_ID ||
|
||||
(value.expectedState !== null &&
|
||||
value.expectedState !== 'active' &&
|
||||
value.expectedState !== 'inactive')
|
||||
) {
|
||||
return invalid('feature or expected state is invalid');
|
||||
}
|
||||
const normalizedState = state(value.state);
|
||||
const expectedGeneration = generation(
|
||||
value.expectedGeneration,
|
||||
'expected generation',
|
||||
0,
|
||||
);
|
||||
if (
|
||||
(expectedGeneration === 0 && value.expectedState !== null) ||
|
||||
(expectedGeneration > 0 && value.expectedState === null) ||
|
||||
normalizedState === value.expectedState
|
||||
) {
|
||||
return invalid('state transition fence is invalid');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
featureId: LOCAL_MODEL_INVOCATION_FEATURE_ID,
|
||||
expectedGeneration,
|
||||
expectedState: value.expectedState,
|
||||
state: normalizedState,
|
||||
mutationId: identity(value.mutationId, 'mutation ID'),
|
||||
requestId: identity(value.requestId, 'request ID'),
|
||||
expectedMigrationDigest: digest(
|
||||
value.expectedMigrationDigest,
|
||||
'expected migration digest',
|
||||
),
|
||||
safety: safety(value.safety, normalizedState),
|
||||
principal: value.principal,
|
||||
});
|
||||
return Object.freeze({
|
||||
...normalized,
|
||||
commandDigest: hash(COMMAND_DIGEST_DOMAIN, commandDigestValue(normalized)),
|
||||
});
|
||||
}
|
||||
|
||||
function transitionDigestValue(
|
||||
value: Omit<LocalModelInvocationFeatureTransition, 'transitionDigest'>,
|
||||
): object {
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function createTransition(
|
||||
command: Readonly<LocalModelInvocationFeatureTransitionCommand>,
|
||||
committedAtMs: number,
|
||||
): Readonly<LocalModelInvocationFeatureTransition> {
|
||||
if (!Number.isSafeInteger(committedAtMs) || committedAtMs < 0) {
|
||||
return invalid('database clock is invalid');
|
||||
}
|
||||
const principal = principalIdentity(command.principal);
|
||||
const value = Object.freeze({
|
||||
schema: LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_SCHEMA,
|
||||
featureId: LOCAL_MODEL_INVOCATION_FEATURE_ID,
|
||||
generation: command.expectedGeneration + 1,
|
||||
previousGeneration:
|
||||
command.expectedGeneration === 0 ? null : command.expectedGeneration,
|
||||
state: command.state,
|
||||
mutationId: command.mutationId,
|
||||
requestId: command.requestId,
|
||||
expectedMigrationDigest: command.expectedMigrationDigest,
|
||||
safety: command.safety,
|
||||
...principal,
|
||||
commandDigest: command.commandDigest,
|
||||
committedAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
...value,
|
||||
transitionDigest: hash(
|
||||
TRANSITION_DIGEST_DOMAIN,
|
||||
transitionDigestValue(value),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
interface TransitionRow extends Record<string, unknown> {
|
||||
readonly transitionJson: unknown;
|
||||
}
|
||||
|
||||
function normalizeTransitionJson(
|
||||
value: unknown,
|
||||
): LocalModelInvocationFeatureTransition {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
Buffer.byteLength(value, 'utf8') > 32 * 1024
|
||||
) {
|
||||
return invalid('durable transition JSON is invalid');
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return invalid('durable transition JSON is invalid');
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return invalid('durable transition JSON is invalid');
|
||||
}
|
||||
const candidate = parsed as Record<string, unknown>;
|
||||
const expectedKeys = [
|
||||
'assurance',
|
||||
'authenticationId',
|
||||
'changedByUserId',
|
||||
'commandDigest',
|
||||
'committedAtMs',
|
||||
'expectedMigrationDigest',
|
||||
'featureId',
|
||||
'generation',
|
||||
'mutationId',
|
||||
'previousGeneration',
|
||||
'requestId',
|
||||
'safety',
|
||||
'schema',
|
||||
'state',
|
||||
'transitionDigest',
|
||||
].sort();
|
||||
if (
|
||||
Object.keys(candidate).sort().join('\0') !== expectedKeys.join('\0') ||
|
||||
candidate.schema !== LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_SCHEMA ||
|
||||
candidate.featureId !== LOCAL_MODEL_INVOCATION_FEATURE_ID ||
|
||||
candidate.assurance !== 'local_console' ||
|
||||
typeof candidate.changedByUserId !== 'string' ||
|
||||
candidate.changedByUserId.length < 1 ||
|
||||
candidate.changedByUserId.length > 255 ||
|
||||
!IDENTITY_PATTERN.test(candidate.authenticationId as string) ||
|
||||
!IDENTITY_PATTERN.test(candidate.mutationId as string) ||
|
||||
!IDENTITY_PATTERN.test(candidate.requestId as string)
|
||||
) {
|
||||
return invalid('durable transition JSON is invalid');
|
||||
}
|
||||
const normalizedState = state(candidate.state);
|
||||
const normalizedGeneration = generation(
|
||||
candidate.generation,
|
||||
'durable generation',
|
||||
1,
|
||||
);
|
||||
const previousGeneration =
|
||||
candidate.previousGeneration === null
|
||||
? null
|
||||
: generation(
|
||||
candidate.previousGeneration,
|
||||
'durable previous generation',
|
||||
1,
|
||||
);
|
||||
const normalizedSafety = safety(
|
||||
candidate.safety as LocalModelInvocationFeatureTransitionCommand['safety'],
|
||||
normalizedState,
|
||||
);
|
||||
const normalized = Object.freeze({
|
||||
schema: LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_SCHEMA,
|
||||
featureId: LOCAL_MODEL_INVOCATION_FEATURE_ID,
|
||||
generation: normalizedGeneration,
|
||||
previousGeneration,
|
||||
state: normalizedState,
|
||||
mutationId: candidate.mutationId as string,
|
||||
requestId: candidate.requestId as string,
|
||||
expectedMigrationDigest: digest(
|
||||
candidate.expectedMigrationDigest,
|
||||
'durable expected migration digest',
|
||||
),
|
||||
safety: normalizedSafety,
|
||||
changedByUserId: candidate.changedByUserId,
|
||||
authenticationId: candidate.authenticationId as string,
|
||||
assurance: 'local_console' as const,
|
||||
commandDigest: digest(candidate.commandDigest, 'durable command digest'),
|
||||
committedAtMs: nonnegativeInteger(
|
||||
candidate.committedAtMs,
|
||||
'durable committed time',
|
||||
),
|
||||
});
|
||||
const transitionDigest = digest(
|
||||
candidate.transitionDigest,
|
||||
'durable transition digest',
|
||||
);
|
||||
if (
|
||||
hash(TRANSITION_DIGEST_DOMAIN, transitionDigestValue(normalized)) !==
|
||||
transitionDigest
|
||||
) {
|
||||
return invalid('durable transition digest is invalid');
|
||||
}
|
||||
return Object.freeze({ ...normalized, transitionDigest });
|
||||
}
|
||||
|
||||
function sameCommand(
|
||||
command: Readonly<LocalModelInvocationFeatureTransitionCommand>,
|
||||
transition: Readonly<LocalModelInvocationFeatureTransition>,
|
||||
): boolean {
|
||||
const principal = principalIdentity(command.principal);
|
||||
return (
|
||||
transition.featureId === command.featureId &&
|
||||
transition.generation === command.expectedGeneration + 1 &&
|
||||
transition.previousGeneration ===
|
||||
(command.expectedGeneration === 0 ? null : command.expectedGeneration) &&
|
||||
transition.state === command.state &&
|
||||
transition.mutationId === command.mutationId &&
|
||||
transition.requestId === command.requestId &&
|
||||
transition.expectedMigrationDigest === command.expectedMigrationDigest &&
|
||||
JSON.stringify(transition.safety) === JSON.stringify(command.safety) &&
|
||||
transition.changedByUserId === principal.changedByUserId &&
|
||||
transition.authenticationId === principal.authenticationId &&
|
||||
transition.assurance === principal.assurance &&
|
||||
transition.commandDigest === command.commandDigest
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalModelInvocationFeatureActivationRepository {
|
||||
readonly #beforeMutation:
|
||||
| LocalModelInvocationFeatureActivationRepositoryOptions['beforeMutation'];
|
||||
|
||||
constructor(
|
||||
private readonly client: DatabaseSync,
|
||||
options: LocalModelInvocationFeatureActivationRepositoryOptions = {},
|
||||
) {
|
||||
if (
|
||||
!client ||
|
||||
typeof client !== 'object' ||
|
||||
!client.isOpen ||
|
||||
typeof options !== 'object' ||
|
||||
options === null ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'beforeMutation') ||
|
||||
(options.beforeMutation !== undefined &&
|
||||
typeof options.beforeMutation !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local ModelInvocation feature activation repository options are invalid',
|
||||
);
|
||||
}
|
||||
this.#beforeMutation = options.beforeMutation;
|
||||
}
|
||||
|
||||
findCurrent(): Readonly<LocalModelInvocationFeatureTransition> | null {
|
||||
assertLocalModelInvocationFeatureReady(this.client);
|
||||
const row = this.client
|
||||
.prepare(
|
||||
`SELECT transition.transition_json AS "transitionJson"
|
||||
FROM "ModelInvocationFeatureHead" head
|
||||
JOIN "ModelInvocationFeatureTransitions" transition
|
||||
ON transition.feature_id = head.feature_id
|
||||
AND transition.generation = head.generation
|
||||
AND transition.transition_digest = head.transition_digest
|
||||
WHERE head.feature_id = ?`,
|
||||
)
|
||||
.get(LOCAL_MODEL_INVOCATION_FEATURE_ID) as TransitionRow | undefined;
|
||||
return row ? normalizeTransitionJson(row.transitionJson) : null;
|
||||
}
|
||||
|
||||
transition(
|
||||
commandValue: Readonly<LocalModelInvocationFeatureTransitionCommand>,
|
||||
): Readonly<CommitLocalModelInvocationFeatureTransitionResult> {
|
||||
const command = createLocalModelInvocationFeatureTransitionCommand({
|
||||
featureId: commandValue.featureId,
|
||||
expectedGeneration: commandValue.expectedGeneration,
|
||||
expectedState: commandValue.expectedState,
|
||||
state: commandValue.state,
|
||||
mutationId: commandValue.mutationId,
|
||||
requestId: commandValue.requestId,
|
||||
expectedMigrationDigest: commandValue.expectedMigrationDigest,
|
||||
safety: commandValue.safety,
|
||||
principal: commandValue.principal,
|
||||
});
|
||||
if (
|
||||
commandValue.commandDigest !== command.commandDigest ||
|
||||
command.expectedMigrationDigest !==
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST
|
||||
) {
|
||||
throw new LocalModelInvocationFeatureTransitionConflictError();
|
||||
}
|
||||
assertLocalModelInvocationFeatureReady(this.client);
|
||||
this.client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
this.#beforeMutation?.(this.client, command);
|
||||
const replay = this.client
|
||||
.prepare(
|
||||
`SELECT transition_json AS "transitionJson"
|
||||
FROM "ModelInvocationFeatureTransitions"
|
||||
WHERE mutation_id = ?`,
|
||||
)
|
||||
.get(command.mutationId) as TransitionRow | undefined;
|
||||
if (replay) {
|
||||
const transition = normalizeTransitionJson(replay.transitionJson);
|
||||
if (!sameCommand(command, transition)) {
|
||||
throw new LocalModelInvocationFeatureTransitionConflictError();
|
||||
}
|
||||
this.client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'existing', transition });
|
||||
}
|
||||
const current = this.findCurrent();
|
||||
if (
|
||||
current?.generation !==
|
||||
(command.expectedGeneration === 0
|
||||
? undefined
|
||||
: command.expectedGeneration) ||
|
||||
(current?.state ?? null) !== command.expectedState
|
||||
) {
|
||||
throw new LocalModelInvocationFeatureTransitionConflictError();
|
||||
}
|
||||
const clock = this.client
|
||||
.prepare(`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS now`)
|
||||
.get() as { readonly now?: unknown };
|
||||
const transition = createTransition(
|
||||
command,
|
||||
nonnegativeInteger(clock.now, 'database clock'),
|
||||
);
|
||||
const transitionJson = JSON.stringify(transition);
|
||||
this.client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationFeatureTransitions" (
|
||||
feature_id, generation, previous_generation, state,
|
||||
mutation_id, request_id, expected_migration_digest,
|
||||
safety_mode, backup_evidence_digest, changed_by_user_id,
|
||||
authentication_id, assurance, command_digest,
|
||||
transition_digest, committed_at_ms, transition_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
transition.featureId,
|
||||
transition.generation,
|
||||
transition.previousGeneration,
|
||||
transition.state,
|
||||
transition.mutationId,
|
||||
transition.requestId,
|
||||
transition.expectedMigrationDigest,
|
||||
transition.safety.mode,
|
||||
transition.safety.backupEvidenceDigest,
|
||||
transition.changedByUserId,
|
||||
transition.authenticationId,
|
||||
transition.assurance,
|
||||
transition.commandDigest,
|
||||
transition.transitionDigest,
|
||||
transition.committedAtMs,
|
||||
transitionJson,
|
||||
);
|
||||
if (command.expectedGeneration === 0) {
|
||||
this.client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationFeatureHead" (
|
||||
feature_id, generation, state, transition_digest, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
transition.featureId,
|
||||
transition.generation,
|
||||
transition.state,
|
||||
transition.transitionDigest,
|
||||
transition.committedAtMs,
|
||||
);
|
||||
} else {
|
||||
const updated = this.client
|
||||
.prepare(
|
||||
`UPDATE "ModelInvocationFeatureHead"
|
||||
SET generation = ?,
|
||||
state = ?,
|
||||
transition_digest = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE feature_id = ?
|
||||
AND generation = ?
|
||||
AND state = ?`,
|
||||
)
|
||||
.run(
|
||||
transition.generation,
|
||||
transition.state,
|
||||
transition.transitionDigest,
|
||||
transition.committedAtMs,
|
||||
transition.featureId,
|
||||
command.expectedGeneration,
|
||||
command.expectedState,
|
||||
);
|
||||
if (updated.changes !== 1) {
|
||||
throw new LocalModelInvocationFeatureTransitionConflictError();
|
||||
}
|
||||
}
|
||||
this.client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'created', transition });
|
||||
} catch (error) {
|
||||
if (this.client.isTransaction) this.client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof InvalidLocalModelInvocationFeatureTransitionError ||
|
||||
error instanceof LocalModelInvocationFeatureTransitionConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalModelInvocationFeatureTransitionUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalModelInvocationFeatureActive(
|
||||
client: DatabaseSync,
|
||||
): Readonly<LocalModelInvocationFeatureTransition> {
|
||||
const current = new LocalModelInvocationFeatureActivationRepository(
|
||||
client,
|
||||
).findCurrent();
|
||||
if (current?.state !== 'active') {
|
||||
throw new LocalModelInvocationFeatureTransitionUnavailableError();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export * from './model-gateway/model';
|
||||
export * from './model-gateway/validation';
|
||||
export * from './model-gateway/gateway';
|
||||
export * from './model-invocation/modelInvocation';
|
||||
export * from './model-invocation/modelInvocationResolution';
|
||||
export * from './model-invocation/durableModelInvocationCoordinator';
|
||||
export * from './profile/profileComposition';
|
||||
export * from './model-provider-credential/providerCredential';
|
||||
export * from './usage/usageLedger';
|
||||
export * from './usage/usageQuota';
|
||||
export * from './pricing/pricing';
|
||||
export * from './pricing/modelPriceCatalog';
|
||||
export * from './pricing/modelPriceCatalogManagement';
|
||||
export * from './model-gateway/openAiCompatibleProvider';
|
||||
export * from './prompt/postgresPluginPackagePromptApplication';
|
||||
@@ -0,0 +1,68 @@
|
||||
export const LOCAL_MODEL_INVOCATION_MIGRATION_STREAM_ID =
|
||||
'ql3-ai-model-invocation-sqlite';
|
||||
export const LOCAL_MODEL_INVOCATION_MIGRATION_ID = '9001-ai-model-invocations';
|
||||
export const LOCAL_MODEL_INVOCATION_USAGE_MIGRATION_ID =
|
||||
'9002-ai-model-usage-ledger';
|
||||
export const LOCAL_MODEL_INVOCATION_QUOTA_MIGRATION_ID =
|
||||
'9003-ai-model-usage-quota';
|
||||
export const LOCAL_MODEL_INVOCATION_PRICING_MIGRATION_ID =
|
||||
'9004-ai-model-pricing-snapshots';
|
||||
export const LOCAL_MODEL_PRICE_CATALOG_MIGRATION_ID =
|
||||
'9005-ai-model-price-catalog';
|
||||
export const LOCAL_MODEL_PRICE_CATALOG_AUTHORIZATION_MIGRATION_ID =
|
||||
'9006-ai-model-price-catalog-authorizations';
|
||||
export const LOCAL_MODEL_INVOCATION_FEATURE_ACTIVATION_MIGRATION_ID =
|
||||
'9007-ai-feature-activation';
|
||||
export const LOCAL_PLUGIN_PACKAGE_PROMPT_ADMISSION_MIGRATION_ID =
|
||||
'9008-ai-plugin-package-prompt-admissions';
|
||||
export const LOCAL_PLUGIN_PACKAGE_PROMPT_FINALIZATION_MIGRATION_ID =
|
||||
'9009-ai-plugin-package-prompt-finalizations';
|
||||
export const LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_MIGRATION_ID =
|
||||
'9010-ai-plugin-package-prompt-output-artifacts';
|
||||
export const LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_MIGRATION_ID =
|
||||
'9011-ai-plugin-package-prompt-output-tombstones';
|
||||
export const LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_MIGRATION_ID =
|
||||
'9012-ai-plugin-package-prompt-output-key-retirements';
|
||||
export const LOCAL_MODEL_PROVIDER_CREDENTIAL_CATALOG_MIGRATION_ID =
|
||||
'9013-ai-model-provider-credential-catalog';
|
||||
export const POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID =
|
||||
'ql3-ai-model-invocation-postgresql';
|
||||
export const POSTGRES_MODEL_INVOCATION_MIGRATION_ID =
|
||||
'pg-9001-ai-model-invocations';
|
||||
export const POSTGRES_MODEL_INVOCATION_USAGE_MIGRATION_ID =
|
||||
'pg-9002-ai-model-usage-ledger';
|
||||
export const POSTGRES_MODEL_INVOCATION_QUOTA_MIGRATION_ID =
|
||||
'pg-9003-ai-model-usage-quota';
|
||||
export const POSTGRES_MODEL_INVOCATION_PRICING_MIGRATION_ID =
|
||||
'pg-9004-ai-model-pricing-snapshots';
|
||||
export const POSTGRES_MODEL_PRICE_CATALOG_MIGRATION_ID =
|
||||
'pg-9005-ai-model-price-catalog';
|
||||
export const POSTGRES_MODEL_PRICE_CATALOG_AUTHORIZATION_MIGRATION_ID =
|
||||
'pg-9006-ai-model-price-catalog-authorizations';
|
||||
export const POSTGRES_PLUGIN_PACKAGE_PROMPT_ADMISSION_MIGRATION_ID =
|
||||
'pg-9007-ai-plugin-package-prompt-admissions';
|
||||
export const POSTGRES_PLUGIN_PACKAGE_PROMPT_FINALIZATION_MIGRATION_ID =
|
||||
'pg-9008-ai-plugin-package-prompt-finalizations';
|
||||
export const POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_MIGRATION_ID =
|
||||
'pg-9009-ai-plugin-package-prompt-output-artifacts';
|
||||
export const POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_MIGRATION_ID =
|
||||
'pg-9010-ai-plugin-package-prompt-output-tombstones';
|
||||
export const POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_MIGRATION_ID =
|
||||
'pg-9011-ai-plugin-package-prompt-output-key-retirements';
|
||||
export const POSTGRES_MODEL_PROVIDER_CREDENTIAL_CATALOG_MIGRATION_ID =
|
||||
'pg-9012-ai-model-provider-credential-catalog';
|
||||
export const POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_MIGRATION_ID =
|
||||
'pg-9013-ai-model-provider-credential-management-boundary';
|
||||
export const POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_MIGRATION_ID =
|
||||
'pg-9014-ai-model-provider-credential-management-identity-ledger';
|
||||
export const POSTGRES_MODEL_PROVIDER_CREDENTIAL_TEST_CONNECTION_MIGRATION_ID =
|
||||
'pg-9015-ai-model-provider-credential-test-connection';
|
||||
export const POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_ROTATION_MIGRATION_ID =
|
||||
'pg-9016-ai-plugin-package-prompt-output-key-rotation';
|
||||
export const POSTGRES_PLUGIN_PACKAGE_PROMPT_PRODUCT_AUTHORIZATION_MIGRATION_ID =
|
||||
'pg-9017-ai-plugin-package-prompt-product-authorization';
|
||||
export const POSTGRES_MODEL_INVOCATION_SCHEMA = 'ql3_ai';
|
||||
export const LOCAL_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE =
|
||||
'QingLong3AiSchemaMigrations';
|
||||
export const POSTGRES_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE =
|
||||
'ai_schema_migrations';
|
||||
@@ -0,0 +1,259 @@
|
||||
import type {
|
||||
PostgresPool,
|
||||
PostgresQueryable,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
MigrationStreamHistoryCorruptionError,
|
||||
runMigrationStream,
|
||||
type MigrationStreamDefinition,
|
||||
type MigrationStreamRecord,
|
||||
type MigrationStreamStore,
|
||||
type MigrationStreamTransaction,
|
||||
} from '@qinglong/runtime-core/migration-stream';
|
||||
|
||||
import {
|
||||
POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID,
|
||||
POSTGRES_MODEL_INVOCATION_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_USAGE_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_QUOTA_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_PRICING_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PRICE_CATALOG_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PRICE_CATALOG_AUTHORIZATION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_ADMISSION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_FINALIZATION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_CATALOG_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_TEST_CONNECTION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_ROTATION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_PRODUCT_AUTHORIZATION_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_SCHEMA,
|
||||
POSTGRES_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE,
|
||||
} from './identities';
|
||||
|
||||
import { postgresCatalogMigrations } from './postgres/catalog';
|
||||
import { postgresCoreMigrations } from './postgres/core';
|
||||
import { postgresCredentialMigrations } from './postgres/credential';
|
||||
import {
|
||||
postgresPromptBaseMigrations,
|
||||
postgresPromptExtensionMigrations,
|
||||
} from './postgres/prompt';
|
||||
import { postgresUsagePricingMigrations } from './postgres/usagePricing';
|
||||
import { historyRecord, type HistoryRow } from './shared';
|
||||
|
||||
const POSTGRES_HISTORY_IDENTITY = Object.freeze({
|
||||
migrationIds: Object.freeze([
|
||||
POSTGRES_MODEL_INVOCATION_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_USAGE_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_QUOTA_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_PRICING_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PRICE_CATALOG_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PRICE_CATALOG_AUTHORIZATION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_ADMISSION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_FINALIZATION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_ROTATION_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_CATALOG_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_TEST_CONNECTION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_PRODUCT_AUTHORIZATION_MIGRATION_ID,
|
||||
]),
|
||||
streamId: POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID,
|
||||
dialect: 'postgresql' as const,
|
||||
});
|
||||
|
||||
async function readPostgresHistory(
|
||||
queryable: PostgresQueryable,
|
||||
migrationId?: string,
|
||||
): Promise<readonly MigrationStreamRecord[]> {
|
||||
const result = await queryable.query<HistoryRow>(
|
||||
`SELECT
|
||||
migration_id AS "migrationId",
|
||||
stream_id AS "streamId",
|
||||
dialect,
|
||||
checksum,
|
||||
applied_at_ms AS "appliedAtMs"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."${POSTGRES_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE}"
|
||||
WHERE stream_id = $1
|
||||
${migrationId === undefined ? '' : 'AND migration_id = $2'}
|
||||
ORDER BY migration_id`,
|
||||
migrationId === undefined
|
||||
? [POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID]
|
||||
: [POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID, migrationId],
|
||||
);
|
||||
return result.rows.map((row) =>
|
||||
historyRecord(row, POSTGRES_HISTORY_IDENTITY),
|
||||
);
|
||||
}
|
||||
|
||||
class PostgresModelInvocationMigrationStore
|
||||
implements MigrationStreamStore<PostgresQueryable>
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {}
|
||||
|
||||
async ensureHistory(): Promise<void> {
|
||||
const result = await this.pool.query<Record<string, unknown>>(
|
||||
`SELECT
|
||||
to_regclass('ql3.schema_migrations') IS NOT NULL AS history,
|
||||
to_regclass('ql3.runs') IS NOT NULL AS runs,
|
||||
to_regclass('ql3.run_events') IS NOT NULL AS events,
|
||||
to_regclass('ql3.step_runs') IS NOT NULL AS steps,
|
||||
to_regclass('ql3.step_run_mutations') IS NOT NULL AS mutations`,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
row?.history !== true ||
|
||||
row.runs !== true ||
|
||||
row.events !== true ||
|
||||
row.steps !== true ||
|
||||
row.mutations !== true
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL ModelInvocation feature requires the main migration stream',
|
||||
);
|
||||
}
|
||||
await this.pool.query(
|
||||
`CREATE SCHEMA IF NOT EXISTS "${POSTGRES_MODEL_INVOCATION_SCHEMA}"`,
|
||||
);
|
||||
await this.pool.query(
|
||||
`REVOKE ALL ON SCHEMA "${POSTGRES_MODEL_INVOCATION_SCHEMA}" FROM PUBLIC`,
|
||||
);
|
||||
await this.pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."${POSTGRES_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE}" (
|
||||
migration_id varchar(128) PRIMARY KEY,
|
||||
stream_id varchar(64) NOT NULL
|
||||
CONSTRAINT ql3_ai_schema_migrations_stream_check
|
||||
CHECK (stream_id = '${POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID}'),
|
||||
dialect varchar(16) NOT NULL
|
||||
CONSTRAINT ql3_ai_schema_migrations_dialect_check
|
||||
CHECK (dialect = 'postgresql'),
|
||||
checksum char(64) NOT NULL
|
||||
CONSTRAINT ql3_ai_schema_migrations_checksum_check
|
||||
CHECK (checksum ~ '^[0-9a-f]{64}$'),
|
||||
applied_at_ms bigint NOT NULL
|
||||
CONSTRAINT ql3_ai_schema_migrations_applied_at_check
|
||||
CHECK (applied_at_ms >= 0)
|
||||
)
|
||||
`);
|
||||
await this.pool.query(
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."${POSTGRES_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE}"
|
||||
FROM PUBLIC`,
|
||||
);
|
||||
}
|
||||
|
||||
async listAll(): Promise<readonly MigrationStreamRecord[]> {
|
||||
return readPostgresHistory(this.pool);
|
||||
}
|
||||
|
||||
async findById(migrationId: string): Promise<MigrationStreamRecord | null> {
|
||||
const rows = await readPostgresHistory(this.pool, migrationId);
|
||||
if (rows.length > 1) {
|
||||
throw new MigrationStreamHistoryCorruptionError(migrationId);
|
||||
}
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
work: (
|
||||
transaction: MigrationStreamTransaction<PostgresQueryable>,
|
||||
) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await this.pool.connect();
|
||||
let began = false;
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
began = true;
|
||||
await client.query(`SELECT set_config('statement_timeout', $1, true)`, [
|
||||
'15s',
|
||||
]);
|
||||
await client.query(`SELECT set_config('lock_timeout', $1, true)`, ['5s']);
|
||||
await client.query(
|
||||
`SELECT set_config('idle_in_transaction_session_timeout', $1, true)`,
|
||||
['15s'],
|
||||
);
|
||||
await client.query(
|
||||
'SELECT pg_advisory_xact_lock($1, $2)',
|
||||
[0x514c, 0x0301],
|
||||
);
|
||||
const result = await work({
|
||||
context: client,
|
||||
findById: async (migrationId) => {
|
||||
const rows = await readPostgresHistory(client, migrationId);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
insert: async (record) => {
|
||||
if (
|
||||
record.streamId !== POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID ||
|
||||
!POSTGRES_HISTORY_IDENTITY.migrationIds.includes(
|
||||
record.migrationId,
|
||||
) ||
|
||||
record.dialect !== 'postgresql'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL ModelInvocation migration identity is invalid',
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."${POSTGRES_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE}"
|
||||
(migration_id, stream_id, dialect, checksum, applied_at_ms)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[
|
||||
record.migrationId,
|
||||
record.streamId,
|
||||
record.dialect,
|
||||
record.checksum,
|
||||
record.appliedAtMs,
|
||||
],
|
||||
);
|
||||
},
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) {
|
||||
try {
|
||||
await client.query('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the migration failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const postgresModelInvocationMigrationDefinition: MigrationStreamDefinition<PostgresQueryable> =
|
||||
Object.freeze({
|
||||
id: POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID,
|
||||
dialect: 'postgresql',
|
||||
migrationIdScheme: 'postgres-prefixed',
|
||||
checksumScheme: 'sha256',
|
||||
migrations: Object.freeze([
|
||||
...postgresCoreMigrations,
|
||||
...postgresUsagePricingMigrations,
|
||||
...postgresCatalogMigrations,
|
||||
...postgresPromptBaseMigrations,
|
||||
...postgresCredentialMigrations,
|
||||
...postgresPromptExtensionMigrations,
|
||||
]),
|
||||
});
|
||||
|
||||
export async function migratePostgresModelInvocationFeature(
|
||||
pool: PostgresPool,
|
||||
): Promise<void> {
|
||||
await runMigrationStream({
|
||||
stream: postgresModelInvocationMigrationDefinition,
|
||||
store: new PostgresModelInvocationMigrationStore(pool),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { PostgresQueryable } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
POSTGRES_MODEL_PRICE_CATALOG_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PRICE_CATALOG_AUTHORIZATION_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_SCHEMA,
|
||||
} from '../identities';
|
||||
|
||||
import { defineSqlMigration } from '../shared';
|
||||
|
||||
const POSTGRES_MODEL_PRICE_CATALOG_PUBLICATION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications" (
|
||||
provider varchar(128) NOT NULL,
|
||||
model varchar(128) NOT NULL,
|
||||
price_revision varchar(128) NOT NULL,
|
||||
catalog_digest char(64) NOT NULL,
|
||||
mutation_id varchar(128) NOT NULL UNIQUE,
|
||||
command_digest char(64) NOT NULL,
|
||||
publication_digest char(64) NOT NULL,
|
||||
published_at_ms bigint NOT NULL,
|
||||
published_by_user_id varchar(128) NOT NULL,
|
||||
publication_json jsonb NOT NULL,
|
||||
PRIMARY KEY (provider, model, price_revision),
|
||||
CONSTRAINT ql3_model_price_catalog_publication_identity_check CHECK (
|
||||
provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
model ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
price_revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
mutation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
published_by_user_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_publication_value_check CHECK (
|
||||
published_at_ms >= 0 AND
|
||||
catalog_digest ~ '^[0-9a-f]{64}$' AND
|
||||
command_digest ~ '^[0-9a-f]{64}$' AND
|
||||
publication_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_publication_json_check CHECK (
|
||||
jsonb_typeof(publication_json) = 'object' AND
|
||||
octet_length(publication_json::text) BETWEEN 2 AND 24576 AND
|
||||
publication_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-price-catalog-publication@v1',
|
||||
'mutationId', mutation_id,
|
||||
'publishedByUserId', published_by_user_id,
|
||||
'commandDigest', command_digest,
|
||||
'publicationDigest', publication_digest
|
||||
) AND
|
||||
publication_json->'entry' @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-price-catalog-entry@v1',
|
||||
'provider', provider,
|
||||
'model', model,
|
||||
'priceRevision', price_revision,
|
||||
'catalogDigest', catalog_digest,
|
||||
'publishedAtMs', published_at_ms
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const POSTGRES_MODEL_PRICE_CATALOG_HEAD_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads" (
|
||||
provider varchar(128) NOT NULL,
|
||||
model varchar(128) NOT NULL,
|
||||
generation integer NOT NULL,
|
||||
previous_head_digest char(64),
|
||||
active_price_revision varchar(128),
|
||||
active_catalog_digest char(64),
|
||||
revoked_price_revision varchar(128),
|
||||
revoked_catalog_digest char(64),
|
||||
action varchar(16) NOT NULL,
|
||||
mutation_id varchar(128) NOT NULL UNIQUE,
|
||||
changed_by_user_id varchar(128) NOT NULL,
|
||||
changed_at_ms bigint NOT NULL,
|
||||
command_digest char(64) NOT NULL,
|
||||
head_digest char(64) NOT NULL UNIQUE,
|
||||
head_json jsonb NOT NULL,
|
||||
PRIMARY KEY (provider, model, generation),
|
||||
FOREIGN KEY (previous_head_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads"
|
||||
(head_digest) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (
|
||||
provider, model, active_price_revision, active_catalog_digest
|
||||
) REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications" (
|
||||
provider, model, price_revision, catalog_digest
|
||||
) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (
|
||||
provider, model, revoked_price_revision, revoked_catalog_digest
|
||||
) REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications" (
|
||||
provider, model, price_revision, catalog_digest
|
||||
) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_price_catalog_head_identity_check CHECK (
|
||||
provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
model ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
mutation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
changed_by_user_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_head_value_check CHECK (
|
||||
generation BETWEEN 1 AND 2147483647 AND
|
||||
((generation = 1 AND previous_head_digest IS NULL) OR
|
||||
(generation > 1 AND previous_head_digest IS NOT NULL)) AND
|
||||
((active_price_revision IS NULL AND active_catalog_digest IS NULL) OR
|
||||
(active_price_revision IS NOT NULL AND
|
||||
active_catalog_digest IS NOT NULL)) AND
|
||||
((revoked_price_revision IS NULL AND revoked_catalog_digest IS NULL) OR
|
||||
(revoked_price_revision IS NOT NULL AND
|
||||
revoked_catalog_digest IS NOT NULL)) AND
|
||||
(revoked_price_revision IS NULL OR
|
||||
revoked_price_revision <> active_price_revision) AND
|
||||
action IN ('activate', 'deactivate', 'revoke') AND
|
||||
((action = 'revoke') = (revoked_price_revision IS NOT NULL)) AND
|
||||
changed_at_ms >= 0 AND
|
||||
(previous_head_digest IS NULL OR
|
||||
previous_head_digest ~ '^[0-9a-f]{64}$') AND
|
||||
(active_catalog_digest IS NULL OR
|
||||
active_catalog_digest ~ '^[0-9a-f]{64}$') AND
|
||||
(revoked_catalog_digest IS NULL OR
|
||||
revoked_catalog_digest ~ '^[0-9a-f]{64}$') AND
|
||||
command_digest ~ '^[0-9a-f]{64}$' AND
|
||||
head_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_head_json_check CHECK (
|
||||
jsonb_typeof(head_json) = 'object' AND
|
||||
octet_length(head_json::text) BETWEEN 2 AND 24576 AND
|
||||
head_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-price-catalog-head@v1',
|
||||
'provider', provider,
|
||||
'model', model,
|
||||
'generation', generation,
|
||||
'previousHeadDigest', previous_head_digest,
|
||||
'activePriceRevision', active_price_revision,
|
||||
'activeCatalogDigest', active_catalog_digest,
|
||||
'revokedPriceRevision', revoked_price_revision,
|
||||
'revokedCatalogDigest', revoked_catalog_digest,
|
||||
'action', action,
|
||||
'mutationId', mutation_id,
|
||||
'changedByUserId', changed_by_user_id,
|
||||
'changedAtMs', changed_at_ms,
|
||||
'commandDigest', command_digest,
|
||||
'headDigest', head_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresModelPriceCatalogMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_MODEL_PRICE_CATALOG_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_MODEL_PRICE_CATALOG_PUBLICATION_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_model_price_catalog_publication_identity_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications"
|
||||
(provider, model, price_revision, catalog_digest)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_price_catalog_publication_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications"
|
||||
(publication_digest)`,
|
||||
POSTGRES_MODEL_PRICE_CATALOG_HEAD_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_price_catalog_head_current_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads"
|
||||
(provider, model, generation DESC)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_price_catalog_revoked_revision_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads"
|
||||
(provider, model, revoked_price_revision)
|
||||
WHERE revoked_price_revision IS NOT NULL`,
|
||||
`GRANT USAGE ON SCHEMA "${POSTGRES_MODEL_INVOCATION_SCHEMA}"
|
||||
TO ql3_admin`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads"
|
||||
FROM PUBLIC`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads"
|
||||
TO ql3_runtime`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads"
|
||||
TO ql3_admin`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const POSTGRES_MODEL_PRICE_CATALOG_AUTHORIZATION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_authorizations" (
|
||||
authorization_id varchar(128) PRIMARY KEY,
|
||||
request_id varchar(128) NOT NULL,
|
||||
operation varchar(16) NOT NULL,
|
||||
provider varchar(128) NOT NULL,
|
||||
model varchar(128) NOT NULL,
|
||||
price_revision varchar(128),
|
||||
catalog_command_digest char(64) NOT NULL UNIQUE,
|
||||
publication_digest char(64) UNIQUE,
|
||||
head_digest char(64) UNIQUE,
|
||||
result_digest char(64) NOT NULL UNIQUE,
|
||||
user_id varchar(128) NOT NULL,
|
||||
authentication_id varchar(128) NOT NULL,
|
||||
assurance varchar(32) NOT NULL,
|
||||
authenticated_at_ms bigint NOT NULL,
|
||||
expires_at_ms bigint NOT NULL,
|
||||
policy_revision varchar(128) NOT NULL,
|
||||
policy_decision_digest char(64) NOT NULL,
|
||||
decision_mode varchar(32) NOT NULL,
|
||||
command_digest char(64) NOT NULL UNIQUE,
|
||||
committed_at_ms bigint NOT NULL,
|
||||
authorization_digest char(64) NOT NULL UNIQUE,
|
||||
reasons_json jsonb NOT NULL,
|
||||
authorization_json jsonb NOT NULL,
|
||||
FOREIGN KEY (publication_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications"
|
||||
(publication_digest) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (head_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads"
|
||||
(head_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_price_catalog_authorization_identity_check CHECK (
|
||||
authorization_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
request_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' AND
|
||||
provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
model ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
(price_revision IS NULL OR
|
||||
price_revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$') AND
|
||||
user_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
authentication_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' AND
|
||||
policy_revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_authorization_value_check CHECK (
|
||||
operation IN ('publish', 'activate', 'deactivate', 'revoke') AND
|
||||
((operation = 'deactivate' AND price_revision IS NULL) OR
|
||||
(operation <> 'deactivate' AND price_revision IS NOT NULL)) AND
|
||||
((operation = 'publish' AND publication_digest IS NOT NULL AND
|
||||
head_digest IS NULL AND result_digest = publication_digest) OR
|
||||
(operation <> 'publish' AND publication_digest IS NULL AND
|
||||
head_digest IS NOT NULL AND result_digest = head_digest)) AND
|
||||
assurance IN ('multi_factor', 'hardware', 'local_console') AND
|
||||
decision_mode IN ('human_confirmation', 'separation_of_duty') AND
|
||||
authenticated_at_ms >= 0 AND
|
||||
expires_at_ms > authenticated_at_ms AND
|
||||
committed_at_ms >= authenticated_at_ms AND
|
||||
committed_at_ms < expires_at_ms AND
|
||||
committed_at_ms - authenticated_at_ms <= 300000 AND
|
||||
catalog_command_digest ~ '^[0-9a-f]{64}$' AND
|
||||
result_digest ~ '^[0-9a-f]{64}$' AND
|
||||
policy_decision_digest ~ '^[0-9a-f]{64}$' AND
|
||||
command_digest ~ '^[0-9a-f]{64}$' AND
|
||||
authorization_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_authorization_reasons_check CHECK (
|
||||
jsonb_typeof(reasons_json) = 'array' AND
|
||||
jsonb_array_length(reasons_json) BETWEEN 1 AND 8 AND
|
||||
octet_length(reasons_json::text) BETWEEN 5 AND 2048
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_authorization_json_check CHECK (
|
||||
jsonb_typeof(authorization_json) = 'object' AND
|
||||
octet_length(authorization_json::text) BETWEEN 2 AND 32768 AND
|
||||
authorization_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-price-catalog-authorization@v1',
|
||||
'authorizationId', authorization_id,
|
||||
'requestId', request_id,
|
||||
'operation', operation,
|
||||
'provider', provider,
|
||||
'model', model,
|
||||
'priceRevision', price_revision,
|
||||
'catalogCommandDigest', catalog_command_digest,
|
||||
'resultDigest', result_digest,
|
||||
'decisionMode', decision_mode,
|
||||
'commandDigest', command_digest,
|
||||
'committedAtMs', committed_at_ms,
|
||||
'authorizationDigest', authorization_digest
|
||||
) AND
|
||||
authorization_json->'principal' @> jsonb_build_object(
|
||||
'subject', jsonb_build_object('type', 'user', 'id', user_id),
|
||||
'authenticationId', authentication_id,
|
||||
'assurance', assurance,
|
||||
'authenticatedAtMs', authenticated_at_ms,
|
||||
'expiresAtMs', expires_at_ms
|
||||
) AND
|
||||
authorization_json->'policy' @> jsonb_build_object(
|
||||
'revision', policy_revision,
|
||||
'decisionDigest', policy_decision_digest,
|
||||
'reasons', reasons_json
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresModelPriceCatalogAuthorizationMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_MODEL_PRICE_CATALOG_AUTHORIZATION_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_MODEL_PRICE_CATALOG_AUTHORIZATION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_price_catalog_authorization_target_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_authorizations"
|
||||
(provider, model, operation, committed_at_ms DESC)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_authorizations"
|
||||
FROM PUBLIC`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_authorizations"
|
||||
TO ql3_admin`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
export const postgresCatalogMigrations = Object.freeze([
|
||||
postgresModelPriceCatalogMigration,
|
||||
postgresModelPriceCatalogAuthorizationMigration,
|
||||
]);
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { PostgresQueryable } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
POSTGRES_MODEL_INVOCATION_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_SCHEMA,
|
||||
} from '../identities';
|
||||
|
||||
import { defineSqlMigration } from '../shared';
|
||||
|
||||
const POSTGRES_START_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts" (
|
||||
invocation_id varchar(128) PRIMARY KEY,
|
||||
project_id varchar(128) NOT NULL,
|
||||
run_id varchar(36) NOT NULL,
|
||||
step_run_id varchar(128) NOT NULL,
|
||||
trace_id varchar(128) NOT NULL,
|
||||
provider varchar(128) NOT NULL,
|
||||
model varchar(128) NOT NULL,
|
||||
policy_revision varchar(128) NOT NULL,
|
||||
request_digest varchar(71) NOT NULL,
|
||||
input_bytes integer NOT NULL,
|
||||
max_output_tokens integer NOT NULL,
|
||||
deadline_at_ms bigint NOT NULL,
|
||||
admitted_at_ms bigint NOT NULL,
|
||||
mutation_id varchar(128) NOT NULL,
|
||||
mutation_digest char(64) NOT NULL,
|
||||
run_event_id varchar(36) NOT NULL,
|
||||
start_digest char(64) NOT NULL,
|
||||
record_json jsonb NOT NULL,
|
||||
FOREIGN KEY (run_id, step_run_id)
|
||||
REFERENCES "ql3"."step_runs" (run_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (mutation_id)
|
||||
REFERENCES "ql3"."step_run_mutations" (mutation_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (run_event_id)
|
||||
REFERENCES "ql3"."run_events" (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_starts_identity_check CHECK (
|
||||
invocation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' AND
|
||||
step_run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
trace_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
model ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
policy_revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
mutation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_starts_value_check CHECK (
|
||||
request_digest ~ '^sha256:[0-9a-f]{64}$' AND
|
||||
mutation_digest ~ '^[0-9a-f]{64}$' AND
|
||||
start_digest ~ '^[0-9a-f]{64}$' AND
|
||||
input_bytes BETWEEN 1 AND 262144 AND
|
||||
max_output_tokens BETWEEN 1 AND 32768 AND
|
||||
admitted_at_ms >= 0 AND deadline_at_ms > admitted_at_ms
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_starts_json_check CHECK (
|
||||
jsonb_typeof(record_json) = 'object' AND
|
||||
octet_length(record_json::text) BETWEEN 2 AND 24576 AND
|
||||
record_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-invocation-start@v1',
|
||||
'invocationId', invocation_id,
|
||||
'projectId', project_id,
|
||||
'runId', run_id,
|
||||
'stepRunId', step_run_id,
|
||||
'traceId', trace_id,
|
||||
'provider', provider,
|
||||
'model', model,
|
||||
'policyRevision', policy_revision,
|
||||
'requestDigest', request_digest,
|
||||
'inputBytes', input_bytes,
|
||||
'maxOutputTokens', max_output_tokens,
|
||||
'deadlineAtMs', deadline_at_ms,
|
||||
'admittedAtMs', admitted_at_ms,
|
||||
'stepRunMutationId', mutation_id,
|
||||
'stepRunMutationDigest', mutation_digest,
|
||||
'runEventId', run_event_id,
|
||||
'startDigest', start_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const POSTGRES_COMPLETION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions" (
|
||||
invocation_id varchar(128) PRIMARY KEY,
|
||||
project_id varchar(128) NOT NULL,
|
||||
run_id varchar(36) NOT NULL,
|
||||
step_run_id varchar(128) NOT NULL,
|
||||
trace_id varchar(128) NOT NULL,
|
||||
start_digest char(64) NOT NULL,
|
||||
outcome varchar(32) NOT NULL,
|
||||
output_bytes integer NOT NULL,
|
||||
error_code varchar(64),
|
||||
completed_at_ms bigint NOT NULL,
|
||||
mutation_id varchar(128) NOT NULL,
|
||||
mutation_digest char(64) NOT NULL,
|
||||
run_event_id varchar(36) NOT NULL,
|
||||
completion_digest char(64) NOT NULL,
|
||||
record_json jsonb NOT NULL,
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts" (invocation_id)
|
||||
ON DELETE RESTRICT,
|
||||
FOREIGN KEY (run_id, step_run_id)
|
||||
REFERENCES "ql3"."step_runs" (run_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (mutation_id)
|
||||
REFERENCES "ql3"."step_run_mutations" (mutation_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (run_event_id)
|
||||
REFERENCES "ql3"."run_events" (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_completions_value_check CHECK (
|
||||
outcome IN ('succeeded', 'failed', 'timed_out', 'outcome_unknown') AND
|
||||
output_bytes BETWEEN 0 AND 1048576 AND completed_at_ms >= 0 AND
|
||||
((outcome = 'succeeded' AND error_code IS NULL) OR
|
||||
(outcome <> 'succeeded' AND
|
||||
error_code ~ '^[A-Z][A-Z0-9_]{0,63}$')) AND
|
||||
start_digest ~ '^[0-9a-f]{64}$' AND
|
||||
mutation_digest ~ '^[0-9a-f]{64}$' AND
|
||||
completion_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_completions_json_check CHECK (
|
||||
jsonb_typeof(record_json) = 'object' AND
|
||||
octet_length(record_json::text) BETWEEN 2 AND 24576 AND
|
||||
record_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-invocation-completion@v1',
|
||||
'invocationId', invocation_id,
|
||||
'projectId', project_id,
|
||||
'runId', run_id,
|
||||
'stepRunId', step_run_id,
|
||||
'traceId', trace_id,
|
||||
'startDigest', start_digest,
|
||||
'outcome', outcome,
|
||||
'outputBytes', output_bytes,
|
||||
'errorCode', error_code,
|
||||
'completedAtMs', completed_at_ms,
|
||||
'stepRunMutationId', mutation_id,
|
||||
'stepRunMutationDigest', mutation_digest,
|
||||
'runEventId', run_event_id,
|
||||
'completionDigest', completion_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const POSTGRES_RESOLUTION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_resolutions" (
|
||||
resolution_id varchar(128) PRIMARY KEY,
|
||||
invocation_id varchar(128) NOT NULL UNIQUE,
|
||||
project_id varchar(128) NOT NULL,
|
||||
run_id varchar(36) NOT NULL,
|
||||
step_run_id varchar(128) NOT NULL,
|
||||
trace_id varchar(128) NOT NULL,
|
||||
completion_digest char(64) NOT NULL,
|
||||
decision varchar(16) NOT NULL,
|
||||
resolved_by_user_id varchar(128) NOT NULL,
|
||||
resolved_at_ms bigint NOT NULL,
|
||||
mutation_id varchar(128) NOT NULL UNIQUE,
|
||||
mutation_digest char(64) NOT NULL,
|
||||
run_event_id varchar(36) NOT NULL UNIQUE,
|
||||
resolution_digest char(64) NOT NULL UNIQUE,
|
||||
record_json jsonb NOT NULL,
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions" (invocation_id)
|
||||
ON DELETE RESTRICT,
|
||||
FOREIGN KEY (run_id, step_run_id)
|
||||
REFERENCES "ql3"."step_runs" (run_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (mutation_id)
|
||||
REFERENCES "ql3"."step_run_mutations" (mutation_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (run_event_id)
|
||||
REFERENCES "ql3"."run_events" (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_resolutions_value_check CHECK (
|
||||
decision IN ('retry', 'fail', 'cancel') AND
|
||||
resolved_at_ms >= 0 AND
|
||||
resolution_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
invocation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' AND
|
||||
step_run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
trace_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
resolved_by_user_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
mutation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' AND
|
||||
completion_digest ~ '^[0-9a-f]{64}$' AND
|
||||
mutation_digest ~ '^[0-9a-f]{64}$' AND
|
||||
resolution_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_resolutions_json_check CHECK (
|
||||
jsonb_typeof(record_json) = 'object' AND
|
||||
octet_length(record_json::text) BETWEEN 2 AND 24576 AND
|
||||
record_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-invocation-resolution@v1',
|
||||
'resolutionId', resolution_id,
|
||||
'invocationId', invocation_id,
|
||||
'projectId', project_id,
|
||||
'runId', run_id,
|
||||
'stepRunId', step_run_id,
|
||||
'traceId', trace_id,
|
||||
'completionDigest', completion_digest,
|
||||
'decision', decision,
|
||||
'resolvedByUserId', resolved_by_user_id,
|
||||
'resolvedAtMs', resolved_at_ms,
|
||||
'stepRunMutationId', mutation_id,
|
||||
'stepRunMutationDigest', mutation_digest,
|
||||
'runEventId', run_event_id,
|
||||
'resolutionDigest', resolution_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresMigration = defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_MODEL_INVOCATION_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_START_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_invocation_starts_step_history_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts"
|
||||
(run_id, step_run_id, admitted_at_ms, invocation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_starts_mutation_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts" (mutation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_starts_event_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts" (run_event_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_starts_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts" (start_digest)`,
|
||||
POSTGRES_COMPLETION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_invocation_completions_step_history_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions"
|
||||
(run_id, step_run_id, completed_at_ms, invocation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_completions_mutation_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions" (mutation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_completions_event_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions" (run_event_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_completions_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions" (completion_digest)`,
|
||||
POSTGRES_RESOLUTION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_invocation_resolutions_step_history_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_resolutions"
|
||||
(run_id, step_run_id, resolved_at_ms, resolution_id)`,
|
||||
`GRANT USAGE ON SCHEMA "${POSTGRES_MODEL_INVOCATION_SCHEMA}"
|
||||
TO ql3_runtime`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_resolutions"
|
||||
FROM PUBLIC`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_resolutions"
|
||||
TO ql3_runtime`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
export const postgresCoreMigrations = Object.freeze([
|
||||
postgresMigration,
|
||||
]);
|
||||
@@ -0,0 +1,513 @@
|
||||
import type { PostgresQueryable } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_CATALOG_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_MIGRATION_ID,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_TEST_CONNECTION_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_SCHEMA,
|
||||
} from '../identities';
|
||||
|
||||
import { defineSqlMigration } from '../shared';
|
||||
|
||||
const POSTGRES_MODEL_PROVIDER_CREDENTIAL_BINDING_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings" (
|
||||
project_id varchar(128) NOT NULL,
|
||||
provider varchar(128) NOT NULL,
|
||||
revision varchar(128) NOT NULL,
|
||||
secret_ref varchar(512) NOT NULL,
|
||||
scheme varchar(16) NOT NULL,
|
||||
binding_digest varchar(71) NOT NULL UNIQUE,
|
||||
binding_json jsonb NOT NULL,
|
||||
PRIMARY KEY (project_id, provider, revision),
|
||||
CONSTRAINT ql3_ai_model_provider_credential_binding_project_fk
|
||||
FOREIGN KEY (project_id) REFERENCES "ql3"."projects" (id)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_model_provider_credential_binding_identity_check CHECK (
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
length(secret_ref) BETWEEN 1 AND 512 AND
|
||||
secret_ref LIKE 'qlsecret:v1:%' AND
|
||||
scheme = 'bearer' AND
|
||||
binding_digest ~ '^sha256:[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_model_provider_credential_binding_json_check CHECK (
|
||||
jsonb_typeof(binding_json) = 'object' AND
|
||||
octet_length(binding_json::text) BETWEEN 2 AND 4096 AND
|
||||
binding_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-provider-credential-binding@v1',
|
||||
'projectId', project_id, 'provider', provider,
|
||||
'revision', revision, 'secretRef', secret_ref, 'scheme', scheme
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const POSTGRES_MODEL_PROVIDER_CREDENTIAL_TRANSITION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions" (
|
||||
project_id varchar(128) NOT NULL,
|
||||
provider varchar(128) NOT NULL,
|
||||
generation integer NOT NULL,
|
||||
action varchar(16) NOT NULL,
|
||||
active_binding_revision varchar(128),
|
||||
active_binding_digest char(64),
|
||||
previous_transition_digest char(64),
|
||||
mutation_id varchar(128) NOT NULL UNIQUE,
|
||||
changed_by_type varchar(32) NOT NULL,
|
||||
changed_by_id varchar(128) NOT NULL,
|
||||
changed_at_ms bigint NOT NULL,
|
||||
command_digest char(64) NOT NULL,
|
||||
transition_digest char(64) NOT NULL UNIQUE,
|
||||
transition_json jsonb NOT NULL,
|
||||
PRIMARY KEY (project_id, provider, generation),
|
||||
CONSTRAINT ql3_ai_model_provider_credential_transition_project_fk
|
||||
FOREIGN KEY (project_id) REFERENCES "ql3"."projects" (id)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_model_provider_credential_transition_binding_fk
|
||||
FOREIGN KEY (project_id, provider, active_binding_revision)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings"
|
||||
(project_id, provider, revision) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_model_provider_credential_transition_identity_check CHECK (
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
generation BETWEEN 1 AND 2147483647 AND
|
||||
action IN ('bind', 'revoke') AND
|
||||
mutation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
changed_by_type IN ('user', 'api_app', 'mcp_client', 'agent', 'system', 'worker') AND
|
||||
changed_by_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
changed_at_ms >= 0
|
||||
),
|
||||
CONSTRAINT ql3_ai_model_provider_credential_transition_state_check CHECK (
|
||||
(action = 'bind' AND active_binding_revision IS NOT NULL AND
|
||||
active_binding_digest ~ '^[0-9a-f]{64}$') OR
|
||||
(action = 'revoke' AND active_binding_revision IS NULL AND
|
||||
active_binding_digest IS NULL)
|
||||
),
|
||||
CONSTRAINT ql3_ai_model_provider_credential_transition_digest_check CHECK (
|
||||
(previous_transition_digest IS NULL OR
|
||||
previous_transition_digest ~ '^[0-9a-f]{64}$') AND
|
||||
command_digest ~ '^[0-9a-f]{64}$' AND
|
||||
transition_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_model_provider_credential_transition_json_check CHECK (
|
||||
jsonb_typeof(transition_json) = 'object' AND
|
||||
octet_length(transition_json::text) BETWEEN 2 AND 8192 AND
|
||||
transition_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-provider-credential-transition@v1',
|
||||
'mutationId', mutation_id, 'projectId', project_id,
|
||||
'provider', provider, 'generation', generation, 'action', action,
|
||||
'activeBindingRevision', active_binding_revision,
|
||||
'activeBindingDigest', active_binding_digest,
|
||||
'previousTransitionDigest', previous_transition_digest,
|
||||
'changedAtMs', changed_at_ms, 'commandDigest', command_digest,
|
||||
'transitionDigest', transition_digest
|
||||
) AND
|
||||
transition_json -> 'changedBy' @> jsonb_build_object(
|
||||
'type', changed_by_type, 'id', changed_by_id
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const POSTGRES_MODEL_PROVIDER_CREDENTIAL_AUDIT_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits" (
|
||||
project_id varchar(128) NOT NULL,
|
||||
provider varchar(128) NOT NULL,
|
||||
request_id varchar(128) NOT NULL,
|
||||
operation varchar(16) NOT NULL,
|
||||
binding_revision varchar(128) NOT NULL,
|
||||
binding_digest varchar(71) NOT NULL,
|
||||
occurred_at_ms bigint NOT NULL,
|
||||
audit_digest char(64) PRIMARY KEY,
|
||||
audit_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_model_provider_credential_audit_identity_uidx
|
||||
UNIQUE (project_id, provider, request_id, operation),
|
||||
CONSTRAINT ql3_ai_model_provider_credential_audit_project_fk
|
||||
FOREIGN KEY (project_id) REFERENCES "ql3"."projects" (id)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_model_provider_credential_audit_identity_check CHECK (
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
request_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
operation IN ('list_models', 'generate', 'stream') AND
|
||||
binding_revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
binding_digest ~ '^sha256:[0-9a-f]{64}$' AND
|
||||
occurred_at_ms >= 0 AND audit_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_model_provider_credential_audit_json_check CHECK (
|
||||
jsonb_typeof(audit_json) = 'object' AND
|
||||
octet_length(audit_json::text) BETWEEN 2 AND 4096 AND
|
||||
audit_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-provider-credential-audit@v1',
|
||||
'projectId', project_id, 'provider', provider,
|
||||
'requestId', request_id, 'operation', operation,
|
||||
'bindingRevision', binding_revision,
|
||||
'bindingDigest', binding_digest, 'occurredAtMs', occurred_at_ms
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresModelProviderCredentialCatalogMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_CATALOG_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_BINDING_TABLE_SQL,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_TRANSITION_TABLE_SQL,
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_AUDIT_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_model_provider_credential_transition_current_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions"
|
||||
(project_id, provider, generation DESC)`,
|
||||
`CREATE INDEX ql3_ai_model_provider_credential_audit_project_time_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits"
|
||||
(project_id, occurred_at_ms DESC, request_id)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits"
|
||||
FROM PUBLIC`,
|
||||
`GRANT USAGE ON SCHEMA "${POSTGRES_MODEL_INVOCATION_SCHEMA}"
|
||||
TO ql3_runtime, ql3_ai_maintenance`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions"
|
||||
TO ql3_runtime`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits"
|
||||
TO ql3_runtime`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions"
|
||||
TO ql3_ai_maintenance`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits"
|
||||
TO ql3_ai_maintenance`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const postgresModelProviderCredentialManagementMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_MIGRATION_ID,
|
||||
[
|
||||
`DO $ql3_ai$
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'GRANT CONNECT ON DATABASE %I TO ql3_ai_credential_manager',
|
||||
current_database()
|
||||
);
|
||||
END
|
||||
$ql3_ai$`,
|
||||
`REVOKE CREATE ON SCHEMA "ql3", "${POSTGRES_MODEL_INVOCATION_SCHEMA}"
|
||||
FROM ql3_ai_credential_manager`,
|
||||
`REVOKE ALL ON ALL TABLES IN SCHEMA "${POSTGRES_MODEL_INVOCATION_SCHEMA}"
|
||||
FROM ql3_ai_credential_manager`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits"
|
||||
FROM ql3_ai_maintenance`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"ql3"."projects",
|
||||
"ql3"."project_role_bindings",
|
||||
"ql3"."security_audit_events"
|
||||
FROM ql3_ai_credential_manager`,
|
||||
`GRANT USAGE ON SCHEMA "ql3", "${POSTGRES_MODEL_INVOCATION_SCHEMA}"
|
||||
TO ql3_ai_credential_manager`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"ql3"."projects",
|
||||
"ql3"."project_role_bindings",
|
||||
"ql3"."security_audit_events"
|
||||
TO ql3_ai_credential_manager`,
|
||||
`GRANT INSERT ON TABLE "ql3"."security_audit_events"
|
||||
TO ql3_ai_credential_manager`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."ai_schema_migrations",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits"
|
||||
TO ql3_ai_credential_manager`,
|
||||
`GRANT INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions"
|
||||
TO ql3_ai_credential_manager`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const postgresModelProviderCredentialManagementIdentityMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_MIGRATION_ID,
|
||||
[
|
||||
`CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_management_identity_keyset_ledger" (
|
||||
authority varchar(64) PRIMARY KEY,
|
||||
generation bigint NOT NULL,
|
||||
digest varchar(43) NOT NULL,
|
||||
issuer varchar(512) NOT NULL,
|
||||
audience varchar(256) NOT NULL,
|
||||
active_key_ids jsonb NOT NULL,
|
||||
revoked_key_ids jsonb NOT NULL,
|
||||
updated_at_ms bigint NOT NULL,
|
||||
CONSTRAINT ql3_ai_provider_credential_identity_authority_check
|
||||
CHECK (authority = 'model-provider-credential-management'),
|
||||
CONSTRAINT ql3_ai_provider_credential_identity_generation_check
|
||||
CHECK (generation >= 1 AND updated_at_ms >= 0),
|
||||
CONSTRAINT ql3_ai_provider_credential_identity_digest_check
|
||||
CHECK (digest ~ '^[A-Za-z0-9_-]{43}$'),
|
||||
CONSTRAINT ql3_ai_provider_credential_identity_trust_domain_check
|
||||
CHECK (
|
||||
char_length(issuer) BETWEEN 1 AND 512 AND
|
||||
issuer !~ '[[:cntrl:]]' AND
|
||||
char_length(audience) BETWEEN 1 AND 256 AND
|
||||
audience !~ '[[:cntrl:]]'
|
||||
),
|
||||
CONSTRAINT ql3_ai_provider_credential_identity_keys_check
|
||||
CHECK (
|
||||
jsonb_typeof(active_key_ids) = 'array' AND
|
||||
jsonb_array_length(active_key_ids) BETWEEN 1 AND 8 AND
|
||||
octet_length(active_key_ids::text) BETWEEN 3 AND 8192 AND
|
||||
jsonb_typeof(revoked_key_ids) = 'array' AND
|
||||
jsonb_array_length(revoked_key_ids) BETWEEN 0 AND 64 AND
|
||||
octet_length(revoked_key_ids::text) BETWEEN 2 AND 16384
|
||||
)
|
||||
)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_management_identity_keyset_ledger"
|
||||
FROM PUBLIC, ql3_ai_maintenance`,
|
||||
`GRANT SELECT, INSERT, UPDATE ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_management_identity_keyset_ledger"
|
||||
TO ql3_ai_credential_manager`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const postgresModelProviderCredentialTestConnectionMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_MODEL_PROVIDER_CREDENTIAL_TEST_CONNECTION_MIGRATION_ID,
|
||||
[
|
||||
`CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_plans" (
|
||||
test_id uuid PRIMARY KEY,
|
||||
request_id varchar(128) NOT NULL,
|
||||
project_id varchar(128) NOT NULL,
|
||||
provider varchar(128) NOT NULL,
|
||||
adapter varchar(32) NOT NULL,
|
||||
base_url varchar(1024) NOT NULL,
|
||||
endpoint_revision varchar(128) NOT NULL,
|
||||
endpoint_config_digest char(64) NOT NULL,
|
||||
deadline_ms integer NOT NULL,
|
||||
max_response_bytes integer NOT NULL,
|
||||
max_models integer NOT NULL,
|
||||
max_cost_microusd bigint NOT NULL,
|
||||
retry_limit integer NOT NULL,
|
||||
requested_by_type varchar(32) NOT NULL,
|
||||
requested_by_id varchar(128) NOT NULL,
|
||||
project_version integer NOT NULL,
|
||||
binding_version integer NOT NULL,
|
||||
planned_at_ms bigint NOT NULL,
|
||||
expires_at_ms bigint NOT NULL,
|
||||
plan_digest char(64) NOT NULL UNIQUE,
|
||||
plan_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_provider_credential_test_plan_project_fk
|
||||
FOREIGN KEY (project_id) REFERENCES "ql3"."projects" (id)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_provider_credential_test_plan_identity_uidx
|
||||
UNIQUE (project_id, request_id),
|
||||
CONSTRAINT ql3_ai_provider_credential_test_plan_digest_uidx
|
||||
UNIQUE (test_id, plan_digest),
|
||||
CONSTRAINT ql3_ai_provider_credential_test_plan_identity_check CHECK (
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
request_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' AND
|
||||
endpoint_revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
requested_by_type = 'user' AND
|
||||
char_length(requested_by_id) BETWEEN 1 AND 128 AND
|
||||
requested_by_id !~ '[[:cntrl:]]'
|
||||
),
|
||||
CONSTRAINT ql3_ai_provider_credential_test_plan_budget_check CHECK (
|
||||
adapter = 'openai-compatible' AND
|
||||
base_url ~ '^https://[^[:cntrl:]]+/$' AND
|
||||
endpoint_config_digest ~ '^[0-9a-f]{64}$' AND
|
||||
deadline_ms BETWEEN 1000 AND 15000 AND
|
||||
max_response_bytes BETWEEN 1024 AND 262144 AND
|
||||
max_models BETWEEN 1 AND 256 AND
|
||||
max_cost_microusd = 0 AND retry_limit = 0
|
||||
),
|
||||
CONSTRAINT ql3_ai_provider_credential_test_plan_fence_check CHECK (
|
||||
project_version BETWEEN 1 AND 2147483647 AND
|
||||
binding_version BETWEEN 1 AND 2147483647 AND
|
||||
planned_at_ms >= 0 AND expires_at_ms > planned_at_ms AND
|
||||
expires_at_ms - planned_at_ms <= 300000 AND
|
||||
plan_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_provider_credential_test_plan_json_check CHECK (
|
||||
jsonb_typeof(plan_json) = 'object' AND
|
||||
octet_length(plan_json::text) BETWEEN 2 AND 16384 AND
|
||||
plan_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-provider-credential-test-plan@v1',
|
||||
'testId', test_id::text, 'requestId', request_id,
|
||||
'projectId', project_id, 'provider', provider,
|
||||
'plannedAtMs', planned_at_ms, 'expiresAtMs', expires_at_ms,
|
||||
'planDigest', plan_digest
|
||||
) AND
|
||||
plan_json -> 'endpoint' @> jsonb_build_object(
|
||||
'provider', provider, 'adapter', adapter, 'baseUrl', base_url,
|
||||
'revision', endpoint_revision,
|
||||
'configDigest', endpoint_config_digest,
|
||||
'deadlineMs', deadline_ms,
|
||||
'maxResponseBytes', max_response_bytes,
|
||||
'maxModels', max_models, 'maxCostMicrousd', max_cost_microusd,
|
||||
'retryLimit', retry_limit
|
||||
) AND
|
||||
plan_json -> 'requestedBy' @> jsonb_build_object(
|
||||
'type', requested_by_type, 'id', requested_by_id
|
||||
) AND
|
||||
plan_json -> 'fence' @> jsonb_build_object(
|
||||
'projectVersion', project_version,
|
||||
'bindingVersion', binding_version
|
||||
)
|
||||
)
|
||||
)`,
|
||||
`CREATE INDEX ql3_ai_provider_credential_test_plan_expiry_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_plans"
|
||||
(expires_at_ms, test_id)`,
|
||||
`CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_quota_buckets" (
|
||||
project_id varchar(128) NOT NULL,
|
||||
subject_id varchar(128) NOT NULL,
|
||||
window_started_at_ms bigint NOT NULL,
|
||||
consumed_count integer NOT NULL,
|
||||
receipt_ids jsonb NOT NULL,
|
||||
updated_at_ms bigint NOT NULL,
|
||||
PRIMARY KEY (project_id, subject_id),
|
||||
CONSTRAINT ql3_ai_provider_credential_test_quota_project_fk
|
||||
FOREIGN KEY (project_id) REFERENCES "ql3"."projects" (id)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_provider_credential_test_quota_identity_check CHECK (
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
char_length(subject_id) BETWEEN 1 AND 128 AND
|
||||
subject_id !~ '[[:cntrl:]]'
|
||||
),
|
||||
CONSTRAINT ql3_ai_provider_credential_test_quota_value_check CHECK (
|
||||
window_started_at_ms >= 0 AND
|
||||
consumed_count BETWEEN 1 AND 32 AND
|
||||
updated_at_ms >= window_started_at_ms AND
|
||||
jsonb_typeof(receipt_ids) = 'array' AND
|
||||
jsonb_array_length(receipt_ids) BETWEEN 1 AND 32 AND
|
||||
octet_length(receipt_ids::text) BETWEEN 3 AND 8192
|
||||
)
|
||||
)`,
|
||||
`CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_executions" (
|
||||
execution_id uuid PRIMARY KEY,
|
||||
test_id uuid NOT NULL UNIQUE,
|
||||
plan_digest char(64) NOT NULL,
|
||||
started_at_ms bigint NOT NULL,
|
||||
execution_digest char(64) NOT NULL UNIQUE,
|
||||
execution_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_provider_credential_test_execution_plan_fk
|
||||
FOREIGN KEY (test_id, plan_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_plans"
|
||||
(test_id, plan_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_provider_credential_test_execution_value_check CHECK (
|
||||
plan_digest ~ '^[0-9a-f]{64}$' AND started_at_ms >= 0 AND
|
||||
execution_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_provider_credential_test_execution_json_check CHECK (
|
||||
jsonb_typeof(execution_json) = 'object' AND
|
||||
octet_length(execution_json::text) BETWEEN 2 AND 4096 AND
|
||||
execution_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-provider-credential-test-execution@v1',
|
||||
'executionId', execution_id::text, 'testId', test_id::text,
|
||||
'planDigest', plan_digest, 'startedAtMs', started_at_ms,
|
||||
'executionDigest', execution_digest
|
||||
)
|
||||
)
|
||||
)`,
|
||||
`ALTER TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_executions"
|
||||
ADD CONSTRAINT ql3_ai_provider_credential_test_execution_identity_uidx
|
||||
UNIQUE (execution_id, test_id, plan_digest)`,
|
||||
`CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_results" (
|
||||
execution_id uuid PRIMARY KEY,
|
||||
test_id uuid NOT NULL UNIQUE,
|
||||
plan_digest char(64) NOT NULL,
|
||||
outcome varchar(16) NOT NULL,
|
||||
model_count integer,
|
||||
duration_ms integer NOT NULL,
|
||||
completed_at_ms bigint NOT NULL,
|
||||
result_digest char(64) NOT NULL UNIQUE,
|
||||
result_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_provider_credential_test_result_execution_fk
|
||||
FOREIGN KEY (execution_id, test_id, plan_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_executions"
|
||||
(execution_id, test_id, plan_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_provider_credential_test_result_value_check CHECK (
|
||||
plan_digest ~ '^[0-9a-f]{64}$' AND
|
||||
((outcome = 'reachable' AND model_count BETWEEN 0 AND 256) OR
|
||||
(outcome = 'unreachable' AND model_count IS NULL)) AND
|
||||
duration_ms BETWEEN 0 AND 15000 AND completed_at_ms >= 0 AND
|
||||
result_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_provider_credential_test_result_json_check CHECK (
|
||||
jsonb_typeof(result_json) = 'object' AND
|
||||
octet_length(result_json::text) BETWEEN 2 AND 4096 AND
|
||||
result_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-provider-credential-test-result@v1',
|
||||
'executionId', execution_id::text, 'testId', test_id::text,
|
||||
'planDigest', plan_digest, 'outcome', outcome,
|
||||
'modelCount', model_count, 'durationMs', duration_ms,
|
||||
'completedAtMs', completed_at_ms, 'resultDigest', result_digest
|
||||
)
|
||||
)
|
||||
)`,
|
||||
`DO $ql3_ai$
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'GRANT CONNECT ON DATABASE %I TO ql3_ai_credential_tester',
|
||||
current_database()
|
||||
);
|
||||
END
|
||||
$ql3_ai$`,
|
||||
`REVOKE CREATE ON SCHEMA "ql3", "${POSTGRES_MODEL_INVOCATION_SCHEMA}"
|
||||
FROM ql3_ai_credential_tester`,
|
||||
`REVOKE ALL ON ALL TABLES IN SCHEMA "ql3", "${POSTGRES_MODEL_INVOCATION_SCHEMA}"
|
||||
FROM ql3_ai_credential_tester`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_plans",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_quota_buckets",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_executions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_results"
|
||||
FROM PUBLIC, ql3_runtime, ql3_ai_maintenance, ql3_admin,
|
||||
ql3_package_manager, ql3_package_executor, ql3_worker_ingress,
|
||||
ql3_ai_credential_manager, ql3_ai_credential_tester`,
|
||||
`GRANT USAGE ON SCHEMA "${POSTGRES_MODEL_INVOCATION_SCHEMA}"
|
||||
TO ql3_ai_credential_tester`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_plans"
|
||||
TO ql3_ai_credential_manager`,
|
||||
`GRANT SELECT, INSERT, UPDATE ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_quota_buckets"
|
||||
TO ql3_ai_credential_manager`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_executions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_results"
|
||||
TO ql3_ai_credential_manager`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."ai_schema_migrations",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_plans",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_executions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_results"
|
||||
TO ql3_ai_credential_tester`,
|
||||
`GRANT INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_executions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_test_results"
|
||||
TO ql3_ai_credential_tester`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
export const postgresCredentialMigrations = Object.freeze([
|
||||
postgresModelProviderCredentialCatalogMigration,
|
||||
postgresModelProviderCredentialManagementMigration,
|
||||
postgresModelProviderCredentialManagementIdentityMigration,
|
||||
postgresModelProviderCredentialTestConnectionMigration,
|
||||
]);
|
||||
@@ -0,0 +1,855 @@
|
||||
import type { PostgresQueryable } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_ADMISSION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_FINALIZATION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_ROTATION_MIGRATION_ID,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_PRODUCT_AUTHORIZATION_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_SCHEMA,
|
||||
POSTGRES_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE,
|
||||
} from '../identities';
|
||||
|
||||
import { defineSqlMigration } from '../shared';
|
||||
|
||||
const POSTGRES_PLUGIN_PACKAGE_PROMPT_ADMISSION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions" (
|
||||
request_id varchar(128) PRIMARY KEY,
|
||||
invocation_id varchar(128) NOT NULL UNIQUE,
|
||||
plan_digest char(64) NOT NULL UNIQUE,
|
||||
run_id varchar(36) NOT NULL UNIQUE,
|
||||
step_run_id varchar(128) NOT NULL UNIQUE,
|
||||
project_id varchar(128) NOT NULL,
|
||||
package_name varchar(63) NOT NULL,
|
||||
installation_id varchar(128) NOT NULL,
|
||||
lock_digest char(64) NOT NULL,
|
||||
generation integer NOT NULL,
|
||||
generation_digest char(64) NOT NULL,
|
||||
materialized_revision_digest char(64) NOT NULL,
|
||||
publication_digest char(64) NOT NULL,
|
||||
prompt_id varchar(128) NOT NULL,
|
||||
prompt_definition_digest char(64) NOT NULL,
|
||||
parameter_digest char(64) NOT NULL,
|
||||
model_request_digest varchar(71) NOT NULL,
|
||||
admitted_at_ms bigint NOT NULL,
|
||||
receipt_digest char(64) NOT NULL UNIQUE,
|
||||
plan_json jsonb NOT NULL,
|
||||
receipt_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_admission_run_fk
|
||||
FOREIGN KEY (run_id) REFERENCES "ql3"."runs" (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_admission_step_fk
|
||||
FOREIGN KEY (run_id, step_run_id)
|
||||
REFERENCES "ql3"."step_runs" (run_id, id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_admission_publication_fk
|
||||
FOREIGN KEY (publication_digest)
|
||||
REFERENCES "ql3"."plugin_package_automation_publications" (
|
||||
publication_digest
|
||||
) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_admission_identity_check CHECK (
|
||||
request_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
invocation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$' AND
|
||||
step_run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
package_name ~ '^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$' AND
|
||||
installation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
prompt_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
generation BETWEEN 1 AND 2147483647 AND admitted_at_ms >= 0
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_admission_digest_check CHECK (
|
||||
plan_digest ~ '^[0-9a-f]{64}$' AND
|
||||
lock_digest ~ '^[0-9a-f]{64}$' AND
|
||||
generation_digest ~ '^[0-9a-f]{64}$' AND
|
||||
materialized_revision_digest ~ '^[0-9a-f]{64}$' AND
|
||||
publication_digest ~ '^[0-9a-f]{64}$' AND
|
||||
prompt_definition_digest ~ '^[0-9a-f]{64}$' AND
|
||||
parameter_digest ~ '^[0-9a-f]{64}$' AND
|
||||
model_request_digest ~ '^sha256:[0-9a-f]{64}$' AND
|
||||
receipt_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_admission_json_check CHECK (
|
||||
jsonb_typeof(plan_json) = 'object' AND
|
||||
octet_length(plan_json::text) BETWEEN 2 AND 32768 AND
|
||||
plan_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/plugin-package-prompt-execution-plan@v1',
|
||||
'requestId', request_id, 'invocationId', invocation_id,
|
||||
'planDigest', plan_digest, 'runId', run_id, 'stepRunId', step_run_id,
|
||||
'parameterDigest', parameter_digest,
|
||||
'modelRequestDigest', model_request_digest,
|
||||
'plannedAtMs', admitted_at_ms
|
||||
) AND
|
||||
plan_json -> 'target' @> jsonb_build_object(
|
||||
'projectId', project_id, 'packageName', package_name,
|
||||
'installationId', installation_id, 'lockDigest', lock_digest,
|
||||
'generation', generation, 'generationDigest', generation_digest,
|
||||
'materializedRevisionDigest', materialized_revision_digest,
|
||||
'publicationDigest', publication_digest, 'promptId', prompt_id,
|
||||
'promptDefinitionDigest', prompt_definition_digest
|
||||
) AND
|
||||
jsonb_typeof(receipt_json) = 'object' AND
|
||||
octet_length(receipt_json::text) BETWEEN 2 AND 16384 AND
|
||||
receipt_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/plugin-package-prompt-admission-receipt@v1',
|
||||
'requestId', request_id, 'invocationId', invocation_id,
|
||||
'planDigest', plan_digest, 'runId', run_id, 'stepRunId', step_run_id,
|
||||
'publicationDigest', publication_digest, 'promptId', prompt_id,
|
||||
'admittedAtMs', admitted_at_ms, 'receiptDigest', receipt_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresPluginPackagePromptAdmissionMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_ADMISSION_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_ADMISSION_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_ai_prompt_admission_run_step_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions"
|
||||
(run_id, step_run_id)`,
|
||||
`CREATE INDEX ql3_ai_prompt_admission_target_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions"
|
||||
(project_id, package_name, admitted_at_ms, request_id)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions"
|
||||
FROM PUBLIC`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions"
|
||||
TO ql3_runtime`,
|
||||
`CREATE FUNCTION
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."plugin_package_prompt_admission_snapshot"(
|
||||
p_project_id varchar,
|
||||
p_package_name varchar,
|
||||
p_publication_digest char(64),
|
||||
p_requested_by_subject_type varchar,
|
||||
p_requested_by_subject_id varchar,
|
||||
p_project_version integer,
|
||||
p_binding_version integer
|
||||
)
|
||||
RETURNS TABLE(publication_json jsonb, revision_json jsonb)
|
||||
LANGUAGE plpgsql
|
||||
VOLATILE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, ql3, ql3_ai
|
||||
AS $ql3_ai$
|
||||
BEGIN
|
||||
IF NOT pg_has_role(session_user, 'ql3_runtime', 'member') THEN
|
||||
RAISE EXCEPTION 'Runtime authority is required'
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END IF;
|
||||
PERFORM 1
|
||||
FROM "ql3"."projects" AS project
|
||||
JOIN "ql3"."project_role_bindings" AS binding
|
||||
ON binding.project_id = project.id
|
||||
AND binding.subject_type = p_requested_by_subject_type
|
||||
AND binding.subject_id = p_requested_by_subject_id
|
||||
AND binding.version = p_binding_version
|
||||
WHERE project.id = p_project_id
|
||||
AND project.status = 'active'
|
||||
AND project.version = p_project_version
|
||||
AND binding.state = 'active'
|
||||
AND binding.version = (
|
||||
SELECT max(candidate.version)
|
||||
FROM "ql3"."project_role_bindings" AS candidate
|
||||
WHERE candidate.project_id = p_project_id
|
||||
AND candidate.subject_type = p_requested_by_subject_type
|
||||
AND candidate.subject_id = p_requested_by_subject_id
|
||||
)
|
||||
FOR SHARE OF project, binding;
|
||||
IF NOT FOUND THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
IF NOT "ql3"."plugin_package_automation_start_allowed"(
|
||||
p_project_id, p_package_name, p_publication_digest
|
||||
) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
RETURN QUERY
|
||||
SELECT publication.publication_json, revision.revision_json
|
||||
FROM "ql3"."plugin_package_automation_publications" AS publication
|
||||
JOIN "ql3"."plugin_package_materialized_revisions" AS revision
|
||||
ON revision.generation_digest = publication.generation_digest
|
||||
AND revision.revision_digest =
|
||||
publication.materialized_revision_digest
|
||||
WHERE publication.project_id = p_project_id
|
||||
AND publication.package_name = p_package_name
|
||||
AND publication.publication_digest = p_publication_digest
|
||||
AND publication.state = 'active'
|
||||
FOR SHARE OF publication, revision;
|
||||
END
|
||||
$ql3_ai$`,
|
||||
`REVOKE ALL ON FUNCTION
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."plugin_package_prompt_admission_snapshot"(
|
||||
varchar, varchar, char(64), varchar, varchar, integer, integer
|
||||
)
|
||||
FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager,
|
||||
ql3_package_executor, ql3_worker_ingress`,
|
||||
`GRANT EXECUTE ON FUNCTION
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."plugin_package_prompt_admission_snapshot"(
|
||||
varchar, varchar, char(64), varchar, varchar, integer, integer
|
||||
)
|
||||
TO ql3_runtime`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const POSTGRES_PLUGIN_PACKAGE_PROMPT_FINALIZATION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_finalizations" (
|
||||
request_id varchar(128) PRIMARY KEY,
|
||||
invocation_id varchar(128) NOT NULL UNIQUE,
|
||||
plan_digest char(64) NOT NULL UNIQUE,
|
||||
run_id varchar(36) NOT NULL UNIQUE,
|
||||
step_run_id varchar(128) NOT NULL UNIQUE,
|
||||
terminal_evidence_kind varchar(16) NOT NULL,
|
||||
terminal_evidence_digest char(64) NOT NULL UNIQUE,
|
||||
final_step_run_digest char(64) NOT NULL,
|
||||
run_status varchar(16) NOT NULL,
|
||||
event_id varchar(36) NOT NULL UNIQUE,
|
||||
final_run_version integer NOT NULL,
|
||||
final_run_event_sequence integer NOT NULL,
|
||||
finalized_at_ms bigint NOT NULL,
|
||||
receipt_digest char(64) NOT NULL UNIQUE,
|
||||
receipt_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_admission_fk
|
||||
FOREIGN KEY (request_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions"
|
||||
(request_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_invocation_fk
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions"
|
||||
(invocation_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_plan_fk
|
||||
FOREIGN KEY (plan_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions"
|
||||
(plan_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_run_fk
|
||||
FOREIGN KEY (run_id) REFERENCES "ql3"."runs" (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_step_fk
|
||||
FOREIGN KEY (run_id, step_run_id)
|
||||
REFERENCES "ql3"."step_runs" (run_id, id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_event_fk
|
||||
FOREIGN KEY (event_id) REFERENCES "ql3"."run_events" (id)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_identity_check CHECK (
|
||||
request_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
invocation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$' AND
|
||||
step_run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
event_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$' AND
|
||||
terminal_evidence_kind IN ('completion', 'resolution') AND
|
||||
run_status IN ('succeeded', 'failed', 'cancelled', 'timed_out') AND
|
||||
final_run_version BETWEEN 3 AND 2147483647 AND
|
||||
final_run_event_sequence = final_run_version AND finalized_at_ms >= 0
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_finalization_digest_check CHECK (
|
||||
plan_digest ~ '^[0-9a-f]{64}$' AND
|
||||
terminal_evidence_digest ~ '^[0-9a-f]{64}$' AND
|
||||
final_step_run_digest ~ '^[0-9a-f]{64}$' AND
|
||||
receipt_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_finalization_json_check CHECK (
|
||||
jsonb_typeof(receipt_json) = 'object' AND
|
||||
octet_length(receipt_json::text) BETWEEN 2 AND 16384 AND
|
||||
receipt_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/plugin-package-prompt-finalization-receipt@v1',
|
||||
'requestId', request_id, 'invocationId', invocation_id,
|
||||
'planDigest', plan_digest, 'runId', run_id, 'stepRunId', step_run_id,
|
||||
'terminalEvidenceKind', terminal_evidence_kind,
|
||||
'terminalEvidenceDigest', terminal_evidence_digest,
|
||||
'finalStepRunDigest', final_step_run_digest, 'runStatus', run_status,
|
||||
'eventId', event_id, 'finalRunVersion', final_run_version,
|
||||
'finalRunEventSequence', final_run_event_sequence,
|
||||
'finalizedAtMs', finalized_at_ms, 'receiptDigest', receipt_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresPluginPackagePromptFinalizationMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_FINALIZATION_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_FINALIZATION_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_ai_prompt_finalization_run_step_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_finalizations"
|
||||
(run_id, step_run_id)`,
|
||||
`CREATE INDEX ql3_ai_prompt_finalization_status_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_finalizations"
|
||||
(run_status, finalized_at_ms, request_id)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_finalizations"
|
||||
FROM PUBLIC`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_finalizations"
|
||||
TO ql3_runtime`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."${POSTGRES_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE}"
|
||||
TO ql3_runtime`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifacts" (
|
||||
artifact_id varchar(128) PRIMARY KEY,
|
||||
project_id varchar(128) NOT NULL,
|
||||
run_id varchar(36) NOT NULL,
|
||||
step_run_id varchar(128) NOT NULL,
|
||||
invocation_id varchar(128) NOT NULL UNIQUE,
|
||||
requested_by_type varchar(32) NOT NULL,
|
||||
requested_by_id varchar(128) NOT NULL,
|
||||
provider varchar(256) NOT NULL,
|
||||
model varchar(256) NOT NULL,
|
||||
content_digest char(64) NOT NULL,
|
||||
output_bytes integer NOT NULL,
|
||||
retention_policy_revision varchar(128) NOT NULL,
|
||||
retention_ms bigint NOT NULL,
|
||||
retention_policy_digest char(64) NOT NULL,
|
||||
retention_eligible_at_ms bigint NOT NULL,
|
||||
key_id varchar(128) NOT NULL,
|
||||
algorithm varchar(16) NOT NULL,
|
||||
plaintext_bytes integer NOT NULL,
|
||||
sealed_at_ms bigint NOT NULL,
|
||||
artifact_digest char(64) NOT NULL UNIQUE,
|
||||
artifact_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_admission_fk
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions"
|
||||
(invocation_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_start_fk
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts"
|
||||
(invocation_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_step_fk
|
||||
FOREIGN KEY (run_id, step_run_id)
|
||||
REFERENCES "ql3"."step_runs" (run_id, id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_identity_check CHECK (
|
||||
artifact_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$' AND
|
||||
step_run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
invocation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
length(requested_by_type) BETWEEN 1 AND 32 AND
|
||||
requested_by_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
length(provider) BETWEEN 1 AND 256 AND
|
||||
length(model) BETWEEN 1 AND 256 AND
|
||||
retention_policy_revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' AND
|
||||
algorithm = 'aes-256-gcm'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_value_check CHECK (
|
||||
output_bytes BETWEEN 0 AND 1048576 AND
|
||||
plaintext_bytes BETWEEN 1 AND 1052672 AND
|
||||
retention_ms BETWEEN 3600000 AND 31536000000 AND
|
||||
sealed_at_ms >= 0 AND
|
||||
retention_eligible_at_ms = sealed_at_ms + retention_ms AND
|
||||
content_digest ~ '^[0-9a-f]{64}$' AND
|
||||
retention_policy_digest ~ '^[0-9a-f]{64}$' AND
|
||||
artifact_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_json_check CHECK (
|
||||
jsonb_typeof(artifact_json) = 'object' AND
|
||||
octet_length(artifact_json::text) BETWEEN 2 AND 1572864 AND
|
||||
artifact_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/plugin-package-prompt-output-artifact@v1',
|
||||
'artifactId', artifact_id, 'projectId', project_id,
|
||||
'runId', run_id, 'stepRunId', step_run_id,
|
||||
'invocationId', invocation_id, 'provider', provider, 'model', model,
|
||||
'contentDigest', content_digest, 'outputBytes', output_bytes,
|
||||
'retentionPolicyDigest', retention_policy_digest,
|
||||
'retentionEligibleAtMs', retention_eligible_at_ms,
|
||||
'keyId', key_id, 'algorithm', algorithm,
|
||||
'plaintextBytes', plaintext_bytes, 'sealedAtMs', sealed_at_ms,
|
||||
'artifactDigest', artifact_digest
|
||||
) AND
|
||||
artifact_json -> 'requestedBy' @> jsonb_build_object(
|
||||
'type', requested_by_type, 'id', requested_by_id
|
||||
) AND
|
||||
artifact_json -> 'retentionPolicy' @> jsonb_build_object(
|
||||
'revision', retention_policy_revision, 'retentionMs', retention_ms
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresPluginPackagePromptOutputArtifactMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_prompt_output_artifact_retention_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifacts"
|
||||
(retention_eligible_at_ms, artifact_id)`,
|
||||
`CREATE INDEX ql3_ai_prompt_output_artifact_project_run_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifacts"
|
||||
(project_id, run_id, artifact_id)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifacts"
|
||||
FROM PUBLIC`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifacts"
|
||||
TO ql3_runtime`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifact_tombstones" (
|
||||
artifact_id varchar(128) PRIMARY KEY,
|
||||
project_id varchar(128) NOT NULL,
|
||||
run_id varchar(36) NOT NULL,
|
||||
step_run_id varchar(128) NOT NULL,
|
||||
invocation_id varchar(128) NOT NULL UNIQUE,
|
||||
artifact_digest char(64) NOT NULL UNIQUE,
|
||||
retention_policy_digest char(64) NOT NULL,
|
||||
retention_eligible_at_ms bigint NOT NULL,
|
||||
key_id varchar(128) NOT NULL,
|
||||
tombstoned_at_ms bigint NOT NULL,
|
||||
tombstone_digest char(64) NOT NULL UNIQUE,
|
||||
tombstone_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_admission_fk
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions"
|
||||
(invocation_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_start_fk
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts"
|
||||
(invocation_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_step_fk
|
||||
FOREIGN KEY (run_id, step_run_id)
|
||||
REFERENCES "ql3"."step_runs" (run_id, id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_identity_check CHECK (
|
||||
artifact_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$' AND
|
||||
step_run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
invocation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_value_check CHECK (
|
||||
retention_eligible_at_ms >= 0 AND
|
||||
tombstoned_at_ms >= retention_eligible_at_ms AND
|
||||
artifact_digest ~ '^[0-9a-f]{64}$' AND
|
||||
retention_policy_digest ~ '^[0-9a-f]{64}$' AND
|
||||
tombstone_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_json_check CHECK (
|
||||
jsonb_typeof(tombstone_json) = 'object' AND
|
||||
octet_length(tombstone_json::text) BETWEEN 2 AND 8192 AND
|
||||
tombstone_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/plugin-package-prompt-output-artifact-tombstone@v1',
|
||||
'tombstonedAtMs', tombstoned_at_ms,
|
||||
'tombstoneDigest', tombstone_digest
|
||||
) AND
|
||||
tombstone_json -> 'reference' @> jsonb_build_object(
|
||||
'artifactId', artifact_id, 'projectId', project_id,
|
||||
'runId', run_id, 'stepRunId', step_run_id,
|
||||
'invocationId', invocation_id, 'artifactDigest', artifact_digest,
|
||||
'retentionPolicyDigest', retention_policy_digest,
|
||||
'retentionEligibleAtMs', retention_eligible_at_ms, 'keyId', key_id
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresPluginPackagePromptOutputTombstoneMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_prompt_output_tombstone_time_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifact_tombstones"
|
||||
(tombstoned_at_ms, artifact_id)`,
|
||||
`CREATE INDEX ql3_ai_prompt_output_tombstone_project_run_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifact_tombstones"
|
||||
(project_id, run_id, artifact_id)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifact_tombstones"
|
||||
FROM PUBLIC`,
|
||||
`DO $$
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'GRANT CONNECT ON DATABASE %I TO ql3_ai_maintenance',
|
||||
current_database()
|
||||
);
|
||||
END
|
||||
$$`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifact_tombstones"
|
||||
TO ql3_runtime`,
|
||||
`GRANT USAGE ON SCHEMA "${POSTGRES_MODEL_INVOCATION_SCHEMA}"
|
||||
TO ql3_ai_maintenance`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."${POSTGRES_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE}",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_admissions",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_finalizations"
|
||||
TO ql3_ai_maintenance`,
|
||||
`GRANT SELECT, DELETE ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifacts"
|
||||
TO ql3_ai_maintenance`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_artifact_tombstones"
|
||||
TO ql3_ai_maintenance`,
|
||||
`GRANT USAGE ON SCHEMA "ql3" TO ql3_ai_maintenance`,
|
||||
`GRANT SELECT ON TABLE "ql3"."runs", "ql3"."step_runs"
|
||||
TO ql3_ai_maintenance`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_PREPARATION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_retirement_preparations" (
|
||||
key_id varchar(128) PRIMARY KEY,
|
||||
retirement_id varchar(128) NOT NULL UNIQUE,
|
||||
request_id varchar(128) NOT NULL UNIQUE,
|
||||
mutation_id varchar(128) NOT NULL UNIQUE,
|
||||
catalog_digest char(64) NOT NULL,
|
||||
material_proof char(64) NOT NULL,
|
||||
prepared_at_ms bigint NOT NULL,
|
||||
preparation_digest char(64) NOT NULL UNIQUE,
|
||||
preparation_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_preparation_fk_uidx
|
||||
UNIQUE (key_id, preparation_digest),
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_preparation_identity_check CHECK (
|
||||
key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' AND
|
||||
retirement_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
request_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
mutation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_preparation_value_check CHECK (
|
||||
prepared_at_ms >= 0 AND
|
||||
catalog_digest ~ '^[0-9a-f]{64}$' AND
|
||||
material_proof ~ '^[0-9a-f]{64}$' AND
|
||||
preparation_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_preparation_json_check CHECK (
|
||||
jsonb_typeof(preparation_json) = 'object' AND
|
||||
octet_length(preparation_json::text) BETWEEN 2 AND 8192 AND
|
||||
preparation_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/plugin-package-prompt-output-key-retirement-preparation@v1',
|
||||
'keyId', key_id, 'retirementId', retirement_id,
|
||||
'requestId', request_id, 'mutationId', mutation_id,
|
||||
'catalogDigest', catalog_digest, 'materialProof', material_proof,
|
||||
'preparedAtMs', prepared_at_ms,
|
||||
'preparationDigest', preparation_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_COMPLETION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_retirement_completions" (
|
||||
key_id varchar(128) PRIMARY KEY,
|
||||
retirement_id varchar(128) NOT NULL UNIQUE,
|
||||
request_id varchar(128) NOT NULL UNIQUE,
|
||||
mutation_id varchar(128) NOT NULL UNIQUE,
|
||||
preparation_digest char(64) NOT NULL UNIQUE,
|
||||
retired_catalog_digest char(64) NOT NULL,
|
||||
absence_proof char(64) NOT NULL,
|
||||
completed_at_ms bigint NOT NULL,
|
||||
completion_digest char(64) NOT NULL UNIQUE,
|
||||
completion_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_completion_preparation_fk
|
||||
FOREIGN KEY (key_id, preparation_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_retirement_preparations"
|
||||
(key_id, preparation_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_completion_identity_check CHECK (
|
||||
key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' AND
|
||||
retirement_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
request_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
mutation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_completion_value_check CHECK (
|
||||
completed_at_ms >= 0 AND
|
||||
preparation_digest ~ '^[0-9a-f]{64}$' AND
|
||||
retired_catalog_digest ~ '^[0-9a-f]{64}$' AND
|
||||
absence_proof ~ '^[0-9a-f]{64}$' AND
|
||||
completion_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_completion_json_check CHECK (
|
||||
jsonb_typeof(completion_json) = 'object' AND
|
||||
octet_length(completion_json::text) BETWEEN 2 AND 8192 AND
|
||||
completion_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/plugin-package-prompt-output-key-retirement-completion@v1',
|
||||
'keyId', key_id, 'retirementId', retirement_id,
|
||||
'requestId', request_id, 'mutationId', mutation_id,
|
||||
'preparationDigest', preparation_digest,
|
||||
'retiredCatalogDigest', retired_catalog_digest,
|
||||
'absenceProof', absence_proof, 'completedAtMs', completed_at_ms,
|
||||
'completionDigest', completion_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresPluginPackagePromptOutputKeyRetirementMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_PREPARATION_TABLE_SQL,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_COMPLETION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_prompt_output_key_retirement_completion_time_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_retirement_completions"
|
||||
(completed_at_ms, key_id)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_retirement_preparations",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_retirement_completions"
|
||||
FROM PUBLIC`,
|
||||
`GRANT SELECT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_retirement_preparations"
|
||||
TO ql3_runtime`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_retirement_preparations",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_retirement_completions"
|
||||
TO ql3_ai_maintenance`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_ROTATION_PREPARATION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_rotation_preparations" (
|
||||
rotation_id varchar(128) PRIMARY KEY,
|
||||
request_id varchar(128) NOT NULL UNIQUE,
|
||||
mutation_id varchar(128) NOT NULL UNIQUE,
|
||||
expected_secret_uid varchar(128) NOT NULL,
|
||||
expected_active_key_id varchar(128) NOT NULL,
|
||||
expected_catalog_digest char(64) NOT NULL,
|
||||
new_key_id varchar(128) NOT NULL UNIQUE,
|
||||
material_proof char(64) NOT NULL,
|
||||
prepared_at_ms bigint NOT NULL,
|
||||
preparation_digest char(64) NOT NULL UNIQUE,
|
||||
preparation_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_key_rotation_preparation_fk_uidx
|
||||
UNIQUE (rotation_id, preparation_digest),
|
||||
CONSTRAINT ql3_ai_prompt_key_rotation_source_uidx
|
||||
UNIQUE (expected_secret_uid, expected_catalog_digest),
|
||||
CONSTRAINT ql3_ai_prompt_key_rotation_preparation_identity_check CHECK (
|
||||
rotation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
request_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
mutation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
expected_secret_uid ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' AND
|
||||
expected_active_key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' AND
|
||||
new_key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' AND
|
||||
expected_active_key_id <> new_key_id
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_key_rotation_preparation_value_check CHECK (
|
||||
prepared_at_ms >= 0 AND
|
||||
expected_catalog_digest ~ '^[0-9a-f]{64}$' AND
|
||||
material_proof ~ '^[0-9a-f]{64}$' AND
|
||||
preparation_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_key_rotation_preparation_json_check CHECK (
|
||||
jsonb_typeof(preparation_json) = 'object' AND
|
||||
octet_length(preparation_json::text) BETWEEN 2 AND 8192 AND
|
||||
preparation_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/plugin-package-prompt-output-key-rotation-preparation@v1',
|
||||
'rotationId', rotation_id, 'requestId', request_id,
|
||||
'mutationId', mutation_id, 'expectedSecretUid', expected_secret_uid,
|
||||
'expectedActiveKeyId', expected_active_key_id,
|
||||
'expectedCatalogDigest', expected_catalog_digest,
|
||||
'newKeyId', new_key_id, 'materialProof', material_proof,
|
||||
'preparedAtMs', prepared_at_ms, 'preparationDigest', preparation_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_ROTATION_COMPLETION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_rotation_completions" (
|
||||
rotation_id varchar(128) PRIMARY KEY,
|
||||
request_id varchar(128) NOT NULL UNIQUE,
|
||||
mutation_id varchar(128) NOT NULL UNIQUE,
|
||||
preparation_digest char(64) NOT NULL UNIQUE,
|
||||
generation bigint NOT NULL,
|
||||
previous_active_key_id varchar(128) NOT NULL,
|
||||
active_key_id varchar(128) NOT NULL,
|
||||
catalog_digest char(64) NOT NULL,
|
||||
material_proof char(64) NOT NULL,
|
||||
completed_at_ms bigint NOT NULL,
|
||||
completion_digest char(64) NOT NULL UNIQUE,
|
||||
completion_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_key_rotation_completion_preparation_fk
|
||||
FOREIGN KEY (rotation_id, preparation_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_rotation_preparations"
|
||||
(rotation_id, preparation_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_key_rotation_completion_identity_check CHECK (
|
||||
rotation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
request_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
mutation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
previous_active_key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' AND
|
||||
active_key_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' AND
|
||||
previous_active_key_id <> active_key_id
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_key_rotation_completion_value_check CHECK (
|
||||
generation BETWEEN 2 AND 9007199254740991 AND completed_at_ms >= 0 AND
|
||||
preparation_digest ~ '^[0-9a-f]{64}$' AND
|
||||
catalog_digest ~ '^[0-9a-f]{64}$' AND
|
||||
material_proof ~ '^[0-9a-f]{64}$' AND
|
||||
completion_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_key_rotation_completion_json_check CHECK (
|
||||
jsonb_typeof(completion_json) = 'object' AND
|
||||
octet_length(completion_json::text) BETWEEN 2 AND 8192 AND
|
||||
completion_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/plugin-package-prompt-output-key-rotation-completion@v1',
|
||||
'rotationId', rotation_id, 'requestId', request_id,
|
||||
'mutationId', mutation_id, 'preparationDigest', preparation_digest,
|
||||
'generation', generation, 'previousActiveKeyId', previous_active_key_id,
|
||||
'activeKeyId', active_key_id, 'catalogDigest', catalog_digest,
|
||||
'materialProof', material_proof, 'completedAtMs', completed_at_ms,
|
||||
'completionDigest', completion_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresPluginPackagePromptOutputKeyRotationMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_ROTATION_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_ROTATION_PREPARATION_TABLE_SQL,
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_ROTATION_COMPLETION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_prompt_key_rotation_completion_time_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_rotation_completions"
|
||||
(completed_at_ms, rotation_id)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_rotation_preparations",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_rotation_completions"
|
||||
FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager,
|
||||
ql3_package_executor, ql3_automation_manager, ql3_worker_ingress,
|
||||
ql3_worker_credential_manager, ql3_worker_credential_executor,
|
||||
ql3_ai_credential_manager, ql3_ai_credential_tester`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_rotation_preparations",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_prompt_output_key_rotation_completions"
|
||||
TO ql3_ai_maintenance`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const postgresPluginPackagePromptProductAuthorizationMigration =
|
||||
defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_PLUGIN_PACKAGE_PROMPT_PRODUCT_AUTHORIZATION_MIGRATION_ID,
|
||||
[
|
||||
`CREATE FUNCTION
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."plugin_package_prompt_authorize_admission"(
|
||||
p_credential_id varchar,
|
||||
p_credential_version integer,
|
||||
p_project_id varchar,
|
||||
p_subject_type varchar,
|
||||
p_subject_id varchar,
|
||||
p_project_version integer,
|
||||
p_binding_version integer,
|
||||
p_audit_event_id uuid,
|
||||
p_request_id varchar,
|
||||
p_planned_at_ms bigint,
|
||||
p_replay boolean
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
VOLATILE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, ql3, ql3_ai
|
||||
AS $ql3_ai$
|
||||
BEGIN
|
||||
IF NOT pg_has_role(session_user, 'ql3_runtime', 'member') THEN
|
||||
RAISE EXCEPTION 'Runtime authority is required'
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END IF;
|
||||
PERFORM pg_advisory_xact_lock(
|
||||
hashtextextended('ql3-api-credential:' || p_credential_id, 0)
|
||||
);
|
||||
PERFORM pg_advisory_xact_lock(
|
||||
hashtextextended(
|
||||
'ql3-identity:' || p_subject_type || ':' || p_subject_id,
|
||||
0
|
||||
)
|
||||
);
|
||||
PERFORM 1
|
||||
FROM "ql3"."api_credentials" AS credential
|
||||
JOIN "ql3"."identity_subjects" AS identity
|
||||
ON identity.subject_type = credential.subject_type
|
||||
AND identity.subject_id = credential.subject_id
|
||||
WHERE credential.credential_id = p_credential_id
|
||||
AND credential.version = p_credential_version
|
||||
AND credential.version = (
|
||||
SELECT max(candidate.version)
|
||||
FROM "ql3"."api_credentials" AS candidate
|
||||
WHERE candidate.credential_id = p_credential_id
|
||||
)
|
||||
AND credential.state = 'active'
|
||||
AND credential.subject_type = p_subject_type
|
||||
AND credential.subject_id = p_subject_id
|
||||
AND credential.not_before_at_ms <=
|
||||
floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AND credential.expires_at_ms >
|
||||
floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AND identity.status = 'active'
|
||||
FOR SHARE OF credential, identity;
|
||||
IF NOT FOUND THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
PERFORM 1
|
||||
FROM "ql3"."projects" AS project
|
||||
JOIN "ql3"."project_role_bindings" AS binding
|
||||
ON binding.project_id = project.id
|
||||
AND binding.subject_type = p_subject_type
|
||||
AND binding.subject_id = p_subject_id
|
||||
AND binding.version = p_binding_version
|
||||
WHERE project.id = p_project_id
|
||||
AND project.status = 'active'
|
||||
AND project.version = p_project_version
|
||||
AND binding.state = 'active'
|
||||
AND binding.role IN ('owner', 'admin', 'operator')
|
||||
AND p_subject_type <> 'agent'
|
||||
AND binding.version = (
|
||||
SELECT max(candidate.version)
|
||||
FROM "ql3"."project_role_bindings" AS candidate
|
||||
WHERE candidate.project_id = p_project_id
|
||||
AND candidate.subject_type = p_subject_type
|
||||
AND candidate.subject_id = p_subject_id
|
||||
)
|
||||
FOR SHARE OF project, binding;
|
||||
IF NOT FOUND THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
IF NOT p_replay THEN
|
||||
INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id, request_id, operation_id, project_id,
|
||||
subject_type, subject_id, authentication_id, outcome, reasons,
|
||||
project_version, binding_version, occurred_at_ms
|
||||
) VALUES (
|
||||
p_audit_event_id, p_request_id, 'prompt.execute', p_project_id,
|
||||
p_subject_type, p_subject_id,
|
||||
'api_credential:' || p_credential_id || ':' || p_credential_version,
|
||||
'allowed', '["project_policy_allowed"]'::jsonb,
|
||||
p_project_version, p_binding_version, p_planned_at_ms
|
||||
);
|
||||
END IF;
|
||||
RETURN true;
|
||||
END
|
||||
$ql3_ai$`,
|
||||
`REVOKE ALL ON FUNCTION
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."plugin_package_prompt_authorize_admission"(
|
||||
varchar, integer, varchar, varchar, varchar, integer, integer,
|
||||
uuid, varchar, bigint, boolean
|
||||
)
|
||||
FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager,
|
||||
ql3_package_executor, ql3_worker_ingress`,
|
||||
`GRANT EXECUTE ON FUNCTION
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."plugin_package_prompt_authorize_admission"(
|
||||
varchar, integer, varchar, varchar, varchar, integer, integer,
|
||||
uuid, varchar, bigint, boolean
|
||||
)
|
||||
TO ql3_runtime`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
export const postgresPromptBaseMigrations = Object.freeze([
|
||||
postgresPluginPackagePromptAdmissionMigration,
|
||||
postgresPluginPackagePromptFinalizationMigration,
|
||||
postgresPluginPackagePromptOutputArtifactMigration,
|
||||
postgresPluginPackagePromptOutputTombstoneMigration,
|
||||
postgresPluginPackagePromptOutputKeyRetirementMigration,
|
||||
]);
|
||||
|
||||
export const postgresPromptExtensionMigrations = Object.freeze([
|
||||
postgresPluginPackagePromptOutputKeyRotationMigration,
|
||||
postgresPluginPackagePromptProductAuthorizationMigration,
|
||||
]);
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { PostgresQueryable } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
POSTGRES_MODEL_INVOCATION_USAGE_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_QUOTA_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_PRICING_MIGRATION_ID,
|
||||
POSTGRES_MODEL_INVOCATION_SCHEMA,
|
||||
} from '../identities';
|
||||
|
||||
import { defineSqlMigration } from '../shared';
|
||||
|
||||
const POSTGRES_USAGE_LEDGER_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_usage_ledger" (
|
||||
invocation_id varchar(128) PRIMARY KEY,
|
||||
project_id varchar(128) NOT NULL,
|
||||
run_id varchar(36) NOT NULL,
|
||||
step_run_id varchar(128) NOT NULL,
|
||||
trace_id varchar(128) NOT NULL,
|
||||
provider varchar(128) NOT NULL,
|
||||
model varchar(128) NOT NULL,
|
||||
policy_revision varchar(128) NOT NULL,
|
||||
completion_digest char(64) NOT NULL,
|
||||
outcome varchar(32) NOT NULL,
|
||||
settled_at_ms bigint NOT NULL,
|
||||
input_bytes integer NOT NULL,
|
||||
output_bytes integer NOT NULL,
|
||||
input_tokens bigint NOT NULL,
|
||||
output_tokens bigint NOT NULL,
|
||||
total_tokens bigint NOT NULL,
|
||||
cost_micros bigint,
|
||||
ledger_digest char(64) NOT NULL,
|
||||
record_json jsonb NOT NULL,
|
||||
FOREIGN KEY (invocation_id, completion_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions"
|
||||
(invocation_id, completion_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_usage_identity_check CHECK (
|
||||
invocation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' AND
|
||||
step_run_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
trace_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
model ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
policy_revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_usage_value_check CHECK (
|
||||
outcome IN ('succeeded', 'failed', 'timed_out', 'outcome_unknown') AND
|
||||
settled_at_ms >= 0 AND
|
||||
input_bytes BETWEEN 1 AND 262144 AND
|
||||
output_bytes BETWEEN 0 AND 1048576 AND
|
||||
input_tokens >= 0 AND output_tokens >= 0 AND
|
||||
total_tokens = input_tokens + output_tokens AND
|
||||
(cost_micros IS NULL OR cost_micros >= 0) AND
|
||||
completion_digest ~ '^[0-9a-f]{64}$' AND
|
||||
ledger_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_usage_json_check CHECK (
|
||||
jsonb_typeof(record_json) = 'object' AND
|
||||
octet_length(record_json::text) BETWEEN 2 AND 24576 AND
|
||||
record_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-invocation-usage-ledger@v1',
|
||||
'invocationId', invocation_id,
|
||||
'projectId', project_id,
|
||||
'runId', run_id,
|
||||
'stepRunId', step_run_id,
|
||||
'traceId', trace_id,
|
||||
'provider', provider,
|
||||
'model', model,
|
||||
'policyRevision', policy_revision,
|
||||
'completionDigest', completion_digest,
|
||||
'outcome', outcome,
|
||||
'settledAtMs', settled_at_ms,
|
||||
'inputBytes', input_bytes,
|
||||
'outputBytes', output_bytes,
|
||||
'inputTokens', input_tokens,
|
||||
'outputTokens', output_tokens,
|
||||
'totalTokens', total_tokens,
|
||||
'costMicros', cost_micros,
|
||||
'ledgerDigest', ledger_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresUsageMigration = defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_MODEL_INVOCATION_USAGE_MIGRATION_ID,
|
||||
[
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_completions_identity_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions"
|
||||
(invocation_id, completion_digest)`,
|
||||
POSTGRES_USAGE_LEDGER_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_invocation_usage_project_time_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_usage_ledger"
|
||||
(project_id, settled_at_ms, invocation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_usage_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_usage_ledger"
|
||||
(ledger_digest)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_usage_ledger"
|
||||
FROM PUBLIC`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_usage_ledger"
|
||||
TO ql3_runtime`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const POSTGRES_QUOTA_RESERVATION_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_reservations" (
|
||||
invocation_id varchar(128) PRIMARY KEY,
|
||||
project_id varchar(128) NOT NULL,
|
||||
model_policy_revision varchar(128) NOT NULL,
|
||||
quota_policy_revision varchar(128) NOT NULL,
|
||||
window_ms bigint NOT NULL,
|
||||
window_start_ms bigint NOT NULL,
|
||||
window_end_ms bigint NOT NULL,
|
||||
max_invocations integer NOT NULL,
|
||||
max_tokens bigint NOT NULL,
|
||||
max_cost_micros bigint,
|
||||
reserved_tokens bigint NOT NULL,
|
||||
reserved_cost_micros bigint,
|
||||
reserved_at_ms bigint NOT NULL,
|
||||
admission_digest char(64) NOT NULL,
|
||||
reservation_digest char(64) NOT NULL,
|
||||
record_json jsonb NOT NULL,
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts"
|
||||
(invocation_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_quota_reservation_value_check CHECK (
|
||||
window_ms IN (60000, 3600000, 86400000) AND
|
||||
window_start_ms >= 0 AND window_end_ms = window_start_ms + window_ms AND
|
||||
reserved_at_ms >= window_start_ms AND reserved_at_ms < window_end_ms AND
|
||||
max_invocations BETWEEN 1 AND 100000 AND
|
||||
max_tokens BETWEEN 1 AND 1000000000000 AND
|
||||
(max_cost_micros IS NULL OR
|
||||
max_cost_micros BETWEEN 0 AND 1000000000000000) AND
|
||||
reserved_tokens BETWEEN 1 AND 1000000000000 AND
|
||||
(reserved_cost_micros IS NULL OR
|
||||
reserved_cost_micros BETWEEN 0 AND 1000000000000000) AND
|
||||
(max_cost_micros IS NULL OR reserved_cost_micros IS NOT NULL) AND
|
||||
admission_digest ~ '^[0-9a-f]{64}$' AND
|
||||
reservation_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_quota_reservation_json_check CHECK (
|
||||
jsonb_typeof(record_json) = 'object' AND
|
||||
octet_length(record_json::text) BETWEEN 2 AND 24576 AND
|
||||
record_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-invocation-quota-reservation@v1',
|
||||
'invocationId', invocation_id,
|
||||
'projectId', project_id,
|
||||
'modelPolicyRevision', model_policy_revision,
|
||||
'quotaPolicyRevision', quota_policy_revision,
|
||||
'windowMs', window_ms,
|
||||
'windowStartMs', window_start_ms,
|
||||
'windowEndMs', window_end_ms,
|
||||
'maxInvocations', max_invocations,
|
||||
'maxTokens', max_tokens,
|
||||
'maxCostMicros', max_cost_micros,
|
||||
'reservedTokens', reserved_tokens,
|
||||
'reservedCostMicros', reserved_cost_micros,
|
||||
'reservedAtMs', reserved_at_ms,
|
||||
'admissionDigest', admission_digest,
|
||||
'reservationDigest', reservation_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const POSTGRES_QUOTA_SETTLEMENT_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_settlements" (
|
||||
invocation_id varchar(128) PRIMARY KEY,
|
||||
project_id varchar(128) NOT NULL,
|
||||
reservation_digest char(64) NOT NULL,
|
||||
completion_digest char(64) NOT NULL,
|
||||
effective_tokens bigint NOT NULL,
|
||||
effective_cost_micros bigint,
|
||||
retained_token_reservation boolean NOT NULL,
|
||||
retained_cost_reservation boolean NOT NULL,
|
||||
settled_at_ms bigint NOT NULL,
|
||||
settlement_digest char(64) NOT NULL,
|
||||
record_json jsonb NOT NULL,
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_reservations"
|
||||
(invocation_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (invocation_id, completion_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions"
|
||||
(invocation_id, completion_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_quota_settlement_value_check CHECK (
|
||||
effective_tokens BETWEEN 0 AND 1000000000000 AND
|
||||
(effective_cost_micros IS NULL OR
|
||||
effective_cost_micros BETWEEN 0 AND 1000000000000000) AND
|
||||
settled_at_ms >= 0 AND
|
||||
reservation_digest ~ '^[0-9a-f]{64}$' AND
|
||||
completion_digest ~ '^[0-9a-f]{64}$' AND
|
||||
settlement_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_quota_settlement_json_check CHECK (
|
||||
jsonb_typeof(record_json) = 'object' AND
|
||||
octet_length(record_json::text) BETWEEN 2 AND 24576 AND
|
||||
record_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-invocation-quota-settlement@v1',
|
||||
'invocationId', invocation_id,
|
||||
'projectId', project_id,
|
||||
'reservationDigest', reservation_digest,
|
||||
'completionDigest', completion_digest,
|
||||
'effectiveTokens', effective_tokens,
|
||||
'effectiveCostMicros', effective_cost_micros,
|
||||
'retainedTokenReservation', retained_token_reservation,
|
||||
'retainedCostReservation', retained_cost_reservation,
|
||||
'settledAtMs', settled_at_ms,
|
||||
'settlementDigest', settlement_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresQuotaMigration = defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_MODEL_INVOCATION_QUOTA_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_QUOTA_RESERVATION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_invocation_quota_reservation_window_idx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_reservations"
|
||||
(project_id, window_start_ms, invocation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_quota_reservation_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_reservations"
|
||||
(reservation_digest)`,
|
||||
POSTGRES_QUOTA_SETTLEMENT_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_quota_settlement_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_settlements"
|
||||
(settlement_digest)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_reservations",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_settlements"
|
||||
FROM PUBLIC`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_reservations",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_settlements"
|
||||
TO ql3_runtime`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
const POSTGRES_PRICE_QUOTE_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_quotes" (
|
||||
invocation_id varchar(128) PRIMARY KEY,
|
||||
project_id varchar(128) NOT NULL,
|
||||
model_policy_revision varchar(128) NOT NULL,
|
||||
provider varchar(128) NOT NULL,
|
||||
model varchar(128) NOT NULL,
|
||||
price_revision varchar(128) NOT NULL,
|
||||
currency varchar(3) NOT NULL,
|
||||
input_micros_per_million_tokens bigint NOT NULL,
|
||||
output_micros_per_million_tokens bigint NOT NULL,
|
||||
max_total_tokens bigint NOT NULL,
|
||||
max_output_tokens bigint NOT NULL,
|
||||
reserved_cost_micros bigint NOT NULL,
|
||||
catalog_digest char(64) NOT NULL,
|
||||
quote_digest char(64) NOT NULL,
|
||||
record_json jsonb NOT NULL,
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts"
|
||||
(invocation_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_price_quote_identity_check CHECK (
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
model_policy_revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
model ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$' AND
|
||||
price_revision ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_price_quote_value_check CHECK (
|
||||
currency = 'USD' AND
|
||||
input_micros_per_million_tokens BETWEEN 0 AND 1000000000000 AND
|
||||
output_micros_per_million_tokens BETWEEN 0 AND 1000000000000 AND
|
||||
max_total_tokens BETWEEN 1 AND 1000000000000 AND
|
||||
max_output_tokens BETWEEN 1 AND max_total_tokens AND
|
||||
reserved_cost_micros BETWEEN 0 AND 1000000000000000 AND
|
||||
catalog_digest ~ '^[0-9a-f]{64}$' AND
|
||||
quote_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_price_quote_json_check CHECK (
|
||||
jsonb_typeof(record_json) = 'object' AND
|
||||
octet_length(record_json::text) BETWEEN 2 AND 24576 AND
|
||||
record_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-invocation-price-quote@v1',
|
||||
'invocationId', invocation_id,
|
||||
'projectId', project_id,
|
||||
'modelPolicyRevision', model_policy_revision,
|
||||
'provider', provider,
|
||||
'model', model,
|
||||
'priceRevision', price_revision,
|
||||
'currency', currency,
|
||||
'inputMicrosPerMillionTokens', input_micros_per_million_tokens,
|
||||
'outputMicrosPerMillionTokens', output_micros_per_million_tokens,
|
||||
'maxTotalTokens', max_total_tokens,
|
||||
'maxOutputTokens', max_output_tokens,
|
||||
'reservedCostMicros', reserved_cost_micros,
|
||||
'catalogDigest', catalog_digest,
|
||||
'quoteDigest', quote_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const POSTGRES_PRICE_SETTLEMENT_TABLE_SQL = `
|
||||
CREATE TABLE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_settlements" (
|
||||
invocation_id varchar(128) PRIMARY KEY,
|
||||
project_id varchar(128) NOT NULL,
|
||||
quote_digest char(64) NOT NULL,
|
||||
completion_digest char(64) NOT NULL,
|
||||
currency varchar(3) NOT NULL,
|
||||
input_tokens bigint NOT NULL,
|
||||
output_tokens bigint NOT NULL,
|
||||
cost_micros bigint NOT NULL,
|
||||
settled_at_ms bigint NOT NULL,
|
||||
settlement_digest char(64) NOT NULL,
|
||||
record_json jsonb NOT NULL,
|
||||
FOREIGN KEY (invocation_id, quote_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_quotes"
|
||||
(invocation_id, quote_digest) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (invocation_id, completion_digest)
|
||||
REFERENCES "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions"
|
||||
(invocation_id, completion_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_price_settlement_value_check CHECK (
|
||||
currency = 'USD' AND
|
||||
input_tokens >= 0 AND output_tokens >= 0 AND
|
||||
cost_micros BETWEEN 0 AND 1000000000000000 AND
|
||||
settled_at_ms >= 0 AND
|
||||
quote_digest ~ '^[0-9a-f]{64}$' AND
|
||||
completion_digest ~ '^[0-9a-f]{64}$' AND
|
||||
settlement_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_price_settlement_json_check CHECK (
|
||||
jsonb_typeof(record_json) = 'object' AND
|
||||
octet_length(record_json::text) BETWEEN 2 AND 24576 AND
|
||||
record_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/model-invocation-price-settlement@v1',
|
||||
'invocationId', invocation_id,
|
||||
'projectId', project_id,
|
||||
'quoteDigest', quote_digest,
|
||||
'completionDigest', completion_digest,
|
||||
'currency', currency,
|
||||
'inputTokens', input_tokens,
|
||||
'outputTokens', output_tokens,
|
||||
'costMicros', cost_micros,
|
||||
'settledAtMs', settled_at_ms,
|
||||
'settlementDigest', settlement_digest
|
||||
)
|
||||
)
|
||||
)`;
|
||||
|
||||
const postgresPricingMigration = defineSqlMigration<PostgresQueryable>(
|
||||
POSTGRES_MODEL_INVOCATION_PRICING_MIGRATION_ID,
|
||||
[
|
||||
POSTGRES_PRICE_QUOTE_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_price_quote_identity_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_quotes"
|
||||
(invocation_id, quote_digest)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_price_quote_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_quotes"
|
||||
(quote_digest)`,
|
||||
POSTGRES_PRICE_SETTLEMENT_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_price_settlement_digest_uidx
|
||||
ON "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_settlements"
|
||||
(settlement_digest)`,
|
||||
`REVOKE ALL ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_quotes",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_settlements"
|
||||
FROM PUBLIC`,
|
||||
`GRANT SELECT, INSERT ON TABLE
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_quotes",
|
||||
"${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_settlements"
|
||||
TO ql3_runtime`,
|
||||
],
|
||||
(context, statement) => context.query(statement).then(() => undefined),
|
||||
);
|
||||
|
||||
export const postgresUsagePricingMigrations = Object.freeze([
|
||||
postgresUsageMigration,
|
||||
postgresQuotaMigration,
|
||||
postgresPricingMigration,
|
||||
]);
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
MigrationStreamHistoryCorruptionError,
|
||||
type MigrationStreamRecord,
|
||||
type MigrationStreamStep,
|
||||
} from '@qinglong/runtime-core/migration-stream';
|
||||
|
||||
export interface HistoryRow extends Record<string, unknown> {
|
||||
readonly migrationId: unknown;
|
||||
readonly streamId: unknown;
|
||||
readonly dialect: unknown;
|
||||
readonly checksum: unknown;
|
||||
readonly appliedAtMs: unknown;
|
||||
}
|
||||
|
||||
function checksum(statements: readonly string[]): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
format: 1,
|
||||
statements,
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function defineSqlMigration<TContext>(
|
||||
id: string,
|
||||
statementsValue: readonly string[],
|
||||
execute: (context: TContext, statement: string) => void | Promise<void>,
|
||||
): MigrationStreamStep<TContext> {
|
||||
const statements = Object.freeze(
|
||||
statementsValue.map((statement) => statement.trim()),
|
||||
);
|
||||
if (
|
||||
statements.length < 1 ||
|
||||
statements.length > 32 ||
|
||||
statements.some(
|
||||
(statement) => statement.length < 1 || statement.length > 64 * 1024,
|
||||
)
|
||||
) {
|
||||
throw new TypeError('ModelInvocation migration statements are invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
id,
|
||||
checksum: checksum(statements),
|
||||
async up(context: TContext): Promise<void> {
|
||||
for (const statement of statements) await execute(context, statement);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function appliedAtMs(value: unknown, migrationId: string): number {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
throw new MigrationStreamHistoryCorruptionError(migrationId);
|
||||
}
|
||||
|
||||
export function historyRecord(
|
||||
row: HistoryRow,
|
||||
expected: Readonly<{
|
||||
migrationIds: readonly string[];
|
||||
streamId: string;
|
||||
dialect: 'sqlite' | 'postgresql';
|
||||
}>,
|
||||
): MigrationStreamRecord {
|
||||
const corruptionId =
|
||||
typeof row.migrationId === 'string'
|
||||
? row.migrationId
|
||||
: expected.migrationIds[0] ?? expected.streamId;
|
||||
if (
|
||||
typeof row.migrationId !== 'string' ||
|
||||
!expected.migrationIds.includes(row.migrationId) ||
|
||||
row.streamId !== expected.streamId ||
|
||||
row.dialect !== expected.dialect ||
|
||||
typeof row.checksum !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(row.checksum)
|
||||
) {
|
||||
throw new MigrationStreamHistoryCorruptionError(corruptionId);
|
||||
}
|
||||
return Object.freeze({
|
||||
migrationId: row.migrationId,
|
||||
streamId: row.streamId,
|
||||
dialect: expected.dialect,
|
||||
checksum: row.checksum,
|
||||
appliedAtMs: appliedAtMs(row.appliedAtMs, row.migrationId),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
MigrationStreamHistoryCorruptionError,
|
||||
auditMigrationStreamHistory,
|
||||
runMigrationStream,
|
||||
type MigrationStreamDefinition,
|
||||
type MigrationStreamRecord,
|
||||
type MigrationStreamStore,
|
||||
type MigrationStreamTransaction,
|
||||
} from '@qinglong/runtime-core/migration-stream';
|
||||
|
||||
import {
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_STREAM_ID,
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_USAGE_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_QUOTA_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_PRICING_MIGRATION_ID,
|
||||
LOCAL_MODEL_PRICE_CATALOG_MIGRATION_ID,
|
||||
LOCAL_MODEL_PRICE_CATALOG_AUTHORIZATION_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_FEATURE_ACTIVATION_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_ADMISSION_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_FINALIZATION_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_MIGRATION_ID,
|
||||
LOCAL_MODEL_PROVIDER_CREDENTIAL_CATALOG_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE,
|
||||
} from './identities';
|
||||
|
||||
import { historyRecord, type HistoryRow } from './shared';
|
||||
import { sqliteCatalogMigrations } from './sqlite/catalog';
|
||||
import type { LocalMigrationContext } from './sqlite/context';
|
||||
import { sqliteCoreMigrations } from './sqlite/core';
|
||||
import { sqliteCredentialMigrations } from './sqlite/credential';
|
||||
import { sqlitePromptMigrations } from './sqlite/prompt';
|
||||
import { sqliteUsagePricingMigrations } from './sqlite/usagePricing';
|
||||
|
||||
const LOCAL_HISTORY_IDENTITY = Object.freeze({
|
||||
migrationIds: Object.freeze([
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_USAGE_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_QUOTA_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_PRICING_MIGRATION_ID,
|
||||
LOCAL_MODEL_PRICE_CATALOG_MIGRATION_ID,
|
||||
LOCAL_MODEL_PRICE_CATALOG_AUTHORIZATION_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_FEATURE_ACTIVATION_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_ADMISSION_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_FINALIZATION_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_MIGRATION_ID,
|
||||
LOCAL_MODEL_PROVIDER_CREDENTIAL_CATALOG_MIGRATION_ID,
|
||||
]),
|
||||
streamId: LOCAL_MODEL_INVOCATION_MIGRATION_STREAM_ID,
|
||||
dialect: 'sqlite' as const,
|
||||
});
|
||||
|
||||
function readLocalHistory(
|
||||
client: DatabaseSync,
|
||||
migrationId?: string,
|
||||
): readonly MigrationStreamRecord[] {
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT
|
||||
migration_id AS "migrationId",
|
||||
stream_id AS "streamId",
|
||||
dialect,
|
||||
checksum,
|
||||
applied_at_ms AS "appliedAtMs"
|
||||
FROM "${LOCAL_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE}"
|
||||
WHERE stream_id = ?
|
||||
${migrationId === undefined ? '' : 'AND migration_id = ?'}
|
||||
ORDER BY migration_id`,
|
||||
)
|
||||
.all(
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_STREAM_ID,
|
||||
...(migrationId === undefined ? [] : [migrationId]),
|
||||
) as unknown as HistoryRow[];
|
||||
return rows.map((row) => historyRecord(row, LOCAL_HISTORY_IDENTITY));
|
||||
}
|
||||
|
||||
class LocalModelInvocationMigrationStore
|
||||
implements MigrationStreamStore<LocalMigrationContext>
|
||||
{
|
||||
constructor(private readonly client: DatabaseSync) {}
|
||||
|
||||
async ensureHistory(): Promise<void> {
|
||||
const required = [
|
||||
'QingLong3SchemaMigrations',
|
||||
'Runs',
|
||||
'RunEvents',
|
||||
'StepRuns',
|
||||
'StepRunMutations',
|
||||
];
|
||||
const rows = this.client
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_schema
|
||||
WHERE type = 'table' AND name IN (${required
|
||||
.map(() => '?')
|
||||
.join(', ')})`,
|
||||
)
|
||||
.all(...required) as unknown as { name?: unknown }[];
|
||||
const names = new Set(
|
||||
rows
|
||||
.map((row) => row.name)
|
||||
.filter((name): name is string => typeof name === 'string'),
|
||||
);
|
||||
if (required.some((name) => !names.has(name))) {
|
||||
throw new TypeError(
|
||||
'Local ModelInvocation feature requires the main SQLite migration stream',
|
||||
);
|
||||
}
|
||||
this.client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "${LOCAL_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE}" (
|
||||
migration_id TEXT PRIMARY KEY,
|
||||
stream_id TEXT NOT NULL
|
||||
CONSTRAINT ql3_ai_migrations_stream_check
|
||||
CHECK (stream_id = '${LOCAL_MODEL_INVOCATION_MIGRATION_STREAM_ID}'),
|
||||
dialect TEXT NOT NULL
|
||||
CONSTRAINT ql3_ai_migrations_dialect_check CHECK (dialect = 'sqlite'),
|
||||
checksum TEXT NOT NULL
|
||||
CONSTRAINT ql3_ai_migrations_checksum_check
|
||||
CHECK (length(checksum) = 64 AND checksum NOT GLOB '*[^0-9a-f]*'),
|
||||
applied_at_ms INTEGER NOT NULL
|
||||
CONSTRAINT ql3_ai_migrations_applied_at_check CHECK (applied_at_ms >= 0)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async listAll(): Promise<readonly MigrationStreamRecord[]> {
|
||||
return readLocalHistory(this.client);
|
||||
}
|
||||
|
||||
async findById(migrationId: string): Promise<MigrationStreamRecord | null> {
|
||||
const rows = readLocalHistory(this.client, migrationId);
|
||||
if (rows.length > 1) {
|
||||
throw new MigrationStreamHistoryCorruptionError(migrationId);
|
||||
}
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
work: (
|
||||
transaction: MigrationStreamTransaction<LocalMigrationContext>,
|
||||
) => Promise<T>,
|
||||
): Promise<T> {
|
||||
this.client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = await work({
|
||||
context: { client: this.client },
|
||||
findById: (migrationId) => this.findById(migrationId),
|
||||
insert: async (record) => {
|
||||
if (
|
||||
record.streamId !== LOCAL_MODEL_INVOCATION_MIGRATION_STREAM_ID ||
|
||||
!LOCAL_HISTORY_IDENTITY.migrationIds.includes(record.migrationId) ||
|
||||
record.dialect !== 'sqlite'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local ModelInvocation migration identity is invalid',
|
||||
);
|
||||
}
|
||||
this.client
|
||||
.prepare(
|
||||
`INSERT INTO "${LOCAL_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE}"
|
||||
(migration_id, stream_id, dialect, checksum, applied_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
record.migrationId,
|
||||
record.streamId,
|
||||
record.dialect,
|
||||
record.checksum,
|
||||
record.appliedAtMs,
|
||||
);
|
||||
},
|
||||
});
|
||||
this.client.exec('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.client.isTransaction) this.client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const localModelInvocationMigrationDefinition: MigrationStreamDefinition<LocalMigrationContext> =
|
||||
Object.freeze({
|
||||
id: LOCAL_MODEL_INVOCATION_MIGRATION_STREAM_ID,
|
||||
dialect: 'sqlite',
|
||||
migrationIdScheme: 'sqlite-numbered',
|
||||
checksumScheme: 'sha256',
|
||||
migrations: Object.freeze([
|
||||
...sqliteCoreMigrations,
|
||||
...sqliteUsagePricingMigrations,
|
||||
...sqliteCatalogMigrations,
|
||||
...sqlitePromptMigrations,
|
||||
...sqliteCredentialMigrations,
|
||||
]),
|
||||
});
|
||||
|
||||
export const LOCAL_MODEL_INVOCATION_MIGRATION_PLAN_DIGEST = createHash('sha256')
|
||||
.update(
|
||||
Buffer.from('qinglong/local-model-invocation-migration-plan@v1\0', 'utf8'),
|
||||
)
|
||||
.update(
|
||||
JSON.stringify(
|
||||
localModelInvocationMigrationDefinition.migrations.map(
|
||||
({ id, checksum: migrationChecksum }) => ({
|
||||
id,
|
||||
checksum: migrationChecksum,
|
||||
}),
|
||||
),
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
.digest('hex');
|
||||
|
||||
export class LocalModelInvocationFeatureNotReadyError extends Error {
|
||||
readonly code = 'LOCAL_MODEL_INVOCATION_FEATURE_NOT_READY';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('The local ModelInvocation feature schema is not ready', options);
|
||||
this.name = 'LocalModelInvocationFeatureNotReadyError';
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_MODEL_INVOCATION_FEATURE_TABLES = Object.freeze([
|
||||
'ModelInvocationCompletions',
|
||||
'ModelInvocationFeatureHead',
|
||||
'ModelInvocationFeatureTransitions',
|
||||
'ModelInvocationPriceQuotes',
|
||||
'ModelInvocationPriceSettlements',
|
||||
'ModelInvocationPromptAdmissions',
|
||||
'ModelInvocationPromptFinalizations',
|
||||
'ModelInvocationPromptOutputArtifactTombstones',
|
||||
'ModelInvocationPromptOutputArtifacts',
|
||||
'ModelInvocationPromptOutputKeyRetirementCompletions',
|
||||
'ModelInvocationPromptOutputKeyRetirementPreparations',
|
||||
'ModelInvocationProviderCredentialAudits',
|
||||
'ModelInvocationProviderCredentialBindings',
|
||||
'ModelInvocationProviderCredentialTransitions',
|
||||
'ModelInvocationQuotaReservations',
|
||||
'ModelInvocationQuotaSettlements',
|
||||
'ModelInvocationResolutions',
|
||||
'ModelInvocationStarts',
|
||||
'ModelInvocationUsageLedger',
|
||||
'ModelPriceCatalogAuthorizations',
|
||||
'ModelPriceCatalogHeads',
|
||||
'ModelPriceCatalogPublications',
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE,
|
||||
]);
|
||||
|
||||
export function assertLocalModelInvocationFeatureReady(
|
||||
client: DatabaseSync,
|
||||
): void {
|
||||
try {
|
||||
if (
|
||||
!client ||
|
||||
typeof client !== 'object' ||
|
||||
!client.isOpen ||
|
||||
typeof client.prepare !== 'function'
|
||||
) {
|
||||
throw new LocalModelInvocationFeatureNotReadyError();
|
||||
}
|
||||
const tables = (
|
||||
client
|
||||
.prepare(
|
||||
`SELECT name
|
||||
FROM sqlite_schema
|
||||
WHERE type = 'table'
|
||||
AND (
|
||||
name = ?
|
||||
OR name LIKE 'ModelInvocation%'
|
||||
OR name LIKE 'ModelPriceCatalog%'
|
||||
)
|
||||
ORDER BY name`,
|
||||
)
|
||||
.all(LOCAL_MODEL_INVOCATION_MIGRATION_HISTORY_TABLE) as {
|
||||
readonly name?: unknown;
|
||||
}[]
|
||||
).map(({ name }) => name);
|
||||
if (
|
||||
tables.length !== LOCAL_MODEL_INVOCATION_FEATURE_TABLES.length ||
|
||||
tables.some(
|
||||
(table, index) =>
|
||||
table !== LOCAL_MODEL_INVOCATION_FEATURE_TABLES[index],
|
||||
)
|
||||
) {
|
||||
throw new LocalModelInvocationFeatureNotReadyError();
|
||||
}
|
||||
const applied = auditMigrationStreamHistory(
|
||||
readLocalHistory(client),
|
||||
localModelInvocationMigrationDefinition,
|
||||
);
|
||||
if (
|
||||
applied.size !== localModelInvocationMigrationDefinition.migrations.length
|
||||
) {
|
||||
throw new LocalModelInvocationFeatureNotReadyError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalModelInvocationFeatureNotReadyError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalModelInvocationFeatureNotReadyError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateLocalModelInvocationFeature(
|
||||
client: DatabaseSync,
|
||||
): Promise<void> {
|
||||
await runMigrationStream({
|
||||
stream: localModelInvocationMigrationDefinition,
|
||||
store: new LocalModelInvocationMigrationStore(client),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import type { LocalMigrationContext } from './context';
|
||||
|
||||
import {
|
||||
LOCAL_MODEL_PRICE_CATALOG_MIGRATION_ID,
|
||||
LOCAL_MODEL_PRICE_CATALOG_AUTHORIZATION_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_FEATURE_ACTIVATION_MIGRATION_ID,
|
||||
} from '../identities';
|
||||
|
||||
import { defineSqlMigration } from '../shared';
|
||||
|
||||
const LOCAL_MODEL_PRICE_CATALOG_PUBLICATION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelPriceCatalogPublications" (
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
price_revision TEXT NOT NULL,
|
||||
catalog_digest TEXT NOT NULL,
|
||||
mutation_id TEXT NOT NULL UNIQUE,
|
||||
command_digest TEXT NOT NULL,
|
||||
publication_digest TEXT NOT NULL,
|
||||
published_at_ms INTEGER NOT NULL,
|
||||
published_by_user_id TEXT NOT NULL,
|
||||
publication_json TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, model, price_revision),
|
||||
CONSTRAINT ql3_model_price_catalog_publication_identity_check CHECK (
|
||||
length(provider) BETWEEN 1 AND 128 AND
|
||||
length(model) BETWEEN 1 AND 128 AND
|
||||
length(price_revision) BETWEEN 1 AND 128 AND
|
||||
length(mutation_id) BETWEEN 1 AND 128 AND
|
||||
length(published_by_user_id) BETWEEN 1 AND 128
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_publication_value_check CHECK (
|
||||
published_at_ms >= 0 AND
|
||||
length(catalog_digest) = 64 AND
|
||||
catalog_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(command_digest) = 64 AND
|
||||
command_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(publication_digest) = 64 AND
|
||||
publication_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_publication_json_check CHECK (
|
||||
length(CAST(publication_json AS BLOB)) BETWEEN 2 AND 24576 AND
|
||||
json_valid(publication_json) AND
|
||||
json_type(publication_json) = 'object' AND
|
||||
json_extract(publication_json, '$.schema') =
|
||||
'qinglong/model-price-catalog-publication@v1' AND
|
||||
json_extract(publication_json, '$.entry.schema') =
|
||||
'qinglong/model-price-catalog-entry@v1' AND
|
||||
json_extract(publication_json, '$.entry.provider') = provider AND
|
||||
json_extract(publication_json, '$.entry.model') = model AND
|
||||
json_extract(publication_json, '$.entry.priceRevision') =
|
||||
price_revision AND
|
||||
json_extract(publication_json, '$.entry.catalogDigest') =
|
||||
catalog_digest AND
|
||||
json_extract(publication_json, '$.entry.publishedAtMs') =
|
||||
published_at_ms AND
|
||||
json_extract(publication_json, '$.mutationId') = mutation_id AND
|
||||
json_extract(publication_json, '$.publishedByUserId') =
|
||||
published_by_user_id AND
|
||||
json_extract(publication_json, '$.commandDigest') = command_digest AND
|
||||
json_extract(publication_json, '$.publicationDigest') =
|
||||
publication_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const LOCAL_MODEL_PRICE_CATALOG_HEAD_TABLE_SQL = `
|
||||
CREATE TABLE "ModelPriceCatalogHeads" (
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
previous_head_digest TEXT,
|
||||
active_price_revision TEXT,
|
||||
active_catalog_digest TEXT,
|
||||
revoked_price_revision TEXT,
|
||||
revoked_catalog_digest TEXT,
|
||||
action TEXT NOT NULL,
|
||||
mutation_id TEXT NOT NULL UNIQUE,
|
||||
changed_by_user_id TEXT NOT NULL,
|
||||
changed_at_ms INTEGER NOT NULL,
|
||||
command_digest TEXT NOT NULL,
|
||||
head_digest TEXT NOT NULL UNIQUE,
|
||||
head_json TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, model, generation),
|
||||
FOREIGN KEY (previous_head_digest)
|
||||
REFERENCES "ModelPriceCatalogHeads" (head_digest) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (
|
||||
provider, model, active_price_revision, active_catalog_digest
|
||||
) REFERENCES "ModelPriceCatalogPublications" (
|
||||
provider, model, price_revision, catalog_digest
|
||||
) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (
|
||||
provider, model, revoked_price_revision, revoked_catalog_digest
|
||||
) REFERENCES "ModelPriceCatalogPublications" (
|
||||
provider, model, price_revision, catalog_digest
|
||||
) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_price_catalog_head_identity_check CHECK (
|
||||
length(provider) BETWEEN 1 AND 128 AND
|
||||
length(model) BETWEEN 1 AND 128 AND
|
||||
length(mutation_id) BETWEEN 1 AND 128 AND
|
||||
length(changed_by_user_id) BETWEEN 1 AND 128
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_head_value_check CHECK (
|
||||
generation BETWEEN 1 AND 2147483647 AND
|
||||
((generation = 1 AND previous_head_digest IS NULL) OR
|
||||
(generation > 1 AND previous_head_digest IS NOT NULL)) AND
|
||||
((active_price_revision IS NULL AND active_catalog_digest IS NULL) OR
|
||||
(active_price_revision IS NOT NULL AND
|
||||
active_catalog_digest IS NOT NULL)) AND
|
||||
((revoked_price_revision IS NULL AND revoked_catalog_digest IS NULL) OR
|
||||
(revoked_price_revision IS NOT NULL AND
|
||||
revoked_catalog_digest IS NOT NULL)) AND
|
||||
(revoked_price_revision IS NULL OR
|
||||
revoked_price_revision <> active_price_revision) AND
|
||||
action IN ('activate', 'deactivate', 'revoke') AND
|
||||
((action = 'revoke') = (revoked_price_revision IS NOT NULL)) AND
|
||||
changed_at_ms >= 0 AND
|
||||
(previous_head_digest IS NULL OR (
|
||||
length(previous_head_digest) = 64 AND
|
||||
previous_head_digest NOT GLOB '*[^0-9a-f]*'
|
||||
)) AND
|
||||
(active_catalog_digest IS NULL OR (
|
||||
length(active_catalog_digest) = 64 AND
|
||||
active_catalog_digest NOT GLOB '*[^0-9a-f]*'
|
||||
)) AND
|
||||
(revoked_catalog_digest IS NULL OR (
|
||||
length(revoked_catalog_digest) = 64 AND
|
||||
revoked_catalog_digest NOT GLOB '*[^0-9a-f]*'
|
||||
)) AND
|
||||
length(command_digest) = 64 AND
|
||||
command_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(head_digest) = 64 AND head_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_head_json_check CHECK (
|
||||
length(CAST(head_json AS BLOB)) BETWEEN 2 AND 24576 AND
|
||||
json_valid(head_json) AND json_type(head_json) = 'object' AND
|
||||
json_extract(head_json, '$.schema') =
|
||||
'qinglong/model-price-catalog-head@v1' AND
|
||||
json_extract(head_json, '$.provider') = provider AND
|
||||
json_extract(head_json, '$.model') = model AND
|
||||
json_extract(head_json, '$.generation') = generation AND
|
||||
((previous_head_digest IS NULL AND
|
||||
json_type(head_json, '$.previousHeadDigest') = 'null') OR
|
||||
json_extract(head_json, '$.previousHeadDigest') =
|
||||
previous_head_digest) AND
|
||||
((active_price_revision IS NULL AND
|
||||
json_type(head_json, '$.activePriceRevision') = 'null') OR
|
||||
json_extract(head_json, '$.activePriceRevision') =
|
||||
active_price_revision) AND
|
||||
((active_catalog_digest IS NULL AND
|
||||
json_type(head_json, '$.activeCatalogDigest') = 'null') OR
|
||||
json_extract(head_json, '$.activeCatalogDigest') =
|
||||
active_catalog_digest) AND
|
||||
((revoked_price_revision IS NULL AND
|
||||
json_type(head_json, '$.revokedPriceRevision') = 'null') OR
|
||||
json_extract(head_json, '$.revokedPriceRevision') =
|
||||
revoked_price_revision) AND
|
||||
((revoked_catalog_digest IS NULL AND
|
||||
json_type(head_json, '$.revokedCatalogDigest') = 'null') OR
|
||||
json_extract(head_json, '$.revokedCatalogDigest') =
|
||||
revoked_catalog_digest) AND
|
||||
json_extract(head_json, '$.action') = action AND
|
||||
json_extract(head_json, '$.mutationId') = mutation_id AND
|
||||
json_extract(head_json, '$.changedByUserId') =
|
||||
changed_by_user_id AND
|
||||
json_extract(head_json, '$.changedAtMs') = changed_at_ms AND
|
||||
json_extract(head_json, '$.commandDigest') = command_digest AND
|
||||
json_extract(head_json, '$.headDigest') = head_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const localModelPriceCatalogMigration =
|
||||
defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_MODEL_PRICE_CATALOG_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_MODEL_PRICE_CATALOG_PUBLICATION_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_model_price_catalog_publication_identity_digest_uidx
|
||||
ON "ModelPriceCatalogPublications"
|
||||
(provider, model, price_revision, catalog_digest)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_price_catalog_publication_digest_uidx
|
||||
ON "ModelPriceCatalogPublications" (publication_digest)`,
|
||||
LOCAL_MODEL_PRICE_CATALOG_HEAD_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_price_catalog_head_current_idx
|
||||
ON "ModelPriceCatalogHeads" (provider, model, generation DESC)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_price_catalog_revoked_revision_uidx
|
||||
ON "ModelPriceCatalogHeads"
|
||||
(provider, model, revoked_price_revision)
|
||||
WHERE revoked_price_revision IS NOT NULL`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
const LOCAL_MODEL_PRICE_CATALOG_AUTHORIZATION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelPriceCatalogAuthorizations" (
|
||||
authorization_id TEXT PRIMARY KEY,
|
||||
request_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
price_revision TEXT,
|
||||
catalog_command_digest TEXT NOT NULL UNIQUE,
|
||||
publication_digest TEXT UNIQUE,
|
||||
head_digest TEXT UNIQUE,
|
||||
result_digest TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL,
|
||||
authentication_id TEXT NOT NULL,
|
||||
assurance TEXT NOT NULL,
|
||||
authenticated_at_ms INTEGER NOT NULL,
|
||||
expires_at_ms INTEGER NOT NULL,
|
||||
policy_revision TEXT NOT NULL,
|
||||
policy_decision_digest TEXT NOT NULL,
|
||||
decision_mode TEXT NOT NULL,
|
||||
command_digest TEXT NOT NULL UNIQUE,
|
||||
committed_at_ms INTEGER NOT NULL,
|
||||
authorization_digest TEXT NOT NULL UNIQUE,
|
||||
reasons_json TEXT NOT NULL,
|
||||
authorization_json TEXT NOT NULL,
|
||||
FOREIGN KEY (publication_digest)
|
||||
REFERENCES "ModelPriceCatalogPublications" (publication_digest)
|
||||
ON DELETE RESTRICT,
|
||||
FOREIGN KEY (head_digest)
|
||||
REFERENCES "ModelPriceCatalogHeads" (head_digest)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_price_catalog_authorization_identity_check CHECK (
|
||||
length(authorization_id) BETWEEN 1 AND 128 AND
|
||||
length(request_id) BETWEEN 1 AND 128 AND
|
||||
length(provider) BETWEEN 1 AND 128 AND
|
||||
length(model) BETWEEN 1 AND 128 AND
|
||||
(price_revision IS NULL OR length(price_revision) BETWEEN 1 AND 128) AND
|
||||
length(user_id) BETWEEN 1 AND 128 AND
|
||||
length(authentication_id) BETWEEN 1 AND 128 AND
|
||||
length(policy_revision) BETWEEN 1 AND 128
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_authorization_value_check CHECK (
|
||||
operation IN ('publish', 'activate', 'deactivate', 'revoke') AND
|
||||
((operation = 'deactivate' AND price_revision IS NULL) OR
|
||||
(operation <> 'deactivate' AND price_revision IS NOT NULL)) AND
|
||||
((operation = 'publish' AND publication_digest IS NOT NULL AND
|
||||
head_digest IS NULL AND result_digest = publication_digest) OR
|
||||
(operation <> 'publish' AND publication_digest IS NULL AND
|
||||
head_digest IS NOT NULL AND result_digest = head_digest)) AND
|
||||
assurance IN ('multi_factor', 'hardware', 'local_console') AND
|
||||
decision_mode IN ('human_confirmation', 'separation_of_duty') AND
|
||||
authenticated_at_ms >= 0 AND
|
||||
expires_at_ms > authenticated_at_ms AND
|
||||
committed_at_ms >= authenticated_at_ms AND
|
||||
committed_at_ms < expires_at_ms AND
|
||||
committed_at_ms - authenticated_at_ms <= 300000 AND
|
||||
length(catalog_command_digest) = 64 AND
|
||||
catalog_command_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(result_digest) = 64 AND
|
||||
result_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(policy_decision_digest) = 64 AND
|
||||
policy_decision_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(command_digest) = 64 AND
|
||||
command_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(authorization_digest) = 64 AND
|
||||
authorization_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_authorization_reasons_check CHECK (
|
||||
length(CAST(reasons_json AS BLOB)) BETWEEN 5 AND 2048 AND
|
||||
json_valid(reasons_json) AND json_type(reasons_json) = 'array' AND
|
||||
json_array_length(reasons_json) BETWEEN 1 AND 8
|
||||
),
|
||||
CONSTRAINT ql3_model_price_catalog_authorization_json_check CHECK (
|
||||
length(CAST(authorization_json AS BLOB)) BETWEEN 2 AND 32768 AND
|
||||
json_valid(authorization_json) AND
|
||||
json_type(authorization_json) = 'object' AND
|
||||
json_extract(authorization_json, '$.schema') =
|
||||
'qinglong/model-price-catalog-authorization@v1' AND
|
||||
json_extract(authorization_json, '$.authorizationId') =
|
||||
authorization_id AND
|
||||
json_extract(authorization_json, '$.requestId') = request_id AND
|
||||
json_extract(authorization_json, '$.operation') = operation AND
|
||||
json_extract(authorization_json, '$.provider') = provider AND
|
||||
json_extract(authorization_json, '$.model') = model AND
|
||||
((price_revision IS NULL AND
|
||||
json_type(authorization_json, '$.priceRevision') = 'null') OR
|
||||
json_extract(authorization_json, '$.priceRevision') =
|
||||
price_revision) AND
|
||||
json_extract(authorization_json, '$.catalogCommandDigest') =
|
||||
catalog_command_digest AND
|
||||
json_extract(authorization_json, '$.resultDigest') = result_digest AND
|
||||
json_extract(authorization_json, '$.principal.subject.type') = 'user' AND
|
||||
json_extract(authorization_json, '$.principal.subject.id') = user_id AND
|
||||
json_extract(authorization_json, '$.principal.authenticationId') =
|
||||
authentication_id AND
|
||||
json_extract(authorization_json, '$.principal.assurance') = assurance AND
|
||||
json_extract(authorization_json, '$.principal.authenticatedAtMs') =
|
||||
authenticated_at_ms AND
|
||||
json_extract(authorization_json, '$.principal.expiresAtMs') =
|
||||
expires_at_ms AND
|
||||
json_extract(authorization_json, '$.policy.revision') =
|
||||
policy_revision AND
|
||||
json_extract(authorization_json, '$.policy.decisionDigest') =
|
||||
policy_decision_digest AND
|
||||
json_extract(authorization_json, '$.decisionMode') = decision_mode AND
|
||||
json_extract(authorization_json, '$.commandDigest') = command_digest AND
|
||||
json_extract(authorization_json, '$.committedAtMs') = committed_at_ms AND
|
||||
json_extract(authorization_json, '$.authorizationDigest') =
|
||||
authorization_digest AND
|
||||
json_extract(authorization_json, '$.policy.reasons') =
|
||||
json(reasons_json)
|
||||
)
|
||||
)`;
|
||||
|
||||
const localModelPriceCatalogAuthorizationMigration =
|
||||
defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_MODEL_PRICE_CATALOG_AUTHORIZATION_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_MODEL_PRICE_CATALOG_AUTHORIZATION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_price_catalog_authorization_target_idx
|
||||
ON "ModelPriceCatalogAuthorizations"
|
||||
(provider, model, operation, committed_at_ms DESC)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
const LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationFeatureTransitions" (
|
||||
feature_id TEXT NOT NULL
|
||||
CONSTRAINT ql3_ai_feature_transition_feature_check
|
||||
CHECK (feature_id = 'model-invocation'),
|
||||
generation INTEGER NOT NULL
|
||||
CONSTRAINT ql3_ai_feature_transition_generation_check
|
||||
CHECK (generation BETWEEN 1 AND 2147483647),
|
||||
previous_generation INTEGER,
|
||||
state TEXT NOT NULL
|
||||
CONSTRAINT ql3_ai_feature_transition_state_check
|
||||
CHECK (state IN ('active', 'inactive')),
|
||||
mutation_id TEXT NOT NULL UNIQUE,
|
||||
request_id TEXT NOT NULL,
|
||||
expected_migration_digest TEXT NOT NULL,
|
||||
safety_mode TEXT NOT NULL
|
||||
CONSTRAINT ql3_ai_feature_transition_safety_check
|
||||
CHECK (safety_mode IN (
|
||||
'fresh_database',
|
||||
'backup_verified',
|
||||
'preserve_existing'
|
||||
)),
|
||||
backup_evidence_digest TEXT,
|
||||
changed_by_user_id TEXT NOT NULL,
|
||||
authentication_id TEXT NOT NULL,
|
||||
assurance TEXT NOT NULL
|
||||
CONSTRAINT ql3_ai_feature_transition_assurance_check
|
||||
CHECK (assurance = 'local_console'),
|
||||
command_digest TEXT NOT NULL,
|
||||
transition_digest TEXT NOT NULL UNIQUE,
|
||||
committed_at_ms INTEGER NOT NULL
|
||||
CONSTRAINT ql3_ai_feature_transition_time_check
|
||||
CHECK (committed_at_ms >= 0),
|
||||
transition_json TEXT NOT NULL,
|
||||
PRIMARY KEY (feature_id, generation),
|
||||
UNIQUE (feature_id, generation, transition_digest),
|
||||
CONSTRAINT ql3_ai_feature_transition_previous_fk
|
||||
FOREIGN KEY (feature_id, previous_generation)
|
||||
REFERENCES "ModelInvocationFeatureTransitions" (feature_id, generation)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_feature_transition_previous_check CHECK (
|
||||
(
|
||||
generation = 1 AND
|
||||
previous_generation IS NULL AND
|
||||
state = 'active'
|
||||
) OR (
|
||||
generation > 1 AND
|
||||
previous_generation = generation - 1
|
||||
)
|
||||
),
|
||||
CONSTRAINT ql3_ai_feature_transition_identity_check CHECK (
|
||||
length(mutation_id) BETWEEN 1 AND 128 AND
|
||||
length(request_id) BETWEEN 1 AND 128 AND
|
||||
length(changed_by_user_id) BETWEEN 1 AND 255 AND
|
||||
length(authentication_id) BETWEEN 1 AND 128
|
||||
),
|
||||
CONSTRAINT ql3_ai_feature_transition_digest_check CHECK (
|
||||
length(expected_migration_digest) = 64 AND
|
||||
expected_migration_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
(
|
||||
backup_evidence_digest IS NULL OR
|
||||
length(backup_evidence_digest) = 64 AND
|
||||
backup_evidence_digest NOT GLOB '*[^0-9a-f]*'
|
||||
) AND
|
||||
length(command_digest) = 64 AND
|
||||
command_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(transition_digest) = 64 AND
|
||||
transition_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_ai_feature_transition_safety_shape_check CHECK (
|
||||
(
|
||||
state = 'active' AND
|
||||
(
|
||||
(
|
||||
safety_mode = 'fresh_database' AND
|
||||
backup_evidence_digest IS NULL
|
||||
) OR (
|
||||
safety_mode = 'backup_verified' AND
|
||||
backup_evidence_digest IS NOT NULL
|
||||
)
|
||||
)
|
||||
) OR (
|
||||
state = 'inactive' AND
|
||||
safety_mode = 'preserve_existing' AND
|
||||
backup_evidence_digest IS NULL
|
||||
)
|
||||
),
|
||||
CONSTRAINT ql3_ai_feature_transition_json_check CHECK (
|
||||
json_valid(transition_json) AND
|
||||
json_type(transition_json) = 'object' AND
|
||||
json_extract(transition_json, '$.schema') =
|
||||
'qinglong/model-invocation-feature-transition@v1' AND
|
||||
json_extract(transition_json, '$.featureId') = feature_id AND
|
||||
json_extract(transition_json, '$.generation') = generation AND
|
||||
(
|
||||
(
|
||||
previous_generation IS NULL AND
|
||||
json_type(transition_json, '$.previousGeneration') = 'null'
|
||||
) OR
|
||||
json_extract(transition_json, '$.previousGeneration') =
|
||||
previous_generation
|
||||
) AND
|
||||
json_extract(transition_json, '$.state') = state AND
|
||||
json_extract(transition_json, '$.mutationId') = mutation_id AND
|
||||
json_extract(transition_json, '$.requestId') = request_id AND
|
||||
json_extract(transition_json, '$.expectedMigrationDigest') =
|
||||
expected_migration_digest AND
|
||||
json_extract(transition_json, '$.safety.mode') = safety_mode AND
|
||||
(
|
||||
(
|
||||
backup_evidence_digest IS NULL AND
|
||||
json_type(transition_json, '$.safety.backupEvidenceDigest') = 'null'
|
||||
) OR
|
||||
json_extract(transition_json, '$.safety.backupEvidenceDigest') =
|
||||
backup_evidence_digest
|
||||
) AND
|
||||
json_extract(transition_json, '$.changedByUserId') =
|
||||
changed_by_user_id AND
|
||||
json_extract(transition_json, '$.authenticationId') =
|
||||
authentication_id AND
|
||||
json_extract(transition_json, '$.assurance') = assurance AND
|
||||
json_extract(transition_json, '$.commandDigest') = command_digest AND
|
||||
json_extract(transition_json, '$.committedAtMs') = committed_at_ms AND
|
||||
json_extract(transition_json, '$.transitionDigest') =
|
||||
transition_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const LOCAL_MODEL_INVOCATION_FEATURE_HEAD_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationFeatureHead" (
|
||||
feature_id TEXT PRIMARY KEY NOT NULL
|
||||
CONSTRAINT ql3_ai_feature_head_feature_check
|
||||
CHECK (feature_id = 'model-invocation'),
|
||||
generation INTEGER NOT NULL
|
||||
CONSTRAINT ql3_ai_feature_head_generation_check
|
||||
CHECK (generation BETWEEN 1 AND 2147483647),
|
||||
state TEXT NOT NULL
|
||||
CONSTRAINT ql3_ai_feature_head_state_check
|
||||
CHECK (state IN ('active', 'inactive')),
|
||||
transition_digest TEXT NOT NULL UNIQUE,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
CONSTRAINT ql3_ai_feature_head_time_check
|
||||
CHECK (updated_at_ms >= 0),
|
||||
CONSTRAINT ql3_ai_feature_head_transition_fk
|
||||
FOREIGN KEY (feature_id, generation, transition_digest)
|
||||
REFERENCES "ModelInvocationFeatureTransitions" (
|
||||
feature_id,
|
||||
generation,
|
||||
transition_digest
|
||||
) ON DELETE RESTRICT
|
||||
)`;
|
||||
|
||||
const localModelInvocationFeatureActivationMigration =
|
||||
defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_MODEL_INVOCATION_FEATURE_ACTIVATION_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_MODEL_INVOCATION_FEATURE_TRANSITION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_feature_transition_state_idx
|
||||
ON "ModelInvocationFeatureTransitions"
|
||||
(feature_id, state, generation DESC)`,
|
||||
LOCAL_MODEL_INVOCATION_FEATURE_HEAD_TABLE_SQL,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
export const sqliteCatalogMigrations = Object.freeze([
|
||||
localModelPriceCatalogMigration,
|
||||
localModelPriceCatalogAuthorizationMigration,
|
||||
localModelInvocationFeatureActivationMigration,
|
||||
]);
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
export interface LocalMigrationContext {
|
||||
readonly client: DatabaseSync;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { LocalMigrationContext } from './context';
|
||||
|
||||
import {
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_ID,
|
||||
} from '../identities';
|
||||
|
||||
import { defineSqlMigration } from '../shared';
|
||||
|
||||
const LOCAL_START_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationStarts" (
|
||||
invocation_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
step_run_id TEXT NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
policy_revision TEXT NOT NULL,
|
||||
request_digest TEXT NOT NULL,
|
||||
input_bytes INTEGER NOT NULL,
|
||||
max_output_tokens INTEGER NOT NULL,
|
||||
deadline_at_ms INTEGER NOT NULL,
|
||||
admitted_at_ms INTEGER NOT NULL,
|
||||
mutation_id TEXT NOT NULL,
|
||||
mutation_digest TEXT NOT NULL,
|
||||
run_event_id TEXT NOT NULL,
|
||||
start_digest TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
FOREIGN KEY (run_id, step_run_id)
|
||||
REFERENCES "StepRuns" (run_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (mutation_id)
|
||||
REFERENCES "StepRunMutations" (mutation_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (run_event_id)
|
||||
REFERENCES "RunEvents" (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_starts_identity_check CHECK (
|
||||
length(invocation_id) BETWEEN 1 AND 128 AND
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(run_id) BETWEEN 1 AND 128 AND
|
||||
length(step_run_id) BETWEEN 1 AND 128 AND
|
||||
length(trace_id) BETWEEN 1 AND 128 AND
|
||||
length(provider) BETWEEN 1 AND 128 AND
|
||||
length(model) BETWEEN 1 AND 128 AND
|
||||
length(policy_revision) BETWEEN 1 AND 128 AND
|
||||
length(mutation_id) BETWEEN 1 AND 128 AND
|
||||
length(run_event_id) BETWEEN 1 AND 36
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_starts_digest_check CHECK (
|
||||
length(request_digest) = 71 AND
|
||||
substr(request_digest, 1, 7) = 'sha256:' AND
|
||||
substr(request_digest, 8) NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(mutation_digest) = 64 AND
|
||||
mutation_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(start_digest) = 64 AND
|
||||
start_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_starts_budget_check CHECK (
|
||||
input_bytes BETWEEN 1 AND 262144 AND
|
||||
max_output_tokens BETWEEN 1 AND 32768 AND
|
||||
admitted_at_ms >= 0 AND deadline_at_ms > admitted_at_ms
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_starts_json_check CHECK (
|
||||
length(CAST(record_json AS BLOB)) BETWEEN 2 AND 24576 AND
|
||||
json_valid(record_json) AND json_type(record_json) = 'object' AND
|
||||
json_extract(record_json, '$.schema') =
|
||||
'qinglong/model-invocation-start@v1' AND
|
||||
json_extract(record_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(record_json, '$.projectId') = project_id AND
|
||||
json_extract(record_json, '$.runId') = run_id AND
|
||||
json_extract(record_json, '$.stepRunId') = step_run_id AND
|
||||
json_extract(record_json, '$.traceId') = trace_id AND
|
||||
json_extract(record_json, '$.provider') = provider AND
|
||||
json_extract(record_json, '$.model') = model AND
|
||||
json_extract(record_json, '$.policyRevision') = policy_revision AND
|
||||
json_extract(record_json, '$.requestDigest') = request_digest AND
|
||||
json_extract(record_json, '$.inputBytes') = input_bytes AND
|
||||
json_extract(record_json, '$.maxOutputTokens') = max_output_tokens AND
|
||||
json_extract(record_json, '$.deadlineAtMs') = deadline_at_ms AND
|
||||
json_extract(record_json, '$.admittedAtMs') = admitted_at_ms AND
|
||||
json_extract(record_json, '$.stepRunMutationId') = mutation_id AND
|
||||
json_extract(record_json, '$.stepRunMutationDigest') = mutation_digest AND
|
||||
json_extract(record_json, '$.runEventId') = run_event_id AND
|
||||
json_extract(record_json, '$.startDigest') = start_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const LOCAL_COMPLETION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationCompletions" (
|
||||
invocation_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
step_run_id TEXT NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
start_digest TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
output_bytes INTEGER NOT NULL,
|
||||
error_code TEXT,
|
||||
completed_at_ms INTEGER NOT NULL,
|
||||
mutation_id TEXT NOT NULL,
|
||||
mutation_digest TEXT NOT NULL,
|
||||
run_event_id TEXT NOT NULL,
|
||||
completion_digest TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "ModelInvocationStarts" (invocation_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (run_id, step_run_id)
|
||||
REFERENCES "StepRuns" (run_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (mutation_id)
|
||||
REFERENCES "StepRunMutations" (mutation_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (run_event_id)
|
||||
REFERENCES "RunEvents" (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_completions_outcome_check CHECK (
|
||||
outcome IN ('succeeded', 'failed', 'timed_out', 'outcome_unknown')
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_completions_value_check CHECK (
|
||||
output_bytes BETWEEN 0 AND 1048576 AND completed_at_ms >= 0 AND
|
||||
((outcome = 'succeeded' AND error_code IS NULL) OR
|
||||
(outcome <> 'succeeded' AND
|
||||
length(error_code) BETWEEN 1 AND 64 AND
|
||||
substr(error_code, 1, 1) GLOB '[A-Z]' AND
|
||||
error_code NOT GLOB '*[^A-Z0-9_]*')) AND
|
||||
length(start_digest) = 64 AND
|
||||
start_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(mutation_digest) = 64 AND
|
||||
mutation_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(completion_digest) = 64 AND
|
||||
completion_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_completions_json_check CHECK (
|
||||
length(CAST(record_json AS BLOB)) BETWEEN 2 AND 24576 AND
|
||||
json_valid(record_json) AND json_type(record_json) = 'object' AND
|
||||
json_extract(record_json, '$.schema') =
|
||||
'qinglong/model-invocation-completion@v1' AND
|
||||
json_extract(record_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(record_json, '$.projectId') = project_id AND
|
||||
json_extract(record_json, '$.runId') = run_id AND
|
||||
json_extract(record_json, '$.stepRunId') = step_run_id AND
|
||||
json_extract(record_json, '$.traceId') = trace_id AND
|
||||
json_extract(record_json, '$.startDigest') = start_digest AND
|
||||
json_extract(record_json, '$.outcome') = outcome AND
|
||||
json_extract(record_json, '$.outputBytes') = output_bytes AND
|
||||
json_extract(record_json, '$.errorCode') IS error_code AND
|
||||
json_extract(record_json, '$.completedAtMs') = completed_at_ms AND
|
||||
json_extract(record_json, '$.stepRunMutationId') = mutation_id AND
|
||||
json_extract(record_json, '$.stepRunMutationDigest') = mutation_digest AND
|
||||
json_extract(record_json, '$.runEventId') = run_event_id AND
|
||||
json_extract(record_json, '$.completionDigest') = completion_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const LOCAL_RESOLUTION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationResolutions" (
|
||||
resolution_id TEXT PRIMARY KEY NOT NULL,
|
||||
invocation_id TEXT NOT NULL UNIQUE,
|
||||
project_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
step_run_id TEXT NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
completion_digest TEXT NOT NULL,
|
||||
decision TEXT NOT NULL,
|
||||
resolved_by_user_id TEXT NOT NULL,
|
||||
resolved_at_ms INTEGER NOT NULL,
|
||||
mutation_id TEXT NOT NULL UNIQUE,
|
||||
mutation_digest TEXT NOT NULL,
|
||||
run_event_id TEXT NOT NULL UNIQUE,
|
||||
resolution_digest TEXT NOT NULL UNIQUE,
|
||||
record_json TEXT NOT NULL,
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "ModelInvocationCompletions" (invocation_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (run_id, step_run_id)
|
||||
REFERENCES "StepRuns" (run_id, id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (mutation_id)
|
||||
REFERENCES "StepRunMutations" (mutation_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (run_event_id)
|
||||
REFERENCES "RunEvents" (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_resolutions_value_check CHECK (
|
||||
decision IN ('retry', 'fail', 'cancel') AND
|
||||
resolved_at_ms >= 0 AND
|
||||
length(resolution_id) BETWEEN 1 AND 128 AND
|
||||
length(invocation_id) BETWEEN 1 AND 128 AND
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(run_id) BETWEEN 1 AND 128 AND
|
||||
length(step_run_id) BETWEEN 1 AND 128 AND
|
||||
length(trace_id) BETWEEN 1 AND 128 AND
|
||||
length(resolved_by_user_id) BETWEEN 1 AND 128 AND
|
||||
length(mutation_id) BETWEEN 1 AND 128 AND
|
||||
length(run_event_id) BETWEEN 1 AND 36 AND
|
||||
length(completion_digest) = 64 AND
|
||||
completion_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(mutation_digest) = 64 AND
|
||||
mutation_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(resolution_digest) = 64 AND
|
||||
resolution_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_resolutions_json_check CHECK (
|
||||
length(CAST(record_json AS BLOB)) BETWEEN 2 AND 24576 AND
|
||||
json_valid(record_json) AND json_type(record_json) = 'object' AND
|
||||
json_extract(record_json, '$.schema') =
|
||||
'qinglong/model-invocation-resolution@v1' AND
|
||||
json_extract(record_json, '$.resolutionId') = resolution_id AND
|
||||
json_extract(record_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(record_json, '$.projectId') = project_id AND
|
||||
json_extract(record_json, '$.runId') = run_id AND
|
||||
json_extract(record_json, '$.stepRunId') = step_run_id AND
|
||||
json_extract(record_json, '$.traceId') = trace_id AND
|
||||
json_extract(record_json, '$.completionDigest') = completion_digest AND
|
||||
json_extract(record_json, '$.decision') = decision AND
|
||||
json_extract(record_json, '$.resolvedByUserId') = resolved_by_user_id AND
|
||||
json_extract(record_json, '$.resolvedAtMs') = resolved_at_ms AND
|
||||
json_extract(record_json, '$.stepRunMutationId') = mutation_id AND
|
||||
json_extract(record_json, '$.stepRunMutationDigest') = mutation_digest AND
|
||||
json_extract(record_json, '$.runEventId') = run_event_id AND
|
||||
json_extract(record_json, '$.resolutionDigest') = resolution_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const localMigration = defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_MODEL_INVOCATION_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_START_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_invocation_starts_step_history_idx
|
||||
ON "ModelInvocationStarts"
|
||||
(run_id, step_run_id, admitted_at_ms, invocation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_starts_mutation_uidx
|
||||
ON "ModelInvocationStarts" (mutation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_starts_event_uidx
|
||||
ON "ModelInvocationStarts" (run_event_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_starts_digest_uidx
|
||||
ON "ModelInvocationStarts" (start_digest)`,
|
||||
LOCAL_COMPLETION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_invocation_completions_step_history_idx
|
||||
ON "ModelInvocationCompletions"
|
||||
(run_id, step_run_id, completed_at_ms, invocation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_completions_mutation_uidx
|
||||
ON "ModelInvocationCompletions" (mutation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_completions_event_uidx
|
||||
ON "ModelInvocationCompletions" (run_event_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_completions_digest_uidx
|
||||
ON "ModelInvocationCompletions" (completion_digest)`,
|
||||
LOCAL_RESOLUTION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_invocation_resolutions_step_history_idx
|
||||
ON "ModelInvocationResolutions"
|
||||
(run_id, step_run_id, resolved_at_ms, resolution_id)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
export const sqliteCoreMigrations = Object.freeze([
|
||||
localMigration,
|
||||
]);
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { LocalMigrationContext } from './context';
|
||||
|
||||
import {
|
||||
LOCAL_MODEL_PROVIDER_CREDENTIAL_CATALOG_MIGRATION_ID,
|
||||
} from '../identities';
|
||||
|
||||
import { defineSqlMigration } from '../shared';
|
||||
|
||||
const LOCAL_MODEL_PROVIDER_CREDENTIAL_BINDING_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationProviderCredentialBindings" (
|
||||
project_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
revision TEXT NOT NULL,
|
||||
binding_digest TEXT NOT NULL,
|
||||
binding_json TEXT NOT NULL,
|
||||
PRIMARY KEY (project_id, provider, revision),
|
||||
CONSTRAINT ql3_ai_local_provider_binding_identity_check CHECK (
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(provider) BETWEEN 1 AND 128 AND
|
||||
length(revision) BETWEEN 1 AND 128 AND
|
||||
project_id NOT GLOB '*[^A-Za-z0-9._:/-]*' AND
|
||||
provider NOT GLOB '*[^A-Za-z0-9._:/-]*' AND
|
||||
revision NOT GLOB '*[^A-Za-z0-9._:/-]*'
|
||||
),
|
||||
CONSTRAINT ql3_ai_local_provider_binding_digest_check CHECK (
|
||||
length(binding_digest) = 64 AND
|
||||
binding_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_ai_local_provider_binding_json_check CHECK (
|
||||
length(CAST(binding_json AS BLOB)) BETWEEN 2 AND 4096 AND
|
||||
json_valid(binding_json) AND json_type(binding_json) = 'object' AND
|
||||
json_extract(binding_json, '$.schema') =
|
||||
'qinglong/model-provider-credential-binding@v1' AND
|
||||
json_extract(binding_json, '$.projectId') = project_id AND
|
||||
json_extract(binding_json, '$.provider') = provider AND
|
||||
json_extract(binding_json, '$.revision') = revision AND
|
||||
json_extract(binding_json, '$.scheme') = 'bearer'
|
||||
)
|
||||
)`;
|
||||
|
||||
const LOCAL_MODEL_PROVIDER_CREDENTIAL_TRANSITION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationProviderCredentialTransitions" (
|
||||
mutation_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
active_binding_revision TEXT,
|
||||
active_binding_digest TEXT,
|
||||
previous_transition_digest TEXT,
|
||||
changed_by_type TEXT NOT NULL,
|
||||
changed_by_id TEXT NOT NULL,
|
||||
changed_at_ms INTEGER NOT NULL,
|
||||
command_digest TEXT NOT NULL,
|
||||
transition_digest TEXT NOT NULL,
|
||||
command_json TEXT NOT NULL,
|
||||
transition_json TEXT NOT NULL,
|
||||
UNIQUE (project_id, provider, generation),
|
||||
UNIQUE (transition_digest),
|
||||
FOREIGN KEY (project_id, provider, active_binding_revision)
|
||||
REFERENCES "ModelInvocationProviderCredentialBindings"
|
||||
(project_id, provider, revision) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_ai_local_provider_transition_identity_check CHECK (
|
||||
length(mutation_id) BETWEEN 1 AND 128 AND
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(provider) BETWEEN 1 AND 128 AND
|
||||
length(changed_by_id) BETWEEN 1 AND 128 AND
|
||||
project_id NOT GLOB '*[^A-Za-z0-9._:/-]*' AND
|
||||
provider NOT GLOB '*[^A-Za-z0-9._:/-]*'
|
||||
),
|
||||
CONSTRAINT ql3_ai_local_provider_transition_value_check CHECK (
|
||||
generation BETWEEN 1 AND 2147483647 AND
|
||||
action IN ('bind', 'revoke') AND
|
||||
changed_by_type = 'user' AND
|
||||
changed_at_ms >= 0 AND
|
||||
((action = 'bind' AND active_binding_revision IS NOT NULL AND
|
||||
active_binding_digest IS NOT NULL) OR
|
||||
(action = 'revoke' AND active_binding_revision IS NULL AND
|
||||
active_binding_digest IS NULL))
|
||||
),
|
||||
CONSTRAINT ql3_ai_local_provider_transition_digest_check CHECK (
|
||||
length(command_digest) = 64 AND command_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(transition_digest) = 64 AND
|
||||
transition_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
(previous_transition_digest IS NULL OR
|
||||
(length(previous_transition_digest) = 64 AND
|
||||
previous_transition_digest NOT GLOB '*[^0-9a-f]*')) AND
|
||||
(active_binding_digest IS NULL OR
|
||||
(length(active_binding_digest) = 64 AND
|
||||
active_binding_digest NOT GLOB '*[^0-9a-f]*'))
|
||||
),
|
||||
CONSTRAINT ql3_ai_local_provider_transition_json_check CHECK (
|
||||
length(CAST(command_json AS BLOB)) BETWEEN 2 AND 8192 AND
|
||||
json_valid(command_json) AND json_type(command_json) = 'object' AND
|
||||
json_extract(command_json, '$.mutationId') = mutation_id AND
|
||||
json_extract(command_json, '$.projectId') = project_id AND
|
||||
json_extract(command_json, '$.provider') = provider AND
|
||||
json_extract(command_json, '$.action') = action AND
|
||||
length(CAST(transition_json AS BLOB)) BETWEEN 2 AND 8192 AND
|
||||
json_valid(transition_json) AND json_type(transition_json) = 'object' AND
|
||||
json_extract(transition_json, '$.schema') =
|
||||
'qinglong/model-provider-credential-transition@v1' AND
|
||||
json_extract(transition_json, '$.mutationId') = mutation_id AND
|
||||
json_extract(transition_json, '$.projectId') = project_id AND
|
||||
json_extract(transition_json, '$.provider') = provider AND
|
||||
json_extract(transition_json, '$.generation') = generation AND
|
||||
json_extract(transition_json, '$.action') = action AND
|
||||
json_extract(transition_json, '$.transitionDigest') = transition_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const LOCAL_MODEL_PROVIDER_CREDENTIAL_AUDIT_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationProviderCredentialAudits" (
|
||||
operation TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
binding_revision TEXT NOT NULL,
|
||||
binding_digest TEXT NOT NULL,
|
||||
occurred_at_ms INTEGER NOT NULL,
|
||||
audit_json TEXT NOT NULL,
|
||||
PRIMARY KEY (operation, project_id, provider, request_id),
|
||||
CONSTRAINT ql3_ai_local_provider_audit_identity_check CHECK (
|
||||
operation IN ('list_models', 'generate', 'stream') AND
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(provider) BETWEEN 1 AND 128 AND
|
||||
length(request_id) BETWEEN 1 AND 128 AND
|
||||
length(binding_revision) BETWEEN 1 AND 128
|
||||
),
|
||||
CONSTRAINT ql3_ai_local_provider_audit_digest_check CHECK (
|
||||
length(binding_digest) = 71 AND
|
||||
binding_digest GLOB 'sha256:[0-9a-f]*' AND
|
||||
occurred_at_ms >= 0
|
||||
),
|
||||
CONSTRAINT ql3_ai_local_provider_audit_json_check CHECK (
|
||||
length(CAST(audit_json AS BLOB)) BETWEEN 2 AND 4096 AND
|
||||
json_valid(audit_json) AND json_type(audit_json) = 'object' AND
|
||||
json_extract(audit_json, '$.schema') =
|
||||
'qinglong/model-provider-credential-audit@v1' AND
|
||||
json_extract(audit_json, '$.operation') = operation AND
|
||||
json_extract(audit_json, '$.projectId') = project_id AND
|
||||
json_extract(audit_json, '$.provider') = provider AND
|
||||
json_extract(audit_json, '$.requestId') = request_id AND
|
||||
json_extract(audit_json, '$.bindingRevision') = binding_revision AND
|
||||
json_extract(audit_json, '$.bindingDigest') = binding_digest AND
|
||||
json_extract(audit_json, '$.occurredAtMs') = occurred_at_ms
|
||||
)
|
||||
)`;
|
||||
|
||||
const localModelProviderCredentialCatalogMigration =
|
||||
defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_MODEL_PROVIDER_CREDENTIAL_CATALOG_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_MODEL_PROVIDER_CREDENTIAL_BINDING_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_ai_local_provider_binding_digest_uidx
|
||||
ON "ModelInvocationProviderCredentialBindings" (binding_digest)`,
|
||||
LOCAL_MODEL_PROVIDER_CREDENTIAL_TRANSITION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_local_provider_transition_current_idx
|
||||
ON "ModelInvocationProviderCredentialTransitions"
|
||||
(project_id, provider, generation DESC)`,
|
||||
LOCAL_MODEL_PROVIDER_CREDENTIAL_AUDIT_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_local_provider_audit_time_idx
|
||||
ON "ModelInvocationProviderCredentialAudits"
|
||||
(project_id, occurred_at_ms DESC, provider, request_id)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
export const sqliteCredentialMigrations = Object.freeze([
|
||||
localModelProviderCredentialCatalogMigration,
|
||||
]);
|
||||
@@ -0,0 +1,539 @@
|
||||
import type { LocalMigrationContext } from './context';
|
||||
|
||||
import {
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_ADMISSION_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_FINALIZATION_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_MIGRATION_ID,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_MIGRATION_ID,
|
||||
} from '../identities';
|
||||
|
||||
import { defineSqlMigration } from '../shared';
|
||||
|
||||
const LOCAL_PLUGIN_PACKAGE_PROMPT_ADMISSION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationPromptAdmissions" (
|
||||
request_id TEXT PRIMARY KEY NOT NULL,
|
||||
invocation_id TEXT NOT NULL UNIQUE,
|
||||
plan_digest TEXT NOT NULL UNIQUE,
|
||||
run_id TEXT NOT NULL UNIQUE,
|
||||
step_run_id TEXT NOT NULL UNIQUE,
|
||||
project_id TEXT NOT NULL,
|
||||
package_name TEXT NOT NULL,
|
||||
installation_id TEXT NOT NULL,
|
||||
lock_digest TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
generation_digest TEXT NOT NULL,
|
||||
materialized_revision_digest TEXT NOT NULL,
|
||||
publication_digest TEXT NOT NULL,
|
||||
prompt_id TEXT NOT NULL,
|
||||
prompt_definition_digest TEXT NOT NULL,
|
||||
parameter_digest TEXT NOT NULL,
|
||||
model_request_digest TEXT NOT NULL,
|
||||
admitted_at_ms INTEGER NOT NULL,
|
||||
receipt_digest TEXT NOT NULL UNIQUE,
|
||||
plan_json TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_admission_run_fk
|
||||
FOREIGN KEY (run_id) REFERENCES "Runs" (id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_admission_step_fk
|
||||
FOREIGN KEY (run_id, step_run_id) REFERENCES "StepRuns" (run_id, id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_admission_publication_fk
|
||||
FOREIGN KEY (publication_digest)
|
||||
REFERENCES "QingLong3PluginPackageAutomationPublications" (
|
||||
publication_digest
|
||||
) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_admission_identity_check CHECK (
|
||||
length(request_id) BETWEEN 1 AND 128 AND
|
||||
length(invocation_id) BETWEEN 1 AND 128 AND
|
||||
length(run_id) BETWEEN 1 AND 128 AND
|
||||
length(step_run_id) BETWEEN 1 AND 128 AND
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(package_name) BETWEEN 1 AND 63 AND
|
||||
length(installation_id) BETWEEN 1 AND 128 AND
|
||||
length(prompt_id) BETWEEN 1 AND 128 AND
|
||||
generation BETWEEN 1 AND 2147483647 AND
|
||||
admitted_at_ms >= 0
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_admission_digest_check CHECK (
|
||||
length(plan_digest) = 64 AND
|
||||
plan_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(lock_digest) = 64 AND
|
||||
lock_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(generation_digest) = 64 AND
|
||||
generation_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(materialized_revision_digest) = 64 AND
|
||||
materialized_revision_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(publication_digest) = 64 AND
|
||||
publication_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(prompt_definition_digest) = 64 AND
|
||||
prompt_definition_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(parameter_digest) = 64 AND
|
||||
parameter_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
model_request_digest GLOB 'sha256:[0-9a-f]*' AND
|
||||
length(model_request_digest) = 71 AND
|
||||
substr(model_request_digest, 8) NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(receipt_digest) = 64 AND
|
||||
receipt_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_admission_plan_json_check CHECK (
|
||||
length(CAST(plan_json AS BLOB)) BETWEEN 2 AND 32768 AND
|
||||
json_valid(plan_json) AND json_type(plan_json) = 'object' AND
|
||||
json_extract(plan_json, '$.schema') =
|
||||
'qinglong/plugin-package-prompt-execution-plan@v1' AND
|
||||
json_extract(plan_json, '$.requestId') = request_id AND
|
||||
json_extract(plan_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(plan_json, '$.planDigest') = plan_digest AND
|
||||
json_extract(plan_json, '$.runId') = run_id AND
|
||||
json_extract(plan_json, '$.stepRunId') = step_run_id AND
|
||||
json_extract(plan_json, '$.target.projectId') = project_id AND
|
||||
json_extract(plan_json, '$.target.packageName') = package_name AND
|
||||
json_extract(plan_json, '$.target.installationId') = installation_id AND
|
||||
json_extract(plan_json, '$.target.lockDigest') = lock_digest AND
|
||||
json_extract(plan_json, '$.target.generation') = generation AND
|
||||
json_extract(plan_json, '$.target.generationDigest') =
|
||||
generation_digest AND
|
||||
json_extract(plan_json, '$.target.materializedRevisionDigest') =
|
||||
materialized_revision_digest AND
|
||||
json_extract(plan_json, '$.target.publicationDigest') =
|
||||
publication_digest AND
|
||||
json_extract(plan_json, '$.target.promptId') = prompt_id AND
|
||||
json_extract(plan_json, '$.target.promptDefinitionDigest') =
|
||||
prompt_definition_digest AND
|
||||
json_extract(plan_json, '$.parameterDigest') = parameter_digest AND
|
||||
json_extract(plan_json, '$.modelRequestDigest') = model_request_digest AND
|
||||
json_extract(plan_json, '$.plannedAtMs') = admitted_at_ms
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_admission_receipt_json_check CHECK (
|
||||
length(CAST(receipt_json AS BLOB)) BETWEEN 2 AND 16384 AND
|
||||
json_valid(receipt_json) AND json_type(receipt_json) = 'object' AND
|
||||
json_extract(receipt_json, '$.schema') =
|
||||
'qinglong/plugin-package-prompt-admission-receipt@v1' AND
|
||||
json_extract(receipt_json, '$.requestId') = request_id AND
|
||||
json_extract(receipt_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(receipt_json, '$.planDigest') = plan_digest AND
|
||||
json_extract(receipt_json, '$.runId') = run_id AND
|
||||
json_extract(receipt_json, '$.stepRunId') = step_run_id AND
|
||||
json_extract(receipt_json, '$.publicationDigest') =
|
||||
publication_digest AND
|
||||
json_extract(receipt_json, '$.promptId') = prompt_id AND
|
||||
json_extract(receipt_json, '$.admittedAtMs') = admitted_at_ms AND
|
||||
json_extract(receipt_json, '$.receiptDigest') = receipt_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const localPluginPackagePromptAdmissionMigration =
|
||||
defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_ADMISSION_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_ADMISSION_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_ai_prompt_admission_run_step_uidx
|
||||
ON "ModelInvocationPromptAdmissions" (run_id, step_run_id)`,
|
||||
`CREATE INDEX ql3_ai_prompt_admission_target_idx
|
||||
ON "ModelInvocationPromptAdmissions"
|
||||
(project_id, package_name, admitted_at_ms, request_id)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
const LOCAL_PLUGIN_PACKAGE_PROMPT_FINALIZATION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationPromptFinalizations" (
|
||||
request_id TEXT PRIMARY KEY NOT NULL,
|
||||
invocation_id TEXT NOT NULL UNIQUE,
|
||||
plan_digest TEXT NOT NULL UNIQUE,
|
||||
run_id TEXT NOT NULL UNIQUE,
|
||||
step_run_id TEXT NOT NULL UNIQUE,
|
||||
terminal_evidence_kind TEXT NOT NULL,
|
||||
terminal_evidence_digest TEXT NOT NULL UNIQUE,
|
||||
final_step_run_digest TEXT NOT NULL,
|
||||
run_status TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
final_run_version INTEGER NOT NULL,
|
||||
final_run_event_sequence INTEGER NOT NULL,
|
||||
finalized_at_ms INTEGER NOT NULL,
|
||||
receipt_digest TEXT NOT NULL UNIQUE,
|
||||
receipt_json TEXT NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_admission_fk
|
||||
FOREIGN KEY (request_id) REFERENCES "ModelInvocationPromptAdmissions" (
|
||||
request_id
|
||||
) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_invocation_fk
|
||||
FOREIGN KEY (invocation_id) REFERENCES "ModelInvocationPromptAdmissions" (
|
||||
invocation_id
|
||||
) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_plan_fk
|
||||
FOREIGN KEY (plan_digest) REFERENCES "ModelInvocationPromptAdmissions" (
|
||||
plan_digest
|
||||
) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_run_fk
|
||||
FOREIGN KEY (run_id) REFERENCES "Runs" (id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_step_fk
|
||||
FOREIGN KEY (run_id, step_run_id) REFERENCES "StepRuns" (run_id, id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_event_fk
|
||||
FOREIGN KEY (event_id) REFERENCES "RunEvents" (id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_finalization_identity_check CHECK (
|
||||
length(request_id) BETWEEN 1 AND 128 AND
|
||||
length(invocation_id) BETWEEN 1 AND 128 AND
|
||||
length(run_id) BETWEEN 1 AND 128 AND
|
||||
length(step_run_id) BETWEEN 1 AND 128 AND
|
||||
length(event_id) BETWEEN 1 AND 128 AND
|
||||
terminal_evidence_kind IN ('completion', 'resolution') AND
|
||||
run_status IN ('succeeded', 'failed', 'cancelled', 'timed_out') AND
|
||||
final_run_version BETWEEN 3 AND 2147483647 AND
|
||||
final_run_event_sequence = final_run_version AND
|
||||
finalized_at_ms >= 0
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_finalization_digest_check CHECK (
|
||||
length(plan_digest) = 64 AND
|
||||
plan_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(terminal_evidence_digest) = 64 AND
|
||||
terminal_evidence_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(final_step_run_digest) = 64 AND
|
||||
final_step_run_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(receipt_digest) = 64 AND
|
||||
receipt_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_finalization_receipt_json_check CHECK (
|
||||
length(CAST(receipt_json AS BLOB)) BETWEEN 2 AND 16384 AND
|
||||
json_valid(receipt_json) AND json_type(receipt_json) = 'object' AND
|
||||
json_extract(receipt_json, '$.schema') =
|
||||
'qinglong/plugin-package-prompt-finalization-receipt@v1' AND
|
||||
json_extract(receipt_json, '$.requestId') = request_id AND
|
||||
json_extract(receipt_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(receipt_json, '$.planDigest') = plan_digest AND
|
||||
json_extract(receipt_json, '$.runId') = run_id AND
|
||||
json_extract(receipt_json, '$.stepRunId') = step_run_id AND
|
||||
json_extract(receipt_json, '$.terminalEvidenceKind') =
|
||||
terminal_evidence_kind AND
|
||||
json_extract(receipt_json, '$.terminalEvidenceDigest') =
|
||||
terminal_evidence_digest AND
|
||||
json_extract(receipt_json, '$.finalStepRunDigest') =
|
||||
final_step_run_digest AND
|
||||
json_extract(receipt_json, '$.runStatus') = run_status AND
|
||||
json_extract(receipt_json, '$.eventId') = event_id AND
|
||||
json_extract(receipt_json, '$.finalRunVersion') = final_run_version AND
|
||||
json_extract(receipt_json, '$.finalRunEventSequence') =
|
||||
final_run_event_sequence AND
|
||||
json_extract(receipt_json, '$.finalizedAtMs') = finalized_at_ms AND
|
||||
json_extract(receipt_json, '$.receiptDigest') = receipt_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const localPluginPackagePromptFinalizationMigration =
|
||||
defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_FINALIZATION_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_FINALIZATION_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_ai_prompt_finalization_run_step_uidx
|
||||
ON "ModelInvocationPromptFinalizations" (run_id, step_run_id)`,
|
||||
`CREATE INDEX ql3_ai_prompt_finalization_status_idx
|
||||
ON "ModelInvocationPromptFinalizations"
|
||||
(run_status, finalized_at_ms, request_id)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
const LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationPromptOutputArtifacts" (
|
||||
artifact_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
step_run_id TEXT NOT NULL,
|
||||
invocation_id TEXT NOT NULL UNIQUE,
|
||||
requested_by_type TEXT NOT NULL,
|
||||
requested_by_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
output_bytes INTEGER NOT NULL,
|
||||
retention_policy_revision TEXT NOT NULL,
|
||||
retention_ms INTEGER NOT NULL,
|
||||
retention_policy_digest TEXT NOT NULL,
|
||||
retention_eligible_at_ms INTEGER NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
plaintext_bytes INTEGER NOT NULL,
|
||||
sealed_at_ms INTEGER NOT NULL,
|
||||
artifact_digest TEXT NOT NULL UNIQUE,
|
||||
artifact_json TEXT NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_admission_fk
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "ModelInvocationPromptAdmissions" (invocation_id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_start_fk
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "ModelInvocationStarts" (invocation_id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_step_fk
|
||||
FOREIGN KEY (run_id, step_run_id) REFERENCES "StepRuns" (run_id, id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_identity_check CHECK (
|
||||
length(artifact_id) BETWEEN 1 AND 128 AND
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(run_id) BETWEEN 1 AND 128 AND
|
||||
length(step_run_id) BETWEEN 1 AND 128 AND
|
||||
length(invocation_id) BETWEEN 1 AND 128 AND
|
||||
length(requested_by_type) BETWEEN 1 AND 32 AND
|
||||
length(requested_by_id) BETWEEN 1 AND 128 AND
|
||||
length(provider) BETWEEN 1 AND 256 AND
|
||||
length(model) BETWEEN 1 AND 256 AND
|
||||
length(retention_policy_revision) BETWEEN 1 AND 128 AND
|
||||
length(key_id) BETWEEN 1 AND 128 AND
|
||||
algorithm = 'aes-256-gcm'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_value_check CHECK (
|
||||
output_bytes BETWEEN 0 AND 1048576 AND
|
||||
plaintext_bytes BETWEEN 1 AND 1052672 AND
|
||||
retention_ms BETWEEN 3600000 AND 31536000000 AND
|
||||
sealed_at_ms >= 0 AND
|
||||
retention_eligible_at_ms = sealed_at_ms + retention_ms AND
|
||||
length(content_digest) = 64 AND
|
||||
content_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(retention_policy_digest) = 64 AND
|
||||
retention_policy_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(artifact_digest) = 64 AND
|
||||
artifact_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_artifact_json_check CHECK (
|
||||
length(CAST(artifact_json AS BLOB)) BETWEEN 2 AND 1572864 AND
|
||||
json_valid(artifact_json) AND json_type(artifact_json) = 'object' AND
|
||||
json_extract(artifact_json, '$.schema') =
|
||||
'qinglong/plugin-package-prompt-output-artifact@v1' AND
|
||||
json_extract(artifact_json, '$.artifactId') = artifact_id AND
|
||||
json_extract(artifact_json, '$.projectId') = project_id AND
|
||||
json_extract(artifact_json, '$.runId') = run_id AND
|
||||
json_extract(artifact_json, '$.stepRunId') = step_run_id AND
|
||||
json_extract(artifact_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(artifact_json, '$.requestedBy.type') = requested_by_type AND
|
||||
json_extract(artifact_json, '$.requestedBy.id') = requested_by_id AND
|
||||
json_extract(artifact_json, '$.provider') = provider AND
|
||||
json_extract(artifact_json, '$.model') = model AND
|
||||
json_extract(artifact_json, '$.contentDigest') = content_digest AND
|
||||
json_extract(artifact_json, '$.outputBytes') = output_bytes AND
|
||||
json_extract(artifact_json, '$.retentionPolicy.revision') =
|
||||
retention_policy_revision AND
|
||||
json_extract(artifact_json, '$.retentionPolicy.retentionMs') =
|
||||
retention_ms AND
|
||||
json_extract(artifact_json, '$.retentionPolicyDigest') =
|
||||
retention_policy_digest AND
|
||||
json_extract(artifact_json, '$.retentionEligibleAtMs') =
|
||||
retention_eligible_at_ms AND
|
||||
json_extract(artifact_json, '$.keyId') = key_id AND
|
||||
json_extract(artifact_json, '$.algorithm') = algorithm AND
|
||||
json_extract(artifact_json, '$.plaintextBytes') = plaintext_bytes AND
|
||||
json_extract(artifact_json, '$.sealedAtMs') = sealed_at_ms AND
|
||||
json_extract(artifact_json, '$.artifactDigest') = artifact_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const localPluginPackagePromptOutputArtifactMigration =
|
||||
defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_ARTIFACT_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_prompt_output_artifact_retention_idx
|
||||
ON "ModelInvocationPromptOutputArtifacts"
|
||||
(retention_eligible_at_ms, artifact_id)`,
|
||||
`CREATE INDEX ql3_ai_prompt_output_artifact_project_run_idx
|
||||
ON "ModelInvocationPromptOutputArtifacts"
|
||||
(project_id, run_id, artifact_id)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
const LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationPromptOutputArtifactTombstones" (
|
||||
artifact_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
step_run_id TEXT NOT NULL,
|
||||
invocation_id TEXT NOT NULL UNIQUE,
|
||||
artifact_digest TEXT NOT NULL UNIQUE,
|
||||
retention_policy_digest TEXT NOT NULL,
|
||||
retention_eligible_at_ms INTEGER NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
tombstoned_at_ms INTEGER NOT NULL,
|
||||
tombstone_digest TEXT NOT NULL UNIQUE,
|
||||
tombstone_json TEXT NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_admission_fk
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "ModelInvocationPromptAdmissions" (invocation_id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_start_fk
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "ModelInvocationStarts" (invocation_id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_step_fk
|
||||
FOREIGN KEY (run_id, step_run_id) REFERENCES "StepRuns" (run_id, id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_identity_check CHECK (
|
||||
length(artifact_id) BETWEEN 1 AND 128 AND
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(run_id) BETWEEN 1 AND 128 AND
|
||||
length(step_run_id) BETWEEN 1 AND 128 AND
|
||||
length(invocation_id) BETWEEN 1 AND 128 AND
|
||||
length(key_id) BETWEEN 1 AND 128
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_value_check CHECK (
|
||||
retention_eligible_at_ms >= 0 AND
|
||||
tombstoned_at_ms >= retention_eligible_at_ms AND
|
||||
length(artifact_digest) = 64 AND
|
||||
artifact_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(retention_policy_digest) = 64 AND
|
||||
retention_policy_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(tombstone_digest) = 64 AND
|
||||
tombstone_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_tombstone_json_check CHECK (
|
||||
length(CAST(tombstone_json AS BLOB)) BETWEEN 2 AND 8192 AND
|
||||
json_valid(tombstone_json) AND json_type(tombstone_json) = 'object' AND
|
||||
json_extract(tombstone_json, '$.schema') =
|
||||
'qinglong/plugin-package-prompt-output-artifact-tombstone@v1' AND
|
||||
json_extract(tombstone_json, '$.reference.artifactId') = artifact_id AND
|
||||
json_extract(tombstone_json, '$.reference.projectId') = project_id AND
|
||||
json_extract(tombstone_json, '$.reference.runId') = run_id AND
|
||||
json_extract(tombstone_json, '$.reference.stepRunId') = step_run_id AND
|
||||
json_extract(tombstone_json, '$.reference.invocationId') = invocation_id AND
|
||||
json_extract(tombstone_json, '$.reference.artifactDigest') = artifact_digest AND
|
||||
json_extract(tombstone_json, '$.reference.retentionPolicyDigest') =
|
||||
retention_policy_digest AND
|
||||
json_extract(tombstone_json, '$.reference.retentionEligibleAtMs') =
|
||||
retention_eligible_at_ms AND
|
||||
json_extract(tombstone_json, '$.reference.keyId') = key_id AND
|
||||
json_extract(tombstone_json, '$.tombstonedAtMs') = tombstoned_at_ms AND
|
||||
json_extract(tombstone_json, '$.tombstoneDigest') = tombstone_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const localPluginPackagePromptOutputTombstoneMigration =
|
||||
defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_TOMBSTONE_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_prompt_output_tombstone_time_idx
|
||||
ON "ModelInvocationPromptOutputArtifactTombstones"
|
||||
(tombstoned_at_ms, artifact_id)`,
|
||||
`CREATE INDEX ql3_ai_prompt_output_tombstone_project_run_idx
|
||||
ON "ModelInvocationPromptOutputArtifactTombstones"
|
||||
(project_id, run_id, artifact_id)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
const LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_PREPARATION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationPromptOutputKeyRetirementPreparations" (
|
||||
key_id TEXT PRIMARY KEY NOT NULL,
|
||||
retirement_id TEXT NOT NULL UNIQUE,
|
||||
request_id TEXT NOT NULL UNIQUE,
|
||||
mutation_id TEXT NOT NULL UNIQUE,
|
||||
catalog_digest TEXT NOT NULL,
|
||||
material_proof TEXT NOT NULL,
|
||||
prepared_at_ms INTEGER NOT NULL,
|
||||
preparation_digest TEXT NOT NULL UNIQUE,
|
||||
preparation_json TEXT NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_preparation_identity_check CHECK (
|
||||
length(key_id) BETWEEN 1 AND 128 AND
|
||||
length(retirement_id) BETWEEN 1 AND 128 AND
|
||||
length(request_id) BETWEEN 1 AND 128 AND
|
||||
length(mutation_id) BETWEEN 1 AND 128
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_preparation_value_check CHECK (
|
||||
prepared_at_ms >= 0 AND
|
||||
length(catalog_digest) = 64 AND catalog_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(material_proof) = 64 AND material_proof NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(preparation_digest) = 64 AND
|
||||
preparation_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_preparation_json_check CHECK (
|
||||
length(CAST(preparation_json AS BLOB)) BETWEEN 2 AND 8192 AND
|
||||
json_valid(preparation_json) AND json_type(preparation_json) = 'object' AND
|
||||
json_extract(preparation_json, '$.schema') =
|
||||
'qinglong/plugin-package-prompt-output-key-retirement-preparation@v1' AND
|
||||
json_extract(preparation_json, '$.keyId') = key_id AND
|
||||
json_extract(preparation_json, '$.retirementId') = retirement_id AND
|
||||
json_extract(preparation_json, '$.requestId') = request_id AND
|
||||
json_extract(preparation_json, '$.mutationId') = mutation_id AND
|
||||
json_extract(preparation_json, '$.catalogDigest') = catalog_digest AND
|
||||
json_extract(preparation_json, '$.materialProof') = material_proof AND
|
||||
json_extract(preparation_json, '$.preparedAtMs') = prepared_at_ms AND
|
||||
json_extract(preparation_json, '$.preparationDigest') = preparation_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_COMPLETION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationPromptOutputKeyRetirementCompletions" (
|
||||
key_id TEXT PRIMARY KEY NOT NULL,
|
||||
retirement_id TEXT NOT NULL UNIQUE,
|
||||
request_id TEXT NOT NULL UNIQUE,
|
||||
mutation_id TEXT NOT NULL UNIQUE,
|
||||
preparation_digest TEXT NOT NULL UNIQUE,
|
||||
retired_catalog_digest TEXT NOT NULL,
|
||||
absence_proof TEXT NOT NULL,
|
||||
completed_at_ms INTEGER NOT NULL,
|
||||
completion_digest TEXT NOT NULL UNIQUE,
|
||||
completion_json TEXT NOT NULL,
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_completion_preparation_fk
|
||||
FOREIGN KEY (key_id, preparation_digest)
|
||||
REFERENCES "ModelInvocationPromptOutputKeyRetirementPreparations"
|
||||
(key_id, preparation_digest)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_completion_identity_check CHECK (
|
||||
length(key_id) BETWEEN 1 AND 128 AND
|
||||
length(retirement_id) BETWEEN 1 AND 128 AND
|
||||
length(request_id) BETWEEN 1 AND 128 AND
|
||||
length(mutation_id) BETWEEN 1 AND 128
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_completion_value_check CHECK (
|
||||
completed_at_ms >= 0 AND
|
||||
length(preparation_digest) = 64 AND
|
||||
preparation_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(retired_catalog_digest) = 64 AND
|
||||
retired_catalog_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(absence_proof) = 64 AND absence_proof NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(completion_digest) = 64 AND completion_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_ai_prompt_output_key_retirement_completion_json_check CHECK (
|
||||
length(CAST(completion_json AS BLOB)) BETWEEN 2 AND 8192 AND
|
||||
json_valid(completion_json) AND json_type(completion_json) = 'object' AND
|
||||
json_extract(completion_json, '$.schema') =
|
||||
'qinglong/plugin-package-prompt-output-key-retirement-completion@v1' AND
|
||||
json_extract(completion_json, '$.keyId') = key_id AND
|
||||
json_extract(completion_json, '$.retirementId') = retirement_id AND
|
||||
json_extract(completion_json, '$.requestId') = request_id AND
|
||||
json_extract(completion_json, '$.mutationId') = mutation_id AND
|
||||
json_extract(completion_json, '$.preparationDigest') = preparation_digest AND
|
||||
json_extract(completion_json, '$.retiredCatalogDigest') =
|
||||
retired_catalog_digest AND
|
||||
json_extract(completion_json, '$.absenceProof') = absence_proof AND
|
||||
json_extract(completion_json, '$.completedAtMs') = completed_at_ms AND
|
||||
json_extract(completion_json, '$.completionDigest') = completion_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const localPluginPackagePromptOutputKeyRetirementMigration =
|
||||
defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_PREPARATION_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_ai_prompt_output_key_retirement_preparation_fk_uidx
|
||||
ON "ModelInvocationPromptOutputKeyRetirementPreparations"
|
||||
(key_id, preparation_digest)`,
|
||||
LOCAL_PLUGIN_PACKAGE_PROMPT_OUTPUT_KEY_RETIREMENT_COMPLETION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_ai_prompt_output_key_retirement_completion_time_idx
|
||||
ON "ModelInvocationPromptOutputKeyRetirementCompletions"
|
||||
(completed_at_ms, key_id)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
export const sqlitePromptMigrations = Object.freeze([
|
||||
localPluginPackagePromptAdmissionMigration,
|
||||
localPluginPackagePromptFinalizationMigration,
|
||||
localPluginPackagePromptOutputArtifactMigration,
|
||||
localPluginPackagePromptOutputTombstoneMigration,
|
||||
localPluginPackagePromptOutputKeyRetirementMigration,
|
||||
]);
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { LocalMigrationContext } from './context';
|
||||
|
||||
import {
|
||||
LOCAL_MODEL_INVOCATION_USAGE_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_QUOTA_MIGRATION_ID,
|
||||
LOCAL_MODEL_INVOCATION_PRICING_MIGRATION_ID,
|
||||
} from '../identities';
|
||||
|
||||
import { defineSqlMigration } from '../shared';
|
||||
|
||||
const LOCAL_USAGE_LEDGER_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationUsageLedger" (
|
||||
invocation_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
step_run_id TEXT NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
policy_revision TEXT NOT NULL,
|
||||
completion_digest TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
settled_at_ms INTEGER NOT NULL,
|
||||
input_bytes INTEGER NOT NULL,
|
||||
output_bytes INTEGER NOT NULL,
|
||||
input_tokens INTEGER NOT NULL,
|
||||
output_tokens INTEGER NOT NULL,
|
||||
total_tokens INTEGER NOT NULL,
|
||||
cost_micros INTEGER,
|
||||
ledger_digest TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
FOREIGN KEY (invocation_id, completion_digest)
|
||||
REFERENCES "ModelInvocationCompletions"
|
||||
(invocation_id, completion_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_usage_identity_check CHECK (
|
||||
length(invocation_id) BETWEEN 1 AND 128 AND
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(run_id) BETWEEN 1 AND 128 AND
|
||||
length(step_run_id) BETWEEN 1 AND 128 AND
|
||||
length(trace_id) BETWEEN 1 AND 128 AND
|
||||
length(provider) BETWEEN 1 AND 128 AND
|
||||
length(model) BETWEEN 1 AND 128 AND
|
||||
length(policy_revision) BETWEEN 1 AND 128
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_usage_value_check CHECK (
|
||||
outcome IN ('succeeded', 'failed', 'timed_out', 'outcome_unknown') AND
|
||||
settled_at_ms >= 0 AND
|
||||
input_bytes BETWEEN 1 AND 262144 AND
|
||||
output_bytes BETWEEN 0 AND 1048576 AND
|
||||
input_tokens >= 0 AND output_tokens >= 0 AND
|
||||
total_tokens = input_tokens + output_tokens AND
|
||||
(cost_micros IS NULL OR cost_micros >= 0) AND
|
||||
length(completion_digest) = 64 AND
|
||||
completion_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(ledger_digest) = 64 AND
|
||||
ledger_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_usage_json_check CHECK (
|
||||
length(CAST(record_json AS BLOB)) BETWEEN 2 AND 24576 AND
|
||||
json_valid(record_json) AND json_type(record_json) = 'object' AND
|
||||
json_extract(record_json, '$.schema') =
|
||||
'qinglong/model-invocation-usage-ledger@v1' AND
|
||||
json_extract(record_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(record_json, '$.projectId') = project_id AND
|
||||
json_extract(record_json, '$.runId') = run_id AND
|
||||
json_extract(record_json, '$.stepRunId') = step_run_id AND
|
||||
json_extract(record_json, '$.traceId') = trace_id AND
|
||||
json_extract(record_json, '$.provider') = provider AND
|
||||
json_extract(record_json, '$.model') = model AND
|
||||
json_extract(record_json, '$.policyRevision') = policy_revision AND
|
||||
json_extract(record_json, '$.completionDigest') = completion_digest AND
|
||||
json_extract(record_json, '$.outcome') = outcome AND
|
||||
json_extract(record_json, '$.settledAtMs') = settled_at_ms AND
|
||||
json_extract(record_json, '$.inputBytes') = input_bytes AND
|
||||
json_extract(record_json, '$.outputBytes') = output_bytes AND
|
||||
json_extract(record_json, '$.inputTokens') = input_tokens AND
|
||||
json_extract(record_json, '$.outputTokens') = output_tokens AND
|
||||
json_extract(record_json, '$.totalTokens') = total_tokens AND
|
||||
((cost_micros IS NULL AND
|
||||
json_type(record_json, '$.costMicros') = 'null') OR
|
||||
json_extract(record_json, '$.costMicros') = cost_micros) AND
|
||||
json_extract(record_json, '$.ledgerDigest') = ledger_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const localUsageMigration = defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_MODEL_INVOCATION_USAGE_MIGRATION_ID,
|
||||
[
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_completions_identity_digest_uidx
|
||||
ON "ModelInvocationCompletions" (invocation_id, completion_digest)`,
|
||||
LOCAL_USAGE_LEDGER_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_invocation_usage_project_time_idx
|
||||
ON "ModelInvocationUsageLedger"
|
||||
(project_id, settled_at_ms, invocation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_usage_digest_uidx
|
||||
ON "ModelInvocationUsageLedger" (ledger_digest)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
const LOCAL_QUOTA_RESERVATION_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationQuotaReservations" (
|
||||
invocation_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
model_policy_revision TEXT NOT NULL,
|
||||
quota_policy_revision TEXT NOT NULL,
|
||||
window_ms INTEGER NOT NULL,
|
||||
window_start_ms INTEGER NOT NULL,
|
||||
window_end_ms INTEGER NOT NULL,
|
||||
max_invocations INTEGER NOT NULL,
|
||||
max_tokens INTEGER NOT NULL,
|
||||
max_cost_micros INTEGER,
|
||||
reserved_tokens INTEGER NOT NULL,
|
||||
reserved_cost_micros INTEGER,
|
||||
reserved_at_ms INTEGER NOT NULL,
|
||||
admission_digest TEXT NOT NULL,
|
||||
reservation_digest TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "ModelInvocationStarts" (invocation_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_quota_reservation_value_check CHECK (
|
||||
window_ms IN (60000, 3600000, 86400000) AND
|
||||
window_start_ms >= 0 AND window_end_ms = window_start_ms + window_ms AND
|
||||
reserved_at_ms >= window_start_ms AND reserved_at_ms < window_end_ms AND
|
||||
max_invocations BETWEEN 1 AND 100000 AND
|
||||
max_tokens BETWEEN 1 AND 1000000000000 AND
|
||||
(max_cost_micros IS NULL OR
|
||||
max_cost_micros BETWEEN 0 AND 1000000000000000) AND
|
||||
reserved_tokens BETWEEN 1 AND 1000000000000 AND
|
||||
(reserved_cost_micros IS NULL OR
|
||||
reserved_cost_micros BETWEEN 0 AND 1000000000000000) AND
|
||||
(max_cost_micros IS NULL OR reserved_cost_micros IS NOT NULL) AND
|
||||
length(admission_digest) = 64 AND
|
||||
admission_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(reservation_digest) = 64 AND
|
||||
reservation_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_quota_reservation_json_check CHECK (
|
||||
length(CAST(record_json AS BLOB)) BETWEEN 2 AND 24576 AND
|
||||
json_valid(record_json) AND json_type(record_json) = 'object' AND
|
||||
json_extract(record_json, '$.schema') =
|
||||
'qinglong/model-invocation-quota-reservation@v1' AND
|
||||
json_extract(record_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(record_json, '$.projectId') = project_id AND
|
||||
json_extract(record_json, '$.modelPolicyRevision') =
|
||||
model_policy_revision AND
|
||||
json_extract(record_json, '$.quotaPolicyRevision') =
|
||||
quota_policy_revision AND
|
||||
json_extract(record_json, '$.windowMs') = window_ms AND
|
||||
json_extract(record_json, '$.windowStartMs') = window_start_ms AND
|
||||
json_extract(record_json, '$.windowEndMs') = window_end_ms AND
|
||||
json_extract(record_json, '$.maxInvocations') = max_invocations AND
|
||||
json_extract(record_json, '$.maxTokens') = max_tokens AND
|
||||
((max_cost_micros IS NULL AND
|
||||
json_type(record_json, '$.maxCostMicros') = 'null') OR
|
||||
json_extract(record_json, '$.maxCostMicros') = max_cost_micros) AND
|
||||
json_extract(record_json, '$.reservedTokens') = reserved_tokens AND
|
||||
((reserved_cost_micros IS NULL AND
|
||||
json_type(record_json, '$.reservedCostMicros') = 'null') OR
|
||||
json_extract(record_json, '$.reservedCostMicros') =
|
||||
reserved_cost_micros) AND
|
||||
json_extract(record_json, '$.reservedAtMs') = reserved_at_ms AND
|
||||
json_extract(record_json, '$.admissionDigest') = admission_digest AND
|
||||
json_extract(record_json, '$.reservationDigest') = reservation_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const LOCAL_QUOTA_SETTLEMENT_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationQuotaSettlements" (
|
||||
invocation_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
reservation_digest TEXT NOT NULL,
|
||||
completion_digest TEXT NOT NULL,
|
||||
effective_tokens INTEGER NOT NULL,
|
||||
effective_cost_micros INTEGER,
|
||||
retained_token_reservation INTEGER NOT NULL,
|
||||
retained_cost_reservation INTEGER NOT NULL,
|
||||
settled_at_ms INTEGER NOT NULL,
|
||||
settlement_digest TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "ModelInvocationQuotaReservations" (invocation_id)
|
||||
ON DELETE RESTRICT,
|
||||
FOREIGN KEY (invocation_id, completion_digest)
|
||||
REFERENCES "ModelInvocationCompletions"
|
||||
(invocation_id, completion_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_quota_settlement_value_check CHECK (
|
||||
effective_tokens BETWEEN 0 AND 1000000000000 AND
|
||||
(effective_cost_micros IS NULL OR
|
||||
effective_cost_micros BETWEEN 0 AND 1000000000000000) AND
|
||||
retained_token_reservation IN (0, 1) AND
|
||||
retained_cost_reservation IN (0, 1) AND
|
||||
settled_at_ms >= 0 AND
|
||||
length(reservation_digest) = 64 AND
|
||||
reservation_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(completion_digest) = 64 AND
|
||||
completion_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(settlement_digest) = 64 AND
|
||||
settlement_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_quota_settlement_json_check CHECK (
|
||||
length(CAST(record_json AS BLOB)) BETWEEN 2 AND 24576 AND
|
||||
json_valid(record_json) AND json_type(record_json) = 'object' AND
|
||||
json_extract(record_json, '$.schema') =
|
||||
'qinglong/model-invocation-quota-settlement@v1' AND
|
||||
json_extract(record_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(record_json, '$.projectId') = project_id AND
|
||||
json_extract(record_json, '$.reservationDigest') =
|
||||
reservation_digest AND
|
||||
json_extract(record_json, '$.completionDigest') = completion_digest AND
|
||||
json_extract(record_json, '$.effectiveTokens') = effective_tokens AND
|
||||
((effective_cost_micros IS NULL AND
|
||||
json_type(record_json, '$.effectiveCostMicros') = 'null') OR
|
||||
json_extract(record_json, '$.effectiveCostMicros') =
|
||||
effective_cost_micros) AND
|
||||
json_extract(record_json, '$.retainedTokenReservation') =
|
||||
retained_token_reservation AND
|
||||
json_extract(record_json, '$.retainedCostReservation') =
|
||||
retained_cost_reservation AND
|
||||
json_extract(record_json, '$.settledAtMs') = settled_at_ms AND
|
||||
json_extract(record_json, '$.settlementDigest') = settlement_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const localQuotaMigration = defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_MODEL_INVOCATION_QUOTA_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_QUOTA_RESERVATION_TABLE_SQL,
|
||||
`CREATE INDEX ql3_model_invocation_quota_reservation_window_idx
|
||||
ON "ModelInvocationQuotaReservations"
|
||||
(project_id, window_start_ms, invocation_id)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_quota_reservation_digest_uidx
|
||||
ON "ModelInvocationQuotaReservations" (reservation_digest)`,
|
||||
LOCAL_QUOTA_SETTLEMENT_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_quota_settlement_digest_uidx
|
||||
ON "ModelInvocationQuotaSettlements" (settlement_digest)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
const LOCAL_PRICE_QUOTE_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationPriceQuotes" (
|
||||
invocation_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
model_policy_revision TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
price_revision TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
input_micros_per_million_tokens INTEGER NOT NULL,
|
||||
output_micros_per_million_tokens INTEGER NOT NULL,
|
||||
max_total_tokens INTEGER NOT NULL,
|
||||
max_output_tokens INTEGER NOT NULL,
|
||||
reserved_cost_micros INTEGER NOT NULL,
|
||||
catalog_digest TEXT NOT NULL,
|
||||
quote_digest TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
FOREIGN KEY (invocation_id)
|
||||
REFERENCES "ModelInvocationStarts" (invocation_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_price_quote_identity_check CHECK (
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(model_policy_revision) BETWEEN 1 AND 128 AND
|
||||
length(provider) BETWEEN 1 AND 128 AND
|
||||
length(model) BETWEEN 1 AND 128 AND
|
||||
length(price_revision) BETWEEN 1 AND 128
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_price_quote_value_check CHECK (
|
||||
currency = 'USD' AND
|
||||
input_micros_per_million_tokens BETWEEN 0 AND 1000000000000 AND
|
||||
output_micros_per_million_tokens BETWEEN 0 AND 1000000000000 AND
|
||||
max_total_tokens BETWEEN 1 AND 1000000000000 AND
|
||||
max_output_tokens BETWEEN 1 AND max_total_tokens AND
|
||||
reserved_cost_micros BETWEEN 0 AND 1000000000000000 AND
|
||||
length(catalog_digest) = 64 AND
|
||||
catalog_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(quote_digest) = 64 AND
|
||||
quote_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_price_quote_json_check CHECK (
|
||||
length(CAST(record_json AS BLOB)) BETWEEN 2 AND 24576 AND
|
||||
json_valid(record_json) AND json_type(record_json) = 'object' AND
|
||||
json_extract(record_json, '$.schema') =
|
||||
'qinglong/model-invocation-price-quote@v1' AND
|
||||
json_extract(record_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(record_json, '$.projectId') = project_id AND
|
||||
json_extract(record_json, '$.modelPolicyRevision') =
|
||||
model_policy_revision AND
|
||||
json_extract(record_json, '$.provider') = provider AND
|
||||
json_extract(record_json, '$.model') = model AND
|
||||
json_extract(record_json, '$.priceRevision') = price_revision AND
|
||||
json_extract(record_json, '$.currency') = currency AND
|
||||
json_extract(record_json, '$.inputMicrosPerMillionTokens') =
|
||||
input_micros_per_million_tokens AND
|
||||
json_extract(record_json, '$.outputMicrosPerMillionTokens') =
|
||||
output_micros_per_million_tokens AND
|
||||
json_extract(record_json, '$.maxTotalTokens') = max_total_tokens AND
|
||||
json_extract(record_json, '$.maxOutputTokens') = max_output_tokens AND
|
||||
json_extract(record_json, '$.reservedCostMicros') =
|
||||
reserved_cost_micros AND
|
||||
json_extract(record_json, '$.catalogDigest') = catalog_digest AND
|
||||
json_extract(record_json, '$.quoteDigest') = quote_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const LOCAL_PRICE_SETTLEMENT_TABLE_SQL = `
|
||||
CREATE TABLE "ModelInvocationPriceSettlements" (
|
||||
invocation_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
quote_digest TEXT NOT NULL,
|
||||
completion_digest TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL,
|
||||
output_tokens INTEGER NOT NULL,
|
||||
cost_micros INTEGER NOT NULL,
|
||||
settled_at_ms INTEGER NOT NULL,
|
||||
settlement_digest TEXT NOT NULL,
|
||||
record_json TEXT NOT NULL,
|
||||
FOREIGN KEY (invocation_id, quote_digest)
|
||||
REFERENCES "ModelInvocationPriceQuotes"
|
||||
(invocation_id, quote_digest) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (invocation_id, completion_digest)
|
||||
REFERENCES "ModelInvocationCompletions"
|
||||
(invocation_id, completion_digest) ON DELETE RESTRICT,
|
||||
CONSTRAINT ql3_model_invocation_price_settlement_value_check CHECK (
|
||||
currency = 'USD' AND
|
||||
input_tokens >= 0 AND output_tokens >= 0 AND
|
||||
cost_micros BETWEEN 0 AND 1000000000000000 AND
|
||||
settled_at_ms >= 0 AND
|
||||
length(quote_digest) = 64 AND
|
||||
quote_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(completion_digest) = 64 AND
|
||||
completion_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(settlement_digest) = 64 AND
|
||||
settlement_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_model_invocation_price_settlement_json_check CHECK (
|
||||
length(CAST(record_json AS BLOB)) BETWEEN 2 AND 24576 AND
|
||||
json_valid(record_json) AND json_type(record_json) = 'object' AND
|
||||
json_extract(record_json, '$.schema') =
|
||||
'qinglong/model-invocation-price-settlement@v1' AND
|
||||
json_extract(record_json, '$.invocationId') = invocation_id AND
|
||||
json_extract(record_json, '$.projectId') = project_id AND
|
||||
json_extract(record_json, '$.quoteDigest') = quote_digest AND
|
||||
json_extract(record_json, '$.completionDigest') =
|
||||
completion_digest AND
|
||||
json_extract(record_json, '$.currency') = currency AND
|
||||
json_extract(record_json, '$.inputTokens') = input_tokens AND
|
||||
json_extract(record_json, '$.outputTokens') = output_tokens AND
|
||||
json_extract(record_json, '$.costMicros') = cost_micros AND
|
||||
json_extract(record_json, '$.settledAtMs') = settled_at_ms AND
|
||||
json_extract(record_json, '$.settlementDigest') = settlement_digest
|
||||
)
|
||||
)`;
|
||||
|
||||
const localPricingMigration = defineSqlMigration<LocalMigrationContext>(
|
||||
LOCAL_MODEL_INVOCATION_PRICING_MIGRATION_ID,
|
||||
[
|
||||
LOCAL_PRICE_QUOTE_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_price_quote_identity_digest_uidx
|
||||
ON "ModelInvocationPriceQuotes" (invocation_id, quote_digest)`,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_price_quote_digest_uidx
|
||||
ON "ModelInvocationPriceQuotes" (quote_digest)`,
|
||||
LOCAL_PRICE_SETTLEMENT_TABLE_SQL,
|
||||
`CREATE UNIQUE INDEX ql3_model_invocation_price_settlement_digest_uidx
|
||||
ON "ModelInvocationPriceSettlements" (settlement_digest)`,
|
||||
],
|
||||
(context, statement) => context.client.exec(statement),
|
||||
);
|
||||
|
||||
export const sqliteUsagePricingMigrations = Object.freeze([
|
||||
localUsageMigration,
|
||||
localQuotaMigration,
|
||||
localPricingMigration,
|
||||
]);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './model-invocation/identities';
|
||||
export * from './model-invocation/postgres';
|
||||
export * from './model-invocation/sqlite';
|
||||
@@ -0,0 +1,769 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
MAX_MODEL_PROVIDERS,
|
||||
type GenerateRequest,
|
||||
type GenerateResult,
|
||||
type ModelChunk,
|
||||
type ModelInvocationAuditRecord,
|
||||
type ModelInvocationAuditResult,
|
||||
type ModelInvocationAuditSink,
|
||||
type ModelInvocationContext,
|
||||
type ModelInvocationPolicy,
|
||||
type ModelInvocationPolicyProvider,
|
||||
type ModelProvider,
|
||||
type ModelUsage,
|
||||
} from './model';
|
||||
import {
|
||||
InvalidModelValueError,
|
||||
measureModelInputBytes,
|
||||
normalizeGenerateRequest,
|
||||
normalizeGenerateResult,
|
||||
normalizeModelChunk,
|
||||
normalizeModelInvocationContext,
|
||||
normalizeModelInvocationPolicy,
|
||||
} from './validation';
|
||||
import {
|
||||
ModelInvocationProjectQuotaExceededError,
|
||||
ModelInvocationQuotaConfigurationError,
|
||||
createModelInvocationQuotaAdmission,
|
||||
} from '../usage/usageQuota';
|
||||
import {
|
||||
ModelPriceUnavailableError,
|
||||
ModelPricingConfigurationError,
|
||||
createModelInvocationPriceQuote,
|
||||
normalizeModelPriceCatalogEntry,
|
||||
priceModelUsage,
|
||||
type ModelInvocationPriceQuote,
|
||||
type ModelPriceCatalogResolver,
|
||||
} from '../pricing/pricing';
|
||||
|
||||
export const MAX_MODEL_GATEWAY_CONCURRENCY = 64;
|
||||
|
||||
export class ModelProviderUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('The requested model provider is unavailable');
|
||||
this.name = 'ModelProviderUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPolicyDeniedError extends Error {
|
||||
readonly code = 'MODEL_POLICY_DENIED';
|
||||
|
||||
constructor() {
|
||||
super('The model invocation is denied by policy');
|
||||
this.name = 'ModelPolicyDeniedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelBudgetExceededError extends Error {
|
||||
readonly code = 'MODEL_BUDGET_EXCEEDED';
|
||||
|
||||
constructor() {
|
||||
super('The model invocation exceeded its bounded budget');
|
||||
this.name = 'ModelBudgetExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelGatewayBusyError extends Error {
|
||||
readonly code = 'MODEL_GATEWAY_BUSY';
|
||||
|
||||
constructor() {
|
||||
super('The model gateway concurrency budget is exhausted');
|
||||
this.name = 'ModelGatewayBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelInvocationAbortedError extends Error {
|
||||
readonly code = 'MODEL_INVOCATION_ABORTED';
|
||||
|
||||
constructor() {
|
||||
super('The model invocation was aborted');
|
||||
this.name = 'ModelInvocationAbortedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelInvocationDeadlineExceededError extends Error {
|
||||
readonly code = 'MODEL_INVOCATION_DEADLINE_EXCEEDED';
|
||||
|
||||
constructor() {
|
||||
super('The model invocation deadline was exceeded');
|
||||
this.name = 'ModelInvocationDeadlineExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelInvocationReplayBlockedError extends Error {
|
||||
readonly code = 'MODEL_INVOCATION_REPLAY_BLOCKED';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'An existing model invocation cannot be executed again automatically',
|
||||
);
|
||||
this.name = 'ModelInvocationReplayBlockedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelAuditUnavailableError extends Error {
|
||||
readonly code = 'MODEL_AUDIT_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('The model invocation audit sink is unavailable', options);
|
||||
this.name = 'ModelAuditUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface BoundedModelGatewayOptions {
|
||||
readonly providers: readonly ModelProvider[];
|
||||
readonly policies: ModelInvocationPolicyProvider;
|
||||
readonly pricing: ModelPriceCatalogResolver;
|
||||
readonly audit: ModelInvocationAuditSink;
|
||||
readonly successfulCompletion?: ModelInvocationSuccessfulCompletionSink;
|
||||
readonly maxConcurrent: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface ModelInvocationSuccessfulCompletionSink {
|
||||
record(
|
||||
audit: Readonly<ModelInvocationAuditRecord>,
|
||||
result: Readonly<GenerateResult>,
|
||||
): Promise<
|
||||
Readonly<
|
||||
| { handled: false }
|
||||
| {
|
||||
handled: true;
|
||||
disposition: ModelInvocationAuditResult;
|
||||
}
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
interface PreparedInvocation {
|
||||
readonly request: Readonly<GenerateRequest>;
|
||||
readonly context: Readonly<ModelInvocationContext>;
|
||||
readonly policy: Readonly<ModelInvocationPolicy>;
|
||||
readonly provider: ModelProvider;
|
||||
readonly requestDigest: string;
|
||||
readonly inputBytes: number;
|
||||
readonly priceQuote: Readonly<ModelInvocationPriceQuote> | null;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
/^[A-Z][A-Z0-9_]{0,63}$/.test(error.code)
|
||||
) {
|
||||
return error.code;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return 'MODEL_INVOCATION_ABORTED';
|
||||
}
|
||||
return 'MODEL_PROVIDER_FAILED';
|
||||
}
|
||||
|
||||
function createRequestDigest(request: Readonly<GenerateRequest>): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update('qinglong/model-invocation-request@v1\0', 'utf8');
|
||||
hash.update(request.provider, 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
hash.update(request.model, 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
hash.update(String(request.maxOutputTokens), 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
hash.update(
|
||||
request.temperature === undefined ? '' : String(request.temperature),
|
||||
);
|
||||
for (const message of request.messages) {
|
||||
hash.update('\0', 'utf8');
|
||||
hash.update(message.role, 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
hash.update(message.content, 'utf8');
|
||||
}
|
||||
return `sha256:${hash.digest('hex')}`;
|
||||
}
|
||||
|
||||
function assertWithinPolicy(
|
||||
request: Readonly<GenerateRequest>,
|
||||
inputBytes: number,
|
||||
policy: Readonly<ModelInvocationPolicy>,
|
||||
): void {
|
||||
if (
|
||||
!policy.allowedProviders.includes(request.provider) ||
|
||||
!policy.allowedModels.includes(request.model)
|
||||
) {
|
||||
throw new ModelPolicyDeniedError();
|
||||
}
|
||||
if (
|
||||
inputBytes > policy.maxInputBytes ||
|
||||
request.maxOutputTokens > policy.maxOutputTokens
|
||||
) {
|
||||
throw new ModelBudgetExceededError();
|
||||
}
|
||||
}
|
||||
|
||||
function assertUsageWithinPolicy(
|
||||
usage: Readonly<ModelUsage>,
|
||||
policy: Readonly<ModelInvocationPolicy>,
|
||||
): void {
|
||||
if (
|
||||
usage.outputTokens > policy.maxOutputTokens ||
|
||||
usage.totalTokens > policy.maxTotalTokens ||
|
||||
(policy.maxCostMicros !== null &&
|
||||
(usage.costMicros === undefined ||
|
||||
usage.costMicros > policy.maxCostMicros))
|
||||
) {
|
||||
throw new ModelBudgetExceededError();
|
||||
}
|
||||
}
|
||||
|
||||
function createInvocationAbort(
|
||||
context: Readonly<ModelInvocationContext>,
|
||||
nowMs: number,
|
||||
): Readonly<{
|
||||
signal: AbortSignal;
|
||||
error(): ModelInvocationAbortedError | ModelInvocationDeadlineExceededError;
|
||||
cancel(): void;
|
||||
dispose(): void;
|
||||
}> {
|
||||
const controller = new AbortController();
|
||||
let cause: 'caller' | 'deadline' | null = null;
|
||||
const abortFromCaller = (): void => {
|
||||
if (cause === null) cause = 'caller';
|
||||
controller.abort();
|
||||
};
|
||||
const abortFromDeadline = (): void => {
|
||||
if (cause === null) cause = 'deadline';
|
||||
controller.abort();
|
||||
};
|
||||
context.signal?.addEventListener('abort', abortFromCaller, { once: true });
|
||||
if (context.signal?.aborted) abortFromCaller();
|
||||
const timer = setTimeout(
|
||||
abortFromDeadline,
|
||||
Math.max(1, context.deadlineAtMs - nowMs),
|
||||
);
|
||||
timer.unref();
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
error():
|
||||
| ModelInvocationAbortedError
|
||||
| ModelInvocationDeadlineExceededError {
|
||||
return cause === 'deadline'
|
||||
? new ModelInvocationDeadlineExceededError()
|
||||
: new ModelInvocationAbortedError();
|
||||
},
|
||||
cancel(): void {
|
||||
abortFromCaller();
|
||||
},
|
||||
dispose(): void {
|
||||
clearTimeout(timer);
|
||||
context.signal?.removeEventListener('abort', abortFromCaller);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function withSignal(
|
||||
context: Readonly<ModelInvocationContext>,
|
||||
signal: AbortSignal,
|
||||
): Readonly<ModelInvocationContext> {
|
||||
return Object.freeze({
|
||||
projectId: context.projectId,
|
||||
runId: context.runId,
|
||||
stepRunId: context.stepRunId,
|
||||
traceId: context.traceId,
|
||||
requestId: context.requestId,
|
||||
deadlineAtMs: context.deadlineAtMs,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
function awaitAbortable<T>(
|
||||
operation: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
abortError: () => Error = () => new ModelInvocationAbortedError(),
|
||||
): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(abortError());
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abort = (): void => reject(abortError());
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export class BoundedModelGateway {
|
||||
readonly #providers: ReadonlyMap<string, ModelProvider>;
|
||||
readonly #policies: ModelInvocationPolicyProvider;
|
||||
readonly #pricing: ModelPriceCatalogResolver;
|
||||
readonly #audit: ModelInvocationAuditSink;
|
||||
readonly #successfulCompletion:
|
||||
| ModelInvocationSuccessfulCompletionSink
|
||||
| undefined;
|
||||
readonly #maxConcurrent: number;
|
||||
readonly #now: () => number;
|
||||
#active = 0;
|
||||
|
||||
constructor(options: BoundedModelGatewayOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!Array.isArray(options.providers) ||
|
||||
options.providers.length < 1 ||
|
||||
options.providers.length > MAX_MODEL_PROVIDERS ||
|
||||
!options.policies ||
|
||||
typeof options.policies.resolve !== 'function' ||
|
||||
!options.pricing ||
|
||||
typeof options.pricing.resolve !== 'function' ||
|
||||
!options.audit ||
|
||||
typeof options.audit.record !== 'function' ||
|
||||
(options.successfulCompletion !== undefined &&
|
||||
(!options.successfulCompletion ||
|
||||
typeof options.successfulCompletion.record !== 'function')) ||
|
||||
!Number.isSafeInteger(options.maxConcurrent) ||
|
||||
options.maxConcurrent < 1 ||
|
||||
options.maxConcurrent > MAX_MODEL_GATEWAY_CONCURRENCY ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new InvalidModelValueError('gateway options are invalid');
|
||||
}
|
||||
const providers = new Map<string, ModelProvider>();
|
||||
for (const provider of options.providers) {
|
||||
if (
|
||||
!provider ||
|
||||
typeof provider.type !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(provider.type) ||
|
||||
typeof provider.generate !== 'function' ||
|
||||
typeof provider.stream !== 'function' ||
|
||||
typeof provider.listModels !== 'function' ||
|
||||
providers.has(provider.type)
|
||||
) {
|
||||
throw new InvalidModelValueError('provider registry is invalid');
|
||||
}
|
||||
providers.set(provider.type, provider);
|
||||
}
|
||||
this.#providers = providers;
|
||||
this.#policies = options.policies;
|
||||
this.#pricing = options.pricing;
|
||||
this.#audit = options.audit;
|
||||
this.#successfulCompletion = options.successfulCompletion;
|
||||
this.#maxConcurrent = options.maxConcurrent;
|
||||
this.#now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
get activeInvocations(): number {
|
||||
return this.#active;
|
||||
}
|
||||
|
||||
supportsSuccessfulCompletionSink(
|
||||
sink: ModelInvocationSuccessfulCompletionSink,
|
||||
): boolean {
|
||||
return this.#successfulCompletion === sink;
|
||||
}
|
||||
|
||||
async #prepare(
|
||||
request: Readonly<GenerateRequest>,
|
||||
context: Readonly<ModelInvocationContext>,
|
||||
signal: AbortSignal,
|
||||
abortError: () => Error,
|
||||
): Promise<PreparedInvocation> {
|
||||
const invocationContext = withSignal(context, signal);
|
||||
const inputBytes = measureModelInputBytes(request.messages);
|
||||
const policy = normalizeModelInvocationPolicy(
|
||||
await awaitAbortable(
|
||||
this.#policies.resolve(invocationContext),
|
||||
signal,
|
||||
abortError,
|
||||
),
|
||||
);
|
||||
assertWithinPolicy(request, inputBytes, policy);
|
||||
const provider = this.#providers.get(request.provider);
|
||||
if (!provider) throw new ModelProviderUnavailableError();
|
||||
const priceRevision = policy.priceRevision;
|
||||
const priceQuote =
|
||||
priceRevision === null
|
||||
? null
|
||||
: await (async () => {
|
||||
const entryValue = await awaitAbortable(
|
||||
this.#pricing.resolve({
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
priceRevision,
|
||||
signal,
|
||||
}),
|
||||
signal,
|
||||
abortError,
|
||||
);
|
||||
if (!entryValue) throw new ModelPriceUnavailableError();
|
||||
const entry = normalizeModelPriceCatalogEntry(entryValue);
|
||||
if (
|
||||
entry.provider !== request.provider ||
|
||||
entry.model !== request.model ||
|
||||
entry.priceRevision !== priceRevision
|
||||
) {
|
||||
throw new ModelPriceUnavailableError();
|
||||
}
|
||||
const quote = createModelInvocationPriceQuote(entry, {
|
||||
invocationId: context.requestId,
|
||||
projectId: context.projectId,
|
||||
modelPolicyRevision: policy.revision,
|
||||
maxTotalTokens: policy.maxTotalTokens,
|
||||
maxOutputTokens: request.maxOutputTokens,
|
||||
});
|
||||
if (
|
||||
policy.maxCostMicros !== null &&
|
||||
quote.reservedCostMicros > policy.maxCostMicros
|
||||
) {
|
||||
throw new ModelBudgetExceededError();
|
||||
}
|
||||
return quote;
|
||||
})();
|
||||
if (
|
||||
priceQuote === null &&
|
||||
(policy.maxCostMicros !== null ||
|
||||
(policy.projectQuota !== undefined &&
|
||||
policy.projectQuota.maxCostMicros !== null))
|
||||
) {
|
||||
throw new ModelPriceUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
request,
|
||||
context: invocationContext,
|
||||
policy,
|
||||
provider,
|
||||
requestDigest: createRequestDigest(request),
|
||||
inputBytes,
|
||||
priceQuote,
|
||||
});
|
||||
}
|
||||
|
||||
async #record(
|
||||
prepared: PreparedInvocation,
|
||||
phase: ModelInvocationAuditRecord['phase'],
|
||||
outputBytes: number,
|
||||
usage: Readonly<ModelUsage> | null,
|
||||
invocationErrorCode: string | null,
|
||||
result?: Readonly<GenerateResult>,
|
||||
): Promise<ModelInvocationAuditResult> {
|
||||
try {
|
||||
const auditRecord = Object.freeze({
|
||||
phase,
|
||||
projectId: prepared.context.projectId,
|
||||
runId: prepared.context.runId,
|
||||
stepRunId: prepared.context.stepRunId,
|
||||
traceId: prepared.context.traceId,
|
||||
requestId: prepared.context.requestId,
|
||||
provider: prepared.request.provider,
|
||||
model: prepared.request.model,
|
||||
policyRevision: prepared.policy.revision,
|
||||
requestDigest: prepared.requestDigest,
|
||||
deadlineAtMs: prepared.context.deadlineAtMs,
|
||||
inputBytes: prepared.inputBytes,
|
||||
maxOutputTokens: prepared.request.maxOutputTokens,
|
||||
outputBytes,
|
||||
usage,
|
||||
errorCode: invocationErrorCode,
|
||||
occurredAtMs: this.#now(),
|
||||
});
|
||||
const quotaAdmission =
|
||||
phase === 'admitted' && prepared.policy.projectQuota
|
||||
? createModelInvocationQuotaAdmission({
|
||||
invocationId: prepared.context.requestId,
|
||||
projectId: prepared.context.projectId,
|
||||
modelPolicyRevision: prepared.policy.revision,
|
||||
reservedTokens: prepared.policy.maxTotalTokens,
|
||||
reservedCostMicros:
|
||||
prepared.policy.projectQuota.maxCostMicros === null
|
||||
? null
|
||||
: prepared.priceQuote?.reservedCostMicros ?? null,
|
||||
quota: prepared.policy.projectQuota,
|
||||
})
|
||||
: undefined;
|
||||
const successfulCompletion =
|
||||
phase === 'completed' && result && this.#successfulCompletion
|
||||
? await this.#successfulCompletion.record(auditRecord, result)
|
||||
: undefined;
|
||||
if (
|
||||
successfulCompletion !== undefined &&
|
||||
(!successfulCompletion ||
|
||||
typeof successfulCompletion !== 'object' ||
|
||||
Array.isArray(successfulCompletion) ||
|
||||
(successfulCompletion.handled !== true &&
|
||||
successfulCompletion.handled !== false) ||
|
||||
(successfulCompletion.handled === false &&
|
||||
Object.keys(successfulCompletion).length !== 1) ||
|
||||
(successfulCompletion.handled === true &&
|
||||
Object.keys(successfulCompletion).sort().join('\0') !==
|
||||
['disposition', 'handled'].join('\0')))
|
||||
) {
|
||||
throw new TypeError('Successful completion disposition is invalid');
|
||||
}
|
||||
const disposition =
|
||||
successfulCompletion?.handled === true
|
||||
? successfulCompletion.disposition
|
||||
: phase === 'admitted' && prepared.priceQuote
|
||||
? await (() => {
|
||||
if (typeof this.#audit.recordWithPricing !== 'function') {
|
||||
throw new ModelPricingConfigurationError();
|
||||
}
|
||||
return this.#audit.recordWithPricing(
|
||||
auditRecord,
|
||||
prepared.priceQuote,
|
||||
quotaAdmission,
|
||||
);
|
||||
})()
|
||||
: quotaAdmission
|
||||
? await (() => {
|
||||
if (typeof this.#audit.recordWithQuota !== 'function') {
|
||||
throw new ModelInvocationQuotaConfigurationError();
|
||||
}
|
||||
return this.#audit.recordWithQuota(auditRecord, quotaAdmission);
|
||||
})()
|
||||
: await this.#audit.record(auditRecord);
|
||||
if (disposition === undefined) return undefined;
|
||||
if (
|
||||
disposition &&
|
||||
typeof disposition === 'object' &&
|
||||
!Array.isArray(disposition) &&
|
||||
(disposition.status === 'created' ||
|
||||
disposition.status === 'existing') &&
|
||||
Object.keys(disposition).length === 1
|
||||
) {
|
||||
return Object.freeze({ status: disposition.status });
|
||||
}
|
||||
throw new TypeError('Model audit disposition is invalid');
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof ModelInvocationProjectQuotaExceededError ||
|
||||
cause instanceof ModelInvocationQuotaConfigurationError ||
|
||||
cause instanceof ModelPricingConfigurationError
|
||||
) {
|
||||
throw cause;
|
||||
}
|
||||
throw new ModelAuditUnavailableError({ cause });
|
||||
}
|
||||
}
|
||||
|
||||
async generate(
|
||||
request: GenerateRequest,
|
||||
context: ModelInvocationContext,
|
||||
): Promise<Readonly<GenerateResult>> {
|
||||
const nowMs = this.#now();
|
||||
const normalizedRequest = normalizeGenerateRequest(request);
|
||||
const normalizedContext = normalizeModelInvocationContext(context, nowMs);
|
||||
if (this.#active >= this.#maxConcurrent) {
|
||||
throw new ModelGatewayBusyError();
|
||||
}
|
||||
this.#active += 1;
|
||||
const abort = createInvocationAbort(normalizedContext, nowMs);
|
||||
let prepared: PreparedInvocation | undefined;
|
||||
let admitted = false;
|
||||
let outputBytes = 0;
|
||||
let usage: Readonly<ModelUsage> | null = null;
|
||||
try {
|
||||
prepared = await this.#prepare(
|
||||
normalizedRequest,
|
||||
normalizedContext,
|
||||
abort.signal,
|
||||
() => abort.error(),
|
||||
);
|
||||
const admission = await this.#record(prepared, 'admitted', 0, null, null);
|
||||
if (admission?.status === 'existing') {
|
||||
throw new ModelInvocationReplayBlockedError();
|
||||
}
|
||||
admitted = true;
|
||||
if (abort.signal.aborted) throw abort.error();
|
||||
const providerResult = normalizeGenerateResult(
|
||||
await awaitAbortable(
|
||||
prepared.provider.generate(prepared.request, prepared.context),
|
||||
abort.signal,
|
||||
() => abort.error(),
|
||||
),
|
||||
);
|
||||
const result = prepared.priceQuote
|
||||
? Object.freeze({
|
||||
...providerResult,
|
||||
usage: priceModelUsage(prepared.priceQuote, providerResult.usage),
|
||||
})
|
||||
: providerResult;
|
||||
if (
|
||||
result.provider !== prepared.request.provider ||
|
||||
result.model !== prepared.request.model
|
||||
) {
|
||||
throw new InvalidModelValueError(
|
||||
'provider result identity does not match the request',
|
||||
);
|
||||
}
|
||||
outputBytes = Buffer.byteLength(result.text, 'utf8');
|
||||
usage = result.usage;
|
||||
if (outputBytes > prepared.policy.maxOutputBytes) {
|
||||
throw new ModelBudgetExceededError();
|
||||
}
|
||||
assertUsageWithinPolicy(usage, prepared.policy);
|
||||
await this.#record(
|
||||
prepared,
|
||||
'completed',
|
||||
outputBytes,
|
||||
usage,
|
||||
null,
|
||||
result,
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const normalizedError =
|
||||
abort.signal.aborted &&
|
||||
!(error instanceof ModelAuditUnavailableError) &&
|
||||
!(error instanceof ModelBudgetExceededError)
|
||||
? abort.error()
|
||||
: error;
|
||||
if (
|
||||
prepared &&
|
||||
admitted &&
|
||||
!(normalizedError instanceof ModelAuditUnavailableError)
|
||||
) {
|
||||
await this.#record(
|
||||
prepared,
|
||||
'failed',
|
||||
outputBytes,
|
||||
usage,
|
||||
errorCode(normalizedError),
|
||||
);
|
||||
}
|
||||
throw normalizedError;
|
||||
} finally {
|
||||
abort.dispose();
|
||||
this.#active -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
async *stream(
|
||||
request: GenerateRequest,
|
||||
context: ModelInvocationContext,
|
||||
): AsyncIterable<Readonly<ModelChunk>> {
|
||||
const nowMs = this.#now();
|
||||
const normalizedRequest = normalizeGenerateRequest(request);
|
||||
const normalizedContext = normalizeModelInvocationContext(context, nowMs);
|
||||
if (this.#active >= this.#maxConcurrent) {
|
||||
throw new ModelGatewayBusyError();
|
||||
}
|
||||
this.#active += 1;
|
||||
const abort = createInvocationAbort(normalizedContext, nowMs);
|
||||
let prepared: PreparedInvocation | undefined;
|
||||
let iterator: AsyncIterator<Readonly<ModelChunk>> | undefined;
|
||||
let admitted = false;
|
||||
let outputBytes = 0;
|
||||
let usage: Readonly<ModelUsage> | null = null;
|
||||
let completed = false;
|
||||
let failureRecorded = false;
|
||||
try {
|
||||
prepared = await this.#prepare(
|
||||
normalizedRequest,
|
||||
normalizedContext,
|
||||
abort.signal,
|
||||
() => abort.error(),
|
||||
);
|
||||
const admission = await this.#record(prepared, 'admitted', 0, null, null);
|
||||
if (admission?.status === 'existing') {
|
||||
throw new ModelInvocationReplayBlockedError();
|
||||
}
|
||||
admitted = true;
|
||||
if (abort.signal.aborted) throw abort.error();
|
||||
iterator = prepared.provider
|
||||
.stream(prepared.request, prepared.context)
|
||||
[Symbol.asyncIterator]();
|
||||
while (true) {
|
||||
const next = await awaitAbortable(iterator.next(), abort.signal, () =>
|
||||
abort.error(),
|
||||
);
|
||||
if (next.done) break;
|
||||
const rawChunk = next.value;
|
||||
const normalizedChunk = normalizeModelChunk(rawChunk);
|
||||
const chunk =
|
||||
normalizedChunk.usage && prepared.priceQuote
|
||||
? Object.freeze({
|
||||
...normalizedChunk,
|
||||
usage: priceModelUsage(
|
||||
prepared.priceQuote,
|
||||
normalizedChunk.usage,
|
||||
),
|
||||
})
|
||||
: normalizedChunk;
|
||||
outputBytes += Buffer.byteLength(chunk.delta, 'utf8');
|
||||
if (outputBytes > prepared.policy.maxOutputBytes) {
|
||||
throw new ModelBudgetExceededError();
|
||||
}
|
||||
if (chunk.usage) {
|
||||
usage = chunk.usage;
|
||||
assertUsageWithinPolicy(usage, prepared.policy);
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
if (!usage) {
|
||||
throw new InvalidModelValueError(
|
||||
'stream completed without final usage',
|
||||
);
|
||||
}
|
||||
completed = true;
|
||||
await this.#record(prepared, 'completed', outputBytes, usage, null);
|
||||
} catch (error) {
|
||||
failureRecorded = true;
|
||||
const normalizedError =
|
||||
abort.signal.aborted &&
|
||||
!(error instanceof ModelAuditUnavailableError) &&
|
||||
!(error instanceof ModelBudgetExceededError)
|
||||
? abort.error()
|
||||
: error;
|
||||
if (
|
||||
prepared &&
|
||||
admitted &&
|
||||
!(normalizedError instanceof ModelAuditUnavailableError)
|
||||
) {
|
||||
await this.#record(
|
||||
prepared,
|
||||
'failed',
|
||||
outputBytes,
|
||||
usage,
|
||||
errorCode(normalizedError),
|
||||
);
|
||||
}
|
||||
throw normalizedError;
|
||||
} finally {
|
||||
try {
|
||||
if (!completed && !failureRecorded && prepared && admitted) {
|
||||
await this.#record(
|
||||
prepared,
|
||||
'failed',
|
||||
outputBytes,
|
||||
usage,
|
||||
'MODEL_STREAM_CANCELLED',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!completed) abort.cancel();
|
||||
if (!completed && iterator?.return) {
|
||||
try {
|
||||
void Promise.resolve(iterator.return()).catch(() => undefined);
|
||||
} catch {
|
||||
// Cleanup cannot regain invocation authority or retain the slot.
|
||||
}
|
||||
}
|
||||
abort.dispose();
|
||||
this.#active -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
export const MODEL_MESSAGE_ROLES = ['system', 'user', 'assistant'] as const;
|
||||
export const MODEL_FINISH_REASONS = [
|
||||
'stop',
|
||||
'length',
|
||||
'content_filter',
|
||||
'tool_call',
|
||||
'unknown',
|
||||
] as const;
|
||||
|
||||
export const MAX_MODEL_PROVIDERS = 8;
|
||||
export const MAX_MODEL_MESSAGES = 64;
|
||||
export const MAX_MODEL_MESSAGE_BYTES = 64 * 1024;
|
||||
export const MAX_MODEL_INPUT_BYTES = 256 * 1024;
|
||||
export const MAX_MODEL_OUTPUT_BYTES = 1024 * 1024;
|
||||
export const MAX_MODEL_OUTPUT_TOKENS = 32_768;
|
||||
export const MAX_MODEL_INVOCATION_MS = 5 * 60_000;
|
||||
|
||||
export type ModelMessageRole = (typeof MODEL_MESSAGE_ROLES)[number];
|
||||
export type ModelFinishReason = (typeof MODEL_FINISH_REASONS)[number];
|
||||
|
||||
export interface ModelMessage {
|
||||
readonly role: ModelMessageRole;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export interface GenerateRequest {
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly messages: readonly ModelMessage[];
|
||||
readonly maxOutputTokens: number;
|
||||
readonly temperature?: number;
|
||||
}
|
||||
|
||||
export interface ModelInvocationContext {
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly traceId: string;
|
||||
readonly requestId: string;
|
||||
readonly deadlineAtMs: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
readonly id: string;
|
||||
readonly displayName?: string;
|
||||
readonly contextWindowTokens?: number;
|
||||
}
|
||||
|
||||
export interface ModelUsage {
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
readonly totalTokens: number;
|
||||
readonly costMicros?: number;
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly text: string;
|
||||
readonly finishReason: ModelFinishReason;
|
||||
readonly usage: ModelUsage;
|
||||
}
|
||||
|
||||
export interface ModelChunk {
|
||||
readonly delta: string;
|
||||
readonly finishReason?: ModelFinishReason;
|
||||
readonly usage?: ModelUsage;
|
||||
}
|
||||
|
||||
export interface ModelProvider {
|
||||
readonly type: string;
|
||||
listModels(
|
||||
context?: Readonly<{ signal?: AbortSignal }>,
|
||||
): Promise<readonly ModelInfo[]>;
|
||||
generate(
|
||||
request: Readonly<GenerateRequest>,
|
||||
context: Readonly<ModelInvocationContext>,
|
||||
): Promise<Readonly<GenerateResult>>;
|
||||
stream(
|
||||
request: Readonly<GenerateRequest>,
|
||||
context: Readonly<ModelInvocationContext>,
|
||||
): AsyncIterable<Readonly<ModelChunk>>;
|
||||
}
|
||||
|
||||
export interface ModelInvocationPolicy {
|
||||
readonly revision: string;
|
||||
readonly allowedProviders: readonly string[];
|
||||
readonly allowedModels: readonly string[];
|
||||
readonly maxInputBytes: number;
|
||||
readonly maxOutputBytes: number;
|
||||
readonly maxOutputTokens: number;
|
||||
readonly maxTotalTokens: number;
|
||||
readonly maxCostMicros: number | null;
|
||||
readonly priceRevision: string | null;
|
||||
readonly projectQuota?: import('../usage/usageQuota').ModelInvocationProjectQuotaPolicy;
|
||||
}
|
||||
|
||||
export interface ModelInvocationPolicyProvider {
|
||||
resolve(
|
||||
context: Readonly<ModelInvocationContext>,
|
||||
): Promise<Readonly<ModelInvocationPolicy>>;
|
||||
}
|
||||
|
||||
export const MODEL_INVOCATION_AUDIT_PHASES = [
|
||||
'admitted',
|
||||
'completed',
|
||||
'failed',
|
||||
] as const;
|
||||
|
||||
export type ModelInvocationAuditPhase =
|
||||
(typeof MODEL_INVOCATION_AUDIT_PHASES)[number];
|
||||
|
||||
export interface ModelInvocationAuditRecord {
|
||||
readonly phase: ModelInvocationAuditPhase;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly traceId: string;
|
||||
readonly requestId: string;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly policyRevision: string;
|
||||
readonly requestDigest: string;
|
||||
readonly deadlineAtMs: number;
|
||||
readonly inputBytes: number;
|
||||
readonly maxOutputTokens: number;
|
||||
readonly outputBytes: number;
|
||||
readonly usage: Readonly<ModelUsage> | null;
|
||||
readonly errorCode: string | null;
|
||||
readonly occurredAtMs: number;
|
||||
}
|
||||
|
||||
export interface ModelInvocationAuditDisposition {
|
||||
readonly status: 'created' | 'existing';
|
||||
}
|
||||
|
||||
export type ModelInvocationAuditResult =
|
||||
void | Readonly<ModelInvocationAuditDisposition>;
|
||||
|
||||
export interface ModelInvocationAuditSink {
|
||||
record(
|
||||
record: Readonly<ModelInvocationAuditRecord>,
|
||||
): Promise<ModelInvocationAuditResult>;
|
||||
recordWithQuota?(
|
||||
record: Readonly<ModelInvocationAuditRecord>,
|
||||
admission: Readonly<
|
||||
import('../usage/usageQuota').ModelInvocationQuotaAdmission
|
||||
>,
|
||||
): Promise<ModelInvocationAuditResult>;
|
||||
recordWithPricing?(
|
||||
record: Readonly<ModelInvocationAuditRecord>,
|
||||
quote: Readonly<import('../pricing/pricing').ModelInvocationPriceQuote>,
|
||||
quotaAdmission?: Readonly<
|
||||
import('../usage/usageQuota').ModelInvocationQuotaAdmission
|
||||
>,
|
||||
): Promise<ModelInvocationAuditResult>;
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import {
|
||||
MAX_MODEL_OUTPUT_BYTES,
|
||||
type GenerateRequest,
|
||||
type GenerateResult,
|
||||
type ModelChunk,
|
||||
type ModelFinishReason,
|
||||
type ModelInfo,
|
||||
type ModelInvocationContext,
|
||||
type ModelProvider,
|
||||
type ModelUsage,
|
||||
} from './model';
|
||||
import {
|
||||
InvalidModelValueError,
|
||||
normalizeGenerateRequest,
|
||||
normalizeModelInvocationContext,
|
||||
normalizeModelInfo,
|
||||
normalizeModelUsage,
|
||||
} from './validation';
|
||||
import type {
|
||||
ModelProviderAuthorizationLease,
|
||||
ModelProviderAuthorizationProvider,
|
||||
ModelProviderAuthorizationRequest,
|
||||
} from '../model-provider-credential/providerCredential';
|
||||
|
||||
const MAX_OPENAI_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_OPENAI_MODELS = 256;
|
||||
const MAX_OPENAI_AUTHORIZATION_BYTES = 4 * 1024;
|
||||
|
||||
export class OpenAiCompatibleConfigurationError extends TypeError {
|
||||
readonly code = 'OPENAI_COMPATIBLE_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`OpenAI-compatible provider configuration is invalid: ${message}`);
|
||||
this.name = 'OpenAiCompatibleConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenAiCompatibleHttpError extends Error {
|
||||
readonly code = 'OPENAI_COMPATIBLE_HTTP_ERROR';
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number) {
|
||||
super(`OpenAI-compatible provider returned HTTP ${status}`);
|
||||
this.name = 'OpenAiCompatibleHttpError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenAiCompatibleProtocolError extends Error {
|
||||
readonly code = 'OPENAI_COMPATIBLE_PROTOCOL_ERROR';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(
|
||||
`OpenAI-compatible provider response is invalid: ${message}`,
|
||||
options,
|
||||
);
|
||||
this.name = 'OpenAiCompatibleProtocolError';
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAiCompatibleCredentialProvider =
|
||||
ModelProviderAuthorizationProvider;
|
||||
|
||||
export interface OpenAiCompatibleListModelsContext {
|
||||
readonly projectId?: string;
|
||||
readonly requestId?: string;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface OpenAiCompatibleProviderOptions {
|
||||
readonly type: string;
|
||||
readonly baseUrl: string;
|
||||
readonly credentials?: OpenAiCompatibleCredentialProvider;
|
||||
readonly fetch?: typeof globalThis.fetch;
|
||||
readonly allowPlaintextLoopback?: boolean;
|
||||
readonly maxResponseBytes?: number;
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string, allowPlaintextLoopback: boolean): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new OpenAiCompatibleConfigurationError('baseUrl is not a URL');
|
||||
}
|
||||
const loopback =
|
||||
url.hostname === 'localhost' ||
|
||||
url.hostname === '127.0.0.1' ||
|
||||
url.hostname === '[::1]' ||
|
||||
url.hostname === '::1';
|
||||
if (
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
(url.protocol !== 'https:' &&
|
||||
!(url.protocol === 'http:' && loopback && allowPlaintextLoopback))
|
||||
) {
|
||||
throw new OpenAiCompatibleConfigurationError(
|
||||
'baseUrl must use HTTPS without credentials, query, or fragment',
|
||||
);
|
||||
}
|
||||
if (!url.pathname.endsWith('/')) url.pathname += '/';
|
||||
return url;
|
||||
}
|
||||
|
||||
function normalizeFinishReason(value: unknown): ModelFinishReason {
|
||||
if (value === 'stop') return 'stop';
|
||||
if (value === 'length') return 'length';
|
||||
if (value === 'content_filter') return 'content_filter';
|
||||
if (value === 'tool_calls' || value === 'function_call') return 'tool_call';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function parseUsage(value: unknown): Readonly<ModelUsage> {
|
||||
if (!isRecord(value)) {
|
||||
throw new OpenAiCompatibleProtocolError('usage is missing');
|
||||
}
|
||||
const inputTokens = value.prompt_tokens;
|
||||
const outputTokens = value.completion_tokens;
|
||||
const totalTokens = value.total_tokens;
|
||||
try {
|
||||
return normalizeModelUsage({
|
||||
inputTokens: inputTokens as number,
|
||||
outputTokens: outputTokens as number,
|
||||
totalTokens: totalTokens as number,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new OpenAiCompatibleProtocolError('usage is invalid', { cause });
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedBytes(
|
||||
response: Response,
|
||||
maximumBytes: number,
|
||||
): Promise<Uint8Array> {
|
||||
if (!response.body) {
|
||||
throw new OpenAiCompatibleProtocolError('response body is missing');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maximumBytes) {
|
||||
throw new OpenAiCompatibleProtocolError(
|
||||
'response exceeds the byte limit',
|
||||
);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const result = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readBoundedJson(
|
||||
response: Response,
|
||||
maximumBytes: number,
|
||||
): Promise<unknown> {
|
||||
const bytes = await readBoundedBytes(response, maximumBytes);
|
||||
try {
|
||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
||||
} catch (cause) {
|
||||
throw new OpenAiCompatibleProtocolError('response JSON is invalid', {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestBody(
|
||||
request: Readonly<GenerateRequest>,
|
||||
stream: boolean,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
model: request.model,
|
||||
messages: request.messages,
|
||||
max_tokens: request.maxOutputTokens,
|
||||
...(request.temperature === undefined
|
||||
? {}
|
||||
: { temperature: request.temperature }),
|
||||
stream,
|
||||
...(stream ? { stream_options: { include_usage: true } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function parseGenerateResult(
|
||||
provider: string,
|
||||
requestedModel: string,
|
||||
value: unknown,
|
||||
): Readonly<GenerateResult> {
|
||||
if (!isRecord(value) || !Array.isArray(value.choices)) {
|
||||
throw new OpenAiCompatibleProtocolError('completion shape is invalid');
|
||||
}
|
||||
const choice = value.choices[0];
|
||||
if (
|
||||
!isRecord(choice) ||
|
||||
!isRecord(choice.message) ||
|
||||
typeof choice.message.content !== 'string'
|
||||
) {
|
||||
throw new OpenAiCompatibleProtocolError('completion choice is invalid');
|
||||
}
|
||||
if (
|
||||
Buffer.byteLength(choice.message.content, 'utf8') > MAX_MODEL_OUTPUT_BYTES
|
||||
) {
|
||||
throw new OpenAiCompatibleProtocolError(
|
||||
'completion text exceeds the hard byte limit',
|
||||
);
|
||||
}
|
||||
const model =
|
||||
typeof value.model === 'string' && value.model.length > 0
|
||||
? value.model
|
||||
: requestedModel;
|
||||
if (model !== requestedModel) {
|
||||
throw new OpenAiCompatibleProtocolError(
|
||||
'completion model does not match the request',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
provider,
|
||||
model,
|
||||
text: choice.message.content,
|
||||
finishReason: normalizeFinishReason(choice.finish_reason),
|
||||
usage: parseUsage(value.usage),
|
||||
});
|
||||
}
|
||||
|
||||
function parseStreamEvent(value: unknown): Readonly<ModelChunk> | null {
|
||||
if (!isRecord(value)) {
|
||||
throw new OpenAiCompatibleProtocolError('stream event is invalid');
|
||||
}
|
||||
const choice = Array.isArray(value.choices) ? value.choices[0] : undefined;
|
||||
let delta = '';
|
||||
let finishReason: ModelFinishReason | undefined;
|
||||
if (choice !== undefined) {
|
||||
if (!isRecord(choice)) {
|
||||
throw new OpenAiCompatibleProtocolError('stream choice is invalid');
|
||||
}
|
||||
if (isRecord(choice.delta) && choice.delta.content !== undefined) {
|
||||
if (typeof choice.delta.content !== 'string') {
|
||||
throw new OpenAiCompatibleProtocolError(
|
||||
'stream delta content is invalid',
|
||||
);
|
||||
}
|
||||
delta = choice.delta.content;
|
||||
}
|
||||
if (choice.finish_reason !== undefined && choice.finish_reason !== null) {
|
||||
finishReason = normalizeFinishReason(choice.finish_reason);
|
||||
}
|
||||
}
|
||||
const usage =
|
||||
value.usage === undefined || value.usage === null
|
||||
? undefined
|
||||
: parseUsage(value.usage);
|
||||
if (delta.length === 0 && finishReason === undefined && usage === undefined) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
delta,
|
||||
...(finishReason === undefined ? {} : { finishReason }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
});
|
||||
}
|
||||
|
||||
export class OpenAiCompatibleProvider implements ModelProvider {
|
||||
readonly type: string;
|
||||
readonly #baseUrl: URL;
|
||||
readonly #credentials: OpenAiCompatibleCredentialProvider | undefined;
|
||||
readonly #fetch: typeof globalThis.fetch;
|
||||
readonly #maxResponseBytes: number;
|
||||
|
||||
constructor(options: OpenAiCompatibleProviderOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.type !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(options.type) ||
|
||||
typeof options.baseUrl !== 'string' ||
|
||||
(options.credentials !== undefined &&
|
||||
(!options.credentials ||
|
||||
typeof options.credentials.authorizationHeader !== 'function')) ||
|
||||
(options.fetch !== undefined && typeof options.fetch !== 'function') ||
|
||||
(options.allowPlaintextLoopback !== undefined &&
|
||||
typeof options.allowPlaintextLoopback !== 'boolean')
|
||||
) {
|
||||
throw new OpenAiCompatibleConfigurationError('options are invalid');
|
||||
}
|
||||
const maxResponseBytes =
|
||||
options.maxResponseBytes ?? MAX_OPENAI_RESPONSE_BYTES;
|
||||
if (
|
||||
!Number.isSafeInteger(maxResponseBytes) ||
|
||||
maxResponseBytes < 1 ||
|
||||
maxResponseBytes > MAX_OPENAI_RESPONSE_BYTES
|
||||
) {
|
||||
throw new OpenAiCompatibleConfigurationError(
|
||||
'maxResponseBytes is invalid',
|
||||
);
|
||||
}
|
||||
this.type = options.type;
|
||||
this.#baseUrl = normalizeBaseUrl(
|
||||
options.baseUrl,
|
||||
options.allowPlaintextLoopback ?? false,
|
||||
);
|
||||
this.#credentials = options.credentials;
|
||||
this.#fetch = options.fetch ?? globalThis.fetch;
|
||||
this.#maxResponseBytes = maxResponseBytes;
|
||||
}
|
||||
|
||||
async #request(
|
||||
url: URL,
|
||||
init: Omit<RequestInit, 'headers'>,
|
||||
authorizationRequest: Readonly<ModelProviderAuthorizationRequest>,
|
||||
): Promise<Response> {
|
||||
let authorization: Readonly<ModelProviderAuthorizationLease> | null = null;
|
||||
let authorizationValue: string | null = null;
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
authorization =
|
||||
(await this.#credentials?.authorizationHeader(authorizationRequest)) ??
|
||||
null;
|
||||
if (
|
||||
authorization !== null &&
|
||||
(!authorization ||
|
||||
typeof authorization !== 'object' ||
|
||||
Array.isArray(authorization) ||
|
||||
Object.keys(authorization).sort().join('\0') !==
|
||||
['dispose', 'value'].join('\0') ||
|
||||
typeof authorization.value !== 'string' ||
|
||||
authorization.value.length < 1 ||
|
||||
Buffer.byteLength(authorization.value, 'utf8') >
|
||||
MAX_OPENAI_AUTHORIZATION_BYTES ||
|
||||
/[\r\n]/.test(authorization.value) ||
|
||||
typeof authorization.dispose !== 'function')
|
||||
) {
|
||||
throw new OpenAiCompatibleConfigurationError(
|
||||
'authorization lease is invalid',
|
||||
);
|
||||
}
|
||||
authorizationValue = authorization === null ? null : authorization.value;
|
||||
response = await this.#fetch(url, {
|
||||
...init,
|
||||
headers: Object.freeze({
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
...(authorizationValue === null
|
||||
? {}
|
||||
: { authorization: authorizationValue }),
|
||||
}),
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
if (authorization && typeof authorization.dispose === 'function') {
|
||||
try {
|
||||
await authorization.dispose();
|
||||
} catch (cause) {
|
||||
await response?.body?.cancel().catch(() => undefined);
|
||||
throw new OpenAiCompatibleConfigurationError(
|
||||
cause instanceof Error
|
||||
? 'authorization lease disposal failed'
|
||||
: 'authorization lease disposal is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(
|
||||
context: Readonly<OpenAiCompatibleListModelsContext> = {},
|
||||
): Promise<readonly ModelInfo[]> {
|
||||
const response = await this.#request(
|
||||
new URL('models', this.#baseUrl),
|
||||
{
|
||||
method: 'GET',
|
||||
...(context.signal === undefined ? {} : { signal: context.signal }),
|
||||
},
|
||||
Object.freeze({
|
||||
operation: 'list_models',
|
||||
provider: this.type,
|
||||
...(context.projectId === undefined
|
||||
? {}
|
||||
: { projectId: context.projectId }),
|
||||
...(context.requestId === undefined
|
||||
? {}
|
||||
: { requestId: context.requestId }),
|
||||
...(context.signal === undefined ? {} : { signal: context.signal }),
|
||||
}),
|
||||
);
|
||||
if (!response.ok) throw new OpenAiCompatibleHttpError(response.status);
|
||||
const value = await readBoundedJson(response, this.#maxResponseBytes);
|
||||
if (!isRecord(value) || !Array.isArray(value.data)) {
|
||||
throw new OpenAiCompatibleProtocolError('model list shape is invalid');
|
||||
}
|
||||
if (value.data.length > MAX_OPENAI_MODELS) {
|
||||
throw new OpenAiCompatibleProtocolError(
|
||||
'model list exceeds the item limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
value.data.map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.id !== 'string') {
|
||||
throw new OpenAiCompatibleProtocolError(
|
||||
'model list entry is invalid',
|
||||
);
|
||||
}
|
||||
return normalizeModelInfo({ id: entry.id });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async generate(
|
||||
request: Readonly<GenerateRequest>,
|
||||
context: Readonly<ModelInvocationContext>,
|
||||
): Promise<Readonly<GenerateResult>> {
|
||||
const normalizedRequest = normalizeGenerateRequest(request);
|
||||
const normalizedContext = normalizeModelInvocationContext(
|
||||
context,
|
||||
Date.now(),
|
||||
);
|
||||
if (normalizedRequest.provider !== this.type) {
|
||||
throw new InvalidModelValueError(
|
||||
'request provider does not match the adapter',
|
||||
);
|
||||
}
|
||||
const response = await this.#request(
|
||||
new URL('chat/completions', this.#baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
body: createRequestBody(normalizedRequest, false),
|
||||
...(normalizedContext.signal === undefined
|
||||
? {}
|
||||
: { signal: normalizedContext.signal }),
|
||||
},
|
||||
Object.freeze({
|
||||
operation: 'generate',
|
||||
provider: this.type,
|
||||
projectId: normalizedContext.projectId,
|
||||
requestId: normalizedContext.requestId,
|
||||
...(normalizedContext.signal === undefined
|
||||
? {}
|
||||
: { signal: normalizedContext.signal }),
|
||||
}),
|
||||
);
|
||||
if (!response.ok) throw new OpenAiCompatibleHttpError(response.status);
|
||||
return parseGenerateResult(
|
||||
this.type,
|
||||
normalizedRequest.model,
|
||||
await readBoundedJson(response, this.#maxResponseBytes),
|
||||
);
|
||||
}
|
||||
|
||||
async *stream(
|
||||
request: Readonly<GenerateRequest>,
|
||||
context: Readonly<ModelInvocationContext>,
|
||||
): AsyncIterable<Readonly<ModelChunk>> {
|
||||
const normalizedRequest = normalizeGenerateRequest(request);
|
||||
const normalizedContext = normalizeModelInvocationContext(
|
||||
context,
|
||||
Date.now(),
|
||||
);
|
||||
if (normalizedRequest.provider !== this.type) {
|
||||
throw new InvalidModelValueError(
|
||||
'request provider does not match the adapter',
|
||||
);
|
||||
}
|
||||
const response = await this.#request(
|
||||
new URL('chat/completions', this.#baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
body: createRequestBody(normalizedRequest, true),
|
||||
...(normalizedContext.signal === undefined
|
||||
? {}
|
||||
: { signal: normalizedContext.signal }),
|
||||
},
|
||||
Object.freeze({
|
||||
operation: 'stream',
|
||||
provider: this.type,
|
||||
projectId: normalizedContext.projectId,
|
||||
requestId: normalizedContext.requestId,
|
||||
...(normalizedContext.signal === undefined
|
||||
? {}
|
||||
: { signal: normalizedContext.signal }),
|
||||
}),
|
||||
);
|
||||
if (!response.ok) throw new OpenAiCompatibleHttpError(response.status);
|
||||
if (!response.body) {
|
||||
throw new OpenAiCompatibleProtocolError('stream body is missing');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
let buffered = '';
|
||||
let receivedBytes = 0;
|
||||
let doneEvent = false;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
receivedBytes += value.byteLength;
|
||||
if (receivedBytes > this.#maxResponseBytes) {
|
||||
throw new OpenAiCompatibleProtocolError(
|
||||
'stream exceeds the byte limit',
|
||||
);
|
||||
}
|
||||
buffered += decoder.decode(value, { stream: true });
|
||||
if (Buffer.byteLength(buffered, 'utf8') > this.#maxResponseBytes) {
|
||||
throw new OpenAiCompatibleProtocolError(
|
||||
'stream event buffer exceeds the byte limit',
|
||||
);
|
||||
}
|
||||
while (true) {
|
||||
const lfBoundary = buffered.indexOf('\n\n');
|
||||
const crlfBoundary = buffered.indexOf('\r\n\r\n');
|
||||
const boundary =
|
||||
lfBoundary < 0
|
||||
? crlfBoundary
|
||||
: crlfBoundary < 0
|
||||
? lfBoundary
|
||||
: Math.min(lfBoundary, crlfBoundary);
|
||||
if (boundary < 0) break;
|
||||
const boundaryLength = buffered.startsWith('\r\n\r\n', boundary)
|
||||
? 4
|
||||
: 2;
|
||||
const event = buffered.slice(0, boundary);
|
||||
buffered = buffered.slice(boundary + boundaryLength);
|
||||
const data = event
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n');
|
||||
if (data.length === 0) continue;
|
||||
if (data === '[DONE]') {
|
||||
doneEvent = true;
|
||||
break;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch (cause) {
|
||||
throw new OpenAiCompatibleProtocolError(
|
||||
'stream event JSON is invalid',
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const chunk = parseStreamEvent(parsed);
|
||||
if (chunk) yield chunk;
|
||||
}
|
||||
if (doneEvent) break;
|
||||
}
|
||||
buffered += decoder.decode();
|
||||
if (!doneEvent) {
|
||||
throw new OpenAiCompatibleProtocolError(
|
||||
'stream ended without a done event',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!doneEvent) await reader.cancel().catch(() => undefined);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, open, realpath } from 'node:fs/promises';
|
||||
import { isAbsolute, normalize, parse, relative } from 'node:path';
|
||||
|
||||
import type { ModelInvocationContext, ModelInvocationPolicy } from './model';
|
||||
import { OpenAiCompatibleProvider } from './openAiCompatibleProvider';
|
||||
import type { ModelGatewayProviderAuthority } from '../profile/profileComposition';
|
||||
import type { ModelProviderAuthorizationProvider } from '../model-provider-credential/providerCredential';
|
||||
import { normalizeModelInvocationPolicy } from './validation';
|
||||
|
||||
export const PROJECTED_MODEL_GATEWAY_AUTHORITY_SCHEMA =
|
||||
'qinglong/projected-model-gateway-authority@v1';
|
||||
|
||||
const MAX_CONFIG_BYTES = 256 * 1024;
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_PROVIDERS = 16;
|
||||
const MAX_PROJECTS = 1024;
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
|
||||
export interface ProjectedModelGatewayProviderConfig {
|
||||
readonly type: string;
|
||||
readonly baseUrl: string;
|
||||
readonly allowPlaintextLoopback: boolean;
|
||||
readonly maxResponseBytes: number;
|
||||
}
|
||||
|
||||
export interface ProjectedModelGatewayProjectConfig {
|
||||
readonly projectId: string;
|
||||
readonly policy: Readonly<ModelInvocationPolicy>;
|
||||
}
|
||||
|
||||
export interface ProjectedModelGatewayAuthorityManifest {
|
||||
readonly schema: typeof PROJECTED_MODEL_GATEWAY_AUTHORITY_SCHEMA;
|
||||
readonly providers: readonly Readonly<ProjectedModelGatewayProviderConfig>[];
|
||||
readonly projects: readonly Readonly<ProjectedModelGatewayProjectConfig>[];
|
||||
}
|
||||
|
||||
export interface ProjectedModelGatewayAuthorityOptions {
|
||||
readonly configFile: string;
|
||||
readonly credentials: ModelProviderAuthorizationProvider;
|
||||
}
|
||||
|
||||
export class ProjectedModelGatewayAuthorityUnavailableError extends Error {
|
||||
readonly code = 'PROJECTED_MODEL_GATEWAY_AUTHORITY_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Projected model gateway authority is unavailable', options);
|
||||
this.name = 'ProjectedModelGatewayAuthorityUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): ProjectedModelGatewayAuthorityUnavailableError {
|
||||
return new ProjectedModelGatewayAuthorityUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !== [...expectedKeys].sort().join('\0')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function identifier(value: unknown): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function configFile(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!isAbsolute(value) ||
|
||||
parse(value).root === value ||
|
||||
normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function providerConfig(
|
||||
value: unknown,
|
||||
): Readonly<ProjectedModelGatewayProviderConfig> {
|
||||
const candidate = exactObject(value, [
|
||||
'allowPlaintextLoopback',
|
||||
'baseUrl',
|
||||
'maxResponseBytes',
|
||||
'type',
|
||||
]);
|
||||
if (
|
||||
typeof candidate.baseUrl !== 'string' ||
|
||||
typeof candidate.allowPlaintextLoopback !== 'boolean' ||
|
||||
!Number.isSafeInteger(candidate.maxResponseBytes) ||
|
||||
(candidate.maxResponseBytes as number) < 1 ||
|
||||
(candidate.maxResponseBytes as number) > 8 * 1024 * 1024
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({
|
||||
type: identifier(candidate.type),
|
||||
baseUrl: candidate.baseUrl,
|
||||
allowPlaintextLoopback: candidate.allowPlaintextLoopback,
|
||||
maxResponseBytes: candidate.maxResponseBytes as number,
|
||||
});
|
||||
}
|
||||
|
||||
function projectConfig(
|
||||
value: unknown,
|
||||
): Readonly<ProjectedModelGatewayProjectConfig> {
|
||||
const candidate = exactObject(value, ['policy', 'projectId']);
|
||||
try {
|
||||
return Object.freeze({
|
||||
projectId: identifier(candidate.projectId),
|
||||
policy: normalizeModelInvocationPolicy(
|
||||
candidate.policy as ModelInvocationPolicy,
|
||||
),
|
||||
});
|
||||
} catch (cause) {
|
||||
throw unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectedModelGatewayAuthorityManifest(
|
||||
value: ProjectedModelGatewayAuthorityManifest,
|
||||
): Readonly<ProjectedModelGatewayAuthorityManifest> {
|
||||
const candidate = exactObject(value, ['projects', 'providers', 'schema']);
|
||||
if (
|
||||
candidate.schema !== PROJECTED_MODEL_GATEWAY_AUTHORITY_SCHEMA ||
|
||||
!Array.isArray(candidate.providers) ||
|
||||
candidate.providers.length < 1 ||
|
||||
candidate.providers.length > MAX_PROVIDERS ||
|
||||
!Array.isArray(candidate.projects) ||
|
||||
candidate.projects.length < 1 ||
|
||||
candidate.projects.length > MAX_PROJECTS
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
const providers = candidate.providers.map(providerConfig);
|
||||
const projects = candidate.projects.map(projectConfig);
|
||||
const providerTypes = providers.map(({ type }) => type);
|
||||
const projectIds = projects.map(({ projectId }) => projectId);
|
||||
if (
|
||||
new Set(providerTypes).size !== providerTypes.length ||
|
||||
new Set(projectIds).size !== projectIds.length ||
|
||||
providerTypes.some(
|
||||
(value, index) => index > 0 && value <= providerTypes[index - 1]!,
|
||||
) ||
|
||||
projectIds.some(
|
||||
(value, index) => index > 0 && value <= projectIds[index - 1]!,
|
||||
) ||
|
||||
projects.some(({ policy }) =>
|
||||
policy.allowedProviders.some(
|
||||
(provider) => !providerTypes.includes(provider),
|
||||
),
|
||||
)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: PROJECTED_MODEL_GATEWAY_AUTHORITY_SCHEMA,
|
||||
providers: Object.freeze(providers),
|
||||
projects: Object.freeze(projects),
|
||||
});
|
||||
}
|
||||
|
||||
export function canonicalProjectedModelGatewayAuthorityManifest(
|
||||
value: ProjectedModelGatewayAuthorityManifest,
|
||||
): Buffer {
|
||||
return Buffer.from(
|
||||
`${JSON.stringify(
|
||||
normalizeProjectedModelGatewayAuthorityManifest(value),
|
||||
)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function remainsBelow(parent: string, candidate: string): boolean {
|
||||
const suffix = relative(parent, candidate);
|
||||
return (
|
||||
suffix.length > 0 &&
|
||||
!isAbsolute(suffix) &&
|
||||
suffix !== '..' &&
|
||||
!suffix.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
|
||||
);
|
||||
}
|
||||
|
||||
async function readManifest(
|
||||
configuredFile: string,
|
||||
): Promise<Readonly<ProjectedModelGatewayAuthorityManifest>> {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
let bytes: Buffer | undefined;
|
||||
let canonical: Buffer | undefined;
|
||||
try {
|
||||
const configured = await lstat(configuredFile);
|
||||
if (!configured.isFile() && !configured.isSymbolicLink())
|
||||
throw unavailable();
|
||||
const configuredParent = parse(configuredFile).dir;
|
||||
const directParent = await lstat(configuredParent);
|
||||
if (!directParent.isDirectory() || directParent.isSymbolicLink()) {
|
||||
throw unavailable();
|
||||
}
|
||||
const parent = await realpath(configuredParent);
|
||||
const target = await realpath(configuredFile);
|
||||
if (!remainsBelow(parent, target)) throw unavailable();
|
||||
handle = await open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const before = await handle.stat();
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.nlink !== 1 ||
|
||||
before.size < 1 ||
|
||||
before.size > MAX_CONFIG_BYTES ||
|
||||
(before.mode & 0o222) !== 0 ||
|
||||
(before.mode & 0o111) !== 0 ||
|
||||
(before.mode & 0o007) !== 0 ||
|
||||
(before.mode & 0o440) === 0
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (
|
||||
bytes.byteLength !== before.size ||
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
after.mtimeMs !== before.mtimeMs ||
|
||||
(await realpath(configuredFile)) !== target ||
|
||||
(await realpath(configuredParent)) !== parent
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
const parsed = JSON.parse(
|
||||
new TextDecoder('utf8', { fatal: true }).decode(bytes),
|
||||
);
|
||||
const manifest = normalizeProjectedModelGatewayAuthorityManifest(
|
||||
parsed as ProjectedModelGatewayAuthorityManifest,
|
||||
);
|
||||
canonical = canonicalProjectedModelGatewayAuthorityManifest(manifest);
|
||||
if (!canonical.equals(bytes)) throw unavailable();
|
||||
return manifest;
|
||||
} catch (cause) {
|
||||
throw cause instanceof ProjectedModelGatewayAuthorityUnavailableError
|
||||
? cause
|
||||
: unavailable(cause);
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
canonical?.fill(0);
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads one immutable, content-free provider/policy snapshot at activation. */
|
||||
export async function loadProjectedModelGatewayProviderAuthority(
|
||||
options: ProjectedModelGatewayAuthorityOptions,
|
||||
): Promise<ModelGatewayProviderAuthority> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).sort().join('\0') !== 'configFile\0credentials' ||
|
||||
typeof options.credentials?.authorizationHeader !== 'function'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
const manifest = await readManifest(configFile(options.configFile));
|
||||
const policies = new Map(
|
||||
manifest.projects.map(({ projectId, policy }) => [projectId, policy]),
|
||||
);
|
||||
return Object.freeze({
|
||||
providers: Object.freeze(
|
||||
manifest.providers.map(
|
||||
(provider) =>
|
||||
new OpenAiCompatibleProvider({
|
||||
...provider,
|
||||
credentials: options.credentials,
|
||||
}),
|
||||
),
|
||||
),
|
||||
policies: Object.freeze({
|
||||
async resolve(
|
||||
context: Readonly<ModelInvocationContext>,
|
||||
): Promise<Readonly<ModelInvocationPolicy>> {
|
||||
const policy = policies.get(context.projectId);
|
||||
if (!policy) throw unavailable();
|
||||
return policy;
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import {
|
||||
MAX_MODEL_INPUT_BYTES,
|
||||
MAX_MODEL_INVOCATION_MS,
|
||||
MAX_MODEL_MESSAGE_BYTES,
|
||||
MAX_MODEL_MESSAGES,
|
||||
MAX_MODEL_OUTPUT_BYTES,
|
||||
MAX_MODEL_OUTPUT_TOKENS,
|
||||
MODEL_FINISH_REASONS,
|
||||
MODEL_MESSAGE_ROLES,
|
||||
type GenerateRequest,
|
||||
type GenerateResult,
|
||||
type ModelChunk,
|
||||
type ModelInfo,
|
||||
type ModelInvocationContext,
|
||||
type ModelInvocationPolicy,
|
||||
type ModelMessage,
|
||||
type ModelUsage,
|
||||
} from './model';
|
||||
import { normalizeModelInvocationProjectQuotaPolicy } from '../usage/usageQuota';
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const REVISION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export class InvalidModelValueError extends TypeError {
|
||||
readonly code = 'MODEL_VALUE_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Model value is invalid: ${message}`);
|
||||
this.name = 'InvalidModelValueError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
name: string,
|
||||
): void {
|
||||
const keys = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
keys.length !== canonical.length ||
|
||||
keys.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new InvalidModelValueError(`${name} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdentifier(value: unknown, name: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new InvalidModelValueError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(value: unknown, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new InvalidModelValueError(`${name} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(
|
||||
value: unknown,
|
||||
name: string,
|
||||
maximum: number,
|
||||
): number {
|
||||
const normalized = normalizeNonNegativeInteger(value, name);
|
||||
if (normalized < 1 || normalized > maximum) {
|
||||
throw new InvalidModelValueError(`${name} is out of range`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeMessage(value: ModelMessage): Readonly<ModelMessage> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidModelValueError('message must be an object');
|
||||
}
|
||||
assertExactKeys(value, ['role', 'content'], 'message');
|
||||
if (!MODEL_MESSAGE_ROLES.includes(value.role)) {
|
||||
throw new InvalidModelValueError('message role is invalid');
|
||||
}
|
||||
if (
|
||||
typeof value.content !== 'string' ||
|
||||
Buffer.byteLength(value.content, 'utf8') > MAX_MODEL_MESSAGE_BYTES
|
||||
) {
|
||||
throw new InvalidModelValueError('message content is invalid');
|
||||
}
|
||||
return Object.freeze({ role: value.role, content: value.content });
|
||||
}
|
||||
|
||||
export function normalizeGenerateRequest(
|
||||
value: GenerateRequest,
|
||||
): Readonly<GenerateRequest> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidModelValueError('request must be an object');
|
||||
}
|
||||
const expected = ['provider', 'model', 'messages', 'maxOutputTokens'];
|
||||
if (Object.hasOwn(value, 'temperature')) expected.push('temperature');
|
||||
assertExactKeys(value, expected, 'request');
|
||||
const provider = normalizeIdentifier(value.provider, 'provider');
|
||||
const model = normalizeIdentifier(value.model, 'model');
|
||||
if (
|
||||
!Array.isArray(value.messages) ||
|
||||
value.messages.length < 1 ||
|
||||
value.messages.length > MAX_MODEL_MESSAGES
|
||||
) {
|
||||
throw new InvalidModelValueError('messages are invalid');
|
||||
}
|
||||
const messages = Object.freeze(value.messages.map(normalizeMessage));
|
||||
if (measureModelInputBytes(messages) > MAX_MODEL_INPUT_BYTES) {
|
||||
throw new InvalidModelValueError('input exceeds the hard byte limit');
|
||||
}
|
||||
const maxOutputTokens = normalizePositiveInteger(
|
||||
value.maxOutputTokens,
|
||||
'maxOutputTokens',
|
||||
MAX_MODEL_OUTPUT_TOKENS,
|
||||
);
|
||||
if (
|
||||
value.temperature !== undefined &&
|
||||
(typeof value.temperature !== 'number' ||
|
||||
!Number.isFinite(value.temperature) ||
|
||||
value.temperature < 0 ||
|
||||
value.temperature > 2)
|
||||
) {
|
||||
throw new InvalidModelValueError('temperature is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
provider,
|
||||
model,
|
||||
messages,
|
||||
maxOutputTokens,
|
||||
...(value.temperature === undefined
|
||||
? {}
|
||||
: { temperature: value.temperature }),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelInvocationContext(
|
||||
value: ModelInvocationContext,
|
||||
nowMs: number,
|
||||
): Readonly<ModelInvocationContext> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidModelValueError('context must be an object');
|
||||
}
|
||||
const expected = [
|
||||
'projectId',
|
||||
'runId',
|
||||
'stepRunId',
|
||||
'traceId',
|
||||
'requestId',
|
||||
'deadlineAtMs',
|
||||
];
|
||||
if (Object.hasOwn(value, 'signal')) expected.push('signal');
|
||||
assertExactKeys(value, expected, 'context');
|
||||
if (
|
||||
!Number.isSafeInteger(nowMs) ||
|
||||
!Number.isSafeInteger(value.deadlineAtMs) ||
|
||||
value.deadlineAtMs <= nowMs ||
|
||||
value.deadlineAtMs - nowMs > MAX_MODEL_INVOCATION_MS
|
||||
) {
|
||||
throw new InvalidModelValueError('deadlineAtMs is invalid');
|
||||
}
|
||||
if (
|
||||
value.signal !== undefined &&
|
||||
(!(value.signal instanceof AbortSignal) || value.signal.aborted)
|
||||
) {
|
||||
throw new InvalidModelValueError('signal is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: normalizeIdentifier(value.projectId, 'projectId'),
|
||||
runId: normalizeIdentifier(value.runId, 'runId'),
|
||||
stepRunId: normalizeIdentifier(value.stepRunId, 'stepRunId'),
|
||||
traceId: normalizeIdentifier(value.traceId, 'traceId'),
|
||||
requestId: normalizeIdentifier(value.requestId, 'requestId'),
|
||||
deadlineAtMs: value.deadlineAtMs,
|
||||
...(value.signal === undefined ? {} : { signal: value.signal }),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelInvocationPolicy(
|
||||
value: ModelInvocationPolicy,
|
||||
): Readonly<ModelInvocationPolicy> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidModelValueError('policy must be an object');
|
||||
}
|
||||
const expected = [
|
||||
'revision',
|
||||
'allowedProviders',
|
||||
'allowedModels',
|
||||
'maxInputBytes',
|
||||
'maxOutputBytes',
|
||||
'maxOutputTokens',
|
||||
'maxTotalTokens',
|
||||
'maxCostMicros',
|
||||
'priceRevision',
|
||||
];
|
||||
if (Object.hasOwn(value, 'projectQuota')) expected.push('projectQuota');
|
||||
assertExactKeys(value, expected, 'policy');
|
||||
if (
|
||||
typeof value.revision !== 'string' ||
|
||||
!REVISION_PATTERN.test(value.revision)
|
||||
) {
|
||||
throw new InvalidModelValueError('policy revision is invalid');
|
||||
}
|
||||
const normalizeAllowlist = (
|
||||
entries: readonly string[],
|
||||
name: string,
|
||||
): readonly string[] => {
|
||||
if (!Array.isArray(entries) || entries.length < 1 || entries.length > 128) {
|
||||
throw new InvalidModelValueError(`${name} is invalid`);
|
||||
}
|
||||
const normalized = entries.map((entry) => normalizeIdentifier(entry, name));
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new InvalidModelValueError(`${name} contains duplicates`);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
};
|
||||
const maxInputBytes = normalizePositiveInteger(
|
||||
value.maxInputBytes,
|
||||
'maxInputBytes',
|
||||
MAX_MODEL_INPUT_BYTES,
|
||||
);
|
||||
const maxOutputBytes = normalizePositiveInteger(
|
||||
value.maxOutputBytes,
|
||||
'maxOutputBytes',
|
||||
MAX_MODEL_OUTPUT_BYTES,
|
||||
);
|
||||
const maxOutputTokens = normalizePositiveInteger(
|
||||
value.maxOutputTokens,
|
||||
'maxOutputTokens',
|
||||
MAX_MODEL_OUTPUT_TOKENS,
|
||||
);
|
||||
const maxTotalTokens = normalizePositiveInteger(
|
||||
value.maxTotalTokens,
|
||||
'maxTotalTokens',
|
||||
MAX_MODEL_OUTPUT_TOKENS * 16,
|
||||
);
|
||||
if (maxOutputTokens > maxTotalTokens) {
|
||||
throw new InvalidModelValueError('maxOutputTokens exceeds maxTotalTokens');
|
||||
}
|
||||
if (
|
||||
value.maxCostMicros !== null &&
|
||||
(!Number.isSafeInteger(value.maxCostMicros) || value.maxCostMicros < 0)
|
||||
) {
|
||||
throw new InvalidModelValueError('maxCostMicros is invalid');
|
||||
}
|
||||
if (
|
||||
value.priceRevision !== null &&
|
||||
(typeof value.priceRevision !== 'string' ||
|
||||
!REVISION_PATTERN.test(value.priceRevision))
|
||||
) {
|
||||
throw new InvalidModelValueError('priceRevision is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
revision: value.revision,
|
||||
allowedProviders: normalizeAllowlist(
|
||||
value.allowedProviders,
|
||||
'allowedProviders',
|
||||
),
|
||||
allowedModels: normalizeAllowlist(value.allowedModels, 'allowedModels'),
|
||||
maxInputBytes,
|
||||
maxOutputBytes,
|
||||
maxOutputTokens,
|
||||
maxTotalTokens,
|
||||
maxCostMicros: value.maxCostMicros,
|
||||
priceRevision: value.priceRevision,
|
||||
...(value.projectQuota === undefined
|
||||
? {}
|
||||
: {
|
||||
projectQuota: normalizeModelInvocationProjectQuotaPolicy(
|
||||
value.projectQuota,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelUsage(value: ModelUsage): Readonly<ModelUsage> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidModelValueError('usage must be an object');
|
||||
}
|
||||
const expected = ['inputTokens', 'outputTokens', 'totalTokens'];
|
||||
if (Object.hasOwn(value, 'costMicros')) expected.push('costMicros');
|
||||
assertExactKeys(value, expected, 'usage');
|
||||
const inputTokens = normalizeNonNegativeInteger(
|
||||
value.inputTokens,
|
||||
'inputTokens',
|
||||
);
|
||||
const outputTokens = normalizeNonNegativeInteger(
|
||||
value.outputTokens,
|
||||
'outputTokens',
|
||||
);
|
||||
const totalTokens = normalizeNonNegativeInteger(
|
||||
value.totalTokens,
|
||||
'totalTokens',
|
||||
);
|
||||
if (totalTokens !== inputTokens + outputTokens) {
|
||||
throw new InvalidModelValueError('totalTokens is inconsistent');
|
||||
}
|
||||
if (
|
||||
value.costMicros !== undefined &&
|
||||
(!Number.isSafeInteger(value.costMicros) || value.costMicros < 0)
|
||||
) {
|
||||
throw new InvalidModelValueError('costMicros is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
...(value.costMicros === undefined ? {} : { costMicros: value.costMicros }),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeGenerateResult(
|
||||
value: GenerateResult,
|
||||
): Readonly<GenerateResult> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidModelValueError('result must be an object');
|
||||
}
|
||||
assertExactKeys(
|
||||
value,
|
||||
['provider', 'model', 'text', 'finishReason', 'usage'],
|
||||
'result',
|
||||
);
|
||||
if (
|
||||
typeof value.text !== 'string' ||
|
||||
Buffer.byteLength(value.text, 'utf8') > MAX_MODEL_OUTPUT_BYTES
|
||||
) {
|
||||
throw new InvalidModelValueError('result text is invalid');
|
||||
}
|
||||
if (!MODEL_FINISH_REASONS.includes(value.finishReason)) {
|
||||
throw new InvalidModelValueError('finishReason is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
provider: normalizeIdentifier(value.provider, 'result provider'),
|
||||
model: normalizeIdentifier(value.model, 'result model'),
|
||||
text: value.text,
|
||||
finishReason: value.finishReason,
|
||||
usage: normalizeModelUsage(value.usage),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelChunk(value: ModelChunk): Readonly<ModelChunk> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidModelValueError('chunk must be an object');
|
||||
}
|
||||
const expected = ['delta'];
|
||||
if (Object.hasOwn(value, 'finishReason')) expected.push('finishReason');
|
||||
if (Object.hasOwn(value, 'usage')) expected.push('usage');
|
||||
assertExactKeys(value, expected, 'chunk');
|
||||
if (
|
||||
typeof value.delta !== 'string' ||
|
||||
Buffer.byteLength(value.delta, 'utf8') > MAX_MODEL_OUTPUT_BYTES
|
||||
) {
|
||||
throw new InvalidModelValueError('chunk delta is invalid');
|
||||
}
|
||||
if (
|
||||
value.finishReason !== undefined &&
|
||||
!MODEL_FINISH_REASONS.includes(value.finishReason)
|
||||
) {
|
||||
throw new InvalidModelValueError('chunk finishReason is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
delta: value.delta,
|
||||
...(value.finishReason === undefined
|
||||
? {}
|
||||
: { finishReason: value.finishReason }),
|
||||
...(value.usage === undefined
|
||||
? {}
|
||||
: { usage: normalizeModelUsage(value.usage) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelInfo(value: ModelInfo): Readonly<ModelInfo> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidModelValueError('model info must be an object');
|
||||
}
|
||||
const expected = ['id'];
|
||||
if (Object.hasOwn(value, 'displayName')) expected.push('displayName');
|
||||
if (Object.hasOwn(value, 'contextWindowTokens')) {
|
||||
expected.push('contextWindowTokens');
|
||||
}
|
||||
assertExactKeys(value, expected, 'model info');
|
||||
const id = normalizeIdentifier(value.id, 'model id');
|
||||
if (
|
||||
value.displayName !== undefined &&
|
||||
(typeof value.displayName !== 'string' ||
|
||||
value.displayName.length < 1 ||
|
||||
Buffer.byteLength(value.displayName, 'utf8') > 256)
|
||||
) {
|
||||
throw new InvalidModelValueError('model displayName is invalid');
|
||||
}
|
||||
if (
|
||||
value.contextWindowTokens !== undefined &&
|
||||
(!Number.isSafeInteger(value.contextWindowTokens) ||
|
||||
value.contextWindowTokens < 1)
|
||||
) {
|
||||
throw new InvalidModelValueError('contextWindowTokens is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
id,
|
||||
...(value.displayName === undefined
|
||||
? {}
|
||||
: { displayName: value.displayName }),
|
||||
...(value.contextWindowTokens === undefined
|
||||
? {}
|
||||
: { contextWindowTokens: value.contextWindowTokens }),
|
||||
});
|
||||
}
|
||||
|
||||
export function measureModelInputBytes(
|
||||
messages: readonly ModelMessage[],
|
||||
): number {
|
||||
return messages.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
Buffer.byteLength(message.role, 'utf8') +
|
||||
Buffer.byteLength(message.content, 'utf8'),
|
||||
0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
import {
|
||||
transitionStepRunMutation,
|
||||
type StepRunStatus,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import type {
|
||||
ModelInvocationAuditDisposition,
|
||||
ModelInvocationAuditRecord,
|
||||
ModelInvocationAuditSink,
|
||||
ModelUsage,
|
||||
} from '../model-gateway/model';
|
||||
import {
|
||||
MAX_MODEL_INVOCATION_RECOVERY_PAGE_SIZE,
|
||||
ModelInvocationConflictError,
|
||||
ModelInvocationRepositoryUnavailableError,
|
||||
createModelInvocationCompletionCommand,
|
||||
createModelInvocationMutationIdentity,
|
||||
createModelInvocationStartCommand,
|
||||
normalizeModelInvocationCompletionRecord,
|
||||
normalizeModelInvocationStartRecord,
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationRepository,
|
||||
type ModelInvocationStartRecord,
|
||||
} from './modelInvocation';
|
||||
import {
|
||||
isQuotaAwareModelInvocationRepository,
|
||||
normalizeModelInvocationQuotaAdmission,
|
||||
type ModelInvocationQuotaAdmission,
|
||||
} from '../usage/usageQuota';
|
||||
import {
|
||||
isPricingAwareModelInvocationRepository,
|
||||
normalizeModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceQuote,
|
||||
} from '../pricing/pricing';
|
||||
import {
|
||||
isPluginPackagePromptOutputCompletionRepository,
|
||||
type PluginPackagePromptOutputCompletionRepository,
|
||||
} from '../prompt-output/pluginPackagePromptOutputCompletion';
|
||||
import {
|
||||
PluginPackagePromptOutputArtifactConflictError,
|
||||
normalizePluginPackagePromptOutputArtifact,
|
||||
pluginPackagePromptOutputArtifactReference,
|
||||
type PluginPackagePromptOutputArtifact,
|
||||
type PluginPackagePromptOutputArtifactReference,
|
||||
} from '../prompt-output/pluginPackagePromptOutputArtifact';
|
||||
|
||||
const MAX_COORDINATOR_ATTEMPTS = 3;
|
||||
|
||||
interface CompletionTransition {
|
||||
readonly to: StepRunStatus;
|
||||
readonly outputRef?: string;
|
||||
readonly resultCode?: string;
|
||||
readonly errorSummary?: string;
|
||||
}
|
||||
|
||||
export interface ModelInvocationRecoverySummary {
|
||||
readonly observedAtMs: number;
|
||||
readonly scanned: number;
|
||||
readonly recovered: number;
|
||||
readonly alreadyCompleted: number;
|
||||
readonly failed: number;
|
||||
readonly hasMore: boolean;
|
||||
}
|
||||
|
||||
function sameUsage(
|
||||
left: Readonly<ModelUsage> | null,
|
||||
right: Readonly<ModelUsage> | null,
|
||||
): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function assertStartMatchesAudit(
|
||||
startValue: ModelInvocationStartRecord,
|
||||
audit: Readonly<ModelInvocationAuditRecord>,
|
||||
): Readonly<ModelInvocationStartRecord> {
|
||||
const start = normalizeModelInvocationStartRecord(startValue);
|
||||
if (
|
||||
start.invocationId !== audit.requestId ||
|
||||
start.projectId !== audit.projectId ||
|
||||
start.runId !== audit.runId ||
|
||||
start.stepRunId !== audit.stepRunId ||
|
||||
start.traceId !== audit.traceId ||
|
||||
start.provider !== audit.provider ||
|
||||
start.model !== audit.model ||
|
||||
start.policyRevision !== audit.policyRevision ||
|
||||
start.requestDigest !== audit.requestDigest ||
|
||||
start.inputBytes !== audit.inputBytes ||
|
||||
start.maxOutputTokens !== audit.maxOutputTokens ||
|
||||
start.deadlineAtMs !== audit.deadlineAtMs
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
function completionTransition(
|
||||
audit: Readonly<ModelInvocationAuditRecord>,
|
||||
successOutputRef?: string,
|
||||
): Readonly<CompletionTransition> {
|
||||
if (audit.phase === 'completed') {
|
||||
return Object.freeze({
|
||||
to: 'succeeded',
|
||||
outputRef: successOutputRef ?? `model-invocation:${audit.requestId}`,
|
||||
});
|
||||
}
|
||||
if (audit.errorCode === 'MODEL_INVOCATION_DEADLINE_EXCEEDED') {
|
||||
return Object.freeze({
|
||||
to: 'timed_out',
|
||||
resultCode: 'model_deadline_exceeded',
|
||||
errorSummary: 'Model invocation deadline exceeded',
|
||||
});
|
||||
}
|
||||
if (
|
||||
audit.errorCode === 'MODEL_INVOCATION_ABORTED' ||
|
||||
audit.errorCode === 'MODEL_STREAM_CANCELLED' ||
|
||||
audit.errorCode === 'MODEL_INVOCATION_OUTCOME_UNKNOWN'
|
||||
) {
|
||||
return Object.freeze({
|
||||
to: 'lost',
|
||||
resultCode: 'model_outcome_unknown',
|
||||
errorSummary: 'Model invocation outcome is unknown',
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
to: 'failed',
|
||||
resultCode: 'model_provider_failed',
|
||||
errorSummary: 'Model invocation failed',
|
||||
});
|
||||
}
|
||||
|
||||
function expectedOutcome(
|
||||
transition: Readonly<CompletionTransition>,
|
||||
): ModelInvocationCompletionRecord['outcome'] {
|
||||
if (transition.to === 'succeeded') return 'succeeded';
|
||||
if (transition.to === 'timed_out') return 'timed_out';
|
||||
if (transition.to === 'lost') return 'outcome_unknown';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
function assertCompletionMatchesAudit(
|
||||
completionValue: ModelInvocationCompletionRecord,
|
||||
start: Readonly<ModelInvocationStartRecord>,
|
||||
audit: Readonly<ModelInvocationAuditRecord>,
|
||||
): Readonly<ModelInvocationCompletionRecord> {
|
||||
const completion = normalizeModelInvocationCompletionRecord(completionValue);
|
||||
const transition = completionTransition(audit);
|
||||
if (
|
||||
completion.invocationId !== start.invocationId ||
|
||||
completion.projectId !== start.projectId ||
|
||||
completion.runId !== start.runId ||
|
||||
completion.stepRunId !== start.stepRunId ||
|
||||
completion.traceId !== start.traceId ||
|
||||
completion.startDigest !== start.startDigest ||
|
||||
completion.outcome !== expectedOutcome(transition) ||
|
||||
completion.outputBytes !== audit.outputBytes ||
|
||||
!sameUsage(completion.usage, audit.usage) ||
|
||||
completion.errorCode !== audit.errorCode
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return completion;
|
||||
}
|
||||
|
||||
function identity(record: Readonly<ModelInvocationAuditRecord>): Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
stepRunId: string;
|
||||
}> {
|
||||
return Object.freeze({
|
||||
projectId: record.projectId,
|
||||
runId: record.runId,
|
||||
stepRunId: record.stepRunId,
|
||||
});
|
||||
}
|
||||
|
||||
export class DurableModelInvocationCoordinator
|
||||
implements ModelInvocationAuditSink
|
||||
{
|
||||
constructor(private readonly repository: ModelInvocationRepository) {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.findStart !== 'function' ||
|
||||
typeof repository.findCompletion !== 'function' ||
|
||||
typeof repository.readAuthority !== 'function' ||
|
||||
typeof repository.admit !== 'function' ||
|
||||
typeof repository.complete !== 'function'
|
||||
) {
|
||||
throw new ModelInvocationRepositoryUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
async record(
|
||||
record: Readonly<ModelInvocationAuditRecord>,
|
||||
): Promise<Readonly<ModelInvocationAuditDisposition>> {
|
||||
return record.phase === 'admitted'
|
||||
? this.#admit(record)
|
||||
: this.#complete(record);
|
||||
}
|
||||
|
||||
async recordWithQuota(
|
||||
record: Readonly<ModelInvocationAuditRecord>,
|
||||
admissionValue: Readonly<ModelInvocationQuotaAdmission>,
|
||||
): Promise<Readonly<ModelInvocationAuditDisposition>> {
|
||||
const admission = normalizeModelInvocationQuotaAdmission(admissionValue);
|
||||
if (
|
||||
record.phase !== 'admitted' ||
|
||||
record.requestId !== admission.invocationId ||
|
||||
record.projectId !== admission.projectId ||
|
||||
record.policyRevision !== admission.modelPolicyRevision ||
|
||||
!isQuotaAwareModelInvocationRepository(this.repository)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return this.#admit(record, admission);
|
||||
}
|
||||
|
||||
async recordWithPricing(
|
||||
record: Readonly<ModelInvocationAuditRecord>,
|
||||
quoteValue: Readonly<ModelInvocationPriceQuote>,
|
||||
admissionValue?: Readonly<ModelInvocationQuotaAdmission>,
|
||||
): Promise<Readonly<ModelInvocationAuditDisposition>> {
|
||||
const quote = normalizeModelInvocationPriceQuote(quoteValue);
|
||||
const admission =
|
||||
admissionValue === undefined
|
||||
? undefined
|
||||
: normalizeModelInvocationQuotaAdmission(admissionValue);
|
||||
if (
|
||||
record.phase !== 'admitted' ||
|
||||
record.requestId !== quote.invocationId ||
|
||||
record.projectId !== quote.projectId ||
|
||||
record.policyRevision !== quote.modelPolicyRevision ||
|
||||
record.provider !== quote.provider ||
|
||||
record.model !== quote.model ||
|
||||
(admission !== undefined &&
|
||||
(admission.invocationId !== quote.invocationId ||
|
||||
admission.projectId !== quote.projectId ||
|
||||
admission.modelPolicyRevision !== quote.modelPolicyRevision)) ||
|
||||
!isPricingAwareModelInvocationRepository(this.repository)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return this.#admit(record, admission, quote);
|
||||
}
|
||||
|
||||
async recordWithPromptOutputArtifact(
|
||||
record: Readonly<ModelInvocationAuditRecord>,
|
||||
artifactValue: PluginPackagePromptOutputArtifact,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
reference: Readonly<PluginPackagePromptOutputArtifactReference>;
|
||||
}>
|
||||
> {
|
||||
const artifact = normalizePluginPackagePromptOutputArtifact(artifactValue);
|
||||
if (
|
||||
record.phase !== 'completed' ||
|
||||
record.requestId !== artifact.invocationId ||
|
||||
record.projectId !== artifact.projectId ||
|
||||
record.runId !== artifact.runId ||
|
||||
record.stepRunId !== artifact.stepRunId ||
|
||||
record.provider !== artifact.provider ||
|
||||
record.model !== artifact.model ||
|
||||
record.outputBytes !== artifact.outputBytes ||
|
||||
!isPluginPackagePromptOutputCompletionRepository(this.repository)
|
||||
) {
|
||||
throw new PluginPackagePromptOutputArtifactConflictError();
|
||||
}
|
||||
const result = await this.#complete(record, artifact);
|
||||
if (!result.reference) {
|
||||
throw new PluginPackagePromptOutputArtifactConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: result.status,
|
||||
reference: result.reference,
|
||||
});
|
||||
}
|
||||
|
||||
async #admit(
|
||||
audit: Readonly<ModelInvocationAuditRecord>,
|
||||
admission?: Readonly<ModelInvocationQuotaAdmission>,
|
||||
quote?: Readonly<ModelInvocationPriceQuote>,
|
||||
): Promise<Readonly<ModelInvocationAuditDisposition>> {
|
||||
const existing = await this.repository.findStart(audit.requestId);
|
||||
if (existing) {
|
||||
assertStartMatchesAudit(existing, audit);
|
||||
if (admission) {
|
||||
if (!isQuotaAwareModelInvocationRepository(this.repository)) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const reservation = await this.repository.findQuotaReservation(
|
||||
audit.requestId,
|
||||
);
|
||||
if (
|
||||
!reservation ||
|
||||
reservation.admissionDigest !== admission.admissionDigest
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
}
|
||||
if (quote) {
|
||||
if (!isPricingAwareModelInvocationRepository(this.repository)) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const storedQuote = await this.repository.findPriceQuote(
|
||||
audit.requestId,
|
||||
);
|
||||
if (!storedQuote || storedQuote.quoteDigest !== quote.quoteDigest) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
}
|
||||
return Object.freeze({ status: 'existing' });
|
||||
}
|
||||
for (let attempt = 0; attempt < MAX_COORDINATOR_ATTEMPTS; attempt += 1) {
|
||||
const authority = await this.repository.readAuthority(identity(audit));
|
||||
if (
|
||||
!authority ||
|
||||
authority.stepRun.status !== 'ready' ||
|
||||
authority.stepRun.kind !== 'model'
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const mutationIdentity = createModelInvocationMutationIdentity(
|
||||
audit.requestId,
|
||||
'start',
|
||||
);
|
||||
const command = createModelInvocationStartCommand(
|
||||
audit,
|
||||
transitionStepRunMutation(
|
||||
authority.stepRun,
|
||||
{
|
||||
expectedVersion: authority.stepRun.version,
|
||||
expectedDigest: authority.stepRun.stepRunDigest,
|
||||
mutationId: mutationIdentity.mutationId,
|
||||
to: 'running',
|
||||
atMs: audit.occurredAtMs,
|
||||
},
|
||||
{
|
||||
expectedRunVersion: authority.runVersion,
|
||||
expectedRunEventSequence: authority.runEventSequence,
|
||||
eventId: mutationIdentity.eventId,
|
||||
dedupeKey: mutationIdentity.dedupeKey,
|
||||
actor: { type: 'executor', id: 'model-gateway' },
|
||||
},
|
||||
),
|
||||
);
|
||||
try {
|
||||
const result = quote
|
||||
? await (() => {
|
||||
if (!isPricingAwareModelInvocationRepository(this.repository)) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return this.repository.admitWithPricing(
|
||||
command,
|
||||
quote,
|
||||
admission,
|
||||
);
|
||||
})()
|
||||
: admission
|
||||
? await (() => {
|
||||
if (!isQuotaAwareModelInvocationRepository(this.repository)) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return this.repository.admitWithQuota(command, admission);
|
||||
})()
|
||||
: await this.repository.admit(command);
|
||||
return Object.freeze({ status: result.status });
|
||||
} catch (error) {
|
||||
const stored = await this.#startAfterFailure(
|
||||
audit,
|
||||
error,
|
||||
admission,
|
||||
quote,
|
||||
);
|
||||
if (stored) return Object.freeze({ status: 'existing' });
|
||||
if (
|
||||
!(error instanceof ModelInvocationConflictError) ||
|
||||
attempt + 1 >= MAX_COORDINATOR_ATTEMPTS
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
|
||||
async #complete(
|
||||
audit: Readonly<ModelInvocationAuditRecord>,
|
||||
artifactValue?: Readonly<PluginPackagePromptOutputArtifact>,
|
||||
): Promise<
|
||||
Readonly<
|
||||
ModelInvocationAuditDisposition & {
|
||||
reference?: Readonly<PluginPackagePromptOutputArtifactReference>;
|
||||
}
|
||||
>
|
||||
> {
|
||||
const artifact = artifactValue
|
||||
? normalizePluginPackagePromptOutputArtifact(artifactValue)
|
||||
: undefined;
|
||||
const artifactRepository = artifact
|
||||
? (this.repository as ModelInvocationRepository &
|
||||
PluginPackagePromptOutputCompletionRepository)
|
||||
: undefined;
|
||||
const startValue = await this.repository.findStart(audit.requestId);
|
||||
if (!startValue) throw new ModelInvocationConflictError();
|
||||
const start = assertStartMatchesAudit(startValue, audit);
|
||||
const existing = await this.repository.findCompletion(audit.requestId);
|
||||
if (existing) {
|
||||
assertCompletionMatchesAudit(existing, start, audit);
|
||||
if (artifact && artifactRepository) {
|
||||
const stored = await artifactRepository.findPromptOutputArtifact(
|
||||
artifact.artifactId,
|
||||
);
|
||||
if (!stored || JSON.stringify(stored) !== JSON.stringify(artifact)) {
|
||||
throw new PluginPackagePromptOutputArtifactConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
reference: pluginPackagePromptOutputArtifactReference(stored),
|
||||
});
|
||||
}
|
||||
return Object.freeze({ status: 'existing' as const });
|
||||
}
|
||||
for (let attempt = 0; attempt < MAX_COORDINATOR_ATTEMPTS; attempt += 1) {
|
||||
const authority = await this.repository.readAuthority(identity(audit));
|
||||
if (
|
||||
!authority ||
|
||||
authority.stepRun.status !== 'running' ||
|
||||
authority.stepRun.version !== start.startedStepRunVersion ||
|
||||
authority.stepRun.stepRunDigest !== start.startedStepRunDigest
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const transition = completionTransition(audit, artifact?.artifactId);
|
||||
const mutationIdentity = createModelInvocationMutationIdentity(
|
||||
audit.requestId,
|
||||
'completion',
|
||||
);
|
||||
const command = createModelInvocationCompletionCommand(
|
||||
start,
|
||||
audit,
|
||||
transitionStepRunMutation(
|
||||
authority.stepRun,
|
||||
{
|
||||
expectedVersion: authority.stepRun.version,
|
||||
expectedDigest: authority.stepRun.stepRunDigest,
|
||||
mutationId: mutationIdentity.mutationId,
|
||||
to: transition.to,
|
||||
atMs: audit.occurredAtMs,
|
||||
...(transition.outputRef === undefined
|
||||
? {}
|
||||
: { outputRef: transition.outputRef }),
|
||||
...(transition.resultCode === undefined
|
||||
? {}
|
||||
: { resultCode: transition.resultCode }),
|
||||
...(transition.errorSummary === undefined
|
||||
? {}
|
||||
: { errorSummary: transition.errorSummary }),
|
||||
},
|
||||
{
|
||||
expectedRunVersion: authority.runVersion,
|
||||
expectedRunEventSequence: authority.runEventSequence,
|
||||
eventId: mutationIdentity.eventId,
|
||||
dedupeKey: mutationIdentity.dedupeKey,
|
||||
actor: { type: 'executor', id: 'model-gateway' },
|
||||
},
|
||||
),
|
||||
artifact?.artifactId,
|
||||
);
|
||||
try {
|
||||
const pricingAware = isPricingAwareModelInvocationRepository(
|
||||
this.repository,
|
||||
);
|
||||
const quote = pricingAware
|
||||
? await this.repository.findPriceQuote(audit.requestId)
|
||||
: null;
|
||||
const quotaAware = isQuotaAwareModelInvocationRepository(
|
||||
this.repository,
|
||||
);
|
||||
const reservation = quotaAware
|
||||
? await this.repository.findQuotaReservation(audit.requestId)
|
||||
: null;
|
||||
const result =
|
||||
artifactRepository && artifact
|
||||
? await artifactRepository.completeWithPromptOutputArtifact(
|
||||
command,
|
||||
artifact,
|
||||
)
|
||||
: pricingAware && quote
|
||||
? await this.repository.completeWithPricing(command)
|
||||
: quotaAware && reservation
|
||||
? await this.repository.completeWithQuota(command)
|
||||
: await this.repository.complete(command);
|
||||
return Object.freeze({
|
||||
status: result.status,
|
||||
...(artifact && artifactRepository
|
||||
? {
|
||||
reference: pluginPackagePromptOutputArtifactReference(artifact),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
const stored = await this.#completionAfterFailure(
|
||||
start,
|
||||
audit,
|
||||
error,
|
||||
artifact,
|
||||
artifactRepository,
|
||||
);
|
||||
if (stored) {
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
...(artifact
|
||||
? {
|
||||
reference:
|
||||
pluginPackagePromptOutputArtifactReference(artifact),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!(error instanceof ModelInvocationConflictError) ||
|
||||
attempt + 1 >= MAX_COORDINATOR_ATTEMPTS
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
|
||||
async #startAfterFailure(
|
||||
audit: Readonly<ModelInvocationAuditRecord>,
|
||||
original: unknown,
|
||||
admission?: Readonly<ModelInvocationQuotaAdmission>,
|
||||
quote?: Readonly<ModelInvocationPriceQuote>,
|
||||
): Promise<Readonly<ModelInvocationStartRecord> | null> {
|
||||
try {
|
||||
const stored = await this.repository.findStart(audit.requestId);
|
||||
if (!stored) return null;
|
||||
const start = assertStartMatchesAudit(stored, audit);
|
||||
if (admission) {
|
||||
if (!isQuotaAwareModelInvocationRepository(this.repository)) {
|
||||
throw original;
|
||||
}
|
||||
const reservation = await this.repository.findQuotaReservation(
|
||||
audit.requestId,
|
||||
);
|
||||
if (
|
||||
!reservation ||
|
||||
reservation.admissionDigest !== admission.admissionDigest
|
||||
) {
|
||||
throw original;
|
||||
}
|
||||
}
|
||||
if (quote) {
|
||||
if (!isPricingAwareModelInvocationRepository(this.repository)) {
|
||||
throw original;
|
||||
}
|
||||
const storedQuote = await this.repository.findPriceQuote(
|
||||
audit.requestId,
|
||||
);
|
||||
if (!storedQuote || storedQuote.quoteDigest !== quote.quoteDigest) {
|
||||
throw original;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
} catch {
|
||||
throw original;
|
||||
}
|
||||
}
|
||||
|
||||
async #completionAfterFailure(
|
||||
start: Readonly<ModelInvocationStartRecord>,
|
||||
audit: Readonly<ModelInvocationAuditRecord>,
|
||||
original: unknown,
|
||||
artifact?: Readonly<PluginPackagePromptOutputArtifact>,
|
||||
artifactRepository?: PluginPackagePromptOutputCompletionRepository,
|
||||
): Promise<Readonly<ModelInvocationCompletionRecord> | null> {
|
||||
try {
|
||||
const stored = await this.repository.findCompletion(audit.requestId);
|
||||
if (!stored) return null;
|
||||
const completion = assertCompletionMatchesAudit(stored, start, audit);
|
||||
if (artifact) {
|
||||
if (!artifactRepository) throw original;
|
||||
const storedArtifact =
|
||||
await artifactRepository.findPromptOutputArtifact(
|
||||
artifact.artifactId,
|
||||
);
|
||||
if (
|
||||
!storedArtifact ||
|
||||
JSON.stringify(storedArtifact) !== JSON.stringify(artifact)
|
||||
) {
|
||||
throw original;
|
||||
}
|
||||
}
|
||||
return completion;
|
||||
} catch {
|
||||
throw original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DurableModelInvocationRecovery {
|
||||
constructor(
|
||||
private readonly repository: ModelInvocationRepository,
|
||||
private readonly coordinator = new DurableModelInvocationCoordinator(
|
||||
repository,
|
||||
),
|
||||
) {}
|
||||
|
||||
async recover(
|
||||
limit = MAX_MODEL_INVOCATION_RECOVERY_PAGE_SIZE,
|
||||
): Promise<Readonly<ModelInvocationRecoverySummary>> {
|
||||
const page = await this.repository.listIncomplete(limit);
|
||||
let recovered = 0;
|
||||
let alreadyCompleted = 0;
|
||||
let failed = 0;
|
||||
for (const start of page.candidates) {
|
||||
try {
|
||||
const result = await this.coordinator.record(
|
||||
Object.freeze({
|
||||
phase: 'failed',
|
||||
projectId: start.projectId,
|
||||
runId: start.runId,
|
||||
stepRunId: start.stepRunId,
|
||||
traceId: start.traceId,
|
||||
requestId: start.invocationId,
|
||||
provider: start.provider,
|
||||
model: start.model,
|
||||
policyRevision: start.policyRevision,
|
||||
requestDigest: start.requestDigest,
|
||||
deadlineAtMs: start.deadlineAtMs,
|
||||
inputBytes: start.inputBytes,
|
||||
maxOutputTokens: start.maxOutputTokens,
|
||||
outputBytes: 0,
|
||||
usage: null,
|
||||
errorCode: 'MODEL_INVOCATION_OUTCOME_UNKNOWN',
|
||||
occurredAtMs: page.observedAtMs,
|
||||
}),
|
||||
);
|
||||
if (result.status === 'created') recovered += 1;
|
||||
else alreadyCompleted += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
observedAtMs: page.observedAtMs,
|
||||
scanned: page.candidates.length,
|
||||
recovered,
|
||||
alreadyCompleted,
|
||||
failed,
|
||||
hasMore: page.hasMore,
|
||||
});
|
||||
}
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
normalizeModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceQuote,
|
||||
} from '../../pricing/pricing';
|
||||
import {
|
||||
ModelInvocationProjectQuotaExceededError,
|
||||
createModelInvocationQuotaReservation,
|
||||
normalizeModelInvocationQuotaAdmission,
|
||||
type ModelInvocationQuotaAdmission,
|
||||
type ModelInvocationQuotaReservation,
|
||||
} from '../../usage/usageQuota';
|
||||
import {
|
||||
ModelInvocationConflictError,
|
||||
normalizeModelInvocationStartCommand,
|
||||
type CommitModelInvocationResult,
|
||||
type ModelInvocationStartCommand,
|
||||
type ModelInvocationStartRecord,
|
||||
} from '../modelInvocation';
|
||||
|
||||
import type { LocalModelInvocationOperationAuthority, Row } from './authority';
|
||||
import {
|
||||
assertLocalFeatureActive,
|
||||
enqueueLocalModelInvocation,
|
||||
integer,
|
||||
unavailable,
|
||||
} from './authority';
|
||||
import { parsePriceQuote, parseQuotaReservation, parseStart } from './codec';
|
||||
import {
|
||||
applyMutation,
|
||||
assertCurrent,
|
||||
insertPriceQuote,
|
||||
insertQuotaReservation,
|
||||
insertStart,
|
||||
quotaWindowUsage,
|
||||
} from './mutations';
|
||||
import { priceQuoteRows, quotaReservationRows, startRows } from './queries';
|
||||
|
||||
export function admitOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
): Promise<Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>> {
|
||||
const command = normalizeModelInvocationStartCommand(commandValue);
|
||||
const start = command.start;
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const existing = startRows(
|
||||
client,
|
||||
`start.invocation_id = ? OR
|
||||
start.mutation_id = ? OR start.run_event_id = ?`,
|
||||
[start.invocationId, start.stepRunMutationId, start.runEventId],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseStart(existing[0]);
|
||||
if (JSON.stringify(stored) !== JSON.stringify(start)) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
assertLocalFeatureActive(client);
|
||||
assertCurrent(client, command.stepRunMutation, start.projectId);
|
||||
applyMutation(client, command.stepRunMutation);
|
||||
insertStart(client, start);
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'created' as const, record: start });
|
||||
} catch (error) {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original transaction failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function admitWithQuotaOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
admissionValue: ModelInvocationQuotaAdmission,
|
||||
): Promise<Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>> {
|
||||
const command = normalizeModelInvocationStartCommand(commandValue);
|
||||
const admission = normalizeModelInvocationQuotaAdmission(admissionValue);
|
||||
const start = command.start;
|
||||
if (
|
||||
admission.invocationId !== start.invocationId ||
|
||||
admission.projectId !== start.projectId ||
|
||||
admission.modelPolicyRevision !== start.policyRevision
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const existing = startRows(
|
||||
client,
|
||||
`start.invocation_id = ? OR
|
||||
start.mutation_id = ? OR start.run_event_id = ?`,
|
||||
[start.invocationId, start.stepRunMutationId, start.runEventId],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseStart(existing[0]);
|
||||
const reservations = quotaReservationRows(
|
||||
client,
|
||||
'reservation.invocation_id = ?',
|
||||
[start.invocationId],
|
||||
);
|
||||
if (
|
||||
JSON.stringify(stored) !== JSON.stringify(start) ||
|
||||
reservations.length !== 1 ||
|
||||
parseQuotaReservation(reservations[0]!).admissionDigest !==
|
||||
admission.admissionDigest
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
assertLocalFeatureActive(client);
|
||||
const observed = client
|
||||
.prepare(
|
||||
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER)
|
||||
AS "observedAtMs"`,
|
||||
)
|
||||
.get() as Row | undefined;
|
||||
if (!observed) throw unavailable();
|
||||
const reservation = createModelInvocationQuotaReservation(
|
||||
admission,
|
||||
integer(observed, 'observedAtMs'),
|
||||
);
|
||||
const usage = quotaWindowUsage(
|
||||
client,
|
||||
start.projectId,
|
||||
reservation.windowStartMs,
|
||||
reservation.windowMs,
|
||||
);
|
||||
if (
|
||||
usage.invocationCount + 1 > reservation.maxInvocations ||
|
||||
usage.effectiveTokens + reservation.reservedTokens >
|
||||
reservation.maxTokens ||
|
||||
(reservation.maxCostMicros !== null &&
|
||||
(usage.unknownCostInvocations !== 0 ||
|
||||
usage.effectiveCostMicros + reservation.reservedCostMicros! >
|
||||
reservation.maxCostMicros))
|
||||
) {
|
||||
throw new ModelInvocationProjectQuotaExceededError();
|
||||
}
|
||||
assertCurrent(client, command.stepRunMutation, start.projectId);
|
||||
applyMutation(client, command.stepRunMutation);
|
||||
insertStart(client, start);
|
||||
insertQuotaReservation(client, reservation);
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'created' as const, record: start });
|
||||
} catch (error) {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original transaction failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function admitWithPricingOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
quoteValue: ModelInvocationPriceQuote,
|
||||
admissionValue?: ModelInvocationQuotaAdmission,
|
||||
): Promise<Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>> {
|
||||
const command = normalizeModelInvocationStartCommand(commandValue);
|
||||
const quote = normalizeModelInvocationPriceQuote(quoteValue);
|
||||
const admission =
|
||||
admissionValue === undefined
|
||||
? undefined
|
||||
: normalizeModelInvocationQuotaAdmission(admissionValue);
|
||||
const start = command.start;
|
||||
if (
|
||||
quote.invocationId !== start.invocationId ||
|
||||
quote.projectId !== start.projectId ||
|
||||
quote.modelPolicyRevision !== start.policyRevision ||
|
||||
quote.provider !== start.provider ||
|
||||
quote.model !== start.model ||
|
||||
quote.maxOutputTokens !== start.maxOutputTokens ||
|
||||
(admission !== undefined &&
|
||||
(admission.invocationId !== start.invocationId ||
|
||||
admission.projectId !== start.projectId ||
|
||||
admission.modelPolicyRevision !== start.policyRevision ||
|
||||
(admission.maxCostMicros !== null &&
|
||||
admission.reservedCostMicros !== quote.reservedCostMicros)))
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const existing = startRows(
|
||||
client,
|
||||
`start.invocation_id = ? OR
|
||||
start.mutation_id = ? OR start.run_event_id = ?`,
|
||||
[start.invocationId, start.stepRunMutationId, start.runEventId],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseStart(existing[0]);
|
||||
const quotes = priceQuoteRows(client, 'quote.invocation_id = ?', [
|
||||
start.invocationId,
|
||||
]);
|
||||
const reservations = quotaReservationRows(
|
||||
client,
|
||||
'reservation.invocation_id = ?',
|
||||
[start.invocationId],
|
||||
);
|
||||
if (
|
||||
JSON.stringify(stored) !== JSON.stringify(start) ||
|
||||
quotes.length !== 1 ||
|
||||
JSON.stringify(parsePriceQuote(quotes[0]!)) !==
|
||||
JSON.stringify(quote) ||
|
||||
reservations.length !== (admission ? 1 : 0) ||
|
||||
(admission !== undefined &&
|
||||
parseQuotaReservation(reservations[0]!).admissionDigest !==
|
||||
admission.admissionDigest)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
assertLocalFeatureActive(client);
|
||||
let reservation: Readonly<ModelInvocationQuotaReservation> | undefined;
|
||||
if (admission) {
|
||||
const observed = client
|
||||
.prepare(
|
||||
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER)
|
||||
AS "observedAtMs"`,
|
||||
)
|
||||
.get() as Row | undefined;
|
||||
if (!observed) throw unavailable();
|
||||
reservation = createModelInvocationQuotaReservation(
|
||||
admission,
|
||||
integer(observed, 'observedAtMs'),
|
||||
);
|
||||
const usage = quotaWindowUsage(
|
||||
client,
|
||||
start.projectId,
|
||||
reservation.windowStartMs,
|
||||
reservation.windowMs,
|
||||
);
|
||||
if (
|
||||
usage.invocationCount + 1 > reservation.maxInvocations ||
|
||||
usage.effectiveTokens + reservation.reservedTokens >
|
||||
reservation.maxTokens ||
|
||||
(reservation.maxCostMicros !== null &&
|
||||
(usage.unknownCostInvocations !== 0 ||
|
||||
usage.effectiveCostMicros + reservation.reservedCostMicros! >
|
||||
reservation.maxCostMicros))
|
||||
) {
|
||||
throw new ModelInvocationProjectQuotaExceededError();
|
||||
}
|
||||
}
|
||||
assertCurrent(client, command.stepRunMutation, start.projectId);
|
||||
applyMutation(client, command.stepRunMutation);
|
||||
insertStart(client, start);
|
||||
insertPriceQuote(client, quote);
|
||||
if (reservation) {
|
||||
insertQuotaReservation(client, reservation);
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'created' as const, record: start });
|
||||
} catch (error) {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original transaction failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { assertLocalModelInvocationFeatureActive } from '../../feature-activation/localModelInvocationFeatureActivation';
|
||||
import {
|
||||
PluginPackagePromptOutputArtifactConflictError,
|
||||
PluginPackagePromptOutputArtifactUnavailableError,
|
||||
} from '../../prompt-output/pluginPackagePromptOutputArtifact';
|
||||
import { ModelInvocationUsageSummaryLimitExceededError } from '../../usage/usageLedger';
|
||||
import { ModelInvocationProjectQuotaExceededError } from '../../usage/usageQuota';
|
||||
import {
|
||||
MAX_MODEL_INVOCATION_RECOVERY_PAGE_SIZE,
|
||||
ModelInvocationConflictError,
|
||||
ModelInvocationRepositoryUnavailableError,
|
||||
} from '../modelInvocation';
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
export interface LocalModelInvocationOperationAuthority {
|
||||
readonly client: DatabaseSync;
|
||||
enqueue<T>(
|
||||
work: () => Promise<T>,
|
||||
rejection: (reason: 'closed' | 'busy') => Error,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
export const TERMINAL_RUN_STATUSES = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
]);
|
||||
|
||||
export function unavailable(
|
||||
cause?: unknown,
|
||||
): ModelInvocationRepositoryUnavailableError {
|
||||
return new ModelInvocationRepositoryUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertLocalFeatureActive(client: DatabaseSync): void {
|
||||
try {
|
||||
assertLocalModelInvocationFeatureActive(client);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function identifier(value: unknown): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function nullableInteger(row: Row, key: string): number | null {
|
||||
return row[key] === null ? null : integer(row, key);
|
||||
}
|
||||
|
||||
export function recoveryLimit(value: number): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 1 ||
|
||||
value > MAX_MODEL_INVOCATION_RECOVERY_PAGE_SIZE
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function sqliteCode(error: unknown): string {
|
||||
if (!error || typeof error !== 'object') return '';
|
||||
const value = (error as { code?: unknown }).code;
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
export function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof ModelInvocationConflictError ||
|
||||
error instanceof ModelInvocationRepositoryUnavailableError ||
|
||||
error instanceof ModelInvocationUsageSummaryLimitExceededError ||
|
||||
error instanceof ModelInvocationProjectQuotaExceededError ||
|
||||
error instanceof PluginPackagePromptOutputArtifactConflictError ||
|
||||
error instanceof PluginPackagePromptOutputArtifactUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const code = sqliteCode(error);
|
||||
if (code.startsWith('ERR_SQLITE_CONSTRAINT')) {
|
||||
return new ModelInvocationConflictError();
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
export class PrivateLocalAuthority
|
||||
implements LocalModelInvocationOperationAuthority
|
||||
{
|
||||
readonly client: DatabaseSync;
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#pending = 0;
|
||||
|
||||
constructor(client: DatabaseSync) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
enqueue<T>(
|
||||
work: () => Promise<T>,
|
||||
rejection: (reason: 'closed' | 'busy') => Error,
|
||||
): Promise<T> {
|
||||
if (this.#pending >= 64) return Promise.reject(rejection('busy'));
|
||||
this.#pending += 1;
|
||||
const result = this.#tail.then(work, work);
|
||||
this.#tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result.finally(() => {
|
||||
this.#pending -= 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthority(
|
||||
value: LocalModelInvocationOperationAuthority | DatabaseSync,
|
||||
): value is LocalModelInvocationOperationAuthority {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'client' in value &&
|
||||
'enqueue' in value &&
|
||||
typeof value.enqueue === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
export function enqueueLocalModelInvocation<T>(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
work: () => T,
|
||||
): Promise<T> {
|
||||
return authority.enqueue(async () => {
|
||||
try {
|
||||
return work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}, unavailable);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
import {
|
||||
normalizeModelInvocationPriceQuote,
|
||||
normalizeModelInvocationPriceSettlement,
|
||||
type ModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceSettlement,
|
||||
} from '../../pricing/pricing';
|
||||
import {
|
||||
normalizeModelInvocationUsageLedgerRecord,
|
||||
type ModelInvocationUsageLedgerRecord,
|
||||
} from '../../usage/usageLedger';
|
||||
import {
|
||||
normalizeModelInvocationQuotaReservation,
|
||||
normalizeModelInvocationQuotaSettlement,
|
||||
type ModelInvocationQuotaReservation,
|
||||
type ModelInvocationQuotaSettlement,
|
||||
} from '../../usage/usageQuota';
|
||||
import {
|
||||
normalizeModelInvocationCompletionRecord,
|
||||
normalizeModelInvocationStartRecord,
|
||||
type ModelInvocationAuthoritySnapshot,
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationStartRecord,
|
||||
} from '../modelInvocation';
|
||||
import {
|
||||
normalizeModelInvocationResolutionRecord,
|
||||
type ModelInvocationResolutionRecord,
|
||||
} from '../modelInvocationResolution';
|
||||
|
||||
import type { Row } from './authority';
|
||||
import { integer, nullableInteger, text, unavailable } from './authority';
|
||||
|
||||
export function parseStart(row: Row): Readonly<ModelInvocationStartRecord> {
|
||||
let start: Readonly<ModelInvocationStartRecord>;
|
||||
try {
|
||||
start = normalizeModelInvocationStartRecord(
|
||||
JSON.parse(text(row, 'recordJson')) as ModelInvocationStartRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
start.invocationId !== text(row, 'invocationId') ||
|
||||
start.projectId !== text(row, 'projectId') ||
|
||||
start.runId !== text(row, 'runId') ||
|
||||
start.stepRunId !== text(row, 'stepRunId') ||
|
||||
start.traceId !== text(row, 'traceId') ||
|
||||
start.provider !== text(row, 'provider') ||
|
||||
start.model !== text(row, 'model') ||
|
||||
start.policyRevision !== text(row, 'policyRevision') ||
|
||||
start.requestDigest !== text(row, 'requestDigest') ||
|
||||
start.inputBytes !== integer(row, 'inputBytes') ||
|
||||
start.maxOutputTokens !== integer(row, 'maxOutputTokens') ||
|
||||
start.deadlineAtMs !== integer(row, 'deadlineAtMs') ||
|
||||
start.admittedAtMs !== integer(row, 'admittedAtMs') ||
|
||||
start.stepRunMutationId !== text(row, 'mutationId') ||
|
||||
start.stepRunMutationDigest !== text(row, 'mutationDigest') ||
|
||||
start.startedStepRunDigest !== text(row, 'stepRunDigest') ||
|
||||
start.startedStepRunVersion !== integer(row, 'stepRunVersion') ||
|
||||
start.runEventId !== text(row, 'runEventId') ||
|
||||
start.startDigest !== text(row, 'startDigest')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
export function parseCompletion(
|
||||
row: Row,
|
||||
): Readonly<ModelInvocationCompletionRecord> {
|
||||
let completion: Readonly<ModelInvocationCompletionRecord>;
|
||||
try {
|
||||
completion = normalizeModelInvocationCompletionRecord(
|
||||
JSON.parse(text(row, 'recordJson')) as ModelInvocationCompletionRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
completion.invocationId !== text(row, 'invocationId') ||
|
||||
completion.projectId !== text(row, 'projectId') ||
|
||||
completion.runId !== text(row, 'runId') ||
|
||||
completion.stepRunId !== text(row, 'stepRunId') ||
|
||||
completion.traceId !== text(row, 'traceId') ||
|
||||
completion.startDigest !== text(row, 'startDigest') ||
|
||||
completion.outcome !== text(row, 'outcome') ||
|
||||
completion.outputBytes !== integer(row, 'outputBytes') ||
|
||||
completion.errorCode !== row.errorCode ||
|
||||
completion.completedAtMs !== integer(row, 'completedAtMs') ||
|
||||
completion.stepRunMutationId !== text(row, 'mutationId') ||
|
||||
completion.stepRunMutationDigest !== text(row, 'mutationDigest') ||
|
||||
completion.completedStepRunDigest !== text(row, 'stepRunDigest') ||
|
||||
completion.completedStepRunVersion !== integer(row, 'stepRunVersion') ||
|
||||
completion.runEventId !== text(row, 'runEventId') ||
|
||||
completion.completionDigest !== text(row, 'completionDigest')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return completion;
|
||||
}
|
||||
|
||||
export function parseUsage(
|
||||
row: Row,
|
||||
): Readonly<ModelInvocationUsageLedgerRecord> {
|
||||
let usage: Readonly<ModelInvocationUsageLedgerRecord>;
|
||||
try {
|
||||
usage = normalizeModelInvocationUsageLedgerRecord(
|
||||
JSON.parse(text(row, 'recordJson')) as ModelInvocationUsageLedgerRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
usage.invocationId !== text(row, 'invocationId') ||
|
||||
usage.projectId !== text(row, 'projectId') ||
|
||||
usage.runId !== text(row, 'runId') ||
|
||||
usage.stepRunId !== text(row, 'stepRunId') ||
|
||||
usage.traceId !== text(row, 'traceId') ||
|
||||
usage.provider !== text(row, 'provider') ||
|
||||
usage.model !== text(row, 'model') ||
|
||||
usage.policyRevision !== text(row, 'policyRevision') ||
|
||||
usage.completionDigest !== text(row, 'completionDigest') ||
|
||||
usage.outcome !== text(row, 'outcome') ||
|
||||
usage.settledAtMs !== integer(row, 'settledAtMs') ||
|
||||
usage.inputBytes !== integer(row, 'inputBytes') ||
|
||||
usage.outputBytes !== integer(row, 'outputBytes') ||
|
||||
usage.inputTokens !== integer(row, 'inputTokens') ||
|
||||
usage.outputTokens !== integer(row, 'outputTokens') ||
|
||||
usage.totalTokens !== integer(row, 'totalTokens') ||
|
||||
usage.costMicros !== nullableInteger(row, 'costMicros') ||
|
||||
usage.ledgerDigest !== text(row, 'ledgerDigest')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
export function parseQuotaReservation(
|
||||
row: Row,
|
||||
): Readonly<ModelInvocationQuotaReservation> {
|
||||
try {
|
||||
return normalizeModelInvocationQuotaReservation(
|
||||
JSON.parse(text(row, 'recordJson')) as ModelInvocationQuotaReservation,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseQuotaSettlement(
|
||||
row: Row,
|
||||
reservation: Readonly<ModelInvocationQuotaReservation>,
|
||||
completion: Readonly<ModelInvocationCompletionRecord>,
|
||||
): Readonly<ModelInvocationQuotaSettlement> {
|
||||
try {
|
||||
return normalizeModelInvocationQuotaSettlement(
|
||||
JSON.parse(text(row, 'recordJson')) as ModelInvocationQuotaSettlement,
|
||||
reservation,
|
||||
completion,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePriceQuote(row: Row): Readonly<ModelInvocationPriceQuote> {
|
||||
try {
|
||||
return normalizeModelInvocationPriceQuote(
|
||||
JSON.parse(text(row, 'recordJson')) as ModelInvocationPriceQuote,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePriceSettlement(
|
||||
row: Row,
|
||||
quote: Readonly<ModelInvocationPriceQuote>,
|
||||
completion: Readonly<ModelInvocationCompletionRecord>,
|
||||
): Readonly<ModelInvocationPriceSettlement> {
|
||||
try {
|
||||
return normalizeModelInvocationPriceSettlement(
|
||||
JSON.parse(text(row, 'recordJson')) as ModelInvocationPriceSettlement,
|
||||
quote,
|
||||
completion,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseResolution(
|
||||
row: Row,
|
||||
): Readonly<ModelInvocationResolutionRecord> {
|
||||
let resolution: Readonly<ModelInvocationResolutionRecord>;
|
||||
try {
|
||||
resolution = normalizeModelInvocationResolutionRecord(
|
||||
JSON.parse(text(row, 'recordJson')) as ModelInvocationResolutionRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
resolution.resolutionId !== text(row, 'resolutionId') ||
|
||||
resolution.invocationId !== text(row, 'invocationId') ||
|
||||
resolution.projectId !== text(row, 'projectId') ||
|
||||
resolution.runId !== text(row, 'runId') ||
|
||||
resolution.stepRunId !== text(row, 'stepRunId') ||
|
||||
resolution.traceId !== text(row, 'traceId') ||
|
||||
resolution.completionDigest !== text(row, 'completionDigest') ||
|
||||
resolution.decision !== text(row, 'decision') ||
|
||||
resolution.resolvedByUserId !== text(row, 'resolvedByUserId') ||
|
||||
resolution.resolvedAtMs !== integer(row, 'resolvedAtMs') ||
|
||||
resolution.stepRunMutationId !== text(row, 'mutationId') ||
|
||||
resolution.stepRunMutationDigest !== text(row, 'mutationDigest') ||
|
||||
resolution.resolvedStepRunDigest !== text(row, 'stepRunDigest') ||
|
||||
resolution.resolvedStepRunVersion !== integer(row, 'stepRunVersion') ||
|
||||
resolution.runEventId !== text(row, 'runEventId') ||
|
||||
resolution.resolutionDigest !== text(row, 'resolutionDigest')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
export function parseAuthority(
|
||||
row: Row,
|
||||
): Readonly<ModelInvocationAuthoritySnapshot> {
|
||||
let stepRun: Readonly<StepRunRecord>;
|
||||
try {
|
||||
stepRun = normalizeStepRunRecord(
|
||||
JSON.parse(text(row, 'stepRunJson')) as StepRunRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
stepRun.id !== text(row, 'stepRunId') ||
|
||||
stepRun.runId !== text(row, 'runId') ||
|
||||
stepRun.kind !== 'model' ||
|
||||
stepRun.status !== text(row, 'stepStatus') ||
|
||||
stepRun.version !== integer(row, 'stepVersion') ||
|
||||
stepRun.stepRunDigest !== text(row, 'stepDigest')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: text(row, 'projectId'),
|
||||
runId: text(row, 'runId'),
|
||||
runVersion: integer(row, 'runVersion'),
|
||||
runEventSequence: integer(row, 'runEventSequence'),
|
||||
stepRun,
|
||||
});
|
||||
}
|
||||
|
||||
export const START_SELECT = `
|
||||
start.invocation_id AS "invocationId",
|
||||
start.project_id AS "projectId",
|
||||
start.run_id AS "runId",
|
||||
start.step_run_id AS "stepRunId",
|
||||
start.trace_id AS "traceId",
|
||||
start.provider AS "provider",
|
||||
start.model AS "model",
|
||||
start.policy_revision AS "policyRevision",
|
||||
start.request_digest AS "requestDigest",
|
||||
start.input_bytes AS "inputBytes",
|
||||
start.max_output_tokens AS "maxOutputTokens",
|
||||
start.deadline_at_ms AS "deadlineAtMs",
|
||||
start.admitted_at_ms AS "admittedAtMs",
|
||||
start.mutation_id AS "mutationId",
|
||||
start.mutation_digest AS "mutationDigest",
|
||||
start.run_event_id AS "runEventId",
|
||||
start.start_digest AS "startDigest",
|
||||
start.record_json AS "recordJson",
|
||||
mutation.step_run_digest AS "stepRunDigest",
|
||||
json_extract(mutation.step_run_json, '$.version') AS "stepRunVersion"
|
||||
`;
|
||||
|
||||
export const COMPLETION_SELECT = `
|
||||
completion.invocation_id AS "invocationId",
|
||||
completion.project_id AS "projectId",
|
||||
completion.run_id AS "runId",
|
||||
completion.step_run_id AS "stepRunId",
|
||||
completion.trace_id AS "traceId",
|
||||
completion.start_digest AS "startDigest",
|
||||
completion.outcome AS "outcome",
|
||||
completion.output_bytes AS "outputBytes",
|
||||
completion.error_code AS "errorCode",
|
||||
completion.completed_at_ms AS "completedAtMs",
|
||||
completion.mutation_id AS "mutationId",
|
||||
completion.mutation_digest AS "mutationDigest",
|
||||
completion.run_event_id AS "runEventId",
|
||||
completion.completion_digest AS "completionDigest",
|
||||
completion.record_json AS "recordJson",
|
||||
mutation.step_run_digest AS "stepRunDigest",
|
||||
json_extract(mutation.step_run_json, '$.version') AS "stepRunVersion"
|
||||
`;
|
||||
|
||||
export const USAGE_SELECT = `
|
||||
usage.invocation_id AS "invocationId",
|
||||
usage.project_id AS "projectId",
|
||||
usage.run_id AS "runId",
|
||||
usage.step_run_id AS "stepRunId",
|
||||
usage.trace_id AS "traceId",
|
||||
usage.provider AS "provider",
|
||||
usage.model AS "model",
|
||||
usage.policy_revision AS "policyRevision",
|
||||
usage.completion_digest AS "completionDigest",
|
||||
usage.outcome AS "outcome",
|
||||
usage.settled_at_ms AS "settledAtMs",
|
||||
usage.input_bytes AS "inputBytes",
|
||||
usage.output_bytes AS "outputBytes",
|
||||
usage.input_tokens AS "inputTokens",
|
||||
usage.output_tokens AS "outputTokens",
|
||||
usage.total_tokens AS "totalTokens",
|
||||
usage.cost_micros AS "costMicros",
|
||||
usage.ledger_digest AS "ledgerDigest",
|
||||
usage.record_json AS "recordJson"
|
||||
`;
|
||||
|
||||
export const RESOLUTION_SELECT = `
|
||||
resolution.resolution_id AS "resolutionId",
|
||||
resolution.invocation_id AS "invocationId",
|
||||
resolution.project_id AS "projectId",
|
||||
resolution.run_id AS "runId",
|
||||
resolution.step_run_id AS "stepRunId",
|
||||
resolution.trace_id AS "traceId",
|
||||
resolution.completion_digest AS "completionDigest",
|
||||
resolution.decision AS "decision",
|
||||
resolution.resolved_by_user_id AS "resolvedByUserId",
|
||||
resolution.resolved_at_ms AS "resolvedAtMs",
|
||||
resolution.mutation_id AS "mutationId",
|
||||
resolution.mutation_digest AS "mutationDigest",
|
||||
resolution.run_event_id AS "runEventId",
|
||||
resolution.resolution_digest AS "resolutionDigest",
|
||||
resolution.record_json AS "recordJson",
|
||||
mutation.step_run_digest AS "stepRunDigest",
|
||||
json_extract(mutation.step_run_json, '$.version') AS "stepRunVersion"
|
||||
`;
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { createModelInvocationPriceSettlement } from '../../pricing/pricing';
|
||||
import { type PluginPackagePromptOutputArtifact } from '../../prompt-output/pluginPackagePromptOutputArtifact';
|
||||
import {
|
||||
assertPluginPackagePromptOutputCompletionBinding,
|
||||
type CommitPluginPackagePromptOutputResult,
|
||||
} from '../../prompt-output/pluginPackagePromptOutputCompletion';
|
||||
import {
|
||||
putLocalPluginPackagePromptOutputArtifactInTransaction,
|
||||
readLocalPluginPackagePromptOutputArtifactInTransaction,
|
||||
} from '../../prompt-output/storage/localPluginPackagePromptOutputArtifactRepository';
|
||||
import { createModelInvocationUsageLedgerRecord } from '../../usage/usageLedger';
|
||||
import { createModelInvocationQuotaSettlement } from '../../usage/usageQuota';
|
||||
import {
|
||||
ModelInvocationConflictError,
|
||||
normalizeModelInvocationCompletionCommand,
|
||||
type CommitModelInvocationResult,
|
||||
type ModelInvocationCompletionCommand,
|
||||
type ModelInvocationCompletionRecord,
|
||||
} from '../modelInvocation';
|
||||
|
||||
import type { LocalModelInvocationOperationAuthority } from './authority';
|
||||
import { enqueueLocalModelInvocation } from './authority';
|
||||
import {
|
||||
parseCompletion,
|
||||
parsePriceQuote,
|
||||
parsePriceSettlement,
|
||||
parseQuotaReservation,
|
||||
parseQuotaSettlement,
|
||||
parseStart,
|
||||
parseUsage,
|
||||
} from './codec';
|
||||
import {
|
||||
applyMutation,
|
||||
assertCurrent,
|
||||
insertCompletion,
|
||||
insertPriceSettlement,
|
||||
insertQuotaSettlement,
|
||||
insertUsage,
|
||||
} from './mutations';
|
||||
import {
|
||||
completionRows,
|
||||
priceQuoteRows,
|
||||
priceSettlementRows,
|
||||
quotaReservationRows,
|
||||
quotaSettlementRows,
|
||||
startRows,
|
||||
usageRows,
|
||||
} from './queries';
|
||||
|
||||
export function completeOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
const command = normalizeModelInvocationCompletionCommand(commandValue);
|
||||
const completion = command.completion;
|
||||
const expectedUsage = createModelInvocationUsageLedgerRecord(
|
||||
command.start,
|
||||
completion,
|
||||
);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const existing = completionRows(
|
||||
client,
|
||||
`completion.invocation_id = ? OR
|
||||
completion.mutation_id = ? OR completion.run_event_id = ?`,
|
||||
[
|
||||
completion.invocationId,
|
||||
completion.stepRunMutationId,
|
||||
completion.runEventId,
|
||||
],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseCompletion(existing[0]);
|
||||
if (JSON.stringify(stored) !== JSON.stringify(completion)) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const usage = usageRows(client, 'usage.invocation_id = ?', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (
|
||||
usage.length !== (expectedUsage ? 1 : 0) ||
|
||||
(expectedUsage &&
|
||||
JSON.stringify(parseUsage(usage[0]!)) !==
|
||||
JSON.stringify(expectedUsage))
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
const starts = startRows(client, 'start.invocation_id = ?', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (
|
||||
starts.length !== 1 ||
|
||||
JSON.stringify(parseStart(starts[0]!)) !== JSON.stringify(command.start)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
assertCurrent(client, command.stepRunMutation, completion.projectId);
|
||||
applyMutation(client, command.stepRunMutation);
|
||||
insertCompletion(client, completion);
|
||||
if (expectedUsage) insertUsage(client, expectedUsage);
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
record: completion,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original transaction failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function completeWithQuotaOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
const command = normalizeModelInvocationCompletionCommand(commandValue);
|
||||
const completion = command.completion;
|
||||
const expectedUsage = createModelInvocationUsageLedgerRecord(
|
||||
command.start,
|
||||
completion,
|
||||
);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const reservationRows = quotaReservationRows(
|
||||
client,
|
||||
'reservation.invocation_id = ?',
|
||||
[completion.invocationId],
|
||||
);
|
||||
if (reservationRows.length !== 1) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const reservation = parseQuotaReservation(reservationRows[0]!);
|
||||
const expectedSettlement = createModelInvocationQuotaSettlement(
|
||||
reservation,
|
||||
completion,
|
||||
);
|
||||
const existing = completionRows(
|
||||
client,
|
||||
`completion.invocation_id = ? OR
|
||||
completion.mutation_id = ? OR completion.run_event_id = ?`,
|
||||
[
|
||||
completion.invocationId,
|
||||
completion.stepRunMutationId,
|
||||
completion.runEventId,
|
||||
],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseCompletion(existing[0]);
|
||||
const usage = usageRows(client, 'usage.invocation_id = ?', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
const settlements = quotaSettlementRows(
|
||||
client,
|
||||
'settlement.invocation_id = ?',
|
||||
[completion.invocationId],
|
||||
);
|
||||
if (
|
||||
JSON.stringify(stored) !== JSON.stringify(completion) ||
|
||||
usage.length !== (expectedUsage ? 1 : 0) ||
|
||||
(expectedUsage &&
|
||||
JSON.stringify(parseUsage(usage[0]!)) !==
|
||||
JSON.stringify(expectedUsage)) ||
|
||||
settlements.length !== 1 ||
|
||||
JSON.stringify(
|
||||
parseQuotaSettlement(settlements[0]!, reservation, completion),
|
||||
) !== JSON.stringify(expectedSettlement)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
const starts = startRows(client, 'start.invocation_id = ?', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (
|
||||
starts.length !== 1 ||
|
||||
JSON.stringify(parseStart(starts[0]!)) !== JSON.stringify(command.start)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
assertCurrent(client, command.stepRunMutation, completion.projectId);
|
||||
applyMutation(client, command.stepRunMutation);
|
||||
insertCompletion(client, completion);
|
||||
if (expectedUsage) insertUsage(client, expectedUsage);
|
||||
insertQuotaSettlement(client, expectedSettlement);
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
record: completion,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original transaction failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function completeWithPricingOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
const command = normalizeModelInvocationCompletionCommand(commandValue);
|
||||
const completion = command.completion;
|
||||
const expectedUsage = createModelInvocationUsageLedgerRecord(
|
||||
command.start,
|
||||
completion,
|
||||
);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const quoteRows = priceQuoteRows(client, 'quote.invocation_id = ?', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (quoteRows.length !== 1) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const quote = parsePriceQuote(quoteRows[0]!);
|
||||
if (
|
||||
quote.invocationId !== command.start.invocationId ||
|
||||
quote.projectId !== command.start.projectId ||
|
||||
quote.modelPolicyRevision !== command.start.policyRevision ||
|
||||
quote.provider !== command.start.provider ||
|
||||
quote.model !== command.start.model
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const expectedPriceSettlement = createModelInvocationPriceSettlement(
|
||||
quote,
|
||||
completion,
|
||||
);
|
||||
const reservationRows = quotaReservationRows(
|
||||
client,
|
||||
'reservation.invocation_id = ?',
|
||||
[completion.invocationId],
|
||||
);
|
||||
if (reservationRows.length > 1) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const reservation = reservationRows[0]
|
||||
? parseQuotaReservation(reservationRows[0])
|
||||
: null;
|
||||
const expectedQuotaSettlement = reservation
|
||||
? createModelInvocationQuotaSettlement(reservation, completion)
|
||||
: null;
|
||||
const existing = completionRows(
|
||||
client,
|
||||
`completion.invocation_id = ? OR
|
||||
completion.mutation_id = ? OR completion.run_event_id = ?`,
|
||||
[
|
||||
completion.invocationId,
|
||||
completion.stepRunMutationId,
|
||||
completion.runEventId,
|
||||
],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseCompletion(existing[0]);
|
||||
const usage = usageRows(client, 'usage.invocation_id = ?', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
const priceSettlements = priceSettlementRows(
|
||||
client,
|
||||
'settlement.invocation_id = ?',
|
||||
[completion.invocationId],
|
||||
);
|
||||
const quotaSettlements = quotaSettlementRows(
|
||||
client,
|
||||
'settlement.invocation_id = ?',
|
||||
[completion.invocationId],
|
||||
);
|
||||
if (
|
||||
JSON.stringify(stored) !== JSON.stringify(completion) ||
|
||||
usage.length !== (expectedUsage ? 1 : 0) ||
|
||||
(expectedUsage &&
|
||||
JSON.stringify(parseUsage(usage[0]!)) !==
|
||||
JSON.stringify(expectedUsage)) ||
|
||||
priceSettlements.length !== (expectedPriceSettlement ? 1 : 0) ||
|
||||
(expectedPriceSettlement &&
|
||||
JSON.stringify(
|
||||
parsePriceSettlement(priceSettlements[0]!, quote, completion),
|
||||
) !== JSON.stringify(expectedPriceSettlement)) ||
|
||||
quotaSettlements.length !== (expectedQuotaSettlement ? 1 : 0) ||
|
||||
(expectedQuotaSettlement &&
|
||||
JSON.stringify(
|
||||
parseQuotaSettlement(
|
||||
quotaSettlements[0]!,
|
||||
reservation!,
|
||||
completion,
|
||||
),
|
||||
) !== JSON.stringify(expectedQuotaSettlement))
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
const starts = startRows(client, 'start.invocation_id = ?', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (
|
||||
starts.length !== 1 ||
|
||||
JSON.stringify(parseStart(starts[0]!)) !== JSON.stringify(command.start)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
assertCurrent(client, command.stepRunMutation, completion.projectId);
|
||||
applyMutation(client, command.stepRunMutation);
|
||||
insertCompletion(client, completion);
|
||||
if (expectedUsage) insertUsage(client, expectedUsage);
|
||||
if (expectedPriceSettlement) {
|
||||
insertPriceSettlement(client, expectedPriceSettlement);
|
||||
}
|
||||
if (expectedQuotaSettlement) {
|
||||
insertQuotaSettlement(client, expectedQuotaSettlement);
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
record: completion,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original transaction failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function completeWithPromptOutputArtifactOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
artifactValue: PluginPackagePromptOutputArtifact,
|
||||
): Promise<Readonly<CommitPluginPackagePromptOutputResult>> {
|
||||
const command = normalizeModelInvocationCompletionCommand(commandValue);
|
||||
const completion = command.completion;
|
||||
const binding = assertPluginPackagePromptOutputCompletionBinding(
|
||||
command,
|
||||
artifactValue,
|
||||
);
|
||||
const expectedUsage = createModelInvocationUsageLedgerRecord(
|
||||
command.start,
|
||||
completion,
|
||||
);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const quoteRows = priceQuoteRows(client, 'quote.invocation_id = ?', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (quoteRows.length > 1) throw new ModelInvocationConflictError();
|
||||
const quote = quoteRows[0] ? parsePriceQuote(quoteRows[0]) : null;
|
||||
if (
|
||||
quote &&
|
||||
(quote.invocationId !== command.start.invocationId ||
|
||||
quote.projectId !== command.start.projectId ||
|
||||
quote.modelPolicyRevision !== command.start.policyRevision ||
|
||||
quote.provider !== command.start.provider ||
|
||||
quote.model !== command.start.model)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const expectedPriceSettlement = quote
|
||||
? createModelInvocationPriceSettlement(quote, completion)
|
||||
: null;
|
||||
const reservationRows = quotaReservationRows(
|
||||
client,
|
||||
'reservation.invocation_id = ?',
|
||||
[completion.invocationId],
|
||||
);
|
||||
if (reservationRows.length > 1) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const reservation = reservationRows[0]
|
||||
? parseQuotaReservation(reservationRows[0])
|
||||
: null;
|
||||
const expectedQuotaSettlement = reservation
|
||||
? createModelInvocationQuotaSettlement(reservation, completion)
|
||||
: null;
|
||||
const existing = completionRows(
|
||||
client,
|
||||
`completion.invocation_id = ? OR
|
||||
completion.mutation_id = ? OR completion.run_event_id = ?`,
|
||||
[
|
||||
completion.invocationId,
|
||||
completion.stepRunMutationId,
|
||||
completion.runEventId,
|
||||
],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseCompletion(existing[0]);
|
||||
const storedArtifact =
|
||||
readLocalPluginPackagePromptOutputArtifactInTransaction(
|
||||
client,
|
||||
binding.artifact.artifactId,
|
||||
);
|
||||
const usage = usageRows(client, 'usage.invocation_id = ?', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
const priceSettlements = priceSettlementRows(
|
||||
client,
|
||||
'settlement.invocation_id = ?',
|
||||
[completion.invocationId],
|
||||
);
|
||||
const quotaSettlements = quotaSettlementRows(
|
||||
client,
|
||||
'settlement.invocation_id = ?',
|
||||
[completion.invocationId],
|
||||
);
|
||||
if (
|
||||
JSON.stringify(stored) !== JSON.stringify(completion) ||
|
||||
!storedArtifact ||
|
||||
JSON.stringify(storedArtifact) !== JSON.stringify(binding.artifact) ||
|
||||
usage.length !== (expectedUsage ? 1 : 0) ||
|
||||
(expectedUsage &&
|
||||
JSON.stringify(parseUsage(usage[0]!)) !==
|
||||
JSON.stringify(expectedUsage)) ||
|
||||
priceSettlements.length !== (expectedPriceSettlement ? 1 : 0) ||
|
||||
(expectedPriceSettlement &&
|
||||
JSON.stringify(
|
||||
parsePriceSettlement(priceSettlements[0]!, quote!, completion),
|
||||
) !== JSON.stringify(expectedPriceSettlement)) ||
|
||||
quotaSettlements.length !== (expectedQuotaSettlement ? 1 : 0) ||
|
||||
(expectedQuotaSettlement &&
|
||||
JSON.stringify(
|
||||
parseQuotaSettlement(
|
||||
quotaSettlements[0]!,
|
||||
reservation!,
|
||||
completion,
|
||||
),
|
||||
) !== JSON.stringify(expectedQuotaSettlement))
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
record: stored,
|
||||
artifact: storedArtifact,
|
||||
reference: binding.reference,
|
||||
});
|
||||
}
|
||||
const starts = startRows(client, 'start.invocation_id = ?', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (
|
||||
starts.length !== 1 ||
|
||||
JSON.stringify(parseStart(starts[0]!)) !== JSON.stringify(command.start)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
assertCurrent(client, command.stepRunMutation, completion.projectId);
|
||||
const artifact = putLocalPluginPackagePromptOutputArtifactInTransaction(
|
||||
client,
|
||||
binding.artifact,
|
||||
).artifact;
|
||||
applyMutation(client, command.stepRunMutation);
|
||||
insertCompletion(client, completion);
|
||||
if (expectedUsage) insertUsage(client, expectedUsage);
|
||||
if (expectedPriceSettlement) {
|
||||
insertPriceSettlement(client, expectedPriceSettlement);
|
||||
}
|
||||
if (expectedQuotaSettlement) {
|
||||
insertQuotaSettlement(client, expectedQuotaSettlement);
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
record: completion,
|
||||
artifact,
|
||||
reference: binding.reference,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original transaction failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { type StepRunMutation } from '@qinglong/runtime-core/step-run';
|
||||
import {
|
||||
type ModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceSettlement,
|
||||
} from '../../pricing/pricing';
|
||||
import { type ModelInvocationUsageLedgerRecord } from '../../usage/usageLedger';
|
||||
import {
|
||||
type ModelInvocationQuotaReservation,
|
||||
type ModelInvocationQuotaSettlement,
|
||||
type ModelInvocationQuotaWindowUsage,
|
||||
} from '../../usage/usageQuota';
|
||||
import {
|
||||
ModelInvocationConflictError,
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationStartRecord,
|
||||
} from '../modelInvocation';
|
||||
import { type ModelInvocationResolutionRecord } from '../modelInvocationResolution';
|
||||
|
||||
import type { Row } from './authority';
|
||||
import { TERMINAL_RUN_STATUSES, integer, text, unavailable } from './authority';
|
||||
|
||||
export function updateStepRun(
|
||||
client: DatabaseSync,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): void {
|
||||
const step = mutation.stepRun;
|
||||
const result = client
|
||||
.prepare(
|
||||
`UPDATE "StepRuns"
|
||||
SET status = ?, version = ?, attempt_count = ?, output_ref = ?,
|
||||
approval_request_id = ?, ready_at_ms = ?, started_at_ms = ?,
|
||||
finished_at_ms = ?, result_code = ?, error_summary = ?,
|
||||
updated_at_ms = ?, last_mutation_id = ?, step_run_digest = ?,
|
||||
step_run_json = ?
|
||||
WHERE id = ? AND run_id = ? AND version = ?
|
||||
AND step_run_digest = ? AND status = ?`,
|
||||
)
|
||||
.run(
|
||||
step.status,
|
||||
step.version,
|
||||
step.attemptCount,
|
||||
step.outputRef,
|
||||
step.approvalRequestId,
|
||||
step.readyAtMs,
|
||||
step.startedAtMs,
|
||||
step.finishedAtMs,
|
||||
step.resultCode,
|
||||
step.errorSummary,
|
||||
step.updatedAtMs,
|
||||
step.lastMutationId,
|
||||
step.stepRunDigest,
|
||||
JSON.stringify(step),
|
||||
step.id,
|
||||
step.runId,
|
||||
mutation.expectedStepRunVersion,
|
||||
mutation.expectedStepRunDigest,
|
||||
mutation.previousStatus,
|
||||
);
|
||||
if (result.changes !== 1) throw new ModelInvocationConflictError();
|
||||
}
|
||||
|
||||
export function updateRun(
|
||||
client: DatabaseSync,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): void {
|
||||
const result = client
|
||||
.prepare(
|
||||
`UPDATE "Runs"
|
||||
SET version = version + 1, event_sequence = event_sequence + 1
|
||||
WHERE id = ? AND version = ? AND event_sequence = ?`,
|
||||
)
|
||||
.run(
|
||||
mutation.runId,
|
||||
mutation.expectedRunVersion,
|
||||
mutation.expectedRunEventSequence,
|
||||
);
|
||||
if (result.changes !== 1) throw new ModelInvocationConflictError();
|
||||
}
|
||||
|
||||
export function insertRunEvent(
|
||||
client: DatabaseSync,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): void {
|
||||
const event = mutation.event;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunEvents" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey!,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
mutation.stepRun.id,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function insertMutation(
|
||||
client: DatabaseSync,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "StepRunMutations" (
|
||||
mutation_id, mutation_digest, run_id, step_run_id,
|
||||
step_run_digest, event_id, event_sequence, run_version,
|
||||
step_run_json, committed_at_ms
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
CAST(unixepoch('subsec') * 1000 AS INTEGER)
|
||||
)`,
|
||||
)
|
||||
.run(
|
||||
mutation.mutationId,
|
||||
mutation.mutationDigest,
|
||||
mutation.runId,
|
||||
mutation.stepRun.id,
|
||||
mutation.stepRun.stepRunDigest,
|
||||
mutation.event.id,
|
||||
mutation.event.sequence,
|
||||
mutation.expectedRunVersion + 1,
|
||||
JSON.stringify(mutation.stepRun),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyMutation(
|
||||
client: DatabaseSync,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): void {
|
||||
updateStepRun(client, mutation);
|
||||
updateRun(client, mutation);
|
||||
insertRunEvent(client, mutation);
|
||||
insertMutation(client, mutation);
|
||||
}
|
||||
|
||||
export function insertStart(
|
||||
client: DatabaseSync,
|
||||
start: Readonly<ModelInvocationStartRecord>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationStarts" (
|
||||
invocation_id, project_id, run_id, step_run_id, trace_id,
|
||||
provider, model, policy_revision, request_digest, input_bytes,
|
||||
max_output_tokens, deadline_at_ms, admitted_at_ms, mutation_id,
|
||||
mutation_digest, run_event_id, start_digest, record_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
start.invocationId,
|
||||
start.projectId,
|
||||
start.runId,
|
||||
start.stepRunId,
|
||||
start.traceId,
|
||||
start.provider,
|
||||
start.model,
|
||||
start.policyRevision,
|
||||
start.requestDigest,
|
||||
start.inputBytes,
|
||||
start.maxOutputTokens,
|
||||
start.deadlineAtMs,
|
||||
start.admittedAtMs,
|
||||
start.stepRunMutationId,
|
||||
start.stepRunMutationDigest,
|
||||
start.runEventId,
|
||||
start.startDigest,
|
||||
JSON.stringify(start),
|
||||
);
|
||||
}
|
||||
|
||||
export function insertCompletion(
|
||||
client: DatabaseSync,
|
||||
completion: Readonly<ModelInvocationCompletionRecord>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationCompletions" (
|
||||
invocation_id, project_id, run_id, step_run_id, trace_id,
|
||||
start_digest, outcome, output_bytes, error_code, completed_at_ms,
|
||||
mutation_id, mutation_digest, run_event_id, completion_digest,
|
||||
record_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
completion.invocationId,
|
||||
completion.projectId,
|
||||
completion.runId,
|
||||
completion.stepRunId,
|
||||
completion.traceId,
|
||||
completion.startDigest,
|
||||
completion.outcome,
|
||||
completion.outputBytes,
|
||||
completion.errorCode,
|
||||
completion.completedAtMs,
|
||||
completion.stepRunMutationId,
|
||||
completion.stepRunMutationDigest,
|
||||
completion.runEventId,
|
||||
completion.completionDigest,
|
||||
JSON.stringify(completion),
|
||||
);
|
||||
}
|
||||
|
||||
export function insertUsage(
|
||||
client: DatabaseSync,
|
||||
usage: Readonly<ModelInvocationUsageLedgerRecord>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationUsageLedger" (
|
||||
invocation_id, project_id, run_id, step_run_id, trace_id,
|
||||
provider, model, policy_revision, completion_digest, outcome,
|
||||
settled_at_ms, input_bytes, output_bytes, input_tokens,
|
||||
output_tokens, total_tokens, cost_micros, ledger_digest, record_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
usage.invocationId,
|
||||
usage.projectId,
|
||||
usage.runId,
|
||||
usage.stepRunId,
|
||||
usage.traceId,
|
||||
usage.provider,
|
||||
usage.model,
|
||||
usage.policyRevision,
|
||||
usage.completionDigest,
|
||||
usage.outcome,
|
||||
usage.settledAtMs,
|
||||
usage.inputBytes,
|
||||
usage.outputBytes,
|
||||
usage.inputTokens,
|
||||
usage.outputTokens,
|
||||
usage.totalTokens,
|
||||
usage.costMicros,
|
||||
usage.ledgerDigest,
|
||||
JSON.stringify(usage),
|
||||
);
|
||||
}
|
||||
|
||||
export function insertQuotaReservation(
|
||||
client: DatabaseSync,
|
||||
reservation: Readonly<ModelInvocationQuotaReservation>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationQuotaReservations" (
|
||||
invocation_id, project_id, model_policy_revision,
|
||||
quota_policy_revision, window_ms, window_start_ms, window_end_ms,
|
||||
max_invocations, max_tokens, max_cost_micros, reserved_tokens,
|
||||
reserved_cost_micros, reserved_at_ms, admission_digest,
|
||||
reservation_digest, record_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
reservation.invocationId,
|
||||
reservation.projectId,
|
||||
reservation.modelPolicyRevision,
|
||||
reservation.quotaPolicyRevision,
|
||||
reservation.windowMs,
|
||||
reservation.windowStartMs,
|
||||
reservation.windowEndMs,
|
||||
reservation.maxInvocations,
|
||||
reservation.maxTokens,
|
||||
reservation.maxCostMicros,
|
||||
reservation.reservedTokens,
|
||||
reservation.reservedCostMicros,
|
||||
reservation.reservedAtMs,
|
||||
reservation.admissionDigest,
|
||||
reservation.reservationDigest,
|
||||
JSON.stringify(reservation),
|
||||
);
|
||||
}
|
||||
|
||||
export function insertQuotaSettlement(
|
||||
client: DatabaseSync,
|
||||
settlement: Readonly<ModelInvocationQuotaSettlement>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationQuotaSettlements" (
|
||||
invocation_id, project_id, reservation_digest, completion_digest,
|
||||
effective_tokens, effective_cost_micros,
|
||||
retained_token_reservation, retained_cost_reservation,
|
||||
settled_at_ms, settlement_digest, record_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
settlement.invocationId,
|
||||
settlement.projectId,
|
||||
settlement.reservationDigest,
|
||||
settlement.completionDigest,
|
||||
settlement.effectiveTokens,
|
||||
settlement.effectiveCostMicros,
|
||||
settlement.retainedTokenReservation ? 1 : 0,
|
||||
settlement.retainedCostReservation ? 1 : 0,
|
||||
settlement.settledAtMs,
|
||||
settlement.settlementDigest,
|
||||
JSON.stringify(settlement),
|
||||
);
|
||||
}
|
||||
|
||||
export function insertPriceQuote(
|
||||
client: DatabaseSync,
|
||||
quote: Readonly<ModelInvocationPriceQuote>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationPriceQuotes" (
|
||||
invocation_id, project_id, model_policy_revision, provider, model,
|
||||
price_revision, currency, input_micros_per_million_tokens,
|
||||
output_micros_per_million_tokens, max_total_tokens, max_output_tokens,
|
||||
reserved_cost_micros, catalog_digest, quote_digest, record_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
quote.invocationId,
|
||||
quote.projectId,
|
||||
quote.modelPolicyRevision,
|
||||
quote.provider,
|
||||
quote.model,
|
||||
quote.priceRevision,
|
||||
quote.currency,
|
||||
quote.inputMicrosPerMillionTokens,
|
||||
quote.outputMicrosPerMillionTokens,
|
||||
quote.maxTotalTokens,
|
||||
quote.maxOutputTokens,
|
||||
quote.reservedCostMicros,
|
||||
quote.catalogDigest,
|
||||
quote.quoteDigest,
|
||||
JSON.stringify(quote),
|
||||
);
|
||||
}
|
||||
|
||||
export function insertPriceSettlement(
|
||||
client: DatabaseSync,
|
||||
settlement: Readonly<ModelInvocationPriceSettlement>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationPriceSettlements" (
|
||||
invocation_id, project_id, quote_digest, completion_digest, currency,
|
||||
input_tokens, output_tokens, cost_micros, settled_at_ms,
|
||||
settlement_digest, record_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
settlement.invocationId,
|
||||
settlement.projectId,
|
||||
settlement.quoteDigest,
|
||||
settlement.completionDigest,
|
||||
settlement.currency,
|
||||
settlement.inputTokens,
|
||||
settlement.outputTokens,
|
||||
settlement.costMicros,
|
||||
settlement.settledAtMs,
|
||||
settlement.settlementDigest,
|
||||
JSON.stringify(settlement),
|
||||
);
|
||||
}
|
||||
|
||||
export function quotaWindowUsage(
|
||||
client: DatabaseSync,
|
||||
projectId: string,
|
||||
windowStartMs: number,
|
||||
windowMs: number,
|
||||
): Readonly<ModelInvocationQuotaWindowUsage> {
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) AS "invocationCount",
|
||||
COALESCE(SUM(COALESCE(
|
||||
settlement.effective_tokens, reservation.reserved_tokens
|
||||
)), 0) AS "effectiveTokens",
|
||||
COALESCE(SUM(COALESCE(
|
||||
settlement.effective_cost_micros,
|
||||
reservation.reserved_cost_micros,
|
||||
0
|
||||
)), 0) AS "effectiveCostMicros",
|
||||
COALESCE(SUM(CASE WHEN
|
||||
settlement.effective_cost_micros IS NULL
|
||||
AND reservation.reserved_cost_micros IS NULL
|
||||
THEN 1 ELSE 0 END), 0) AS "unknownCostInvocations"
|
||||
FROM "ModelInvocationQuotaReservations" AS reservation
|
||||
LEFT JOIN "ModelInvocationQuotaSettlements" AS settlement
|
||||
ON settlement.invocation_id = reservation.invocation_id
|
||||
WHERE reservation.project_id = ?
|
||||
AND reservation.window_start_ms = ?
|
||||
AND reservation.window_ms = ?`,
|
||||
)
|
||||
.get(projectId, windowStartMs, windowMs) as Row | undefined;
|
||||
if (!row) throw unavailable();
|
||||
return Object.freeze({
|
||||
projectId,
|
||||
windowStartMs,
|
||||
windowEndMs: windowStartMs + windowMs,
|
||||
invocationCount: integer(row, 'invocationCount'),
|
||||
effectiveTokens: integer(row, 'effectiveTokens'),
|
||||
effectiveCostMicros: integer(row, 'effectiveCostMicros'),
|
||||
unknownCostInvocations: integer(row, 'unknownCostInvocations'),
|
||||
});
|
||||
}
|
||||
|
||||
export function insertResolution(
|
||||
client: DatabaseSync,
|
||||
resolution: Readonly<ModelInvocationResolutionRecord>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationResolutions" (
|
||||
resolution_id, invocation_id, project_id, run_id, step_run_id,
|
||||
trace_id, completion_digest, decision, resolved_by_user_id,
|
||||
resolved_at_ms, mutation_id, mutation_digest, run_event_id,
|
||||
resolution_digest, record_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
resolution.resolutionId,
|
||||
resolution.invocationId,
|
||||
resolution.projectId,
|
||||
resolution.runId,
|
||||
resolution.stepRunId,
|
||||
resolution.traceId,
|
||||
resolution.completionDigest,
|
||||
resolution.decision,
|
||||
resolution.resolvedByUserId,
|
||||
resolution.resolvedAtMs,
|
||||
resolution.stepRunMutationId,
|
||||
resolution.stepRunMutationDigest,
|
||||
resolution.runEventId,
|
||||
resolution.resolutionDigest,
|
||||
JSON.stringify(resolution),
|
||||
);
|
||||
}
|
||||
|
||||
export function assertCurrent(
|
||||
client: DatabaseSync,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
projectId: string,
|
||||
): void {
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT
|
||||
step.kind AS "stepKind", step.status AS "stepStatus",
|
||||
step.version AS "stepVersion",
|
||||
step.step_run_digest AS "stepDigest",
|
||||
run.project_id AS "projectId", run.status AS "runStatus",
|
||||
run.version AS "runVersion",
|
||||
run.event_sequence AS "runEventSequence"
|
||||
FROM "StepRuns" AS step
|
||||
JOIN "Runs" AS run ON run.id = step.run_id
|
||||
WHERE step.id = ? AND step.run_id = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(mutation.stepRun.id, mutation.runId) as Row[];
|
||||
const row = rows[0];
|
||||
if (
|
||||
rows.length !== 1 ||
|
||||
!row ||
|
||||
text(row, 'stepKind') !== 'model' ||
|
||||
text(row, 'stepStatus') !== mutation.previousStatus ||
|
||||
integer(row, 'stepVersion') !== mutation.expectedStepRunVersion ||
|
||||
text(row, 'stepDigest') !== mutation.expectedStepRunDigest ||
|
||||
text(row, 'projectId') !== projectId ||
|
||||
integer(row, 'runVersion') !== mutation.expectedRunVersion ||
|
||||
integer(row, 'runEventSequence') !== mutation.expectedRunEventSequence ||
|
||||
TERMINAL_RUN_STATUSES.has(text(row, 'runStatus'))
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import type { Row } from './authority';
|
||||
import {
|
||||
COMPLETION_SELECT,
|
||||
RESOLUTION_SELECT,
|
||||
START_SELECT,
|
||||
USAGE_SELECT,
|
||||
} from './codec';
|
||||
|
||||
export function startRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly (string | number)[],
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT ${START_SELECT}
|
||||
FROM "ModelInvocationStarts" AS start
|
||||
JOIN "StepRunMutations" AS mutation
|
||||
ON mutation.mutation_id = start.mutation_id
|
||||
JOIN "RunEvents" AS event ON event.id = start.run_event_id
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(...values) as Row[];
|
||||
}
|
||||
|
||||
export function completionRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly (string | number)[],
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT ${COMPLETION_SELECT}
|
||||
FROM "ModelInvocationCompletions" AS completion
|
||||
JOIN "StepRunMutations" AS mutation
|
||||
ON mutation.mutation_id = completion.mutation_id
|
||||
JOIN "RunEvents" AS event ON event.id = completion.run_event_id
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(...values) as Row[];
|
||||
}
|
||||
|
||||
export function usageRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly (string | number)[],
|
||||
limit = 2,
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT ${USAGE_SELECT}
|
||||
FROM "ModelInvocationUsageLedger" AS usage
|
||||
WHERE ${where}
|
||||
ORDER BY usage.settled_at_ms, usage.invocation_id
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(...values, limit) as Row[];
|
||||
}
|
||||
|
||||
export function quotaReservationRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly (string | number)[],
|
||||
limit = 2,
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT reservation.record_json AS "recordJson"
|
||||
FROM "ModelInvocationQuotaReservations" AS reservation
|
||||
WHERE ${where}
|
||||
ORDER BY reservation.window_start_ms, reservation.invocation_id
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(...values, limit) as Row[];
|
||||
}
|
||||
|
||||
export function quotaSettlementRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly (string | number)[],
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT settlement.record_json AS "recordJson"
|
||||
FROM "ModelInvocationQuotaSettlements" AS settlement
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(...values) as Row[];
|
||||
}
|
||||
|
||||
export function priceQuoteRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly (string | number)[],
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT quote.record_json AS "recordJson"
|
||||
FROM "ModelInvocationPriceQuotes" AS quote
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(...values) as Row[];
|
||||
}
|
||||
|
||||
export function priceSettlementRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly (string | number)[],
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT settlement.record_json AS "recordJson"
|
||||
FROM "ModelInvocationPriceSettlements" AS settlement
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(...values) as Row[];
|
||||
}
|
||||
|
||||
export function resolutionRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly (string | number)[],
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT ${RESOLUTION_SELECT}
|
||||
FROM "ModelInvocationResolutions" AS resolution
|
||||
JOIN "StepRunMutations" AS mutation
|
||||
ON mutation.mutation_id = resolution.mutation_id
|
||||
JOIN "RunEvents" AS event ON event.id = resolution.run_event_id
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(...values) as Row[];
|
||||
}
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
type ModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceSettlement,
|
||||
} from '../../pricing/pricing';
|
||||
import { type PluginPackagePromptOutputArtifact } from '../../prompt-output/pluginPackagePromptOutputArtifact';
|
||||
import type { PluginPackagePromptOutputArtifactTombstone } from '../../prompt-output/pluginPackagePromptOutputRetention';
|
||||
import { readLocalPluginPackagePromptOutputArtifactInTransaction } from '../../prompt-output/storage/localPluginPackagePromptOutputArtifactRepository';
|
||||
import { readLocalPluginPackagePromptOutputArtifactTombstoneInTransaction } from '../../prompt-output/storage/localPluginPackagePromptOutputRetentionRepository';
|
||||
import {
|
||||
MAX_MODEL_INVOCATION_USAGE_SUMMARY_ROWS,
|
||||
ModelInvocationUsageSummaryLimitExceededError,
|
||||
normalizeModelInvocationUsageLedgerQuery,
|
||||
normalizeModelInvocationUsageLedgerSummaryQuery,
|
||||
type ModelInvocationUsageLedgerPage,
|
||||
type ModelInvocationUsageLedgerQuery,
|
||||
type ModelInvocationUsageLedgerRecord,
|
||||
type ModelInvocationUsageLedgerSummary,
|
||||
type ModelInvocationUsageLedgerSummaryQuery,
|
||||
} from '../../usage/usageLedger';
|
||||
import {
|
||||
type ModelInvocationQuotaReservation,
|
||||
type ModelInvocationQuotaSettlement,
|
||||
type ModelInvocationQuotaWindowUsage,
|
||||
} from '../../usage/usageQuota';
|
||||
import {
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationStartRecord,
|
||||
} from '../modelInvocation';
|
||||
|
||||
import type { LocalModelInvocationOperationAuthority, Row } from './authority';
|
||||
import {
|
||||
enqueueLocalModelInvocation,
|
||||
identifier,
|
||||
integer,
|
||||
unavailable,
|
||||
} from './authority';
|
||||
import {
|
||||
parseCompletion,
|
||||
parsePriceQuote,
|
||||
parsePriceSettlement,
|
||||
parseQuotaReservation,
|
||||
parseQuotaSettlement,
|
||||
parseStart,
|
||||
parseUsage,
|
||||
} from './codec';
|
||||
import { quotaWindowUsage } from './mutations';
|
||||
import {
|
||||
completionRows,
|
||||
priceQuoteRows,
|
||||
priceSettlementRows,
|
||||
quotaReservationRows,
|
||||
quotaSettlementRows,
|
||||
startRows,
|
||||
usageRows,
|
||||
} from './queries';
|
||||
|
||||
export function findStartOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationStartRecord> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const rows = startRows(client, 'start.invocation_id = ?', [invocationId]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parseStart(rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function findCompletionOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationCompletionRecord> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const rows = completionRows(client, 'completion.invocation_id = ?', [
|
||||
invocationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parseCompletion(rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function findPromptOutputArtifactOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
artifactIdValue: string,
|
||||
): Promise<Readonly<PluginPackagePromptOutputArtifact> | null> {
|
||||
const artifactId = identifier(artifactIdValue);
|
||||
return enqueueLocalModelInvocation(authority, () =>
|
||||
readLocalPluginPackagePromptOutputArtifactInTransaction(client, artifactId),
|
||||
);
|
||||
}
|
||||
|
||||
export function findPromptOutputArtifactTombstoneOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
artifactIdValue: string,
|
||||
): Promise<Readonly<PluginPackagePromptOutputArtifactTombstone> | null> {
|
||||
const artifactId = identifier(artifactIdValue);
|
||||
return enqueueLocalModelInvocation(authority, () =>
|
||||
readLocalPluginPackagePromptOutputArtifactTombstoneInTransaction(
|
||||
client,
|
||||
artifactId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function findUsageOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerRecord> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const rows = usageRows(client, 'usage.invocation_id = ?', [invocationId]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parseUsage(rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function findPriceQuoteOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationPriceQuote> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const rows = priceQuoteRows(client, 'quote.invocation_id = ?', [
|
||||
invocationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parsePriceQuote(rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function findPriceSettlementOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationPriceSettlement> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const quotes = priceQuoteRows(client, 'quote.invocation_id = ?', [
|
||||
invocationId,
|
||||
]);
|
||||
const completions = completionRows(client, 'completion.invocation_id = ?', [
|
||||
invocationId,
|
||||
]);
|
||||
const settlements = priceSettlementRows(
|
||||
client,
|
||||
'settlement.invocation_id = ?',
|
||||
[invocationId],
|
||||
);
|
||||
if (quotes.length > 1 || completions.length > 1 || settlements.length > 1) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (!settlements[0]) return null;
|
||||
if (!quotes[0] || !completions[0]) throw unavailable();
|
||||
return parsePriceSettlement(
|
||||
settlements[0],
|
||||
parsePriceQuote(quotes[0]),
|
||||
parseCompletion(completions[0]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function findQuotaReservationOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationQuotaReservation> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const rows = quotaReservationRows(client, 'reservation.invocation_id = ?', [
|
||||
invocationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parseQuotaReservation(rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function findQuotaSettlementOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationQuotaSettlement> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const reservations = quotaReservationRows(
|
||||
client,
|
||||
'reservation.invocation_id = ?',
|
||||
[invocationId],
|
||||
);
|
||||
const completions = completionRows(client, 'completion.invocation_id = ?', [
|
||||
invocationId,
|
||||
]);
|
||||
const settlements = quotaSettlementRows(
|
||||
client,
|
||||
'settlement.invocation_id = ?',
|
||||
[invocationId],
|
||||
);
|
||||
if (
|
||||
reservations.length > 1 ||
|
||||
completions.length > 1 ||
|
||||
settlements.length > 1
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (!settlements[0]) return null;
|
||||
if (!reservations[0] || !completions[0]) throw unavailable();
|
||||
return parseQuotaSettlement(
|
||||
settlements[0],
|
||||
parseQuotaReservation(reservations[0]),
|
||||
parseCompletion(completions[0]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function readQuotaWindowUsageOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
projectIdValue: string,
|
||||
atMsValue?: number,
|
||||
): Promise<Readonly<ModelInvocationQuotaWindowUsage> | null> {
|
||||
const projectId = identifier(projectIdValue);
|
||||
if (
|
||||
atMsValue !== undefined &&
|
||||
(!Number.isSafeInteger(atMsValue) || atMsValue < 0)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const atMs =
|
||||
atMsValue ??
|
||||
integer(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS "atMs"`,
|
||||
)
|
||||
.get() as Row,
|
||||
'atMs',
|
||||
);
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT reservation.record_json AS "recordJson"
|
||||
FROM "ModelInvocationQuotaReservations" AS reservation
|
||||
WHERE reservation.project_id = ?
|
||||
AND reservation.window_start_ms <= ?
|
||||
AND reservation.window_end_ms > ?
|
||||
ORDER BY reservation.reserved_at_ms DESC,
|
||||
reservation.invocation_id DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(projectId, atMs, atMs) as Row | undefined;
|
||||
if (!row) return null;
|
||||
const reservation = parseQuotaReservation(row);
|
||||
return quotaWindowUsage(
|
||||
client,
|
||||
projectId,
|
||||
reservation.windowStartMs,
|
||||
reservation.windowMs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function listProjectUsageOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
queryValue: ModelInvocationUsageLedgerQuery,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerPage>> {
|
||||
const query = normalizeModelInvocationUsageLedgerQuery(queryValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const cursor = query.after;
|
||||
const rows = usageRows(
|
||||
client,
|
||||
`usage.project_id = ?
|
||||
AND usage.settled_at_ms >= ? AND usage.settled_at_ms < ?
|
||||
${
|
||||
cursor
|
||||
? `AND (
|
||||
usage.settled_at_ms > ? OR
|
||||
(usage.settled_at_ms = ? AND usage.invocation_id > ?)
|
||||
)`
|
||||
: ''
|
||||
}`,
|
||||
[
|
||||
query.projectId,
|
||||
query.fromMsInclusive,
|
||||
query.toMsExclusive,
|
||||
...(cursor
|
||||
? [cursor.settledAtMs, cursor.settledAtMs, cursor.invocationId]
|
||||
: []),
|
||||
],
|
||||
query.limit + 1,
|
||||
);
|
||||
return Object.freeze({
|
||||
records: Object.freeze(
|
||||
rows.slice(0, query.limit).map((row) => parseUsage(row)),
|
||||
),
|
||||
hasMore: rows.length > query.limit,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeProjectUsageOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
queryValue: ModelInvocationUsageLedgerSummaryQuery,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerSummary>> {
|
||||
const query = normalizeModelInvocationUsageLedgerSummaryQuery(queryValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) AS "invocationCount",
|
||||
COALESCE(SUM(input_tokens), 0) AS "inputTokens",
|
||||
COALESCE(SUM(output_tokens), 0) AS "outputTokens",
|
||||
COALESCE(SUM(total_tokens), 0) AS "totalTokens",
|
||||
COALESCE(SUM(cost_micros), 0) AS "knownCostMicros",
|
||||
COALESCE(SUM(CASE WHEN cost_micros IS NULL THEN 1 ELSE 0 END), 0)
|
||||
AS "unknownCostInvocations"
|
||||
FROM (
|
||||
SELECT input_tokens, output_tokens, total_tokens, cost_micros
|
||||
FROM "ModelInvocationUsageLedger"
|
||||
WHERE project_id = ?
|
||||
AND settled_at_ms >= ? AND settled_at_ms < ?
|
||||
ORDER BY settled_at_ms, invocation_id
|
||||
LIMIT ?
|
||||
) AS bounded_usage`,
|
||||
)
|
||||
.get(
|
||||
query.projectId,
|
||||
query.fromMsInclusive,
|
||||
query.toMsExclusive,
|
||||
MAX_MODEL_INVOCATION_USAGE_SUMMARY_ROWS + 1,
|
||||
) as Row | undefined;
|
||||
if (!row) throw unavailable();
|
||||
if (
|
||||
integer(row, 'invocationCount') > MAX_MODEL_INVOCATION_USAGE_SUMMARY_ROWS
|
||||
) {
|
||||
throw new ModelInvocationUsageSummaryLimitExceededError();
|
||||
}
|
||||
return Object.freeze({
|
||||
invocationCount: integer(row, 'invocationCount'),
|
||||
inputTokens: integer(row, 'inputTokens'),
|
||||
outputTokens: integer(row, 'outputTokens'),
|
||||
totalTokens: integer(row, 'totalTokens'),
|
||||
knownCostMicros: integer(row, 'knownCostMicros'),
|
||||
unknownCostInvocations: integer(row, 'unknownCostInvocations'),
|
||||
});
|
||||
});
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
ModelInvocationConflictError,
|
||||
type CommitModelInvocationResult,
|
||||
type ModelInvocationAuthoritySnapshot,
|
||||
type ModelInvocationRecoveryPage,
|
||||
} from '../modelInvocation';
|
||||
import {
|
||||
normalizeModelInvocationResolutionCommand,
|
||||
type ModelInvocationResolutionCommand,
|
||||
type ModelInvocationResolutionRecord,
|
||||
} from '../modelInvocationResolution';
|
||||
|
||||
import type { LocalModelInvocationOperationAuthority, Row } from './authority';
|
||||
import {
|
||||
TERMINAL_RUN_STATUSES,
|
||||
enqueueLocalModelInvocation,
|
||||
identifier,
|
||||
integer,
|
||||
recoveryLimit,
|
||||
text,
|
||||
unavailable,
|
||||
} from './authority';
|
||||
import {
|
||||
START_SELECT,
|
||||
parseAuthority,
|
||||
parseCompletion,
|
||||
parseResolution,
|
||||
parseStart,
|
||||
} from './codec';
|
||||
import { applyMutation, assertCurrent, insertResolution } from './mutations';
|
||||
import { completionRows, resolutionRows } from './queries';
|
||||
|
||||
export function findResolutionOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationResolutionRecord> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const rows = resolutionRows(client, 'resolution.invocation_id = ?', [
|
||||
invocationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parseResolution(rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function readAuthorityOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
identity: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
stepRunId: string;
|
||||
}>,
|
||||
): Promise<Readonly<ModelInvocationAuthoritySnapshot> | null> {
|
||||
const projectId = identifier(identity?.projectId);
|
||||
const runId = identifier(identity?.runId);
|
||||
const stepRunId = identifier(identity?.stepRunId);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT
|
||||
run.project_id AS "projectId", run.id AS "runId",
|
||||
run.status AS "runStatus", run.version AS "runVersion",
|
||||
run.event_sequence AS "runEventSequence",
|
||||
step.id AS "stepRunId", step.status AS "stepStatus",
|
||||
step.version AS "stepVersion",
|
||||
step.step_run_digest AS "stepDigest",
|
||||
step.step_run_json AS "stepRunJson"
|
||||
FROM "Runs" AS run
|
||||
JOIN "StepRuns" AS step ON step.run_id = run.id
|
||||
WHERE run.project_id = ? AND run.id = ? AND step.id = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(projectId, runId, stepRunId) as Row[];
|
||||
if (rows.length > 1) throw unavailable();
|
||||
const row = rows[0];
|
||||
if (!row || TERMINAL_RUN_STATUSES.has(text(row, 'runStatus'))) {
|
||||
return null;
|
||||
}
|
||||
return parseAuthority(row);
|
||||
});
|
||||
}
|
||||
|
||||
export function listIncompleteOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
limitValue: number,
|
||||
): Promise<Readonly<ModelInvocationRecoveryPage>> {
|
||||
const limit = recoveryLimit(limitValue);
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
const observed = client
|
||||
.prepare(
|
||||
`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER)
|
||||
AS "observedAtMs"`,
|
||||
)
|
||||
.get() as Row | undefined;
|
||||
if (!observed) throw unavailable();
|
||||
const observedAtMs = integer(observed, 'observedAtMs');
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT ${START_SELECT}
|
||||
FROM "ModelInvocationStarts" AS start
|
||||
JOIN "StepRunMutations" AS mutation
|
||||
ON mutation.mutation_id = start.mutation_id
|
||||
JOIN "RunEvents" AS event ON event.id = start.run_event_id
|
||||
LEFT JOIN "ModelInvocationCompletions" AS completion
|
||||
ON completion.invocation_id = start.invocation_id
|
||||
WHERE completion.invocation_id IS NULL
|
||||
AND start.deadline_at_ms <= ?
|
||||
ORDER BY start.deadline_at_ms, start.invocation_id
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(observedAtMs, limit + 1) as Row[];
|
||||
const hasMore = rows.length > limit;
|
||||
return Object.freeze({
|
||||
observedAtMs,
|
||||
candidates: Object.freeze(
|
||||
rows.slice(0, limit).map((row) => parseStart(row)),
|
||||
),
|
||||
hasMore,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
commandValue: ModelInvocationResolutionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationResolutionRecord>>
|
||||
> {
|
||||
const command = normalizeModelInvocationResolutionCommand(commandValue);
|
||||
const resolution = command.resolution;
|
||||
return enqueueLocalModelInvocation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const existing = resolutionRows(
|
||||
client,
|
||||
`resolution.invocation_id = ? OR
|
||||
resolution.resolution_id = ? OR
|
||||
resolution.mutation_id = ? OR resolution.run_event_id = ?`,
|
||||
[
|
||||
resolution.invocationId,
|
||||
resolution.resolutionId,
|
||||
resolution.stepRunMutationId,
|
||||
resolution.runEventId,
|
||||
],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseResolution(existing[0]);
|
||||
if (JSON.stringify(stored) !== JSON.stringify(resolution)) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
const completions = completionRows(
|
||||
client,
|
||||
'completion.invocation_id = ?',
|
||||
[resolution.invocationId],
|
||||
);
|
||||
if (
|
||||
completions.length !== 1 ||
|
||||
JSON.stringify(parseCompletion(completions[0]!)) !==
|
||||
JSON.stringify(command.completion) ||
|
||||
command.completion.outcome !== 'outcome_unknown'
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
assertCurrent(client, command.stepRunMutation, resolution.projectId);
|
||||
applyMutation(client, command.stepRunMutation);
|
||||
insertResolution(client, resolution);
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
record: resolution,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original transaction failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
type ModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceSettlement,
|
||||
type PricingAwareModelInvocationRepository,
|
||||
} from '../../pricing/pricing';
|
||||
import { type PluginPackagePromptOutputArtifact } from '../../prompt-output/pluginPackagePromptOutputArtifact';
|
||||
import {
|
||||
type CommitPluginPackagePromptOutputResult,
|
||||
type PluginPackagePromptOutputCompletionRepository,
|
||||
} from '../../prompt-output/pluginPackagePromptOutputCompletion';
|
||||
import type { PluginPackagePromptOutputArtifactTombstone } from '../../prompt-output/pluginPackagePromptOutputRetention';
|
||||
import {
|
||||
type ModelInvocationUsageLedgerPage,
|
||||
type ModelInvocationUsageLedgerQuery,
|
||||
type ModelInvocationUsageLedgerRecord,
|
||||
type ModelInvocationUsageLedgerRepository,
|
||||
type ModelInvocationUsageLedgerSummary,
|
||||
type ModelInvocationUsageLedgerSummaryQuery,
|
||||
} from '../../usage/usageLedger';
|
||||
import {
|
||||
type ModelInvocationQuotaAdmission,
|
||||
type ModelInvocationQuotaReservation,
|
||||
type ModelInvocationQuotaSettlement,
|
||||
type ModelInvocationQuotaWindowUsage,
|
||||
type QuotaAwareModelInvocationRepository,
|
||||
} from '../../usage/usageQuota';
|
||||
import {
|
||||
type CommitModelInvocationResult,
|
||||
type ModelInvocationAuthoritySnapshot,
|
||||
type ModelInvocationCompletionCommand,
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationRecoveryPage,
|
||||
type ModelInvocationRepository,
|
||||
type ModelInvocationStartCommand,
|
||||
type ModelInvocationStartRecord,
|
||||
} from '../modelInvocation';
|
||||
import {
|
||||
type ModelInvocationResolutionCommand,
|
||||
type ModelInvocationResolutionRecord,
|
||||
type ModelInvocationResolutionRepository,
|
||||
} from '../modelInvocationResolution';
|
||||
|
||||
import {
|
||||
admitOperation,
|
||||
admitWithPricingOperation,
|
||||
admitWithQuotaOperation,
|
||||
} from './admissionOperations';
|
||||
import {
|
||||
PrivateLocalAuthority,
|
||||
isAuthority,
|
||||
type LocalModelInvocationOperationAuthority,
|
||||
} from './authority';
|
||||
import {
|
||||
completeOperation,
|
||||
completeWithPricingOperation,
|
||||
completeWithPromptOutputArtifactOperation,
|
||||
completeWithQuotaOperation,
|
||||
} from './completionOperations';
|
||||
import {
|
||||
findCompletionOperation,
|
||||
findPriceQuoteOperation,
|
||||
findPriceSettlementOperation,
|
||||
findPromptOutputArtifactOperation,
|
||||
findPromptOutputArtifactTombstoneOperation,
|
||||
findQuotaReservationOperation,
|
||||
findQuotaSettlementOperation,
|
||||
findStartOperation,
|
||||
findUsageOperation,
|
||||
listProjectUsageOperation,
|
||||
readQuotaWindowUsageOperation,
|
||||
summarizeProjectUsageOperation,
|
||||
} from './readOperations';
|
||||
import {
|
||||
findResolutionOperation,
|
||||
listIncompleteOperation,
|
||||
readAuthorityOperation,
|
||||
resolveOperation,
|
||||
} from './recoveryResolutionOperations';
|
||||
|
||||
export class LocalModelInvocationRepository
|
||||
implements
|
||||
ModelInvocationRepository,
|
||||
ModelInvocationResolutionRepository,
|
||||
ModelInvocationUsageLedgerRepository,
|
||||
QuotaAwareModelInvocationRepository,
|
||||
PricingAwareModelInvocationRepository,
|
||||
PluginPackagePromptOutputCompletionRepository
|
||||
{
|
||||
readonly #authority: LocalModelInvocationOperationAuthority;
|
||||
readonly #client: DatabaseSync;
|
||||
|
||||
constructor(
|
||||
authority: LocalModelInvocationOperationAuthority | DatabaseSync,
|
||||
) {
|
||||
this.#authority = isAuthority(authority)
|
||||
? authority
|
||||
: new PrivateLocalAuthority(authority);
|
||||
this.#client = this.#authority.client;
|
||||
}
|
||||
|
||||
findStart(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationStartRecord> | null> {
|
||||
return findStartOperation(this.#authority, this.#client, invocationIdValue);
|
||||
}
|
||||
|
||||
findCompletion(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationCompletionRecord> | null> {
|
||||
return findCompletionOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
invocationIdValue,
|
||||
);
|
||||
}
|
||||
|
||||
findPromptOutputArtifact(
|
||||
artifactIdValue: string,
|
||||
): Promise<Readonly<PluginPackagePromptOutputArtifact> | null> {
|
||||
return findPromptOutputArtifactOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
artifactIdValue,
|
||||
);
|
||||
}
|
||||
|
||||
findPromptOutputArtifactTombstone(
|
||||
artifactIdValue: string,
|
||||
): Promise<Readonly<PluginPackagePromptOutputArtifactTombstone> | null> {
|
||||
return findPromptOutputArtifactTombstoneOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
artifactIdValue,
|
||||
);
|
||||
}
|
||||
|
||||
findUsage(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerRecord> | null> {
|
||||
return findUsageOperation(this.#authority, this.#client, invocationIdValue);
|
||||
}
|
||||
|
||||
findPriceQuote(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationPriceQuote> | null> {
|
||||
return findPriceQuoteOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
invocationIdValue,
|
||||
);
|
||||
}
|
||||
|
||||
findPriceSettlement(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationPriceSettlement> | null> {
|
||||
return findPriceSettlementOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
invocationIdValue,
|
||||
);
|
||||
}
|
||||
|
||||
findQuotaReservation(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationQuotaReservation> | null> {
|
||||
return findQuotaReservationOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
invocationIdValue,
|
||||
);
|
||||
}
|
||||
|
||||
findQuotaSettlement(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationQuotaSettlement> | null> {
|
||||
return findQuotaSettlementOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
invocationIdValue,
|
||||
);
|
||||
}
|
||||
|
||||
readQuotaWindowUsage(
|
||||
projectIdValue: string,
|
||||
atMsValue?: number,
|
||||
): Promise<Readonly<ModelInvocationQuotaWindowUsage> | null> {
|
||||
return readQuotaWindowUsageOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
projectIdValue,
|
||||
atMsValue,
|
||||
);
|
||||
}
|
||||
|
||||
listProjectUsage(
|
||||
queryValue: ModelInvocationUsageLedgerQuery,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerPage>> {
|
||||
return listProjectUsageOperation(this.#authority, this.#client, queryValue);
|
||||
}
|
||||
|
||||
summarizeProjectUsage(
|
||||
queryValue: ModelInvocationUsageLedgerSummaryQuery,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerSummary>> {
|
||||
return summarizeProjectUsageOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
queryValue,
|
||||
);
|
||||
}
|
||||
|
||||
findResolution(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationResolutionRecord> | null> {
|
||||
return findResolutionOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
invocationIdValue,
|
||||
);
|
||||
}
|
||||
|
||||
readAuthority(
|
||||
identity: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
stepRunId: string;
|
||||
}>,
|
||||
): Promise<Readonly<ModelInvocationAuthoritySnapshot> | null> {
|
||||
return readAuthorityOperation(this.#authority, this.#client, identity);
|
||||
}
|
||||
|
||||
listIncomplete(
|
||||
limitValue: number,
|
||||
): Promise<Readonly<ModelInvocationRecoveryPage>> {
|
||||
return listIncompleteOperation(this.#authority, this.#client, limitValue);
|
||||
}
|
||||
|
||||
admit(
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>
|
||||
> {
|
||||
return admitOperation(this.#authority, this.#client, commandValue);
|
||||
}
|
||||
|
||||
admitWithQuota(
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
admissionValue: ModelInvocationQuotaAdmission,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>
|
||||
> {
|
||||
return admitWithQuotaOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
commandValue,
|
||||
admissionValue,
|
||||
);
|
||||
}
|
||||
|
||||
admitWithPricing(
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
quoteValue: ModelInvocationPriceQuote,
|
||||
admissionValue?: ModelInvocationQuotaAdmission,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>
|
||||
> {
|
||||
return admitWithPricingOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
commandValue,
|
||||
quoteValue,
|
||||
admissionValue,
|
||||
);
|
||||
}
|
||||
|
||||
complete(
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
return completeOperation(this.#authority, this.#client, commandValue);
|
||||
}
|
||||
|
||||
completeWithQuota(
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
return completeWithQuotaOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
commandValue,
|
||||
);
|
||||
}
|
||||
|
||||
completeWithPricing(
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
return completeWithPricingOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
commandValue,
|
||||
);
|
||||
}
|
||||
|
||||
completeWithPromptOutputArtifact(
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
artifactValue: PluginPackagePromptOutputArtifact,
|
||||
): Promise<Readonly<CommitPluginPackagePromptOutputResult>> {
|
||||
return completeWithPromptOutputArtifactOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
commandValue,
|
||||
artifactValue,
|
||||
);
|
||||
}
|
||||
|
||||
resolve(
|
||||
commandValue: ModelInvocationResolutionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationResolutionRecord>>
|
||||
> {
|
||||
return resolveOperation(this.#authority, this.#client, commandValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LocalModelInvocationRepository } from './local-model-invocation-repository/repository';
|
||||
export type { LocalModelInvocationOperationAuthority } from './local-model-invocation-repository/authority';
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { StepRunStatus } from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import type { ModelInvocationAuditRecord } from '../../model-gateway/model';
|
||||
import { normalizeModelUsage } from '../../model-gateway/validation';
|
||||
import type { ModelInvocationOutcome } from './contracts';
|
||||
import {
|
||||
ERROR_CODE_PATTERN,
|
||||
dataRecord,
|
||||
exactKeys,
|
||||
identifier,
|
||||
integer,
|
||||
invalid,
|
||||
requestDigest,
|
||||
} from './common';
|
||||
|
||||
export function normalizeAdmissionAudit(
|
||||
value: ModelInvocationAuditRecord,
|
||||
): Readonly<ModelInvocationAuditRecord> {
|
||||
const candidate = normalizeAuditCommon(value);
|
||||
if (
|
||||
candidate.phase !== 'admitted' ||
|
||||
candidate.outputBytes !== 0 ||
|
||||
candidate.usage !== null ||
|
||||
candidate.errorCode !== null
|
||||
) {
|
||||
invalid('admission audit facts are invalid');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function normalizeAuditCommon(
|
||||
value: ModelInvocationAuditRecord,
|
||||
): Readonly<ModelInvocationAuditRecord> {
|
||||
const candidate = dataRecord(value, 'audit record');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'deadlineAtMs',
|
||||
'errorCode',
|
||||
'inputBytes',
|
||||
'maxOutputTokens',
|
||||
'model',
|
||||
'occurredAtMs',
|
||||
'outputBytes',
|
||||
'phase',
|
||||
'policyRevision',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestDigest',
|
||||
'requestId',
|
||||
'runId',
|
||||
'stepRunId',
|
||||
'traceId',
|
||||
'usage',
|
||||
],
|
||||
'audit record',
|
||||
);
|
||||
if (!['admitted', 'completed', 'failed'].includes(value.phase)) {
|
||||
invalid('audit phase is invalid');
|
||||
}
|
||||
const usage = value.usage === null ? null : normalizeModelUsage(value.usage);
|
||||
const errorCode =
|
||||
value.errorCode === null
|
||||
? null
|
||||
: typeof value.errorCode === 'string' &&
|
||||
ERROR_CODE_PATTERN.test(value.errorCode)
|
||||
? value.errorCode
|
||||
: invalid('audit error code is invalid');
|
||||
return Object.freeze({
|
||||
phase: value.phase,
|
||||
projectId: identifier(value.projectId, 'audit project id'),
|
||||
runId: identifier(value.runId, 'audit Run id'),
|
||||
stepRunId: identifier(value.stepRunId, 'audit StepRun id'),
|
||||
traceId: identifier(value.traceId, 'audit trace id'),
|
||||
requestId: identifier(value.requestId, 'audit request id'),
|
||||
provider: identifier(value.provider, 'audit provider'),
|
||||
model: identifier(value.model, 'audit model'),
|
||||
policyRevision: identifier(value.policyRevision, 'audit policy revision'),
|
||||
requestDigest: requestDigest(value.requestDigest),
|
||||
deadlineAtMs: integer(
|
||||
value.deadlineAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'audit deadline',
|
||||
),
|
||||
inputBytes: integer(value.inputBytes, 1, 256 * 1024, 'audit input bytes'),
|
||||
maxOutputTokens: integer(
|
||||
value.maxOutputTokens,
|
||||
1,
|
||||
32_768,
|
||||
'audit max output tokens',
|
||||
),
|
||||
outputBytes: integer(
|
||||
value.outputBytes,
|
||||
0,
|
||||
1024 * 1024,
|
||||
'audit output bytes',
|
||||
),
|
||||
usage,
|
||||
errorCode,
|
||||
occurredAtMs: integer(
|
||||
value.occurredAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'audit time',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function outcomeFor(audit: Readonly<ModelInvocationAuditRecord>): Readonly<{
|
||||
outcome: ModelInvocationOutcome;
|
||||
stepStatus: StepRunStatus;
|
||||
resultCode?: string;
|
||||
errorSummary?: string;
|
||||
}> {
|
||||
if (audit.phase === 'completed') {
|
||||
if (!audit.usage || audit.errorCode !== null) {
|
||||
invalid('successful completion facts are invalid');
|
||||
}
|
||||
return Object.freeze({ outcome: 'succeeded', stepStatus: 'succeeded' });
|
||||
}
|
||||
if (audit.phase !== 'failed' || audit.errorCode === null) {
|
||||
invalid('failed completion facts are invalid');
|
||||
}
|
||||
if (audit.errorCode === 'MODEL_INVOCATION_DEADLINE_EXCEEDED') {
|
||||
return Object.freeze({
|
||||
outcome: 'timed_out',
|
||||
stepStatus: 'timed_out',
|
||||
resultCode: 'model_deadline_exceeded',
|
||||
errorSummary: 'Model invocation deadline exceeded',
|
||||
});
|
||||
}
|
||||
if (
|
||||
audit.errorCode === 'MODEL_INVOCATION_ABORTED' ||
|
||||
audit.errorCode === 'MODEL_STREAM_CANCELLED' ||
|
||||
audit.errorCode === 'MODEL_INVOCATION_OUTCOME_UNKNOWN'
|
||||
) {
|
||||
return Object.freeze({
|
||||
outcome: 'outcome_unknown',
|
||||
stepStatus: 'lost',
|
||||
resultCode: 'model_outcome_unknown',
|
||||
errorSummary: 'Model invocation outcome is unknown',
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
outcome: 'failed',
|
||||
stepStatus: 'failed',
|
||||
resultCode: 'model_provider_failed',
|
||||
errorSummary: 'Model invocation failed',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
MAX_MODEL_INVOCATION_RECORD_JSON_BYTES,
|
||||
MODEL_INVOCATION_MUTATION_PHASES,
|
||||
InvalidModelInvocationError,
|
||||
type ModelInvocationMutationPhase,
|
||||
} from './contracts';
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
export const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
export const START_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-invocation-start-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
export const START_COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-invocation-start-command-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
export const COMPLETION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-invocation-completion-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
export const COMPLETION_COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-invocation-completion-command-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const MUTATION_IDENTITY_DOMAIN = Buffer.from(
|
||||
'qinglong/model-invocation-mutation-identity@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
export function invalid(message: string): never {
|
||||
throw new InvalidModelInvocationError(message);
|
||||
}
|
||||
|
||||
export function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
return invalid(`${label} must be a plain object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requestDigest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) {
|
||||
return invalid('request digest is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createModelInvocationMutationIdentity(
|
||||
invocationIdValue: string,
|
||||
phaseValue: ModelInvocationMutationPhase,
|
||||
): Readonly<{
|
||||
mutationId: string;
|
||||
eventId: string;
|
||||
dedupeKey: string;
|
||||
}> {
|
||||
const invocationId = identifier(invocationIdValue, 'invocation id');
|
||||
if (!MODEL_INVOCATION_MUTATION_PHASES.includes(phaseValue)) {
|
||||
invalid('mutation phase is invalid');
|
||||
}
|
||||
const identityDigest = createHash('sha256')
|
||||
.update(MUTATION_IDENTITY_DOMAIN)
|
||||
.update(phaseValue, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(invocationId, 'utf8')
|
||||
.digest('hex');
|
||||
const eventHex =
|
||||
identityDigest.slice(0, 12) +
|
||||
'8' +
|
||||
identityDigest.slice(13, 16) +
|
||||
'8' +
|
||||
identityDigest.slice(17, 32);
|
||||
return Object.freeze({
|
||||
mutationId: `ql3mi.${phaseValue}.mutation.${identityDigest}`,
|
||||
eventId: `${eventHex.slice(0, 8)}-${eventHex.slice(8, 12)}-${eventHex.slice(
|
||||
12,
|
||||
16,
|
||||
)}-${eventHex.slice(16, 20)}-${eventHex.slice(20, 32)}`,
|
||||
dedupeKey: `ql3mi.${phaseValue}.dedupe.${identityDigest}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function integer(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function hash(domain: Uint8Array, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function assertJsonBudget(value: unknown, label: string): void {
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(value), 'utf8') >
|
||||
MAX_MODEL_INVOCATION_RECORD_JSON_BYTES
|
||||
) {
|
||||
invalid(`${label} exceeds its JSON budget`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { normalizeStepRunMutation, type StepRunMutation } from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import type { ModelInvocationAuditRecord } from '../../model-gateway/model';
|
||||
import { normalizeModelUsage } from '../../model-gateway/validation';
|
||||
import {
|
||||
MODEL_INVOCATION_COMPLETION_COMMAND_SCHEMA,
|
||||
MODEL_INVOCATION_COMPLETION_SCHEMA,
|
||||
MODEL_INVOCATION_OUTCOMES,
|
||||
type ModelInvocationCompletionCommand,
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationStartRecord,
|
||||
} from './contracts';
|
||||
import {
|
||||
COMPLETION_COMMAND_DIGEST_DOMAIN,
|
||||
COMPLETION_DIGEST_DOMAIN,
|
||||
ERROR_CODE_PATTERN,
|
||||
assertJsonBudget,
|
||||
createModelInvocationMutationIdentity,
|
||||
dataRecord,
|
||||
digest,
|
||||
exactKeys,
|
||||
hash,
|
||||
identifier,
|
||||
integer,
|
||||
invalid,
|
||||
} from './common';
|
||||
import { normalizeAuditCommon, outcomeFor } from './audit';
|
||||
import { normalizeModelInvocationStartRecord } from './startProtocol';
|
||||
|
||||
function completionWithoutDigest(
|
||||
value: Readonly<ModelInvocationCompletionRecord>,
|
||||
): Omit<ModelInvocationCompletionRecord, 'completionDigest'> {
|
||||
const { completionDigest: _completionDigest, ...unsigned } = value;
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
export function normalizeModelInvocationCompletionRecord(
|
||||
value: ModelInvocationCompletionRecord,
|
||||
): Readonly<ModelInvocationCompletionRecord> {
|
||||
const candidate = dataRecord(value, 'completion record');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'completedAtMs',
|
||||
'completedStepRunDigest',
|
||||
'completedStepRunVersion',
|
||||
'completionDigest',
|
||||
'errorCode',
|
||||
'invocationId',
|
||||
'outcome',
|
||||
'outputBytes',
|
||||
'projectId',
|
||||
'runEventId',
|
||||
'runId',
|
||||
'schema',
|
||||
'startDigest',
|
||||
'stepRunId',
|
||||
'stepRunMutationDigest',
|
||||
'stepRunMutationId',
|
||||
'traceId',
|
||||
'usage',
|
||||
],
|
||||
'completion record',
|
||||
);
|
||||
if (
|
||||
value.schema !== MODEL_INVOCATION_COMPLETION_SCHEMA ||
|
||||
!MODEL_INVOCATION_OUTCOMES.includes(value.outcome)
|
||||
) {
|
||||
invalid('completion schema or outcome is invalid');
|
||||
}
|
||||
const usage = value.usage === null ? null : normalizeModelUsage(value.usage);
|
||||
const errorCode =
|
||||
value.errorCode === null
|
||||
? null
|
||||
: typeof value.errorCode === 'string' &&
|
||||
ERROR_CODE_PATTERN.test(value.errorCode)
|
||||
? value.errorCode
|
||||
: invalid('completion error code is invalid');
|
||||
const normalized = Object.freeze({
|
||||
schema: MODEL_INVOCATION_COMPLETION_SCHEMA,
|
||||
invocationId: identifier(value.invocationId, 'invocation id'),
|
||||
projectId: identifier(value.projectId, 'project id'),
|
||||
runId: identifier(value.runId, 'Run id'),
|
||||
stepRunId: identifier(value.stepRunId, 'StepRun id'),
|
||||
traceId: identifier(value.traceId, 'trace id'),
|
||||
startDigest: digest(value.startDigest, 'start digest'),
|
||||
outcome: value.outcome,
|
||||
outputBytes: integer(value.outputBytes, 0, 1024 * 1024, 'output bytes'),
|
||||
usage,
|
||||
errorCode,
|
||||
completedStepRunVersion: integer(
|
||||
value.completedStepRunVersion,
|
||||
3,
|
||||
2_147_483_647,
|
||||
'completed StepRun version',
|
||||
),
|
||||
stepRunMutationId: identifier(
|
||||
value.stepRunMutationId,
|
||||
'StepRun mutation id',
|
||||
),
|
||||
stepRunMutationDigest: digest(
|
||||
value.stepRunMutationDigest,
|
||||
'StepRun mutation digest',
|
||||
),
|
||||
completedStepRunDigest: digest(
|
||||
value.completedStepRunDigest,
|
||||
'completed StepRun digest',
|
||||
),
|
||||
runEventId: identifier(value.runEventId, 'RunEvent id'),
|
||||
completedAtMs: integer(
|
||||
value.completedAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'completed time',
|
||||
),
|
||||
completionDigest: digest(value.completionDigest, 'completion digest'),
|
||||
});
|
||||
const identity = createModelInvocationMutationIdentity(
|
||||
normalized.invocationId,
|
||||
'completion',
|
||||
);
|
||||
if (
|
||||
(normalized.outcome === 'succeeded') !==
|
||||
(normalized.usage !== null && normalized.errorCode === null) ||
|
||||
(normalized.outcome !== 'succeeded' && normalized.errorCode === null) ||
|
||||
normalized.stepRunMutationId !== identity.mutationId ||
|
||||
normalized.runEventId !== identity.eventId ||
|
||||
hash(COMPLETION_DIGEST_DOMAIN, completionWithoutDigest(normalized)) !==
|
||||
normalized.completionDigest
|
||||
) {
|
||||
invalid('completion facts or digest are invalid');
|
||||
}
|
||||
assertJsonBudget(normalized, 'completion record');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createModelInvocationCompletionCommand(
|
||||
startValue: ModelInvocationStartRecord,
|
||||
auditValue: ModelInvocationAuditRecord,
|
||||
mutationValue: StepRunMutation,
|
||||
successOutputRefValue?: string,
|
||||
): Readonly<ModelInvocationCompletionCommand> {
|
||||
const start = normalizeModelInvocationStartRecord(startValue);
|
||||
const audit = normalizeAuditCommon(auditValue);
|
||||
const mutation = normalizeStepRunMutation(mutationValue);
|
||||
const outcome = outcomeFor(audit);
|
||||
const identity = createModelInvocationMutationIdentity(
|
||||
audit.requestId,
|
||||
'completion',
|
||||
);
|
||||
const successOutputRef =
|
||||
successOutputRefValue ?? `model-invocation:${start.invocationId}`;
|
||||
if (
|
||||
audit.requestId !== start.invocationId ||
|
||||
audit.projectId !== start.projectId ||
|
||||
audit.runId !== start.runId ||
|
||||
audit.stepRunId !== start.stepRunId ||
|
||||
audit.traceId !== start.traceId ||
|
||||
audit.provider !== start.provider ||
|
||||
audit.model !== start.model ||
|
||||
audit.policyRevision !== start.policyRevision ||
|
||||
audit.requestDigest !== start.requestDigest ||
|
||||
audit.deadlineAtMs !== start.deadlineAtMs ||
|
||||
audit.inputBytes !== start.inputBytes ||
|
||||
audit.maxOutputTokens !== start.maxOutputTokens ||
|
||||
audit.occurredAtMs < start.admittedAtMs ||
|
||||
mutation.previousStatus !== 'running' ||
|
||||
mutation.mutationId !== identity.mutationId ||
|
||||
mutation.expectedStepRunVersion !== start.startedStepRunVersion ||
|
||||
mutation.expectedStepRunDigest !== start.startedStepRunDigest ||
|
||||
mutation.stepRun.runId !== start.runId ||
|
||||
mutation.stepRun.id !== start.stepRunId ||
|
||||
mutation.stepRun.kind !== 'model' ||
|
||||
mutation.stepRun.status !== outcome.stepStatus ||
|
||||
mutation.stepRun.updatedAtMs !== audit.occurredAtMs ||
|
||||
mutation.event.id !== identity.eventId ||
|
||||
mutation.event.dedupeKey !== identity.dedupeKey ||
|
||||
mutation.event.type !== `step.${outcome.stepStatus}` ||
|
||||
(outcome.stepStatus === 'succeeded'
|
||||
? mutation.stepRun.outputRef !== successOutputRef
|
||||
: mutation.stepRun.resultCode !== outcome.resultCode ||
|
||||
mutation.stepRun.errorSummary !== outcome.errorSummary)
|
||||
) {
|
||||
invalid('completion identity or StepRun mutation is not exact');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_INVOCATION_COMPLETION_SCHEMA,
|
||||
invocationId: start.invocationId,
|
||||
projectId: start.projectId,
|
||||
runId: start.runId,
|
||||
stepRunId: start.stepRunId,
|
||||
traceId: start.traceId,
|
||||
startDigest: start.startDigest,
|
||||
outcome: outcome.outcome,
|
||||
outputBytes: audit.outputBytes,
|
||||
usage: audit.usage,
|
||||
errorCode: audit.errorCode,
|
||||
completedStepRunVersion: mutation.stepRun.version,
|
||||
stepRunMutationId: mutation.mutationId,
|
||||
stepRunMutationDigest: mutation.mutationDigest,
|
||||
completedStepRunDigest: mutation.stepRun.stepRunDigest,
|
||||
runEventId: mutation.event.id,
|
||||
completedAtMs: audit.occurredAtMs,
|
||||
});
|
||||
const completion = normalizeModelInvocationCompletionRecord({
|
||||
...unsigned,
|
||||
completionDigest: hash(COMPLETION_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
const commandUnsigned = Object.freeze({
|
||||
schema: MODEL_INVOCATION_COMPLETION_COMMAND_SCHEMA,
|
||||
start,
|
||||
completion,
|
||||
stepRunMutation: mutation,
|
||||
});
|
||||
return Object.freeze({
|
||||
...commandUnsigned,
|
||||
commandDigest: hash(COMPLETION_COMMAND_DIGEST_DOMAIN, commandUnsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelInvocationCompletionCommand(
|
||||
value: ModelInvocationCompletionCommand,
|
||||
): Readonly<ModelInvocationCompletionCommand> {
|
||||
const candidate = dataRecord(value, 'completion command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
['commandDigest', 'completion', 'schema', 'start', 'stepRunMutation'],
|
||||
'completion command',
|
||||
);
|
||||
if (value.schema !== MODEL_INVOCATION_COMPLETION_COMMAND_SCHEMA) {
|
||||
invalid('completion command schema is invalid');
|
||||
}
|
||||
const start = normalizeModelInvocationStartRecord(value.start);
|
||||
const completion = normalizeModelInvocationCompletionRecord(value.completion);
|
||||
const mutation = normalizeStepRunMutation(value.stepRunMutation);
|
||||
const canonical = createModelInvocationCompletionCommand(
|
||||
start,
|
||||
{
|
||||
phase: completion.outcome === 'succeeded' ? 'completed' : 'failed',
|
||||
projectId: completion.projectId,
|
||||
runId: completion.runId,
|
||||
stepRunId: completion.stepRunId,
|
||||
traceId: completion.traceId,
|
||||
requestId: completion.invocationId,
|
||||
provider: start.provider,
|
||||
model: start.model,
|
||||
policyRevision: start.policyRevision,
|
||||
requestDigest: start.requestDigest,
|
||||
deadlineAtMs: start.deadlineAtMs,
|
||||
inputBytes: start.inputBytes,
|
||||
maxOutputTokens: start.maxOutputTokens,
|
||||
outputBytes: completion.outputBytes,
|
||||
usage: completion.usage,
|
||||
errorCode: completion.errorCode,
|
||||
occurredAtMs: completion.completedAtMs,
|
||||
},
|
||||
mutation,
|
||||
completion.outcome === 'succeeded'
|
||||
? mutation.stepRun.outputRef ?? undefined
|
||||
: undefined,
|
||||
);
|
||||
if (
|
||||
canonical.completion.completionDigest !== completion.completionDigest ||
|
||||
digest(value.commandDigest, 'command digest') !== canonical.commandDigest
|
||||
) {
|
||||
invalid('completion command is not canonical');
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { StepRunRecord, StepRunMutation } from '@qinglong/runtime-core/step-run';
|
||||
import type { ModelUsage } from '../../model-gateway/model';
|
||||
|
||||
export const MODEL_INVOCATION_START_SCHEMA =
|
||||
'qinglong/model-invocation-start@v1' as const;
|
||||
export const MODEL_INVOCATION_START_COMMAND_SCHEMA =
|
||||
'qinglong/model-invocation-start-command@v1' as const;
|
||||
export const MODEL_INVOCATION_COMPLETION_SCHEMA =
|
||||
'qinglong/model-invocation-completion@v1' as const;
|
||||
export const MODEL_INVOCATION_COMPLETION_COMMAND_SCHEMA =
|
||||
'qinglong/model-invocation-completion-command@v1' as const;
|
||||
|
||||
export const MAX_MODEL_INVOCATION_RECORD_JSON_BYTES = 24 * 1024;
|
||||
export const MAX_MODEL_INVOCATION_RECOVERY_PAGE_SIZE = 128;
|
||||
export const MODEL_INVOCATION_OUTCOMES = [
|
||||
'succeeded',
|
||||
'failed',
|
||||
'timed_out',
|
||||
'outcome_unknown',
|
||||
] as const;
|
||||
export const MODEL_INVOCATION_MUTATION_PHASES = [
|
||||
'start',
|
||||
'completion',
|
||||
'resolution',
|
||||
] as const;
|
||||
|
||||
export type ModelInvocationOutcome = (typeof MODEL_INVOCATION_OUTCOMES)[number];
|
||||
export type ModelInvocationMutationPhase =
|
||||
(typeof MODEL_INVOCATION_MUTATION_PHASES)[number];
|
||||
|
||||
export interface ModelInvocationStartRecord {
|
||||
readonly schema: typeof MODEL_INVOCATION_START_SCHEMA;
|
||||
readonly invocationId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly traceId: string;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly policyRevision: string;
|
||||
readonly requestDigest: string;
|
||||
readonly inputBytes: number;
|
||||
readonly maxOutputTokens: number;
|
||||
readonly deadlineAtMs: number;
|
||||
readonly startedStepRunVersion: number;
|
||||
readonly stepRunMutationId: string;
|
||||
readonly stepRunMutationDigest: string;
|
||||
readonly startedStepRunDigest: string;
|
||||
readonly runEventId: string;
|
||||
readonly admittedAtMs: number;
|
||||
readonly startDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelInvocationStartCommand {
|
||||
readonly schema: typeof MODEL_INVOCATION_START_COMMAND_SCHEMA;
|
||||
readonly start: Readonly<ModelInvocationStartRecord>;
|
||||
readonly stepRunMutation: Readonly<StepRunMutation>;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelInvocationCompletionRecord {
|
||||
readonly schema: typeof MODEL_INVOCATION_COMPLETION_SCHEMA;
|
||||
readonly invocationId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly traceId: string;
|
||||
readonly startDigest: string;
|
||||
readonly outcome: ModelInvocationOutcome;
|
||||
readonly outputBytes: number;
|
||||
readonly usage: Readonly<ModelUsage> | null;
|
||||
readonly errorCode: string | null;
|
||||
readonly completedStepRunVersion: number;
|
||||
readonly stepRunMutationId: string;
|
||||
readonly stepRunMutationDigest: string;
|
||||
readonly completedStepRunDigest: string;
|
||||
readonly runEventId: string;
|
||||
readonly completedAtMs: number;
|
||||
readonly completionDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelInvocationCompletionCommand {
|
||||
readonly schema: typeof MODEL_INVOCATION_COMPLETION_COMMAND_SCHEMA;
|
||||
readonly start: Readonly<ModelInvocationStartRecord>;
|
||||
readonly completion: Readonly<ModelInvocationCompletionRecord>;
|
||||
readonly stepRunMutation: Readonly<StepRunMutation>;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface CommitModelInvocationResult<T> {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly record: Readonly<T>;
|
||||
}
|
||||
|
||||
export interface ModelInvocationAuthoritySnapshot {
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly runVersion: number;
|
||||
readonly runEventSequence: number;
|
||||
readonly stepRun: Readonly<StepRunRecord>;
|
||||
}
|
||||
|
||||
export interface ModelInvocationRecoveryPage {
|
||||
readonly observedAtMs: number;
|
||||
readonly candidates: readonly Readonly<ModelInvocationStartRecord>[];
|
||||
readonly hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface ModelInvocationRepository {
|
||||
findStart(
|
||||
invocationId: string,
|
||||
): Promise<Readonly<ModelInvocationStartRecord> | null>;
|
||||
findCompletion(
|
||||
invocationId: string,
|
||||
): Promise<Readonly<ModelInvocationCompletionRecord> | null>;
|
||||
readAuthority(
|
||||
identity: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
stepRunId: string;
|
||||
}>,
|
||||
): Promise<Readonly<ModelInvocationAuthoritySnapshot> | null>;
|
||||
listIncomplete(limit: number): Promise<Readonly<ModelInvocationRecoveryPage>>;
|
||||
admit(
|
||||
command: ModelInvocationStartCommand,
|
||||
): Promise<Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>>;
|
||||
complete(
|
||||
command: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
>;
|
||||
}
|
||||
|
||||
export class InvalidModelInvocationError extends TypeError {
|
||||
readonly code = 'MODEL_INVOCATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Model invocation is invalid: ${message}`);
|
||||
this.name = 'InvalidModelInvocationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelInvocationConflictError extends Error {
|
||||
readonly code = 'MODEL_INVOCATION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Model invocation conflicts with durable state');
|
||||
this.name = 'ModelInvocationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelInvocationRepositoryUnavailableError extends Error {
|
||||
readonly code = 'MODEL_INVOCATION_REPOSITORY_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Model invocation repository is unavailable', options);
|
||||
this.name = 'ModelInvocationRepositoryUnavailableError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { normalizeStepRunMutation, type StepRunMutation } from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import type { ModelInvocationAuditRecord } from '../../model-gateway/model';
|
||||
import {
|
||||
MODEL_INVOCATION_START_COMMAND_SCHEMA,
|
||||
MODEL_INVOCATION_START_SCHEMA,
|
||||
type ModelInvocationStartCommand,
|
||||
type ModelInvocationStartRecord,
|
||||
} from './contracts';
|
||||
import {
|
||||
START_COMMAND_DIGEST_DOMAIN,
|
||||
START_DIGEST_DOMAIN,
|
||||
assertJsonBudget,
|
||||
createModelInvocationMutationIdentity,
|
||||
dataRecord,
|
||||
digest,
|
||||
exactKeys,
|
||||
hash,
|
||||
identifier,
|
||||
integer,
|
||||
invalid,
|
||||
requestDigest,
|
||||
} from './common';
|
||||
import { normalizeAdmissionAudit } from './audit';
|
||||
|
||||
function startWithoutDigest(
|
||||
value: Readonly<ModelInvocationStartRecord>,
|
||||
): Omit<ModelInvocationStartRecord, 'startDigest'> {
|
||||
const { startDigest: _startDigest, ...unsigned } = value;
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
export function normalizeModelInvocationStartRecord(
|
||||
value: ModelInvocationStartRecord,
|
||||
): Readonly<ModelInvocationStartRecord> {
|
||||
const candidate = dataRecord(value, 'start record');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'admittedAtMs',
|
||||
'deadlineAtMs',
|
||||
'inputBytes',
|
||||
'invocationId',
|
||||
'maxOutputTokens',
|
||||
'model',
|
||||
'policyRevision',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestDigest',
|
||||
'runEventId',
|
||||
'runId',
|
||||
'schema',
|
||||
'startDigest',
|
||||
'startedStepRunDigest',
|
||||
'startedStepRunVersion',
|
||||
'stepRunId',
|
||||
'stepRunMutationDigest',
|
||||
'stepRunMutationId',
|
||||
'traceId',
|
||||
],
|
||||
'start record',
|
||||
);
|
||||
if (value.schema !== MODEL_INVOCATION_START_SCHEMA) {
|
||||
invalid('start schema is invalid');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: MODEL_INVOCATION_START_SCHEMA,
|
||||
invocationId: identifier(value.invocationId, 'invocation id'),
|
||||
projectId: identifier(value.projectId, 'project id'),
|
||||
runId: identifier(value.runId, 'Run id'),
|
||||
stepRunId: identifier(value.stepRunId, 'StepRun id'),
|
||||
traceId: identifier(value.traceId, 'trace id'),
|
||||
provider: identifier(value.provider, 'provider'),
|
||||
model: identifier(value.model, 'model'),
|
||||
policyRevision: identifier(value.policyRevision, 'policy revision'),
|
||||
requestDigest: requestDigest(value.requestDigest),
|
||||
inputBytes: integer(value.inputBytes, 1, 256 * 1024, 'input bytes'),
|
||||
maxOutputTokens: integer(
|
||||
value.maxOutputTokens,
|
||||
1,
|
||||
32_768,
|
||||
'max output tokens',
|
||||
),
|
||||
deadlineAtMs: integer(
|
||||
value.deadlineAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'deadline',
|
||||
),
|
||||
startedStepRunVersion: integer(
|
||||
value.startedStepRunVersion,
|
||||
2,
|
||||
2_147_483_647,
|
||||
'started StepRun version',
|
||||
),
|
||||
stepRunMutationId: identifier(
|
||||
value.stepRunMutationId,
|
||||
'StepRun mutation id',
|
||||
),
|
||||
stepRunMutationDigest: digest(
|
||||
value.stepRunMutationDigest,
|
||||
'StepRun mutation digest',
|
||||
),
|
||||
startedStepRunDigest: digest(
|
||||
value.startedStepRunDigest,
|
||||
'started StepRun digest',
|
||||
),
|
||||
runEventId: identifier(value.runEventId, 'RunEvent id'),
|
||||
admittedAtMs: integer(
|
||||
value.admittedAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'admitted time',
|
||||
),
|
||||
startDigest: digest(value.startDigest, 'start digest'),
|
||||
});
|
||||
const identity = createModelInvocationMutationIdentity(
|
||||
normalized.invocationId,
|
||||
'start',
|
||||
);
|
||||
if (
|
||||
normalized.deadlineAtMs <= normalized.admittedAtMs ||
|
||||
normalized.stepRunMutationId !== identity.mutationId ||
|
||||
normalized.runEventId !== identity.eventId ||
|
||||
hash(START_DIGEST_DOMAIN, startWithoutDigest(normalized)) !==
|
||||
normalized.startDigest
|
||||
) {
|
||||
invalid('start time or digest is invalid');
|
||||
}
|
||||
assertJsonBudget(normalized, 'start record');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertStartMutation(
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
audit: Readonly<ModelInvocationAuditRecord>,
|
||||
): void {
|
||||
const identity = createModelInvocationMutationIdentity(
|
||||
audit.requestId,
|
||||
'start',
|
||||
);
|
||||
if (
|
||||
mutation.previousStatus !== 'ready' ||
|
||||
mutation.mutationId !== identity.mutationId ||
|
||||
mutation.stepRun.kind !== 'model' ||
|
||||
mutation.stepRun.status !== 'running' ||
|
||||
mutation.stepRun.runId !== audit.runId ||
|
||||
mutation.stepRun.id !== audit.stepRunId ||
|
||||
mutation.stepRun.updatedAtMs !== audit.occurredAtMs ||
|
||||
mutation.event.id !== identity.eventId ||
|
||||
mutation.event.dedupeKey !== identity.dedupeKey ||
|
||||
mutation.event.type !== 'step.running'
|
||||
) {
|
||||
invalid('start StepRun mutation is not exact');
|
||||
}
|
||||
}
|
||||
|
||||
export function createModelInvocationStartCommand(
|
||||
auditValue: ModelInvocationAuditRecord,
|
||||
mutationValue: StepRunMutation,
|
||||
): Readonly<ModelInvocationStartCommand> {
|
||||
const audit = normalizeAdmissionAudit(auditValue);
|
||||
const mutation = normalizeStepRunMutation(mutationValue);
|
||||
assertStartMutation(mutation, audit);
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_INVOCATION_START_SCHEMA,
|
||||
invocationId: audit.requestId,
|
||||
projectId: audit.projectId,
|
||||
runId: audit.runId,
|
||||
stepRunId: audit.stepRunId,
|
||||
traceId: audit.traceId,
|
||||
provider: audit.provider,
|
||||
model: audit.model,
|
||||
policyRevision: audit.policyRevision,
|
||||
requestDigest: audit.requestDigest,
|
||||
inputBytes: audit.inputBytes,
|
||||
maxOutputTokens: audit.maxOutputTokens,
|
||||
deadlineAtMs: audit.deadlineAtMs,
|
||||
startedStepRunVersion: mutation.stepRun.version,
|
||||
stepRunMutationId: mutation.mutationId,
|
||||
stepRunMutationDigest: mutation.mutationDigest,
|
||||
startedStepRunDigest: mutation.stepRun.stepRunDigest,
|
||||
runEventId: mutation.event.id,
|
||||
admittedAtMs: audit.occurredAtMs,
|
||||
});
|
||||
const start = normalizeModelInvocationStartRecord({
|
||||
...unsigned,
|
||||
startDigest: hash(START_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
const commandUnsigned = Object.freeze({
|
||||
schema: MODEL_INVOCATION_START_COMMAND_SCHEMA,
|
||||
start,
|
||||
stepRunMutation: mutation,
|
||||
});
|
||||
return Object.freeze({
|
||||
...commandUnsigned,
|
||||
commandDigest: hash(START_COMMAND_DIGEST_DOMAIN, commandUnsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelInvocationStartCommand(
|
||||
value: ModelInvocationStartCommand,
|
||||
): Readonly<ModelInvocationStartCommand> {
|
||||
const candidate = dataRecord(value, 'start command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
['commandDigest', 'schema', 'start', 'stepRunMutation'],
|
||||
'start command',
|
||||
);
|
||||
if (value.schema !== MODEL_INVOCATION_START_COMMAND_SCHEMA) {
|
||||
invalid('start command schema is invalid');
|
||||
}
|
||||
const start = normalizeModelInvocationStartRecord(value.start);
|
||||
const mutation = normalizeStepRunMutation(value.stepRunMutation);
|
||||
const canonical = createModelInvocationStartCommand(
|
||||
{
|
||||
phase: 'admitted',
|
||||
projectId: start.projectId,
|
||||
runId: start.runId,
|
||||
stepRunId: start.stepRunId,
|
||||
traceId: start.traceId,
|
||||
requestId: start.invocationId,
|
||||
provider: start.provider,
|
||||
model: start.model,
|
||||
policyRevision: start.policyRevision,
|
||||
requestDigest: start.requestDigest,
|
||||
deadlineAtMs: start.deadlineAtMs,
|
||||
inputBytes: start.inputBytes,
|
||||
maxOutputTokens: start.maxOutputTokens,
|
||||
outputBytes: 0,
|
||||
usage: null,
|
||||
errorCode: null,
|
||||
occurredAtMs: start.admittedAtMs,
|
||||
},
|
||||
mutation,
|
||||
);
|
||||
if (
|
||||
canonical.start.startDigest !== start.startDigest ||
|
||||
digest(value.commandDigest, 'command digest') !== canonical.commandDigest
|
||||
) {
|
||||
invalid('start command is not canonical');
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export {
|
||||
MAX_MODEL_INVOCATION_RECORD_JSON_BYTES,
|
||||
MAX_MODEL_INVOCATION_RECOVERY_PAGE_SIZE,
|
||||
MODEL_INVOCATION_COMPLETION_COMMAND_SCHEMA,
|
||||
MODEL_INVOCATION_COMPLETION_SCHEMA,
|
||||
MODEL_INVOCATION_MUTATION_PHASES,
|
||||
MODEL_INVOCATION_OUTCOMES,
|
||||
MODEL_INVOCATION_START_COMMAND_SCHEMA,
|
||||
MODEL_INVOCATION_START_SCHEMA,
|
||||
InvalidModelInvocationError,
|
||||
ModelInvocationConflictError,
|
||||
ModelInvocationRepositoryUnavailableError,
|
||||
type CommitModelInvocationResult,
|
||||
type ModelInvocationAuthoritySnapshot,
|
||||
type ModelInvocationCompletionCommand,
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationMutationPhase,
|
||||
type ModelInvocationOutcome,
|
||||
type ModelInvocationRecoveryPage,
|
||||
type ModelInvocationRepository,
|
||||
type ModelInvocationStartCommand,
|
||||
type ModelInvocationStartRecord,
|
||||
} from './model-invocation/contracts';
|
||||
export { createModelInvocationMutationIdentity } from './model-invocation/common';
|
||||
export {
|
||||
createModelInvocationStartCommand,
|
||||
normalizeModelInvocationStartCommand,
|
||||
normalizeModelInvocationStartRecord,
|
||||
} from './model-invocation/startProtocol';
|
||||
export {
|
||||
createModelInvocationCompletionCommand,
|
||||
normalizeModelInvocationCompletionCommand,
|
||||
normalizeModelInvocationCompletionRecord,
|
||||
} from './model-invocation/completionProtocol';
|
||||
@@ -0,0 +1,584 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// Model Invocation owns durable ambiguity resolution alongside its transaction contract.
|
||||
|
||||
import {
|
||||
normalizeStepRunMutation,
|
||||
transitionStepRunMutation,
|
||||
type StepRunMutation,
|
||||
type StepRunStatus,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import {
|
||||
MAX_MODEL_INVOCATION_RECORD_JSON_BYTES,
|
||||
ModelInvocationConflictError,
|
||||
ModelInvocationRepositoryUnavailableError,
|
||||
createModelInvocationMutationIdentity,
|
||||
normalizeModelInvocationCompletionRecord,
|
||||
type CommitModelInvocationResult,
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationRepository,
|
||||
} from './modelInvocation';
|
||||
|
||||
export const MODEL_INVOCATION_RESOLUTION_SCHEMA =
|
||||
'qinglong/model-invocation-resolution@v1' as const;
|
||||
export const MODEL_INVOCATION_RESOLUTION_COMMAND_SCHEMA =
|
||||
'qinglong/model-invocation-resolution-command@v1' as const;
|
||||
export const MODEL_INVOCATION_RESOLUTION_DECISIONS = [
|
||||
'retry',
|
||||
'fail',
|
||||
'cancel',
|
||||
] as const;
|
||||
|
||||
export type ModelInvocationResolutionDecision =
|
||||
(typeof MODEL_INVOCATION_RESOLUTION_DECISIONS)[number];
|
||||
|
||||
export interface ModelInvocationResolutionRecord {
|
||||
readonly schema: typeof MODEL_INVOCATION_RESOLUTION_SCHEMA;
|
||||
readonly resolutionId: string;
|
||||
readonly invocationId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly stepRunId: string;
|
||||
readonly traceId: string;
|
||||
readonly completionDigest: string;
|
||||
readonly decision: ModelInvocationResolutionDecision;
|
||||
readonly resolvedByUserId: string;
|
||||
readonly resolvedStepRunVersion: number;
|
||||
readonly stepRunMutationId: string;
|
||||
readonly stepRunMutationDigest: string;
|
||||
readonly resolvedStepRunDigest: string;
|
||||
readonly runEventId: string;
|
||||
readonly resolvedAtMs: number;
|
||||
readonly resolutionDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelInvocationResolutionCommand {
|
||||
readonly schema: typeof MODEL_INVOCATION_RESOLUTION_COMMAND_SCHEMA;
|
||||
readonly completion: Readonly<ModelInvocationCompletionRecord>;
|
||||
readonly resolution: Readonly<ModelInvocationResolutionRecord>;
|
||||
readonly stepRunMutation: Readonly<StepRunMutation>;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelInvocationResolutionRepository
|
||||
extends ModelInvocationRepository {
|
||||
findResolution(
|
||||
invocationId: string,
|
||||
): Promise<Readonly<ModelInvocationResolutionRecord> | null>;
|
||||
resolve(
|
||||
command: ModelInvocationResolutionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationResolutionRecord>>
|
||||
>;
|
||||
}
|
||||
|
||||
export class InvalidModelInvocationResolutionError extends TypeError {
|
||||
readonly code = 'MODEL_INVOCATION_RESOLUTION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Model invocation resolution is invalid: ${message}`);
|
||||
this.name = 'InvalidModelInvocationResolutionError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const RESOLUTION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-invocation-resolution-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const RESOLUTION_COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-invocation-resolution-command-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidModelInvocationResolutionError(message);
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
return invalid(`${label} must be a plain object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function hash(domain: Uint8Array, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function withoutDigest(
|
||||
value: Readonly<ModelInvocationResolutionRecord>,
|
||||
): Omit<ModelInvocationResolutionRecord, 'resolutionDigest'> {
|
||||
const { resolutionDigest: _resolutionDigest, ...unsigned } = value;
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
export function resolutionTransition(
|
||||
decision: ModelInvocationResolutionDecision,
|
||||
): Readonly<{
|
||||
to: StepRunStatus;
|
||||
resultCode?: string;
|
||||
errorSummary?: string;
|
||||
}> {
|
||||
if (decision === 'retry') return Object.freeze({ to: 'ready' });
|
||||
if (decision === 'fail') {
|
||||
return Object.freeze({
|
||||
to: 'failed',
|
||||
resultCode: 'model_outcome_rejected',
|
||||
errorSummary: 'Unknown model outcome rejected by operator',
|
||||
});
|
||||
}
|
||||
if (decision === 'cancel') {
|
||||
return Object.freeze({
|
||||
to: 'cancelled',
|
||||
resultCode: 'model_outcome_cancelled',
|
||||
});
|
||||
}
|
||||
return invalid('decision is invalid');
|
||||
}
|
||||
|
||||
export function normalizeModelInvocationResolutionRecord(
|
||||
value: ModelInvocationResolutionRecord,
|
||||
): Readonly<ModelInvocationResolutionRecord> {
|
||||
const candidate = dataRecord(value, 'resolution record');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'completionDigest',
|
||||
'decision',
|
||||
'invocationId',
|
||||
'projectId',
|
||||
'resolutionDigest',
|
||||
'resolutionId',
|
||||
'resolvedAtMs',
|
||||
'resolvedByUserId',
|
||||
'resolvedStepRunDigest',
|
||||
'resolvedStepRunVersion',
|
||||
'runEventId',
|
||||
'runId',
|
||||
'schema',
|
||||
'stepRunId',
|
||||
'stepRunMutationDigest',
|
||||
'stepRunMutationId',
|
||||
'traceId',
|
||||
],
|
||||
'resolution record',
|
||||
);
|
||||
if (
|
||||
value.schema !== MODEL_INVOCATION_RESOLUTION_SCHEMA ||
|
||||
!MODEL_INVOCATION_RESOLUTION_DECISIONS.includes(value.decision)
|
||||
) {
|
||||
invalid('resolution schema or decision is invalid');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: MODEL_INVOCATION_RESOLUTION_SCHEMA,
|
||||
resolutionId: identifier(value.resolutionId, 'resolution id'),
|
||||
invocationId: identifier(value.invocationId, 'invocation id'),
|
||||
projectId: identifier(value.projectId, 'project id'),
|
||||
runId: identifier(value.runId, 'Run id'),
|
||||
stepRunId: identifier(value.stepRunId, 'StepRun id'),
|
||||
traceId: identifier(value.traceId, 'trace id'),
|
||||
completionDigest: digest(value.completionDigest, 'completion digest'),
|
||||
decision: value.decision,
|
||||
resolvedByUserId: identifier(value.resolvedByUserId, 'resolving user id'),
|
||||
resolvedStepRunVersion: integer(
|
||||
value.resolvedStepRunVersion,
|
||||
4,
|
||||
2_147_483_647,
|
||||
'resolved StepRun version',
|
||||
),
|
||||
stepRunMutationId: identifier(
|
||||
value.stepRunMutationId,
|
||||
'StepRun mutation id',
|
||||
),
|
||||
stepRunMutationDigest: digest(
|
||||
value.stepRunMutationDigest,
|
||||
'StepRun mutation digest',
|
||||
),
|
||||
resolvedStepRunDigest: digest(
|
||||
value.resolvedStepRunDigest,
|
||||
'resolved StepRun digest',
|
||||
),
|
||||
runEventId: identifier(value.runEventId, 'RunEvent id'),
|
||||
resolvedAtMs: integer(
|
||||
value.resolvedAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'resolved time',
|
||||
),
|
||||
resolutionDigest: digest(value.resolutionDigest, 'resolution digest'),
|
||||
});
|
||||
const identity = createModelInvocationMutationIdentity(
|
||||
normalized.invocationId,
|
||||
'resolution',
|
||||
);
|
||||
if (
|
||||
normalized.resolutionId !== identity.dedupeKey ||
|
||||
normalized.stepRunMutationId !== identity.mutationId ||
|
||||
normalized.runEventId !== identity.eventId ||
|
||||
hash(RESOLUTION_DIGEST_DOMAIN, withoutDigest(normalized)) !==
|
||||
normalized.resolutionDigest
|
||||
) {
|
||||
invalid('resolution identity or digest is invalid');
|
||||
}
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
||||
MAX_MODEL_INVOCATION_RECORD_JSON_BYTES
|
||||
) {
|
||||
invalid('resolution record exceeds its JSON budget');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createModelInvocationResolutionCommand(
|
||||
completionValue: ModelInvocationCompletionRecord,
|
||||
decision: ModelInvocationResolutionDecision,
|
||||
resolvedByUserIdValue: string,
|
||||
mutationValue: StepRunMutation,
|
||||
): Readonly<ModelInvocationResolutionCommand> {
|
||||
const completion = normalizeModelInvocationCompletionRecord(completionValue);
|
||||
const transition = resolutionTransition(decision);
|
||||
const resolvedByUserId = identifier(
|
||||
resolvedByUserIdValue,
|
||||
'resolving user id',
|
||||
);
|
||||
const mutation = normalizeStepRunMutation(mutationValue);
|
||||
const identity = createModelInvocationMutationIdentity(
|
||||
completion.invocationId,
|
||||
'resolution',
|
||||
);
|
||||
if (
|
||||
completion.outcome !== 'outcome_unknown' ||
|
||||
mutation.previousStatus !== 'lost' ||
|
||||
mutation.expectedStepRunVersion !== completion.completedStepRunVersion ||
|
||||
mutation.expectedStepRunDigest !== completion.completedStepRunDigest ||
|
||||
mutation.mutationId !== identity.mutationId ||
|
||||
mutation.runId !== completion.runId ||
|
||||
mutation.stepRun.id !== completion.stepRunId ||
|
||||
mutation.stepRun.runId !== completion.runId ||
|
||||
mutation.stepRun.kind !== 'model' ||
|
||||
mutation.stepRun.status !== transition.to ||
|
||||
mutation.event.id !== identity.eventId ||
|
||||
mutation.event.dedupeKey !== identity.dedupeKey ||
|
||||
mutation.event.type !== `step.${transition.to}` ||
|
||||
mutation.event.actorType !== 'user' ||
|
||||
mutation.event.actorId !== resolvedByUserId ||
|
||||
mutation.stepRun.updatedAtMs < completion.completedAtMs ||
|
||||
(transition.to === 'ready'
|
||||
? mutation.stepRun.outputRef !== null ||
|
||||
mutation.stepRun.resultCode !== null ||
|
||||
mutation.stepRun.errorSummary !== null
|
||||
: mutation.stepRun.resultCode !== transition.resultCode ||
|
||||
mutation.stepRun.errorSummary !==
|
||||
(transition.errorSummary === undefined
|
||||
? null
|
||||
: transition.errorSummary))
|
||||
) {
|
||||
invalid('resolution completion or StepRun mutation is not exact');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_INVOCATION_RESOLUTION_SCHEMA,
|
||||
resolutionId: identity.dedupeKey,
|
||||
invocationId: completion.invocationId,
|
||||
projectId: completion.projectId,
|
||||
runId: completion.runId,
|
||||
stepRunId: completion.stepRunId,
|
||||
traceId: completion.traceId,
|
||||
completionDigest: completion.completionDigest,
|
||||
decision,
|
||||
resolvedByUserId,
|
||||
resolvedStepRunVersion: mutation.stepRun.version,
|
||||
stepRunMutationId: mutation.mutationId,
|
||||
stepRunMutationDigest: mutation.mutationDigest,
|
||||
resolvedStepRunDigest: mutation.stepRun.stepRunDigest,
|
||||
runEventId: mutation.event.id,
|
||||
resolvedAtMs: mutation.stepRun.updatedAtMs,
|
||||
});
|
||||
const resolution = normalizeModelInvocationResolutionRecord({
|
||||
...unsigned,
|
||||
resolutionDigest: hash(RESOLUTION_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
const commandUnsigned = Object.freeze({
|
||||
schema: MODEL_INVOCATION_RESOLUTION_COMMAND_SCHEMA,
|
||||
completion,
|
||||
resolution,
|
||||
stepRunMutation: mutation,
|
||||
});
|
||||
return Object.freeze({
|
||||
...commandUnsigned,
|
||||
commandDigest: hash(RESOLUTION_COMMAND_DIGEST_DOMAIN, commandUnsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelInvocationResolutionCommand(
|
||||
value: ModelInvocationResolutionCommand,
|
||||
): Readonly<ModelInvocationResolutionCommand> {
|
||||
const candidate = dataRecord(value, 'resolution command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
['commandDigest', 'completion', 'resolution', 'schema', 'stepRunMutation'],
|
||||
'resolution command',
|
||||
);
|
||||
if (value.schema !== MODEL_INVOCATION_RESOLUTION_COMMAND_SCHEMA) {
|
||||
invalid('resolution command schema is invalid');
|
||||
}
|
||||
const completion = normalizeModelInvocationCompletionRecord(value.completion);
|
||||
const resolution = normalizeModelInvocationResolutionRecord(value.resolution);
|
||||
const mutation = normalizeStepRunMutation(value.stepRunMutation);
|
||||
const canonical = createModelInvocationResolutionCommand(
|
||||
completion,
|
||||
resolution.decision,
|
||||
resolution.resolvedByUserId,
|
||||
mutation,
|
||||
);
|
||||
if (
|
||||
canonical.resolution.resolutionDigest !== resolution.resolutionDigest ||
|
||||
digest(value.commandDigest, 'command digest') !== canonical.commandDigest
|
||||
) {
|
||||
invalid('resolution command is not canonical');
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
export function assertResolutionMatchesDecision(
|
||||
resolutionValue: ModelInvocationResolutionRecord,
|
||||
completionValue: ModelInvocationCompletionRecord,
|
||||
decision: ModelInvocationResolutionDecision,
|
||||
resolvedByUserId: string,
|
||||
): Readonly<ModelInvocationResolutionRecord> {
|
||||
const resolution = normalizeModelInvocationResolutionRecord(resolutionValue);
|
||||
const completion = normalizeModelInvocationCompletionRecord(completionValue);
|
||||
if (
|
||||
resolution.invocationId !== completion.invocationId ||
|
||||
resolution.projectId !== completion.projectId ||
|
||||
resolution.runId !== completion.runId ||
|
||||
resolution.stepRunId !== completion.stepRunId ||
|
||||
resolution.traceId !== completion.traceId ||
|
||||
resolution.completionDigest !== completion.completionDigest ||
|
||||
resolution.decision !== decision ||
|
||||
resolution.resolvedByUserId !== resolvedByUserId
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
export interface ResolveModelInvocationOptions {
|
||||
readonly invocationId: string;
|
||||
readonly decision: ModelInvocationResolutionDecision;
|
||||
readonly resolvedByUserId: string;
|
||||
readonly resolvedAtMs: number;
|
||||
}
|
||||
|
||||
const MAX_RESOLUTION_COORDINATOR_ATTEMPTS = 3;
|
||||
|
||||
export class DurableModelInvocationResolutionCoordinator {
|
||||
constructor(
|
||||
private readonly repository: ModelInvocationResolutionRepository,
|
||||
) {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.findCompletion !== 'function' ||
|
||||
typeof repository.findResolution !== 'function' ||
|
||||
typeof repository.readAuthority !== 'function' ||
|
||||
typeof repository.resolve !== 'function'
|
||||
) {
|
||||
throw new ModelInvocationRepositoryUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(
|
||||
optionsValue: ResolveModelInvocationOptions,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationResolutionRecord>>
|
||||
> {
|
||||
const options = dataRecord(optionsValue, 'resolution options');
|
||||
exactKeys(
|
||||
options,
|
||||
['decision', 'invocationId', 'resolvedAtMs', 'resolvedByUserId'],
|
||||
'resolution options',
|
||||
);
|
||||
const invocationId = identifier(optionsValue.invocationId, 'invocation id');
|
||||
const decision = optionsValue.decision;
|
||||
resolutionTransition(decision);
|
||||
const resolvedByUserId = identifier(
|
||||
optionsValue.resolvedByUserId,
|
||||
'resolving user id',
|
||||
);
|
||||
const resolvedAtMs = integer(
|
||||
optionsValue.resolvedAtMs,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'resolved time',
|
||||
);
|
||||
const completion = await this.repository.findCompletion(invocationId);
|
||||
if (!completion || completion.outcome !== 'outcome_unknown') {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const existing = await this.repository.findResolution(invocationId);
|
||||
if (existing) {
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
record: assertResolutionMatchesDecision(
|
||||
existing,
|
||||
completion,
|
||||
decision,
|
||||
resolvedByUserId,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < MAX_RESOLUTION_COORDINATOR_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
const authority = await this.repository.readAuthority({
|
||||
projectId: completion.projectId,
|
||||
runId: completion.runId,
|
||||
stepRunId: completion.stepRunId,
|
||||
});
|
||||
if (
|
||||
!authority ||
|
||||
authority.stepRun.kind !== 'model' ||
|
||||
authority.stepRun.status !== 'lost' ||
|
||||
authority.stepRun.version !== completion.completedStepRunVersion ||
|
||||
authority.stepRun.stepRunDigest !== completion.completedStepRunDigest
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const transition = resolutionTransition(decision);
|
||||
const identity = createModelInvocationMutationIdentity(
|
||||
invocationId,
|
||||
'resolution',
|
||||
);
|
||||
const command = createModelInvocationResolutionCommand(
|
||||
completion,
|
||||
decision,
|
||||
resolvedByUserId,
|
||||
transitionStepRunMutation(
|
||||
authority.stepRun,
|
||||
{
|
||||
expectedVersion: authority.stepRun.version,
|
||||
expectedDigest: authority.stepRun.stepRunDigest,
|
||||
mutationId: identity.mutationId,
|
||||
to: transition.to,
|
||||
atMs: resolvedAtMs,
|
||||
...(transition.resultCode === undefined
|
||||
? {}
|
||||
: { resultCode: transition.resultCode }),
|
||||
...(transition.errorSummary === undefined
|
||||
? {}
|
||||
: { errorSummary: transition.errorSummary }),
|
||||
},
|
||||
{
|
||||
expectedRunVersion: authority.runVersion,
|
||||
expectedRunEventSequence: authority.runEventSequence,
|
||||
eventId: identity.eventId,
|
||||
dedupeKey: identity.dedupeKey,
|
||||
actor: { type: 'user', id: resolvedByUserId },
|
||||
},
|
||||
),
|
||||
);
|
||||
try {
|
||||
return await this.repository.resolve(command);
|
||||
} catch (error) {
|
||||
const stored = await this.#resolutionAfterFailure(
|
||||
completion,
|
||||
decision,
|
||||
resolvedByUserId,
|
||||
error,
|
||||
);
|
||||
if (stored) {
|
||||
return Object.freeze({ status: 'existing', record: stored });
|
||||
}
|
||||
if (
|
||||
!(error instanceof ModelInvocationConflictError) ||
|
||||
attempt + 1 >= MAX_RESOLUTION_COORDINATOR_ATTEMPTS
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
|
||||
async #resolutionAfterFailure(
|
||||
completion: Readonly<ModelInvocationCompletionRecord>,
|
||||
decision: ModelInvocationResolutionDecision,
|
||||
resolvedByUserId: string,
|
||||
original: unknown,
|
||||
): Promise<Readonly<ModelInvocationResolutionRecord> | null> {
|
||||
try {
|
||||
const stored = await this.repository.findResolution(
|
||||
completion.invocationId,
|
||||
);
|
||||
return stored
|
||||
? assertResolutionMatchesDecision(
|
||||
stored,
|
||||
completion,
|
||||
decision,
|
||||
resolvedByUserId,
|
||||
)
|
||||
: null;
|
||||
} catch {
|
||||
throw original;
|
||||
}
|
||||
}
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
normalizeModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceQuote,
|
||||
} from '../../pricing/pricing';
|
||||
import {
|
||||
ModelInvocationProjectQuotaExceededError,
|
||||
createModelInvocationQuotaReservation,
|
||||
normalizeModelInvocationQuotaAdmission,
|
||||
type ModelInvocationQuotaAdmission,
|
||||
type ModelInvocationQuotaReservation,
|
||||
} from '../../usage/usageQuota';
|
||||
import {
|
||||
ModelInvocationConflictError,
|
||||
normalizeModelInvocationStartCommand,
|
||||
type CommitModelInvocationResult,
|
||||
type ModelInvocationStartCommand,
|
||||
type ModelInvocationStartRecord,
|
||||
} from '../modelInvocation';
|
||||
|
||||
import type { Row } from './authority';
|
||||
import { integer, unavailable } from './authority';
|
||||
import { parsePriceQuote, parseQuotaReservation, parseStart } from './codec';
|
||||
import {
|
||||
applyMutation,
|
||||
assertCurrent,
|
||||
insertPriceQuote,
|
||||
insertQuotaReservation,
|
||||
insertStart,
|
||||
quotaWindowUsage,
|
||||
} from './mutations';
|
||||
import { priceQuoteRows, quotaReservationRows, startRows } from './queries';
|
||||
import { runPostgresModelInvocationTransaction } from './transaction';
|
||||
|
||||
export async function admitOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
): Promise<Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>> {
|
||||
const command = normalizeModelInvocationStartCommand(commandValue);
|
||||
const start = command.start;
|
||||
return runPostgresModelInvocationTransaction(pool, async (client) => {
|
||||
const existing = await startRows(
|
||||
client,
|
||||
`start.invocation_id = $1 OR
|
||||
start.mutation_id = $2 OR start.run_event_id = $3`,
|
||||
[start.invocationId, start.stepRunMutationId, start.runEventId],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseStart(existing[0]);
|
||||
if (JSON.stringify(stored) !== JSON.stringify(start)) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
await assertCurrent(client, command.stepRunMutation, start.projectId);
|
||||
await applyMutation(client, command.stepRunMutation);
|
||||
await insertStart(client, start);
|
||||
return Object.freeze({ status: 'created' as const, record: start });
|
||||
});
|
||||
}
|
||||
|
||||
export async function admitWithQuotaOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
admissionValue: ModelInvocationQuotaAdmission,
|
||||
): Promise<Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>> {
|
||||
const command = normalizeModelInvocationStartCommand(commandValue);
|
||||
const admission = normalizeModelInvocationQuotaAdmission(admissionValue);
|
||||
const start = command.start;
|
||||
if (
|
||||
admission.invocationId !== start.invocationId ||
|
||||
admission.projectId !== start.projectId ||
|
||||
admission.modelPolicyRevision !== start.policyRevision
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return runPostgresModelInvocationTransaction(pool, async (client) => {
|
||||
const existing = await startRows(
|
||||
client,
|
||||
`start.invocation_id = $1 OR
|
||||
start.mutation_id = $2 OR start.run_event_id = $3`,
|
||||
[start.invocationId, start.stepRunMutationId, start.runEventId],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseStart(existing[0]);
|
||||
const reservations = await quotaReservationRows(
|
||||
client,
|
||||
'reservation.invocation_id = $1',
|
||||
[start.invocationId],
|
||||
);
|
||||
if (
|
||||
JSON.stringify(stored) !== JSON.stringify(start) ||
|
||||
reservations.length !== 1 ||
|
||||
parseQuotaReservation(reservations[0]!).admissionDigest !==
|
||||
admission.admissionDigest
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
const observation = await client.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM statement_timestamp()) * 1000)::bigint
|
||||
AS "observedAtMs"`,
|
||||
);
|
||||
const observedRow = observation.rows[0];
|
||||
if (observation.rows.length !== 1 || !observedRow) throw unavailable();
|
||||
const reservation = createModelInvocationQuotaReservation(
|
||||
admission,
|
||||
integer(observedRow, 'observedAtMs'),
|
||||
);
|
||||
await client.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
|
||||
[
|
||||
JSON.stringify([
|
||||
start.projectId,
|
||||
reservation.windowStartMs,
|
||||
reservation.windowMs,
|
||||
]),
|
||||
],
|
||||
);
|
||||
const usage = await quotaWindowUsage(
|
||||
client,
|
||||
start.projectId,
|
||||
reservation.windowStartMs,
|
||||
reservation.windowMs,
|
||||
);
|
||||
if (
|
||||
usage.invocationCount + 1 > reservation.maxInvocations ||
|
||||
usage.effectiveTokens + reservation.reservedTokens >
|
||||
reservation.maxTokens ||
|
||||
(reservation.maxCostMicros !== null &&
|
||||
(usage.unknownCostInvocations !== 0 ||
|
||||
usage.effectiveCostMicros + reservation.reservedCostMicros! >
|
||||
reservation.maxCostMicros))
|
||||
) {
|
||||
throw new ModelInvocationProjectQuotaExceededError();
|
||||
}
|
||||
await assertCurrent(client, command.stepRunMutation, start.projectId);
|
||||
await applyMutation(client, command.stepRunMutation);
|
||||
await insertStart(client, start);
|
||||
await insertQuotaReservation(client, reservation);
|
||||
return Object.freeze({ status: 'created' as const, record: start });
|
||||
});
|
||||
}
|
||||
|
||||
export async function admitWithPricingOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
quoteValue: ModelInvocationPriceQuote,
|
||||
admissionValue?: ModelInvocationQuotaAdmission,
|
||||
): Promise<Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>> {
|
||||
const command = normalizeModelInvocationStartCommand(commandValue);
|
||||
const quote = normalizeModelInvocationPriceQuote(quoteValue);
|
||||
const admission =
|
||||
admissionValue === undefined
|
||||
? undefined
|
||||
: normalizeModelInvocationQuotaAdmission(admissionValue);
|
||||
const start = command.start;
|
||||
if (
|
||||
quote.invocationId !== start.invocationId ||
|
||||
quote.projectId !== start.projectId ||
|
||||
quote.modelPolicyRevision !== start.policyRevision ||
|
||||
quote.provider !== start.provider ||
|
||||
quote.model !== start.model ||
|
||||
quote.maxOutputTokens !== start.maxOutputTokens ||
|
||||
(admission !== undefined &&
|
||||
(admission.invocationId !== start.invocationId ||
|
||||
admission.projectId !== start.projectId ||
|
||||
admission.modelPolicyRevision !== start.policyRevision ||
|
||||
(admission.maxCostMicros !== null &&
|
||||
admission.reservedCostMicros !== quote.reservedCostMicros)))
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return runPostgresModelInvocationTransaction(pool, async (client) => {
|
||||
const existing = await startRows(
|
||||
client,
|
||||
`start.invocation_id = $1 OR
|
||||
start.mutation_id = $2 OR start.run_event_id = $3`,
|
||||
[start.invocationId, start.stepRunMutationId, start.runEventId],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const [quotes, reservations] = await Promise.all([
|
||||
priceQuoteRows(client, 'quote.invocation_id = $1', [
|
||||
start.invocationId,
|
||||
]),
|
||||
quotaReservationRows(client, 'reservation.invocation_id = $1', [
|
||||
start.invocationId,
|
||||
]),
|
||||
]);
|
||||
const stored = parseStart(existing[0]);
|
||||
if (
|
||||
JSON.stringify(stored) !== JSON.stringify(start) ||
|
||||
quotes.length !== 1 ||
|
||||
JSON.stringify(parsePriceQuote(quotes[0]!)) !== JSON.stringify(quote) ||
|
||||
reservations.length !== (admission ? 1 : 0) ||
|
||||
(admission !== undefined &&
|
||||
parseQuotaReservation(reservations[0]!).admissionDigest !==
|
||||
admission.admissionDigest)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
let reservation: Readonly<ModelInvocationQuotaReservation> | undefined;
|
||||
if (admission) {
|
||||
const observation = await client.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM statement_timestamp()) * 1000)::bigint
|
||||
AS "observedAtMs"`,
|
||||
);
|
||||
const observedRow = observation.rows[0];
|
||||
if (observation.rows.length !== 1 || !observedRow) {
|
||||
throw unavailable();
|
||||
}
|
||||
reservation = createModelInvocationQuotaReservation(
|
||||
admission,
|
||||
integer(observedRow, 'observedAtMs'),
|
||||
);
|
||||
await client.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
|
||||
[
|
||||
JSON.stringify([
|
||||
start.projectId,
|
||||
reservation.windowStartMs,
|
||||
reservation.windowMs,
|
||||
]),
|
||||
],
|
||||
);
|
||||
const usage = await quotaWindowUsage(
|
||||
client,
|
||||
start.projectId,
|
||||
reservation.windowStartMs,
|
||||
reservation.windowMs,
|
||||
);
|
||||
if (
|
||||
usage.invocationCount + 1 > reservation.maxInvocations ||
|
||||
usage.effectiveTokens + reservation.reservedTokens >
|
||||
reservation.maxTokens ||
|
||||
(reservation.maxCostMicros !== null &&
|
||||
(usage.unknownCostInvocations !== 0 ||
|
||||
usage.effectiveCostMicros + reservation.reservedCostMicros! >
|
||||
reservation.maxCostMicros))
|
||||
) {
|
||||
throw new ModelInvocationProjectQuotaExceededError();
|
||||
}
|
||||
}
|
||||
await assertCurrent(client, command.stepRunMutation, start.projectId);
|
||||
await applyMutation(client, command.stepRunMutation);
|
||||
await insertStart(client, start);
|
||||
await insertPriceQuote(client, quote);
|
||||
if (reservation) await insertQuotaReservation(client, reservation);
|
||||
return Object.freeze({ status: 'created' as const, record: start });
|
||||
});
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import type { PostgresQueryable } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
PluginPackagePromptOutputArtifactConflictError,
|
||||
PluginPackagePromptOutputArtifactUnavailableError,
|
||||
} from '../../prompt-output/pluginPackagePromptOutputArtifact';
|
||||
import { ModelInvocationUsageSummaryLimitExceededError } from '../../usage/usageLedger';
|
||||
import { ModelInvocationProjectQuotaExceededError } from '../../usage/usageQuota';
|
||||
import {
|
||||
MAX_MODEL_INVOCATION_RECOVERY_PAGE_SIZE,
|
||||
ModelInvocationConflictError,
|
||||
ModelInvocationRepositoryUnavailableError,
|
||||
} from '../modelInvocation';
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
export type Queryable = Pick<PostgresQueryable, 'query'>;
|
||||
|
||||
export const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
export const TERMINAL_RUN_STATUSES = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
]);
|
||||
export const RETRYABLE_SQL_STATES = new Set(['40001', '40P01']);
|
||||
export const MAX_TRANSACTION_ATTEMPTS = 3;
|
||||
|
||||
export function unavailable(
|
||||
cause?: unknown,
|
||||
): ModelInvocationRepositoryUnavailableError {
|
||||
return new ModelInvocationRepositoryUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function identifier(value: unknown): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
export function nullableInteger(row: Row, key: string): number | null {
|
||||
return row[key] === null ? null : integer(row, key);
|
||||
}
|
||||
|
||||
export function recoveryLimit(value: number): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 1 ||
|
||||
value > MAX_MODEL_INVOCATION_RECOVERY_PAGE_SIZE
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function jsonObject(row: Row, key: string): Record<string, unknown> {
|
||||
const value = row[key];
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
export function sqlState(error: unknown): string {
|
||||
if (!error || typeof error !== 'object') return '';
|
||||
const value = (error as { code?: unknown }).code;
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
export function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof ModelInvocationConflictError ||
|
||||
error instanceof ModelInvocationRepositoryUnavailableError ||
|
||||
error instanceof ModelInvocationUsageSummaryLimitExceededError ||
|
||||
error instanceof ModelInvocationProjectQuotaExceededError ||
|
||||
error instanceof PluginPackagePromptOutputArtifactConflictError ||
|
||||
error instanceof PluginPackagePromptOutputArtifactUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = sqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new ModelInvocationConflictError();
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import {
|
||||
normalizeModelInvocationPriceQuote,
|
||||
normalizeModelInvocationPriceSettlement,
|
||||
type ModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceSettlement,
|
||||
} from '../../pricing/pricing';
|
||||
import {
|
||||
normalizeModelInvocationUsageLedgerRecord,
|
||||
type ModelInvocationUsageLedgerRecord,
|
||||
} from '../../usage/usageLedger';
|
||||
import {
|
||||
normalizeModelInvocationQuotaReservation,
|
||||
normalizeModelInvocationQuotaSettlement,
|
||||
type ModelInvocationQuotaReservation,
|
||||
type ModelInvocationQuotaSettlement,
|
||||
} from '../../usage/usageQuota';
|
||||
import {
|
||||
normalizeModelInvocationCompletionRecord,
|
||||
normalizeModelInvocationStartRecord,
|
||||
type ModelInvocationAuthoritySnapshot,
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationStartRecord,
|
||||
} from '../modelInvocation';
|
||||
import {
|
||||
normalizeModelInvocationResolutionRecord,
|
||||
type ModelInvocationResolutionRecord,
|
||||
} from '../modelInvocationResolution';
|
||||
|
||||
import type { Row } from './authority';
|
||||
import {
|
||||
integer,
|
||||
jsonObject,
|
||||
nullableInteger,
|
||||
text,
|
||||
unavailable,
|
||||
} from './authority';
|
||||
|
||||
export function parseStart(row: Row): Readonly<ModelInvocationStartRecord> {
|
||||
let start: Readonly<ModelInvocationStartRecord>;
|
||||
try {
|
||||
start = normalizeModelInvocationStartRecord(
|
||||
jsonObject(row, 'recordJson') as unknown as ModelInvocationStartRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
start.invocationId !== text(row, 'invocationId') ||
|
||||
start.projectId !== text(row, 'projectId') ||
|
||||
start.runId !== text(row, 'runId') ||
|
||||
start.stepRunId !== text(row, 'stepRunId') ||
|
||||
start.traceId !== text(row, 'traceId') ||
|
||||
start.provider !== text(row, 'provider') ||
|
||||
start.model !== text(row, 'model') ||
|
||||
start.policyRevision !== text(row, 'policyRevision') ||
|
||||
start.requestDigest !== text(row, 'requestDigest') ||
|
||||
start.inputBytes !== integer(row, 'inputBytes') ||
|
||||
start.maxOutputTokens !== integer(row, 'maxOutputTokens') ||
|
||||
start.deadlineAtMs !== integer(row, 'deadlineAtMs') ||
|
||||
start.admittedAtMs !== integer(row, 'admittedAtMs') ||
|
||||
start.stepRunMutationId !== text(row, 'mutationId') ||
|
||||
start.stepRunMutationDigest !== text(row, 'mutationDigest') ||
|
||||
start.startedStepRunDigest !== text(row, 'stepRunDigest') ||
|
||||
start.startedStepRunVersion !== integer(row, 'stepRunVersion') ||
|
||||
start.runEventId !== text(row, 'runEventId') ||
|
||||
start.startDigest !== text(row, 'startDigest')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
export function parseCompletion(
|
||||
row: Row,
|
||||
): Readonly<ModelInvocationCompletionRecord> {
|
||||
let completion: Readonly<ModelInvocationCompletionRecord>;
|
||||
try {
|
||||
completion = normalizeModelInvocationCompletionRecord(
|
||||
jsonObject(
|
||||
row,
|
||||
'recordJson',
|
||||
) as unknown as ModelInvocationCompletionRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
completion.invocationId !== text(row, 'invocationId') ||
|
||||
completion.projectId !== text(row, 'projectId') ||
|
||||
completion.runId !== text(row, 'runId') ||
|
||||
completion.stepRunId !== text(row, 'stepRunId') ||
|
||||
completion.traceId !== text(row, 'traceId') ||
|
||||
completion.startDigest !== text(row, 'startDigest') ||
|
||||
completion.outcome !== text(row, 'outcome') ||
|
||||
completion.outputBytes !== integer(row, 'outputBytes') ||
|
||||
completion.errorCode !== row.errorCode ||
|
||||
completion.completedAtMs !== integer(row, 'completedAtMs') ||
|
||||
completion.stepRunMutationId !== text(row, 'mutationId') ||
|
||||
completion.stepRunMutationDigest !== text(row, 'mutationDigest') ||
|
||||
completion.completedStepRunDigest !== text(row, 'stepRunDigest') ||
|
||||
completion.completedStepRunVersion !== integer(row, 'stepRunVersion') ||
|
||||
completion.runEventId !== text(row, 'runEventId') ||
|
||||
completion.completionDigest !== text(row, 'completionDigest')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return completion;
|
||||
}
|
||||
|
||||
export function parseUsage(
|
||||
row: Row,
|
||||
): Readonly<ModelInvocationUsageLedgerRecord> {
|
||||
let usage: Readonly<ModelInvocationUsageLedgerRecord>;
|
||||
try {
|
||||
usage = normalizeModelInvocationUsageLedgerRecord(
|
||||
jsonObject(
|
||||
row,
|
||||
'recordJson',
|
||||
) as unknown as ModelInvocationUsageLedgerRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
usage.invocationId !== text(row, 'invocationId') ||
|
||||
usage.projectId !== text(row, 'projectId') ||
|
||||
usage.runId !== text(row, 'runId') ||
|
||||
usage.stepRunId !== text(row, 'stepRunId') ||
|
||||
usage.traceId !== text(row, 'traceId') ||
|
||||
usage.provider !== text(row, 'provider') ||
|
||||
usage.model !== text(row, 'model') ||
|
||||
usage.policyRevision !== text(row, 'policyRevision') ||
|
||||
usage.completionDigest !== text(row, 'completionDigest') ||
|
||||
usage.outcome !== text(row, 'outcome') ||
|
||||
usage.settledAtMs !== integer(row, 'settledAtMs') ||
|
||||
usage.inputBytes !== integer(row, 'inputBytes') ||
|
||||
usage.outputBytes !== integer(row, 'outputBytes') ||
|
||||
usage.inputTokens !== integer(row, 'inputTokens') ||
|
||||
usage.outputTokens !== integer(row, 'outputTokens') ||
|
||||
usage.totalTokens !== integer(row, 'totalTokens') ||
|
||||
usage.costMicros !== nullableInteger(row, 'costMicros') ||
|
||||
usage.ledgerDigest !== text(row, 'ledgerDigest')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
export function parseQuotaReservation(
|
||||
row: Row,
|
||||
): Readonly<ModelInvocationQuotaReservation> {
|
||||
try {
|
||||
return normalizeModelInvocationQuotaReservation(
|
||||
jsonObject(
|
||||
row,
|
||||
'recordJson',
|
||||
) as unknown as ModelInvocationQuotaReservation,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseQuotaSettlement(
|
||||
row: Row,
|
||||
reservation: Readonly<ModelInvocationQuotaReservation>,
|
||||
completion: Readonly<ModelInvocationCompletionRecord>,
|
||||
): Readonly<ModelInvocationQuotaSettlement> {
|
||||
try {
|
||||
return normalizeModelInvocationQuotaSettlement(
|
||||
jsonObject(
|
||||
row,
|
||||
'recordJson',
|
||||
) as unknown as ModelInvocationQuotaSettlement,
|
||||
reservation,
|
||||
completion,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePriceQuote(row: Row): Readonly<ModelInvocationPriceQuote> {
|
||||
try {
|
||||
return normalizeModelInvocationPriceQuote(
|
||||
jsonObject(row, 'recordJson') as unknown as ModelInvocationPriceQuote,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePriceSettlement(
|
||||
row: Row,
|
||||
quote: Readonly<ModelInvocationPriceQuote>,
|
||||
completion: Readonly<ModelInvocationCompletionRecord>,
|
||||
): Readonly<ModelInvocationPriceSettlement> {
|
||||
try {
|
||||
return normalizeModelInvocationPriceSettlement(
|
||||
jsonObject(
|
||||
row,
|
||||
'recordJson',
|
||||
) as unknown as ModelInvocationPriceSettlement,
|
||||
quote,
|
||||
completion,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseResolution(
|
||||
row: Row,
|
||||
): Readonly<ModelInvocationResolutionRecord> {
|
||||
let resolution: Readonly<ModelInvocationResolutionRecord>;
|
||||
try {
|
||||
resolution = normalizeModelInvocationResolutionRecord(
|
||||
jsonObject(
|
||||
row,
|
||||
'recordJson',
|
||||
) as unknown as ModelInvocationResolutionRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
resolution.resolutionId !== text(row, 'resolutionId') ||
|
||||
resolution.invocationId !== text(row, 'invocationId') ||
|
||||
resolution.projectId !== text(row, 'projectId') ||
|
||||
resolution.runId !== text(row, 'runId') ||
|
||||
resolution.stepRunId !== text(row, 'stepRunId') ||
|
||||
resolution.traceId !== text(row, 'traceId') ||
|
||||
resolution.completionDigest !== text(row, 'completionDigest') ||
|
||||
resolution.decision !== text(row, 'decision') ||
|
||||
resolution.resolvedByUserId !== text(row, 'resolvedByUserId') ||
|
||||
resolution.resolvedAtMs !== integer(row, 'resolvedAtMs') ||
|
||||
resolution.stepRunMutationId !== text(row, 'mutationId') ||
|
||||
resolution.stepRunMutationDigest !== text(row, 'mutationDigest') ||
|
||||
resolution.resolvedStepRunDigest !== text(row, 'stepRunDigest') ||
|
||||
resolution.resolvedStepRunVersion !== integer(row, 'stepRunVersion') ||
|
||||
resolution.runEventId !== text(row, 'runEventId') ||
|
||||
resolution.resolutionDigest !== text(row, 'resolutionDigest')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
export function parseAuthority(
|
||||
row: Row,
|
||||
): Readonly<ModelInvocationAuthoritySnapshot> {
|
||||
let stepRun: Readonly<StepRunRecord>;
|
||||
try {
|
||||
stepRun = normalizeStepRunRecord(
|
||||
jsonObject(row, 'stepRunJson') as unknown as StepRunRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
stepRun.id !== text(row, 'stepRunId') ||
|
||||
stepRun.runId !== text(row, 'runId') ||
|
||||
stepRun.kind !== 'model' ||
|
||||
stepRun.status !== text(row, 'stepStatus') ||
|
||||
stepRun.version !== integer(row, 'stepVersion') ||
|
||||
stepRun.stepRunDigest !== text(row, 'stepDigest')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: text(row, 'projectId'),
|
||||
runId: text(row, 'runId'),
|
||||
runVersion: integer(row, 'runVersion'),
|
||||
runEventSequence: integer(row, 'runEventSequence'),
|
||||
stepRun,
|
||||
});
|
||||
}
|
||||
|
||||
export const START_SELECT = `
|
||||
start.invocation_id AS "invocationId",
|
||||
start.project_id AS "projectId",
|
||||
start.run_id AS "runId",
|
||||
start.step_run_id AS "stepRunId",
|
||||
start.trace_id AS "traceId",
|
||||
start.provider,
|
||||
start.model,
|
||||
start.policy_revision AS "policyRevision",
|
||||
start.request_digest AS "requestDigest",
|
||||
start.input_bytes AS "inputBytes",
|
||||
start.max_output_tokens AS "maxOutputTokens",
|
||||
start.deadline_at_ms AS "deadlineAtMs",
|
||||
start.admitted_at_ms AS "admittedAtMs",
|
||||
start.mutation_id AS "mutationId",
|
||||
start.mutation_digest AS "mutationDigest",
|
||||
start.run_event_id AS "runEventId",
|
||||
start.start_digest AS "startDigest",
|
||||
start.record_json AS "recordJson",
|
||||
mutation.step_run_digest AS "stepRunDigest",
|
||||
mutation.step_run_json->>'version' AS "stepRunVersion"
|
||||
`;
|
||||
|
||||
export const COMPLETION_SELECT = `
|
||||
completion.invocation_id AS "invocationId",
|
||||
completion.project_id AS "projectId",
|
||||
completion.run_id AS "runId",
|
||||
completion.step_run_id AS "stepRunId",
|
||||
completion.trace_id AS "traceId",
|
||||
completion.start_digest AS "startDigest",
|
||||
completion.outcome,
|
||||
completion.output_bytes AS "outputBytes",
|
||||
completion.error_code AS "errorCode",
|
||||
completion.completed_at_ms AS "completedAtMs",
|
||||
completion.mutation_id AS "mutationId",
|
||||
completion.mutation_digest AS "mutationDigest",
|
||||
completion.run_event_id AS "runEventId",
|
||||
completion.completion_digest AS "completionDigest",
|
||||
completion.record_json AS "recordJson",
|
||||
mutation.step_run_digest AS "stepRunDigest",
|
||||
mutation.step_run_json->>'version' AS "stepRunVersion"
|
||||
`;
|
||||
|
||||
export const USAGE_SELECT = `
|
||||
usage.invocation_id AS "invocationId",
|
||||
usage.project_id AS "projectId",
|
||||
usage.run_id AS "runId",
|
||||
usage.step_run_id AS "stepRunId",
|
||||
usage.trace_id AS "traceId",
|
||||
usage.provider,
|
||||
usage.model,
|
||||
usage.policy_revision AS "policyRevision",
|
||||
usage.completion_digest AS "completionDigest",
|
||||
usage.outcome,
|
||||
usage.settled_at_ms AS "settledAtMs",
|
||||
usage.input_bytes AS "inputBytes",
|
||||
usage.output_bytes AS "outputBytes",
|
||||
usage.input_tokens AS "inputTokens",
|
||||
usage.output_tokens AS "outputTokens",
|
||||
usage.total_tokens AS "totalTokens",
|
||||
usage.cost_micros AS "costMicros",
|
||||
usage.ledger_digest AS "ledgerDigest",
|
||||
usage.record_json AS "recordJson"
|
||||
`;
|
||||
|
||||
export const RESOLUTION_SELECT = `
|
||||
resolution.resolution_id AS "resolutionId",
|
||||
resolution.invocation_id AS "invocationId",
|
||||
resolution.project_id AS "projectId",
|
||||
resolution.run_id AS "runId",
|
||||
resolution.step_run_id AS "stepRunId",
|
||||
resolution.trace_id AS "traceId",
|
||||
resolution.completion_digest AS "completionDigest",
|
||||
resolution.decision,
|
||||
resolution.resolved_by_user_id AS "resolvedByUserId",
|
||||
resolution.resolved_at_ms AS "resolvedAtMs",
|
||||
resolution.mutation_id AS "mutationId",
|
||||
resolution.mutation_digest AS "mutationDigest",
|
||||
resolution.run_event_id AS "runEventId",
|
||||
resolution.resolution_digest AS "resolutionDigest",
|
||||
resolution.record_json AS "recordJson",
|
||||
mutation.step_run_digest AS "stepRunDigest",
|
||||
mutation.step_run_json->>'version' AS "stepRunVersion"
|
||||
`;
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import { createModelInvocationPriceSettlement } from '../../pricing/pricing';
|
||||
import { type PluginPackagePromptOutputArtifact } from '../../prompt-output/pluginPackagePromptOutputArtifact';
|
||||
import {
|
||||
assertPluginPackagePromptOutputCompletionBinding,
|
||||
type CommitPluginPackagePromptOutputResult,
|
||||
} from '../../prompt-output/pluginPackagePromptOutputCompletion';
|
||||
import {
|
||||
putPostgresPluginPackagePromptOutputArtifactInTransaction,
|
||||
readPostgresPluginPackagePromptOutputArtifactInTransaction,
|
||||
} from '../../prompt-output/storage/postgresPluginPackagePromptOutputArtifactRepository';
|
||||
import { createModelInvocationUsageLedgerRecord } from '../../usage/usageLedger';
|
||||
import { createModelInvocationQuotaSettlement } from '../../usage/usageQuota';
|
||||
import {
|
||||
ModelInvocationConflictError,
|
||||
normalizeModelInvocationCompletionCommand,
|
||||
type CommitModelInvocationResult,
|
||||
type ModelInvocationCompletionCommand,
|
||||
type ModelInvocationCompletionRecord,
|
||||
} from '../modelInvocation';
|
||||
|
||||
import {
|
||||
parseCompletion,
|
||||
parsePriceQuote,
|
||||
parsePriceSettlement,
|
||||
parseQuotaReservation,
|
||||
parseQuotaSettlement,
|
||||
parseStart,
|
||||
parseUsage,
|
||||
} from './codec';
|
||||
import {
|
||||
applyMutation,
|
||||
assertCurrent,
|
||||
insertCompletion,
|
||||
insertPriceSettlement,
|
||||
insertQuotaSettlement,
|
||||
insertUsage,
|
||||
} from './mutations';
|
||||
import {
|
||||
completionRows,
|
||||
priceQuoteRows,
|
||||
priceSettlementRows,
|
||||
quotaReservationRows,
|
||||
quotaSettlementRows,
|
||||
startRows,
|
||||
usageRows,
|
||||
} from './queries';
|
||||
import { runPostgresModelInvocationTransaction } from './transaction';
|
||||
|
||||
export async function completeOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
const command = normalizeModelInvocationCompletionCommand(commandValue);
|
||||
const completion = command.completion;
|
||||
const expectedUsage = createModelInvocationUsageLedgerRecord(
|
||||
command.start,
|
||||
completion,
|
||||
);
|
||||
return runPostgresModelInvocationTransaction(pool, async (client) => {
|
||||
const existing = await completionRows(
|
||||
client,
|
||||
`completion.invocation_id = $1 OR
|
||||
completion.mutation_id = $2 OR completion.run_event_id = $3`,
|
||||
[
|
||||
completion.invocationId,
|
||||
completion.stepRunMutationId,
|
||||
completion.runEventId,
|
||||
],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseCompletion(existing[0]);
|
||||
if (JSON.stringify(stored) !== JSON.stringify(completion)) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const usage = await usageRows(client, 'usage.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (
|
||||
usage.length !== (expectedUsage ? 1 : 0) ||
|
||||
(expectedUsage &&
|
||||
JSON.stringify(parseUsage(usage[0]!)) !==
|
||||
JSON.stringify(expectedUsage))
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
const starts = await startRows(client, 'start.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (
|
||||
starts.length !== 1 ||
|
||||
JSON.stringify(parseStart(starts[0]!)) !== JSON.stringify(command.start)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
await assertCurrent(client, command.stepRunMutation, completion.projectId);
|
||||
await applyMutation(client, command.stepRunMutation);
|
||||
await insertCompletion(client, completion);
|
||||
if (expectedUsage) await insertUsage(client, expectedUsage);
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
record: completion,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeWithQuotaOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
const command = normalizeModelInvocationCompletionCommand(commandValue);
|
||||
const completion = command.completion;
|
||||
const expectedUsage = createModelInvocationUsageLedgerRecord(
|
||||
command.start,
|
||||
completion,
|
||||
);
|
||||
return runPostgresModelInvocationTransaction(pool, async (client) => {
|
||||
const reservationRows = await quotaReservationRows(
|
||||
client,
|
||||
'reservation.invocation_id = $1',
|
||||
[completion.invocationId],
|
||||
);
|
||||
if (reservationRows.length !== 1) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const reservation = parseQuotaReservation(reservationRows[0]!);
|
||||
const expectedSettlement = createModelInvocationQuotaSettlement(
|
||||
reservation,
|
||||
completion,
|
||||
);
|
||||
const existing = await completionRows(
|
||||
client,
|
||||
`completion.invocation_id = $1 OR
|
||||
completion.mutation_id = $2 OR completion.run_event_id = $3`,
|
||||
[
|
||||
completion.invocationId,
|
||||
completion.stepRunMutationId,
|
||||
completion.runEventId,
|
||||
],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseCompletion(existing[0]);
|
||||
const usage = await usageRows(client, 'usage.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
const settlements = await quotaSettlementRows(
|
||||
client,
|
||||
'settlement.invocation_id = $1',
|
||||
[completion.invocationId],
|
||||
);
|
||||
if (
|
||||
JSON.stringify(stored) !== JSON.stringify(completion) ||
|
||||
usage.length !== (expectedUsage ? 1 : 0) ||
|
||||
(expectedUsage &&
|
||||
JSON.stringify(parseUsage(usage[0]!)) !==
|
||||
JSON.stringify(expectedUsage)) ||
|
||||
settlements.length !== 1 ||
|
||||
JSON.stringify(
|
||||
parseQuotaSettlement(settlements[0]!, reservation, completion),
|
||||
) !== JSON.stringify(expectedSettlement)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
const starts = await startRows(client, 'start.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (
|
||||
starts.length !== 1 ||
|
||||
JSON.stringify(parseStart(starts[0]!)) !== JSON.stringify(command.start)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
await assertCurrent(client, command.stepRunMutation, completion.projectId);
|
||||
await applyMutation(client, command.stepRunMutation);
|
||||
await insertCompletion(client, completion);
|
||||
if (expectedUsage) await insertUsage(client, expectedUsage);
|
||||
await insertQuotaSettlement(client, expectedSettlement);
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
record: completion,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeWithPricingOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
const command = normalizeModelInvocationCompletionCommand(commandValue);
|
||||
const completion = command.completion;
|
||||
const expectedUsage = createModelInvocationUsageLedgerRecord(
|
||||
command.start,
|
||||
completion,
|
||||
);
|
||||
return runPostgresModelInvocationTransaction(pool, async (client) => {
|
||||
const quoteRows = await priceQuoteRows(client, 'quote.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (quoteRows.length !== 1) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const quote = parsePriceQuote(quoteRows[0]!);
|
||||
if (
|
||||
quote.invocationId !== command.start.invocationId ||
|
||||
quote.projectId !== command.start.projectId ||
|
||||
quote.modelPolicyRevision !== command.start.policyRevision ||
|
||||
quote.provider !== command.start.provider ||
|
||||
quote.model !== command.start.model
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const expectedPriceSettlement = createModelInvocationPriceSettlement(
|
||||
quote,
|
||||
completion,
|
||||
);
|
||||
const reservationRows = await quotaReservationRows(
|
||||
client,
|
||||
'reservation.invocation_id = $1',
|
||||
[completion.invocationId],
|
||||
);
|
||||
if (reservationRows.length > 1) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const reservation = reservationRows[0]
|
||||
? parseQuotaReservation(reservationRows[0])
|
||||
: null;
|
||||
const expectedQuotaSettlement = reservation
|
||||
? createModelInvocationQuotaSettlement(reservation, completion)
|
||||
: null;
|
||||
const existing = await completionRows(
|
||||
client,
|
||||
`completion.invocation_id = $1 OR
|
||||
completion.mutation_id = $2 OR completion.run_event_id = $3`,
|
||||
[
|
||||
completion.invocationId,
|
||||
completion.stepRunMutationId,
|
||||
completion.runEventId,
|
||||
],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const [usage, priceSettlements, quotaSettlements] = await Promise.all([
|
||||
usageRows(client, 'usage.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]),
|
||||
priceSettlementRows(client, 'settlement.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]),
|
||||
quotaSettlementRows(client, 'settlement.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]),
|
||||
]);
|
||||
const stored = parseCompletion(existing[0]);
|
||||
if (
|
||||
JSON.stringify(stored) !== JSON.stringify(completion) ||
|
||||
usage.length !== (expectedUsage ? 1 : 0) ||
|
||||
(expectedUsage &&
|
||||
JSON.stringify(parseUsage(usage[0]!)) !==
|
||||
JSON.stringify(expectedUsage)) ||
|
||||
priceSettlements.length !== (expectedPriceSettlement ? 1 : 0) ||
|
||||
(expectedPriceSettlement &&
|
||||
JSON.stringify(
|
||||
parsePriceSettlement(priceSettlements[0]!, quote, completion),
|
||||
) !== JSON.stringify(expectedPriceSettlement)) ||
|
||||
quotaSettlements.length !== (expectedQuotaSettlement ? 1 : 0) ||
|
||||
(expectedQuotaSettlement &&
|
||||
JSON.stringify(
|
||||
parseQuotaSettlement(
|
||||
quotaSettlements[0]!,
|
||||
reservation!,
|
||||
completion,
|
||||
),
|
||||
) !== JSON.stringify(expectedQuotaSettlement))
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
const starts = await startRows(client, 'start.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (
|
||||
starts.length !== 1 ||
|
||||
JSON.stringify(parseStart(starts[0]!)) !== JSON.stringify(command.start)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
await assertCurrent(client, command.stepRunMutation, completion.projectId);
|
||||
await applyMutation(client, command.stepRunMutation);
|
||||
await insertCompletion(client, completion);
|
||||
if (expectedUsage) await insertUsage(client, expectedUsage);
|
||||
if (expectedPriceSettlement) {
|
||||
await insertPriceSettlement(client, expectedPriceSettlement);
|
||||
}
|
||||
if (expectedQuotaSettlement) {
|
||||
await insertQuotaSettlement(client, expectedQuotaSettlement);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
record: completion,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeWithPromptOutputArtifactOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
artifactValue: PluginPackagePromptOutputArtifact,
|
||||
): Promise<Readonly<CommitPluginPackagePromptOutputResult>> {
|
||||
const command = normalizeModelInvocationCompletionCommand(commandValue);
|
||||
const completion = command.completion;
|
||||
const binding = assertPluginPackagePromptOutputCompletionBinding(
|
||||
command,
|
||||
artifactValue,
|
||||
);
|
||||
const expectedUsage = createModelInvocationUsageLedgerRecord(
|
||||
command.start,
|
||||
completion,
|
||||
);
|
||||
return runPostgresModelInvocationTransaction(pool, async (client) => {
|
||||
const quoteRows = await priceQuoteRows(client, 'quote.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (quoteRows.length > 1) throw new ModelInvocationConflictError();
|
||||
const quote = quoteRows[0] ? parsePriceQuote(quoteRows[0]) : null;
|
||||
if (
|
||||
quote &&
|
||||
(quote.invocationId !== command.start.invocationId ||
|
||||
quote.projectId !== command.start.projectId ||
|
||||
quote.modelPolicyRevision !== command.start.policyRevision ||
|
||||
quote.provider !== command.start.provider ||
|
||||
quote.model !== command.start.model)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const expectedPriceSettlement = quote
|
||||
? createModelInvocationPriceSettlement(quote, completion)
|
||||
: null;
|
||||
const reservationRows = await quotaReservationRows(
|
||||
client,
|
||||
'reservation.invocation_id = $1',
|
||||
[completion.invocationId],
|
||||
);
|
||||
if (reservationRows.length > 1) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
const reservation = reservationRows[0]
|
||||
? parseQuotaReservation(reservationRows[0])
|
||||
: null;
|
||||
const expectedQuotaSettlement = reservation
|
||||
? createModelInvocationQuotaSettlement(reservation, completion)
|
||||
: null;
|
||||
const existing = await completionRows(
|
||||
client,
|
||||
`completion.invocation_id = $1 OR
|
||||
completion.mutation_id = $2 OR completion.run_event_id = $3`,
|
||||
[
|
||||
completion.invocationId,
|
||||
completion.stepRunMutationId,
|
||||
completion.runEventId,
|
||||
],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const [storedArtifact, usage, priceSettlements, quotaSettlements] =
|
||||
await Promise.all([
|
||||
readPostgresPluginPackagePromptOutputArtifactInTransaction(
|
||||
client,
|
||||
binding.artifact.artifactId,
|
||||
),
|
||||
usageRows(client, 'usage.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]),
|
||||
priceSettlementRows(client, 'settlement.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]),
|
||||
quotaSettlementRows(client, 'settlement.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]),
|
||||
]);
|
||||
const stored = parseCompletion(existing[0]);
|
||||
if (
|
||||
JSON.stringify(stored) !== JSON.stringify(completion) ||
|
||||
!storedArtifact ||
|
||||
JSON.stringify(storedArtifact) !== JSON.stringify(binding.artifact) ||
|
||||
usage.length !== (expectedUsage ? 1 : 0) ||
|
||||
(expectedUsage &&
|
||||
JSON.stringify(parseUsage(usage[0]!)) !==
|
||||
JSON.stringify(expectedUsage)) ||
|
||||
priceSettlements.length !== (expectedPriceSettlement ? 1 : 0) ||
|
||||
(expectedPriceSettlement &&
|
||||
JSON.stringify(
|
||||
parsePriceSettlement(priceSettlements[0]!, quote!, completion),
|
||||
) !== JSON.stringify(expectedPriceSettlement)) ||
|
||||
quotaSettlements.length !== (expectedQuotaSettlement ? 1 : 0) ||
|
||||
(expectedQuotaSettlement &&
|
||||
JSON.stringify(
|
||||
parseQuotaSettlement(
|
||||
quotaSettlements[0]!,
|
||||
reservation!,
|
||||
completion,
|
||||
),
|
||||
) !== JSON.stringify(expectedQuotaSettlement))
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
record: stored,
|
||||
artifact: storedArtifact,
|
||||
reference: binding.reference,
|
||||
});
|
||||
}
|
||||
const starts = await startRows(client, 'start.invocation_id = $1', [
|
||||
completion.invocationId,
|
||||
]);
|
||||
if (
|
||||
starts.length !== 1 ||
|
||||
JSON.stringify(parseStart(starts[0]!)) !== JSON.stringify(command.start)
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
await assertCurrent(client, command.stepRunMutation, completion.projectId);
|
||||
const artifact = (
|
||||
await putPostgresPluginPackagePromptOutputArtifactInTransaction(
|
||||
client,
|
||||
binding.artifact,
|
||||
)
|
||||
).artifact;
|
||||
await applyMutation(client, command.stepRunMutation);
|
||||
await insertCompletion(client, completion);
|
||||
if (expectedUsage) await insertUsage(client, expectedUsage);
|
||||
if (expectedPriceSettlement) {
|
||||
await insertPriceSettlement(client, expectedPriceSettlement);
|
||||
}
|
||||
if (expectedQuotaSettlement) {
|
||||
await insertQuotaSettlement(client, expectedQuotaSettlement);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
record: completion,
|
||||
artifact,
|
||||
reference: binding.reference,
|
||||
});
|
||||
});
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
import type { PostgresClient } from '@qinglong/runtime-core';
|
||||
import { type StepRunMutation } from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import { POSTGRES_MODEL_INVOCATION_SCHEMA } from '../../migration/modelInvocationMigration';
|
||||
import {
|
||||
type ModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceSettlement,
|
||||
} from '../../pricing/pricing';
|
||||
import { type ModelInvocationUsageLedgerRecord } from '../../usage/usageLedger';
|
||||
import {
|
||||
type ModelInvocationQuotaReservation,
|
||||
type ModelInvocationQuotaSettlement,
|
||||
type ModelInvocationQuotaWindowUsage,
|
||||
} from '../../usage/usageQuota';
|
||||
import {
|
||||
ModelInvocationConflictError,
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationStartRecord,
|
||||
} from '../modelInvocation';
|
||||
import { type ModelInvocationResolutionRecord } from '../modelInvocationResolution';
|
||||
|
||||
import type { Queryable, Row } from './authority';
|
||||
import { TERMINAL_RUN_STATUSES, integer, text, unavailable } from './authority';
|
||||
|
||||
export async function updateStepRun(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): Promise<void> {
|
||||
const step = mutation.stepRun;
|
||||
const result = await client.query(
|
||||
`UPDATE "ql3"."step_runs"
|
||||
SET status = $1, version = $2, attempt_count = $3, output_ref = $4,
|
||||
approval_request_id = $5, ready_at_ms = $6, started_at_ms = $7,
|
||||
finished_at_ms = $8, result_code = $9, error_summary = $10,
|
||||
updated_at_ms = $11, last_mutation_id = $12,
|
||||
step_run_digest = $13, step_run_json = $14::jsonb
|
||||
WHERE id = $15 AND run_id = $16 AND version = $17
|
||||
AND step_run_digest = $18 AND status = $19`,
|
||||
[
|
||||
step.status,
|
||||
step.version,
|
||||
step.attemptCount,
|
||||
step.outputRef,
|
||||
step.approvalRequestId,
|
||||
step.readyAtMs,
|
||||
step.startedAtMs,
|
||||
step.finishedAtMs,
|
||||
step.resultCode,
|
||||
step.errorSummary,
|
||||
step.updatedAtMs,
|
||||
step.lastMutationId,
|
||||
step.stepRunDigest,
|
||||
JSON.stringify(step),
|
||||
step.id,
|
||||
step.runId,
|
||||
mutation.expectedStepRunVersion,
|
||||
mutation.expectedStepRunDigest,
|
||||
mutation.previousStatus,
|
||||
],
|
||||
);
|
||||
if (result.rowCount !== 1) throw new ModelInvocationConflictError();
|
||||
}
|
||||
|
||||
export async function updateRun(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): Promise<void> {
|
||||
const result = await client.query(
|
||||
`UPDATE "ql3"."runs"
|
||||
SET version = version + 1, event_sequence = event_sequence + 1
|
||||
WHERE id = $1 AND version = $2 AND event_sequence = $3`,
|
||||
[
|
||||
mutation.runId,
|
||||
mutation.expectedRunVersion,
|
||||
mutation.expectedRunEventSequence,
|
||||
],
|
||||
);
|
||||
if (result.rowCount !== 1) throw new ModelInvocationConflictError();
|
||||
}
|
||||
|
||||
export async function insertRunEvent(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): Promise<void> {
|
||||
const event = mutation.event;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_events" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9::jsonb, $10)`,
|
||||
[
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
mutation.stepRun.id,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertMutation(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."step_run_mutations" (
|
||||
mutation_id, mutation_digest, run_id, step_run_id,
|
||||
step_run_digest, event_id, event_sequence, run_version,
|
||||
step_run_json, committed_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb,
|
||||
floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint
|
||||
)`,
|
||||
[
|
||||
mutation.mutationId,
|
||||
mutation.mutationDigest,
|
||||
mutation.runId,
|
||||
mutation.stepRun.id,
|
||||
mutation.stepRun.stepRunDigest,
|
||||
mutation.event.id,
|
||||
mutation.event.sequence,
|
||||
mutation.expectedRunVersion + 1,
|
||||
JSON.stringify(mutation.stepRun),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function applyMutation(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): Promise<void> {
|
||||
await updateStepRun(client, mutation);
|
||||
await updateRun(client, mutation);
|
||||
await insertRunEvent(client, mutation);
|
||||
await insertMutation(client, mutation);
|
||||
}
|
||||
|
||||
export async function insertStart(
|
||||
client: PostgresClient,
|
||||
start: Readonly<ModelInvocationStartRecord>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts" (
|
||||
invocation_id, project_id, run_id, step_run_id, trace_id,
|
||||
provider, model, policy_revision, request_digest, input_bytes,
|
||||
max_output_tokens, deadline_at_ms, admitted_at_ms, mutation_id,
|
||||
mutation_digest, run_event_id, start_digest, record_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18::jsonb
|
||||
)`,
|
||||
[
|
||||
start.invocationId,
|
||||
start.projectId,
|
||||
start.runId,
|
||||
start.stepRunId,
|
||||
start.traceId,
|
||||
start.provider,
|
||||
start.model,
|
||||
start.policyRevision,
|
||||
start.requestDigest,
|
||||
start.inputBytes,
|
||||
start.maxOutputTokens,
|
||||
start.deadlineAtMs,
|
||||
start.admittedAtMs,
|
||||
start.stepRunMutationId,
|
||||
start.stepRunMutationDigest,
|
||||
start.runEventId,
|
||||
start.startDigest,
|
||||
JSON.stringify(start),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertCompletion(
|
||||
client: PostgresClient,
|
||||
completion: Readonly<ModelInvocationCompletionRecord>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions" (
|
||||
invocation_id, project_id, run_id, step_run_id, trace_id,
|
||||
start_digest, outcome, output_bytes, error_code, completed_at_ms,
|
||||
mutation_id, mutation_digest, run_event_id, completion_digest,
|
||||
record_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15::jsonb
|
||||
)`,
|
||||
[
|
||||
completion.invocationId,
|
||||
completion.projectId,
|
||||
completion.runId,
|
||||
completion.stepRunId,
|
||||
completion.traceId,
|
||||
completion.startDigest,
|
||||
completion.outcome,
|
||||
completion.outputBytes,
|
||||
completion.errorCode,
|
||||
completion.completedAtMs,
|
||||
completion.stepRunMutationId,
|
||||
completion.stepRunMutationDigest,
|
||||
completion.runEventId,
|
||||
completion.completionDigest,
|
||||
JSON.stringify(completion),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertUsage(
|
||||
client: PostgresClient,
|
||||
usage: Readonly<ModelInvocationUsageLedgerRecord>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_usage_ledger" (
|
||||
invocation_id, project_id, run_id, step_run_id, trace_id,
|
||||
provider, model, policy_revision, completion_digest, outcome,
|
||||
settled_at_ms, input_bytes, output_bytes, input_tokens,
|
||||
output_tokens, total_tokens, cost_micros, ledger_digest, record_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19::jsonb
|
||||
)`,
|
||||
[
|
||||
usage.invocationId,
|
||||
usage.projectId,
|
||||
usage.runId,
|
||||
usage.stepRunId,
|
||||
usage.traceId,
|
||||
usage.provider,
|
||||
usage.model,
|
||||
usage.policyRevision,
|
||||
usage.completionDigest,
|
||||
usage.outcome,
|
||||
usage.settledAtMs,
|
||||
usage.inputBytes,
|
||||
usage.outputBytes,
|
||||
usage.inputTokens,
|
||||
usage.outputTokens,
|
||||
usage.totalTokens,
|
||||
usage.costMicros,
|
||||
usage.ledgerDigest,
|
||||
JSON.stringify(usage),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertQuotaReservation(
|
||||
client: PostgresClient,
|
||||
reservation: Readonly<ModelInvocationQuotaReservation>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_reservations" (
|
||||
invocation_id, project_id, model_policy_revision,
|
||||
quota_policy_revision, window_ms, window_start_ms, window_end_ms,
|
||||
max_invocations, max_tokens, max_cost_micros, reserved_tokens,
|
||||
reserved_cost_micros, reserved_at_ms, admission_digest,
|
||||
reservation_digest, record_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16::jsonb
|
||||
)`,
|
||||
[
|
||||
reservation.invocationId,
|
||||
reservation.projectId,
|
||||
reservation.modelPolicyRevision,
|
||||
reservation.quotaPolicyRevision,
|
||||
reservation.windowMs,
|
||||
reservation.windowStartMs,
|
||||
reservation.windowEndMs,
|
||||
reservation.maxInvocations,
|
||||
reservation.maxTokens,
|
||||
reservation.maxCostMicros,
|
||||
reservation.reservedTokens,
|
||||
reservation.reservedCostMicros,
|
||||
reservation.reservedAtMs,
|
||||
reservation.admissionDigest,
|
||||
reservation.reservationDigest,
|
||||
JSON.stringify(reservation),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertQuotaSettlement(
|
||||
client: PostgresClient,
|
||||
settlement: Readonly<ModelInvocationQuotaSettlement>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_settlements" (
|
||||
invocation_id, project_id, reservation_digest, completion_digest,
|
||||
effective_tokens, effective_cost_micros,
|
||||
retained_token_reservation, retained_cost_reservation,
|
||||
settled_at_ms, settlement_digest, record_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb
|
||||
)`,
|
||||
[
|
||||
settlement.invocationId,
|
||||
settlement.projectId,
|
||||
settlement.reservationDigest,
|
||||
settlement.completionDigest,
|
||||
settlement.effectiveTokens,
|
||||
settlement.effectiveCostMicros,
|
||||
settlement.retainedTokenReservation,
|
||||
settlement.retainedCostReservation,
|
||||
settlement.settledAtMs,
|
||||
settlement.settlementDigest,
|
||||
JSON.stringify(settlement),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertPriceQuote(
|
||||
client: PostgresClient,
|
||||
quote: Readonly<ModelInvocationPriceQuote>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_quotes" (
|
||||
invocation_id, project_id, model_policy_revision, provider, model,
|
||||
price_revision, currency, input_micros_per_million_tokens,
|
||||
output_micros_per_million_tokens, max_total_tokens, max_output_tokens,
|
||||
reserved_cost_micros, catalog_digest, quote_digest, record_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
||||
$15::jsonb
|
||||
)`,
|
||||
[
|
||||
quote.invocationId,
|
||||
quote.projectId,
|
||||
quote.modelPolicyRevision,
|
||||
quote.provider,
|
||||
quote.model,
|
||||
quote.priceRevision,
|
||||
quote.currency,
|
||||
quote.inputMicrosPerMillionTokens,
|
||||
quote.outputMicrosPerMillionTokens,
|
||||
quote.maxTotalTokens,
|
||||
quote.maxOutputTokens,
|
||||
quote.reservedCostMicros,
|
||||
quote.catalogDigest,
|
||||
quote.quoteDigest,
|
||||
JSON.stringify(quote),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertPriceSettlement(
|
||||
client: PostgresClient,
|
||||
settlement: Readonly<ModelInvocationPriceSettlement>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_settlements" (
|
||||
invocation_id, project_id, quote_digest, completion_digest, currency,
|
||||
input_tokens, output_tokens, cost_micros, settled_at_ms,
|
||||
settlement_digest, record_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb
|
||||
)`,
|
||||
[
|
||||
settlement.invocationId,
|
||||
settlement.projectId,
|
||||
settlement.quoteDigest,
|
||||
settlement.completionDigest,
|
||||
settlement.currency,
|
||||
settlement.inputTokens,
|
||||
settlement.outputTokens,
|
||||
settlement.costMicros,
|
||||
settlement.settledAtMs,
|
||||
settlement.settlementDigest,
|
||||
JSON.stringify(settlement),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function quotaWindowUsage(
|
||||
queryable: Queryable,
|
||||
projectId: string,
|
||||
windowStartMs: number,
|
||||
windowMs: number,
|
||||
): Promise<Readonly<ModelInvocationQuotaWindowUsage>> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT
|
||||
COUNT(*)::text AS "invocationCount",
|
||||
COALESCE(SUM(COALESCE(
|
||||
settlement.effective_tokens, reservation.reserved_tokens
|
||||
)), 0)::text AS "effectiveTokens",
|
||||
COALESCE(SUM(COALESCE(
|
||||
settlement.effective_cost_micros,
|
||||
reservation.reserved_cost_micros,
|
||||
0
|
||||
)), 0)::text AS "effectiveCostMicros",
|
||||
COUNT(*) FILTER (
|
||||
WHERE settlement.effective_cost_micros IS NULL
|
||||
AND reservation.reserved_cost_micros IS NULL
|
||||
)::text AS "unknownCostInvocations"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_reservations"
|
||||
AS reservation
|
||||
LEFT JOIN "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_settlements"
|
||||
AS settlement ON settlement.invocation_id = reservation.invocation_id
|
||||
WHERE reservation.project_id = $1
|
||||
AND reservation.window_start_ms = $2
|
||||
AND reservation.window_ms = $3`,
|
||||
[projectId, windowStartMs, windowMs],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (result.rows.length !== 1 || !row) throw unavailable();
|
||||
return Object.freeze({
|
||||
projectId,
|
||||
windowStartMs,
|
||||
windowEndMs: windowStartMs + windowMs,
|
||||
invocationCount: integer(row, 'invocationCount'),
|
||||
effectiveTokens: integer(row, 'effectiveTokens'),
|
||||
effectiveCostMicros: integer(row, 'effectiveCostMicros'),
|
||||
unknownCostInvocations: integer(row, 'unknownCostInvocations'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function insertResolution(
|
||||
client: PostgresClient,
|
||||
resolution: Readonly<ModelInvocationResolutionRecord>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_resolutions" (
|
||||
resolution_id, invocation_id, project_id, run_id, step_run_id,
|
||||
trace_id, completion_digest, decision, resolved_by_user_id,
|
||||
resolved_at_ms, mutation_id, mutation_digest, run_event_id,
|
||||
resolution_digest, record_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15::jsonb
|
||||
)`,
|
||||
[
|
||||
resolution.resolutionId,
|
||||
resolution.invocationId,
|
||||
resolution.projectId,
|
||||
resolution.runId,
|
||||
resolution.stepRunId,
|
||||
resolution.traceId,
|
||||
resolution.completionDigest,
|
||||
resolution.decision,
|
||||
resolution.resolvedByUserId,
|
||||
resolution.resolvedAtMs,
|
||||
resolution.stepRunMutationId,
|
||||
resolution.stepRunMutationDigest,
|
||||
resolution.runEventId,
|
||||
resolution.resolutionDigest,
|
||||
JSON.stringify(resolution),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertCurrent(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
const result = await client.query<Row>(
|
||||
`SELECT
|
||||
step.kind AS "stepKind", step.status AS "stepStatus",
|
||||
step.version AS "stepVersion", step.step_run_digest AS "stepDigest",
|
||||
run.project_id AS "projectId", run.status AS "runStatus",
|
||||
run.version AS "runVersion",
|
||||
run.event_sequence AS "runEventSequence"
|
||||
FROM "ql3"."step_runs" AS step
|
||||
JOIN "ql3"."runs" AS run ON run.id = step.run_id
|
||||
WHERE step.id = $1 AND step.run_id = $2
|
||||
LIMIT 2
|
||||
FOR UPDATE OF step, run`,
|
||||
[mutation.stepRun.id, mutation.runId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
!row ||
|
||||
text(row, 'stepKind') !== 'model' ||
|
||||
text(row, 'stepStatus') !== mutation.previousStatus ||
|
||||
integer(row, 'stepVersion') !== mutation.expectedStepRunVersion ||
|
||||
text(row, 'stepDigest') !== mutation.expectedStepRunDigest ||
|
||||
text(row, 'projectId') !== projectId ||
|
||||
integer(row, 'runVersion') !== mutation.expectedRunVersion ||
|
||||
integer(row, 'runEventSequence') !== mutation.expectedRunEventSequence ||
|
||||
TERMINAL_RUN_STATUSES.has(text(row, 'runStatus'))
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { POSTGRES_MODEL_INVOCATION_SCHEMA } from '../../migration/modelInvocationMigration';
|
||||
|
||||
import type { Queryable, Row } from './authority';
|
||||
import {
|
||||
COMPLETION_SELECT,
|
||||
RESOLUTION_SELECT,
|
||||
START_SELECT,
|
||||
USAGE_SELECT,
|
||||
} from './codec';
|
||||
|
||||
export async function startRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT ${START_SELECT}
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts" AS start
|
||||
JOIN "ql3"."step_run_mutations" AS mutation
|
||||
ON mutation.mutation_id = start.mutation_id
|
||||
JOIN "ql3"."run_events" AS event ON event.id = start.run_event_id
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
values,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function completionRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT ${COMPLETION_SELECT}
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions" AS completion
|
||||
JOIN "ql3"."step_run_mutations" AS mutation
|
||||
ON mutation.mutation_id = completion.mutation_id
|
||||
JOIN "ql3"."run_events" AS event ON event.id = completion.run_event_id
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
values,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function usageRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
limit = 2,
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT ${USAGE_SELECT}
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_usage_ledger" AS usage
|
||||
WHERE ${where}
|
||||
ORDER BY usage.settled_at_ms, usage.invocation_id
|
||||
LIMIT $${values.length + 1}`,
|
||||
[...values, limit],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function quotaReservationRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
limit = 2,
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT reservation.record_json AS "recordJson"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_reservations"
|
||||
AS reservation
|
||||
WHERE ${where}
|
||||
ORDER BY reservation.window_start_ms, reservation.invocation_id
|
||||
LIMIT $${values.length + 1}`,
|
||||
[...values, limit],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function quotaSettlementRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT settlement.record_json AS "recordJson"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_settlements"
|
||||
AS settlement
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
values,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function priceQuoteRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT quote.record_json AS "recordJson"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_quotes"
|
||||
AS quote
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
values,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function priceSettlementRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT settlement.record_json AS "recordJson"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_price_settlements"
|
||||
AS settlement
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
values,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function resolutionRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT ${RESOLUTION_SELECT}
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_resolutions" AS resolution
|
||||
JOIN "ql3"."step_run_mutations" AS mutation
|
||||
ON mutation.mutation_id = resolution.mutation_id
|
||||
JOIN "ql3"."run_events" AS event ON event.id = resolution.run_event_id
|
||||
WHERE ${where}
|
||||
LIMIT 2`,
|
||||
values,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import { POSTGRES_MODEL_INVOCATION_SCHEMA } from '../../migration/modelInvocationMigration';
|
||||
import {
|
||||
type ModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceSettlement,
|
||||
} from '../../pricing/pricing';
|
||||
import { type PluginPackagePromptOutputArtifact } from '../../prompt-output/pluginPackagePromptOutputArtifact';
|
||||
import type { PluginPackagePromptOutputArtifactTombstone } from '../../prompt-output/pluginPackagePromptOutputRetention';
|
||||
import { readPostgresPluginPackagePromptOutputArtifactInTransaction } from '../../prompt-output/storage/postgresPluginPackagePromptOutputArtifactRepository';
|
||||
import { readPostgresPluginPackagePromptOutputArtifactTombstoneInTransaction } from '../../prompt-output/storage/postgresPluginPackagePromptOutputRetentionRepository';
|
||||
import {
|
||||
MAX_MODEL_INVOCATION_USAGE_SUMMARY_ROWS,
|
||||
ModelInvocationUsageSummaryLimitExceededError,
|
||||
normalizeModelInvocationUsageLedgerQuery,
|
||||
normalizeModelInvocationUsageLedgerSummaryQuery,
|
||||
type ModelInvocationUsageLedgerPage,
|
||||
type ModelInvocationUsageLedgerQuery,
|
||||
type ModelInvocationUsageLedgerRecord,
|
||||
type ModelInvocationUsageLedgerSummary,
|
||||
type ModelInvocationUsageLedgerSummaryQuery,
|
||||
} from '../../usage/usageLedger';
|
||||
import {
|
||||
type ModelInvocationQuotaReservation,
|
||||
type ModelInvocationQuotaSettlement,
|
||||
type ModelInvocationQuotaWindowUsage,
|
||||
} from '../../usage/usageQuota';
|
||||
import {
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationStartRecord,
|
||||
} from '../modelInvocation';
|
||||
|
||||
import type { Row } from './authority';
|
||||
import { identifier, integer, mapStorageError, unavailable } from './authority';
|
||||
import {
|
||||
parseCompletion,
|
||||
parsePriceQuote,
|
||||
parsePriceSettlement,
|
||||
parseQuotaReservation,
|
||||
parseQuotaSettlement,
|
||||
parseStart,
|
||||
parseUsage,
|
||||
} from './codec';
|
||||
import { quotaWindowUsage } from './mutations';
|
||||
import {
|
||||
completionRows,
|
||||
priceQuoteRows,
|
||||
priceSettlementRows,
|
||||
quotaReservationRows,
|
||||
quotaSettlementRows,
|
||||
startRows,
|
||||
usageRows,
|
||||
} from './queries';
|
||||
|
||||
export async function findStartOperation(
|
||||
pool: PostgresPool,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationStartRecord> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
try {
|
||||
const rows = await startRows(pool, 'start.invocation_id = $1', [
|
||||
invocationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parseStart(rows[0]) : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findCompletionOperation(
|
||||
pool: PostgresPool,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationCompletionRecord> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
try {
|
||||
const rows = await completionRows(pool, 'completion.invocation_id = $1', [
|
||||
invocationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parseCompletion(rows[0]) : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findPromptOutputArtifactOperation(
|
||||
pool: PostgresPool,
|
||||
artifactIdValue: string,
|
||||
): Promise<Readonly<PluginPackagePromptOutputArtifact> | null> {
|
||||
const artifactId = identifier(artifactIdValue);
|
||||
try {
|
||||
return await readPostgresPluginPackagePromptOutputArtifactInTransaction(
|
||||
pool,
|
||||
artifactId,
|
||||
);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findPromptOutputArtifactTombstoneOperation(
|
||||
pool: PostgresPool,
|
||||
artifactIdValue: string,
|
||||
): Promise<Readonly<PluginPackagePromptOutputArtifactTombstone> | null> {
|
||||
const artifactId = identifier(artifactIdValue);
|
||||
try {
|
||||
return await readPostgresPluginPackagePromptOutputArtifactTombstoneInTransaction(
|
||||
pool,
|
||||
artifactId,
|
||||
);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findUsageOperation(
|
||||
pool: PostgresPool,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerRecord> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
try {
|
||||
const rows = await usageRows(pool, 'usage.invocation_id = $1', [
|
||||
invocationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parseUsage(rows[0]) : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findPriceQuoteOperation(
|
||||
pool: PostgresPool,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationPriceQuote> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
try {
|
||||
const rows = await priceQuoteRows(pool, 'quote.invocation_id = $1', [
|
||||
invocationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parsePriceQuote(rows[0]) : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findPriceSettlementOperation(
|
||||
pool: PostgresPool,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationPriceSettlement> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
try {
|
||||
const [quotes, completions, settlements] = await Promise.all([
|
||||
priceQuoteRows(pool, 'quote.invocation_id = $1', [invocationId]),
|
||||
completionRows(pool, 'completion.invocation_id = $1', [invocationId]),
|
||||
priceSettlementRows(pool, 'settlement.invocation_id = $1', [
|
||||
invocationId,
|
||||
]),
|
||||
]);
|
||||
if (quotes.length > 1 || completions.length > 1 || settlements.length > 1) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (!settlements[0]) return null;
|
||||
if (!quotes[0] || !completions[0]) throw unavailable();
|
||||
return parsePriceSettlement(
|
||||
settlements[0],
|
||||
parsePriceQuote(quotes[0]),
|
||||
parseCompletion(completions[0]),
|
||||
);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findQuotaReservationOperation(
|
||||
pool: PostgresPool,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationQuotaReservation> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
try {
|
||||
const rows = await quotaReservationRows(
|
||||
pool,
|
||||
'reservation.invocation_id = $1',
|
||||
[invocationId],
|
||||
);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parseQuotaReservation(rows[0]) : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findQuotaSettlementOperation(
|
||||
pool: PostgresPool,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationQuotaSettlement> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
try {
|
||||
const [reservations, completions, settlements] = await Promise.all([
|
||||
quotaReservationRows(pool, 'reservation.invocation_id = $1', [
|
||||
invocationId,
|
||||
]),
|
||||
completionRows(pool, 'completion.invocation_id = $1', [invocationId]),
|
||||
quotaSettlementRows(pool, 'settlement.invocation_id = $1', [
|
||||
invocationId,
|
||||
]),
|
||||
]);
|
||||
if (
|
||||
reservations.length > 1 ||
|
||||
completions.length > 1 ||
|
||||
settlements.length > 1
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (!settlements[0]) return null;
|
||||
if (!reservations[0] || !completions[0]) throw unavailable();
|
||||
return parseQuotaSettlement(
|
||||
settlements[0],
|
||||
parseQuotaReservation(reservations[0]),
|
||||
parseCompletion(completions[0]),
|
||||
);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readQuotaWindowUsageOperation(
|
||||
pool: PostgresPool,
|
||||
projectIdValue: string,
|
||||
atMsValue?: number,
|
||||
): Promise<Readonly<ModelInvocationQuotaWindowUsage> | null> {
|
||||
const projectId = identifier(projectIdValue);
|
||||
if (
|
||||
atMsValue !== undefined &&
|
||||
(!Number.isSafeInteger(atMsValue) || atMsValue < 0)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
try {
|
||||
let atMs = atMsValue;
|
||||
if (atMs === undefined) {
|
||||
const observation = await pool.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM statement_timestamp()) * 1000)::bigint
|
||||
AS "atMs"`,
|
||||
);
|
||||
const row = observation.rows[0];
|
||||
if (observation.rows.length !== 1 || !row) throw unavailable();
|
||||
atMs = integer(row, 'atMs');
|
||||
}
|
||||
const result = await pool.query<Row>(
|
||||
`SELECT reservation.record_json AS "recordJson"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_quota_reservations"
|
||||
AS reservation
|
||||
WHERE reservation.project_id = $1
|
||||
AND reservation.window_start_ms <= $2
|
||||
AND reservation.window_end_ms > $3
|
||||
ORDER BY reservation.reserved_at_ms DESC,
|
||||
reservation.invocation_id DESC
|
||||
LIMIT 1`,
|
||||
[projectId, atMs, atMs],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) return null;
|
||||
const reservation = parseQuotaReservation(row);
|
||||
return quotaWindowUsage(
|
||||
pool,
|
||||
projectId,
|
||||
reservation.windowStartMs,
|
||||
reservation.windowMs,
|
||||
);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProjectUsageOperation(
|
||||
pool: PostgresPool,
|
||||
queryValue: ModelInvocationUsageLedgerQuery,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerPage>> {
|
||||
const query = normalizeModelInvocationUsageLedgerQuery(queryValue);
|
||||
const cursor = query.after;
|
||||
try {
|
||||
const rows = await usageRows(
|
||||
pool,
|
||||
`usage.project_id = $1
|
||||
AND usage.settled_at_ms >= $2 AND usage.settled_at_ms < $3
|
||||
${
|
||||
cursor
|
||||
? `AND (
|
||||
usage.settled_at_ms > $4 OR
|
||||
(usage.settled_at_ms = $5 AND usage.invocation_id > $6)
|
||||
)`
|
||||
: ''
|
||||
}`,
|
||||
[
|
||||
query.projectId,
|
||||
query.fromMsInclusive,
|
||||
query.toMsExclusive,
|
||||
...(cursor
|
||||
? [cursor.settledAtMs, cursor.settledAtMs, cursor.invocationId]
|
||||
: []),
|
||||
],
|
||||
query.limit + 1,
|
||||
);
|
||||
return Object.freeze({
|
||||
records: Object.freeze(
|
||||
rows.slice(0, query.limit).map((row) => parseUsage(row)),
|
||||
),
|
||||
hasMore: rows.length > query.limit,
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function summarizeProjectUsageOperation(
|
||||
pool: PostgresPool,
|
||||
queryValue: ModelInvocationUsageLedgerSummaryQuery,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerSummary>> {
|
||||
const query = normalizeModelInvocationUsageLedgerSummaryQuery(queryValue);
|
||||
try {
|
||||
const result = await pool.query<Row>(
|
||||
`SELECT
|
||||
COUNT(*)::text AS "invocationCount",
|
||||
COALESCE(SUM(input_tokens), 0)::text AS "inputTokens",
|
||||
COALESCE(SUM(output_tokens), 0)::text AS "outputTokens",
|
||||
COALESCE(SUM(total_tokens), 0)::text AS "totalTokens",
|
||||
COALESCE(SUM(cost_micros), 0)::text AS "knownCostMicros",
|
||||
COUNT(*) FILTER (WHERE cost_micros IS NULL)::text
|
||||
AS "unknownCostInvocations"
|
||||
FROM (
|
||||
SELECT input_tokens, output_tokens, total_tokens, cost_micros
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_usage_ledger"
|
||||
WHERE project_id = $1
|
||||
AND settled_at_ms >= $2 AND settled_at_ms < $3
|
||||
ORDER BY settled_at_ms, invocation_id
|
||||
LIMIT $4
|
||||
) AS bounded_usage`,
|
||||
[
|
||||
query.projectId,
|
||||
query.fromMsInclusive,
|
||||
query.toMsExclusive,
|
||||
MAX_MODEL_INVOCATION_USAGE_SUMMARY_ROWS + 1,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (result.rows.length !== 1 || !row) throw unavailable();
|
||||
if (
|
||||
integer(row, 'invocationCount') > MAX_MODEL_INVOCATION_USAGE_SUMMARY_ROWS
|
||||
) {
|
||||
throw new ModelInvocationUsageSummaryLimitExceededError();
|
||||
}
|
||||
return Object.freeze({
|
||||
invocationCount: integer(row, 'invocationCount'),
|
||||
inputTokens: integer(row, 'inputTokens'),
|
||||
outputTokens: integer(row, 'outputTokens'),
|
||||
totalTokens: integer(row, 'totalTokens'),
|
||||
knownCostMicros: integer(row, 'knownCostMicros'),
|
||||
unknownCostInvocations: integer(row, 'unknownCostInvocations'),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import { POSTGRES_MODEL_INVOCATION_SCHEMA } from '../../migration/modelInvocationMigration';
|
||||
import {
|
||||
ModelInvocationConflictError,
|
||||
type CommitModelInvocationResult,
|
||||
type ModelInvocationAuthoritySnapshot,
|
||||
type ModelInvocationRecoveryPage,
|
||||
} from '../modelInvocation';
|
||||
import {
|
||||
normalizeModelInvocationResolutionCommand,
|
||||
type ModelInvocationResolutionCommand,
|
||||
type ModelInvocationResolutionRecord,
|
||||
} from '../modelInvocationResolution';
|
||||
|
||||
import type { Row } from './authority';
|
||||
import {
|
||||
TERMINAL_RUN_STATUSES,
|
||||
identifier,
|
||||
integer,
|
||||
mapStorageError,
|
||||
recoveryLimit,
|
||||
text,
|
||||
unavailable,
|
||||
} from './authority';
|
||||
import {
|
||||
START_SELECT,
|
||||
parseAuthority,
|
||||
parseCompletion,
|
||||
parseResolution,
|
||||
parseStart,
|
||||
} from './codec';
|
||||
import { applyMutation, assertCurrent, insertResolution } from './mutations';
|
||||
import { completionRows, resolutionRows } from './queries';
|
||||
import { runPostgresModelInvocationTransaction } from './transaction';
|
||||
|
||||
export async function findResolutionOperation(
|
||||
pool: PostgresPool,
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationResolutionRecord> | null> {
|
||||
const invocationId = identifier(invocationIdValue);
|
||||
try {
|
||||
const rows = await resolutionRows(pool, 'resolution.invocation_id = $1', [
|
||||
invocationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? parseResolution(rows[0]) : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAuthorityOperation(
|
||||
pool: PostgresPool,
|
||||
identity: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
stepRunId: string;
|
||||
}>,
|
||||
): Promise<Readonly<ModelInvocationAuthoritySnapshot> | null> {
|
||||
const projectId = identifier(identity?.projectId);
|
||||
const runId = identifier(identity?.runId);
|
||||
const stepRunId = identifier(identity?.stepRunId);
|
||||
try {
|
||||
const result = await pool.query<Row>(
|
||||
`SELECT
|
||||
run.project_id AS "projectId", run.id AS "runId",
|
||||
run.status AS "runStatus", run.version AS "runVersion",
|
||||
run.event_sequence AS "runEventSequence",
|
||||
step.id AS "stepRunId", step.status AS "stepStatus",
|
||||
step.version AS "stepVersion",
|
||||
step.step_run_digest AS "stepDigest",
|
||||
step.step_run_json AS "stepRunJson"
|
||||
FROM "ql3"."runs" AS run
|
||||
JOIN "ql3"."step_runs" AS step ON step.run_id = run.id
|
||||
WHERE run.project_id = $1 AND run.id = $2 AND step.id = $3
|
||||
LIMIT 2`,
|
||||
[projectId, runId, stepRunId],
|
||||
);
|
||||
if (result.rows.length > 1) throw unavailable();
|
||||
const row = result.rows[0];
|
||||
if (!row || TERMINAL_RUN_STATUSES.has(text(row, 'runStatus'))) {
|
||||
return null;
|
||||
}
|
||||
return parseAuthority(row);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listIncompleteOperation(
|
||||
pool: PostgresPool,
|
||||
limitValue: number,
|
||||
): Promise<Readonly<ModelInvocationRecoveryPage>> {
|
||||
const limit = recoveryLimit(limitValue);
|
||||
try {
|
||||
const observation = await pool.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM statement_timestamp()) * 1000)::bigint
|
||||
AS "observedAtMs"`,
|
||||
);
|
||||
const observedRow = observation.rows[0];
|
||||
if (observation.rows.length !== 1 || !observedRow) {
|
||||
throw unavailable();
|
||||
}
|
||||
const observedAtMs = integer(observedRow, 'observedAtMs');
|
||||
const result = await pool.query<Row>(
|
||||
`SELECT ${START_SELECT}
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_starts" AS start
|
||||
JOIN "ql3"."step_run_mutations" AS mutation
|
||||
ON mutation.mutation_id = start.mutation_id
|
||||
JOIN "ql3"."run_events" AS event ON event.id = start.run_event_id
|
||||
LEFT JOIN "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_invocation_completions" AS completion
|
||||
ON completion.invocation_id = start.invocation_id
|
||||
WHERE completion.invocation_id IS NULL
|
||||
AND start.deadline_at_ms <= $1
|
||||
ORDER BY start.deadline_at_ms, start.invocation_id
|
||||
LIMIT $2`,
|
||||
[observedAtMs, limit + 1],
|
||||
);
|
||||
const hasMore = result.rows.length > limit;
|
||||
return Object.freeze({
|
||||
observedAtMs,
|
||||
candidates: Object.freeze(
|
||||
result.rows.slice(0, limit).map((row) => parseStart(row)),
|
||||
),
|
||||
hasMore,
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: ModelInvocationResolutionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationResolutionRecord>>
|
||||
> {
|
||||
const command = normalizeModelInvocationResolutionCommand(commandValue);
|
||||
const resolution = command.resolution;
|
||||
return runPostgresModelInvocationTransaction(pool, async (client) => {
|
||||
const existing = await resolutionRows(
|
||||
client,
|
||||
`resolution.invocation_id = $1 OR
|
||||
resolution.resolution_id = $2 OR
|
||||
resolution.mutation_id = $3 OR resolution.run_event_id = $4`,
|
||||
[
|
||||
resolution.invocationId,
|
||||
resolution.resolutionId,
|
||||
resolution.stepRunMutationId,
|
||||
resolution.runEventId,
|
||||
],
|
||||
);
|
||||
if (existing.length > 1) throw new ModelInvocationConflictError();
|
||||
if (existing[0]) {
|
||||
const stored = parseResolution(existing[0]);
|
||||
if (JSON.stringify(stored) !== JSON.stringify(resolution)) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'existing' as const, record: stored });
|
||||
}
|
||||
const completions = await completionRows(
|
||||
client,
|
||||
'completion.invocation_id = $1',
|
||||
[resolution.invocationId],
|
||||
);
|
||||
if (
|
||||
completions.length !== 1 ||
|
||||
JSON.stringify(parseCompletion(completions[0]!)) !==
|
||||
JSON.stringify(command.completion) ||
|
||||
command.completion.outcome !== 'outcome_unknown'
|
||||
) {
|
||||
throw new ModelInvocationConflictError();
|
||||
}
|
||||
await assertCurrent(client, command.stepRunMutation, resolution.projectId);
|
||||
await applyMutation(client, command.stepRunMutation);
|
||||
await insertResolution(client, resolution);
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
record: resolution,
|
||||
});
|
||||
});
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
type ModelInvocationPriceQuote,
|
||||
type ModelInvocationPriceSettlement,
|
||||
type PricingAwareModelInvocationRepository,
|
||||
} from '../../pricing/pricing';
|
||||
import { type PluginPackagePromptOutputArtifact } from '../../prompt-output/pluginPackagePromptOutputArtifact';
|
||||
import {
|
||||
type CommitPluginPackagePromptOutputResult,
|
||||
type PluginPackagePromptOutputCompletionRepository,
|
||||
} from '../../prompt-output/pluginPackagePromptOutputCompletion';
|
||||
import type { PluginPackagePromptOutputArtifactTombstone } from '../../prompt-output/pluginPackagePromptOutputRetention';
|
||||
import {
|
||||
type ModelInvocationUsageLedgerPage,
|
||||
type ModelInvocationUsageLedgerQuery,
|
||||
type ModelInvocationUsageLedgerRecord,
|
||||
type ModelInvocationUsageLedgerRepository,
|
||||
type ModelInvocationUsageLedgerSummary,
|
||||
type ModelInvocationUsageLedgerSummaryQuery,
|
||||
} from '../../usage/usageLedger';
|
||||
import {
|
||||
type ModelInvocationQuotaAdmission,
|
||||
type ModelInvocationQuotaReservation,
|
||||
type ModelInvocationQuotaSettlement,
|
||||
type ModelInvocationQuotaWindowUsage,
|
||||
type QuotaAwareModelInvocationRepository,
|
||||
} from '../../usage/usageQuota';
|
||||
import {
|
||||
type CommitModelInvocationResult,
|
||||
type ModelInvocationAuthoritySnapshot,
|
||||
type ModelInvocationCompletionCommand,
|
||||
type ModelInvocationCompletionRecord,
|
||||
type ModelInvocationRecoveryPage,
|
||||
type ModelInvocationRepository,
|
||||
type ModelInvocationStartCommand,
|
||||
type ModelInvocationStartRecord,
|
||||
} from '../modelInvocation';
|
||||
import {
|
||||
type ModelInvocationResolutionCommand,
|
||||
type ModelInvocationResolutionRecord,
|
||||
type ModelInvocationResolutionRepository,
|
||||
} from '../modelInvocationResolution';
|
||||
|
||||
import {
|
||||
admitOperation,
|
||||
admitWithPricingOperation,
|
||||
admitWithQuotaOperation,
|
||||
} from './admissionOperations';
|
||||
import { unavailable } from './authority';
|
||||
import {
|
||||
completeOperation,
|
||||
completeWithPricingOperation,
|
||||
completeWithPromptOutputArtifactOperation,
|
||||
completeWithQuotaOperation,
|
||||
} from './completionOperations';
|
||||
import {
|
||||
findCompletionOperation,
|
||||
findPriceQuoteOperation,
|
||||
findPriceSettlementOperation,
|
||||
findPromptOutputArtifactOperation,
|
||||
findPromptOutputArtifactTombstoneOperation,
|
||||
findQuotaReservationOperation,
|
||||
findQuotaSettlementOperation,
|
||||
findStartOperation,
|
||||
findUsageOperation,
|
||||
listProjectUsageOperation,
|
||||
readQuotaWindowUsageOperation,
|
||||
summarizeProjectUsageOperation,
|
||||
} from './readOperations';
|
||||
import {
|
||||
findResolutionOperation,
|
||||
listIncompleteOperation,
|
||||
readAuthorityOperation,
|
||||
resolveOperation,
|
||||
} from './recoveryResolutionOperations';
|
||||
|
||||
export class PostgresModelInvocationRepository
|
||||
implements
|
||||
ModelInvocationRepository,
|
||||
ModelInvocationResolutionRepository,
|
||||
ModelInvocationUsageLedgerRepository,
|
||||
QuotaAwareModelInvocationRepository,
|
||||
PricingAwareModelInvocationRepository,
|
||||
PluginPackagePromptOutputCompletionRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async findStart(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationStartRecord> | null> {
|
||||
return findStartOperation(this.pool, invocationIdValue);
|
||||
}
|
||||
|
||||
async findCompletion(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationCompletionRecord> | null> {
|
||||
return findCompletionOperation(this.pool, invocationIdValue);
|
||||
}
|
||||
|
||||
async findPromptOutputArtifact(
|
||||
artifactIdValue: string,
|
||||
): Promise<Readonly<PluginPackagePromptOutputArtifact> | null> {
|
||||
return findPromptOutputArtifactOperation(this.pool, artifactIdValue);
|
||||
}
|
||||
|
||||
async findPromptOutputArtifactTombstone(
|
||||
artifactIdValue: string,
|
||||
): Promise<Readonly<PluginPackagePromptOutputArtifactTombstone> | null> {
|
||||
return findPromptOutputArtifactTombstoneOperation(
|
||||
this.pool,
|
||||
artifactIdValue,
|
||||
);
|
||||
}
|
||||
|
||||
async findUsage(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerRecord> | null> {
|
||||
return findUsageOperation(this.pool, invocationIdValue);
|
||||
}
|
||||
|
||||
async findPriceQuote(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationPriceQuote> | null> {
|
||||
return findPriceQuoteOperation(this.pool, invocationIdValue);
|
||||
}
|
||||
|
||||
async findPriceSettlement(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationPriceSettlement> | null> {
|
||||
return findPriceSettlementOperation(this.pool, invocationIdValue);
|
||||
}
|
||||
|
||||
async findQuotaReservation(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationQuotaReservation> | null> {
|
||||
return findQuotaReservationOperation(this.pool, invocationIdValue);
|
||||
}
|
||||
|
||||
async findQuotaSettlement(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationQuotaSettlement> | null> {
|
||||
return findQuotaSettlementOperation(this.pool, invocationIdValue);
|
||||
}
|
||||
|
||||
async readQuotaWindowUsage(
|
||||
projectIdValue: string,
|
||||
atMsValue?: number,
|
||||
): Promise<Readonly<ModelInvocationQuotaWindowUsage> | null> {
|
||||
return readQuotaWindowUsageOperation(this.pool, projectIdValue, atMsValue);
|
||||
}
|
||||
|
||||
async listProjectUsage(
|
||||
queryValue: ModelInvocationUsageLedgerQuery,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerPage>> {
|
||||
return listProjectUsageOperation(this.pool, queryValue);
|
||||
}
|
||||
|
||||
async summarizeProjectUsage(
|
||||
queryValue: ModelInvocationUsageLedgerSummaryQuery,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerSummary>> {
|
||||
return summarizeProjectUsageOperation(this.pool, queryValue);
|
||||
}
|
||||
|
||||
async findResolution(
|
||||
invocationIdValue: string,
|
||||
): Promise<Readonly<ModelInvocationResolutionRecord> | null> {
|
||||
return findResolutionOperation(this.pool, invocationIdValue);
|
||||
}
|
||||
|
||||
async readAuthority(
|
||||
identity: Readonly<{
|
||||
projectId: string;
|
||||
runId: string;
|
||||
stepRunId: string;
|
||||
}>,
|
||||
): Promise<Readonly<ModelInvocationAuthoritySnapshot> | null> {
|
||||
return readAuthorityOperation(this.pool, identity);
|
||||
}
|
||||
|
||||
async listIncomplete(
|
||||
limitValue: number,
|
||||
): Promise<Readonly<ModelInvocationRecoveryPage>> {
|
||||
return listIncompleteOperation(this.pool, limitValue);
|
||||
}
|
||||
|
||||
async admit(
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>
|
||||
> {
|
||||
return admitOperation(this.pool, commandValue);
|
||||
}
|
||||
|
||||
async admitWithQuota(
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
admissionValue: ModelInvocationQuotaAdmission,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>
|
||||
> {
|
||||
return admitWithQuotaOperation(this.pool, commandValue, admissionValue);
|
||||
}
|
||||
|
||||
async admitWithPricing(
|
||||
commandValue: ModelInvocationStartCommand,
|
||||
quoteValue: ModelInvocationPriceQuote,
|
||||
admissionValue?: ModelInvocationQuotaAdmission,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>
|
||||
> {
|
||||
return admitWithPricingOperation(
|
||||
this.pool,
|
||||
commandValue,
|
||||
quoteValue,
|
||||
admissionValue,
|
||||
);
|
||||
}
|
||||
|
||||
async complete(
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
return completeOperation(this.pool, commandValue);
|
||||
}
|
||||
|
||||
async completeWithQuota(
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
return completeWithQuotaOperation(this.pool, commandValue);
|
||||
}
|
||||
|
||||
async completeWithPricing(
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
> {
|
||||
return completeWithPricingOperation(this.pool, commandValue);
|
||||
}
|
||||
|
||||
async completeWithPromptOutputArtifact(
|
||||
commandValue: ModelInvocationCompletionCommand,
|
||||
artifactValue: PluginPackagePromptOutputArtifact,
|
||||
): Promise<Readonly<CommitPluginPackagePromptOutputResult>> {
|
||||
return completeWithPromptOutputArtifactOperation(
|
||||
this.pool,
|
||||
commandValue,
|
||||
artifactValue,
|
||||
);
|
||||
}
|
||||
|
||||
async resolve(
|
||||
commandValue: ModelInvocationResolutionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationResolutionRecord>>
|
||||
> {
|
||||
return resolveOperation(this.pool, commandValue);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
MAX_TRANSACTION_ATTEMPTS,
|
||||
RETRYABLE_SQL_STATES,
|
||||
mapStorageError,
|
||||
sqlState,
|
||||
unavailable,
|
||||
} from './authority';
|
||||
|
||||
export async function begin(client: PostgresClient): Promise<void> {
|
||||
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||
await client.query(`SELECT set_config('statement_timeout', $1, true)`, [
|
||||
'5s',
|
||||
]);
|
||||
await client.query(`SELECT set_config('lock_timeout', $1, true)`, ['2s']);
|
||||
await client.query(
|
||||
`SELECT set_config('idle_in_transaction_session_timeout', $1, true)`,
|
||||
['5s'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function rollback(client: PostgresClient): Promise<void> {
|
||||
try {
|
||||
await client.query('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original transaction failure.
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPostgresModelInvocationTransaction<T>(
|
||||
pool: PostgresPool,
|
||||
work: (client: PostgresClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt < MAX_TRANSACTION_ATTEMPTS; attempt += 1) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await pool.connect();
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await begin(client);
|
||||
began = true;
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollback(client);
|
||||
if (
|
||||
RETRYABLE_SQL_STATES.has(sqlState(error)) &&
|
||||
attempt + 1 < MAX_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PostgresModelInvocationRepository } from './postgres-model-invocation-repository/repository';
|
||||
+699
@@ -0,0 +1,699 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { parseSecretRef } from '@qinglong/runtime-core/secret-reference';
|
||||
|
||||
import {
|
||||
ModelProviderCredentialAdministrationAuthorizationFenceConflictError,
|
||||
ModelProviderCredentialAdministrationMutationConflictError,
|
||||
normalizeAuthorizedModelProviderCredentialInspection,
|
||||
normalizeAuthorizedModelProviderCredentialTransitionMutation,
|
||||
type AuthorizedModelProviderCredentialInspection,
|
||||
type AuthorizedModelProviderCredentialTransitionMutation,
|
||||
type ModelProviderCredentialAdministrationInspectionRepository,
|
||||
type ModelProviderCredentialAdministrationRepository,
|
||||
} from './modelProviderCredentialAdministration';
|
||||
import {
|
||||
InvalidModelProviderCredentialTransitionError,
|
||||
ModelProviderCredentialCatalogUnavailableError,
|
||||
ModelProviderCredentialTransitionConflictError,
|
||||
createModelProviderCredentialTransition,
|
||||
modelProviderCredentialBindingForTransition,
|
||||
normalizeModelProviderCredentialTransition,
|
||||
normalizeModelProviderCredentialTransitionCommand,
|
||||
type CommitModelProviderCredentialTransitionResult,
|
||||
type ModelProviderCredentialTransition,
|
||||
type ModelProviderCredentialTransitionCommand,
|
||||
} from './modelProviderCredentialCatalog';
|
||||
import type { LocalModelInvocationOperationAuthority } from '../model-invocation/localModelInvocationRepository';
|
||||
import {
|
||||
MODEL_PROVIDER_CREDENTIAL_AUDIT_SCHEMA,
|
||||
MODEL_PROVIDER_CREDENTIAL_OPERATIONS,
|
||||
digestModelProviderCredentialBinding,
|
||||
normalizeModelProviderCredentialBinding,
|
||||
type ModelProviderCredentialAuditRecord,
|
||||
type ModelProviderCredentialAuditSink,
|
||||
type ModelProviderCredentialBinding,
|
||||
type ModelProviderCredentialBindingLookup,
|
||||
type ModelProviderCredentialBindingSource,
|
||||
} from './providerCredential';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export type LocalModelProviderCredentialAuthorizationInput =
|
||||
| Readonly<{
|
||||
kind: 'mutation';
|
||||
value: Readonly<AuthorizedModelProviderCredentialTransitionMutation>;
|
||||
replay: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: 'inspection';
|
||||
value: Readonly<AuthorizedModelProviderCredentialInspection>;
|
||||
replay: false;
|
||||
}>;
|
||||
|
||||
export interface LocalModelProviderCredentialAuthorizationGuard {
|
||||
confirm(input: LocalModelProviderCredentialAuthorizationInput): void;
|
||||
}
|
||||
|
||||
export interface LocalModelProviderCredentialRepositoryOptions {
|
||||
readonly now?: () => number;
|
||||
readonly authorization?: LocalModelProviderCredentialAuthorizationGuard;
|
||||
}
|
||||
|
||||
class PrivateLocalAuthority implements LocalModelInvocationOperationAuthority {
|
||||
readonly client: DatabaseSync;
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#pending = 0;
|
||||
|
||||
constructor(client: DatabaseSync) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
enqueue<T>(
|
||||
work: () => Promise<T>,
|
||||
rejection: (reason: 'closed' | 'busy') => Error,
|
||||
): Promise<T> {
|
||||
if (!this.client.isOpen) return Promise.reject(rejection('closed'));
|
||||
if (this.#pending >= 64) return Promise.reject(rejection('busy'));
|
||||
this.#pending += 1;
|
||||
const result = this.#tail.then(work, work);
|
||||
this.#tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result.finally(() => {
|
||||
this.#pending -= 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthority(
|
||||
value: LocalModelInvocationOperationAuthority | DatabaseSync,
|
||||
): value is LocalModelInvocationOperationAuthority {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'client' in value &&
|
||||
'enqueue' in value &&
|
||||
typeof value.enqueue === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function nullableText(row: Row, key: string): string | null {
|
||||
return row[key] === null ? null : text(row, key);
|
||||
}
|
||||
|
||||
function sqliteConstraint(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
const errcode = (error as { errcode?: unknown }).errcode;
|
||||
return (
|
||||
(typeof code === 'string' && code.includes('SQLITE_CONSTRAINT')) ||
|
||||
(typeof errcode === 'number' && (errcode & 0xff) === 19)
|
||||
);
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidModelProviderCredentialTransitionError ||
|
||||
error instanceof ModelProviderCredentialTransitionConflictError ||
|
||||
error instanceof ModelProviderCredentialCatalogUnavailableError ||
|
||||
error instanceof
|
||||
ModelProviderCredentialAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof ModelProviderCredentialAdministrationMutationConflictError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (sqliteConstraint(error)) {
|
||||
return new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
return new ModelProviderCredentialCatalogUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(value)
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUseAudit(
|
||||
value: Readonly<ModelProviderCredentialAuditRecord>,
|
||||
): Readonly<ModelProviderCredentialAuditRecord> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
[
|
||||
'bindingDigest',
|
||||
'bindingRevision',
|
||||
'occurredAtMs',
|
||||
'operation',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestId',
|
||||
'schema',
|
||||
]
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
value.schema !== MODEL_PROVIDER_CREDENTIAL_AUDIT_SCHEMA ||
|
||||
!MODEL_PROVIDER_CREDENTIAL_OPERATIONS.includes(value.operation) ||
|
||||
typeof value.bindingDigest !== 'string' ||
|
||||
!/^sha256:[0-9a-f]{64}$/.test(value.bindingDigest) ||
|
||||
!Number.isSafeInteger(value.occurredAtMs) ||
|
||||
value.occurredAtMs < 0
|
||||
) {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_AUDIT_SCHEMA,
|
||||
operation: value.operation,
|
||||
projectId: identifier(value.projectId, 'projectId'),
|
||||
provider: identifier(value.provider, 'provider'),
|
||||
requestId: identifier(value.requestId, 'requestId'),
|
||||
bindingRevision: identifier(value.bindingRevision, 'bindingRevision'),
|
||||
bindingDigest: value.bindingDigest,
|
||||
occurredAtMs: value.occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function sameUseAudit(
|
||||
left: Readonly<ModelProviderCredentialAuditRecord>,
|
||||
right: Readonly<ModelProviderCredentialAuditRecord>,
|
||||
): boolean {
|
||||
return (
|
||||
canonical({ ...left, occurredAtMs: right.occurredAtMs }) ===
|
||||
canonical(right)
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalModelProviderCredentialRepository
|
||||
implements
|
||||
ModelProviderCredentialAdministrationRepository,
|
||||
ModelProviderCredentialAdministrationInspectionRepository,
|
||||
ModelProviderCredentialBindingSource,
|
||||
ModelProviderCredentialAuditSink
|
||||
{
|
||||
readonly #authority: LocalModelInvocationOperationAuthority;
|
||||
readonly #now: () => number;
|
||||
readonly #authorization:
|
||||
| LocalModelProviderCredentialAuthorizationGuard
|
||||
| undefined;
|
||||
|
||||
constructor(
|
||||
authority: LocalModelInvocationOperationAuthority | DatabaseSync,
|
||||
options: LocalModelProviderCredentialRepositoryOptions = {},
|
||||
) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => key !== 'authorization' && key !== 'now',
|
||||
) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.authorization !== undefined &&
|
||||
typeof options.authorization.confirm !== 'function')
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Local model provider credential repository options are invalid',
|
||||
);
|
||||
}
|
||||
this.#authority = isAuthority(authority)
|
||||
? authority
|
||||
: new PrivateLocalAuthority(authority);
|
||||
this.#now = options.now ?? Date.now;
|
||||
this.#authorization = options.authorization;
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T | Promise<T>): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => new ModelProviderCredentialCatalogUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
#rowByMutation(mutationId: string): Row | undefined {
|
||||
return this.#authority.client
|
||||
.prepare(
|
||||
`SELECT command_json AS "commandJson",
|
||||
transition_json AS "transitionJson"
|
||||
FROM "ModelInvocationProviderCredentialTransitions"
|
||||
WHERE mutation_id = ?`,
|
||||
)
|
||||
.get(mutationId) as Row | undefined;
|
||||
}
|
||||
|
||||
#currentRow(projectId: string, provider: string): Row | undefined {
|
||||
return this.#authority.client
|
||||
.prepare(
|
||||
`SELECT transition_json AS "transitionJson"
|
||||
FROM "ModelInvocationProviderCredentialTransitions"
|
||||
WHERE project_id = ? AND provider = ?
|
||||
ORDER BY generation DESC LIMIT 1`,
|
||||
)
|
||||
.get(projectId, provider) as Row | undefined;
|
||||
}
|
||||
|
||||
#transition(row: Row): Readonly<ModelProviderCredentialTransition> {
|
||||
try {
|
||||
return normalizeModelProviderCredentialTransition(
|
||||
JSON.parse(
|
||||
text(row, 'transitionJson'),
|
||||
) as ModelProviderCredentialTransition,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ModelProviderCredentialCatalogUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
#current(
|
||||
projectId: string,
|
||||
provider: string,
|
||||
): Readonly<ModelProviderCredentialTransition> | null {
|
||||
const row = this.#currentRow(projectId, provider);
|
||||
if (!row) return null;
|
||||
const transition = this.#transition(row);
|
||||
if (
|
||||
transition.projectId !== projectId ||
|
||||
transition.provider !== provider
|
||||
) {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
return transition;
|
||||
}
|
||||
|
||||
#existing(
|
||||
command: Readonly<ModelProviderCredentialTransitionCommand>,
|
||||
): Readonly<CommitModelProviderCredentialTransitionResult> | null {
|
||||
const row = this.#rowByMutation(command.mutationId);
|
||||
if (!row) return null;
|
||||
let storedCommand: Readonly<ModelProviderCredentialTransitionCommand>;
|
||||
try {
|
||||
storedCommand = normalizeModelProviderCredentialTransitionCommand(
|
||||
JSON.parse(
|
||||
text(row, 'commandJson'),
|
||||
) as ModelProviderCredentialTransitionCommand,
|
||||
);
|
||||
} catch {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
if (canonical(storedCommand) !== canonical(command)) {
|
||||
throw new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
transition: this.#transition(row),
|
||||
});
|
||||
}
|
||||
|
||||
#assertSecretExists(binding: Readonly<ModelProviderCredentialBinding>): void {
|
||||
let reference;
|
||||
try {
|
||||
reference = parseSecretRef(binding.secretRef);
|
||||
} catch {
|
||||
throw new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
const row =
|
||||
reference.version === undefined
|
||||
? this.#authority.client
|
||||
.prepare(
|
||||
`SELECT version FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE project_id = ? AND secret_name = ?
|
||||
ORDER BY version DESC LIMIT 1`,
|
||||
)
|
||||
.get(reference.projectId, reference.name)
|
||||
: this.#authority.client
|
||||
.prepare(
|
||||
`SELECT version FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE project_id = ? AND secret_name = ? AND version = ?`,
|
||||
)
|
||||
.get(reference.projectId, reference.name, reference.version);
|
||||
if (!row) throw new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
|
||||
#binding(
|
||||
transition: Readonly<ModelProviderCredentialTransition>,
|
||||
): Readonly<ModelProviderCredentialBinding> | null {
|
||||
if (transition.action === 'revoke') {
|
||||
return modelProviderCredentialBindingForTransition(transition, null);
|
||||
}
|
||||
const row = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT binding_digest AS "bindingDigest",
|
||||
binding_json AS "bindingJson"
|
||||
FROM "ModelInvocationProviderCredentialBindings"
|
||||
WHERE project_id = ? AND provider = ? AND revision = ?`,
|
||||
)
|
||||
.get(
|
||||
transition.projectId,
|
||||
transition.provider,
|
||||
transition.activeBindingRevision,
|
||||
) as Row | undefined;
|
||||
if (!row) throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
let binding: Readonly<ModelProviderCredentialBinding>;
|
||||
try {
|
||||
binding = normalizeModelProviderCredentialBinding(
|
||||
JSON.parse(text(row, 'bindingJson')) as ModelProviderCredentialBinding,
|
||||
);
|
||||
} catch {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
if (
|
||||
digestModelProviderCredentialBinding(binding).slice(7) !==
|
||||
text(row, 'bindingDigest')
|
||||
) {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
return modelProviderCredentialBindingForTransition(transition, binding);
|
||||
}
|
||||
|
||||
#insertBinding(binding: Readonly<ModelProviderCredentialBinding>): void {
|
||||
const digest = digestModelProviderCredentialBinding(binding).slice(7);
|
||||
const existing = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT binding_digest AS "bindingDigest",
|
||||
binding_json AS "bindingJson"
|
||||
FROM "ModelInvocationProviderCredentialBindings"
|
||||
WHERE project_id = ? AND provider = ? AND revision = ?`,
|
||||
)
|
||||
.get(binding.projectId, binding.provider, binding.revision) as
|
||||
| Row
|
||||
| undefined;
|
||||
if (existing) {
|
||||
if (
|
||||
text(existing, 'bindingDigest') !== digest ||
|
||||
text(existing, 'bindingJson') !== canonical(binding)
|
||||
) {
|
||||
throw new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.#authority.client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationProviderCredentialBindings" (
|
||||
project_id, provider, revision, binding_digest, binding_json
|
||||
) VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
binding.projectId,
|
||||
binding.provider,
|
||||
binding.revision,
|
||||
digest,
|
||||
canonical(binding),
|
||||
);
|
||||
}
|
||||
|
||||
#insertTransition(
|
||||
command: Readonly<ModelProviderCredentialTransitionCommand>,
|
||||
transition: Readonly<ModelProviderCredentialTransition>,
|
||||
): void {
|
||||
this.#authority.client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationProviderCredentialTransitions" (
|
||||
mutation_id, project_id, provider, generation, action,
|
||||
active_binding_revision, active_binding_digest,
|
||||
previous_transition_digest, changed_by_type, changed_by_id,
|
||||
changed_at_ms, command_digest, transition_digest,
|
||||
command_json, transition_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
transition.mutationId,
|
||||
transition.projectId,
|
||||
transition.provider,
|
||||
transition.generation,
|
||||
transition.action,
|
||||
transition.activeBindingRevision,
|
||||
transition.activeBindingDigest,
|
||||
transition.previousTransitionDigest,
|
||||
transition.changedBy.type,
|
||||
transition.changedBy.id,
|
||||
transition.changedAtMs,
|
||||
transition.commandDigest,
|
||||
transition.transitionDigest,
|
||||
canonical(command),
|
||||
canonical(transition),
|
||||
);
|
||||
}
|
||||
|
||||
#commit(
|
||||
command: Readonly<ModelProviderCredentialTransitionCommand>,
|
||||
authorized?: Readonly<AuthorizedModelProviderCredentialTransitionMutation>,
|
||||
): Readonly<CommitModelProviderCredentialTransitionResult> {
|
||||
let began = false;
|
||||
try {
|
||||
this.#authority.client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const existing = this.#existing(command);
|
||||
if (existing) {
|
||||
if (authorized) {
|
||||
if (!this.#authorization) {
|
||||
throw new ModelProviderCredentialAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
this.#authorization.confirm(
|
||||
Object.freeze({
|
||||
kind: 'mutation',
|
||||
value: authorized,
|
||||
replay: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.#authority.client.exec('COMMIT');
|
||||
began = false;
|
||||
return existing;
|
||||
}
|
||||
if (authorized) {
|
||||
if (!this.#authorization) {
|
||||
throw new ModelProviderCredentialAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
this.#authorization.confirm(
|
||||
Object.freeze({ kind: 'mutation', value: authorized, replay: false }),
|
||||
);
|
||||
}
|
||||
const previous = this.#current(command.projectId, command.provider);
|
||||
const changedAtMs = this.#now();
|
||||
if (!Number.isSafeInteger(changedAtMs) || changedAtMs < 0) {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
const transition = createModelProviderCredentialTransition(
|
||||
command,
|
||||
previous,
|
||||
changedAtMs,
|
||||
);
|
||||
if (command.binding) {
|
||||
this.#assertSecretExists(command.binding);
|
||||
this.#insertBinding(command.binding);
|
||||
}
|
||||
this.#insertTransition(command, transition);
|
||||
this.#authority.client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'created' as const, transition });
|
||||
} finally {
|
||||
if (began && this.#authority.client.isTransaction) {
|
||||
try {
|
||||
this.#authority.client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original fail-closed error.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findCurrentTransition(projectIdValue: string, providerValue: string) {
|
||||
const projectId = identifier(projectIdValue, 'projectId');
|
||||
const provider = identifier(providerValue, 'provider');
|
||||
return this.#enqueue(() => this.#current(projectId, provider));
|
||||
}
|
||||
|
||||
commit(commandValue: Readonly<ModelProviderCredentialTransitionCommand>) {
|
||||
const command =
|
||||
normalizeModelProviderCredentialTransitionCommand(commandValue);
|
||||
return this.#enqueue(() => this.#commit(command));
|
||||
}
|
||||
|
||||
commitAuthorized(
|
||||
mutationValue: AuthorizedModelProviderCredentialTransitionMutation,
|
||||
) {
|
||||
const mutation =
|
||||
normalizeAuthorizedModelProviderCredentialTransitionMutation(
|
||||
mutationValue,
|
||||
);
|
||||
return this.#enqueue(() => this.#commit(mutation.command, mutation));
|
||||
}
|
||||
|
||||
inspectAuthorized(
|
||||
inspectionValue: AuthorizedModelProviderCredentialInspection,
|
||||
): Promise<Readonly<ModelProviderCredentialTransition> | null> {
|
||||
const inspection =
|
||||
normalizeAuthorizedModelProviderCredentialInspection(inspectionValue);
|
||||
return this.#enqueue(() => {
|
||||
if (!this.#authorization) {
|
||||
throw new ModelProviderCredentialAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
this.#authority.client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const transition = this.#current(
|
||||
inspection.projectId,
|
||||
inspection.provider,
|
||||
);
|
||||
this.#authorization.confirm(
|
||||
Object.freeze({
|
||||
kind: 'inspection',
|
||||
value: inspection,
|
||||
replay: false,
|
||||
}),
|
||||
);
|
||||
this.#authority.client.exec('COMMIT');
|
||||
began = false;
|
||||
return transition;
|
||||
} finally {
|
||||
if (began && this.#authority.client.isTransaction) {
|
||||
try {
|
||||
this.#authority.client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original fail-closed error.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolveModelProviderCredentialBinding(
|
||||
lookupValue: Readonly<ModelProviderCredentialBindingLookup>,
|
||||
): Promise<Readonly<ModelProviderCredentialBinding> | null> {
|
||||
if (
|
||||
!lookupValue ||
|
||||
typeof lookupValue !== 'object' ||
|
||||
Array.isArray(lookupValue) ||
|
||||
Object.keys(lookupValue).sort().join('\0') !== 'projectId\0provider'
|
||||
) {
|
||||
return Promise.reject(
|
||||
new ModelProviderCredentialCatalogUnavailableError(),
|
||||
);
|
||||
}
|
||||
const projectId = identifier(lookupValue.projectId, 'projectId');
|
||||
const provider = identifier(lookupValue.provider, 'provider');
|
||||
return this.#enqueue(() => {
|
||||
const transition = this.#current(projectId, provider);
|
||||
return transition ? this.#binding(transition) : null;
|
||||
});
|
||||
}
|
||||
|
||||
record(
|
||||
recordValue: Readonly<ModelProviderCredentialAuditRecord>,
|
||||
): Promise<void> {
|
||||
const record = normalizeUseAudit(recordValue);
|
||||
return this.#enqueue(() => {
|
||||
let began = false;
|
||||
try {
|
||||
this.#authority.client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const transition = this.#current(record.projectId, record.provider);
|
||||
if (
|
||||
!transition ||
|
||||
transition.action !== 'bind' ||
|
||||
transition.activeBindingRevision !== record.bindingRevision ||
|
||||
`sha256:${transition.activeBindingDigest}` !== record.bindingDigest
|
||||
) {
|
||||
throw new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
const existing = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT audit_json AS "auditJson"
|
||||
FROM "ModelInvocationProviderCredentialAudits"
|
||||
WHERE operation = ? AND project_id = ? AND provider = ?
|
||||
AND request_id = ?`,
|
||||
)
|
||||
.get(
|
||||
record.operation,
|
||||
record.projectId,
|
||||
record.provider,
|
||||
record.requestId,
|
||||
) as Row | undefined;
|
||||
if (existing) {
|
||||
let stored: Readonly<ModelProviderCredentialAuditRecord>;
|
||||
try {
|
||||
stored = normalizeUseAudit(
|
||||
JSON.parse(
|
||||
text(existing, 'auditJson'),
|
||||
) as ModelProviderCredentialAuditRecord,
|
||||
);
|
||||
} catch {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
if (!sameUseAudit(stored, record)) {
|
||||
throw new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
} else {
|
||||
this.#authority.client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelInvocationProviderCredentialAudits" (
|
||||
operation, project_id, provider, request_id,
|
||||
binding_revision, binding_digest, occurred_at_ms, audit_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
record.operation,
|
||||
record.projectId,
|
||||
record.provider,
|
||||
record.requestId,
|
||||
record.bindingRevision,
|
||||
record.bindingDigest,
|
||||
record.occurredAtMs,
|
||||
canonical(record),
|
||||
);
|
||||
}
|
||||
this.#authority.client.exec('COMMIT');
|
||||
began = false;
|
||||
} finally {
|
||||
if (began && this.#authority.client.isTransaction) {
|
||||
try {
|
||||
this.#authority.client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original fail-closed error.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
export const MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_SCHEMA =
|
||||
'qinglong/model-provider-credential-test-allowlist@v1' as const;
|
||||
export const MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_SCHEMA =
|
||||
'qinglong/model-provider-credential-test-plan@v1' as const;
|
||||
export const MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTION_SCHEMA =
|
||||
'qinglong/model-provider-credential-test-execution@v1' as const;
|
||||
export const MODEL_PROVIDER_CREDENTIAL_TEST_RESULT_SCHEMA =
|
||||
'qinglong/model-provider-credential-test-result@v1' as const;
|
||||
export const MAX_MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_ENTRIES = 16;
|
||||
export const MAX_MODEL_PROVIDER_CREDENTIAL_TEST_DEADLINE_MS = 15_000;
|
||||
export const MAX_MODEL_PROVIDER_CREDENTIAL_TEST_RESPONSE_BYTES = 256 * 1_024;
|
||||
export const MAX_MODEL_PROVIDER_CREDENTIAL_TEST_MODELS = 256;
|
||||
export const MAX_MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_LIFETIME_MS =
|
||||
5 * 60 * 1_000;
|
||||
|
||||
export interface ModelProviderCredentialTestEndpointInput {
|
||||
readonly provider: string;
|
||||
readonly adapter: 'openai-compatible';
|
||||
readonly baseUrl: string;
|
||||
readonly revision: string;
|
||||
readonly deadlineMs: number;
|
||||
readonly maxResponseBytes: number;
|
||||
readonly maxModels: number;
|
||||
readonly maxCostMicrousd: 0;
|
||||
readonly retryLimit: 0;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialTestEndpoint
|
||||
extends ModelProviderCredentialTestEndpointInput {
|
||||
readonly configDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialTestAllowlist {
|
||||
readonly schema: typeof MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_SCHEMA;
|
||||
readonly revision: string;
|
||||
readonly providers: readonly Readonly<ModelProviderCredentialTestEndpoint>[];
|
||||
readonly catalogDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialTestPlan {
|
||||
readonly schema: typeof MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_SCHEMA;
|
||||
readonly testId: string;
|
||||
readonly requestId: string;
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
readonly endpoint: Readonly<ModelProviderCredentialTestEndpoint>;
|
||||
readonly requestedBy: Readonly<{ type: 'user'; id: string }>;
|
||||
readonly fence: Readonly<{
|
||||
projectVersion: number;
|
||||
bindingVersion: number;
|
||||
}>;
|
||||
readonly plannedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly planDigest: string;
|
||||
}
|
||||
|
||||
export interface CreateModelProviderCredentialTestPlanInput {
|
||||
readonly testId: string;
|
||||
readonly requestId: string;
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
readonly endpoint: Readonly<ModelProviderCredentialTestEndpoint>;
|
||||
readonly requestedBy: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly plannedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialTestExecution {
|
||||
readonly schema: typeof MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTION_SCHEMA;
|
||||
readonly executionId: string;
|
||||
readonly testId: string;
|
||||
readonly planDigest: string;
|
||||
readonly startedAtMs: number;
|
||||
readonly executionDigest: string;
|
||||
}
|
||||
|
||||
export type ModelProviderCredentialTestOutcome = 'reachable' | 'unreachable';
|
||||
|
||||
export interface ModelProviderCredentialTestResult {
|
||||
readonly schema: typeof MODEL_PROVIDER_CREDENTIAL_TEST_RESULT_SCHEMA;
|
||||
readonly executionId: string;
|
||||
readonly testId: string;
|
||||
readonly planDigest: string;
|
||||
readonly outcome: ModelProviderCredentialTestOutcome;
|
||||
readonly modelCount: number | null;
|
||||
readonly durationMs: number;
|
||||
readonly completedAtMs: number;
|
||||
readonly resultDigest: string;
|
||||
}
|
||||
|
||||
export class InvalidModelProviderCredentialTestConnectionError extends TypeError {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_TEST_CONNECTION_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Model provider credential test connection value is invalid');
|
||||
this.name = 'InvalidModelProviderCredentialTestConnectionError';
|
||||
}
|
||||
}
|
||||
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export const MODEL_PROVIDER_CREDENTIAL_TEST_CONTROL_PATTERN =
|
||||
/[\u0000-\u001f\u007f]/;
|
||||
|
||||
export function invalid(): never {
|
||||
throw new InvalidModelProviderCredentialTestConnectionError();
|
||||
}
|
||||
|
||||
export function exact(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const actual = Reflect.ownKeys(candidate);
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.some((key) => typeof key !== 'string') ||
|
||||
actual.length !== expected.length ||
|
||||
actual
|
||||
.map(String)
|
||||
.sort()
|
||||
.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(candidate);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
({ get, set, enumerable }) =>
|
||||
get !== undefined || set !== undefined || enumerable !== true,
|
||||
)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function identifier(value: unknown, request = false): string {
|
||||
const pattern = request ? REQUEST_ID_PATTERN : IDENTIFIER_PATTERN;
|
||||
if (typeof value !== 'string' || !pattern.test(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function uuid(value: unknown): string {
|
||||
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function integer(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function sha256(domain: string, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain, 'utf8')
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_ENTRIES,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_DEADLINE_MS,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_MODELS,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_RESPONSE_BYTES,
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_SCHEMA,
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_CONTROL_PATTERN,
|
||||
digest,
|
||||
exact,
|
||||
identifier,
|
||||
integer,
|
||||
invalid,
|
||||
sha256,
|
||||
type ModelProviderCredentialTestAllowlist,
|
||||
type ModelProviderCredentialTestEndpoint,
|
||||
type ModelProviderCredentialTestEndpointInput,
|
||||
} from './contractProtocol';
|
||||
|
||||
const ENDPOINT_DIGEST_DOMAIN =
|
||||
'qinglong/model-provider-credential-test-endpoint-digest@v1\0';
|
||||
const ALLOWLIST_DIGEST_DOMAIN =
|
||||
'qinglong/model-provider-credential-test-allowlist-digest@v1\0';
|
||||
|
||||
function canonicalBaseUrl(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
Buffer.byteLength(value, 'utf8') > 1_024 ||
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username !== '' ||
|
||||
url.password !== '' ||
|
||||
url.search !== '' ||
|
||||
url.hash !== '' ||
|
||||
url.hostname.length < 1 ||
|
||||
url.pathname.length < 1 ||
|
||||
!url.pathname.endsWith('/') ||
|
||||
url.toString() !== value
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function endpointSemantic(
|
||||
endpoint: Readonly<ModelProviderCredentialTestEndpointInput>,
|
||||
): Readonly<ModelProviderCredentialTestEndpointInput> {
|
||||
return Object.freeze({
|
||||
provider: endpoint.provider,
|
||||
adapter: endpoint.adapter,
|
||||
baseUrl: endpoint.baseUrl,
|
||||
revision: endpoint.revision,
|
||||
deadlineMs: endpoint.deadlineMs,
|
||||
maxResponseBytes: endpoint.maxResponseBytes,
|
||||
maxModels: endpoint.maxModels,
|
||||
maxCostMicrousd: 0,
|
||||
retryLimit: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function createModelProviderCredentialTestEndpoint(
|
||||
value: ModelProviderCredentialTestEndpointInput,
|
||||
): Readonly<ModelProviderCredentialTestEndpoint> {
|
||||
exact(value, [
|
||||
'adapter',
|
||||
'baseUrl',
|
||||
'deadlineMs',
|
||||
'maxCostMicrousd',
|
||||
'maxModels',
|
||||
'maxResponseBytes',
|
||||
'provider',
|
||||
'retryLimit',
|
||||
'revision',
|
||||
]);
|
||||
const semantic = endpointSemantic({
|
||||
provider: identifier(value.provider),
|
||||
adapter: value.adapter === 'openai-compatible' ? value.adapter : invalid(),
|
||||
baseUrl: canonicalBaseUrl(value.baseUrl),
|
||||
revision: identifier(value.revision),
|
||||
deadlineMs: integer(
|
||||
value.deadlineMs,
|
||||
1_000,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_DEADLINE_MS,
|
||||
),
|
||||
maxResponseBytes: integer(
|
||||
value.maxResponseBytes,
|
||||
1_024,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_RESPONSE_BYTES,
|
||||
),
|
||||
maxModels: integer(
|
||||
value.maxModels,
|
||||
1,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_MODELS,
|
||||
),
|
||||
maxCostMicrousd:
|
||||
value.maxCostMicrousd === 0 ? value.maxCostMicrousd : invalid(),
|
||||
retryLimit: value.retryLimit === 0 ? value.retryLimit : invalid(),
|
||||
});
|
||||
return Object.freeze({
|
||||
...semantic,
|
||||
configDigest: sha256(ENDPOINT_DIGEST_DOMAIN, semantic),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelProviderCredentialTestEndpoint(
|
||||
value: ModelProviderCredentialTestEndpoint,
|
||||
): Readonly<ModelProviderCredentialTestEndpoint> {
|
||||
exact(value, [
|
||||
'adapter',
|
||||
'baseUrl',
|
||||
'configDigest',
|
||||
'deadlineMs',
|
||||
'maxCostMicrousd',
|
||||
'maxModels',
|
||||
'maxResponseBytes',
|
||||
'provider',
|
||||
'retryLimit',
|
||||
'revision',
|
||||
]);
|
||||
const normalized = createModelProviderCredentialTestEndpoint({
|
||||
provider: value.provider,
|
||||
adapter: value.adapter,
|
||||
baseUrl: value.baseUrl,
|
||||
revision: value.revision,
|
||||
deadlineMs: value.deadlineMs,
|
||||
maxResponseBytes: value.maxResponseBytes,
|
||||
maxModels: value.maxModels,
|
||||
maxCostMicrousd: value.maxCostMicrousd,
|
||||
retryLimit: value.retryLimit,
|
||||
});
|
||||
if (digest(value.configDigest) !== normalized.configDigest) invalid();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function allowlistSemantic(
|
||||
revision: string,
|
||||
providers: readonly Readonly<ModelProviderCredentialTestEndpoint>[],
|
||||
) {
|
||||
return Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_SCHEMA,
|
||||
revision,
|
||||
providers,
|
||||
});
|
||||
}
|
||||
|
||||
export function createModelProviderCredentialTestAllowlist(
|
||||
value: Readonly<{
|
||||
revision: string;
|
||||
providers: readonly ModelProviderCredentialTestEndpointInput[];
|
||||
}>,
|
||||
): Readonly<ModelProviderCredentialTestAllowlist> {
|
||||
exact(value, ['providers', 'revision']);
|
||||
if (
|
||||
!Array.isArray(value.providers) ||
|
||||
value.providers.length < 1 ||
|
||||
value.providers.length >
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_ENTRIES
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const providers = Object.freeze(
|
||||
value.providers
|
||||
.map(createModelProviderCredentialTestEndpoint)
|
||||
.sort((left, right) => left.provider.localeCompare(right.provider)),
|
||||
);
|
||||
if (
|
||||
providers.some(
|
||||
(provider, index) =>
|
||||
index > 0 && providers[index - 1]?.provider === provider.provider,
|
||||
)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const semantic = allowlistSemantic(identifier(value.revision), providers);
|
||||
return Object.freeze({
|
||||
...semantic,
|
||||
catalogDigest: sha256(ALLOWLIST_DIGEST_DOMAIN, semantic),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelProviderCredentialTestAllowlist(
|
||||
value: ModelProviderCredentialTestAllowlist,
|
||||
): Readonly<ModelProviderCredentialTestAllowlist> {
|
||||
exact(value, ['catalogDigest', 'providers', 'revision', 'schema']);
|
||||
if (
|
||||
value.schema !== MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_SCHEMA ||
|
||||
!Array.isArray(value.providers) ||
|
||||
value.providers.length < 1 ||
|
||||
value.providers.length >
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_ENTRIES
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const providers = Object.freeze(
|
||||
value.providers.map(normalizeModelProviderCredentialTestEndpoint),
|
||||
);
|
||||
if (
|
||||
providers.some(
|
||||
(provider, index) =>
|
||||
(index > 0 &&
|
||||
providers[index - 1]!.provider.localeCompare(provider.provider) >=
|
||||
0) ||
|
||||
provider.configDigest !== value.providers[index]?.configDigest,
|
||||
)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const semantic = allowlistSemantic(identifier(value.revision), providers);
|
||||
if (
|
||||
digest(value.catalogDigest) !== sha256(ALLOWLIST_DIGEST_DOMAIN, semantic)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze({ ...semantic, catalogDigest: value.catalogDigest });
|
||||
}
|
||||
|
||||
export function resolveModelProviderCredentialTestEndpoint(
|
||||
allowlistValue: ModelProviderCredentialTestAllowlist,
|
||||
providerValue: string,
|
||||
): Readonly<ModelProviderCredentialTestEndpoint> {
|
||||
const allowlist =
|
||||
normalizeModelProviderCredentialTestAllowlist(allowlistValue);
|
||||
const provider = identifier(providerValue);
|
||||
const endpoint = allowlist.providers.find(
|
||||
(candidate) => candidate.provider === provider,
|
||||
);
|
||||
if (!endpoint) invalid();
|
||||
return endpoint;
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_DEADLINE_MS,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_MODELS,
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTION_SCHEMA,
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_RESULT_SCHEMA,
|
||||
digest,
|
||||
exact,
|
||||
integer,
|
||||
invalid,
|
||||
sha256,
|
||||
uuid,
|
||||
type ModelProviderCredentialTestExecution,
|
||||
type ModelProviderCredentialTestOutcome,
|
||||
type ModelProviderCredentialTestResult,
|
||||
} from './contractProtocol';
|
||||
|
||||
const EXECUTION_DIGEST_DOMAIN =
|
||||
'qinglong/model-provider-credential-test-execution-digest@v1\0';
|
||||
const RESULT_DIGEST_DOMAIN =
|
||||
'qinglong/model-provider-credential-test-result-digest@v1\0';
|
||||
|
||||
function executionSemantic(
|
||||
value: Omit<ModelProviderCredentialTestExecution, 'executionDigest'>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTION_SCHEMA,
|
||||
executionId: value.executionId,
|
||||
testId: value.testId,
|
||||
planDigest: value.planDigest,
|
||||
startedAtMs: value.startedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createModelProviderCredentialTestExecution(
|
||||
value: Readonly<{
|
||||
executionId: string;
|
||||
testId: string;
|
||||
planDigest: string;
|
||||
startedAtMs: number;
|
||||
}>,
|
||||
): Readonly<ModelProviderCredentialTestExecution> {
|
||||
exact(value, ['executionId', 'planDigest', 'startedAtMs', 'testId']);
|
||||
const semantic = executionSemantic({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTION_SCHEMA,
|
||||
executionId: uuid(value.executionId),
|
||||
testId: uuid(value.testId),
|
||||
planDigest: digest(value.planDigest),
|
||||
startedAtMs: integer(value.startedAtMs, 0, Number.MAX_SAFE_INTEGER),
|
||||
});
|
||||
return Object.freeze({
|
||||
...semantic,
|
||||
executionDigest: sha256(EXECUTION_DIGEST_DOMAIN, semantic),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelProviderCredentialTestExecution(
|
||||
value: ModelProviderCredentialTestExecution,
|
||||
): Readonly<ModelProviderCredentialTestExecution> {
|
||||
exact(value, [
|
||||
'executionDigest',
|
||||
'executionId',
|
||||
'planDigest',
|
||||
'schema',
|
||||
'startedAtMs',
|
||||
'testId',
|
||||
]);
|
||||
if (value.schema !== MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTION_SCHEMA) {
|
||||
invalid();
|
||||
}
|
||||
const normalized = createModelProviderCredentialTestExecution({
|
||||
executionId: value.executionId,
|
||||
testId: value.testId,
|
||||
planDigest: value.planDigest,
|
||||
startedAtMs: value.startedAtMs,
|
||||
});
|
||||
if (digest(value.executionDigest) !== normalized.executionDigest) invalid();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resultSemantic(
|
||||
value: Omit<ModelProviderCredentialTestResult, 'resultDigest'>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TEST_RESULT_SCHEMA,
|
||||
executionId: value.executionId,
|
||||
testId: value.testId,
|
||||
planDigest: value.planDigest,
|
||||
outcome: value.outcome,
|
||||
modelCount: value.modelCount,
|
||||
durationMs: value.durationMs,
|
||||
completedAtMs: value.completedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createModelProviderCredentialTestResult(
|
||||
value: Readonly<{
|
||||
executionId: string;
|
||||
testId: string;
|
||||
planDigest: string;
|
||||
outcome: ModelProviderCredentialTestOutcome;
|
||||
modelCount: number | null;
|
||||
durationMs: number;
|
||||
completedAtMs: number;
|
||||
}>,
|
||||
): Readonly<ModelProviderCredentialTestResult> {
|
||||
exact(value, [
|
||||
'completedAtMs',
|
||||
'durationMs',
|
||||
'executionId',
|
||||
'modelCount',
|
||||
'outcome',
|
||||
'planDigest',
|
||||
'testId',
|
||||
]);
|
||||
const outcome =
|
||||
value.outcome === 'reachable' || value.outcome === 'unreachable'
|
||||
? value.outcome
|
||||
: invalid();
|
||||
const modelCount =
|
||||
value.modelCount === null
|
||||
? null
|
||||
: integer(value.modelCount, 0, MAX_MODEL_PROVIDER_CREDENTIAL_TEST_MODELS);
|
||||
if (
|
||||
(outcome === 'reachable' && modelCount === null) ||
|
||||
(outcome === 'unreachable' && modelCount !== null)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const semantic = resultSemantic({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TEST_RESULT_SCHEMA,
|
||||
executionId: uuid(value.executionId),
|
||||
testId: uuid(value.testId),
|
||||
planDigest: digest(value.planDigest),
|
||||
outcome,
|
||||
modelCount,
|
||||
durationMs: integer(
|
||||
value.durationMs,
|
||||
0,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_DEADLINE_MS,
|
||||
),
|
||||
completedAtMs: integer(value.completedAtMs, 0, Number.MAX_SAFE_INTEGER),
|
||||
});
|
||||
return Object.freeze({
|
||||
...semantic,
|
||||
resultDigest: sha256(RESULT_DIGEST_DOMAIN, semantic),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelProviderCredentialTestResult(
|
||||
value: ModelProviderCredentialTestResult,
|
||||
): Readonly<ModelProviderCredentialTestResult> {
|
||||
exact(value, [
|
||||
'completedAtMs',
|
||||
'durationMs',
|
||||
'executionId',
|
||||
'modelCount',
|
||||
'outcome',
|
||||
'planDigest',
|
||||
'resultDigest',
|
||||
'schema',
|
||||
'testId',
|
||||
]);
|
||||
if (value.schema !== MODEL_PROVIDER_CREDENTIAL_TEST_RESULT_SCHEMA) invalid();
|
||||
const normalized = createModelProviderCredentialTestResult({
|
||||
executionId: value.executionId,
|
||||
testId: value.testId,
|
||||
planDigest: value.planDigest,
|
||||
outcome: value.outcome,
|
||||
modelCount: value.modelCount,
|
||||
durationMs: value.durationMs,
|
||||
completedAtMs: value.completedAtMs,
|
||||
});
|
||||
if (digest(value.resultDigest) !== normalized.resultDigest) invalid();
|
||||
return normalized;
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import {
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_LIFETIME_MS,
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_CONTROL_PATTERN,
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_SCHEMA,
|
||||
digest,
|
||||
exact,
|
||||
identifier,
|
||||
integer,
|
||||
invalid,
|
||||
sha256,
|
||||
uuid,
|
||||
type CreateModelProviderCredentialTestPlanInput,
|
||||
type ModelProviderCredentialTestPlan,
|
||||
} from './contractProtocol';
|
||||
import { normalizeModelProviderCredentialTestEndpoint } from './endpointAllowlistProtocol';
|
||||
|
||||
const PLAN_DIGEST_DOMAIN =
|
||||
'qinglong/model-provider-credential-test-plan-digest@v1\0';
|
||||
|
||||
function user(value: SecuritySubject): Readonly<{ type: 'user'; id: string }> {
|
||||
exact(value, ['id', 'type']);
|
||||
if (
|
||||
value.type !== 'user' ||
|
||||
typeof value.id !== 'string' ||
|
||||
value.id.length < 1 ||
|
||||
Buffer.byteLength(value.id, 'utf8') > 128 ||
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_CONTROL_PATTERN.test(value.id)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze({ type: 'user' as const, id: value.id });
|
||||
}
|
||||
|
||||
function fence(value: SecurityPolicyFence): Readonly<{
|
||||
projectVersion: number;
|
||||
bindingVersion: number;
|
||||
}> {
|
||||
exact(value, ['bindingVersion', 'projectVersion']);
|
||||
if (value.bindingVersion === null) invalid();
|
||||
return Object.freeze({
|
||||
projectVersion: integer(value.projectVersion, 1, 2_147_483_647),
|
||||
bindingVersion: integer(value.bindingVersion, 1, 2_147_483_647),
|
||||
});
|
||||
}
|
||||
|
||||
function planSemantic(
|
||||
input: Omit<ModelProviderCredentialTestPlan, 'planDigest'>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_SCHEMA,
|
||||
testId: input.testId,
|
||||
requestId: input.requestId,
|
||||
projectId: input.projectId,
|
||||
provider: input.provider,
|
||||
endpoint: input.endpoint,
|
||||
requestedBy: input.requestedBy,
|
||||
fence: input.fence,
|
||||
plannedAtMs: input.plannedAtMs,
|
||||
expiresAtMs: input.expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createModelProviderCredentialTestPlan(
|
||||
value: CreateModelProviderCredentialTestPlanInput,
|
||||
): Readonly<ModelProviderCredentialTestPlan> {
|
||||
exact(value, [
|
||||
'endpoint',
|
||||
'expiresAtMs',
|
||||
'fence',
|
||||
'plannedAtMs',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestId',
|
||||
'requestedBy',
|
||||
'testId',
|
||||
]);
|
||||
const plannedAtMs = integer(value.plannedAtMs, 0, Number.MAX_SAFE_INTEGER);
|
||||
const expiresAtMs = integer(value.expiresAtMs, 0, Number.MAX_SAFE_INTEGER);
|
||||
const endpoint = normalizeModelProviderCredentialTestEndpoint(value.endpoint);
|
||||
const provider = identifier(value.provider);
|
||||
if (
|
||||
endpoint.provider !== provider ||
|
||||
expiresAtMs <= plannedAtMs ||
|
||||
expiresAtMs - plannedAtMs >
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_LIFETIME_MS
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const semantic = planSemantic({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_SCHEMA,
|
||||
testId: uuid(value.testId),
|
||||
requestId: identifier(value.requestId, true),
|
||||
projectId: identifier(value.projectId),
|
||||
provider,
|
||||
endpoint,
|
||||
requestedBy: user(value.requestedBy),
|
||||
fence: fence(value.fence),
|
||||
plannedAtMs,
|
||||
expiresAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
...semantic,
|
||||
planDigest: sha256(PLAN_DIGEST_DOMAIN, semantic),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelProviderCredentialTestPlan(
|
||||
value: ModelProviderCredentialTestPlan,
|
||||
): Readonly<ModelProviderCredentialTestPlan> {
|
||||
exact(value, [
|
||||
'endpoint',
|
||||
'expiresAtMs',
|
||||
'fence',
|
||||
'planDigest',
|
||||
'plannedAtMs',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestId',
|
||||
'requestedBy',
|
||||
'schema',
|
||||
'testId',
|
||||
]);
|
||||
if (value.schema !== MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_SCHEMA) invalid();
|
||||
const normalized = createModelProviderCredentialTestPlan({
|
||||
testId: value.testId,
|
||||
requestId: value.requestId,
|
||||
projectId: value.projectId,
|
||||
provider: value.provider,
|
||||
endpoint: value.endpoint,
|
||||
requestedBy: value.requestedBy,
|
||||
fence: value.fence,
|
||||
plannedAtMs: value.plannedAtMs,
|
||||
expiresAtMs: value.expiresAtMs,
|
||||
});
|
||||
if (digest(value.planDigest) !== normalized.planDigest) invalid();
|
||||
return normalized;
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import { normalizeProjectPolicySubject } from '@qinglong/runtime-core/project-policy';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import {
|
||||
normalizeModelProviderCredentialTransitionCommand,
|
||||
type CommitModelProviderCredentialTransitionResult,
|
||||
type ModelProviderCredentialCatalogRepository,
|
||||
type ModelProviderCredentialTransition,
|
||||
type ModelProviderCredentialTransitionCommand,
|
||||
} from './modelProviderCredentialCatalog';
|
||||
|
||||
export interface AuthorizedModelProviderCredentialTransitionMutation {
|
||||
readonly command: ModelProviderCredentialTransitionCommand;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialAdministrationRepository
|
||||
extends ModelProviderCredentialCatalogRepository {
|
||||
commitAuthorized(
|
||||
mutation: AuthorizedModelProviderCredentialTransitionMutation,
|
||||
): Promise<Readonly<CommitModelProviderCredentialTransitionResult>>;
|
||||
}
|
||||
|
||||
export const MODEL_PROVIDER_CREDENTIAL_INSPECTION_OPERATION_ID =
|
||||
'model_provider_credential.inspect' as const;
|
||||
|
||||
export interface AuthorizedModelProviderCredentialInspection {
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialAdministrationInspectionRepository {
|
||||
inspectAuthorized(
|
||||
inspection: AuthorizedModelProviderCredentialInspection,
|
||||
): Promise<Readonly<ModelProviderCredentialTransition> | null>;
|
||||
}
|
||||
|
||||
export class InvalidModelProviderCredentialAdministrationMutationError extends TypeError {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_ADMINISTRATION_MUTATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Model provider credential administration mutation is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'InvalidModelProviderCredentialAdministrationMutationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialAdministrationAuthorizationFenceConflictError extends Error {
|
||||
readonly code =
|
||||
'MODEL_PROVIDER_CREDENTIAL_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Model provider credential administration authorization fence changed',
|
||||
);
|
||||
this.name =
|
||||
'ModelProviderCredentialAdministrationAuthorizationFenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialAdministrationMutationConflictError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_ADMINISTRATION_MUTATION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Model provider credential administration mutation conflicts with durable state',
|
||||
);
|
||||
this.name = 'ModelProviderCredentialAdministrationMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
throw new InvalidModelProviderCredentialAdministrationMutationError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeFence(
|
||||
value: SecurityPolicyFence,
|
||||
): Readonly<SecurityPolicyFence> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['bindingVersion', 'projectVersion']) ||
|
||||
!Number.isSafeInteger(value.projectVersion) ||
|
||||
value.projectVersion < 1 ||
|
||||
!Number.isSafeInteger(value.bindingVersion) ||
|
||||
(value.bindingVersion as number) < 1
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialAdministrationMutationError(
|
||||
'authorization fence is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
projectVersion: value.projectVersion,
|
||||
bindingVersion: value.bindingVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export function modelProviderCredentialAdministrationOperationId(
|
||||
action: ModelProviderCredentialTransitionCommand['action'],
|
||||
): 'model_provider_credential.bind' | 'model_provider_credential.revoke' {
|
||||
if (action === 'bind') return 'model_provider_credential.bind';
|
||||
if (action === 'revoke') return 'model_provider_credential.revoke';
|
||||
throw new InvalidModelProviderCredentialAdministrationMutationError(
|
||||
'action is invalid',
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAuthorizedModelProviderCredentialTransitionMutation(
|
||||
value: AuthorizedModelProviderCredentialTransitionMutation,
|
||||
): Readonly<AuthorizedModelProviderCredentialTransitionMutation> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['actor', 'audit', 'command', 'fence'])
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialAdministrationMutationError(
|
||||
'mutation shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const command = normalizeModelProviderCredentialTransitionCommand(
|
||||
value.command,
|
||||
);
|
||||
const actor = normalizeProjectPolicySubject(value.actor);
|
||||
const fence = normalizeFence(value.fence);
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
if (
|
||||
command.changedBy.type !== 'user' ||
|
||||
!sameSubject(command.changedBy, actor) ||
|
||||
actor.type !== 'user' ||
|
||||
audit.eventId !== command.mutationId ||
|
||||
audit.operationId !==
|
||||
modelProviderCredentialAdministrationOperationId(command.action) ||
|
||||
audit.projectId !== command.projectId ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!audit.subject ||
|
||||
!sameSubject(audit.subject, actor) ||
|
||||
audit.authenticationId === null ||
|
||||
!audit.fence ||
|
||||
audit.fence.projectVersion !== fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== fence.bindingVersion
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialAdministrationMutationError(
|
||||
'audit binding is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ command, actor, fence, audit });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidModelProviderCredentialAdministrationMutationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidModelProviderCredentialAdministrationMutationError(
|
||||
'mutation value is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAuthorizedModelProviderCredentialInspection(
|
||||
value: AuthorizedModelProviderCredentialInspection,
|
||||
): Readonly<AuthorizedModelProviderCredentialInspection> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['actor', 'audit', 'fence', 'projectId', 'provider'])
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialAdministrationMutationError(
|
||||
'inspection shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const projectId = identity(value.projectId, 'projectId');
|
||||
const provider = identity(value.provider, 'provider');
|
||||
const actor = normalizeProjectPolicySubject(value.actor);
|
||||
const fence = normalizeFence(value.fence);
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
if (
|
||||
actor.type !== 'user' ||
|
||||
audit.operationId !== MODEL_PROVIDER_CREDENTIAL_INSPECTION_OPERATION_ID ||
|
||||
audit.projectId !== projectId ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!audit.subject ||
|
||||
!sameSubject(audit.subject, actor) ||
|
||||
audit.authenticationId === null ||
|
||||
!audit.fence ||
|
||||
audit.fence.projectVersion !== fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== fence.bindingVersion
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialAdministrationMutationError(
|
||||
'inspection audit binding is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ projectId, provider, actor, fence, audit });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidModelProviderCredentialAdministrationMutationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidModelProviderCredentialAdministrationMutationError(
|
||||
'inspection value is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
SECURITY_SUBJECT_TYPES,
|
||||
type SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import {
|
||||
MODEL_PROVIDER_CREDENTIAL_BINDING_SCHEMA,
|
||||
digestModelProviderCredentialBinding,
|
||||
normalizeModelProviderCredentialBinding,
|
||||
type ModelProviderCredentialBinding,
|
||||
} from './providerCredential';
|
||||
|
||||
export const MODEL_PROVIDER_CREDENTIAL_TRANSITION_COMMAND_SCHEMA =
|
||||
'qinglong/model-provider-credential-transition-command@v1';
|
||||
export const MODEL_PROVIDER_CREDENTIAL_TRANSITION_SCHEMA =
|
||||
'qinglong/model-provider-credential-transition@v1';
|
||||
|
||||
export interface ModelProviderCredentialTransitionCommand {
|
||||
readonly schema: typeof MODEL_PROVIDER_CREDENTIAL_TRANSITION_COMMAND_SCHEMA;
|
||||
readonly mutationId: string;
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly action: 'bind' | 'revoke';
|
||||
readonly binding: Readonly<ModelProviderCredentialBinding> | null;
|
||||
readonly changedBy: Readonly<SecuritySubject>;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialTransition {
|
||||
readonly schema: typeof MODEL_PROVIDER_CREDENTIAL_TRANSITION_SCHEMA;
|
||||
readonly mutationId: string;
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
readonly generation: number;
|
||||
readonly action: 'bind' | 'revoke';
|
||||
readonly activeBindingRevision: string | null;
|
||||
readonly activeBindingDigest: string | null;
|
||||
readonly previousTransitionDigest: string | null;
|
||||
readonly changedBy: Readonly<SecuritySubject>;
|
||||
readonly changedAtMs: number;
|
||||
readonly commandDigest: string;
|
||||
readonly transitionDigest: string;
|
||||
}
|
||||
|
||||
export interface CommitModelProviderCredentialTransitionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly transition: Readonly<ModelProviderCredentialTransition>;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialCatalogRepository {
|
||||
findCurrentTransition(
|
||||
projectId: string,
|
||||
provider: string,
|
||||
): Promise<Readonly<ModelProviderCredentialTransition> | null>;
|
||||
commit(
|
||||
command: Readonly<ModelProviderCredentialTransitionCommand>,
|
||||
): Promise<Readonly<CommitModelProviderCredentialTransitionResult>>;
|
||||
}
|
||||
|
||||
export class InvalidModelProviderCredentialTransitionError extends TypeError {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_TRANSITION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Model provider credential transition is invalid: ${message}`);
|
||||
this.name = 'InvalidModelProviderCredentialTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTransitionConflictError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_TRANSITION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'The model provider credential transition conflicts with durable state',
|
||||
);
|
||||
this.name = 'ModelProviderCredentialTransitionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialCatalogUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_CATALOG_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('The model provider credential catalog is unavailable', options);
|
||||
this.name = 'ModelProviderCredentialCatalogUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const PROVIDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !== [...expectedKeys].sort().join('\0')
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function providerIdentity(value: unknown): string {
|
||||
if (typeof value !== 'string' || !PROVIDER_PATTERN.test(value)) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'provider is invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function generation(value: unknown, minimum: number): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'generation is invalid',
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'changedAtMs is invalid',
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function nullableDigest(value: unknown, label: string): string | null {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function subject(value: unknown): Readonly<SecuritySubject> {
|
||||
const candidate = exactObject(value, ['id', 'type'], 'changedBy');
|
||||
if (
|
||||
!SECURITY_SUBJECT_TYPES.includes(candidate.type as SecuritySubject['type'])
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'changedBy type is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
type: candidate.type as SecuritySubject['type'],
|
||||
id: identity(candidate.id, 'changedBy id'),
|
||||
});
|
||||
}
|
||||
|
||||
function digest(label: string, value: object): string {
|
||||
return createHash('sha256')
|
||||
.update(label, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function commandDigestInput(
|
||||
command: Omit<ModelProviderCredentialTransitionCommand, 'commandDigest'>,
|
||||
): object {
|
||||
return {
|
||||
schema: command.schema,
|
||||
mutationId: command.mutationId,
|
||||
projectId: command.projectId,
|
||||
provider: command.provider,
|
||||
expectedGeneration: command.expectedGeneration,
|
||||
action: command.action,
|
||||
binding: command.binding,
|
||||
changedBy: command.changedBy,
|
||||
};
|
||||
}
|
||||
|
||||
export function createModelProviderCredentialTransitionCommand(
|
||||
value: Omit<ModelProviderCredentialTransitionCommand, 'commandDigest'>,
|
||||
): Readonly<ModelProviderCredentialTransitionCommand> {
|
||||
const candidate = exactObject(
|
||||
value,
|
||||
[
|
||||
'action',
|
||||
'binding',
|
||||
'changedBy',
|
||||
'expectedGeneration',
|
||||
'mutationId',
|
||||
'projectId',
|
||||
'provider',
|
||||
'schema',
|
||||
],
|
||||
'command',
|
||||
);
|
||||
if (
|
||||
candidate.schema !== MODEL_PROVIDER_CREDENTIAL_TRANSITION_COMMAND_SCHEMA
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'command schema is invalid',
|
||||
);
|
||||
}
|
||||
const projectId = identity(candidate.projectId, 'projectId');
|
||||
const provider = providerIdentity(candidate.provider);
|
||||
const action = candidate.action;
|
||||
if (action !== 'bind' && action !== 'revoke') {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'action is invalid',
|
||||
);
|
||||
}
|
||||
let binding: Readonly<ModelProviderCredentialBinding> | null = null;
|
||||
if (action === 'bind') {
|
||||
if (!candidate.binding) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'bind requires a binding',
|
||||
);
|
||||
}
|
||||
try {
|
||||
binding = normalizeModelProviderCredentialBinding(
|
||||
candidate.binding as ModelProviderCredentialBinding,
|
||||
);
|
||||
} catch {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'binding is invalid',
|
||||
);
|
||||
}
|
||||
if (binding.projectId !== projectId || binding.provider !== provider) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'binding identity does not match the command',
|
||||
);
|
||||
}
|
||||
} else if (candidate.binding !== null) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'revoke cannot contain a binding',
|
||||
);
|
||||
}
|
||||
const command = Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TRANSITION_COMMAND_SCHEMA,
|
||||
mutationId: identity(candidate.mutationId, 'mutationId'),
|
||||
projectId,
|
||||
provider,
|
||||
expectedGeneration: generation(candidate.expectedGeneration, 0),
|
||||
action,
|
||||
binding,
|
||||
changedBy: subject(candidate.changedBy),
|
||||
});
|
||||
return Object.freeze({
|
||||
...command,
|
||||
commandDigest: digest(
|
||||
'qinglong/model-provider-credential-transition-command@v1',
|
||||
commandDigestInput(command),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelProviderCredentialTransitionCommand(
|
||||
value: ModelProviderCredentialTransitionCommand,
|
||||
): Readonly<ModelProviderCredentialTransitionCommand> {
|
||||
const candidate = exactObject(
|
||||
value,
|
||||
[
|
||||
'action',
|
||||
'binding',
|
||||
'changedBy',
|
||||
'commandDigest',
|
||||
'expectedGeneration',
|
||||
'mutationId',
|
||||
'projectId',
|
||||
'provider',
|
||||
'schema',
|
||||
],
|
||||
'command',
|
||||
);
|
||||
const normalized = createModelProviderCredentialTransitionCommand({
|
||||
schema:
|
||||
candidate.schema as typeof MODEL_PROVIDER_CREDENTIAL_TRANSITION_COMMAND_SCHEMA,
|
||||
mutationId: candidate.mutationId as string,
|
||||
projectId: candidate.projectId as string,
|
||||
provider: candidate.provider as string,
|
||||
expectedGeneration: candidate.expectedGeneration as number,
|
||||
action: candidate.action as 'bind' | 'revoke',
|
||||
binding: candidate.binding as ModelProviderCredentialBinding | null,
|
||||
changedBy: candidate.changedBy as SecuritySubject,
|
||||
});
|
||||
if (candidate.commandDigest !== normalized.commandDigest) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'commandDigest is invalid',
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function transitionDigestInput(
|
||||
transition: Omit<ModelProviderCredentialTransition, 'transitionDigest'>,
|
||||
): object {
|
||||
return {
|
||||
schema: transition.schema,
|
||||
mutationId: transition.mutationId,
|
||||
projectId: transition.projectId,
|
||||
provider: transition.provider,
|
||||
generation: transition.generation,
|
||||
action: transition.action,
|
||||
activeBindingRevision: transition.activeBindingRevision,
|
||||
activeBindingDigest: transition.activeBindingDigest,
|
||||
previousTransitionDigest: transition.previousTransitionDigest,
|
||||
changedBy: transition.changedBy,
|
||||
changedAtMs: transition.changedAtMs,
|
||||
commandDigest: transition.commandDigest,
|
||||
};
|
||||
}
|
||||
|
||||
export function createModelProviderCredentialTransition(
|
||||
commandValue: ModelProviderCredentialTransitionCommand,
|
||||
previous: Readonly<ModelProviderCredentialTransition> | null,
|
||||
changedAtMs: number,
|
||||
): Readonly<ModelProviderCredentialTransition> {
|
||||
const command =
|
||||
normalizeModelProviderCredentialTransitionCommand(commandValue);
|
||||
const currentGeneration = previous?.generation ?? 0;
|
||||
if (
|
||||
command.expectedGeneration !== currentGeneration ||
|
||||
(previous !== null &&
|
||||
(previous.projectId !== command.projectId ||
|
||||
previous.provider !== command.provider))
|
||||
) {
|
||||
throw new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
const transition = Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TRANSITION_SCHEMA,
|
||||
mutationId: command.mutationId,
|
||||
projectId: command.projectId,
|
||||
provider: command.provider,
|
||||
generation: currentGeneration + 1,
|
||||
action: command.action,
|
||||
activeBindingRevision: command.binding?.revision ?? null,
|
||||
activeBindingDigest:
|
||||
command.binding === null
|
||||
? null
|
||||
: digestModelProviderCredentialBinding(command.binding).slice(7),
|
||||
previousTransitionDigest: previous?.transitionDigest ?? null,
|
||||
changedBy: command.changedBy,
|
||||
changedAtMs: timestamp(changedAtMs),
|
||||
commandDigest: command.commandDigest,
|
||||
});
|
||||
return Object.freeze({
|
||||
...transition,
|
||||
transitionDigest: digest(
|
||||
'qinglong/model-provider-credential-transition@v1',
|
||||
transitionDigestInput(transition),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelProviderCredentialTransition(
|
||||
value: ModelProviderCredentialTransition,
|
||||
): Readonly<ModelProviderCredentialTransition> {
|
||||
const candidate = exactObject(
|
||||
value,
|
||||
[
|
||||
'action',
|
||||
'activeBindingDigest',
|
||||
'activeBindingRevision',
|
||||
'changedAtMs',
|
||||
'changedBy',
|
||||
'commandDigest',
|
||||
'generation',
|
||||
'mutationId',
|
||||
'previousTransitionDigest',
|
||||
'projectId',
|
||||
'provider',
|
||||
'schema',
|
||||
'transitionDigest',
|
||||
],
|
||||
'transition',
|
||||
);
|
||||
if (candidate.schema !== MODEL_PROVIDER_CREDENTIAL_TRANSITION_SCHEMA) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'transition schema is invalid',
|
||||
);
|
||||
}
|
||||
const action = candidate.action;
|
||||
if (action !== 'bind' && action !== 'revoke') {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'transition action is invalid',
|
||||
);
|
||||
}
|
||||
const activeBindingRevision =
|
||||
candidate.activeBindingRevision === null
|
||||
? null
|
||||
: identity(candidate.activeBindingRevision, 'active binding revision');
|
||||
const activeBindingDigest = nullableDigest(
|
||||
candidate.activeBindingDigest,
|
||||
'active binding digest',
|
||||
);
|
||||
if (
|
||||
(action === 'bind' &&
|
||||
(activeBindingRevision === null || activeBindingDigest === null)) ||
|
||||
(action === 'revoke' &&
|
||||
(activeBindingRevision !== null || activeBindingDigest !== null))
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'transition binding state is invalid',
|
||||
);
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_TRANSITION_SCHEMA,
|
||||
mutationId: identity(candidate.mutationId, 'mutationId'),
|
||||
projectId: identity(candidate.projectId, 'projectId'),
|
||||
provider: providerIdentity(candidate.provider),
|
||||
generation: generation(candidate.generation, 1),
|
||||
action,
|
||||
activeBindingRevision,
|
||||
activeBindingDigest,
|
||||
previousTransitionDigest: nullableDigest(
|
||||
candidate.previousTransitionDigest,
|
||||
'previous transition digest',
|
||||
),
|
||||
changedBy: subject(candidate.changedBy),
|
||||
changedAtMs: timestamp(candidate.changedAtMs),
|
||||
commandDigest: nullableDigest(candidate.commandDigest, 'command digest')!,
|
||||
});
|
||||
const expectedDigest = digest(
|
||||
'qinglong/model-provider-credential-transition@v1',
|
||||
transitionDigestInput(normalized),
|
||||
);
|
||||
if (candidate.transitionDigest !== expectedDigest) {
|
||||
throw new InvalidModelProviderCredentialTransitionError(
|
||||
'transitionDigest is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...normalized, transitionDigest: expectedDigest });
|
||||
}
|
||||
|
||||
export function modelProviderCredentialBindingForTransition(
|
||||
transitionValue: ModelProviderCredentialTransition,
|
||||
bindingValue: ModelProviderCredentialBinding | null,
|
||||
): Readonly<ModelProviderCredentialBinding> | null {
|
||||
const transition =
|
||||
normalizeModelProviderCredentialTransition(transitionValue);
|
||||
if (transition.action === 'revoke') {
|
||||
if (bindingValue !== null) {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (bindingValue === null) {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
const binding = normalizeModelProviderCredentialBinding(bindingValue);
|
||||
if (
|
||||
binding.schema !== MODEL_PROVIDER_CREDENTIAL_BINDING_SCHEMA ||
|
||||
binding.projectId !== transition.projectId ||
|
||||
binding.provider !== transition.provider ||
|
||||
binding.revision !== transition.activeBindingRevision ||
|
||||
digestModelProviderCredentialBinding(binding).slice(7) !==
|
||||
transition.activeBindingDigest
|
||||
) {
|
||||
throw new ModelProviderCredentialCatalogUnavailableError();
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
// Stable Model Provider Credential Test Connection facade.
|
||||
export {
|
||||
InvalidModelProviderCredentialTestConnectionError,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_ENTRIES,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_DEADLINE_MS,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_MODELS,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_LIFETIME_MS,
|
||||
MAX_MODEL_PROVIDER_CREDENTIAL_TEST_RESPONSE_BYTES,
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_ALLOWLIST_SCHEMA,
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTION_SCHEMA,
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_SCHEMA,
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_RESULT_SCHEMA,
|
||||
type CreateModelProviderCredentialTestPlanInput,
|
||||
type ModelProviderCredentialTestAllowlist,
|
||||
type ModelProviderCredentialTestEndpoint,
|
||||
type ModelProviderCredentialTestEndpointInput,
|
||||
type ModelProviderCredentialTestExecution,
|
||||
type ModelProviderCredentialTestOutcome,
|
||||
type ModelProviderCredentialTestPlan,
|
||||
type ModelProviderCredentialTestResult,
|
||||
} from './model-provider-credential-test-connection-protocol/contractProtocol';
|
||||
export {
|
||||
createModelProviderCredentialTestAllowlist,
|
||||
createModelProviderCredentialTestEndpoint,
|
||||
normalizeModelProviderCredentialTestAllowlist,
|
||||
normalizeModelProviderCredentialTestEndpoint,
|
||||
resolveModelProviderCredentialTestEndpoint,
|
||||
} from './model-provider-credential-test-connection-protocol/endpointAllowlistProtocol';
|
||||
export {
|
||||
createModelProviderCredentialTestExecution,
|
||||
createModelProviderCredentialTestResult,
|
||||
normalizeModelProviderCredentialTestExecution,
|
||||
normalizeModelProviderCredentialTestResult,
|
||||
} from './model-provider-credential-test-connection-protocol/executionResultProtocol';
|
||||
export {
|
||||
createModelProviderCredentialTestPlan,
|
||||
normalizeModelProviderCredentialTestPlan,
|
||||
} from './model-provider-credential-test-connection-protocol/planProtocol';
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import type { PostgresClient } from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import {
|
||||
ModelProviderCredentialAdministrationAuthorizationFenceConflictError,
|
||||
ModelProviderCredentialAdministrationMutationConflictError,
|
||||
type AuthorizedModelProviderCredentialTransitionMutation,
|
||||
} from '../modelProviderCredentialAdministration';
|
||||
import { ModelProviderCredentialCatalogUnavailableError } from '../modelProviderCredentialCatalog';
|
||||
import {
|
||||
identity,
|
||||
integer,
|
||||
sqlState,
|
||||
unavailable,
|
||||
type Queryable,
|
||||
type Row,
|
||||
} from './storageProtocol';
|
||||
|
||||
export function administrationAuditFromRow(
|
||||
row: Row,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
const reasons = row.auditReasons;
|
||||
if (!Array.isArray(reasons)) throw unavailable();
|
||||
const subjectType = row.auditSubjectType;
|
||||
const subjectId = row.auditSubjectId;
|
||||
const authenticationId = row.auditAuthenticationId;
|
||||
const projectVersion = row.auditProjectVersion;
|
||||
const bindingVersion = row.auditBindingVersion;
|
||||
try {
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: identity(row.auditEventId),
|
||||
requestId: identity(row.auditRequestId),
|
||||
operationId: identity(row.auditOperationId),
|
||||
projectId: identity(row.auditProjectId),
|
||||
subject: {
|
||||
type: identity(subjectType) as NonNullable<
|
||||
SecurityAuditRecord['subject']
|
||||
>['type'],
|
||||
id: identity(subjectId),
|
||||
},
|
||||
authenticationId: identity(authenticationId),
|
||||
outcome: identity(row.auditOutcome) as SecurityAuditRecord['outcome'],
|
||||
reasons: reasons as string[],
|
||||
fence: {
|
||||
projectVersion: integer(projectVersion),
|
||||
bindingVersion: integer(bindingVersion),
|
||||
},
|
||||
occurredAtMs: integer(row.auditOccurredAtMs),
|
||||
});
|
||||
} catch (cause) {
|
||||
throw unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
export async function administrationAuditRows(
|
||||
queryable: Queryable,
|
||||
eventId: string,
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT
|
||||
event_id AS "auditEventId",
|
||||
request_id AS "auditRequestId",
|
||||
operation_id AS "auditOperationId",
|
||||
project_id AS "auditProjectId",
|
||||
subject_type AS "auditSubjectType",
|
||||
subject_id AS "auditSubjectId",
|
||||
authentication_id AS "auditAuthenticationId",
|
||||
outcome AS "auditOutcome",
|
||||
reasons AS "auditReasons",
|
||||
project_version AS "auditProjectVersion",
|
||||
binding_version AS "auditBindingVersion",
|
||||
occurred_at_ms AS "auditOccurredAtMs"
|
||||
FROM "ql3"."security_audit_events"
|
||||
WHERE event_id = $1
|
||||
LIMIT 2`,
|
||||
[eventId],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export function sameAdministrationReplayAudit(
|
||||
left: Readonly<SecurityAuditRecord>,
|
||||
right: Readonly<SecurityAuditRecord>,
|
||||
): boolean {
|
||||
const { occurredAtMs: _leftOccurredAtMs, ...leftSemantic } = left;
|
||||
const { occurredAtMs: _rightOccurredAtMs, ...rightSemantic } = right;
|
||||
return JSON.stringify(leftSemantic) === JSON.stringify(rightSemantic);
|
||||
}
|
||||
|
||||
export async function confirmAdministrationFence(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<AuthorizedModelProviderCredentialTransitionMutation>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const project = await client.query<Row>(
|
||||
`SELECT status, version FROM "ql3"."projects" WHERE id = $1`,
|
||||
[mutation.command.projectId],
|
||||
);
|
||||
const binding = await client.query<Row>(
|
||||
`SELECT version, state
|
||||
FROM "ql3"."project_role_bindings"
|
||||
WHERE project_id = $1 AND subject_type = $2 AND subject_id = $3
|
||||
ORDER BY version DESC
|
||||
LIMIT 1`,
|
||||
[mutation.command.projectId, mutation.actor.type, mutation.actor.id],
|
||||
);
|
||||
if (
|
||||
project.rows.length !== 1 ||
|
||||
project.rows[0]?.status !== 'active' ||
|
||||
integer(project.rows[0]?.version) !== mutation.fence.projectVersion ||
|
||||
binding.rows.length !== 1 ||
|
||||
binding.rows[0]?.state !== 'active' ||
|
||||
integer(binding.rows[0]?.version) !== mutation.fence.bindingVersion
|
||||
) {
|
||||
throw new ModelProviderCredentialAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ModelProviderCredentialAdministrationAuthorizationFenceConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ModelProviderCredentialAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
export async function insertAdministrationAudit(
|
||||
client: PostgresClient,
|
||||
audit: Readonly<SecurityAuditRecord>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id, request_id, operation_id, project_id, subject_type,
|
||||
subject_id, authentication_id, outcome, reasons, project_version,
|
||||
binding_version, occurred_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)`,
|
||||
[
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function mapAdministrationStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof
|
||||
ModelProviderCredentialAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
ModelProviderCredentialAdministrationMutationConflictError ||
|
||||
error instanceof ModelProviderCredentialCatalogUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (['23503', '23505', '23514', '40001', '40P01'].includes(sqlState(error))) {
|
||||
return new ModelProviderCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import { POSTGRES_MODEL_INVOCATION_SCHEMA } from '../../migration/modelInvocationMigration';
|
||||
import {
|
||||
modelProviderCredentialBindingForTransition,
|
||||
type ModelProviderCredentialTransition,
|
||||
} from '../modelProviderCredentialCatalog';
|
||||
import {
|
||||
type ModelProviderCredentialAuditRecord,
|
||||
type ModelProviderCredentialAuditSink,
|
||||
type ModelProviderCredentialBinding,
|
||||
type ModelProviderCredentialBindingLookup,
|
||||
type ModelProviderCredentialBindingSource,
|
||||
} from '../providerCredential';
|
||||
import {
|
||||
PROVIDER_PATTERN,
|
||||
auditDigest,
|
||||
bindingFromRow,
|
||||
identity,
|
||||
integer,
|
||||
jsonObject,
|
||||
mapStorageError,
|
||||
normalizeAuditRecord,
|
||||
normalizeLookup,
|
||||
transitionFromRow,
|
||||
unavailable,
|
||||
type Queryable,
|
||||
type Row,
|
||||
} from './storageProtocol';
|
||||
|
||||
/**
|
||||
* PostgreSQL runtime reader plus append-only content-free credential audit.
|
||||
* Administrative binding mutations are exposed by the subclass so runtime
|
||||
* composition never receives mutation authority.
|
||||
*/
|
||||
export class PostgresModelProviderCredentialReader
|
||||
implements
|
||||
ModelProviderCredentialBindingSource,
|
||||
ModelProviderCredentialAuditSink
|
||||
{
|
||||
constructor(protected readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL model provider credential pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected async transitionRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT
|
||||
project_id AS "projectId",
|
||||
provider,
|
||||
generation,
|
||||
mutation_id AS "mutationId",
|
||||
command_digest AS "commandDigest",
|
||||
transition_digest AS "transitionDigest",
|
||||
transition_json AS "transitionJson"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions"
|
||||
WHERE ${where}
|
||||
ORDER BY generation DESC
|
||||
LIMIT 2`,
|
||||
values,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async findCurrentTransition(
|
||||
projectIdValue: string,
|
||||
providerValue: string,
|
||||
): Promise<Readonly<ModelProviderCredentialTransition> | null> {
|
||||
const projectId = identity(projectIdValue);
|
||||
const provider = identity(providerValue, PROVIDER_PATTERN);
|
||||
try {
|
||||
const rows = await this.transitionRows(
|
||||
this.pool,
|
||||
'project_id = $1 AND provider = $2',
|
||||
[projectId, provider],
|
||||
);
|
||||
if (
|
||||
rows.length > 1 &&
|
||||
integer(rows[0]!.generation) === integer(rows[1]!.generation)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return rows[0] ? transitionFromRow(rows[0]) : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveModelProviderCredentialBinding(
|
||||
lookupValue: Readonly<ModelProviderCredentialBindingLookup>,
|
||||
): Promise<Readonly<ModelProviderCredentialBinding> | null> {
|
||||
const lookup = normalizeLookup(lookupValue);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT
|
||||
transition.project_id AS "projectId",
|
||||
transition.provider,
|
||||
transition.generation,
|
||||
transition.mutation_id AS "mutationId",
|
||||
transition.command_digest AS "commandDigest",
|
||||
transition.transition_digest AS "transitionDigest",
|
||||
transition.transition_json AS "transitionJson",
|
||||
binding.project_id AS "bindingProjectId",
|
||||
binding.provider AS "bindingProvider",
|
||||
binding.revision,
|
||||
binding.secret_ref AS "secretRef",
|
||||
binding.scheme,
|
||||
binding.binding_digest AS "bindingDigest",
|
||||
binding.binding_json AS "bindingJson"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions"
|
||||
AS transition
|
||||
LEFT JOIN "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings"
|
||||
AS binding
|
||||
ON binding.project_id = transition.project_id
|
||||
AND binding.provider = transition.provider
|
||||
AND binding.revision = transition.active_binding_revision
|
||||
WHERE transition.project_id = $1
|
||||
AND transition.provider = $2
|
||||
ORDER BY transition.generation DESC
|
||||
LIMIT 1`,
|
||||
[lookup.projectId, lookup.provider],
|
||||
);
|
||||
if (result.rows.length !== 1) return null;
|
||||
const row = result.rows[0]!;
|
||||
const transition = transitionFromRow(row);
|
||||
if (transition.action === 'revoke') {
|
||||
return modelProviderCredentialBindingForTransition(transition, null);
|
||||
}
|
||||
if (
|
||||
row.bindingProjectId !== transition.projectId ||
|
||||
row.bindingProvider !== transition.provider
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return modelProviderCredentialBindingForTransition(
|
||||
transition,
|
||||
bindingFromRow(row),
|
||||
);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async record(
|
||||
recordValue: Readonly<ModelProviderCredentialAuditRecord>,
|
||||
): Promise<void> {
|
||||
const record = normalizeAuditRecord(recordValue);
|
||||
const digest = auditDigest(record);
|
||||
try {
|
||||
const inserted = await this.pool.query<Row>(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits" (
|
||||
project_id, provider, request_id, operation, binding_revision,
|
||||
binding_digest, occurred_at_ms, audit_digest, audit_json
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
||||
ON CONFLICT (project_id, provider, request_id, operation) DO NOTHING
|
||||
RETURNING audit_digest AS "auditDigest"`,
|
||||
[
|
||||
record.projectId,
|
||||
record.provider,
|
||||
record.requestId,
|
||||
record.operation,
|
||||
record.bindingRevision,
|
||||
record.bindingDigest,
|
||||
record.occurredAtMs,
|
||||
digest,
|
||||
JSON.stringify(record),
|
||||
],
|
||||
);
|
||||
if (
|
||||
inserted.rows.length === 1 &&
|
||||
inserted.rows[0]?.auditDigest === digest
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const existing = await this.pool.query<Row>(
|
||||
`SELECT audit_digest AS "auditDigest", audit_json AS "auditJson"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_audits"
|
||||
WHERE project_id = $1 AND provider = $2
|
||||
AND request_id = $3 AND operation = $4
|
||||
LIMIT 2`,
|
||||
[record.projectId, record.provider, record.requestId, record.operation],
|
||||
);
|
||||
if (
|
||||
existing.rows.length !== 1 ||
|
||||
existing.rows[0]?.auditDigest !== digest ||
|
||||
JSON.stringify(
|
||||
normalizeAuditRecord(
|
||||
jsonObject(
|
||||
existing.rows[0]?.auditJson,
|
||||
) as unknown as ModelProviderCredentialAuditRecord,
|
||||
),
|
||||
) !== JSON.stringify(record)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
import type { PostgresClient } from '@qinglong/runtime-core';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import { POSTGRES_MODEL_INVOCATION_SCHEMA } from '../../migration/modelInvocationMigration';
|
||||
import {
|
||||
ModelProviderCredentialAdministrationMutationConflictError,
|
||||
normalizeAuthorizedModelProviderCredentialTransitionMutation,
|
||||
type AuthorizedModelProviderCredentialTransitionMutation,
|
||||
type ModelProviderCredentialAdministrationRepository,
|
||||
} from '../modelProviderCredentialAdministration';
|
||||
import {
|
||||
ModelProviderCredentialTransitionConflictError,
|
||||
createModelProviderCredentialTransition,
|
||||
normalizeModelProviderCredentialTransitionCommand,
|
||||
type CommitModelProviderCredentialTransitionResult,
|
||||
type ModelProviderCredentialCatalogRepository,
|
||||
type ModelProviderCredentialTransitionCommand,
|
||||
} from '../modelProviderCredentialCatalog';
|
||||
import { digestModelProviderCredentialBinding } from '../providerCredential';
|
||||
import {
|
||||
administrationAuditFromRow,
|
||||
administrationAuditRows,
|
||||
confirmAdministrationFence,
|
||||
insertAdministrationAudit,
|
||||
mapAdministrationStorageError,
|
||||
sameAdministrationReplayAudit,
|
||||
} from './administrationProtocol';
|
||||
import { PostgresModelProviderCredentialReader } from './reader';
|
||||
import {
|
||||
integer,
|
||||
mapStorageError,
|
||||
rollback,
|
||||
transitionFromRow,
|
||||
unavailable,
|
||||
type Row,
|
||||
} from './storageProtocol';
|
||||
|
||||
export class PostgresModelProviderCredentialRepository
|
||||
extends PostgresModelProviderCredentialReader
|
||||
implements
|
||||
ModelProviderCredentialCatalogRepository,
|
||||
ModelProviderCredentialAdministrationRepository
|
||||
{
|
||||
async commit(
|
||||
commandValue: Readonly<ModelProviderCredentialTransitionCommand>,
|
||||
): Promise<Readonly<CommitModelProviderCredentialTransitionResult>> {
|
||||
const command =
|
||||
normalizeModelProviderCredentialTransitionCommand(commandValue);
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||
await client.query(
|
||||
'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
|
||||
[JSON.stringify([command.projectId, command.provider])],
|
||||
);
|
||||
const replayRows = await this.transitionRows(client, 'mutation_id = $1', [
|
||||
command.mutationId,
|
||||
]);
|
||||
if (replayRows.length > 1) throw unavailable();
|
||||
if (replayRows[0]) {
|
||||
const stored = transitionFromRow(replayRows[0]);
|
||||
if (stored.commandDigest !== command.commandDigest) {
|
||||
throw new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
transition: stored,
|
||||
});
|
||||
}
|
||||
const currentRows = await this.transitionRows(
|
||||
client,
|
||||
'project_id = $1 AND provider = $2',
|
||||
[command.projectId, command.provider],
|
||||
);
|
||||
const current = currentRows[0] ? transitionFromRow(currentRows[0]) : null;
|
||||
if ((current?.generation ?? 0) !== command.expectedGeneration) {
|
||||
throw new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
const clock = await client.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint AS now`,
|
||||
);
|
||||
if (clock.rows.length !== 1 || !clock.rows[0]) throw unavailable();
|
||||
const transition = createModelProviderCredentialTransition(
|
||||
command,
|
||||
current,
|
||||
integer(clock.rows[0].now),
|
||||
);
|
||||
if (command.binding) {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings" (
|
||||
project_id, provider, revision, secret_ref, scheme,
|
||||
binding_digest, binding_json
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
||||
[
|
||||
command.binding.projectId,
|
||||
command.binding.provider,
|
||||
command.binding.revision,
|
||||
command.binding.secretRef,
|
||||
command.binding.scheme,
|
||||
digestModelProviderCredentialBinding(command.binding),
|
||||
JSON.stringify(command.binding),
|
||||
],
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions" (
|
||||
project_id, provider, generation, action,
|
||||
active_binding_revision, active_binding_digest,
|
||||
previous_transition_digest, mutation_id,
|
||||
changed_by_type, changed_by_id, changed_at_ms,
|
||||
command_digest, transition_digest, transition_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14::jsonb
|
||||
)`,
|
||||
[
|
||||
transition.projectId,
|
||||
transition.provider,
|
||||
transition.generation,
|
||||
transition.action,
|
||||
transition.activeBindingRevision,
|
||||
transition.activeBindingDigest,
|
||||
transition.previousTransitionDigest,
|
||||
transition.mutationId,
|
||||
transition.changedBy.type,
|
||||
transition.changedBy.id,
|
||||
transition.changedAtMs,
|
||||
transition.commandDigest,
|
||||
transition.transitionDigest,
|
||||
JSON.stringify(transition),
|
||||
],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({ status: 'created' as const, transition });
|
||||
} catch (error) {
|
||||
await rollback(client);
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async commitAuthorized(
|
||||
mutationValue: AuthorizedModelProviderCredentialTransitionMutation,
|
||||
): Promise<Readonly<CommitModelProviderCredentialTransitionResult>> {
|
||||
const mutation =
|
||||
normalizeAuthorizedModelProviderCredentialTransitionMutation(
|
||||
mutationValue,
|
||||
);
|
||||
const { command, audit } = mutation;
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||
await client.query(
|
||||
'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
|
||||
[JSON.stringify([command.projectId, command.provider])],
|
||||
);
|
||||
await confirmAdministrationFence(client, mutation);
|
||||
const replayRows = await this.transitionRows(client, 'mutation_id = $1', [
|
||||
command.mutationId,
|
||||
]);
|
||||
const auditRows = await administrationAuditRows(client, audit.eventId);
|
||||
if (replayRows.length > 1 || auditRows.length > 1) {
|
||||
throw new ModelProviderCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
if (replayRows[0]) {
|
||||
const stored = transitionFromRow(replayRows[0]);
|
||||
let storedAudit: Readonly<SecurityAuditRecord>;
|
||||
try {
|
||||
if (!auditRows[0]) {
|
||||
throw new ModelProviderCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
storedAudit = administrationAuditFromRow(auditRows[0]);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ModelProviderCredentialAdministrationMutationConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ModelProviderCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
if (
|
||||
stored.commandDigest !== command.commandDigest ||
|
||||
!sameAdministrationReplayAudit(storedAudit, audit)
|
||||
) {
|
||||
throw new ModelProviderCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
transition: stored,
|
||||
});
|
||||
}
|
||||
if (auditRows[0]) {
|
||||
throw new ModelProviderCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
const currentRows = await this.transitionRows(
|
||||
client,
|
||||
'project_id = $1 AND provider = $2',
|
||||
[command.projectId, command.provider],
|
||||
);
|
||||
const current = currentRows[0] ? transitionFromRow(currentRows[0]) : null;
|
||||
if ((current?.generation ?? 0) !== command.expectedGeneration) {
|
||||
throw new ModelProviderCredentialAdministrationMutationConflictError();
|
||||
}
|
||||
const clock = await client.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint AS now`,
|
||||
);
|
||||
if (clock.rows.length !== 1 || !clock.rows[0]) throw unavailable();
|
||||
const transition = createModelProviderCredentialTransition(
|
||||
command,
|
||||
current,
|
||||
integer(clock.rows[0].now),
|
||||
);
|
||||
if (command.binding) {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_bindings" (
|
||||
project_id, provider, revision, secret_ref, scheme,
|
||||
binding_digest, binding_json
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
||||
[
|
||||
command.binding.projectId,
|
||||
command.binding.provider,
|
||||
command.binding.revision,
|
||||
command.binding.secretRef,
|
||||
command.binding.scheme,
|
||||
digestModelProviderCredentialBinding(command.binding),
|
||||
JSON.stringify(command.binding),
|
||||
],
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_transitions" (
|
||||
project_id, provider, generation, action,
|
||||
active_binding_revision, active_binding_digest,
|
||||
previous_transition_digest, mutation_id,
|
||||
changed_by_type, changed_by_id, changed_at_ms,
|
||||
command_digest, transition_digest, transition_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14::jsonb
|
||||
)`,
|
||||
[
|
||||
transition.projectId,
|
||||
transition.provider,
|
||||
transition.generation,
|
||||
transition.action,
|
||||
transition.activeBindingRevision,
|
||||
transition.activeBindingDigest,
|
||||
transition.previousTransitionDigest,
|
||||
transition.mutationId,
|
||||
transition.changedBy.type,
|
||||
transition.changedBy.id,
|
||||
transition.changedAtMs,
|
||||
transition.commandDigest,
|
||||
transition.transitionDigest,
|
||||
JSON.stringify(transition),
|
||||
],
|
||||
);
|
||||
await insertAdministrationAudit(client, audit);
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({ status: 'created' as const, transition });
|
||||
} catch (error) {
|
||||
await rollback(client);
|
||||
throw mapAdministrationStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresQueryable,
|
||||
} from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
ModelProviderCredentialCatalogUnavailableError,
|
||||
ModelProviderCredentialTransitionConflictError,
|
||||
normalizeModelProviderCredentialTransition,
|
||||
type ModelProviderCredentialTransition,
|
||||
} from '../modelProviderCredentialCatalog';
|
||||
import {
|
||||
MODEL_PROVIDER_CREDENTIAL_AUDIT_SCHEMA,
|
||||
MODEL_PROVIDER_CREDENTIAL_OPERATIONS,
|
||||
digestModelProviderCredentialBinding,
|
||||
normalizeModelProviderCredentialBinding,
|
||||
type ModelProviderCredentialAuditRecord,
|
||||
type ModelProviderCredentialBinding,
|
||||
type ModelProviderCredentialBindingLookup,
|
||||
} from '../providerCredential';
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
export type Queryable = Pick<PostgresQueryable, 'query'>;
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
export const PROVIDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
||||
|
||||
export function unavailable(
|
||||
cause?: unknown,
|
||||
): ModelProviderCredentialCatalogUnavailableError {
|
||||
return new ModelProviderCredentialCatalogUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function sqlState(error: unknown): string {
|
||||
if (!error || typeof error !== 'object') return '';
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : '';
|
||||
}
|
||||
|
||||
export function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof ModelProviderCredentialTransitionConflictError ||
|
||||
error instanceof ModelProviderCredentialCatalogUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (['23503', '23505', '23514', '40001', '40P01'].includes(sqlState(error))) {
|
||||
return new ModelProviderCredentialTransitionConflictError();
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
export function identity(
|
||||
value: unknown,
|
||||
pattern: RegExp = IDENTITY_PATTERN,
|
||||
): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function integer(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
export function jsonObject(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
!Array.isArray(parsed) &&
|
||||
Object.getPrototypeOf(parsed) === Object.prototype
|
||||
) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Mapped to the stable unavailable error below.
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
export function bindingFromRow(
|
||||
row: Row,
|
||||
): Readonly<ModelProviderCredentialBinding> {
|
||||
let binding: Readonly<ModelProviderCredentialBinding>;
|
||||
try {
|
||||
binding = normalizeModelProviderCredentialBinding(
|
||||
jsonObject(row.bindingJson) as unknown as ModelProviderCredentialBinding,
|
||||
);
|
||||
} catch (cause) {
|
||||
throw unavailable(cause);
|
||||
}
|
||||
if (
|
||||
binding.projectId !== identity(row.projectId) ||
|
||||
binding.provider !== identity(row.provider, PROVIDER_PATTERN) ||
|
||||
binding.revision !== identity(row.revision) ||
|
||||
binding.secretRef !==
|
||||
identity(row.secretRef, /^qlsecret:v1:[^\0\r\n]{1,500}$/) ||
|
||||
binding.scheme !== row.scheme ||
|
||||
digestModelProviderCredentialBinding(binding) !== row.bindingDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
export function transitionFromRow(
|
||||
row: Row,
|
||||
): Readonly<ModelProviderCredentialTransition> {
|
||||
let transition: Readonly<ModelProviderCredentialTransition>;
|
||||
try {
|
||||
transition = normalizeModelProviderCredentialTransition(
|
||||
jsonObject(
|
||||
row.transitionJson,
|
||||
) as unknown as ModelProviderCredentialTransition,
|
||||
);
|
||||
} catch (cause) {
|
||||
throw unavailable(cause);
|
||||
}
|
||||
if (
|
||||
transition.projectId !== identity(row.projectId) ||
|
||||
transition.provider !== identity(row.provider, PROVIDER_PATTERN) ||
|
||||
transition.generation !== integer(row.generation) ||
|
||||
transition.mutationId !== identity(row.mutationId) ||
|
||||
transition.commandDigest !== row.commandDigest ||
|
||||
transition.transitionDigest !== row.transitionDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return transition;
|
||||
}
|
||||
|
||||
export async function rollback(client: PostgresClient): Promise<void> {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
}
|
||||
|
||||
export function normalizeLookup(
|
||||
value: Readonly<ModelProviderCredentialBindingLookup>,
|
||||
): Readonly<ModelProviderCredentialBindingLookup> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !== 'projectId\0provider'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: identity(value.projectId),
|
||||
provider: identity(value.provider, PROVIDER_PATTERN),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAuditRecord(
|
||||
value: Readonly<ModelProviderCredentialAuditRecord>,
|
||||
): Readonly<ModelProviderCredentialAuditRecord> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
[
|
||||
'bindingDigest',
|
||||
'bindingRevision',
|
||||
'occurredAtMs',
|
||||
'operation',
|
||||
'projectId',
|
||||
'provider',
|
||||
'requestId',
|
||||
'schema',
|
||||
]
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
value.schema !== MODEL_PROVIDER_CREDENTIAL_AUDIT_SCHEMA ||
|
||||
!MODEL_PROVIDER_CREDENTIAL_OPERATIONS.includes(value.operation) ||
|
||||
typeof value.bindingDigest !== 'string' ||
|
||||
!SHA256_PATTERN.test(value.bindingDigest)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_AUDIT_SCHEMA,
|
||||
operation: value.operation,
|
||||
projectId: identity(value.projectId),
|
||||
provider: identity(value.provider, PROVIDER_PATTERN),
|
||||
requestId: identity(value.requestId),
|
||||
bindingRevision: identity(value.bindingRevision),
|
||||
bindingDigest: value.bindingDigest,
|
||||
occurredAtMs: integer(value.occurredAtMs),
|
||||
});
|
||||
}
|
||||
|
||||
export function auditDigest(
|
||||
record: Readonly<ModelProviderCredentialAuditRecord>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update('qinglong/model-provider-credential-audit@v1', 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(JSON.stringify(record), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import type { PostgresClient } from '@qinglong/runtime-core';
|
||||
|
||||
import { InvalidModelProviderCredentialTestConnectionError } from '../modelProviderCredentialTestConnection';
|
||||
import {
|
||||
ModelProviderCredentialTestPlanUnavailableError,
|
||||
type PostgresModelProviderCredentialTestPlanOptions,
|
||||
} from './contracts';
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
const DEFAULT_QUOTA_WINDOW_MS = 60_000;
|
||||
const DEFAULT_QUOTA_LIMIT = 5;
|
||||
export const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
|
||||
export function exact(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidModelProviderCredentialTestConnectionError();
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const actual = Object.keys(candidate).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialTestConnectionError();
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function options(
|
||||
value: PostgresModelProviderCredentialTestPlanOptions | undefined,
|
||||
): Readonly<{ quotaWindowMs: number; quotaLimit: number }> {
|
||||
if (value !== undefined) exact(value, ['quotaLimit', 'quotaWindowMs']);
|
||||
const quotaWindowMs = value?.quotaWindowMs ?? DEFAULT_QUOTA_WINDOW_MS;
|
||||
const quotaLimit = value?.quotaLimit ?? DEFAULT_QUOTA_LIMIT;
|
||||
if (
|
||||
!Number.isSafeInteger(quotaWindowMs) ||
|
||||
quotaWindowMs < 1_000 ||
|
||||
quotaWindowMs > 5 * 60_000 ||
|
||||
!Number.isSafeInteger(quotaLimit) ||
|
||||
quotaLimit < 1 ||
|
||||
quotaLimit > 32
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL model provider credential test plan options are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ quotaWindowMs, quotaLimit });
|
||||
}
|
||||
|
||||
export function integer(value: unknown, minimum = 0): number {
|
||||
const normalized =
|
||||
typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)
|
||||
? Number(value)
|
||||
: value;
|
||||
if (!Number.isSafeInteger(normalized) || (normalized as number) < minimum) {
|
||||
throw new ModelProviderCredentialTestPlanUnavailableError();
|
||||
}
|
||||
return normalized as number;
|
||||
}
|
||||
|
||||
export function text(value: unknown, pattern: RegExp = IDENTIFIER_PATTERN): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) {
|
||||
throw new ModelProviderCredentialTestPlanUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function rollback(client: PostgresClient): Promise<void> {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
}
|
||||
|
||||
export function sqlState(error: unknown): string {
|
||||
if (!error || typeof error !== 'object') return '';
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : '';
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import type {
|
||||
ModelProviderCredentialTestAllowlist,
|
||||
ModelProviderCredentialTestExecution,
|
||||
ModelProviderCredentialTestPlan,
|
||||
ModelProviderCredentialTestResult,
|
||||
} from '../modelProviderCredentialTestConnection';
|
||||
|
||||
export const MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_OPERATION_ID =
|
||||
'model_provider_credential.test.plan';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export interface AuthorizedModelProviderCredentialTestPlan {
|
||||
readonly plan: ModelProviderCredentialTestPlan;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface CreateModelProviderCredentialTestPlanResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly plan: Readonly<ModelProviderCredentialTestPlan>;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialTestPlanRepository {
|
||||
createAuthorized(
|
||||
value: AuthorizedModelProviderCredentialTestPlan,
|
||||
): Promise<Readonly<CreateModelProviderCredentialTestPlanResult>>;
|
||||
}
|
||||
|
||||
export interface PostgresModelProviderCredentialTestPlanOptions {
|
||||
readonly quotaWindowMs?: number;
|
||||
readonly quotaLimit?: number;
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTestPlanAuthorizationFenceConflictError extends Error {
|
||||
readonly code =
|
||||
'MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_AUTHORIZATION_FENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Model provider credential test plan authorization fence changed');
|
||||
this.name =
|
||||
'ModelProviderCredentialTestPlanAuthorizationFenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTestPlanConflictError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Model provider credential test plan conflicts with durable state');
|
||||
this.name = 'ModelProviderCredentialTestPlanConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTestPlanQuotaExceededError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_QUOTA_EXCEEDED';
|
||||
|
||||
constructor(readonly retryAfterMs: number) {
|
||||
super('Model provider credential test plan quota is exceeded');
|
||||
this.name = 'ModelProviderCredentialTestPlanQuotaExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTestPlanUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Model provider credential test plan is unavailable', options);
|
||||
this.name = 'ModelProviderCredentialTestPlanUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface BeginModelProviderCredentialTestExecutionInput {
|
||||
readonly executionId: string;
|
||||
readonly testId: string;
|
||||
readonly allowlist: ModelProviderCredentialTestAllowlist;
|
||||
}
|
||||
|
||||
export interface BeginModelProviderCredentialTestExecutionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly plan: Readonly<ModelProviderCredentialTestPlan>;
|
||||
readonly execution: Readonly<ModelProviderCredentialTestExecution>;
|
||||
readonly result: Readonly<ModelProviderCredentialTestResult> | null;
|
||||
}
|
||||
|
||||
export interface CompleteModelProviderCredentialTestExecutionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly result: Readonly<ModelProviderCredentialTestResult>;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialTestExecutionRepository {
|
||||
beginExecution(
|
||||
value: BeginModelProviderCredentialTestExecutionInput,
|
||||
): Promise<Readonly<BeginModelProviderCredentialTestExecutionResult>>;
|
||||
complete(
|
||||
value: ModelProviderCredentialTestResult,
|
||||
): Promise<Readonly<CompleteModelProviderCredentialTestExecutionResult>>;
|
||||
}
|
||||
|
||||
export interface PostgresModelProviderCredentialTesterReadinessReport {
|
||||
readonly ready: true;
|
||||
readonly currentUser: string;
|
||||
readonly migrationIds: readonly string[];
|
||||
readonly writablePrimary: true;
|
||||
readonly testerAuthority: true;
|
||||
readonly leastPrivilege: true;
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTestExecutionRejectedError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTION_REJECTED';
|
||||
|
||||
constructor() {
|
||||
super('Model provider credential test execution is rejected');
|
||||
this.name = 'ModelProviderCredentialTestExecutionRejectedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTestExecutionConflictError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Model provider credential test execution conflicts with durable state',
|
||||
);
|
||||
this.name = 'ModelProviderCredentialTestExecutionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialTestExecutionUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_TEST_EXECUTION_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Model provider credential test execution is unavailable', options);
|
||||
this.name = 'ModelProviderCredentialTestExecutionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresModelProviderCredentialTesterNotReadyError extends Error {
|
||||
readonly code = 'POSTGRES_MODEL_PROVIDER_CREDENTIAL_TESTER_NOT_READY';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('PostgreSQL model provider credential tester is not ready', options);
|
||||
this.name = 'PostgresModelProviderCredentialTesterNotReadyError';
|
||||
}
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
InvalidModelProviderCredentialTestConnectionError,
|
||||
createModelProviderCredentialTestExecution,
|
||||
normalizeModelProviderCredentialTestAllowlist,
|
||||
normalizeModelProviderCredentialTestExecution,
|
||||
normalizeModelProviderCredentialTestPlan,
|
||||
normalizeModelProviderCredentialTestResult,
|
||||
resolveModelProviderCredentialTestEndpoint,
|
||||
type ModelProviderCredentialTestAllowlist,
|
||||
type ModelProviderCredentialTestExecution,
|
||||
type ModelProviderCredentialTestPlan,
|
||||
type ModelProviderCredentialTestResult,
|
||||
} from '../modelProviderCredentialTestConnection';
|
||||
import {
|
||||
ModelProviderCredentialTestExecutionConflictError,
|
||||
ModelProviderCredentialTestExecutionRejectedError,
|
||||
ModelProviderCredentialTestExecutionUnavailableError,
|
||||
type BeginModelProviderCredentialTestExecutionInput,
|
||||
type BeginModelProviderCredentialTestExecutionResult,
|
||||
type CompleteModelProviderCredentialTestExecutionResult,
|
||||
type ModelProviderCredentialTestExecutionRepository,
|
||||
} from './contracts';
|
||||
import {
|
||||
UUID_V4_PATTERN,
|
||||
exact,
|
||||
integer,
|
||||
rollback,
|
||||
sqlState,
|
||||
type Row,
|
||||
} from './common';
|
||||
|
||||
function executionError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidModelProviderCredentialTestConnectionError ||
|
||||
error instanceof ModelProviderCredentialTestExecutionRejectedError ||
|
||||
error instanceof ModelProviderCredentialTestExecutionConflictError ||
|
||||
error instanceof ModelProviderCredentialTestExecutionUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (['23503', '23505', '23514', '40001', '40P01'].includes(sqlState(error))) {
|
||||
return new ModelProviderCredentialTestExecutionConflictError();
|
||||
}
|
||||
return new ModelProviderCredentialTestExecutionUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStoredPlan(
|
||||
value: unknown,
|
||||
): Readonly<ModelProviderCredentialTestPlan> {
|
||||
try {
|
||||
return normalizeModelProviderCredentialTestPlan(
|
||||
value as ModelProviderCredentialTestPlan,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ModelProviderCredentialTestExecutionUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoredExecution(
|
||||
value: unknown,
|
||||
): Readonly<ModelProviderCredentialTestExecution> {
|
||||
try {
|
||||
return normalizeModelProviderCredentialTestExecution(
|
||||
value as ModelProviderCredentialTestExecution,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ModelProviderCredentialTestExecutionUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoredResult(
|
||||
value: unknown,
|
||||
): Readonly<ModelProviderCredentialTestResult> {
|
||||
try {
|
||||
return normalizeModelProviderCredentialTestResult(
|
||||
value as ModelProviderCredentialTestResult,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ModelProviderCredentialTestExecutionUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTestPlan(
|
||||
client: PostgresClient,
|
||||
testId: string,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
plan: Readonly<ModelProviderCredentialTestPlan>;
|
||||
observedAtMs: number;
|
||||
}>
|
||||
> {
|
||||
const result = await client.query<Row>(
|
||||
`WITH database_clock AS (
|
||||
SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AS now_ms
|
||||
)
|
||||
SELECT plan.plan_json AS "planJson",
|
||||
database_clock.now_ms AS "observedAtMs"
|
||||
FROM "ql3_ai"."model_provider_credential_test_plans" AS plan
|
||||
CROSS JOIN database_clock
|
||||
WHERE plan.test_id = $1::uuid
|
||||
LIMIT 2`,
|
||||
[testId],
|
||||
);
|
||||
if (result.rows.length !== 1) {
|
||||
throw new ModelProviderCredentialTestExecutionRejectedError();
|
||||
}
|
||||
const plan = normalizeStoredPlan(result.rows[0]?.planJson);
|
||||
const observedAtMs = integer(result.rows[0]?.observedAtMs);
|
||||
if (plan.testId !== testId || observedAtMs >= plan.expiresAtMs) {
|
||||
throw new ModelProviderCredentialTestExecutionRejectedError();
|
||||
}
|
||||
return Object.freeze({ plan, observedAtMs });
|
||||
}
|
||||
|
||||
function confirmExecutionAllowlist(
|
||||
plan: Readonly<ModelProviderCredentialTestPlan>,
|
||||
allowlistValue: ModelProviderCredentialTestAllowlist,
|
||||
): void {
|
||||
let selected;
|
||||
try {
|
||||
const allowlist =
|
||||
normalizeModelProviderCredentialTestAllowlist(allowlistValue);
|
||||
selected = resolveModelProviderCredentialTestEndpoint(
|
||||
allowlist,
|
||||
plan.provider,
|
||||
);
|
||||
} catch {
|
||||
throw new ModelProviderCredentialTestExecutionRejectedError();
|
||||
}
|
||||
if (JSON.stringify(selected) !== JSON.stringify(plan.endpoint)) {
|
||||
throw new ModelProviderCredentialTestExecutionRejectedError();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExecutionState(
|
||||
client: PostgresClient,
|
||||
testId: string,
|
||||
): Promise<Readonly<{
|
||||
execution: Readonly<ModelProviderCredentialTestExecution>;
|
||||
result: Readonly<ModelProviderCredentialTestResult> | null;
|
||||
}> | null> {
|
||||
const selected = await client.query<Row>(
|
||||
`SELECT execution.execution_json AS "executionJson",
|
||||
result.result_json AS "resultJson"
|
||||
FROM "ql3_ai"."model_provider_credential_test_executions" AS execution
|
||||
LEFT JOIN "ql3_ai"."model_provider_credential_test_results" AS result
|
||||
ON result.execution_id = execution.execution_id
|
||||
WHERE execution.test_id = $1::uuid
|
||||
LIMIT 2`,
|
||||
[testId],
|
||||
);
|
||||
if (selected.rows.length === 0) return null;
|
||||
if (selected.rows.length !== 1) {
|
||||
throw new ModelProviderCredentialTestExecutionConflictError();
|
||||
}
|
||||
const execution = normalizeStoredExecution(selected.rows[0]?.executionJson);
|
||||
const result =
|
||||
selected.rows[0]?.resultJson === null ||
|
||||
selected.rows[0]?.resultJson === undefined
|
||||
? null
|
||||
: normalizeStoredResult(selected.rows[0]?.resultJson);
|
||||
if (
|
||||
execution.testId !== testId ||
|
||||
(result !== null &&
|
||||
(result.testId !== testId ||
|
||||
result.executionId !== execution.executionId ||
|
||||
result.planDigest !== execution.planDigest))
|
||||
) {
|
||||
throw new ModelProviderCredentialTestExecutionConflictError();
|
||||
}
|
||||
return Object.freeze({ execution, result });
|
||||
}
|
||||
|
||||
async function insertExecution(
|
||||
client: PostgresClient,
|
||||
execution: Readonly<ModelProviderCredentialTestExecution>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3_ai"."model_provider_credential_test_executions" (
|
||||
execution_id, test_id, plan_digest, started_at_ms,
|
||||
execution_digest, execution_json
|
||||
) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6::jsonb)`,
|
||||
[
|
||||
execution.executionId,
|
||||
execution.testId,
|
||||
execution.planDigest,
|
||||
execution.startedAtMs,
|
||||
execution.executionDigest,
|
||||
JSON.stringify(execution),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function insertResult(
|
||||
client: PostgresClient,
|
||||
result: Readonly<ModelProviderCredentialTestResult>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3_ai"."model_provider_credential_test_results" (
|
||||
execution_id, test_id, plan_digest, outcome, model_count,
|
||||
duration_ms, completed_at_ms, result_digest, result_json
|
||||
) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
|
||||
[
|
||||
result.executionId,
|
||||
result.testId,
|
||||
result.planDigest,
|
||||
result.outcome,
|
||||
result.modelCount,
|
||||
result.durationMs,
|
||||
result.completedAtMs,
|
||||
result.resultDigest,
|
||||
JSON.stringify(result),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export class PostgresModelProviderCredentialTestExecutionRepository
|
||||
implements ModelProviderCredentialTestExecutionRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (!pool || typeof pool.connect !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL model provider credential test execution pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async beginExecution(
|
||||
value: BeginModelProviderCredentialTestExecutionInput,
|
||||
): Promise<Readonly<BeginModelProviderCredentialTestExecutionResult>> {
|
||||
exact(value, ['allowlist', 'executionId', 'testId']);
|
||||
if (
|
||||
typeof value.executionId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.executionId) ||
|
||||
typeof value.testId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(value.testId)
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialTestConnectionError();
|
||||
}
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw new ModelProviderCredentialTestExecutionUnavailableError({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||
await client.query(
|
||||
'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
|
||||
[
|
||||
JSON.stringify([
|
||||
value.testId,
|
||||
'model-provider-credential-test-execution',
|
||||
]),
|
||||
],
|
||||
);
|
||||
const { plan, observedAtMs } = await loadTestPlan(client, value.testId);
|
||||
confirmExecutionAllowlist(plan, value.allowlist);
|
||||
const stored = await loadExecutionState(client, value.testId);
|
||||
if (stored) {
|
||||
if (
|
||||
stored.execution.executionId !== value.executionId ||
|
||||
stored.execution.planDigest !== plan.planDigest
|
||||
) {
|
||||
throw new ModelProviderCredentialTestExecutionConflictError();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
plan,
|
||||
execution: stored.execution,
|
||||
result: stored.result,
|
||||
});
|
||||
}
|
||||
const execution = createModelProviderCredentialTestExecution({
|
||||
executionId: value.executionId,
|
||||
testId: value.testId,
|
||||
planDigest: plan.planDigest,
|
||||
startedAtMs: observedAtMs,
|
||||
});
|
||||
await insertExecution(client, execution);
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
plan,
|
||||
execution,
|
||||
result: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await rollback(client);
|
||||
throw executionError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async complete(
|
||||
value: ModelProviderCredentialTestResult,
|
||||
): Promise<Readonly<CompleteModelProviderCredentialTestExecutionResult>> {
|
||||
const candidate = normalizeModelProviderCredentialTestResult(value);
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw new ModelProviderCredentialTestExecutionUnavailableError({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||
await client.query(
|
||||
'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
|
||||
[
|
||||
JSON.stringify([
|
||||
candidate.testId,
|
||||
'model-provider-credential-test-execution',
|
||||
]),
|
||||
],
|
||||
);
|
||||
const stored = await loadExecutionState(client, candidate.testId);
|
||||
if (
|
||||
!stored ||
|
||||
stored.execution.executionId !== candidate.executionId ||
|
||||
stored.execution.planDigest !== candidate.planDigest
|
||||
) {
|
||||
throw new ModelProviderCredentialTestExecutionConflictError();
|
||||
}
|
||||
if (stored.result) {
|
||||
if (JSON.stringify(stored.result) !== JSON.stringify(candidate)) {
|
||||
throw new ModelProviderCredentialTestExecutionConflictError();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
result: stored.result,
|
||||
});
|
||||
}
|
||||
await insertResult(client, candidate);
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({ status: 'created' as const, result: candidate });
|
||||
} catch (error) {
|
||||
await rollback(client);
|
||||
throw executionError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import {
|
||||
InvalidModelProviderCredentialTestConnectionError,
|
||||
normalizeModelProviderCredentialTestPlan,
|
||||
type ModelProviderCredentialTestPlan,
|
||||
} from '../modelProviderCredentialTestConnection';
|
||||
import {
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_OPERATION_ID,
|
||||
ModelProviderCredentialTestPlanAuthorizationFenceConflictError,
|
||||
ModelProviderCredentialTestPlanConflictError,
|
||||
ModelProviderCredentialTestPlanQuotaExceededError,
|
||||
ModelProviderCredentialTestPlanUnavailableError,
|
||||
type AuthorizedModelProviderCredentialTestPlan,
|
||||
type CreateModelProviderCredentialTestPlanResult,
|
||||
type ModelProviderCredentialTestPlanRepository,
|
||||
type PostgresModelProviderCredentialTestPlanOptions,
|
||||
} from './contracts';
|
||||
import {
|
||||
UUID_V4_PATTERN,
|
||||
exact,
|
||||
integer,
|
||||
options,
|
||||
rollback,
|
||||
sqlState,
|
||||
text,
|
||||
type Row,
|
||||
} from './common';
|
||||
|
||||
function normalizeAuthorized(
|
||||
value: AuthorizedModelProviderCredentialTestPlan,
|
||||
): Readonly<AuthorizedModelProviderCredentialTestPlan> {
|
||||
exact(value, ['audit', 'plan']);
|
||||
try {
|
||||
const plan = normalizeModelProviderCredentialTestPlan(value.plan);
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
if (
|
||||
audit.eventId !== plan.testId ||
|
||||
audit.requestId !== plan.requestId ||
|
||||
audit.operationId !== MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_OPERATION_ID ||
|
||||
audit.projectId !== plan.projectId ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
audit.subject?.type !== 'user' ||
|
||||
audit.subject.id !== plan.requestedBy.id ||
|
||||
audit.authenticationId === null ||
|
||||
audit.fence?.projectVersion !== plan.fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== plan.fence.bindingVersion
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialTestConnectionError();
|
||||
}
|
||||
return Object.freeze({ plan, audit });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidModelProviderCredentialTestConnectionError) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidModelProviderCredentialTestConnectionError();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAuditRow(row: Row): Readonly<SecurityAuditRecord> {
|
||||
if (!Array.isArray(row.reasons)) {
|
||||
throw new ModelProviderCredentialTestPlanUnavailableError();
|
||||
}
|
||||
try {
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: text(row.eventId, UUID_V4_PATTERN),
|
||||
requestId: text(row.requestId),
|
||||
operationId: text(row.operationId),
|
||||
projectId: text(row.projectId),
|
||||
subject: {
|
||||
type: text(row.subjectType) as 'user',
|
||||
id: text(row.subjectId),
|
||||
},
|
||||
authenticationId: text(row.authenticationId),
|
||||
outcome: text(row.outcome) as SecurityAuditRecord['outcome'],
|
||||
reasons: row.reasons as string[],
|
||||
fence: {
|
||||
projectVersion: integer(row.projectVersion, 1),
|
||||
bindingVersion: integer(row.bindingVersion, 1),
|
||||
},
|
||||
occurredAtMs: integer(row.occurredAtMs),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ModelProviderCredentialTestPlanUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ModelProviderCredentialTestPlanUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function semanticAuditEqual(
|
||||
left: Readonly<SecurityAuditRecord>,
|
||||
right: Readonly<SecurityAuditRecord>,
|
||||
): boolean {
|
||||
const { occurredAtMs: _leftTime, ...leftSemantic } = left;
|
||||
const { occurredAtMs: _rightTime, ...rightSemantic } = right;
|
||||
return JSON.stringify(leftSemantic) === JSON.stringify(rightSemantic);
|
||||
}
|
||||
|
||||
function samePlan(
|
||||
left: Readonly<ModelProviderCredentialTestPlan>,
|
||||
right: Readonly<ModelProviderCredentialTestPlan>,
|
||||
): boolean {
|
||||
const {
|
||||
planDigest: _leftPlanDigest,
|
||||
plannedAtMs: leftPlannedAtMs,
|
||||
expiresAtMs: leftExpiresAtMs,
|
||||
...leftIdentity
|
||||
} = left;
|
||||
const {
|
||||
planDigest: _rightPlanDigest,
|
||||
plannedAtMs: rightPlannedAtMs,
|
||||
expiresAtMs: rightExpiresAtMs,
|
||||
...rightIdentity
|
||||
} = right;
|
||||
return (
|
||||
leftExpiresAtMs - leftPlannedAtMs === rightExpiresAtMs - rightPlannedAtMs &&
|
||||
JSON.stringify(leftIdentity) === JSON.stringify(rightIdentity)
|
||||
);
|
||||
}
|
||||
|
||||
function mapError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidModelProviderCredentialTestConnectionError ||
|
||||
error instanceof
|
||||
ModelProviderCredentialTestPlanAuthorizationFenceConflictError ||
|
||||
error instanceof ModelProviderCredentialTestPlanConflictError ||
|
||||
error instanceof ModelProviderCredentialTestPlanQuotaExceededError ||
|
||||
error instanceof ModelProviderCredentialTestPlanUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (['23503', '23505', '23514', '40001', '40P01'].includes(sqlState(error))) {
|
||||
return new ModelProviderCredentialTestPlanConflictError();
|
||||
}
|
||||
return new ModelProviderCredentialTestPlanUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmFence(
|
||||
client: PostgresClient,
|
||||
value: Readonly<AuthorizedModelProviderCredentialTestPlan>,
|
||||
): Promise<void> {
|
||||
const project = await client.query<Row>(
|
||||
`SELECT status, version FROM "ql3"."projects" WHERE id = $1`,
|
||||
[value.plan.projectId],
|
||||
);
|
||||
const binding = await client.query<Row>(
|
||||
`SELECT version, state
|
||||
FROM "ql3"."project_role_bindings"
|
||||
WHERE project_id = $1 AND subject_type = 'user' AND subject_id = $2
|
||||
ORDER BY version DESC
|
||||
LIMIT 1`,
|
||||
[value.plan.projectId, value.plan.requestedBy.id],
|
||||
);
|
||||
if (
|
||||
project.rows.length !== 1 ||
|
||||
project.rows[0]?.status !== 'active' ||
|
||||
integer(project.rows[0]?.version, 1) !== value.plan.fence.projectVersion ||
|
||||
binding.rows.length !== 1 ||
|
||||
binding.rows[0]?.state !== 'active' ||
|
||||
integer(binding.rows[0]?.version, 1) !== value.plan.fence.bindingVersion
|
||||
) {
|
||||
throw new ModelProviderCredentialTestPlanAuthorizationFenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async function plansByIdentity(
|
||||
client: PostgresClient,
|
||||
plan: Readonly<ModelProviderCredentialTestPlan>,
|
||||
): Promise<readonly Readonly<ModelProviderCredentialTestPlan>[]> {
|
||||
const result = await client.query<Row>(
|
||||
`SELECT plan_json AS "planJson"
|
||||
FROM "ql3_ai"."model_provider_credential_test_plans"
|
||||
WHERE test_id = $1::uuid
|
||||
OR (project_id = $2 AND request_id = $3)
|
||||
LIMIT 3`,
|
||||
[plan.testId, plan.projectId, plan.requestId],
|
||||
);
|
||||
try {
|
||||
return Object.freeze(
|
||||
result.rows.map((row) =>
|
||||
normalizeModelProviderCredentialTestPlan(
|
||||
row.planJson as ModelProviderCredentialTestPlan,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ModelProviderCredentialTestPlanUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function auditById(
|
||||
client: PostgresClient,
|
||||
eventId: string,
|
||||
): Promise<Readonly<SecurityAuditRecord> | null> {
|
||||
const result = await client.query<Row>(
|
||||
`SELECT event_id AS "eventId", request_id AS "requestId",
|
||||
operation_id AS "operationId", project_id AS "projectId",
|
||||
subject_type AS "subjectType", subject_id AS "subjectId",
|
||||
authentication_id AS "authenticationId", outcome, reasons,
|
||||
project_version AS "projectVersion",
|
||||
binding_version AS "bindingVersion",
|
||||
occurred_at_ms AS "occurredAtMs"
|
||||
FROM "ql3"."security_audit_events"
|
||||
WHERE event_id = $1::uuid
|
||||
LIMIT 2`,
|
||||
[eventId],
|
||||
);
|
||||
if (result.rows.length > 1) {
|
||||
throw new ModelProviderCredentialTestPlanConflictError();
|
||||
}
|
||||
return result.rows[0] ? normalizeAuditRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async function quotaHasReceipt(
|
||||
client: PostgresClient,
|
||||
plan: Readonly<ModelProviderCredentialTestPlan>,
|
||||
): Promise<boolean> {
|
||||
const result = await client.query<Row>(
|
||||
`SELECT receipt_ids ? $3::text AS "hasReceipt"
|
||||
FROM "ql3_ai"."model_provider_credential_test_quota_buckets"
|
||||
WHERE project_id = $1 AND subject_id = $2`,
|
||||
[plan.projectId, plan.requestedBy.id, plan.testId],
|
||||
);
|
||||
return result.rows.length === 1 && result.rows[0]?.hasReceipt === true;
|
||||
}
|
||||
|
||||
async function consumeQuota(
|
||||
client: PostgresClient,
|
||||
plan: Readonly<ModelProviderCredentialTestPlan>,
|
||||
quotaWindowMs: number,
|
||||
quotaLimit: number,
|
||||
): Promise<void> {
|
||||
let result = await client.query<Row>(
|
||||
`WITH database_clock AS (
|
||||
SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AS now_ms
|
||||
)
|
||||
INSERT INTO "ql3_ai"."model_provider_credential_test_quota_buckets" (
|
||||
project_id, subject_id, window_started_at_ms, consumed_count,
|
||||
receipt_ids, updated_at_ms
|
||||
)
|
||||
SELECT $1, $2, (now_ms / $4::bigint) * $4::bigint, 1,
|
||||
jsonb_build_array($3::text), now_ms
|
||||
FROM database_clock
|
||||
ON CONFLICT (project_id, subject_id)
|
||||
DO UPDATE SET
|
||||
window_started_at_ms = CASE
|
||||
WHEN model_provider_credential_test_quota_buckets.window_started_at_ms
|
||||
+ $4::bigint <= EXCLUDED.updated_at_ms
|
||||
THEN (EXCLUDED.updated_at_ms / $4::bigint) * $4::bigint
|
||||
ELSE model_provider_credential_test_quota_buckets.window_started_at_ms
|
||||
END,
|
||||
consumed_count = CASE
|
||||
WHEN model_provider_credential_test_quota_buckets.window_started_at_ms
|
||||
+ $4::bigint <= EXCLUDED.updated_at_ms THEN 1
|
||||
WHEN model_provider_credential_test_quota_buckets.receipt_ids ? $3::text
|
||||
THEN model_provider_credential_test_quota_buckets.consumed_count
|
||||
ELSE model_provider_credential_test_quota_buckets.consumed_count + 1
|
||||
END,
|
||||
receipt_ids = CASE
|
||||
WHEN model_provider_credential_test_quota_buckets.window_started_at_ms
|
||||
+ $4::bigint <= EXCLUDED.updated_at_ms
|
||||
THEN jsonb_build_array($3::text)
|
||||
WHEN model_provider_credential_test_quota_buckets.receipt_ids ? $3::text
|
||||
THEN model_provider_credential_test_quota_buckets.receipt_ids
|
||||
ELSE model_provider_credential_test_quota_buckets.receipt_ids ||
|
||||
jsonb_build_array($3::text)
|
||||
END,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
WHERE model_provider_credential_test_quota_buckets.window_started_at_ms
|
||||
+ $4::bigint <= EXCLUDED.updated_at_ms
|
||||
OR model_provider_credential_test_quota_buckets.receipt_ids ? $3::text
|
||||
OR model_provider_credential_test_quota_buckets.consumed_count < $5
|
||||
RETURNING consumed_count AS "consumedCount",
|
||||
window_started_at_ms + $4::bigint AS "resetAtMs",
|
||||
updated_at_ms AS "observedAtMs"`,
|
||||
[
|
||||
plan.projectId,
|
||||
plan.requestedBy.id,
|
||||
plan.testId,
|
||||
quotaWindowMs,
|
||||
quotaLimit,
|
||||
],
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
result = await client.query<Row>(
|
||||
`WITH database_clock AS (
|
||||
SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AS now_ms
|
||||
)
|
||||
SELECT consumed_count AS "consumedCount",
|
||||
window_started_at_ms + $3::bigint AS "resetAtMs",
|
||||
database_clock.now_ms AS "observedAtMs"
|
||||
FROM "ql3_ai"."model_provider_credential_test_quota_buckets"
|
||||
CROSS JOIN database_clock
|
||||
WHERE project_id = $1 AND subject_id = $2
|
||||
LIMIT 2`,
|
||||
[plan.projectId, plan.requestedBy.id, quotaWindowMs],
|
||||
);
|
||||
if (result.rows.length !== 1) {
|
||||
throw new ModelProviderCredentialTestPlanUnavailableError();
|
||||
}
|
||||
const retryAfterMs = Math.max(
|
||||
1,
|
||||
integer(result.rows[0]?.resetAtMs) -
|
||||
integer(result.rows[0]?.observedAtMs),
|
||||
);
|
||||
throw new ModelProviderCredentialTestPlanQuotaExceededError(retryAfterMs);
|
||||
}
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
integer(result.rows[0]?.consumedCount, 1) > quotaLimit
|
||||
) {
|
||||
throw new ModelProviderCredentialTestPlanUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
async function insertPlan(
|
||||
client: PostgresClient,
|
||||
plan: Readonly<ModelProviderCredentialTestPlan>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3_ai"."model_provider_credential_test_plans" (
|
||||
test_id, request_id, project_id, provider, adapter, base_url,
|
||||
endpoint_revision, endpoint_config_digest, deadline_ms,
|
||||
max_response_bytes, max_models, max_cost_microusd, retry_limit,
|
||||
requested_by_type, requested_by_id, project_version, binding_version,
|
||||
planned_at_ms, expires_at_ms, plan_digest, plan_json
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||
$13, $14, $15, $16, $17, $18, $19, $20, $21::jsonb
|
||||
)`,
|
||||
[
|
||||
plan.testId,
|
||||
plan.requestId,
|
||||
plan.projectId,
|
||||
plan.provider,
|
||||
plan.endpoint.adapter,
|
||||
plan.endpoint.baseUrl,
|
||||
plan.endpoint.revision,
|
||||
plan.endpoint.configDigest,
|
||||
plan.endpoint.deadlineMs,
|
||||
plan.endpoint.maxResponseBytes,
|
||||
plan.endpoint.maxModels,
|
||||
plan.endpoint.maxCostMicrousd,
|
||||
plan.endpoint.retryLimit,
|
||||
plan.requestedBy.type,
|
||||
plan.requestedBy.id,
|
||||
plan.fence.projectVersion,
|
||||
plan.fence.bindingVersion,
|
||||
plan.plannedAtMs,
|
||||
plan.expiresAtMs,
|
||||
plan.planDigest,
|
||||
JSON.stringify(plan),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function insertAudit(
|
||||
client: PostgresClient,
|
||||
audit: Readonly<SecurityAuditRecord>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id, request_id, operation_id, project_id, subject_type,
|
||||
subject_id, authentication_id, outcome, reasons, project_version,
|
||||
binding_version, occurred_at_ms
|
||||
) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)`,
|
||||
[
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export class PostgresModelProviderCredentialTestPlanRepository
|
||||
implements ModelProviderCredentialTestPlanRepository
|
||||
{
|
||||
readonly #quotaWindowMs: number;
|
||||
readonly #quotaLimit: number;
|
||||
|
||||
constructor(
|
||||
private readonly pool: PostgresPool,
|
||||
optionValue?: PostgresModelProviderCredentialTestPlanOptions,
|
||||
) {
|
||||
if (!pool || typeof pool.connect !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL model provider credential test plan pool is invalid',
|
||||
);
|
||||
}
|
||||
const reviewed = options(optionValue);
|
||||
this.#quotaWindowMs = reviewed.quotaWindowMs;
|
||||
this.#quotaLimit = reviewed.quotaLimit;
|
||||
}
|
||||
|
||||
async createAuthorized(
|
||||
value: AuthorizedModelProviderCredentialTestPlan,
|
||||
): Promise<Readonly<CreateModelProviderCredentialTestPlanResult>> {
|
||||
const authorized = normalizeAuthorized(value);
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw new ModelProviderCredentialTestPlanUnavailableError({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||
await client.query(
|
||||
'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
|
||||
[
|
||||
JSON.stringify([
|
||||
authorized.plan.projectId,
|
||||
'model-provider-credential-test-plan',
|
||||
authorized.plan.requestedBy.id,
|
||||
]),
|
||||
],
|
||||
);
|
||||
await confirmFence(client, authorized);
|
||||
const storedPlans = await plansByIdentity(client, authorized.plan);
|
||||
const storedAudit = await auditById(client, authorized.audit.eventId);
|
||||
if (storedPlans.length > 0 || storedAudit) {
|
||||
if (
|
||||
storedPlans.length !== 1 ||
|
||||
!samePlan(storedPlans[0]!, authorized.plan) ||
|
||||
!storedAudit ||
|
||||
!semanticAuditEqual(storedAudit, authorized.audit) ||
|
||||
!(await quotaHasReceipt(client, authorized.plan))
|
||||
) {
|
||||
throw new ModelProviderCredentialTestPlanConflictError();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
plan: storedPlans[0]!,
|
||||
});
|
||||
}
|
||||
await consumeQuota(
|
||||
client,
|
||||
authorized.plan,
|
||||
this.#quotaWindowMs,
|
||||
this.#quotaLimit,
|
||||
);
|
||||
await insertPlan(client, authorized.plan);
|
||||
await insertAudit(client, authorized.audit);
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
plan: authorized.plan,
|
||||
});
|
||||
} catch (error) {
|
||||
await rollback(client);
|
||||
throw mapError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID,
|
||||
postgresModelInvocationMigrationDefinition,
|
||||
} from '../../migration/modelInvocationMigration';
|
||||
import {
|
||||
PostgresModelProviderCredentialTesterNotReadyError,
|
||||
type PostgresModelProviderCredentialTesterReadinessReport,
|
||||
} from './contracts';
|
||||
import type { Row } from './common';
|
||||
|
||||
export async function assertPostgresModelProviderCredentialTesterReady(
|
||||
pool: PostgresPool,
|
||||
): Promise<Readonly<PostgresModelProviderCredentialTesterReadinessReport>> {
|
||||
if (!pool || typeof pool.query !== 'function') {
|
||||
throw new PostgresModelProviderCredentialTesterNotReadyError();
|
||||
}
|
||||
try {
|
||||
const history = await pool.query<Row>(
|
||||
`SELECT migration_id AS "migrationId", stream_id AS "streamId",
|
||||
dialect, checksum
|
||||
FROM "ql3_ai"."ai_schema_migrations"
|
||||
ORDER BY migration_id`,
|
||||
);
|
||||
const expected = postgresModelInvocationMigrationDefinition.migrations;
|
||||
if (
|
||||
history.rows.length !== expected.length ||
|
||||
history.rows.some(
|
||||
(row, index) =>
|
||||
row.migrationId !== expected[index]!.id ||
|
||||
row.streamId !== POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID ||
|
||||
row.dialect !== 'postgresql' ||
|
||||
row.checksum !== expected[index]!.checksum,
|
||||
)
|
||||
) {
|
||||
throw new Error('migration history is invalid');
|
||||
}
|
||||
const facts = await pool.query<Row>(
|
||||
`SELECT current_user AS "currentUser",
|
||||
(NOT pg_is_in_recovery()) AND
|
||||
current_setting('transaction_read_only') = 'off'
|
||||
AS "writablePrimary",
|
||||
pg_has_role(current_user, 'ql3_ai_credential_tester', 'member') AND
|
||||
NOT pg_has_role(current_user, 'ql3_ai_credential_manager', 'member') AND
|
||||
NOT pg_has_role(current_user, 'ql3_ai_maintenance', 'member') AND
|
||||
NOT pg_has_role(current_user, 'ql3_runtime', 'member') AND
|
||||
NOT pg_has_role(current_user, 'ql3_admin', 'member')
|
||||
AS "testerAuthority",
|
||||
has_schema_privilege(current_user, 'ql3_ai', 'USAGE') AND
|
||||
NOT has_schema_privilege(current_user, 'ql3_ai', 'CREATE') AND
|
||||
has_table_privilege(current_user, 'ql3_ai.ai_schema_migrations', 'SELECT') AND
|
||||
NOT has_table_privilege(current_user, 'ql3_ai.ai_schema_migrations', 'INSERT,UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, 'ql3_ai.model_provider_credential_bindings', 'SELECT') AND
|
||||
NOT has_table_privilege(current_user, 'ql3_ai.model_provider_credential_bindings', 'INSERT,UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, 'ql3_ai.model_provider_credential_transitions', 'SELECT') AND
|
||||
NOT has_table_privilege(current_user, 'ql3_ai.model_provider_credential_transitions', 'INSERT,UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, 'ql3_ai.model_provider_credential_audits', 'SELECT,INSERT') AND
|
||||
NOT has_table_privilege(current_user, 'ql3_ai.model_provider_credential_audits', 'UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, 'ql3_ai.model_provider_credential_test_plans', 'SELECT') AND
|
||||
NOT has_table_privilege(current_user, 'ql3_ai.model_provider_credential_test_plans', 'INSERT,UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, 'ql3_ai.model_provider_credential_test_executions', 'SELECT,INSERT') AND
|
||||
NOT has_table_privilege(current_user, 'ql3_ai.model_provider_credential_test_executions', 'UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, 'ql3_ai.model_provider_credential_test_results', 'SELECT,INSERT') AND
|
||||
NOT has_table_privilege(current_user, 'ql3_ai.model_provider_credential_test_results', 'UPDATE,DELETE') AND
|
||||
NOT has_table_privilege(current_user, 'ql3_ai.model_provider_credential_test_quota_buckets', 'SELECT,INSERT,UPDATE,DELETE') AND
|
||||
NOT has_schema_privilege(current_user, 'ql3', 'USAGE') AND
|
||||
NOT has_table_privilege(
|
||||
current_user,
|
||||
(
|
||||
SELECT relation.oid
|
||||
FROM pg_catalog.pg_class AS relation
|
||||
JOIN pg_catalog.pg_namespace AS namespace
|
||||
ON namespace.oid = relation.relnamespace
|
||||
WHERE namespace.nspname = 'ql3'
|
||||
AND relation.relname = 'security_audit_events'
|
||||
),
|
||||
'SELECT,INSERT,UPDATE,DELETE'
|
||||
) AND
|
||||
NOT has_table_privilege(current_user, 'ql3_ai.model_invocation_prompt_output_artifacts', 'SELECT,INSERT,UPDATE,DELETE')
|
||||
AS "leastPrivilege"`,
|
||||
);
|
||||
const row = facts.rows[0];
|
||||
if (
|
||||
facts.rows.length !== 1 ||
|
||||
typeof row?.currentUser !== 'string' ||
|
||||
row.writablePrimary !== true ||
|
||||
row.testerAuthority !== true ||
|
||||
row.leastPrivilege !== true
|
||||
) {
|
||||
throw new Error('tester authority is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
ready: true as const,
|
||||
currentUser: row.currentUser,
|
||||
migrationIds: Object.freeze(expected.map(({ id }) => id)),
|
||||
writablePrimary: true as const,
|
||||
testerAuthority: true as const,
|
||||
leastPrivilege: true as const,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new PostgresModelProviderCredentialTesterNotReadyError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
|
||||
import { normalizeProjectPolicySubject } from '@qinglong/runtime-core/project-policy';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
// Durable audit read model owned by the credential-management capability.
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const MANAGEMENT_OPERATIONS = Object.freeze([
|
||||
'model_provider_credential.bind',
|
||||
'model_provider_credential.revoke',
|
||||
] as const);
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export const MAX_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUDIT_PAGE_SIZE = 32;
|
||||
export const MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUDIT_QUERY_OPERATION_ID =
|
||||
'model_provider_credential.audit.list';
|
||||
|
||||
export interface ModelProviderCredentialManagementAuditCursor {
|
||||
readonly occurredAtMs: number;
|
||||
readonly eventId: string;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialManagementAuditQuery {
|
||||
readonly schemaVersion: 1;
|
||||
readonly queryId: string;
|
||||
readonly requestId: string;
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly before?: ModelProviderCredentialManagementAuditCursor;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialManagementAuditRecord {
|
||||
readonly eventId: string;
|
||||
readonly requestId: string;
|
||||
readonly operation: 'provider-credential.bind' | 'provider-credential.revoke';
|
||||
readonly actor: Readonly<{ type: 'user'; id: string }>;
|
||||
readonly fence: Readonly<{
|
||||
projectVersion: number;
|
||||
bindingVersion: number;
|
||||
}>;
|
||||
readonly occurredAtMs: number;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialManagementAuditPage {
|
||||
readonly projectId: string;
|
||||
readonly records: readonly Readonly<ModelProviderCredentialManagementAuditRecord>[];
|
||||
readonly nextCursor: Readonly<ModelProviderCredentialManagementAuditCursor> | null;
|
||||
}
|
||||
|
||||
export interface AuthorizedModelProviderCredentialManagementAuditQuery {
|
||||
readonly query: ModelProviderCredentialManagementAuditQuery;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialManagementAuditQueryRepository {
|
||||
listAuthorized(
|
||||
query: AuthorizedModelProviderCredentialManagementAuditQuery,
|
||||
): Promise<Readonly<ModelProviderCredentialManagementAuditPage>>;
|
||||
}
|
||||
|
||||
export class InvalidModelProviderCredentialManagementAuditQueryError extends TypeError {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUDIT_QUERY_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Model provider credential management audit query is invalid');
|
||||
this.name = 'InvalidModelProviderCredentialManagementAuditQueryError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialManagementAuditAuthorizationFenceConflictError extends Error {
|
||||
readonly code =
|
||||
'MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUDIT_AUTHORIZATION_FENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Model provider credential management audit authorization fence changed',
|
||||
);
|
||||
this.name =
|
||||
'ModelProviderCredentialManagementAuditAuthorizationFenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialManagementAuditConflictError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUDIT_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Model provider credential management audit query conflicts with durable state',
|
||||
);
|
||||
this.name = 'ModelProviderCredentialManagementAuditConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialManagementAuditUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUDIT_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Model provider credential management audit is unavailable', options);
|
||||
this.name = 'ModelProviderCredentialManagementAuditUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new InvalidModelProviderCredentialManagementAuditQueryError();
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number {
|
||||
const normalized =
|
||||
typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)
|
||||
? Number(value)
|
||||
: value;
|
||||
if (!Number.isSafeInteger(normalized) || (normalized as number) < 1) {
|
||||
throw new ModelProviderCredentialManagementAuditUnavailableError();
|
||||
}
|
||||
return normalized as number;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number {
|
||||
const normalized =
|
||||
typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)
|
||||
? Number(value)
|
||||
: value;
|
||||
if (!Number.isSafeInteger(normalized) || (normalized as number) < 0) {
|
||||
throw new ModelProviderCredentialManagementAuditUnavailableError();
|
||||
}
|
||||
return normalized as number;
|
||||
}
|
||||
|
||||
function string(value: unknown, pattern: RegExp = IDENTITY_PATTERN): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) {
|
||||
throw new ModelProviderCredentialManagementAuditUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeFence(value: SecurityPolicyFence): Readonly<{
|
||||
projectVersion: number;
|
||||
bindingVersion: number;
|
||||
}> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['bindingVersion', 'projectVersion']) ||
|
||||
!Number.isSafeInteger(value.projectVersion) ||
|
||||
value.projectVersion < 1 ||
|
||||
!Number.isSafeInteger(value.bindingVersion) ||
|
||||
(value.bindingVersion as number) < 1
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
projectVersion: value.projectVersion,
|
||||
bindingVersion: value.bindingVersion as number,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelProviderCredentialManagementAuditQuery(
|
||||
value: ModelProviderCredentialManagementAuditQuery,
|
||||
): Readonly<ModelProviderCredentialManagementAuditQuery> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(
|
||||
value,
|
||||
value.before === undefined
|
||||
? ['limit', 'projectId', 'queryId', 'requestId', 'schemaVersion']
|
||||
: [
|
||||
'before',
|
||||
'limit',
|
||||
'projectId',
|
||||
'queryId',
|
||||
'requestId',
|
||||
'schemaVersion',
|
||||
],
|
||||
) ||
|
||||
value.schemaVersion !== 1 ||
|
||||
!UUID_V4_PATTERN.test(value.queryId) ||
|
||||
!IDENTITY_PATTERN.test(value.requestId) ||
|
||||
!IDENTITY_PATTERN.test(value.projectId) ||
|
||||
!Number.isSafeInteger(value.limit) ||
|
||||
value.limit < 1 ||
|
||||
value.limit > MAX_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUDIT_PAGE_SIZE
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
let before:
|
||||
| Readonly<ModelProviderCredentialManagementAuditCursor>
|
||||
| undefined;
|
||||
if (value.before !== undefined) {
|
||||
if (
|
||||
!value.before ||
|
||||
typeof value.before !== 'object' ||
|
||||
Array.isArray(value.before) ||
|
||||
!exactKeys(value.before, ['eventId', 'occurredAtMs']) ||
|
||||
!Number.isSafeInteger(value.before.occurredAtMs) ||
|
||||
value.before.occurredAtMs < 0 ||
|
||||
!UUID_V4_PATTERN.test(value.before.eventId)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
before = Object.freeze({ ...value.before });
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
queryId: value.queryId,
|
||||
requestId: value.requestId,
|
||||
projectId: value.projectId,
|
||||
limit: value.limit,
|
||||
...(before ? { before } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
export function normalizeAuthorizedModelProviderCredentialManagementAuditQuery(
|
||||
value: AuthorizedModelProviderCredentialManagementAuditQuery,
|
||||
): Readonly<AuthorizedModelProviderCredentialManagementAuditQuery> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['actor', 'audit', 'fence', 'query'])
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
try {
|
||||
const query = normalizeModelProviderCredentialManagementAuditQuery(
|
||||
value.query,
|
||||
);
|
||||
const actor = normalizeProjectPolicySubject(value.actor);
|
||||
const fence = normalizeFence(value.fence);
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
if (
|
||||
actor.type !== 'user' ||
|
||||
audit.eventId !== query.queryId ||
|
||||
audit.requestId !== query.requestId ||
|
||||
audit.operationId !==
|
||||
MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_AUDIT_QUERY_OPERATION_ID ||
|
||||
audit.projectId !== query.projectId ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!audit.subject ||
|
||||
!sameSubject(audit.subject, actor) ||
|
||||
audit.authenticationId === null ||
|
||||
!audit.fence ||
|
||||
audit.fence.projectVersion !== fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== fence.bindingVersion
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze({ query, actor, fence, audit });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidModelProviderCredentialManagementAuditQueryError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function auditRecordFromRow(row: Row): Readonly<SecurityAuditRecord> {
|
||||
const reasons = row.reasons;
|
||||
if (!Array.isArray(reasons)) {
|
||||
throw new ModelProviderCredentialManagementAuditUnavailableError();
|
||||
}
|
||||
try {
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: string(row.eventId, UUID_V4_PATTERN),
|
||||
requestId: string(row.requestId),
|
||||
operationId: string(row.operationId),
|
||||
projectId: string(row.projectId),
|
||||
subject: {
|
||||
type: string(row.subjectType) as NonNullable<
|
||||
SecurityAuditRecord['subject']
|
||||
>['type'],
|
||||
id: string(row.subjectId),
|
||||
},
|
||||
authenticationId: string(row.authenticationId),
|
||||
outcome: string(row.outcome) as SecurityAuditRecord['outcome'],
|
||||
reasons: reasons as string[],
|
||||
fence: {
|
||||
projectVersion: positiveInteger(row.projectVersion),
|
||||
bindingVersion: positiveInteger(row.bindingVersion),
|
||||
},
|
||||
occurredAtMs: nonNegativeInteger(row.occurredAtMs),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ModelProviderCredentialManagementAuditUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ModelProviderCredentialManagementAuditUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function contentFreeRecord(
|
||||
audit: Readonly<SecurityAuditRecord>,
|
||||
): Readonly<ModelProviderCredentialManagementAuditRecord> {
|
||||
if (
|
||||
!MANAGEMENT_OPERATIONS.includes(
|
||||
audit.operationId as (typeof MANAGEMENT_OPERATIONS)[number],
|
||||
) ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
audit.subject?.type !== 'user' ||
|
||||
!audit.fence ||
|
||||
audit.fence.bindingVersion === null
|
||||
) {
|
||||
throw new ModelProviderCredentialManagementAuditUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
eventId: audit.eventId,
|
||||
requestId: audit.requestId,
|
||||
operation:
|
||||
audit.operationId === 'model_provider_credential.bind'
|
||||
? ('provider-credential.bind' as const)
|
||||
: ('provider-credential.revoke' as const),
|
||||
actor: Object.freeze({ type: 'user' as const, id: audit.subject.id }),
|
||||
fence: Object.freeze({
|
||||
projectVersion: audit.fence.projectVersion,
|
||||
bindingVersion: audit.fence.bindingVersion,
|
||||
}),
|
||||
occurredAtMs: audit.occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
async function rollback(client: PostgresClient): Promise<void> {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
}
|
||||
|
||||
function sqlState(error: unknown): string {
|
||||
if (!error || typeof error !== 'object') return '';
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : '';
|
||||
}
|
||||
|
||||
function semanticAuditEqual(
|
||||
left: Readonly<SecurityAuditRecord>,
|
||||
right: Readonly<SecurityAuditRecord>,
|
||||
): boolean {
|
||||
const { occurredAtMs: _leftTime, ...leftSemantic } = left;
|
||||
const { occurredAtMs: _rightTime, ...rightSemantic } = right;
|
||||
return JSON.stringify(leftSemantic) === JSON.stringify(rightSemantic);
|
||||
}
|
||||
|
||||
async function confirmFence(
|
||||
client: PostgresClient,
|
||||
value: Readonly<AuthorizedModelProviderCredentialManagementAuditQuery>,
|
||||
): Promise<void> {
|
||||
const project = await client.query<Row>(
|
||||
`SELECT status, version FROM "ql3"."projects" WHERE id = $1`,
|
||||
[value.query.projectId],
|
||||
);
|
||||
const binding = await client.query<Row>(
|
||||
`SELECT version, state
|
||||
FROM "ql3"."project_role_bindings"
|
||||
WHERE project_id = $1 AND subject_type = $2 AND subject_id = $3
|
||||
ORDER BY version DESC
|
||||
LIMIT 1`,
|
||||
[value.query.projectId, value.actor.type, value.actor.id],
|
||||
);
|
||||
if (
|
||||
project.rows.length !== 1 ||
|
||||
project.rows[0]?.status !== 'active' ||
|
||||
positiveInteger(project.rows[0]?.version) !== value.fence.projectVersion ||
|
||||
binding.rows.length !== 1 ||
|
||||
binding.rows[0]?.state !== 'active' ||
|
||||
positiveInteger(binding.rows[0]?.version) !== value.fence.bindingVersion
|
||||
) {
|
||||
throw new ModelProviderCredentialManagementAuditAuthorizationFenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async function auditById(
|
||||
client: PostgresClient,
|
||||
eventId: string,
|
||||
): Promise<Readonly<SecurityAuditRecord> | null> {
|
||||
const result = await client.query<Row>(
|
||||
`SELECT event_id AS "eventId", request_id AS "requestId",
|
||||
operation_id AS "operationId", project_id AS "projectId",
|
||||
subject_type AS "subjectType", subject_id AS "subjectId",
|
||||
authentication_id AS "authenticationId", outcome, reasons,
|
||||
project_version AS "projectVersion",
|
||||
binding_version AS "bindingVersion",
|
||||
occurred_at_ms AS "occurredAtMs"
|
||||
FROM "ql3"."security_audit_events"
|
||||
WHERE event_id = $1
|
||||
LIMIT 2`,
|
||||
[eventId],
|
||||
);
|
||||
if (result.rows.length > 1) {
|
||||
throw new ModelProviderCredentialManagementAuditConflictError();
|
||||
}
|
||||
return result.rows[0] ? auditRecordFromRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async function insertAudit(
|
||||
client: PostgresClient,
|
||||
audit: Readonly<SecurityAuditRecord>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id, request_id, operation_id, project_id, subject_type,
|
||||
subject_id, authentication_id, outcome, reasons, project_version,
|
||||
binding_version, occurred_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)`,
|
||||
[
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function mapError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidModelProviderCredentialManagementAuditQueryError ||
|
||||
error instanceof
|
||||
ModelProviderCredentialManagementAuditAuthorizationFenceConflictError ||
|
||||
error instanceof ModelProviderCredentialManagementAuditConflictError ||
|
||||
error instanceof ModelProviderCredentialManagementAuditUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (['23503', '23505', '23514', '40001', '40P01'].includes(sqlState(error))) {
|
||||
return new ModelProviderCredentialManagementAuditConflictError();
|
||||
}
|
||||
return new ModelProviderCredentialManagementAuditUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class PostgresModelProviderCredentialManagementAuditQueryRepository
|
||||
implements ModelProviderCredentialManagementAuditQueryRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (!pool || typeof pool.connect !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL model provider credential management audit pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listAuthorized(
|
||||
input: AuthorizedModelProviderCredentialManagementAuditQuery,
|
||||
): Promise<Readonly<ModelProviderCredentialManagementAuditPage>> {
|
||||
const authorized =
|
||||
normalizeAuthorizedModelProviderCredentialManagementAuditQuery(input);
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw new ModelProviderCredentialManagementAuditUnavailableError({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||
await client.query(
|
||||
'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
|
||||
[
|
||||
JSON.stringify([
|
||||
authorized.query.projectId,
|
||||
'model-provider-credential-audit-query',
|
||||
authorized.actor.type,
|
||||
authorized.actor.id,
|
||||
]),
|
||||
],
|
||||
);
|
||||
await confirmFence(client, authorized);
|
||||
const storedAudit = await auditById(client, authorized.audit.eventId);
|
||||
if (storedAudit && !semanticAuditEqual(storedAudit, authorized.audit)) {
|
||||
throw new ModelProviderCredentialManagementAuditConflictError();
|
||||
}
|
||||
const values: unknown[] = [authorized.query.projectId];
|
||||
const before = authorized.query.before;
|
||||
let cursor = '';
|
||||
if (before) {
|
||||
values.push(before.occurredAtMs, before.eventId);
|
||||
cursor = 'AND (occurred_at_ms, event_id) < ($2, $3::uuid)';
|
||||
}
|
||||
values.push(authorized.query.limit + 1);
|
||||
const limitParameter = `$${values.length}`;
|
||||
const result = await client.query<Row>(
|
||||
`SELECT event_id AS "eventId", request_id AS "requestId",
|
||||
operation_id AS "operationId", project_id AS "projectId",
|
||||
subject_type AS "subjectType", subject_id AS "subjectId",
|
||||
authentication_id AS "authenticationId", outcome, reasons,
|
||||
project_version AS "projectVersion",
|
||||
binding_version AS "bindingVersion",
|
||||
occurred_at_ms AS "occurredAtMs"
|
||||
FROM "ql3"."security_audit_events"
|
||||
WHERE project_id = $1
|
||||
AND operation_id IN (
|
||||
'model_provider_credential.bind',
|
||||
'model_provider_credential.revoke'
|
||||
)
|
||||
${cursor}
|
||||
ORDER BY occurred_at_ms DESC, event_id DESC
|
||||
LIMIT ${limitParameter}`,
|
||||
values,
|
||||
);
|
||||
const hasMore = result.rows.length > authorized.query.limit;
|
||||
const records = Object.freeze(
|
||||
result.rows
|
||||
.slice(0, authorized.query.limit)
|
||||
.map((row) => contentFreeRecord(auditRecordFromRow(row))),
|
||||
);
|
||||
const last = records.at(-1);
|
||||
if (!storedAudit) await insertAudit(client, authorized.audit);
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
projectId: authorized.query.projectId,
|
||||
records,
|
||||
nextCursor:
|
||||
hasMore && last
|
||||
? Object.freeze({
|
||||
occurredAtMs: last.occurredAtMs,
|
||||
eventId: last.eventId,
|
||||
})
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await rollback(client);
|
||||
throw mapError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID,
|
||||
POSTGRES_MODEL_INVOCATION_SCHEMA,
|
||||
postgresModelInvocationMigrationDefinition,
|
||||
} from '../migration/modelInvocationMigration';
|
||||
|
||||
const AUTHORITY = 'model-provider-credential-management';
|
||||
const DIGEST_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export interface ModelProviderCredentialManagementIdentityKeysetSnapshot {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generation: number;
|
||||
readonly digest: string;
|
||||
readonly issuer: string;
|
||||
readonly audience: string;
|
||||
readonly activeKeyIds: readonly string[];
|
||||
readonly revokedKeyIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface PostgresModelProviderCredentialManagerReadinessReport {
|
||||
readonly ready: true;
|
||||
readonly currentUser: string;
|
||||
readonly migrationIds: readonly string[];
|
||||
readonly writablePrimary: true;
|
||||
readonly managerAuthority: true;
|
||||
readonly leastPrivilege: true;
|
||||
}
|
||||
|
||||
export class PostgresModelProviderCredentialManagementIdentityLedgerConflictError extends Error {
|
||||
readonly code =
|
||||
'POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_LEDGER_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'PostgreSQL model provider credential management identity ledger conflicts',
|
||||
);
|
||||
this.name =
|
||||
'PostgresModelProviderCredentialManagementIdentityLedgerConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresModelProviderCredentialManagementIdentityLedgerUnavailableError extends Error {
|
||||
readonly code =
|
||||
'POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_LEDGER_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super(
|
||||
'PostgreSQL model provider credential management identity ledger is unavailable',
|
||||
options,
|
||||
);
|
||||
this.name =
|
||||
'PostgresModelProviderCredentialManagementIdentityLedgerUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresModelProviderCredentialManagerNotReadyError extends Error {
|
||||
readonly code = 'POSTGRES_MODEL_PROVIDER_CREDENTIAL_MANAGER_NOT_READY';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('PostgreSQL model provider credential manager is not ready', options);
|
||||
this.name = 'PostgresModelProviderCredentialManagerNotReadyError';
|
||||
}
|
||||
}
|
||||
|
||||
function reviewedKeyIds(
|
||||
value: readonly string[],
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): readonly string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < minimum ||
|
||||
value.length > maximum
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Model provider credential management identity key ids are invalid',
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const keyId of value) {
|
||||
if (
|
||||
typeof keyId !== 'string' ||
|
||||
!KEY_ID_PATTERN.test(keyId) ||
|
||||
seen.has(keyId)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Model provider credential management identity key ids are invalid',
|
||||
);
|
||||
}
|
||||
seen.add(keyId);
|
||||
}
|
||||
return Object.freeze([...value].sort());
|
||||
}
|
||||
|
||||
function reviewedSnapshot(
|
||||
value: Readonly<ModelProviderCredentialManagementIdentityKeysetSnapshot>,
|
||||
): Readonly<ModelProviderCredentialManagementIdentityKeysetSnapshot> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).length !== 7 ||
|
||||
Object.keys(value).some(
|
||||
(key) =>
|
||||
![
|
||||
'schemaVersion',
|
||||
'generation',
|
||||
'digest',
|
||||
'issuer',
|
||||
'audience',
|
||||
'activeKeyIds',
|
||||
'revokedKeyIds',
|
||||
].includes(key),
|
||||
) ||
|
||||
value.schemaVersion !== 1 ||
|
||||
!Number.isSafeInteger(value.generation) ||
|
||||
value.generation < 1 ||
|
||||
typeof value.digest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.digest) ||
|
||||
typeof value.issuer !== 'string' ||
|
||||
value.issuer.length < 1 ||
|
||||
value.issuer.length > 512 ||
|
||||
CONTROL_PATTERN.test(value.issuer) ||
|
||||
typeof value.audience !== 'string' ||
|
||||
value.audience.length < 1 ||
|
||||
value.audience.length > 256 ||
|
||||
CONTROL_PATTERN.test(value.audience)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Model provider credential management identity snapshot is invalid',
|
||||
);
|
||||
}
|
||||
const activeKeyIds = reviewedKeyIds(value.activeKeyIds, 1, 8);
|
||||
const revokedKeyIds = reviewedKeyIds(value.revokedKeyIds, 0, 64);
|
||||
if (activeKeyIds.some((keyId) => revokedKeyIds.includes(keyId))) {
|
||||
throw new TypeError(
|
||||
'Model provider credential management identity snapshot is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value, activeKeyIds, revokedKeyIds });
|
||||
}
|
||||
|
||||
function integer(row: Row, name: string): number {
|
||||
const value = Number(row[name]);
|
||||
if (!Number.isSafeInteger(value) || value < 1)
|
||||
throw new Error(`invalid ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(row: Row, name: string): string {
|
||||
const value = row[name];
|
||||
if (typeof value !== 'string') throw new Error(`invalid ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function textArray(row: Row, name: string): readonly string[] {
|
||||
const value = row[name];
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.some((candidate) => typeof candidate !== 'string')
|
||||
) {
|
||||
throw new Error(`invalid ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameArray(left: readonly string[], right: readonly string[]): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => value === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
function includesAll(
|
||||
candidate: readonly string[],
|
||||
required: readonly string[],
|
||||
): boolean {
|
||||
const values = new Set(candidate);
|
||||
return required.every((value) => values.has(value));
|
||||
}
|
||||
|
||||
async function rollback(client: PostgresClient): Promise<void> {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
}
|
||||
|
||||
export class PostgresModelProviderCredentialManagementIdentityLedgerRepository {
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (!pool || typeof pool.connect !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL model provider credential management identity pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async observe(
|
||||
value: Readonly<ModelProviderCredentialManagementIdentityKeysetSnapshot>,
|
||||
): Promise<void> {
|
||||
const candidate = reviewedSnapshot(value);
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw new PostgresModelProviderCredentialManagementIdentityLedgerUnavailableError(
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_management_identity_keyset_ledger" (
|
||||
authority, generation, digest, issuer, audience,
|
||||
active_key_ids, revoked_key_ids, updated_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6::jsonb, $7::jsonb,
|
||||
floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
) ON CONFLICT (authority) DO NOTHING`,
|
||||
[
|
||||
AUTHORITY,
|
||||
candidate.generation,
|
||||
candidate.digest,
|
||||
candidate.issuer,
|
||||
candidate.audience,
|
||||
JSON.stringify(candidate.activeKeyIds),
|
||||
JSON.stringify(candidate.revokedKeyIds),
|
||||
],
|
||||
);
|
||||
const selected = await client.query<Row>(
|
||||
`SELECT generation, digest, issuer, audience,
|
||||
active_key_ids AS "activeKeyIds",
|
||||
revoked_key_ids AS "revokedKeyIds"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_management_identity_keyset_ledger"
|
||||
WHERE authority = $1
|
||||
FOR UPDATE`,
|
||||
[AUTHORITY],
|
||||
);
|
||||
if (selected.rows.length !== 1)
|
||||
throw new Error('identity ledger row is missing');
|
||||
const current = selected.rows[0]!;
|
||||
const generation = integer(current, 'generation');
|
||||
const digest = text(current, 'digest');
|
||||
const issuer = text(current, 'issuer');
|
||||
const audience = text(current, 'audience');
|
||||
const activeKeyIds = textArray(current, 'activeKeyIds');
|
||||
const revokedKeyIds = textArray(current, 'revokedKeyIds');
|
||||
const exact =
|
||||
generation === candidate.generation &&
|
||||
digest === candidate.digest &&
|
||||
issuer === candidate.issuer &&
|
||||
audience === candidate.audience &&
|
||||
sameArray(activeKeyIds, candidate.activeKeyIds) &&
|
||||
sameArray(revokedKeyIds, candidate.revokedKeyIds);
|
||||
if (!exact) {
|
||||
const retained = [
|
||||
...candidate.activeKeyIds,
|
||||
...candidate.revokedKeyIds,
|
||||
];
|
||||
if (
|
||||
candidate.generation <= generation ||
|
||||
candidate.issuer !== issuer ||
|
||||
candidate.audience !== audience ||
|
||||
!includesAll(candidate.revokedKeyIds, revokedKeyIds) ||
|
||||
!includesAll(retained, activeKeyIds)
|
||||
) {
|
||||
throw new PostgresModelProviderCredentialManagementIdentityLedgerConflictError();
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_provider_credential_management_identity_keyset_ledger"
|
||||
SET generation = $2,
|
||||
digest = $3,
|
||||
active_key_ids = $4::jsonb,
|
||||
revoked_key_ids = $5::jsonb,
|
||||
updated_at_ms =
|
||||
floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
WHERE authority = $1`,
|
||||
[
|
||||
AUTHORITY,
|
||||
candidate.generation,
|
||||
candidate.digest,
|
||||
JSON.stringify(candidate.activeKeyIds),
|
||||
JSON.stringify(candidate.revokedKeyIds),
|
||||
],
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await rollback(client);
|
||||
if (
|
||||
error instanceof
|
||||
PostgresModelProviderCredentialManagementIdentityLedgerConflictError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PostgresModelProviderCredentialManagementIdentityLedgerUnavailableError(
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertPostgresModelProviderCredentialManagerReady(
|
||||
pool: PostgresPool,
|
||||
): Promise<Readonly<PostgresModelProviderCredentialManagerReadinessReport>> {
|
||||
if (!pool || typeof pool.query !== 'function') {
|
||||
throw new PostgresModelProviderCredentialManagerNotReadyError();
|
||||
}
|
||||
try {
|
||||
const history = await pool.query<Row>(
|
||||
`SELECT migration_id AS "migrationId", stream_id AS "streamId",
|
||||
dialect, checksum
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."ai_schema_migrations"
|
||||
ORDER BY migration_id`,
|
||||
);
|
||||
const expected = postgresModelInvocationMigrationDefinition.migrations;
|
||||
if (
|
||||
history.rows.length !== expected.length ||
|
||||
history.rows.some(
|
||||
(row, index) =>
|
||||
row.migrationId !== expected[index]!.id ||
|
||||
row.streamId !== POSTGRES_MODEL_INVOCATION_MIGRATION_STREAM_ID ||
|
||||
row.dialect !== 'postgresql' ||
|
||||
row.checksum !== expected[index]!.checksum,
|
||||
)
|
||||
) {
|
||||
throw new Error('migration history is invalid');
|
||||
}
|
||||
const facts = await pool.query<Row>(
|
||||
`SELECT current_user AS "currentUser",
|
||||
(NOT pg_is_in_recovery()) AND
|
||||
current_setting('transaction_read_only') = 'off'
|
||||
AS "writablePrimary",
|
||||
pg_has_role(current_user, 'ql3_ai_credential_manager', 'member') AND
|
||||
NOT pg_has_role(current_user, 'ql3_ai_credential_tester', 'member') AND
|
||||
NOT pg_has_role(current_user, 'ql3_ai_maintenance', 'member') AND
|
||||
NOT pg_has_role(current_user, 'ql3_runtime', 'member') AND
|
||||
NOT pg_has_role(current_user, 'ql3_admin', 'member')
|
||||
AS "managerAuthority",
|
||||
has_schema_privilege(current_user, 'ql3', 'USAGE') AND
|
||||
NOT has_schema_privilege(current_user, 'ql3', 'CREATE') AND
|
||||
has_schema_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}', 'USAGE') AND
|
||||
NOT has_schema_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}', 'CREATE') AND
|
||||
has_table_privilege(current_user, 'ql3.projects', 'SELECT') AND
|
||||
NOT has_table_privilege(current_user, 'ql3.projects', 'INSERT,UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, 'ql3.project_role_bindings', 'SELECT') AND
|
||||
NOT has_table_privilege(current_user, 'ql3.project_role_bindings', 'INSERT,UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, 'ql3.security_audit_events', 'SELECT,INSERT') AND
|
||||
NOT has_table_privilege(current_user, 'ql3.security_audit_events', 'UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.ai_schema_migrations', 'SELECT') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.ai_schema_migrations', 'INSERT,UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_bindings', 'SELECT,INSERT') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_bindings', 'UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_transitions', 'SELECT,INSERT') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_transitions', 'UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_audits', 'SELECT') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_audits', 'INSERT,UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_management_identity_keyset_ledger', 'SELECT,INSERT,UPDATE') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_management_identity_keyset_ledger', 'DELETE') AND
|
||||
has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_test_plans', 'SELECT,INSERT') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_test_plans', 'UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_test_quota_buckets', 'SELECT,INSERT,UPDATE') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_test_quota_buckets', 'DELETE') AND
|
||||
has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_test_executions', 'SELECT') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_test_executions', 'INSERT,UPDATE,DELETE') AND
|
||||
has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_test_results', 'SELECT') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_provider_credential_test_results', 'INSERT,UPDATE,DELETE') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_invocation_prompt_output_artifacts', 'SELECT,INSERT,UPDATE,DELETE') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_invocation_prompt_output_key_retirement_preparations', 'SELECT,INSERT,UPDATE,DELETE') AND
|
||||
NOT has_table_privilege(current_user, '${POSTGRES_MODEL_INVOCATION_SCHEMA}.model_price_catalog_publications', 'SELECT,INSERT,UPDATE,DELETE')
|
||||
AS "leastPrivilege"`,
|
||||
);
|
||||
const row = facts.rows[0];
|
||||
if (
|
||||
facts.rows.length !== 1 ||
|
||||
typeof row?.currentUser !== 'string' ||
|
||||
row.writablePrimary !== true ||
|
||||
row.managerAuthority !== true ||
|
||||
row.leastPrivilege !== true
|
||||
) {
|
||||
throw new Error('manager authority is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
ready: true as const,
|
||||
currentUser: row.currentUser,
|
||||
migrationIds: Object.freeze(expected.map(({ id }) => id)),
|
||||
writablePrimary: true as const,
|
||||
managerAuthority: true as const,
|
||||
leastPrivilege: true as const,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PostgresModelProviderCredentialManagerNotReadyError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PostgresModelProviderCredentialManagerNotReadyError({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export { PostgresModelProviderCredentialReader } from './postgres-model-provider-credential-repository/reader';
|
||||
export { PostgresModelProviderCredentialRepository } from './postgres-model-provider-credential-repository/repository';
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
MODEL_PROVIDER_CREDENTIAL_TEST_PLAN_OPERATION_ID,
|
||||
ModelProviderCredentialTestExecutionConflictError,
|
||||
ModelProviderCredentialTestExecutionRejectedError,
|
||||
ModelProviderCredentialTestExecutionUnavailableError,
|
||||
ModelProviderCredentialTestPlanAuthorizationFenceConflictError,
|
||||
ModelProviderCredentialTestPlanConflictError,
|
||||
ModelProviderCredentialTestPlanQuotaExceededError,
|
||||
ModelProviderCredentialTestPlanUnavailableError,
|
||||
PostgresModelProviderCredentialTesterNotReadyError,
|
||||
type AuthorizedModelProviderCredentialTestPlan,
|
||||
type BeginModelProviderCredentialTestExecutionInput,
|
||||
type BeginModelProviderCredentialTestExecutionResult,
|
||||
type CompleteModelProviderCredentialTestExecutionResult,
|
||||
type CreateModelProviderCredentialTestPlanResult,
|
||||
type ModelProviderCredentialTestExecutionRepository,
|
||||
type ModelProviderCredentialTestPlanRepository,
|
||||
type PostgresModelProviderCredentialTestPlanOptions,
|
||||
type PostgresModelProviderCredentialTesterReadinessReport,
|
||||
} from './postgres-model-provider-credential-test-connection/contracts';
|
||||
export { PostgresModelProviderCredentialTestPlanRepository } from './postgres-model-provider-credential-test-connection/planRepository';
|
||||
export { PostgresModelProviderCredentialTestExecutionRepository } from './postgres-model-provider-credential-test-connection/executionRepository';
|
||||
export { assertPostgresModelProviderCredentialTesterReady } from './postgres-model-provider-credential-test-connection/readiness';
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, open, realpath } from 'node:fs/promises';
|
||||
import { isAbsolute, join, normalize, parse, relative } from 'node:path';
|
||||
|
||||
import { parseSecretRef } from '@qinglong/runtime-core/secret-reference';
|
||||
|
||||
import {
|
||||
MAX_MODEL_PROVIDER_AUTHORIZATION_BYTES,
|
||||
type ModelProviderSecretMaterial,
|
||||
type ModelProviderSecretMaterialProvider,
|
||||
type ModelProviderSecretMaterialRequest,
|
||||
} from './providerCredential';
|
||||
|
||||
const MAX_ROOT_DIRECTORY_BYTES = 4096;
|
||||
const SECRET_FILE_NAME = /^[0-9a-f]{64}$/;
|
||||
|
||||
export interface ProjectedModelProviderSecretMaterialOptions {
|
||||
/**
|
||||
* Direct read-only volume root. Each key is the lowercase SHA-256 of one
|
||||
* canonical SecretRef. Kubernetes atomic-writer symlinks are accepted only
|
||||
* when their resolved regular file remains below this root.
|
||||
*/
|
||||
readonly rootDirectory: string;
|
||||
}
|
||||
|
||||
export class ProjectedModelProviderSecretMaterialUnavailableError extends Error {
|
||||
readonly code = 'PROJECTED_MODEL_PROVIDER_SECRET_MATERIAL_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Projected model provider Secret material is unavailable', options);
|
||||
this.name = 'ProjectedModelProviderSecretMaterialUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): ProjectedModelProviderSecretMaterialUnavailableError {
|
||||
return new ProjectedModelProviderSecretMaterialUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function rootDirectory(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!isAbsolute(value) ||
|
||||
parse(value).root === value ||
|
||||
normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_ROOT_DIRECTORY_BYTES
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function remainsBelow(root: string, candidate: string): boolean {
|
||||
const suffix = relative(root, candidate);
|
||||
return (
|
||||
suffix.length > 0 &&
|
||||
!isAbsolute(suffix) &&
|
||||
suffix !== '..' &&
|
||||
!suffix.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolvedRoot(configuredRoot: string): Promise<string> {
|
||||
try {
|
||||
const stat = await lstat(configuredRoot);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw unavailable();
|
||||
return await realpath(configuredRoot);
|
||||
} catch (cause) {
|
||||
throw cause instanceof ProjectedModelProviderSecretMaterialUnavailableError
|
||||
? cause
|
||||
: unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
export function projectedModelProviderSecretFileName(
|
||||
secretRef: string,
|
||||
): string {
|
||||
try {
|
||||
parseSecretRef(secretRef);
|
||||
} catch (cause) {
|
||||
throw unavailable(cause);
|
||||
}
|
||||
const result = createHash('sha256').update(secretRef, 'utf8').digest('hex');
|
||||
if (!SECRET_FILE_NAME.test(result)) throw unavailable();
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeRequest(
|
||||
value: Readonly<ModelProviderSecretMaterialRequest>,
|
||||
): Readonly<ModelProviderSecretMaterialRequest> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw unavailable();
|
||||
}
|
||||
const expected = ['projectId', 'secretRef'];
|
||||
if (value.signal !== undefined) expected.push('signal');
|
||||
if (
|
||||
Object.keys(value).sort().join('\0') !== expected.sort().join('\0') ||
|
||||
typeof value.projectId !== 'string' ||
|
||||
value.projectId.length < 1 ||
|
||||
Buffer.byteLength(value.projectId, 'utf8') > 128 ||
|
||||
(value.signal !== undefined && typeof value.signal.aborted !== 'boolean')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
try {
|
||||
if (parseSecretRef(value.secretRef).projectId !== value.projectId) {
|
||||
throw unavailable();
|
||||
}
|
||||
} catch (cause) {
|
||||
throw cause instanceof ProjectedModelProviderSecretMaterialUnavailableError
|
||||
? cause
|
||||
: unavailable(cause);
|
||||
}
|
||||
if (value.signal?.aborted) throw unavailable(value.signal.reason);
|
||||
return Object.freeze({
|
||||
projectId: value.projectId,
|
||||
secretRef: value.secretRef,
|
||||
...(value.signal === undefined ? {} : { signal: value.signal }),
|
||||
});
|
||||
}
|
||||
|
||||
async function readMaterial(
|
||||
configuredRoot: string,
|
||||
secretRef: string,
|
||||
): Promise<Buffer> {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
const root = await resolvedRoot(configuredRoot);
|
||||
const candidate = join(
|
||||
root,
|
||||
projectedModelProviderSecretFileName(secretRef),
|
||||
);
|
||||
const target = await realpath(candidate);
|
||||
if (!remainsBelow(root, target)) throw unavailable();
|
||||
handle = await open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const before = await handle.stat();
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.nlink !== 1 ||
|
||||
before.size < 1 ||
|
||||
before.size > MAX_MODEL_PROVIDER_AUTHORIZATION_BYTES ||
|
||||
(before.mode & 0o222) !== 0 ||
|
||||
(before.mode & 0o111) !== 0 ||
|
||||
(before.mode & 0o007) !== 0 ||
|
||||
(before.mode & 0o440) === 0
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (
|
||||
bytes.byteLength !== before.size ||
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
after.mtimeMs !== before.mtimeMs ||
|
||||
(await realpath(candidate)) !== target ||
|
||||
(await realpath(configuredRoot)) !== root
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
const owned = bytes;
|
||||
bytes = undefined;
|
||||
return owned;
|
||||
} catch (cause) {
|
||||
throw cause instanceof ProjectedModelProviderSecretMaterialUnavailableError
|
||||
? cause
|
||||
: unavailable(cause);
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-client, zero-cache Cluster material provider for Kubernetes Secret,
|
||||
* CSI, or Secret-operator projections. Every authorization reopens exactly one
|
||||
* bounded file, so an atomic projection replacement rotates an unpinned
|
||||
* SecretRef without a watcher, timer, API credential, or process restart.
|
||||
*/
|
||||
export class ProjectedModelProviderSecretMaterialProvider
|
||||
implements ModelProviderSecretMaterialProvider
|
||||
{
|
||||
readonly #rootDirectory: string;
|
||||
|
||||
constructor(options: ProjectedModelProviderSecretMaterialOptions) {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw unavailable();
|
||||
}
|
||||
this.#rootDirectory = rootDirectory(options.rootDirectory);
|
||||
}
|
||||
|
||||
async verify(): Promise<void> {
|
||||
await resolvedRoot(this.#rootDirectory);
|
||||
}
|
||||
|
||||
async resolveProjectSecretMaterial(
|
||||
requestValue: Readonly<ModelProviderSecretMaterialRequest>,
|
||||
): Promise<Readonly<ModelProviderSecretMaterial>> {
|
||||
const request = normalizeRequest(requestValue);
|
||||
const bytes = await readMaterial(this.#rootDirectory, request.secretRef);
|
||||
if (request.signal?.aborted) {
|
||||
bytes.fill(0);
|
||||
throw unavailable(request.signal.reason);
|
||||
}
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
secretRef: request.secretRef,
|
||||
bytes,
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
bytes.fill(0);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProjectedModelProviderSecretMaterialProvider(
|
||||
options: ProjectedModelProviderSecretMaterialOptions,
|
||||
): Promise<Readonly<ProjectedModelProviderSecretMaterialProvider>> {
|
||||
const provider = new ProjectedModelProviderSecretMaterialProvider(options);
|
||||
await provider.verify();
|
||||
return provider;
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { parseSecretRef } from '@qinglong/runtime-core/secret-reference';
|
||||
|
||||
// Core contract for the model-provider-credential bounded capability.
|
||||
export const MODEL_PROVIDER_CREDENTIAL_BINDING_SCHEMA =
|
||||
'qinglong/model-provider-credential-binding@v1';
|
||||
export const MODEL_PROVIDER_CREDENTIAL_AUDIT_SCHEMA =
|
||||
'qinglong/model-provider-credential-audit@v1';
|
||||
export const MODEL_PROVIDER_CREDENTIAL_OPERATIONS = [
|
||||
'list_models',
|
||||
'generate',
|
||||
'stream',
|
||||
] as const;
|
||||
export const MAX_MODEL_PROVIDER_AUTHORIZATION_BYTES = 4 * 1024;
|
||||
|
||||
const BEARER_PREFIX = 'Bearer ';
|
||||
const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9\-._~+/]+=*$/;
|
||||
|
||||
export type ModelProviderCredentialOperation =
|
||||
(typeof MODEL_PROVIDER_CREDENTIAL_OPERATIONS)[number];
|
||||
|
||||
export interface ModelProviderCredentialBinding {
|
||||
readonly schema: typeof MODEL_PROVIDER_CREDENTIAL_BINDING_SCHEMA;
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
readonly revision: string;
|
||||
readonly secretRef: string;
|
||||
readonly scheme: 'bearer';
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialBindingLookup {
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialBindingSource {
|
||||
resolveModelProviderCredentialBinding(
|
||||
lookup: Readonly<ModelProviderCredentialBindingLookup>,
|
||||
): Promise<Readonly<ModelProviderCredentialBinding> | null>;
|
||||
}
|
||||
|
||||
export interface ModelProviderSecretMaterialRequest {
|
||||
readonly projectId: string;
|
||||
readonly secretRef: string;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ModelProviderSecretMaterial {
|
||||
readonly secretRef: string;
|
||||
/**
|
||||
* Consumer-owned plaintext bytes. The consumer must call dispose exactly
|
||||
* once and must not retain a string or another copy beyond the operation.
|
||||
*/
|
||||
readonly bytes: Uint8Array;
|
||||
dispose(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ModelProviderSecretMaterialProvider {
|
||||
resolveProjectSecretMaterial(
|
||||
request: Readonly<ModelProviderSecretMaterialRequest>,
|
||||
): Promise<Readonly<ModelProviderSecretMaterial> | null>;
|
||||
}
|
||||
|
||||
export interface ModelProviderAuthorizationRequest {
|
||||
readonly operation: ModelProviderCredentialOperation;
|
||||
readonly provider: string;
|
||||
readonly projectId?: string;
|
||||
readonly requestId?: string;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ModelProviderAuthorizationLease {
|
||||
readonly value: string;
|
||||
dispose(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ModelProviderAuthorizationProvider {
|
||||
authorizationHeader(
|
||||
request: Readonly<ModelProviderAuthorizationRequest>,
|
||||
): Promise<Readonly<ModelProviderAuthorizationLease> | null>;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialAuditRecord {
|
||||
readonly schema: typeof MODEL_PROVIDER_CREDENTIAL_AUDIT_SCHEMA;
|
||||
readonly operation: ModelProviderCredentialOperation;
|
||||
readonly projectId: string;
|
||||
readonly provider: string;
|
||||
readonly requestId: string;
|
||||
readonly bindingRevision: string;
|
||||
readonly bindingDigest: string;
|
||||
readonly occurredAtMs: number;
|
||||
}
|
||||
|
||||
export interface ModelProviderCredentialAuditSink {
|
||||
record(record: Readonly<ModelProviderCredentialAuditRecord>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BoundModelProviderCredentialOptions {
|
||||
readonly bindings: ModelProviderCredentialBindingSource;
|
||||
readonly secrets: ModelProviderSecretMaterialProvider;
|
||||
readonly audit: ModelProviderCredentialAuditSink;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class InvalidModelProviderCredentialBindingError extends TypeError {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_BINDING_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Model provider credential binding is invalid: ${message}`);
|
||||
this.name = 'InvalidModelProviderCredentialBindingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelProviderCredentialUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PROVIDER_CREDENTIAL_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('The model provider credential is unavailable', options);
|
||||
this.name = 'ModelProviderCredentialUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
return (
|
||||
actual.length === sortedExpected.length &&
|
||||
actual.every((key, index) => key === sortedExpected[index])
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidModelProviderCredentialBindingError(
|
||||
`${label} is not an object`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string, maximumBytes = 128): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes ||
|
||||
/[\u0000-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialBindingError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function providerIdentifier(value: unknown): string {
|
||||
const provider = identifier(value, 'provider');
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(provider)) {
|
||||
throw new InvalidModelProviderCredentialBindingError('provider is invalid');
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
function operation(value: unknown): ModelProviderCredentialOperation {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!MODEL_PROVIDER_CREDENTIAL_OPERATIONS.includes(
|
||||
value as ModelProviderCredentialOperation,
|
||||
)
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialBindingError(
|
||||
'operation is invalid',
|
||||
);
|
||||
}
|
||||
return value as ModelProviderCredentialOperation;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new ModelProviderCredentialUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function normalizeModelProviderCredentialBinding(
|
||||
value: ModelProviderCredentialBinding,
|
||||
): Readonly<ModelProviderCredentialBinding> {
|
||||
const binding = record(value, 'binding');
|
||||
if (
|
||||
!exactKeys(binding, [
|
||||
'projectId',
|
||||
'provider',
|
||||
'revision',
|
||||
'schema',
|
||||
'scheme',
|
||||
'secretRef',
|
||||
]) ||
|
||||
binding.schema !== MODEL_PROVIDER_CREDENTIAL_BINDING_SCHEMA ||
|
||||
binding.scheme !== 'bearer'
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialBindingError('shape is invalid');
|
||||
}
|
||||
const projectId = identifier(binding.projectId, 'projectId');
|
||||
const provider = providerIdentifier(binding.provider);
|
||||
const revision = identifier(binding.revision, 'revision');
|
||||
let secretRef: string;
|
||||
try {
|
||||
secretRef = identifier(binding.secretRef, 'secretRef', 512);
|
||||
if (parseSecretRef(secretRef).projectId !== projectId) {
|
||||
throw new InvalidModelProviderCredentialBindingError(
|
||||
'Secret belongs to another Project',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidModelProviderCredentialBindingError) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidModelProviderCredentialBindingError(
|
||||
'secretRef is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_BINDING_SCHEMA,
|
||||
projectId,
|
||||
provider,
|
||||
revision,
|
||||
secretRef,
|
||||
scheme: 'bearer',
|
||||
});
|
||||
}
|
||||
|
||||
export function digestModelProviderCredentialBinding(
|
||||
value: ModelProviderCredentialBinding,
|
||||
): string {
|
||||
const binding = normalizeModelProviderCredentialBinding(value);
|
||||
const hash = createHash('sha256');
|
||||
hash.update('qinglong/model-provider-credential-binding@v1\0', 'utf8');
|
||||
hash.update(binding.projectId, 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
hash.update(binding.provider, 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
hash.update(binding.revision, 'utf8');
|
||||
hash.update('\0', 'utf8');
|
||||
hash.update(binding.secretRef, 'utf8');
|
||||
hash.update('\0bearer', 'utf8');
|
||||
return `sha256:${hash.digest('hex')}`;
|
||||
}
|
||||
|
||||
function normalizeAuthorizationRequest(
|
||||
value: Readonly<ModelProviderAuthorizationRequest>,
|
||||
): Readonly<
|
||||
Required<Omit<ModelProviderAuthorizationRequest, 'signal'>> & {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
> {
|
||||
const request = record(value, 'authorization request');
|
||||
const expected = ['operation', 'projectId', 'provider', 'requestId'];
|
||||
if (request.signal !== undefined) expected.push('signal');
|
||||
if (
|
||||
!exactKeys(request, expected) ||
|
||||
request.projectId === undefined ||
|
||||
request.requestId === undefined ||
|
||||
(request.signal !== undefined &&
|
||||
typeof (request.signal as AbortSignal).aborted !== 'boolean')
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialBindingError(
|
||||
'authorization request is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
operation: operation(request.operation),
|
||||
provider: providerIdentifier(request.provider),
|
||||
projectId: identifier(request.projectId, 'projectId'),
|
||||
requestId: identifier(request.requestId, 'requestId'),
|
||||
...(request.signal === undefined
|
||||
? {}
|
||||
: { signal: request.signal as AbortSignal }),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMaterial(
|
||||
value: Readonly<ModelProviderSecretMaterial>,
|
||||
expectedSecretRef: string,
|
||||
): Readonly<ModelProviderSecretMaterial> {
|
||||
const material = record(value, 'Secret material');
|
||||
if (
|
||||
!exactKeys(material, ['bytes', 'dispose', 'secretRef']) ||
|
||||
material.secretRef !== expectedSecretRef ||
|
||||
!(material.bytes instanceof Uint8Array) ||
|
||||
typeof material.dispose !== 'function'
|
||||
) {
|
||||
throw new ModelProviderCredentialUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function disposeMaterial(
|
||||
material: Readonly<ModelProviderSecretMaterial> | undefined,
|
||||
): Promise<void> {
|
||||
if (!material || typeof material.dispose !== 'function') return;
|
||||
await material.dispose();
|
||||
}
|
||||
|
||||
function bearerToken(bytes: Uint8Array): Buffer {
|
||||
const token = Buffer.from(bytes);
|
||||
if (
|
||||
token.length === 0 ||
|
||||
token.length >
|
||||
MAX_MODEL_PROVIDER_AUTHORIZATION_BYTES - BEARER_PREFIX.length ||
|
||||
token.some((byte) => byte > 0x7f)
|
||||
) {
|
||||
token.fill(0);
|
||||
throw new ModelProviderCredentialUnavailableError();
|
||||
}
|
||||
const value = token.toString('ascii');
|
||||
if (
|
||||
!BEARER_TOKEN_PATTERN.test(value) ||
|
||||
Buffer.byteLength(value, 'ascii') !== token.length
|
||||
) {
|
||||
token.fill(0);
|
||||
throw new ModelProviderCredentialUnavailableError();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-invocation credential bridge. Bindings are metadata-only and are
|
||||
* re-resolved for every request, so an unversioned SecretRef rotates without a
|
||||
* watcher or cache. Plaintext is owned only by a short-lived authorization
|
||||
* lease and is wiped before the provider starts reading its response body.
|
||||
*/
|
||||
export class BoundModelProviderCredentialProvider
|
||||
implements ModelProviderAuthorizationProvider
|
||||
{
|
||||
readonly #bindings: ModelProviderCredentialBindingSource;
|
||||
readonly #secrets: ModelProviderSecretMaterialProvider;
|
||||
readonly #audit: ModelProviderCredentialAuditSink;
|
||||
readonly #now: () => number;
|
||||
|
||||
constructor(options: BoundModelProviderCredentialOptions) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.bindings?.resolveModelProviderCredentialBinding !==
|
||||
'function' ||
|
||||
typeof options.secrets?.resolveProjectSecretMaterial !== 'function' ||
|
||||
typeof options.audit?.record !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new InvalidModelProviderCredentialBindingError(
|
||||
'provider options are invalid',
|
||||
);
|
||||
}
|
||||
this.#bindings = options.bindings;
|
||||
this.#secrets = options.secrets;
|
||||
this.#audit = options.audit;
|
||||
this.#now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async authorizationHeader(
|
||||
request: Readonly<ModelProviderAuthorizationRequest>,
|
||||
): Promise<Readonly<ModelProviderAuthorizationLease>> {
|
||||
let material: Readonly<ModelProviderSecretMaterial> | undefined;
|
||||
let token: Buffer | undefined;
|
||||
try {
|
||||
const normalizedRequest = normalizeAuthorizationRequest(request);
|
||||
if (normalizedRequest.signal?.aborted) {
|
||||
throw new ModelProviderCredentialUnavailableError();
|
||||
}
|
||||
const rawBinding =
|
||||
await this.#bindings.resolveModelProviderCredentialBinding(
|
||||
Object.freeze({
|
||||
projectId: normalizedRequest.projectId,
|
||||
provider: normalizedRequest.provider,
|
||||
}),
|
||||
);
|
||||
if (!rawBinding) throw new ModelProviderCredentialUnavailableError();
|
||||
const binding = normalizeModelProviderCredentialBinding(rawBinding);
|
||||
if (
|
||||
binding.projectId !== normalizedRequest.projectId ||
|
||||
binding.provider !== normalizedRequest.provider
|
||||
) {
|
||||
throw new ModelProviderCredentialUnavailableError();
|
||||
}
|
||||
const bindingDigest = digestModelProviderCredentialBinding(binding);
|
||||
material =
|
||||
(await this.#secrets.resolveProjectSecretMaterial(
|
||||
Object.freeze({
|
||||
projectId: normalizedRequest.projectId,
|
||||
secretRef: binding.secretRef,
|
||||
...(normalizedRequest.signal === undefined
|
||||
? {}
|
||||
: { signal: normalizedRequest.signal }),
|
||||
}),
|
||||
)) ?? undefined;
|
||||
if (!material) throw new ModelProviderCredentialUnavailableError();
|
||||
const normalizedMaterial = normalizeMaterial(material, binding.secretRef);
|
||||
token = bearerToken(normalizedMaterial.bytes);
|
||||
await disposeMaterial(material);
|
||||
material = undefined;
|
||||
if (normalizedRequest.signal?.aborted) {
|
||||
throw new ModelProviderCredentialUnavailableError();
|
||||
}
|
||||
await this.#audit.record(
|
||||
Object.freeze({
|
||||
schema: MODEL_PROVIDER_CREDENTIAL_AUDIT_SCHEMA,
|
||||
operation: normalizedRequest.operation,
|
||||
projectId: normalizedRequest.projectId,
|
||||
provider: normalizedRequest.provider,
|
||||
requestId: normalizedRequest.requestId,
|
||||
bindingRevision: binding.revision,
|
||||
bindingDigest,
|
||||
occurredAtMs: timestamp(this.#now()),
|
||||
}),
|
||||
);
|
||||
const ownedToken = token;
|
||||
token = undefined;
|
||||
let disposed = false;
|
||||
return Object.freeze({
|
||||
get value(): string {
|
||||
if (disposed) throw new ModelProviderCredentialUnavailableError();
|
||||
return `${BEARER_PREFIX}${ownedToken.toString('ascii')}`;
|
||||
},
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
ownedToken.fill(0);
|
||||
},
|
||||
});
|
||||
} catch (cause) {
|
||||
token?.fill(0);
|
||||
try {
|
||||
await disposeMaterial(material);
|
||||
} catch {
|
||||
// The stable fail-closed error below intentionally hides provider data.
|
||||
}
|
||||
if (cause instanceof InvalidModelProviderCredentialBindingError) {
|
||||
throw cause;
|
||||
}
|
||||
throw new ModelProviderCredentialUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import {
|
||||
createModelPriceCatalogPublishCommand,
|
||||
createModelPriceCatalogTransitionCommand,
|
||||
type ModelPriceCatalogPublishCommand,
|
||||
type ModelPriceCatalogTransitionCommand,
|
||||
} from '../modelPriceCatalog';
|
||||
import {
|
||||
MAX_MODEL_PRICE_CATALOG_PRINCIPAL_AGE_MS,
|
||||
MODEL_PRICE_CATALOG_AUTHORIZATION_COMMAND_SCHEMA,
|
||||
MODEL_PRICE_CATALOG_AUTHORIZATION_SCHEMA,
|
||||
MODEL_PRICE_CATALOG_POLICY_DECISION_SCHEMA,
|
||||
type ModelPriceCatalogAuthorization,
|
||||
type ModelPriceCatalogAuthorizationCommand,
|
||||
type ModelPriceCatalogPolicyDecision,
|
||||
} from './contracts';
|
||||
import {
|
||||
decisionMode,
|
||||
digest,
|
||||
exactKeys,
|
||||
hash,
|
||||
identity,
|
||||
integer,
|
||||
invalid,
|
||||
normalizeReasons,
|
||||
normalizeStoredPrincipal,
|
||||
nullableIdentity,
|
||||
operation,
|
||||
record,
|
||||
requestId,
|
||||
validateOperationRevision,
|
||||
} from './validation';
|
||||
|
||||
const POLICY_DECISION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-price-catalog-policy-decision-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const AUTHORIZATION_COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-price-catalog-authorization-command-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const AUTHORIZATION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-price-catalog-authorization-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
export function createModelPriceCatalogPolicyDecision(
|
||||
value: Omit<ModelPriceCatalogPolicyDecision, 'schema' | 'decisionDigest'>,
|
||||
): Readonly<ModelPriceCatalogPolicyDecision> {
|
||||
const candidate = record(value, 'policy decision');
|
||||
exactKeys(candidate, ['effect', 'revision', 'reasons'], 'policy decision');
|
||||
if (value.effect !== 'allow' && value.effect !== 'deny') {
|
||||
return invalid('policy effect is invalid');
|
||||
}
|
||||
const semantic = Object.freeze({
|
||||
schema: MODEL_PRICE_CATALOG_POLICY_DECISION_SCHEMA,
|
||||
effect: value.effect,
|
||||
revision: identity(value.revision, 'policy revision'),
|
||||
reasons: normalizeReasons(value.reasons),
|
||||
});
|
||||
return Object.freeze({
|
||||
...semantic,
|
||||
decisionDigest: hash(POLICY_DECISION_DIGEST_DOMAIN, semantic),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelPriceCatalogPolicyDecision(
|
||||
value: ModelPriceCatalogPolicyDecision,
|
||||
): Readonly<ModelPriceCatalogPolicyDecision> {
|
||||
const candidate = record(value, 'policy decision');
|
||||
exactKeys(
|
||||
candidate,
|
||||
['schema', 'effect', 'revision', 'reasons', 'decisionDigest'],
|
||||
'policy decision',
|
||||
);
|
||||
if (value.schema !== MODEL_PRICE_CATALOG_POLICY_DECISION_SCHEMA) {
|
||||
return invalid('policy decision schema is invalid');
|
||||
}
|
||||
const normalized = createModelPriceCatalogPolicyDecision({
|
||||
effect: value.effect,
|
||||
revision: value.revision,
|
||||
reasons: value.reasons,
|
||||
});
|
||||
if (normalized.decisionDigest !== value.decisionDigest) {
|
||||
return invalid('policy decision digest is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createModelPriceCatalogAuthorizationCommand(
|
||||
value: Omit<
|
||||
ModelPriceCatalogAuthorizationCommand,
|
||||
'schema' | 'commandDigest'
|
||||
>,
|
||||
): Readonly<ModelPriceCatalogAuthorizationCommand> {
|
||||
const candidate = record(value, 'authorization command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'authorizationId',
|
||||
'requestId',
|
||||
'operation',
|
||||
'provider',
|
||||
'model',
|
||||
'priceRevision',
|
||||
'catalogCommandDigest',
|
||||
'principal',
|
||||
'policy',
|
||||
'decisionMode',
|
||||
],
|
||||
'authorization command',
|
||||
);
|
||||
const normalizedOperation = operation(value.operation);
|
||||
const priceRevision = nullableIdentity(
|
||||
value.priceRevision,
|
||||
'authorization price revision',
|
||||
);
|
||||
validateOperationRevision(normalizedOperation, priceRevision);
|
||||
const principal = normalizeStoredPrincipal(value.principal);
|
||||
const policy = normalizeModelPriceCatalogPolicyDecision(value.policy);
|
||||
if (policy.effect !== 'allow') {
|
||||
return invalid('authorization policy must allow');
|
||||
}
|
||||
const semantic = Object.freeze({
|
||||
schema: MODEL_PRICE_CATALOG_AUTHORIZATION_COMMAND_SCHEMA,
|
||||
authorizationId: identity(value.authorizationId, 'authorization identity'),
|
||||
requestId: requestId(value.requestId),
|
||||
operation: normalizedOperation,
|
||||
provider: identity(value.provider, 'authorization provider'),
|
||||
model: identity(value.model, 'authorization model'),
|
||||
priceRevision,
|
||||
catalogCommandDigest: digest(
|
||||
value.catalogCommandDigest,
|
||||
'catalog command digest',
|
||||
),
|
||||
principal,
|
||||
policy,
|
||||
decisionMode: decisionMode(value.decisionMode),
|
||||
});
|
||||
return Object.freeze({
|
||||
...semantic,
|
||||
commandDigest: hash(AUTHORIZATION_COMMAND_DIGEST_DOMAIN, semantic),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelPriceCatalogAuthorizationCommand(
|
||||
value: ModelPriceCatalogAuthorizationCommand,
|
||||
): Readonly<ModelPriceCatalogAuthorizationCommand> {
|
||||
const candidate = record(value, 'authorization command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'schema',
|
||||
'authorizationId',
|
||||
'requestId',
|
||||
'operation',
|
||||
'provider',
|
||||
'model',
|
||||
'priceRevision',
|
||||
'catalogCommandDigest',
|
||||
'principal',
|
||||
'policy',
|
||||
'decisionMode',
|
||||
'commandDigest',
|
||||
],
|
||||
'authorization command',
|
||||
);
|
||||
if (value.schema !== MODEL_PRICE_CATALOG_AUTHORIZATION_COMMAND_SCHEMA) {
|
||||
return invalid('authorization command schema is invalid');
|
||||
}
|
||||
const normalized = createModelPriceCatalogAuthorizationCommand({
|
||||
authorizationId: value.authorizationId,
|
||||
requestId: value.requestId,
|
||||
operation: value.operation,
|
||||
provider: value.provider,
|
||||
model: value.model,
|
||||
priceRevision: value.priceRevision,
|
||||
catalogCommandDigest: value.catalogCommandDigest,
|
||||
principal: value.principal,
|
||||
policy: value.policy,
|
||||
decisionMode: value.decisionMode,
|
||||
});
|
||||
if (normalized.commandDigest !== value.commandDigest) {
|
||||
return invalid('authorization command digest is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeModelPriceCatalogPublishAuthorization(
|
||||
catalogCommandValue: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
authorizationValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Readonly<ModelPriceCatalogAuthorizationCommand> {
|
||||
const catalogCommand = createModelPriceCatalogPublishCommand({
|
||||
provider: catalogCommandValue.provider,
|
||||
model: catalogCommandValue.model,
|
||||
priceRevision: catalogCommandValue.priceRevision,
|
||||
currency: catalogCommandValue.currency,
|
||||
inputMicrosPerMillionTokens:
|
||||
catalogCommandValue.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens:
|
||||
catalogCommandValue.outputMicrosPerMillionTokens,
|
||||
mutationId: catalogCommandValue.mutationId,
|
||||
publishedByUserId: catalogCommandValue.publishedByUserId,
|
||||
});
|
||||
if (catalogCommand.commandDigest !== catalogCommandValue.commandDigest) {
|
||||
return invalid('publish command digest is invalid');
|
||||
}
|
||||
const authorization =
|
||||
normalizeModelPriceCatalogAuthorizationCommand(authorizationValue);
|
||||
if (
|
||||
authorization.operation !== 'publish' ||
|
||||
authorization.provider !== catalogCommand.provider ||
|
||||
authorization.model !== catalogCommand.model ||
|
||||
authorization.priceRevision !== catalogCommand.priceRevision ||
|
||||
authorization.catalogCommandDigest !== catalogCommand.commandDigest ||
|
||||
authorization.principal.subject.id !== catalogCommand.publishedByUserId
|
||||
) {
|
||||
return invalid('publish authorization binding is invalid');
|
||||
}
|
||||
return authorization;
|
||||
}
|
||||
|
||||
export function normalizeModelPriceCatalogTransitionAuthorization(
|
||||
catalogCommandValue: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
authorizationValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Readonly<ModelPriceCatalogAuthorizationCommand> {
|
||||
const catalogCommand = createModelPriceCatalogTransitionCommand({
|
||||
provider: catalogCommandValue.provider,
|
||||
model: catalogCommandValue.model,
|
||||
expectedGeneration: catalogCommandValue.expectedGeneration,
|
||||
expectedHeadDigest: catalogCommandValue.expectedHeadDigest,
|
||||
action: catalogCommandValue.action,
|
||||
priceRevision: catalogCommandValue.priceRevision,
|
||||
mutationId: catalogCommandValue.mutationId,
|
||||
changedByUserId: catalogCommandValue.changedByUserId,
|
||||
});
|
||||
if (catalogCommand.commandDigest !== catalogCommandValue.commandDigest) {
|
||||
return invalid('transition command digest is invalid');
|
||||
}
|
||||
const authorization =
|
||||
normalizeModelPriceCatalogAuthorizationCommand(authorizationValue);
|
||||
if (
|
||||
authorization.operation !== catalogCommand.action ||
|
||||
authorization.provider !== catalogCommand.provider ||
|
||||
authorization.model !== catalogCommand.model ||
|
||||
authorization.priceRevision !== catalogCommand.priceRevision ||
|
||||
authorization.catalogCommandDigest !== catalogCommand.commandDigest ||
|
||||
authorization.principal.subject.id !== catalogCommand.changedByUserId
|
||||
) {
|
||||
return invalid('transition authorization binding is invalid');
|
||||
}
|
||||
return authorization;
|
||||
}
|
||||
|
||||
export function createModelPriceCatalogAuthorization(
|
||||
commandValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
resultDigestValue: string,
|
||||
committedAtMsValue: number,
|
||||
): Readonly<ModelPriceCatalogAuthorization> {
|
||||
const command = normalizeModelPriceCatalogAuthorizationCommand(commandValue);
|
||||
const resultDigest = digest(resultDigestValue, 'authorization result digest');
|
||||
const committedAtMs = integer(
|
||||
committedAtMsValue,
|
||||
'authorization commit time',
|
||||
);
|
||||
if (
|
||||
committedAtMs < command.principal.authenticatedAtMs ||
|
||||
committedAtMs >= command.principal.expiresAtMs ||
|
||||
committedAtMs - command.principal.authenticatedAtMs >
|
||||
MAX_MODEL_PRICE_CATALOG_PRINCIPAL_AGE_MS
|
||||
) {
|
||||
return invalid('authorization principal is not active at commit');
|
||||
}
|
||||
const semantic = Object.freeze({
|
||||
schema: MODEL_PRICE_CATALOG_AUTHORIZATION_SCHEMA,
|
||||
authorizationId: command.authorizationId,
|
||||
requestId: command.requestId,
|
||||
operation: command.operation,
|
||||
provider: command.provider,
|
||||
model: command.model,
|
||||
priceRevision: command.priceRevision,
|
||||
catalogCommandDigest: command.catalogCommandDigest,
|
||||
resultDigest,
|
||||
principal: command.principal,
|
||||
policy: command.policy,
|
||||
decisionMode: command.decisionMode,
|
||||
commandDigest: command.commandDigest,
|
||||
committedAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
...semantic,
|
||||
authorizationDigest: hash(AUTHORIZATION_DIGEST_DOMAIN, semantic),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelPriceCatalogAuthorization(
|
||||
value: ModelPriceCatalogAuthorization,
|
||||
): Readonly<ModelPriceCatalogAuthorization> {
|
||||
const candidate = record(value, 'authorization');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'schema',
|
||||
'authorizationId',
|
||||
'requestId',
|
||||
'operation',
|
||||
'provider',
|
||||
'model',
|
||||
'priceRevision',
|
||||
'catalogCommandDigest',
|
||||
'resultDigest',
|
||||
'principal',
|
||||
'policy',
|
||||
'decisionMode',
|
||||
'commandDigest',
|
||||
'committedAtMs',
|
||||
'authorizationDigest',
|
||||
],
|
||||
'authorization',
|
||||
);
|
||||
if (value.schema !== MODEL_PRICE_CATALOG_AUTHORIZATION_SCHEMA) {
|
||||
return invalid('authorization schema is invalid');
|
||||
}
|
||||
const command = createModelPriceCatalogAuthorizationCommand({
|
||||
authorizationId: value.authorizationId,
|
||||
requestId: value.requestId,
|
||||
operation: value.operation,
|
||||
provider: value.provider,
|
||||
model: value.model,
|
||||
priceRevision: value.priceRevision,
|
||||
catalogCommandDigest: value.catalogCommandDigest,
|
||||
principal: value.principal,
|
||||
policy: value.policy,
|
||||
decisionMode: value.decisionMode,
|
||||
});
|
||||
if (command.commandDigest !== value.commandDigest) {
|
||||
return invalid('authorization command digest is invalid');
|
||||
}
|
||||
const normalized = createModelPriceCatalogAuthorization(
|
||||
command,
|
||||
value.resultDigest,
|
||||
value.committedAtMs,
|
||||
);
|
||||
if (normalized.authorizationDigest !== value.authorizationDigest) {
|
||||
return invalid('authorization digest is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type {
|
||||
SecurityPrincipal,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import { MODEL_PRICE_CURRENCY } from '../pricing';
|
||||
import type {
|
||||
CommitModelPriceCatalogHeadResult,
|
||||
CommitModelPriceCatalogPublicationResult,
|
||||
ModelPriceCatalogAction,
|
||||
ModelPriceCatalogAdministrationRepository,
|
||||
ModelPriceCatalogPublishCommand,
|
||||
ModelPriceCatalogTransitionCommand,
|
||||
} from '../modelPriceCatalog';
|
||||
|
||||
export const MODEL_PRICE_CATALOG_POLICY_DECISION_SCHEMA =
|
||||
'qinglong/model-price-catalog-policy-decision@v1' as const;
|
||||
export const MODEL_PRICE_CATALOG_AUTHORIZATION_COMMAND_SCHEMA =
|
||||
'qinglong/model-price-catalog-authorization-command@v1' as const;
|
||||
export const MODEL_PRICE_CATALOG_AUTHORIZATION_SCHEMA =
|
||||
'qinglong/model-price-catalog-authorization@v1' as const;
|
||||
export const MODEL_PRICE_CATALOG_MANAGEMENT_OPERATIONS = [
|
||||
'publish',
|
||||
'activate',
|
||||
'deactivate',
|
||||
'revoke',
|
||||
] as const;
|
||||
export const MODEL_PRICE_CATALOG_MANAGEMENT_DECISION_MODES = [
|
||||
'human_confirmation',
|
||||
'separation_of_duty',
|
||||
] as const;
|
||||
export const MAX_MODEL_PRICE_CATALOG_PRINCIPAL_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
export type ModelPriceCatalogManagementOperation =
|
||||
(typeof MODEL_PRICE_CATALOG_MANAGEMENT_OPERATIONS)[number];
|
||||
export type ModelPriceCatalogManagementDecisionMode =
|
||||
(typeof MODEL_PRICE_CATALOG_MANAGEMENT_DECISION_MODES)[number];
|
||||
|
||||
export interface ModelPriceCatalogPolicyDecision {
|
||||
readonly schema: typeof MODEL_PRICE_CATALOG_POLICY_DECISION_SCHEMA;
|
||||
readonly effect: 'allow' | 'deny';
|
||||
readonly revision: string;
|
||||
readonly reasons: readonly string[];
|
||||
readonly decisionDigest: string;
|
||||
}
|
||||
|
||||
export interface AuthorizeModelPriceCatalogManagementRequest {
|
||||
readonly operation: ModelPriceCatalogManagementOperation;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly priceRevision: string | null;
|
||||
readonly requestId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogManagementAuthorizer {
|
||||
authorize(
|
||||
request: Readonly<AuthorizeModelPriceCatalogManagementRequest>,
|
||||
): Promise<Readonly<ModelPriceCatalogPolicyDecision>>;
|
||||
}
|
||||
|
||||
export interface ConsumeModelPriceCatalogManagementQuotaCommand {
|
||||
readonly operation: ModelPriceCatalogManagementOperation;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogManagementQuota {
|
||||
consume(
|
||||
command: Readonly<ConsumeModelPriceCatalogManagementQuotaCommand>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogAuthorizationCommand {
|
||||
readonly schema: typeof MODEL_PRICE_CATALOG_AUTHORIZATION_COMMAND_SCHEMA;
|
||||
readonly authorizationId: string;
|
||||
readonly requestId: string;
|
||||
readonly operation: ModelPriceCatalogManagementOperation;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly priceRevision: string | null;
|
||||
readonly catalogCommandDigest: string;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policy: Readonly<ModelPriceCatalogPolicyDecision>;
|
||||
readonly decisionMode: ModelPriceCatalogManagementDecisionMode;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogAuthorization {
|
||||
readonly schema: typeof MODEL_PRICE_CATALOG_AUTHORIZATION_SCHEMA;
|
||||
readonly authorizationId: string;
|
||||
readonly requestId: string;
|
||||
readonly operation: ModelPriceCatalogManagementOperation;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly priceRevision: string | null;
|
||||
readonly catalogCommandDigest: string;
|
||||
readonly resultDigest: string;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
readonly policy: Readonly<ModelPriceCatalogPolicyDecision>;
|
||||
readonly decisionMode: ModelPriceCatalogManagementDecisionMode;
|
||||
readonly commandDigest: string;
|
||||
readonly committedAtMs: number;
|
||||
readonly authorizationDigest: string;
|
||||
}
|
||||
|
||||
export interface CommitAuthorizedModelPriceCatalogPublicationResult
|
||||
extends CommitModelPriceCatalogPublicationResult {
|
||||
readonly authorization: Readonly<ModelPriceCatalogAuthorization>;
|
||||
}
|
||||
|
||||
export interface CommitAuthorizedModelPriceCatalogHeadResult
|
||||
extends CommitModelPriceCatalogHeadResult {
|
||||
readonly authorization: Readonly<ModelPriceCatalogAuthorization>;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogAuthorizedAdministrationRepository
|
||||
extends ModelPriceCatalogAdministrationRepository {
|
||||
findAuthorization(
|
||||
authorizationId: string,
|
||||
): Promise<Readonly<ModelPriceCatalogAuthorization> | null>;
|
||||
publishAuthorized(
|
||||
command: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
authorization: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogPublicationResult>>;
|
||||
transitionAuthorized(
|
||||
command: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
authorization: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogHeadResult>>;
|
||||
}
|
||||
|
||||
export interface BaseManagementRequest {
|
||||
readonly authorizationId: string;
|
||||
readonly requestId: string;
|
||||
readonly mutationId: string;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
}
|
||||
|
||||
export interface PublishModelPriceCatalogRequest extends BaseManagementRequest {
|
||||
readonly priceRevision: string;
|
||||
readonly currency: typeof MODEL_PRICE_CURRENCY;
|
||||
readonly inputMicrosPerMillionTokens: number;
|
||||
readonly outputMicrosPerMillionTokens: number;
|
||||
}
|
||||
|
||||
export interface TransitionModelPriceCatalogRequest
|
||||
extends BaseManagementRequest {
|
||||
readonly expectedGeneration: number;
|
||||
readonly expectedHeadDigest: string | null;
|
||||
readonly action: ModelPriceCatalogAction;
|
||||
readonly priceRevision: string | null;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogManagementService {
|
||||
publish(
|
||||
request: Readonly<PublishModelPriceCatalogRequest>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogPublicationResult>>;
|
||||
transition(
|
||||
request: Readonly<TransitionModelPriceCatalogRequest>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogHeadResult>>;
|
||||
}
|
||||
|
||||
export interface CreateModelPriceCatalogManagementServiceOptions {
|
||||
readonly decisionMode: ModelPriceCatalogManagementDecisionMode;
|
||||
readonly authorizer: ModelPriceCatalogManagementAuthorizer;
|
||||
readonly quota?: ModelPriceCatalogManagementQuota;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export class InvalidModelPriceCatalogManagementValueError extends TypeError {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_MANAGEMENT_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Model price catalog management is invalid: ${message}`);
|
||||
this.name = 'InvalidModelPriceCatalogManagementValueError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPriceCatalogManagementAuthenticationError extends Error {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_MANAGEMENT_AUTHENTICATION_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Model price catalog management requires a recent strong User');
|
||||
this.name = 'ModelPriceCatalogManagementAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPriceCatalogManagementAuthorizationError extends Error {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_MANAGEMENT_FORBIDDEN';
|
||||
|
||||
constructor() {
|
||||
super('Model price catalog management is not authorized');
|
||||
this.name = 'ModelPriceCatalogManagementAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPriceCatalogManagementSeparationOfDutyError extends Error {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_MANAGEMENT_SEPARATION_OF_DUTY_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('Model price activation requires a different publishing User');
|
||||
this.name = 'ModelPriceCatalogManagementSeparationOfDutyError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPriceCatalogManagementQuotaExceededError extends Error {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_MANAGEMENT_QUOTA_EXCEEDED';
|
||||
|
||||
constructor(readonly retryAfterMs: number) {
|
||||
if (
|
||||
!Number.isSafeInteger(retryAfterMs) ||
|
||||
retryAfterMs < 1 ||
|
||||
retryAfterMs > 5 * 60 * 1000
|
||||
) {
|
||||
throw new TypeError('Model price catalog quota retry delay is invalid');
|
||||
}
|
||||
super('Model price catalog management quota is exhausted');
|
||||
this.name = 'ModelPriceCatalogManagementQuotaExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPriceCatalogManagementUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_MANAGEMENT_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Model price catalog management is unavailable', options);
|
||||
this.name = 'ModelPriceCatalogManagementUnavailableError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
createModelPriceCatalogPublishCommand,
|
||||
createModelPriceCatalogTransitionCommand,
|
||||
type ModelPriceCatalogPublication,
|
||||
} from '../modelPriceCatalog';
|
||||
import {
|
||||
createModelPriceCatalogAuthorizationCommand,
|
||||
normalizeModelPriceCatalogPolicyDecision,
|
||||
} from './authorization';
|
||||
import {
|
||||
InvalidModelPriceCatalogManagementValueError,
|
||||
MODEL_PRICE_CATALOG_MANAGEMENT_DECISION_MODES,
|
||||
ModelPriceCatalogManagementAuthorizationError,
|
||||
ModelPriceCatalogManagementQuotaExceededError,
|
||||
ModelPriceCatalogManagementSeparationOfDutyError,
|
||||
ModelPriceCatalogManagementUnavailableError,
|
||||
type BaseManagementRequest,
|
||||
type CommitAuthorizedModelPriceCatalogHeadResult,
|
||||
type CommitAuthorizedModelPriceCatalogPublicationResult,
|
||||
type CreateModelPriceCatalogManagementServiceOptions,
|
||||
type ModelPriceCatalogAuthorizationCommand,
|
||||
type ModelPriceCatalogAuthorizedAdministrationRepository,
|
||||
type ModelPriceCatalogManagementOperation,
|
||||
type ModelPriceCatalogManagementService,
|
||||
type ModelPriceCatalogPolicyDecision,
|
||||
type PublishModelPriceCatalogRequest,
|
||||
type TransitionModelPriceCatalogRequest,
|
||||
} from './contracts';
|
||||
import {
|
||||
currentTime,
|
||||
exactRequest,
|
||||
normalizedPrincipal,
|
||||
unavailable,
|
||||
} from './validation';
|
||||
export function createModelPriceCatalogManagementService(
|
||||
repository: ModelPriceCatalogAuthorizedAdministrationRepository,
|
||||
options: CreateModelPriceCatalogManagementServiceOptions,
|
||||
): Readonly<ModelPriceCatalogManagementService> {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.findPublication !== 'function' ||
|
||||
typeof repository.findCurrent !== 'function' ||
|
||||
typeof repository.findAuthorization !== 'function' ||
|
||||
typeof repository.publishAuthorized !== 'function' ||
|
||||
typeof repository.transitionAuthorized !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'decisionMode' &&
|
||||
key !== 'authorizer' &&
|
||||
key !== 'quota' &&
|
||||
key !== 'now',
|
||||
) ||
|
||||
!MODEL_PRICE_CATALOG_MANAGEMENT_DECISION_MODES.includes(
|
||||
options.decisionMode,
|
||||
) ||
|
||||
!options.authorizer ||
|
||||
typeof options.authorizer.authorize !== 'function' ||
|
||||
(options.quota !== undefined &&
|
||||
(!options.quota || typeof options.quota.consume !== 'function')) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new InvalidModelPriceCatalogManagementValueError(
|
||||
'service options are invalid',
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
const authorize = async (
|
||||
operationValue: ModelPriceCatalogManagementOperation,
|
||||
request: BaseManagementRequest,
|
||||
priceRevision: string | null,
|
||||
catalogCommandDigest: string,
|
||||
): Promise<Readonly<ModelPriceCatalogAuthorizationCommand>> => {
|
||||
const principal = normalizedPrincipal(request.principal, currentTime(now));
|
||||
let policy: Readonly<ModelPriceCatalogPolicyDecision>;
|
||||
try {
|
||||
policy = normalizeModelPriceCatalogPolicyDecision(
|
||||
await options.authorizer.authorize({
|
||||
operation: operationValue,
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
priceRevision,
|
||||
requestId: request.requestId,
|
||||
principal,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (policy.effect !== 'allow') {
|
||||
throw new ModelPriceCatalogManagementAuthorizationError();
|
||||
}
|
||||
if (options.quota) {
|
||||
try {
|
||||
await options.quota.consume({
|
||||
operation: operationValue,
|
||||
subject: principal.subject,
|
||||
idempotencyKey: request.authorizationId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ModelPriceCatalogManagementQuotaExceededError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
return createModelPriceCatalogAuthorizationCommand({
|
||||
authorizationId: request.authorizationId,
|
||||
requestId: request.requestId,
|
||||
operation: operationValue,
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
priceRevision,
|
||||
catalogCommandDigest,
|
||||
principal,
|
||||
policy,
|
||||
decisionMode: options.decisionMode,
|
||||
});
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async publish(
|
||||
request: Readonly<PublishModelPriceCatalogRequest>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogPublicationResult>> {
|
||||
exactRequest(
|
||||
request,
|
||||
[
|
||||
'authorizationId',
|
||||
'requestId',
|
||||
'mutationId',
|
||||
'provider',
|
||||
'model',
|
||||
'principal',
|
||||
'priceRevision',
|
||||
'currency',
|
||||
'inputMicrosPerMillionTokens',
|
||||
'outputMicrosPerMillionTokens',
|
||||
],
|
||||
'publish request',
|
||||
);
|
||||
const principal = normalizedPrincipal(
|
||||
request.principal,
|
||||
currentTime(now),
|
||||
);
|
||||
const command = createModelPriceCatalogPublishCommand({
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
priceRevision: request.priceRevision,
|
||||
currency: request.currency,
|
||||
inputMicrosPerMillionTokens: request.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens: request.outputMicrosPerMillionTokens,
|
||||
mutationId: request.mutationId,
|
||||
publishedByUserId: principal.subject.id,
|
||||
});
|
||||
const authorization = await authorize(
|
||||
'publish',
|
||||
{ ...request, principal },
|
||||
request.priceRevision,
|
||||
command.commandDigest,
|
||||
);
|
||||
return repository.publishAuthorized(command, authorization);
|
||||
},
|
||||
|
||||
async transition(
|
||||
request: Readonly<TransitionModelPriceCatalogRequest>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogHeadResult>> {
|
||||
exactRequest(
|
||||
request,
|
||||
[
|
||||
'authorizationId',
|
||||
'requestId',
|
||||
'mutationId',
|
||||
'provider',
|
||||
'model',
|
||||
'principal',
|
||||
'expectedGeneration',
|
||||
'expectedHeadDigest',
|
||||
'action',
|
||||
'priceRevision',
|
||||
],
|
||||
'transition request',
|
||||
);
|
||||
const principal = normalizedPrincipal(
|
||||
request.principal,
|
||||
currentTime(now),
|
||||
);
|
||||
const command = createModelPriceCatalogTransitionCommand({
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
expectedGeneration: request.expectedGeneration,
|
||||
expectedHeadDigest: request.expectedHeadDigest,
|
||||
action: request.action,
|
||||
priceRevision: request.priceRevision,
|
||||
mutationId: request.mutationId,
|
||||
changedByUserId: principal.subject.id,
|
||||
});
|
||||
const authorization = await authorize(
|
||||
request.action,
|
||||
{ ...request, principal },
|
||||
request.priceRevision,
|
||||
command.commandDigest,
|
||||
);
|
||||
if (
|
||||
options.decisionMode === 'separation_of_duty' &&
|
||||
request.action === 'activate'
|
||||
) {
|
||||
let publication: Readonly<ModelPriceCatalogPublication> | null;
|
||||
try {
|
||||
publication = await repository.findPublication({
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
priceRevision: request.priceRevision!,
|
||||
});
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (!publication) {
|
||||
throw new ModelPriceCatalogManagementUnavailableError();
|
||||
}
|
||||
if (publication.publishedByUserId === principal.subject.id) {
|
||||
throw new ModelPriceCatalogManagementSeparationOfDutyError();
|
||||
}
|
||||
}
|
||||
return repository.transitionAuthorized(command, authorization);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityAuthenticationAssurance,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import {
|
||||
InvalidModelPriceCatalogManagementValueError,
|
||||
MAX_MODEL_PRICE_CATALOG_PRINCIPAL_AGE_MS,
|
||||
ModelPriceCatalogManagementAuthenticationError,
|
||||
ModelPriceCatalogManagementUnavailableError,
|
||||
type ModelPriceCatalogManagementDecisionMode,
|
||||
type ModelPriceCatalogManagementOperation,
|
||||
} from './contracts';
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const STRONG_ASSURANCES = new Set<SecurityAuthenticationAssurance>([
|
||||
'multi_factor',
|
||||
'hardware',
|
||||
'local_console',
|
||||
]);
|
||||
|
||||
export function invalid(message: string): never {
|
||||
throw new InvalidModelPriceCatalogManagementValueError(message);
|
||||
}
|
||||
|
||||
export function record(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
return invalid(`${label} must be a plain object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requestId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !REQUEST_ID_PATTERN.test(value)) {
|
||||
return invalid('request id is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function nullableIdentity(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): string | null {
|
||||
return value === null ? null : identity(value, label);
|
||||
}
|
||||
|
||||
export function integer(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function hash(domain: Buffer, value: object): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function operation(
|
||||
value: unknown,
|
||||
): ModelPriceCatalogManagementOperation {
|
||||
if (
|
||||
value !== 'publish' &&
|
||||
value !== 'activate' &&
|
||||
value !== 'deactivate' &&
|
||||
value !== 'revoke'
|
||||
) {
|
||||
return invalid('operation is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function decisionMode(
|
||||
value: unknown,
|
||||
): ModelPriceCatalogManagementDecisionMode {
|
||||
if (value !== 'human_confirmation' && value !== 'separation_of_duty') {
|
||||
return invalid('decision mode is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeReasons(value: unknown): readonly string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < 1 ||
|
||||
value.length > 8 ||
|
||||
value.some(
|
||||
(reason) => typeof reason !== 'string' || !REASON_PATTERN.test(reason),
|
||||
)
|
||||
) {
|
||||
return invalid('policy reasons are invalid');
|
||||
}
|
||||
return Object.freeze([...value]);
|
||||
}
|
||||
|
||||
export function normalizedPrincipal(
|
||||
value: SecurityPrincipal,
|
||||
nowMs: number,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(value, nowMs);
|
||||
} catch {
|
||||
throw new ModelPriceCatalogManagementAuthenticationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
!STRONG_ASSURANCES.has(principal.assurance) ||
|
||||
nowMs - principal.authenticatedAtMs >
|
||||
MAX_MODEL_PRICE_CATALOG_PRINCIPAL_AGE_MS
|
||||
) {
|
||||
throw new ModelPriceCatalogManagementAuthenticationError();
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
export function normalizeStoredPrincipal(
|
||||
value: SecurityPrincipal,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
const candidate = record(value, 'principal');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'subject',
|
||||
'authenticationId',
|
||||
'authenticatedAtMs',
|
||||
'expiresAtMs',
|
||||
'assurance',
|
||||
],
|
||||
'principal',
|
||||
);
|
||||
const subject = record(value.subject, 'principal subject');
|
||||
exactKeys(subject, ['type', 'id'], 'principal subject');
|
||||
if (
|
||||
value.subject.type !== 'user' ||
|
||||
!STRONG_ASSURANCES.has(value.assurance) ||
|
||||
typeof value.authenticationId !== 'string' ||
|
||||
!IDENTITY_PATTERN.test(value.authenticationId)
|
||||
) {
|
||||
return invalid('principal identity is invalid');
|
||||
}
|
||||
const authenticatedAtMs = integer(
|
||||
value.authenticatedAtMs,
|
||||
'principal authenticatedAtMs',
|
||||
);
|
||||
const expiresAtMs = integer(value.expiresAtMs, 'principal expiresAtMs');
|
||||
if (expiresAtMs <= authenticatedAtMs) {
|
||||
return invalid('principal lifetime is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
subject: Object.freeze({
|
||||
type: 'user' as const,
|
||||
id: identity(value.subject.id, 'principal User id'),
|
||||
}),
|
||||
authenticationId: value.authenticationId,
|
||||
authenticatedAtMs,
|
||||
expiresAtMs,
|
||||
assurance: value.assurance,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateOperationRevision(
|
||||
operationValue: ModelPriceCatalogManagementOperation,
|
||||
revision: string | null,
|
||||
): void {
|
||||
if (
|
||||
(operationValue === 'deactivate' && revision !== null) ||
|
||||
(operationValue !== 'deactivate' && revision === null)
|
||||
) {
|
||||
invalid('operation price revision is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function exactRequest(
|
||||
value: unknown,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const candidate = record(value, label);
|
||||
exactKeys(candidate, expected, label);
|
||||
}
|
||||
|
||||
export function currentTime(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new ModelPriceCatalogManagementUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function unavailable(
|
||||
error: unknown,
|
||||
): ModelPriceCatalogManagementUnavailableError {
|
||||
return new ModelPriceCatalogManagementUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
// Catalog facts are immutable pricing-domain contracts shared by both storage dialects.
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
MODEL_PRICE_CURRENCY,
|
||||
createModelPriceCatalogEntry,
|
||||
normalizeModelPriceCatalogEntry,
|
||||
type ModelPriceCatalogEntry,
|
||||
type ModelPriceCatalogLookup,
|
||||
type ModelPriceCatalogResolver,
|
||||
} from './pricing';
|
||||
|
||||
export const MODEL_PRICE_CATALOG_PUBLISH_COMMAND_SCHEMA =
|
||||
'qinglong/model-price-catalog-publish-command@v1' as const;
|
||||
export const MODEL_PRICE_CATALOG_PUBLICATION_SCHEMA =
|
||||
'qinglong/model-price-catalog-publication@v1' as const;
|
||||
export const MODEL_PRICE_CATALOG_TRANSITION_COMMAND_SCHEMA =
|
||||
'qinglong/model-price-catalog-transition-command@v1' as const;
|
||||
export const MODEL_PRICE_CATALOG_HEAD_SCHEMA =
|
||||
'qinglong/model-price-catalog-head@v1' as const;
|
||||
export const MODEL_PRICE_CATALOG_ACTIONS = [
|
||||
'activate',
|
||||
'deactivate',
|
||||
'revoke',
|
||||
] as const;
|
||||
export const MAX_MODEL_PRICE_CATALOG_GENERATION = 2_147_483_647;
|
||||
|
||||
export type ModelPriceCatalogAction =
|
||||
(typeof MODEL_PRICE_CATALOG_ACTIONS)[number];
|
||||
|
||||
export interface ModelPriceCatalogPublishCommand {
|
||||
readonly schema: typeof MODEL_PRICE_CATALOG_PUBLISH_COMMAND_SCHEMA;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly priceRevision: string;
|
||||
readonly currency: typeof MODEL_PRICE_CURRENCY;
|
||||
readonly inputMicrosPerMillionTokens: number;
|
||||
readonly outputMicrosPerMillionTokens: number;
|
||||
readonly mutationId: string;
|
||||
readonly publishedByUserId: string;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogPublication {
|
||||
readonly schema: typeof MODEL_PRICE_CATALOG_PUBLICATION_SCHEMA;
|
||||
readonly entry: Readonly<ModelPriceCatalogEntry>;
|
||||
readonly mutationId: string;
|
||||
readonly publishedByUserId: string;
|
||||
readonly commandDigest: string;
|
||||
readonly publicationDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogTransitionCommand {
|
||||
readonly schema: typeof MODEL_PRICE_CATALOG_TRANSITION_COMMAND_SCHEMA;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly expectedHeadDigest: string | null;
|
||||
readonly action: ModelPriceCatalogAction;
|
||||
readonly priceRevision: string | null;
|
||||
readonly mutationId: string;
|
||||
readonly changedByUserId: string;
|
||||
readonly commandDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogHead {
|
||||
readonly schema: typeof MODEL_PRICE_CATALOG_HEAD_SCHEMA;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly generation: number;
|
||||
readonly previousHeadDigest: string | null;
|
||||
readonly activePriceRevision: string | null;
|
||||
readonly activeCatalogDigest: string | null;
|
||||
readonly revokedPriceRevision: string | null;
|
||||
readonly revokedCatalogDigest: string | null;
|
||||
readonly action: ModelPriceCatalogAction;
|
||||
readonly mutationId: string;
|
||||
readonly changedByUserId: string;
|
||||
readonly changedAtMs: number;
|
||||
readonly commandDigest: string;
|
||||
readonly headDigest: string;
|
||||
}
|
||||
|
||||
export interface CommitModelPriceCatalogPublicationResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly publication: Readonly<ModelPriceCatalogPublication>;
|
||||
}
|
||||
|
||||
export interface CommitModelPriceCatalogHeadResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly head: Readonly<ModelPriceCatalogHead>;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogReader extends ModelPriceCatalogResolver {
|
||||
findPublication(
|
||||
lookup: Omit<ModelPriceCatalogLookup, 'signal'>,
|
||||
): Promise<Readonly<ModelPriceCatalogPublication> | null>;
|
||||
findCurrent(
|
||||
provider: string,
|
||||
model: string,
|
||||
): Promise<Readonly<ModelPriceCatalogHead> | null>;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogAdministrationRepository
|
||||
extends ModelPriceCatalogReader {
|
||||
publish(
|
||||
command: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
): Promise<Readonly<CommitModelPriceCatalogPublicationResult>>;
|
||||
transition(
|
||||
command: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
): Promise<Readonly<CommitModelPriceCatalogHeadResult>>;
|
||||
}
|
||||
|
||||
export class InvalidModelPriceCatalogError extends TypeError {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Model price catalog is invalid: ${message}`);
|
||||
this.name = 'InvalidModelPriceCatalogError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPriceCatalogConflictError extends Error {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Model price catalog conflicts with durable state');
|
||||
this.name = 'ModelPriceCatalogConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPriceCatalogUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Model price catalog is unavailable', options);
|
||||
this.name = 'ModelPriceCatalogUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const PUBLISH_COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-price-catalog-publish-command-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const PUBLICATION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-price-catalog-publication-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const TRANSITION_COMMAND_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-price-catalog-transition-command-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const HEAD_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-price-catalog-head-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidModelPriceCatalogError(message);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
return invalid(`${label} must be a plain object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableDigest(value: unknown, label: string): string | null {
|
||||
return value === null ? null : digest(value, label);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum = Number.MAX_SAFE_INTEGER,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function action(value: unknown): ModelPriceCatalogAction {
|
||||
if (value !== 'activate' && value !== 'deactivate' && value !== 'revoke') {
|
||||
return invalid('action is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hash(domain: Buffer, value: object): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function createModelPriceCatalogPublishCommand(
|
||||
value: Omit<ModelPriceCatalogPublishCommand, 'schema' | 'commandDigest'>,
|
||||
): Readonly<ModelPriceCatalogPublishCommand> {
|
||||
const candidate = record(value, 'publish command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'currency',
|
||||
'inputMicrosPerMillionTokens',
|
||||
'model',
|
||||
'mutationId',
|
||||
'outputMicrosPerMillionTokens',
|
||||
'priceRevision',
|
||||
'provider',
|
||||
'publishedByUserId',
|
||||
],
|
||||
'publish command',
|
||||
);
|
||||
const semanticEntry = createModelPriceCatalogEntry({
|
||||
provider: value.provider,
|
||||
model: value.model,
|
||||
priceRevision: value.priceRevision,
|
||||
currency: value.currency,
|
||||
inputMicrosPerMillionTokens: value.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens: value.outputMicrosPerMillionTokens,
|
||||
publishedAtMs: 0,
|
||||
});
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_PRICE_CATALOG_PUBLISH_COMMAND_SCHEMA,
|
||||
provider: semanticEntry.provider,
|
||||
model: semanticEntry.model,
|
||||
priceRevision: semanticEntry.priceRevision,
|
||||
currency: semanticEntry.currency,
|
||||
inputMicrosPerMillionTokens: semanticEntry.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens: semanticEntry.outputMicrosPerMillionTokens,
|
||||
mutationId: identity(value.mutationId, 'mutation id'),
|
||||
publishedByUserId: identity(value.publishedByUserId, 'publisher user id'),
|
||||
});
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
commandDigest: hash(PUBLISH_COMMAND_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelPriceCatalogPublishCommand(
|
||||
value: ModelPriceCatalogPublishCommand,
|
||||
): Readonly<ModelPriceCatalogPublishCommand> {
|
||||
const candidate = record(value, 'publish command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'commandDigest',
|
||||
'currency',
|
||||
'inputMicrosPerMillionTokens',
|
||||
'model',
|
||||
'mutationId',
|
||||
'outputMicrosPerMillionTokens',
|
||||
'priceRevision',
|
||||
'provider',
|
||||
'publishedByUserId',
|
||||
'schema',
|
||||
],
|
||||
'publish command',
|
||||
);
|
||||
if (value.schema !== MODEL_PRICE_CATALOG_PUBLISH_COMMAND_SCHEMA) {
|
||||
invalid('publish command schema is invalid');
|
||||
}
|
||||
const normalized = createModelPriceCatalogPublishCommand({
|
||||
provider: value.provider,
|
||||
model: value.model,
|
||||
priceRevision: value.priceRevision,
|
||||
currency: value.currency,
|
||||
inputMicrosPerMillionTokens: value.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens: value.outputMicrosPerMillionTokens,
|
||||
mutationId: value.mutationId,
|
||||
publishedByUserId: value.publishedByUserId,
|
||||
});
|
||||
if (
|
||||
digest(value.commandDigest, 'publish command digest') !==
|
||||
normalized.commandDigest
|
||||
) {
|
||||
invalid('publish command digest is inconsistent');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createModelPriceCatalogPublication(
|
||||
commandValue: ModelPriceCatalogPublishCommand,
|
||||
publishedAtMsValue: number,
|
||||
): Readonly<ModelPriceCatalogPublication> {
|
||||
const command = normalizeModelPriceCatalogPublishCommand(commandValue);
|
||||
const entry = createModelPriceCatalogEntry({
|
||||
provider: command.provider,
|
||||
model: command.model,
|
||||
priceRevision: command.priceRevision,
|
||||
currency: command.currency,
|
||||
inputMicrosPerMillionTokens: command.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens: command.outputMicrosPerMillionTokens,
|
||||
publishedAtMs: integer(publishedAtMsValue, 'publish time', 0),
|
||||
});
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_PRICE_CATALOG_PUBLICATION_SCHEMA,
|
||||
entry,
|
||||
mutationId: command.mutationId,
|
||||
publishedByUserId: command.publishedByUserId,
|
||||
commandDigest: command.commandDigest,
|
||||
});
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
publicationDigest: hash(PUBLICATION_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelPriceCatalogPublication(
|
||||
value: ModelPriceCatalogPublication,
|
||||
): Readonly<ModelPriceCatalogPublication> {
|
||||
const candidate = record(value, 'publication');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'commandDigest',
|
||||
'entry',
|
||||
'mutationId',
|
||||
'publicationDigest',
|
||||
'publishedByUserId',
|
||||
'schema',
|
||||
],
|
||||
'publication',
|
||||
);
|
||||
if (value.schema !== MODEL_PRICE_CATALOG_PUBLICATION_SCHEMA) {
|
||||
invalid('publication schema is invalid');
|
||||
}
|
||||
const entry = normalizeModelPriceCatalogEntry(value.entry);
|
||||
const command = createModelPriceCatalogPublishCommand({
|
||||
provider: entry.provider,
|
||||
model: entry.model,
|
||||
priceRevision: entry.priceRevision,
|
||||
currency: entry.currency,
|
||||
inputMicrosPerMillionTokens: entry.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens: entry.outputMicrosPerMillionTokens,
|
||||
mutationId: value.mutationId,
|
||||
publishedByUserId: value.publishedByUserId,
|
||||
});
|
||||
if (
|
||||
digest(value.commandDigest, 'publish command digest') !==
|
||||
command.commandDigest
|
||||
) {
|
||||
invalid('publication command is inconsistent');
|
||||
}
|
||||
const normalized = createModelPriceCatalogPublication(
|
||||
command,
|
||||
entry.publishedAtMs,
|
||||
);
|
||||
if (
|
||||
digest(value.publicationDigest, 'publication digest') !==
|
||||
normalized.publicationDigest
|
||||
) {
|
||||
invalid('publication digest is inconsistent');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createModelPriceCatalogTransitionCommand(
|
||||
value: Omit<ModelPriceCatalogTransitionCommand, 'schema' | 'commandDigest'>,
|
||||
): Readonly<ModelPriceCatalogTransitionCommand> {
|
||||
const candidate = record(value, 'transition command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'action',
|
||||
'changedByUserId',
|
||||
'expectedGeneration',
|
||||
'expectedHeadDigest',
|
||||
'model',
|
||||
'mutationId',
|
||||
'priceRevision',
|
||||
'provider',
|
||||
],
|
||||
'transition command',
|
||||
);
|
||||
const normalizedAction = action(value.action);
|
||||
const expectedGeneration = integer(
|
||||
value.expectedGeneration,
|
||||
'expected generation',
|
||||
0,
|
||||
MAX_MODEL_PRICE_CATALOG_GENERATION - 1,
|
||||
);
|
||||
const expectedHeadDigest = nullableDigest(
|
||||
value.expectedHeadDigest,
|
||||
'expected head digest',
|
||||
);
|
||||
if ((expectedGeneration === 0) !== (expectedHeadDigest === null)) {
|
||||
invalid('expected head fence is invalid');
|
||||
}
|
||||
const priceRevision =
|
||||
value.priceRevision === null
|
||||
? null
|
||||
: identity(value.priceRevision, 'price revision');
|
||||
if ((normalizedAction === 'deactivate') !== (priceRevision === null)) {
|
||||
invalid('transition price revision is invalid');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_PRICE_CATALOG_TRANSITION_COMMAND_SCHEMA,
|
||||
provider: identity(value.provider, 'provider'),
|
||||
model: identity(value.model, 'model'),
|
||||
expectedGeneration,
|
||||
expectedHeadDigest,
|
||||
action: normalizedAction,
|
||||
priceRevision,
|
||||
mutationId: identity(value.mutationId, 'mutation id'),
|
||||
changedByUserId: identity(value.changedByUserId, 'actor user id'),
|
||||
});
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
commandDigest: hash(TRANSITION_COMMAND_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelPriceCatalogTransitionCommand(
|
||||
value: ModelPriceCatalogTransitionCommand,
|
||||
): Readonly<ModelPriceCatalogTransitionCommand> {
|
||||
const candidate = record(value, 'transition command');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'action',
|
||||
'changedByUserId',
|
||||
'commandDigest',
|
||||
'expectedGeneration',
|
||||
'expectedHeadDigest',
|
||||
'model',
|
||||
'mutationId',
|
||||
'priceRevision',
|
||||
'provider',
|
||||
'schema',
|
||||
],
|
||||
'transition command',
|
||||
);
|
||||
if (value.schema !== MODEL_PRICE_CATALOG_TRANSITION_COMMAND_SCHEMA) {
|
||||
invalid('transition command schema is invalid');
|
||||
}
|
||||
const normalized = createModelPriceCatalogTransitionCommand({
|
||||
provider: value.provider,
|
||||
model: value.model,
|
||||
expectedGeneration: value.expectedGeneration,
|
||||
expectedHeadDigest: value.expectedHeadDigest,
|
||||
action: value.action,
|
||||
priceRevision: value.priceRevision,
|
||||
mutationId: value.mutationId,
|
||||
changedByUserId: value.changedByUserId,
|
||||
});
|
||||
if (
|
||||
digest(value.commandDigest, 'transition command digest') !==
|
||||
normalized.commandDigest
|
||||
) {
|
||||
invalid('transition command digest is inconsistent');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createModelPriceCatalogHead(
|
||||
previousValue: ModelPriceCatalogHead | null,
|
||||
commandValue: ModelPriceCatalogTransitionCommand,
|
||||
targetValue: ModelPriceCatalogPublication | null,
|
||||
targetRevoked: boolean,
|
||||
changedAtMsValue: number,
|
||||
): Readonly<ModelPriceCatalogHead> {
|
||||
const previous =
|
||||
previousValue === null
|
||||
? null
|
||||
: normalizeModelPriceCatalogHead(previousValue);
|
||||
const command = normalizeModelPriceCatalogTransitionCommand(commandValue);
|
||||
const target =
|
||||
targetValue === null
|
||||
? null
|
||||
: normalizeModelPriceCatalogPublication(targetValue);
|
||||
if (
|
||||
(previous?.generation ?? 0) !== command.expectedGeneration ||
|
||||
(previous?.headDigest ?? null) !== command.expectedHeadDigest ||
|
||||
(previous !== null &&
|
||||
(previous.provider !== command.provider ||
|
||||
previous.model !== command.model))
|
||||
) {
|
||||
invalid('transition fence is inconsistent');
|
||||
}
|
||||
if (
|
||||
command.action === 'deactivate'
|
||||
? target !== null
|
||||
: !target ||
|
||||
target.entry.provider !== command.provider ||
|
||||
target.entry.model !== command.model ||
|
||||
target.entry.priceRevision !== command.priceRevision
|
||||
) {
|
||||
invalid('transition target is inconsistent');
|
||||
}
|
||||
if (targetRevoked) invalid('transition target is revoked');
|
||||
|
||||
let activePriceRevision = previous?.activePriceRevision ?? null;
|
||||
let activeCatalogDigest = previous?.activeCatalogDigest ?? null;
|
||||
let revokedPriceRevision: string | null = null;
|
||||
let revokedCatalogDigest: string | null = null;
|
||||
if (command.action === 'activate') {
|
||||
if (
|
||||
activePriceRevision === target!.entry.priceRevision &&
|
||||
activeCatalogDigest === target!.entry.catalogDigest
|
||||
) {
|
||||
invalid('transition does not change catalog state');
|
||||
}
|
||||
activePriceRevision = target!.entry.priceRevision;
|
||||
activeCatalogDigest = target!.entry.catalogDigest;
|
||||
} else if (command.action === 'deactivate') {
|
||||
if (activePriceRevision === null) {
|
||||
invalid('transition does not change catalog state');
|
||||
}
|
||||
activePriceRevision = null;
|
||||
activeCatalogDigest = null;
|
||||
} else {
|
||||
revokedPriceRevision = target!.entry.priceRevision;
|
||||
revokedCatalogDigest = target!.entry.catalogDigest;
|
||||
if (activePriceRevision === revokedPriceRevision) {
|
||||
activePriceRevision = null;
|
||||
activeCatalogDigest = null;
|
||||
}
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_PRICE_CATALOG_HEAD_SCHEMA,
|
||||
provider: command.provider,
|
||||
model: command.model,
|
||||
generation: command.expectedGeneration + 1,
|
||||
previousHeadDigest: command.expectedHeadDigest,
|
||||
activePriceRevision,
|
||||
activeCatalogDigest,
|
||||
revokedPriceRevision,
|
||||
revokedCatalogDigest,
|
||||
action: command.action,
|
||||
mutationId: command.mutationId,
|
||||
changedByUserId: command.changedByUserId,
|
||||
changedAtMs: integer(changedAtMsValue, 'change time', 0),
|
||||
commandDigest: command.commandDigest,
|
||||
});
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
headDigest: hash(HEAD_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelPriceCatalogHead(
|
||||
value: ModelPriceCatalogHead,
|
||||
): Readonly<ModelPriceCatalogHead> {
|
||||
const candidate = record(value, 'catalog head');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'action',
|
||||
'activeCatalogDigest',
|
||||
'activePriceRevision',
|
||||
'changedAtMs',
|
||||
'changedByUserId',
|
||||
'commandDigest',
|
||||
'generation',
|
||||
'headDigest',
|
||||
'model',
|
||||
'mutationId',
|
||||
'previousHeadDigest',
|
||||
'provider',
|
||||
'revokedCatalogDigest',
|
||||
'revokedPriceRevision',
|
||||
'schema',
|
||||
],
|
||||
'catalog head',
|
||||
);
|
||||
if (value.schema !== MODEL_PRICE_CATALOG_HEAD_SCHEMA) {
|
||||
invalid('catalog head schema is invalid');
|
||||
}
|
||||
const generation = integer(
|
||||
value.generation,
|
||||
'generation',
|
||||
1,
|
||||
MAX_MODEL_PRICE_CATALOG_GENERATION,
|
||||
);
|
||||
const previousHeadDigest = nullableDigest(
|
||||
value.previousHeadDigest,
|
||||
'previous head digest',
|
||||
);
|
||||
if ((generation === 1) !== (previousHeadDigest === null)) {
|
||||
invalid('catalog head generation fence is invalid');
|
||||
}
|
||||
const activePriceRevision =
|
||||
value.activePriceRevision === null
|
||||
? null
|
||||
: identity(value.activePriceRevision, 'active price revision');
|
||||
const activeCatalogDigest = nullableDigest(
|
||||
value.activeCatalogDigest,
|
||||
'active catalog digest',
|
||||
);
|
||||
const revokedPriceRevision =
|
||||
value.revokedPriceRevision === null
|
||||
? null
|
||||
: identity(value.revokedPriceRevision, 'revoked price revision');
|
||||
const revokedCatalogDigest = nullableDigest(
|
||||
value.revokedCatalogDigest,
|
||||
'revoked catalog digest',
|
||||
);
|
||||
if (
|
||||
(activePriceRevision === null) !== (activeCatalogDigest === null) ||
|
||||
(revokedPriceRevision === null) !== (revokedCatalogDigest === null) ||
|
||||
(revokedPriceRevision !== null &&
|
||||
revokedPriceRevision === activePriceRevision)
|
||||
) {
|
||||
invalid('catalog head projection is invalid');
|
||||
}
|
||||
const normalizedAction = action(value.action);
|
||||
if ((normalizedAction === 'revoke') !== (revokedPriceRevision !== null)) {
|
||||
invalid('catalog head revocation projection is invalid');
|
||||
}
|
||||
if (
|
||||
(normalizedAction === 'activate' && activePriceRevision === null) ||
|
||||
(normalizedAction === 'deactivate' && activePriceRevision !== null)
|
||||
) {
|
||||
invalid('catalog head action projection is invalid');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_PRICE_CATALOG_HEAD_SCHEMA,
|
||||
provider: identity(value.provider, 'provider'),
|
||||
model: identity(value.model, 'model'),
|
||||
generation,
|
||||
previousHeadDigest,
|
||||
activePriceRevision,
|
||||
activeCatalogDigest,
|
||||
revokedPriceRevision,
|
||||
revokedCatalogDigest,
|
||||
action: normalizedAction,
|
||||
mutationId: identity(value.mutationId, 'mutation id'),
|
||||
changedByUserId: identity(value.changedByUserId, 'actor user id'),
|
||||
changedAtMs: integer(value.changedAtMs, 'change time', 0),
|
||||
commandDigest: digest(value.commandDigest, 'transition command digest'),
|
||||
});
|
||||
const headDigest = digest(value.headDigest, 'head digest');
|
||||
if (headDigest !== hash(HEAD_DIGEST_DOMAIN, unsigned)) {
|
||||
invalid('catalog head digest is inconsistent');
|
||||
}
|
||||
return Object.freeze({ ...unsigned, headDigest });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Human authorization is part of Pricing management, not Profile composition.
|
||||
export {
|
||||
MAX_MODEL_PRICE_CATALOG_PRINCIPAL_AGE_MS,
|
||||
MODEL_PRICE_CATALOG_AUTHORIZATION_COMMAND_SCHEMA,
|
||||
MODEL_PRICE_CATALOG_AUTHORIZATION_SCHEMA,
|
||||
MODEL_PRICE_CATALOG_MANAGEMENT_DECISION_MODES,
|
||||
MODEL_PRICE_CATALOG_MANAGEMENT_OPERATIONS,
|
||||
MODEL_PRICE_CATALOG_POLICY_DECISION_SCHEMA,
|
||||
InvalidModelPriceCatalogManagementValueError,
|
||||
ModelPriceCatalogManagementAuthenticationError,
|
||||
ModelPriceCatalogManagementAuthorizationError,
|
||||
ModelPriceCatalogManagementQuotaExceededError,
|
||||
ModelPriceCatalogManagementSeparationOfDutyError,
|
||||
ModelPriceCatalogManagementUnavailableError,
|
||||
type AuthorizeModelPriceCatalogManagementRequest,
|
||||
type CommitAuthorizedModelPriceCatalogHeadResult,
|
||||
type CommitAuthorizedModelPriceCatalogPublicationResult,
|
||||
type ConsumeModelPriceCatalogManagementQuotaCommand,
|
||||
type CreateModelPriceCatalogManagementServiceOptions,
|
||||
type ModelPriceCatalogAuthorization,
|
||||
type ModelPriceCatalogAuthorizationCommand,
|
||||
type ModelPriceCatalogAuthorizedAdministrationRepository,
|
||||
type ModelPriceCatalogManagementAuthorizer,
|
||||
type ModelPriceCatalogManagementDecisionMode,
|
||||
type ModelPriceCatalogManagementOperation,
|
||||
type ModelPriceCatalogManagementQuota,
|
||||
type ModelPriceCatalogManagementService,
|
||||
type ModelPriceCatalogPolicyDecision,
|
||||
type PublishModelPriceCatalogRequest,
|
||||
type TransitionModelPriceCatalogRequest,
|
||||
} from './model-price-catalog-management/contracts';
|
||||
export {
|
||||
createModelPriceCatalogAuthorization,
|
||||
createModelPriceCatalogAuthorizationCommand,
|
||||
createModelPriceCatalogPolicyDecision,
|
||||
normalizeModelPriceCatalogAuthorization,
|
||||
normalizeModelPriceCatalogAuthorizationCommand,
|
||||
normalizeModelPriceCatalogPolicyDecision,
|
||||
normalizeModelPriceCatalogPublishAuthorization,
|
||||
normalizeModelPriceCatalogTransitionAuthorization,
|
||||
} from './model-price-catalog-management/authorization';
|
||||
export { createModelPriceCatalogManagementService } from './model-price-catalog-management/service';
|
||||
@@ -0,0 +1,671 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
// Pricing owns immutable quotes and settlements; metering and quota remain sibling domains.
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
CommitModelInvocationResult,
|
||||
ModelInvocationCompletionCommand,
|
||||
ModelInvocationCompletionRecord,
|
||||
ModelInvocationRepository,
|
||||
ModelInvocationStartCommand,
|
||||
ModelInvocationStartRecord,
|
||||
} from '../model-invocation/modelInvocation';
|
||||
import { normalizeModelInvocationCompletionRecord } from '../model-invocation/modelInvocation';
|
||||
import type { ModelUsage } from '../model-gateway/model';
|
||||
import { normalizeModelUsage } from '../model-gateway/validation';
|
||||
import type { ModelInvocationQuotaAdmission } from '../usage/usageQuota';
|
||||
|
||||
export const MODEL_PRICE_CATALOG_ENTRY_SCHEMA =
|
||||
'qinglong/model-price-catalog-entry@v1' as const;
|
||||
export const MODEL_INVOCATION_PRICE_QUOTE_SCHEMA =
|
||||
'qinglong/model-invocation-price-quote@v1' as const;
|
||||
export const MODEL_INVOCATION_PRICE_SETTLEMENT_SCHEMA =
|
||||
'qinglong/model-invocation-price-settlement@v1' as const;
|
||||
export const MODEL_PRICE_CURRENCY = 'USD' as const;
|
||||
export const MODEL_PRICE_RATE_UNIT_TOKENS = 1_000_000;
|
||||
export const MAX_MODEL_PRICE_CATALOG_ENTRIES = 256;
|
||||
export const MAX_MODEL_PRICE_RATE_MICROS = 1_000_000_000_000;
|
||||
export const MAX_MODEL_PRICE_COST_MICROS = 1_000_000_000_000_000;
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const CATALOG_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-price-catalog-entry-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const QUOTE_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-invocation-price-quote-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const SETTLEMENT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/model-invocation-price-settlement-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
export interface ModelPriceCatalogEntry {
|
||||
readonly schema: typeof MODEL_PRICE_CATALOG_ENTRY_SCHEMA;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly priceRevision: string;
|
||||
readonly currency: typeof MODEL_PRICE_CURRENCY;
|
||||
readonly inputMicrosPerMillionTokens: number;
|
||||
readonly outputMicrosPerMillionTokens: number;
|
||||
readonly publishedAtMs: number;
|
||||
readonly catalogDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogLookup {
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly priceRevision: string;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogResolver {
|
||||
resolve(
|
||||
lookup: Readonly<ModelPriceCatalogLookup>,
|
||||
): Promise<Readonly<ModelPriceCatalogEntry> | null>;
|
||||
}
|
||||
|
||||
export interface ModelInvocationPriceQuote {
|
||||
readonly schema: typeof MODEL_INVOCATION_PRICE_QUOTE_SCHEMA;
|
||||
readonly invocationId: string;
|
||||
readonly projectId: string;
|
||||
readonly modelPolicyRevision: string;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly priceRevision: string;
|
||||
readonly currency: typeof MODEL_PRICE_CURRENCY;
|
||||
readonly inputMicrosPerMillionTokens: number;
|
||||
readonly outputMicrosPerMillionTokens: number;
|
||||
readonly maxTotalTokens: number;
|
||||
readonly maxOutputTokens: number;
|
||||
readonly reservedCostMicros: number;
|
||||
readonly catalogDigest: string;
|
||||
readonly quoteDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelInvocationPriceSettlement {
|
||||
readonly schema: typeof MODEL_INVOCATION_PRICE_SETTLEMENT_SCHEMA;
|
||||
readonly invocationId: string;
|
||||
readonly projectId: string;
|
||||
readonly quoteDigest: string;
|
||||
readonly completionDigest: string;
|
||||
readonly currency: typeof MODEL_PRICE_CURRENCY;
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
readonly costMicros: number;
|
||||
readonly settledAtMs: number;
|
||||
readonly settlementDigest: string;
|
||||
}
|
||||
|
||||
export interface ModelInvocationPricingRepository {
|
||||
findPriceQuote(
|
||||
invocationId: string,
|
||||
): Promise<Readonly<ModelInvocationPriceQuote> | null>;
|
||||
findPriceSettlement(
|
||||
invocationId: string,
|
||||
): Promise<Readonly<ModelInvocationPriceSettlement> | null>;
|
||||
}
|
||||
|
||||
export interface PricingAwareModelInvocationRepository
|
||||
extends ModelInvocationRepository,
|
||||
ModelInvocationPricingRepository {
|
||||
admitWithPricing(
|
||||
command: ModelInvocationStartCommand,
|
||||
quote: ModelInvocationPriceQuote,
|
||||
quotaAdmission?: ModelInvocationQuotaAdmission,
|
||||
): Promise<Readonly<CommitModelInvocationResult<ModelInvocationStartRecord>>>;
|
||||
completeWithPricing(
|
||||
command: ModelInvocationCompletionCommand,
|
||||
): Promise<
|
||||
Readonly<CommitModelInvocationResult<ModelInvocationCompletionRecord>>
|
||||
>;
|
||||
}
|
||||
|
||||
export class InvalidModelPricingError extends TypeError {
|
||||
readonly code = 'MODEL_PRICING_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Model pricing is invalid: ${message}`);
|
||||
this.name = 'InvalidModelPricingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPriceUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PRICE_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('The exact model price revision is unavailable');
|
||||
this.name = 'ModelPriceUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPricingConfigurationError extends Error {
|
||||
readonly code = 'MODEL_PRICING_CONFIGURATION_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('The model pricing authority is not durably configured');
|
||||
this.name = 'ModelPricingConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidModelPricingError(message);
|
||||
}
|
||||
|
||||
function plainObject(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
return invalid(`${label} must be a plain object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum = Number.MAX_SAFE_INTEGER,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hash(domain: Buffer, value: object): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function rateCost(tokens: number, rateMicros: number): number {
|
||||
const numerator = BigInt(tokens) * BigInt(rateMicros);
|
||||
const unit = BigInt(MODEL_PRICE_RATE_UNIT_TOKENS);
|
||||
const rounded = (numerator + unit - 1n) / unit;
|
||||
if (rounded > BigInt(MAX_MODEL_PRICE_COST_MICROS)) {
|
||||
return invalid('calculated cost exceeds its reviewed limit');
|
||||
}
|
||||
return Number(rounded);
|
||||
}
|
||||
|
||||
function totalCost(
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
inputRate: number,
|
||||
outputRate: number,
|
||||
): number {
|
||||
const cost =
|
||||
rateCost(inputTokens, inputRate) + rateCost(outputTokens, outputRate);
|
||||
return integer(cost, 'calculated cost', 0, MAX_MODEL_PRICE_COST_MICROS);
|
||||
}
|
||||
|
||||
function catalogKey(
|
||||
value: Pick<ModelPriceCatalogEntry, 'provider' | 'model' | 'priceRevision'>,
|
||||
): string {
|
||||
return JSON.stringify([value.provider, value.model, value.priceRevision]);
|
||||
}
|
||||
|
||||
export function createModelPriceCatalogEntry(
|
||||
value: Omit<ModelPriceCatalogEntry, 'schema' | 'catalogDigest'>,
|
||||
): Readonly<ModelPriceCatalogEntry> {
|
||||
const candidate = plainObject(value, 'catalog entry');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'currency',
|
||||
'inputMicrosPerMillionTokens',
|
||||
'model',
|
||||
'outputMicrosPerMillionTokens',
|
||||
'priceRevision',
|
||||
'provider',
|
||||
'publishedAtMs',
|
||||
],
|
||||
'catalog entry',
|
||||
);
|
||||
if (value.currency !== MODEL_PRICE_CURRENCY) {
|
||||
invalid('catalog currency is unsupported');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_PRICE_CATALOG_ENTRY_SCHEMA,
|
||||
provider: identifier(value.provider, 'provider'),
|
||||
model: identifier(value.model, 'model'),
|
||||
priceRevision: identifier(value.priceRevision, 'price revision'),
|
||||
currency: MODEL_PRICE_CURRENCY,
|
||||
inputMicrosPerMillionTokens: integer(
|
||||
value.inputMicrosPerMillionTokens,
|
||||
'input price',
|
||||
0,
|
||||
MAX_MODEL_PRICE_RATE_MICROS,
|
||||
),
|
||||
outputMicrosPerMillionTokens: integer(
|
||||
value.outputMicrosPerMillionTokens,
|
||||
'output price',
|
||||
0,
|
||||
MAX_MODEL_PRICE_RATE_MICROS,
|
||||
),
|
||||
publishedAtMs: integer(value.publishedAtMs, 'publish time', 0),
|
||||
});
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
catalogDigest: hash(CATALOG_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelPriceCatalogEntry(
|
||||
value: ModelPriceCatalogEntry,
|
||||
): Readonly<ModelPriceCatalogEntry> {
|
||||
const candidate = plainObject(value, 'catalog entry');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'catalogDigest',
|
||||
'currency',
|
||||
'inputMicrosPerMillionTokens',
|
||||
'model',
|
||||
'outputMicrosPerMillionTokens',
|
||||
'priceRevision',
|
||||
'provider',
|
||||
'publishedAtMs',
|
||||
'schema',
|
||||
],
|
||||
'catalog entry',
|
||||
);
|
||||
if (value.schema !== MODEL_PRICE_CATALOG_ENTRY_SCHEMA) {
|
||||
invalid('catalog schema is invalid');
|
||||
}
|
||||
const normalized = createModelPriceCatalogEntry({
|
||||
provider: value.provider,
|
||||
model: value.model,
|
||||
priceRevision: value.priceRevision,
|
||||
currency: value.currency,
|
||||
inputMicrosPerMillionTokens: value.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens: value.outputMicrosPerMillionTokens,
|
||||
publishedAtMs: value.publishedAtMs,
|
||||
});
|
||||
if (
|
||||
digest(value.catalogDigest, 'catalog digest') !== normalized.catalogDigest
|
||||
) {
|
||||
invalid('catalog digest is inconsistent');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export class StaticModelPriceCatalog implements ModelPriceCatalogResolver {
|
||||
readonly #entries: ReadonlyMap<string, Readonly<ModelPriceCatalogEntry>>;
|
||||
|
||||
constructor(entries: readonly ModelPriceCatalogEntry[]) {
|
||||
if (
|
||||
!Array.isArray(entries) ||
|
||||
entries.length < 1 ||
|
||||
entries.length > MAX_MODEL_PRICE_CATALOG_ENTRIES
|
||||
) {
|
||||
invalid('catalog size is invalid');
|
||||
}
|
||||
const indexed = new Map<string, Readonly<ModelPriceCatalogEntry>>();
|
||||
for (const value of entries) {
|
||||
const entry = normalizeModelPriceCatalogEntry(value);
|
||||
const key = catalogKey(entry);
|
||||
if (indexed.has(key)) invalid('catalog identity is duplicated');
|
||||
indexed.set(key, entry);
|
||||
}
|
||||
this.#entries = indexed;
|
||||
}
|
||||
|
||||
async resolve(
|
||||
lookupValue: Readonly<ModelPriceCatalogLookup>,
|
||||
): Promise<Readonly<ModelPriceCatalogEntry> | null> {
|
||||
const lookup = plainObject(lookupValue, 'catalog lookup');
|
||||
exactKeys(
|
||||
lookup,
|
||||
lookupValue.signal === undefined
|
||||
? ['model', 'priceRevision', 'provider']
|
||||
: ['model', 'priceRevision', 'provider', 'signal'],
|
||||
'catalog lookup',
|
||||
);
|
||||
if (
|
||||
lookupValue.signal !== undefined &&
|
||||
!(lookupValue.signal instanceof AbortSignal)
|
||||
) {
|
||||
invalid('catalog signal is invalid');
|
||||
}
|
||||
if (lookupValue.signal?.aborted) {
|
||||
throw lookupValue.signal.reason;
|
||||
}
|
||||
return (
|
||||
this.#entries.get(
|
||||
catalogKey({
|
||||
provider: identifier(lookupValue.provider, 'provider'),
|
||||
model: identifier(lookupValue.model, 'model'),
|
||||
priceRevision: identifier(
|
||||
lookupValue.priceRevision,
|
||||
'price revision',
|
||||
),
|
||||
}),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createModelInvocationPriceQuote(
|
||||
entryValue: ModelPriceCatalogEntry,
|
||||
options: Readonly<{
|
||||
invocationId: string;
|
||||
projectId: string;
|
||||
modelPolicyRevision: string;
|
||||
maxTotalTokens: number;
|
||||
maxOutputTokens: number;
|
||||
}>,
|
||||
): Readonly<ModelInvocationPriceQuote> {
|
||||
const entry = normalizeModelPriceCatalogEntry(entryValue);
|
||||
const maxTotalTokens = integer(
|
||||
options.maxTotalTokens,
|
||||
'maximum total tokens',
|
||||
1,
|
||||
1_000_000_000_000,
|
||||
);
|
||||
const maxOutputTokens = integer(
|
||||
options.maxOutputTokens,
|
||||
'maximum output tokens',
|
||||
1,
|
||||
maxTotalTokens,
|
||||
);
|
||||
const expensiveOutput =
|
||||
entry.outputMicrosPerMillionTokens > entry.inputMicrosPerMillionTokens;
|
||||
const reservedOutputTokens = expensiveOutput ? maxOutputTokens : 0;
|
||||
const reservedInputTokens = maxTotalTokens - reservedOutputTokens;
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_INVOCATION_PRICE_QUOTE_SCHEMA,
|
||||
invocationId: identifier(options.invocationId, 'invocation id'),
|
||||
projectId: identifier(options.projectId, 'Project id'),
|
||||
modelPolicyRevision: identifier(
|
||||
options.modelPolicyRevision,
|
||||
'model policy revision',
|
||||
),
|
||||
provider: entry.provider,
|
||||
model: entry.model,
|
||||
priceRevision: entry.priceRevision,
|
||||
currency: entry.currency,
|
||||
inputMicrosPerMillionTokens: entry.inputMicrosPerMillionTokens,
|
||||
outputMicrosPerMillionTokens: entry.outputMicrosPerMillionTokens,
|
||||
maxTotalTokens,
|
||||
maxOutputTokens,
|
||||
reservedCostMicros: totalCost(
|
||||
reservedInputTokens,
|
||||
reservedOutputTokens,
|
||||
entry.inputMicrosPerMillionTokens,
|
||||
entry.outputMicrosPerMillionTokens,
|
||||
),
|
||||
catalogDigest: entry.catalogDigest,
|
||||
});
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
quoteDigest: hash(QUOTE_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelInvocationPriceQuote(
|
||||
value: ModelInvocationPriceQuote,
|
||||
): Readonly<ModelInvocationPriceQuote> {
|
||||
const candidate = plainObject(value, 'price quote');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'catalogDigest',
|
||||
'currency',
|
||||
'inputMicrosPerMillionTokens',
|
||||
'invocationId',
|
||||
'maxOutputTokens',
|
||||
'maxTotalTokens',
|
||||
'model',
|
||||
'modelPolicyRevision',
|
||||
'outputMicrosPerMillionTokens',
|
||||
'priceRevision',
|
||||
'projectId',
|
||||
'provider',
|
||||
'quoteDigest',
|
||||
'reservedCostMicros',
|
||||
'schema',
|
||||
],
|
||||
'price quote',
|
||||
);
|
||||
if (
|
||||
value.schema !== MODEL_INVOCATION_PRICE_QUOTE_SCHEMA ||
|
||||
value.currency !== MODEL_PRICE_CURRENCY
|
||||
) {
|
||||
invalid('price quote schema or currency is invalid');
|
||||
}
|
||||
const maxTotalTokens = integer(
|
||||
value.maxTotalTokens,
|
||||
'maximum total tokens',
|
||||
1,
|
||||
1_000_000_000_000,
|
||||
);
|
||||
const maxOutputTokens = integer(
|
||||
value.maxOutputTokens,
|
||||
'maximum output tokens',
|
||||
1,
|
||||
maxTotalTokens,
|
||||
);
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_INVOCATION_PRICE_QUOTE_SCHEMA,
|
||||
invocationId: identifier(value.invocationId, 'invocation id'),
|
||||
projectId: identifier(value.projectId, 'Project id'),
|
||||
modelPolicyRevision: identifier(
|
||||
value.modelPolicyRevision,
|
||||
'model policy revision',
|
||||
),
|
||||
provider: identifier(value.provider, 'provider'),
|
||||
model: identifier(value.model, 'model'),
|
||||
priceRevision: identifier(value.priceRevision, 'price revision'),
|
||||
currency: MODEL_PRICE_CURRENCY,
|
||||
inputMicrosPerMillionTokens: integer(
|
||||
value.inputMicrosPerMillionTokens,
|
||||
'input price',
|
||||
0,
|
||||
MAX_MODEL_PRICE_RATE_MICROS,
|
||||
),
|
||||
outputMicrosPerMillionTokens: integer(
|
||||
value.outputMicrosPerMillionTokens,
|
||||
'output price',
|
||||
0,
|
||||
MAX_MODEL_PRICE_RATE_MICROS,
|
||||
),
|
||||
maxTotalTokens,
|
||||
maxOutputTokens,
|
||||
reservedCostMicros: integer(
|
||||
value.reservedCostMicros,
|
||||
'reserved cost',
|
||||
0,
|
||||
MAX_MODEL_PRICE_COST_MICROS,
|
||||
),
|
||||
catalogDigest: digest(value.catalogDigest, 'catalog digest'),
|
||||
});
|
||||
const expensiveOutput =
|
||||
unsigned.outputMicrosPerMillionTokens >
|
||||
unsigned.inputMicrosPerMillionTokens;
|
||||
const expectedCost = totalCost(
|
||||
unsigned.maxTotalTokens - (expensiveOutput ? unsigned.maxOutputTokens : 0),
|
||||
expensiveOutput ? unsigned.maxOutputTokens : 0,
|
||||
unsigned.inputMicrosPerMillionTokens,
|
||||
unsigned.outputMicrosPerMillionTokens,
|
||||
);
|
||||
if (
|
||||
unsigned.reservedCostMicros !== expectedCost ||
|
||||
digest(value.quoteDigest, 'quote digest') !==
|
||||
hash(QUOTE_DIGEST_DOMAIN, unsigned)
|
||||
) {
|
||||
invalid('price quote cost or digest is inconsistent');
|
||||
}
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
quoteDigest: value.quoteDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function createModelInvocationPriceSettlement(
|
||||
quoteValue: ModelInvocationPriceQuote,
|
||||
completionValue: ModelInvocationCompletionRecord,
|
||||
): Readonly<ModelInvocationPriceSettlement> | null {
|
||||
const quote = normalizeModelInvocationPriceQuote(quoteValue);
|
||||
const completion = normalizeModelInvocationCompletionRecord(completionValue);
|
||||
if (
|
||||
completion.invocationId !== quote.invocationId ||
|
||||
completion.projectId !== quote.projectId
|
||||
) {
|
||||
invalid('completion is detached from its price quote');
|
||||
}
|
||||
if (!completion.usage) return null;
|
||||
if (
|
||||
completion.usage.totalTokens > quote.maxTotalTokens ||
|
||||
completion.usage.outputTokens > quote.maxOutputTokens
|
||||
) {
|
||||
invalid('completion usage exceeds its price quote');
|
||||
}
|
||||
const costMicros = calculateModelInvocationPriceCost(quote, completion.usage);
|
||||
if (completion.usage.costMicros !== costMicros) {
|
||||
invalid('completion cost does not match its price quote');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: MODEL_INVOCATION_PRICE_SETTLEMENT_SCHEMA,
|
||||
invocationId: quote.invocationId,
|
||||
projectId: quote.projectId,
|
||||
quoteDigest: quote.quoteDigest,
|
||||
completionDigest: completion.completionDigest,
|
||||
currency: quote.currency,
|
||||
inputTokens: completion.usage.inputTokens,
|
||||
outputTokens: completion.usage.outputTokens,
|
||||
costMicros,
|
||||
settledAtMs: completion.completedAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
settlementDigest: hash(SETTLEMENT_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateModelInvocationPriceCost(
|
||||
quoteValue: ModelInvocationPriceQuote,
|
||||
usageValue: ModelUsage,
|
||||
): number {
|
||||
const quote = normalizeModelInvocationPriceQuote(quoteValue);
|
||||
const usage = normalizeModelUsage(usageValue);
|
||||
if (
|
||||
usage.totalTokens > quote.maxTotalTokens ||
|
||||
usage.outputTokens > quote.maxOutputTokens
|
||||
) {
|
||||
invalid('usage exceeds its price quote');
|
||||
}
|
||||
return totalCost(
|
||||
usage.inputTokens,
|
||||
usage.outputTokens,
|
||||
quote.inputMicrosPerMillionTokens,
|
||||
quote.outputMicrosPerMillionTokens,
|
||||
);
|
||||
}
|
||||
|
||||
export function priceModelUsage(
|
||||
quoteValue: ModelInvocationPriceQuote,
|
||||
usageValue: ModelUsage,
|
||||
): Readonly<ModelUsage> {
|
||||
const usage = normalizeModelUsage(usageValue);
|
||||
return Object.freeze({
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
costMicros: calculateModelInvocationPriceCost(quoteValue, usage),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelInvocationPriceSettlement(
|
||||
value: ModelInvocationPriceSettlement,
|
||||
quoteValue: ModelInvocationPriceQuote,
|
||||
completionValue: ModelInvocationCompletionRecord,
|
||||
): Readonly<ModelInvocationPriceSettlement> {
|
||||
const record = plainObject(value, 'price settlement');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'completionDigest',
|
||||
'costMicros',
|
||||
'currency',
|
||||
'inputTokens',
|
||||
'invocationId',
|
||||
'outputTokens',
|
||||
'projectId',
|
||||
'quoteDigest',
|
||||
'schema',
|
||||
'settledAtMs',
|
||||
'settlementDigest',
|
||||
],
|
||||
'price settlement',
|
||||
);
|
||||
if (value.schema !== MODEL_INVOCATION_PRICE_SETTLEMENT_SCHEMA) {
|
||||
invalid('price settlement schema is invalid');
|
||||
}
|
||||
const expected = createModelInvocationPriceSettlement(
|
||||
quoteValue,
|
||||
completionValue,
|
||||
);
|
||||
if (
|
||||
!expected ||
|
||||
Object.keys(expected).some(
|
||||
(key) =>
|
||||
record[key] !== expected[key as keyof ModelInvocationPriceSettlement],
|
||||
)
|
||||
) {
|
||||
invalid('price settlement is inconsistent');
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
export function isPricingAwareModelInvocationRepository(
|
||||
value: ModelInvocationRepository,
|
||||
): value is PricingAwareModelInvocationRepository {
|
||||
const candidate = value as Partial<PricingAwareModelInvocationRepository>;
|
||||
return (
|
||||
typeof candidate.findPriceQuote === 'function' &&
|
||||
typeof candidate.findPriceSettlement === 'function' &&
|
||||
typeof candidate.admitWithPricing === 'function' &&
|
||||
typeof candidate.completeWithPricing === 'function'
|
||||
);
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import type { LocalModelInvocationOperationAuthority } from '../../../model-invocation/localModelInvocationRepository';
|
||||
import {
|
||||
InvalidModelPriceCatalogError,
|
||||
ModelPriceCatalogConflictError,
|
||||
ModelPriceCatalogUnavailableError,
|
||||
} from '../../modelPriceCatalog';
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
|
||||
export function unavailable(
|
||||
cause?: unknown,
|
||||
): ModelPriceCatalogUnavailableError {
|
||||
return new ModelPriceCatalogUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function sqliteConstraint(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
const number = (error as { errcode?: unknown }).errcode;
|
||||
return (
|
||||
(typeof code === 'string' && code.startsWith('ERR_SQLITE_CONSTRAINT')) ||
|
||||
(typeof number === 'number' && (number & 0xff) === 19)
|
||||
);
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidModelPriceCatalogError ||
|
||||
error instanceof ModelPriceCatalogConflictError ||
|
||||
error instanceof ModelPriceCatalogUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
return sqliteConstraint(error)
|
||||
? new ModelPriceCatalogConflictError()
|
||||
: unavailable(error);
|
||||
}
|
||||
|
||||
export function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
|
||||
throw new InvalidModelPriceCatalogError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class PrivateLocalAuthority
|
||||
implements LocalModelInvocationOperationAuthority
|
||||
{
|
||||
readonly client: DatabaseSync;
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#pending = 0;
|
||||
|
||||
constructor(client: DatabaseSync) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
enqueue<T>(
|
||||
work: () => Promise<T>,
|
||||
rejection: (reason: 'closed' | 'busy') => Error,
|
||||
): Promise<T> {
|
||||
if (this.#pending >= 64) return Promise.reject(rejection('busy'));
|
||||
this.#pending += 1;
|
||||
const result = this.#tail.then(work, work);
|
||||
this.#tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result.finally(() => {
|
||||
this.#pending -= 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthority(
|
||||
value: LocalModelInvocationOperationAuthority | DatabaseSync,
|
||||
): value is LocalModelInvocationOperationAuthority {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'client' in value &&
|
||||
'enqueue' in value &&
|
||||
typeof value.enqueue === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
export function enqueueOperation<T>(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
work: () => T,
|
||||
): Promise<T> {
|
||||
return authority.enqueue(async () => {
|
||||
try {
|
||||
return work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}, unavailable);
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import type { LocalModelInvocationOperationAuthority } from '../../../model-invocation/localModelInvocationRepository';
|
||||
import {
|
||||
createModelPriceCatalogHead,
|
||||
createModelPriceCatalogPublication,
|
||||
InvalidModelPriceCatalogError,
|
||||
ModelPriceCatalogConflictError,
|
||||
normalizeModelPriceCatalogPublishCommand,
|
||||
normalizeModelPriceCatalogTransitionCommand,
|
||||
type ModelPriceCatalogHead,
|
||||
type ModelPriceCatalogPublishCommand,
|
||||
type ModelPriceCatalogTransitionCommand,
|
||||
} from '../../modelPriceCatalog';
|
||||
import {
|
||||
createModelPriceCatalogAuthorization,
|
||||
normalizeModelPriceCatalogPublishAuthorization,
|
||||
normalizeModelPriceCatalogTransitionAuthorization,
|
||||
type CommitAuthorizedModelPriceCatalogHeadResult,
|
||||
type CommitAuthorizedModelPriceCatalogPublicationResult,
|
||||
type ModelPriceCatalogAuthorizationCommand,
|
||||
} from '../../modelPriceCatalogManagement';
|
||||
|
||||
import { enqueueOperation } from './authority';
|
||||
import {
|
||||
authorizationFromRow,
|
||||
authorizationRows,
|
||||
headFromRow,
|
||||
headRows,
|
||||
insertAuthorization,
|
||||
integer,
|
||||
isFreshReauthorizationReplay,
|
||||
publicationFromRow,
|
||||
publicationRows,
|
||||
type Row,
|
||||
} from './records';
|
||||
|
||||
export type BeforeAuthorizedMutation = (
|
||||
client: DatabaseSync,
|
||||
authorization: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
) => void;
|
||||
|
||||
function rollback(client: DatabaseSync, began: boolean): void {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original failure; the shared authority owns close.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function publishAuthorizedOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
beforeAuthorizedMutation: BeforeAuthorizedMutation | undefined,
|
||||
commandValue: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
authorizationValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogPublicationResult>> {
|
||||
const command = normalizeModelPriceCatalogPublishCommand(commandValue);
|
||||
const authorizationCommand = normalizeModelPriceCatalogPublishAuthorization(
|
||||
command,
|
||||
authorizationValue,
|
||||
);
|
||||
return enqueueOperation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
beforeAuthorizedMutation?.(client, authorizationCommand);
|
||||
const rows = publicationRows(
|
||||
client,
|
||||
`mutation_id = ? OR (provider = ? AND model = ? AND price_revision = ?)`,
|
||||
[
|
||||
command.mutationId,
|
||||
command.provider,
|
||||
command.model,
|
||||
command.priceRevision,
|
||||
],
|
||||
);
|
||||
const storedAuthorizationRows = authorizationRows(
|
||||
client,
|
||||
'authorization_id = ? OR catalog_command_digest = ?',
|
||||
[
|
||||
authorizationCommand.authorizationId,
|
||||
authorizationCommand.catalogCommandDigest,
|
||||
],
|
||||
);
|
||||
if (rows.length > 1 || storedAuthorizationRows.length > 1)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
if (rows[0]) {
|
||||
const publication = publicationFromRow(rows[0]);
|
||||
const authorization = storedAuthorizationRows[0]
|
||||
? authorizationFromRow(storedAuthorizationRows[0])
|
||||
: null;
|
||||
if (
|
||||
publication.commandDigest !== command.commandDigest ||
|
||||
!authorization ||
|
||||
!isFreshReauthorizationReplay(authorization, authorizationCommand) ||
|
||||
authorization.resultDigest !== publication.publicationDigest
|
||||
)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
publication,
|
||||
authorization,
|
||||
});
|
||||
}
|
||||
if (storedAuthorizationRows[0])
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
const clock = client
|
||||
.prepare(`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS now`)
|
||||
.get() as Row;
|
||||
const nowMs = integer(clock, 'now');
|
||||
const publication = createModelPriceCatalogPublication(command, nowMs);
|
||||
const authorization = createModelPriceCatalogAuthorization(
|
||||
authorizationCommand,
|
||||
publication.publicationDigest,
|
||||
nowMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelPriceCatalogPublications" (provider, model, price_revision, catalog_digest, mutation_id, command_digest, publication_digest, published_at_ms, published_by_user_id, publication_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
publication.entry.provider,
|
||||
publication.entry.model,
|
||||
publication.entry.priceRevision,
|
||||
publication.entry.catalogDigest,
|
||||
publication.mutationId,
|
||||
publication.commandDigest,
|
||||
publication.publicationDigest,
|
||||
publication.entry.publishedAtMs,
|
||||
publication.publishedByUserId,
|
||||
JSON.stringify(publication),
|
||||
);
|
||||
insertAuthorization(client, authorization);
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
publication,
|
||||
authorization,
|
||||
});
|
||||
} catch (error) {
|
||||
rollback(client, began);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function transitionAuthorizedOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
beforeAuthorizedMutation: BeforeAuthorizedMutation | undefined,
|
||||
commandValue: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
authorizationValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogHeadResult>> {
|
||||
const command = normalizeModelPriceCatalogTransitionCommand(commandValue);
|
||||
const authorizationCommand =
|
||||
normalizeModelPriceCatalogTransitionAuthorization(
|
||||
command,
|
||||
authorizationValue,
|
||||
);
|
||||
return enqueueOperation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
beforeAuthorizedMutation?.(client, authorizationCommand);
|
||||
const replayRows = headRows(
|
||||
client,
|
||||
`provider = ? AND model = ? AND (mutation_id = ? OR generation = ?)`,
|
||||
[
|
||||
command.provider,
|
||||
command.model,
|
||||
command.mutationId,
|
||||
command.expectedGeneration + 1,
|
||||
],
|
||||
);
|
||||
const storedAuthorizationRows = authorizationRows(
|
||||
client,
|
||||
'authorization_id = ? OR catalog_command_digest = ?',
|
||||
[
|
||||
authorizationCommand.authorizationId,
|
||||
authorizationCommand.catalogCommandDigest,
|
||||
],
|
||||
);
|
||||
if (replayRows.length > 1 || storedAuthorizationRows.length > 1)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
if (replayRows[0]) {
|
||||
const head = headFromRow(replayRows[0]);
|
||||
const authorization = storedAuthorizationRows[0]
|
||||
? authorizationFromRow(storedAuthorizationRows[0])
|
||||
: null;
|
||||
if (
|
||||
head.commandDigest !== command.commandDigest ||
|
||||
!authorization ||
|
||||
!isFreshReauthorizationReplay(authorization, authorizationCommand) ||
|
||||
authorization.resultDigest !== head.headDigest
|
||||
)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
head,
|
||||
authorization,
|
||||
});
|
||||
}
|
||||
if (storedAuthorizationRows[0])
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
const currentRows = headRows(client, 'provider = ? AND model = ?', [
|
||||
command.provider,
|
||||
command.model,
|
||||
]);
|
||||
if (
|
||||
currentRows.length > 1 &&
|
||||
integer(currentRows[0]!, 'generation') ===
|
||||
integer(currentRows[1]!, 'generation')
|
||||
)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
const current = currentRows[0] ? headFromRow(currentRows[0]) : null;
|
||||
const targetRows =
|
||||
command.priceRevision === null
|
||||
? []
|
||||
: publicationRows(
|
||||
client,
|
||||
'provider = ? AND model = ? AND price_revision = ?',
|
||||
[command.provider, command.model, command.priceRevision],
|
||||
);
|
||||
if (targetRows.length > 1) throw new ModelPriceCatalogConflictError();
|
||||
const target = targetRows[0] ? publicationFromRow(targetRows[0]) : null;
|
||||
if (command.action === 'activate') {
|
||||
if (!target) throw new ModelPriceCatalogConflictError();
|
||||
const targetAuthorizationRows = authorizationRows(
|
||||
client,
|
||||
`operation = 'publish' AND result_digest = ?`,
|
||||
[target.publicationDigest],
|
||||
);
|
||||
if (targetAuthorizationRows.length !== 1)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
const targetAuthorization = authorizationFromRow(
|
||||
targetAuthorizationRows[0]!,
|
||||
);
|
||||
if (
|
||||
authorizationCommand.decisionMode === 'separation_of_duty' &&
|
||||
targetAuthorization.principal.subject.id ===
|
||||
authorizationCommand.principal.subject.id
|
||||
)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
}
|
||||
const revoked =
|
||||
command.priceRevision === null
|
||||
? false
|
||||
: !!client
|
||||
.prepare(
|
||||
`SELECT 1 FROM "ModelPriceCatalogHeads" WHERE provider = ? AND model = ? AND revoked_price_revision = ? LIMIT 1`,
|
||||
)
|
||||
.get(command.provider, command.model, command.priceRevision);
|
||||
const clock = client
|
||||
.prepare(`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS now`)
|
||||
.get() as Row;
|
||||
const nowMs = integer(clock, 'now');
|
||||
let head: Readonly<ModelPriceCatalogHead>;
|
||||
try {
|
||||
head = createModelPriceCatalogHead(
|
||||
current,
|
||||
command,
|
||||
target,
|
||||
revoked,
|
||||
nowMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidModelPriceCatalogError)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
throw error;
|
||||
}
|
||||
const authorization = createModelPriceCatalogAuthorization(
|
||||
authorizationCommand,
|
||||
head.headDigest,
|
||||
nowMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelPriceCatalogHeads" (provider, model, generation, previous_head_digest, active_price_revision, active_catalog_digest, revoked_price_revision, revoked_catalog_digest, action, mutation_id, changed_by_user_id, changed_at_ms, command_digest, head_digest, head_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
head.provider,
|
||||
head.model,
|
||||
head.generation,
|
||||
head.previousHeadDigest,
|
||||
head.activePriceRevision,
|
||||
head.activeCatalogDigest,
|
||||
head.revokedPriceRevision,
|
||||
head.revokedCatalogDigest,
|
||||
head.action,
|
||||
head.mutationId,
|
||||
head.changedByUserId,
|
||||
head.changedAtMs,
|
||||
head.commandDigest,
|
||||
head.headDigest,
|
||||
JSON.stringify(head),
|
||||
);
|
||||
insertAuthorization(client, authorization);
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'created' as const, head, authorization });
|
||||
} catch (error) {
|
||||
rollback(client, began);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import type { LocalModelInvocationOperationAuthority } from '../../../model-invocation/localModelInvocationRepository';
|
||||
import {
|
||||
createModelPriceCatalogHead,
|
||||
createModelPriceCatalogPublication,
|
||||
InvalidModelPriceCatalogError,
|
||||
ModelPriceCatalogConflictError,
|
||||
normalizeModelPriceCatalogPublishCommand,
|
||||
normalizeModelPriceCatalogTransitionCommand,
|
||||
type CommitModelPriceCatalogHeadResult,
|
||||
type CommitModelPriceCatalogPublicationResult,
|
||||
type ModelPriceCatalogHead,
|
||||
type ModelPriceCatalogPublishCommand,
|
||||
type ModelPriceCatalogTransitionCommand,
|
||||
} from '../../modelPriceCatalog';
|
||||
|
||||
import { enqueueOperation } from './authority';
|
||||
import {
|
||||
headFromRow,
|
||||
headRows,
|
||||
integer,
|
||||
publicationFromRow,
|
||||
publicationRows,
|
||||
type Row,
|
||||
} from './records';
|
||||
|
||||
function rollback(client: DatabaseSync, began: boolean): void {
|
||||
if (began && client.isTransaction) {
|
||||
try {
|
||||
client.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original failure; the shared authority owns close.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function publishOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
commandValue: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
): Promise<Readonly<CommitModelPriceCatalogPublicationResult>> {
|
||||
const command = normalizeModelPriceCatalogPublishCommand(commandValue);
|
||||
return enqueueOperation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const rows = publicationRows(
|
||||
client,
|
||||
`mutation_id = ? OR (provider = ? AND model = ? AND price_revision = ?)`,
|
||||
[
|
||||
command.mutationId,
|
||||
command.provider,
|
||||
command.model,
|
||||
command.priceRevision,
|
||||
],
|
||||
);
|
||||
if (rows.length > 1) throw new ModelPriceCatalogConflictError();
|
||||
if (rows[0]) {
|
||||
const stored = publicationFromRow(rows[0]);
|
||||
if (stored.commandDigest !== command.commandDigest)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
publication: stored,
|
||||
});
|
||||
}
|
||||
const clock = client
|
||||
.prepare(`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS now`)
|
||||
.get() as Row;
|
||||
const publication = createModelPriceCatalogPublication(
|
||||
command,
|
||||
integer(clock, 'now'),
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelPriceCatalogPublications" (provider, model, price_revision, catalog_digest, mutation_id, command_digest, publication_digest, published_at_ms, published_by_user_id, publication_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
publication.entry.provider,
|
||||
publication.entry.model,
|
||||
publication.entry.priceRevision,
|
||||
publication.entry.catalogDigest,
|
||||
publication.mutationId,
|
||||
publication.commandDigest,
|
||||
publication.publicationDigest,
|
||||
publication.entry.publishedAtMs,
|
||||
publication.publishedByUserId,
|
||||
JSON.stringify(publication),
|
||||
);
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'created' as const, publication });
|
||||
} catch (error) {
|
||||
rollback(client, began);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function transitionOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
commandValue: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
): Promise<Readonly<CommitModelPriceCatalogHeadResult>> {
|
||||
const command = normalizeModelPriceCatalogTransitionCommand(commandValue);
|
||||
return enqueueOperation(authority, () => {
|
||||
let began = false;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
began = true;
|
||||
const replayRows = headRows(
|
||||
client,
|
||||
`provider = ? AND model = ? AND (mutation_id = ? OR generation = ?)`,
|
||||
[
|
||||
command.provider,
|
||||
command.model,
|
||||
command.mutationId,
|
||||
command.expectedGeneration + 1,
|
||||
],
|
||||
);
|
||||
if (replayRows.length > 1) throw new ModelPriceCatalogConflictError();
|
||||
if (replayRows[0]) {
|
||||
const stored = headFromRow(replayRows[0]);
|
||||
if (stored.commandDigest !== command.commandDigest)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'existing' as const, head: stored });
|
||||
}
|
||||
const currentRows = headRows(client, 'provider = ? AND model = ?', [
|
||||
command.provider,
|
||||
command.model,
|
||||
]);
|
||||
if (
|
||||
currentRows.length > 1 &&
|
||||
integer(currentRows[0]!, 'generation') ===
|
||||
integer(currentRows[1]!, 'generation')
|
||||
)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
const current = currentRows[0] ? headFromRow(currentRows[0]) : null;
|
||||
const targetRows =
|
||||
command.priceRevision === null
|
||||
? []
|
||||
: publicationRows(
|
||||
client,
|
||||
'provider = ? AND model = ? AND price_revision = ?',
|
||||
[command.provider, command.model, command.priceRevision],
|
||||
);
|
||||
if (targetRows.length > 1) throw new ModelPriceCatalogConflictError();
|
||||
const target = targetRows[0] ? publicationFromRow(targetRows[0]) : null;
|
||||
const revoked =
|
||||
command.priceRevision === null
|
||||
? false
|
||||
: !!client
|
||||
.prepare(
|
||||
`SELECT 1 FROM "ModelPriceCatalogHeads" WHERE provider = ? AND model = ? AND revoked_price_revision = ? LIMIT 1`,
|
||||
)
|
||||
.get(command.provider, command.model, command.priceRevision);
|
||||
const clock = client
|
||||
.prepare(`SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS now`)
|
||||
.get() as Row;
|
||||
let head: Readonly<ModelPriceCatalogHead>;
|
||||
try {
|
||||
head = createModelPriceCatalogHead(
|
||||
current,
|
||||
command,
|
||||
target,
|
||||
revoked,
|
||||
integer(clock, 'now'),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidModelPriceCatalogError)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
throw error;
|
||||
}
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelPriceCatalogHeads" (provider, model, generation, previous_head_digest, active_price_revision, active_catalog_digest, revoked_price_revision, revoked_catalog_digest, action, mutation_id, changed_by_user_id, changed_at_ms, command_digest, head_digest, head_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
head.provider,
|
||||
head.model,
|
||||
head.generation,
|
||||
head.previousHeadDigest,
|
||||
head.activePriceRevision,
|
||||
head.activeCatalogDigest,
|
||||
head.revokedPriceRevision,
|
||||
head.revokedCatalogDigest,
|
||||
head.action,
|
||||
head.mutationId,
|
||||
head.changedByUserId,
|
||||
head.changedAtMs,
|
||||
head.commandDigest,
|
||||
head.headDigest,
|
||||
JSON.stringify(head),
|
||||
);
|
||||
client.exec('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'created' as const, head });
|
||||
} catch (error) {
|
||||
rollback(client, began);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import type { LocalModelInvocationOperationAuthority } from '../../../model-invocation/localModelInvocationRepository';
|
||||
import {
|
||||
InvalidModelPriceCatalogError,
|
||||
type ModelPriceCatalogHead,
|
||||
type ModelPriceCatalogPublication,
|
||||
} from '../../modelPriceCatalog';
|
||||
import type { ModelPriceCatalogAuthorization } from '../../modelPriceCatalogManagement';
|
||||
import type {
|
||||
ModelPriceCatalogEntry,
|
||||
ModelPriceCatalogLookup,
|
||||
} from '../../pricing';
|
||||
|
||||
import { enqueueOperation, identifier, unavailable } from './authority';
|
||||
import {
|
||||
authorizationFromRow,
|
||||
authorizationRows,
|
||||
headFromRow,
|
||||
headRows,
|
||||
integer,
|
||||
publicationFromRow,
|
||||
publicationRows,
|
||||
type Row,
|
||||
} from './records';
|
||||
|
||||
export function findAuthorizationOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
authorizationIdValue: string,
|
||||
): Promise<Readonly<ModelPriceCatalogAuthorization> | null> {
|
||||
const authorizationId = identifier(
|
||||
authorizationIdValue,
|
||||
'authorization identity',
|
||||
);
|
||||
return enqueueOperation(authority, () => {
|
||||
const rows = authorizationRows(client, 'authorization_id = ?', [
|
||||
authorizationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? authorizationFromRow(rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function findPublicationOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
lookupValue: Omit<ModelPriceCatalogLookup, 'signal'>,
|
||||
): Promise<Readonly<ModelPriceCatalogPublication> | null> {
|
||||
const provider = identifier(lookupValue.provider, 'provider');
|
||||
const model = identifier(lookupValue.model, 'model');
|
||||
const priceRevision = identifier(lookupValue.priceRevision, 'price revision');
|
||||
return enqueueOperation(authority, () => {
|
||||
const rows = publicationRows(
|
||||
client,
|
||||
'provider = ? AND model = ? AND price_revision = ?',
|
||||
[provider, model, priceRevision],
|
||||
);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? publicationFromRow(rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function findCurrentOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
providerValue: string,
|
||||
modelValue: string,
|
||||
): Promise<Readonly<ModelPriceCatalogHead> | null> {
|
||||
const provider = identifier(providerValue, 'provider');
|
||||
const model = identifier(modelValue, 'model');
|
||||
return enqueueOperation(authority, () => {
|
||||
const rows = headRows(client, 'provider = ? AND model = ?', [
|
||||
provider,
|
||||
model,
|
||||
]);
|
||||
if (
|
||||
rows.length > 1 &&
|
||||
integer(rows[0]!, 'generation') === integer(rows[1]!, 'generation')
|
||||
)
|
||||
throw unavailable();
|
||||
return rows[0] ? headFromRow(rows[0]) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveOperation(
|
||||
authority: LocalModelInvocationOperationAuthority,
|
||||
client: DatabaseSync,
|
||||
lookupValue: Readonly<ModelPriceCatalogLookup>,
|
||||
): Promise<Readonly<ModelPriceCatalogEntry> | null> {
|
||||
if (
|
||||
lookupValue.signal !== undefined &&
|
||||
!(lookupValue.signal instanceof AbortSignal)
|
||||
) {
|
||||
throw new InvalidModelPriceCatalogError('catalog signal is invalid');
|
||||
}
|
||||
if (lookupValue.signal?.aborted) throw lookupValue.signal.reason;
|
||||
const provider = identifier(lookupValue.provider, 'provider');
|
||||
const model = identifier(lookupValue.model, 'model');
|
||||
const priceRevision = identifier(lookupValue.priceRevision, 'price revision');
|
||||
const entry = await enqueueOperation(authority, () => {
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT publication.provider, publication.model, publication.price_revision AS "priceRevision", publication.catalog_digest AS "catalogDigest", publication.mutation_id AS "mutationId", publication.command_digest AS "commandDigest", publication.publication_digest AS "publicationDigest", publication.published_at_ms AS "publishedAtMs", publication.published_by_user_id AS "publishedByUserId", publication.publication_json AS "publicationJson" FROM "ModelPriceCatalogHeads" AS head JOIN "ModelPriceCatalogPublications" AS publication ON publication.provider = head.provider AND publication.model = head.model AND publication.price_revision = head.active_price_revision AND publication.catalog_digest = head.active_catalog_digest WHERE head.provider = ? AND head.model = ? AND head.active_price_revision = ? AND head.generation = (SELECT max(latest.generation) FROM "ModelPriceCatalogHeads" AS latest WHERE latest.provider = ? AND latest.model = ?) LIMIT 2`,
|
||||
)
|
||||
.all(provider, model, priceRevision, provider, model) as Row[];
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? publicationFromRow(rows[0]).entry : null;
|
||||
});
|
||||
if (lookupValue.signal?.aborted) throw lookupValue.signal.reason;
|
||||
return entry;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
ModelPriceCatalogUnavailableError,
|
||||
normalizeModelPriceCatalogHead,
|
||||
normalizeModelPriceCatalogPublication,
|
||||
type ModelPriceCatalogHead,
|
||||
type ModelPriceCatalogPublication,
|
||||
} from '../../modelPriceCatalog';
|
||||
import {
|
||||
normalizeModelPriceCatalogAuthorization,
|
||||
type ModelPriceCatalogAuthorization,
|
||||
type ModelPriceCatalogAuthorizationCommand,
|
||||
} from '../../modelPriceCatalogManagement';
|
||||
|
||||
import { unavailable } from './authority';
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
export function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function nullableText(row: Row, key: string): string | null {
|
||||
const value = row[key];
|
||||
if (value !== null && typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsedJson(row: Row, key: string): Record<string, unknown> {
|
||||
const value = text(row, key);
|
||||
if (Buffer.byteLength(value, 'utf8') > 24 * 1024) throw unavailable();
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
Object.getPrototypeOf(parsed) !== Object.prototype
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if (error instanceof ModelPriceCatalogUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function publicationFromRow(
|
||||
row: Row,
|
||||
): Readonly<ModelPriceCatalogPublication> {
|
||||
let publication: Readonly<ModelPriceCatalogPublication>;
|
||||
try {
|
||||
publication = normalizeModelPriceCatalogPublication(
|
||||
parsedJson(
|
||||
row,
|
||||
'publicationJson',
|
||||
) as unknown as ModelPriceCatalogPublication,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
publication.entry.provider !== text(row, 'provider') ||
|
||||
publication.entry.model !== text(row, 'model') ||
|
||||
publication.entry.priceRevision !== text(row, 'priceRevision') ||
|
||||
publication.entry.catalogDigest !== text(row, 'catalogDigest') ||
|
||||
publication.mutationId !== text(row, 'mutationId') ||
|
||||
publication.commandDigest !== text(row, 'commandDigest') ||
|
||||
publication.publicationDigest !== text(row, 'publicationDigest') ||
|
||||
publication.entry.publishedAtMs !== integer(row, 'publishedAtMs') ||
|
||||
publication.publishedByUserId !== text(row, 'publishedByUserId') ||
|
||||
JSON.stringify(publication) !== text(row, 'publicationJson')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return publication;
|
||||
}
|
||||
|
||||
export function headFromRow(row: Row): Readonly<ModelPriceCatalogHead> {
|
||||
let head: Readonly<ModelPriceCatalogHead>;
|
||||
try {
|
||||
head = normalizeModelPriceCatalogHead(
|
||||
parsedJson(row, 'headJson') as unknown as ModelPriceCatalogHead,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
head.provider !== text(row, 'provider') ||
|
||||
head.model !== text(row, 'model') ||
|
||||
head.generation !== integer(row, 'generation') ||
|
||||
head.previousHeadDigest !== nullableText(row, 'previousHeadDigest') ||
|
||||
head.activePriceRevision !== nullableText(row, 'activePriceRevision') ||
|
||||
head.activeCatalogDigest !== nullableText(row, 'activeCatalogDigest') ||
|
||||
head.revokedPriceRevision !== nullableText(row, 'revokedPriceRevision') ||
|
||||
head.revokedCatalogDigest !== nullableText(row, 'revokedCatalogDigest') ||
|
||||
head.action !== text(row, 'action') ||
|
||||
head.mutationId !== text(row, 'mutationId') ||
|
||||
head.changedByUserId !== text(row, 'changedByUserId') ||
|
||||
head.changedAtMs !== integer(row, 'changedAtMs') ||
|
||||
head.commandDigest !== text(row, 'commandDigest') ||
|
||||
head.headDigest !== text(row, 'headDigest') ||
|
||||
JSON.stringify(head) !== text(row, 'headJson')
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
export function authorizationFromRow(
|
||||
row: Row,
|
||||
): Readonly<ModelPriceCatalogAuthorization> {
|
||||
const value = text(row, 'authorizationJson');
|
||||
if (Buffer.byteLength(value, 'utf8') > 32 * 1024) throw unavailable();
|
||||
let authorization: Readonly<ModelPriceCatalogAuthorization>;
|
||||
try {
|
||||
authorization = normalizeModelPriceCatalogAuthorization(
|
||||
JSON.parse(value) as ModelPriceCatalogAuthorization,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
authorization.authorizationId !== text(row, 'authorizationId') ||
|
||||
authorization.requestId !== text(row, 'requestId') ||
|
||||
authorization.operation !== text(row, 'operation') ||
|
||||
authorization.provider !== text(row, 'provider') ||
|
||||
authorization.model !== text(row, 'model') ||
|
||||
authorization.priceRevision !== nullableText(row, 'priceRevision') ||
|
||||
authorization.catalogCommandDigest !== text(row, 'catalogCommandDigest') ||
|
||||
authorization.resultDigest !== text(row, 'resultDigest') ||
|
||||
authorization.principal.subject.id !== text(row, 'userId') ||
|
||||
authorization.principal.authenticationId !==
|
||||
text(row, 'authenticationId') ||
|
||||
authorization.principal.assurance !== text(row, 'assurance') ||
|
||||
authorization.principal.authenticatedAtMs !==
|
||||
integer(row, 'authenticatedAtMs') ||
|
||||
authorization.principal.expiresAtMs !== integer(row, 'expiresAtMs') ||
|
||||
authorization.policy.revision !== text(row, 'policyRevision') ||
|
||||
authorization.policy.decisionDigest !== text(row, 'policyDecisionDigest') ||
|
||||
authorization.decisionMode !== text(row, 'decisionMode') ||
|
||||
authorization.commandDigest !== text(row, 'commandDigest') ||
|
||||
authorization.committedAtMs !== integer(row, 'committedAtMs') ||
|
||||
authorization.authorizationDigest !== text(row, 'authorizationDigest') ||
|
||||
JSON.stringify(authorization.policy.reasons) !== text(row, 'reasonsJson') ||
|
||||
JSON.stringify(authorization) !== value
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return authorization;
|
||||
}
|
||||
|
||||
export function isFreshReauthorizationReplay(
|
||||
stored: Readonly<ModelPriceCatalogAuthorization>,
|
||||
incoming: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): boolean {
|
||||
return (
|
||||
stored.authorizationId === incoming.authorizationId &&
|
||||
stored.requestId === incoming.requestId &&
|
||||
stored.operation === incoming.operation &&
|
||||
stored.provider === incoming.provider &&
|
||||
stored.model === incoming.model &&
|
||||
stored.priceRevision === incoming.priceRevision &&
|
||||
stored.catalogCommandDigest === incoming.catalogCommandDigest &&
|
||||
stored.principal.subject.type === incoming.principal.subject.type &&
|
||||
stored.principal.subject.id === incoming.principal.subject.id &&
|
||||
stored.principal.authenticationId === incoming.principal.authenticationId &&
|
||||
stored.principal.assurance === incoming.principal.assurance &&
|
||||
stored.policy.decisionDigest === incoming.policy.decisionDigest &&
|
||||
stored.decisionMode === incoming.decisionMode
|
||||
);
|
||||
}
|
||||
|
||||
export function publicationRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly SQLInputValue[],
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT provider, model, price_revision AS "priceRevision", catalog_digest AS "catalogDigest", mutation_id AS "mutationId", command_digest AS "commandDigest", publication_digest AS "publicationDigest", published_at_ms AS "publishedAtMs", published_by_user_id AS "publishedByUserId", publication_json AS "publicationJson" FROM "ModelPriceCatalogPublications" WHERE ${where} ORDER BY provider, model, price_revision LIMIT 2`,
|
||||
)
|
||||
.all(...values) as Row[];
|
||||
}
|
||||
|
||||
export function headRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly SQLInputValue[],
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT provider, model, generation, previous_head_digest AS "previousHeadDigest", active_price_revision AS "activePriceRevision", active_catalog_digest AS "activeCatalogDigest", revoked_price_revision AS "revokedPriceRevision", revoked_catalog_digest AS "revokedCatalogDigest", action, mutation_id AS "mutationId", changed_by_user_id AS "changedByUserId", changed_at_ms AS "changedAtMs", command_digest AS "commandDigest", head_digest AS "headDigest", head_json AS "headJson" FROM "ModelPriceCatalogHeads" WHERE ${where} ORDER BY generation DESC LIMIT 2`,
|
||||
)
|
||||
.all(...values) as Row[];
|
||||
}
|
||||
|
||||
export function authorizationRows(
|
||||
client: DatabaseSync,
|
||||
where: string,
|
||||
values: readonly SQLInputValue[],
|
||||
): readonly Row[] {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT authorization_id AS "authorizationId", request_id AS "requestId", operation, provider, model, price_revision AS "priceRevision", catalog_command_digest AS "catalogCommandDigest", result_digest AS "resultDigest", user_id AS "userId", authentication_id AS "authenticationId", assurance, authenticated_at_ms AS "authenticatedAtMs", expires_at_ms AS "expiresAtMs", policy_revision AS "policyRevision", policy_decision_digest AS "policyDecisionDigest", decision_mode AS "decisionMode", command_digest AS "commandDigest", committed_at_ms AS "committedAtMs", authorization_digest AS "authorizationDigest", reasons_json AS "reasonsJson", authorization_json AS "authorizationJson" FROM "ModelPriceCatalogAuthorizations" WHERE ${where} ORDER BY committed_at_ms DESC LIMIT 2`,
|
||||
)
|
||||
.all(...values) as Row[];
|
||||
}
|
||||
|
||||
export function insertAuthorization(
|
||||
client: DatabaseSync,
|
||||
authorization: Readonly<ModelPriceCatalogAuthorization>,
|
||||
): void {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ModelPriceCatalogAuthorizations" (authorization_id, request_id, operation, provider, model, price_revision, catalog_command_digest, publication_digest, head_digest, result_digest, user_id, authentication_id, assurance, authenticated_at_ms, expires_at_ms, policy_revision, policy_decision_digest, decision_mode, command_digest, committed_at_ms, authorization_digest, reasons_json, authorization_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
authorization.authorizationId,
|
||||
authorization.requestId,
|
||||
authorization.operation,
|
||||
authorization.provider,
|
||||
authorization.model,
|
||||
authorization.priceRevision,
|
||||
authorization.catalogCommandDigest,
|
||||
authorization.operation === 'publish' ? authorization.resultDigest : null,
|
||||
authorization.operation === 'publish' ? null : authorization.resultDigest,
|
||||
authorization.resultDigest,
|
||||
authorization.principal.subject.id,
|
||||
authorization.principal.authenticationId,
|
||||
authorization.principal.assurance,
|
||||
authorization.principal.authenticatedAtMs,
|
||||
authorization.principal.expiresAtMs,
|
||||
authorization.policy.revision,
|
||||
authorization.policy.decisionDigest,
|
||||
authorization.decisionMode,
|
||||
authorization.commandDigest,
|
||||
authorization.committedAtMs,
|
||||
authorization.authorizationDigest,
|
||||
JSON.stringify(authorization.policy.reasons),
|
||||
JSON.stringify(authorization),
|
||||
);
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import type { LocalModelInvocationOperationAuthority } from '../../../model-invocation/localModelInvocationRepository';
|
||||
import {
|
||||
InvalidModelPriceCatalogError,
|
||||
type CommitModelPriceCatalogHeadResult,
|
||||
type CommitModelPriceCatalogPublicationResult,
|
||||
type ModelPriceCatalogAdministrationRepository,
|
||||
type ModelPriceCatalogHead,
|
||||
type ModelPriceCatalogPublication,
|
||||
type ModelPriceCatalogPublishCommand,
|
||||
type ModelPriceCatalogTransitionCommand,
|
||||
} from '../../modelPriceCatalog';
|
||||
import {
|
||||
type CommitAuthorizedModelPriceCatalogHeadResult,
|
||||
type CommitAuthorizedModelPriceCatalogPublicationResult,
|
||||
type ModelPriceCatalogAuthorization,
|
||||
type ModelPriceCatalogAuthorizationCommand,
|
||||
type ModelPriceCatalogAuthorizedAdministrationRepository,
|
||||
} from '../../modelPriceCatalogManagement';
|
||||
import type {
|
||||
ModelPriceCatalogEntry,
|
||||
ModelPriceCatalogLookup,
|
||||
} from '../../pricing';
|
||||
|
||||
import {
|
||||
publishAuthorizedOperation,
|
||||
transitionAuthorizedOperation,
|
||||
} from './authorizedMutationOperations';
|
||||
import { PrivateLocalAuthority, isAuthority } from './authority';
|
||||
import {
|
||||
publishOperation,
|
||||
transitionOperation,
|
||||
} from './catalogMutationOperations';
|
||||
import {
|
||||
findAuthorizationOperation,
|
||||
findCurrentOperation,
|
||||
findPublicationOperation,
|
||||
resolveOperation,
|
||||
} from './readOperations';
|
||||
|
||||
export interface LocalModelPriceCatalogRepositoryOptions {
|
||||
readonly beforeAuthorizedMutation?: (
|
||||
client: DatabaseSync,
|
||||
authorization: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export class LocalModelPriceCatalogRepository
|
||||
implements
|
||||
ModelPriceCatalogAdministrationRepository,
|
||||
ModelPriceCatalogAuthorizedAdministrationRepository
|
||||
{
|
||||
readonly #authority: LocalModelInvocationOperationAuthority;
|
||||
readonly #client: DatabaseSync;
|
||||
readonly #beforeAuthorizedMutation:
|
||||
| NonNullable<
|
||||
LocalModelPriceCatalogRepositoryOptions['beforeAuthorizedMutation']
|
||||
>
|
||||
| undefined;
|
||||
|
||||
constructor(
|
||||
authority: LocalModelInvocationOperationAuthority | DatabaseSync,
|
||||
options: LocalModelPriceCatalogRepositoryOptions = {},
|
||||
) {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some((key) => key !== 'beforeAuthorizedMutation') ||
|
||||
(options.beforeAuthorizedMutation !== undefined &&
|
||||
typeof options.beforeAuthorizedMutation !== 'function')
|
||||
) {
|
||||
throw new InvalidModelPriceCatalogError(
|
||||
'local repository options are invalid',
|
||||
);
|
||||
}
|
||||
this.#authority = isAuthority(authority)
|
||||
? authority
|
||||
: new PrivateLocalAuthority(authority);
|
||||
this.#client = this.#authority.client;
|
||||
this.#beforeAuthorizedMutation = options.beforeAuthorizedMutation;
|
||||
}
|
||||
|
||||
findAuthorization(
|
||||
authorizationIdValue: string,
|
||||
): Promise<Readonly<ModelPriceCatalogAuthorization> | null> {
|
||||
return findAuthorizationOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
authorizationIdValue,
|
||||
);
|
||||
}
|
||||
|
||||
findPublication(
|
||||
lookupValue: Omit<ModelPriceCatalogLookup, 'signal'>,
|
||||
): Promise<Readonly<ModelPriceCatalogPublication> | null> {
|
||||
return findPublicationOperation(this.#authority, this.#client, lookupValue);
|
||||
}
|
||||
|
||||
findCurrent(
|
||||
providerValue: string,
|
||||
modelValue: string,
|
||||
): Promise<Readonly<ModelPriceCatalogHead> | null> {
|
||||
return findCurrentOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
providerValue,
|
||||
modelValue,
|
||||
);
|
||||
}
|
||||
|
||||
resolve(
|
||||
lookupValue: Readonly<ModelPriceCatalogLookup>,
|
||||
): Promise<Readonly<ModelPriceCatalogEntry> | null> {
|
||||
return resolveOperation(this.#authority, this.#client, lookupValue);
|
||||
}
|
||||
|
||||
publish(
|
||||
commandValue: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
): Promise<Readonly<CommitModelPriceCatalogPublicationResult>> {
|
||||
return publishOperation(this.#authority, this.#client, commandValue);
|
||||
}
|
||||
|
||||
transition(
|
||||
commandValue: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
): Promise<Readonly<CommitModelPriceCatalogHeadResult>> {
|
||||
return transitionOperation(this.#authority, this.#client, commandValue);
|
||||
}
|
||||
|
||||
publishAuthorized(
|
||||
commandValue: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
authorizationValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogPublicationResult>> {
|
||||
return publishAuthorizedOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
this.#beforeAuthorizedMutation,
|
||||
commandValue,
|
||||
authorizationValue,
|
||||
);
|
||||
}
|
||||
|
||||
transitionAuthorized(
|
||||
commandValue: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
authorizationValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogHeadResult>> {
|
||||
return transitionAuthorizedOperation(
|
||||
this.#authority,
|
||||
this.#client,
|
||||
this.#beforeAuthorizedMutation,
|
||||
commandValue,
|
||||
authorizationValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
LocalModelPriceCatalogRepository,
|
||||
type LocalModelPriceCatalogRepositoryOptions,
|
||||
} from './local-model-price-catalog-repository/repository';
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
InvalidModelPriceCatalogError,
|
||||
ModelPriceCatalogConflictError,
|
||||
ModelPriceCatalogUnavailableError,
|
||||
} from '../../modelPriceCatalog';
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
|
||||
export function unavailable(
|
||||
cause?: unknown,
|
||||
): ModelPriceCatalogUnavailableError {
|
||||
return new ModelPriceCatalogUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function sqlState(error: unknown): string {
|
||||
if (!error || typeof error !== 'object') return '';
|
||||
const value = (error as { code?: unknown }).code;
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
export function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidModelPriceCatalogError ||
|
||||
error instanceof ModelPriceCatalogConflictError ||
|
||||
error instanceof ModelPriceCatalogUnavailableError
|
||||
)
|
||||
return error;
|
||||
const state = sqlState(error);
|
||||
if (
|
||||
state === '23503' ||
|
||||
state === '23505' ||
|
||||
state === '23514' ||
|
||||
state === '40001' ||
|
||||
state === '40P01'
|
||||
)
|
||||
return new ModelPriceCatalogConflictError();
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
export function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value))
|
||||
throw new InvalidModelPriceCatalogError(`${label} is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function assertPool(pool: PostgresPool): void {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
)
|
||||
throw new TypeError('PostgreSQL Model price catalog pool is invalid');
|
||||
}
|
||||
|
||||
export async function acquireClient(
|
||||
pool: PostgresPool,
|
||||
): Promise<PostgresClient> {
|
||||
try {
|
||||
return await pool.connect();
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rollback(client: PostgresClient): Promise<void> {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function runTransaction<T>(
|
||||
pool: PostgresPool,
|
||||
provider: string,
|
||||
model: string,
|
||||
work: (client: PostgresClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await acquireClient(pool);
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||
await client.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
|
||||
[JSON.stringify([provider, model])],
|
||||
);
|
||||
return await work(client);
|
||||
} catch (error) {
|
||||
await rollback(client);
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
createModelPriceCatalogHead,
|
||||
createModelPriceCatalogPublication,
|
||||
InvalidModelPriceCatalogError,
|
||||
ModelPriceCatalogConflictError,
|
||||
normalizeModelPriceCatalogPublishCommand,
|
||||
normalizeModelPriceCatalogTransitionCommand,
|
||||
type ModelPriceCatalogHead,
|
||||
type ModelPriceCatalogPublishCommand,
|
||||
type ModelPriceCatalogTransitionCommand,
|
||||
} from '../../modelPriceCatalog';
|
||||
import {
|
||||
createModelPriceCatalogAuthorization,
|
||||
normalizeModelPriceCatalogPublishAuthorization,
|
||||
normalizeModelPriceCatalogTransitionAuthorization,
|
||||
type CommitAuthorizedModelPriceCatalogHeadResult,
|
||||
type CommitAuthorizedModelPriceCatalogPublicationResult,
|
||||
type ModelPriceCatalogAuthorizationCommand,
|
||||
} from '../../modelPriceCatalogManagement';
|
||||
|
||||
import { runTransaction } from './authority';
|
||||
import {
|
||||
authorizationFromRow,
|
||||
authorizationRows,
|
||||
databaseClock,
|
||||
headFromRow,
|
||||
headRows,
|
||||
insertAuthorization,
|
||||
insertHead,
|
||||
insertPublication,
|
||||
integer,
|
||||
publicationFromRow,
|
||||
publicationRows,
|
||||
wasRevoked,
|
||||
} from './records';
|
||||
|
||||
export function publishAuthorizedOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
authorizationValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogPublicationResult>> {
|
||||
const command = normalizeModelPriceCatalogPublishCommand(commandValue);
|
||||
const authorizationCommand = normalizeModelPriceCatalogPublishAuthorization(
|
||||
command,
|
||||
authorizationValue,
|
||||
);
|
||||
return runTransaction(
|
||||
pool,
|
||||
command.provider,
|
||||
command.model,
|
||||
async (client) => {
|
||||
const rows = await publicationRows(
|
||||
client,
|
||||
`mutation_id = $1 OR (provider = $2 AND model = $3 AND price_revision = $4)`,
|
||||
[
|
||||
command.mutationId,
|
||||
command.provider,
|
||||
command.model,
|
||||
command.priceRevision,
|
||||
],
|
||||
);
|
||||
const storedAuthorizationRows = await authorizationRows(
|
||||
client,
|
||||
'authorization_id = $1 OR catalog_command_digest = $2',
|
||||
[
|
||||
authorizationCommand.authorizationId,
|
||||
authorizationCommand.catalogCommandDigest,
|
||||
],
|
||||
);
|
||||
if (rows.length > 1 || storedAuthorizationRows.length > 1)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
if (rows[0]) {
|
||||
const publication = publicationFromRow(rows[0]);
|
||||
const authorization = storedAuthorizationRows[0]
|
||||
? authorizationFromRow(storedAuthorizationRows[0])
|
||||
: null;
|
||||
if (
|
||||
publication.commandDigest !== command.commandDigest ||
|
||||
!authorization ||
|
||||
authorization.commandDigest !== authorizationCommand.commandDigest ||
|
||||
authorization.resultDigest !== publication.publicationDigest
|
||||
)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
publication,
|
||||
authorization,
|
||||
});
|
||||
}
|
||||
if (storedAuthorizationRows[0])
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
const nowMs = await databaseClock(client);
|
||||
const publication = createModelPriceCatalogPublication(command, nowMs);
|
||||
const authorization = createModelPriceCatalogAuthorization(
|
||||
authorizationCommand,
|
||||
publication.publicationDigest,
|
||||
nowMs,
|
||||
);
|
||||
await insertPublication(client, publication);
|
||||
await insertAuthorization(client, authorization);
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
publication,
|
||||
authorization,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function transitionAuthorizedOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
authorizationValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogHeadResult>> {
|
||||
const command = normalizeModelPriceCatalogTransitionCommand(commandValue);
|
||||
const authorizationCommand =
|
||||
normalizeModelPriceCatalogTransitionAuthorization(
|
||||
command,
|
||||
authorizationValue,
|
||||
);
|
||||
return runTransaction(
|
||||
pool,
|
||||
command.provider,
|
||||
command.model,
|
||||
async (client) => {
|
||||
const replayRows = await headRows(
|
||||
client,
|
||||
`provider = $1 AND model = $2 AND (mutation_id = $3 OR generation = $4)`,
|
||||
[
|
||||
command.provider,
|
||||
command.model,
|
||||
command.mutationId,
|
||||
command.expectedGeneration + 1,
|
||||
],
|
||||
);
|
||||
const storedAuthorizationRows = await authorizationRows(
|
||||
client,
|
||||
'authorization_id = $1 OR catalog_command_digest = $2',
|
||||
[
|
||||
authorizationCommand.authorizationId,
|
||||
authorizationCommand.catalogCommandDigest,
|
||||
],
|
||||
);
|
||||
if (replayRows.length > 1 || storedAuthorizationRows.length > 1)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
if (replayRows[0]) {
|
||||
const head = headFromRow(replayRows[0]);
|
||||
const authorization = storedAuthorizationRows[0]
|
||||
? authorizationFromRow(storedAuthorizationRows[0])
|
||||
: null;
|
||||
if (
|
||||
head.commandDigest !== command.commandDigest ||
|
||||
!authorization ||
|
||||
authorization.commandDigest !== authorizationCommand.commandDigest ||
|
||||
authorization.resultDigest !== head.headDigest
|
||||
)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
head,
|
||||
authorization,
|
||||
});
|
||||
}
|
||||
if (storedAuthorizationRows[0])
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
const currentRows = await headRows(
|
||||
client,
|
||||
'provider = $1 AND model = $2',
|
||||
[command.provider, command.model],
|
||||
);
|
||||
if (
|
||||
currentRows.length > 1 &&
|
||||
integer(currentRows[0]!, 'generation') ===
|
||||
integer(currentRows[1]!, 'generation')
|
||||
)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
const current = currentRows[0] ? headFromRow(currentRows[0]) : null;
|
||||
const targetRows =
|
||||
command.priceRevision === null
|
||||
? []
|
||||
: await publicationRows(
|
||||
client,
|
||||
'provider = $1 AND model = $2 AND price_revision = $3',
|
||||
[command.provider, command.model, command.priceRevision],
|
||||
);
|
||||
if (targetRows.length > 1) throw new ModelPriceCatalogConflictError();
|
||||
const target = targetRows[0] ? publicationFromRow(targetRows[0]) : null;
|
||||
if (command.action === 'activate') {
|
||||
if (!target) throw new ModelPriceCatalogConflictError();
|
||||
const targetAuthorizationRows = await authorizationRows(
|
||||
client,
|
||||
`operation = 'publish' AND result_digest = $1`,
|
||||
[target.publicationDigest],
|
||||
);
|
||||
if (targetAuthorizationRows.length !== 1)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
const targetAuthorization = authorizationFromRow(
|
||||
targetAuthorizationRows[0]!,
|
||||
);
|
||||
if (
|
||||
authorizationCommand.decisionMode === 'separation_of_duty' &&
|
||||
targetAuthorization.principal.subject.id ===
|
||||
authorizationCommand.principal.subject.id
|
||||
)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
}
|
||||
const revoked = await wasRevoked(
|
||||
client,
|
||||
command.provider,
|
||||
command.model,
|
||||
command.priceRevision,
|
||||
);
|
||||
const nowMs = await databaseClock(client);
|
||||
let head: Readonly<ModelPriceCatalogHead>;
|
||||
try {
|
||||
head = createModelPriceCatalogHead(
|
||||
current,
|
||||
command,
|
||||
target,
|
||||
revoked,
|
||||
nowMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidModelPriceCatalogError)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
throw error;
|
||||
}
|
||||
const authorization = createModelPriceCatalogAuthorization(
|
||||
authorizationCommand,
|
||||
head.headDigest,
|
||||
nowMs,
|
||||
);
|
||||
await insertHead(client, head);
|
||||
await insertAuthorization(client, authorization);
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({ status: 'created' as const, head, authorization });
|
||||
},
|
||||
);
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
createModelPriceCatalogHead,
|
||||
createModelPriceCatalogPublication,
|
||||
InvalidModelPriceCatalogError,
|
||||
ModelPriceCatalogConflictError,
|
||||
normalizeModelPriceCatalogPublishCommand,
|
||||
normalizeModelPriceCatalogTransitionCommand,
|
||||
type CommitModelPriceCatalogHeadResult,
|
||||
type CommitModelPriceCatalogPublicationResult,
|
||||
type ModelPriceCatalogHead,
|
||||
type ModelPriceCatalogPublishCommand,
|
||||
type ModelPriceCatalogTransitionCommand,
|
||||
} from '../../modelPriceCatalog';
|
||||
|
||||
import { runTransaction } from './authority';
|
||||
import {
|
||||
databaseClock,
|
||||
headFromRow,
|
||||
headRows,
|
||||
insertHead,
|
||||
insertPublication,
|
||||
integer,
|
||||
publicationFromRow,
|
||||
publicationRows,
|
||||
wasRevoked,
|
||||
} from './records';
|
||||
|
||||
export function publishOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
): Promise<Readonly<CommitModelPriceCatalogPublicationResult>> {
|
||||
const command = normalizeModelPriceCatalogPublishCommand(commandValue);
|
||||
return runTransaction(
|
||||
pool,
|
||||
command.provider,
|
||||
command.model,
|
||||
async (client) => {
|
||||
const rows = await publicationRows(
|
||||
client,
|
||||
`mutation_id = $1 OR (provider = $2 AND model = $3 AND price_revision = $4)`,
|
||||
[
|
||||
command.mutationId,
|
||||
command.provider,
|
||||
command.model,
|
||||
command.priceRevision,
|
||||
],
|
||||
);
|
||||
if (rows.length > 1) throw new ModelPriceCatalogConflictError();
|
||||
if (rows[0]) {
|
||||
const stored = publicationFromRow(rows[0]);
|
||||
if (stored.commandDigest !== command.commandDigest)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
publication: stored,
|
||||
});
|
||||
}
|
||||
const publication = createModelPriceCatalogPublication(
|
||||
command,
|
||||
await databaseClock(client),
|
||||
);
|
||||
await insertPublication(client, publication);
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({ status: 'created' as const, publication });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function transitionOperation(
|
||||
pool: PostgresPool,
|
||||
commandValue: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
): Promise<Readonly<CommitModelPriceCatalogHeadResult>> {
|
||||
const command = normalizeModelPriceCatalogTransitionCommand(commandValue);
|
||||
return runTransaction(
|
||||
pool,
|
||||
command.provider,
|
||||
command.model,
|
||||
async (client) => {
|
||||
const replayRows = await headRows(
|
||||
client,
|
||||
`provider = $1 AND model = $2 AND (mutation_id = $3 OR generation = $4)`,
|
||||
[
|
||||
command.provider,
|
||||
command.model,
|
||||
command.mutationId,
|
||||
command.expectedGeneration + 1,
|
||||
],
|
||||
);
|
||||
if (replayRows.length > 1) throw new ModelPriceCatalogConflictError();
|
||||
if (replayRows[0]) {
|
||||
const stored = headFromRow(replayRows[0]);
|
||||
if (stored.commandDigest !== command.commandDigest)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({ status: 'existing' as const, head: stored });
|
||||
}
|
||||
const currentRows = await headRows(
|
||||
client,
|
||||
'provider = $1 AND model = $2',
|
||||
[command.provider, command.model],
|
||||
);
|
||||
if (
|
||||
currentRows.length > 1 &&
|
||||
integer(currentRows[0]!, 'generation') ===
|
||||
integer(currentRows[1]!, 'generation')
|
||||
)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
const current = currentRows[0] ? headFromRow(currentRows[0]) : null;
|
||||
const targetRows =
|
||||
command.priceRevision === null
|
||||
? []
|
||||
: await publicationRows(
|
||||
client,
|
||||
'provider = $1 AND model = $2 AND price_revision = $3',
|
||||
[command.provider, command.model, command.priceRevision],
|
||||
);
|
||||
if (targetRows.length > 1) throw new ModelPriceCatalogConflictError();
|
||||
const target = targetRows[0] ? publicationFromRow(targetRows[0]) : null;
|
||||
let head: Readonly<ModelPriceCatalogHead>;
|
||||
try {
|
||||
head = createModelPriceCatalogHead(
|
||||
current,
|
||||
command,
|
||||
target,
|
||||
await wasRevoked(
|
||||
client,
|
||||
command.provider,
|
||||
command.model,
|
||||
command.priceRevision,
|
||||
),
|
||||
await databaseClock(client),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidModelPriceCatalogError)
|
||||
throw new ModelPriceCatalogConflictError();
|
||||
throw error;
|
||||
}
|
||||
await insertHead(client, head);
|
||||
await client.query('COMMIT');
|
||||
return Object.freeze({ status: 'created' as const, head });
|
||||
},
|
||||
);
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
|
||||
import { POSTGRES_MODEL_INVOCATION_SCHEMA } from '../../../migration/modelInvocationMigration';
|
||||
import {
|
||||
InvalidModelPriceCatalogError,
|
||||
type ModelPriceCatalogHead,
|
||||
type ModelPriceCatalogPublication,
|
||||
} from '../../modelPriceCatalog';
|
||||
import type { ModelPriceCatalogAuthorization } from '../../modelPriceCatalogManagement';
|
||||
import type {
|
||||
ModelPriceCatalogEntry,
|
||||
ModelPriceCatalogLookup,
|
||||
} from '../../pricing';
|
||||
|
||||
import { identifier, mapStorageError, unavailable } from './authority';
|
||||
import {
|
||||
authorizationFromRow,
|
||||
authorizationRows,
|
||||
headFromRow,
|
||||
headRows,
|
||||
integer,
|
||||
publicationFromRow,
|
||||
publicationRows,
|
||||
type Row,
|
||||
} from './records';
|
||||
|
||||
export async function findAuthorizationOperation(
|
||||
pool: PostgresPool,
|
||||
authorizationIdValue: string,
|
||||
): Promise<Readonly<ModelPriceCatalogAuthorization> | null> {
|
||||
const authorizationId = identifier(
|
||||
authorizationIdValue,
|
||||
'authorization identity',
|
||||
);
|
||||
try {
|
||||
const rows = await authorizationRows(pool, 'authorization_id = $1', [
|
||||
authorizationId,
|
||||
]);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? authorizationFromRow(rows[0]) : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findPublicationOperation(
|
||||
pool: PostgresPool,
|
||||
lookupValue: Omit<ModelPriceCatalogLookup, 'signal'>,
|
||||
): Promise<Readonly<ModelPriceCatalogPublication> | null> {
|
||||
const provider = identifier(lookupValue.provider, 'provider');
|
||||
const model = identifier(lookupValue.model, 'model');
|
||||
const priceRevision = identifier(lookupValue.priceRevision, 'price revision');
|
||||
try {
|
||||
const rows = await publicationRows(
|
||||
pool,
|
||||
'provider = $1 AND model = $2 AND price_revision = $3',
|
||||
[provider, model, priceRevision],
|
||||
);
|
||||
if (rows.length > 1) throw unavailable();
|
||||
return rows[0] ? publicationFromRow(rows[0]) : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findCurrentOperation(
|
||||
pool: PostgresPool,
|
||||
providerValue: string,
|
||||
modelValue: string,
|
||||
): Promise<Readonly<ModelPriceCatalogHead> | null> {
|
||||
const provider = identifier(providerValue, 'provider');
|
||||
const model = identifier(modelValue, 'model');
|
||||
try {
|
||||
const rows = await headRows(pool, 'provider = $1 AND model = $2', [
|
||||
provider,
|
||||
model,
|
||||
]);
|
||||
if (
|
||||
rows.length > 1 &&
|
||||
integer(rows[0]!, 'generation') === integer(rows[1]!, 'generation')
|
||||
)
|
||||
throw unavailable();
|
||||
return rows[0] ? headFromRow(rows[0]) : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveOperation(
|
||||
pool: PostgresPool,
|
||||
lookupValue: Readonly<ModelPriceCatalogLookup>,
|
||||
): Promise<Readonly<ModelPriceCatalogEntry> | null> {
|
||||
if (
|
||||
lookupValue.signal !== undefined &&
|
||||
!(lookupValue.signal instanceof AbortSignal)
|
||||
)
|
||||
throw new InvalidModelPriceCatalogError('catalog signal is invalid');
|
||||
if (lookupValue.signal?.aborted) throw lookupValue.signal.reason;
|
||||
const provider = identifier(lookupValue.provider, 'provider');
|
||||
const model = identifier(lookupValue.model, 'model');
|
||||
const priceRevision = identifier(lookupValue.priceRevision, 'price revision');
|
||||
let entry: Readonly<ModelPriceCatalogEntry> | null;
|
||||
try {
|
||||
const result = await pool.query<Row>(
|
||||
`SELECT publication.provider, publication.model,
|
||||
publication.price_revision AS "priceRevision",
|
||||
publication.catalog_digest AS "catalogDigest",
|
||||
publication.mutation_id AS "mutationId",
|
||||
publication.command_digest AS "commandDigest",
|
||||
publication.publication_digest AS "publicationDigest",
|
||||
publication.published_at_ms AS "publishedAtMs",
|
||||
publication.published_by_user_id AS "publishedByUserId",
|
||||
publication.publication_json AS "publicationJson"
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads" AS head
|
||||
JOIN "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications" AS publication
|
||||
ON publication.provider = head.provider
|
||||
AND publication.model = head.model
|
||||
AND publication.price_revision = head.active_price_revision
|
||||
AND publication.catalog_digest = head.active_catalog_digest
|
||||
WHERE head.provider = $1 AND head.model = $2
|
||||
AND head.active_price_revision = $3
|
||||
AND head.generation = (
|
||||
SELECT max(latest.generation)
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads" AS latest
|
||||
WHERE latest.provider = $1 AND latest.model = $2
|
||||
)
|
||||
LIMIT 2`,
|
||||
[provider, model, priceRevision],
|
||||
);
|
||||
if (result.rows.length > 1) throw unavailable();
|
||||
entry = result.rows[0] ? publicationFromRow(result.rows[0]).entry : null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
if (lookupValue.signal?.aborted) throw lookupValue.signal.reason;
|
||||
return entry;
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
import type { PostgresClient, PostgresQueryable } from '@qinglong/runtime-core';
|
||||
|
||||
import { POSTGRES_MODEL_INVOCATION_SCHEMA } from '../../../migration/modelInvocationMigration';
|
||||
import {
|
||||
normalizeModelPriceCatalogHead,
|
||||
normalizeModelPriceCatalogPublication,
|
||||
type ModelPriceCatalogHead,
|
||||
type ModelPriceCatalogPublication,
|
||||
} from '../../modelPriceCatalog';
|
||||
import {
|
||||
normalizeModelPriceCatalogAuthorization,
|
||||
type ModelPriceCatalogAuthorization,
|
||||
} from '../../modelPriceCatalogManagement';
|
||||
|
||||
import { unavailable } from './authority';
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresQueryable, 'query'>;
|
||||
|
||||
export function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
|
||||
return value;
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
function nullableText(row: Row, key: string): string | null {
|
||||
const value = row[key];
|
||||
if (value !== null && typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function jsonObject(row: Row, key: string): Record<string, unknown> {
|
||||
const value = row[key];
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
)
|
||||
return value as Record<string, unknown>;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
!Array.isArray(parsed) &&
|
||||
Object.getPrototypeOf(parsed) === Object.prototype
|
||||
)
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
// Mapped below.
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
export function publicationFromRow(
|
||||
row: Row,
|
||||
): Readonly<ModelPriceCatalogPublication> {
|
||||
let publication: Readonly<ModelPriceCatalogPublication>;
|
||||
try {
|
||||
publication = normalizeModelPriceCatalogPublication(
|
||||
jsonObject(
|
||||
row,
|
||||
'publicationJson',
|
||||
) as unknown as ModelPriceCatalogPublication,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
publication.entry.provider !== text(row, 'provider') ||
|
||||
publication.entry.model !== text(row, 'model') ||
|
||||
publication.entry.priceRevision !== text(row, 'priceRevision') ||
|
||||
publication.entry.catalogDigest !== text(row, 'catalogDigest') ||
|
||||
publication.mutationId !== text(row, 'mutationId') ||
|
||||
publication.commandDigest !== text(row, 'commandDigest') ||
|
||||
publication.publicationDigest !== text(row, 'publicationDigest') ||
|
||||
publication.entry.publishedAtMs !== integer(row, 'publishedAtMs') ||
|
||||
publication.publishedByUserId !== text(row, 'publishedByUserId')
|
||||
)
|
||||
throw unavailable();
|
||||
return publication;
|
||||
}
|
||||
|
||||
export function headFromRow(row: Row): Readonly<ModelPriceCatalogHead> {
|
||||
let head: Readonly<ModelPriceCatalogHead>;
|
||||
try {
|
||||
head = normalizeModelPriceCatalogHead(
|
||||
jsonObject(row, 'headJson') as unknown as ModelPriceCatalogHead,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
head.provider !== text(row, 'provider') ||
|
||||
head.model !== text(row, 'model') ||
|
||||
head.generation !== integer(row, 'generation') ||
|
||||
head.previousHeadDigest !== nullableText(row, 'previousHeadDigest') ||
|
||||
head.activePriceRevision !== nullableText(row, 'activePriceRevision') ||
|
||||
head.activeCatalogDigest !== nullableText(row, 'activeCatalogDigest') ||
|
||||
head.revokedPriceRevision !== nullableText(row, 'revokedPriceRevision') ||
|
||||
head.revokedCatalogDigest !== nullableText(row, 'revokedCatalogDigest') ||
|
||||
head.action !== text(row, 'action') ||
|
||||
head.mutationId !== text(row, 'mutationId') ||
|
||||
head.changedByUserId !== text(row, 'changedByUserId') ||
|
||||
head.changedAtMs !== integer(row, 'changedAtMs') ||
|
||||
head.commandDigest !== text(row, 'commandDigest') ||
|
||||
head.headDigest !== text(row, 'headDigest')
|
||||
)
|
||||
throw unavailable();
|
||||
return head;
|
||||
}
|
||||
|
||||
export function authorizationFromRow(
|
||||
row: Row,
|
||||
): Readonly<ModelPriceCatalogAuthorization> {
|
||||
let authorization: Readonly<ModelPriceCatalogAuthorization>;
|
||||
try {
|
||||
authorization = normalizeModelPriceCatalogAuthorization(
|
||||
jsonObject(
|
||||
row,
|
||||
'authorizationJson',
|
||||
) as unknown as ModelPriceCatalogAuthorization,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
let normalizedReasons: unknown;
|
||||
try {
|
||||
const reasons = row.reasonsJson;
|
||||
normalizedReasons =
|
||||
typeof reasons === 'string' ? JSON.parse(reasons) : reasons;
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
authorization.authorizationId !== text(row, 'authorizationId') ||
|
||||
authorization.requestId !== text(row, 'requestId') ||
|
||||
authorization.operation !== text(row, 'operation') ||
|
||||
authorization.provider !== text(row, 'provider') ||
|
||||
authorization.model !== text(row, 'model') ||
|
||||
authorization.priceRevision !== nullableText(row, 'priceRevision') ||
|
||||
authorization.catalogCommandDigest !== text(row, 'catalogCommandDigest') ||
|
||||
authorization.resultDigest !== text(row, 'resultDigest') ||
|
||||
authorization.principal.subject.id !== text(row, 'userId') ||
|
||||
authorization.principal.authenticationId !==
|
||||
text(row, 'authenticationId') ||
|
||||
authorization.principal.assurance !== text(row, 'assurance') ||
|
||||
authorization.principal.authenticatedAtMs !==
|
||||
integer(row, 'authenticatedAtMs') ||
|
||||
authorization.principal.expiresAtMs !== integer(row, 'expiresAtMs') ||
|
||||
authorization.policy.revision !== text(row, 'policyRevision') ||
|
||||
authorization.policy.decisionDigest !== text(row, 'policyDecisionDigest') ||
|
||||
authorization.decisionMode !== text(row, 'decisionMode') ||
|
||||
authorization.commandDigest !== text(row, 'commandDigest') ||
|
||||
authorization.committedAtMs !== integer(row, 'committedAtMs') ||
|
||||
authorization.authorizationDigest !== text(row, 'authorizationDigest') ||
|
||||
JSON.stringify(authorization.policy.reasons) !==
|
||||
JSON.stringify(normalizedReasons)
|
||||
)
|
||||
throw unavailable();
|
||||
return authorization;
|
||||
}
|
||||
|
||||
export async function publicationRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT provider, model, price_revision AS "priceRevision", catalog_digest AS "catalogDigest", mutation_id AS "mutationId", command_digest AS "commandDigest", publication_digest AS "publicationDigest", published_at_ms AS "publishedAtMs", published_by_user_id AS "publishedByUserId", publication_json AS "publicationJson" FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications" WHERE ${where} ORDER BY provider, model, price_revision LIMIT 2`,
|
||||
values,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function headRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT provider, model, generation, previous_head_digest AS "previousHeadDigest", active_price_revision AS "activePriceRevision", active_catalog_digest AS "activeCatalogDigest", revoked_price_revision AS "revokedPriceRevision", revoked_catalog_digest AS "revokedCatalogDigest", action, mutation_id AS "mutationId", changed_by_user_id AS "changedByUserId", changed_at_ms AS "changedAtMs", command_digest AS "commandDigest", head_digest AS "headDigest", head_json AS "headJson" FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads" WHERE ${where} ORDER BY generation DESC LIMIT 2`,
|
||||
values,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function authorizationRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT authorization_id AS "authorizationId", request_id AS "requestId", operation, provider, model, price_revision AS "priceRevision", catalog_command_digest AS "catalogCommandDigest", result_digest AS "resultDigest", user_id AS "userId", authentication_id AS "authenticationId", assurance, authenticated_at_ms AS "authenticatedAtMs", expires_at_ms AS "expiresAtMs", policy_revision AS "policyRevision", policy_decision_digest AS "policyDecisionDigest", decision_mode AS "decisionMode", command_digest AS "commandDigest", committed_at_ms AS "committedAtMs", authorization_digest AS "authorizationDigest", reasons_json AS "reasonsJson", authorization_json AS "authorizationJson" FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_authorizations" WHERE ${where} ORDER BY committed_at_ms DESC LIMIT 2`,
|
||||
values,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function insertAuthorization(
|
||||
client: PostgresClient,
|
||||
authorization: Readonly<ModelPriceCatalogAuthorization>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_authorizations" (authorization_id, request_id, operation, provider, model, price_revision, catalog_command_digest, publication_digest, head_digest, result_digest, user_id, authentication_id, assurance, authenticated_at_ms, expires_at_ms, policy_revision, policy_decision_digest, decision_mode, command_digest, committed_at_ms, authorization_digest, reasons_json, authorization_json) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22::jsonb, $23::jsonb)`,
|
||||
[
|
||||
authorization.authorizationId,
|
||||
authorization.requestId,
|
||||
authorization.operation,
|
||||
authorization.provider,
|
||||
authorization.model,
|
||||
authorization.priceRevision,
|
||||
authorization.catalogCommandDigest,
|
||||
authorization.operation === 'publish' ? authorization.resultDigest : null,
|
||||
authorization.operation === 'publish' ? null : authorization.resultDigest,
|
||||
authorization.resultDigest,
|
||||
authorization.principal.subject.id,
|
||||
authorization.principal.authenticationId,
|
||||
authorization.principal.assurance,
|
||||
authorization.principal.authenticatedAtMs,
|
||||
authorization.principal.expiresAtMs,
|
||||
authorization.policy.revision,
|
||||
authorization.policy.decisionDigest,
|
||||
authorization.decisionMode,
|
||||
authorization.commandDigest,
|
||||
authorization.committedAtMs,
|
||||
authorization.authorizationDigest,
|
||||
JSON.stringify(authorization.policy.reasons),
|
||||
JSON.stringify(authorization),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function databaseClock(client: PostgresClient): Promise<number> {
|
||||
const clock = await client.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AS now`,
|
||||
);
|
||||
if (clock.rows.length !== 1 || !clock.rows[0]) throw unavailable();
|
||||
return integer(clock.rows[0], 'now');
|
||||
}
|
||||
|
||||
export async function insertPublication(
|
||||
client: PostgresClient,
|
||||
publication: Readonly<ModelPriceCatalogPublication>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_publications" (
|
||||
provider, model, price_revision, catalog_digest,
|
||||
mutation_id, command_digest, publication_digest,
|
||||
published_at_ms, published_by_user_id, publication_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb
|
||||
)`,
|
||||
[
|
||||
publication.entry.provider,
|
||||
publication.entry.model,
|
||||
publication.entry.priceRevision,
|
||||
publication.entry.catalogDigest,
|
||||
publication.mutationId,
|
||||
publication.commandDigest,
|
||||
publication.publicationDigest,
|
||||
publication.entry.publishedAtMs,
|
||||
publication.publishedByUserId,
|
||||
JSON.stringify(publication),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertHead(
|
||||
client: PostgresClient,
|
||||
head: Readonly<ModelPriceCatalogHead>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads" (
|
||||
provider, model, generation, previous_head_digest,
|
||||
active_price_revision, active_catalog_digest,
|
||||
revoked_price_revision, revoked_catalog_digest,
|
||||
action, mutation_id, changed_by_user_id, changed_at_ms,
|
||||
command_digest, head_digest, head_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||
$13, $14, $15::jsonb
|
||||
)`,
|
||||
[
|
||||
head.provider,
|
||||
head.model,
|
||||
head.generation,
|
||||
head.previousHeadDigest,
|
||||
head.activePriceRevision,
|
||||
head.activeCatalogDigest,
|
||||
head.revokedPriceRevision,
|
||||
head.revokedCatalogDigest,
|
||||
head.action,
|
||||
head.mutationId,
|
||||
head.changedByUserId,
|
||||
head.changedAtMs,
|
||||
head.commandDigest,
|
||||
head.headDigest,
|
||||
JSON.stringify(head),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function wasRevoked(
|
||||
client: PostgresClient,
|
||||
provider: string,
|
||||
model: string,
|
||||
priceRevision: string | null,
|
||||
): Promise<boolean> {
|
||||
if (priceRevision === null) return false;
|
||||
const result = await client.query<Row>(
|
||||
`SELECT 1
|
||||
FROM "${POSTGRES_MODEL_INVOCATION_SCHEMA}"."model_price_catalog_heads"
|
||||
WHERE provider = $1 AND model = $2
|
||||
AND revoked_price_revision = $3
|
||||
LIMIT 1`,
|
||||
[provider, model, priceRevision],
|
||||
);
|
||||
return result.rows.length !== 0;
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
PostgresQueryable,
|
||||
} from '@qinglong/runtime-core';
|
||||
|
||||
import {
|
||||
type CommitModelPriceCatalogHeadResult,
|
||||
type CommitModelPriceCatalogPublicationResult,
|
||||
type ModelPriceCatalogAdministrationRepository,
|
||||
type ModelPriceCatalogHead,
|
||||
type ModelPriceCatalogPublication,
|
||||
type ModelPriceCatalogPublishCommand,
|
||||
type ModelPriceCatalogReader,
|
||||
type ModelPriceCatalogTransitionCommand,
|
||||
} from '../../modelPriceCatalog';
|
||||
import {
|
||||
type CommitAuthorizedModelPriceCatalogHeadResult,
|
||||
type CommitAuthorizedModelPriceCatalogPublicationResult,
|
||||
type ModelPriceCatalogAuthorization,
|
||||
type ModelPriceCatalogAuthorizationCommand,
|
||||
type ModelPriceCatalogAuthorizedAdministrationRepository,
|
||||
} from '../../modelPriceCatalogManagement';
|
||||
import type {
|
||||
ModelPriceCatalogEntry,
|
||||
ModelPriceCatalogLookup,
|
||||
} from '../../pricing';
|
||||
|
||||
import {
|
||||
publishAuthorizedOperation,
|
||||
transitionAuthorizedOperation,
|
||||
} from './authorizedMutationOperations';
|
||||
import { assertPool } from './authority';
|
||||
import {
|
||||
publishOperation,
|
||||
transitionOperation,
|
||||
} from './catalogMutationOperations';
|
||||
import {
|
||||
findAuthorizationOperation,
|
||||
findCurrentOperation,
|
||||
findPublicationOperation,
|
||||
resolveOperation,
|
||||
} from './readOperations';
|
||||
import {
|
||||
authorizationRows,
|
||||
headRows,
|
||||
insertAuthorization,
|
||||
publicationRows,
|
||||
type Row,
|
||||
} from './records';
|
||||
|
||||
type Queryable = Pick<PostgresQueryable, 'query'>;
|
||||
|
||||
export class PostgresModelPriceCatalogReader
|
||||
implements ModelPriceCatalogReader
|
||||
{
|
||||
constructor(protected readonly pool: PostgresPool) {
|
||||
assertPool(pool);
|
||||
}
|
||||
|
||||
protected publicationRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
return publicationRows(queryable, where, values);
|
||||
}
|
||||
|
||||
protected headRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
return headRows(queryable, where, values);
|
||||
}
|
||||
|
||||
protected authorizationRows(
|
||||
queryable: Queryable,
|
||||
where: string,
|
||||
values: readonly unknown[],
|
||||
): Promise<readonly Row[]> {
|
||||
return authorizationRows(queryable, where, values);
|
||||
}
|
||||
|
||||
findPublication(
|
||||
lookupValue: Omit<ModelPriceCatalogLookup, 'signal'>,
|
||||
): Promise<Readonly<ModelPriceCatalogPublication> | null> {
|
||||
return findPublicationOperation(this.pool, lookupValue);
|
||||
}
|
||||
|
||||
findCurrent(
|
||||
providerValue: string,
|
||||
modelValue: string,
|
||||
): Promise<Readonly<ModelPriceCatalogHead> | null> {
|
||||
return findCurrentOperation(this.pool, providerValue, modelValue);
|
||||
}
|
||||
|
||||
resolve(
|
||||
lookupValue: Readonly<ModelPriceCatalogLookup>,
|
||||
): Promise<Readonly<ModelPriceCatalogEntry> | null> {
|
||||
return resolveOperation(this.pool, lookupValue);
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresModelPriceCatalogRepository
|
||||
extends PostgresModelPriceCatalogReader
|
||||
implements
|
||||
ModelPriceCatalogAdministrationRepository,
|
||||
ModelPriceCatalogAuthorizedAdministrationRepository
|
||||
{
|
||||
private insertAuthorization(
|
||||
client: PostgresClient,
|
||||
authorization: Readonly<ModelPriceCatalogAuthorization>,
|
||||
): Promise<void> {
|
||||
return insertAuthorization(client, authorization);
|
||||
}
|
||||
|
||||
findAuthorization(
|
||||
authorizationIdValue: string,
|
||||
): Promise<Readonly<ModelPriceCatalogAuthorization> | null> {
|
||||
return findAuthorizationOperation(this.pool, authorizationIdValue);
|
||||
}
|
||||
|
||||
publish(
|
||||
commandValue: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
): Promise<Readonly<CommitModelPriceCatalogPublicationResult>> {
|
||||
return publishOperation(this.pool, commandValue);
|
||||
}
|
||||
|
||||
transition(
|
||||
commandValue: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
): Promise<Readonly<CommitModelPriceCatalogHeadResult>> {
|
||||
return transitionOperation(this.pool, commandValue);
|
||||
}
|
||||
|
||||
publishAuthorized(
|
||||
commandValue: Readonly<ModelPriceCatalogPublishCommand>,
|
||||
authorizationValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogPublicationResult>> {
|
||||
return publishAuthorizedOperation(
|
||||
this.pool,
|
||||
commandValue,
|
||||
authorizationValue,
|
||||
);
|
||||
}
|
||||
|
||||
transitionAuthorized(
|
||||
commandValue: Readonly<ModelPriceCatalogTransitionCommand>,
|
||||
authorizationValue: Readonly<ModelPriceCatalogAuthorizationCommand>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogHeadResult>> {
|
||||
return transitionAuthorizedOperation(
|
||||
this.pool,
|
||||
commandValue,
|
||||
authorizationValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PostgresModelPriceCatalogReader,
|
||||
PostgresModelPriceCatalogRepository,
|
||||
} from './postgres-model-price-catalog-repository/repository';
|
||||
@@ -0,0 +1,265 @@
|
||||
import type {
|
||||
GenerateRequest,
|
||||
GenerateResult,
|
||||
ModelChunk,
|
||||
ModelInvocationContext,
|
||||
ModelInvocationPolicyProvider,
|
||||
ModelProvider,
|
||||
} from '../../model-gateway/model';
|
||||
import type {
|
||||
DurableModelInvocationCoordinator,
|
||||
ModelInvocationRecoverySummary,
|
||||
} from '../../model-invocation/durableModelInvocationCoordinator';
|
||||
import type { ModelInvocationSuccessfulCompletionSink } from '../../model-gateway/gateway';
|
||||
import type {
|
||||
ModelInvocationResolutionDecision,
|
||||
ModelInvocationResolutionRecord,
|
||||
ModelInvocationResolutionRepository,
|
||||
ResolveModelInvocationOptions,
|
||||
} from '../../model-invocation/modelInvocationResolution';
|
||||
import type {
|
||||
ModelInvocationUsageLedgerPage,
|
||||
ModelInvocationUsageLedgerQuery,
|
||||
ModelInvocationUsageLedgerRepository,
|
||||
ModelInvocationUsageLedgerSummary,
|
||||
ModelInvocationUsageLedgerSummaryQuery,
|
||||
} from '../../usage/usageLedger';
|
||||
import type {
|
||||
ModelInvocationQuotaRepository,
|
||||
ModelInvocationQuotaWindowUsage,
|
||||
} from '../../usage/usageQuota';
|
||||
import type {
|
||||
ModelInvocationPriceQuote,
|
||||
ModelInvocationPriceSettlement,
|
||||
ModelInvocationPricingRepository,
|
||||
ModelPriceCatalogResolver,
|
||||
} from '../../pricing/pricing';
|
||||
import type {
|
||||
CommitAuthorizedModelPriceCatalogHeadResult,
|
||||
CommitAuthorizedModelPriceCatalogPublicationResult,
|
||||
ModelPriceCatalogAuthorizedAdministrationRepository,
|
||||
ModelPriceCatalogManagementAuthorizer,
|
||||
ModelPriceCatalogManagementDecisionMode,
|
||||
ModelPriceCatalogManagementQuota,
|
||||
PublishModelPriceCatalogRequest,
|
||||
TransitionModelPriceCatalogRequest,
|
||||
} from '../../pricing/modelPriceCatalogManagement';
|
||||
|
||||
export const MODEL_GATEWAY_PROFILES = [
|
||||
'edge',
|
||||
'standalone',
|
||||
'cluster',
|
||||
] as const;
|
||||
export const MODEL_GATEWAY_PROFILE_STATES = [
|
||||
'disabled',
|
||||
'storage_ready',
|
||||
'recovery_ready',
|
||||
'active',
|
||||
'draining',
|
||||
'stopped',
|
||||
'failed',
|
||||
] as const;
|
||||
|
||||
export type ModelGatewayProfile = (typeof MODEL_GATEWAY_PROFILES)[number];
|
||||
export type ModelGatewayProfileState =
|
||||
(typeof MODEL_GATEWAY_PROFILE_STATES)[number];
|
||||
|
||||
export interface ModelGatewayProfileAudit {
|
||||
readonly profile: ModelGatewayProfile;
|
||||
readonly state: ModelGatewayProfileState;
|
||||
readonly maxConcurrent?: number;
|
||||
readonly recoveryLimit?: number;
|
||||
readonly recovered?: number;
|
||||
readonly alreadyCompleted?: number;
|
||||
}
|
||||
|
||||
export interface ModelGatewayStorageAuthority {
|
||||
readonly repository: ModelInvocationResolutionRepository &
|
||||
ModelInvocationUsageLedgerRepository &
|
||||
ModelInvocationQuotaRepository &
|
||||
ModelInvocationPricingRepository;
|
||||
readonly pricing: ModelPriceCatalogResolver;
|
||||
close?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ModelGatewayProviderAuthority {
|
||||
readonly providers: readonly ModelProvider[];
|
||||
readonly policies: ModelInvocationPolicyProvider;
|
||||
dispose?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface BootstrapModelGatewayProfileOptions {
|
||||
readonly enabled?: boolean;
|
||||
readonly profile: ModelGatewayProfile;
|
||||
readonly loadStorage: () => Promise<ModelGatewayStorageAuthority>;
|
||||
readonly loadProviders: () => Promise<ModelGatewayProviderAuthority>;
|
||||
readonly confirmActive?: () => void | Promise<void>;
|
||||
readonly createSuccessfulCompletion?: (
|
||||
coordinator: DurableModelInvocationCoordinator,
|
||||
) => ModelInvocationSuccessfulCompletionSink;
|
||||
readonly audit: (
|
||||
record: Readonly<ModelGatewayProfileAudit>,
|
||||
) => void | Promise<void>;
|
||||
readonly maxConcurrent?: number;
|
||||
readonly recoveryLimit?: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface ActiveModelGatewayCapability {
|
||||
readonly profile: ModelGatewayProfile;
|
||||
readonly recovery: Readonly<ModelInvocationRecoverySummary>;
|
||||
readonly maxConcurrent: number;
|
||||
readonly recoveryLimit: number;
|
||||
readonly accepting: boolean;
|
||||
readonly activeOperations: number;
|
||||
supportsSuccessfulCompletionSink(
|
||||
sink: ModelInvocationSuccessfulCompletionSink,
|
||||
): boolean;
|
||||
generate(
|
||||
request: GenerateRequest,
|
||||
context: ModelInvocationContext,
|
||||
): Promise<Readonly<GenerateResult>>;
|
||||
stream(
|
||||
request: GenerateRequest,
|
||||
context: ModelInvocationContext,
|
||||
): AsyncIterable<Readonly<ModelChunk>>;
|
||||
resolveUnknown(options: {
|
||||
readonly invocationId: string;
|
||||
readonly decision: ModelInvocationResolutionDecision;
|
||||
readonly resolvedByUserId: string;
|
||||
readonly resolvedAtMs: number;
|
||||
}): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
record: Readonly<ModelInvocationResolutionRecord>;
|
||||
}>
|
||||
>;
|
||||
listProjectUsage(
|
||||
query: ModelInvocationUsageLedgerQuery,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerPage>>;
|
||||
summarizeProjectUsage(
|
||||
query: ModelInvocationUsageLedgerSummaryQuery,
|
||||
): Promise<Readonly<ModelInvocationUsageLedgerSummary>>;
|
||||
readQuotaWindowUsage(
|
||||
projectId: string,
|
||||
atMs?: number,
|
||||
): Promise<Readonly<ModelInvocationQuotaWindowUsage> | null>;
|
||||
findPriceQuote(
|
||||
invocationId: string,
|
||||
): Promise<Readonly<ModelInvocationPriceQuote> | null>;
|
||||
findPriceSettlement(
|
||||
invocationId: string,
|
||||
): Promise<Readonly<ModelInvocationPriceSettlement> | null>;
|
||||
stop(): Promise<'draining' | 'stopped'>;
|
||||
}
|
||||
|
||||
export type BootstrapModelGatewayProfileResult =
|
||||
| {
|
||||
readonly status: 'disabled';
|
||||
readonly profile: ModelGatewayProfile;
|
||||
stop(): Promise<'stopped'>;
|
||||
}
|
||||
| {
|
||||
readonly status: 'active';
|
||||
readonly profile: ModelGatewayProfile;
|
||||
readonly capability: ActiveModelGatewayCapability;
|
||||
};
|
||||
|
||||
export class ModelGatewayProfileUnavailableError extends Error {
|
||||
readonly code = 'MODEL_GATEWAY_PROFILE_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('The model gateway Profile is unavailable', options);
|
||||
this.name = 'ModelGatewayProfileUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelGatewayProfileDrainingError extends Error {
|
||||
readonly code = 'MODEL_GATEWAY_PROFILE_DRAINING';
|
||||
|
||||
constructor() {
|
||||
super('The model gateway Profile is draining');
|
||||
this.name = 'ModelGatewayProfileDrainingError';
|
||||
}
|
||||
}
|
||||
|
||||
export const MODEL_PRICE_CATALOG_MANAGEMENT_PROFILE_STATES = [
|
||||
'disabled',
|
||||
'authority_ready',
|
||||
'active',
|
||||
'draining',
|
||||
'stopped',
|
||||
'failed',
|
||||
] as const;
|
||||
|
||||
export type ModelPriceCatalogManagementProfileState =
|
||||
(typeof MODEL_PRICE_CATALOG_MANAGEMENT_PROFILE_STATES)[number];
|
||||
|
||||
export interface ModelPriceCatalogManagementProfileAudit {
|
||||
readonly profile: ModelGatewayProfile;
|
||||
readonly state: ModelPriceCatalogManagementProfileState;
|
||||
readonly decisionMode: ModelPriceCatalogManagementDecisionMode;
|
||||
}
|
||||
|
||||
export interface ModelPriceCatalogManagementAuthority {
|
||||
readonly repository: ModelPriceCatalogAuthorizedAdministrationRepository;
|
||||
readonly authorizer: ModelPriceCatalogManagementAuthorizer;
|
||||
readonly quota?: ModelPriceCatalogManagementQuota;
|
||||
close?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface BootstrapModelPriceCatalogManagementProfileOptions {
|
||||
readonly enabled?: boolean;
|
||||
readonly profile: ModelGatewayProfile;
|
||||
readonly loadAuthority: () => Promise<ModelPriceCatalogManagementAuthority>;
|
||||
readonly audit: (
|
||||
record: Readonly<ModelPriceCatalogManagementProfileAudit>,
|
||||
) => void | Promise<void>;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface ActiveModelPriceCatalogManagementCapability {
|
||||
readonly profile: ModelGatewayProfile;
|
||||
readonly decisionMode: ModelPriceCatalogManagementDecisionMode;
|
||||
readonly accepting: boolean;
|
||||
readonly activeOperations: number;
|
||||
publish(
|
||||
request: Readonly<PublishModelPriceCatalogRequest>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogPublicationResult>>;
|
||||
transition(
|
||||
request: Readonly<TransitionModelPriceCatalogRequest>,
|
||||
): Promise<Readonly<CommitAuthorizedModelPriceCatalogHeadResult>>;
|
||||
stop(): Promise<'draining' | 'stopped'>;
|
||||
}
|
||||
|
||||
export type BootstrapModelPriceCatalogManagementProfileResult =
|
||||
| {
|
||||
readonly status: 'disabled';
|
||||
readonly profile: ModelGatewayProfile;
|
||||
readonly decisionMode: ModelPriceCatalogManagementDecisionMode;
|
||||
stop(): Promise<'stopped'>;
|
||||
}
|
||||
| {
|
||||
readonly status: 'active';
|
||||
readonly profile: ModelGatewayProfile;
|
||||
readonly decisionMode: ModelPriceCatalogManagementDecisionMode;
|
||||
readonly capability: ActiveModelPriceCatalogManagementCapability;
|
||||
};
|
||||
|
||||
export class ModelPriceCatalogManagementProfileUnavailableError extends Error {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_MANAGEMENT_PROFILE_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('The model price catalog management Profile is unavailable', options);
|
||||
this.name = 'ModelPriceCatalogManagementProfileUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelPriceCatalogManagementProfileDrainingError extends Error {
|
||||
readonly code = 'MODEL_PRICE_CATALOG_MANAGEMENT_PROFILE_DRAINING';
|
||||
|
||||
constructor() {
|
||||
super('The model price catalog management Profile is draining');
|
||||
this.name = 'ModelPriceCatalogManagementProfileDrainingError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
ModelGatewayProviderAuthority,
|
||||
ModelGatewayStorageAuthority,
|
||||
ModelPriceCatalogManagementAuthority,
|
||||
} from './contracts';
|
||||
|
||||
export async function bestEffortAudit<T>(
|
||||
audit: (record: Readonly<T>) => void | Promise<void>,
|
||||
record: Readonly<T>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await audit(record);
|
||||
} catch {
|
||||
// Diagnostic failure cannot replace the activation failure.
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispose(
|
||||
authority: ModelGatewayProviderAuthority | ModelGatewayStorageAuthority,
|
||||
method: 'dispose' | 'close',
|
||||
): Promise<void> {
|
||||
const operation =
|
||||
method === 'dispose'
|
||||
? (authority as ModelGatewayProviderAuthority).dispose
|
||||
: (authority as ModelGatewayStorageAuthority).close;
|
||||
if (operation) await operation.call(authority);
|
||||
}
|
||||
|
||||
export async function closeModelPriceCatalogManagementAuthority(
|
||||
authority: ModelPriceCatalogManagementAuthority,
|
||||
): Promise<void> {
|
||||
if (authority.close) await authority.close.call(authority);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import type {
|
||||
GenerateRequest,
|
||||
ModelInvocationContext,
|
||||
} from '../../model-gateway/model';
|
||||
import type { ModelInvocationSuccessfulCompletionSink } from '../../model-gateway/gateway';
|
||||
import type { ResolveModelInvocationOptions } from '../../model-invocation/modelInvocationResolution';
|
||||
import type {
|
||||
ModelInvocationUsageLedgerQuery,
|
||||
ModelInvocationUsageLedgerSummaryQuery,
|
||||
} from '../../usage/usageLedger';
|
||||
import {
|
||||
MODEL_GATEWAY_PROFILES,
|
||||
ModelGatewayProfileDrainingError,
|
||||
ModelGatewayProfileUnavailableError,
|
||||
type ActiveModelGatewayCapability,
|
||||
type BootstrapModelGatewayProfileOptions,
|
||||
type BootstrapModelGatewayProfileResult,
|
||||
type ModelGatewayProviderAuthority,
|
||||
type ModelGatewayStorageAuthority,
|
||||
} from './contracts';
|
||||
import { bestEffortAudit, dispose } from './lifecycle';
|
||||
|
||||
const DEFAULT_PROFILE_BUDGETS = Object.freeze({
|
||||
edge: Object.freeze({ maxConcurrent: 1, recoveryLimit: 4 }),
|
||||
standalone: Object.freeze({ maxConcurrent: 4, recoveryLimit: 32 }),
|
||||
cluster: Object.freeze({ maxConcurrent: 32, recoveryLimit: 128 }),
|
||||
});
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function assertOptions(options: BootstrapModelGatewayProfileOptions): void {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!MODEL_GATEWAY_PROFILES.includes(options.profile) ||
|
||||
(options.enabled !== undefined && typeof options.enabled !== 'boolean') ||
|
||||
typeof options.loadStorage !== 'function' ||
|
||||
typeof options.loadProviders !== 'function' ||
|
||||
(options.confirmActive !== undefined &&
|
||||
typeof options.confirmActive !== 'function') ||
|
||||
(options.createSuccessfulCompletion !== undefined &&
|
||||
typeof options.createSuccessfulCompletion !== 'function') ||
|
||||
typeof options.audit !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError('Model gateway Profile options are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function assertStorage(
|
||||
value: ModelGatewayStorageAuthority,
|
||||
): ModelGatewayStorageAuthority {
|
||||
const repository = value?.repository;
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!repository ||
|
||||
typeof repository.findStart !== 'function' ||
|
||||
typeof repository.findCompletion !== 'function' ||
|
||||
typeof repository.findResolution !== 'function' ||
|
||||
typeof repository.readAuthority !== 'function' ||
|
||||
typeof repository.listIncomplete !== 'function' ||
|
||||
typeof repository.admit !== 'function' ||
|
||||
typeof repository.complete !== 'function' ||
|
||||
typeof repository.resolve !== 'function' ||
|
||||
typeof repository.findUsage !== 'function' ||
|
||||
typeof repository.listProjectUsage !== 'function' ||
|
||||
typeof repository.summarizeProjectUsage !== 'function' ||
|
||||
typeof repository.findQuotaReservation !== 'function' ||
|
||||
typeof repository.findQuotaSettlement !== 'function' ||
|
||||
typeof repository.readQuotaWindowUsage !== 'function' ||
|
||||
typeof repository.findPriceQuote !== 'function' ||
|
||||
typeof repository.findPriceSettlement !== 'function' ||
|
||||
!value.pricing ||
|
||||
typeof value.pricing.resolve !== 'function' ||
|
||||
(value.close !== undefined && typeof value.close !== 'function')
|
||||
) {
|
||||
throw new TypeError('Model gateway storage authority is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertProviders(
|
||||
value: ModelGatewayProviderAuthority,
|
||||
): ModelGatewayProviderAuthority {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!Array.isArray(value.providers) ||
|
||||
!value.policies ||
|
||||
typeof value.policies.resolve !== 'function' ||
|
||||
(value.dispose !== undefined && typeof value.dispose !== 'function')
|
||||
) {
|
||||
throw new TypeError('Model gateway provider authority is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile-gated optional AI composition root. Disabled mode never invokes the
|
||||
* storage/provider loaders. Enabled mode proves durable storage and bounded
|
||||
* recovery before provider credentials become reachable.
|
||||
*/
|
||||
export async function bootstrapModelGatewayProfile(
|
||||
options: BootstrapModelGatewayProfileOptions,
|
||||
): Promise<BootstrapModelGatewayProfileResult> {
|
||||
assertOptions(options);
|
||||
if (!(options.enabled ?? false)) {
|
||||
await options.audit({ profile: options.profile, state: 'disabled' });
|
||||
return Object.freeze({
|
||||
status: 'disabled',
|
||||
profile: options.profile,
|
||||
stop: async () => 'stopped' as const,
|
||||
});
|
||||
}
|
||||
|
||||
const defaults = DEFAULT_PROFILE_BUDGETS[options.profile];
|
||||
const maxConcurrent = integer(
|
||||
options.maxConcurrent ?? defaults.maxConcurrent,
|
||||
1,
|
||||
64,
|
||||
'Model gateway Profile concurrency',
|
||||
);
|
||||
const recoveryLimit = integer(
|
||||
options.recoveryLimit ?? defaults.recoveryLimit,
|
||||
1,
|
||||
128,
|
||||
'Model gateway Profile recovery limit',
|
||||
);
|
||||
let storage: ModelGatewayStorageAuthority | undefined;
|
||||
let providers: ModelGatewayProviderAuthority | undefined;
|
||||
try {
|
||||
storage = await options.loadStorage();
|
||||
storage = assertStorage(storage);
|
||||
await options.audit({
|
||||
profile: options.profile,
|
||||
state: 'storage_ready',
|
||||
maxConcurrent,
|
||||
recoveryLimit,
|
||||
});
|
||||
|
||||
const [coordinatorModule, { BoundedModelGateway }] = await Promise.all([
|
||||
import('../../model-invocation/durableModelInvocationCoordinator.js'),
|
||||
import('../../model-gateway/gateway.js'),
|
||||
]);
|
||||
const recovery = await new coordinatorModule.DurableModelInvocationRecovery(
|
||||
storage.repository,
|
||||
).recover(recoveryLimit);
|
||||
if (recovery.failed !== 0 || recovery.hasMore) {
|
||||
throw new ModelGatewayProfileUnavailableError();
|
||||
}
|
||||
await options.audit({
|
||||
profile: options.profile,
|
||||
state: 'recovery_ready',
|
||||
maxConcurrent,
|
||||
recoveryLimit,
|
||||
recovered: recovery.recovered,
|
||||
alreadyCompleted: recovery.alreadyCompleted,
|
||||
});
|
||||
|
||||
providers = await options.loadProviders();
|
||||
providers = assertProviders(providers);
|
||||
const { DurableModelInvocationResolutionCoordinator } = await import(
|
||||
'../../model-invocation/modelInvocationResolution.js'
|
||||
);
|
||||
const coordinator = new coordinatorModule.DurableModelInvocationCoordinator(
|
||||
storage.repository,
|
||||
);
|
||||
const successfulCompletion =
|
||||
options.createSuccessfulCompletion?.(coordinator);
|
||||
if (
|
||||
successfulCompletion !== undefined &&
|
||||
(!successfulCompletion ||
|
||||
typeof successfulCompletion.record !== 'function')
|
||||
) {
|
||||
throw new ModelGatewayProfileUnavailableError();
|
||||
}
|
||||
const gateway = new BoundedModelGateway({
|
||||
providers: providers.providers,
|
||||
policies: providers.policies,
|
||||
pricing: storage.pricing,
|
||||
audit: coordinator,
|
||||
...(successfulCompletion === undefined ? {} : { successfulCompletion }),
|
||||
maxConcurrent,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
});
|
||||
const resolver = new DurableModelInvocationResolutionCoordinator(
|
||||
storage.repository,
|
||||
);
|
||||
let accepting = true;
|
||||
let activeOperations = 0;
|
||||
let stopPromise: Promise<'stopped'> | undefined;
|
||||
let drainingAudited = false;
|
||||
|
||||
const auditDraining = async (): Promise<void> => {
|
||||
if (drainingAudited) return;
|
||||
drainingAudited = true;
|
||||
await options.audit({
|
||||
profile: options.profile,
|
||||
state: 'draining',
|
||||
maxConcurrent,
|
||||
recoveryLimit,
|
||||
});
|
||||
};
|
||||
const finalizeStop = (): Promise<'stopped'> => {
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = (async () => {
|
||||
await dispose(providers!, 'dispose');
|
||||
await dispose(storage!, 'close');
|
||||
await options.audit({
|
||||
profile: options.profile,
|
||||
state: 'stopped',
|
||||
maxConcurrent,
|
||||
recoveryLimit,
|
||||
});
|
||||
return 'stopped' as const;
|
||||
})();
|
||||
return stopPromise;
|
||||
};
|
||||
const beginOperation = async (): Promise<void> => {
|
||||
if (!accepting) throw new ModelGatewayProfileDrainingError();
|
||||
if (options.confirmActive) {
|
||||
try {
|
||||
await options.confirmActive();
|
||||
} catch {
|
||||
accepting = false;
|
||||
await auditDraining();
|
||||
if (activeOperations === 0) await finalizeStop();
|
||||
throw new ModelGatewayProfileDrainingError();
|
||||
}
|
||||
}
|
||||
if (!accepting) throw new ModelGatewayProfileDrainingError();
|
||||
activeOperations += 1;
|
||||
};
|
||||
const finishOperation = (): void => {
|
||||
activeOperations -= 1;
|
||||
if (!accepting && activeOperations === 0) {
|
||||
void finalizeStop().catch(async () => {
|
||||
await bestEffortAudit(options.audit, {
|
||||
profile: options.profile,
|
||||
state: 'failed',
|
||||
maxConcurrent,
|
||||
recoveryLimit,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const capability: ActiveModelGatewayCapability = Object.freeze({
|
||||
profile: options.profile,
|
||||
recovery,
|
||||
maxConcurrent,
|
||||
recoveryLimit,
|
||||
get accepting() {
|
||||
return accepting;
|
||||
},
|
||||
get activeOperations() {
|
||||
return activeOperations;
|
||||
},
|
||||
supportsSuccessfulCompletionSink(
|
||||
sink: ModelInvocationSuccessfulCompletionSink,
|
||||
) {
|
||||
return gateway.supportsSuccessfulCompletionSink(sink);
|
||||
},
|
||||
async generate(
|
||||
request: GenerateRequest,
|
||||
context: ModelInvocationContext,
|
||||
) {
|
||||
await beginOperation();
|
||||
try {
|
||||
return await gateway.generate(request, context);
|
||||
} finally {
|
||||
finishOperation();
|
||||
}
|
||||
},
|
||||
async *stream(request: GenerateRequest, context: ModelInvocationContext) {
|
||||
await beginOperation();
|
||||
try {
|
||||
yield* gateway.stream(request, context);
|
||||
} finally {
|
||||
finishOperation();
|
||||
}
|
||||
},
|
||||
async resolveUnknown(resolutionOptions: ResolveModelInvocationOptions) {
|
||||
await beginOperation();
|
||||
try {
|
||||
return await resolver.resolve(resolutionOptions);
|
||||
} finally {
|
||||
finishOperation();
|
||||
}
|
||||
},
|
||||
async listProjectUsage(query: ModelInvocationUsageLedgerQuery) {
|
||||
await beginOperation();
|
||||
try {
|
||||
return await storage!.repository.listProjectUsage(query);
|
||||
} finally {
|
||||
finishOperation();
|
||||
}
|
||||
},
|
||||
async summarizeProjectUsage(
|
||||
query: ModelInvocationUsageLedgerSummaryQuery,
|
||||
) {
|
||||
await beginOperation();
|
||||
try {
|
||||
return await storage!.repository.summarizeProjectUsage(query);
|
||||
} finally {
|
||||
finishOperation();
|
||||
}
|
||||
},
|
||||
async readQuotaWindowUsage(projectId: string, atMs?: number) {
|
||||
await beginOperation();
|
||||
try {
|
||||
return await storage!.repository.readQuotaWindowUsage(
|
||||
projectId,
|
||||
atMs,
|
||||
);
|
||||
} finally {
|
||||
finishOperation();
|
||||
}
|
||||
},
|
||||
async findPriceQuote(invocationId: string) {
|
||||
await beginOperation();
|
||||
try {
|
||||
return await storage!.repository.findPriceQuote(invocationId);
|
||||
} finally {
|
||||
finishOperation();
|
||||
}
|
||||
},
|
||||
async findPriceSettlement(invocationId: string) {
|
||||
await beginOperation();
|
||||
try {
|
||||
return await storage!.repository.findPriceSettlement(invocationId);
|
||||
} finally {
|
||||
finishOperation();
|
||||
}
|
||||
},
|
||||
async stop() {
|
||||
accepting = false;
|
||||
if (activeOperations !== 0) {
|
||||
await auditDraining();
|
||||
return 'draining';
|
||||
}
|
||||
return finalizeStop();
|
||||
},
|
||||
});
|
||||
await options.audit({
|
||||
profile: options.profile,
|
||||
state: 'active',
|
||||
maxConcurrent,
|
||||
recoveryLimit,
|
||||
recovered: recovery.recovered,
|
||||
alreadyCompleted: recovery.alreadyCompleted,
|
||||
});
|
||||
return Object.freeze({
|
||||
status: 'active',
|
||||
profile: options.profile,
|
||||
capability,
|
||||
});
|
||||
} catch (cause) {
|
||||
if (providers) {
|
||||
try {
|
||||
await dispose(providers, 'dispose');
|
||||
} catch {
|
||||
// Preserve the activation failure.
|
||||
}
|
||||
}
|
||||
if (storage) {
|
||||
try {
|
||||
await dispose(storage, 'close');
|
||||
} catch {
|
||||
// Preserve the activation failure.
|
||||
}
|
||||
}
|
||||
await bestEffortAudit(options.audit, {
|
||||
profile: options.profile,
|
||||
state: 'failed',
|
||||
maxConcurrent,
|
||||
recoveryLimit,
|
||||
});
|
||||
throw cause instanceof ModelGatewayProfileUnavailableError
|
||||
? cause
|
||||
: new ModelGatewayProfileUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user