@plumbus/core 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ai/__tests__/ai-service.test.js +704 -1
  3. package/dist/ai/__tests__/ai-service.test.js.map +1 -1
  4. package/dist/ai/__tests__/chunking.test.js +34 -0
  5. package/dist/ai/__tests__/chunking.test.js.map +1 -1
  6. package/dist/ai/__tests__/model-pricing.test.js +45 -1
  7. package/dist/ai/__tests__/model-pricing.test.js.map +1 -1
  8. package/dist/ai/__tests__/provider.test.d.ts.map +1 -1
  9. package/dist/ai/__tests__/provider.test.js +730 -1
  10. package/dist/ai/__tests__/provider.test.js.map +1 -1
  11. package/dist/ai/__tests__/validation.test.js +200 -1
  12. package/dist/ai/__tests__/validation.test.js.map +1 -1
  13. package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts +2 -0
  14. package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts.map +1 -0
  15. package/dist/ai/__tests__/zod-to-provider-schema.test.js +60 -0
  16. package/dist/ai/__tests__/zod-to-provider-schema.test.js.map +1 -0
  17. package/dist/ai/ai-service.d.ts +19 -2
  18. package/dist/ai/ai-service.d.ts.map +1 -1
  19. package/dist/ai/ai-service.js +544 -110
  20. package/dist/ai/ai-service.js.map +1 -1
  21. package/dist/ai/cost-tracker.d.ts +26 -1
  22. package/dist/ai/cost-tracker.d.ts.map +1 -1
  23. package/dist/ai/cost-tracker.js +1 -0
  24. package/dist/ai/cost-tracker.js.map +1 -1
  25. package/dist/ai/index.d.ts +6 -4
  26. package/dist/ai/index.d.ts.map +1 -1
  27. package/dist/ai/index.js +7 -3
  28. package/dist/ai/index.js.map +1 -1
  29. package/dist/ai/model-pricing.d.ts +24 -1
  30. package/dist/ai/model-pricing.d.ts.map +1 -1
  31. package/dist/ai/model-pricing.js +83 -56
  32. package/dist/ai/model-pricing.js.map +1 -1
  33. package/dist/ai/provider.d.ts +110 -1
  34. package/dist/ai/provider.d.ts.map +1 -1
  35. package/dist/ai/provider.js +358 -32
  36. package/dist/ai/provider.js.map +1 -1
  37. package/dist/ai/rag/chunking.js +53 -3
  38. package/dist/ai/rag/chunking.js.map +1 -1
  39. package/dist/ai/refusal.d.ts +28 -0
  40. package/dist/ai/refusal.d.ts.map +1 -0
  41. package/dist/ai/refusal.js +36 -0
  42. package/dist/ai/refusal.js.map +1 -0
  43. package/dist/ai/validation.d.ts +40 -8
  44. package/dist/ai/validation.d.ts.map +1 -1
  45. package/dist/ai/validation.js +97 -6
  46. package/dist/ai/validation.js.map +1 -1
  47. package/dist/ai/zod-to-provider-schema.d.ts +15 -0
  48. package/dist/ai/zod-to-provider-schema.d.ts.map +1 -0
  49. package/dist/ai/zod-to-provider-schema.js +200 -0
  50. package/dist/ai/zod-to-provider-schema.js.map +1 -0
  51. package/dist/cli/__tests__/doctor.test.js +104 -2
  52. package/dist/cli/__tests__/doctor.test.js.map +1 -1
  53. package/dist/cli/__tests__/init.test.js +127 -7
  54. package/dist/cli/__tests__/init.test.js.map +1 -1
  55. package/dist/cli/__tests__/migrate.test.js +159 -2
  56. package/dist/cli/__tests__/migrate.test.js.map +1 -1
  57. package/dist/cli/commands/__tests__/commands.test.js +5 -0
  58. package/dist/cli/commands/__tests__/commands.test.js.map +1 -1
  59. package/dist/cli/commands/dev.d.ts.map +1 -1
  60. package/dist/cli/commands/dev.js +21 -1
  61. package/dist/cli/commands/dev.js.map +1 -1
  62. package/dist/cli/commands/doctor.d.ts +2 -0
  63. package/dist/cli/commands/doctor.d.ts.map +1 -1
  64. package/dist/cli/commands/doctor.js +78 -0
  65. package/dist/cli/commands/doctor.js.map +1 -1
  66. package/dist/cli/commands/generate.d.ts.map +1 -1
  67. package/dist/cli/commands/generate.js +7 -2
  68. package/dist/cli/commands/generate.js.map +1 -1
  69. package/dist/cli/commands/init.d.ts +16 -2
  70. package/dist/cli/commands/init.d.ts.map +1 -1
  71. package/dist/cli/commands/init.js +226 -25
  72. package/dist/cli/commands/init.js.map +1 -1
  73. package/dist/cli/commands/migrate.d.ts.map +1 -1
  74. package/dist/cli/commands/migrate.js +258 -8
  75. package/dist/cli/commands/migrate.js.map +1 -1
  76. package/dist/cli/commands/start.d.ts.map +1 -1
  77. package/dist/cli/commands/start.js +21 -1
  78. package/dist/cli/commands/start.js.map +1 -1
  79. package/dist/data/__tests__/drift-inspector.test.d.ts +2 -0
  80. package/dist/data/__tests__/drift-inspector.test.d.ts.map +1 -0
  81. package/dist/data/__tests__/drift-inspector.test.js +170 -0
  82. package/dist/data/__tests__/drift-inspector.test.js.map +1 -0
  83. package/dist/data/__tests__/framework-migration-rewriter.test.d.ts +2 -0
  84. package/dist/data/__tests__/framework-migration-rewriter.test.d.ts.map +1 -0
  85. package/dist/data/__tests__/framework-migration-rewriter.test.js +329 -0
  86. package/dist/data/__tests__/framework-migration-rewriter.test.js.map +1 -0
  87. package/dist/data/__tests__/migration.test.js +93 -2
  88. package/dist/data/__tests__/migration.test.js.map +1 -1
  89. package/dist/data/__tests__/repository.test.js +126 -0
  90. package/dist/data/__tests__/repository.test.js.map +1 -1
  91. package/dist/data/drift-inspector.d.ts +41 -0
  92. package/dist/data/drift-inspector.d.ts.map +1 -0
  93. package/dist/data/drift-inspector.js +197 -0
  94. package/dist/data/drift-inspector.js.map +1 -0
  95. package/dist/data/framework-migration-rewriter.d.ts +61 -0
  96. package/dist/data/framework-migration-rewriter.d.ts.map +1 -0
  97. package/dist/data/framework-migration-rewriter.js +357 -0
  98. package/dist/data/framework-migration-rewriter.js.map +1 -0
  99. package/dist/data/index.d.ts +2 -1
  100. package/dist/data/index.d.ts.map +1 -1
  101. package/dist/data/index.js +2 -1
  102. package/dist/data/index.js.map +1 -1
  103. package/dist/data/migration.d.ts +31 -1
  104. package/dist/data/migration.d.ts.map +1 -1
  105. package/dist/data/migration.js +113 -3
  106. package/dist/data/migration.js.map +1 -1
  107. package/dist/data/repository.d.ts.map +1 -1
  108. package/dist/data/repository.js +21 -0
  109. package/dist/data/repository.js.map +1 -1
  110. package/dist/define/__tests__/definePrompt.test.js +37 -0
  111. package/dist/define/__tests__/definePrompt.test.js.map +1 -1
  112. package/dist/define/definePrompt.d.ts +13 -0
  113. package/dist/define/definePrompt.d.ts.map +1 -1
  114. package/dist/define/definePrompt.js +3 -0
  115. package/dist/define/definePrompt.js.map +1 -1
  116. package/dist/errors/http.d.ts.map +1 -1
  117. package/dist/errors/http.js +2 -0
  118. package/dist/errors/http.js.map +1 -1
  119. package/dist/errors/index.d.ts +12 -0
  120. package/dist/errors/index.d.ts.map +1 -1
  121. package/dist/errors/index.js +21 -0
  122. package/dist/errors/index.js.map +1 -1
  123. package/dist/events/__tests__/event-emitter.test.js +72 -0
  124. package/dist/events/__tests__/event-emitter.test.js.map +1 -1
  125. package/dist/events/emitter.d.ts.map +1 -1
  126. package/dist/events/emitter.js +51 -0
  127. package/dist/events/emitter.js.map +1 -1
  128. package/dist/execution/__tests__/context-factory.test.js +1 -0
  129. package/dist/execution/__tests__/context-factory.test.js.map +1 -1
  130. package/dist/execution/context-factory.d.ts.map +1 -1
  131. package/dist/execution/context-factory.js +2 -0
  132. package/dist/execution/context-factory.js.map +1 -1
  133. package/dist/flows/__tests__/flow-engine.test.js +451 -4
  134. package/dist/flows/__tests__/flow-engine.test.js.map +1 -1
  135. package/dist/flows/__tests__/flow-service.test.js +8 -0
  136. package/dist/flows/__tests__/flow-service.test.js.map +1 -1
  137. package/dist/flows/__tests__/step-executor.test.js +8 -2
  138. package/dist/flows/__tests__/step-executor.test.js.map +1 -1
  139. package/dist/flows/engine.d.ts +50 -1
  140. package/dist/flows/engine.d.ts.map +1 -1
  141. package/dist/flows/engine.js +427 -23
  142. package/dist/flows/engine.js.map +1 -1
  143. package/dist/flows/flow-service.d.ts.map +1 -1
  144. package/dist/flows/flow-service.js +4 -0
  145. package/dist/flows/flow-service.js.map +1 -1
  146. package/dist/flows/index.d.ts +1 -1
  147. package/dist/flows/index.d.ts.map +1 -1
  148. package/dist/flows/index.js +1 -1
  149. package/dist/flows/index.js.map +1 -1
  150. package/dist/flows/schema.d.ts +34 -0
  151. package/dist/flows/schema.d.ts.map +1 -1
  152. package/dist/flows/schema.js +3 -0
  153. package/dist/flows/schema.js.map +1 -1
  154. package/dist/index.d.ts +4 -4
  155. package/dist/index.d.ts.map +1 -1
  156. package/dist/index.js +3 -3
  157. package/dist/index.js.map +1 -1
  158. package/dist/server/bootstrap.d.ts +14 -0
  159. package/dist/server/bootstrap.d.ts.map +1 -1
  160. package/dist/server/bootstrap.js +23 -4
  161. package/dist/server/bootstrap.js.map +1 -1
  162. package/dist/testing/context.d.ts.map +1 -1
  163. package/dist/testing/context.js +37 -1
  164. package/dist/testing/context.js.map +1 -1
  165. package/dist/types/context.d.ts +110 -0
  166. package/dist/types/context.d.ts.map +1 -1
  167. package/dist/types/enums.d.ts +2 -0
  168. package/dist/types/enums.d.ts.map +1 -1
  169. package/dist/types/enums.js +2 -0
  170. package/dist/types/enums.js.map +1 -1
  171. package/dist/types/prompt.d.ts +64 -0
  172. package/dist/types/prompt.d.ts.map +1 -1
  173. package/dist/worker/__tests__/bootstrap.test.js +36 -2
  174. package/dist/worker/__tests__/bootstrap.test.js.map +1 -1
  175. package/dist/worker/bootstrap.d.ts +7 -0
  176. package/dist/worker/bootstrap.d.ts.map +1 -1
  177. package/dist/worker/bootstrap.js +153 -12
  178. package/dist/worker/bootstrap.js.map +1 -1
  179. package/instructions/framework.md +7 -0
  180. package/instructions/guardrails.md +39 -0
  181. package/instructions/patterns.md +10 -0
  182. package/package.json +3 -2
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { createAnthropicAdapter, createOpenAIAdapter, createProviderAdapter, ProviderAPIError, } from '../provider.js';
2
+ import { createAnthropicAdapter, createOpenAIAdapter, createProviderAdapter, joinAndFilterModels, ProviderAPIError, } from '../provider.js';
3
+ import { AIIncompleteOutputError, AIRefusalError } from '../refusal.js';
4
+ // The built-in OpenAI and Anthropic adapters always implement `listModels`;
5
+ // the interface marks it optional only for third-party adapters. This helper
6
+ // narrows the type so tests don't need non-null assertions on every call.
7
+ function callListModels(adapter, filter) {
8
+ if (!adapter.listModels)
9
+ throw new Error('adapter must implement listModels');
10
+ return adapter.listModels(filter);
11
+ }
3
12
  // ── Helper: create a mock provider ──
