@robota-sdk/agent-provider 3.0.0-beta.64

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 (220) hide show
  1. package/LICENSE +21 -0
  2. package/dist/browser/index.d.ts +1104 -0
  3. package/dist/browser/index.d.ts.map +1 -0
  4. package/dist/browser/index.js +7 -0
  5. package/dist/browser/index.js.map +1 -0
  6. package/dist/loggers/index.cjs +1 -0
  7. package/dist/loggers/index.d.ts +151 -0
  8. package/dist/loggers/index.d.ts.map +1 -0
  9. package/dist/loggers/index.js +2 -0
  10. package/dist/loggers/index.js.map +1 -0
  11. package/dist/node/anthropic/index.cjs +1 -0
  12. package/dist/node/anthropic/index.d.ts +158 -0
  13. package/dist/node/anthropic/index.d.ts.map +1 -0
  14. package/dist/node/anthropic/index.js +1 -0
  15. package/dist/node/anthropic--1vgLC-e.js +5 -0
  16. package/dist/node/anthropic--1vgLC-e.js.map +1 -0
  17. package/dist/node/anthropic-BFQ6DSCP.cjs +4 -0
  18. package/dist/node/bytedance/index.cjs +1 -0
  19. package/dist/node/bytedance/index.d.ts +74 -0
  20. package/dist/node/bytedance/index.d.ts.map +1 -0
  21. package/dist/node/bytedance/index.js +1 -0
  22. package/dist/node/bytedance-C_0sF_pJ.js +2 -0
  23. package/dist/node/bytedance-C_0sF_pJ.js.map +1 -0
  24. package/dist/node/bytedance-DVPxqEiC.cjs +1 -0
  25. package/dist/node/chunk-Bmb41Sf3.cjs +1 -0
  26. package/dist/node/deepseek/index.cjs +1 -0
  27. package/dist/node/deepseek/index.d.ts +2 -0
  28. package/dist/node/deepseek/index.js +1 -0
  29. package/dist/node/deepseek-_8Ixx7rA.js +2 -0
  30. package/dist/node/deepseek-_8Ixx7rA.js.map +1 -0
  31. package/dist/node/deepseek-oA2Y6bD0.cjs +1 -0
  32. package/dist/node/gemini/index.cjs +1 -0
  33. package/dist/node/gemini/index.d.ts +173 -0
  34. package/dist/node/gemini/index.d.ts.map +1 -0
  35. package/dist/node/gemini/index.js +1 -0
  36. package/dist/node/gemini-Bh2U87MY.js +4 -0
  37. package/dist/node/gemini-Bh2U87MY.js.map +1 -0
  38. package/dist/node/gemini-DSaNCxZj.cjs +3 -0
  39. package/dist/node/gemma/index.cjs +1 -0
  40. package/dist/node/gemma/index.d.ts +2 -0
  41. package/dist/node/gemma/index.js +1 -0
  42. package/dist/node/gemma-Dp_AfCUR.js +2 -0
  43. package/dist/node/gemma-Dp_AfCUR.js.map +1 -0
  44. package/dist/node/gemma-G-Pf_PnX.cjs +1 -0
  45. package/dist/node/google/index.cjs +1 -0
  46. package/dist/node/google/index.d.ts +14 -0
  47. package/dist/node/google/index.d.ts.map +1 -0
  48. package/dist/node/google/index.js +2 -0
  49. package/dist/node/google/index.js.map +1 -0
  50. package/dist/node/index-B6PnlDMd.d.ts +82 -0
  51. package/dist/node/index-B6PnlDMd.d.ts.map +1 -0
  52. package/dist/node/index-B7UvPJcI.d.ts +315 -0
  53. package/dist/node/index-B7UvPJcI.d.ts.map +1 -0
  54. package/dist/node/index-BLPOTNb5.d.ts +98 -0
  55. package/dist/node/index-BLPOTNb5.d.ts.map +1 -0
  56. package/dist/node/index-BqixM_XD.d.ts +231 -0
  57. package/dist/node/index-BqixM_XD.d.ts.map +1 -0
  58. package/dist/node/index-C3beaqKO.d.ts +231 -0
  59. package/dist/node/index-C3beaqKO.d.ts.map +1 -0
  60. package/dist/node/index-Cp2XRh9G.d.ts +82 -0
  61. package/dist/node/index-Cp2XRh9G.d.ts.map +1 -0
  62. package/dist/node/index-DSv5xruI.d.ts +98 -0
  63. package/dist/node/index-DSv5xruI.d.ts.map +1 -0
  64. package/dist/node/index-w0bV1uaP.d.ts +315 -0
  65. package/dist/node/index-w0bV1uaP.d.ts.map +1 -0
  66. package/dist/node/index.cjs +1 -0
  67. package/dist/node/index.d.ts +8 -0
  68. package/dist/node/index.js +1 -0
  69. package/dist/node/openai/index.cjs +1 -0
  70. package/dist/node/openai/index.d.ts +2 -0
  71. package/dist/node/openai/index.js +1 -0
  72. package/dist/node/openai-CRQjg4xF.js +2 -0
  73. package/dist/node/openai-CRQjg4xF.js.map +1 -0
  74. package/dist/node/openai-compatible-BYfyY5lb.cjs +1 -0
  75. package/dist/node/openai-compatible-Dm4Sof9e.js +2 -0
  76. package/dist/node/openai-compatible-Dm4Sof9e.js.map +1 -0
  77. package/dist/node/openai-xWC6pY7r.cjs +1 -0
  78. package/dist/node/qwen/index.cjs +1 -0
  79. package/dist/node/qwen/index.d.ts +2 -0
  80. package/dist/node/qwen/index.js +1 -0
  81. package/dist/node/qwen-ChUZobTL.js +2 -0
  82. package/dist/node/qwen-ChUZobTL.js.map +1 -0
  83. package/dist/node/qwen-CjT71vSM.cjs +1 -0
  84. package/package.json +157 -0
  85. package/src/anthropic/__tests__/abort-streaming.test.ts +199 -0
  86. package/src/anthropic/__tests__/model-catalog-refresh.test.ts +92 -0
  87. package/src/anthropic/__tests__/provider-definition.test.ts +55 -0
  88. package/src/anthropic/__tests__/provider.test.ts +1357 -0
  89. package/src/anthropic/__tests__/response-parser.test.ts +326 -0
  90. package/src/anthropic/index.ts +22 -0
  91. package/src/anthropic/message-converter.ts +181 -0
  92. package/src/anthropic/model-catalog-refresh.ts +128 -0
  93. package/src/anthropic/parsers/response-parser.ts +184 -0
  94. package/src/anthropic/provider-definition.ts +93 -0
  95. package/src/anthropic/provider.ts +290 -0
  96. package/src/anthropic/streaming-handler.ts +204 -0
  97. package/src/anthropic/types/api-types.ts +158 -0
  98. package/src/anthropic/types.ts +79 -0
  99. package/src/bytedance/http-client.test.ts +288 -0
  100. package/src/bytedance/http-client.ts +163 -0
  101. package/src/bytedance/index.ts +2 -0
  102. package/src/bytedance/provider.spec.ts +320 -0
  103. package/src/bytedance/provider.ts +171 -0
  104. package/src/bytedance/status-mapper.test.ts +299 -0
  105. package/src/bytedance/status-mapper.ts +141 -0
  106. package/src/bytedance/types.ts +68 -0
  107. package/src/deepseek/defaults.ts +4 -0
  108. package/src/deepseek/index.ts +22 -0
  109. package/src/deepseek/model-catalog-refresh.test.ts +57 -0
  110. package/src/deepseek/model-catalog-refresh.ts +105 -0
  111. package/src/deepseek/model-catalog.ts +55 -0
  112. package/src/deepseek/provider-definition.test.ts +109 -0
  113. package/src/deepseek/provider-definition.ts +132 -0
  114. package/src/deepseek/provider.test.ts +324 -0
  115. package/src/deepseek/provider.ts +298 -0
  116. package/src/deepseek/types.ts +37 -0
  117. package/src/gemini/execution-helpers.ts +233 -0
  118. package/src/gemini/genai-transport.test.ts +208 -0
  119. package/src/gemini/image-operations.test.ts +448 -0
  120. package/src/gemini/image-operations.ts +261 -0
  121. package/src/gemini/index.ts +11 -0
  122. package/src/gemini/message-converter.test.ts +616 -0
  123. package/src/gemini/message-converter.ts +140 -0
  124. package/src/gemini/model-catalog-refresh.test.ts +107 -0
  125. package/src/gemini/model-catalog-refresh.ts +92 -0
  126. package/src/gemini/provider-definition.test.ts +70 -0
  127. package/src/gemini/provider-definition.ts +78 -0
  128. package/src/gemini/provider-extended.test.ts +898 -0
  129. package/src/gemini/provider.spec.ts +216 -0
  130. package/src/gemini/provider.ts +279 -0
  131. package/src/gemini/request-converter.ts +226 -0
  132. package/src/gemini/tool-schema-converter.ts +78 -0
  133. package/src/gemini/types/api-types.ts +235 -0
  134. package/src/gemini/types.ts +121 -0
  135. package/src/gemma/index.ts +5 -0
  136. package/src/gemma/message-factory.ts +38 -0
  137. package/src/gemma/provider-definition.test.ts +43 -0
  138. package/src/gemma/provider-definition.ts +84 -0
  139. package/src/gemma/provider-projection.ts +49 -0
  140. package/src/gemma/provider.test.ts +628 -0
  141. package/src/gemma/provider.ts +308 -0
  142. package/src/gemma/pseudo-command-envelope.ts +58 -0
  143. package/src/gemma/pseudo-tool-call-projector.ts +243 -0
  144. package/src/gemma/pseudo-tool-call-tag-parser.ts +153 -0
  145. package/src/gemma/pseudo-tool-call-types.ts +31 -0
  146. package/src/gemma/reasoning-projector.test.ts +52 -0
  147. package/src/gemma/reasoning-projector.ts +144 -0
  148. package/src/gemma/streaming-projection.ts +79 -0
  149. package/src/gemma/tool-call-argument-parser.ts +126 -0
  150. package/src/gemma/tool-call-projector.test.ts +227 -0
  151. package/src/gemma/tool-call-projector.ts +264 -0
  152. package/src/gemma/types.ts +27 -0
  153. package/src/google/index.ts +11 -0
  154. package/src/google/provider-compat.test.ts +19 -0
  155. package/src/google/provider-definition.ts +6 -0
  156. package/src/google/provider.ts +10 -0
  157. package/src/google/types.ts +5 -0
  158. package/src/index.ts +9 -0
  159. package/src/openai/adapter.test.ts +494 -0
  160. package/src/openai/adapter.ts +145 -0
  161. package/src/openai/chat-completions-chat.ts +189 -0
  162. package/src/openai/executor-integration.test.ts +206 -0
  163. package/src/openai/index.ts +21 -0
  164. package/src/openai/interfaces/payload-logger.ts +48 -0
  165. package/src/openai/loggers/console-payload-logger.test.ts +173 -0
  166. package/src/openai/loggers/console-payload-logger.ts +94 -0
  167. package/src/openai/loggers/console.ts +9 -0
  168. package/src/openai/loggers/file-payload-logger.test.ts +238 -0
  169. package/src/openai/loggers/file-payload-logger.ts +112 -0
  170. package/src/openai/loggers/file.ts +9 -0
  171. package/src/openai/loggers/index.ts +12 -0
  172. package/src/openai/loggers/sanitize-openai-log-data.test.ts +89 -0
  173. package/src/openai/loggers/sanitize-openai-log-data.ts +14 -0
  174. package/src/openai/message-converter.ts +22 -0
  175. package/src/openai/model-catalog-refresh.test.ts +92 -0
  176. package/src/openai/model-catalog-refresh.ts +115 -0
  177. package/src/openai/openai-request-format.ts +92 -0
  178. package/src/openai/parsers/response-parser.test.ts +407 -0
  179. package/src/openai/parsers/response-parser.ts +47 -0
  180. package/src/openai/provider-definition.test.ts +75 -0
  181. package/src/openai/provider-definition.ts +132 -0
  182. package/src/openai/provider.test.ts +1402 -0
  183. package/src/openai/provider.ts +237 -0
  184. package/src/openai/responses-chat.ts +258 -0
  185. package/src/openai/responses-converter.ts +112 -0
  186. package/src/openai/responses-parser.ts +285 -0
  187. package/src/openai/responses-stream-utils.ts +45 -0
  188. package/src/openai/responses-types.ts +195 -0
  189. package/src/openai/streaming/stream-assembler.ts +3 -0
  190. package/src/openai/streaming/stream-handler.test.ts +367 -0
  191. package/src/openai/streaming/stream-handler.ts +119 -0
  192. package/src/openai/types/api-types.ts +112 -0
  193. package/src/openai/types.ts +194 -0
  194. package/src/qwen/defaults.ts +26 -0
  195. package/src/qwen/index.ts +5 -0
  196. package/src/qwen/model-catalog-refresh.test.ts +91 -0
  197. package/src/qwen/model-catalog-refresh.ts +97 -0
  198. package/src/qwen/provider-capabilities.ts +34 -0
  199. package/src/qwen/provider-definition.test.ts +139 -0
  200. package/src/qwen/provider-definition.ts +173 -0
  201. package/src/qwen/provider-streaming-assembly.ts +40 -0
  202. package/src/qwen/provider.test.ts +640 -0
  203. package/src/qwen/provider.ts +293 -0
  204. package/src/qwen/responses-chat.ts +194 -0
  205. package/src/qwen/responses-converter.ts +104 -0
  206. package/src/qwen/responses-parser.ts +299 -0
  207. package/src/qwen/responses-stream-utils.ts +38 -0
  208. package/src/qwen/types.ts +228 -0
  209. package/src/shared/openai-compatible/endpoint-probe.test.ts +52 -0
  210. package/src/shared/openai-compatible/endpoint-probe.ts +43 -0
  211. package/src/shared/openai-compatible/index.ts +6 -0
  212. package/src/shared/openai-compatible/message-converter.test.ts +111 -0
  213. package/src/shared/openai-compatible/message-converter.ts +84 -0
  214. package/src/shared/openai-compatible/native-payload-observer.test.ts +43 -0
  215. package/src/shared/openai-compatible/native-payload-observer.ts +26 -0
  216. package/src/shared/openai-compatible/response-parser.test.ts +172 -0
  217. package/src/shared/openai-compatible/response-parser.ts +180 -0
  218. package/src/shared/openai-compatible/stream-assembler.test.ts +266 -0
  219. package/src/shared/openai-compatible/stream-assembler.ts +248 -0
  220. package/src/shared/openai-compatible/types.ts +59 -0
