Codex に構造化出力を頼んだ仕事が、生成を始めてから約3秒で「プロセスの失敗」として落ちました。Codex に仕事を分担させる仕組みを技術検証していたときのことです。すぐに落ちたので認証やネットワークを疑いたくなりますが、返っていたのは HTTP 400 の invalid_json_schema で、原因は私たちが送った JSON Schema の properties と required の食い違いでした。

結論

  • 私たちが送った schema で candidates.items.properties.selectionRationale を required に入れていなかったため、Structured Outputs の受け口が HTTP 400 invalid_json_schema を返していました。
  • 認証、利用量の上限、ワーカーの死活(heartbeat)を見ても、おかしな所はありませんでした。認証やネットワークを直しても解けない種類の失敗です。
  • 正規の schema で欠けていた required を足しました。あわせて、API へ送る複製だけを再帰的にたどり、すべての object で全 properties を required に入れる形に直しました。
  • 保存済みの成果物を手元で検証するときは正規の schema のまま使い、古い版との互換のための optional を残しました。

試した環境

試作した仕組みでは、常駐するワーカーが Codex の CLI を動かし、OpenAI の Structured Outputs で、JSON Schema に合う JSON を返させていました。この記事は、当時の CLI を通した構造化出力の話です。

Codex に頼んだのは、保存済みの調査結果 16 件(そのうち 12 件は候補ごとの結果)を入力にして、1本のまとめを作らせる統合の仕事です。

何が起きたか

統合の仕事を始めると、生成に入った直後に失敗し、私たちの仕組みはこれを「プロセスの失敗」に分類しました。失敗は約3秒で決まり、成果物は残らず、検索も追加の試行もしていません。

そのときの Codex のセッションに絞って調べると、Structured Outputs の受け口が HTTP 400 invalid_json_schema を返していました。送った JSON Schema で、candidates.items.properties.selectionRationale を required に入れていなかったのです。candidates という配列の要素の object で、properties に selectionRationale(候補を選んだ理由)を宣言しながら、required にはその名前を書いていませんでした。

認証でもネットワークでもなかった

開始直後に落ちて、プロセスの失敗という大きな分類で記録される。この見え方は、認証切れやネットワークの障害とよく似ています。ただ、当時の状態を並べると、おかしかったのは送った schema だけでした。

見た所当時の状態
認証正常
利用量の上限正常
ワーカーの死活(heartbeat)正常
送った JSON SchemaHTTP 400 invalid_json_schema

この種類の失敗は、認証やネットワークを直しても解けません。プロセスの失敗という分類だけで判断せず、API が返した状態コードとエラーの種類まで見る必要があります。

なぜ properties と required をそろえる必要があったか

私たちの理解では、Codex が使うのは、OpenAI の Structured Outputs が受け付ける JSON Schema の一部です。その範囲では、object に宣言した properties がすべて required に入っている必要があります。同じ範囲では、長さに上限のある配列やスカラーの値の const は受け付けますが、配列の値を持つ const は断ります。

配列の値の const については、以前から手を打っていました。要素の制約で範囲が十分に絞れる場合に限って、送信用の schema から外していたのです。今回、そこに required をそろえる処理を書き足しました。

受付の机で、木箱の6つの仕切りのうち1つにだけ札が無く、そこに札を付け足そうとしている絵。そばの鍵とケーブルには問題がない

直し方: 送る schema と手元の schema を分ける

次の図は、直した後の schema の扱いです。正規の schema はそのまま手元の検証に使い、API へ送るときだけ複製を作って直します。

正規の schema から送信用の複製を作り、手元の検証は正規のままにする

まず、正規の schema で、selectionRationale を required に足しました。

次に、API へ渡す schema の複製だけを再帰的にたどり、object ごとに、宣言した properties の名前をすべて required に入れるようにしました。

function codexCompatibleOutputSchema(schema: JsonObject, depth = 0): JsonObject {
  if (depth > 32) throw new CodexRuntimeError("invalid_request");
  // …子の schema をたどる処理と、配列の値の const を外す処理(省略)…
  const properties = compatible["properties"];
  if (isJsonObject(properties)) {
    compatible["required"] = Object.keys(properties);
  }
  return compatible;
}

最後に、正規の schema では、過去に保存した成果物との互換に要る validationPlan、experiencePlan、visualOptions を optional のまま残しました。新しく生成する応答ではこれらも必ず埋めさせますが、手元で古い成果物を検証するときは、無くても通ります。

1つの schema で両方をまかなおうとすると、困ったことになります。送信に合わせて全部を必須にすれば古い成果物が検証で落ち、古い成果物に合わせて optional にすれば API に断られます。生成に使う契約と、過去のデータを検証する契約を分けたので、保存済みの成果物を壊さずに直せました。

あわせて、23 種類の仕事、12 の最上位の schema、63 の object を調べました。どの object も closed(宣言していないプロパティを許さない形)になっていることも確かめています。

どう確かめたか

直した後に同じ保存済みの入力 16 件でやり直すと、72,032 バイトのまとめを保存でき、selectionRationale が抜けた候補は 0 件でした。

確かめていないこと

  • いまの OpenAI の製品で同じ制約かどうかは確かめていません。また、ここで示した変換は、あらゆる JSON Schema に使える互換の変換として保証するものではありません。
  • optional の項目を機械的に必須にしても、意味が保てるとは限りません。今回の変換は、私たちの仕組みの生成の契約と、古い成果物との互換に合わせて行ったものです。
  • schema を直して生成が通っても、まとめの中身が良いとは限りません。

おわりに

構造化出力が開始直後に落ちたら、認証やネットワークを疑う前に、API が返した状態コードとエラーの種類を見てください。invalid_json_schema なら、まずは送った schema の properties と required を見比べてみてください。

※ OpenAI の Structured Outputs が受け付ける JSON Schema の範囲や Codex CLI の振る舞いは執筆時点の情報で、いまは変わっている可能性があります。