@strav/brain 0.4.31 → 1.0.0-alpha.9
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 +17 -20
- package/src/agent.ts +42 -76
- package/src/agent_result.ts +32 -0
- package/src/agent_runner.ts +61 -0
- package/src/brain_config.ts +72 -0
- package/src/brain_error.ts +29 -0
- package/src/brain_manager.ts +170 -123
- package/src/brain_provider.ts +90 -6
- package/src/define_tool.ts +42 -0
- package/src/index.ts +40 -42
- package/src/provider.ts +74 -0
- package/src/providers/anthropic_provider.ts +347 -231
- package/src/thread.ts +99 -0
- package/src/tool.ts +28 -44
- package/src/tool_execution_error.ts +26 -0
- package/src/types.ts +129 -241
- package/CHANGELOG.md +0 -44
- package/README.md +0 -121
- package/src/helpers.ts +0 -1082
- package/src/mcp_toolbox.ts +0 -62
- package/src/memory/context_budget.ts +0 -120
- package/src/memory/index.ts +0 -17
- package/src/memory/memory_manager.ts +0 -168
- package/src/memory/semantic_memory.ts +0 -89
- package/src/memory/strategies/sliding_window.ts +0 -20
- package/src/memory/strategies/summarize.ts +0 -157
- package/src/memory/thread_store.ts +0 -56
- package/src/memory/token_counter.ts +0 -101
- package/src/memory/types.ts +0 -68
- package/src/providers/google_provider.ts +0 -496
- package/src/providers/openai_provider.ts +0 -569
- package/src/providers/openai_responses_provider.ts +0 -321
- package/src/utils/error_scrub.ts +0 -5
- package/src/utils/prompt.ts +0 -65
- package/src/utils/retry.ts +0 -104
- package/src/utils/schema.ts +0 -27
- package/src/utils/sse_parser.ts +0 -62
- package/src/workflow.ts +0 -199
- package/tsconfig.json +0 -5
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
import { parseSSE } from '../utils/sse_parser.ts'
|
|
2
|
-
import { retryableFetch, type RetryOptions } from '../utils/retry.ts'
|
|
3
|
-
import { ExternalServiceError } from '@strav/kernel'
|
|
4
|
-
import { scrubProviderError } from '../utils/error_scrub.ts'
|
|
5
|
-
import type {
|
|
6
|
-
AIProvider,
|
|
7
|
-
CompletionRequest,
|
|
8
|
-
CompletionResponse,
|
|
9
|
-
StreamChunk,
|
|
10
|
-
ProviderConfig,
|
|
11
|
-
Message,
|
|
12
|
-
ToolCall,
|
|
13
|
-
Usage,
|
|
14
|
-
} from '../types.ts'
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* OpenAI Responses API provider (`/v1/responses`).
|
|
18
|
-
*
|
|
19
|
-
* Drop-in replacement for the Chat Completions provider.
|
|
20
|
-
* Implements the same `AIProvider` interface so Thread, AgentRunner,
|
|
21
|
-
* and all Brain helpers work unchanged.
|
|
22
|
-
*/
|
|
23
|
-
export class OpenAIResponsesProvider implements AIProvider {
|
|
24
|
-
readonly name: string
|
|
25
|
-
private apiKey: string
|
|
26
|
-
private baseUrl: string
|
|
27
|
-
private defaultModel: string
|
|
28
|
-
private defaultMaxTokens?: number
|
|
29
|
-
private retryOptions: RetryOptions
|
|
30
|
-
|
|
31
|
-
constructor(config: ProviderConfig, name?: string) {
|
|
32
|
-
this.name = name ?? 'openai'
|
|
33
|
-
this.apiKey = config.apiKey
|
|
34
|
-
this.baseUrl = (config.baseUrl ?? 'https://api.openai.com').replace(/\/$/, '')
|
|
35
|
-
this.defaultModel = config.model
|
|
36
|
-
this.defaultMaxTokens = config.maxTokens
|
|
37
|
-
this.retryOptions = {
|
|
38
|
-
maxRetries: config.maxRetries ?? 3,
|
|
39
|
-
baseDelay: config.retryBaseDelay ?? 1000,
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ── Non-streaming completion ────────────────────────────────────────────
|
|
44
|
-
|
|
45
|
-
async complete(request: CompletionRequest): Promise<CompletionResponse> {
|
|
46
|
-
const body = this.buildRequestBody(request, false)
|
|
47
|
-
|
|
48
|
-
const response = await retryableFetch(
|
|
49
|
-
'OpenAI',
|
|
50
|
-
`${this.baseUrl}/v1/responses`,
|
|
51
|
-
{ method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
|
|
52
|
-
this.retryOptions
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
const data: any = await response.json()
|
|
56
|
-
return this.parseResponse(data)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ── Streaming completion ────────────────────────────────────────────────
|
|
60
|
-
|
|
61
|
-
async *stream(request: CompletionRequest): AsyncIterable<StreamChunk> {
|
|
62
|
-
const body = this.buildRequestBody(request, true)
|
|
63
|
-
|
|
64
|
-
const response = await retryableFetch(
|
|
65
|
-
'OpenAI',
|
|
66
|
-
`${this.baseUrl}/v1/responses`,
|
|
67
|
-
{ method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
|
|
68
|
-
this.retryOptions
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
if (!response.body) {
|
|
72
|
-
throw new ExternalServiceError('OpenAI', undefined, 'No stream body returned')
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Track function call items by output_index for tool_start/tool_delta mapping
|
|
76
|
-
const toolIndexMap = new Map<number, { callId: string; name: string }>()
|
|
77
|
-
let toolCounter = 0
|
|
78
|
-
|
|
79
|
-
for await (const sse of parseSSE(response.body)) {
|
|
80
|
-
const eventType = sse.event ?? ''
|
|
81
|
-
let data: any
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
data = JSON.parse(sse.data)
|
|
85
|
-
} catch {
|
|
86
|
-
continue
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ── Text content ──────────────────────────────────────────────
|
|
90
|
-
if (eventType === 'response.output_text.delta') {
|
|
91
|
-
yield { type: 'text', text: data.delta ?? '' }
|
|
92
|
-
continue
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ── Function call start ───────────────────────────────────────
|
|
96
|
-
if (eventType === 'response.output_item.added' && data.item?.type === 'function_call') {
|
|
97
|
-
const index = toolCounter++
|
|
98
|
-
toolIndexMap.set(data.output_index ?? index, {
|
|
99
|
-
callId: data.item.call_id ?? '',
|
|
100
|
-
name: data.item.name ?? '',
|
|
101
|
-
})
|
|
102
|
-
yield {
|
|
103
|
-
type: 'tool_start',
|
|
104
|
-
toolCall: { id: data.item.call_id ?? '', name: data.item.name ?? '' },
|
|
105
|
-
toolIndex: index,
|
|
106
|
-
}
|
|
107
|
-
continue
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ── Function call argument deltas ─────────────────────────────
|
|
111
|
-
if (eventType === 'response.function_call_arguments.delta') {
|
|
112
|
-
// Map output_index to our sequential toolIndex
|
|
113
|
-
const outputIdx = data.output_index ?? 0
|
|
114
|
-
let toolIdx = 0
|
|
115
|
-
for (const [oi] of toolIndexMap) {
|
|
116
|
-
if (oi === outputIdx) break
|
|
117
|
-
toolIdx++
|
|
118
|
-
}
|
|
119
|
-
yield { type: 'tool_delta', text: data.delta ?? '', toolIndex: toolIdx }
|
|
120
|
-
continue
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ── Function call arguments done ──────────────────────────────
|
|
124
|
-
if (eventType === 'response.function_call_arguments.done') {
|
|
125
|
-
const outputIdx = data.output_index ?? 0
|
|
126
|
-
let toolIdx = 0
|
|
127
|
-
for (const [oi] of toolIndexMap) {
|
|
128
|
-
if (oi === outputIdx) break
|
|
129
|
-
toolIdx++
|
|
130
|
-
}
|
|
131
|
-
yield { type: 'tool_end', toolIndex: toolIdx }
|
|
132
|
-
continue
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ── Response completed ────────────────────────────────────────
|
|
136
|
-
if (eventType === 'response.completed') {
|
|
137
|
-
const usage = data.response?.usage
|
|
138
|
-
if (usage) {
|
|
139
|
-
yield {
|
|
140
|
-
type: 'usage',
|
|
141
|
-
usage: {
|
|
142
|
-
inputTokens: usage.input_tokens ?? 0,
|
|
143
|
-
outputTokens: usage.output_tokens ?? 0,
|
|
144
|
-
totalTokens: usage.total_tokens ?? 0,
|
|
145
|
-
},
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
yield { type: 'done' }
|
|
149
|
-
break
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ── Error ─────────────────────────────────────────────────────
|
|
153
|
-
if (eventType === 'error') {
|
|
154
|
-
const message = typeof data.message === 'string' ? data.message : JSON.stringify(data)
|
|
155
|
-
throw new ExternalServiceError('OpenAI', undefined, scrubProviderError(message))
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ── Private helpers ─────────────────────────────────────────────────────
|
|
161
|
-
|
|
162
|
-
private buildHeaders(): Record<string, string> {
|
|
163
|
-
return {
|
|
164
|
-
'content-type': 'application/json',
|
|
165
|
-
authorization: `Bearer ${this.apiKey}`,
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
private buildRequestBody(request: CompletionRequest, stream: boolean): Record<string, unknown> {
|
|
170
|
-
const body: Record<string, unknown> = {
|
|
171
|
-
model: request.model ?? this.defaultModel,
|
|
172
|
-
input: this.mapMessages(request.messages),
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// System prompt → instructions
|
|
176
|
-
if (request.system) {
|
|
177
|
-
body.instructions = request.system
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (stream) body.stream = true
|
|
181
|
-
if (request.maxTokens ?? this.defaultMaxTokens) {
|
|
182
|
-
body.max_output_tokens = request.maxTokens ?? this.defaultMaxTokens
|
|
183
|
-
}
|
|
184
|
-
// Note: temperature is not supported by the Responses API for some models
|
|
185
|
-
// if (request.temperature !== undefined) body.temperature = request.temperature
|
|
186
|
-
if (request.stopSequences?.length) body.stop = request.stopSequences
|
|
187
|
-
|
|
188
|
-
// Tools
|
|
189
|
-
if (request.tools?.length) {
|
|
190
|
-
body.tools = request.tools.map(t => ({
|
|
191
|
-
type: 'function',
|
|
192
|
-
name: t.name,
|
|
193
|
-
description: t.description,
|
|
194
|
-
parameters: t.parameters,
|
|
195
|
-
}))
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Tool choice
|
|
199
|
-
if (request.toolChoice) {
|
|
200
|
-
if (typeof request.toolChoice === 'string') {
|
|
201
|
-
body.tool_choice = request.toolChoice
|
|
202
|
-
} else {
|
|
203
|
-
body.tool_choice = {
|
|
204
|
-
type: 'function',
|
|
205
|
-
name: request.toolChoice.name,
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Structured output
|
|
211
|
-
if (request.schema) {
|
|
212
|
-
body.text = {
|
|
213
|
-
format: {
|
|
214
|
-
type: 'json_schema',
|
|
215
|
-
name: 'response',
|
|
216
|
-
schema: request.schema,
|
|
217
|
-
strict: true,
|
|
218
|
-
},
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return body
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Translate Brain Message[] into Responses API input items.
|
|
227
|
-
*
|
|
228
|
-
* User messages → { role: 'user', content }
|
|
229
|
-
* Assistant messages → assistant message item + separate function_call items
|
|
230
|
-
* Tool messages → { type: 'function_call_output', call_id, output }
|
|
231
|
-
*/
|
|
232
|
-
private mapMessages(messages: Message[]): any[] {
|
|
233
|
-
const items: any[] = []
|
|
234
|
-
|
|
235
|
-
for (const msg of messages) {
|
|
236
|
-
if (msg.role === 'user') {
|
|
237
|
-
items.push({
|
|
238
|
-
role: 'user',
|
|
239
|
-
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
240
|
-
})
|
|
241
|
-
} else if (msg.role === 'assistant') {
|
|
242
|
-
const text = typeof msg.content === 'string' ? msg.content : ''
|
|
243
|
-
|
|
244
|
-
// Add assistant message item (only if there's text content)
|
|
245
|
-
if (text) {
|
|
246
|
-
items.push({
|
|
247
|
-
type: 'message',
|
|
248
|
-
role: 'assistant',
|
|
249
|
-
content: [{ type: 'output_text', text }],
|
|
250
|
-
})
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Add function_call items for any tool calls
|
|
254
|
-
if (msg.toolCalls?.length) {
|
|
255
|
-
for (const tc of msg.toolCalls) {
|
|
256
|
-
items.push({
|
|
257
|
-
type: 'function_call',
|
|
258
|
-
call_id: tc.id,
|
|
259
|
-
name: tc.name,
|
|
260
|
-
arguments: JSON.stringify(tc.arguments),
|
|
261
|
-
})
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
} else if (msg.role === 'tool') {
|
|
265
|
-
items.push({
|
|
266
|
-
type: 'function_call_output',
|
|
267
|
-
call_id: msg.toolCallId ?? '',
|
|
268
|
-
output: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
269
|
-
})
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
return items
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Parse a non-streaming Responses API response into Brain CompletionResponse.
|
|
278
|
-
*/
|
|
279
|
-
private parseResponse(data: any): CompletionResponse {
|
|
280
|
-
const output: any[] = data.output ?? []
|
|
281
|
-
let content = ''
|
|
282
|
-
const toolCalls: ToolCall[] = []
|
|
283
|
-
|
|
284
|
-
for (const item of output) {
|
|
285
|
-
if (item.type === 'message' && item.role === 'assistant') {
|
|
286
|
-
for (const part of item.content ?? []) {
|
|
287
|
-
if (part.type === 'output_text') {
|
|
288
|
-
content += part.text ?? ''
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
} else if (item.type === 'function_call') {
|
|
292
|
-
let args: Record<string, unknown> = {}
|
|
293
|
-
try {
|
|
294
|
-
args = JSON.parse(item.arguments ?? '{}')
|
|
295
|
-
} catch {
|
|
296
|
-
args = item.arguments ? { _raw: item.arguments } : {}
|
|
297
|
-
}
|
|
298
|
-
toolCalls.push({
|
|
299
|
-
id: item.call_id ?? item.id ?? '',
|
|
300
|
-
name: item.name ?? '',
|
|
301
|
-
arguments: args,
|
|
302
|
-
})
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const usage: Usage = {
|
|
307
|
-
inputTokens: data.usage?.input_tokens ?? 0,
|
|
308
|
-
outputTokens: data.usage?.output_tokens ?? 0,
|
|
309
|
-
totalTokens: data.usage?.total_tokens ?? 0,
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
let stopReason: CompletionResponse['stopReason'] = 'end'
|
|
313
|
-
if (toolCalls.length > 0) {
|
|
314
|
-
stopReason = 'tool_use'
|
|
315
|
-
} else if (data.status === 'incomplete') {
|
|
316
|
-
stopReason = 'max_tokens'
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
return { id: data.id ?? '', content, toolCalls, stopReason, usage, raw: data }
|
|
320
|
-
}
|
|
321
|
-
}
|
package/src/utils/error_scrub.ts
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// Re-export the shared kernel helper so the same scrubber is used across
|
|
2
|
-
// every package that wraps upstream-provider errors. Keeping this thin
|
|
3
|
-
// re-export avoids a breaking import-path change for callers that
|
|
4
|
-
// already pulled `scrubProviderError` from `@strav/brain/utils/error_scrub`.
|
|
5
|
-
export { scrubProviderError } from '@strav/kernel'
|
package/src/utils/prompt.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Heuristic detector for prompt-injection markers in untrusted strings
|
|
3
|
-
* destined for the system prompt. The interpolation in
|
|
4
|
-
* `agent.instructions` does naïve `replaceAll` of `{{key}}` placeholders
|
|
5
|
-
* with string values — any user-controlled value flowing through is a
|
|
6
|
-
* prompt-injection vector against the LLM.
|
|
7
|
-
*
|
|
8
|
-
* We can't fully solve this at the template layer (the proper fix is to
|
|
9
|
-
* pass values as structured user-role messages, not interpolate them
|
|
10
|
-
* into the system role). What we can do is detect the easy cases and
|
|
11
|
-
* warn the developer that a value looks suspicious. Detection here is
|
|
12
|
-
* deliberately loose — false positives are cheap, missed cases let
|
|
13
|
-
* exploits through silently.
|
|
14
|
-
*/
|
|
15
|
-
const INJECTION_PATTERNS: readonly RegExp[] = [
|
|
16
|
-
/ignore\s+(?:[\w\s]{0,30}\s+)?(?:instructions?|prompts?|messages?)/i,
|
|
17
|
-
/disregard\s+(?:[\w\s]{0,30}\s+)?(?:instructions?|prompts?|messages?)/i,
|
|
18
|
-
/(?:^|\n)\s*system\s*[:>]/i,
|
|
19
|
-
/(?:^|\n)\s*assistant\s*[:>]/i,
|
|
20
|
-
/\bsystem\s*:\s*\S/i,
|
|
21
|
-
/you\s+are\s+now\s+(?:a|an|the)/i,
|
|
22
|
-
/act\s+as\s+(?:a|an|the)\s+(?:different|new)/i,
|
|
23
|
-
/\[INST\]|\[\/INST\]/i,
|
|
24
|
-
/<\|im_(?:start|end)\|>/i,
|
|
25
|
-
/<\|system\|>|<\|user\|>|<\|assistant\|>/i,
|
|
26
|
-
/###\s*(?:system|instruction|new\s+instruction)/i,
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
/** Return true if the string contains a known prompt-injection marker. */
|
|
30
|
-
export function looksLikePromptInjection(value: string): boolean {
|
|
31
|
-
if (!value || typeof value !== 'string') return false
|
|
32
|
-
return INJECTION_PATTERNS.some(re => re.test(value))
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Substitute `{{key}}` placeholders in a system-prompt template with
|
|
37
|
-
* values from `context`. Emits a `console.warn` when a value matches
|
|
38
|
-
* `looksLikePromptInjection()` so developers notice when untrusted
|
|
39
|
-
* input is reaching the system prompt.
|
|
40
|
-
*
|
|
41
|
-
* The replacement still happens — the warning is informational. Callers
|
|
42
|
-
* who need hard rejection should validate context themselves before
|
|
43
|
-
* calling. The framework cannot decide whether a given context value is
|
|
44
|
-
* trusted; only the application can.
|
|
45
|
-
*/
|
|
46
|
-
export function interpolateInstructions(
|
|
47
|
-
template: string,
|
|
48
|
-
context: Record<string, unknown>
|
|
49
|
-
): string {
|
|
50
|
-
let out = template
|
|
51
|
-
for (const [key, rawValue] of Object.entries(context)) {
|
|
52
|
-
const stringValue = String(rawValue)
|
|
53
|
-
if (looksLikePromptInjection(stringValue)) {
|
|
54
|
-
console.warn(
|
|
55
|
-
`[brain] Possible prompt-injection in agent context.${key} — ` +
|
|
56
|
-
`the value contains markers commonly used to override system ` +
|
|
57
|
-
`instructions. Treat untrusted user input as user-role messages, ` +
|
|
58
|
-
`not as interpolated system-prompt context. ` +
|
|
59
|
-
`See packages/brain/CLAUDE.md ("Prompt-injection threat model").`
|
|
60
|
-
)
|
|
61
|
-
}
|
|
62
|
-
out = out.replaceAll(`{{${key}}}`, stringValue)
|
|
63
|
-
}
|
|
64
|
-
return out
|
|
65
|
-
}
|
package/src/utils/retry.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { ExternalServiceError } from '@strav/kernel'
|
|
2
|
-
import { scrubProviderError } from './error_scrub.ts'
|
|
3
|
-
|
|
4
|
-
export interface RetryOptions {
|
|
5
|
-
maxRetries?: number
|
|
6
|
-
baseDelay?: number
|
|
7
|
-
maxDelay?: number
|
|
8
|
-
retryableStatuses?: number[]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const DEFAULT_RETRYABLE = [429, 500, 502, 503, 529]
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Fetch with automatic retry and exponential backoff for transient errors.
|
|
15
|
-
*
|
|
16
|
-
* Retries on 429 (rate limit), 5xx, and network failures.
|
|
17
|
-
* Parses the `retry-after` header when available; otherwise uses
|
|
18
|
-
* exponential backoff with jitter.
|
|
19
|
-
*
|
|
20
|
-
* Returns the successful `Response`. On final failure, throws
|
|
21
|
-
* `ExternalServiceError` with the last status and body.
|
|
22
|
-
*/
|
|
23
|
-
export async function retryableFetch(
|
|
24
|
-
service: string,
|
|
25
|
-
url: string,
|
|
26
|
-
init: RequestInit,
|
|
27
|
-
options?: RetryOptions
|
|
28
|
-
): Promise<Response> {
|
|
29
|
-
const maxRetries = options?.maxRetries ?? 3
|
|
30
|
-
const baseDelay = options?.baseDelay ?? 1000
|
|
31
|
-
const maxDelay = options?.maxDelay ?? 60_000
|
|
32
|
-
const retryable = options?.retryableStatuses ?? DEFAULT_RETRYABLE
|
|
33
|
-
|
|
34
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
35
|
-
let response: Response
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
response = await fetch(url, init)
|
|
39
|
-
} catch (err) {
|
|
40
|
-
// Network error (DNS, connection refused, etc.). Some Bun/Node
|
|
41
|
-
// network errors include the URL — scrub before surfacing in
|
|
42
|
-
// case it carries credentials in query params.
|
|
43
|
-
if (attempt === maxRetries) {
|
|
44
|
-
throw new ExternalServiceError(
|
|
45
|
-
service,
|
|
46
|
-
undefined,
|
|
47
|
-
scrubProviderError(err instanceof Error ? err.message : String(err))
|
|
48
|
-
)
|
|
49
|
-
}
|
|
50
|
-
await sleep(backoffDelay(attempt, baseDelay, maxDelay))
|
|
51
|
-
continue
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (response.ok) return response
|
|
55
|
-
|
|
56
|
-
// Non-retryable status — fail immediately. Provider response bodies
|
|
57
|
-
// can echo request headers or other context; scrub before wrapping.
|
|
58
|
-
if (!retryable.includes(response.status)) {
|
|
59
|
-
const text = await response.text()
|
|
60
|
-
throw new ExternalServiceError(service, response.status, scrubProviderError(text))
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Retryable status — wait and retry (unless last attempt)
|
|
64
|
-
if (attempt === maxRetries) {
|
|
65
|
-
const text = await response.text()
|
|
66
|
-
throw new ExternalServiceError(service, response.status, scrubProviderError(text))
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const delay = parseRetryAfter(response) ?? backoffDelay(attempt, baseDelay, maxDelay)
|
|
70
|
-
await sleep(delay)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Unreachable, but satisfies TypeScript
|
|
74
|
-
throw new ExternalServiceError(service, undefined, 'Retry loop exited unexpectedly')
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Parse the `retry-after` header into milliseconds.
|
|
79
|
-
* Supports both delta-seconds ("2") and HTTP-date formats.
|
|
80
|
-
*/
|
|
81
|
-
function parseRetryAfter(response: Response): number | null {
|
|
82
|
-
const header = response.headers.get('retry-after')
|
|
83
|
-
if (!header) return null
|
|
84
|
-
|
|
85
|
-
const seconds = Number(header)
|
|
86
|
-
if (!Number.isNaN(seconds)) return seconds * 1000
|
|
87
|
-
|
|
88
|
-
// HTTP-date format
|
|
89
|
-
const date = Date.parse(header)
|
|
90
|
-
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
|
|
91
|
-
|
|
92
|
-
return null
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Exponential backoff with jitter: base * 2^attempt + random jitter, capped at maxDelay. */
|
|
96
|
-
function backoffDelay(attempt: number, baseDelay: number, maxDelay: number): number {
|
|
97
|
-
const exp = baseDelay * 2 ** attempt
|
|
98
|
-
const jitter = Math.random() * baseDelay
|
|
99
|
-
return Math.min(exp + jitter, maxDelay)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function sleep(ms: number): Promise<void> {
|
|
103
|
-
return new Promise(resolve => setTimeout(resolve, ms))
|
|
104
|
-
}
|
package/src/utils/schema.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import type { JsonSchema } from '../types.ts'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Convert a Zod schema to a JSON Schema object.
|
|
5
|
-
*
|
|
6
|
-
* Detection logic:
|
|
7
|
-
* - If the input has a `toJSONSchema()` method (Zod v4+), use it directly
|
|
8
|
-
* - If the input is already a plain object (raw JSON Schema), return as-is
|
|
9
|
-
* - null/undefined pass through unchanged
|
|
10
|
-
*
|
|
11
|
-
* The `$schema` meta-field is stripped from the output since
|
|
12
|
-
* AI provider APIs don't expect it.
|
|
13
|
-
*/
|
|
14
|
-
export function zodToJsonSchema(schema: any): JsonSchema {
|
|
15
|
-
if (schema == null) return schema
|
|
16
|
-
|
|
17
|
-
// Zod v4+: native toJSONSchema() method
|
|
18
|
-
if (typeof schema.toJSONSchema === 'function') {
|
|
19
|
-
const jsonSchema = schema.toJSONSchema()
|
|
20
|
-
// Strip the $schema meta-field — providers don't need it
|
|
21
|
-
const { $schema, ...rest } = jsonSchema
|
|
22
|
-
return rest as JsonSchema
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Already a plain JSON Schema object
|
|
26
|
-
return schema as JsonSchema
|
|
27
|
-
}
|
package/src/utils/sse_parser.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import type { SSEEvent } from '../types.ts'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Parse a Server-Sent Events stream into structured events.
|
|
5
|
-
*
|
|
6
|
-
* Handles:
|
|
7
|
-
* - Chunks split at arbitrary byte boundaries
|
|
8
|
-
* - Multi-line `data:` fields (concatenated with newlines)
|
|
9
|
-
* - Optional `event:` field
|
|
10
|
-
* - Empty lines / keepalive comments
|
|
11
|
-
*/
|
|
12
|
-
export async function* parseSSE(stream: ReadableStream<Uint8Array>): AsyncIterable<SSEEvent> {
|
|
13
|
-
const reader = stream.getReader()
|
|
14
|
-
const decoder = new TextDecoder()
|
|
15
|
-
let buffer = ''
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
while (true) {
|
|
19
|
-
const { done, value } = await reader.read()
|
|
20
|
-
if (done) break
|
|
21
|
-
|
|
22
|
-
buffer += decoder.decode(value, { stream: true })
|
|
23
|
-
|
|
24
|
-
const events = buffer.split('\n\n')
|
|
25
|
-
// Last element is either empty (if buffer ended with \n\n) or incomplete
|
|
26
|
-
buffer = events.pop()!
|
|
27
|
-
|
|
28
|
-
for (const block of events) {
|
|
29
|
-
const parsed = parseBlock(block)
|
|
30
|
-
if (parsed) yield parsed
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Flush any remaining data in buffer
|
|
35
|
-
if (buffer.trim()) {
|
|
36
|
-
const parsed = parseBlock(buffer)
|
|
37
|
-
if (parsed) yield parsed
|
|
38
|
-
}
|
|
39
|
-
} finally {
|
|
40
|
-
reader.releaseLock()
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function parseBlock(block: string): SSEEvent | null {
|
|
45
|
-
let event: string | undefined
|
|
46
|
-
const dataLines: string[] = []
|
|
47
|
-
|
|
48
|
-
for (const line of block.split('\n')) {
|
|
49
|
-
if (line.startsWith('event: ')) {
|
|
50
|
-
event = line.slice(7)
|
|
51
|
-
} else if (line.startsWith('data: ')) {
|
|
52
|
-
dataLines.push(line.slice(6))
|
|
53
|
-
} else if (line === 'data:') {
|
|
54
|
-
dataLines.push('')
|
|
55
|
-
}
|
|
56
|
-
// Skip comments (lines starting with ':') and other fields
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (dataLines.length === 0) return null
|
|
60
|
-
|
|
61
|
-
return { event, data: dataLines.join('\n') }
|
|
62
|
-
}
|