4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenAI構造化出力、OCI Responses API(grok-4.3)で全例を試した

4
Posted at

初めに

OpenAI の Structured Outputs は、モデルが与えた JSON Schema に必ず従って返事を返す機能です。

- 必須キーの欠落がない
- 不正な enum 値がでたらめに出てこない
- 検証してリトライするコードが要らない

OpenAI 公式ドキュメント(Structured model outputs)は例が豊富で、読み応えがあります。

ただ、例は OpenAI 自身のモデルを叩く前提で書かれています。

そこで今回は、この例を Oracle の OCI Generative AI(正式名称: Oracle Cloud Infrastructure Generative AI)で全部実行してみました。

API は OCI Responses API(日本語版: OCI Responses API)です。OpenAI 互換の endpoint なので、OpenAI Python SDK そのままで動きます。

モデルは xai.grok-4.3 のみを使用します。

公式の例は英語ですが、入力テキストは日本語に置き換えて実行しています。

結論

先に結論です。

全 11 例すべて成功:

- text_format (Pydantic) によるパース: 動いた
- ネストされた schema (chain of thought): 動いた
- 生の JSON Schema (text.format): 動いた
- エッジケース検出 (max_output_tokens): 動いた
- refusal 対応コード: 動いた
- ストリーミング + text_format: 動いた
- 文字列制約 (pattern / format): 動いた
- anyOf: 動いた
- $defs / $ref: 動いた
- 再帰 schema: 動いた
- JSON mode: 動いた

つまり、OpenAI 公式ドキュメントの構造化出力の例は、OCI Responses API に対してほぼそのまま流用できます。

OpenAI 直叩きとの差分は、クライアント初期化の 2 つだけです。

OpenAI 直叩き OCI Responses API
base_url デフォルト https://inference.generativeai.${region}.oci.oraclecloud.com/openai/v1
api_key OpenAI API key OCI Generative AI API key
project 不要 OCI Generative AI project の OCID
model gpt-5.6 など xai.grok-4.3 など

OCI Responses API の準備

公式ドキュメントの「Create Your First Response」と同じ形です。

from openai import OpenAI

client = OpenAI(
    base_url="https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1",
    api_key="<your-api-key>",  # OCI Generative AI API key
    project="ocid1.generativeaiproject.oc1.us-chicago-1.xxxxxxxxxxxxxxxxxxxx",
)

response = client.responses.create(
    model="xai.grok-4.3",
    input="データベースとは何かを一文で説明してください。"
)

print(response.output_text)

ポイントです。

- base_url は OpenAI 互換 endpoint を指す
  (末尾の /v1 は必須。落として 404 になります)
- project は OCI Generative AI project の OCID
  (SDK は OpenAI-Project ヘッダとして送信します)
- API key は model と同じ region で作成する
- API key 方式で OpenAI SDK が使える region は限定されています
  (Osaka / Chicago / Ashburn / Phoenix / Frankfurt / Hyderabad)

base_url について補足です。

API key のドキュメントには .../20231130/actions/v1 という base_url も記載されています。Responses API のドキュメントには .../openai/v1 と記載されています。

両方試したところ、どちらも動きました。Responses API を使う場合は .../openai/v1 を使います。

※ xAI Grok モデルは xAI がプロビジョニングした tenancy 上の OCI データセンターでホストされ、xAI が管理しています(公式ドキュメントより)。

以降の例では、clientMODEL = "xai.grok-4.3" を定義した前提で書きます。

例 1. text_format による基本抽出

公式ガイド冒頭の例です。

Pydantic モデルを text_format に渡すと、OpenAI SDK が JSON Schema 化して client.responses.parse で構造化データを取得できます。

from pydantic import BaseModel


class CalendarEvent(BaseModel):
    name: str
    date: str
    participants: list[str]


response = client.responses.parse(
    model=MODEL,
    input=[
        {"role": "system", "content": "イベントの情報を抽出してください。"},
        {
            "role": "user",
            "content": "山田太郎と佐藤花子は金曜日に科学展に行く予定です。",
        },
    ],
    text_format=CalendarEvent,
)

event = response.output_parsed
print("type:", type(event).__name__)
print(event.model_dump_json())

出力です。

type: CalendarEvent
{"name":"科学展","date":"金曜日","participants":["山田太郎","佐藤花子"]}

response.output_parsedCalendarEvent 型のインスタンスです。

例 2. Chain of thought (ネストされた schema)

公式ガイドの「Chain of thought」の例です。

ステップごとに解説させる、典型的な教育系ユースケースです。

class Step(BaseModel):
    explanation: str
    output: str


