メインコンテンツへスキップ

Chat CompletionsからResponses APIへ関数呼び出しを移行:call_idと承認の実装

4 分で読めますAPIガイド

item IDではなくcall_idを返し、モデルの提案を承認済みscopeと照合し、replayでも業務処理が一度しか走らないことまで検証します。

Responses APIの関数呼び出し移行図。function_callをアプリが検証・実行し、同じcall_idのfunction_call_outputを返して最終回答を得る。冪等台帳で二重実行を防止

Chat Completionsのfunction callingをResponses APIへ移す際、最も危険な取り違えはfunction_callのitem idを結果へ返すことです。必要なのは元のcall_idです。モデルの提案をアプリが検証・承認して実行し、同じcall_idfunction_call_outputを返して初めて最終回答へ進めます。

最初のstagingテストでは、次の三点を同時に証明してください。

  • fc_...形式のitem idではなく、call_...形式のcall_idで結果が対応する。
  • 承認済みscopeと異なる引数は、モデルが提案しても実行されない。
  • 同じcallをreplayしても、予約やDB writeの件数が増えない。

このページでは実システムを変更せず、ローカルの会議室予約fixtureで完全ループとnegative testを実行します。OpenAIのFunction callingガイドが現在のtool loopと相関フィールドの事実ownerです。

60秒で確認する二つのID

Responsesの出力には、たとえば次のようなitemが含まれます。

json
{ "id": "fc_01ABC", "type": "function_call", "call_id": "call_01XYZ", "name": "reserve_meeting_room", "arguments": "{\"room\":\"tokyo-3f-a\",\"date\":\"2026-07-30\",\"start_time\":\"14:00\"}" }

idは出力item自身の識別子です。ツール結果の相関には使用しません。アプリが返す入力は次の形です。

json
{ "type": "function_call_output", "call_id": "call_01XYZ", "output": "{\"ok\":true,\"reservation_id\":\"RSV-1001\"}" }

Chat Completionsではmessage.tool_callsrole: "tool"tool_call_idを扱いました。Responsesではresponse.outputtypeごとに走査し、各function_callへ一つのfunction_call_outputを返します。配列番号はidentityではなく、output_textはtool dispatch用の情報源ではありません。

エンドポイント、messages → input、tool定義のフラット化はOpenAIのResponses移行ガイドで確認できます。

schemaを移す前に承認境界を決める

会議室予約、メール送信、決済、クラウド操作では、function callは権限付与ではありません。モデルが出した引数をそのまま実行せず、アプリが保持する信頼済みの承認情報と比較します。

この例の承認ルールは次の通りです。

  1. 承認IDはモデル引数から受け取らず、ログイン済みworkflowのサーバー状態から取得する。
  2. 承認scopeには部屋、日付、開始時刻を固定する。
  3. function callの引数がscopeと一つでも違えば実行しない。
  4. 承認済みでも、業務冪等キーがすでに成功していれば保存済み結果を返す。

Responsesのfunction toolではnamedescriptionparameterstools[]直下に置きます。strict: trueなら各objectのadditionalProperties: falseと、全propertyのrequired指定が必要です。任意値はnullable型で設計します。単にschema準拠JSONが欲しいだけならfunctionを作らず、Structured Outputsとfunction callingの選び方で契約を分けてください。

承認ゲート付きの完全JavaScriptループ

次のコードは予約をローカルMapへ保存するだけで、実際の会議室システムには接続しません。通常実行はResponses loop、RUN_REPLAY_TEST=1はAPIを呼ばないreplay negative testです。

responses-room-booking.mjsとして保存してください。

