@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.
Files changed (62) hide show
  1. package/package.json +58 -6
  2. package/src/helpers/fetch.js +21 -9
  3. package/src/helpers/path.js +36 -0
  4. package/src/helpers/redact.js +2 -1
  5. package/src/http/config.js +10 -0
  6. package/src/http/cost.d.ts +20 -0
  7. package/src/http/cost.js +41 -0
  8. package/src/http/fetch/events.js +10 -0
  9. package/src/http/fetch/index.d.ts +26 -0
  10. package/src/http/fetch/index.js +78 -0
  11. package/src/http/fetch/logger.d.ts +10 -0
  12. package/src/http/fetch/logger.js +56 -0
  13. package/src/http/index.d.ts +27 -0
  14. package/src/http/index.js +9 -0
  15. package/src/http/ky/index.d.ts +25 -0
  16. package/src/http/ky/index.js +10 -0
  17. package/src/http/request_tag.js +30 -0
  18. package/src/llm/agent.js +141 -0
  19. package/src/llm/ai_provider.js +131 -0
  20. package/src/llm/ai_sdk_options.js +126 -0
  21. package/src/llm/consts.js +6 -0
  22. package/src/llm/deprecated_provider_aliases.js +8 -0
  23. package/src/llm/generate.js +88 -0
  24. package/src/llm/index.d.ts +631 -0
  25. package/src/llm/index.js +5 -0
  26. package/src/llm/prompt/content.js +70 -0
  27. package/src/llm/prompt/interpolations.js +58 -0
  28. package/src/llm/prompt/loader.js +67 -0
  29. package/src/llm/prompt/markup/attributes.js +52 -0
  30. package/src/llm/prompt/markup/nodes.js +71 -0
  31. package/src/llm/prompt/markup/tokenizer.js +3 -0
  32. package/src/llm/prompt/markup/tokens.js +26 -0
  33. package/src/llm/prompt/validations.js +129 -0
  34. package/src/llm/utils/cost.js +150 -0
  35. package/src/llm/utils/error_handler.js +53 -0
  36. package/src/llm/utils/file.js +42 -0
  37. package/src/llm/utils/google_vertex_auth.js +31 -0
  38. package/src/llm/utils/grounding.js +49 -0
  39. package/src/llm/utils/image.js +37 -0
  40. package/src/llm/utils/legacy_cost_attribute.js +75 -0
  41. package/src/llm/utils/metering.js +126 -0
  42. package/src/llm/utils/models.js +15 -0
  43. package/src/llm/utils/models_pricing.js +93 -0
  44. package/src/llm/utils/models_pricing_snapshot.json +4166 -0
  45. package/src/llm/utils/skills.js +74 -0
  46. package/src/llm/utils/sources.js +73 -0
  47. package/src/llm/utils/stream.js +75 -0
  48. package/src/llm/utils/tools.js +48 -0
  49. package/src/llm/utils/usage.js +156 -0
  50. package/src/llm/utils/usage_tools.js +23 -0
  51. package/src/llm/utils/wrap.js +277 -0
  52. package/src/llm/validations.js +234 -0
  53. package/src/sdk/helpers/path.d.ts +2 -0
  54. package/src/sdk/helpers/path.js +1 -0
  55. package/src/tracing/trace_attribute.d.ts +0 -17
  56. package/src/tracing/trace_attribute.js +1 -29
  57. package/src/worker/configs.js +23 -4
  58. package/src/worker/index.js +16 -47
  59. package/src/worker/interceptors/activity.js +0 -1
  60. package/src/worker/interceptors/workflow.js +0 -1
  61. package/src/worker/telemetry.js +122 -2
  62. 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 "&lt;${tagName}&gt;".`
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,3 @@
1
+ const tokenizer = /<!--[\s\S]*?-->|<\/\s*[A-Za-z][\w-]*\s*>|<[A-Za-z][\w-]*(?:"[^"]*"|'[^']*'|[^'"<>])*>|<[A-Za-z][\w-]*[^<>]*>|[^<]+|</g;
2
+
3
+ export const tokenize = value => value.match( tokenizer );
@@ -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
+ };