@robota-sdk/agent-provider-openai 3.0.0-beta.63 → 3.0.0-beta.82
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 +661 -21
- package/dist/node/index.cjs +1 -2
- package/dist/node/index.d.cts +271 -267
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +271 -267
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -2
- package/dist/node/index.js.map +1 -0
- package/dist/node/loggers/index.cjs +1 -0
- package/dist/node/loggers/index.d.cts +94 -0
- package/dist/node/loggers/index.d.cts.map +1 -0
- package/dist/node/loggers/index.d.ts +94 -0
- package/dist/node/loggers/index.d.ts.map +1 -0
- package/dist/node/loggers/index.js +2 -0
- package/dist/node/loggers/index.js.map +1 -0
- package/dist/node/payload-logger-BaW0K8yI.d.cts +61 -0
- package/dist/node/payload-logger-BaW0K8yI.d.cts.map +1 -0
- package/dist/node/payload-logger-BaW0K8yI.d.ts +61 -0
- package/dist/node/payload-logger-BaW0K8yI.d.ts.map +1 -0
- package/dist/node/rolldown-runtime-CMqjfN_6.cjs +1 -0
- package/package.json +69 -86
- package/src/index.ts +1 -0
- package/src/openai/__tests__/endpoint-provenance.test.ts +44 -0
- package/src/openai/__tests__/provider-errors.test.ts +114 -0
- package/src/openai/__tests__/request-format.test.ts +59 -0
- package/src/openai/__tests__/strict-tools-closure.test.ts +136 -0
- package/src/openai/__tests__/tool-schema-projection.test.ts +302 -0
- package/src/openai/__tests__/trace-context-wire.test.ts +114 -0
- package/src/openai/adapter.test.ts +494 -0
- package/src/openai/adapter.ts +145 -0
- package/src/openai/chat-completions-chat.ts +225 -0
- package/src/openai/executor-integration.test.ts +213 -0
- package/src/openai/index.ts +19 -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 +96 -0
- package/src/openai/loggers/console.ts +9 -0
- package/src/openai/loggers/file-payload-logger.test.ts +243 -0
- package/src/openai/loggers/file-payload-logger.ts +124 -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 +23 -0
- package/src/openai/model-effort-table.ts +19 -0
- package/src/openai/model-effort-verification-config.test.ts +45 -0
- package/src/openai/model-effort-verification-config.ts +35 -0
- package/src/openai/openai-request-format.ts +116 -0
- package/src/openai/parsers/response-parser.test.ts +407 -0
- package/src/openai/parsers/response-parser.ts +48 -0
- package/src/openai/provider-definition.test.ts +74 -0
- package/src/openai/provider-definition.ts +139 -0
- package/src/openai/provider.test.ts +1686 -0
- package/src/openai/provider.ts +392 -0
- package/src/openai/reasoning-effort.test.ts +272 -0
- package/src/openai/reasoning-effort.ts +49 -0
- package/src/openai/request-id.ts +39 -0
- package/src/openai/request-options.ts +14 -0
- package/src/openai/responses-chat.ts +285 -0
- package/src/openai/responses-converter.ts +122 -0
- package/src/openai/responses-parser.ts +297 -0
- package/src/openai/responses-stream-utils.ts +45 -0
- package/src/openai/responses-types.ts +193 -0
- package/src/openai/streaming/stream-assembler.ts +3 -0
- package/src/openai/types/api-types.ts +113 -0
- package/src/openai/types.ts +235 -0
- package/CHANGELOG.md +0 -614
- package/README.md +0 -467
- package/dist/browser/index.d.ts +0 -307
- package/dist/browser/index.js +0 -2
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export const VERCEL_AI_GATEWAY_BASE_URL = 'https://ai-gateway.vercel.sh/v1';
|
|
4
|
+
export const VERCEL_AI_GATEWAY_MODEL = 'openai/gpt-5';
|
|
5
|
+
|
|
6
|
+
export interface IVercelAiGatewayModelEffortConfig {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
baseURL: string;
|
|
9
|
+
model: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveVercelAiGatewayModelEffortConfig(
|
|
13
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
14
|
+
): IVercelAiGatewayModelEffortConfig {
|
|
15
|
+
const apiKey = environment.AI_GATEWAY_API_KEY;
|
|
16
|
+
if (apiKey === undefined || apiKey.length === 0) {
|
|
17
|
+
throw new Error('AI_GATEWAY_API_KEY is required');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
apiKey,
|
|
22
|
+
baseURL: VERCEL_AI_GATEWAY_BASE_URL,
|
|
23
|
+
model: VERCEL_AI_GATEWAY_MODEL,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loadVercelAiGatewayModelEffortConfig(
|
|
28
|
+
environment: Record<string, string | undefined>,
|
|
29
|
+
environmentFilePath: string,
|
|
30
|
+
environmentFileExists: (path: string) => boolean = existsSync,
|
|
31
|
+
loadEnvironmentFile: (path: string) => void = process.loadEnvFile,
|
|
32
|
+
): IVercelAiGatewayModelEffortConfig {
|
|
33
|
+
if (environmentFileExists(environmentFilePath)) loadEnvironmentFile(environmentFilePath);
|
|
34
|
+
return resolveVercelAiGatewayModelEffortConfig(environment);
|
|
35
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IOpenAIResponsesTextConfig,
|
|
3
|
+
IOpenAIResponsesTextFormatJsonSchema,
|
|
4
|
+
} from './responses-types';
|
|
5
|
+
import type { IOpenAIProviderOptions, TOpenAIProviderOptionValue } from './types';
|
|
6
|
+
import type { IChatOptions } from '@robota-sdk/agent-core';
|
|
7
|
+
|
|
8
|
+
export interface IOpenAIChatTextFormatText {
|
|
9
|
+
type: 'text';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface IOpenAIChatTextFormatJsonObject {
|
|
13
|
+
type: 'json_object';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IOpenAIChatTextFormatJsonSchema {
|
|
17
|
+
type: 'json_schema';
|
|
18
|
+
json_schema: {
|
|
19
|
+
name: string;
|
|
20
|
+
schema: Record<string, TOpenAIProviderOptionValue>;
|
|
21
|
+
description?: string;
|
|
22
|
+
strict?: boolean;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TOpenAIChatResponseFormat =
|
|
27
|
+
IOpenAIChatTextFormatText | IOpenAIChatTextFormatJsonObject | IOpenAIChatTextFormatJsonSchema;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Merge a per-call `IChatOptions.responseFormat` onto the provider options (CORE-015).
|
|
31
|
+
* A `json_schema` chat option carries its schema payload into `jsonSchema`; the
|
|
32
|
+
* provider-level `jsonSchema` option remains as-is for `text`/`json_object`.
|
|
33
|
+
*/
|
|
34
|
+
export function mergeChatResponseFormat(
|
|
35
|
+
providerOptions: IOpenAIProviderOptions,
|
|
36
|
+
responseFormat: IChatOptions['responseFormat'],
|
|
37
|
+
): IOpenAIProviderOptions {
|
|
38
|
+
if (responseFormat?.type === undefined) {
|
|
39
|
+
return providerOptions;
|
|
40
|
+
}
|
|
41
|
+
if (responseFormat.type === 'json_schema') {
|
|
42
|
+
return {
|
|
43
|
+
...providerOptions,
|
|
44
|
+
responseFormat: 'json_schema',
|
|
45
|
+
jsonSchema: {
|
|
46
|
+
name: responseFormat.name ?? 'structured_output',
|
|
47
|
+
schema: responseFormat.schema as Record<string, TOpenAIProviderOptionValue>,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return { ...providerOptions, responseFormat: responseFormat.type };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildOpenAIChatResponseFormat(
|
|
55
|
+
options: IOpenAIProviderOptions,
|
|
56
|
+
): TOpenAIChatResponseFormat | undefined {
|
|
57
|
+
if (options.responseFormat === undefined || options.responseFormat === 'text') {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (options.responseFormat === 'json_object') {
|
|
61
|
+
return { type: 'json_object' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const schema = requireJsonSchema(options);
|
|
65
|
+
return {
|
|
66
|
+
type: 'json_schema',
|
|
67
|
+
json_schema: {
|
|
68
|
+
name: schema.name,
|
|
69
|
+
schema: schema.schema,
|
|
70
|
+
...(schema.description !== undefined && { description: schema.description }),
|
|
71
|
+
...(schema.strict !== undefined && { strict: schema.strict }),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildOpenAIResponsesTextConfig(
|
|
77
|
+
options: IOpenAIProviderOptions,
|
|
78
|
+
): IOpenAIResponsesTextConfig | undefined {
|
|
79
|
+
if (options.responseFormat === undefined || options.responseFormat === 'text') {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
if (options.responseFormat === 'json_object') {
|
|
83
|
+
return { format: { type: 'json_object' } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const schema = requireJsonSchema(options);
|
|
87
|
+
const format: IOpenAIResponsesTextFormatJsonSchema = {
|
|
88
|
+
type: 'json_schema',
|
|
89
|
+
name: schema.name,
|
|
90
|
+
schema: schema.schema,
|
|
91
|
+
...(schema.description !== undefined && { description: schema.description }),
|
|
92
|
+
...(schema.strict !== undefined && { strict: schema.strict }),
|
|
93
|
+
};
|
|
94
|
+
return { format };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requireJsonSchema(input: IOpenAIProviderOptions): {
|
|
98
|
+
name: string;
|
|
99
|
+
schema: Record<string, TOpenAIProviderOptionValue>;
|
|
100
|
+
description?: string;
|
|
101
|
+
strict?: boolean;
|
|
102
|
+
} {
|
|
103
|
+
const schema = input.jsonSchema;
|
|
104
|
+
if (input.responseFormat !== 'json_schema') {
|
|
105
|
+
throw new Error(`Unsupported OpenAI response format: ${input.responseFormat}`);
|
|
106
|
+
}
|
|
107
|
+
if (!schema?.schema) {
|
|
108
|
+
throw new Error('OpenAI jsonSchema.schema is required when responseFormat is json_schema');
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
name: schema.name,
|
|
112
|
+
schema: schema.schema,
|
|
113
|
+
...(schema.description !== undefined && { description: schema.description }),
|
|
114
|
+
...(schema.strict !== undefined && { strict: schema.strict }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { OpenAIResponseParser } from './response-parser';
|
|
3
|
+
import type { ILogger } from '@robota-sdk/agent-core';
|
|
4
|
+
import type OpenAI from 'openai';
|
|
5
|
+
|
|
6
|
+
function createMockLogger(): ILogger {
|
|
7
|
+
return {
|
|
8
|
+
info: vi.fn(),
|
|
9
|
+
warn: vi.fn(),
|
|
10
|
+
error: vi.fn(),
|
|
11
|
+
debug: vi.fn(),
|
|
12
|
+
log: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createChatCompletion(
|
|
17
|
+
overrides: Partial<OpenAI.Chat.ChatCompletion> = {},
|
|
18
|
+
): OpenAI.Chat.ChatCompletion {
|
|
19
|
+
return {
|
|
20
|
+
id: 'chatcmpl-test',
|
|
21
|
+
object: 'chat.completion',
|
|
22
|
+
created: Date.now(),
|
|
23
|
+
model: 'gpt-4',
|
|
24
|
+
choices: [
|
|
25
|
+
{
|
|
26
|
+
index: 0,
|
|
27
|
+
message: {
|
|
28
|
+
role: 'assistant',
|
|
29
|
+
content: 'Hello!',
|
|
30
|
+
refusal: null,
|
|
31
|
+
},
|
|
32
|
+
finish_reason: 'stop',
|
|
33
|
+
logprobs: null,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
...overrides,
|
|
37
|
+
} as OpenAI.Chat.ChatCompletion;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createStreamChunk(
|
|
41
|
+
overrides: Record<string, unknown> = {},
|
|
42
|
+
): OpenAI.Chat.ChatCompletionChunk {
|
|
43
|
+
return {
|
|
44
|
+
id: 'chatcmpl-chunk',
|
|
45
|
+
object: 'chat.completion.chunk',
|
|
46
|
+
created: Date.now(),
|
|
47
|
+
model: 'gpt-4',
|
|
48
|
+
choices: [
|
|
49
|
+
{
|
|
50
|
+
index: 0,
|
|
51
|
+
delta: { content: 'Hi' },
|
|
52
|
+
finish_reason: null,
|
|
53
|
+
logprobs: null,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
...overrides,
|
|
57
|
+
} as OpenAI.Chat.ChatCompletionChunk;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('OpenAIResponseParser', () => {
|
|
61
|
+
describe('constructor', () => {
|
|
62
|
+
it('should use SilentLogger when no logger is provided', () => {
|
|
63
|
+
const parser = new OpenAIResponseParser();
|
|
64
|
+
// Parser should work without throwing
|
|
65
|
+
const response = createChatCompletion();
|
|
66
|
+
const result = parser.parseResponse(response);
|
|
67
|
+
expect(result.role).toBe('assistant');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should accept a custom logger', () => {
|
|
71
|
+
const logger = createMockLogger();
|
|
72
|
+
const parser = new OpenAIResponseParser(logger);
|
|
73
|
+
const response = createChatCompletion();
|
|
74
|
+
const result = parser.parseResponse(response);
|
|
75
|
+
expect(result.role).toBe('assistant');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('parseResponse', () => {
|
|
80
|
+
it('should parse a simple text response', () => {
|
|
81
|
+
const parser = new OpenAIResponseParser();
|
|
82
|
+
const response = createChatCompletion();
|
|
83
|
+
const result = parser.parseResponse(response);
|
|
84
|
+
|
|
85
|
+
expect(result.role).toBe('assistant');
|
|
86
|
+
expect(result.content).toBe('Hello!');
|
|
87
|
+
expect(result.timestamp).toBeInstanceOf(Date);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should parse response with empty content as empty string', () => {
|
|
91
|
+
const parser = new OpenAIResponseParser();
|
|
92
|
+
const response = createChatCompletion({
|
|
93
|
+
choices: [
|
|
94
|
+
{
|
|
95
|
+
index: 0,
|
|
96
|
+
message: { role: 'assistant', content: null, refusal: null },
|
|
97
|
+
finish_reason: 'stop',
|
|
98
|
+
logprobs: null,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
const result = parser.parseResponse(response);
|
|
103
|
+
expect(result.content).toBe('');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should parse response with tool calls', () => {
|
|
107
|
+
const parser = new OpenAIResponseParser();
|
|
108
|
+
const response = createChatCompletion({
|
|
109
|
+
choices: [
|
|
110
|
+
{
|
|
111
|
+
index: 0,
|
|
112
|
+
message: {
|
|
113
|
+
role: 'assistant',
|
|
114
|
+
content: null,
|
|
115
|
+
refusal: null,
|
|
116
|
+
tool_calls: [
|
|
117
|
+
{
|
|
118
|
+
id: 'call_abc',
|
|
119
|
+
type: 'function',
|
|
120
|
+
function: {
|
|
121
|
+
name: 'calculate',
|
|
122
|
+
arguments: '{"a":1,"b":2}',
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
finish_reason: 'tool_calls',
|
|
128
|
+
logprobs: null,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const result = parser.parseResponse(response);
|
|
134
|
+
expect(result.role).toBe('assistant');
|
|
135
|
+
expect(result.content).toBe('');
|
|
136
|
+
expect(result).toHaveProperty('toolCalls');
|
|
137
|
+
const assistantResult = result as {
|
|
138
|
+
toolCalls: Array<{
|
|
139
|
+
id: string;
|
|
140
|
+
type: string;
|
|
141
|
+
function: { name: string; arguments: string };
|
|
142
|
+
}>;
|
|
143
|
+
};
|
|
144
|
+
expect(assistantResult.toolCalls).toHaveLength(1);
|
|
145
|
+
expect(assistantResult.toolCalls[0].id).toBe('call_abc');
|
|
146
|
+
expect(assistantResult.toolCalls[0].function.name).toBe('calculate');
|
|
147
|
+
expect(assistantResult.toolCalls[0].function.arguments).toBe('{"a":1,"b":2}');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should parse response with usage information', () => {
|
|
151
|
+
const parser = new OpenAIResponseParser();
|
|
152
|
+
const response = createChatCompletion({
|
|
153
|
+
usage: {
|
|
154
|
+
prompt_tokens: 10,
|
|
155
|
+
completion_tokens: 20,
|
|
156
|
+
total_tokens: 30,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = parser.parseResponse(response);
|
|
161
|
+
expect(result).toHaveProperty('usage');
|
|
162
|
+
const withUsage = result as unknown as {
|
|
163
|
+
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
|
164
|
+
};
|
|
165
|
+
expect(withUsage.usage.promptTokens).toBe(10);
|
|
166
|
+
expect(withUsage.usage.completionTokens).toBe(20);
|
|
167
|
+
expect(withUsage.usage.totalTokens).toBe(30);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should include finishReason in metadata', () => {
|
|
171
|
+
const parser = new OpenAIResponseParser();
|
|
172
|
+
const response = createChatCompletion();
|
|
173
|
+
const result = parser.parseResponse(response);
|
|
174
|
+
expect(result.metadata?.finishReason).toBe('stop');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should throw when response has no choices', () => {
|
|
178
|
+
const parser = new OpenAIResponseParser();
|
|
179
|
+
const response = createChatCompletion({ choices: [] });
|
|
180
|
+
|
|
181
|
+
expect(() => parser.parseResponse(response)).toThrow('OpenAI response parsing failed');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should throw with descriptive error and log on parsing failure', () => {
|
|
185
|
+
const logger = createMockLogger();
|
|
186
|
+
const parser = new OpenAIResponseParser(logger);
|
|
187
|
+
const response = createChatCompletion({ choices: [] });
|
|
188
|
+
|
|
189
|
+
expect(() => parser.parseResponse(response)).toThrow(
|
|
190
|
+
'OpenAI response parsing failed: No choices found in OpenAI response',
|
|
191
|
+
);
|
|
192
|
+
expect(logger.error).toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should handle response without usage field', () => {
|
|
196
|
+
const parser = new OpenAIResponseParser();
|
|
197
|
+
const response = createChatCompletion();
|
|
198
|
+
// Default createChatCompletion has no usage
|
|
199
|
+
const result = parser.parseResponse(response);
|
|
200
|
+
expect(result).not.toHaveProperty('usage');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should parse response with multiple tool calls', () => {
|
|
204
|
+
const parser = new OpenAIResponseParser();
|
|
205
|
+
const response = createChatCompletion({
|
|
206
|
+
choices: [
|
|
207
|
+
{
|
|
208
|
+
index: 0,
|
|
209
|
+
message: {
|
|
210
|
+
role: 'assistant',
|
|
211
|
+
content: null,
|
|
212
|
+
refusal: null,
|
|
213
|
+
tool_calls: [
|
|
214
|
+
{
|
|
215
|
+
id: 'call_1',
|
|
216
|
+
type: 'function',
|
|
217
|
+
function: { name: 'fn1', arguments: '{}' },
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: 'call_2',
|
|
221
|
+
type: 'function',
|
|
222
|
+
function: { name: 'fn2', arguments: '{"x":1}' },
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
finish_reason: 'tool_calls',
|
|
227
|
+
logprobs: null,
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const result = parser.parseResponse(response);
|
|
233
|
+
const assistantResult = result as { toolCalls: Array<{ id: string }> };
|
|
234
|
+
expect(assistantResult.toolCalls).toHaveLength(2);
|
|
235
|
+
expect(assistantResult.toolCalls[0].id).toBe('call_1');
|
|
236
|
+
expect(assistantResult.toolCalls[1].id).toBe('call_2');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('parseStreamingChunk', () => {
|
|
241
|
+
it('should parse a chunk with content', () => {
|
|
242
|
+
const parser = new OpenAIResponseParser();
|
|
243
|
+
const chunk = createStreamChunk();
|
|
244
|
+
const result = parser.parseStreamingChunk(chunk);
|
|
245
|
+
|
|
246
|
+
expect(result).not.toBeNull();
|
|
247
|
+
expect(result!.role).toBe('assistant');
|
|
248
|
+
expect(result!.content).toBe('Hi');
|
|
249
|
+
expect(result!.metadata?.isStreamChunk).toBe(true);
|
|
250
|
+
expect(result!.metadata?.isComplete).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should return null when chunk has no choices', () => {
|
|
254
|
+
const parser = new OpenAIResponseParser();
|
|
255
|
+
const chunk = createStreamChunk({ choices: [] });
|
|
256
|
+
const result = parser.parseStreamingChunk(chunk);
|
|
257
|
+
expect(result).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should handle chunk with empty content', () => {
|
|
261
|
+
const parser = new OpenAIResponseParser();
|
|
262
|
+
const chunk = createStreamChunk({
|
|
263
|
+
choices: [
|
|
264
|
+
{
|
|
265
|
+
index: 0,
|
|
266
|
+
delta: { content: '' },
|
|
267
|
+
finish_reason: null,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
const result = parser.parseStreamingChunk(chunk);
|
|
272
|
+
expect(result).not.toBeNull();
|
|
273
|
+
expect(result!.content).toBe('');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should mark chunk as complete when finish_reason is stop', () => {
|
|
277
|
+
const parser = new OpenAIResponseParser();
|
|
278
|
+
const chunk = createStreamChunk({
|
|
279
|
+
choices: [
|
|
280
|
+
{
|
|
281
|
+
index: 0,
|
|
282
|
+
delta: { content: '' },
|
|
283
|
+
finish_reason: 'stop',
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
});
|
|
287
|
+
const result = parser.parseStreamingChunk(chunk);
|
|
288
|
+
expect(result!.metadata?.isComplete).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should mark chunk as complete when finish_reason is tool_calls', () => {
|
|
292
|
+
const parser = new OpenAIResponseParser();
|
|
293
|
+
const chunk = createStreamChunk({
|
|
294
|
+
choices: [
|
|
295
|
+
{
|
|
296
|
+
index: 0,
|
|
297
|
+
delta: { content: '' },
|
|
298
|
+
finish_reason: 'tool_calls',
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
});
|
|
302
|
+
const result = parser.parseStreamingChunk(chunk);
|
|
303
|
+
expect(result!.metadata?.isComplete).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should parse chunk with tool calls', () => {
|
|
307
|
+
const parser = new OpenAIResponseParser();
|
|
308
|
+
const chunk = createStreamChunk({
|
|
309
|
+
choices: [
|
|
310
|
+
{
|
|
311
|
+
index: 0,
|
|
312
|
+
delta: {
|
|
313
|
+
tool_calls: [
|
|
314
|
+
{
|
|
315
|
+
index: 0,
|
|
316
|
+
id: 'call_stream_1',
|
|
317
|
+
type: 'function',
|
|
318
|
+
function: {
|
|
319
|
+
name: 'search',
|
|
320
|
+
arguments: '{"q":"test"}',
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
finish_reason: null,
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const result = parser.parseStreamingChunk(chunk);
|
|
331
|
+
expect(result).not.toBeNull();
|
|
332
|
+
expect(result!.role).toBe('assistant');
|
|
333
|
+
expect(result!.content).toBe('');
|
|
334
|
+
const withToolCalls = result as unknown as {
|
|
335
|
+
toolCalls: Array<{ id: string; function: { name: string; arguments: string } }>;
|
|
336
|
+
};
|
|
337
|
+
expect(withToolCalls.toolCalls).toHaveLength(1);
|
|
338
|
+
expect(withToolCalls.toolCalls[0].id).toBe('call_stream_1');
|
|
339
|
+
expect(withToolCalls.toolCalls[0].function.name).toBe('search');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should handle chunk with tool calls missing optional fields', () => {
|
|
343
|
+
const parser = new OpenAIResponseParser();
|
|
344
|
+
const chunk = createStreamChunk({
|
|
345
|
+
choices: [
|
|
346
|
+
{
|
|
347
|
+
index: 0,
|
|
348
|
+
delta: {
|
|
349
|
+
tool_calls: [
|
|
350
|
+
{
|
|
351
|
+
index: 0,
|
|
352
|
+
// Missing id, function.name, function.arguments
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
},
|
|
356
|
+
finish_reason: null,
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const result = parser.parseStreamingChunk(chunk);
|
|
362
|
+
expect(result).not.toBeNull();
|
|
363
|
+
const withToolCalls = result as unknown as {
|
|
364
|
+
toolCalls: Array<{ id: string; function: { name: string; arguments: string } }>;
|
|
365
|
+
};
|
|
366
|
+
expect(withToolCalls.toolCalls[0].id).toBe('');
|
|
367
|
+
expect(withToolCalls.toolCalls[0].function.name).toBe('');
|
|
368
|
+
expect(withToolCalls.toolCalls[0].function.arguments).toBe('');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should handle chunk with no delta content (undefined)', () => {
|
|
372
|
+
const parser = new OpenAIResponseParser();
|
|
373
|
+
const chunk = createStreamChunk({
|
|
374
|
+
choices: [
|
|
375
|
+
{
|
|
376
|
+
index: 0,
|
|
377
|
+
delta: {},
|
|
378
|
+
finish_reason: null,
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
const result = parser.parseStreamingChunk(chunk);
|
|
383
|
+
expect(result).not.toBeNull();
|
|
384
|
+
expect(result!.content).toBe('');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should throw with descriptive error on parsing failure', () => {
|
|
388
|
+
const logger = createMockLogger();
|
|
389
|
+
const parser = new OpenAIResponseParser(logger);
|
|
390
|
+
|
|
391
|
+
// Force an error by passing a chunk that causes internal error
|
|
392
|
+
const badChunk = {
|
|
393
|
+
choices: [
|
|
394
|
+
{
|
|
395
|
+
get delta(): never {
|
|
396
|
+
throw new Error('Access error');
|
|
397
|
+
},
|
|
398
|
+
finish_reason: null,
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
} as unknown as OpenAI.Chat.ChatCompletionChunk;
|
|
402
|
+
|
|
403
|
+
expect(() => parser.parseStreamingChunk(badChunk)).toThrow('OpenAI chunk parsing failed');
|
|
404
|
+
expect(logger.error).toHaveBeenCalled();
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { OpenAICompatibleResponseParser } from '@robota-sdk/agent-provider-openai-compatible/shared';
|
|
2
|
+
|
|
3
|
+
import type { ILogger, TUniversalMessage } from '@robota-sdk/agent-core';
|
|
4
|
+
import type OpenAI from 'openai';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* OpenAI response parser compatibility wrapper.
|
|
8
|
+
*
|
|
9
|
+
* The implementation delegates to the shared OpenAI-compatible parser while
|
|
10
|
+
* preserving the OpenAI-branded error messages that are part of this package's
|
|
11
|
+
* current test contract.
|
|
12
|
+
*/
|
|
13
|
+
export class OpenAIResponseParser {
|
|
14
|
+
private readonly parser: OpenAICompatibleResponseParser;
|
|
15
|
+
|
|
16
|
+
constructor(logger?: ILogger) {
|
|
17
|
+
this.parser = new OpenAICompatibleResponseParser({ logger });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
parseResponse(response: OpenAI.Chat.ChatCompletion): TUniversalMessage {
|
|
21
|
+
try {
|
|
22
|
+
return this.parser.parseResponse(response);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const message = normalizeParserMessage(
|
|
25
|
+
error instanceof Error ? error.message : 'OpenAI response parsing failed',
|
|
26
|
+
);
|
|
27
|
+
throw new Error(`OpenAI response parsing failed: ${message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
parseStreamingChunk(chunk: OpenAI.Chat.ChatCompletionChunk): TUniversalMessage | null {
|
|
32
|
+
try {
|
|
33
|
+
return this.parser.parseStreamingChunk(chunk);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const message = normalizeParserMessage(
|
|
36
|
+
error instanceof Error ? error.message : 'OpenAI chunk parsing failed',
|
|
37
|
+
);
|
|
38
|
+
throw new Error(`OpenAI chunk parsing failed: ${message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeParserMessage(message: string): string {
|
|
44
|
+
return message
|
|
45
|
+
.replace(/^OpenAI-compatible response parsing failed: /, '')
|
|
46
|
+
.replace(/^OpenAI-compatible chunk parsing failed: /, '')
|
|
47
|
+
.replace('OpenAI-compatible response', 'OpenAI response');
|
|
48
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createOpenAIProviderDefinition,
|
|
4
|
+
DEFAULT_OPENAI_PROVIDER_API_KEY_REFERENCE,
|
|
5
|
+
DEFAULT_OPENAI_PROVIDER_MODEL,
|
|
6
|
+
} from './index';
|
|
7
|
+
|
|
8
|
+
describe('createOpenAIProviderDefinition', () => {
|
|
9
|
+
it('uses official OpenAI setup defaults without a model-family default', () => {
|
|
10
|
+
const definition = createOpenAIProviderDefinition();
|
|
11
|
+
const modelStep = definition.setupSteps?.find((step) => step.key === 'model');
|
|
12
|
+
const apiKeyStep = definition.setupSteps?.find((step) => step.key === 'apiKey');
|
|
13
|
+
|
|
14
|
+
expect(DEFAULT_OPENAI_PROVIDER_MODEL).toBeUndefined();
|
|
15
|
+
expect(definition.defaults?.model).toBeUndefined();
|
|
16
|
+
expect(definition.defaults?.apiKey).toBe(DEFAULT_OPENAI_PROVIDER_API_KEY_REFERENCE);
|
|
17
|
+
expect(definition.defaults?.baseURL).toBeUndefined();
|
|
18
|
+
expect(definition.displayName).toBe('OpenAI');
|
|
19
|
+
expect(definition.setupHelpLinks).toEqual([
|
|
20
|
+
{
|
|
21
|
+
kind: 'api-key',
|
|
22
|
+
label: 'OpenAI API keys',
|
|
23
|
+
url: 'https://platform.openai.com/api-keys',
|
|
24
|
+
sourceUrl: 'https://developers.openai.com/api/reference/overview#authentication',
|
|
25
|
+
lastVerifiedAt: '2026-05-08',
|
|
26
|
+
},
|
|
27
|
+
]);
|
|
28
|
+
expect(modelStep).toMatchObject({
|
|
29
|
+
key: 'model',
|
|
30
|
+
title: 'OpenAI model',
|
|
31
|
+
required: true,
|
|
32
|
+
});
|
|
33
|
+
expect(modelStep?.defaultValue).toBeUndefined();
|
|
34
|
+
expect(apiKeyStep).toMatchObject({
|
|
35
|
+
key: 'apiKey',
|
|
36
|
+
title: 'OpenAI API key',
|
|
37
|
+
defaultValue: '$ENV:OPENAI_API_KEY',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('passes explicit apiSurface provider options into construction', () => {
|
|
42
|
+
const definition = createOpenAIProviderDefinition();
|
|
43
|
+
|
|
44
|
+
const provider = definition.createProvider({
|
|
45
|
+
name: 'openai',
|
|
46
|
+
model: 'gpt-4o',
|
|
47
|
+
apiKey: 'sk-test',
|
|
48
|
+
options: { apiSurface: 'chat-completions' },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(provider.name).toBe('openai');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects native web tools for OpenAI-compatible baseURL profiles', () => {
|
|
55
|
+
const definition = createOpenAIProviderDefinition();
|
|
56
|
+
|
|
57
|
+
expect(() =>
|
|
58
|
+
definition.createProvider({
|
|
59
|
+
name: 'openai',
|
|
60
|
+
model: 'local-model',
|
|
61
|
+
apiKey: 'lm-studio',
|
|
62
|
+
baseURL: 'http://localhost:1234/v1',
|
|
63
|
+
options: {
|
|
64
|
+
builtInWebTools: {
|
|
65
|
+
webSearch: true,
|
|
66
|
+
webFetch: true,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
).toThrow(
|
|
71
|
+
'Provider openai profile uses an OpenAI-compatible Chat Completions endpoint; native web search/fetch is not supported for this profile.',
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|