@milkdown/crepe 7.20.0 → 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.
Files changed (173) hide show
  1. package/lib/cjs/builder.js +1 -0
  2. package/lib/cjs/builder.js.map +1 -1
  3. package/lib/cjs/feature/ai/index.js +1492 -0
  4. package/lib/cjs/feature/ai/index.js.map +1 -0
  5. package/lib/cjs/feature/block-edit/index.js +1 -0
  6. package/lib/cjs/feature/block-edit/index.js.map +1 -1
  7. package/lib/cjs/feature/code-mirror/index.js +1 -0
  8. package/lib/cjs/feature/code-mirror/index.js.map +1 -1
  9. package/lib/cjs/feature/cursor/index.js +1 -0
  10. package/lib/cjs/feature/cursor/index.js.map +1 -1
  11. package/lib/cjs/feature/image-block/index.js +1 -0
  12. package/lib/cjs/feature/image-block/index.js.map +1 -1
  13. package/lib/cjs/feature/latex/index.js +2 -0
  14. package/lib/cjs/feature/latex/index.js.map +1 -1
  15. package/lib/cjs/feature/link-tooltip/index.js +1 -0
  16. package/lib/cjs/feature/link-tooltip/index.js.map +1 -1
  17. package/lib/cjs/feature/list-item/index.js +1 -0
  18. package/lib/cjs/feature/list-item/index.js.map +1 -1
  19. package/lib/cjs/feature/placeholder/index.js +1 -0
  20. package/lib/cjs/feature/placeholder/index.js.map +1 -1
  21. package/lib/cjs/feature/table/index.js +1 -0
  22. package/lib/cjs/feature/table/index.js.map +1 -1
  23. package/lib/cjs/feature/toolbar/index.js +488 -3
  24. package/lib/cjs/feature/toolbar/index.js.map +1 -1
  25. package/lib/cjs/feature/top-bar/index.js +1 -0
  26. package/lib/cjs/feature/top-bar/index.js.map +1 -1
  27. package/lib/cjs/index.js +1424 -25
  28. package/lib/cjs/index.js.map +1 -1
  29. package/lib/cjs/llm-providers/anthropic/index.js +147 -0
  30. package/lib/cjs/llm-providers/anthropic/index.js.map +1 -0
  31. package/lib/cjs/llm-providers/openai/index.js +138 -0
  32. package/lib/cjs/llm-providers/openai/index.js.map +1 -0
  33. package/lib/esm/builder.js +1 -0
  34. package/lib/esm/builder.js.map +1 -1
  35. package/lib/esm/feature/ai/index.js +1487 -0
  36. package/lib/esm/feature/ai/index.js.map +1 -0
  37. package/lib/esm/feature/block-edit/index.js +1 -0
  38. package/lib/esm/feature/block-edit/index.js.map +1 -1
  39. package/lib/esm/feature/code-mirror/index.js +1 -0
  40. package/lib/esm/feature/code-mirror/index.js.map +1 -1
  41. package/lib/esm/feature/cursor/index.js +1 -0
  42. package/lib/esm/feature/cursor/index.js.map +1 -1
  43. package/lib/esm/feature/image-block/index.js +1 -0
  44. package/lib/esm/feature/image-block/index.js.map +1 -1
  45. package/lib/esm/feature/latex/index.js +2 -0
  46. package/lib/esm/feature/latex/index.js.map +1 -1
  47. package/lib/esm/feature/link-tooltip/index.js +1 -0
  48. package/lib/esm/feature/link-tooltip/index.js.map +1 -1
  49. package/lib/esm/feature/list-item/index.js +1 -0
  50. package/lib/esm/feature/list-item/index.js.map +1 -1
  51. package/lib/esm/feature/placeholder/index.js +1 -0
  52. package/lib/esm/feature/placeholder/index.js.map +1 -1
  53. package/lib/esm/feature/table/index.js +1 -0
  54. package/lib/esm/feature/table/index.js.map +1 -1
  55. package/lib/esm/feature/toolbar/index.js +490 -5
  56. package/lib/esm/feature/toolbar/index.js.map +1 -1
  57. package/lib/esm/feature/top-bar/index.js +1 -0
  58. package/lib/esm/feature/top-bar/index.js.map +1 -1
  59. package/lib/esm/index.js +1414 -15
  60. package/lib/esm/index.js.map +1 -1
  61. package/lib/esm/llm-providers/anthropic/index.js +145 -0
  62. package/lib/esm/llm-providers/anthropic/index.js.map +1 -0
  63. package/lib/esm/llm-providers/openai/index.js +136 -0
  64. package/lib/esm/llm-providers/openai/index.js.map +1 -0
  65. package/lib/theme/common/ai.css +446 -0
  66. package/lib/theme/common/code-mirror.css +14 -0
  67. package/lib/theme/common/diff.css +177 -0
  68. package/lib/theme/common/style.css +2 -0
  69. package/lib/tsconfig.tsbuildinfo +1 -1
  70. package/lib/types/feature/ai/ai.spec.d.ts +2 -0
  71. package/lib/types/feature/ai/ai.spec.d.ts.map +1 -0
  72. package/lib/types/feature/ai/commands.d.ts +24 -0
  73. package/lib/types/feature/ai/commands.d.ts.map +1 -0
  74. package/lib/types/feature/ai/context.d.ts +4 -0
  75. package/lib/types/feature/ai/context.d.ts.map +1 -0
  76. package/lib/types/feature/ai/diff-actions/index.d.ts +12 -0
  77. package/lib/types/feature/ai/diff-actions/index.d.ts.map +1 -0
  78. package/lib/types/feature/ai/diff-actions/view.d.ts +21 -0
  79. package/lib/types/feature/ai/diff-actions/view.d.ts.map +1 -0
  80. package/lib/types/feature/ai/index.d.ts +7 -0
  81. package/lib/types/feature/ai/index.d.ts.map +1 -0
  82. package/lib/types/feature/ai/instruction-tooltip/component.d.ts +26 -0
  83. package/lib/types/feature/ai/instruction-tooltip/component.d.ts.map +1 -0
  84. package/lib/types/feature/ai/instruction-tooltip/index.d.ts +17 -0
  85. package/lib/types/feature/ai/instruction-tooltip/index.d.ts.map +1 -0
  86. package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts +50 -0
  87. package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts.map +1 -0
  88. package/lib/types/feature/ai/instruction-tooltip/view.d.ts +19 -0
  89. package/lib/types/feature/ai/instruction-tooltip/view.d.ts.map +1 -0
  90. package/lib/types/feature/ai/streaming-indicator.d.ts +9 -0
  91. package/lib/types/feature/ai/streaming-indicator.d.ts.map +1 -0
  92. package/lib/types/feature/ai/types.d.ts +58 -0
  93. package/lib/types/feature/ai/types.d.ts.map +1 -0
  94. package/lib/types/feature/index.d.ts +4 -1
  95. package/lib/types/feature/index.d.ts.map +1 -1
  96. package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts +2 -0
  97. package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts.map +1 -0
  98. package/lib/types/feature/latex/inline-tooltip/view.d.ts.map +1 -1
  99. package/lib/types/feature/loader.d.ts.map +1 -1
  100. package/lib/types/feature/toolbar/config.d.ts.map +1 -1
  101. package/lib/types/feature/toolbar/index.d.ts +1 -0
  102. package/lib/types/feature/toolbar/index.d.ts.map +1 -1
  103. package/lib/types/icons/ai.d.ts +2 -0
  104. package/lib/types/icons/ai.d.ts.map +1 -0
  105. package/lib/types/icons/chevron-left.d.ts +2 -0
  106. package/lib/types/icons/chevron-left.d.ts.map +1 -0
  107. package/lib/types/icons/chevron-right.d.ts +2 -0
  108. package/lib/types/icons/chevron-right.d.ts.map +1 -0
  109. package/lib/types/icons/enter-key.d.ts +2 -0
  110. package/lib/types/icons/enter-key.d.ts.map +1 -0
  111. package/lib/types/icons/grammar-check.d.ts +2 -0
  112. package/lib/types/icons/grammar-check.d.ts.map +1 -0
  113. package/lib/types/icons/index.d.ts +11 -0
  114. package/lib/types/icons/index.d.ts.map +1 -1
  115. package/lib/types/icons/longer.d.ts +2 -0
  116. package/lib/types/icons/longer.d.ts.map +1 -0
  117. package/lib/types/icons/retry.d.ts +2 -0
  118. package/lib/types/icons/retry.d.ts.map +1 -0
  119. package/lib/types/icons/send-prompt.d.ts +2 -0
  120. package/lib/types/icons/send-prompt.d.ts.map +1 -0
  121. package/lib/types/icons/send.d.ts +2 -0
  122. package/lib/types/icons/send.d.ts.map +1 -0
  123. package/lib/types/icons/shorter.d.ts +2 -0
  124. package/lib/types/icons/shorter.d.ts.map +1 -0
  125. package/lib/types/icons/translate.d.ts +2 -0
  126. package/lib/types/icons/translate.d.ts.map +1 -0
  127. package/lib/types/llm-providers/anthropic/index.d.ts +21 -0
  128. package/lib/types/llm-providers/anthropic/index.d.ts.map +1 -0
  129. package/lib/types/llm-providers/openai/index.d.ts +15 -0
  130. package/lib/types/llm-providers/openai/index.d.ts.map +1 -0
  131. package/lib/types/llm-providers/providers.spec.d.ts +2 -0
  132. package/lib/types/llm-providers/providers.spec.d.ts.map +1 -0
  133. package/lib/types/llm-providers/shared.d.ts +16 -0
  134. package/lib/types/llm-providers/shared.d.ts.map +1 -0
  135. package/package.json +18 -2
  136. package/src/feature/ai/ai.spec.ts +742 -0
  137. package/src/feature/ai/commands.ts +257 -0
  138. package/src/feature/ai/context.ts +45 -0
  139. package/src/feature/ai/diff-actions/index.ts +95 -0
  140. package/src/feature/ai/diff-actions/view.ts +237 -0
  141. package/src/feature/ai/index.ts +118 -0
  142. package/src/feature/ai/instruction-tooltip/component.tsx +414 -0
  143. package/src/feature/ai/instruction-tooltip/index.ts +101 -0
  144. package/src/feature/ai/instruction-tooltip/suggestions.ts +249 -0
  145. package/src/feature/ai/instruction-tooltip/view.ts +159 -0
  146. package/src/feature/ai/streaming-indicator.ts +183 -0
  147. package/src/feature/ai/types.ts +178 -0
  148. package/src/feature/index.ts +8 -2
  149. package/src/feature/latex/inline-tooltip/inline-tooltip.spec.ts +81 -0
  150. package/src/feature/latex/inline-tooltip/view.ts +2 -0
  151. package/src/feature/loader.ts +4 -0
  152. package/src/feature/toolbar/config.ts +27 -1
  153. package/src/feature/toolbar/index.ts +1 -0
  154. package/src/icons/ai.ts +14 -0
  155. package/src/icons/chevron-left.ts +15 -0
  156. package/src/icons/chevron-right.ts +15 -0
  157. package/src/icons/enter-key.ts +13 -0
  158. package/src/icons/grammar-check.ts +13 -0
  159. package/src/icons/index.ts +11 -0
  160. package/src/icons/longer.ts +13 -0
  161. package/src/icons/retry.ts +13 -0
  162. package/src/icons/send-prompt.ts +13 -0
  163. package/src/icons/send.ts +13 -0
  164. package/src/icons/shorter.ts +13 -0
  165. package/src/icons/translate.ts +13 -0
  166. package/src/llm-providers/anthropic/index.ts +132 -0
  167. package/src/llm-providers/openai/index.ts +109 -0
  168. package/src/llm-providers/providers.spec.ts +472 -0
  169. package/src/llm-providers/shared.ts +160 -0
  170. package/src/theme/common/ai.css +430 -0
  171. package/src/theme/common/code-mirror.css +14 -0
  172. package/src/theme/common/diff.css +196 -0
  173. package/src/theme/common/style.css +2 -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
+ }