初めに
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 が管理しています(公式ドキュメントより)。
以降の例では、client と MODEL = "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_parsed は CalendarEvent 型のインスタンスです。
例 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 -
status が incomplete になり、incomplete_details.reason が max_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 に、email は format: 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 認証を検討するのがおすすめです(公式ドキュメントより)。
参考リンク