diff --git a/crates/node/model_pricing.d.ts b/crates/node/model_pricing.d.ts index e1932a469..f0e971f24 100644 --- a/crates/node/model_pricing.d.ts +++ b/crates/node/model_pricing.d.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { Json, JsonObject } from './index'; -import type { ComponentSpec as PluginComponentSpec, ConfigReport } from './plugin'; +import type { ComponentSpecFor, ConfigReport } from './plugin'; /** Plugin kind used by the model pricing component. */ export declare const PRICING_PLUGIN_KIND = 'pricing'; @@ -111,12 +111,14 @@ export declare function fileSource(path: string): FileSource; export declare function pricingConfig(sources?: PricingSource[]): PricingConfig; /** Wrap model pricing config as a top-level plugin component. */ export declare function ComponentSpec( - config: PricingConfig | JsonObject, + config: PricingConfig, options?: { enabled?: boolean; }, -): PluginComponentSpec; +): ComponentSpecFor<'pricing'>; /** Validate a model pricing config document without activating it. */ export declare function validateConfig(config: PricingConfig | JsonObject): ConfigReport; +/** Validate an untyped model pricing document and return it as a config; throws on error diagnostics. */ +export declare function parseConfig(document: JsonObject): PricingConfig; export type { Json }; diff --git a/crates/node/model_pricing.js b/crates/node/model_pricing.js index 06ff48034..d73eb30ba 100644 --- a/crates/node/model_pricing.js +++ b/crates/node/model_pricing.js @@ -171,6 +171,22 @@ function validateConfig(config) { }).config; } +/** + * Validate an untyped model pricing document and return it as a config. + * + * @param {object} document - Model pricing component configuration document. + * @returns {object} The same document once validation reports no errors. + */ +function parseConfig(document) { + const errors = validateConfig(document).diagnostics.filter((diagnostic) => diagnostic.level === 'error'); + if (errors.length > 0) { + throw new Error(`invalid model pricing config: ${errors.map((diagnostic) => diagnostic.message).join('; ')}`, { + cause: errors, + }); + } + return document; +} + module.exports = { PRICING_PLUGIN_KIND, defaultConfig, @@ -185,4 +201,5 @@ module.exports = { pricingConfig, ComponentSpec, validateConfig, + parseConfig, }; diff --git a/crates/node/pii_redaction.d.ts b/crates/node/pii_redaction.d.ts index 5b98d2407..ed67c85f4 100644 --- a/crates/node/pii_redaction.d.ts +++ b/crates/node/pii_redaction.d.ts @@ -69,4 +69,4 @@ export declare function ComponentSpec( options?: { enabled?: boolean; }, -): import('./plugin.js').ComponentSpec; +): ComponentSpec; diff --git a/crates/node/plugin.d.ts b/crates/node/plugin.d.ts index de7f7ce92..0ab14d0fc 100644 --- a/crates/node/plugin.d.ts +++ b/crates/node/plugin.d.ts @@ -4,7 +4,14 @@ /// import type { AdaptivePluginConfig } from './adaptive'; -import type { ConfigDiagnostic, Json, JsonObject, JsonObjectCompatible, PluginContext, ResourceMetricsConfig } from './index'; +import type { + ConfigDiagnostic, + Json, + JsonObject, + JsonObjectCompatible, + PluginContext, + ResourceMetricsConfig, +} from './index'; import type { PricingConfig } from './model_pricing'; import type { Config as ObservabilityConfig } from './observability'; import type { Config as PiiRedactionConfig } from './pii_redaction'; @@ -71,23 +78,33 @@ export type KnownComponentSpec = { [K in ComponentKind]: { kind: K; enabled?: boolean; config?: ComponentConfigs[K] }; }[ComponentKind]; -/** One top-level plugin component. */ -export interface ComponentSpec { +declare const openComponent: unique symbol; + +/** Config of a component created with `ComponentSpec` for a kind registered through `registerPlugin`. */ +export type OpenComponentConfig = { + readonly [openComponent]: true; +}; + +/** Component for a kind registered through `registerPlugin`; create one with `ComponentSpec`. */ +export interface OpenComponentSpec { kind: string; enabled?: boolean; - config?: JsonObject; + config?: C & OpenComponentConfig; } export type ComponentConfigFor = K extends ComponentKind ? ComponentConfigs[K] : JsonObject; export type ComponentSpecFor = K extends ComponentKind ? Extract - : ComponentSpec; + : OpenComponentSpec; + +/** One top-level plugin component. */ +export type ComponentSpec = KnownComponentSpec | OpenComponentSpec; /** Canonical plugin configuration document. */ export interface PluginConfig { version?: number; - components?: Array; + components?: ComponentSpec[]; policy?: ConfigPolicy; /** Startup-only host service profile; omitted settings use file layers. */ diagnostics?: { enabled?: boolean; limits?: Record }; @@ -181,13 +198,20 @@ export declare function defaultConfig(): PluginConfig; * @remarks Setting `options.enabled = false` preserves the component for * validation while skipping runtime registration during `initialize`. */ -export declare function ComponentSpec = JsonObject>( +export declare function ComponentSpec( kind: K, - config?: K extends ComponentKind ? ComponentConfigs[K] : C, + config?: ComponentConfigs[K], options?: { enabled?: boolean; }, ): ComponentSpecFor; +export declare function ComponentSpec = JsonObject>( + kind: K extends ComponentKind ? never : K, + config?: C, + options?: { + enabled?: boolean; + }, +): OpenComponentSpec; /** * Initialize the core-owned static and dynamic plugin host. * diff --git a/crates/node/root-types.d.ts b/crates/node/root-types.d.ts index 14f5c7fe1..e7bdeb958 100644 --- a/crates/node/root-types.d.ts +++ b/crates/node/root-types.d.ts @@ -26,7 +26,9 @@ type BareObject = V extends unknown : never : never : never; -type JsonShaped = [Extract | BareObject] extends [never] ? T : never; +type Shaped = [Extract | BareObject] extends [never] + ? Mapped + : never; type JsonProperty = [unknown] extends [V] ? unknown : V extends JsonValue @@ -84,53 +86,43 @@ interface EventSanitizeFieldsOpen { export type JsonCompatible = | JsonValue - | ([T] extends [{} | null] - ? [JsonShaped] extends [never] - ? never - : { [K in keyof T]: JsonProperty } - : never); + | ([T] extends [{} | null] ? Shaped }> : never); export type JsonObjectCompatible = | JsonObject - | ([T] extends [readonly unknown[]] - ? never - : [T] extends [object] - ? [JsonShaped] extends [never] - ? never - : { [K in keyof T]: JsonProperty } - : never); + | ([T] extends [object] + ? Shaped }, NonJsonValue | readonly unknown[]> + : never); export type LlmRequestCompatible = | LlmRequestOpen | ([T] extends [object] - ? [JsonShaped] extends [never] - ? never - : { [K in keyof T]: K extends 'headers' ? JsonObjectProperty : JsonProperty } & Record< + ? Shaped< + T, + { [K in keyof T]: K extends 'headers' ? JsonObjectProperty : JsonProperty } & Record< 'headers' | 'content', unknown > + > : never); export type ToolExecutionResultCompatible = | ToolExecutionResultOpen - | ([T] extends [object] - ? [JsonShaped] extends [never] - ? never - : { [K in keyof T]: JsonProperty } & Record<'result', unknown> - : never); + | ([T] extends [object] ? Shaped } & Record<'result', unknown>> : never); export type ToolExecutionInterceptOutcomeCompatible = | ToolExecutionInterceptOutcome | ([T] extends [object] - ? [JsonShaped] extends [never] - ? never - : { [K in keyof T]: K extends 'pendingMarks' ? PendingMarksProperty : JsonProperty } & Record< + ? Shaped< + T, + { [K in keyof T]: K extends 'pendingMarks' ? PendingMarksProperty : JsonProperty } & Record< 'result', unknown > + > : never); export type LlmRequestInterceptOutcomeCompatible = | LlmRequestInterceptOutcome | ([T] extends [object] - ? [JsonShaped] extends [never] - ? never - : { + ? Shaped< + T, + { [K in keyof T]: K extends 'request' ? LlmRequestProperty : K extends 'pendingMarks' @@ -139,21 +131,14 @@ export type LlmRequestInterceptOutcomeCompatible = ? LlmOptimizationContribution[] : JsonProperty; } & Record<'request', unknown> + > : never); export type EventSanitizeFieldsCompatible = | EventSanitizeFieldsOpen - | ([T] extends [object] - ? [JsonShaped] extends [never] - ? never - : { [K in keyof T]: JsonProperty } - : never); + | ([T] extends [object] ? Shaped }> : never); export type EventMetadataCompatible = | EventMetadata - | ([T] extends [object] - ? [JsonShaped] extends [never] - ? never - : { [K in keyof T]: EventMetadataProperty } - : never); + | ([T] extends [object] ? Shaped }> : never); export type MetricMeasurementsCompatible = | readonly MetricMeasurement[] | ([T] extends [readonly unknown[]] ? { [I in keyof T]: MetricMeasurementProperty } : never); @@ -618,7 +603,11 @@ export interface PluginContext { name: string, priority: number, breakChain: boolean, - callback: (args: { name: string; request: LlmRequestShape; annotated: Json | null }) => TOutcome | Promise, + callback: (args: { + name: string; + request: LlmRequestShape; + annotated: Json | null; + }) => TOutcome | Promise, ): void; registerLlmExecutionIntercept>( name: string, diff --git a/crates/node/src/api/mod.rs b/crates/node/src/api/mod.rs index 5afc19826..abe8b795c 100644 --- a/crates/node/src/api/mod.rs +++ b/crates/node/src/api/mod.rs @@ -117,7 +117,7 @@ fn cleanup_node_environment() { /// Emit a structured operational log record through Relay's configured sinks. #[napi( - ts_generic_types = "TFields extends JsonCompatible = Json", + ts_generic_types = "TFields extends JsonObjectCompatible = JsonObject", catch_unwind )] pub fn log( @@ -144,7 +144,7 @@ macro_rules! log_levels { ($($level:ident: $doc:literal,)*) => {$( #[doc = $doc] #[napi( - ts_generic_types = "TFields extends JsonCompatible = Json", + ts_generic_types = "TFields extends JsonObjectCompatible = JsonObject", catch_unwind )] pub fn $level( diff --git a/crates/node/tests/model_pricing_tests.mjs b/crates/node/tests/model_pricing_tests.mjs index e12724e47..a62aa9541 100644 --- a/crates/node/tests/model_pricing_tests.mjs +++ b/crates/node/tests/model_pricing_tests.mjs @@ -92,6 +92,15 @@ describe('model pricing plugin helpers', () => { ); }); + it('parses untyped documents into configs', () => { + const document = { sources: [pricing.inlineSource(pricing.inlineCatalog([pricedEntry()]))] }; + assert.equal(pricing.parseConfig(document), document); + assert.throws( + () => pricing.parseConfig({ sources: 1 }), + (error) => /invalid model pricing config/.test(error.message) && error.cause[0].code === 'pricing.invalid_config', + ); + }); + it('accepts and validates cache write rates', () => { const report = (rate) => pricing.validateConfig( diff --git a/crates/node/tests/public_json_compatible_api_fixture.ts b/crates/node/tests/public_json_compatible_api_fixture.ts index 6bd96e46d..10d5d0145 100644 --- a/crates/node/tests/public_json_compatible_api_fixture.ts +++ b/crates/node/tests/public_json_compatible_api_fixture.ts @@ -3,6 +3,7 @@ import { event, + info, llmCall, llmCallEnd, llmCallExecuteAsync, @@ -155,13 +156,16 @@ declare const badMeta: BadMeta; declare const undefinedMeta: UndefinedMeta; declare const withMethod: WithMethod; declare const withUndefined: WithUndefined; +declare const paramsOrList: SdkParams | string[]; async function* chunks(): AsyncIterable { yield { id: '1', delta: { content: 'x' } }; } const jsonRoundTrip: AssertEqual, JsonValue> = true; -const shapeRoundTrip: AssertEqual ? true : false, true> = - true; +const shapeRoundTrip: AssertEqual< + LlmRequestShape extends LlmRequestCompatible ? true : false, + true +> = true; toolCall('interface', params); toolCall('json', json, null, null, record, json); @@ -193,6 +197,8 @@ llmCall('headers', { headers, content: null }); llmCall('instance', new LlmRequest({}, { x: 1 })); llmCall('factory', LlmRequest.from(headers, params)); const built: LlmRequestShape = LlmRequest.from({}, params); +info('fields', null, settings); +info('json-fields', null, record); llmCall('extra-json', { headers: {}, content: null, extra: 1 }); llmCall('shape', shape, null, null, markData, markData); llmCallEnd(llmCall('end', shape), sdk, markData); @@ -219,8 +225,20 @@ llmCallExecuteAsync( ({ original }) => ({ headers: original.headers, content: params }), (response) => response, ); -llmStreamCallExecute('stream', { headers: {}, content: params }, () => chunks(), undefined, (): SdkResponse => sdk); -llmStreamCallExecute('stream-json', shape, async () => chunks(), (chunk) => void chunk, async () => json); +llmStreamCallExecute( + 'stream', + { headers: {}, content: params }, + () => chunks(), + undefined, + (): SdkResponse => sdk, +); +llmStreamCallExecute( + 'stream-json', + shape, + async () => chunks(), + (chunk) => void chunk, + async () => json, +); registerToolExecutionIntercept('outcome', 1, async (context, next) => ({ result: await next(params), pendingMarks: [{ name: 'm', data: markData }], @@ -235,7 +253,9 @@ registerLlmRequestIntercept('interface', 1, false, ({ request }) => ({ pendingMarks: [{ name: 'm' }], })); registerLlmRequestIntercept('typed', 1, false, async () => requestOutcome); -registerLlmExecutionIntercept('next', 1, (request, context, next) => next({ headers: request.headers, content: params })); +registerLlmExecutionIntercept('next', 1, (request, context, next) => + next({ headers: request.headers, content: params }), +); registerLlmExecutionIntercept('interface', 1, async () => sdk); registerLlmSanitizeRequestGuardrail('shape', 1, () => shape); registerLlmSanitizeRequestGuardrail('nullable', 1, async () => nullableShape); @@ -251,7 +271,12 @@ registerEventMetadataInjector('interface', 1, () => meta); event('data', null, markData, markData); metric('typed', measurements); metric('extra', [{ name: 'm', kind: MetricKind.Counter, valueType: MetricValueType.U64, value: 1, extra: () => 1 }]); -metric('interface', [{ name: 'm', kind: MetricKind.Counter, valueType: MetricValueType.U64, value: 1, attributes: markData }], null, markData); +metric( + 'interface', + [{ name: 'm', kind: MetricKind.Counter, valueType: MetricValueType.U64, value: 1, attributes: markData }], + null, + markData, +); const scoped: Promise = withScope('scope', ScopeType.Agent, async (): Promise => sdk); const voided: Promise = withScope('void', ScopeType.Agent, async () => {}); const fromJson: Promise = withScope('json', ScopeType.Agent, () => json, null, null, markData, markData, params); @@ -310,6 +335,7 @@ function forwardMeasurements(value: T): void { metric('forward', value); } plugin.ComponentSpec('example.add-tag', {}); +plugin.ComponentSpec('example.add-tag', sdkOrNull === null ? settings : sdk); const openKind: AssertEqual = true; // @ts-expect-error methods are not JSON @@ -385,16 +411,24 @@ registerLlmSanitizeRequestGuardrail('map', 1, () => ({ headers: {}, content: new registerLlmSanitizeResponseGuardrail('map', 1, () => new Map()); // @ts-expect-error measurements must be measurement records metric('records', [{ name: 'm' }]); -// @ts-expect-error measurement attributes must be JSON -metric('attributes', [{ name: 'm', kind: MetricKind.Counter, valueType: MetricValueType.U64, value: 1, attributes: new Map() }]); +metric('attributes', [ + // @ts-expect-error measurement attributes must be JSON + { name: 'm', kind: MetricKind.Counter, valueType: MetricValueType.U64, value: 1, attributes: new Map() }, +]); // @ts-expect-error a scope callback must return JSON withScope('map', ScopeType.Agent, () => new Map()); // @ts-expect-error stream chunks must be JSON llmStreamCallExecute('map', shape, async function* () { yield new Map(); }); -// @ts-expect-error the stream aggregate must be JSON -llmStreamCallExecute('aggregate', shape, () => chunks(), undefined, () => new Map()); +llmStreamCallExecute( + 'aggregate', + shape, + () => chunks(), + undefined, + // @ts-expect-error the stream aggregate must be JSON + () => new Map(), +); // @ts-expect-error codec outputs must be JSON new OpenAIChatCodec().decodeResponse(new Map()); // @ts-expect-error a known kind rejects unknown fields @@ -409,6 +443,14 @@ plugin.ComponentSpec('example.add-tag', () => 1); LlmRequest.from('x', null); // @ts-expect-error content must be JSON LlmRequest.from({}, new Map()); +// @ts-expect-error a union with an array member is not a JSON object +LlmRequest.from(paramsOrList, null); +// @ts-expect-error a union with an array member is not a JSON object +plugin.ComponentSpec('example.add-tag', paramsOrList); +// @ts-expect-error log fields must be a JSON object +info('array-fields', null, [1]); +// @ts-expect-error log fields must be a JSON object +info('string-fields', null, 'x'); export const checked = [ jsonRoundTrip, diff --git a/crates/node/tests/public_model_pricing_api_fixture.ts b/crates/node/tests/public_model_pricing_api_fixture.ts index 54e6aa488..97f00b0dd 100644 --- a/crates/node/tests/public_model_pricing_api_fixture.ts +++ b/crates/node/tests/public_model_pricing_api_fixture.ts @@ -10,11 +10,13 @@ import { pricingConfig, promptCache, tokenRates, + parseConfig, validateConfig, type ModelPricing, type PricingCatalog, type PricingConfig, } from '../model_pricing.js'; +import type { JsonObject } from '../index.js'; const entry: ModelPricing = catalogEntry({ provider: 'openai', @@ -35,6 +37,8 @@ const config: PricingConfig = pricingConfig([fileSource('./pricing.json'), inlin ComponentSpec(config); ComponentSpec({ sources: [] }); validateConfig(config).diagnostics.map((diagnostic) => diagnostic.level); +declare const document: JsonObject; +ComponentSpec(parseConfig(document)); catalogEntry({ provider: 'openai', diff --git a/crates/node/tests/public_plugin_api_fixture.ts b/crates/node/tests/public_plugin_api_fixture.ts index 6dca5f2d6..2b0b4bbbd 100644 --- a/crates/node/tests/public_plugin_api_fixture.ts +++ b/crates/node/tests/public_plugin_api_fixture.ts @@ -2,14 +2,23 @@ // SPDX-License-Identifier: Apache-2.0 import { ComponentSpec as adaptiveComponentSpec, defaultConfig as adaptiveDefaultConfig } from '../adaptive.js'; +import { ComponentSpec as pricingComponentSpec } from '../model_pricing.js'; +import { ComponentSpec as piiComponentSpec, defaultConfig as piiDefaultConfig } from '../pii_redaction.js'; import { listRuntimeRegistrations, registerConditionalMiddlewareGuardrail, RuntimeRegistrationKind, + type JsonObject, type RuntimeRegistrationKindName, } from '../index.js'; import * as plugin from '../plugin.js'; -import type { ComponentKind, KnownComponentSpec, PluginConfig } from '../plugin.js'; +import type { + ComponentKind, + ComponentSpecFor, + KnownComponentSpec, + OpenComponentSpec, + PluginConfig, +} from '../plugin.js'; type AssertEqual = [T] extends [U] ? ([U] extends [T] ? true : false) : false; @@ -22,6 +31,12 @@ const pricing = plugin.ComponentSpec('pricing', { sources: [{ type: 'file', path const pricingKind: AssertEqual = true; const open = plugin.ComponentSpec('example.add-tag', { tag: 'documentation' }); const openKind: AssertEqual = true; +const openTag: string | undefined = open.config?.tag; +const erasedOpen: ComponentSpecFor = open; +const forwardedOpen: JsonObject | undefined = erasedOpen.config; +declare const dynamicKind: string; +const dynamic = plugin.ComponentSpec(dynamicKind, { tag: 'documentation' }); +const dynamicSpec: AssertEqual> = true; const config: PluginConfig = { version: 1, @@ -34,6 +49,8 @@ const config: PluginConfig = { }, pricing, open, + pricingComponentSpec({ sources: [] }), + piiComponentSpec(piiDefaultConfig()), ], }; @@ -58,4 +75,26 @@ const malformed: KnownComponentSpec = { kind: 'resource_metrics', config: { poll // @ts-expect-error Registration kinds must be known names. listRuntimeRegistrations(['subscribers']); -export const checked = [kinds, pricingKind, openKind, config, subscriberKind, everyKind, name, malformed]; +const malformedKnown: PluginConfig = { + // @ts-expect-error A known kind inside a plugin config rejects unknown fields. + components: [{ kind: 'pricing', config: { sources: [], typo: 1 } }], +}; + +const inlineOpen: PluginConfig = { + // @ts-expect-error An open kind must be created with ComponentSpec. + components: [{ kind: 'example.add-tag', config: { tag: 'documentation' } }], +}; + +export const checked = [ + kinds, + pricingKind, + openKind, + openTag, + config, + subscriberKind, + everyKind, + name, + malformed, + malformedKnown, + inlineOpen, +]; diff --git a/crates/node/tests/typed_tests.mjs b/crates/node/tests/typed_tests.mjs index e26041564..b287a7f30 100644 --- a/crates/node/tests/typed_tests.mjs +++ b/crates/node/tests/typed_tests.mjs @@ -8,12 +8,7 @@ import * as pluginHost from './plugin_host_test_helper.mjs'; const require = createRequire(import.meta.url); const lib = require('../index.js'); -const { - typedToolExecute, - typedLlmExecute, - typedLlmStreamExecute, - JsonPassthrough, -} = require('../typed.js'); +const { typedToolExecute, typedLlmExecute, typedLlmStreamExecute, JsonPassthrough } = require('../typed.js'); const adaptive = require('../adaptive.js'); const plugin = require('../plugin.js');