@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
package/src/types.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Module Types
|
|
3
|
+
*
|
|
4
|
+
* Shared type definitions for AI drivers and agents.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface AIMessage {
|
|
8
|
+
role: 'user' | 'assistant' | 'system'
|
|
9
|
+
content: string | AIMessageContent[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AIMessageContent {
|
|
13
|
+
type: 'text' | 'image_url' | 'image'
|
|
14
|
+
text?: string
|
|
15
|
+
image_url?: { url: string, detail?: 'auto' | 'low' | 'high' }
|
|
16
|
+
source?: { type: 'base64', media_type: string, data: string }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AIDriver {
|
|
20
|
+
name: string
|
|
21
|
+
process: (command: string, context: string, history: AIMessage[]) => Promise<string>
|
|
22
|
+
stream?: (command: string, context: string, history: AIMessage[]) => AsyncGenerator<string>
|
|
23
|
+
embed?: (input: string | string[]) => Promise<number[] | number[][]>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AIDriverConfig {
|
|
27
|
+
apiKey?: string
|
|
28
|
+
baseUrl?: string
|
|
29
|
+
model?: string
|
|
30
|
+
maxTokens?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StreamingResult {
|
|
34
|
+
stream: ReadableStream<Uint8Array>
|
|
35
|
+
fullResponse: Promise<string>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EmbeddingResult {
|
|
39
|
+
embedding: number[]
|
|
40
|
+
index: number
|
|
41
|
+
object: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EmbeddingsResponse {
|
|
45
|
+
data: EmbeddingResult[]
|
|
46
|
+
model: string
|
|
47
|
+
usage: {
|
|
48
|
+
prompt_tokens: number
|
|
49
|
+
total_tokens: number
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Tool / function definition that the model can call back into.
|
|
55
|
+
* Cross-provider shape: OpenAI's `tools[]` and Anthropic's `tools[]`
|
|
56
|
+
* map to this same structure via the JSON Schema for parameters.
|
|
57
|
+
*/
|
|
58
|
+
export interface AITool {
|
|
59
|
+
name: string
|
|
60
|
+
description?: string
|
|
61
|
+
/** JSON Schema for the tool's input. */
|
|
62
|
+
parameters?: Record<string, unknown>
|
|
63
|
+
/** OpenAI-only `tool_choice` semantics: 'auto' (default), 'required', or { name }. */
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Structured-output / JSON-mode response format. Modeled after
|
|
68
|
+
* OpenAI's `response_format` but the Anthropic driver maps it to
|
|
69
|
+
* the tools-as-json pattern internally (stacksjs/stacks#1878 A-1).
|
|
70
|
+
*/
|
|
71
|
+
export type AIResponseFormat =
|
|
72
|
+
| { type: 'text' }
|
|
73
|
+
| { type: 'json_object' }
|
|
74
|
+
| {
|
|
75
|
+
type: 'json_schema'
|
|
76
|
+
json_schema: {
|
|
77
|
+
name: string
|
|
78
|
+
schema: Record<string, unknown>
|
|
79
|
+
strict?: boolean
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ChatCompletionOptions {
|
|
84
|
+
model?: string
|
|
85
|
+
maxTokens?: number
|
|
86
|
+
temperature?: number
|
|
87
|
+
topP?: number
|
|
88
|
+
stop?: string | string[]
|
|
89
|
+
stream?: boolean
|
|
90
|
+
/**
|
|
91
|
+
* Tools / functions the model can call (stacksjs/stacks#1878 A-1).
|
|
92
|
+
* OpenAI threads as `tools` directly; Anthropic threads as
|
|
93
|
+
* `tools` (Claude 3.5+) — the cross-driver shape is the same.
|
|
94
|
+
*/
|
|
95
|
+
tools?: AITool[]
|
|
96
|
+
/**
|
|
97
|
+
* Force the model to call a specific tool, or any tool, or no
|
|
98
|
+
* tool. OpenAI semantics. Anthropic supports the same via
|
|
99
|
+
* `tool_choice` field in Messages API.
|
|
100
|
+
*/
|
|
101
|
+
toolChoice?: 'auto' | 'required' | 'none' | { name: string }
|
|
102
|
+
/**
|
|
103
|
+
* Force structured output (stacksjs/stacks#1878 A-1).
|
|
104
|
+
* - `{ type: 'text' }` → freeform (the default)
|
|
105
|
+
* - `{ type: 'json_object' }` → guaranteed JSON, schema not enforced
|
|
106
|
+
* - `{ type: 'json_schema', json_schema: {...} }` → JSON matching schema
|
|
107
|
+
*/
|
|
108
|
+
responseFormat?: AIResponseFormat
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface AIResult {
|
|
112
|
+
content: string
|
|
113
|
+
model: string
|
|
114
|
+
usage?: {
|
|
115
|
+
promptTokens: number
|
|
116
|
+
completionTokens: number
|
|
117
|
+
totalTokens: number
|
|
118
|
+
}
|
|
119
|
+
finishReason?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ClaudeAPIResponse {
|
|
123
|
+
content: Array<{ type: string, text: string }>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface OpenAIAPIResponse {
|
|
127
|
+
choices: Array<{ message: { content: string } }>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface OllamaAPIResponse {
|
|
131
|
+
message: { content: string }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ClaudeStreamEvent {
|
|
135
|
+
type: string
|
|
136
|
+
subtype?: string
|
|
137
|
+
message?: {
|
|
138
|
+
content: Array<{
|
|
139
|
+
type: string
|
|
140
|
+
text?: string
|
|
141
|
+
name?: string
|
|
142
|
+
input?: Record<string, unknown>
|
|
143
|
+
}>
|
|
144
|
+
}
|
|
145
|
+
delta?: { text?: string }
|
|
146
|
+
result?: string
|
|
147
|
+
// Content block events for better streaming
|
|
148
|
+
index?: number
|
|
149
|
+
content_block?: {
|
|
150
|
+
type: string
|
|
151
|
+
text?: string
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Buddy Types
|
|
156
|
+
export interface RepoState {
|
|
157
|
+
path: string
|
|
158
|
+
name: string
|
|
159
|
+
branch: string
|
|
160
|
+
hasChanges: boolean
|
|
161
|
+
lastCommit?: string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface GitHubCredentials {
|
|
165
|
+
token: string
|
|
166
|
+
username: string
|
|
167
|
+
name: string
|
|
168
|
+
email: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface BuddyState {
|
|
172
|
+
repo: RepoState | null
|
|
173
|
+
conversationHistory: AIMessage[]
|
|
174
|
+
currentDriver: string
|
|
175
|
+
github: GitHubCredentials | null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface BuddyConfig {
|
|
179
|
+
workDir: string
|
|
180
|
+
commitMessage: string
|
|
181
|
+
ollamaHost: string
|
|
182
|
+
ollamaModel: string
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface BuddyApiKeys {
|
|
186
|
+
anthropic?: string
|
|
187
|
+
openai?: string
|
|
188
|
+
claudeCliHost?: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Image types
|
|
192
|
+
export interface ImageGenerationConfig {
|
|
193
|
+
provider: 'openai'
|
|
194
|
+
model?: string
|
|
195
|
+
apiKey?: string
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Search/RAG types
|
|
199
|
+
export interface SearchConfig {
|
|
200
|
+
embeddingProvider: 'openai' | 'ollama'
|
|
201
|
+
embeddingModel?: string
|
|
202
|
+
generationProvider?: 'anthropic' | 'openai' | 'ollama'
|
|
203
|
+
generationModel?: string
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// MCP types
|
|
207
|
+
export interface MCPConfig {
|
|
208
|
+
servers: Array<{
|
|
209
|
+
name: string
|
|
210
|
+
command?: string
|
|
211
|
+
args?: string[]
|
|
212
|
+
url?: string
|
|
213
|
+
env?: Record<string, string>
|
|
214
|
+
}>
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// AI module config (used by @stacksjs/config)
|
|
218
|
+
export interface AIConfig {
|
|
219
|
+
default?: string
|
|
220
|
+
models?: string[]
|
|
221
|
+
drivers?: {
|
|
222
|
+
anthropic?: AIDriverConfig & { anthropicVersion?: string }
|
|
223
|
+
openai?: AIDriverConfig & { embeddingModel?: string }
|
|
224
|
+
ollama?: AIDriverConfig & { host?: string; embeddingModel?: string }
|
|
225
|
+
}
|
|
226
|
+
image?: ImageGenerationConfig
|
|
227
|
+
search?: SearchConfig
|
|
228
|
+
mcp?: MCPConfig
|
|
229
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InvokeModelCommandInput,
|
|
3
|
+
InvokeModelCommandOutput,
|
|
4
|
+
InvokeModelWithResponseStreamCommandInput,
|
|
5
|
+
InvokeModelWithResponseStreamCommandOutput,
|
|
6
|
+
} from '@stacksjs/ts-cloud/aws'
|
|
7
|
+
import process from 'node:process'
|
|
8
|
+
|
|
9
|
+
// Lazy-load the runtime BedrockRuntimeClient — see client-bedrock.ts
|
|
10
|
+
// for the rationale (ts-cloud's /aws subpath ships types but not always
|
|
11
|
+
// the JS bundle, and an eager top-level import takes the whole API
|
|
12
|
+
// server down at boot).
|
|
13
|
+
let _client: any | null = null
|
|
14
|
+
async function getClient(): Promise<any> {
|
|
15
|
+
if (_client)
|
|
16
|
+
return _client
|
|
17
|
+
const mod: any = await import('@stacksjs/ts-cloud/aws')
|
|
18
|
+
if (!mod?.BedrockRuntimeClient) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'@stacksjs/ts-cloud/aws does not export BedrockRuntimeClient — rebuild ts-cloud or remove the AI dependency.',
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
_client = new mod.BedrockRuntimeClient(process.env.REGION || 'us-east-1')
|
|
24
|
+
return _client
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* Invoke Model
|
|
29
|
+
* @param {InvokeModelCommandInput} params
|
|
30
|
+
* @returns {Promise<InvokeModelCommandOutput>}
|
|
31
|
+
* @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/BedrockRuntime.html#invokeModel-property
|
|
32
|
+
*/
|
|
33
|
+
export async function invokeModel(params: InvokeModelCommandInput): Promise<InvokeModelCommandOutput> {
|
|
34
|
+
const c = await getClient()
|
|
35
|
+
return c.invokeModel(params)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* Invoke Model With Response Stream
|
|
40
|
+
* @param {InvokeModelWithResponseStreamCommandInput} params
|
|
41
|
+
* @returns {Promise<InvokeModelWithResponseStreamCommandOutput>}
|
|
42
|
+
* @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/BedrockRuntime.html#invokeModelWithResponseStream-property
|
|
43
|
+
*/
|
|
44
|
+
export async function invokeModelWithResponseStream(
|
|
45
|
+
params: InvokeModelWithResponseStreamCommandInput,
|
|
46
|
+
): Promise<InvokeModelWithResponseStreamCommandOutput> {
|
|
47
|
+
const c = await getClient()
|
|
48
|
+
return c.invokeModelWithResponseStream(params)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type { InvokeModelCommandInput, InvokeModelWithResponseStreamCommandInput }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateModelCustomizationJobCommandInput,
|
|
3
|
+
CreateModelCustomizationJobCommandOutput,
|
|
4
|
+
GetModelCustomizationJobCommandInput,
|
|
5
|
+
GetModelCustomizationJobCommandOutput,
|
|
6
|
+
ListFoundationModelsCommandInput,
|
|
7
|
+
ListFoundationModelsCommandOutput,
|
|
8
|
+
} from '@stacksjs/ts-cloud/aws'
|
|
9
|
+
import process from 'node:process'
|
|
10
|
+
|
|
11
|
+
// Lazy-load the runtime BedrockClient. `@stacksjs/ts-cloud/aws` ships the
|
|
12
|
+
// type declarations but the JS bundle for that subpath isn't always
|
|
13
|
+
// present (we hit this on `bun install` from a registry where the package
|
|
14
|
+
// hadn't published the /aws bundle). Importing eagerly with a top-level
|
|
15
|
+
// `import { BedrockClient } from ...` crashes the entire API server at
|
|
16
|
+
// boot. With this shim, modules that don't actually call the helpers
|
|
17
|
+
// below load fine, and callers who do use Bedrock get a clear error.
|
|
18
|
+
let _client: any | null = null
|
|
19
|
+
async function getClient(): Promise<any> {
|
|
20
|
+
if (_client)
|
|
21
|
+
return _client
|
|
22
|
+
const mod: any = await import('@stacksjs/ts-cloud/aws')
|
|
23
|
+
if (!mod?.BedrockClient) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'@stacksjs/ts-cloud/aws does not export BedrockClient — rebuild ts-cloud or remove the AI dependency.',
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
_client = new mod.BedrockClient(process.env.REGION || 'us-east-1')
|
|
29
|
+
return _client
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* Create Model Customization Job
|
|
34
|
+
* @param {CreateModelCustomizationJobCommandInput} params
|
|
35
|
+
* @returns {Promise<CreateModelCustomizationJobCommandOutput>}
|
|
36
|
+
* @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Bedrock.html#CreateModelCustomizationJob-property
|
|
37
|
+
*/
|
|
38
|
+
export async function createModelCustomizationJob(
|
|
39
|
+
param: CreateModelCustomizationJobCommandInput,
|
|
40
|
+
): Promise<CreateModelCustomizationJobCommandOutput> {
|
|
41
|
+
const client = await getClient()
|
|
42
|
+
return client.createModelCustomizationJob(param)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* Get Model Customization Job
|
|
47
|
+
* @param {GetModelCustomizationJobCommandInput} params
|
|
48
|
+
* @returns {Promise<GetModelCustomizationJobCommandOutput>}
|
|
49
|
+
* @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Bedrock.html#getModelCustomizationJob-property
|
|
50
|
+
*/
|
|
51
|
+
export async function getModelCustomizationJob(
|
|
52
|
+
params: GetModelCustomizationJobCommandInput,
|
|
53
|
+
): Promise<GetModelCustomizationJobCommandOutput> {
|
|
54
|
+
const client = await getClient()
|
|
55
|
+
return client.getModelCustomizationJob(params)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* List Foundation Models
|
|
60
|
+
* @param {ListFoundationModelsCommandInput} params
|
|
61
|
+
* @returns {Promise<ListFoundationModelsCommandOutput>}
|
|
62
|
+
* @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Bedrock.html#listFoundationModels-property
|
|
63
|
+
*/
|
|
64
|
+
export async function listFoundationModels(
|
|
65
|
+
params: ListFoundationModelsCommandInput,
|
|
66
|
+
): Promise<ListFoundationModelsCommandOutput> {
|
|
67
|
+
const client = await getClient()
|
|
68
|
+
return client.listFoundationModels(params)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type {
|
|
72
|
+
CreateModelCustomizationJobCommandInput,
|
|
73
|
+
GetModelCustomizationJobCommandInput,
|
|
74
|
+
ListFoundationModelsCommandInput,
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { log } from '@stacksjs/cli'
|
|
2
|
+
import { ai } from '@stacksjs/config'
|
|
3
|
+
|
|
4
|
+
// Lazy-load ts-cloud/aws — see client-bedrock.ts for context.
|
|
5
|
+
async function getBedrockClient(): Promise<any> {
|
|
6
|
+
const mod: any = await import('@stacksjs/ts-cloud/aws')
|
|
7
|
+
if (!mod?.BedrockClient) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
'@stacksjs/ts-cloud/aws does not export BedrockClient — rebuild ts-cloud or remove the AI dependency.',
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
return new mod.BedrockClient('us-east-1')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function requestModelAccess(): Promise<void> {
|
|
16
|
+
const client = await getBedrockClient()
|
|
17
|
+
|
|
18
|
+
const models = ai.models
|
|
19
|
+
if (!models)
|
|
20
|
+
throw new Error('No AI models found. Please set ./config/ai.ts values.')
|
|
21
|
+
|
|
22
|
+
for (const model of models) {
|
|
23
|
+
try {
|
|
24
|
+
log.info(`Requesting access to model ${model}`)
|
|
25
|
+
const data = await client.requestModelAccess({ modelId: model })
|
|
26
|
+
log.info(`Response for model ${model}:`, data)
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
log.error(`Error requesting access to model ${model}:`, error)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry helper for AI driver HTTP calls (stacksjs/stacks#1878 A-5).
|
|
3
|
+
*
|
|
4
|
+
* Background: OpenAI / Anthropic / etc. routinely return 429 (rate
|
|
5
|
+
* limit) and 5xx (capacity / overloaded) responses with a
|
|
6
|
+
* `Retry-After` header indicating when the caller should try again.
|
|
7
|
+
* The pre-fix AI drivers threw immediately on any non-2xx, surfacing
|
|
8
|
+
* transient capacity issues as hard user-facing failures.
|
|
9
|
+
*
|
|
10
|
+
* This helper wraps `fetch()` with:
|
|
11
|
+
* - Honor `Retry-After` (seconds or HTTP-date) for 429 + 503
|
|
12
|
+
* - Exponential backoff + jitter for other 5xx
|
|
13
|
+
* - Cap at `maxRetries` attempts (default 3)
|
|
14
|
+
* - Surface the final non-recoverable response to the caller
|
|
15
|
+
*
|
|
16
|
+
* No retry on 4xx other than 429 — those are caller bugs (bad API
|
|
17
|
+
* key, malformed request) that won't clear with more retries.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Configurable retry policy. Defaults are tuned for the typical
|
|
22
|
+
* "Anthropic returned 429, try again in 3s" case without being so
|
|
23
|
+
* aggressive that a permanent outage hangs the request loop for
|
|
24
|
+
* minutes.
|
|
25
|
+
*/
|
|
26
|
+
export interface RetryConfig {
|
|
27
|
+
/** Max retry attempts (NOT including the initial call). Default: 3. */
|
|
28
|
+
maxRetries?: number
|
|
29
|
+
/** Base delay in ms for exponential backoff. Default: 500. */
|
|
30
|
+
baseDelayMs?: number
|
|
31
|
+
/** Cap on any single delay in ms. Default: 30s. */
|
|
32
|
+
maxDelayMs?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_RETRY: Required<RetryConfig> = {
|
|
36
|
+
maxRetries: 3,
|
|
37
|
+
baseDelayMs: 500,
|
|
38
|
+
maxDelayMs: 30_000,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fetch with automatic retry on 429 / 5xx. Returns the final
|
|
43
|
+
* `Response` — the caller checks `.ok` and parses the body as usual.
|
|
44
|
+
*
|
|
45
|
+
* Does NOT retry network-level errors (connection reset, DNS
|
|
46
|
+
* failure) — those throw synchronously and the caller's existing
|
|
47
|
+
* try/catch handles them.
|
|
48
|
+
*/
|
|
49
|
+
export async function fetchWithRetry(
|
|
50
|
+
input: RequestInfo | URL,
|
|
51
|
+
init?: RequestInit,
|
|
52
|
+
config: RetryConfig = {},
|
|
53
|
+
): Promise<Response> {
|
|
54
|
+
const cfg = { ...DEFAULT_RETRY, ...config }
|
|
55
|
+
let lastResponse: Response | undefined
|
|
56
|
+
|
|
57
|
+
for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) {
|
|
58
|
+
lastResponse = await fetch(input, init)
|
|
59
|
+
|
|
60
|
+
// 2xx — done.
|
|
61
|
+
if (lastResponse.ok) return lastResponse
|
|
62
|
+
|
|
63
|
+
// Don't retry client errors except 429 (rate limit).
|
|
64
|
+
if (lastResponse.status < 500 && lastResponse.status !== 429)
|
|
65
|
+
return lastResponse
|
|
66
|
+
|
|
67
|
+
// Out of retries — return whatever we got so the caller can
|
|
68
|
+
// surface a meaningful error.
|
|
69
|
+
if (attempt === cfg.maxRetries) return lastResponse
|
|
70
|
+
|
|
71
|
+
// Decide how long to wait before the next attempt.
|
|
72
|
+
const retryAfterMs = parseRetryAfter(lastResponse.headers.get('Retry-After'))
|
|
73
|
+
const backoff = retryAfterMs ?? exponentialBackoff(attempt, cfg)
|
|
74
|
+
const delay = Math.min(backoff, cfg.maxDelayMs)
|
|
75
|
+
|
|
76
|
+
// Drain the body so the connection can be reused (Bun
|
|
77
|
+
// sometimes warns about un-consumed bodies otherwise).
|
|
78
|
+
try {
|
|
79
|
+
await lastResponse.text().catch(() => {})
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// ignore
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await new Promise(resolve => setTimeout(resolve, delay))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Loop always returns inside; this is unreachable.
|
|
89
|
+
return lastResponse!
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse a `Retry-After` header. Accepts either a delta-seconds
|
|
94
|
+
* integer (`'3'`) or an HTTP-date (`'Wed, 21 Oct 2026 07:28:00 GMT'`).
|
|
95
|
+
* Returns null if the header is missing or unparseable.
|
|
96
|
+
*/
|
|
97
|
+
function parseRetryAfter(header: string | null): number | null {
|
|
98
|
+
if (!header) return null
|
|
99
|
+
// Delta-seconds form.
|
|
100
|
+
const seconds = Number.parseInt(header, 10)
|
|
101
|
+
if (Number.isFinite(seconds) && String(seconds) === header.trim()) {
|
|
102
|
+
return Math.max(0, seconds * 1000)
|
|
103
|
+
}
|
|
104
|
+
// HTTP-date form.
|
|
105
|
+
const dateMs = Date.parse(header)
|
|
106
|
+
if (Number.isFinite(dateMs)) {
|
|
107
|
+
return Math.max(0, dateMs - Date.now())
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Exponential backoff with full jitter. `attempt` is 0-indexed, so
|
|
114
|
+
* the first retry uses `base * 2^0 * rand` = up to base ms; the
|
|
115
|
+
* second uses up to 2*base ms; the third up to 4*base ms; etc.
|
|
116
|
+
*
|
|
117
|
+
* Full jitter (vs equal jitter) is what AWS recommends — spreads
|
|
118
|
+
* herding traffic better when many clients retry the same upstream
|
|
119
|
+
* after a brief outage.
|
|
120
|
+
*/
|
|
121
|
+
function exponentialBackoff(attempt: number, cfg: Required<RetryConfig>): number {
|
|
122
|
+
const cap = Math.min(cfg.baseDelayMs * 2 ** attempt, cfg.maxDelayMs)
|
|
123
|
+
return Math.random() * cap
|
|
124
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-counting + prompt-sanitization utils (stacksjs/stacks#1878 A-7).
|
|
3
|
+
*
|
|
4
|
+
* These are heuristic helpers — not exact-tokenizer replacements.
|
|
5
|
+
* Real exact counts come from the provider's tokenizer (tiktoken
|
|
6
|
+
* for OpenAI, anthropic's tokenizer endpoint). Both are heavy
|
|
7
|
+
* dependencies / network calls; this module ships a quick estimate
|
|
8
|
+
* that's good enough for cost projection and prompt-budget checks
|
|
9
|
+
* without paying the install/runtime cost.
|
|
10
|
+
*
|
|
11
|
+
* Calibration: empirical "chars-per-token" ratios from each
|
|
12
|
+
* provider's documentation. English prose hits ~4 chars/token on
|
|
13
|
+
* average; code and JSON skew lower (~3.5). The estimator picks a
|
|
14
|
+
* conservative ratio per model family so estimates over-count
|
|
15
|
+
* slightly — better to budget too tight than blow your context.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Rough chars-per-token for a model. Tightened down to the
|
|
20
|
+
* conservative end of each provider's published range so apps
|
|
21
|
+
* pre-checking against the model's context window don't get
|
|
22
|
+
* blindsided by a slightly higher actual count.
|
|
23
|
+
*/
|
|
24
|
+
function charsPerToken(model: string): number {
|
|
25
|
+
const m = model.toLowerCase()
|
|
26
|
+
// GPT-4 / GPT-4o / GPT-5: ~4 chars/token average; conservative 3.5.
|
|
27
|
+
if (m.startsWith('gpt-')) return 3.5
|
|
28
|
+
// Claude family: roughly 4 chars/token; conservative 3.5.
|
|
29
|
+
if (m.startsWith('claude')) return 3.5
|
|
30
|
+
// Ollama / local models vary widely; fall through to default.
|
|
31
|
+
// Default: 3.5 — undercounts long English prose slightly, which
|
|
32
|
+
// is the safer direction.
|
|
33
|
+
return 3.5
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Estimate the number of tokens in `text` for the given model.
|
|
38
|
+
* Heuristic only — for exact counts, use the provider's tokenizer.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* if (estimateTokens(prompt, 'gpt-4o') > 100_000) {
|
|
43
|
+
* throw new Error('prompt too long; consider chunking')
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function estimateTokens(text: string, model: string = 'gpt-4o'): number {
|
|
48
|
+
if (!text) return 0
|
|
49
|
+
const ratio = charsPerToken(model)
|
|
50
|
+
// Add 1 to the divisor so very short strings round up to a
|
|
51
|
+
// non-zero estimate (single-char inputs still cost a token).
|
|
52
|
+
return Math.max(1, Math.ceil(text.length / ratio))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Estimate total tokens for a chat-completion request: sum of
|
|
57
|
+
* every message's content plus a fixed per-message overhead
|
|
58
|
+
* (matches the rough "+4 per message + 2 for the conversation"
|
|
59
|
+
* heuristic OpenAI's docs publish).
|
|
60
|
+
*/
|
|
61
|
+
export function estimateMessageTokens(
|
|
62
|
+
messages: Array<{ role: string, content: string | unknown }>,
|
|
63
|
+
model: string = 'gpt-4o',
|
|
64
|
+
): number {
|
|
65
|
+
const PER_MESSAGE_OVERHEAD = 4
|
|
66
|
+
const CONVERSATION_OVERHEAD = 2
|
|
67
|
+
let total = CONVERSATION_OVERHEAD
|
|
68
|
+
for (const msg of messages) {
|
|
69
|
+
total += PER_MESSAGE_OVERHEAD
|
|
70
|
+
if (typeof msg.content === 'string') {
|
|
71
|
+
total += estimateTokens(msg.content, model)
|
|
72
|
+
}
|
|
73
|
+
else if (Array.isArray(msg.content)) {
|
|
74
|
+
// Multi-modal content arrays — count text blocks. Image blocks
|
|
75
|
+
// are model-specific (Anthropic ~85+ tokens per image, OpenAI
|
|
76
|
+
// tile-based); the heuristic adds 100 tokens per image as a
|
|
77
|
+
// round-number proxy. Apps that need real counts pre-flight via
|
|
78
|
+
// the provider's tokenizer.
|
|
79
|
+
for (const block of msg.content as Array<{ type: string, text?: string }>) {
|
|
80
|
+
if (block.type === 'text' && block.text)
|
|
81
|
+
total += estimateTokens(block.text, model)
|
|
82
|
+
else if (block.type === 'image' || block.type === 'image_url')
|
|
83
|
+
total += 100
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return total
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Patterns that match common prompt-injection / jailbreak attempts.
|
|
92
|
+
* NOT exhaustive — adversarial input is an open research problem.
|
|
93
|
+
* This catches the most common patterns ("ignore previous instructions",
|
|
94
|
+
* etc.) so apps have a cheap first-line defense; layered defenses
|
|
95
|
+
* (system-prompt isolation, output-side guards) are still required.
|
|
96
|
+
*/
|
|
97
|
+
const INJECTION_PATTERNS: RegExp[] = [
|
|
98
|
+
/\bignore\s+(?:all\s+)?(?:previous|prior|above)\s+instructions?\b/i,
|
|
99
|
+
/\bdisregard\s+(?:all\s+)?(?:previous|prior|above)\b/i,
|
|
100
|
+
/\bforget\s+(?:everything|all)\s+(?:you|i)\b/i,
|
|
101
|
+
/\byou\s+are\s+now\s+a\s+\w+/i, // "you are now a pirate AI"
|
|
102
|
+
/\bnew\s+instructions?:\s*/i,
|
|
103
|
+
/\bsystem\s*[:>]\s*/i, // injection attempts framed as system prompts
|
|
104
|
+
/\b(?:reveal|show|print|output|display)\s+(?:your|the)\s+(?:system\s+)?prompt\b/i,
|
|
105
|
+
/<\s*\/?\s*system\s*>/i, // pseudo-system XML tags
|
|
106
|
+
/\[INST\]|\[\/INST\]/i, // llama-instruct framing
|
|
107
|
+
/^\s*###\s+(?:instruction|system)/im,
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
export interface SanitizeResult {
|
|
111
|
+
/** True when no injection patterns matched. */
|
|
112
|
+
ok: boolean
|
|
113
|
+
/** List of pattern descriptions that matched (empty when ok). */
|
|
114
|
+
matched: string[]
|
|
115
|
+
/** The input, with matched patterns replaced by `[redacted]`. */
|
|
116
|
+
cleaned: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Inspect `text` for common prompt-injection patterns. Returns
|
|
121
|
+
* `{ ok, matched, cleaned }`. Apps decide what to do with the
|
|
122
|
+
* result — reject the request (`if (!result.ok) throw...`), pass
|
|
123
|
+
* the cleaned text on (`useText(result.cleaned)`), or just log
|
|
124
|
+
* for audit while letting the original through.
|
|
125
|
+
*
|
|
126
|
+
* **Limits:** this is heuristic. Adversarial inputs can paraphrase
|
|
127
|
+
* around any specific pattern. Use as a cheap first filter; for
|
|
128
|
+
* real defense, isolate the user input from the system prompt
|
|
129
|
+
* structurally (different roles, JSON-mode for the system layer)
|
|
130
|
+
* and guard the output side too.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* const check = sanitizePrompt(userInput)
|
|
135
|
+
* if (!check.ok) {
|
|
136
|
+
* log.warn('possible injection attempt', { patterns: check.matched })
|
|
137
|
+
* // option A: reject
|
|
138
|
+
* throw new HttpError(400, 'invalid input')
|
|
139
|
+
* // option B: pass cleaned
|
|
140
|
+
* await chat([{ role: 'user', content: check.cleaned }])
|
|
141
|
+
* }
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export function sanitizePrompt(text: string): SanitizeResult {
|
|
145
|
+
if (!text) return { ok: true, matched: [], cleaned: text }
|
|
146
|
+
const matched: string[] = []
|
|
147
|
+
let cleaned = text
|
|
148
|
+
for (const pattern of INJECTION_PATTERNS) {
|
|
149
|
+
if (pattern.test(cleaned)) {
|
|
150
|
+
matched.push(pattern.toString())
|
|
151
|
+
cleaned = cleaned.replace(pattern, '[redacted]')
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
ok: matched.length === 0,
|
|
156
|
+
matched,
|
|
157
|
+
cleaned,
|
|
158
|
+
}
|
|
159
|
+
}
|