4
13
  // biome-ignore lint/suspicious/noExportsInTest: shared test helper used by multiple test files
5
14
  export function createMockProvider(overrides) {
@@ -72,6 +81,239 @@ describe('AI Provider Adapters', () => {
72
81
  expect(body.messages[0].role).toBe('system');
73
82
  vi.unstubAllGlobals();
74
83
  });
84
+ it('uses max_completion_tokens for newer OpenAI completion models', async () => {
85
+ const mockFetch = vi.fn().mockResolvedValue({
86
+ ok: true,
87
+ json: async () => ({
88
+ choices: [{ message: { content: 'Hello' }, finish_reason: 'stop' }],
89
+ model: 'gpt-5-mini',
90
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
91
+ }),
92
+ });
93
+ vi.stubGlobal('fetch', mockFetch);
94
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test', model: 'gpt-5-mini' });
95
+ await adapter.complete({ prompt: 'Say hello', maxTokens: 1234 });
96
+ const call = mockFetch.mock.calls[0];
97
+ const body = JSON.parse(call?.[1].body);
98
+ expect(body.max_completion_tokens).toBe(1234);
99
+ expect(body.max_tokens).toBeUndefined();
100
+ vi.unstubAllGlobals();
101
+ });
102
+ it('uses max_completion_tokens for newer OpenAI streaming models', async () => {
103
+ const mockFetch = vi.fn().mockResolvedValue(new Response('data: [DONE]\n\n', {
104
+ status: 200,
105
+ headers: { 'Content-Type': 'text/event-stream' },
106
+ }));
107
+ vi.stubGlobal('fetch', mockFetch);
108
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test', model: 'o4-mini' });
109
+ for await (const event of adapter.stream({ prompt: 'Stream hello', maxTokens: 4321 })) {
110
+ expect(event.type).toBe('done');
111
+ break;
112
+ }
113
+ const call = mockFetch.mock.calls[0];
114
+ const body = JSON.parse(call?.[1].body);
115
+ expect(body.max_completion_tokens).toBe(4321);
116
+ expect(body.max_tokens).toBeUndefined();
117
+ vi.unstubAllGlobals();
118
+ });
119
+ it('sends strict JSON Schema response_format when responseSchema is provided', async () => {
120
+ const mockFetch = vi.fn().mockResolvedValue({
121
+ ok: true,
122
+ json: async () => ({
123
+ choices: [{ message: { content: '{"name":"Alice"}' }, finish_reason: 'stop' }],
124
+ model: 'gpt-4o',
125
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
126
+ }),
127
+ });
128
+ vi.stubGlobal('fetch', mockFetch);
129
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
130
+ await adapter.complete({
131
+ prompt: 'Return JSON',
132
+ responseFormat: 'json',
133
+ responseSchema: {
134
+ type: 'object',
135
+ properties: { name: { type: 'string' } },
136
+ required: ['name'],
137
+ additionalProperties: false,
138
+ },
139
+ });
140
+ const call = mockFetch.mock.calls[0];
141
+ const body = JSON.parse(call?.[1].body);
142
+ expect(body.response_format).toEqual({
143
+ type: 'json_schema',
144
+ json_schema: {
145
+ name: 'response',
146
+ strict: true,
147
+ schema: {
148
+ type: 'object',
149
+ properties: { name: { type: 'string' } },
150
+ required: ['name'],
151
+ additionalProperties: false,
152
+ },
153
+ },
154
+ });
155
+ vi.unstubAllGlobals();
156
+ });
157
+ it('uses strict tool-call arguments for Grok structured output', async () => {
158
+ const mockFetch = vi.fn().mockResolvedValue({
159
+ ok: true,
160
+ json: async () => ({
161
+ choices: [
162
+ {
163
+ message: {
164
+ content: null,
165
+ tool_calls: [
166
+ {
167
+ type: 'function',
168
+ function: {
169
+ name: 'return_structured_response',
170
+ arguments: '{"name":"Alice"}',
171
+ },
172
+ },
173
+ ],
174
+ },
175
+ finish_reason: 'tool_calls',
176
+ },
177
+ ],
178
+ model: 'grok-4-1-fast-non-reasoning',
179
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
180
+ }),
181
+ });
182
+ vi.stubGlobal('fetch', mockFetch);
183
+ const adapter = createOpenAIAdapter({
184
+ apiKey: 'sk-test',
185
+ model: 'grok-4-1-fast-non-reasoning',
186
+ });
187
+ const result = await adapter.complete({
188
+ prompt: 'Return JSON',
189
+ responseFormat: 'json',
190
+ structuredOutputTransport: 'tool',
191
+ responseSchema: {
192
+ type: 'object',
193
+ properties: { name: { type: 'string' } },
194
+ required: ['name'],
195
+ additionalProperties: false,
196
+ },
197
+ });
198
+ const call = mockFetch.mock.calls[0];
199
+ const body = JSON.parse(call?.[1].body);
200
+ expect(body.response_format).toBeUndefined();
201
+ expect(body.tool_choice).toEqual({
202
+ type: 'function',
203
+ function: { name: 'return_structured_response' },
204
+ });
205
+ expect(body.tools).toEqual([
206
+ {
207
+ type: 'function',
208
+ function: {
209
+ name: 'return_structured_response',
210
+ description: 'Return the complete structured response for this request.',
211
+ strict: true,
212
+ parameters: {
213
+ type: 'object',
214
+ properties: { name: { type: 'string' } },
215
+ required: ['name'],
216
+ additionalProperties: false,
217
+ },
218
+ },
219
+ },
220
+ ]);
221
+ expect(result.content).toBe('{"name":"Alice"}');
222
+ expect(result.finishReason).toBe('tool_calls');
223
+ vi.unstubAllGlobals();
224
+ });
225
+ it('keeps Grok structured output on response_format unless explicitly opted into tools', async () => {
226
+ const mockFetch = vi.fn().mockResolvedValue({
227
+ ok: true,
228
+ json: async () => ({
229
+ choices: [{ message: { content: '{"name":"Alice"}' }, finish_reason: 'stop' }],
230
+ model: 'grok-4-1-fast-non-reasoning',
231
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
232
+ }),
233
+ });
234
+ vi.stubGlobal('fetch', mockFetch);
235
+ const adapter = createOpenAIAdapter({
236
+ apiKey: 'sk-test',
237
+ model: 'grok-4-1-fast-non-reasoning',
238
+ });
239
+ await adapter.complete({
240
+ prompt: 'Return JSON',
241
+ responseFormat: 'json',
242
+ responseSchema: {
243
+ type: 'object',
244
+ properties: { name: { type: 'string' } },
245
+ required: ['name'],
246
+ additionalProperties: false,
247
+ },
248
+ });
249
+ const call = mockFetch.mock.calls[0];
250
+ const body = JSON.parse(call?.[1].body);
251
+ expect(body.tools).toBeUndefined();
252
+ expect(body.tool_choice).toBeUndefined();
253
+ expect(body.response_format?.type).toBe('json_schema');
254
+ vi.unstubAllGlobals();
255
+ });
256
+ it('throws a descriptive error when structured JSON content is empty', async () => {
257
+ const mockFetch = vi.fn().mockResolvedValue({
258
+ ok: true,
259
+ json: async () => ({
260
+ id: 'chatcmpl-empty',
261
+ choices: [
262
+ {
263
+ message: { content: null, role: 'assistant' },
264
+ finish_reason: 'stop',
265
+ },
266
+ ],
267
+ model: 'grok-4-1-fast-non-reasoning',
268
+ usage: { prompt_tokens: 10, completion_tokens: 0, total_tokens: 10 },
269
+ }),
270
+ });
271
+ vi.stubGlobal('fetch', mockFetch);
272
+ const adapter = createOpenAIAdapter({
273
+ apiKey: 'sk-test',
274
+ model: 'grok-4-1-fast-non-reasoning',
275
+ });
276
+ await expect(adapter.complete({
277
+ prompt: 'Return JSON',
278
+ responseFormat: 'json',
279
+ responseSchema: {
280
+ type: 'object',
281
+ properties: { evidence: { type: 'array', items: { type: 'object' } } },
282
+ required: ['evidence'],
283
+ additionalProperties: false,
284
+ },
285
+ })).rejects.toThrow(/OpenAI-compatible provider returned empty JSON response content .*finish_reason=stop/);
286
+ // Ensure the framework does NOT phone home to a debug ingest endpoint.
287
+ const debugCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/ingest/'));
288
+ expect(debugCall).toBeUndefined();
289
+ vi.unstubAllGlobals();
290
+ });
291
+ it('throws AIRefusalError when OpenAI returns a refusal', async () => {
292
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
293
+ ok: true,
294
+ json: async () => ({
295
+ choices: [{ message: { content: null, refusal: 'No.' }, finish_reason: 'stop' }],
296
+ model: 'gpt-4o',
297
+ usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
298
+ }),
299
+ }));
300
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
301
+ await expect(adapter.complete({ prompt: 'bad', responseFormat: 'json' })).rejects.toThrow(AIRefusalError);
302
+ vi.unstubAllGlobals();
303
+ });
304
+ it('throws AIIncompleteOutputError when OpenAI stops for length', async () => {
305
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
306
+ ok: true,
307
+ json: async () => ({
308
+ choices: [{ message: { content: '{"partial":' }, finish_reason: 'length' }],
309
+ model: 'gpt-4o',
310
+ usage: { prompt_tokens: 3, completion_tokens: 8, total_tokens: 11 },
311
+ }),
312
+ }));
313
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
314
+ await expect(adapter.complete({ prompt: 'long', responseFormat: 'json' })).rejects.toThrow(AIIncompleteOutputError);
315
+ vi.unstubAllGlobals();
316
+ });
75
317
  it('throws on non-retryable API error', async () => {
76
318
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
77
319
  ok: false,
@@ -152,6 +394,36 @@ describe('AI Provider Adapters', () => {
152
394
  expect(result.model).toBe('text-embedding-3-small');
153
395
  vi.unstubAllGlobals();
154
396
  });
397
+ it('sends multi-turn messages verbatim and does not append prompt as an extra user message', async () => {
398
+ const mockFetch = vi.fn().mockResolvedValue({
399
+ ok: true,
400
+ json: async () => ({
401
+ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
402
+ model: 'gpt-4o',
403
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
404
+ }),
405
+ });
406
+ vi.stubGlobal('fetch', mockFetch);
407
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
408
+ await adapter.complete({
409
+ prompt: 'IGNORED_WHEN_MESSAGES',
410
+ system: 'Sys',
411
+ messages: [
412
+ { role: 'user', content: 'Hi' },
413
+ { role: 'assistant', content: 'Hello' },
414
+ { role: 'user', content: 'Last' },
415
+ ],
416
+ });
417
+ const call = mockFetch.mock.calls[0];
418
+ const body = JSON.parse(call?.[1].body);
419
+ expect(body.messages).toEqual([
420
+ { role: 'system', content: 'Sys' },
421
+ { role: 'user', content: 'Hi' },
422
+ { role: 'assistant', content: 'Hello' },
423
+ { role: 'user', content: 'Last' },
424
+ ]);
425
+ vi.unstubAllGlobals();
426
+ });
155
427
  });
