@milkdown/crepe 7.19.2 → 7.21.0
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/lib/cjs/builder.js +44 -1
- package/lib/cjs/builder.js.map +1 -1
- package/lib/cjs/feature/ai/index.js +1492 -0
- package/lib/cjs/feature/ai/index.js.map +1 -0
- package/lib/cjs/feature/block-edit/index.js +9 -2
- package/lib/cjs/feature/block-edit/index.js.map +1 -1
- package/lib/cjs/feature/code-mirror/index.js +2 -0
- package/lib/cjs/feature/code-mirror/index.js.map +1 -1
- package/lib/cjs/feature/cursor/index.js +2 -0
- package/lib/cjs/feature/cursor/index.js.map +1 -1
- package/lib/cjs/feature/image-block/index.js +5 -1
- package/lib/cjs/feature/image-block/index.js.map +1 -1
- package/lib/cjs/feature/latex/index.js +7 -0
- package/lib/cjs/feature/latex/index.js.map +1 -1
- package/lib/cjs/feature/link-tooltip/index.js +2 -0
- package/lib/cjs/feature/link-tooltip/index.js.map +1 -1
- package/lib/cjs/feature/list-item/index.js +2 -0
- package/lib/cjs/feature/list-item/index.js.map +1 -1
- package/lib/cjs/feature/placeholder/index.js +2 -0
- package/lib/cjs/feature/placeholder/index.js.map +1 -1
- package/lib/cjs/feature/table/index.js +2 -0
- package/lib/cjs/feature/table/index.js.map +1 -1
- package/lib/cjs/feature/toolbar/index.js +497 -5
- package/lib/cjs/feature/toolbar/index.js.map +1 -1
- package/lib/cjs/feature/top-bar/index.js +791 -0
- package/lib/cjs/feature/top-bar/index.js.map +1 -0
- package/lib/cjs/index.js +2047 -160
- package/lib/cjs/index.js.map +1 -1
- package/lib/cjs/llm-providers/anthropic/index.js +147 -0
- package/lib/cjs/llm-providers/anthropic/index.js.map +1 -0
- package/lib/cjs/llm-providers/openai/index.js +138 -0
- package/lib/cjs/llm-providers/openai/index.js.map +1 -0
- package/lib/esm/builder.js +44 -1
- package/lib/esm/builder.js.map +1 -1
- package/lib/esm/feature/ai/index.js +1487 -0
- package/lib/esm/feature/ai/index.js.map +1 -0
- package/lib/esm/feature/block-edit/index.js +9 -2
- package/lib/esm/feature/block-edit/index.js.map +1 -1
- package/lib/esm/feature/code-mirror/index.js +2 -0
- package/lib/esm/feature/code-mirror/index.js.map +1 -1
- package/lib/esm/feature/cursor/index.js +2 -0
- package/lib/esm/feature/cursor/index.js.map +1 -1
- package/lib/esm/feature/image-block/index.js +5 -1
- package/lib/esm/feature/image-block/index.js.map +1 -1
- package/lib/esm/feature/latex/index.js +7 -0
- package/lib/esm/feature/latex/index.js.map +1 -1
- package/lib/esm/feature/link-tooltip/index.js +2 -0
- package/lib/esm/feature/link-tooltip/index.js.map +1 -1
- package/lib/esm/feature/list-item/index.js +2 -0
- package/lib/esm/feature/list-item/index.js.map +1 -1
- package/lib/esm/feature/placeholder/index.js +2 -0
- package/lib/esm/feature/placeholder/index.js.map +1 -1
- package/lib/esm/feature/table/index.js +2 -0
- package/lib/esm/feature/table/index.js.map +1 -1
- package/lib/esm/feature/toolbar/index.js +499 -7
- package/lib/esm/feature/toolbar/index.js.map +1 -1
- package/lib/esm/feature/top-bar/index.js +789 -0
- package/lib/esm/feature/top-bar/index.js.map +1 -0
- package/lib/esm/index.js +2040 -153
- package/lib/esm/index.js.map +1 -1
- package/lib/esm/llm-providers/anthropic/index.js +145 -0
- package/lib/esm/llm-providers/anthropic/index.js.map +1 -0
- package/lib/esm/llm-providers/openai/index.js +136 -0
- package/lib/esm/llm-providers/openai/index.js.map +1 -0
- package/lib/theme/common/ai.css +446 -0
- package/lib/theme/common/code-mirror.css +14 -0
- package/lib/theme/common/diff.css +177 -0
- package/lib/theme/common/style.css +3 -0
- package/lib/theme/common/top-bar.css +152 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/types/core/builder.d.ts +2 -1
- package/lib/types/core/builder.d.ts.map +1 -1
- package/lib/types/feature/ai/ai.spec.d.ts +2 -0
- package/lib/types/feature/ai/ai.spec.d.ts.map +1 -0
- package/lib/types/feature/ai/commands.d.ts +24 -0
- package/lib/types/feature/ai/commands.d.ts.map +1 -0
- package/lib/types/feature/ai/context.d.ts +4 -0
- package/lib/types/feature/ai/context.d.ts.map +1 -0
- package/lib/types/feature/ai/diff-actions/index.d.ts +12 -0
- package/lib/types/feature/ai/diff-actions/index.d.ts.map +1 -0
- package/lib/types/feature/ai/diff-actions/view.d.ts +21 -0
- package/lib/types/feature/ai/diff-actions/view.d.ts.map +1 -0
- package/lib/types/feature/ai/index.d.ts +7 -0
- package/lib/types/feature/ai/index.d.ts.map +1 -0
- package/lib/types/feature/ai/instruction-tooltip/component.d.ts +26 -0
- package/lib/types/feature/ai/instruction-tooltip/component.d.ts.map +1 -0
- package/lib/types/feature/ai/instruction-tooltip/index.d.ts +17 -0
- package/lib/types/feature/ai/instruction-tooltip/index.d.ts.map +1 -0
- package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts +50 -0
- package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts.map +1 -0
- package/lib/types/feature/ai/instruction-tooltip/view.d.ts +19 -0
- package/lib/types/feature/ai/instruction-tooltip/view.d.ts.map +1 -0
- package/lib/types/feature/ai/streaming-indicator.d.ts +9 -0
- package/lib/types/feature/ai/streaming-indicator.d.ts.map +1 -0
- package/lib/types/feature/ai/types.d.ts +58 -0
- package/lib/types/feature/ai/types.d.ts.map +1 -0
- package/lib/types/feature/block-edit/handle/component.d.ts.map +1 -1
- package/lib/types/feature/block-edit/menu/component.d.ts.map +1 -1
- package/lib/types/feature/image-block/index.d.ts +2 -0
- package/lib/types/feature/image-block/index.d.ts.map +1 -1
- package/lib/types/feature/index.d.ts +7 -1
- package/lib/types/feature/index.d.ts.map +1 -1
- package/lib/types/feature/latex/inline-tooltip/component.d.ts.map +1 -1
- package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts +2 -0
- package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts.map +1 -0
- package/lib/types/feature/latex/inline-tooltip/view.d.ts.map +1 -1
- package/lib/types/feature/loader.d.ts.map +1 -1
- package/lib/types/feature/toolbar/component.d.ts.map +1 -1
- package/lib/types/feature/toolbar/config.d.ts +1 -1
- package/lib/types/feature/toolbar/config.d.ts.map +1 -1
- package/lib/types/feature/toolbar/index.d.ts +1 -0
- package/lib/types/feature/toolbar/index.d.ts.map +1 -1
- package/lib/types/feature/top-bar/component.d.ts +11 -0
- package/lib/types/feature/top-bar/component.d.ts.map +1 -0
- package/lib/types/feature/top-bar/config.d.ts +34 -0
- package/lib/types/feature/top-bar/config.d.ts.map +1 -0
- package/lib/types/feature/top-bar/index.d.ts +26 -0
- package/lib/types/feature/top-bar/index.d.ts.map +1 -0
- package/lib/types/icons/ai.d.ts +2 -0
- package/lib/types/icons/ai.d.ts.map +1 -0
- package/lib/types/icons/chevron-left.d.ts +2 -0
- package/lib/types/icons/chevron-left.d.ts.map +1 -0
- package/lib/types/icons/chevron-right.d.ts +2 -0
- package/lib/types/icons/chevron-right.d.ts.map +1 -0
- package/lib/types/icons/code-block.d.ts +2 -0
- package/lib/types/icons/code-block.d.ts.map +1 -0
- package/lib/types/icons/enter-key.d.ts +2 -0
- package/lib/types/icons/enter-key.d.ts.map +1 -0
- package/lib/types/icons/grammar-check.d.ts +2 -0
- package/lib/types/icons/grammar-check.d.ts.map +1 -0
- package/lib/types/icons/index.d.ts +12 -0
- package/lib/types/icons/index.d.ts.map +1 -1
- package/lib/types/icons/longer.d.ts +2 -0
- package/lib/types/icons/longer.d.ts.map +1 -0
- package/lib/types/icons/retry.d.ts +2 -0
- package/lib/types/icons/retry.d.ts.map +1 -0
- package/lib/types/icons/send-prompt.d.ts +2 -0
- package/lib/types/icons/send-prompt.d.ts.map +1 -0
- package/lib/types/icons/send.d.ts +2 -0
- package/lib/types/icons/send.d.ts.map +1 -0
- package/lib/types/icons/shorter.d.ts +2 -0
- package/lib/types/icons/shorter.d.ts.map +1 -0
- package/lib/types/icons/translate.d.ts +2 -0
- package/lib/types/icons/translate.d.ts.map +1 -0
- package/lib/types/llm-providers/anthropic/index.d.ts +21 -0
- package/lib/types/llm-providers/anthropic/index.d.ts.map +1 -0
- package/lib/types/llm-providers/openai/index.d.ts +15 -0
- package/lib/types/llm-providers/openai/index.d.ts.map +1 -0
- package/lib/types/llm-providers/providers.spec.d.ts +2 -0
- package/lib/types/llm-providers/providers.spec.d.ts.map +1 -0
- package/lib/types/llm-providers/shared.d.ts +16 -0
- package/lib/types/llm-providers/shared.d.ts.map +1 -0
- package/lib/types/utils/group-builder.d.ts +1 -1
- package/lib/types/utils/group-builder.d.ts.map +1 -1
- package/lib/types/utils/keep-alive.d.ts +2 -0
- package/lib/types/utils/keep-alive.d.ts.map +1 -0
- package/package.json +34 -13
- package/src/core/builder.ts +39 -2
- package/src/feature/ai/ai.spec.ts +742 -0
- package/src/feature/ai/commands.ts +257 -0
- package/src/feature/ai/context.ts +45 -0
- package/src/feature/ai/diff-actions/index.ts +95 -0
- package/src/feature/ai/diff-actions/view.ts +237 -0
- package/src/feature/ai/index.ts +118 -0
- package/src/feature/ai/instruction-tooltip/component.tsx +414 -0
- package/src/feature/ai/instruction-tooltip/index.ts +101 -0
- package/src/feature/ai/instruction-tooltip/suggestions.ts +249 -0
- package/src/feature/ai/instruction-tooltip/view.ts +159 -0
- package/src/feature/ai/streaming-indicator.ts +183 -0
- package/src/feature/ai/types.ts +178 -0
- package/src/feature/block-edit/handle/component.tsx +3 -2
- package/src/feature/block-edit/menu/component.tsx +3 -2
- package/src/feature/block-edit/menu/config.ts +1 -1
- package/src/feature/image-block/index.ts +4 -0
- package/src/feature/index.ts +14 -2
- package/src/feature/latex/inline-tooltip/component.tsx +4 -2
- package/src/feature/latex/inline-tooltip/inline-tooltip.spec.ts +81 -0
- package/src/feature/latex/inline-tooltip/view.ts +2 -0
- package/src/feature/loader.ts +8 -0
- package/src/feature/toolbar/component.tsx +7 -5
- package/src/feature/toolbar/config.ts +27 -1
- package/src/feature/toolbar/index.ts +1 -0
- package/src/feature/top-bar/component.tsx +198 -0
- package/src/feature/top-bar/config.ts +367 -0
- package/src/feature/top-bar/index.ts +113 -0
- package/src/icons/ai.ts +14 -0
- package/src/icons/chevron-left.ts +15 -0
- package/src/icons/chevron-right.ts +15 -0
- package/src/icons/code-block.ts +12 -0
- package/src/icons/enter-key.ts +13 -0
- package/src/icons/grammar-check.ts +13 -0
- package/src/icons/index.ts +12 -0
- package/src/icons/longer.ts +13 -0
- package/src/icons/retry.ts +13 -0
- package/src/icons/send-prompt.ts +13 -0
- package/src/icons/send.ts +13 -0
- package/src/icons/shorter.ts +13 -0
- package/src/icons/translate.ts +13 -0
- package/src/llm-providers/anthropic/index.ts +132 -0
- package/src/llm-providers/openai/index.ts +109 -0
- package/src/llm-providers/providers.spec.ts +472 -0
- package/src/llm-providers/shared.ts +160 -0
- package/src/theme/common/ai.css +430 -0
- package/src/theme/common/code-mirror.css +14 -0
- package/src/theme/common/diff.css +196 -0
- package/src/theme/common/style.css +3 -0
- package/src/theme/common/top-bar.css +156 -0
- package/src/utils/group-builder.ts +1 -1
- package/src/utils/keep-alive.ts +3 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
type FetchSig = (...args: Parameters<typeof fetch>) => Promise<Response>
|
|
4
|
+
|
|
5
|
+
import type { AIPromptContext } from '../feature/ai/types'
|
|
6
|
+
|
|
7
|
+
import { createAnthropicProvider } from './anthropic'
|
|
8
|
+
import { createOpenAIProvider } from './openai'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_SYSTEM_PROMPT,
|
|
11
|
+
buildDefaultUserMessage,
|
|
12
|
+
parseSSE,
|
|
13
|
+
resolveSystemPrompt,
|
|
14
|
+
} from './shared'
|
|
15
|
+
|
|
16
|
+
const sampleContext: AIPromptContext = {
|
|
17
|
+
document: '# Title\n\nHello.',
|
|
18
|
+
selection: 'Hello.',
|
|
19
|
+
instruction: 'Make it longer.',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sseResponse(events: string[]): Response {
|
|
23
|
+
const text = events.map((e) => `data: ${e}\n\n`).join('')
|
|
24
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
25
|
+
start(controller) {
|
|
26
|
+
controller.enqueue(new TextEncoder().encode(text))
|
|
27
|
+
controller.close()
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
return new Response(stream, {
|
|
31
|
+
status: 200,
|
|
32
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function chunkedSseResponse(rawChunks: string[]): Response {
|
|
37
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
38
|
+
start(controller) {
|
|
39
|
+
const encoder = new TextEncoder()
|
|
40
|
+
for (const chunk of rawChunks) {
|
|
41
|
+
controller.enqueue(encoder.encode(chunk))
|
|
42
|
+
}
|
|
43
|
+
controller.close()
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
return new Response(stream, {
|
|
47
|
+
status: 200,
|
|
48
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function collect<T>(iter: AsyncIterable<T>): Promise<T[]> {
|
|
53
|
+
const out: T[] = []
|
|
54
|
+
for await (const v of iter) out.push(v)
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('shared helpers', () => {
|
|
59
|
+
test('buildDefaultUserMessage includes selection when non-empty', () => {
|
|
60
|
+
const msg = buildDefaultUserMessage(sampleContext)
|
|
61
|
+
expect(msg).toContain('<document>\n# Title\n\nHello.\n</document>')
|
|
62
|
+
expect(msg).toContain('<selection>\nHello.\n</selection>')
|
|
63
|
+
expect(msg).toContain('<instruction>\nMake it longer.\n</instruction>')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('buildDefaultUserMessage omits selection when empty', () => {
|
|
67
|
+
const msg = buildDefaultUserMessage({ ...sampleContext, selection: '' })
|
|
68
|
+
expect(msg).not.toContain('<selection>')
|
|
69
|
+
expect(msg).toContain('<instruction>')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('resolveSystemPrompt: undefined → default, null → null, string → as-is', () => {
|
|
73
|
+
expect(resolveSystemPrompt(undefined)).toBe(DEFAULT_SYSTEM_PROMPT)
|
|
74
|
+
expect(resolveSystemPrompt(null)).toBeNull()
|
|
75
|
+
expect(resolveSystemPrompt('custom')).toBe('custom')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('parseSSE strips `data: ` and ignores other lines', async () => {
|
|
79
|
+
const response = chunkedSseResponse([
|
|
80
|
+
'event: ping\n',
|
|
81
|
+
'data: hello\n',
|
|
82
|
+
'\n',
|
|
83
|
+
': comment\n',
|
|
84
|
+
'data:nospace\n',
|
|
85
|
+
'\n',
|
|
86
|
+
])
|
|
87
|
+
const ac = new AbortController()
|
|
88
|
+
const out = await collect(parseSSE(response, ac.signal))
|
|
89
|
+
expect(out).toEqual(['hello', 'nospace'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('parseSSE handles a payload split across reads', async () => {
|
|
93
|
+
const response = chunkedSseResponse(['data: hel', 'lo\n\ndata: world\n\n'])
|
|
94
|
+
const ac = new AbortController()
|
|
95
|
+
const out = await collect(parseSSE(response, ac.signal))
|
|
96
|
+
expect(out).toEqual(['hello', 'world'])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('parseSSE preserves significant whitespace in the trailing payload', async () => {
|
|
100
|
+
// No trailing newline — the buffer reaches the tail flush. The
|
|
101
|
+
// payload here intentionally contains leading and trailing spaces
|
|
102
|
+
// (a streamed token boundary the model emitted on purpose); the
|
|
103
|
+
// tail flush must not strip them.
|
|
104
|
+
const response = chunkedSseResponse(['data: hello world '])
|
|
105
|
+
const ac = new AbortController()
|
|
106
|
+
const out = await collect(parseSSE(response, ac.signal))
|
|
107
|
+
expect(out).toEqual([' hello world '])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('parseSSE stops yielding after abort', async () => {
|
|
111
|
+
const ac = new AbortController()
|
|
112
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
113
|
+
async start(controller) {
|
|
114
|
+
const encoder = new TextEncoder()
|
|
115
|
+
controller.enqueue(encoder.encode('data: a\n\n'))
|
|
116
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
117
|
+
ac.abort()
|
|
118
|
+
controller.enqueue(encoder.encode('data: b\n\n'))
|
|
119
|
+
controller.close()
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
const response = new Response(stream, { status: 200 })
|
|
123
|
+
const out = await collect(parseSSE(response, ac.signal))
|
|
124
|
+
expect(out).toEqual(['a'])
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('createOpenAIProvider', () => {
|
|
129
|
+
let originalFetch: typeof fetch
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
originalFetch = globalThis.fetch
|
|
132
|
+
})
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
globalThis.fetch = originalFetch
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('refuses apiKey in browser without dangerouslyAllowBrowser', () => {
|
|
138
|
+
expect(() =>
|
|
139
|
+
createOpenAIProvider({ apiKey: 'sk-test', model: 'gpt-4o-mini' })
|
|
140
|
+
).toThrow(/Refusing to send your API key/)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('allows browser apiKey with dangerouslyAllowBrowser', () => {
|
|
144
|
+
expect(() =>
|
|
145
|
+
createOpenAIProvider({
|
|
146
|
+
apiKey: 'sk-test',
|
|
147
|
+
model: 'gpt-4o-mini',
|
|
148
|
+
dangerouslyAllowBrowser: true,
|
|
149
|
+
})
|
|
150
|
+
).not.toThrow()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('does not require dangerouslyAllowBrowser when apiKey is absent', () => {
|
|
154
|
+
expect(() =>
|
|
155
|
+
createOpenAIProvider({
|
|
156
|
+
baseURL: 'https://my-proxy.example.com',
|
|
157
|
+
headers: { Authorization: 'Bearer session-token' },
|
|
158
|
+
model: 'gpt-4o-mini',
|
|
159
|
+
})
|
|
160
|
+
).not.toThrow()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('refuses apiKey in worker context (no DOM, WorkerGlobalScope present)', () => {
|
|
164
|
+
// Simulate Service/Web/Shared Worker: no `document`, but
|
|
165
|
+
// `WorkerGlobalScope` is the global type.
|
|
166
|
+
vi.stubGlobal('document', undefined)
|
|
167
|
+
vi.stubGlobal('WorkerGlobalScope', class {})
|
|
168
|
+
try {
|
|
169
|
+
expect(() =>
|
|
170
|
+
createOpenAIProvider({ apiKey: 'sk-test', model: 'gpt-4o-mini' })
|
|
171
|
+
).toThrow(/Refusing to send your API key/)
|
|
172
|
+
} finally {
|
|
173
|
+
vi.unstubAllGlobals()
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('does not flag Node/SSR (no DOM, no WorkerGlobalScope)', () => {
|
|
178
|
+
vi.stubGlobal('document', undefined)
|
|
179
|
+
vi.stubGlobal('WorkerGlobalScope', undefined)
|
|
180
|
+
try {
|
|
181
|
+
expect(() =>
|
|
182
|
+
createOpenAIProvider({ apiKey: 'sk-test', model: 'gpt-4o-mini' })
|
|
183
|
+
).not.toThrow()
|
|
184
|
+
} finally {
|
|
185
|
+
vi.unstubAllGlobals()
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('streams text from delta.content and stops at [DONE]', async () => {
|
|
190
|
+
const fetchMock = vi.fn<FetchSig>(async () =>
|
|
191
|
+
sseResponse([
|
|
192
|
+
JSON.stringify({ choices: [{ delta: { content: 'Hello' } }] }),
|
|
193
|
+
JSON.stringify({ choices: [{ delta: { content: ', world' } }] }),
|
|
194
|
+
JSON.stringify({ choices: [{ delta: {} }] }),
|
|
195
|
+
'[DONE]',
|
|
196
|
+
JSON.stringify({ choices: [{ delta: { content: 'never' } }] }),
|
|
197
|
+
])
|
|
198
|
+
)
|
|
199
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
200
|
+
|
|
201
|
+
const provider = createOpenAIProvider({
|
|
202
|
+
apiKey: 'sk-test',
|
|
203
|
+
model: 'gpt-4o-mini',
|
|
204
|
+
dangerouslyAllowBrowser: true,
|
|
205
|
+
})
|
|
206
|
+
const out = await collect(
|
|
207
|
+
provider(sampleContext, new AbortController().signal)
|
|
208
|
+
)
|
|
209
|
+
expect(out.join('')).toBe('Hello, world')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('sends Authorization, model, stream:true, and default messages', async () => {
|
|
213
|
+
const fetchMock = vi.fn<FetchSig>(async () => sseResponse(['[DONE]']))
|
|
214
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
215
|
+
|
|
216
|
+
const provider = createOpenAIProvider({
|
|
217
|
+
apiKey: 'sk-test',
|
|
218
|
+
model: 'gpt-4o-mini',
|
|
219
|
+
dangerouslyAllowBrowser: true,
|
|
220
|
+
})
|
|
221
|
+
await collect(provider(sampleContext, new AbortController().signal))
|
|
222
|
+
|
|
223
|
+
expect(fetchMock).toHaveBeenCalledOnce()
|
|
224
|
+
const [url, init] = fetchMock.mock.calls[0]!
|
|
225
|
+
expect(url).toBe('https://api.openai.com/v1/chat/completions')
|
|
226
|
+
const headers = (init as RequestInit).headers as Record<string, string>
|
|
227
|
+
expect(headers.Authorization).toBe('Bearer sk-test')
|
|
228
|
+
expect(headers['Content-Type']).toBe('application/json')
|
|
229
|
+
const body = JSON.parse(String((init as RequestInit).body))
|
|
230
|
+
expect(body.model).toBe('gpt-4o-mini')
|
|
231
|
+
expect(body.stream).toBe(true)
|
|
232
|
+
expect(body.messages[0].role).toBe('system')
|
|
233
|
+
expect(body.messages[0].content).toBe(DEFAULT_SYSTEM_PROMPT)
|
|
234
|
+
expect(body.messages[1].role).toBe('user')
|
|
235
|
+
expect(body.messages[1].content).toContain('<instruction>')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('honors baseURL, custom headers, and extra body fields', async () => {
|
|
239
|
+
const fetchMock = vi.fn<FetchSig>(async () => sseResponse(['[DONE]']))
|
|
240
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
241
|
+
|
|
242
|
+
const provider = createOpenAIProvider({
|
|
243
|
+
baseURL: 'https://my-proxy.example.com/',
|
|
244
|
+
headers: { Authorization: 'Bearer session-token' },
|
|
245
|
+
model: 'gpt-4o-mini',
|
|
246
|
+
body: { temperature: 0.2 },
|
|
247
|
+
})
|
|
248
|
+
await collect(provider(sampleContext, new AbortController().signal))
|
|
249
|
+
|
|
250
|
+
const [url, init] = fetchMock.mock.calls[0]!
|
|
251
|
+
expect(url).toBe('https://my-proxy.example.com/v1/chat/completions')
|
|
252
|
+
const headers = (init as RequestInit).headers as Record<string, string>
|
|
253
|
+
expect(headers.Authorization).toBe('Bearer session-token')
|
|
254
|
+
const body = JSON.parse(String((init as RequestInit).body))
|
|
255
|
+
expect(body.temperature).toBe(0.2)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('omits system message when systemPrompt is null', async () => {
|
|
259
|
+
const fetchMock = vi.fn<FetchSig>(async () => sseResponse(['[DONE]']))
|
|
260
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
261
|
+
|
|
262
|
+
const provider = createOpenAIProvider({
|
|
263
|
+
apiKey: 'sk-test',
|
|
264
|
+
model: 'gpt-4o-mini',
|
|
265
|
+
systemPrompt: null,
|
|
266
|
+
dangerouslyAllowBrowser: true,
|
|
267
|
+
})
|
|
268
|
+
await collect(provider(sampleContext, new AbortController().signal))
|
|
269
|
+
const body = JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))
|
|
270
|
+
expect(body.messages).toHaveLength(1)
|
|
271
|
+
expect(body.messages[0].role).toBe('user')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('keeps an empty-string systemPrompt instead of omitting it', async () => {
|
|
275
|
+
const fetchMock = vi.fn<FetchSig>(async () => sseResponse(['[DONE]']))
|
|
276
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
277
|
+
|
|
278
|
+
const provider = createOpenAIProvider({
|
|
279
|
+
apiKey: 'sk-test',
|
|
280
|
+
model: 'gpt-4o-mini',
|
|
281
|
+
systemPrompt: '',
|
|
282
|
+
dangerouslyAllowBrowser: true,
|
|
283
|
+
})
|
|
284
|
+
await collect(provider(sampleContext, new AbortController().signal))
|
|
285
|
+
const body = JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))
|
|
286
|
+
expect(body.messages).toHaveLength(2)
|
|
287
|
+
expect(body.messages[0]).toEqual({ role: 'system', content: '' })
|
|
288
|
+
expect(body.messages[1].role).toBe('user')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('throws on non-2xx with body included', async () => {
|
|
292
|
+
globalThis.fetch = vi.fn(
|
|
293
|
+
async () =>
|
|
294
|
+
new Response('rate limited', {
|
|
295
|
+
status: 429,
|
|
296
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
297
|
+
})
|
|
298
|
+
) as unknown as typeof fetch
|
|
299
|
+
|
|
300
|
+
const provider = createOpenAIProvider({
|
|
301
|
+
apiKey: 'sk-test',
|
|
302
|
+
model: 'gpt-4o-mini',
|
|
303
|
+
dangerouslyAllowBrowser: true,
|
|
304
|
+
})
|
|
305
|
+
await expect(
|
|
306
|
+
collect(provider(sampleContext, new AbortController().signal))
|
|
307
|
+
).rejects.toThrow(/429.*rate limited/)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('passes the abort signal through to fetch', async () => {
|
|
311
|
+
const fetchMock = vi.fn<FetchSig>(async () => sseResponse(['[DONE]']))
|
|
312
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
313
|
+
|
|
314
|
+
const provider = createOpenAIProvider({
|
|
315
|
+
apiKey: 'sk-test',
|
|
316
|
+
model: 'gpt-4o-mini',
|
|
317
|
+
dangerouslyAllowBrowser: true,
|
|
318
|
+
})
|
|
319
|
+
const ac = new AbortController()
|
|
320
|
+
await collect(provider(sampleContext, ac.signal))
|
|
321
|
+
const init = fetchMock.mock.calls[0]![1] as RequestInit
|
|
322
|
+
expect(init.signal).toBe(ac.signal)
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
describe('createAnthropicProvider', () => {
|
|
327
|
+
let originalFetch: typeof fetch
|
|
328
|
+
beforeEach(() => {
|
|
329
|
+
originalFetch = globalThis.fetch
|
|
330
|
+
})
|
|
331
|
+
afterEach(() => {
|
|
332
|
+
globalThis.fetch = originalFetch
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('streams text from text_delta and stops at message_stop', async () => {
|
|
336
|
+
const fetchMock = vi.fn<FetchSig>(async () =>
|
|
337
|
+
sseResponse([
|
|
338
|
+
JSON.stringify({ type: 'message_start' }),
|
|
339
|
+
JSON.stringify({ type: 'content_block_start' }),
|
|
340
|
+
JSON.stringify({
|
|
341
|
+
type: 'content_block_delta',
|
|
342
|
+
delta: { type: 'text_delta', text: 'Hello' },
|
|
343
|
+
}),
|
|
344
|
+
JSON.stringify({
|
|
345
|
+
type: 'content_block_delta',
|
|
346
|
+
delta: { type: 'text_delta', text: ', world' },
|
|
347
|
+
}),
|
|
348
|
+
JSON.stringify({ type: 'content_block_stop' }),
|
|
349
|
+
JSON.stringify({ type: 'message_stop' }),
|
|
350
|
+
// Anything after message_stop must be ignored.
|
|
351
|
+
JSON.stringify({
|
|
352
|
+
type: 'content_block_delta',
|
|
353
|
+
delta: { type: 'text_delta', text: 'never' },
|
|
354
|
+
}),
|
|
355
|
+
])
|
|
356
|
+
)
|
|
357
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
358
|
+
|
|
359
|
+
const provider = createAnthropicProvider({
|
|
360
|
+
apiKey: 'sk-ant-test',
|
|
361
|
+
model: 'claude-sonnet-4-5',
|
|
362
|
+
dangerouslyAllowBrowser: true,
|
|
363
|
+
})
|
|
364
|
+
const out = await collect(
|
|
365
|
+
provider(sampleContext, new AbortController().signal)
|
|
366
|
+
)
|
|
367
|
+
expect(out.join('')).toBe('Hello, world')
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test('sends x-api-key, anthropic-version, max_tokens, and system field', async () => {
|
|
371
|
+
const fetchMock = vi.fn<FetchSig>(async () =>
|
|
372
|
+
sseResponse([JSON.stringify({ type: 'message_stop' })])
|
|
373
|
+
)
|
|
374
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
375
|
+
|
|
376
|
+
const provider = createAnthropicProvider({
|
|
377
|
+
apiKey: 'sk-ant-test',
|
|
378
|
+
model: 'claude-sonnet-4-5',
|
|
379
|
+
maxTokens: 2048,
|
|
380
|
+
dangerouslyAllowBrowser: true,
|
|
381
|
+
})
|
|
382
|
+
await collect(provider(sampleContext, new AbortController().signal))
|
|
383
|
+
|
|
384
|
+
const [url, init] = fetchMock.mock.calls[0]!
|
|
385
|
+
expect(url).toBe('https://api.anthropic.com/v1/messages')
|
|
386
|
+
const headers = (init as RequestInit).headers as Record<string, string>
|
|
387
|
+
expect(headers['x-api-key']).toBe('sk-ant-test')
|
|
388
|
+
expect(headers['anthropic-version']).toBe('2023-06-01')
|
|
389
|
+
expect(headers['anthropic-dangerous-direct-browser-access']).toBe('true')
|
|
390
|
+
const body = JSON.parse(String((init as RequestInit).body))
|
|
391
|
+
expect(body.model).toBe('claude-sonnet-4-5')
|
|
392
|
+
expect(body.max_tokens).toBe(2048)
|
|
393
|
+
expect(body.stream).toBe(true)
|
|
394
|
+
expect(body.system).toBe(DEFAULT_SYSTEM_PROMPT)
|
|
395
|
+
expect(body.messages).toHaveLength(1)
|
|
396
|
+
expect(body.messages[0].role).toBe('user')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('omits system field when systemPrompt is null', async () => {
|
|
400
|
+
const fetchMock = vi.fn<FetchSig>(async () =>
|
|
401
|
+
sseResponse([JSON.stringify({ type: 'message_stop' })])
|
|
402
|
+
)
|
|
403
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
404
|
+
|
|
405
|
+
const provider = createAnthropicProvider({
|
|
406
|
+
apiKey: 'sk-ant-test',
|
|
407
|
+
model: 'claude-sonnet-4-5',
|
|
408
|
+
systemPrompt: null,
|
|
409
|
+
dangerouslyAllowBrowser: true,
|
|
410
|
+
})
|
|
411
|
+
await collect(provider(sampleContext, new AbortController().signal))
|
|
412
|
+
const body = JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))
|
|
413
|
+
expect(body.system).toBeUndefined()
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('keeps an empty-string systemPrompt instead of omitting it', async () => {
|
|
417
|
+
const fetchMock = vi.fn<FetchSig>(async () =>
|
|
418
|
+
sseResponse([JSON.stringify({ type: 'message_stop' })])
|
|
419
|
+
)
|
|
420
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
421
|
+
|
|
422
|
+
const provider = createAnthropicProvider({
|
|
423
|
+
apiKey: 'sk-ant-test',
|
|
424
|
+
model: 'claude-sonnet-4-5',
|
|
425
|
+
systemPrompt: '',
|
|
426
|
+
dangerouslyAllowBrowser: true,
|
|
427
|
+
})
|
|
428
|
+
await collect(provider(sampleContext, new AbortController().signal))
|
|
429
|
+
const body = JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))
|
|
430
|
+
expect(body.system).toBe('')
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('proxy mode: omits x-api-key and direct-browser-access header', async () => {
|
|
434
|
+
const fetchMock = vi.fn<FetchSig>(async () =>
|
|
435
|
+
sseResponse([JSON.stringify({ type: 'message_stop' })])
|
|
436
|
+
)
|
|
437
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
438
|
+
|
|
439
|
+
const provider = createAnthropicProvider({
|
|
440
|
+
baseURL: 'https://my-proxy.example.com',
|
|
441
|
+
headers: { Authorization: 'Bearer session-token' },
|
|
442
|
+
model: 'claude-sonnet-4-5',
|
|
443
|
+
})
|
|
444
|
+
await collect(provider(sampleContext, new AbortController().signal))
|
|
445
|
+
|
|
446
|
+
const [url, init] = fetchMock.mock.calls[0]!
|
|
447
|
+
expect(url).toBe('https://my-proxy.example.com/v1/messages')
|
|
448
|
+
const headers = (init as RequestInit).headers as Record<string, string>
|
|
449
|
+
expect(headers['x-api-key']).toBeUndefined()
|
|
450
|
+
expect(headers['anthropic-dangerous-direct-browser-access']).toBeUndefined()
|
|
451
|
+
expect(headers.Authorization).toBe('Bearer session-token')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
test('throws on non-2xx with body included', async () => {
|
|
455
|
+
globalThis.fetch = vi.fn(
|
|
456
|
+
async () =>
|
|
457
|
+
new Response('overloaded', {
|
|
458
|
+
status: 529,
|
|
459
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
460
|
+
})
|
|
461
|
+
) as unknown as typeof fetch
|
|
462
|
+
|
|
463
|
+
const provider = createAnthropicProvider({
|
|
464
|
+
apiKey: 'sk-ant-test',
|
|
465
|
+
model: 'claude-sonnet-4-5',
|
|
466
|
+
dangerouslyAllowBrowser: true,
|
|
467
|
+
})
|
|
468
|
+
await expect(
|
|
469
|
+
collect(provider(sampleContext, new AbortController().signal))
|
|
470
|
+
).rejects.toThrow(/529.*overloaded/)
|
|
471
|
+
})
|
|
472
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { AIPromptContext } from '../feature/ai/types'
|
|
2
|
+
|
|
3
|
+
/// Default system prompt used by both `createOpenAIProvider` and
|
|
4
|
+
/// `createAnthropicProvider`. Constrains the model to emit raw markdown
|
|
5
|
+
/// suitable for the streaming + diff plugins to apply directly.
|
|
6
|
+
export const DEFAULT_SYSTEM_PROMPT = `You are a writing assistant embedded in a markdown editor.
|
|
7
|
+
|
|
8
|
+
Rules:
|
|
9
|
+
- Output markdown only. Never wrap your output in code fences (e.g. \`\`\`markdown ... \`\`\`).
|
|
10
|
+
- Never include preambles, explanations, or sign-offs — output only the edited or generated content itself.
|
|
11
|
+
- Preserve the original markdown structure (headings, lists, links, code blocks) unless the instruction explicitly asks to change it.
|
|
12
|
+
- If a <selection> is provided, return only the replacement for that selection — do not repeat surrounding document context.
|
|
13
|
+
- If no <selection> is provided, return content to insert at the cursor that flows with the surrounding document.`
|
|
14
|
+
|
|
15
|
+
/// Default user-message body. Wraps document, selection, and
|
|
16
|
+
/// instruction in XML-ish tags so the model can tell them apart.
|
|
17
|
+
export function buildDefaultUserMessage(context: AIPromptContext): string {
|
|
18
|
+
const parts: string[] = [`<document>\n${context.document}\n</document>`]
|
|
19
|
+
if (context.selection) {
|
|
20
|
+
parts.push(`<selection>\n${context.selection}\n</selection>`)
|
|
21
|
+
}
|
|
22
|
+
parts.push(`<instruction>\n${context.instruction}\n</instruction>`)
|
|
23
|
+
return parts.join('\n\n')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Common config shared by every built-in provider.
|
|
27
|
+
export interface BaseProviderConfig {
|
|
28
|
+
/// API key used when calling the provider directly. Omit when routing
|
|
29
|
+
/// through your own backend (set `baseURL` + `headers` instead).
|
|
30
|
+
apiKey?: string
|
|
31
|
+
|
|
32
|
+
/// Override the API base URL. Defaults to the provider's official
|
|
33
|
+
/// endpoint. Point this at your own backend proxy in production
|
|
34
|
+
/// deployments so the API key never reaches the browser.
|
|
35
|
+
baseURL?: string
|
|
36
|
+
|
|
37
|
+
/// Extra headers merged into every request. Use this to inject your
|
|
38
|
+
/// app's session token when proxying through your backend.
|
|
39
|
+
headers?: Record<string, string>
|
|
40
|
+
|
|
41
|
+
/// Model identifier (e.g. `gpt-4o-mini`, `claude-sonnet-4-5`).
|
|
42
|
+
model: string
|
|
43
|
+
|
|
44
|
+
/// System prompt. Defaults to a markdown-output-only prompt that
|
|
45
|
+
/// works well with the streaming + diff plugins. Pass a custom string
|
|
46
|
+
/// to fully replace it; pass `null` to send no system prompt at all.
|
|
47
|
+
systemPrompt?: string | null
|
|
48
|
+
|
|
49
|
+
/// Required when calling the provider directly from a browser with an
|
|
50
|
+
/// `apiKey`. Setting this to `true` is an explicit acknowledgement
|
|
51
|
+
/// that your API key will be visible to anyone inspecting network
|
|
52
|
+
/// traffic. Recommended only for desktop apps, personal tools, or
|
|
53
|
+
/// BYOK setups where each user supplies their own key. Routing
|
|
54
|
+
/// through a backend (via `baseURL` + `headers`) does not need this.
|
|
55
|
+
dangerouslyAllowBrowser?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Detects any client-side execution context where the API key would
|
|
59
|
+
/// be visible to user-controlled code: the main browser thread (DOM)
|
|
60
|
+
/// and Web/Service/Shared/Worklet workers (`WorkerGlobalScope` is the
|
|
61
|
+
/// global type in worker contexts). Node/SSR has neither.
|
|
62
|
+
/// Computed on each call (rather than at module load) so tests can
|
|
63
|
+
/// stub the relevant globals.
|
|
64
|
+
function isBrowserLike(): boolean {
|
|
65
|
+
const g = globalThis as Record<string, unknown>
|
|
66
|
+
if (g.document !== undefined) return true
|
|
67
|
+
if (g.WorkerGlobalScope !== undefined) return true
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Throws when `apiKey` is set in a client-side context (main browser
|
|
72
|
+
/// thread or Worker) without explicit `dangerouslyAllowBrowser` opt-in.
|
|
73
|
+
/// Routing through a backend proxy (no `apiKey`, with `baseURL` +
|
|
74
|
+
/// `headers`) bypasses this check because the key never reaches the
|
|
75
|
+
/// client.
|
|
76
|
+
export function assertBrowserSafe(
|
|
77
|
+
config: BaseProviderConfig,
|
|
78
|
+
providerName: string
|
|
79
|
+
): void {
|
|
80
|
+
if (!config.apiKey) return
|
|
81
|
+
if (!isBrowserLike()) return
|
|
82
|
+
if (config.dangerouslyAllowBrowser) return
|
|
83
|
+
throw new Error(
|
|
84
|
+
`[${providerName}] Refusing to send your API key from a browser. ` +
|
|
85
|
+
`Direct browser → provider calls expose the key to every visitor. ` +
|
|
86
|
+
`Either route through your backend (set \`baseURL\` + \`headers\` ` +
|
|
87
|
+
`and omit \`apiKey\`), or set \`dangerouslyAllowBrowser: true\` to ` +
|
|
88
|
+
`acknowledge the risk (recommended only for desktop apps or BYOK setups).`
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Resolve the system prompt. `undefined` → default, `null` → omitted,
|
|
93
|
+
/// string → used as-is.
|
|
94
|
+
export function resolveSystemPrompt(
|
|
95
|
+
systemPrompt: string | null | undefined
|
|
96
|
+
): string | null {
|
|
97
|
+
if (systemPrompt === null) return null
|
|
98
|
+
return systemPrompt ?? DEFAULT_SYSTEM_PROMPT
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Parse an SSE stream from `response.body`. Yields the payload after
|
|
102
|
+
/// `data: ` for each event; ignores `event:`, `id:`, `retry:`, and
|
|
103
|
+
/// comment lines. Stops cleanly when the signal aborts or the stream
|
|
104
|
+
/// ends. Both OpenAI and Anthropic streaming endpoints use this format.
|
|
105
|
+
export async function* parseSSE(
|
|
106
|
+
response: Response,
|
|
107
|
+
signal: AbortSignal
|
|
108
|
+
): AsyncGenerator<string> {
|
|
109
|
+
if (!response.body) return
|
|
110
|
+
const reader = response.body.getReader()
|
|
111
|
+
const decoder = new TextDecoder('utf-8')
|
|
112
|
+
let buffer = ''
|
|
113
|
+
try {
|
|
114
|
+
while (true) {
|
|
115
|
+
if (signal.aborted) return
|
|
116
|
+
const { done, value } = await reader.read()
|
|
117
|
+
if (signal.aborted) return
|
|
118
|
+
if (done) break
|
|
119
|
+
buffer += decoder.decode(value, { stream: true })
|
|
120
|
+
let nl: number
|
|
121
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
122
|
+
const raw = buffer.slice(0, nl)
|
|
123
|
+
buffer = buffer.slice(nl + 1)
|
|
124
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
|
|
125
|
+
if (line.startsWith('data: ')) {
|
|
126
|
+
yield line.slice(6)
|
|
127
|
+
} else if (line.startsWith('data:')) {
|
|
128
|
+
yield line.slice(5)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Flush any trailing data line that wasn't newline-terminated.
|
|
133
|
+
// Only strip a trailing `\r` (handle CRLF without an LF) — never
|
|
134
|
+
// `trim()` here, since the payload's leading/trailing whitespace
|
|
135
|
+
// is significant for streamed token content.
|
|
136
|
+
const tail = buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer
|
|
137
|
+
if (tail.startsWith('data: ')) yield tail.slice(6)
|
|
138
|
+
else if (tail.startsWith('data:')) yield tail.slice(5)
|
|
139
|
+
} finally {
|
|
140
|
+
reader.releaseLock()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// Throw a useful error when the API responds with a non-2xx status.
|
|
145
|
+
/// Reads the body once for diagnostics.
|
|
146
|
+
export async function readErrorBody(
|
|
147
|
+
response: Response,
|
|
148
|
+
providerName: string
|
|
149
|
+
): Promise<Error> {
|
|
150
|
+
let body = ''
|
|
151
|
+
try {
|
|
152
|
+
body = await response.text()
|
|
153
|
+
} catch {
|
|
154
|
+
// ignore — we still want to throw a status-only error
|
|
155
|
+
}
|
|
156
|
+
return new Error(
|
|
157
|
+
`[${providerName}] Request failed with status ${response.status}` +
|
|
158
|
+
(body ? `: ${body.slice(0, 500)}` : '')
|
|
159
|
+
)
|
|
160
|
+
}
|