class MathReasoning(BaseModel):
    steps: list[Step]
    final_answer: str


response = client.responses.parse(
    model=MODEL,
    input=[
        {
            "role": "system",
            "content": "あなたは親切な数学のチューターです。ユーザーをステップごとに解答に導いてください。",
        },
        {"role": "user", "content": "8x + 7 = -23 を解く方法を教えてください"},
    ],
    text_format=MathReasoning,
)

math_reasoning = response.output_parsed
import json
print(json.dumps(math_reasoning.model_dump(), ensure_ascii=False, indent=2))

出力です。

{
  "steps": [
    {
      "explanation": "方程式の両辺から7を引いて、xの項だけにします。",
      "output": "8x + 7 - 7 = -23 - 7 → 8x = -30"
    },
    {
      "explanation": "両辺を8で割ってxを求めます。",
      "output": "8x ÷ 8 = -30 ÷ 8 → x = -30/8 = -15/4"
    }
  ],
  "final_answer": "x = -15/4"
}

array の items が別の Pydantic モデルでも問題ありません。

例 3. 生の JSON Schema (text.format)

Pydantic を使わず、生の JSON Schema を text.format に渡すパターンです。

response = client.responses.create(
    model=MODEL,
    input=[
        {
            "role": "system",
            "content": "あなたは親切な数学のチューターです。ユーザーをステップごとに解答に導いてください。",
        },
        {"role": "user", "content": "8x + 7 = -23 を解く方法を教えてください"},
    ],
    text={
        "format": {
            "type": "json_schema",
            "name": "math_response",
            "schema": {
                "type": "object",
                "properties": {
                    "steps": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "explanation": {"type": "string"},
                                "output": {"type": "string"},
                            },
                            "required": ["explanation", "output"],
                            "additionalProperties": False,
                        },
                    },
                    "final_answer": {"type": "string"},
                },
                "required": ["steps", "final_answer"],
                "additionalProperties": False,
            },
            "strict": True,
        },
    },
)

print("status:", response.status)
print(response.output_text)

出力です。

status: completed
{
  "steps": [
    {
      "explanation": "方程式から定数項を移動させてxの項を孤立させます。両辺から7を引きます。",
      "output": "8x + 7 - 7 = -23 - 7 → 8x = -30"
    },
    {
      "explanation": "両辺を8で割ってxを求めます。",
      "output": "8x / 8 = -30 / 8 → x = -30/8 = -15/4"
    }
  ],
  "final_answer": "x = -15/4"
}

client.responses.create だと output_parsed ではなく output_text に JSON 文字列が入ります。

注意点として、schema を初めて使うリクエストは追加のレイテンシが発生します(OpenAI 公式ドキュメントより)。同じ schema の 2 リクエスト目以降は発生しません。

例 4. エッジケースの検出 (max_output_tokens)

公式ガイドの「Handle edge cases」の例です。

出力が schema に満たない、不完全な JSON が返るケースを検出するコードです。

max_output_tokens=50 を入れて意図的に途中で切らしてみます。

try:
    response = client.responses.create(
        model=MODEL,
        input=[
            {
                "role": "system",
                "content": "あなたは親切な数学のチューターです。ユーザーをステップごとに解答に導いてください。",
            },
            {"role": "user", "content": "8x + 7 = -23 を解く方法を教えてください"},
        ],
        text={
            "format": {
                "type": "json_schema",
                "name": "math_response",
                "strict": True,
                "schema": {
                    "type": "object",
                    "properties": {
                        "steps": {
                            "type": "array",
                            "items": {
                                "type": "object",
                                "properties": {
                                    "explanation": {"type": "string"},
                                    "output": {"type": "string"},
                                },
                                "required": ["explanation", "output"],
                                "additionalProperties": False,
                            },
                        },
                        "final_answer": {"type": "string"},
                    },
                    "required": ["steps", "final_answer"],
                    "additionalProperties": False,
                },
            },
        },
        max_output_tokens=50,
    )

    print("status:", response.status)
    print("incomplete_details:", response.incomplete_details)

    if (
        response.status == "incomplete"
        and response.incomplete_details.reason == "max_output_tokens"
    ):
        print(">>> Incomplete response (max_output_tokens に達して切れた)")
        print("partial text:", response.output_text)

    message = next((item for item in response.output if item.type == "message"), None)
    math_response = message.content[0] if message and message.content else None

    if not math_response:
        raise Exception("No response content")

    if math_response.type == "refusal":
        print("REFUSAL:", math_response.refusal)
    elif math_response.type == "output_text":
        print("PARTIAL JSON:")
        print(math_response.text)
    else:
        raise Exception("No response content")
