Files
qinglong/packages/ql3-ai/src/prompt/plugin-package-prompt-execution/plan.ts
T

516 lines
16 KiB
TypeScript

import { Buffer } from 'node:buffer';
import { normalizeProjectPolicySubject } from '@qinglong/runtime-core/project-policy';
import { normalizePluginPackageAutomationPublication } from '@qinglong/runtime-core/plugin-package-automation-publication';
import {
normalizePluginPackagePromptResource,
type PluginPackagePromptResource,
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
import {
MAX_MODEL_INPUT_BYTES,
MAX_MODEL_INVOCATION_MS,
MAX_MODEL_MESSAGE_BYTES,
MAX_MODEL_OUTPUT_TOKENS,
} from '../../model-gateway/model';
import {
normalizePluginPackagePromptOutputArtifactRetentionPolicy,
pluginPackagePromptOutputArtifactRetentionPolicyDigest,
} from '../../prompt-output/pluginPackagePromptOutputArtifact';
import {
MAX_PLUGIN_PACKAGE_PROMPT_EXECUTION_PLAN_BYTES,
MAX_PLUGIN_PACKAGE_PROMPT_PARAMETER_VALUE_BYTES,
PLUGIN_PACKAGE_PROMPT_EXECUTION_PLAN_SCHEMA,
type PluginPackagePromptExecutionPlan,
type PluginPackagePromptExecutionPlanTarget,
type PluginPackagePromptOutputIntent,
type PreparePluginPackagePromptExecutionInput,
type PreparePluginPackagePromptOutputIntent,
type PreparedPluginPackagePromptExecution,
} from './contracts';
import {
dataRecord,
digest,
exactKeys,
hash,
identity,
invalid,
nullableTemperature,
packageName,
positiveInteger,
sha256,
timestamp,
} from './validation';
const PLAN_DIGEST_DOMAIN =
'qinglong/plugin-package-prompt-execution-plan-digest@v1\0';
const PROMPT_DIGEST_DOMAIN =
'qinglong/plugin-package-prompt-definition-digest@v1\0';
const PARAMETER_DIGEST_DOMAIN =
'qinglong/plugin-package-prompt-parameter-digest@v1\0';
const REQUEST_DIGEST_DOMAIN =
'qinglong/plugin-package-prompt-model-request-digest@v1\0';
const IDENTITY_DOMAIN =
'qinglong/plugin-package-prompt-execution-identity@v1\0';
function executionIdentity(
prefix: 'ppi' | 'ppr' | 'pps',
value: Readonly<{
requestId: string;
projectId: string;
publicationDigest: string;
promptId: string;
}>,
): string {
const valueDigest = hash(IDENTITY_DOMAIN, { prefix, ...value });
return `${prefix}:${valueDigest.slice(0, 32)}`;
}
export function pluginPackagePromptDefinitionDigest(
value: PluginPackagePromptResource,
): string {
return hash(
PROMPT_DIGEST_DOMAIN,
normalizePluginPackagePromptResource(value),
);
}
function canonicalParameters(
prompt: Readonly<PluginPackagePromptResource>,
value: Readonly<Record<string, string>>,
): Readonly<{
entries: readonly Readonly<[string, string | null]>[];
replacements: ReadonlyMap<string, string>;
}> {
const parameters = dataRecord(value, 'Prompt parameters');
const declared = new Map(
prompt.parameters.map((parameter) => [parameter.name, parameter] as const),
);
if (Object.keys(parameters).some((name) => !declared.has(name))) {
return invalid('Prompt parameters contain an undeclared name');
}
const entries: [string, string | null][] = [];
const replacements = new Map<string, string>();
for (const parameter of prompt.parameters) {
const raw = parameters[parameter.name];
if (raw === undefined) {
if (parameter.required) {
return invalid(`Prompt parameter ${parameter.name} is required`);
}
entries.push([parameter.name, null]);
replacements.set(parameter.name, '');
continue;
}
if (
typeof raw !== 'string' ||
Buffer.byteLength(raw, 'utf8') >
MAX_PLUGIN_PACKAGE_PROMPT_PARAMETER_VALUE_BYTES
) {
return invalid(`Prompt parameter ${parameter.name} is invalid`);
}
entries.push([parameter.name, raw]);
replacements.set(parameter.name, raw);
}
return Object.freeze({
entries: Object.freeze(entries.map((entry) => Object.freeze(entry))),
replacements,
});
}
function renderPrompt(
prompt: Readonly<PluginPackagePromptResource>,
replacements: ReadonlyMap<string, string>,
): string {
const rendered = prompt.template.replace(
/\{\{([A-Za-z][A-Za-z0-9_.-]{0,63})\}\}/g,
(_match, name: string) => replacements.get(name) ?? '',
);
const bytes = Buffer.byteLength(rendered, 'utf8');
if (bytes > MAX_MODEL_MESSAGE_BYTES || bytes > MAX_MODEL_INPUT_BYTES) {
return invalid('rendered Prompt exceeds the model input budget');
}
return rendered;
}
function planFields(
value: Omit<PluginPackagePromptExecutionPlan, 'planDigest'>,
): object {
return {
schema: value.schema,
requestId: value.requestId,
invocationId: value.invocationId,
runId: value.runId,
stepRunId: value.stepRunId,
traceId: value.traceId,
requestedBySubject: value.requestedBySubject,
policyFence: value.policyFence,
target: value.target,
provider: value.provider,
model: value.model,
maxOutputTokens: value.maxOutputTokens,
temperature: value.temperature,
parameterDigest: value.parameterDigest,
modelRequestDigest: value.modelRequestDigest,
inputBytes: value.inputBytes,
...(value.output === undefined ? {} : { output: value.output }),
deadlineAtMs: value.deadlineAtMs,
plannedAtMs: value.plannedAtMs,
};
}
function normalizeOutputIntent(
value: PluginPackagePromptOutputIntent,
): Readonly<PluginPackagePromptOutputIntent> {
const output = dataRecord(value, 'Prompt output intent');
if (value.mode === 'live_only') {
exactKeys(output, ['mode'], [], 'Prompt output intent');
return Object.freeze({ mode: 'live_only' as const });
}
if (value.mode !== 'durable_artifact') {
return invalid('Prompt output mode is invalid');
}
exactKeys(
output,
['mode', 'retentionPolicy', 'retentionPolicyDigest'],
[],
'Prompt output intent',
);
const retentionPolicy =
normalizePluginPackagePromptOutputArtifactRetentionPolicy(
value.retentionPolicy,
);
const retentionPolicyDigest = digest(
value.retentionPolicyDigest,
'retentionPolicyDigest',
);
if (
retentionPolicyDigest !==
pluginPackagePromptOutputArtifactRetentionPolicyDigest(retentionPolicy)
) {
return invalid('Prompt output retention policy digest is invalid');
}
return Object.freeze({
mode: 'durable_artifact' as const,
retentionPolicy,
retentionPolicyDigest,
});
}
function prepareOutputIntent(
value: PreparePluginPackagePromptOutputIntent | undefined,
): Readonly<PluginPackagePromptOutputIntent> {
if (value === undefined || value.mode === 'live_only') {
if (value !== undefined) {
exactKeys(
dataRecord(value, 'Prompt output intent'),
['mode'],
[],
'Prompt output intent',
);
}
return Object.freeze({ mode: 'live_only' as const });
}
if (value.mode !== 'durable_artifact') {
return invalid('Prompt output mode is invalid');
}
exactKeys(
dataRecord(value, 'Prompt output intent'),
['mode', 'retentionPolicy'],
[],
'Prompt output intent',
);
const retentionPolicy =
normalizePluginPackagePromptOutputArtifactRetentionPolicy(
value.retentionPolicy,
);
return Object.freeze({
mode: 'durable_artifact' as const,
retentionPolicy,
retentionPolicyDigest:
pluginPackagePromptOutputArtifactRetentionPolicyDigest(retentionPolicy),
});
}
export function pluginPackagePromptExecutionPlanDigest(
value: Omit<PluginPackagePromptExecutionPlan, 'planDigest'>,
): string {
return hash(PLAN_DIGEST_DOMAIN, planFields(value));
}
function normalizeTarget(
value: PluginPackagePromptExecutionPlanTarget,
): Readonly<PluginPackagePromptExecutionPlanTarget> {
const target = dataRecord(value, 'Prompt target');
exactKeys(
target,
[
'projectId',
'packageName',
'installationId',
'lockDigest',
'generation',
'generationDigest',
'materializedRevisionDigest',
'publicationDigest',
'promptId',
'promptDefinitionDigest',
],
[],
'Prompt target',
);
return Object.freeze({
projectId: identity(value.projectId, 'projectId'),
packageName: packageName(value.packageName),
installationId: identity(value.installationId, 'installationId'),
lockDigest: digest(value.lockDigest, 'lockDigest'),
generation: positiveInteger(value.generation, 2_147_483_647, 'generation'),
generationDigest: digest(value.generationDigest, 'generationDigest'),
materializedRevisionDigest: digest(
value.materializedRevisionDigest,
'materializedRevisionDigest',
),
publicationDigest: digest(value.publicationDigest, 'publicationDigest'),
promptId: identity(value.promptId, 'promptId'),
promptDefinitionDigest: digest(
value.promptDefinitionDigest,
'promptDefinitionDigest',
),
});
}
export function normalizePluginPackagePromptExecutionPlan(
value: PluginPackagePromptExecutionPlan,
): Readonly<PluginPackagePromptExecutionPlan> {
const plan = dataRecord(value, 'Prompt execution plan');
exactKeys(
plan,
[
'schema',
'requestId',
'invocationId',
'runId',
'stepRunId',
'traceId',
'requestedBySubject',
'policyFence',
'target',
'provider',
'model',
'maxOutputTokens',
'temperature',
'parameterDigest',
'modelRequestDigest',
'inputBytes',
'deadlineAtMs',
'plannedAtMs',
'planDigest',
],
['output'],
'Prompt execution plan',
);
if (value.schema !== PLUGIN_PACKAGE_PROMPT_EXECUTION_PLAN_SCHEMA) {
return invalid('schema is unsupported');
}
const target = normalizeTarget(value.target);
const requestId = identity(value.requestId, 'requestId');
const identityInput = Object.freeze({
requestId,
projectId: target.projectId,
publicationDigest: target.publicationDigest,
promptId: target.promptId,
});
const fence = dataRecord(value.policyFence, 'Prompt execution policy fence');
exactKeys(
fence,
['projectVersion', 'bindingVersion'],
[],
'Prompt execution policy fence',
);
const policyFence = Object.freeze({
projectVersion: positiveInteger(
value.policyFence.projectVersion,
2_147_483_647,
'policyFence.projectVersion',
),
bindingVersion: positiveInteger(
value.policyFence.bindingVersion,
2_147_483_647,
'policyFence.bindingVersion',
),
});
const normalized = Object.freeze({
schema: PLUGIN_PACKAGE_PROMPT_EXECUTION_PLAN_SCHEMA,
requestId,
invocationId: identity(value.invocationId, 'invocationId'),
runId: identity(value.runId, 'runId'),
stepRunId: identity(value.stepRunId, 'stepRunId'),
traceId: identity(value.traceId, 'traceId'),
requestedBySubject: normalizeProjectPolicySubject(value.requestedBySubject),
policyFence,
target,
provider: identity(value.provider, 'provider'),
model: identity(value.model, 'model'),
maxOutputTokens: positiveInteger(
value.maxOutputTokens,
MAX_MODEL_OUTPUT_TOKENS,
'maxOutputTokens',
),
temperature: nullableTemperature(value.temperature),
parameterDigest: digest(value.parameterDigest, 'parameterDigest'),
modelRequestDigest: sha256(value.modelRequestDigest, 'modelRequestDigest'),
inputBytes: positiveInteger(
value.inputBytes,
MAX_MODEL_INPUT_BYTES,
'inputBytes',
),
...(value.output === undefined
? {}
: { output: normalizeOutputIntent(value.output) }),
deadlineAtMs: timestamp(value.deadlineAtMs, 'deadlineAtMs'),
plannedAtMs: timestamp(value.plannedAtMs, 'plannedAtMs'),
planDigest: digest(value.planDigest, 'planDigest'),
});
if (
normalized.invocationId !== executionIdentity('ppi', identityInput) ||
normalized.runId !== executionIdentity('ppr', identityInput) ||
normalized.stepRunId !== executionIdentity('pps', identityInput) ||
normalized.deadlineAtMs <= normalized.plannedAtMs ||
normalized.deadlineAtMs - normalized.plannedAtMs >
MAX_MODEL_INVOCATION_MS ||
pluginPackagePromptExecutionPlanDigest(normalized) !==
normalized.planDigest ||
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
MAX_PLUGIN_PACKAGE_PROMPT_EXECUTION_PLAN_BYTES
) {
return invalid('identity, deadline, digest, or size is invalid');
}
return normalized;
}
export function preparePluginPackagePromptExecution(
input: PreparePluginPackagePromptExecutionInput,
): Readonly<PreparedPluginPackagePromptExecution> {
const source = dataRecord(input, 'Prompt execution input');
exactKeys(
source,
[
'publication',
'expectedPublicationDigest',
'promptId',
'requestId',
'traceId',
'requestedBySubject',
'policyFence',
'parameters',
'provider',
'model',
'maxOutputTokens',
'deadlineAtMs',
'plannedAtMs',
],
['output', 'signal', 'temperature'],
'Prompt execution input',
);
const publication = normalizePluginPackageAutomationPublication(
input.publication,
);
const expectedPublicationDigest = digest(
input.expectedPublicationDigest,
'expectedPublicationDigest',
);
const promptId = identity(input.promptId, 'promptId');
if (
publication.publicationDigest !== expectedPublicationDigest ||
publication.state !== 'active'
) {
return invalid('publication is not the expected active generation');
}
const prompt = publication.definitions.prompts.find(
(candidate) => candidate.id === promptId,
);
if (!prompt) return invalid('Prompt is not published');
const parameters = canonicalParameters(prompt, input.parameters);
const rendered = renderPrompt(prompt, parameters.replacements);
const provider = identity(input.provider, 'provider');
const model = identity(input.model, 'model');
const maxOutputTokens = positiveInteger(
input.maxOutputTokens,
MAX_MODEL_OUTPUT_TOKENS,
'maxOutputTokens',
);
const temperature =
input.temperature === undefined
? null
: nullableTemperature(input.temperature);
const request = Object.freeze({
provider,
model,
messages: Object.freeze([
Object.freeze({ role: 'user' as const, content: rendered }),
]),
maxOutputTokens,
...(temperature === null ? {} : { temperature }),
});
const requestId = identity(input.requestId, 'requestId');
if (
input.signal !== undefined &&
(typeof input.signal !== 'object' ||
typeof input.signal.aborted !== 'boolean' ||
typeof input.signal.addEventListener !== 'function')
) {
return invalid('signal is invalid');
}
const identityInput = Object.freeze({
requestId,
projectId: publication.target.projectId,
publicationDigest: publication.publicationDigest,
promptId,
});
const unsigned = Object.freeze({
schema: PLUGIN_PACKAGE_PROMPT_EXECUTION_PLAN_SCHEMA,
requestId,
invocationId: executionIdentity('ppi', identityInput),
runId: executionIdentity('ppr', identityInput),
stepRunId: executionIdentity('pps', identityInput),
traceId: identity(input.traceId, 'traceId'),
requestedBySubject: normalizeProjectPolicySubject(input.requestedBySubject),
policyFence: Object.freeze({
projectVersion: positiveInteger(
input.policyFence.projectVersion,
2_147_483_647,
'policyFence.projectVersion',
),
bindingVersion: positiveInteger(
input.policyFence.bindingVersion,
2_147_483_647,
'policyFence.bindingVersion',
),
}),
target: Object.freeze({
...publication.target,
publicationDigest: publication.publicationDigest,
promptId,
promptDefinitionDigest: pluginPackagePromptDefinitionDigest(prompt),
}),
provider,
model,
maxOutputTokens,
temperature,
parameterDigest: hash(PARAMETER_DIGEST_DOMAIN, parameters.entries),
modelRequestDigest: `sha256:${hash(REQUEST_DIGEST_DOMAIN, request)}`,
inputBytes: Buffer.byteLength(rendered, 'utf8'),
output: prepareOutputIntent(input.output),
deadlineAtMs: timestamp(input.deadlineAtMs, 'deadlineAtMs'),
plannedAtMs: timestamp(input.plannedAtMs, 'plannedAtMs'),
});
const plan = normalizePluginPackagePromptExecutionPlan({
...unsigned,
planDigest: pluginPackagePromptExecutionPlanDigest(unsigned),
});
return Object.freeze({
plan,
request,
...(input.signal === undefined ? {} : { signal: input.signal }),
});
}