156
428
  describe('createAnthropicAdapter', () => {
157
429
  it("creates an adapter with name 'anthropic'", () => {
@@ -186,10 +458,171 @@ describe('AI Provider Adapters', () => {
186
458
  }));
187
459
  vi.unstubAllGlobals();
188
460
  });
461
+ it('sends Anthropic output_config.format when responseSchema is provided', async () => {
462
+ const mockFetch = vi.fn().mockResolvedValue({
463
+ ok: true,
464
+ json: async () => ({
465
+ content: [{ type: 'text', text: '{"name":"Alice"}' }],
466
+ model: 'claude-sonnet-4-5',
467
+ usage: { input_tokens: 10, output_tokens: 8 },
468
+ stop_reason: 'end_turn',
469
+ }),
470
+ });
471
+ vi.stubGlobal('fetch', mockFetch);
472
+ const adapter = createAnthropicAdapter({ apiKey: 'ant-test' });
473
+ await adapter.complete({
474
+ prompt: 'Return JSON',
475
+ responseFormat: 'json',
476
+ responseSchema: {
477
+ type: 'object',
478
+ properties: { name: { type: 'string' } },
479
+ required: ['name'],
480
+ additionalProperties: false,
481
+ },
482
+ });
483
+ const call = mockFetch.mock.calls[0];
484
+ const body = JSON.parse(call?.[1].body);
485
+ expect(body.output_config).toEqual({
486
+ format: {
487
+ type: 'json_schema',
488
+ schema: {
489
+ type: 'object',
490
+ properties: { name: { type: 'string' } },
491
+ required: ['name'],
492
+ additionalProperties: false,
493
+ },
494
+ },
495
+ });
496
+ expect(call?.[1].headers).not.toHaveProperty('anthropic-beta');
497
+ vi.unstubAllGlobals();
498
+ });
499
+ it('throws AIRefusalError when Anthropic returns stop_reason refusal', async () => {
500
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
501
+ ok: true,
502
+ json: async () => ({
503
+ content: [{ type: 'text', text: 'No.' }],
504
+ model: 'claude-sonnet-4-5',
505
+ usage: { input_tokens: 10, output_tokens: 2 },
506
+ stop_reason: 'refusal',
507
+ }),
508
+ }));
509
+ const adapter = createAnthropicAdapter({ apiKey: 'ant-test' });
510
+ await expect(adapter.complete({ prompt: 'bad', responseFormat: 'json' })).rejects.toThrow(AIRefusalError);
511
+ vi.unstubAllGlobals();
512
+ });
513
+ it('throws AIIncompleteOutputError when Anthropic reaches max_tokens', async () => {
514
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
515
+ ok: true,
516
+ json: async () => ({
517
+ content: [{ type: 'text', text: '{"partial":' }],
518
+ model: 'claude-sonnet-4-5',
519
+ usage: { input_tokens: 10, output_tokens: 8 },
520
+ stop_reason: 'max_tokens',
521
+ }),
522
+ }));
523
+ const adapter = createAnthropicAdapter({ apiKey: 'ant-test' });
524
+ await expect(adapter.complete({ prompt: 'long', responseFormat: 'json' })).rejects.toThrow(AIIncompleteOutputError);
525
+ vi.unstubAllGlobals();
526
+ });
189
527
  it('throws on embed (unsupported)', async () => {
190
528
  const adapter = createAnthropicAdapter({ apiKey: 'ant-test' });
191
529
  await expect(adapter.embed({ texts: ['hello'] })).rejects.toThrow('Anthropic does not provide an embedding API');
192
530
  });
