@unified-llm/core
Version:
Unified LLM interface (in-memory).
552 lines • 26 kB
JavaScript
// src/utils/responses-client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { accumulateUsage, calculateUsageCost, } from "../../utils/token-utils.js";
import { createMcpTransport, sanitizeToolCallResult } from "../../utils/mcp-utils.js";
import { createDefaultClock } from "../../utils/timing.js";
export const NOOP_LOGGER = {
debug: () => { },
info: () => { },
warn: () => { },
error: () => { },
child: () => NOOP_LOGGER,
};
function toModelSafeError(err) {
if (err instanceof Error) {
return { name: err.name, message: err.message };
}
return { name: "Error", message: String(err) };
}
/**
* Runs an async task while measuring duration and logging success or failure.
* Logs include duration_ms and, on failure, a normalized error object.
*/
async function logTimed(logger, clock, event, meta, fn, level = "info") {
const start = clock.nowMs();
try {
const result = await fn();
const end = clock.nowMs();
const durationMs = Math.max(0, end - start);
logger[level](event, {
...meta,
ok: true,
duration_ms: durationMs,
...(clock.nowEpochMs ? { timestamp_epoch_ms: clock.nowEpochMs() } : {}),
});
return result;
}
catch (err) {
const end = clock.nowMs();
const durationMs = Math.max(0, end - start);
logger.error(event, {
...meta,
ok: false,
duration_ms: durationMs,
error: toModelSafeError(err),
...(clock.nowEpochMs ? { timestamp_epoch_ms: clock.nowEpochMs() } : {}),
});
throw err;
}
}
// ---------------------------------------------------------
// Responses API を叩くヘルパー
// ---------------------------------------------------------
async function callResponsesAPI(body, opts) {
var _a, _b, _c;
const { apiKey, onProgress, signal } = opts;
if (onProgress) {
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new Error("callResponsesAPI streaming requires body to be a non-array object.");
}
}
const requestBody = onProgress
? { ...body, stream: true }
: body;
const res = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`OpenAI Responses API error: ${res.status} ${res.statusText}\n${text}`);
}
if (!onProgress) {
return res.json();
}
if (!res.body) {
throw new Error("OpenAI Responses API error: missing response body.");
}
// fetch() のレスポンスボディ(ReadableStream)を、手動で読み取るための Reader を取得
const reader = res.body.getReader();
// バイト列(Uint8Array) → 文字列 へデコードするためのデコーダ
// { stream: true } を使うことで、チャンク境界で文字が途中になっても崩れにくくなる
const decoder = new TextDecoder();
// SSE はネットワーク都合で「イベント途中まで」しか届かないことがあるため、
// 受信した文字列をここに溜めて、イベント境界(空行)まで揃ったら切り出して処理する
let buffer = "";
// OpenAI の SSE ストリーム上で "response.completed" を受け取ったら、ここに最終 response を保持する
let completedResponse = null;
// onProgress は呼び出し側で好きに実装できるが、
// ここで例外が起きてもストリーム処理全体が止まらないように握りつぶす
const emitProgress = (event) => {
try {
onProgress(event);
}
catch (_a) {
// ignore progress sink errors
}
};
// SSE の 1イベントは「空行」で区切られる(\n\n または \r\n\r\n)
// buffer の中から「最初のイベント境界」の位置を探す
const findBoundaryIndex = (value) => {
const lfIndex = value.indexOf("\n\n"); // LF 区切り
const crlfIndex = value.indexOf("\r\n\r\n"); // CRLF 区切り
if (lfIndex === -1)
return crlfIndex;
if (crlfIndex === -1)
return lfIndex;
return Math.min(lfIndex, crlfIndex);
};
try {
// ストリームが終わるまで読み続ける
while (true) {
// ReadableStream から次のチャンクを読む(任意サイズ・任意タイミングで届く)
const { value, done } = await reader.read();
if (done)
break;
// 受信したバイト列を文字列にデコードして buffer に追記
buffer += decoder.decode(value, { stream: true });
// buffer 内に SSE のイベント境界(空行)がある限り、イベント単位に切り出して処理する
let boundaryIndex = findBoundaryIndex(buffer);
while (boundaryIndex !== -1) {
// 境界が \r\n\r\n なら 4文字、\n\n なら 2文字ぶん進めて buffer を消費する
const boundaryLength = buffer.startsWith("\r\n\r\n", boundaryIndex)
? 4
: 2;
// 1イベントぶんの生テキスト(SSE の 1メッセージ)
const rawEvent = buffer.slice(0, boundaryIndex);
// 処理済みイベント + 区切り(空行)を buffer から取り除く
buffer = buffer.slice(boundaryIndex + boundaryLength);
// 次のイベント境界を探す(1チャンクに複数イベントが含まれることがある)
boundaryIndex = findBoundaryIndex(buffer);
// 空イベント(空行だけ等)は無視
if (!rawEvent.trim()) {
continue;
}
// SSE は "data:" 行が複数行になる場合がある。
// ここでは "data:" 行だけを抜き出して、"data:" プレフィックスを剥がして連結する
const dataLines = rawEvent
.split(/\r\n|\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.replace(/^data:\s?/, ""));
// 連結した data を JSON 文字列として扱う(OpenAI のイベントは JSON)
const data = dataLines.join("\n").trim();
// 空、または SSE の終端シグナル "[DONE]" は無視
if (!data || data === "[DONE]") {
continue;
}
// JSON として解釈できない data は捨てて次へ(堅牢性優先)
let event;
try {
event = JSON.parse(data);
}
catch (_d) {
continue;
}
// 呼び出し元へイベントを通知(UI更新やログ等)
emitProgress(event);
// "response.completed" が来たら、この呼び出しの最終 response が確定
if ((event === null || event === void 0 ? void 0 : event.type) === "response.completed") {
completedResponse = (_a = event.response) !== null && _a !== void 0 ? _a : null;
break;
}
// ストリームが "incomplete" で終わった場合は例外扱いにする
if ((event === null || event === void 0 ? void 0 : event.type) === "response.incomplete") {
const reason = (_c = (_b = event === null || event === void 0 ? void 0 : event.response) === null || _b === void 0 ? void 0 : _b.incomplete_details) === null || _c === void 0 ? void 0 : _c.reason;
throw new Error(`OpenAI Responses API incomplete: ${reason !== null && reason !== void 0 ? reason : "unknown reason"}`);
}
// failed / error は即例外扱いにする(上位でログ・復旧等)
if ((event === null || event === void 0 ? void 0 : event.type) === "response.failed" || (event === null || event === void 0 ? void 0 : event.type) === "error") {
throw new Error(`OpenAI Responses API error event: ${JSON.stringify(event)}`);
}
}
// completed を受け取ったら outer ループも抜ける(これ以上読む必要がない)
if (completedResponse) {
break;
}
}
}
finally {
// 途中で break / throw した場合でも Reader をキャンセルして確実にリソース解放する
await reader.cancel().catch(() => { });
}
if (!completedResponse) {
throw new Error("OpenAI Responses API error: response.completed not received.");
}
return completedResponse;
}
// ---------------------------------------------------------
// Responses API の出力から最終テキストを取り出すヘルパー
// ---------------------------------------------------------
function getOutputText(response) {
// SDK ラッパが output_text を付けてくれているケース
if (typeof (response === null || response === void 0 ? void 0 : response.output_text) === "string") {
return response.output_text;
}
// 生 output から message -> content -> output_text を順序通りに連結する
if (Array.isArray(response === null || response === void 0 ? void 0 : response.output)) {
const messageTexts = [];
for (const item of response.output) {
if (item.type === "message" && Array.isArray(item.content)) {
const parts = item.content
.filter((c) => (c === null || c === void 0 ? void 0 : c.type) === "output_text" && typeof c.text === "string")
.map((c) => c.text);
if (parts.length > 0) {
messageTexts.push(parts.join(""));
}
}
}
if (messageTexts.length > 0) {
return messageTexts.join("\n");
}
}
throw new Error("No text output found in Responses API result.");
}
// ---------------------------------------------------------
// JSONパースヘルパー
// ---------------------------------------------------------
function safeJsonParse(value, fallback) {
if (!value)
return fallback;
try {
return JSON.parse(value);
}
catch (_a) {
return fallback;
}
}
/**
* MCP クライアントを接続し、Responses API向けの tools 定義を組み立てる。
* @returns mcpClients: 接続済みのMCPクライアント配列。
* @returns openAiTools: LLMに渡すfunctionツール定義。
* MCPのツール一覧はSDK独自の形式のため、そのままResponses APIに渡すとtoolとして解釈されず、
* tool_choiceでの呼び出しができない(結果としてツール実行が発生しない)。
*/
async function setupMcpClientsAndTools(options) {
const { mcpServers } = options;
const mcpClients = [];
const openAiTools = [];
const toolNameToClient = new Map();
try {
for (const server of mcpServers) {
const transport = createMcpTransport(server);
const mcpClient = new Client({ name: "local-mcp-responses-client", version: "1.0.0" }, { capabilities: {} });
await mcpClient.connect(transport, {});
mcpClients.push(mcpClient);
const toolsList = await mcpClient.listTools();
const allowedTools = toolsList.tools.filter((tool) => { var _a, _b; return (_b = (_a = server.allowedTools) === null || _a === void 0 ? void 0 : _a.includes(tool.name)) !== null && _b !== void 0 ? _b : true; });
for (const tool of allowedTools) {
if (toolNameToClient.has(tool.name)) {
throw new Error(`Tool name collision across MCP servers: ${tool.name}`);
}
toolNameToClient.set(tool.name, mcpClient);
openAiTools.push({
type: "function",
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
});
}
}
}
catch (error) {
await Promise.allSettled(mcpClients.map(async (client) => {
try {
if (typeof client.close === "function") {
await client.close();
}
}
catch (_a) {
// ignore
}
}));
throw error;
}
return { mcpClients, openAiTools, toolNameToClient };
}
/**
* Responses API の output から function_call を抽出する。
* 呼び出しがなければログを出して空配列を返す。
*
* @param response - Responses API のレスポンス
* @returns function_call の配列
*/
function getFunctionCallsFromResponse(response) {
const output = Array.isArray(response === null || response === void 0 ? void 0 : response.output) ? response.output : [];
const functionCalls = output.filter((item) => (item === null || item === void 0 ? void 0 : item.type) === "function_call");
return functionCalls;
}
/**
* Responses API の function_call を実行し、tool output 配列を作成する。
* ローカルツールが一致すればそれを優先し、なければ MCP ツールを呼び出す。
* 返却値は Responses API に渡す `function_call_output` 形式。
*
* @param functionCalls - モデルが要求した function_call の配列
* @param toolNameToClient - MCP ツール名とクライアントの対応表
* @param localToolHandlers - ローカルツール名とハンドラの対応表
* @returns tool output の配列
*/
async function callFunctionTools(functionCalls, toolNameToClient, localToolHandlers, options) {
var _a, _b;
const logger = (_a = options === null || options === void 0 ? void 0 : options.logger) !== null && _a !== void 0 ? _a : NOOP_LOGGER;
const clock = (_b = options === null || options === void 0 ? void 0 : options.clock) !== null && _b !== void 0 ? _b : createDefaultClock();
const outputTasks = functionCalls.map(async (fc) => {
var _a;
if (!fc.call_id) {
throw new Error(`Missing call_id for function call: ${fc.name}`);
}
try {
const args = typeof fc.arguments === "string"
? safeJsonParse(fc.arguments, {})
: (_a = fc.arguments) !== null && _a !== void 0 ? _a : {};
const localHandler = localToolHandlers === null || localToolHandlers === void 0 ? void 0 : localToolHandlers.get(fc.name);
if (localHandler) {
logger.info("tool.call.request", {
tool: fc.name,
call_id: fc.call_id,
kind: "local",
args: JSON.stringify(args),
});
const result = await logTimed(logger, clock, "tool.call.completed", {
tool: fc.name,
call_id: fc.call_id,
kind: "local",
args_keys_count: Object.keys(args).length,
}, async () => localHandler(args), "info");
const outputText = typeof result === "string"
? result
: JSON.stringify(result !== null && result !== void 0 ? result : { ok: true });
logger.info("tool.call.result", {
tool: fc.name,
call_id: fc.call_id,
kind: "local",
result: outputText,
});
return {
type: "function_call_output",
call_id: fc.call_id,
output: outputText,
};
}
const mcpClient = toolNameToClient.get(fc.name);
if (!mcpClient) {
throw new Error(`No MCP client registered for tool: ${fc.name}`);
}
logger.info("tool.call.request", {
tool: fc.name,
call_id: fc.call_id,
kind: "mcp",
args: JSON.stringify(args),
});
const result = await logTimed(logger, clock, "tool.call.completed", {
tool: fc.name,
call_id: fc.call_id,
kind: "mcp",
args_keys_count: Object.keys(args).length,
}, async () => mcpClient.callTool({ name: fc.name, arguments: args }), "info");
const { sanitizedResult } = sanitizeToolCallResult(result);
logger.info("tool.call.result", {
tool: fc.name,
call_id: fc.call_id,
kind: "mcp",
result: JSON.stringify(sanitizedResult),
});
return {
call_id: fc.call_id,
type: "function_call_output",
output: JSON.stringify(sanitizedResult),
};
}
catch (err) {
logger.error("tool.call.failed", {
tool: fc.name,
call_id: fc.call_id,
error: toModelSafeError(err),
});
return {
type: "function_call_output",
call_id: fc.call_id,
output: JSON.stringify({ ok: false, error: toModelSafeError(err) }),
};
}
});
return Promise.all(outputTasks);
}
/**
* Responses API の tool calling loop を実行する。
* MCP ツール/ローカルツールの呼び出しを処理し、
* `previous_response_id` で連続呼び出しを継続する。
* ループ回数は環境変数 `RESPONSES_MAX_LOOPS` で指定し、未設定時は 10 回。
*
* @param options - 入力、ツール定義、使用量の集計先、Structured Output 等の設定
* @returns Responses API の最終レスポンス
*/
async function runToolCallingLoop(options) {
const { baseInput, openAiTools, toolNameToClient, localToolHandlers, usageTotals, model, apiKey, thread, structuredOutput, temperature, truncation, onProgress, signal, logger, clock, } = options;
const maxLoops = Number(process.env.RESPONSES_MAX_LOOPS) || 10;
const buildRequestBody = (input, previousResponseId) => ({
model,
input,
previous_response_id: previousResponseId,
tools: openAiTools,
tool_choice: "auto",
parallel_tool_calls: true,
...(structuredOutput ? { text: structuredOutput } : {}),
...(truncation ? { truncation } : {}),
...(temperature !== undefined ? { temperature } : {}),
});
const requestContext = thread
? thread.buildRequestContextForResponsesAPI(baseInput)
: { input: baseInput };
let response = await logTimed(logger, clock, "llm.step.completed", {
model,
previous_response_id: requestContext.previous_response_id,
}, async () => callResponsesAPI(buildRequestBody(requestContext.input, requestContext.previous_response_id), { apiKey, onProgress, signal }), "info");
logger.debug("responses.api.result", {
responseJson: JSON.stringify(response, null, 2),
});
accumulateUsage(usageTotals, response === null || response === void 0 ? void 0 : response.usage);
thread === null || thread === void 0 ? void 0 : thread.updatePreviousResponseId(response === null || response === void 0 ? void 0 : response.id);
if (thread && Array.isArray(response === null || response === void 0 ? void 0 : response.output)) {
thread.appendToHistory(response.output);
}
let lastResponse = response;
for (let loop = 0; loop < maxLoops; loop++) {
const functionCalls = getFunctionCallsFromResponse(response);
if (functionCalls.length === 0) {
break;
}
const functionOutputs = await callFunctionTools(functionCalls, toolNameToClient, localToolHandlers, { logger, clock });
thread === null || thread === void 0 ? void 0 : thread.appendToHistory(functionOutputs);
response = await logTimed(logger, clock, "llm.step.completed", {
model,
previous_response_id: response.id,
}, async () => callResponsesAPI(buildRequestBody(functionOutputs, response.id), { apiKey, onProgress, signal }), "info");
logger.debug("responses.api.result", {
responseJson: JSON.stringify(response, null, 2),
});
accumulateUsage(usageTotals, response === null || response === void 0 ? void 0 : response.usage);
thread === null || thread === void 0 ? void 0 : thread.updatePreviousResponseId(response === null || response === void 0 ? void 0 : response.id);
if (thread && Array.isArray(response === null || response === void 0 ? void 0 : response.output)) {
thread.appendToHistory(response.output);
}
lastResponse = response;
}
return lastResponse;
}
/**
* Responses API を用いて、MCP ツールとローカルツールの呼び出しを含む
* 反復処理(tool calling loop)を実行する。
* `previous_response_id` を使って会話履歴を連結し、最終出力を取得する。
* ループ回数は環境変数 `RESPONSES_MAX_LOOPS` で指定し、未設定時は 10 回。
*
* @param options - モデル設定、入力、MCP/ローカルツール、Structured Output 等の実行オプション
* @returns Responses API の最終レスポンス
*/
export async function callResponsesApiAgent(options) {
var _a, _b;
const { mcpServers, model, apiKey, baseInput, thread, structuredOutput, config, localTools, onProgress, signal, logger: loggerOption, clock: clockOption, } = options;
const logger = loggerOption !== null && loggerOption !== void 0 ? loggerOption : NOOP_LOGGER;
const clock = clockOption !== null && clockOption !== void 0 ? clockOption : createDefaultClock();
const resolvedApiKey = apiKey !== null && apiKey !== void 0 ? apiKey : process.env.OPENAI_API_KEY;
if (!resolvedApiKey) {
throw new Error("OPENAI_API_KEY is missing.");
}
const temperature = (_a = config === null || config === void 0 ? void 0 : config.temperature) !== null && _a !== void 0 ? _a : undefined;
const truncation = (_b = config === null || config === void 0 ? void 0 : config.truncation) !== null && _b !== void 0 ? _b : undefined;
// --------------------------------------------------
// 1. MCP クライアントの準備(Streamable HTTP 接続)
// --------------------------------------------------
let mcpClients = [];
let openAiTools = [];
let toolNameToClient = new Map();
// ---------------------------------------------------------
// 2. Responses API へのループ呼び出し(previous_response_id 使用)
// ---------------------------------------------------------
const usageTotals = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
cachedInputTokens: 0,
};
let lastResponse;
try {
({ mcpClients, openAiTools, toolNameToClient } =
await setupMcpClientsAndTools({
mcpServers,
}));
if (localTools) {
const seenLocalToolNames = new Set();
for (const tool of localTools.tools) {
if (toolNameToClient.has(tool.name)) {
throw new Error(`Tool name collision between MCP and local tools: ${tool.name}`);
}
if (seenLocalToolNames.has(tool.name)) {
throw new Error(`Duplicate local tool name: ${tool.name}`);
}
if (!localTools.handlers.has(tool.name)) {
throw new Error(`Missing local tool handler: ${tool.name}`);
}
seenLocalToolNames.add(tool.name);
}
openAiTools.push(...localTools.tools);
}
lastResponse = await runToolCallingLoop({
baseInput,
openAiTools,
toolNameToClient,
localToolHandlers: localTools === null || localTools === void 0 ? void 0 : localTools.handlers,
usageTotals,
model,
apiKey: resolvedApiKey,
thread,
structuredOutput,
truncation,
temperature,
onProgress,
signal,
logger,
clock,
});
if (!lastResponse) {
throw new Error("No response from OpenAI Responses API.");
}
// ---------------------------------------------------------
// 3. 最終的な JSON を取得して表示
// ---------------------------------------------------------
const outputText = getOutputText(lastResponse);
logger.info("responses.output_text", { outputText });
const result = structuredOutput ? JSON.parse(outputText) : outputText;
const estimatedCostJpy = calculateUsageCost(usageTotals, model, {
currencyMultiplier: 160,
});
return { result, usageTotals, estimatedCostJpy };
}
finally {
await Promise.allSettled(mcpClients.map(async (client) => {
try {
if (typeof client.close === "function") {
await client.close();
}
}
catch (_a) {
// ignore
}
}));
}
}
//# sourceMappingURL=responses-api-agent.js.map