@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.
- package/package.json +3 -2
- package/src/agents/claude/index.ts +310 -0
- package/src/agents/index.ts +7 -0
- package/src/buddy.ts +619 -0
- package/src/drivers/anthropic/index.ts +430 -0
- package/src/drivers/claude-agent-sdk/index.ts +370 -0
- package/src/drivers/index.ts +14 -0
- package/src/drivers/ollama/index.ts +514 -0
- package/src/drivers/openai/index.ts +529 -0
- package/src/image.ts +607 -0
- package/src/index.ts +53 -0
- package/src/mcp.ts +658 -0
- package/src/personalization.ts +490 -0
- package/src/search.ts +555 -0
- package/src/text.ts +79 -0
- package/src/types.ts +229 -0
- package/src/utils/client-bedrock-runtime.ts +51 -0
- package/src/utils/client-bedrock.ts +75 -0
- package/src/utils/model-access.ts +32 -0
- package/src/utils/retry.ts +124 -0
- package/src/utils/tokens.ts +159 -0
- package/src/utils/usage.ts +98 -0
- package/src/utils/vision.ts +119 -0
|
@@ -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
|
+
}
|