graphlit-client
Version:
Graphlit API Client for TypeScript
281 lines (280 loc) • 11.8 kB
JavaScript
import { getModelName } from "../model-mapping.js";
import { ProviderError, extractRequestId, isNetworkError, isRateLimitError, isRetryableServerError, } from "../types/internal.js";
import { createHash } from "node:crypto";
function isFunctionCallOutputItem(item) {
return (typeof item === "object" &&
item !== null &&
item.type === "function_call");
}
function toToolCallId(item, outputIndex) {
return item.call_id || item.id || `fc_${Date.now()}_${outputIndex}`;
}
function shortHash(value) {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
export async function streamWithOpenAIResponses(specification, instructions, input, tools, openaiClient, onEvent, abortSignal, reasoningEffort, toolChoice) {
let fullMessage = "";
let tokenCount = 0;
let usageData;
let outputItems = [];
const toolCallsByIndex = new Map();
const toolCallIdsByItemId = new Map();
const startedToolCallIds = new Set();
const parsedToolCallIds = new Set();
const getOrCreateToolCall = (outputIndex, item) => {
const existing = toolCallsByIndex.get(outputIndex);
if (existing) {
if (item?.name) {
existing.name = item.name;
}
if (typeof item?.arguments === "string" && !existing.arguments) {
existing.arguments = item.arguments;
}
return existing;
}
const toolCall = {
__typename: "ConversationToolCall",
id: toToolCallId(item || {}, outputIndex),
name: item?.name || "",
arguments: item?.arguments || "",
};
toolCallsByIndex.set(outputIndex, toolCall);
if (item?.id) {
toolCallIdsByItemId.set(item.id, toolCall.id);
}
return toolCall;
};
const emitToolCallStartIfNeeded = (toolCall) => {
if (startedToolCallIds.has(toolCall.id)) {
return;
}
startedToolCallIds.add(toolCall.id);
onEvent({
type: "tool_call_start",
toolCall: {
id: toolCall.id,
name: toolCall.name,
},
});
};
const emitToolCallParsedIfNeeded = (toolCall) => {
if (!toolCall.arguments || parsedToolCallIds.has(toolCall.id)) {
return;
}
parsedToolCallIds.add(toolCall.id);
onEvent({
type: "tool_call_parsed",
toolCall: {
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
},
});
};
try {
const modelName = getModelName(specification);
if (!modelName) {
throw new Error(`No model name found for specification: ${specification.name} (service: ${specification.serviceType})`);
}
const request = {
model: modelName,
input,
stream: true,
store: false,
include: ["reasoning.encrypted_content"],
};
if (instructions?.trim()) {
request.instructions = instructions.trim();
}
if (specification.id) {
request.prompt_cache_key = `spec:${specification.id}:${shortHash(JSON.stringify({
instructions: request.instructions,
tools,
model: modelName,
}))}`;
}
// GPT-5.4+: temperature/top_p are only supported with reasoning effort "none".
// Sending temperature with any other reasoning effort raises an error.
const effectiveEffort = reasoningEffort?.toLowerCase();
if (specification.openAI?.temperature !== undefined &&
(!effectiveEffort || effectiveEffort === "none")) {
request.temperature = specification.openAI.temperature;
}
if (specification.openAI?.completionTokenLimit) {
request.max_output_tokens = specification.openAI.completionTokenLimit;
}
if (tools?.length) {
request.tools = tools;
request.tool_choice = toolChoice || "auto";
}
else if (toolChoice === "none") {
request.tool_choice = "none";
}
if (effectiveEffort && effectiveEffort !== "none") {
request.reasoning = { effort: effectiveEffort };
}
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`🤖 [OpenAI Responses] Model Config: Model=${modelName} | Temperature=${specification.openAI?.temperature} | MaxTokens=${specification.openAI?.completionTokenLimit || "null"} | Tools=${tools?.length || 0} | ToolChoice=${toolChoice || "auto"} | ReasoningEffort=${reasoningEffort || "none"} | Spec="${specification.name}"`);
if (tools?.length) {
console.log(`🔧 [OpenAI Responses] Tools: ${tools.map((t) => t.name).join(", ")}`);
}
}
const stream = await openaiClient.responses.create(request, {
signal: abortSignal,
});
for await (const event of stream) {
switch (event.type) {
case "response.output_text.delta":
if (event.delta) {
fullMessage += event.delta;
tokenCount++;
onEvent({ type: "token", token: event.delta });
}
break;
case "response.output_item.added": {
const item = event.item;
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`📦 [OpenAI Responses] Output item added: type=${item?.type} index=${event.output_index}`);
}
if (item?.type !== "function_call") {
break;
}
const toolCall = getOrCreateToolCall(event.output_index, item);
emitToolCallStartIfNeeded(toolCall);
break;
}
case "response.function_call_arguments.delta": {
const toolCallId = toolCallIdsByItemId.get(event.item_id) ||
toolCallsByIndex.get(event.output_index)?.id;
const toolCall = toolCallsByIndex.get(event.output_index);
if (!toolCall || !toolCallId) {
break;
}
toolCall.arguments += event.delta;
onEvent({
type: "tool_call_delta",
toolCallId,
argumentDelta: event.delta,
});
break;
}
case "response.function_call_arguments.done": {
const toolCall = toolCallsByIndex.get(event.output_index);
if (!toolCall) {
break;
}
toolCall.arguments = event.arguments;
emitToolCallParsedIfNeeded(toolCall);
break;
}
case "response.output_item.done": {
const item = event.item;
if (item?.type !== "function_call") {
break;
}
const toolCall = getOrCreateToolCall(event.output_index, item);
if (item.id) {
toolCallIdsByItemId.set(item.id, toolCall.id);
}
emitToolCallStartIfNeeded(toolCall);
if (typeof item.arguments === "string") {
toolCall.arguments = item.arguments;
emitToolCallParsedIfNeeded(toolCall);
}
break;
}
case "response.completed":
usageData = event.response?.usage;
outputItems = (event.response?.output || []);
if (Array.isArray(event.response?.output)) {
event.response.output.forEach((item, outputIndex) => {
if (!isFunctionCallOutputItem(item)) {
return;
}
const toolCall = getOrCreateToolCall(outputIndex, item);
if (item.id) {
toolCallIdsByItemId.set(item.id, toolCall.id);
}
if (typeof item.arguments === "string") {
toolCall.arguments = item.arguments;
}
});
}
if (!fullMessage && typeof event.response?.output_text === "string") {
fullMessage = event.response.output_text;
}
break;
// Known lifecycle events — no action needed
case "response.created":
case "response.in_progress":
case "response.content_part.added":
case "response.content_part.done":
case "response.output_text.done":
break;
case "error":
case "response.error":
throw new Error(event.error?.message || "OpenAI Responses streaming error");
case "response.failed":
throw new Error(event.response?.error?.message || "OpenAI Responses request failed");
default:
if (process.env.DEBUG_GRAPHLIT_SDK_STREAMING) {
console.log(`⚠️ [OpenAI Responses] Unhandled event type: ${event.type}`, JSON.stringify(event, null, 2));
}
break;
}
}
const orderedToolCalls = Array.from(toolCallsByIndex.entries())
.sort(([left], [right]) => left - right)
.map(([, toolCall]) => toolCall);
for (const toolCall of orderedToolCalls) {
emitToolCallParsedIfNeeded(toolCall);
}
onEvent({
type: "complete",
tokens: tokenCount,
});
return {
message: fullMessage,
toolCalls: orderedToolCalls,
usage: usageData,
outputItems,
};
}
catch (error) {
const errorMessage = error.message || error.toString();
if (isRateLimitError(error)) {
throw new ProviderError(`OpenAI rate limit exceeded: ${errorMessage}`, {
provider: "openai",
statusCode: 429,
retryable: true,
requestId: extractRequestId(error),
cause: error,
});
}
if (isNetworkError(error)) {
throw new ProviderError(`OpenAI network error: ${errorMessage}`, {
provider: "openai",
statusCode: error.status ?? 0,
retryable: true,
requestId: extractRequestId(error),
cause: error,
});
}
if (isRetryableServerError(error)) {
throw new ProviderError(`OpenAI server error: ${errorMessage}`, {
provider: "openai",
statusCode: error.status ?? error.statusCode ?? 500,
retryable: true,
requestId: extractRequestId(error),
cause: error,
});
}
throw new ProviderError(`OpenAI error: ${errorMessage}`, {
provider: "openai",
statusCode: error.status ?? error.statusCode ?? 400,
retryable: false,
requestId: extractRequestId(error),
cause: error,
});
}
}