Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
537 lines
13 KiB
TypeScript
537 lines
13 KiB
TypeScript
import type { JSONSchema7 } from 'json-schema';
|
|
import type { IHttpRequestMethods } from 'n8n-workflow';
|
|
|
|
import {
|
|
BaseChatModel,
|
|
getParametersJsonSchema,
|
|
parseSSEStream,
|
|
type TokenUsage,
|
|
type Tool,
|
|
type ToolCall,
|
|
type ChatModelConfig,
|
|
type GenerateResult,
|
|
type Message,
|
|
type MessageContent,
|
|
type ProviderTool,
|
|
type StreamChunk,
|
|
} from 'src';
|
|
|
|
// Types
|
|
type OpenAITool =
|
|
| {
|
|
type: 'function';
|
|
name: string;
|
|
description?: string;
|
|
parameters: JSONSchema7;
|
|
strict?: boolean;
|
|
}
|
|
| {
|
|
type: 'web_search';
|
|
};
|
|
|
|
type OpenAIToolChoice = 'auto' | 'required' | 'none' | { type: 'function'; name: string };
|
|
|
|
type ResponsesInputItem =
|
|
| { role: 'user'; content: string }
|
|
| { role: 'user'; content: Array<{ type: 'input_text'; text: string }> }
|
|
| {
|
|
type: 'message';
|
|
role: 'assistant';
|
|
content: Array<{ type: 'output_text'; text: string }>;
|
|
}
|
|
| {
|
|
type: 'function_call';
|
|
call_id: string;
|
|
name: string;
|
|
arguments: string;
|
|
}
|
|
| { type: 'function_call_output'; call_id: string; output: string };
|
|
|
|
interface OpenAIResponsesRequest {
|
|
model: string;
|
|
input: string | ResponsesInputItem[];
|
|
instructions?: string;
|
|
max_output_tokens?: number;
|
|
temperature?: number;
|
|
top_p?: number;
|
|
tools?: OpenAITool[];
|
|
tool_choice?: OpenAIToolChoice;
|
|
parallel_tool_calls?: boolean;
|
|
store?: boolean;
|
|
stream?: boolean;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
interface OpenAIResponsesResponse {
|
|
id: string;
|
|
object: string;
|
|
created_at: string;
|
|
model: string;
|
|
output: ResponsesOutputItem[];
|
|
status: string;
|
|
usage?: {
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
total_tokens: number;
|
|
input_tokens_details?: {
|
|
cached_tokens?: number;
|
|
};
|
|
output_tokens_details?: {
|
|
reasoning_tokens?: number;
|
|
};
|
|
};
|
|
incomplete_details?: Record<string, unknown>;
|
|
metadata?: Record<string, unknown>;
|
|
user?: string;
|
|
service_tier?: string;
|
|
}
|
|
|
|
type ResponsesOutputItem =
|
|
| {
|
|
type: 'message';
|
|
role: 'assistant';
|
|
id?: string;
|
|
content: Array<{
|
|
type: 'output_text';
|
|
text: string;
|
|
}>;
|
|
}
|
|
| {
|
|
type: 'function_call';
|
|
id?: string;
|
|
call_id: string;
|
|
name: string;
|
|
arguments: string;
|
|
}
|
|
| {
|
|
type: 'reasoning';
|
|
id?: string;
|
|
summary: Array<{
|
|
type: string;
|
|
text: string;
|
|
}>;
|
|
};
|
|
|
|
interface OpenAIStreamEvent {
|
|
type: string;
|
|
delta?: string;
|
|
output_index?: number;
|
|
item?: Record<string, unknown>;
|
|
response?: Record<string, unknown>;
|
|
}
|
|
|
|
// Helpers
|
|
|
|
async function* parseOpenAIStreamEvents(
|
|
body: AsyncIterableIterator<Buffer | Uint8Array>,
|
|
): AsyncIterable<OpenAIStreamEvent> {
|
|
for await (const message of parseSSEStream(body)) {
|
|
if (!message.data) continue;
|
|
if (message.data === '[DONE]') continue;
|
|
|
|
try {
|
|
const event = JSON.parse(message.data);
|
|
yield event as OpenAIStreamEvent;
|
|
} catch (e) {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
console.warn('Failed to parse OpenAI SSE event:', message.data);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function genericMessagesToResponsesInput(messages: Message[]): {
|
|
instructions?: string;
|
|
input: string | ResponsesInputItem[];
|
|
} {
|
|
const instructionsParts: string[] = [];
|
|
const inputItems: ResponsesInputItem[] = [];
|
|
|
|
for (const msg of messages) {
|
|
if (msg.role === 'system') {
|
|
for (const contentPart of msg.content) {
|
|
if (contentPart.type === 'text') {
|
|
instructionsParts.push(contentPart.text);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (msg.role === 'user') {
|
|
for (const contentPart of msg.content) {
|
|
if (contentPart.type === 'text') {
|
|
inputItems.push({
|
|
role: 'user',
|
|
content: contentPart.text,
|
|
});
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (msg.role === 'assistant') {
|
|
for (const contentPart of msg.content) {
|
|
if (contentPart.type === 'text') {
|
|
inputItems.push({
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'output_text',
|
|
text: contentPart.text,
|
|
},
|
|
],
|
|
});
|
|
} else if (contentPart.type === 'tool-call') {
|
|
if (!contentPart.toolCallId) {
|
|
throw new Error('Tool call ID is required');
|
|
}
|
|
inputItems.push({
|
|
type: 'function_call',
|
|
call_id: contentPart.toolCallId,
|
|
name: contentPart.toolName,
|
|
arguments: contentPart.input,
|
|
});
|
|
} else if (contentPart.type === 'reasoning') {
|
|
inputItems.push({
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'output_text',
|
|
text: contentPart.text,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (msg.role === 'tool') {
|
|
for (const contentPart of msg.content) {
|
|
if (contentPart.type === 'tool-result') {
|
|
const output =
|
|
typeof contentPart.result === 'string'
|
|
? contentPart.result
|
|
: JSON.stringify(contentPart.result);
|
|
inputItems.push({
|
|
type: 'function_call_output',
|
|
call_id: contentPart.toolCallId,
|
|
output,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const instructions = instructionsParts.length > 0 ? instructionsParts.join('\n\n') : undefined;
|
|
|
|
const single = inputItems[0];
|
|
if (
|
|
inputItems.length === 1 &&
|
|
single &&
|
|
'role' in single &&
|
|
single.role === 'user' &&
|
|
typeof single.content === 'string'
|
|
) {
|
|
return { instructions, input: single.content };
|
|
}
|
|
return { instructions, input: inputItems };
|
|
}
|
|
|
|
function genericToolToResponsesTool(tool: Tool): OpenAITool {
|
|
if (tool.type === 'provider') {
|
|
if (tool.name === 'web_search') {
|
|
return {
|
|
type: 'web_search',
|
|
...tool.args,
|
|
};
|
|
}
|
|
throw new Error(`Unsupported provider tool: ${tool.name}`);
|
|
}
|
|
const parameters = getParametersJsonSchema(tool);
|
|
return {
|
|
type: 'function',
|
|
name: tool.name,
|
|
description: tool.description,
|
|
parameters,
|
|
strict: tool.strict,
|
|
};
|
|
}
|
|
|
|
function parseResponsesOutput(output: ResponsesOutputItem[]): {
|
|
text: string;
|
|
toolCalls: ToolCall[];
|
|
} {
|
|
let text = '';
|
|
const toolCalls: ToolCall[] = [];
|
|
|
|
for (const item of output) {
|
|
if (item.type === 'message' && item.role === 'assistant') {
|
|
for (const block of item.content) {
|
|
if (block.type === 'output_text') {
|
|
text += block.text;
|
|
}
|
|
}
|
|
}
|
|
if (item.type === 'function_call') {
|
|
try {
|
|
toolCalls.push({
|
|
id: item.call_id,
|
|
name: item.name,
|
|
arguments: JSON.parse(item.arguments) as Record<string, unknown>,
|
|
argumentsRaw: item.arguments,
|
|
});
|
|
} catch (e) {
|
|
throw new Error(`Failed to parse function call arguments: ${item.arguments}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { text, toolCalls };
|
|
}
|
|
|
|
function parseTokenUsage(
|
|
usage: OpenAIResponsesResponse['usage'] | undefined,
|
|
): TokenUsage | undefined {
|
|
return usage
|
|
? {
|
|
promptTokens: usage.input_tokens ?? 0,
|
|
completionTokens: usage.output_tokens ?? 0,
|
|
totalTokens: usage.total_tokens ?? 0,
|
|
inputTokenDetails: {
|
|
...(!!usage.input_tokens_details?.cached_tokens && {
|
|
cacheRead: usage.input_tokens_details.cached_tokens,
|
|
}),
|
|
},
|
|
outputTokenDetails: {
|
|
...(!!usage.output_tokens_details?.reasoning_tokens && {
|
|
reasoning: usage.output_tokens_details.reasoning_tokens,
|
|
}),
|
|
},
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
interface OpenAIChatModelConfig extends ChatModelConfig {
|
|
apiKey?: string;
|
|
baseURL?: string;
|
|
|
|
providerTools?: ProviderTool[];
|
|
}
|
|
|
|
interface RequestConfig {
|
|
httpRequest: (
|
|
method: IHttpRequestMethods,
|
|
url: string,
|
|
body?: object,
|
|
headers?: Record<string, string>,
|
|
) => Promise<{ body: unknown }>;
|
|
openStream: (
|
|
method: IHttpRequestMethods,
|
|
url: string,
|
|
body?: object,
|
|
headers?: Record<string, string>,
|
|
) => Promise<{ body: AsyncIterableIterator<Buffer | Uint8Array> }>;
|
|
}
|
|
|
|
export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
|
|
private baseURL: string;
|
|
|
|
constructor(
|
|
modelId: string = 'gpt-4o',
|
|
private requests: RequestConfig,
|
|
config?: OpenAIChatModelConfig,
|
|
) {
|
|
super('openai', modelId, config);
|
|
this.baseURL = config?.baseURL ?? 'https://api.openai.com/v1';
|
|
}
|
|
|
|
private getTools(config?: OpenAIChatModelConfig) {
|
|
const ownTools = this.tools;
|
|
const providerTools = config?.providerTools ?? this.defaultConfig?.providerTools ?? [];
|
|
return [...ownTools, ...providerTools].map(genericToolToResponsesTool);
|
|
}
|
|
|
|
async generate(messages: Message[], config?: OpenAIChatModelConfig): Promise<GenerateResult> {
|
|
const merged = this.mergeConfig(config);
|
|
const { instructions, input } = genericMessagesToResponsesInput(messages);
|
|
const tools = this.getTools(config);
|
|
const requestBody: OpenAIResponsesRequest = {
|
|
model: this.modelId,
|
|
input,
|
|
instructions,
|
|
max_output_tokens: merged.maxTokens,
|
|
temperature: merged.temperature,
|
|
top_p: merged.topP,
|
|
tools,
|
|
parallel_tool_calls: true,
|
|
store: false,
|
|
stream: false,
|
|
};
|
|
|
|
const response = await this.requests.httpRequest(
|
|
'POST',
|
|
`${this.baseURL}/responses`,
|
|
requestBody,
|
|
);
|
|
const body = response.body as OpenAIResponsesResponse;
|
|
|
|
const { text, toolCalls } = parseResponsesOutput(body.output);
|
|
|
|
const usage = parseTokenUsage(body.usage);
|
|
|
|
const responseMetadata: Record<string, unknown> = {
|
|
model_provider: 'openai',
|
|
model: body.model,
|
|
created_at: body.created_at,
|
|
id: body.id,
|
|
incomplete_details: body.incomplete_details,
|
|
metadata: body.metadata,
|
|
object: body.object,
|
|
status: body.status,
|
|
user: body.user,
|
|
service_tier: body.service_tier,
|
|
model_name: body.model,
|
|
output: body.output,
|
|
};
|
|
|
|
for (const item of body.output as unknown[]) {
|
|
const o = item as Record<string, unknown>;
|
|
if (o.type === 'reasoning') {
|
|
responseMetadata.reasoning = o;
|
|
}
|
|
}
|
|
|
|
const content: MessageContent[] = [];
|
|
if (toolCalls.length) {
|
|
for (const toolCall of toolCalls) {
|
|
content.push({
|
|
type: 'tool-call',
|
|
toolCallId: toolCall.id,
|
|
toolName: toolCall.name,
|
|
input: JSON.stringify(toolCall.arguments),
|
|
});
|
|
}
|
|
}
|
|
content.push({ type: 'text', text });
|
|
|
|
const message: Message = {
|
|
role: 'assistant',
|
|
content,
|
|
id: body.id,
|
|
};
|
|
|
|
return {
|
|
id: body.id,
|
|
finishReason: body.status === 'completed' ? 'stop' : 'other',
|
|
usage,
|
|
message,
|
|
rawResponse: body,
|
|
providerMetadata: responseMetadata,
|
|
};
|
|
}
|
|
|
|
async *stream(messages: Message[], config?: OpenAIChatModelConfig): AsyncIterable<StreamChunk> {
|
|
const merged = this.mergeConfig(config) as OpenAIChatModelConfig;
|
|
const { instructions, input } = genericMessagesToResponsesInput(messages);
|
|
|
|
const tools = this.getTools(config);
|
|
|
|
const requestBody: OpenAIResponsesRequest = {
|
|
model: this.modelId,
|
|
input,
|
|
instructions,
|
|
max_output_tokens: merged.maxTokens,
|
|
temperature: merged.temperature,
|
|
top_p: merged.topP,
|
|
tools,
|
|
parallel_tool_calls: true,
|
|
store: false,
|
|
stream: true,
|
|
};
|
|
|
|
const streamResponse = await this.requests.openStream(
|
|
'POST',
|
|
`${this.baseURL}/responses`,
|
|
requestBody,
|
|
);
|
|
const streamBody = streamResponse.body;
|
|
|
|
const toolCallBuffers: Record<number, { name: string; arguments: string }> = {};
|
|
|
|
for await (const event of parseOpenAIStreamEvents(streamBody)) {
|
|
const type = event.type;
|
|
|
|
if (type === 'response.output_text.delta') {
|
|
const delta = event.delta;
|
|
if (delta) {
|
|
yield { type: 'text-delta', delta };
|
|
}
|
|
}
|
|
|
|
if (type === 'response.output_item.added') {
|
|
const item = event.item;
|
|
if (item?.type === 'function_call') {
|
|
const idx = event.output_index ?? 0;
|
|
toolCallBuffers[idx] = {
|
|
name: (item.name as string) ?? '',
|
|
arguments: (item.arguments as string) ?? '',
|
|
};
|
|
}
|
|
if (item?.type === 'reasoning') {
|
|
const summary = (item.summary as Array<Record<string, unknown>>) ?? [];
|
|
const reasoningText = summary
|
|
.map((s) => s.text)
|
|
.filter(Boolean)
|
|
.join('');
|
|
if (reasoningText) {
|
|
yield { type: 'reasoning-delta', delta: reasoningText };
|
|
}
|
|
}
|
|
}
|
|
|
|
if (type === 'response.reasoning_summary_text.delta') {
|
|
const delta = event.delta;
|
|
if (delta) {
|
|
yield { type: 'reasoning-delta', delta };
|
|
}
|
|
}
|
|
|
|
if (type === 'response.function_call_arguments.delta') {
|
|
const idx = event.output_index ?? 0;
|
|
const delta = event.delta;
|
|
if (toolCallBuffers[idx] && delta) {
|
|
toolCallBuffers[idx].arguments += delta;
|
|
}
|
|
}
|
|
|
|
if (type === 'response.output_item.done') {
|
|
const item = event.item;
|
|
if (item?.type === 'function_call') {
|
|
const idx = event.output_index ?? 0;
|
|
const buf = toolCallBuffers[idx];
|
|
if (buf) {
|
|
yield {
|
|
type: 'tool-call-delta',
|
|
id: (item.call_id as string) ?? (item.id as string),
|
|
name: buf.name,
|
|
argumentsDelta: buf.arguments,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (type === 'response.done' || type === 'response.completed') {
|
|
const responseData =
|
|
(event.response as unknown as OpenAIResponsesResponse) ??
|
|
(event as unknown as OpenAIResponsesResponse);
|
|
yield {
|
|
type: 'finish',
|
|
finishReason: 'stop',
|
|
usage: parseTokenUsage(responseData.usage),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|