except Exception as e:
    print("EXCEPTION:", type(e).__name__, e)

出力です。

status: incomplete
incomplete_details: IncompleteDetails(reason='max_output_tokens')
>>> Incomplete response (max_output_tokens に達して切れた)
partial text: {
  "steps": [
    {
      "explanation": "方程式 8x + 7 = -23 を解くために、まず両辺から7を引きます。",
      "output": "8x = -23 -
PARTIAL JSON:
{
  "steps": [
    {
      "explanation": "方程式 8x + 7 = -23 を解くために、まず両辺から7を引きます。",
      "output": "8x = -23 -

statusincomplete になり、incomplete_details.reasonmax_output_tokens です。

途中で切れた JSON を json.loads に渡す前に、必ずこの status チェックを入れるのが本番コードの鉄則です。

例 5. refusal の処理

公式ガイドの「Refusals with Structured Outputs」の例です。

モデルが安全上の理由で拒否すると、refusal タイプの content が入ります。

for output in response.output:
    if output.type != "message":
        continue

    for item in output.content:
        if item.type == "refusal":
            print("REFUSAL:", item.refusal)
            continue

        if not item.parsed:
            raise Exception("Could not parse response")

        print("PARSED:", item.parsed)

今回は数学の問題なので拒否は起きず、通常パスをたどりました。

PARSED: steps=[Step(explanation='Start with the equation 8x + 7 = -23. Subtract 7 from both sides to isolate the term with x.', output='8x = -23 - 7 = -30'), Step(explanation='Divide both sides by 8 to solve for x.', output='x = -30 / 8 = -15/4')] final_answer='-15/4'

item.parsed に Pydantic モデルがそのまま入ります。

ここで興味深い点があります。

system プロンプトは日本語なのに、explanation が英語で返っています(例 2 では日本語でした)。

Structured Outputs が保証するのは schema の形だけです。中身の内容や言語までは保証されません。

ユーザー入力付きの本番アプリでは、拒否・ハルシネーション・言語混ざりに対するプロンプト側の設計が引き続き要ります。

例 6. ストリーミング + text_format

公式ガイドの「Streaming」の例です。

client.responses.stream で、生成中に delta を受け取りつつ最後にパース済みオブジェクトを取ります。

from typing import List


class EntitiesModel(BaseModel):
    attributes: List[str]
    colors: List[str]
    animals: List[str]


with client.responses.stream(
    model=MODEL,
    input=[
        {"role": "system", "content": "入力テキストからエンティティを抽出してください。"},
        {
            "role": "user",
            "content": "鋭い青い目をした素早い茶色の狐が、寝ている茶色の犬の上に飛び越えた",
        },
    ],
    text_format=EntitiesModel,
) as stream:
    for event in stream:
        if event.type == "response.refusal.delta":
            print("REFUSAL_DELTA:", event.delta, end="")
        elif event.type == "response.output_text.delta":
            print(event.delta, end="")
        elif event.type == "response.error":
            print("ERROR:", event.error, end="")
        elif event.type == "response.completed":
            print("\nCompleted")

    final_response = stream.get_final_response()
    print("final type:", type(final_response.output_parsed).__name__)
    print(final_response.output_parsed.model_dump_json())

出力です。

{"attributes":["sharp","quick","sleeping"],"colors":["blue","brown"],"animals":["fox","dog"]}
Completed
final type: EntitiesModel
{"attributes":["sharp","quick","sleeping"],"colors":["blue","brown"],"animals":["fox","dog"]}

delta は JSON 文字列の断片として流れてきます。

get_final_response()output_parsed が最終的な EntitiesModel です。

抽出したエンティティは英語で返ってきました(例 5 と同じく、中身の言語は保証外です)。

例 7. 文字列制約 (pattern / format)

公式ガイドの「String Restrictions」の例です。

pattern(正規表現)と format(既定の型)を使った生の JSON Schema です。

response = client.responses.create(
    model=MODEL,
    input=[
        {
            "role": "system",
            "content": "ユーザー情報を抽出してください。",
        },
        {
            "role": "user",
            "content": "ユーザーの表示名は「山田太郎」、ユーザー名は @yamada_taro、メールアドレスは taro.yamada@example.co.jp です。",
        },
    ],
    text={
        "format": {
            "type": "json_schema",
            "name": "user_data",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "name": {
                        "type": "string",
                        "description": "ユーザーの表示名",
                    },
                    "username": {
                        "type": "string",
                        "description": "ユーザー名。@ で始まること。",
                        "pattern": "^@[a-zA-Z0-9_]+$",
                    },
                    "email": {
                        "type": "string",
                        "description": "ユーザーのメールアドレス",
                        "format": "email",
                    },
                },
                "additionalProperties": False,
                "required": ["name", "username", "email"],
            },
        },
    },
)

print("status:", response.status)
print(response.output_text)

出力です。

status: completed
{"name":"山田太郎","username":"@yamada_taro","email":"taro.yamada@example.co.jp"}

username@ 始まりの pattern に、emailformat: email に従っています。

例 8. anyOf

公式ガイドの「For anyOf, the nested schemas must each be a valid JSON Schema」の例です。

item は user オブジェクトと address オブジェクトのどちらかになる、という union 型の schema です。

response = client.responses.create(
    model=MODEL,
    input=[
        {
            "role": "system",
            "content": "テキストからデータベースに挿入するオブジェクトを抽出してください。ユーザー情報が書かれていれば user オブジェクトに、住所が書かれていれば address オブジェクトにしてください。",
        },
        {
            "role": "user",
            "content": "田中花子さんは28歳です。",
        },
    ],
    text={
        "format": {
            "type": "json_schema",
            "name": "insert_item",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "item": {
                        "anyOf": [
                            {
                                "type": "object",
                                "description": "データベースに挿入する user オブジェクト",
                                "properties": {
                                    "name": {"type": "string", "description": "ユーザーの名前"},
                                    "age": {"type": "number", "description": "ユーザーの年齢"},
                                },
                                "additionalProperties": False,
                                "required": ["name", "age"],
                            },
                            {
                                "type": "object",
                                "description": "データベースに挿入する address オブジェクト",
                                "properties": {
                                    "number": {"type": "string", "description": "番地"},
                                    "street": {"type": "string", "description": "町名"},
                                    "city": {"type": "string", "description": "市区町村"},
                                },
                                "additionalProperties": False,
                                "required": ["number", "street", "city"],
                            },
                        ]
                    }
                },
                "additionalProperties": False,
                "required": ["item"],
            },
        },
    },
)

print("status:", response.status)
print(response.output_text)

出力です。

status: completed
{"item":{"name":"田中花子","age":28}}

入力にユーザー情報しかなかったため、user オブジェクトの側が選ばれました。

例 9. $defs / $ref

公式ガイドの「Definitions are supported」の例です。

繰り返し使うサブ schema を $defs に定義し、$ref で参照します。

response = client.responses.create(
    model=MODEL,
    input=[
        {
            "role": "system",
            "content": "あなたは親切な数学のチューターです。ユーザーをステップごとに解答に導いてください。",
        },
        {"role": "user", "content": "2x - 5 = 11 を解く方法を教えてください"},
    ],
    text={
        "format": {
            "type": "json_schema",
            "name": "math_response",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "steps": {
                        "type": "array",
                        "items": {
                            "$ref": "#/$defs/step",
                        },
                    },
                    "final_answer": {"type": "string"},
                },
                "$defs": {
                    "step": {
                        "type": "object",
                        "properties": {
                            "explanation": {"type": "string"},
                            "output": {"type": "string"},
                        },
                        "required": ["explanation", "output"],
                        "additionalProperties": False,
                    }
                },
                "required": ["steps", "final_answer"],
                "additionalProperties": False,
            },
        },
    },
)

