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