@adia-ai/llm 0.8.18 → 0.8.20

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.20] — 2026-07-28
4
+
5
+ ### Fixed
6
+ - **The npm tarball no longer ships `*.test.js`** (gh#462) — the `files` roster excluded examples/html but never unit tests, so 4 test file(s) rode the 0.8.19 tarball. `!**/*.test.js` appended as the roster's LAST entry (npm's files globbing is order-sensitive — a leading exclude is overridden by later directory includes). Proof: `npm pack --dry-run` → 0 test files, runtime files intact.
7
+
8
+ ## [0.8.19] — 2026-07-27
9
+
10
+ ### Maintenance
11
+ - **Lockstep version bump only.** No source changes in this package; bumped to maintain the 11-package version coherence enforced by `scripts/release/check-lockstep.mjs`. Substantive v0.8.19 work shipped in form/ cluster publishes (form-popover-ui installable, gh#448) + select-ui summary-label (gh#442) + row-hover container-ladder token fix. See `packages/web-modules/CHANGELOG.md#0819--2026-07-27` for details.
12
+
3
13
  ## [0.8.18] — 2026-07-27
4
14
 
5
15
  ### Maintenance
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adia-ai/llm",
3
- "version": "0.8.18",
4
- "description": "Provider-agnostic LLM client \u2014 anthropic / openai / gemini adapters with a unified chat() + streamChat() facade. Used by AdiaUI's chat-shell and the A2UI generation pipeline; works in browser (with proxyUrl) and Node.",
3
+ "version": "0.8.20",
4
+ "description": "Provider-agnostic LLM client anthropic / openai / gemini adapters with a unified chat() + streamChat() facade. Used by AdiaUI's chat-shell and the A2UI generation pipeline; works in browser (with proxyUrl) and Node.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -53,7 +53,8 @@
53
53
  "index.d.ts.map",
54
54
  "index.js.map",
55
55
  "README.md",
56
- "CHANGELOG.md"
56
+ "CHANGELOG.md",
57
+ "!**/*.test.js"
57
58
  ],
58
59
  "scripts": {
59
60
  "build": "tsc --build tsconfig.build.json",
@@ -1,258 +0,0 @@
1
- /**
2
- * Adapter buildRequest contracts — the per-provider body/header shapes.
3
- *
4
- * These are the boundaries the bridge depends on (exposed via passthroughRequest()
5
- * and as the source of truth for direct-mode requests). Regression-test the
6
- * subtle defaults the README + skill claim:
7
- * - Anthropic: max_tokens defaulted, prompt-cache when opts.cache=true
8
- * - OpenAI: stream_options when streaming, system → first message
9
- * - Gemini: API key in URL, role 'model' for assistant
10
- */
11
-
12
- import { describe, it, expect } from 'vitest';
13
- import { anthropic } from '../adapters/anthropic.js';
14
- import { openai } from '../adapters/openai.js';
15
- import { gemini } from '../adapters/gemini.js';
16
-
17
- describe('anthropic.buildRequest', () => {
18
- it('returns the canonical Anthropic Messages endpoint', () => {
19
- const r = anthropic.buildRequest({ apiKey: 'sk-ant', model: 'claude-haiku-4-5', messages: [] });
20
- expect(r.url).toBe('https://api.anthropic.com/v1/messages');
21
- });
22
-
23
- it('sets x-api-key + anthropic-version + content-type headers', () => {
24
- const r = anthropic.buildRequest({ apiKey: 'sk-ant-key', model: 'claude-haiku-4-5', messages: [] });
25
- expect(r.headers['x-api-key']).toBe('sk-ant-key');
26
- expect(r.headers['anthropic-version']).toBeTruthy();
27
- expect(r.headers['content-type']).toBe('application/json');
28
- });
29
-
30
- it('defaults max_tokens to a non-zero value', () => {
31
- // The README + skill assert a default. Regress if someone removes it.
32
- const r = anthropic.buildRequest({ apiKey: 'k', model: 'claude-haiku-4-5', messages: [] });
33
- expect(r.body.max_tokens).toBeGreaterThan(0);
34
- });
35
-
36
- it('caller-supplied maxTokens overrides default', () => {
37
- const r = anthropic.buildRequest({ apiKey: 'k', model: 'claude-haiku-4-5', messages: [], maxTokens: 4096 });
38
- expect(r.body.max_tokens).toBe(4096);
39
- });
40
-
41
- it('omits system when not provided', () => {
42
- const r = anthropic.buildRequest({ apiKey: 'k', model: 'claude-haiku-4-5', messages: [] });
43
- expect(r.body).not.toHaveProperty('system');
44
- });
45
-
46
- it('passes plain string system when cache=false (default)', () => {
47
- const r = anthropic.buildRequest({
48
- apiKey: 'k',
49
- model: 'claude-haiku-4-5',
50
- messages: [],
51
- system: 'You are helpful.',
52
- });
53
- expect(r.body.system).toBe('You are helpful.');
54
- });
55
-
56
- it('wraps system in cache-control block when cache=true', () => {
57
- const r = anthropic.buildRequest({
58
- apiKey: 'k',
59
- model: 'claude-haiku-4-5',
60
- messages: [],
61
- system: 'You are helpful.',
62
- cache: true,
63
- });
64
- expect(Array.isArray(r.body.system)).toBe(true);
65
- expect(r.body.system[0]).toMatchObject({
66
- type: 'text',
67
- text: 'You are helpful.',
68
- cache_control: { type: 'ephemeral' },
69
- });
70
- });
71
-
72
- it('emits thinking config when opts.thinking=true', () => {
73
- const r = anthropic.buildRequest({
74
- apiKey: 'k',
75
- model: 'claude-sonnet-4-6',
76
- messages: [],
77
- thinking: true,
78
- });
79
- expect(r.body.thinking).toMatchObject({ type: 'enabled' });
80
- expect(r.body.thinking.budget_tokens).toBeGreaterThan(0);
81
- });
82
-
83
- it('respects custom thinkingBudget', () => {
84
- const r = anthropic.buildRequest({
85
- apiKey: 'k',
86
- model: 'claude-sonnet-4-6',
87
- messages: [],
88
- thinking: true,
89
- thinkingBudget: 25000,
90
- });
91
- expect(r.body.thinking.budget_tokens).toBe(25000);
92
- });
93
-
94
- it('reflects stream:true in body', () => {
95
- const r = anthropic.buildRequest({ apiKey: 'k', model: 'claude-haiku-4-5', messages: [], stream: true });
96
- expect(r.body.stream).toBe(true);
97
- });
98
- });
99
-
100
- describe('anthropic.parseResponse', () => {
101
- it('extracts text + usage + stopReason', () => {
102
- const out = anthropic.parseResponse({
103
- content: [{ type: 'text', text: 'hello' }],
104
- usage: { input_tokens: 10, output_tokens: 5 },
105
- stop_reason: 'end_turn',
106
- });
107
- expect(out.text).toBe('hello');
108
- expect(out.usage.input).toBe(10);
109
- expect(out.usage.output).toBe(5);
110
- expect(out.stopReason).toBe('end_turn');
111
- });
112
-
113
- it('records cache telemetry (cacheCreation, cacheRead) when present', () => {
114
- const out = anthropic.parseResponse({
115
- content: [{ type: 'text', text: 'x' }],
116
- usage: {
117
- input_tokens: 100,
118
- output_tokens: 5,
119
- cache_creation_input_tokens: 80,
120
- cache_read_input_tokens: 0,
121
- },
122
- stop_reason: 'end_turn',
123
- });
124
- expect(out.usage.cacheCreation).toBe(80);
125
- expect(out.usage.cacheRead).toBe(0);
126
- });
127
-
128
- it('defaults cache telemetry to 0 when absent (back-compat)', () => {
129
- const out = anthropic.parseResponse({
130
- content: [{ type: 'text', text: 'x' }],
131
- usage: { input_tokens: 1, output_tokens: 1 },
132
- stop_reason: 'end_turn',
133
- });
134
- expect(out.usage.cacheCreation).toBe(0);
135
- expect(out.usage.cacheRead).toBe(0);
136
- });
137
-
138
- it('returns empty text when content is missing', () => {
139
- const out = anthropic.parseResponse({ usage: {}, stop_reason: 'end_turn' });
140
- expect(out.text).toBe('');
141
- });
142
- });
143
-
144
- describe('openai.buildRequest', () => {
145
- it('targets the Chat Completions endpoint with Bearer auth', () => {
146
- const r = openai.buildRequest({ apiKey: 'sk-key', model: 'gpt-4o', messages: [] });
147
- expect(r.url).toBe('https://api.openai.com/v1/chat/completions');
148
- expect(r.headers.authorization).toBe('Bearer sk-key');
149
- });
150
-
151
- it('prepends system as the first message (OpenAI shape)', () => {
152
- const r = openai.buildRequest({
153
- apiKey: 'k',
154
- model: 'gpt-4o',
155
- messages: [{ role: 'user', content: 'hi' }],
156
- system: 'You are concise.',
157
- });
158
- expect(r.body.messages[0]).toEqual({ role: 'system', content: 'You are concise.' });
159
- expect(r.body.messages[1]).toEqual({ role: 'user', content: 'hi' });
160
- });
161
-
162
- it('does NOT prepend system when omitted', () => {
163
- const r = openai.buildRequest({
164
- apiKey: 'k',
165
- model: 'gpt-4o',
166
- messages: [{ role: 'user', content: 'hi' }],
167
- });
168
- expect(r.body.messages).toEqual([{ role: 'user', content: 'hi' }]);
169
- });
170
-
171
- it('emits stream_options.include_usage when streaming', () => {
172
- const r = openai.buildRequest({ apiKey: 'k', model: 'gpt-4o', messages: [], stream: true });
173
- expect(r.body.stream).toBe(true);
174
- expect(r.body.stream_options).toEqual({ include_usage: true });
175
- });
176
-
177
- it('omits stream_options when not streaming', () => {
178
- const r = openai.buildRequest({ apiKey: 'k', model: 'gpt-4o', messages: [] });
179
- expect(r.body.stream_options).toBeUndefined();
180
- });
181
-
182
- it('forwards temperature when specified (including 0)', () => {
183
- const r = openai.buildRequest({ apiKey: 'k', model: 'gpt-4o', messages: [], temperature: 0 });
184
- expect(r.body.temperature).toBe(0);
185
- });
186
- });
187
-
188
- describe('openai.parseResponse', () => {
189
- it('maps stop_reason "stop" → "end" (normalized)', () => {
190
- const out = openai.parseResponse({
191
- choices: [{ message: { content: 'hi' }, finish_reason: 'stop' }],
192
- usage: { prompt_tokens: 3, completion_tokens: 1 },
193
- });
194
- expect(out.stopReason).toBe('end');
195
- });
196
-
197
- it('preserves non-"stop" finish_reasons (e.g. length)', () => {
198
- const out = openai.parseResponse({
199
- choices: [{ message: { content: 'hi' }, finish_reason: 'length' }],
200
- usage: {},
201
- });
202
- expect(out.stopReason).toBe('length');
203
- });
204
- });
205
-
206
- describe('gemini.buildRequest', () => {
207
- it('targets generativelanguage.googleapis.com with the model in the URL path', () => {
208
- const r = gemini.buildRequest({ apiKey: 'AIza-xyz', model: 'gemini-2.5-flash', messages: [] });
209
- expect(r.url).toContain('generativelanguage.googleapis.com');
210
- expect(r.url).toContain('gemini-2.5-flash');
211
- });
212
-
213
- it('uses x-goog-api-key header for auth (not Authorization Bearer or x-api-key)', () => {
214
- const r = gemini.buildRequest({ apiKey: 'AIza-xyz', model: 'gemini-2.5-flash', messages: [] });
215
- expect(r.headers['x-goog-api-key']).toBe('AIza-xyz');
216
- expect(r.headers.authorization).toBeUndefined();
217
- expect(r.headers['x-api-key']).toBeUndefined();
218
- });
219
-
220
- it('maps role:assistant → role:model (Gemini convention)', () => {
221
- const r = gemini.buildRequest({
222
- apiKey: 'k',
223
- model: 'gemini-2.5-flash',
224
- messages: [
225
- { role: 'user', content: 'hi' },
226
- { role: 'assistant', content: 'hello' },
227
- ],
228
- });
229
- expect(r.body.contents[0].role).toBe('user');
230
- expect(r.body.contents[1].role).toBe('model');
231
- });
232
-
233
- it('wraps system in systemInstruction (not first message)', () => {
234
- const r = gemini.buildRequest({
235
- apiKey: 'k',
236
- model: 'gemini-2.5-flash',
237
- messages: [{ role: 'user', content: 'hi' }],
238
- system: 'You are concise.',
239
- });
240
- expect(r.body.systemInstruction).toEqual({ parts: [{ text: 'You are concise.' }] });
241
- // System must NOT be in contents (different from OpenAI shape)
242
- expect(r.body.contents.find((c) => c.parts?.[0]?.text === 'You are concise.')).toBeUndefined();
243
- });
244
-
245
- it('switches to streamGenerateContent endpoint when streaming', () => {
246
- const direct = gemini.buildRequest({ apiKey: 'k', model: 'gemini-2.5-flash', messages: [] });
247
- const stream = gemini.buildRequest({ apiKey: 'k', model: 'gemini-2.5-flash', messages: [], stream: true });
248
- expect(direct.url).toContain(':generateContent');
249
- expect(direct.url).not.toContain('stream');
250
- expect(stream.url).toContain('streamGenerateContent');
251
- expect(stream.url).toContain('alt=sse');
252
- });
253
-
254
- it('puts maxTokens into generationConfig.maxOutputTokens (Gemini-specific name)', () => {
255
- const r = gemini.buildRequest({ apiKey: 'k', model: 'gemini-2.5-flash', messages: [], maxTokens: 4096 });
256
- expect(r.body.generationConfig.maxOutputTokens).toBe(4096);
257
- });
258
- });
@@ -1,71 +0,0 @@
1
- /**
2
- * createClient — defaults baked in, per-call overrides.
3
- */
4
-
5
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
- import { createClient } from '../adapters/index.js';
7
-
8
- let lastFetch;
9
-
10
- function ok(body) {
11
- return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
12
- }
13
-
14
- beforeEach(() => {
15
- lastFetch = null;
16
- globalThis.fetch = vi.fn(async (url, init) => {
17
- lastFetch = { url, init, body: init?.body ? JSON.parse(init.body) : null };
18
- // Anthropic-shaped response (default)
19
- return ok({ content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 1, output_tokens: 1 }, stop_reason: 'end_turn' });
20
- });
21
- });
22
-
23
- afterEach(() => vi.restoreAllMocks());
24
-
25
- describe('createClient', () => {
26
- it('returns { chat, stream } functions', () => {
27
- const client = createClient({ provider: 'anthropic', apiKey: 'k' });
28
- expect(typeof client.chat).toBe('function');
29
- expect(typeof client.stream).toBe('function');
30
- });
31
-
32
- it('bakes defaults into chat() calls', async () => {
33
- const client = createClient({
34
- provider: 'anthropic',
35
- apiKey: 'sk-baked',
36
- model: 'claude-haiku-4-5',
37
- });
38
- await client.chat({ messages: [{ role: 'user', content: 'hi' }] });
39
- expect(lastFetch.url).toContain('api.anthropic.com');
40
- expect(lastFetch.init.headers['x-api-key']).toBe('sk-baked');
41
- expect(lastFetch.body.model).toBe('claude-haiku-4-5');
42
- });
43
-
44
- it('per-call options override defaults', async () => {
45
- const client = createClient({
46
- provider: 'anthropic',
47
- apiKey: 'sk-default',
48
- model: 'claude-haiku-4-5',
49
- });
50
- await client.chat({
51
- apiKey: 'sk-override',
52
- model: 'claude-sonnet-4-6',
53
- messages: [{ role: 'user', content: 'hi' }],
54
- });
55
- expect(lastFetch.init.headers['x-api-key']).toBe('sk-override');
56
- expect(lastFetch.body.model).toBe('claude-sonnet-4-6');
57
- });
58
-
59
- it('default proxyUrl is forwarded to chat()', async () => {
60
- const client = createClient({
61
- provider: 'anthropic',
62
- apiKey: 'k',
63
- model: 'claude-haiku-4-5',
64
- proxyUrl: '/api/chat',
65
- });
66
- await client.chat({ messages: [{ role: 'user', content: 'hi' }] });
67
- expect(lastFetch.url).toBe('/api/chat');
68
- // proxyRequest body shape
69
- expect(lastFetch.body).toHaveProperty('provider', 'anthropic');
70
- });
71
- });
@@ -1,244 +0,0 @@
1
- /**
2
- * Adapter router internals — detectProvider, isPassthroughProxy, body shapes.
3
- *
4
- * These functions are not exported but are tested through the public chat()/
5
- * streamChat() surface. We exercise them by intercepting fetch() and asserting
6
- * on the request URL/headers/body shape.
7
- *
8
- * Critical invariants:
9
- * - detectProvider correctly routes Claude/GPT/Gemini model names
10
- * - explicit `provider` overrides detection
11
- * - unknown model + no provider throws
12
- * - passthrough URLs trigger adapter.buildRequest() (real upstream shape + auth)
13
- * - smart-proxy URLs trigger proxyRequest() (provider-neutral body, no auth)
14
- * - The PASSTHROUGH_PROXY_RE regex only matches /api/llm/<provider>/ shapes
15
- */
16
-
17
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
18
- import { chat } from '../adapters/index.js';
19
-
20
- let fetchMock;
21
- let lastFetch;
22
-
23
- function ok(body) {
24
- return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
25
- }
26
-
27
- beforeEach(() => {
28
- lastFetch = null;
29
- fetchMock = vi.fn(async (url, init) => {
30
- lastFetch = { url, init, body: init?.body ? JSON.parse(init.body) : null };
31
- // Generic minimal valid response per provider — chat() parseResponse only
32
- // needs minimal fields here since we're testing routing, not parsing.
33
- if (typeof url === 'string' && url.includes('anthropic')) {
34
- return ok({ content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 1, output_tokens: 1 }, stop_reason: 'end_turn' });
35
- }
36
- if (typeof url === 'string' && url.includes('openai')) {
37
- return ok({ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } });
38
- }
39
- if (typeof url === 'string' && url.includes('googleapis') || (typeof url === 'string' && url.includes('gemini'))) {
40
- return ok({ candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } });
41
- }
42
- // Smart-proxy / generic — return anthropic-shaped (default)
43
- return ok({ content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 1, output_tokens: 1 }, stop_reason: 'end_turn' });
44
- });
45
- globalThis.fetch = fetchMock;
46
- });
47
-
48
- afterEach(() => {
49
- vi.restoreAllMocks();
50
- });
51
-
52
- describe('detectProvider (via chat() routing)', () => {
53
- const cases = [
54
- { model: 'claude-haiku-4-5-20251001', expectUrl: 'api.anthropic.com' },
55
- { model: 'claude-sonnet-4-6', expectUrl: 'api.anthropic.com' },
56
- { model: 'anthropic/claude-foo', expectUrl: 'api.anthropic.com' },
57
- { model: 'gpt-4o-mini', expectUrl: 'api.openai.com' },
58
- { model: 'gpt-4o', expectUrl: 'api.openai.com' },
59
- { model: 'o1-preview', expectUrl: 'api.openai.com' },
60
- { model: 'o3-mini', expectUrl: 'api.openai.com' },
61
- { model: 'o4-mini', expectUrl: 'api.openai.com' },
62
- { model: 'openai/gpt-foo', expectUrl: 'api.openai.com' },
63
- { model: 'gemini-2.5-flash', expectUrl: 'generativelanguage' },
64
- { model: 'google/gemini-pro', expectUrl: 'generativelanguage' },
65
- ];
66
-
67
- for (const c of cases) {
68
- it(`routes ${c.model} → ${c.expectUrl}`, async () => {
69
- await chat({ apiKey: 'k', model: c.model, messages: [{ role: 'user', content: 'hi' }] });
70
- expect(lastFetch.url).toContain(c.expectUrl);
71
- });
72
- }
73
-
74
- it('throws when model is unrecognized and no provider given', async () => {
75
- await expect(
76
- chat({ apiKey: 'k', model: 'totally-fake-model', messages: [] })
77
- ).rejects.toThrow(/Cannot detect provider/);
78
- });
79
-
80
- it('explicit provider overrides model-name detection', async () => {
81
- // 'gpt-style' name but force anthropic provider — should hit anthropic URL
82
- await chat({ provider: 'anthropic', apiKey: 'k', model: 'gpt-fake', messages: [{ role: 'user', content: 'hi' }] });
83
- expect(lastFetch.url).toContain('api.anthropic.com');
84
- });
85
-
86
- it('throws on unknown explicit provider', async () => {
87
- await expect(
88
- chat({ provider: 'notreal', apiKey: 'k', model: 'gpt-4o', messages: [] })
89
- ).rejects.toThrow(/Unknown provider/);
90
- });
91
- });
92
-
93
- describe('isPassthroughProxy URL routing', () => {
94
- it('passthrough URL → adapter buildRequest (real upstream body + auth header)', async () => {
95
- await chat({
96
- apiKey: 'sk-ant-abc',
97
- model: 'claude-haiku-4-5-20251001',
98
- messages: [{ role: 'user', content: 'hi' }],
99
- proxyUrl: '/api/llm/anthropic/v1/messages',
100
- });
101
- // URL should be the passthrough URL (not api.anthropic.com)
102
- expect(lastFetch.url).toBe('/api/llm/anthropic/v1/messages');
103
- // Headers should include x-api-key (anthropic adapter auth)
104
- expect(lastFetch.init.headers['x-api-key']).toBe('sk-ant-abc');
105
- expect(lastFetch.init.headers['anthropic-version']).toBeDefined();
106
- // Body should be Anthropic-shaped (max_tokens, not maxTokens)
107
- expect(lastFetch.body).toHaveProperty('max_tokens');
108
- expect(lastFetch.body).not.toHaveProperty('provider'); // no provider-neutral key
109
- });
110
-
111
- it('smart-proxy URL → proxyRequest (provider-neutral body, no auth header)', async () => {
112
- await chat({
113
- apiKey: 'sk-ant-abc',
114
- model: 'claude-haiku-4-5-20251001',
115
- messages: [{ role: 'user', content: 'hi' }],
116
- proxyUrl: '/api/chat',
117
- });
118
- expect(lastFetch.url).toBe('/api/chat');
119
- // No upstream auth headers — proxy holds the key
120
- expect(lastFetch.init.headers['x-api-key']).toBeUndefined();
121
- expect(lastFetch.init.headers['authorization']).toBeUndefined();
122
- // Body should be provider-neutral
123
- expect(lastFetch.body).toMatchObject({
124
- provider: 'anthropic',
125
- model: 'claude-haiku-4-5-20251001',
126
- messages: [{ role: 'user', content: 'hi' }],
127
- });
128
- // No upstream-specific keys
129
- expect(lastFetch.body).not.toHaveProperty('max_tokens');
130
- });
131
-
132
- it('passthrough regex distinguishes /api/llm/<provider>/ from /api/llm-foo/', async () => {
133
- // '/api/llm-foo' is NOT a passthrough — should go through proxyRequest
134
- await chat({
135
- apiKey: 'k',
136
- model: 'gpt-4o',
137
- messages: [{ role: 'user', content: 'hi' }],
138
- proxyUrl: '/api/llm-similar/something',
139
- });
140
- // proxyRequest body has 'provider' key
141
- expect(lastFetch.body).toHaveProperty('provider');
142
- });
143
-
144
- // Passthrough swaps ONLY the upstream origin (v0.8.4): the proxy prefix
145
- // (up to the provider segment) joins buildRequest()'s own path + query —
146
- // a static full-URL swap dropped gemini's `models/<model>:<action>` path.
147
- // These anthropic-bodied requests therefore always land on the prefix +
148
- // anthropic's /v1/messages, whatever trailing path the proxyUrl carried.
149
- const passthroughShapes = [
150
- ['/api/llm/anthropic/v1/messages', '/api/llm/anthropic/v1/messages'],
151
- ['/api/llm/openai/v1/chat/completions', '/api/llm/openai/v1/messages'],
152
- ['/api/llm/gemini/foo', '/api/llm/gemini/v1/messages'],
153
- ['/api/llm/anthropic', '/api/llm/anthropic/v1/messages'], // bare provider prefix works now
154
- ];
155
- for (const [url, expected] of passthroughShapes) {
156
- it(`recognizes ${url} as passthrough`, async () => {
157
- await chat({
158
- apiKey: 'k',
159
- model: 'claude-haiku-4-5-20251001',
160
- messages: [{ role: 'user', content: 'hi' }],
161
- proxyUrl: url,
162
- });
163
- expect(lastFetch.url).toBe(expected);
164
- // Passthrough body: anthropic-shaped (no `provider` key)
165
- expect(lastFetch.body).not.toHaveProperty('provider');
166
- });
167
- }
168
-
169
- it('passthrough keeps the upstream path + query (the gemini model:action fix)', async () => {
170
- await chat({
171
- apiKey: 'k',
172
- model: 'gemini-2.5-flash',
173
- messages: [{ role: 'user', content: 'hi' }],
174
- proxyUrl: '/api/llm/gemini',
175
- });
176
- expect(lastFetch.url).toBe('/api/llm/gemini/v1beta/models/gemini-2.5-flash:generateContent');
177
- expect(lastFetch.body).not.toHaveProperty('provider');
178
- });
179
- });
180
-
181
- describe('proxyRequest body shape', () => {
182
- it('forwards optional fields when present', async () => {
183
- await chat({
184
- apiKey: 'k',
185
- model: 'gpt-4o-mini',
186
- messages: [{ role: 'user', content: 'hi' }],
187
- proxyUrl: '/api/chat',
188
- system: 'You are concise.',
189
- maxTokens: 1024,
190
- temperature: 0.5,
191
- thinking: true,
192
- });
193
- expect(lastFetch.body).toMatchObject({
194
- provider: 'openai',
195
- system: 'You are concise.',
196
- maxTokens: 1024,
197
- temperature: 0.5,
198
- thinking: true,
199
- stream: false,
200
- });
201
- });
202
-
203
- it('omits optional fields when undefined (no null pollution)', async () => {
204
- await chat({
205
- apiKey: 'k',
206
- model: 'gpt-4o',
207
- messages: [{ role: 'user', content: 'hi' }],
208
- proxyUrl: '/api/chat',
209
- });
210
- expect(lastFetch.body).not.toHaveProperty('system');
211
- expect(lastFetch.body).not.toHaveProperty('maxTokens');
212
- expect(lastFetch.body).not.toHaveProperty('temperature');
213
- expect(lastFetch.body).not.toHaveProperty('thinking');
214
- });
215
-
216
- it('temperature: 0 is forwarded (not coerced to omitted)', async () => {
217
- await chat({
218
- apiKey: 'k',
219
- model: 'gpt-4o',
220
- messages: [{ role: 'user', content: 'hi' }],
221
- proxyUrl: '/api/chat',
222
- temperature: 0,
223
- });
224
- expect(lastFetch.body.temperature).toBe(0);
225
- });
226
- });
227
-
228
- describe('error handling', () => {
229
- it('rejects on upstream error response with adapter-tagged message', async () => {
230
- fetchMock.mockResolvedValueOnce(
231
- new Response(JSON.stringify({ error: { message: 'boom' } }), { status: 400 })
232
- );
233
- await expect(
234
- chat({ apiKey: 'k', model: 'claude-haiku-4-5-20251001', messages: [] })
235
- ).rejects.toThrow('boom');
236
- });
237
-
238
- it('falls back to "API error <status>" when upstream JSON missing error.message', async () => {
239
- fetchMock.mockResolvedValueOnce(new Response('not json', { status: 500 }));
240
- await expect(
241
- chat({ apiKey: 'k', model: 'claude-haiku-4-5-20251001', messages: [] })
242
- ).rejects.toThrow(/anthropic API error 500/);
243
- });
244
- });
@@ -1,108 +0,0 @@
1
- /**
2
- * SSE parser — partial-line buffering, double-newline splitting, [DONE] detection.
3
- *
4
- * The SSE parser is consumed by all 3 adapter parseStream functions so a bug
5
- * here would silently corrupt streaming for every provider. The parser is
6
- * exercised against synthetic ReadableStreams that emulate real upstream
7
- * chunking patterns (split mid-line, split mid-data, [DONE] termination).
8
- */
9
-
10
- import { describe, it, expect } from 'vitest';
11
- import { readSSE } from '../adapters/sse.js';
12
-
13
- /** Helper: build a Response.body-like ReadableStream from a list of byte chunks. */
14
- function streamOf(...chunks) {
15
- const encoder = new TextEncoder();
16
- return new ReadableStream({
17
- start(controller) {
18
- for (const c of chunks) controller.enqueue(encoder.encode(c));
19
- controller.close();
20
- },
21
- });
22
- }
23
-
24
- async function collect(stream) {
25
- const events = [];
26
- for await (const ev of readSSE(stream)) events.push(ev);
27
- return events;
28
- }
29
-
30
- describe('readSSE', () => {
31
- it('parses a single complete event', async () => {
32
- const events = await collect(streamOf('data: hello\n\n'));
33
- expect(events).toEqual([{ event: undefined, data: 'hello', done: false }]);
34
- });
35
-
36
- it('parses event-typed messages', async () => {
37
- const events = await collect(streamOf('event: ping\ndata: {}\n\n'));
38
- expect(events).toEqual([{ event: 'ping', data: '{}', done: false }]);
39
- });
40
-
41
- it('strips the optional leading space after data:', async () => {
42
- const events = await collect(streamOf('data: leading-space\n\ndata:no-space\n\n'));
43
- expect(events.map((e) => e.data)).toEqual(['leading-space', 'no-space']);
44
- });
45
-
46
- it('joins multi-line data with newlines', async () => {
47
- const events = await collect(streamOf('data: line1\ndata: line2\n\n'));
48
- expect(events).toEqual([{ event: undefined, data: 'line1\nline2', done: false }]);
49
- });
50
-
51
- it('skips comment lines (lines starting with ":")', async () => {
52
- const events = await collect(streamOf(': keep-alive\ndata: payload\n\n'));
53
- expect(events).toEqual([{ event: undefined, data: 'payload', done: false }]);
54
- });
55
-
56
- it('detects [DONE] sentinel', async () => {
57
- const events = await collect(streamOf('data: [DONE]\n\n'));
58
- expect(events[0]).toMatchObject({ data: '[DONE]', done: true });
59
- });
60
-
61
- it('does not flag [DONE] in the middle of arbitrary data', async () => {
62
- const events = await collect(streamOf('data: not-quite[DONE]\n\n'));
63
- expect(events[0].done).toBe(false);
64
- });
65
-
66
- it('handles partial-line buffering across chunk boundaries', async () => {
67
- // One event split across THREE chunks at arbitrary points.
68
- const events = await collect(streamOf('data: he', 'l', 'lo\n\n'));
69
- expect(events).toEqual([{ event: undefined, data: 'hello', done: false }]);
70
- });
71
-
72
- it('handles double-newline split across chunk boundary', async () => {
73
- // Event terminator split between chunks.
74
- const events = await collect(streamOf('data: a\n', '\ndata: b\n\n'));
75
- expect(events.map((e) => e.data)).toEqual(['a', 'b']);
76
- });
77
-
78
- it('handles \\r\\n line endings (Windows-style upstream)', async () => {
79
- const events = await collect(streamOf('event: x\r\ndata: y\r\n\r\n'));
80
- expect(events).toEqual([{ event: 'x', data: 'y', done: false }]);
81
- });
82
-
83
- it('flushes a trailing event without final \\n\\n', async () => {
84
- // Some upstreams close the stream without a final blank line.
85
- const events = await collect(streamOf('data: trailing'));
86
- expect(events).toEqual([{ event: undefined, data: 'trailing', done: false }]);
87
- });
88
-
89
- it('skips empty-data events', async () => {
90
- // event: foo with no data: should NOT yield (no dataLines)
91
- const events = await collect(streamOf('event: foo\n\ndata: real\n\n'));
92
- expect(events).toEqual([{ event: undefined, data: 'real', done: false }]);
93
- });
94
-
95
- it('handles a long stream of mixed events', async () => {
96
- const events = await collect(
97
- streamOf(
98
- 'data: 1\n\n',
99
- ': keep-alive\n\n',
100
- 'event: tick\ndata: 2\n\n',
101
- 'data: 3\n\n',
102
- 'data: [DONE]\n\n'
103
- )
104
- );
105
- expect(events.map((e) => e.data)).toEqual(['1', '2', '3', '[DONE]']);
106
- expect(events.at(-1).done).toBe(true);
107
- });
108
- });