print("status:", response.status)
print(response.output_text)

出力です。

status: completed
{
  "steps": [
    {
      "explanation": "方程式 2x - 5 = 11 の両辺に 5 を加えて -5 を消します。",
      "output": "2x = 16"
    },
    {
      "explanation": "両辺を 2 で割って x を求めます。",
      "output": "x = 8"
    }
  ],
  "final_answer": "x = 8"
}

例 10. 再帰 schema

公式ガイドの「Recursive schemas are supported」の例です。

children の items が #(ルート自体)を参照する、動的 UI 生成用の schema です。

response = client.responses.create(
    model=MODEL,
    input=[
        {
            "role": "system",
            "content": "指示された UI を JSON として生成してください。",
        },
        {
            "role": "user",
            "content": "「ログアウト」ボタンが中央に配置されたシンプルなヘッダー UI を作ってください。",
        },
    ],
    text={
        "format": {
            "type": "json_schema",
            "name": "ui",
            "description": "動的に生成された UI",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "type": {
                        "type": "string",
                        "description": "UI コンポーネントの種類",
                        "enum": ["div", "button", "header", "section", "field", "form"],
                    },
                    "label": {
                        "type": "string",
                        "description": "UI コンポーネントのラベル。ボタンやフォーム・フィールドに使用",
                    },
                    "children": {
                        "type": "array",
                        "description": "ネストされた UI コンポーネント",
                        "items": {"$ref": "#"},
                    },
                    "attributes": {
                        "type": "array",
                        "description": "UI コンポーネントの任意の属性",
                        "items": {
                            "type": "object",
                            "properties": {
                                "name": {
                                    "type": "string",
                                    "description": "属性の名前。例: onClick や className",
                                },
                                "value": {
                                    "type": "string",
                                    "description": "属性の値",
                                },
                            },
                            "additionalProperties": False,
                            "required": ["name", "value"],
                        },
                    },
                },
                "required": ["type", "label", "children", "attributes"],
                "additionalProperties": False,
            },
        },
    },
)

