@outputai/core 0.15.1-next.4a437bf.0 → 0.15.1-next.8ef04e2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +58 -6
- package/src/helpers/fetch.js +21 -9
- package/src/helpers/path.js +36 -0
- package/src/helpers/redact.js +2 -1
- package/src/http/config.js +10 -0
- package/src/http/cost.d.ts +20 -0
- package/src/http/cost.js +41 -0
- package/src/http/fetch/events.js +10 -0
- package/src/http/fetch/index.d.ts +26 -0
- package/src/http/fetch/index.js +78 -0
- package/src/http/fetch/logger.d.ts +10 -0
- package/src/http/fetch/logger.js +56 -0
- package/src/http/index.d.ts +27 -0
- package/src/http/index.js +9 -0
- package/src/http/ky/index.d.ts +25 -0
- package/src/http/ky/index.js +10 -0
- package/src/http/request_tag.js +30 -0
- package/src/llm/agent.js +141 -0
- package/src/llm/ai_provider.js +131 -0
- package/src/llm/ai_sdk_options.js +126 -0
- package/src/llm/consts.js +6 -0
- package/src/llm/deprecated_provider_aliases.js +8 -0
- package/src/llm/generate.js +88 -0
- package/src/llm/index.d.ts +631 -0
- package/src/llm/index.js +5 -0
- package/src/llm/prompt/content.js +70 -0
- package/src/llm/prompt/interpolations.js +58 -0
- package/src/llm/prompt/loader.js +67 -0
- package/src/llm/prompt/markup/attributes.js +52 -0
- package/src/llm/prompt/markup/nodes.js +71 -0
- package/src/llm/prompt/markup/tokenizer.js +3 -0
- package/src/llm/prompt/markup/tokens.js +26 -0
- package/src/llm/prompt/validations.js +129 -0
- package/src/llm/utils/cost.js +150 -0
- package/src/llm/utils/error_handler.js +53 -0
- package/src/llm/utils/file.js +42 -0
- package/src/llm/utils/google_vertex_auth.js +31 -0
- package/src/llm/utils/grounding.js +49 -0
- package/src/llm/utils/image.js +37 -0
- package/src/llm/utils/legacy_cost_attribute.js +75 -0
- package/src/llm/utils/metering.js +126 -0
- package/src/llm/utils/models.js +15 -0
- package/src/llm/utils/models_pricing.js +93 -0
- package/src/llm/utils/models_pricing_snapshot.json +4166 -0
- package/src/llm/utils/skills.js +74 -0
- package/src/llm/utils/sources.js +73 -0
- package/src/llm/utils/stream.js +75 -0
- package/src/llm/utils/tools.js +48 -0
- package/src/llm/utils/usage.js +156 -0
- package/src/llm/utils/usage_tools.js +23 -0
- package/src/llm/utils/wrap.js +277 -0
- package/src/llm/validations.js +234 -0
- package/src/sdk/helpers/path.d.ts +2 -0
- package/src/sdk/helpers/path.js +1 -0
- package/src/tracing/trace_attribute.d.ts +0 -17
- package/src/tracing/trace_attribute.js +1 -29
- package/src/worker/configs.js +23 -4
- package/src/worker/index.js +16 -47
- package/src/worker/interceptors/activity.js +0 -1
- package/src/worker/interceptors/workflow.js +0 -1
- package/src/worker/telemetry.js +122 -2
- package/src/worker/worker_options.js +43 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isPlainObject } from '#helpers/object';
|
|
2
|
+
import { encodeXML, decodeXML } from 'entities';
|
|
3
|
+
|
|
4
|
+
export const interpolationFilterToken = '__var_safe';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* XML-escapes a value.
|
|
8
|
+
* @param {unknown} value - Any value to escape
|
|
9
|
+
* @returns {string} Escaped value or '' if original value was null/undefined
|
|
10
|
+
*/
|
|
11
|
+
export const encode = value => [ null, undefined ].includes( value ) ? '' : encodeXML( String( value ) );
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Matches {% raw %}...{% endraw %} or {{ ... }} tags
|
|
15
|
+
*/
|
|
16
|
+
const VAR_OR_RAW = /(\{%\s*raw\s*%\}[\s\S]*?\{%\s*endraw\s*%\})|\{\{\s*([\s\S]+?)\s*\}\}/g;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Appends `| __var_safe` filter to every `{{ ... }}` expression.
|
|
20
|
+
*
|
|
21
|
+
* The filter XML-escape the interpolated content so it cannot be parsed as new message blocks.
|
|
22
|
+
*
|
|
23
|
+
* Without this, a variable whose value contains `<system>` or `</user>` would inject extra
|
|
24
|
+
* message blocks.
|
|
25
|
+
*
|
|
26
|
+
* Raw regions `{% raw %} ... {% endraw %}` are emitted verbatim by Liquid and preserved
|
|
27
|
+
* unchanged.
|
|
28
|
+
*
|
|
29
|
+
* Note: The `g` flag advances past any matched raw regions, so `{{ ... }}` inside them are
|
|
30
|
+
* preserved as well.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} raw - Raw template string
|
|
33
|
+
* @returns {string} Template with `| __var_safe` appended to each interpolation
|
|
34
|
+
*/
|
|
35
|
+
export const pipeInterpolations = raw =>
|
|
36
|
+
raw.replace( VAR_OR_RAW, ( _, rawRegion, expressionContent ) =>
|
|
37
|
+
rawRegion === undefined ? `{{ ${expressionContent.trim()} | ${interpolationFilterToken} }}` : rawRegion
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Recursive decode a XML-encoded value.
|
|
42
|
+
* @param {unknown} value - Value to decode
|
|
43
|
+
* @returns {unknown} Decoded value
|
|
44
|
+
*/
|
|
45
|
+
export const decode = value => {
|
|
46
|
+
if ( typeof value === 'string' ) {
|
|
47
|
+
return decodeXML( value );
|
|
48
|
+
}
|
|
49
|
+
if ( Array.isArray( value ) ) {
|
|
50
|
+
return value.map( decode );
|
|
51
|
+
}
|
|
52
|
+
if ( isPlainObject( value ) ) {
|
|
53
|
+
return Object.fromEntries(
|
|
54
|
+
Object.entries( value ).map( ( [ key, item ] ) => [ key, decode( item ) ] )
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Context, Liquid } from 'liquidjs';
|
|
2
|
+
import { parsePromptSchema } from './validations.js';
|
|
3
|
+
import { FatalError } from '#errors';
|
|
4
|
+
import { pipeInterpolations, interpolationFilterToken, encode, decode } from './interpolations.js';
|
|
5
|
+
import { resolveInvocationDir } from '#helpers/path';
|
|
6
|
+
import { searchAndReadFile } from '../utils/file.js';
|
|
7
|
+
import matter from 'gray-matter';
|
|
8
|
+
import { parseContent } from './content.js';
|
|
9
|
+
|
|
10
|
+
const liquid = new Liquid( {
|
|
11
|
+
strictFilters: true,
|
|
12
|
+
strictVariables: true,
|
|
13
|
+
lenientIf: true
|
|
14
|
+
} );
|
|
15
|
+
|
|
16
|
+
liquid.registerFilter( interpolationFilterToken, encode );
|
|
17
|
+
|
|
18
|
+
const splitPromptContent = text => {
|
|
19
|
+
const dummyEngine = () => ( {} ); // dummy engine to allow for split only
|
|
20
|
+
const file = matter( text, { engines: { yaml: dummyEngine } } );
|
|
21
|
+
return { rawFrontmatter: file.matter, rawContent: file.content };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const renderContent = ( rawContent, context ) => liquid.parseAndRenderSync( pipeInterpolations( rawContent ), context ).trim();
|
|
25
|
+
|
|
26
|
+
const renderFrontmatter = ( rawFrontmatter, context ) => liquid.parseAndRenderSync( pipeInterpolations( rawFrontmatter ), context );
|
|
27
|
+
|
|
28
|
+
const parseFrontmatter = yml => decode( matter( `---\n${yml}\n---\n` ).data );
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load a prompt file and render it with variables.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} name - Name of the prompt file (without .prompt extension)
|
|
34
|
+
* @param {Record<string, unknown>} [variables] - Variables to interpolate
|
|
35
|
+
* @param {string} [dir] - Directory to search for the prompt file (defaults to stack-resolved invocation dir)
|
|
36
|
+
* @returns {Prompt} Loaded and rendered prompt object.
|
|
37
|
+
*/
|
|
38
|
+
export const loadPrompt = ( name, variables = {}, dir = resolveInvocationDir() ) => {
|
|
39
|
+
// Small closure to encapsulate errors with "name"
|
|
40
|
+
const tryStep = ( fn, msg ) => {
|
|
41
|
+
try {
|
|
42
|
+
return fn();
|
|
43
|
+
} catch ( e ) {
|
|
44
|
+
throw new FatalError( [ `${msg} on prompt "${name}"`, e.message ].join( ': ' ), { cause: e } );
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const file = searchAndReadFile( dir, `${name}.prompt` );
|
|
49
|
+
if ( !file ) {
|
|
50
|
+
throw new FatalError( `Prompt file "${name}" not found.` );
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { rawFrontmatter, rawContent } = tryStep( () => splitPromptContent( file.content ), 'Error parsing frontmatter' );
|
|
54
|
+
const context = new Context( variables, liquid.options, { sync: true } );
|
|
55
|
+
const renderedFrontmatter = tryStep( () => renderFrontmatter( rawFrontmatter, context ), 'Error rendering frontmatter' );
|
|
56
|
+
const content = tryStep( () => renderContent( rawContent, context ), 'Error rendering content' );
|
|
57
|
+
|
|
58
|
+
if ( !content ) {
|
|
59
|
+
throw new FatalError( `Prompt "${name}" has no content.` );
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const config = tryStep( () => parseFrontmatter( renderedFrontmatter ), 'Error converting frontmatter yaml to js' );
|
|
63
|
+
|
|
64
|
+
const { messages, instructions } = tryStep( () => parseContent( content, config.messageOptions ), 'Error parsing content' );
|
|
65
|
+
|
|
66
|
+
return parsePromptSchema( { name, config, messages, instructions, fileDir: file.dir, variables } );
|
|
67
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ValidationError } from '#errors';
|
|
2
|
+
|
|
3
|
+
const attributeSeparator = /(?<![=\s])\s+(?!\s*=)(?=(?:[^"']|"[^"]*"|'[^']*')*$)/;
|
|
4
|
+
|
|
5
|
+
export const extractAttributes = rawAttributes => {
|
|
6
|
+
if ( !rawAttributes?.trim() ) {
|
|
7
|
+
return {};
|
|
8
|
+
}
|
|
9
|
+
const pairs = rawAttributes.trim().split( attributeSeparator );
|
|
10
|
+
const attributeKeyRule = /^[A-Za-z][\w-]*$/;
|
|
11
|
+
|
|
12
|
+
return Object.fromEntries(
|
|
13
|
+
pairs.map( p => {
|
|
14
|
+
const splitIndex = p.indexOf( '=' );
|
|
15
|
+
if ( splitIndex === -1 ) {
|
|
16
|
+
const key = p.toLowerCase();
|
|
17
|
+
if ( !attributeKeyRule.test( key ) ) {
|
|
18
|
+
throw new ValidationError( `Invalid attribute name "${key}".` );
|
|
19
|
+
}
|
|
20
|
+
return [ key, true ];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const key = p.slice( 0, splitIndex ).trim().toLowerCase();
|
|
24
|
+
if ( !key ) {
|
|
25
|
+
throw new ValidationError( `Invalid attribute "${p}": name is missing.` );
|
|
26
|
+
}
|
|
27
|
+
if ( !attributeKeyRule.test( key ) ) {
|
|
28
|
+
throw new ValidationError( `Invalid attribute name "${key}".` );
|
|
29
|
+
}
|
|
30
|
+
const value = p.slice( splitIndex + 1 ).trim();
|
|
31
|
+
const openingQuote = value[0];
|
|
32
|
+
const isQuoted = openingQuote === '\'' || openingQuote === '"';
|
|
33
|
+
|
|
34
|
+
if ( !isQuoted ) {
|
|
35
|
+
const invalidCharacter = value.match( /['"<>=]/ )?.[0];
|
|
36
|
+
if ( invalidCharacter ) {
|
|
37
|
+
throw new ValidationError( `Invalid attribute "${key}": unquoted value contains invalid character \`${invalidCharacter}\`.` );
|
|
38
|
+
}
|
|
39
|
+
return [ key, value ];
|
|
40
|
+
} else {
|
|
41
|
+
if ( value.at( -1 ) !== openingQuote ) {
|
|
42
|
+
throw new ValidationError( `Invalid attribute "${key}": quoted value is missing its closing quote (${openingQuote}).` );
|
|
43
|
+
}
|
|
44
|
+
const unquoted = value.slice( 1, -1 );
|
|
45
|
+
if ( unquoted.includes( openingQuote ) ) {
|
|
46
|
+
throw new ValidationError( `Invalid attribute "${key}": quoted value contains an unexpected quote (${openingQuote}).` );
|
|
47
|
+
}
|
|
48
|
+
return [ key, unquoted ];
|
|
49
|
+
}
|
|
50
|
+
} )
|
|
51
|
+
);
|
|
52
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isOpeningTag, isClosingTag, isEmptyText, isSelfClosingTag, isComment, extractTag } from './tokens.js' ;
|
|
2
|
+
import { tokenize } from './tokenizer.js';
|
|
3
|
+
import { ValidationError } from '#errors';
|
|
4
|
+
|
|
5
|
+
class Node {
|
|
6
|
+
constructor( tagName, attributes, content = '' ) {
|
|
7
|
+
this.tagName = tagName;
|
|
8
|
+
this.attributes = attributes;
|
|
9
|
+
this.content = content;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const parseMarkup = content => {
|
|
14
|
+
const tokens = tokenize( content );
|
|
15
|
+
if ( !tokens ) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const state = {
|
|
20
|
+
currentNode: null
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const nodes = [];
|
|
24
|
+
|
|
25
|
+
// If the first ever valid text isnt a markup or comment, don't try to parse and return no nodes
|
|
26
|
+
const firstContentToken = tokens.find( token => !isComment( token ) && !isEmptyText( token ) );
|
|
27
|
+
if ( firstContentToken && !isOpeningTag( firstContentToken ) && !isClosingTag( firstContentToken ) && !isSelfClosingTag( firstContentToken ) ) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for ( const token of tokens ) {
|
|
32
|
+
if ( isOpeningTag( token ) ) {
|
|
33
|
+
const { tagName, attributes } = extractTag( token );
|
|
34
|
+
if ( !state.currentNode ) {
|
|
35
|
+
state.currentNode = new Node( tagName, attributes );
|
|
36
|
+
} else {
|
|
37
|
+
if ( tagName === state.currentNode.tagName ) {
|
|
38
|
+
throw new ValidationError(
|
|
39
|
+
`Invalid child tag: <${tagName}> cannot appear inside another <${tagName}>. Escape it as "<${tagName}>".`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
state.currentNode.content += token;
|
|
43
|
+
}
|
|
44
|
+
} else if ( isClosingTag( token ) ) {
|
|
45
|
+
if ( !state.currentNode ) {
|
|
46
|
+
throw new ValidationError( `Invalid root tag: closing tag "${token}" has no matching opening tag.` );
|
|
47
|
+
}
|
|
48
|
+
if ( extractTag( token ).tagName === state.currentNode.tagName ) {
|
|
49
|
+
nodes.push( state.currentNode );
|
|
50
|
+
state.currentNode = null;
|
|
51
|
+
} else {
|
|
52
|
+
state.currentNode.content += token;
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
if ( state.currentNode ) {
|
|
56
|
+
state.currentNode.content += token;
|
|
57
|
+
} else {
|
|
58
|
+
if ( isSelfClosingTag( token ) ) {
|
|
59
|
+
throw new ValidationError( `Invalid root tag: self-closing tag "${token}" is not allowed.` );
|
|
60
|
+
} else if ( !isComment( token ) && !isEmptyText( token ) ) {
|
|
61
|
+
throw new ValidationError( 'Invalid root text: text is not allowed outside message tags.' );
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if ( state.currentNode ) {
|
|
68
|
+
throw new ValidationError( `Invalid tag: <${state.currentNode.tagName}> is missing its closing tag.` );
|
|
69
|
+
}
|
|
70
|
+
return nodes;
|
|
71
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ValidationError } from '#errors';
|
|
2
|
+
import { extractAttributes } from './attributes.js';
|
|
3
|
+
|
|
4
|
+
export const extractTag = v => {
|
|
5
|
+
const capture = v.match( /^<\/?\s*([\w-]+)(?:\s+(.+?))?\s*>$/ );
|
|
6
|
+
if ( !capture ) {
|
|
7
|
+
throw new ValidationError( `Could not parse tag "${v}".` );
|
|
8
|
+
}
|
|
9
|
+
const [ name, rawAttributes ] = capture.slice( 1, 3 );
|
|
10
|
+
if ( !name ) {
|
|
11
|
+
throw new ValidationError( `Could not parse tag "${v}".` );
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
tagName: name.toLowerCase(),
|
|
15
|
+
attributes: rawAttributes ? extractAttributes( rawAttributes ) : {}
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const hasClosing = v => /\/\s*>$/.test( v );
|
|
20
|
+
const hasOpening = v => /^<[A-Za-z][\w-]*(?=\s|\/?>)[^<]*>$/.test( v );
|
|
21
|
+
|
|
22
|
+
export const isOpeningTag = v => hasOpening( v ) && !hasClosing( v );
|
|
23
|
+
export const isComment = v => v.startsWith( '<!--' ) && v.endsWith( '-->' ) && v.length >= 7 && !v.slice( 4, -3 ).includes( '-->' );
|
|
24
|
+
export const isSelfClosingTag = v => hasOpening( v ) && hasClosing( v );
|
|
25
|
+
export const isEmptyText = v => /^\s*$/.test( v );
|
|
26
|
+
export const isClosingTag = v => /^<\/\s*[A-Za-z]+[\w-]*\s*>$/.test( v );
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ValidationError } from '#errors';
|
|
3
|
+
import { Logger } from '#runtime_logger';
|
|
4
|
+
import { deprecatedProviderAliases } from '../deprecated_provider_aliases.js';
|
|
5
|
+
import { Role } from '../consts.js';
|
|
6
|
+
|
|
7
|
+
const log = Logger.createLogger( 'LLM' );
|
|
8
|
+
|
|
9
|
+
const handleDeprecatedKeys = prompt => {
|
|
10
|
+
const { config: { maxTokens, maxOutputTokens, provider } } = prompt;
|
|
11
|
+
|
|
12
|
+
// @TODO: maxTokens was replaced by maxOutputTokens in v0.12, it can be removed down the road
|
|
13
|
+
if ( Number.isFinite( maxTokens ) ) {
|
|
14
|
+
if ( Number.isFinite( maxOutputTokens ) ) {
|
|
15
|
+
log.warn( 'Prompt has both "maxOutputTokens" and deprecated "maxTokens" keys set, "maxTokens" value will be ignored.' );
|
|
16
|
+
} else {
|
|
17
|
+
log.warn( 'Prompt has deprecated key "maxTokens". Its value was set to "maxOutputTokens".' );
|
|
18
|
+
prompt.config.maxOutputTokens = maxTokens;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// @TODO: this handle aliases removal in v0.11, it can be removed down the road
|
|
23
|
+
if ( Object.hasOwn( deprecatedProviderAliases, provider ) ) {
|
|
24
|
+
const canonical = deprecatedProviderAliases[provider];
|
|
25
|
+
log.warn( `Prompt uses deprecated provider alias "${provider}". Use "${canonical}" instead.` );
|
|
26
|
+
prompt.config.provider = canonical;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return prompt;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const objectMapSchema = z.record(
|
|
33
|
+
z.string(),
|
|
34
|
+
z.record(
|
|
35
|
+
z.string(),
|
|
36
|
+
z.unknown()
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const promptConfigSchema = z.object( {
|
|
41
|
+
aspectRatio: z.string().regex( /^\d+:\d+$/ ).optional(),
|
|
42
|
+
frequencyPenalty: z.number().optional(),
|
|
43
|
+
maxImagesPerCall: z.number().int().positive().optional(),
|
|
44
|
+
maxOutputTokens: z.number().int().positive().optional(),
|
|
45
|
+
maxSteps: z.number().int().positive().default( 10 ),
|
|
46
|
+
maxTokens: z.number().int().positive().optional(),
|
|
47
|
+
// A provider-namespaced options object, e.g. { anthropic: { cacheControl: { type: 'ephemeral' } } }
|
|
48
|
+
messageOptions: z.record( z.string(), objectMapSchema ).optional(),
|
|
49
|
+
model: z.string().min( 1 ),
|
|
50
|
+
n: z.number().int().positive().optional(),
|
|
51
|
+
presencePenalty: z.number().optional(),
|
|
52
|
+
provider: z.string().min( 1 ),
|
|
53
|
+
providerOptions: z.object( {
|
|
54
|
+
thinking: z.object( {
|
|
55
|
+
type: z.enum( [ 'enabled', 'disabled' ] ),
|
|
56
|
+
budgetTokens: z.number().optional()
|
|
57
|
+
} ).loose().optional()
|
|
58
|
+
} ).loose().optional(),
|
|
59
|
+
seed: z.number().int().optional(),
|
|
60
|
+
size: z.string().regex( /^\d+x\d+$/ ).optional(),
|
|
61
|
+
skills: z.preprocess( v => Array.isArray( v ) ? v : [].concat( v ?? [] ), z.array( z.string().min( 1 ) ) ),
|
|
62
|
+
stopSequences: z.array( z.string() ).optional(),
|
|
63
|
+
temperature: z.number().nonnegative().optional(),
|
|
64
|
+
topK: z.number().nonnegative().optional(),
|
|
65
|
+
topP: z.number().nonnegative().optional(),
|
|
66
|
+
tools: objectMapSchema.optional()
|
|
67
|
+
} ).strict();
|
|
68
|
+
|
|
69
|
+
export const promptSchema = z.object( {
|
|
70
|
+
name: z.string(),
|
|
71
|
+
config: promptConfigSchema,
|
|
72
|
+
messages: z.array(
|
|
73
|
+
z.object( {
|
|
74
|
+
role: z.enum( Object.values( Role ) ),
|
|
75
|
+
content: z.string(),
|
|
76
|
+
providerOptions: objectMapSchema.optional()
|
|
77
|
+
} ).strict()
|
|
78
|
+
),
|
|
79
|
+
instructions: z.string().trim().min( 1 ).nullable().default( null ),
|
|
80
|
+
fileDir: z.string(),
|
|
81
|
+
variables: z.record( z.string(), z.unknown() ).default( {} )
|
|
82
|
+
} ).strict().superRefine( ( prompt, ctx ) => {
|
|
83
|
+
const hasMessages = prompt.messages.length > 0;
|
|
84
|
+
const hasInstructions = !!prompt.instructions;
|
|
85
|
+
const addIssue = message => ctx.addIssue( { code: 'custom', path: [ 'messages', 'instructions' ], message } );
|
|
86
|
+
|
|
87
|
+
if ( !hasMessages && !hasInstructions ) {
|
|
88
|
+
addIssue( 'Prompt must include either message blocks or plain instructions.' );
|
|
89
|
+
} else if ( hasMessages && hasInstructions ) {
|
|
90
|
+
addIssue( 'Prompt cannot include both message blocks and plain instructions.' );
|
|
91
|
+
}
|
|
92
|
+
} ).transform( handleDeprecatedKeys );
|
|
93
|
+
|
|
94
|
+
const promptConfigKeys = new Set( Object.keys( promptConfigSchema.shape ) );
|
|
95
|
+
|
|
96
|
+
/** Converts a string to camel case */
|
|
97
|
+
const toCamelCase = value => value.replace( /_([a-z])/g, ( _, letter ) => letter.toUpperCase() );
|
|
98
|
+
|
|
99
|
+
/** Appends camelCase suggestions to unrecognized top-level config keys written in snake_case. */
|
|
100
|
+
const appendCamelCaseSuggestions = error => {
|
|
101
|
+
for ( const issue of error.issues ) {
|
|
102
|
+
if ( issue.code !== 'unrecognized_keys' || issue.path.length !== 1 || issue.path[0] !== 'config' ) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const suggestions = issue.keys
|
|
107
|
+
.filter( key => key.includes( '_' ) )
|
|
108
|
+
.map( key => ( { key, camel: toCamelCase( key ) } ) )
|
|
109
|
+
.filter( ( { key, camel } ) => camel !== key && promptConfigKeys.has( camel ) )
|
|
110
|
+
.map( ( { key, camel } ) => `"${key}" is not valid; use "${camel}"` );
|
|
111
|
+
|
|
112
|
+
if ( suggestions.length > 0 ) {
|
|
113
|
+
issue.message = `${issue.message}. ${suggestions.join( '. ' )}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const parsePromptSchema = prompt => {
|
|
119
|
+
const result = promptSchema.safeParse( prompt );
|
|
120
|
+
if ( !result.success ) {
|
|
121
|
+
appendCamelCaseSuggestions( result.error );
|
|
122
|
+
|
|
123
|
+
throw new ValidationError(
|
|
124
|
+
`Invalid prompt file ${prompt.name}: ${z.prettifyError( result.error )}`,
|
|
125
|
+
{ cause: result.error }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return result.data;
|
|
129
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { fetchModelsPricing, Freshness } from './models_pricing.js';
|
|
2
|
+
import { BaseAttribute } from '#trace_attribute';
|
|
3
|
+
import { LLMGenerationUsage, LLMGenerationUsageItem } from './usage.js';
|
|
4
|
+
import { GroundingPpmMap } from './grounding.js';
|
|
5
|
+
import { Logger } from '#runtime_logger';
|
|
6
|
+
import Decimal from 'decimal.js';
|
|
7
|
+
|
|
8
|
+
const exists = v => Number.isFinite( v );
|
|
9
|
+
|
|
10
|
+
export class LLMGenerationCostItem {
|
|
11
|
+
static Status = Object.freeze( {
|
|
12
|
+
OK: 'ok',
|
|
13
|
+
FALLBACK: 'fallback',
|
|
14
|
+
MISSING: 'missing'
|
|
15
|
+
} );
|
|
16
|
+
|
|
17
|
+
group;
|
|
18
|
+
label;
|
|
19
|
+
amount;
|
|
20
|
+
ppm;
|
|
21
|
+
total;
|
|
22
|
+
status;
|
|
23
|
+
|
|
24
|
+
constructor( group, label, amount, ppm, total, status ) {
|
|
25
|
+
this.group = group;
|
|
26
|
+
this.label = label;
|
|
27
|
+
this.amount = amount;
|
|
28
|
+
this.ppm = ppm;
|
|
29
|
+
this.total = total;
|
|
30
|
+
this.status = status;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class LLMGenerationCost extends BaseAttribute {
|
|
35
|
+
static TYPE = 'llm:generation:cost';
|
|
36
|
+
static Status = Object.freeze( {
|
|
37
|
+
PRECISE: 'precise',
|
|
38
|
+
IMPRECISE: 'imprecise',
|
|
39
|
+
INCOMPLETE: 'incomplete'
|
|
40
|
+
} );
|
|
41
|
+
providerId;
|
|
42
|
+
modelId;
|
|
43
|
+
input = null;
|
|
44
|
+
output = null;
|
|
45
|
+
tools = null;
|
|
46
|
+
total = null;
|
|
47
|
+
status = LLMGenerationCost.Status.INCOMPLETE;
|
|
48
|
+
pricingFreshness = null;
|
|
49
|
+
items = [];
|
|
50
|
+
|
|
51
|
+
constructor( modelId, providerId, items, usageStatus, pricingFreshness ) {
|
|
52
|
+
super( LLMGenerationCost.TYPE );
|
|
53
|
+
this.modelId = modelId;
|
|
54
|
+
this.providerId = providerId;
|
|
55
|
+
this.items = items;
|
|
56
|
+
this.pricingFreshness = pricingFreshness;
|
|
57
|
+
|
|
58
|
+
const meaningfulItems = items.filter( v => v.amount > 0 );
|
|
59
|
+
|
|
60
|
+
if ( meaningfulItems.some( v => v.status === LLMGenerationCostItem.Status.MISSING ) || usageStatus === LLMGenerationUsage.Status.INCOMPLETE ) {
|
|
61
|
+
this.status = LLMGenerationCost.Status.INCOMPLETE;
|
|
62
|
+
} else if ( meaningfulItems.some( v => v.status === LLMGenerationCostItem.Status.FALLBACK ) || pricingFreshness === Freshness.SNAPSHOT ) {
|
|
63
|
+
this.status = LLMGenerationCost.Status.IMPRECISE;
|
|
64
|
+
} else {
|
|
65
|
+
this.status = LLMGenerationCost.Status.PRECISE;
|
|
66
|
+
}
|
|
67
|
+
const sumGroup = group => {
|
|
68
|
+
const groupItems = items.filter( p => p.group === group && exists( p.total ) );
|
|
69
|
+
return groupItems.length > 0 ? groupItems.reduce( ( s, p ) => s.add( p.total ), Decimal( 0 ) ).toNumber() : null;
|
|
70
|
+
};
|
|
71
|
+
this.input = sumGroup( LLMGenerationUsageItem.Group.INPUT );
|
|
72
|
+
this.output = sumGroup( LLMGenerationUsageItem.Group.OUTPUT );
|
|
73
|
+
this.tools = sumGroup( LLMGenerationUsageItem.Group.TOOLS );
|
|
74
|
+
if ( exists( this.input ) || exists( this.output ) || exists( this.tools ) ) {
|
|
75
|
+
this.total = Decimal( this.input ?? 0 ).add( this.output ?? 0 ).add( this.tools ?? 0 ).toNumber();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const resolveValue = values => {
|
|
81
|
+
if ( exists( values[0] ) ) {
|
|
82
|
+
return { ppm: values[0], status: LLMGenerationCostItem.Status.OK };
|
|
83
|
+
}
|
|
84
|
+
const fallback = values.slice( 1 ).find( exists );
|
|
85
|
+
if ( exists( fallback ) ) {
|
|
86
|
+
return { ppm: fallback, status: LLMGenerationCostItem.Status.FALLBACK };
|
|
87
|
+
}
|
|
88
|
+
return { ppm: null, status: LLMGenerationCostItem.Status.MISSING };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const resolvePrice = ( { group, label, pricing } ) => {
|
|
92
|
+
// Tool charges are never token-priced: models.dev has no rate for them.
|
|
93
|
+
if ( group === LLMGenerationUsageItem.Group.TOOLS ) {
|
|
94
|
+
return resolveValue( [ GroundingPpmMap.get( label ) ] );
|
|
95
|
+
}
|
|
96
|
+
if ( !pricing ) {
|
|
97
|
+
return resolveValue( [] );
|
|
98
|
+
}
|
|
99
|
+
if ( group === LLMGenerationUsageItem.Group.OUTPUT ) {
|
|
100
|
+
const values = [ pricing.output ];
|
|
101
|
+
if ( label === 'reasoning' ) {
|
|
102
|
+
values.unshift( pricing.reasoning );
|
|
103
|
+
}
|
|
104
|
+
return resolveValue( values );
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const values = [ pricing.input ];
|
|
108
|
+
if ( label === 'cache_read' ) {
|
|
109
|
+
values.unshift( pricing.cache_read );
|
|
110
|
+
} else if ( label === 'cache_write' ) {
|
|
111
|
+
values.unshift( pricing.cache_write );
|
|
112
|
+
}
|
|
113
|
+
return resolveValue( values );
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Calculates the cost of an LLM call based on the LLMGenerationUsage.
|
|
118
|
+
*
|
|
119
|
+
* @param {LLMGenerationUsage} usage - Normalized LLM generation usage
|
|
120
|
+
* @returns {Promise<LLMGenerationCost | null>} LLM generation cost with input, output, total and breakdown
|
|
121
|
+
*/
|
|
122
|
+
export const calculateCosts = async usage => {
|
|
123
|
+
const { models, freshness: pricingFreshness } = await fetchModelsPricing();
|
|
124
|
+
|
|
125
|
+
if ( !models ) {
|
|
126
|
+
Logger.warn( 'Failed to fetch models pricing', { namespace: 'LLM' } );
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { providerId, modelId } = usage;
|
|
131
|
+
|
|
132
|
+
const pricing = models.get( `${providerId}/${modelId}` );
|
|
133
|
+
if ( !pricing ) {
|
|
134
|
+
Logger.warn( 'Missing pricing reference for model', { namespace: 'LLM', modelId, providerId } );
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const items = usage.items.map( ( { group, label, amount } ) => {
|
|
138
|
+
const { ppm, status } = resolvePrice( { pricing, group, label } );
|
|
139
|
+
const total = exists( ppm ) ? Decimal( amount ).div( 1_000_000 ).mul( ppm ).toNumber() : null;
|
|
140
|
+
return new LLMGenerationCostItem( group, label, amount, ppm, total, status );
|
|
141
|
+
} );
|
|
142
|
+
|
|
143
|
+
const unrated = items.find( v =>
|
|
144
|
+
v.group === LLMGenerationUsageItem.Group.TOOLS && v.status === LLMGenerationCostItem.Status.MISSING );
|
|
145
|
+
if ( unrated ) {
|
|
146
|
+
Logger.warn( 'Tool call with no rate for model', { namespace: 'LLM', modelId, providerId, type: unrated.label } );
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return new LLMGenerationCost( modelId, providerId, items, usage.status, pricingFreshness );
|
|
150
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as ai from 'ai';
|
|
2
|
+
import { FatalError, TransparentFatalError } from '#errors';
|
|
3
|
+
|
|
4
|
+
const nonRetryableAiSdkErrorTypes = [
|
|
5
|
+
ai.InvalidArgumentError, // Invalid call settings are deterministic caller bugs, so retrying the same activity cannot fix them.
|
|
6
|
+
ai.InvalidDataContentError, // Invalid media content has the wrong local shape/encoding and will fail again with the same input.
|
|
7
|
+
ai.InvalidPromptError, // Invalid prompt structure is a deterministic request-construction error.
|
|
8
|
+
ai.LoadAPIKeyError, // Missing or invalid API key configuration will not change during an activity retry.
|
|
9
|
+
ai.LoadSettingError, // Missing or invalid provider settings are deployment/configuration problems.
|
|
10
|
+
ai.NoImageGeneratedError, // Image generation completed provider calls but collected zero images; repeating identical input is not useful.
|
|
11
|
+
ai.NoSuchProviderError, // A missing provider id is a deterministic provider registry/configuration error.
|
|
12
|
+
ai.NoSuchModelError, // A missing model id is a deterministic provider/model configuration error.
|
|
13
|
+
ai.UnsupportedFunctionalityError // The selected model/output mode does not support the requested feature.
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Maps an AI SDK error to a framework error:
|
|
18
|
+
* - AI SDK Unrecoverable error types become TransparentFatalErrors;
|
|
19
|
+
* - AI SDK API error with isRetryable=false become TransparentFatalErrors;
|
|
20
|
+
* Some errors are not mapped:
|
|
21
|
+
* - Grammar which technically are isRetryable=false, will be rethrown, because they are actually transient;
|
|
22
|
+
* - Other errors are rethrown as well;
|
|
23
|
+
*
|
|
24
|
+
* @param {object} error - Original Error
|
|
25
|
+
* @returns {object} A new Error
|
|
26
|
+
*/
|
|
27
|
+
export const mapAiError = error => {
|
|
28
|
+
if ( !( error instanceof Error ) ) {
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if ( error instanceof FatalError ) {
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isApiError = ai.APICallError.isInstance( error );
|
|
37
|
+
const isGrammarCompilationError = error.message === 'Grammar compilation timed out.';
|
|
38
|
+
|
|
39
|
+
// This error is actually transient, so instead of FatalError, return it
|
|
40
|
+
if ( isApiError && error.statusCode === 400 && isGrammarCompilationError ) {
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Non-retryable API failures are already classified by AI SDK as permanent provider failures.
|
|
45
|
+
if ( isApiError && !error.isRetryable ) {
|
|
46
|
+
return new TransparentFatalError( error );
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if ( nonRetryableAiSdkErrorTypes.some( E => E.isInstance( error ) ) ) {
|
|
50
|
+
return new TransparentFatalError( error );
|
|
51
|
+
}
|
|
52
|
+
return error;
|
|
53
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { FatalError } from '#errors';
|
|
4
|
+
|
|
5
|
+
const scanDir = dir => {
|
|
6
|
+
try {
|
|
7
|
+
return readdirSync( dir, { withFileTypes: true } );
|
|
8
|
+
} catch ( error ) {
|
|
9
|
+
throw new FatalError( `Error scanning directory "${dir}"`, { cause: error } );
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const readFile = path => {
|
|
14
|
+
try {
|
|
15
|
+
return readFileSync( path, 'utf-8' );
|
|
16
|
+
} catch ( error ) {
|
|
17
|
+
throw new FatalError( `Error reading file "${path}"`, { cause: error } );
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Recursively search for a file by name and return both its content and containing directory.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} dir - Directory to search
|
|
25
|
+
* @param {string} name - File name to find
|
|
26
|
+
* @returns {{ content: string, dir: string } | null}
|
|
27
|
+
*/
|
|
28
|
+
export const searchAndReadFile = ( dir, name ) => {
|
|
29
|
+
for ( const entry of scanDir( dir ) ) {
|
|
30
|
+
const path = join( dir, entry.name );
|
|
31
|
+
if ( entry.name === name ) {
|
|
32
|
+
return { dir, content: readFile( path ) };
|
|
33
|
+
}
|
|
34
|
+
if ( entry.isDirectory() && !entry.isSymbolicLink() ) {
|
|
35
|
+
const result = searchAndReadFile( path, name );
|
|
36
|
+
if ( result ) {
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
};
|