531
+ it('sends multi-turn messages verbatim when messages array is provided', async () => {
532
+ const mockFetch = vi.fn().mockResolvedValue({
533
+ ok: true,
534
+ json: async () => ({
535
+ content: [{ type: 'text', text: 'ok' }],
536
+ model: 'claude-sonnet-4-20250514',
537
+ usage: { input_tokens: 10, output_tokens: 5 },
538
+ stop_reason: 'end_turn',
539
+ }),
540
+ });
541
+ vi.stubGlobal('fetch', mockFetch);
542
+ const adapter = createAnthropicAdapter({ apiKey: 'ant-test' });
543
+ await adapter.complete({
544
+ prompt: 'IGNORED',
545
+ system: 'Merged system text',
546
+ messages: [
547
+ { role: 'user', content: 'First' },
548
+ { role: 'assistant', content: 'Second' },
549
+ ],
550
+ });
551
+ const call = mockFetch.mock.calls[0];
552
+ const body = JSON.parse(call?.[1].body);
553
+ expect(body.system).toBe('Merged system text');
554
+ expect(body.messages).toEqual([
555
+ { role: 'user', content: 'First' },
556
+ { role: 'assistant', content: 'Second' },
557
+ ]);
558
+ vi.unstubAllGlobals();
559
+ });
560
+ });
561
+ describe('external AbortSignal propagation', () => {
562
+ it('fetch is called with a signal that fires when the caller aborts', async () => {
563
+ let capturedSignal;
564
+ const mockFetch = vi.fn().mockImplementation(async (_url, init) => {
565
+ capturedSignal = init.signal;
566
+ // Never resolves unless aborted — simulates an in-flight long request.
567
+ return await new Promise((_resolve, reject) => {
568
+ capturedSignal?.addEventListener('abort', () => {
569
+ const err = new Error('aborted');
570
+ err.name = 'AbortError';
571
+ reject(err);
572
+ });
573
+ });
574
+ });
575
+ vi.stubGlobal('fetch', mockFetch);
576
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
577
+ const ac = new AbortController();
578
+ const p = adapter.complete({ prompt: 'long', signal: ac.signal });
579
+ // Give the mock a tick to be called.
580
+ await new Promise((r) => setTimeout(r, 10));
581
+ ac.abort();
582
+ await expect(p).rejects.toThrow(/abort/i);
583
+ expect(capturedSignal).toBeInstanceOf(AbortSignal);
584
+ vi.unstubAllGlobals();
585
+ });
586
+ it('completing normally before abort does not surface as aborted', async () => {
587
+ const mockFetch = vi.fn().mockResolvedValue({
588
+ ok: true,
589
+ json: async () => ({
590
+ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
591
+ model: 'gpt-4o',
592
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
593
+ }),
594
+ });
595
+ vi.stubGlobal('fetch', mockFetch);
596
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
597
+ const ac = new AbortController(); // never aborted
598
+ const result = await adapter.complete({ prompt: 'fast', signal: ac.signal });
599
+ expect(result.content).toBe('ok');
600
+ vi.unstubAllGlobals();
601
+ });
602
+ it('uses a 600 second default request timeout for OpenAI-compatible calls', async () => {
603
+ const originalTimeout = AbortSignal.timeout;
604
+ let capturedTimeoutMs;
605
+ const timeoutSpy = vi
606
+ .spyOn(AbortSignal, 'timeout')
607
+ .mockImplementation((timeoutMs) => {
608
+ capturedTimeoutMs = timeoutMs;
609
+ return originalTimeout.call(AbortSignal, timeoutMs);
610
+ });
611
+ const mockFetch = vi.fn().mockResolvedValue({
612
+ ok: true,
613
+ json: async () => ({
614
+ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
615
+ model: 'gpt-4o',
616
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
617
+ }),
618
+ });
619
+ vi.stubGlobal('fetch', mockFetch);
620
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
621
+ await adapter.complete({ prompt: 'fast' });
622
+ expect(capturedTimeoutMs).toBe(120_000);
623
+ timeoutSpy.mockRestore();
624
+ vi.unstubAllGlobals();
625
+ });
193
626
  });
