@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/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
+ }