@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,74 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { resolve, basename } from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { ValidationError } from '#errors';
5
+
6
+ /**
7
+ * @typedef Skill
8
+ * @property {string} name
9
+ * @property {string} description
10
+ * @property {string} instructions
11
+ */
12
+
13
+ /**
14
+ * Recursively load skill paths. Explicit files may use any extension; directories load `.md` files only.
15
+ * Frontmatter may provide `name` and `description`; body becomes the instructions.
16
+ *
17
+ * @param {string[]} paths
18
+ * @param {boolean} markdownFilesOnly
19
+ * @returns {Skills[]}
20
+ */
21
+ const recursiveLoadSkillPaths = ( paths, markdownFilesOnly ) => {
22
+ const loaded = [];
23
+ for ( const path of paths ) {
24
+ if ( !existsSync( path ) ) {
25
+ throw new ValidationError( `Skill path "${path}" not found` );
26
+ }
27
+ const stats = lstatSync( path );
28
+ if ( stats.isSymbolicLink() ) {
29
+ continue;
30
+ }
31
+ if ( stats.isDirectory() ) {
32
+ loaded.push( ...recursiveLoadSkillPaths(
33
+ readdirSync( path ).sort().map( f => resolve( path, f ) ),
34
+ true
35
+ ) );
36
+ continue;
37
+ }
38
+ if ( markdownFilesOnly && !path.endsWith( '.md' ) ) {
39
+ continue;
40
+ }
41
+
42
+ const raw = readFileSync( path, 'utf-8' );
43
+ const { data, content } = matter( raw );
44
+ loaded.push( {
45
+ name: data.name ?? basename( path, '.md' ),
46
+ description: data.description ?? basename( path, '.md' ),
47
+ instructions: content.trim()
48
+ } );
49
+ }
50
+ return loaded;
51
+ };
52
+
53
+ /**
54
+ * Recursively load skill files without following symbolic links.
55
+ *
56
+ * @param {string[]} paths
57
+ * @returns {Skills[]}
58
+ */
59
+ export const recursiveLoadSkillFile = paths => recursiveLoadSkillPaths( paths, false );
60
+
61
+ /**
62
+ * Resolve all skills available to a prompt.
63
+ *
64
+ * @param {object} prompt - Loaded prompt object
65
+ * @returns {{ name: string, description: string, instructions: string }[]}
66
+ */
67
+ export const loadSkills = prompt => {
68
+ const baseDir = prompt.fileDir;
69
+
70
+ // skills referenced in the prompt config
71
+ const promptSkillPaths = prompt.config.skills.map( f => resolve( baseDir, f ) );
72
+
73
+ return recursiveLoadSkillFile( promptSkillPaths );
74
+ };
@@ -0,0 +1,73 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Logger } from '#runtime_logger';
3
+
4
+ /** Builds the final source shape */
5
+ const buildSource = ( { url, title } ) => {
6
+ const trimmedUrl = url.trim();
7
+ return {
8
+ type: 'source',
9
+ sourceType: 'url',
10
+ id: createHash( 'sha256' ).update( trimmedUrl ).digest( 'hex' ).slice( 0, 16 ),
11
+ url: trimmedUrl,
12
+ title: title ?? ''
13
+ };
14
+ };
15
+
16
+ /** Return value it is array, otherwise return [] */
17
+ const asArray = v => Array.isArray( v ) ? v : [];
18
+
19
+ const isNonBlankUrl = url => typeof url === 'string' && url.trim().length > 0;
20
+
21
+ /**
22
+ * Extracts source from search tool results embedded in AI SDK step data.
23
+ *
24
+ * Detects any tool result containing a `results[]` array whose items have a `url` string field.
25
+ * This covers perplexitySearch, tavilySearch, exaSearch, and any future tool with the same shape.
26
+ * Deduplicate key is "url"
27
+ *
28
+ * @param {Array} steps - AI SDK response steps (response.steps)
29
+ * @returns {Array<{ key: string, source: object }>}
30
+ */
31
+ const extractSourcesFromSteps = steps =>
32
+ asArray( steps )
33
+ .flatMap( step => asArray( step?.toolResults ) )
34
+ .flatMap( toolResult => asArray( toolResult?.output?.results ) )
35
+ .filter( item => isNonBlankUrl( item?.url ) )
36
+ .map( item => {
37
+ const source = buildSource( item );
38
+ return { key: source.url, source };
39
+ } );
40
+
41
+ /**
42
+ * Extract only the valid sources from the response (has url or id).
43
+ * Deduplicate key is `url` when set, otherwise `id` (document sources have `id` and no `url`).
44
+ *
45
+ * @param {Array} sources
46
+ * @returns {Array<{ key: string, source: object }>}
47
+ */
48
+ const extractValidSourcesFromResponse = sources =>
49
+ asArray( sources )
50
+ .filter( item => item !== null && typeof item === 'object' && ( isNonBlankUrl( item.url ) || typeof item.id === 'string' ) )
51
+ .map( item => ( { key: isNonBlankUrl( item.url ) ? item.url.trim() : item.id, source: item } ) );
52
+
53
+ /** Deduplicate wrapped sources by their key */
54
+ const deduplicateSources = sources => new Map( sources.map( item => [ item.key, item.source ] ) ).values().toArray();
55
+
56
+ /**
57
+ * Extract sources from tools usage and final response, deduplicate, and return.
58
+ * Response sources are preferred over tools when the key matches.
59
+ *
60
+ * @param {object} response AI SDK response
61
+ * @returns {object[]} Merged sources
62
+ */
63
+ export const extractSources = response => {
64
+ try {
65
+ const { steps, sources: sourcesFromResponse } = response ?? {};
66
+ const sourcesFromTools = extractSourcesFromSteps( asArray( steps ) );
67
+ const validSourcesFromResponse = extractValidSourcesFromResponse( sourcesFromResponse );
68
+ return deduplicateSources( sourcesFromTools.concat( validSourcesFromResponse ) );
69
+ } catch ( error ) {
70
+ Logger.error( 'Sources extraction failed', { namespace: 'LLM', error: error?.message || String( error ) } );
71
+ return [];
72
+ }
73
+ };
@@ -0,0 +1,75 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+
3
+ /** How long a failed stream may go quiet before the drain gives up on its remaining parts */
4
+ const FAILURE_DRAIN_TIMEOUT_MS = 250;
5
+
6
+ const TIMED_OUT = Symbol( 'drain-timed-out' );
7
+
8
+ /** Extracts the error an abort or error part should surface, or null for every other part */
9
+ const extractError = ( part, abortSignal ) => {
10
+ if ( part?.type === 'abort' ) {
11
+ const reason = abortSignal?.reason;
12
+ return reason instanceof Error ?
13
+ reason :
14
+ new Error( part.reason ?? 'Streaming aborted.', { cause: reason } );
15
+ }
16
+
17
+ if ( part?.type === 'error' ) {
18
+ return part.error instanceof Error ?
19
+ part.error :
20
+ new Error( part.error ? String( part.error ) : 'Streaming failed.', { cause: part.error } );
21
+ }
22
+
23
+ if ( abortSignal?.aborted ) {
24
+ return abortSignal.reason instanceof Error ?
25
+ abortSignal.reason :
26
+ new Error( 'Streaming aborted.', { cause: abortSignal.reason } );
27
+ }
28
+
29
+ return null;
30
+ };
31
+
32
+ /** Reads the next part, resolving with `TIMED_OUT` when the stream goes quiet for longer than `timeoutMs` */
33
+ const nextWithin = async ( iterator, timeoutMs ) =>
34
+ Promise.race( [ iterator.next(), delay( timeoutMs, TIMED_OUT, { ref: false } ) ] );
35
+
36
+ /**
37
+ * Consumes a streaming result until it completes, then throws the first abort or error part it saw.
38
+ * Callers must not throw from the AI SDK `onError` callback; that errors the stream before these parts are delivered.
39
+ *
40
+ * The draining continues past a failure on purpose: the AI SDK fires its lifecycle callbacks as parts flow through the
41
+ * stream, so `onStepEnd` and `onEnd` - and with them the usage of everything already spent - only arrive if something
42
+ * keeps reading. A stalled provider can leave the stream open forever, so once a failure is captured every read waits
43
+ * at most `FAILURE_DRAIN_TIMEOUT_MS` for the next part, and the drain gives up and throws anyway when none arrives.
44
+ * A healthy stream ends immediately after a failure, so the timeout is not part of the normal path; a successful
45
+ * stream is never timed out.
46
+ *
47
+ * @param {object} stream - AI SDK stream result with `stream`
48
+ * @param {AbortSignal} [abortSignal] - Used to recover the original abort reason
49
+ */
50
+ export const drainStream = async ( stream, abortSignal ) => {
51
+ const iterator = stream.stream[Symbol.asyncIterator]();
52
+ const state = { error: null };
53
+
54
+ while ( true ) {
55
+ const hasDeadline = state.error || abortSignal?.aborted;
56
+ const result = await ( hasDeadline ? nextWithin( iterator, FAILURE_DRAIN_TIMEOUT_MS ) : iterator.next() );
57
+
58
+ if ( result.done ) {
59
+ break;
60
+ }
61
+
62
+ if ( result?.value || abortSignal?.aborted ) {
63
+ state.error ??= extractError( result.value, abortSignal );
64
+ }
65
+
66
+ if ( result === TIMED_OUT ) {
67
+ iterator.return?.()?.catch( () => {} );
68
+ break;
69
+ }
70
+ }
71
+
72
+ if ( state.error ) {
73
+ throw state.error;
74
+ }
75
+ };
@@ -0,0 +1,48 @@
1
+ import { tool as createTool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { ValidationError } from '#errors';
4
+ import { getProvider } from '../ai_provider.js';
5
+
6
+ /**
7
+ * Build the `load_skill` AI SDK tool that the LLM calls to retrieve full skill instructions.
8
+ *
9
+ * @param {Skill[]} skills
10
+ * @returns {import('ai').Tool}
11
+ */
12
+ export const buildLoadSkillTool = skills => createTool( {
13
+ description: 'Get detailed instructions for a named skill',
14
+ inputSchema: z.object( { name: z.string().describe( 'Name of the skill to load' ) } ),
15
+ execute: ( { name } ) => {
16
+ const sk = skills.find( s => s.name === name );
17
+ if ( !sk ) {
18
+ return `Skill "${name}" not found. Available: ${skills.map( s => s.name ).join( ', ' )}`;
19
+ }
20
+ return sk.instructions;
21
+ }
22
+ } );
23
+
24
+ const buildProviderError = ( prompt, suffix ) => `Provider "${prompt.config.provider}" used by prompt "${prompt.name}" does not support ${suffix}.`;
25
+
26
+ /** Load tools from a prompt provider. */
27
+ export const loadPromptTools = prompt => {
28
+ const promptToolNames = Object.keys( prompt.config.tools ?? {} );
29
+ if ( promptToolNames.length === 0 ) {
30
+ return null;
31
+ }
32
+
33
+ const { tools } = getProvider( prompt.config.provider );
34
+ if ( !tools ) {
35
+ throw new ValidationError( buildProviderError( prompt, 'provider-specific tools' ) );
36
+ }
37
+
38
+ const missingTools = promptToolNames.filter( t => !Object.hasOwn( tools, t ) || typeof tools[t] !== 'function' );
39
+ if ( missingTools.length > 0 ) {
40
+ throw new ValidationError( buildProviderError( prompt, `these tools: ${missingTools.join( ', ' )}` ) );
41
+ }
42
+
43
+ const loadedTools = {};
44
+ for ( const name of promptToolNames ) {
45
+ loadedTools[name] = tools[name]( prompt.config.tools[name] );
46
+ }
47
+ return loadedTools;
48
+ };
@@ -0,0 +1,156 @@
1
+ import Decimal from 'decimal.js';
2
+ import { BaseAttribute } from '#trace_attribute';
3
+ import { parseGroundingUsage } from './grounding.js';
4
+ import { extractUsageFromSteps } from './usage_tools.js';
5
+
6
+ const exists = v => Number.isSafeInteger( v ) && v >= 0;
7
+
8
+ const safeSum = ( ...values ) => values.filter( exists ).reduce( ( t, v ) => t + v, 0 );
9
+
10
+ export class LLMGenerationUsageItem {
11
+ static Group = { INPUT: 'input', OUTPUT: 'output', TOOLS: 'tools' };
12
+
13
+ group;
14
+ label;
15
+ amount;
16
+
17
+ constructor( group, label, amount ) {
18
+ this.group = group;
19
+ this.label = label;
20
+ this.amount = amount;
21
+ }
22
+ }
23
+
24
+ export class LLMGenerationUsage extends BaseAttribute {
25
+ static TYPE = 'llm:generation:usage';
26
+
27
+ static Status = Object.freeze( {
28
+ COMPLETE: 'complete',
29
+ INCOMPLETE: 'incomplete'
30
+ } );
31
+
32
+ providerId;
33
+ modelId;
34
+ status = LLMGenerationUsage.Status.INCOMPLETE;
35
+ input = null;
36
+ output = null;
37
+ total = null;
38
+ items = [];
39
+
40
+ constructor( modelId, providerId, items ) {
41
+ super( LLMGenerationUsage.TYPE );
42
+ this.providerId = providerId;
43
+ this.modelId = modelId;
44
+ this.items = items;
45
+ const inputItems = items.filter( p => p.group === LLMGenerationUsageItem.Group.INPUT );
46
+ if ( inputItems.length > 0 ) {
47
+ this.input = inputItems.reduce( ( s, p ) => s.add( p.amount ), Decimal( 0 ) ).toNumber();
48
+ }
49
+ const outputItems = items.filter( p => p.group === LLMGenerationUsageItem.Group.OUTPUT );
50
+ if ( outputItems.length > 0 ) {
51
+ this.output = outputItems.reduce( ( s, p ) => s.add( p.amount ), Decimal( 0 ) ).toNumber();
52
+ }
53
+
54
+ if ( exists( this.input ) || exists( this.output ) ) {
55
+ this.total = ( this.input ?? 0 ) + ( this.output ?? 0 );
56
+ }
57
+ this.status = LLMGenerationUsage.Status[exists( this.input ) && exists( this.output ) ? 'COMPLETE' : 'INCOMPLETE'];
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Returns usage from response or reconstruct it from steps.
63
+ *
64
+ * The token check is load-bearing: aborted and truncated calls still report an aggregate, but with
65
+ * every token count undefined, and the steps are then the only record of what was already spent.
66
+ */
67
+ const resolveUsage = ( { usage, steps } ) => {
68
+ if ( usage && ( exists( usage.inputTokens ) || exists( usage.outputTokens ) ) ) {
69
+ return usage;
70
+ }
71
+ return Array.isArray( steps ) && steps.length > 0 ? extractUsageFromSteps( steps ) : null;
72
+ };
73
+
74
+ /**
75
+ * Converts raw AI SDK usage to LLMGenerationUsage data.
76
+ *
77
+ * Reads `response.usage`, falling back to the sum of `response.steps` usage when the aggregate is
78
+ * missing tokens (truncated or failed streams). Grounding is aggregated from `response.steps`, so a
79
+ * partial `{ steps }` response is accepted when only the steps are known.
80
+ *
81
+ * @param {object} args
82
+ * @param {object} args.prompt - Output prompt with model configuration
83
+ * @param {object} [args.usage] - AI SDK response usage
84
+ * @param {object} [args.steps] - AI SDK response steps
85
+ *
86
+ * @returns {LLMGenerationUsage | null} LLM generation usage with input, output, total and detailed breakdown
87
+ */
88
+ export const parseLLMUsage = ( { prompt, usage: usageArg = null, steps = [] } ) => {
89
+ const usage = resolveUsage( { usage: usageArg, steps } );
90
+ if ( !usage ) {
91
+ return null;
92
+ }
93
+ const { provider: providerId, model: modelId } = prompt.config;
94
+ const { inputTokens, inputTokenDetails, outputTokens, outputTokenDetails } = usage;
95
+ const { noCacheTokens, cacheReadTokens, cacheWriteTokens } = inputTokenDetails ?? {};
96
+ const { textTokens, reasoningTokens } = outputTokenDetails ?? {};
97
+
98
+ const items = [];
99
+
100
+ if ( exists( inputTokens ) ) {
101
+ // the sum of the components must be equal to the tokens
102
+ const useDetails = inputTokens > 0 && safeSum( noCacheTokens, cacheReadTokens, cacheWriteTokens ) === inputTokens;
103
+ const group = LLMGenerationUsageItem.Group.INPUT;
104
+
105
+ if ( useDetails ) {
106
+ if ( exists( noCacheTokens ) ) {
107
+ items.push( new LLMGenerationUsageItem( group, 'no_cache', noCacheTokens ) );
108
+ }
109
+ if ( exists( cacheReadTokens ) ) {
110
+ items.push( new LLMGenerationUsageItem( group, 'cache_read', cacheReadTokens ) );
111
+ }
112
+ if ( exists( cacheWriteTokens ) ) {
113
+ items.push( new LLMGenerationUsageItem( group, 'cache_write', cacheWriteTokens ) );
114
+ }
115
+ } else {
116
+ items.push( new LLMGenerationUsageItem( group, null, inputTokens ) );
117
+ }
118
+ }
119
+
120
+ if ( exists( outputTokens ) ) {
121
+ // the sum of the components must be equal to the tokens
122
+ const useDetails = outputTokens > 0 && safeSum( textTokens, reasoningTokens ) === outputTokens;
123
+ const group = LLMGenerationUsageItem.Group.OUTPUT;
124
+
125
+ if ( useDetails ) {
126
+ if ( exists( textTokens ) ) {
127
+ items.push( new LLMGenerationUsageItem( group, 'text', textTokens ) );
128
+ }
129
+ if ( exists( reasoningTokens ) ) {
130
+ items.push( new LLMGenerationUsageItem( group, 'reasoning', reasoningTokens ) );
131
+ }
132
+ } else {
133
+ items.push( new LLMGenerationUsageItem( group, null, outputTokens ) );
134
+ }
135
+ }
136
+
137
+ // Grounding is billed per step, so aggregate across every step rather than only the final one.
138
+ // Per-query families (Gemini 3) return queries.length per step; per-prompt families (Gemini 2.x)
139
+ // return 1 per grounded step. Summing yields total queries and grounded-step count respectively.
140
+ if ( Array.isArray( steps ) ) {
141
+ const grounding = steps
142
+ .filter( step => step?.providerMetadata )
143
+ .map( step => parseGroundingUsage( modelId, step.providerMetadata ) )
144
+ .filter( Boolean );
145
+ if ( grounding.length > 0 ) {
146
+ const amount = grounding.reduce( ( sum, g ) => sum + g.amount, 0 );
147
+ items.push( new LLMGenerationUsageItem( LLMGenerationUsageItem.Group.TOOLS, grounding[0].label, amount ) );
148
+ }
149
+ }
150
+
151
+ if ( items.length === 0 ) {
152
+ return null;
153
+ }
154
+
155
+ return new LLMGenerationUsage( modelId, providerId, items );
156
+ };
@@ -0,0 +1,23 @@
1
+ const sum = ( a, b ) => {
2
+ if ( Number.isFinite( a ) ) {
3
+ return a + ( Number.isFinite( b ) ? b : 0 );
4
+ } else {
5
+ return Number.isFinite( b ) ? b : undefined;
6
+ }
7
+ };
8
+
9
+ const isPlainObject = value => value !== null && typeof value === 'object';
10
+
11
+ /** Deep numeric merge of two AI SDK usage objects; `raw` is provider-shaped and never summed */
12
+ const mergeUsage = ( total, usage ) => Object.entries( usage )
13
+ .filter( ( [ key ] ) => key !== 'raw' )
14
+ .reduce( ( merged, [ key, value ] ) => ( {
15
+ ...merged,
16
+ [key]: isPlainObject( value ) ? mergeUsage( merged[key] ?? {}, value ) : sum( merged[key], value )
17
+ } ), total );
18
+
19
+ /** Sums per-step AI SDK usage */
20
+ export const extractUsageFromSteps = steps => steps
21
+ .map( step => step?.usage )
22
+ .filter( Boolean )
23
+ .reduce( ( total, usage ) => mergeUsage( total, usage ), {} );