194
627
  describe('createProviderAdapter', () => {
195
628
  it('creates an OpenAI adapter for "openai"', () => {
@@ -213,5 +646,301 @@ describe('AI Provider Adapters', () => {
213
646
  expect(adapter.name).toBe('openai');
214
647
  });
215
648
  });
649
+ describe('joinAndFilterModels', () => {
650
+ it('joins entries against the pricing catalog (kind + prices for known, unknown otherwise)', () => {
651
+ const result = joinAndFilterModels({
652
+ provider: 'openai',
653
+ entries: [{ id: 'gpt-4o' }, { id: 'text-embedding-3-small' }, { id: 'llama3:8b' }],
654
+ filter: undefined,
655
+ isOfficial: true,
656
+ });
657
+ expect(result).toHaveLength(3);
658
+ const gpt = result.find((m) => m.id === 'gpt-4o');
659
+ expect(gpt?.kind).toBe('text');
660
+ expect(gpt?.inputPerMTok).toBe(2.5);
661
+ const emb = result.find((m) => m.id === 'text-embedding-3-small');
662
+ expect(emb?.kind).toBe('embedding');
663
+ const llama = result.find((m) => m.id === 'llama3:8b');
664
+ expect(llama?.kind).toBe('unknown');
665
+ expect(llama?.inputPerMTok).toBeNull();
666
+ expect(llama?.outputPerMTok).toBeNull();
667
+ });
668
+ it('passes through every entry when no filter is supplied', () => {
669
+ const r = joinAndFilterModels({
670
+ provider: 'openai',
671
+ entries: [{ id: 'gpt-4o' }, { id: 'text-embedding-3-small' }, { id: 'mystery' }],
672
+ filter: undefined,
673
+ isOfficial: false,
674
+ });
675
+ expect(r.map((m) => m.id)).toEqual(['gpt-4o', 'text-embedding-3-small', 'mystery']);
676
+ });
677
+ it('single-kind filter against the official endpoint excludes unknowns', () => {
678
+ const r = joinAndFilterModels({
679
+ provider: 'openai',
680
+ entries: [{ id: 'gpt-4o' }, { id: 'text-embedding-3-small' }, { id: 'nomic-embed' }],
681
+ filter: { kind: 'embedding' },
682
+ isOfficial: true,
683
+ });
684
+ expect(r.map((m) => m.id)).toEqual(['text-embedding-3-small']);
685
+ });
686
+ it('single-kind filter against a custom endpoint includes unknowns alongside matches', () => {
687
+ const r = joinAndFilterModels({
688
+ provider: 'ollama',
689
+ entries: [{ id: 'gpt-4o' }, { id: 'text-embedding-3-small' }, { id: 'nomic-embed' }],
690
+ filter: { kind: 'embedding' },
691
+ isOfficial: false,
692
+ });
693
+ expect(r.map((m) => m.id).sort()).toEqual(['nomic-embed', 'text-embedding-3-small']);
694
+ });
695
+ it('multi-kind filter accepts arrays', () => {
696
+ const r = joinAndFilterModels({
697
+ provider: 'openai',
698
+ entries: [
699
+ { id: 'gpt-4o' },
700
+ { id: 'text-embedding-3-small' },
701
+ { id: 'omni-moderation-latest' },
702
+ ],
703
+ filter: { kind: ['text', 'embedding'] },
704
+ isOfficial: true,
705
+ });
706
+ expect(r.map((m) => m.id).sort()).toEqual(['gpt-4o', 'text-embedding-3-small']);
707
+ });
708
+ it('carries through optional displayName / createdAt / ownedBy fields', () => {
709
+ const r = joinAndFilterModels({
710
+ provider: 'anthropic',
711
+ entries: [
712
+ {
713
+ id: 'claude-opus-4-7',
714
+ displayName: 'Claude Opus 4.7',
715
+ createdAt: '2026-01-15T00:00:00Z',
716
+ },
717
+ ],
718
+ filter: undefined,
719
+ isOfficial: true,
720
+ });
721
+ expect(r[0]?.displayName).toBe('Claude Opus 4.7');
722
+ expect(r[0]?.createdAt).toBe('2026-01-15T00:00:00Z');
723
+ });
724
+ });
725
+ describe('OpenAI listModels', () => {
726
+ it('returns joined entries from /v1/models with kind + pricing', async () => {
727
+ const mockFetch = vi.fn().mockResolvedValue({
728
+ ok: true,
729
+ json: async () => ({
730
+ object: 'list',
731
+ data: [
732
+ { id: 'gpt-4o', object: 'model', created: 1715367049, owned_by: 'system' },
733
+ {
734
+ id: 'text-embedding-3-small',
735
+ object: 'model',
736
+ created: 1700000000,
737
+ owned_by: 'openai',
738
+ },
739
+ ],
740
+ }),
741
+ });
742
+ vi.stubGlobal('fetch', mockFetch);
743
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
744
+ const models = await callListModels(adapter);
745
+ const call = mockFetch.mock.calls[0];
746
+ expect(call?.[0]).toBe('https://api.openai.com/v1/models');
747
+ expect(call?.[1].headers.Authorization).toBe('Bearer sk-test');
748
+ expect(models).toHaveLength(2);
749
+ expect(models[0]).toMatchObject({
750
+ id: 'gpt-4o',
751
+ provider: 'openai',
752
+ kind: 'text',
753
+ inputPerMTok: 2.5,
754
+ outputPerMTok: 10,
755
+ ownedBy: 'system',
756
+ });
757
+ expect(models[0]?.createdAt).toBe(new Date(1715367049 * 1000).toISOString());
758
+ vi.unstubAllGlobals();
759
+ });
760
+ it('filters by kind against the official endpoint (unknowns excluded)', async () => {
761
+ const mockFetch = vi.fn().mockResolvedValue({
762
+ ok: true,
763
+ json: async () => ({
764
+ data: [
765
+ { id: 'gpt-4o' },
766
+ { id: 'text-embedding-3-small' },
767
+ { id: 'fine-tune-ft-custom-12345' },
768
+ ],
769
+ }),
770
+ });
771
+ vi.stubGlobal('fetch', mockFetch);
772
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
773
+ const models = await callListModels(adapter, { kind: 'embedding' });
774
+ expect(models.map((m) => m.id)).toEqual(['text-embedding-3-small']);
775
+ vi.unstubAllGlobals();
776
+ });
777
+ it('filters by kind against a custom endpoint and includes unknowns', async () => {
778
+ const mockFetch = vi.fn().mockResolvedValue({
779
+ ok: true,
780
+ json: async () => ({
781
+ data: [{ id: 'gpt-4o' }, { id: 'nomic-embed-text' }, { id: 'llama3:8b' }],
782
+ }),
783
+ });
784
+ vi.stubGlobal('fetch', mockFetch);
785
+ const adapter = createOpenAIAdapter({
786
+ apiKey: 'ollama',
787
+ baseUrl: 'http://localhost:11434/v1',
788
+ });
789
+ const models = await callListModels(adapter, { kind: 'embedding' });
790
+ // text-known stays out, but both unknowns (nomic-embed-text, llama3:8b) come through.
791
+ expect(models.map((m) => m.id).sort()).toEqual(['llama3:8b', 'nomic-embed-text']);
792
+ vi.unstubAllGlobals();
793
+ });
794
+ it('returns empty list and warns on 404 (endpoint not implemented)', async () => {
795
+ const mockFetch = vi.fn().mockResolvedValue({
796
+ ok: false,
797
+ status: 404,
798
+ statusText: 'Not Found',
799
+ });
800
+ vi.stubGlobal('fetch', mockFetch);
801
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
802
+ const adapter = createOpenAIAdapter({
803
+ apiKey: 'azure-key',
804
+ baseUrl: 'https://example.openai.azure.com/openai/v1',
805
+ });
806
+ const models = await callListModels(adapter);
807
+ expect(models).toEqual([]);
808
+ expect(warn).toHaveBeenCalledTimes(1);
809
+ expect(warn.mock.calls[0]?.[0]).toMatch(/returned 404/);
810
+ warn.mockRestore();
811
+ vi.unstubAllGlobals();
812
+ });
813
+ it('returns empty list and warns on network error', async () => {
814
+ const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
815
+ vi.stubGlobal('fetch', mockFetch);
816
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
817
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
818
+ const models = await callListModels(adapter);
819
+ expect(models).toEqual([]);
820
+ expect(warn).toHaveBeenCalledTimes(1);
821
+ expect(warn.mock.calls[0]?.[0]).toMatch(/ECONNREFUSED/);
822
+ warn.mockRestore();
823
+ vi.unstubAllGlobals();
824
+ });
825
+ it('returns empty list when response shape is unexpected', async () => {
826
+ const mockFetch = vi.fn().mockResolvedValue({
827
+ ok: true,
828
+ json: async () => ({ message: 'hello' }),
829
+ });
830
+ vi.stubGlobal('fetch', mockFetch);
831
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
832
+ const adapter = createOpenAIAdapter({ apiKey: 'sk-test' });
833
+ const models = await callListModels(adapter);
834
+ expect(models).toEqual([]);
835
+ expect(warn).toHaveBeenCalledTimes(1);
836
+ warn.mockRestore();
837
+ vi.unstubAllGlobals();
838
+ });
839
+ });
840
+ describe('Anthropic listModels', () => {
841
+ it('returns joined entries from /v1/models with displayName + createdAt', async () => {
842
+ const mockFetch = vi.fn().mockResolvedValue({
843
+ ok: true,
844
+ json: async () => ({
845
+ data: [
846
+ {
847
+ id: 'claude-opus-4-7',
848
+ type: 'model',
849
+ display_name: 'Claude Opus 4.7',
850
+ created_at: '2026-01-15T00:00:00Z',
851
+ },
852
+ {
853
+ id: 'claude-sonnet-4-6',
854
+ type: 'model',
855
+ display_name: 'Claude Sonnet 4.6',
856
+ created_at: '2025-09-01T00:00:00Z',
857
+ },
858
+ ],
859
+ has_more: false,
860
+ last_id: 'claude-sonnet-4-6',
861
+ }),
862
+ });
863
+ vi.stubGlobal('fetch', mockFetch);
864
+ const adapter = createAnthropicAdapter({ apiKey: 'ant-test' });
865
+ const models = await callListModels(adapter);
866
+ const call = mockFetch.mock.calls[0];
867
+ expect(String(call?.[0])).toContain('https://api.anthropic.com/v1/models');
868
+ expect(call?.[1].headers['x-api-key']).toBe('ant-test');
869
+ expect(call?.[1].headers['anthropic-version']).toBe('2023-06-01');
870
+ expect(models).toHaveLength(2);
871
+ expect(models[0]).toMatchObject({
872
+ id: 'claude-opus-4-7',
873
+ provider: 'anthropic',
874
+ kind: 'text',
875
+ inputPerMTok: 5,
876
+ outputPerMTok: 25,
877
+ displayName: 'Claude Opus 4.7',
878
+ createdAt: '2026-01-15T00:00:00Z',
879
+ });
880
+ vi.unstubAllGlobals();
881
+ });
882
+ it('paginates via has_more / last_id', async () => {
883
+ const mockFetch = vi
884
+ .fn()
885
+ .mockResolvedValueOnce({
886
+ ok: true,
887
+ json: async () => ({
888
+ data: [{ id: 'claude-opus-4-7', display_name: 'Claude Opus 4.7' }],
889
+ has_more: true,
890
+ last_id: 'claude-opus-4-7',
891
+ }),
892
+ })
893
+ .mockResolvedValueOnce({
894
+ ok: true,
895
+ json: async () => ({
896
+ data: [{ id: 'claude-sonnet-4-6', display_name: 'Claude Sonnet 4.6' }],
897
+ has_more: false,
898
+ last_id: 'claude-sonnet-4-6',
899
+ }),
900
+ });
901
+ vi.stubGlobal('fetch', mockFetch);
902
+ const adapter = createAnthropicAdapter({ apiKey: 'ant-test' });
903
+ const models = await callListModels(adapter);
904
+ expect(mockFetch).toHaveBeenCalledTimes(2);
905
+ const page2Url = String(mockFetch.mock.calls[1]?.[0]);
906
+ expect(page2Url).toContain('after_id=claude-opus-4-7');
907
+ expect(models.map((m) => m.id)).toEqual(['claude-opus-4-7', 'claude-sonnet-4-6']);
908
+ vi.unstubAllGlobals();
909
+ });
910
+ it('returns empty list and warns on 401', async () => {
911
+ const mockFetch = vi.fn().mockResolvedValue({
912
+ ok: false,
913
+ status: 401,
914
+ statusText: 'Unauthorized',
915
+ });
916
+ vi.stubGlobal('fetch', mockFetch);
917
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
918
+ const adapter = createAnthropicAdapter({ apiKey: 'bad-key' });
919
+ const models = await callListModels(adapter);
920
+ expect(models).toEqual([]);
921
+ expect(warn).toHaveBeenCalled();
922
+ warn.mockRestore();
923
+ vi.unstubAllGlobals();
924
+ });
925
+ it('filters by kind (Anthropic models are all text)', async () => {
926
+ const mockFetch = vi.fn().mockResolvedValue({
927
+ ok: true,
928
+ json: async () => ({
929
+ data: [
930
+ { id: 'claude-opus-4-7', display_name: 'Claude Opus 4.7' },
931
+ { id: 'claude-sonnet-4-6', display_name: 'Claude Sonnet 4.6' },
932
+ ],
933
+ has_more: false,
934
+ }),
935
+ });
936
+ vi.stubGlobal('fetch', mockFetch);
937
+ const adapter = createAnthropicAdapter({ apiKey: 'ant-test' });
938
+ const embeddings = await callListModels(adapter, { kind: 'embedding' });
939
+ expect(embeddings).toEqual([]);
940
+ const texts = await callListModels(adapter, { kind: 'text' });
941
+ expect(texts.map((m) => m.id)).toEqual(['claude-opus-4-7', 'claude-sonnet-4-6']);
942
+ vi.unstubAllGlobals();
943
+ });
944
+ });
216
945
  });
217
946
  //# sourceMappingURL=provider.test.js.map