@stacksjs/ai 0.70.53 → 0.70.55

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.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * AI usage tracking (stacksjs/stacks#1878 A-6).
3
+ *
4
+ * Background: `AIResult.usage` returns token counts per-call but
5
+ * nothing aggregates them. Apps that want "this user has spent
6
+ * $X this month" build the aggregation themselves — wiring a
7
+ * listener on every model invocation, persisting the running
8
+ * total, etc.
9
+ *
10
+ * This module ships a singleton recorder that drivers emit to on
11
+ * each completion. Apps install one or more `UsageReporter`
12
+ * functions that get called with `{ provider, model, prompt_tokens,
13
+ * completion_tokens, timestamp, durationMs }` and decide what to
14
+ * do (store to DB, push to Datadog, etc.). Default behavior with
15
+ * no reporter is a no-op — the framework doesn't impose a sink.
16
+ */
17
+
18
+ export interface UsageRecord {
19
+ /** Provider name (e.g. `'openai'`, `'anthropic'`). */
20
+ provider: string
21
+ /** Model id from the response (e.g. `'gpt-4o'`, `'claude-sonnet-4'`). */
22
+ model: string
23
+ /** Prompt-side token count. */
24
+ promptTokens: number
25
+ /** Completion-side token count. */
26
+ completionTokens: number
27
+ /** Total tokens (`promptTokens + completionTokens`). */
28
+ totalTokens: number
29
+ /** Wall-clock duration of the completion in ms. */
30
+ durationMs: number
31
+ /** Wall-clock timestamp the completion finished, in epoch ms. */
32
+ timestamp: number
33
+ /** Optional caller-supplied tag (user id, request id, etc.). */
34
+ metadata?: Record<string, unknown>
35
+ }
36
+
37
+ /**
38
+ * A reporter is called once per recorded completion. Multiple
39
+ * reporters can be installed simultaneously; they fire in
40
+ * registration order. Reporters MUST NOT throw — errors are
41
+ * caught and logged but otherwise ignored so a flaky metrics
42
+ * sink doesn't break the user's completion call.
43
+ */
44
+ export type UsageReporter = (record: UsageRecord) => void | Promise<void>
45
+
46
+ const reporters: UsageReporter[] = []
47
+
48
+ /**
49
+ * Register a usage reporter. Returns an `unregister` callback for
50
+ * apps that want to swap reporters at runtime (test setup/teardown,
51
+ * tenant isolation, etc.).
52
+ */
53
+ export function onUsage(reporter: UsageReporter): () => void {
54
+ reporters.push(reporter)
55
+ return () => {
56
+ const idx = reporters.indexOf(reporter)
57
+ if (idx >= 0) reporters.splice(idx, 1)
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Drop every registered reporter. Useful for tests.
63
+ */
64
+ export function clearUsageReporters(): void {
65
+ reporters.length = 0
66
+ }
67
+
68
+ /**
69
+ * Emit a usage record to every registered reporter. Called by
70
+ * driver completion paths after the response lands. Reporter
71
+ * errors are caught + logged so a misbehaving sink doesn't
72
+ * propagate up to the caller.
73
+ */
74
+ export function recordUsage(record: UsageRecord): void {
75
+ for (const reporter of reporters) {
76
+ try {
77
+ const result = reporter(record)
78
+ if (result && typeof (result as Promise<void>).then === 'function') {
79
+ (result as Promise<void>).catch((err) => {
80
+ // eslint-disable-next-line no-console
81
+ console.error('[ai/usage] reporter rejected:', err)
82
+ })
83
+ }
84
+ }
85
+ catch (err) {
86
+ // eslint-disable-next-line no-console
87
+ console.error('[ai/usage] reporter threw:', err)
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Snapshot the currently-registered reporters. Useful for tests
94
+ * to assert behavior without exposing the internal array.
95
+ */
96
+ export function listUsageReporters(): readonly UsageReporter[] {
97
+ return reporters
98
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Vision message normalization (stacksjs/stacks#1878 A-3).
3
+ *
4
+ * Background: `AIMessage.content` already accepts a content-array
5
+ * with `{ type: 'image_url' }` or `{ type: 'image', source: {...} }`
6
+ * blocks. Both OpenAI and Anthropic accept image inputs but via
7
+ * different wire shapes:
8
+ *
9
+ * - **OpenAI** — content array with `{ type: 'image_url', image_url: { url, detail } }`
10
+ * where `url` is either an https URL or a base64 data URI.
11
+ * - **Anthropic** — content array with `{ type: 'image', source: { type: 'base64', media_type, data } }`
12
+ * for base64 OR `{ type: 'image', source: { type: 'url', url } }` for URL form (Claude 3.5+).
13
+ *
14
+ * Driver users shouldn't have to know which wire format each
15
+ * provider wants. These helpers translate between the two so an
16
+ * app that switches between OpenAI and Anthropic doesn't have to
17
+ * rewrite its message construction.
18
+ */
19
+
20
+ import type { AIMessage, AIMessageContent } from '../types'
21
+
22
+ /**
23
+ * Normalize a message-content block for the OpenAI wire format.
24
+ * Pass-through for `image_url` blocks; converts Anthropic-style
25
+ * `{ type: 'image', source: {...} }` to OpenAI's `image_url` shape.
26
+ */
27
+ function toOpenAIContent(block: AIMessageContent): unknown {
28
+ if (block.type === 'text') return { type: 'text', text: block.text ?? '' }
29
+ if (block.type === 'image_url') return { type: 'image_url', image_url: block.image_url }
30
+ if (block.type === 'image' && block.source) {
31
+ if (block.source.type === 'base64') {
32
+ const dataUri = `data:${block.source.media_type};base64,${block.source.data}`
33
+ return { type: 'image_url', image_url: { url: dataUri } }
34
+ }
35
+ }
36
+ // Unknown block type — pass through and let the API surface the error.
37
+ return block
38
+ }
39
+
40
+ /**
41
+ * Normalize a message-content block for the Anthropic wire format.
42
+ * Pass-through for `image` blocks; converts OpenAI-style `image_url`
43
+ * (either https URL or data URI) into Anthropic's `image` source shape.
44
+ */
45
+ function toAnthropicContent(block: AIMessageContent): unknown {
46
+ if (block.type === 'text') return { type: 'text', text: block.text ?? '' }
47
+ if (block.type === 'image' && block.source)
48
+ return { type: 'image', source: block.source }
49
+ if (block.type === 'image_url' && block.image_url) {
50
+ const url = block.image_url.url
51
+ // data: URIs unwrap to Anthropic's base64 source shape.
52
+ const dataMatch = url.match(/^data:([^;]+);base64,(.+)$/)
53
+ if (dataMatch) {
54
+ return {
55
+ type: 'image',
56
+ source: { type: 'base64', media_type: dataMatch[1]!, data: dataMatch[2]! },
57
+ }
58
+ }
59
+ // Non-data URL — pass through as the url source variant (Claude 3.5+).
60
+ return { type: 'image', source: { type: 'url', url } }
61
+ }
62
+ return block
63
+ }
64
+
65
+ /**
66
+ * Normalize an entire `messages` array for the requested provider.
67
+ * Messages whose `content` is a plain string pass through unchanged.
68
+ * Messages with a content array get each block translated.
69
+ */
70
+ export function normalizeMessagesForProvider(
71
+ messages: AIMessage[],
72
+ provider: 'openai' | 'anthropic',
73
+ ): AIMessage[] {
74
+ return messages.map((msg) => {
75
+ if (typeof msg.content === 'string') return msg
76
+ const mapper = provider === 'openai' ? toOpenAIContent : toAnthropicContent
77
+ return {
78
+ role: msg.role,
79
+ content: msg.content.map(mapper) as AIMessageContent[],
80
+ }
81
+ })
82
+ }
83
+
84
+ /**
85
+ * Convenience: convert a single command + optional image inputs into
86
+ * an `AIMessage` content array suitable for `chat()` calls. Used by
87
+ * the higher-level `text()` / `chat()` helpers when a user passes
88
+ * `{ command, images }` together.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * const content = buildMessageWithImages('What is in this image?', [
93
+ * { url: 'https://example.com/cat.jpg' },
94
+ * ])
95
+ * await chat([{ role: 'user', content }])
96
+ * ```
97
+ */
98
+ export function buildMessageWithImages(
99
+ command: string,
100
+ images: Array<{ url?: string, dataBase64?: string, mediaType?: string, detail?: 'auto' | 'low' | 'high' }>,
101
+ ): AIMessageContent[] {
102
+ const blocks: AIMessageContent[] = []
103
+ for (const img of images) {
104
+ if (img.dataBase64 && img.mediaType) {
105
+ blocks.push({
106
+ type: 'image',
107
+ source: { type: 'base64', media_type: img.mediaType, data: img.dataBase64 },
108
+ })
109
+ }
110
+ else if (img.url) {
111
+ blocks.push({
112
+ type: 'image_url',
113
+ image_url: { url: img.url, detail: img.detail },
114
+ })
115
+ }
116
+ }
117
+ blocks.push({ type: 'text', text: command })
118
+ return blocks
119
+ }