@flink-app/openai-adapter 2.0.0-alpha.100
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/README.md +624 -0
- package/dist/OpenAIAdapter.d.ts +245 -0
- package/dist/OpenAIAdapter.d.ts.map +1 -0
- package/dist/OpenAIAdapter.js +667 -0
- package/dist/OpenAIAdapter.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.OpenAIAdapter = void 0;
|
|
7
|
+
const flink_1 = require("@flink-app/flink");
|
|
8
|
+
const openai_1 = __importDefault(require("openai"));
|
|
9
|
+
/**
|
|
10
|
+
* OpenAI adapter for Flink AI framework using the Responses API
|
|
11
|
+
*
|
|
12
|
+
* Built on OpenAI's Responses API (evolution of Chat Completions) which provides:
|
|
13
|
+
* - Step-aware reasoning: Model can return multiple tool calls in a single response
|
|
14
|
+
* - Explicit tool invocation: Tool calls and results are first-class typed items
|
|
15
|
+
* - Better performance: 3% improvement on SWE-bench, 5% on TAUBench
|
|
16
|
+
*
|
|
17
|
+
* ## Architecture
|
|
18
|
+
*
|
|
19
|
+
* IMPORTANT: The Responses API does NOT run your agent loop.
|
|
20
|
+
*
|
|
21
|
+
* What it provides:
|
|
22
|
+
* - Multiple tool calls in one response (but you execute them)
|
|
23
|
+
* - Structured step types (message, function_call, function_call_output)
|
|
24
|
+
* - Streaming event taxonomy
|
|
25
|
+
*
|
|
26
|
+
* What you still handle:
|
|
27
|
+
* - Tool execution (Flink's AgentRunner does this)
|
|
28
|
+
* - Multi-turn loops (call API → execute tools → call API again)
|
|
29
|
+
* - Deciding when to stop
|
|
30
|
+
* - Managing conversation state
|
|
31
|
+
*
|
|
32
|
+
* This is intentional - it gives you full control over the agent loop.
|
|
33
|
+
*
|
|
34
|
+
* ## Streaming-Only Design
|
|
35
|
+
*
|
|
36
|
+
* This adapter uses streaming as the primary method because:
|
|
37
|
+
* - Better user experience (time-to-first-token <500ms)
|
|
38
|
+
* - Prevents HTTP timeouts for long responses
|
|
39
|
+
* - FlinkAgent's lazy generator pattern supports both streaming and non-streaming UX
|
|
40
|
+
*
|
|
41
|
+
* For non-streaming consumption, simply await the final result:
|
|
42
|
+
* ```typescript
|
|
43
|
+
* const response = agent.execute({ message: "Hello" });
|
|
44
|
+
* const result = await response.result; // Consumes stream but returns final result
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* ## System Instructions - Two Approaches
|
|
48
|
+
*
|
|
49
|
+
* You can provide system instructions in two ways:
|
|
50
|
+
*
|
|
51
|
+
* 1. **Top-level instructions** (via Flink's instructions parameter):
|
|
52
|
+
* - Automatically prepended as system message to conversation
|
|
53
|
+
* - Simpler approach, recommended for most cases
|
|
54
|
+
* - Following Vercel AI SDK pattern
|
|
55
|
+
*
|
|
56
|
+
* 2. **System role messages** (via messages array):
|
|
57
|
+
* - Add `{ role: "system", content: "..." }` to messages
|
|
58
|
+
* - Gives more control over message structure and placement
|
|
59
|
+
* - Useful when you need system context at specific points in conversation
|
|
60
|
+
*
|
|
61
|
+
* Both approaches can be used together - system messages in input array + top-level instructions.
|
|
62
|
+
*
|
|
63
|
+
* ## Features
|
|
64
|
+
* - ✅ Step-based tool calling with multiple calls per response
|
|
65
|
+
* - ✅ Streaming support with event-based updates
|
|
66
|
+
* - ✅ Structured outputs with JSON schema (100% reliability)
|
|
67
|
+
* - ✅ Support for all OpenAI models (GPT-4.1, GPT-5, o-series)
|
|
68
|
+
* - ✅ Flexible system instructions (top-level or in messages)
|
|
69
|
+
*
|
|
70
|
+
* ## Basic Usage
|
|
71
|
+
* ```typescript
|
|
72
|
+
* import { OpenAIAdapter } from "@flink-app/openai-adapter";
|
|
73
|
+
* import { FlinkApp } from "@flink-app/flink";
|
|
74
|
+
*
|
|
75
|
+
* const app = new FlinkApp({
|
|
76
|
+
* ai: {
|
|
77
|
+
* llms: {
|
|
78
|
+
* default: new OpenAIAdapter({
|
|
79
|
+
* apiKey: process.env.OPENAI_API_KEY!,
|
|
80
|
+
* model: "gpt-5"
|
|
81
|
+
* }),
|
|
82
|
+
* },
|
|
83
|
+
* },
|
|
84
|
+
* });
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* ## Structured Outputs
|
|
88
|
+
* ```typescript
|
|
89
|
+
* const adapter = new OpenAIAdapter({
|
|
90
|
+
* apiKey: process.env.OPENAI_API_KEY!,
|
|
91
|
+
* model: "gpt-5",
|
|
92
|
+
* structuredOutput: {
|
|
93
|
+
* type: "json_schema",
|
|
94
|
+
* name: "user_profile",
|
|
95
|
+
* schema: {
|
|
96
|
+
* type: "object",
|
|
97
|
+
* properties: {
|
|
98
|
+
* name: { type: "string" },
|
|
99
|
+
* age: { type: "number" }
|
|
100
|
+
* },
|
|
101
|
+
* required: ["name", "age"],
|
|
102
|
+
* additionalProperties: false
|
|
103
|
+
* },
|
|
104
|
+
* strict: true
|
|
105
|
+
* }
|
|
106
|
+
* });
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
class OpenAIAdapter {
|
|
110
|
+
constructor(optionsOrApiKey, model) {
|
|
111
|
+
this.log = flink_1.FlinkLogFactory.createLogger("flink.ai.openai");
|
|
112
|
+
if (typeof optionsOrApiKey === "string") {
|
|
113
|
+
// Legacy constructor: new OpenAIAdapter(apiKey, model)
|
|
114
|
+
this.client = new openai_1.default({ apiKey: optionsOrApiKey });
|
|
115
|
+
this.model = model;
|
|
116
|
+
this.debug = false; // Default: no debug logging
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// New constructor: new OpenAIAdapter({ apiKey, model, structuredOutput, debug })
|
|
120
|
+
this.client = new openai_1.default({ apiKey: optionsOrApiKey.apiKey });
|
|
121
|
+
this.model = optionsOrApiKey.model;
|
|
122
|
+
this.structuredOutput = optionsOrApiKey.structuredOutput;
|
|
123
|
+
this.debug = optionsOrApiKey.debug ?? false; // Default: no debug logging
|
|
124
|
+
}
|
|
125
|
+
// Enable debug logging for this component if debug flag is set
|
|
126
|
+
if (this.debug) {
|
|
127
|
+
this.log.setLevel("debug");
|
|
128
|
+
this.log.info("OpenAI adapter initialized with debug logging enabled");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async *stream(params) {
|
|
132
|
+
try {
|
|
133
|
+
// Convert instructions to system message + Flink messages to OpenAI Responses API format
|
|
134
|
+
// Following Vercel AI SDK pattern: instructions are prepended as a system message
|
|
135
|
+
const input = this.convertMessagesToResponsesInput(params.instructions, params.messages);
|
|
136
|
+
// Convert tools to Responses API format
|
|
137
|
+
const tools = params.tools.length > 0 ? this.convertToolsToResponsesFormat(params.tools) : undefined;
|
|
138
|
+
this.log.trace(JSON.stringify(tools));
|
|
139
|
+
// Build text format config for structured outputs
|
|
140
|
+
const textFormat = this.structuredOutput
|
|
141
|
+
? {
|
|
142
|
+
format: {
|
|
143
|
+
type: "json_schema",
|
|
144
|
+
name: this.structuredOutput.name,
|
|
145
|
+
description: this.structuredOutput.description,
|
|
146
|
+
schema: this.structuredOutput.schema,
|
|
147
|
+
strict: this.structuredOutput.strict ?? true,
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
: undefined;
|
|
151
|
+
// Call Responses API with streaming
|
|
152
|
+
// Note: Each stream is one turn; Flink's AgentRunner handles the multi-turn loop
|
|
153
|
+
// IMPORTANT: Do NOT include top-level instructions field - we prepend as system message in input array
|
|
154
|
+
const streamParams = {
|
|
155
|
+
model: this.model,
|
|
156
|
+
input: input,
|
|
157
|
+
tools: tools,
|
|
158
|
+
max_output_tokens: params.maxTokens,
|
|
159
|
+
text: textFormat,
|
|
160
|
+
stream: true,
|
|
161
|
+
// Only include temperature for models that support it
|
|
162
|
+
// Reasoning models (o1, o3, gpt-5) don't support temperature
|
|
163
|
+
...(this.isReasoningModel() ? {} : { temperature: params.temperature }),
|
|
164
|
+
};
|
|
165
|
+
this.log.debug("Starting OpenAI stream request", {
|
|
166
|
+
model: this.model,
|
|
167
|
+
messageCount: input.length,
|
|
168
|
+
toolCount: tools?.length || 0,
|
|
169
|
+
maxTokens: params.maxTokens,
|
|
170
|
+
hasStructuredOutput: !!this.structuredOutput,
|
|
171
|
+
});
|
|
172
|
+
if (this.debug) {
|
|
173
|
+
this.log.debug("Full request params:", streamParams);
|
|
174
|
+
}
|
|
175
|
+
const stream = await this.client.responses.stream(streamParams);
|
|
176
|
+
this.log.debug("Stream connection established");
|
|
177
|
+
let totalInputTokens = 0;
|
|
178
|
+
let totalOutputTokens = 0;
|
|
179
|
+
let cachedInputTokens = 0;
|
|
180
|
+
let finalStatus = "completed";
|
|
181
|
+
// Track function calls in progress (streaming may send partial data)
|
|
182
|
+
const functionCallsInProgress = new Map();
|
|
183
|
+
for await (const event of stream) {
|
|
184
|
+
// Handle different event types from Responses streaming
|
|
185
|
+
if (event.type === "response.output_item.added") {
|
|
186
|
+
const addedEvent = event;
|
|
187
|
+
const item = addedEvent.item;
|
|
188
|
+
if (item?.type === "function_call") {
|
|
189
|
+
functionCallsInProgress.set(item.call_id, {
|
|
190
|
+
name: item.name || "",
|
|
191
|
+
arguments: "",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else if (event.type === "response.output_text.delta") {
|
|
196
|
+
const textEvent = event;
|
|
197
|
+
yield { type: "text", delta: textEvent.delta || "" };
|
|
198
|
+
}
|
|
199
|
+
else if (event.type === "response.function_call_arguments.delta") {
|
|
200
|
+
const argsEvent = event;
|
|
201
|
+
const callId = argsEvent.item_id;
|
|
202
|
+
const existing = functionCallsInProgress.get(callId);
|
|
203
|
+
if (existing) {
|
|
204
|
+
existing.arguments += argsEvent.delta || "";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else if (event.type === "response.output_item.done") {
|
|
208
|
+
const doneEvent = event;
|
|
209
|
+
const item = doneEvent.item;
|
|
210
|
+
if (item?.type === "function_call") {
|
|
211
|
+
// Read arguments from the done event item, not from accumulated deltas
|
|
212
|
+
// The item.arguments property contains the complete arguments string
|
|
213
|
+
const funcItem = item;
|
|
214
|
+
// Log the tool call decision from LLM
|
|
215
|
+
this.log.debug("LLM decided to call tool", {
|
|
216
|
+
name: funcItem.name,
|
|
217
|
+
call_id: funcItem.call_id,
|
|
218
|
+
arguments: funcItem.arguments,
|
|
219
|
+
});
|
|
220
|
+
// Handle empty string arguments (LLM sometimes sends "" instead of "{}")
|
|
221
|
+
const argsStr = funcItem.arguments?.trim() || "{}";
|
|
222
|
+
yield {
|
|
223
|
+
type: "tool_call",
|
|
224
|
+
toolCall: {
|
|
225
|
+
id: funcItem.call_id,
|
|
226
|
+
name: funcItem.name,
|
|
227
|
+
input: JSON.parse(argsStr),
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (event.type === "response.completed") {
|
|
233
|
+
const completedEvent = event;
|
|
234
|
+
const response = completedEvent.response;
|
|
235
|
+
if (response?.usage) {
|
|
236
|
+
totalInputTokens = response.usage.input_tokens || 0;
|
|
237
|
+
totalOutputTokens = response.usage.output_tokens || 0;
|
|
238
|
+
// Capture cached tokens from prompt_tokens_details
|
|
239
|
+
// @ts-ignore - OpenAI SDK types may not include this yet
|
|
240
|
+
cachedInputTokens = response.usage.input_tokens_details?.cached_tokens || 0;
|
|
241
|
+
}
|
|
242
|
+
finalStatus = response?.status || "completed";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Log token usage
|
|
246
|
+
this.log.info("OpenAI request completed", {
|
|
247
|
+
inputTokens: totalInputTokens,
|
|
248
|
+
outputTokens: totalOutputTokens,
|
|
249
|
+
cachedInputTokens: cachedInputTokens,
|
|
250
|
+
totalTokens: totalInputTokens + totalOutputTokens,
|
|
251
|
+
status: finalStatus,
|
|
252
|
+
model: this.model,
|
|
253
|
+
});
|
|
254
|
+
// Yield usage
|
|
255
|
+
yield {
|
|
256
|
+
type: "usage",
|
|
257
|
+
usage: {
|
|
258
|
+
inputTokens: totalInputTokens,
|
|
259
|
+
outputTokens: totalOutputTokens,
|
|
260
|
+
...(cachedInputTokens > 0 && { cachedInputTokens }),
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
// Yield done
|
|
264
|
+
yield {
|
|
265
|
+
type: "done",
|
|
266
|
+
stopReason: this.normalizeStopReason(finalStatus),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
// Log the error
|
|
271
|
+
this.log.error("OpenAI API request failed", {
|
|
272
|
+
status: error.status,
|
|
273
|
+
message: error.message,
|
|
274
|
+
model: this.model,
|
|
275
|
+
error: error,
|
|
276
|
+
});
|
|
277
|
+
// Enhanced error handling for OpenAI API errors
|
|
278
|
+
if (error.status === 400) {
|
|
279
|
+
throw new Error(`OpenAI API error (400 Bad Request): ${error.message || "Invalid request"}. ` +
|
|
280
|
+
`Common causes: invalid model name, malformed messages, or invalid tool schemas. ` +
|
|
281
|
+
`Model: ${this.model}`);
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Convert Flink messages to OpenAI Responses API input format
|
|
288
|
+
*
|
|
289
|
+
* Always sends full conversation history for reliability.
|
|
290
|
+
*
|
|
291
|
+
* The Responses API accepts:
|
|
292
|
+
* 1. System messages: { role: "system", content: "..." }
|
|
293
|
+
* 2. User messages: { role: "user", content: "..." }
|
|
294
|
+
* 3. Assistant messages: { role: "assistant", content: "..." }
|
|
295
|
+
* 4. Function calls: { type: "function_call", ... } ← From previous responses
|
|
296
|
+
* 5. Function call outputs: { type: "function_call_output", ... } ← Tool results
|
|
297
|
+
*
|
|
298
|
+
* Multi-turn pattern:
|
|
299
|
+
* - User: "What's the weather?"
|
|
300
|
+
* - Assistant: "Let me check" + function_call
|
|
301
|
+
* - function_call_output: "22°C"
|
|
302
|
+
* - Assistant: "It's 22°C and sunny"
|
|
303
|
+
* - User: "What about tomorrow?"
|
|
304
|
+
*
|
|
305
|
+
* All message types are preserved for full conversation context.
|
|
306
|
+
*/
|
|
307
|
+
/**
|
|
308
|
+
* Build the OpenAI user content from text/image content blocks.
|
|
309
|
+
* Returns a plain string when there are no images (most common case),
|
|
310
|
+
* or an array of input_text / input_image items for multimodal messages.
|
|
311
|
+
* Returns null if there are no user-facing blocks.
|
|
312
|
+
*/
|
|
313
|
+
buildOpenAIUserContent(blocks) {
|
|
314
|
+
const textBlocks = blocks.filter((b) => b.type === "text");
|
|
315
|
+
const imageBlocks = blocks.filter((b) => b.type === "image");
|
|
316
|
+
if (imageBlocks.length === 0) {
|
|
317
|
+
// Text-only: keep as plain string for backwards compat
|
|
318
|
+
const firstText = textBlocks[0];
|
|
319
|
+
return firstText && firstText.type === "text" ? firstText.text : null;
|
|
320
|
+
}
|
|
321
|
+
// Multimodal: build content array
|
|
322
|
+
const content = [];
|
|
323
|
+
for (const block of textBlocks) {
|
|
324
|
+
if (block.type === "text")
|
|
325
|
+
content.push({ type: "input_text", text: block.text });
|
|
326
|
+
}
|
|
327
|
+
for (const block of imageBlocks) {
|
|
328
|
+
if (block.type === "image")
|
|
329
|
+
content.push({ type: "input_image", image_url: block.url });
|
|
330
|
+
}
|
|
331
|
+
return content.length > 0 ? content : null;
|
|
332
|
+
}
|
|
333
|
+
convertMessagesToResponsesInput(instructions, messages) {
|
|
334
|
+
const result = [];
|
|
335
|
+
// 1. Prepend instructions as system message (Vercel AI SDK pattern)
|
|
336
|
+
if (instructions) {
|
|
337
|
+
result.push({
|
|
338
|
+
role: "system",
|
|
339
|
+
content: instructions,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
// Convert all messages to full conversation history
|
|
343
|
+
for (const msg of messages) {
|
|
344
|
+
// 2. Handle additional system messages (for dynamic context)
|
|
345
|
+
if (msg.role === "system") {
|
|
346
|
+
result.push({
|
|
347
|
+
role: "system",
|
|
348
|
+
content: msg.content,
|
|
349
|
+
});
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// 3. Handle user messages
|
|
353
|
+
if (msg.role === "user") {
|
|
354
|
+
if (typeof msg.content === "string") {
|
|
355
|
+
// Simple text message
|
|
356
|
+
result.push({
|
|
357
|
+
role: "user",
|
|
358
|
+
content: msg.content,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
// Handle content blocks (tool results + text + images)
|
|
363
|
+
const content = msg.content;
|
|
364
|
+
const toolResultBlocks = content.filter((block) => block.type === "tool_result");
|
|
365
|
+
const userBlocks = content.filter((block) => block.type !== "tool_result");
|
|
366
|
+
// Add function call outputs (tool results) — these are top-level items
|
|
367
|
+
for (const block of toolResultBlocks) {
|
|
368
|
+
if (block.type === "tool_result") {
|
|
369
|
+
result.push({
|
|
370
|
+
type: "function_call_output",
|
|
371
|
+
call_id: block.tool_use_id,
|
|
372
|
+
output: block.content,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Add user content (text + images) if present
|
|
377
|
+
const userContent = this.buildOpenAIUserContent(userBlocks);
|
|
378
|
+
if (userContent) {
|
|
379
|
+
result.push({ role: "user", content: userContent });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
// 4. Handle assistant messages
|
|
385
|
+
if (msg.role === "assistant") {
|
|
386
|
+
if (typeof msg.content === "string") {
|
|
387
|
+
// Pass through text-only assistant messages
|
|
388
|
+
result.push({
|
|
389
|
+
role: "assistant",
|
|
390
|
+
content: msg.content,
|
|
391
|
+
});
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
// Handle assistant messages with content blocks (tool calls)
|
|
395
|
+
const content = msg.content;
|
|
396
|
+
const textBlocks = content.filter((block) => block.type === "text");
|
|
397
|
+
const toolUseBlocks = content.filter((block) => block.type === "tool_use");
|
|
398
|
+
// Add assistant message if there's text content
|
|
399
|
+
if (textBlocks.length > 0) {
|
|
400
|
+
const firstTextBlock = textBlocks[0];
|
|
401
|
+
if (firstTextBlock.type === "text") {
|
|
402
|
+
result.push({
|
|
403
|
+
role: "assistant",
|
|
404
|
+
content: firstTextBlock.text,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Add function calls (these MUST be included for context)
|
|
409
|
+
for (const block of toolUseBlocks) {
|
|
410
|
+
if (block.type === "tool_use") {
|
|
411
|
+
result.push({
|
|
412
|
+
type: "function_call",
|
|
413
|
+
call_id: block.id,
|
|
414
|
+
name: block.name,
|
|
415
|
+
arguments: JSON.stringify(block.input),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Convert Flink tools to Responses API format
|
|
426
|
+
*
|
|
427
|
+
* Responses API uses internally-tagged format:
|
|
428
|
+
* { type: "function", name: "...", description: "...", parameters: {...} }
|
|
429
|
+
*
|
|
430
|
+
* vs Chat Completions externally-tagged format:
|
|
431
|
+
* { type: "function", function: { name: "...", description: "...", parameters: {...} } }
|
|
432
|
+
*/
|
|
433
|
+
convertToolsToResponsesFormat(tools) {
|
|
434
|
+
return tools.map((tool) => ({
|
|
435
|
+
type: "function",
|
|
436
|
+
name: tool.name,
|
|
437
|
+
description: tool.description,
|
|
438
|
+
parameters: this.sanitizeSchemaForStrictMode(tool.inputSchema),
|
|
439
|
+
strict: true,
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Sanitize JSON Schema for OpenAI's strict mode
|
|
444
|
+
*
|
|
445
|
+
* OpenAI's strict mode only supports a subset of JSON Schema.
|
|
446
|
+
* This function removes unsupported keywords that would cause the model
|
|
447
|
+
* to silently fail and return empty {} arguments.
|
|
448
|
+
*
|
|
449
|
+
* Unsupported keywords (stripped):
|
|
450
|
+
* - $schema (meta-schema reference)
|
|
451
|
+
* - minLength, maxLength, pattern (string constraints)
|
|
452
|
+
* - minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf (number constraints)
|
|
453
|
+
* - minItems, maxItems, uniqueItems (array constraints)
|
|
454
|
+
* - minProperties, maxProperties (object constraints)
|
|
455
|
+
* - format (string format hints like "email", "uri")
|
|
456
|
+
*
|
|
457
|
+
* Enforced requirements:
|
|
458
|
+
* - additionalProperties: false on all objects (required by strict mode)
|
|
459
|
+
* - required array must include all properties without default values
|
|
460
|
+
*
|
|
461
|
+
* Note: Removed constraints should be documented in the description field
|
|
462
|
+
* where the model can read them as natural language instructions.
|
|
463
|
+
*
|
|
464
|
+
* Reference: https://platform.openai.com/docs/guides/structured-outputs
|
|
465
|
+
*/
|
|
466
|
+
sanitizeSchemaForStrictMode(schema) {
|
|
467
|
+
// Deep clone to avoid mutating the original schema
|
|
468
|
+
const sanitized = JSON.parse(JSON.stringify(schema));
|
|
469
|
+
// OpenAI requires the root schema to be of type "object"
|
|
470
|
+
if (sanitized.type !== "object") {
|
|
471
|
+
throw new Error(`OpenAI tool input schema must have root type "object", got "${sanitized.type ?? "unknown"}". ` + `Wrap your input type in an object interface.`);
|
|
472
|
+
}
|
|
473
|
+
const unsupportedKeywords = [
|
|
474
|
+
"$schema",
|
|
475
|
+
"minLength",
|
|
476
|
+
"maxLength",
|
|
477
|
+
"pattern",
|
|
478
|
+
"minimum",
|
|
479
|
+
"maximum",
|
|
480
|
+
"exclusiveMinimum",
|
|
481
|
+
"exclusiveMaximum",
|
|
482
|
+
"multipleOf",
|
|
483
|
+
"minItems",
|
|
484
|
+
"maxItems",
|
|
485
|
+
"uniqueItems",
|
|
486
|
+
"minProperties",
|
|
487
|
+
"maxProperties",
|
|
488
|
+
"format",
|
|
489
|
+
];
|
|
490
|
+
const sanitizeNode = (node) => {
|
|
491
|
+
if (!node || typeof node !== "object")
|
|
492
|
+
return;
|
|
493
|
+
// OpenAI does not allow any sibling keywords alongside $ref — strip them all
|
|
494
|
+
if (node.$ref) {
|
|
495
|
+
const ref = node.$ref;
|
|
496
|
+
for (const key of Object.keys(node)) {
|
|
497
|
+
if (key !== "$ref")
|
|
498
|
+
delete node[key];
|
|
499
|
+
}
|
|
500
|
+
node.$ref = ref;
|
|
501
|
+
return; // nothing else to process on a $ref node
|
|
502
|
+
}
|
|
503
|
+
// Remove unsupported keywords from this node
|
|
504
|
+
for (const keyword of unsupportedKeywords) {
|
|
505
|
+
delete node[keyword];
|
|
506
|
+
}
|
|
507
|
+
// Convert const to enum (OpenAI strict mode prefers enum; const alone may lack type)
|
|
508
|
+
// e.g. { "const": "normal" } → { "type": "string", "enum": ["normal"] }
|
|
509
|
+
if (node.hasOwnProperty("const")) {
|
|
510
|
+
const val = node.const;
|
|
511
|
+
if (!node.type) {
|
|
512
|
+
if (val === null)
|
|
513
|
+
node.type = "null";
|
|
514
|
+
else if (typeof val === "string")
|
|
515
|
+
node.type = "string";
|
|
516
|
+
else if (typeof val === "number")
|
|
517
|
+
node.type = "number";
|
|
518
|
+
else if (typeof val === "boolean")
|
|
519
|
+
node.type = "boolean";
|
|
520
|
+
}
|
|
521
|
+
node.enum = [val];
|
|
522
|
+
delete node.const;
|
|
523
|
+
}
|
|
524
|
+
// Enforce additionalProperties: false on objects (required by strict mode)
|
|
525
|
+
// Also ensure properties exists (OpenAI requires it even for empty objects)
|
|
526
|
+
if (node.type === "object" && !node.properties) {
|
|
527
|
+
node.properties = {};
|
|
528
|
+
}
|
|
529
|
+
if (node.type === "object" && node.properties) {
|
|
530
|
+
node.additionalProperties = false;
|
|
531
|
+
// Fix the required array to only include properties without defaults
|
|
532
|
+
// OpenAI requires all properties to either be in 'required' or have a 'default'
|
|
533
|
+
if (node.required && Array.isArray(node.required)) {
|
|
534
|
+
const propertyKeys = Object.keys(node.properties);
|
|
535
|
+
const requiredFields = propertyKeys.filter((key) => {
|
|
536
|
+
const prop = node.properties[key];
|
|
537
|
+
return !prop.hasOwnProperty("default");
|
|
538
|
+
});
|
|
539
|
+
node.required = requiredFields;
|
|
540
|
+
}
|
|
541
|
+
else if (node.properties) {
|
|
542
|
+
// If no required array exists, create one with all properties without defaults
|
|
543
|
+
const propertyKeys = Object.keys(node.properties);
|
|
544
|
+
const requiredFields = propertyKeys.filter((key) => {
|
|
545
|
+
const prop = node.properties[key];
|
|
546
|
+
return !prop.hasOwnProperty("default");
|
|
547
|
+
});
|
|
548
|
+
if (requiredFields.length > 0) {
|
|
549
|
+
node.required = requiredFields;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Recursively sanitize nested structures
|
|
554
|
+
if (node.properties) {
|
|
555
|
+
for (const key in node.properties) {
|
|
556
|
+
sanitizeNode(node.properties[key]);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (node.items) {
|
|
560
|
+
if (Array.isArray(node.items)) {
|
|
561
|
+
node.items.forEach(sanitizeNode);
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
sanitizeNode(node.items);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (node.allOf)
|
|
568
|
+
node.allOf.forEach(sanitizeNode);
|
|
569
|
+
if (node.anyOf) {
|
|
570
|
+
node.anyOf.forEach(sanitizeNode);
|
|
571
|
+
// Collapse anyOf of simple typed enums into a cleaner form.
|
|
572
|
+
// e.g. anyOf[{type:"string",enum:["a"]},{type:"string",enum:["b"]},{type:"number",enum:[1]}]
|
|
573
|
+
// → anyOf[{type:"string",enum:["a","b"]},{type:"number",enum:[1]}]
|
|
574
|
+
// And if only one type remains, unwrap the anyOf entirely:
|
|
575
|
+
// → {type:"string",enum:["a","b"]}
|
|
576
|
+
const isSimpleEnum = (e) => e &&
|
|
577
|
+
typeof e === "object" &&
|
|
578
|
+
typeof e.type === "string" &&
|
|
579
|
+
Array.isArray(e.enum) &&
|
|
580
|
+
!e.$ref &&
|
|
581
|
+
!e.anyOf &&
|
|
582
|
+
!e.allOf &&
|
|
583
|
+
!e.oneOf &&
|
|
584
|
+
!e.properties &&
|
|
585
|
+
!e.items;
|
|
586
|
+
if (node.anyOf.every(isSimpleEnum)) {
|
|
587
|
+
const byType = {};
|
|
588
|
+
for (const entry of node.anyOf) {
|
|
589
|
+
if (!byType[entry.type])
|
|
590
|
+
byType[entry.type] = [];
|
|
591
|
+
byType[entry.type].push(...entry.enum);
|
|
592
|
+
}
|
|
593
|
+
const types = Object.keys(byType);
|
|
594
|
+
if (types.length === 1) {
|
|
595
|
+
node.type = types[0];
|
|
596
|
+
node.enum = byType[types[0]];
|
|
597
|
+
delete node.anyOf;
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
node.anyOf = types.map((t) => ({ type: t, enum: byType[t] }));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (node.oneOf)
|
|
605
|
+
node.oneOf.forEach(sanitizeNode);
|
|
606
|
+
if (node.not)
|
|
607
|
+
sanitizeNode(node.not);
|
|
608
|
+
// Sanitize schemas in $defs (added by ToolExecutor to make schemas self-contained)
|
|
609
|
+
if (node.$defs && typeof node.$defs === "object") {
|
|
610
|
+
for (const def of Object.values(node.$defs)) {
|
|
611
|
+
sanitizeNode(def);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
sanitizeNode(sanitized);
|
|
616
|
+
return sanitized;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Check if the model is a reasoning model that doesn't support temperature
|
|
620
|
+
*
|
|
621
|
+
* Reasoning models use a different internal process and don't allow temperature control:
|
|
622
|
+
* - o1, o1-mini, o1-preview
|
|
623
|
+
* - o3, o3-mini
|
|
624
|
+
* - gpt-5 (uses reasoning approach)
|
|
625
|
+
*/
|
|
626
|
+
isReasoningModel() {
|
|
627
|
+
const modelLower = this.model.toLowerCase();
|
|
628
|
+
return modelLower.startsWith("o1") || modelLower.startsWith("o3") || modelLower.startsWith("gpt-5");
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Normalize stop reason from Responses API to Flink format
|
|
632
|
+
*
|
|
633
|
+
* LIMITATION: This mapping is lossy. Responses API provides richer status:
|
|
634
|
+
* - "completed" → "end_turn"
|
|
635
|
+
* - "incomplete" → "max_tokens"
|
|
636
|
+
* - "failed" → "max_tokens" (loses error signal)
|
|
637
|
+
* - "cancelled" → "end_turn" (loses cancellation signal)
|
|
638
|
+
*
|
|
639
|
+
* Future improvement: Add LLMResponse.metadata field to preserve:
|
|
640
|
+
* - Raw status from provider
|
|
641
|
+
* - Safety stop reasons
|
|
642
|
+
* - Tool execution errors
|
|
643
|
+
* - Model-specific error codes
|
|
644
|
+
*
|
|
645
|
+
* For now, we map to Flink's limited set and lose granularity.
|
|
646
|
+
*/
|
|
647
|
+
normalizeStopReason(status) {
|
|
648
|
+
// Map Responses API status to Flink's stopReason enum
|
|
649
|
+
switch (status) {
|
|
650
|
+
case "incomplete":
|
|
651
|
+
return "max_tokens"; // Hit token limit
|
|
652
|
+
case "failed":
|
|
653
|
+
// TODO: This loses important signal (safety, tool_error, model_error)
|
|
654
|
+
// Consider logging or adding metadata field
|
|
655
|
+
return "max_tokens"; // Best approximation for now
|
|
656
|
+
case "cancelled":
|
|
657
|
+
return "end_turn"; // User/system cancelled
|
|
658
|
+
case "completed":
|
|
659
|
+
default:
|
|
660
|
+
// For Responses API, tool calls are part of the response items
|
|
661
|
+
// so we typically get "completed" even when tools were called
|
|
662
|
+
return "end_turn";
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
exports.OpenAIAdapter = OpenAIAdapter;
|
|
667
|
+
//# sourceMappingURL=OpenAIAdapter.js.map
|