@adia-ai/llm 0.3.1 → 0.3.3

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
@@ -8,6 +8,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
 
9
9
  _No pending changes._
10
10
 
11
+ ## [0.3.3] - 2026-05-07
12
+
13
+ **Lockstep cut.** All 9 published `@adia-ai/*` packages now share version `0.3.3`, governed by [`docs/specs/package-architecture.md` § 15](../../../docs/specs/package-architecture.md#15-versioning-policy). Internal `@adia-ai/*` ranges stay at `^0.3.0` (patch-cut asymmetry — caret floats `0.3.x`).
14
+
15
+ ### Fixed
16
+
17
+ - **Browser proxy 401 in passthrough mode** (`c639bda8`, post-v0.3.2).
18
+ `chat()` and `streamChat()` always routed `proxyUrl` through
19
+ `proxyRequest()` which sends a provider-neutral body — that contract
20
+ fits the smart proxy at `packages/llm/server.js` but breaks the
21
+ Vite-dev passthrough proxy (`/api/llm/<provider>/<rest>` → real
22
+ upstream URL), which expects the adapter's real upstream body shape
23
+ + auth headers. Added `isPassthroughProxy(proxyUrl)` URL-shape
24
+ detection (regex `/\/api\/llm\/[a-z]+(\/|$)/`) and a new
25
+ `passthroughRequest()` that calls `adapter.buildRequest()` (real
26
+ upstream body + adapter auth) with the URL replaced. Smart-proxy
27
+ routes continue to use `proxyRequest()`. Documented in
28
+ `packages/llm/README.md` § Browser proxy mode.
29
+
30
+ ### Documentation
31
+
32
+ - README expanded to document the dual-proxy architecture (smart vs.
33
+ passthrough), with side-by-side comparison table and Vite-proxy
34
+ example for the passthrough case (`b00542d7`).
35
+
36
+ ### Tests
37
+
38
+ - **86 unit tests landed** across 6 files (closes backlog #15 — the
39
+ package previously had zero coverage):
40
+ - `models.test.js` (7) — MODELS shape contract that 3 apps depend on
41
+ - `adapters/router.test.js` (26) — detectProvider, isPassthroughProxy,
42
+ smart-proxy vs passthrough dispatch, error paths
43
+ - `adapters/build-request.test.js` (28) — anthropic prompt-cache,
44
+ thinking budget, openai system-prepend, gemini systemInstruction +
45
+ role:assistant→model + endpoint switching
46
+ - `adapters/sse.test.js` (13) — partial-line buffering, double-newline
47
+ splits across chunk boundaries, [DONE] sentinel, \\r\\n line endings
48
+ - `adapters/client.test.js` (4) — createClient defaults + override
49
+ - `llm-stub.test.js` (8) — StubLLMAdapter contract for free-tier evals
50
+ - New `npm run test:llm` script for package-scoped runs.
51
+
52
+ ## [0.3.2] - 2026-05-06
53
+
54
+ **9-package lockstep patch cut to v0.3.2.** All lockstep members share
55
+ one version per [`docs/specs/package-architecture.md` § 15](../../docs/specs/package-architecture.md#15-versioning-policy).
56
+ Internal `@adia-ai/*` dep ranges unchanged at `^0.3.0`.
57
+
58
+ ### No source changes
59
+
60
+ This package's source is byte-identical to v0.3.1. The cut bumps
61
+ version only.
62
+
63
+ ### Changed
64
+
65
+ - `version`: `0.3.1` → `0.3.2`.
66
+
11
67
  ## [0.3.1] - 2026-05-06
12
68
 
13
69
  **9-package lockstep patch cut.** All 9 published `@adia-ai/*` packages bump 0.3.0 → 0.3.1 per [`docs/specs/package-architecture.md` § 15](../../docs/specs/package-architecture.md#15-versioning-policy). Internal `@adia-ai/*` dep ranges remain at `^0.3.0` (covers `0.3.1` under semver — patch-cut asymmetry).
package/README.md CHANGED
@@ -25,8 +25,16 @@ for await (const chunk of streamChat({
25
25
 
26
26
  ## Browser proxy mode
27
27
 
28
- Pass `proxyUrl` to route through your server-side proxy (which holds the
29
- API key). The client speaks a provider-neutral protocol to the proxy:
28
+ `proxyUrl` routes through a server-side proxy so the API key never
29
+ reaches the browser. The client supports **two proxy shapes** and
30
+ auto-detects which to use based on the URL:
31
+
32
+ ### Smart proxy (provider-neutral body)
33
+
34
+ The default. Send any `proxyUrl` that doesn't match the passthrough
35
+ pattern below — typically your own backend route like `/api/chat`. The
36
+ client speaks a single provider-neutral protocol; the proxy holds the
37
+ API key and dispatches internally to the right upstream adapter.
30
38
 
31
39
  ```js
32
40
  for await (const chunk of streamChat({
@@ -51,9 +59,69 @@ The body sent to the proxy:
51
59
  }
52
60
  ```
53
61
 
54
- The proxy reformats per upstream provider and pipes the SSE bytes
55
- verbatim. The reference proxy implementation is at `server.js` in the
56
- chat-ui repo root.
62
+ The proxy reformats per upstream provider and pipes SSE bytes
63
+ verbatim. The reference smart-proxy implementation is at
64
+ `packages/llm/server.js` in the chat-ui repo (route: `POST /api/chat`,
65
+ plus `/api/generate`, `/api/generate/reset`, `/api/convert-html` for
66
+ the A2UI generation pipeline). It is **not** shipped with the npm
67
+ package — it's a development convenience for the in-repo apps.
68
+
69
+ ### Passthrough proxy (real upstream body)
70
+
71
+ When `proxyUrl` matches `/api/llm/<provider>/<rest>` (the Vite-dev
72
+ shape used by `chat-ui` apps), the client switches to passthrough
73
+ mode. The proxy is "dumb" — it just rewrites the URL to the real
74
+ upstream (`https://api.<provider>.com/<rest>`) and forwards bytes
75
+ unchanged. The client sends the **real upstream body shape** plus the
76
+ adapter's normal auth headers.
77
+
78
+ This is auto-detected — you don't pick it explicitly. If you mounted
79
+ a Vite proxy like:
80
+
81
+ ```js
82
+ // vite.config.js
83
+ server: {
84
+ proxy: {
85
+ '/api/llm/anthropic': {
86
+ target: 'https://api.anthropic.com',
87
+ rewrite: (p) => p.replace(/^\/api\/llm\/anthropic/, ''),
88
+ },
89
+ },
90
+ },
91
+ ```
92
+
93
+ …then passing `proxyUrl: '/api/llm/anthropic/v1/messages'` will
94
+ produce a request the upstream understands directly.
95
+
96
+ | Shape | URL pattern | Body | Auth header | Use when |
97
+ |---|---|---|---|---|
98
+ | Smart | `/api/chat` (anything non-passthrough) | provider-neutral | none (server holds key) | You control the proxy and want one route across providers |
99
+ | Passthrough | `/api/llm/<provider>/<rest>` | real upstream shape | adapter's own (e.g. `x-api-key`) | You're using Vite/nginx URL-rewrite and don't want server-side dispatch |
100
+
101
+ Detection lives in `adapters/index.js` — regex
102
+ `/\/api\/llm\/[a-z]+(\/|$)/`.
103
+
104
+ ### Production deployment
105
+
106
+ Neither `server.js` (smart proxy reference) nor any Vite/nginx URL
107
+ rewrite (passthrough reference) is shipped by the npm package — both
108
+ are development-time conveniences for the in-repo apps. Production
109
+ consumers must **deploy their own proxy**: a small server that holds
110
+ your provider API key(s) and either:
111
+
112
+ 1. **Speaks the smart-proxy contract** — accepts the provider-neutral
113
+ body documented above and dispatches per-provider. See
114
+ `packages/llm/server.js` for a reference implementation (Express +
115
+ `chat`/`streamChat` from this package, ~150 LOC).
116
+ 2. **Speaks the passthrough contract** — exposes
117
+ `/api/llm/<provider>/<rest>` and forwards to
118
+ `https://api.<provider>.com/<rest>` with the real API-key header
119
+ injected server-side. See the Vite config snippet above for the
120
+ shape; a 50-line nginx or Express proxy works fine.
121
+
122
+ Either contract works — the client auto-detects which one your proxy
123
+ implements by URL shape. Pick the one that matches your existing
124
+ infrastructure.
57
125
 
58
126
  ## Subpath exports
59
127
 
@@ -0,0 +1,258 @@
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
+ });
@@ -0,0 +1,71 @@
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
+ });
package/adapters/index.js CHANGED
@@ -62,12 +62,32 @@ function resolveAdapter(opts) {
62
62
 
63
63
  // ── Proxy mode ──
64
64
  //
65
- // When `proxyUrl` is set, the client speaks a provider-neutral protocol
66
- // to the proxy: { provider, model, messages, system?, maxTokens?,
67
- // temperature?, thinking?, stream }. The proxy holds the real API key
68
- // and reformats per upstream provider. Each adapter still parses the
69
- // upstream's streamed body via its own parseStream — the proxy pipes
70
- // the SSE bytes verbatim.
65
+ // Two proxy flavors are supported:
66
+ //
67
+ // 1. Smart proxy (e.g. packages/llm/server.js on :3456) speaks a
68
+ // provider-neutral protocol: { provider, model, messages, system?,
69
+ // maxTokens?, temperature?, thinking?, stream }. The proxy holds
70
+ // the real API key and reformats per upstream provider.
71
+ //
72
+ // 2. Passthrough proxy (e.g. Vite dev server's /api/llm/<provider>/...)
73
+ // — dumb URL rewriter that forwards the request body + headers
74
+ // verbatim to the upstream API. The client must send the real
75
+ // upstream body shape AND the real auth header (x-api-key for
76
+ // Anthropic, Authorization: Bearer for OpenAI/Gemini).
77
+ //
78
+ // We distinguish by URL shape: anything matching `/api/llm/<provider>/`
79
+ // is treated as a passthrough proxy and routed through buildRequest()
80
+ // with the URL replaced. Everything else is assumed to be a smart proxy.
81
+ //
82
+ // Each adapter still parses the upstream's streamed body via its own
83
+ // parseStream — passthrough proxies pipe SSE bytes verbatim, smart
84
+ // proxies must do the same.
85
+
86
+ const PASSTHROUGH_PROXY_RE = /\/api\/llm\/[a-z]+(\/|$)/;
87
+
88
+ function isPassthroughProxy(url) {
89
+ return typeof url === 'string' && PASSTHROUGH_PROXY_RE.test(url);
90
+ }
71
91
 
72
92
  function proxyRequest(opts, stream) {
73
93
  const provider = opts.provider || detectProvider(opts.model);
@@ -88,6 +108,16 @@ function proxyRequest(opts, stream) {
88
108
  };
89
109
  }
90
110
 
111
+ /**
112
+ * Build a passthrough-proxy request: real upstream body + real auth
113
+ * header, but URL pointed at the proxy. The proxy forwards verbatim.
114
+ */
115
+ function passthroughRequest(opts, stream) {
116
+ const adapter = resolveAdapter(opts);
117
+ const built = adapter.buildRequest({ ...opts, stream });
118
+ return { ...built, url: opts.proxyUrl };
119
+ }
120
+
91
121
  // ── Standalone functions ──
92
122
 
93
123
  /**
@@ -97,7 +127,7 @@ function proxyRequest(opts, stream) {
97
127
  export async function chat(opts) {
98
128
  const adapter = resolveAdapter(opts);
99
129
  const { url, headers, body } = opts.proxyUrl
100
- ? proxyRequest(opts, false)
130
+ ? (isPassthroughProxy(opts.proxyUrl) ? passthroughRequest(opts, false) : proxyRequest(opts, false))
101
131
  : adapter.buildRequest({ ...opts, stream: false });
102
132
 
103
133
  const res = await fetch(url, {
@@ -122,7 +152,7 @@ export async function chat(opts) {
122
152
  export async function* streamChat(opts) {
123
153
  const adapter = resolveAdapter(opts);
124
154
  const { url, headers, body } = opts.proxyUrl
125
- ? proxyRequest(opts, true)
155
+ ? (isPassthroughProxy(opts.proxyUrl) ? passthroughRequest(opts, true) : proxyRequest(opts, true))
126
156
  : adapter.buildRequest({ ...opts, stream: true });
127
157
 
128
158
  let res;
@@ -0,0 +1,228 @@
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
+ const passthroughShapes = [
145
+ '/api/llm/anthropic/v1/messages',
146
+ '/api/llm/openai/v1/chat/completions',
147
+ '/api/llm/gemini/foo',
148
+ '/api/llm/anthropic', // bare provider, end of string
149
+ ];
150
+ for (const url of passthroughShapes) {
151
+ it(`recognizes ${url} as passthrough`, async () => {
152
+ await chat({
153
+ apiKey: 'k',
154
+ model: 'claude-haiku-4-5-20251001',
155
+ messages: [{ role: 'user', content: 'hi' }],
156
+ proxyUrl: url,
157
+ });
158
+ expect(lastFetch.url).toBe(url);
159
+ // Passthrough body: anthropic-shaped (no `provider` key)
160
+ expect(lastFetch.body).not.toHaveProperty('provider');
161
+ });
162
+ }
163
+ });
164
+
165
+ describe('proxyRequest body shape', () => {
166
+ it('forwards optional fields when present', async () => {
167
+ await chat({
168
+ apiKey: 'k',
169
+ model: 'gpt-4o-mini',
170
+ messages: [{ role: 'user', content: 'hi' }],
171
+ proxyUrl: '/api/chat',
172
+ system: 'You are concise.',
173
+ maxTokens: 1024,
174
+ temperature: 0.5,
175
+ thinking: true,
176
+ });
177
+ expect(lastFetch.body).toMatchObject({
178
+ provider: 'openai',
179
+ system: 'You are concise.',
180
+ maxTokens: 1024,
181
+ temperature: 0.5,
182
+ thinking: true,
183
+ stream: false,
184
+ });
185
+ });
186
+
187
+ it('omits optional fields when undefined (no null pollution)', async () => {
188
+ await chat({
189
+ apiKey: 'k',
190
+ model: 'gpt-4o',
191
+ messages: [{ role: 'user', content: 'hi' }],
192
+ proxyUrl: '/api/chat',
193
+ });
194
+ expect(lastFetch.body).not.toHaveProperty('system');
195
+ expect(lastFetch.body).not.toHaveProperty('maxTokens');
196
+ expect(lastFetch.body).not.toHaveProperty('temperature');
197
+ expect(lastFetch.body).not.toHaveProperty('thinking');
198
+ });
199
+
200
+ it('temperature: 0 is forwarded (not coerced to omitted)', async () => {
201
+ await chat({
202
+ apiKey: 'k',
203
+ model: 'gpt-4o',
204
+ messages: [{ role: 'user', content: 'hi' }],
205
+ proxyUrl: '/api/chat',
206
+ temperature: 0,
207
+ });
208
+ expect(lastFetch.body.temperature).toBe(0);
209
+ });
210
+ });
211
+
212
+ describe('error handling', () => {
213
+ it('rejects on upstream error response with adapter-tagged message', async () => {
214
+ fetchMock.mockResolvedValueOnce(
215
+ new Response(JSON.stringify({ error: { message: 'boom' } }), { status: 400 })
216
+ );
217
+ await expect(
218
+ chat({ apiKey: 'k', model: 'claude-haiku-4-5-20251001', messages: [] })
219
+ ).rejects.toThrow('boom');
220
+ });
221
+
222
+ it('falls back to "API error <status>" when upstream JSON missing error.message', async () => {
223
+ fetchMock.mockResolvedValueOnce(new Response('not json', { status: 500 }));
224
+ await expect(
225
+ chat({ apiKey: 'k', model: 'claude-haiku-4-5-20251001', messages: [] })
226
+ ).rejects.toThrow(/anthropic API error 500/);
227
+ });
228
+ });
@@ -0,0 +1,108 @@
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
+ });
package/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * consumer that needs to talk to anthropic / openai / gemini.
7
7
  *
8
8
  * import { chat, streamChat, createClient } from '@adia-ai/llm';
9
+ * import { MODELS, DEFAULT_MODEL } from '@adia-ai/llm/models';
9
10
  * import { createAdapter } from '@adia-ai/llm/bridge';
10
11
  * import { StubLLMAdapter } from '@adia-ai/llm/stub';
11
12
  */
@@ -15,3 +16,8 @@ export {
15
16
  streamChat,
16
17
  createClient,
17
18
  } from './adapters/index.js';
19
+
20
+ export {
21
+ MODELS,
22
+ DEFAULT_MODEL,
23
+ } from './models.js';
package/models.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared model catalog — chat-input-ui grouped-options shape.
3
+ *
4
+ * Three consumers (as of 2026-05-06): apps/chat/, apps/genui/gen-ui-ux/,
5
+ * apps/genui/gen-ui/. Each previously carried a near-identical literal
6
+ * array; this module promotes them to one source.
7
+ *
8
+ * Format matches `<chat-input-ui>.models` setter (a 2D grouped-options
9
+ * structure consumed by an internal `<select-ui>` with `<optgroup>`s).
10
+ *
11
+ * import { MODELS, DEFAULT_MODEL } from '@adia-ai/llm/models';
12
+ * chatInput.models = MODELS;
13
+ * chatInput.model = DEFAULT_MODEL;
14
+ */
15
+
16
+ export const MODELS = [
17
+ {
18
+ label: 'Anthropic',
19
+ options: [
20
+ { value: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
21
+ { value: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
22
+ ],
23
+ },
24
+ {
25
+ label: 'OpenAI',
26
+ options: [
27
+ { value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
28
+ ],
29
+ },
30
+ {
31
+ label: 'Google',
32
+ options: [
33
+ { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
34
+ ],
35
+ },
36
+ ];
37
+
38
+ export const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@adia-ai/llm",
3
- "version": "0.3.1",
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.",
3
+ "version": "0.3.3",
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.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./index.js",
8
8
  "./adapters/*": "./adapters/*.js",
9
9
  "./bridge": "./llm-bridge.js",
10
+ "./models": "./models.js",
10
11
  "./stub": "./llm-stub.js",
11
12
  "./package.json": "./package.json"
12
13
  },
@@ -14,6 +15,7 @@
14
15
  "adapters/",
15
16
  "llm-bridge.js",
16
17
  "llm-stub.js",
18
+ "models.js",
17
19
  "index.js",
18
20
  "README.md",
19
21
  "CHANGELOG.md"