@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 +56 -0
- package/README.md +73 -5
- package/adapters/build-request.test.js +258 -0
- package/adapters/client.test.js +71 -0
- package/adapters/index.js +38 -8
- package/adapters/router.test.js +228 -0
- package/adapters/sse.test.js +108 -0
- package/index.js +6 -0
- package/models.js +38 -0
- package/package.json +4 -2
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
|
-
|
|
29
|
-
|
|
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
|
|
55
|
-
verbatim. The reference proxy implementation is at
|
|
56
|
-
chat-ui repo
|
|
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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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.
|
|
4
|
-
"description": "Provider-agnostic LLM client
|
|
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"
|