@@ -0,0 +1,1357 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type {
3
+ TUniversalMessage,
4
+ IChatOptions,
5
+ IToolSchema,
6
+ IExecutor,
7
+ IAssistantMessage,
8
+ IProviderNativeRawPayloadEvent,
9
+ } from '@robota-sdk/agent-core';
10
+
11
+ // Mock the @anthropic-ai/sdk module
12
+ vi.mock('@anthropic-ai/sdk', () => {
13
+ const MockAnthropic = vi.fn().mockImplementation(() => ({
14
+ messages: {
15
+ create: vi.fn(),
16
+ },
17
+ }));
18
+ return { default: MockAnthropic };
19
+ });
20
+
21
+ import Anthropic from '@anthropic-ai/sdk';
22
+ import { AnthropicProvider } from '../provider';
23
+ import type { IAnthropicProviderOptions } from '../types';
24
+
25
+ // Helper: build a minimal Anthropic text response
26
+ function makeTextResponse(text: string, model = 'claude-3-opus-20240229'): Anthropic.Message {
27
+ return {
28
+ id: 'msg_test',
29
+ type: 'message',
30
+ role: 'assistant',
31
+ content: [{ type: 'text', text }],
32
+ model,
33
+ stop_reason: 'end_turn',
34
+ stop_sequence: null,
35
+ usage: { input_tokens: 10, output_tokens: 20 },
36
+ } as Anthropic.Message;
37
+ }
38
+
39
+ // Helper: build a tool_use response
40
+ function makeToolUseResponse(
41
+ toolId: string,
42
+ name: string,
43
+ input: Record<string, unknown>,
44
+ ): Anthropic.Message {
45
+ return {
46
+ id: 'msg_test',
47
+ type: 'message',
48
+ role: 'assistant',
49
+ content: [
50
+ {
51
+ type: 'tool_use',
52
+ id: toolId,
53
+ name,
54
+ input,
55
+ },
56
+ ],
57
+ model: 'claude-3-opus-20240229',
58
+ stop_reason: 'tool_use',
59
+ stop_sequence: null,
60
+ usage: { input_tokens: 15, output_tokens: 25 },
61
+ } as unknown as Anthropic.Message;
62
+ }
63
+
64
+ /**
65
+ * Convert a makeTextResponse/makeToolUseResponse result into an async iterable
66
+ * of streaming events, matching the shape that chatWithStreaming expects.
67
+ */
68
+ function makeStreamEvents(response: Anthropic.Message): AsyncIterable<Record<string, unknown>> {
69
+ const events: Array<Record<string, unknown>> = [
70
+ {
71
+ type: 'message_start',
72
+ message: {
73
+ usage: { input_tokens: response.usage.input_tokens, output_tokens: 0 },
74
+ model: response.model,
75
+ },
76
+ },
77
+ ];
78
+
79
+ let blockIndex = 0;
80
+ for (const block of response.content) {
81
+ if (block.type === 'text') {
82
+ events.push({
83
+ type: 'content_block_start',
84
+ index: blockIndex,
85
+ content_block: { type: 'text' },
86
+ });
87
+ events.push({
88
+ type: 'content_block_delta',
89
+ index: blockIndex,
90
+ delta: { type: 'text_delta', text: (block as Anthropic.TextBlock).text },
91
+ });
92
+ events.push({ type: 'content_block_stop', index: blockIndex });
93
+ blockIndex++;
94
+ } else if (block.type === 'tool_use') {
95
+ const toolBlock = block as unknown as { id: string; name: string; input: unknown };
96
+ events.push({
97
+ type: 'content_block_start',
98
+ index: blockIndex,
99
+ content_block: { type: 'tool_use', id: toolBlock.id, name: toolBlock.name, input: {} },
100
+ });
101
+ events.push({
102
+ type: 'content_block_delta',
103
+ index: blockIndex,
104
+ delta: { type: 'input_json_delta', partial_json: JSON.stringify(toolBlock.input) },
105
+ });
106
+ events.push({ type: 'content_block_stop', index: blockIndex });
107
+ blockIndex++;
108
+ }
109
+ }
110
+
111
+ events.push({
112
+ type: 'message_delta',
113
+ delta: { stop_reason: response.stop_reason },
114
+ usage: { output_tokens: response.usage.output_tokens },
115
+ });
116
+ events.push({ type: 'message_stop' });
117
+
118
+ return {
119
+ async *[Symbol.asyncIterator]() {
120
+ for (const event of events) {
121
+ yield event;
122
+ }
123
+ },
124
+ };
125
+ }
126
+
127
+ describe('AnthropicProvider', () => {
128
+ let mockClient: { messages: { create: ReturnType<typeof vi.fn> } };
129
+
130
+ beforeEach(() => {
131
+ vi.clearAllMocks();
132
+ mockClient = {
133
+ messages: {
134
+ create: vi.fn(),
135
+ },
136
+ };
137
+ });
138
+
139
+ // ── Constructor ──────────────────────────────────────────────
140
+
141
+ describe('constructor', () => {
142
+ it('should accept a pre-built client', () => {
143
+ const provider = new AnthropicProvider({
144
+ client: mockClient as unknown as Anthropic,
145
+ });
146
+ expect(provider.name).toBe('anthropic');
147
+ expect(provider.version).toBe('1.0.0');
148
+ });
149
+
150
+ it('should create a client from apiKey', () => {
151
+ const provider = new AnthropicProvider({ apiKey: 'sk-ant-test' });
152
+ expect(provider).toBeDefined();
153
+ expect(Anthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'sk-ant-test' }));
154
+ });
155
+
156
+ it('should pass timeout and baseURL when creating client from apiKey', () => {
157
+ new AnthropicProvider({
158
+ apiKey: 'sk-ant-test',
159
+ timeout: 5000,
160
+ baseURL: 'https://custom.api',
161
+ });
162
+ expect(Anthropic).toHaveBeenCalledWith(
163
+ expect.objectContaining({
164
+ apiKey: 'sk-ant-test',
165
+ timeout: 5000,
166
+ baseURL: 'https://custom.api',
167
+ }),
168
+ );
169
+ });
170
+
171
+ it('should accept an executor without apiKey or client', () => {
172
+ const executor: IExecutor = {
173
+ executeChat: vi.fn(),
174
+ executeChatStream: vi.fn(),
175
+ supportsTools: () => true,
176
+ validateConfig: () => true,
177
+ name: 'mock-executor',
178
+ version: '1.0.0',
179
+ };
180
+ const provider = new AnthropicProvider({ executor });
181
+ expect(provider).toBeDefined();
182
+ });
183
+
184
+ it('should throw when no client, apiKey, or executor is provided', () => {
185
+ expect(() => new AnthropicProvider({} as IAnthropicProviderOptions)).toThrow(
186
+ 'Either Anthropic client, apiKey, or executor is required',
187
+ );
188
+ });
189
+ });
190
+
191
+ // ── validateConfig ───────────────────────────────────────────
192
+
193
+ describe('validateConfig', () => {
194
+ it('should return true when client and apiKey are present', () => {
195
+ const provider = new AnthropicProvider({
196
+ client: mockClient as unknown as Anthropic,
197
+ apiKey: 'sk-ant-test',
198
+ });
199
+ expect(provider.validateConfig()).toBe(true);
200
+ });
201
+
202
+ it('should return false when apiKey is missing', () => {
203
+ const provider = new AnthropicProvider({
204
+ client: mockClient as unknown as Anthropic,
205
+ });
206
+ expect(provider.validateConfig()).toBe(false);
207
+ });
208
+ });
209
+
210
+ // ── supportsTools ────────────────────────────────────────────
211
+
212
+ describe('supportsTools', () => {
213
+ it('should return true', () => {
214
+ const provider = new AnthropicProvider({
215
+ client: mockClient as unknown as Anthropic,
216
+ });
217
+ expect(provider.supportsTools()).toBe(true);
218
+ });
219
+ });
220
+
221
+ describe('capabilities', () => {
222
+ it('reports server web search support and generic configuration', () => {
223
+ const provider = new AnthropicProvider({
224
+ client: mockClient as unknown as Anthropic,
225
+ });
226
+
227
+ expect(provider.getCapabilities().nativeWebTools.webSearch).toEqual({
228
+ supported: true,
229
+ enabled: false,
230
+ source: 'anthropic-messages',
231
+ reason: 'Call configureNativeWebTools({ webSearch: true }) or set enableWebTools.',
232
+ });
233
+
234
+ provider.configureNativeWebTools({ webSearch: true });
235
+
236
+ expect(provider.enableWebTools).toBe(true);
237
+ expect(provider.getCapabilities().nativeWebTools).toEqual({
238
+ webSearch: { supported: true, enabled: true, source: 'anthropic-messages' },
239
+ webFetch: {
240
+ supported: false,
241
+ enabled: false,
242
+ source: 'anthropic-messages',
243
+ reason: 'Anthropic provider exposes server web search only.',
244
+ },
245
+ });
246
+ });
247
+ });
248
+
249
+ // ── dispose ──────────────────────────────────────────────────
250
+
251
+ describe('dispose', () => {
252
+ it('should resolve without error', async () => {
253
+ const provider = new AnthropicProvider({
254
+ client: mockClient as unknown as Anthropic,
255
+ });
256
+ await expect(provider.dispose()).resolves.toBeUndefined();
257
+ });
258
+ });
259
+
260
+ // ── chat() — validation ─────────────────────────────────────
261
+
262
+ describe('chat — validation', () => {
263
+ let provider: AnthropicProvider;
264
+
265
+ beforeEach(() => {
266
+ provider = new AnthropicProvider({
267
+ client: mockClient as unknown as Anthropic,
268
+ });
269
+ });
270
+
271
+ it('should throw when messages is not an array', async () => {
272
+ await expect(
273
+ provider.chat('not-an-array' as unknown as TUniversalMessage[], {}),
274
+ ).rejects.toThrow('Messages must be an array');
275
+ });
276
+
277
+ it('should throw when messages array is empty', async () => {
278
+ await expect(provider.chat([], {})).rejects.toThrow('Messages array cannot be empty');
279
+ });
280
+
281
+ it('should throw when a message has an invalid role', async () => {
282
+ const messages = [{ role: 'invalid', content: 'hi' }] as unknown as TUniversalMessage[];
283
+ await expect(provider.chat(messages, {})).rejects.toThrow('Invalid message role: invalid');
284
+ });
285
+
286
+ it('should throw when model is not specified', async () => {
287
+ const messages: TUniversalMessage[] = [
288
+ {
289
+ id: 'msg-1',
290
+ state: 'complete' as const,
291
+ role: 'user',
292
+ content: 'Hello',
293
+ timestamp: new Date(),
294
+ },
295
+ ];
296
+ await expect(provider.chat(messages, {})).rejects.toThrow(
297
+ 'Model is required in chat options',
298
+ );
299
+ });
300
+ });
301
+
302
+ // ── chat() — direct execution ───────────────────────────────
303
+
304
+ describe('chat — direct execution', () => {
305
+ let provider: AnthropicProvider;
306
+
307
+ beforeEach(() => {
308
+ provider = new AnthropicProvider({
309
+ client: mockClient as unknown as Anthropic,
310
+ });
311
+ });
312
+
313
+ it('should send correct request params and return text response', async () => {
314
+ const apiResponse = makeTextResponse('Hello there!');
315
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(apiResponse));
316
+
317
+ const messages: TUniversalMessage[] = [
318
+ {
319
+ id: 'msg-1',
320
+ state: 'complete' as const,
321
+ role: 'user',
322
+ content: 'Hi',
323
+ timestamp: new Date(),
324
+ },
325
+ ];
326
+ const options: IChatOptions = { model: 'claude-3-opus-20240229', maxTokens: 1024 };
327
+
328
+ const result = await provider.chat(messages, options);
329
+
330
+ expect(mockClient.messages.create).toHaveBeenCalledWith(
331
+ expect.objectContaining({
332
+ model: 'claude-3-opus-20240229',
333
+ max_tokens: 1024,
334
+ messages: [{ role: 'user', content: 'Hi' }],
335
+ }),
336
+ undefined,
337
+ );
338
+ expect(result.role).toBe('assistant');
339
+ expect(result.content).toBe('Hello there!');
340
+ expect(result.metadata).toBeDefined();
341
+ expect(result.metadata?.inputTokens).toBe(10);
342
+ expect(result.metadata?.outputTokens).toBe(20);
343
+ });
344
+
345
+ it('emits native Anthropic request and ordered stream event payloads', async () => {
346
+ const apiResponse = makeTextResponse('Hello there!');
347
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(apiResponse));
348
+ const events: IProviderNativeRawPayloadEvent[] = [];
349
+
350
+ await provider.chat(
351
+ [
352
+ {
353
+ id: 'msg-1',
354
+ state: 'complete' as const,
355
+ role: 'user',
356
+ content: 'Hi',
357
+ timestamp: new Date(),
358
+ },
359
+ ],
360
+ {
361
+ model: 'claude-3-opus-20240229',
362
+ onProviderNativeRawPayload: (event) => events.push(event),
363
+ },
364
+ );
365
+
366
+ expect(events[0]).toEqual(
367
+ expect.objectContaining({
368
+ provider: 'anthropic',
369
+ apiSurface: 'anthropic-messages',
370
+ payloadKind: 'request',
371
+ payload: expect.objectContaining({ model: 'claude-3-opus-20240229', stream: true }),
372
+ }),
373
+ );
374
+ expect(events.slice(1).map((event) => event.payloadKind)).toEqual([
375
+ 'stream_event',
376
+ 'stream_event',
377
+ 'stream_event',
378
+ 'stream_event',
379
+ 'stream_event',
380
+ 'stream_event',
381
+ ]);
382
+ expect(events.slice(1).map((event) => event.sequence)).toEqual([0, 1, 2, 3, 4, 5]);
383
+ });
384
+
385
+ it('should use model maxOutput when maxTokens is not specified', async () => {
386
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(makeTextResponse('ok')));
387
+
388
+ const messages: TUniversalMessage[] = [
389
+ {
390
+ id: 'msg-1',
391
+ state: 'complete' as const,
392
+ role: 'user',
393
+ content: 'Hi',
394
+ timestamp: new Date(),
395
+ },
396
+ ];
397
+ // claude-sonnet-4-6 has maxOutput: 64000 in CLAUDE_MODELS
398
+ await provider.chat(messages, { model: 'claude-sonnet-4-6' });
399
+
400
+ expect(mockClient.messages.create).toHaveBeenCalledWith(
401
+ expect.objectContaining({ max_tokens: 64000 }),
402
+ undefined,
403
+ );
404
+ });
405
+
406
+ it('should use DEFAULT_MAX_OUTPUT for unknown models', async () => {
407
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(makeTextResponse('ok')));
408
+
409
+ const messages: TUniversalMessage[] = [
410
+ {
411
+ id: 'msg-1',
412
+ state: 'complete' as const,
413
+ role: 'user',
414
+ content: 'Hi',
415
+ timestamp: new Date(),
416
+ },
417
+ ];
418
+ await provider.chat(messages, { model: 'unknown-model' });
419
+
420
+ // DEFAULT_MAX_OUTPUT = 16384
421
+ expect(mockClient.messages.create).toHaveBeenCalledWith(
422
+ expect.objectContaining({ max_tokens: 16384 }),
423
+ undefined,
424
+ );
425
+ });
426
+
427
+ it('should include temperature when specified', async () => {
428
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(makeTextResponse('ok')));
429
+
430
+ const messages: TUniversalMessage[] = [
431
+ {
432
+ id: 'msg-1',
433
+ state: 'complete' as const,
434
+ role: 'user',
435
+ content: 'Hi',
436
+ timestamp: new Date(),
437
+ },
438
+ ];
439
+ await provider.chat(messages, { model: 'claude-3-opus-20240229', temperature: 0.5 });
440
+
441
+ expect(mockClient.messages.create).toHaveBeenCalledWith(
442
+ expect.objectContaining({ temperature: 0.5 }),
443
+ undefined,
444
+ );
445
+ });
446
+
447
+ it('should include tools in Anthropic format when specified', async () => {
448
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(makeTextResponse('ok')));
449
+
450
+ const tools: IToolSchema[] = [
451
+ {
452
+ name: 'get_weather',
453
+ description: 'Get the weather',
454
+ parameters: { type: 'object', properties: { city: { type: 'string' } } },
455
+ },
456
+ ];
457
+ const messages: TUniversalMessage[] = [
458
+ {
459
+ id: 'msg-1',
460
+ state: 'complete' as const,
461
+ role: 'user',
462
+ content: 'Weather?',
463
+ timestamp: new Date(),
464
+ },
465
+ ];
466
+ await provider.chat(messages, { model: 'claude-3-opus-20240229', tools });
467
+
468
+ const callArgs = mockClient.messages.create.mock.calls[0][0];
469
+ expect(callArgs.tools).toEqual([
470
+ {
471
+ name: 'get_weather',
472
+ description: 'Get the weather',
473
+ input_schema: { type: 'object', properties: { city: { type: 'string' } } },
474
+ },
475
+ ]);
476
+ });
477
+
478
+ it('should handle tool_use response', async () => {
479
+ const apiResponse = makeToolUseResponse('call_1', 'get_weather', { city: 'Seoul' });
480
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(apiResponse));
481
+
482
+ const messages: TUniversalMessage[] = [
483
+ {
484
+ id: 'msg-1',
485
+ state: 'complete' as const,
486
+ role: 'user',
487
+ content: 'Weather in Seoul',
488
+ timestamp: new Date(),
489
+ },
490
+ ];
491
+ const result = await provider.chat(messages, { model: 'claude-3-opus-20240229' });
492
+
493
+ expect(result.role).toBe('assistant');
494
+ expect(result.content).toBe('');
495
+ const assistantResult = result as IAssistantMessage;
496
+ expect(assistantResult.toolCalls).toHaveLength(1);
497
+ expect(assistantResult.toolCalls![0]).toEqual({
498
+ id: 'call_1',
499
+ type: 'function',
500
+ function: {
501
+ name: 'get_weather',
502
+ arguments: JSON.stringify({ city: 'Seoul' }),
503
+ },
504
+ });
505
+ });
506
+
507
+ it('should return empty content when streaming response has no content blocks', async () => {
508
+ // With always-streaming, empty content just returns empty string (no throw)
509
+ const emptyResponse = {
510
+ id: 'msg_test',
511
+ type: 'message',
512
+ role: 'assistant',
513
+ content: [],
514
+ model: 'claude-3-opus-20240229',
515
+ stop_reason: 'end_turn',
516
+ stop_sequence: null,
517
+ usage: { input_tokens: 0, output_tokens: 0 },
518
+ } as unknown as Anthropic.Message;
519
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(emptyResponse));
520
+
521
+ const messages: TUniversalMessage[] = [
522
+ {
523
+ id: 'msg-1',
524
+ state: 'complete' as const,
525
+ role: 'user',
526
+ content: 'Hi',
527
+ timestamp: new Date(),
528
+ },
529
+ ];
530
+ const result = await provider.chat(messages, { model: 'claude-3-opus-20240229' });
531
+ expect(result.role).toBe('assistant');
532
+ expect(result.content).toBe('');
533
+ });
534
+
535
+ it('should skip unsupported content types and return empty content', async () => {
536
+ // With streaming, unsupported block types are simply ignored
537
+ const weirdResponse = {
538
+ id: 'msg_test',
539
+ type: 'message',
540
+ role: 'assistant',
541
+ content: [{ type: 'image', data: 'base64...' }],
542
+ model: 'claude-3-opus-20240229',
543
+ stop_reason: 'end_turn',
544
+ stop_sequence: null,
545
+ usage: { input_tokens: 0, output_tokens: 0 },
546
+ } as unknown as Anthropic.Message;
547
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(weirdResponse));
548
+
549
+ const messages: TUniversalMessage[] = [
550
+ {
551
+ id: 'msg-1',
552
+ state: 'complete' as const,
553
+ role: 'user',
554
+ content: 'Hi',
555
+ timestamp: new Date(),
556
+ },
557
+ ];
558
+ const result = await provider.chat(messages, { model: 'claude-3-opus-20240229' });
559
+ expect(result.role).toBe('assistant');
560
+ expect(result.content).toBe('');
561
+ });
562
+
563
+ it('should include stopReason in metadata when stop_reason is present', async () => {
564
+ const response = makeTextResponse('done');
565
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(response));
566
+
567
+ const messages: TUniversalMessage[] = [
568
+ {
569
+ id: 'msg-1',
570
+ state: 'complete' as const,
571
+ role: 'user',
572
+ content: 'Hi',
573
+ timestamp: new Date(),
574
+ },
575
+ ];
576
+ const result = await provider.chat(messages, { model: 'claude-3-opus-20240229' });
577
+ expect(result.metadata?.stopReason).toBe('end_turn');
578
+ });
579
+
580
+ it('should omit stopReason from metadata when stop_reason is null', async () => {
581
+ const response = makeTextResponse('done');
582
+ (response as unknown as Record<string, unknown>).stop_reason = null;
583
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(response));
584
+
585
+ const messages: TUniversalMessage[] = [
586
+ {
587
+ id: 'msg-1',
588
+ state: 'complete' as const,
589
+ role: 'user',
590
+ content: 'Hi',
591
+ timestamp: new Date(),
592
+ },
593
+ ];
594
+ const result = await provider.chat(messages, { model: 'claude-3-opus-20240229' });
595
+ expect(result.metadata?.stopReason).toBeUndefined();
596
+ });
597
+ });
598
+
599
+ // ── chat() — message conversion ─────────────────────────────
600
+
601
+ describe('chat — message format conversion', () => {
602
+ let provider: AnthropicProvider;
603
+
604
+ beforeEach(() => {
605
+ provider = new AnthropicProvider({
606
+ client: mockClient as unknown as Anthropic,
607
+ });
608
+ mockClient.messages.create.mockResolvedValue(makeStreamEvents(makeTextResponse('ok')));
609
+ });
610
+
611
+ it('should convert user messages', async () => {
612
+ const messages: TUniversalMessage[] = [
613
+ {
614
+ id: 'msg-1',
615
+ state: 'complete' as const,
616
+ role: 'user',
617
+ content: 'Hello',
618
+ timestamp: new Date(),
619
+ },
620
+ ];
621
+ await provider.chat(messages, { model: 'claude-3-opus-20240229' });
622
+
623
+ const sentMessages = mockClient.messages.create.mock.calls[0][0].messages;
624
+ expect(sentMessages).toEqual([{ role: 'user', content: 'Hello' }]);
625
+ });
626
+
627
+ it('should extract system messages to system parameter', async () => {
628
+ const messages: TUniversalMessage[] = [
629
+ {
630
+ id: 'msg-1',
631
+ state: 'complete' as const,
632
+ role: 'system',
633
+ content: 'You are helpful',
634
+ timestamp: new Date(),
635
+ },
636
+ {
637
+ id: 'msg-1',
638
+ state: 'complete' as const,
639
+ role: 'user',
640
+ content: 'Hi',
641
+ timestamp: new Date(),
642
+ },
643
+ ];
644
+ await provider.chat(messages, { model: 'claude-3-opus-20240229' });
645
+
646
+ const params = mockClient.messages.create.mock.calls[0][0];
647
+ expect(params.system).toBe('You are helpful');
648
+ expect(params.messages).toEqual([{ role: 'user', content: 'Hi' }]);
649
+ });
650
+
651
+ it('should convert assistant messages with toolCalls to content blocks', async () => {
652
+ const messages: TUniversalMessage[] = [
653
+ {
654
+ id: 'msg-1',
655
+ state: 'complete' as const,
656
+ role: 'user',
657
+ content: 'Hi',
658
+ timestamp: new Date(),
659
+ },
660
+ {
661
+ id: 'msg-2',
662
+ state: 'complete' as const,
663
+ role: 'assistant',
664
+ content: '',
665
+ timestamp: new Date(),
666
+ toolCalls: [
667
+ {
668
+ id: 'call_1',
669
+ type: 'function' as const,
670
+ function: {
671
+ name: 'get_weather',
672
+ arguments: JSON.stringify({ city: 'Tokyo' }),
673
+ },
674
+ },
675
+ ],
676
+ },
677
+ ];
678
+ await provider.chat(messages, { model: 'claude-3-opus-20240229' });
679
+
680
+ const sentMessages = mockClient.messages.create.mock.calls[0][0].messages;
681
+ expect(sentMessages[1]).toEqual({
682
+ role: 'assistant',
683
+ content: [
684
+ {
685
+ type: 'tool_use',
686
+ id: 'call_1',
687
+ name: 'get_weather',
688
+ input: { city: 'Tokyo' },
689
+ },
690
+ ],
691
+ });
692
+ });
693
+
694
+ it('should convert regular assistant messages as string content', async () => {
695
+ const messages: TUniversalMessage[] = [
696
+ {
697
+ id: 'msg-1',
698
+ state: 'complete' as const,
699
+ role: 'user',
700
+ content: 'Hi',
701
+ timestamp: new Date(),
702
+ },
703
+ {
704
+ id: 'msg-2',
705
+ state: 'complete' as const,
706
+ role: 'assistant',
707
+ content: 'Hello!',
708
+ timestamp: new Date(),
709
+ },
710
+ {
711
+ id: 'msg-3',
712
+ state: 'complete' as const,
713
+ role: 'user',
714
+ content: 'More',
715
+ timestamp: new Date(),
716
+ },
717
+ ];
718
+ await provider.chat(messages, { model: 'claude-3-opus-20240229' });
719
+
720
+ const sentMessages = mockClient.messages.create.mock.calls[0][0].messages;
721
+ expect(sentMessages[1]).toEqual({ role: 'assistant', content: 'Hello!' });
722
+ });
723
+
724
+ it('should default content to empty string when undefined', async () => {
725
+ const messages: TUniversalMessage[] = [
726
+ {
727
+ id: 'msg-1',
728
+ state: 'complete' as const,
729
+ role: 'user',
730
+ content: undefined as unknown as string,
731
+ timestamp: new Date(),
732
+ },
733
+ ];
734
+ await provider.chat(messages, { model: 'claude-3-opus-20240229' });
735
+
736
+ const sentMessages = mockClient.messages.create.mock.calls[0][0].messages;
737
+ expect(sentMessages[0].content).toBe('');
738
+ });
739
+ });
740
+
741
+ // ── chat() — executor delegation ────────────────────────────
742
+
743
+ describe('chat — executor delegation', () => {
744
+ it('should delegate to executor when configured', async () => {
745
+ const expectedResponse: TUniversalMessage = {
746
+ id: 'msg-1',
747
+ state: 'complete' as const,
748
+ role: 'assistant',
749
+ content: 'From executor',
750
+ timestamp: new Date(),
751
+ };
752
+ const executor: IExecutor = {
753
+ executeChat: vi.fn().mockResolvedValue(expectedResponse),
754
+ executeChatStream: vi.fn(),
755
+ supportsTools: () => true,
756
+ validateConfig: () => true,
757
+ name: 'mock-executor',
758
+ version: '1.0.0',
759
+ };
760
+
761
+ const provider = new AnthropicProvider({ executor });
762
+ const messages: TUniversalMessage[] = [
763
+ {
764
+ id: 'msg-1',
765
+ state: 'complete' as const,
766
+ role: 'user',
767
+ content: 'Hi',
768
+ timestamp: new Date(),
769
+ },
770
+ ];
771
+
772
+ const result = await provider.chat(messages, { model: 'claude-3-opus-20240229' });
773
+ expect(result).toBe(expectedResponse);
774
+ expect(executor.executeChat).toHaveBeenCalledWith(
775
+ expect.objectContaining({
776
+ provider: 'anthropic',
777
+ model: 'claude-3-opus-20240229',
778
+ }),
779
+ );
780
+ });
781
+
782
+ it('should propagate executor errors', async () => {
783
+ const executor: IExecutor = {
784
+ executeChat: vi.fn().mockRejectedValue(new Error('executor failed')),
785
+ executeChatStream: vi.fn(),
786
+ supportsTools: () => true,
787
+ validateConfig: () => true,
788
+ name: 'mock-executor',
789
+ version: '1.0.0',
790
+ };
791
+
792
+ const provider = new AnthropicProvider({ executor });
793
+ const messages: TUniversalMessage[] = [
794
+ {
795
+ id: 'msg-1',
796
+ state: 'complete' as const,
797
+ role: 'user',
798
+ content: 'Hi',
799
+ timestamp: new Date(),
800
+ },
801
+ ];
802
+
803
+ await expect(provider.chat(messages, { model: 'claude-3-opus-20240229' })).rejects.toThrow(
804
+ 'executor failed',
805
+ );
806
+ });
807
+ });
808
+
809
+ // ── chatStream() — validation ───────────────────────────────
810
+
811
+ describe('chatStream — validation', () => {
812
+ let provider: AnthropicProvider;
813
+
814
+ beforeEach(() => {
815
+ provider = new AnthropicProvider({
816
+ client: mockClient as unknown as Anthropic,
817
+ });
818
+ });
819
+
820
+ it('should throw when messages array is empty', async () => {
821
+ const gen = provider.chatStream([], { model: 'claude-3-opus-20240229' });
822
+ await expect(gen[Symbol.asyncIterator]().next()).rejects.toThrow(
823
+ 'Messages array cannot be empty',
824
+ );
825
+ });
826
+
827
+ it('should throw when model is not specified', async () => {
828
+ const messages: TUniversalMessage[] = [
829
+ {
830
+ id: 'msg-1',
831
+ state: 'complete' as const,
832
+ role: 'user',
833
+ content: 'Hi',
834
+ timestamp: new Date(),
835
+ },
836
+ ];
837
+ const gen = provider.chatStream(messages, {});
838
+ await expect(gen[Symbol.asyncIterator]().next()).rejects.toThrow(
839
+ 'Model is required in chat options',
840
+ );
841
+ });
842
+ });
843
+
844
+ // ── chatStream() — direct execution ─────────────────────────
845
+
846
+ describe('chatStream — direct execution', () => {
847
+ let provider: AnthropicProvider;
848
+
849
+ beforeEach(() => {
850
+ provider = new AnthropicProvider({
851
+ client: mockClient as unknown as Anthropic,
852
+ });
853
+ });
854
+
855
+ it('should yield text_delta chunks as assistant messages', async () => {
856
+ const asyncChunks = (async function* () {
857
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello' } };
858
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: ' world' } };
859
+ })();
860
+
861
+ mockClient.messages.create.mockResolvedValue(asyncChunks);
862
+
863
+ const messages: TUniversalMessage[] = [
864
+ {
865
+ id: 'msg-1',
866
+ state: 'complete' as const,
867
+ role: 'user',
868
+ content: 'Hi',
869
+ timestamp: new Date(),
870
+ },
871
+ ];
872
+
873
+ const chunks: TUniversalMessage[] = [];
874
+ for await (const chunk of provider.chatStream(messages, {
875
+ model: 'claude-3-opus-20240229',
876
+ })) {
877
+ chunks.push(chunk);
878
+ }
879
+
880
+ expect(chunks).toHaveLength(2);
881
+ expect(chunks[0].role).toBe('assistant');
882
+ expect(chunks[0].content).toBe('Hello');
883
+ expect(chunks[1].content).toBe(' world');
884
+ });
885
+
886
+ it('should skip non-text-delta chunks', async () => {
887
+ const asyncChunks = (async function* () {
888
+ yield { type: 'message_start', message: {} };
889
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hi' } };
890
+ yield { type: 'content_block_stop' };
891
+ yield { type: 'message_stop' };
892
+ })();
893
+
894
+ mockClient.messages.create.mockResolvedValue(asyncChunks);
895
+
896
+ const messages: TUniversalMessage[] = [
897
+ {
898
+ id: 'msg-1',
899
+ state: 'complete' as const,
900
+ role: 'user',
901
+ content: 'Hi',
902
+ timestamp: new Date(),
903
+ },
904
+ ];
905
+
906
+ const chunks: TUniversalMessage[] = [];
907
+ for await (const chunk of provider.chatStream(messages, {
908
+ model: 'claude-3-opus-20240229',
909
+ })) {
910
+ chunks.push(chunk);
911
+ }
912
+
913
+ expect(chunks).toHaveLength(1);
914
+ expect(chunks[0].content).toBe('Hi');
915
+ });
916
+
917
+ it('should include temperature and tools in stream request', async () => {
918
+ const asyncChunks = (async function* () {
919
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'ok' } };
920
+ })();
921
+ mockClient.messages.create.mockResolvedValue(asyncChunks);
922
+
923
+ const tools: IToolSchema[] = [
924
+ {
925
+ name: 'search',
926
+ description: 'Search',
927
+ parameters: { type: 'object', properties: {} },
928
+ },
929
+ ];
930
+ const messages: TUniversalMessage[] = [
931
+ {
932
+ id: 'msg-1',
933
+ state: 'complete' as const,
934
+ role: 'user',
935
+ content: 'Hi',
936
+ timestamp: new Date(),
937
+ },
938
+ ];
939
+
940
+ // Consume the stream
941
+ for await (const _chunk of provider.chatStream(messages, {
942
+ model: 'claude-3-opus-20240229',
943
+ temperature: 0.7,
944
+ tools,
945
+ })) {
946
+ // just consume
947
+ }
948
+
949
+ expect(mockClient.messages.create).toHaveBeenCalledWith(
950
+ expect.objectContaining({
951
+ stream: true,
952
+ temperature: 0.7,
953
+ tools: expect.arrayContaining([expect.objectContaining({ name: 'search' })]),
954
+ }),
955
+ );
956
+ });
957
+
958
+ it('should include enabled server web search in stream request', async () => {
959
+ const asyncChunks = (async function* () {
960
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'ok' } };
961
+ })();
962
+ mockClient.messages.create.mockResolvedValue(asyncChunks);
963
+ provider.configureNativeWebTools({ webSearch: true });
964
+
965
+ const messages: TUniversalMessage[] = [
966
+ {
967
+ id: 'msg-1',
968
+ state: 'complete' as const,
969
+ role: 'user',
970
+ content: 'Search current docs',
971
+ timestamp: new Date(),
972
+ },
973
+ ];
974
+
975
+ for await (const _chunk of provider.chatStream(messages, {
976
+ model: 'claude-3-opus-20240229',
977
+ nativeWebTools: { webSearch: true },
978
+ })) {
979
+ // just consume
980
+ }
981
+
982
+ expect(mockClient.messages.create).toHaveBeenCalledWith(
983
+ expect.objectContaining({
984
+ tools: expect.arrayContaining([
985
+ expect.objectContaining({ name: 'web_search', type: 'web_search_20250305' }),
986
+ ]),
987
+ }),
988
+ );
989
+ });
990
+ });
991
+
992
+ // ── chatStream() — executor delegation ──────────────────────
993
+
994
+ describe('chatStream — executor delegation', () => {
995
+ it('should delegate streaming to executor when configured', async () => {
996
+ const streamChunks: TUniversalMessage[] = [
997
+ {
998
+ id: 'msg-1',
999
+ state: 'complete' as const,
1000
+ role: 'assistant',
1001
+ content: 'chunk1',
1002
+ timestamp: new Date(),
1003
+ },
1004
+ {
1005
+ id: 'msg-2',
1006
+ state: 'complete' as const,
1007
+ role: 'assistant',
1008
+ content: 'chunk2',
1009
+ timestamp: new Date(),
1010
+ },
1011
+ ];
1012
+
1013
+ const executor: IExecutor = {
1014
+ executeChat: vi.fn(),
1015
+ executeChatStream: vi.fn().mockImplementation(async function* () {
1016
+ for (const c of streamChunks) {
1017
+ yield c;
1018
+ }
1019
+ }),
1020
+ supportsTools: () => true,
1021
+ validateConfig: () => true,
1022
+ name: 'mock-executor',
1023
+ version: '1.0.0',
1024
+ };
1025
+
1026
+ const provider = new AnthropicProvider({ executor });
1027
+ const messages: TUniversalMessage[] = [
1028
+ {
1029
+ id: 'msg-1',
1030
+ state: 'complete' as const,
1031
+ role: 'user',
1032
+ content: 'Hi',
1033
+ timestamp: new Date(),
1034
+ },
1035
+ ];
1036
+
1037
+ const collected: TUniversalMessage[] = [];
1038
+ for await (const chunk of provider.chatStream(messages, {
1039
+ model: 'claude-3-opus-20240229',
1040
+ })) {
1041
+ collected.push(chunk);
1042
+ }
1043
+
1044
+ expect(collected).toHaveLength(2);
1045
+ expect(collected[0].content).toBe('chunk1');
1046
+ });
1047
+ });
1048
+
1049
+ // ── chatStream() — no client available ──────────────────────
1050
+
1051
+ describe('chatStream — no client', () => {
1052
+ it('should throw when neither client nor executor is provided', () => {
1053
+ expect(() => new AnthropicProvider({} as IAnthropicProviderOptions)).toThrow(
1054
+ 'Either Anthropic client, apiKey, or executor is required',
1055
+ );
1056
+ });
1057
+ });
1058
+
1059
+ // ── chat() — no client for direct path ──────────────────────
1060
+
1061
+ describe('chat — no client for direct execution', () => {
1062
+ it('should throw when executor is not set and client is unavailable', async () => {
1063
+ expect(() => new AnthropicProvider({} as IAnthropicProviderOptions)).toThrow(
1064
+ 'Either Anthropic client, apiKey, or executor is required',
1065
+ );
1066
+ });
1067
+ });
1068
+
1069
+ // ── chatWithStreaming — content block parsing ──────────────
1070
+
1071
+ describe('chatWithStreaming — content block parsing', () => {
1072
+ let provider: AnthropicProvider;
1073
+ let textDeltas: string[];
1074
+
1075
+ beforeEach(() => {
1076
+ provider = new AnthropicProvider({
1077
+ client: mockClient as unknown as Anthropic,
1078
+ });
1079
+ textDeltas = [];
1080
+ provider.onTextDelta = (delta: string) => textDeltas.push(delta);
1081
+ });
1082
+
1083
+ function makeStream(events: Array<Record<string, unknown>>) {
1084
+ return (async function* () {
1085
+ for (const e of events) yield e;
1086
+ })();
1087
+ }
1088
+
1089
+ it('should parse text-only streaming response', async () => {
1090
+ mockClient.messages.create.mockResolvedValue(
1091
+ makeStream([
1092
+ {
1093
+ type: 'message_start',
1094
+ message: { usage: { input_tokens: 10, output_tokens: 0 }, model: 'test' },
1095
+ },
1096
+ { type: 'content_block_start', content_block: { type: 'text' } },
1097
+ { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello' } },
1098
+ { type: 'content_block_delta', delta: { type: 'text_delta', text: ' world' } },
1099
+ { type: 'content_block_stop' },
1100
+ {
1101
+ type: 'message_delta',
1102
+ delta: { stop_reason: 'end_turn' },
1103
+ usage: { output_tokens: 5 },
1104
+ },
1105
+ ]),
1106
+ );
1107
+
1108
+ const messages: TUniversalMessage[] = [
1109
+ {
1110
+ id: 'msg-1',
1111
+ state: 'complete' as const,
1112
+ role: 'user',
1113
+ content: 'Hi',
1114
+ timestamp: new Date(),
1115
+ },
1116
+ ];
1117
+ const result = await provider.chat(messages, { model: 'test' });
1118
+
1119
+ expect(result.content).toBe('Hello world');
1120
+ expect(textDeltas).toEqual(['Hello', ' world']);
1121
+ expect(result.metadata?.['stopReason']).toBe('end_turn');
1122
+ });
1123
+
1124
+ it('should parse tool_use blocks in streaming', async () => {
1125
+ mockClient.messages.create.mockResolvedValue(
1126
+ makeStream([
1127
+ {
1128
+ type: 'message_start',
1129
+ message: { usage: { input_tokens: 10, output_tokens: 0 }, model: 'test' },
1130
+ },
1131
+ {
1132
+ type: 'content_block_start',
1133
+ content_block: { type: 'tool_use', id: 'call_1', name: 'Bash', input: {} },
1134
+ },
1135
+ {
1136
+ type: 'content_block_delta',
1137
+ delta: { type: 'input_json_delta', partial_json: '{"command":' },
1138
+ },
1139
+ {
1140
+ type: 'content_block_delta',
1141
+ delta: { type: 'input_json_delta', partial_json: '"ls"}' },
1142
+ },
1143
+ { type: 'content_block_stop' },
1144
+ {
1145
+ type: 'message_delta',
1146
+ delta: { stop_reason: 'tool_use' },
1147
+ usage: { output_tokens: 10 },
1148
+ },
1149
+ ]),
1150
+ );
1151
+
1152
+ const result = await provider.chat(
1153
+ [
1154
+ {
1155
+ id: 'msg-1',
1156
+ state: 'complete' as const,
1157
+ role: 'user',
1158
+ content: 'list files',
1159
+ timestamp: new Date(),
1160
+ },
1161
+ ],
1162
+ { model: 'test' },
1163
+ );
1164
+
1165
+ const msg = result as IAssistantMessage;
1166
+ expect(msg.toolCalls).toHaveLength(1);
1167
+ expect(msg.toolCalls?.[0]?.function.name).toBe('Bash');
1168
+ expect(JSON.parse(msg.toolCalls?.[0]?.function.arguments ?? '{}')).toEqual({
1169
+ command: 'ls',
1170
+ });
1171
+ });
1172
+
1173
+ it('should parse server_tool_use (web_search) in streaming', async () => {
1174
+ mockClient.messages.create.mockResolvedValue(
1175
+ makeStream([
1176
+ {
1177
+ type: 'message_start',
1178
+ message: { usage: { input_tokens: 10, output_tokens: 0 }, model: 'test' },
1179
+ },
1180
+ {
1181
+ type: 'content_block_start',
1182
+ content_block: {
1183
+ type: 'server_tool_use',
1184
+ name: 'web_search',
1185
+ input: { query: 'Next.js latest' },
1186
+ },
1187
+ },
1188
+ { type: 'content_block_stop' },
1189
+ {
1190
+ type: 'content_block_start',
1191
+ content_block: {
1192
+ type: 'web_search_tool_result',
1193
+ content: [
1194
+ {
1195
+ type: 'web_search_result',
1196
+ title: 'Next.js 16.2 Released',
1197
+ url: 'https://nextjs.org/blog/16.2',
1198
+ },
1199
+ {
1200
+ type: 'web_search_result',
1201
+ title: 'Next.js GitHub',
1202
+ url: 'https://github.com/vercel/next.js',
1203
+ },
1204
+ ],
1205
+ },
1206
+ },
1207
+ { type: 'content_block_stop' },
1208
+ { type: 'content_block_start', content_block: { type: 'text' } },
1209
+ {
1210
+ type: 'content_block_delta',
1211
+ delta: { type: 'text_delta', text: 'Based on search results...' },
1212
+ },
1213
+ { type: 'content_block_stop' },
1214
+ {
1215
+ type: 'message_delta',
1216
+ delta: { stop_reason: 'end_turn' },
1217
+ usage: { output_tokens: 20 },
1218
+ },
1219
+ ]),
1220
+ );
1221
+
1222
+ const serverToolCalls: Array<{ name: string; input: Record<string, string> }> = [];
1223
+ provider.onServerToolUse = (name, input) => serverToolCalls.push({ name, input });
1224
+
1225
+ const result = await provider.chat(
1226
+ [
1227
+ {
1228
+ id: 'msg-1',
1229
+ state: 'complete' as const,
1230
+ role: 'user',
1231
+ content: 'search Next.js',
1232
+ timestamp: new Date(),
1233
+ },
1234
+ ],
1235
+ { model: 'test' },
1236
+ );
1237
+
1238
+ // Should contain search label + results + answer text
1239
+ expect(result.content).toContain('Searching: "Next.js latest"');
1240
+ expect(result.content).toContain('Next.js 16.2 Released');
1241
+ expect(result.content).toContain('Based on search results...');
1242
+
1243
+ // onServerToolUse callback should have fired
1244
+ expect(serverToolCalls).toHaveLength(1);
1245
+ expect(serverToolCalls[0]?.name).toBe('web_search');
1246
+ expect(serverToolCalls[0]?.input.query).toBe('Next.js latest');
1247
+
1248
+ // onTextDelta should have received search indicator
1249
+ expect(textDeltas.some((d) => d.includes('Searching'))).toBe(true);
1250
+ });
1251
+
1252
+ it('should parse mixed text + tool_use + server_tool_use', async () => {
1253
+ mockClient.messages.create.mockResolvedValue(
1254
+ makeStream([
1255
+ {
1256
+ type: 'message_start',
1257
+ message: { usage: { input_tokens: 10, output_tokens: 0 }, model: 'test' },
1258
+ },
1259
+ { type: 'content_block_start', content_block: { type: 'text' } },
1260
+ {
1261
+ type: 'content_block_delta',
1262
+ delta: { type: 'text_delta', text: 'Let me search and read.' },
1263
+ },
1264
+ { type: 'content_block_stop' },
1265
+ {
1266
+ type: 'content_block_start',
1267
+ content_block: {
1268
+ type: 'server_tool_use',
1269
+ name: 'web_search',
1270
+ input: { query: 'test' },
1271
+ },
1272
+ },
1273
+ { type: 'content_block_stop' },
1274
+ {
1275
+ type: 'content_block_start',
1276
+ content_block: {
1277
+ type: 'web_search_tool_result',
1278
+ content: [
1279
+ { type: 'web_search_result', title: 'Result 1', url: 'https://example.com' },
1280
+ ],
1281
+ },
1282
+ },
1283
+ { type: 'content_block_stop' },
1284
+ {
1285
+ type: 'content_block_start',
1286
+ content_block: { type: 'tool_use', id: 'call_2', name: 'Read', input: {} },
1287
+ },
1288
+ {
1289
+ type: 'content_block_delta',
1290
+ delta: { type: 'input_json_delta', partial_json: '{"filePath":"test.ts"}' },
1291
+ },
1292
+ { type: 'content_block_stop' },
1293
+ {
1294
+ type: 'message_delta',
1295
+ delta: { stop_reason: 'tool_use' },
1296
+ usage: { output_tokens: 30 },
1297
+ },
1298
+ ]),
1299
+ );
1300
+
1301
+ const result = await provider.chat(
1302
+ [
1303
+ {
1304
+ id: 'msg-1',
1305
+ state: 'complete' as const,
1306
+ role: 'user',
1307
+ content: 'search and read',
1308
+ timestamp: new Date(),
1309
+ },
1310
+ ],
1311
+ { model: 'test' },
1312
+ );
1313
+
1314
+ // Text parts present
1315
+ expect(result.content).toContain('Let me search and read.');
1316
+ expect(result.content).toContain('Searching: "test"');
1317
+ expect(result.content).toContain('Result 1');
1318
+
1319
+ // FunctionTool call present
1320
+ const msg = result as IAssistantMessage;
1321
+ expect(msg.toolCalls).toHaveLength(1);
1322
+ expect(msg.toolCalls?.[0]?.function.name).toBe('Read');
1323
+ });
1324
+
1325
+ it('should handle empty streaming response', async () => {
1326
+ mockClient.messages.create.mockResolvedValue(
1327
+ makeStream([
1328
+ {
1329
+ type: 'message_start',
1330
+ message: { usage: { input_tokens: 5, output_tokens: 0 }, model: 'test' },
1331
+ },
1332
+ {
1333
+ type: 'message_delta',
1334
+ delta: { stop_reason: 'end_turn' },
1335
+ usage: { output_tokens: 0 },
1336
+ },
1337
+ ]),
1338
+ );
1339
+
1340
+ const result = await provider.chat(
1341
+ [
1342
+ {
1343
+ id: 'msg-1',
1344
+ state: 'complete' as const,
1345
+ role: 'user',
1346
+ content: 'empty',
1347
+ timestamp: new Date(),
1348
+ },
1349
+ ],
1350
+ { model: 'test' },
1351
+ );
1352
+
1353
+ expect(result.content).toBe('');
1354
+ expect(textDeltas).toEqual([]);
1355
+ });
1356
+ });
1357
+ });