javascript
import assert from "node:assert/strict"; import OpenAI from "openai"; const tools = [ { type: "function", name: "reserve_meeting_room", description: "承認済みscope内で会議室を予約する", strict: true, parameters: { type: "object", properties: { room: { type: "string", enum: ["tokyo-3f-a", "tokyo-3f-b"], }, date: { type: "string", description: "YYYY-MM-DD", }, start_time: { type: "string", description: "HH:MM", }, }, required: ["room", "date", "start_time"], additionalProperties: false, }, }, ]; // 実運用では認証済みworkflowから取得する。モデル引数には含めない。 const trustedApproval = Object.freeze({ approval_id: "APR-2026-071", approved: true, scope: { room: "tokyo-3f-a", date: "2026-07-30", start_time: "14:00", }, }); const handlers = new Map([ ["reserve_meeting_room", reserveMeetingRoom], ]); const callLedger = new Map(); const businessLedger = new Map(); let reservationWrites = 0; function parseAndAuthorize(call) { const handler = handlers.get(call.name); if (!handler) { throw new Error(`許可されていないtool: ${call.name}`); } let args; try { args = JSON.parse(call.arguments); } catch { throw new Error("argumentsが正しいJSONではありません"); } if ( !["tokyo-3f-a", "tokyo-3f-b"].includes(args.room) || !/^\d{4}-\d{2}-\d{2}$/.test(args.date) || !/^\d{2}:\d{2}$/.test(args.start_time) ) { throw new Error("argumentsがアプリ検証に失敗しました"); } const approved = trustedApproval.approved; const sameScope = args.room === trustedApproval.scope.room && args.date === trustedApproval.scope.date && args.start_time === trustedApproval.scope.start_time; if (!approved || !sameScope) { throw new Error("承認済みscopeと一致しません"); } return { handler, args }; } async function reserveMeetingRoom(args) { const businessKey = [ args.room, args.date, args.start_time, trustedApproval.approval_id, ].join(":"); if (businessLedger.has(businessKey)) { return businessLedger.get(businessKey); } reservationWrites += 1; const result = { ok: true, reservation_id: `RSV-${1000 + reservationWrites}`, room: args.room, date: args.date, start_time: args.start_time, }; businessLedger.set(businessKey, result); return result; } async function executeOnce(responseId, call) { const callKey = `${responseId}:${call.call_id}`; if (callLedger.has(callKey)) { return callLedger.get(callKey); } const pending = (async () => { try { const { handler, args } = parseAndAuthorize(call); return { ok: true, data: await handler(args) }; } catch (error) { return { ok: false, code: "execution_blocked", message: String(error.message ?? error), }; } })(); // 実行完了前に登録し、同時replayも同じPromiseへ合流させる。 callLedger.set(callKey, pending); return pending; } if (process.env.RUN_REPLAY_TEST === "1") { const replayCall = { type: "function_call", call_id: "call_replay_001", name: "reserve_meeting_room", arguments: JSON.stringify(trustedApproval.scope), }; const first = await executeOnce("resp_fixture_001", replayCall); const replay = await executeOnce("resp_fixture_001", replayCall); assert.deepEqual(replay, first); assert.equal(reservationWrites, 1); console.log("PASS: replayしても予約writeは1件"); process.exit(0); } const model = process.env.OPENAI_MODEL; if (!model) { throw new Error("OPENAI_MODELを設定してください"); } const client = new OpenAI(); const instructions = [ "あなたは社内会議室の予約担当です。", "予約には reserve_meeting_room を使ってください。", "アプリが拒否した場合は予約済みと主張しないでください。", ].join("\n"); const input = [ { role: "user", content: "7月30日14時から東京3階Aを予約してください。", }, ]; let finalText = ""; for (let round = 0; round < 6; round += 1) { const response = await client.responses.create({ model, instructions, input, tools, parallel_tool_calls: false, store: false, include: ["reasoning.encrypted_content"], }); if (response.status === "incomplete") { const detail = JSON.stringify(response.incomplete_details ?? {}); throw new Error(`incomplete responseのため安全停止: ${detail}`); } input.push(...response.output); const calls = response.output.filter( (item) => item.type === "function_call", ); if (calls.length === 0) { if (!response.output_text) { throw new Error("callも完全な最終テキストもありません"); } finalText = response.output_text; break; } // 並列を無効にしていても、返されたcallは全件処理する。 for (const call of calls) { const result = await executeOnce(response.id, call); input.push({ type: "function_call_output", call_id: call.call_id, output: JSON.stringify(result), }); } } if (!finalText) { throw new Error("最大roundまでに最終回答へ到達しませんでした"); } console.log(finalText);

ローカルのreplay testを先に実行します。

bash
npm install openai RUN_REPLAY_TEST=1 node responses-room-booking.mjs

期待する結果はPASSとwrite件数1です。次に、承認scopeの部屋をtokyo-3f-bへ改変したfixtureを追加し、execution_blockedとなりwrite件数が増えないことを確認してください。

OpenAIを使うloopは、認証済み環境で次のように実行します。

bash
OPENAI_MODEL="検証済みのfunction対応モデルID" node responses-room-booking.mjs

コードは現在の公式フィールドに合わせていますが、この記事は実アカウントの観測結果を捏造しません。利用するモデル、組織、データ方針でstaging traceを取得してください。

replay testが守る範囲

