@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,640 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import type OpenAI from 'openai';
3
+ import type {
4
+ IProviderNativeRawPayloadEvent,
5
+ IToolSchema,
6
+ TUniversalMessage,
7
+ } from '@robota-sdk/agent-core';
8
+ import {
9
+ DEFAULT_QWEN_PROVIDER_BASE_URL,
10
+ DEFAULT_QWEN_PROVIDER_RESPONSES_BASE_URL,
11
+ QwenProvider,
12
+ } from './index';
13
+
14
+ vi.mock('openai', () => {
15
+ const MockOpenAI = vi.fn().mockImplementation(() => ({
16
+ chat: {
17
+ completions: {
18
+ create: vi.fn(),
19
+ },
20
+ },
21
+ responses: {
22
+ create: vi.fn(),
23
+ },
24
+ }));
25
+ return { default: MockOpenAI };
26
+ });
27
+
28
+ const timestamp = new Date('2026-05-01T00:00:00.000Z');
29
+
30
+ interface IOpenAIClientMock {
31
+ chat: {
32
+ completions: {
33
+ create: ReturnType<typeof vi.fn>;
34
+ };
35
+ };
36
+ responses: {
37
+ create: ReturnType<typeof vi.fn>;
38
+ };
39
+ }
40
+
41
+ function getClient(provider: QwenProvider): IOpenAIClientMock {
42
+ return (provider as unknown as { client: IOpenAIClientMock }).client;
43
+ }
44
+
45
+ function getResponsesClient(provider: QwenProvider): IOpenAIClientMock {
46
+ return (provider as unknown as { responsesClient: IOpenAIClientMock }).responsesClient;
47
+ }
48
+
49
+ function createUserMessage(content: string): TUniversalMessage {
50
+ return {
51
+ id: 'user-1',
52
+ role: 'user',
53
+ content,
54
+ state: 'complete',
55
+ timestamp,
56
+ };
57
+ }
58
+
59
+ function createToolSchema(): IToolSchema {
60
+ return {
61
+ name: 'inspect_file',
62
+ description: 'Inspect a file',
63
+ parameters: {
64
+ type: 'object',
65
+ properties: {
66
+ path: { type: 'string' },
67
+ },
68
+ required: ['path'],
69
+ },
70
+ };
71
+ }
72
+
73
+ async function* asyncIterableFrom<T>(items: T[]): AsyncIterable<T> {
74
+ for (const item of items) {
75
+ yield item;
76
+ }
77
+ }
78
+
79
+ function createChunk(
80
+ content: string,
81
+ finishReason: OpenAI.Chat.ChatCompletionChunk.Choice['finish_reason'] = null,
82
+ ): OpenAI.Chat.ChatCompletionChunk {
83
+ return {
84
+ id: 'chunk-1',
85
+ object: 'chat.completion.chunk',
86
+ created: 1,
87
+ model: 'qwen-plus',
88
+ choices: [
89
+ {
90
+ index: 0,
91
+ delta: { content },
92
+ finish_reason: finishReason,
93
+ logprobs: null,
94
+ },
95
+ ],
96
+ };
97
+ }
98
+
99
+ describe('QwenProvider', () => {
100
+ beforeEach(() => {
101
+ vi.clearAllMocks();
102
+ });
103
+
104
+ it('creates an OpenAI-compatible client with Qwen endpoint options', async () => {
105
+ const OpenAIModule = await import('openai');
106
+ const OpenAIConstructor = vi.mocked(OpenAIModule.default);
107
+
108
+ const provider = new QwenProvider({
109
+ apiKey: 'dashscope-key',
110
+ baseURL: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
111
+ timeout: 1000,
112
+ });
113
+
114
+ expect(provider.name).toBe('qwen');
115
+ expect(OpenAIConstructor).toHaveBeenCalledWith({
116
+ apiKey: 'dashscope-key',
117
+ baseURL: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
118
+ timeout: 1000,
119
+ });
120
+ });
121
+
122
+ it('uses the documented Qwen OpenAI-compatible base URL by default', async () => {
123
+ const OpenAIModule = await import('openai');
124
+ const OpenAIConstructor = vi.mocked(OpenAIModule.default);
125
+
126
+ new QwenProvider({
127
+ apiKey: 'dashscope-key',
128
+ });
129
+
130
+ expect(OpenAIConstructor).toHaveBeenCalledWith({
131
+ apiKey: 'dashscope-key',
132
+ baseURL: DEFAULT_QWEN_PROVIDER_BASE_URL,
133
+ });
134
+ });
135
+
136
+ it('creates a Responses API client when provider-side web tools are configured', async () => {
137
+ const OpenAIModule = await import('openai');
138
+ const OpenAIConstructor = vi.mocked(OpenAIModule.default);
139
+
140
+ new QwenProvider({
141
+ apiKey: 'dashscope-key',
142
+ builtInWebTools: { webSearch: true },
143
+ });
144
+
145
+ expect(OpenAIConstructor).toHaveBeenCalledWith({
146
+ apiKey: 'dashscope-key',
147
+ baseURL: DEFAULT_QWEN_PROVIDER_RESPONSES_BASE_URL,
148
+ });
149
+ });
150
+
151
+ it('reports provider-native web capabilities from built-in web tool options', () => {
152
+ const disabled = new QwenProvider({ apiKey: 'dashscope-key' });
153
+ const enabled = new QwenProvider({
154
+ apiKey: 'dashscope-key',
155
+ builtInWebTools: { webFetch: true },
156
+ });
157
+
158
+ expect(disabled.getCapabilities().nativeWebTools).toEqual({
159
+ webSearch: {
160
+ supported: true,
161
+ enabled: false,
162
+ source: 'qwen-responses',
163
+ reason: 'Enable builtInWebTools.webSearch or builtInWebTools.webFetch.',
164
+ },
165
+ webFetch: {
166
+ supported: true,
167
+ enabled: false,
168
+ source: 'qwen-responses',
169
+ reason: 'Enable builtInWebTools.webFetch.',
170
+ },
171
+ });
172
+ expect(enabled.getCapabilities().nativeWebTools).toEqual({
173
+ webSearch: { supported: true, enabled: true, source: 'qwen-responses' },
174
+ webFetch: { supported: true, enabled: true, source: 'qwen-responses' },
175
+ });
176
+ });
177
+
178
+ it('rejects request-level native web tools when Qwen built-in web tools are disabled', async () => {
179
+ const provider = new QwenProvider({ apiKey: 'dashscope-key' });
180
+
181
+ await expect(
182
+ provider.chat([createUserMessage('Search the web')], {
183
+ model: 'qwen-plus',
184
+ nativeWebTools: { webSearch: true },
185
+ }),
186
+ ).rejects.toThrow(
187
+ 'Provider qwen supports native web search but it is not enabled. Enable builtInWebTools.webSearch or builtInWebTools.webFetch.',
188
+ );
189
+ });
190
+
191
+ it('sends OpenAI-compatible messages and tools, then parses native tool calls', async () => {
192
+ const provider = new QwenProvider({
193
+ apiKey: 'dashscope-key',
194
+ baseURL: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
195
+ });
196
+ const client = getClient(provider);
197
+ client.chat.completions.create.mockResolvedValue({
198
+ id: 'chatcmpl-test',
199
+ object: 'chat.completion',
200
+ created: 1,
201
+ model: 'qwen-plus',
202
+ choices: [
203
+ {
204
+ index: 0,
205
+ message: {
206
+ role: 'assistant',
207
+ content: '',
208
+ refusal: null,
209
+ tool_calls: [
210
+ {
211
+ id: 'call_1',
212
+ type: 'function',
213
+ function: {
214
+ name: 'inspect_file',
215
+ arguments: '{"path":"README.md"}',
216
+ },
217
+ },
218
+ ],
219
+ },
220
+ finish_reason: 'tool_calls',
221
+ logprobs: null,
222
+ },
223
+ ],
224
+ usage: {
225
+ prompt_tokens: 10,
226
+ completion_tokens: 4,
227
+ total_tokens: 14,
228
+ },
229
+ } satisfies OpenAI.Chat.ChatCompletion);
230
+
231
+ const result = await provider.chat([createUserMessage('Inspect README')], {
232
+ model: 'qwen-plus',
233
+ tools: [createToolSchema()],
234
+ });
235
+
236
+ expect(client.chat.completions.create).toHaveBeenCalledWith({
237
+ model: 'qwen-plus',
238
+ messages: [{ role: 'user', content: 'Inspect README' }],
239
+ tools: [
240
+ {
241
+ type: 'function',
242
+ function: {
243
+ name: 'inspect_file',
244
+ description: 'Inspect a file',
245
+ parameters: createToolSchema().parameters,
246
+ },
247
+ },
248
+ ],
249
+ tool_choice: 'auto',
250
+ });
251
+ expect(result.content).toBe('');
252
+ expect(result.metadata?.['finishReason']).toBe('tool_calls');
253
+ if (result.role !== 'assistant') throw new Error('Expected assistant message');
254
+ expect(result).toMatchObject({
255
+ usage: {
256
+ promptTokens: 10,
257
+ completionTokens: 4,
258
+ totalTokens: 14,
259
+ },
260
+ });
261
+ expect(result.toolCalls).toEqual([
262
+ {
263
+ id: 'call_1',
264
+ type: 'function',
265
+ function: {
266
+ name: 'inspect_file',
267
+ arguments: '{"path":"README.md"}',
268
+ },
269
+ },
270
+ ]);
271
+ });
272
+
273
+ it('emits native Chat Completions request and response payloads', async () => {
274
+ const provider = new QwenProvider({ apiKey: 'dashscope-key' });
275
+ const client = getClient(provider);
276
+ client.chat.completions.create.mockResolvedValue({
277
+ id: 'qwen-chat-native',
278
+ object: 'chat.completion',
279
+ created: 1,
280
+ model: 'qwen-plus',
281
+ choices: [
282
+ {
283
+ index: 0,
284
+ message: { role: 'assistant', content: 'native', refusal: null },
285
+ finish_reason: 'stop',
286
+ logprobs: null,
287
+ },
288
+ ],
289
+ } satisfies OpenAI.Chat.ChatCompletion);
290
+ const events: IProviderNativeRawPayloadEvent[] = [];
291
+
292
+ await provider.chat([createUserMessage('Hello')], {
293
+ model: 'qwen-plus',
294
+ onProviderNativeRawPayload: (event) => events.push(event),
295
+ });
296
+
297
+ expect(events).toEqual([
298
+ expect.objectContaining({
299
+ provider: 'qwen',
300
+ apiSurface: 'chat-completions',
301
+ payloadKind: 'request',
302
+ }),
303
+ expect.objectContaining({
304
+ provider: 'qwen',
305
+ apiSurface: 'chat-completions',
306
+ payloadKind: 'response',
307
+ payload: expect.objectContaining({ id: 'qwen-chat-native' }),
308
+ }),
309
+ ]);
310
+ });
311
+
312
+ it('uses streaming assembly when text deltas are requested', async () => {
313
+ const provider = new QwenProvider({ apiKey: 'dashscope-key' });
314
+ const client = getClient(provider);
315
+ client.chat.completions.create.mockResolvedValue(
316
+ asyncIterableFrom([createChunk('Hello'), createChunk(' from '), createChunk('Qwen', 'stop')]),
317
+ );
318
+ const onTextDelta = vi.fn();
319
+
320
+ const result = await provider.chat([createUserMessage('Hello')], {
321
+ model: 'qwen-plus',
322
+ onTextDelta,
323
+ });
324
+
325
+ expect(client.chat.completions.create).toHaveBeenCalledWith(
326
+ {
327
+ model: 'qwen-plus',
328
+ messages: [{ role: 'user', content: 'Hello' }],
329
+ stream: true,
330
+ },
331
+ undefined,
332
+ );
333
+ expect(onTextDelta).toHaveBeenNthCalledWith(1, 'Hello');
334
+ expect(onTextDelta).toHaveBeenNthCalledWith(2, ' from ');
335
+ expect(onTextDelta).toHaveBeenNthCalledWith(3, 'Qwen');
336
+ expect(result.content).toBe('Hello from Qwen');
337
+ expect(result.metadata?.['model']).toBe('qwen-plus');
338
+ expect(result.metadata?.['finishReason']).toBe('stop');
339
+ });
340
+
341
+ it('emits ordered native Chat Completions stream chunks', async () => {
342
+ const provider = new QwenProvider({ apiKey: 'dashscope-key' });
343
+ const client = getClient(provider);
344
+ client.chat.completions.create.mockResolvedValue(
345
+ asyncIterableFrom([createChunk('Hello'), createChunk('Qwen', 'stop')]),
346
+ );
347
+ const events: IProviderNativeRawPayloadEvent[] = [];
348
+
349
+ await provider.chat([createUserMessage('Hello')], {
350
+ model: 'qwen-plus',
351
+ onTextDelta: vi.fn(),
352
+ onProviderNativeRawPayload: (event) => events.push(event),
353
+ });
354
+
355
+ expect(events.map((event) => event.payloadKind)).toEqual([
356
+ 'request',
357
+ 'stream_event',
358
+ 'stream_event',
359
+ ]);
360
+ expect(
361
+ events.filter((event) => event.payloadKind === 'stream_event').map((event) => event.sequence),
362
+ ).toEqual([0, 1]);
363
+ });
364
+
365
+ it('uses Qwen Responses API with web_search when built-in web search is enabled', async () => {
366
+ const provider = new QwenProvider({
367
+ apiKey: 'dashscope-key',
368
+ builtInWebTools: { webSearch: true, enableThinking: true },
369
+ });
370
+ const client = getResponsesClient(provider);
371
+ client.responses.create.mockResolvedValue({
372
+ id: 'resp_1',
373
+ model: 'qwen3.6-plus',
374
+ output_text: 'Search-backed answer',
375
+ status: 'completed',
376
+ output: [
377
+ { type: 'web_search_call', id: 'ws_1', status: 'completed' },
378
+ {
379
+ type: 'message',
380
+ content: [{ type: 'output_text', text: 'Search-backed answer' }],
381
+ },
382
+ ],
383
+ usage: {
384
+ input_tokens: 10,
385
+ output_tokens: 4,
386
+ total_tokens: 14,
387
+ x_tools: { web_search: { count: 1 } },
388
+ },
389
+ });
390
+
391
+ const result = await provider.chat([createUserMessage('Latest Qwen news')], {
392
+ model: 'qwen3.6-plus',
393
+ });
394
+
395
+ expect(client.responses.create).toHaveBeenCalledWith(
396
+ {
397
+ model: 'qwen3.6-plus',
398
+ input: [{ role: 'user', content: 'Latest Qwen news' }],
399
+ tools: [{ type: 'web_search' }],
400
+ enable_thinking: true,
401
+ },
402
+ undefined,
403
+ );
404
+ expect(result.content).toBe('Search-backed answer');
405
+ expect(result.metadata?.['providerToolMode']).toBe('qwen_responses');
406
+ expect(result.metadata?.['providerBuiltInToolsEnabled']).toEqual(['web_search']);
407
+ expect(result.metadata?.['providerBuiltInToolsUsed']).toEqual(['web_search']);
408
+ expect(result.metadata?.['qwenWebSearchCalls']).toBe(1);
409
+ });
410
+
411
+ it('emits native Qwen Responses request and response payloads', async () => {
412
+ const provider = new QwenProvider({
413
+ apiKey: 'dashscope-key',
414
+ builtInWebTools: { webSearch: true },
415
+ });
416
+ const client = getResponsesClient(provider);
417
+ client.responses.create.mockResolvedValue({
418
+ id: 'qwen-resp-native',
419
+ model: 'qwen3.6-plus',
420
+ output_text: 'Search-backed answer',
421
+ status: 'completed',
422
+ output: [],
423
+ });
424
+ const events: IProviderNativeRawPayloadEvent[] = [];
425
+
426
+ await provider.chat([createUserMessage('Latest')], {
427
+ model: 'qwen3.6-plus',
428
+ onProviderNativeRawPayload: (event) => events.push(event),
429
+ });
430
+
431
+ expect(events).toEqual([
432
+ expect.objectContaining({
433
+ provider: 'qwen',
434
+ apiSurface: 'responses',
435
+ payloadKind: 'request',
436
+ }),
437
+ expect.objectContaining({
438
+ provider: 'qwen',
439
+ apiSurface: 'responses',
440
+ payloadKind: 'response',
441
+ payload: expect.objectContaining({ id: 'qwen-resp-native' }),
442
+ }),
443
+ ]);
444
+ });
445
+
446
+ it('adds web_search and web_extractor when built-in web fetch is enabled', async () => {
447
+ const provider = new QwenProvider({
448
+ apiKey: 'dashscope-key',
449
+ builtInWebTools: { webFetch: true },
450
+ });
451
+ const client = getResponsesClient(provider);
452
+ client.responses.create.mockResolvedValue({
453
+ output_text: 'Fetched answer',
454
+ output: [
455
+ {
456
+ type: 'web_extractor_call',
457
+ id: 'wx_1',
458
+ status: 'completed',
459
+ goal: 'fetch a page',
460
+ output: 'page text',
461
+ },
462
+ ],
463
+ usage: {
464
+ input_tokens: 5,
465
+ output_tokens: 3,
466
+ total_tokens: 8,
467
+ x_tools: { web_search: { count: 1 }, web_extractor: { count: 1 } },
468
+ },
469
+ });
470
+
471
+ const result = await provider.chat([createUserMessage('Fetch https://example.com')], {
472
+ model: 'qwen3.6-plus',
473
+ });
474
+
475
+ expect(client.responses.create).toHaveBeenCalledWith(
476
+ {
477
+ model: 'qwen3.6-plus',
478
+ input: [{ role: 'user', content: 'Fetch https://example.com' }],
479
+ tools: [{ type: 'web_search' }, { type: 'web_extractor' }],
480
+ },
481
+ undefined,
482
+ );
483
+ expect(result.metadata?.['providerBuiltInToolsEnabled']).toEqual([
484
+ 'web_search',
485
+ 'web_extractor',
486
+ ]);
487
+ expect(result.metadata?.['qwenWebExtractorCalls']).toBe(1);
488
+ });
489
+
490
+ it('streams Qwen Responses API text deltas and records provider-side tool provenance', async () => {
491
+ const provider = new QwenProvider({
492
+ apiKey: 'dashscope-key',
493
+ builtInWebTools: { webFetch: true },
494
+ });
495
+ const client = getResponsesClient(provider);
496
+ client.responses.create.mockResolvedValue(
497
+ asyncIterableFrom([
498
+ { type: 'response.output_text.delta', delta: 'Hello ' },
499
+ {
500
+ type: 'response.output_item.done',
501
+ item: {
502
+ type: 'web_extractor_call',
503
+ id: 'wx_1',
504
+ status: 'completed',
505
+ goal: 'extract page',
506
+ output: 'content',
507
+ },
508
+ },
509
+ { type: 'response.output_text.delta', delta: 'world' },
510
+ {
511
+ type: 'response.completed',
512
+ response: {
513
+ id: 'resp_1',
514
+ model: 'qwen3.6-plus',
515
+ status: 'completed',
516
+ output_text: 'Hello world',
517
+ output: [],
518
+ usage: {
519
+ input_tokens: 12,
520
+ output_tokens: 2,
521
+ total_tokens: 14,
522
+ x_tools: { web_search: { count: 1 }, web_extractor: { count: 1 } },
523
+ },
524
+ },
525
+ },
526
+ ]),
527
+ );
528
+ const onTextDelta = vi.fn();
529
+
530
+ const result = await provider.chat([createUserMessage('Fetch example')], {
531
+ model: 'qwen3.6-plus',
532
+ onTextDelta,
533
+ });
534
+
535
+ expect(client.responses.create).toHaveBeenCalledWith(
536
+ {
537
+ model: 'qwen3.6-plus',
538
+ input: [{ role: 'user', content: 'Fetch example' }],
539
+ tools: [{ type: 'web_search' }, { type: 'web_extractor' }],
540
+ stream: true,
541
+ },
542
+ undefined,
543
+ );
544
+ expect(onTextDelta).toHaveBeenNthCalledWith(1, 'Hello ');
545
+ expect(onTextDelta).toHaveBeenNthCalledWith(2, 'world');
546
+ expect(result.content).toBe('Hello world');
547
+ expect(result.metadata?.['providerBuiltInToolsUsed']).toEqual(['web_search', 'web_extractor']);
548
+ });
549
+
550
+ it('keeps local function tools distinct from provider-side built-in tools', async () => {
551
+ const provider = new QwenProvider({
552
+ apiKey: 'dashscope-key',
553
+ builtInWebTools: { webSearch: true },
554
+ });
555
+ const client = getResponsesClient(provider);
556
+ client.responses.create.mockResolvedValue({
557
+ output_text: '',
558
+ output: [
559
+ {
560
+ type: 'function_call',
561
+ call_id: 'call_1',
562
+ name: 'inspect_file',
563
+ arguments: '{"path":"README.md"}',
564
+ },
565
+ ],
566
+ usage: {
567
+ input_tokens: 10,
568
+ output_tokens: 4,
569
+ total_tokens: 14,
570
+ },
571
+ });
572
+
573
+ const result = await provider.chat([createUserMessage('Inspect README')], {
574
+ model: 'qwen3.6-plus',
575
+ tools: [createToolSchema()],
576
+ });
577
+
578
+ expect(client.responses.create).toHaveBeenCalledWith(
579
+ {
580
+ model: 'qwen3.6-plus',
581
+ input: [{ role: 'user', content: 'Inspect README' }],
582
+ tools: [
583
+ { type: 'web_search' },
584
+ {
585
+ type: 'function',
586
+ name: 'inspect_file',
587
+ description: 'Inspect a file',
588
+ parameters: createToolSchema().parameters,
589
+ },
590
+ ],
591
+ },
592
+ undefined,
593
+ );
594
+ if (result.role !== 'assistant') throw new Error('Expected assistant message');
595
+ expect(result.toolCalls).toEqual([
596
+ {
597
+ id: 'call_1',
598
+ type: 'function',
599
+ function: {
600
+ name: 'inspect_file',
601
+ arguments: '{"path":"README.md"}',
602
+ },
603
+ },
604
+ ]);
605
+ expect(result.metadata?.['providerBuiltInToolsUsed']).toBeUndefined();
606
+ });
607
+
608
+ it('yields universal assistant messages from direct chatStream chunks', async () => {
609
+ const provider = new QwenProvider({ apiKey: 'dashscope-key' });
610
+ const client = getClient(provider);
611
+ client.chat.completions.create.mockResolvedValue(
612
+ asyncIterableFrom([createChunk('Part one'), createChunk(' done', 'stop')]),
613
+ );
614
+
615
+ const chunks: TUniversalMessage[] = [];
616
+ for await (const chunk of provider.chatStream?.([createUserMessage('Stream')], {
617
+ model: 'qwen-plus',
618
+ }) ?? []) {
619
+ chunks.push(chunk);
620
+ }
621
+
622
+ expect(client.chat.completions.create).toHaveBeenCalledWith({
623
+ model: 'qwen-plus',
624
+ messages: [{ role: 'user', content: 'Stream' }],
625
+ stream: true,
626
+ });
627
+ expect(chunks.map((chunk) => chunk.content)).toEqual(['Part one', ' done']);
628
+ expect(chunks[1]?.metadata?.['isComplete']).toBe(true);
629
+ });
630
+
631
+ it('wraps upstream chat failures with Qwen context', async () => {
632
+ const provider = new QwenProvider({ apiKey: 'dashscope-key' });
633
+ const client = getClient(provider);
634
+ client.chat.completions.create.mockRejectedValue(new Error('Invalid API key'));
635
+
636
+ await expect(
637
+ provider.chat([createUserMessage('Hello')], { model: 'qwen-plus' }),
638
+ ).rejects.toThrow('Qwen chat failed: Invalid API key');
639
+ });
640
+ });