@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,111 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { IToolSchema, TUniversalMessage } from '@robota-sdk/agent-core';
3
+ import { convertToOpenAICompatibleMessages, convertToOpenAICompatibleTools } from './index';
4
+
5
+ const timestamp = new Date('2026-05-01T00:00:00.000Z');
6
+
7
+ describe('OpenAI-compatible message converter', () => {
8
+ it('converts universal chat messages to Chat Completions messages', () => {
9
+ const messages: TUniversalMessage[] = [
10
+ {
11
+ id: 'system-1',
12
+ role: 'system',
13
+ content: 'You are concise.',
14
+ state: 'complete',
15
+ timestamp,
16
+ },
17
+ {
18
+ id: 'user-1',
19
+ role: 'user',
20
+ content: 'Search docs',
21
+ state: 'complete',
22
+ timestamp,
23
+ },
24
+ {
25
+ id: 'assistant-1',
26
+ role: 'assistant',
27
+ content: '',
28
+ state: 'complete',
29
+ timestamp,
30
+ toolCalls: [
31
+ {
32
+ id: 'call-1',
33
+ type: 'function',
34
+ function: { name: 'search', arguments: '{"q":"docs"}' },
35
+ },
36
+ ],
37
+ },
38
+ {
39
+ id: 'tool-1',
40
+ role: 'tool',
41
+ content: 'result',
42
+ state: 'complete',
43
+ timestamp,
44
+ toolCallId: 'call-1',
45
+ },
46
+ ];
47
+
48
+ expect(convertToOpenAICompatibleMessages(messages)).toEqual([
49
+ { role: 'system', content: 'You are concise.' },
50
+ { role: 'user', content: 'Search docs' },
51
+ {
52
+ role: 'assistant',
53
+ content: null,
54
+ tool_calls: [
55
+ {
56
+ id: 'call-1',
57
+ type: 'function',
58
+ function: { name: 'search', arguments: '{"q":"docs"}' },
59
+ },
60
+ ],
61
+ },
62
+ { role: 'tool', content: 'result', tool_call_id: 'call-1' },
63
+ ]);
64
+ });
65
+
66
+ it('rejects tool messages without a toolCallId', () => {
67
+ const messages: TUniversalMessage[] = [
68
+ {
69
+ id: 'tool-1',
70
+ role: 'tool',
71
+ content: 'result',
72
+ state: 'complete',
73
+ timestamp,
74
+ toolCallId: '',
75
+ },
76
+ ];
77
+
78
+ expect(() => convertToOpenAICompatibleMessages(messages)).toThrow(
79
+ 'Tool message missing toolCallId',
80
+ );
81
+ });
82
+
83
+ it('converts universal tool schemas to OpenAI-compatible function tools', () => {
84
+ const tools: IToolSchema[] = [
85
+ {
86
+ name: 'read_file',
87
+ description: 'Read a file',
88
+ parameters: {
89
+ type: 'object',
90
+ properties: { path: { type: 'string' } },
91
+ required: ['path'],
92
+ },
93
+ },
94
+ ];
95
+
96
+ expect(convertToOpenAICompatibleTools(tools)).toEqual([
97
+ {
98
+ type: 'function',
99
+ function: {
100
+ name: 'read_file',
101
+ description: 'Read a file',
102
+ parameters: {
103
+ type: 'object',
104
+ properties: { path: { type: 'string' } },
105
+ required: ['path'],
106
+ },
107
+ },
108
+ },
109
+ ]);
110
+ });
111
+ });
@@ -0,0 +1,84 @@
1
+ import type OpenAI from 'openai';
2
+ import type {
3
+ IAssistantMessage,
4
+ IToolCall,
5
+ IToolSchema,
6
+ TUniversalMessage,
7
+ } from '@robota-sdk/agent-core';
8
+
9
+ export function convertToOpenAICompatibleMessages(
10
+ messages: TUniversalMessage[],
11
+ ): OpenAI.Chat.ChatCompletionMessageParam[] {
12
+ return messages.map((message) => convertMessage(message));
13
+ }
14
+
15
+ export function convertToOpenAICompatibleTools(
16
+ tools: IToolSchema[],
17
+ ): OpenAI.Chat.ChatCompletionTool[] {
18
+ return tools.map((tool) => ({
19
+ type: 'function',
20
+ function: {
21
+ name: tool.name,
22
+ description: tool.description,
23
+ parameters: tool.parameters,
24
+ },
25
+ }));
26
+ }
27
+
28
+ function convertMessage(message: TUniversalMessage): OpenAI.Chat.ChatCompletionMessageParam {
29
+ if (message.role === 'user') {
30
+ return {
31
+ role: 'user',
32
+ content: message.content || '',
33
+ };
34
+ }
35
+
36
+ if (message.role === 'assistant') {
37
+ return convertAssistantMessage(message);
38
+ }
39
+
40
+ if (message.role === 'system') {
41
+ return {
42
+ role: 'system',
43
+ content: message.content || '',
44
+ };
45
+ }
46
+
47
+ if (message.role === 'tool') {
48
+ if (!message.toolCallId || message.toolCallId.trim().length === 0) {
49
+ throw new Error(`Tool message missing toolCallId: ${JSON.stringify(message)}`);
50
+ }
51
+ return {
52
+ role: 'tool',
53
+ content: message.content || '',
54
+ tool_call_id: message.toolCallId,
55
+ };
56
+ }
57
+
58
+ const exhaustive: never = message;
59
+ throw new Error(`Unsupported message role: ${JSON.stringify(exhaustive)}`);
60
+ }
61
+
62
+ function convertAssistantMessage(
63
+ message: IAssistantMessage,
64
+ ): OpenAI.Chat.ChatCompletionAssistantMessageParam {
65
+ if (message.toolCalls && message.toolCalls.length > 0) {
66
+ return {
67
+ role: 'assistant',
68
+ content: message.content === '' ? null : message.content || null,
69
+ tool_calls: message.toolCalls.map((toolCall: IToolCall) => ({
70
+ id: toolCall.id,
71
+ type: 'function',
72
+ function: {
73
+ name: toolCall.function.name,
74
+ arguments: toolCall.function.arguments,
75
+ },
76
+ })),
77
+ };
78
+ }
79
+
80
+ return {
81
+ role: 'assistant',
82
+ content: message.content === null ? null : message.content || '',
83
+ };
84
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { observeProviderNativeRawPayloadStream } from './native-payload-observer';
3
+ import type { IProviderNativeRawPayloadEvent } from '@robota-sdk/agent-core';
4
+
5
+ async function* asyncIterableFrom<T extends object>(items: T[]): AsyncIterable<T> {
6
+ for (const item of items) {
7
+ yield item;
8
+ }
9
+ }
10
+
11
+ describe('observeProviderNativeRawPayloadStream', () => {
12
+ it('emits ordered stream_event payloads and yields original chunks unchanged', async () => {
13
+ const chunks = [{ id: 'chunk-1' }, { id: 'chunk-2' }];
14
+ const events: IProviderNativeRawPayloadEvent[] = [];
15
+ const yielded: object[] = [];
16
+
17
+ for await (const chunk of observeProviderNativeRawPayloadStream(asyncIterableFrom(chunks), {
18
+ provider: 'provider',
19
+ apiSurface: 'chat-completions',
20
+ onProviderNativeRawPayload: (event) => events.push(event),
21
+ })) {
22
+ yielded.push(chunk);
23
+ }
24
+
25
+ expect(yielded).toEqual(chunks);
26
+ expect(events).toEqual([
27
+ expect.objectContaining({
28
+ provider: 'provider',
29
+ apiSurface: 'chat-completions',
30
+ payloadKind: 'stream_event',
31
+ sequence: 0,
32
+ payload: chunks[0],
33
+ }),
34
+ expect.objectContaining({
35
+ provider: 'provider',
36
+ apiSurface: 'chat-completions',
37
+ payloadKind: 'stream_event',
38
+ sequence: 1,
39
+ payload: chunks[1],
40
+ }),
41
+ ]);
42
+ });
43
+ });
@@ -0,0 +1,26 @@
1
+ import type { TProviderNativeRawPayloadCallback } from '@robota-sdk/agent-core';
2
+
3
+ export interface IObserveProviderNativeRawPayloadStreamOptions {
4
+ provider: string;
5
+ apiSurface?: string;
6
+ onProviderNativeRawPayload?: TProviderNativeRawPayloadCallback;
7
+ initialSequence?: number;
8
+ }
9
+
10
+ export async function* observeProviderNativeRawPayloadStream<TPayload extends object>(
11
+ stream: AsyncIterable<TPayload>,
12
+ options: IObserveProviderNativeRawPayloadStreamOptions,
13
+ ): AsyncIterable<TPayload> {
14
+ let sequence = options.initialSequence ?? 0;
15
+ for await (const payload of stream) {
16
+ options.onProviderNativeRawPayload?.({
17
+ provider: options.provider,
18
+ ...(options.apiSurface !== undefined && { apiSurface: options.apiSurface }),
19
+ payloadKind: 'stream_event',
20
+ sequence,
21
+ payload,
22
+ });
23
+ sequence++;
24
+ yield payload;
25
+ }
26
+ }
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type OpenAI from 'openai';
3
+ import { OpenAICompatibleResponseParser } from './index';
4
+
5
+ describe('OpenAICompatibleResponseParser', () => {
6
+ it('parses full chat completion responses into universal assistant messages', () => {
7
+ const parser = new OpenAICompatibleResponseParser();
8
+ const response: OpenAI.Chat.ChatCompletion = {
9
+ id: 'chatcmpl-test',
10
+ object: 'chat.completion',
11
+ created: 1,
12
+ model: 'local-model',
13
+ choices: [
14
+ {
15
+ index: 0,
16
+ message: {
17
+ role: 'assistant',
18
+ content: 'Visible answer',
19
+ refusal: null,
20
+ },
21
+ finish_reason: 'stop',
22
+ logprobs: null,
23
+ },
24
+ ],
25
+ usage: {
26
+ prompt_tokens: 3,
27
+ completion_tokens: 4,
28
+ total_tokens: 7,
29
+ },
30
+ };
31
+
32
+ const result = parser.parseResponse(response);
33
+
34
+ expect(result.role).toBe('assistant');
35
+ if (result.role !== 'assistant') throw new Error('Expected assistant message');
36
+ expect(result.content).toBe('Visible answer');
37
+ expect(result).toHaveProperty('usage');
38
+ const withUsage = result as unknown as {
39
+ usage: { promptTokens: number; completionTokens: number; totalTokens: number };
40
+ };
41
+ expect(withUsage.usage).toEqual({
42
+ promptTokens: 3,
43
+ completionTokens: 4,
44
+ totalTokens: 7,
45
+ });
46
+ expect(result.metadata?.['finishReason']).toBe('stop');
47
+ });
48
+
49
+ it('applies an injected provider-owned text tool-call projector to full responses', () => {
50
+ const parser = new OpenAICompatibleResponseParser({
51
+ toolCallTextProjector: {
52
+ project: (text: string) => ({
53
+ visibleText: text === 'provider native payload' ? '' : text,
54
+ toolCalls:
55
+ text === 'provider native payload'
56
+ ? [
57
+ {
58
+ id: 'projected-call-1',
59
+ type: 'function' as const,
60
+ function: { name: 'InjectedTool', arguments: '{"value":"ok"}' },
61
+ },
62
+ ]
63
+ : [],
64
+ removedToolCallText: text === 'provider native payload',
65
+ ...(text === 'provider native payload' && { rawToolCallText: text }),
66
+ }),
67
+ flush: () => ({
68
+ visibleText: '',
69
+ toolCalls: [],
70
+ removedToolCallText: false,
71
+ }),
72
+ },
73
+ });
74
+ const response: OpenAI.Chat.ChatCompletion = {
75
+ id: 'chatcmpl-test',
76
+ object: 'chat.completion',
77
+ created: 1,
78
+ model: 'local-model',
79
+ choices: [
80
+ {
81
+ index: 0,
82
+ message: {
83
+ role: 'assistant',
84
+ content: 'provider native payload',
85
+ refusal: null,
86
+ },
87
+ finish_reason: 'tool_calls',
88
+ logprobs: null,
89
+ },
90
+ ],
91
+ };
92
+
93
+ const result = parser.parseResponse(response);
94
+
95
+ expect(result.content).toBe('');
96
+ expect(result.metadata?.['toolCallTextProjected']).toBe(true);
97
+ expect(result.metadata?.['rawToolCallText']).toBe('provider native payload');
98
+ if (result.role !== 'assistant') throw new Error('Expected assistant message');
99
+ expect(result.toolCalls).toEqual([
100
+ {
101
+ id: 'projected-call-1',
102
+ type: 'function',
103
+ function: { name: 'InjectedTool', arguments: '{"value":"ok"}' },
104
+ },
105
+ ]);
106
+ });
107
+
108
+ it('passes native tool calls through so core can return a normal tool-result error', () => {
109
+ const parser = new OpenAICompatibleResponseParser();
110
+ const response: OpenAI.Chat.ChatCompletion = {
111
+ id: 'chatcmpl-test',
112
+ object: 'chat.completion',
113
+ created: 1,
114
+ model: 'local-model',
115
+ choices: [
116
+ {
117
+ index: 0,
118
+ message: {
119
+ role: 'assistant',
120
+ content: null,
121
+ refusal: null,
122
+ tool_calls: [
123
+ {
124
+ id: 'call-1',
125
+ type: 'function',
126
+ function: { name: 'UndeclaredTool', arguments: '{}' },
127
+ },
128
+ ],
129
+ },
130
+ finish_reason: 'tool_calls',
131
+ logprobs: null,
132
+ },
133
+ ],
134
+ };
135
+
136
+ const result = parser.parseResponse(response);
137
+
138
+ if (result.role !== 'assistant') throw new Error('Expected assistant message');
139
+ expect(result.toolCalls).toEqual([
140
+ {
141
+ id: 'call-1',
142
+ type: 'function',
143
+ function: { name: 'UndeclaredTool', arguments: '{}' },
144
+ },
145
+ ]);
146
+ });
147
+
148
+ it('applies text projection while parsing streaming chunks', () => {
149
+ const parser = new OpenAICompatibleResponseParser({
150
+ textProjector: (text) => text.replace('[hidden]', ''),
151
+ });
152
+ const chunk: OpenAI.Chat.ChatCompletionChunk = {
153
+ id: 'chunk-1',
154
+ object: 'chat.completion.chunk',
155
+ created: 1,
156
+ model: 'local-model',
157
+ choices: [
158
+ {
159
+ index: 0,
160
+ delta: { content: '[hidden]Visible' },
161
+ finish_reason: null,
162
+ logprobs: null,
163
+ },
164
+ ],
165
+ };
166
+
167
+ const result = parser.parseStreamingChunk(chunk);
168
+
169
+ expect(result?.content).toBe('Visible');
170
+ expect(result?.metadata?.['isStreamChunk']).toBe(true);
171
+ });
172
+ });
@@ -0,0 +1,180 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type OpenAI from 'openai';
3
+ import type { ILogger, IToolCall, TUniversalMessage } from '@robota-sdk/agent-core';
4
+ import { SilentLogger } from '@robota-sdk/agent-core';
5
+ import type {
6
+ IOpenAICompatibleToolCallTextProjector,
7
+ IOpenAICompatibleToolCallTextProjection,
8
+ TOpenAICompatibleTextProjector,
9
+ } from './types';
10
+
11
+ export interface IOpenAICompatibleResponseParserOptions {
12
+ logger?: ILogger;
13
+ textProjector?: TOpenAICompatibleTextProjector;
14
+ toolCallTextProjector?: IOpenAICompatibleToolCallTextProjector;
15
+ }
16
+
17
+ export class OpenAICompatibleResponseParser {
18
+ private readonly logger: ILogger;
19
+ private readonly textProjector?: TOpenAICompatibleTextProjector;
20
+ private readonly toolCallTextProjector?: IOpenAICompatibleToolCallTextProjector;
21
+
22
+ constructor(options: IOpenAICompatibleResponseParserOptions = {}) {
23
+ this.logger = options.logger ?? SilentLogger;
24
+ this.textProjector = options.textProjector;
25
+ this.toolCallTextProjector = options.toolCallTextProjector;
26
+ }
27
+
28
+ parseResponse(response: OpenAI.Chat.ChatCompletion): TUniversalMessage {
29
+ try {
30
+ const choice = response.choices?.[0];
31
+ if (!choice) {
32
+ throw new Error('No choices found in OpenAI-compatible response');
33
+ }
34
+
35
+ return this.parseChoice(choice, response.usage);
36
+ } catch (error) {
37
+ const message =
38
+ error instanceof Error ? error.message : 'OpenAI-compatible response parsing failed';
39
+ this.logger.error('Response parsing failed', { error: message });
40
+ throw new Error(`OpenAI-compatible response parsing failed: ${message}`);
41
+ }
42
+ }
43
+
44
+ private parseChoice(
45
+ choice: OpenAI.Chat.ChatCompletion.Choice,
46
+ usage: OpenAI.CompletionUsage | undefined,
47
+ ): TUniversalMessage {
48
+ const message = choice.message;
49
+ const toolTextProjection = this.projectToolCallText(message.content || '');
50
+ const toolTextFlush = this.toolCallTextProjector?.flush();
51
+ const nativeToolCalls = this.parseNativeToolCalls(message);
52
+ const projectedToolCalls = [
53
+ ...toolTextProjection.toolCalls,
54
+ ...(toolTextFlush?.toolCalls ?? []),
55
+ ];
56
+ const toolCalls = [...nativeToolCalls, ...projectedToolCalls];
57
+
58
+ return {
59
+ id: randomUUID(),
60
+ state: 'complete',
61
+ role: 'assistant',
62
+ content: this.projectText(
63
+ toolTextProjection.visibleText + (toolTextFlush?.visibleText ?? ''),
64
+ ),
65
+ timestamp: new Date(),
66
+ ...(toolCalls.length > 0 && { toolCalls }),
67
+ ...(usage && { usage: this.parseUsage(usage) }),
68
+ metadata: {
69
+ finishReason: choice.finish_reason || undefined,
70
+ ...this.buildToolTextMetadata(toolTextProjection, toolTextFlush),
71
+ },
72
+ };
73
+ }
74
+
75
+ private parseNativeToolCalls(message: OpenAI.Chat.ChatCompletionMessage): IToolCall[] {
76
+ return (
77
+ message.tool_calls?.map((toolCall) => ({
78
+ id: toolCall.id,
79
+ type: 'function' as const,
80
+ function: {
81
+ name: toolCall.function.name,
82
+ arguments: toolCall.function.arguments,
83
+ },
84
+ })) || []
85
+ );
86
+ }
87
+
88
+ private parseUsage(usage: OpenAI.CompletionUsage): {
89
+ promptTokens: number;
90
+ completionTokens: number;
91
+ totalTokens: number;
92
+ } {
93
+ return {
94
+ promptTokens: usage.prompt_tokens,
95
+ completionTokens: usage.completion_tokens,
96
+ totalTokens: usage.total_tokens,
97
+ };
98
+ }
99
+
100
+ parseStreamingChunk(chunk: OpenAI.Chat.ChatCompletionChunk): TUniversalMessage | null {
101
+ try {
102
+ const choice = chunk.choices?.[0];
103
+ if (!choice) {
104
+ return null;
105
+ }
106
+
107
+ const finishReason = choice.finish_reason;
108
+ const toolCalls = choice.delta.tool_calls?.map((toolCall) => ({
109
+ id: toolCall.id || '',
110
+ type: 'function' as const,
111
+ function: {
112
+ name: toolCall.function?.name || '',
113
+ arguments: toolCall.function?.arguments || '',
114
+ },
115
+ }));
116
+
117
+ if (toolCalls) {
118
+ return {
119
+ id: randomUUID(),
120
+ state: 'complete',
121
+ role: 'assistant',
122
+ content: '',
123
+ timestamp: new Date(),
124
+ toolCalls,
125
+ metadata: {
126
+ isStreamChunk: true,
127
+ isComplete: finishReason === 'stop' || finishReason === 'tool_calls',
128
+ },
129
+ };
130
+ }
131
+
132
+ return {
133
+ id: randomUUID(),
134
+ state: 'complete',
135
+ role: 'assistant',
136
+ content: this.projectText(choice.delta.content || ''),
137
+ timestamp: new Date(),
138
+ metadata: {
139
+ isStreamChunk: true,
140
+ isComplete: finishReason === 'stop' || finishReason === 'tool_calls',
141
+ },
142
+ };
143
+ } catch (error) {
144
+ const message =
145
+ error instanceof Error ? error.message : 'OpenAI-compatible chunk parsing failed';
146
+ this.logger.error('Chunk parsing failed', { error: message });
147
+ throw new Error(`OpenAI-compatible chunk parsing failed: ${message}`);
148
+ }
149
+ }
150
+
151
+ private projectText(text: string): string {
152
+ return this.textProjector ? this.textProjector(text) : text;
153
+ }
154
+
155
+ private projectToolCallText(text: string): IOpenAICompatibleToolCallTextProjection {
156
+ return (
157
+ this.toolCallTextProjector?.project(text) ?? {
158
+ visibleText: text,
159
+ toolCalls: [],
160
+ removedToolCallText: false,
161
+ }
162
+ );
163
+ }
164
+
165
+ private buildToolTextMetadata(
166
+ projection: IOpenAICompatibleToolCallTextProjection,
167
+ flush: IOpenAICompatibleToolCallTextProjection | undefined,
168
+ ): Record<string, string | boolean | undefined> {
169
+ const rawToolCallText = [projection.rawToolCallText, flush?.rawToolCallText]
170
+ .filter((text): text is string => typeof text === 'string' && text.length > 0)
171
+ .join('');
172
+ const removedToolCallText =
173
+ projection.removedToolCallText || (flush?.removedToolCallText ?? false);
174
+
175
+ return {
176
+ ...(removedToolCallText && { toolCallTextProjected: true }),
177
+ ...(rawToolCallText.length > 0 && { rawToolCallText }),
178
+ };
179
+ }
180
+ }