print("status:", response.status)
print(response.output_text)

出力です。

status: completed
{
  "type": "header",
  "label": "Header",
  "children": [
    {
      "type": "button",
      "label": "ログアウト",
      "children": [],
      "attributes": [
        {"name": "className", "value": "center"}
      ]
    }
  ],
  "attributes": [
    {"name": "className", "value": "header"}
  ]
}

header の中に button がネストされています。再帰的に正しく生成されています。

例 11. JSON mode

公式ガイドの「JSON mode」の例です。

Structured Outputs とは別に、text.format{"type": "json_object"} を渡すだけの JSON mode も試します。

we_did_not_specify_stop_tokens = True

try:
    response = client.responses.create(
        model=MODEL,
        input=[
            {
                "role": "system",
                "content": "あなたは JSON を出力するように設計された親切なアシスタントです。",
            },
            {
                "role": "user",
                "content": '2020年のワールドシリーズの優勝チームは? {"winner": "チーム名"} の形式で回答してください。',
            },
        ],
        text={"format": {"type": "json_object"}},
    )

    message = next((item for item in response.output if item.type == "message"), None)
    message_content = message.content[0] if message and message.content else None

    if (
        response.status == "incomplete"
        and response.incomplete_details.reason == "max_output_tokens"
    ):
        raise RuntimeError("The response was truncated before the JSON completed.")

    if message_content and message_content.type == "refusal":
        print("REFUSAL:", message_content.refusal)

    if (
        response.status == "incomplete"
        and response.incomplete_details.reason == "content_filter"
    ):
        raise RuntimeError("The response was interrupted by the content filter.")

    if response.status == "completed":
        if we_did_not_specify_stop_tokens:
            print("status:", response.status)
            print(response.output_text)
except Exception as e:
    print("EXCEPTION:", type(e).__name__, e)

出力です。

status: completed
{"winner": "Los Angeles Dodgers"}

JSON mode は schema を保証しません。保証が要るなら Structured Outputs の方を使う、という公式ドキュメントの推奨どおりです。

注意点: schema に従わなければならない制約

全部動いた、と書きましたが、OpenAI 構造化出力には schema 側に従わなければならない制約があります。OCI 環境でも同じ制約が効きます。

- ルートは object でなければならない(anyOf は不可)
- 全フィールドは required でなければならない
  (オプションは string と null の union で擬似的に作る)
- object は always additionalProperties: false
- ネストは最大 10 階層 / 全 object properties は最大 5000
- enum 値は全体で最大 1000
- allOf / not / if / then / else は未サポート
- 対応型: string / number / boolean / integer / object / array / enum / anyOf

この制約を外れた schema を strict: true で送るとエラーになります。

Pydantic で書くと、ほぼ自動的に正しい形になります。だから公式ドキュメントも、生の JSON Schema 直接指定より Pydantic / zod 経由を強く推奨しています。

まとめ

OpenAI 公式ドキュメントの Structured Outputs の全例を、OCI Responses API で xai.grok-4.3 に実行させてみました。

- 11/11 の例がそのまま動作した
- 差分は base_url と project の 2 つだけ
- text_format / 生 schema / ストリーミング / エッジケース検出すべて OK
- anyOf / $defs / 再帰 schema にも問題なし

OCI Generative AI の Responses API は OpenAI 互換がかなり本物です。

OpenAI のコード資産(型定義、prompt、エラーハンドリング)をそのまま OCI に持っていけるのは、マルチクラウド設計ではかなり効きます。

一方で、構造化出力が保証するのは「schema の形」だけです。

中身の言語や品質、拒否やハルシネーションへの対応は、プロンプトとアプリケーション側の設計で引き続きカバーしてください。

API key は model と同じ region で作成し、本番では IAM 認証を検討するのがおすすめです(公式ドキュメントより)。


参考リンク

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?