response.id + call_idのcall ledgerは、同じprovider callの重複実行を止めます。しかし、モデル要求全体を再送すると新しいresponseとcall_idが発行される可能性があります。その場合でも同じ予約を止めるのが、部屋・日時・信頼済み承認IDから作るbusiness ledgerです。

実運用では二つのMapをDBへ置き換え、実行前に一意制約付きのpendingを確保します。timeoutは未実行の証明ではありません。worker再起動後もsucceeded結果を再利用し、承認scope変更時は新しい承認IDを要求します。

複数のfunction_callが返る構成では、全件をtypeで抽出し、結果を各call_idへ返します。初期移行はparallel_tool_calls: falseが安全です。並列化するなら、互いに独立した読み取りに限定するか、同じ部屋・時間帯へのwriteをbusiness keyで直列化してください。

状態継続と復旧を選ぶ

状態方式はコード行数ではなく、事故時に何を復元できるかで決めます。

  • 手動typed-item replaystore: false、独自監査、履歴圧縮が必要な場合。response.outputのmessage、function call、必要なreasoning itemを順序どおり保持します。
  • previous_response_id:同じOpenAIサービス上で簡潔に継続する場合。conversationと併用せず、トップレベルinstructionsは毎回送ります。過去入力tokenも課金対象です。
  • Conversation:複数jobや端末で長期状態を共有する場合。保持、権限、削除、障害復旧を先に設計します。

Responsesは既定でresponseを保存します。保持制約がある場合はstore: falseと組織のdata controlsを確認します。現在の詳細はConversation stateガイドを参照してください。

incompleteは「少しテキストがあるから成功」と扱いません。incomplete_details、response ID、保存済みtool resultを記録し、未実行callは実行せず停止します。再開時は採用した状態方式から完全なitem列を復元し、成功済みの業務処理はledgerから再利用します。

streamingではdoneより前に承認しない

Responsesの関数引数はresponse.function_call_arguments.deltaで分割され、response.function_call_arguments.doneで確定します。deltaの途中ではJSONも承認scopeも不完全なので、予約処理を呼んではいけません。

response.output_item.addedでcall用bufferを作り、output_indexまたはitem identityごとにdeltaを蓄積します。done後にだけJSON parse、schema validation、approval check、business ledgerの順で処理します。

最終回答のstreamが切れても、予約自体を再実行しません。保存したtool resultを状態方式に従って返し直します。公式のStreaming Responsesガイドに合わせ、テキストevent、引数event、error、completionを別々にテストしてください。

stagingで落とすべきnegative cases

以下は成功テストより先に固定します。

  • item idfunction_call_output.call_idへ誤投入するとテストが失敗する。
  • 同一response/callの二回replayでもwrite件数は1。
  • 新しいcall IDでも同じ業務scopeと承認IDなら予約は増えない。
  • 未承認、期限切れ承認、またはscope外の部屋・日時は実行前に拒否される。
  • 壊れたJSON、未知tool、tool exceptionは構造化結果となり、予約済みとは表示されない。
  • incomplete responseは部分テキストを採用せず、安全停止する。
  • 二つのcallを返すfixtureは全件が個別のcall_idで完了するか、並列無効の制約を確認する。
  • state継続後もinstructionsと承認境界が失われない。
  • streamingはargument done前にwriteしない。
  • traceにはresponse ID、call ID、approval ID、validation、idempotency、tool statusだけを残し、API keyや機密引数を記録しない。

releaseとrollback

まず社内利用者だけに、ローカルfixtureと同じ一部屋・一時間のテスト枠を開放します。旧Chat adapterと新Responses adapterへ同じ入力を流し、引数、承認判定、write件数、tool result、最終回答を比較します。

プロトコル移行と同時にparallelやstreamingを有効にしません。一つずつfeature flagで変更し、unmatched call_id、承認外実行、duplicate write、incomplete誤受理のどれかが一件でも出たら新経路を停止します。

rollbackはモデル経路だけをChat Completionsへ戻します。すでに成功した予約を再送しないよう、business ledgerは新旧adapterで共有してください。

API keyや最初のResponses requestが未完ならOpenAI API Keyの準備手順、project/organizationの所有関係はOrganization IDの整理、quota問題はquota exceededの切り分けで先に解決します。

最後に、RUN_REPLAY_TEST=1、scope改変テスト、stagingの実Responses loopの順で証拠を残します。三つが通り、feature flagで安全に戻せてから実業務の予約backendへ接続してください。

#OpenAI API#Responses API#Function Calling#JavaScript
Share: