🧭

RubyLLM × さくらのAI Engine — 「OpenAI 互換」でハマったこと

に公開

さくらのAI Engine(以下 さくら)は OpenAI 互換 API を提供しており、base_url を差し替えるだけで OpenAI 用に書いたクライアントが動きます。RubyLLM から繋ぐぶんには、API キーと openai_api_base を さくら の値に差し替えるだけで chat.ask までは通ります。ただし「OpenAI 互換」が保証するのは「同じクライアントで叩ける」段階までで、振る舞いが OpenAI と同じわけではありません。本記事では さくら に切り替えたときに実際に詰まった 2 点 — with_schema が効かない点と、tools を使ったときの挙動がモデルにより異なる件 — を、さくら と公式 OpenAI のコード・応答を並べて示します。

RubyLLM から さくらのAI Engine に繋ぐ最小設定

config/initializers/ruby_llm.rb
RubyLLM.configure do |c|
  c.openai_api_key  = ENV.fetch('SAKURA_AI_ACCOUNT_KEY')
  c.openai_api_base = 'https://api.ai.sakura.ad.jp/v1'  # 末尾の /v1 まで含める
end

chat を作るときは provider:assume_model_exists: を明示します。

chat = RubyLLM.chat(
  model: 'gpt-oss-120b',
  provider: :openai,            # registry に同名モデルが他 provider 配下にあるため明示
  assume_model_exists: true     # registry 未登録モデルを暫定 capability で解決
)
chat.ask('こんにちは')
# => "こんにちは!今日はどのようなお手伝いをいたしましょうか?"

provider: :openai, assume_model_exists: true を省くと、RubyLLM の models.json に同名モデル ID gpt-oss-120b が Azure / Bedrock / OpenRouter 配下で登録されているため、別 provider の設定(azure_api_key 等)を要求されて失敗します。

ここまでは公式 OpenAI と同じコードで動きます。問題はこの先です。

「OpenAI 互換」の意味 — 同じクライアントで叩けるだけ

「OpenAI 互換」と書かれていれば base_url 差し替えで同じコードが同じ結果を返す、と読みたくなりますが、実際に保証されているのは前段だけです。 Bearer 認証、POST /v1/chat/completions への JSON リクエスト、stream: true の SSE chunked 応答、tool_choice: auto 指定時の tool_calls 配列といった HTTP プロトコルの形は OpenAI と一致しており、OpenAI SDK や RubyLLM を base_url 差し替えで さくら に向ける運用は成り立ちます。

成り立たないのは、「リクエストが受理された後にどう解釈されるか」「エラーボディがどう構造化されるか」「特定モードがどう動くか」です。さくら 公式 OpenAPI 仕様(ai-engine-inference-api.json)に書かれていない機能は、リクエストが 200 で受理されても効かないことがあり、書かれているモデル機能でもモデル単位で挙動が違うことがあります。次節以降で扱う 2 件はそれぞれの典型例です。

ハマったこと 1: with_schema が効かない

RubyLLM::Schema で構造化出力を期待したのに、戻り値が Hash でなく自然文の String で返ってきました。例外も警告も出ません。

同じスキーマで OpenAI と さくら を叩く

temperature_celsiuscondition の 2 フィールドだけのシンプルなスキーマを用意し、中立プロンプト「東京の架空の天気を返して」を渡します。プロンプト側で JSON 形式を指示しないことで、response_format が効いた場合だけ JSON が返ります。

class WeatherSchema < RubyLLM::Schema
  number :temperature_celsius, description: '摂氏気温'
  string :condition, enum: %w[sunny cloudy rainy snowy], description: '天気'
end

公式 OpenAI(gpt-4o-mini)は schema 通りの Hash を返します。

chat = RubyLLM.chat(model: 'gpt-4o-mini')
msg = chat.with_schema(WeatherSchema).ask('東京の架空の天気を返して')

msg.content
# => {"temperature_celsius" => 22, "condition" => "sunny"}
msg.content.class
# => Hash

さくら(gpt-oss-120b)は同じコードで自然文の String を返します。

chat = RubyLLM.chat(
  model: 'gpt-oss-120b', provider: :openai, assume_model_exists: true
)
msg = chat.with_schema(WeatherSchema).ask('東京の架空の天気を返して')

msg.content
# => "**東京(架空)の天気予報(2026年5月10日〜5月16日)**\n\n| 日付 | 天気 | ..."
msg.content.class
# => String

with_schema の戻り値が String になっているのに、例外は発生していません。

なぜそうなるか

さくらの AI Engine Inference API v1.0.0POST /v1/chat/completions requestBody には response_format プロパティの記述がありません。response_format: json_schema は OpenAI の Structured Outputs が前提とする機能ですが、さくら側ではこのプロパティに対応していないため、リクエストは 200 で受理されながら schema は無視されます。RubyLLM の送信ペイロード自体は {"type":"json_schema","json_schema":{...,"strict":true}} で OpenAI 仕様どおりです(webmock で確認)。サーバ側で参照されていません。

戻り値が String になる理由は RubyLLM 側にあります。with_schema を使ったときの戻り値生成部はこうなっています。

https://github.com/crmne/ruby_llm/blob/ff392893bb5366937688fa82bc0841185491f84c/lib/ruby_llm/chat.rb#L172-L178

公式 OpenAI なら strict: true の constrained decoding で出力が必ず JSON になるためこのフォールバックはほぼ起動しませんが、さくら で response_format が効かない結果モデルが自然文を返した瞬間、JSON.parseJSON::ParserError で落ち、text がそのまま戻ります。

対処

with_schema を「型安全な構造化出力の保証」と読まず、戻り値型を毎回アサートします。

class SchemaNotEnforced < StandardError; end

msg = chat.with_schema(WeatherSchema).ask('東京の架空の天気を返して')

unless msg.content.is_a?(Hash)
  raise SchemaNotEnforced,
        "expected Hash, got #{msg.content.class}: #{msg.content.inspect[0, 200]}"
end

# Hash だとしても enum / required は満たされている保証がない
allowed = %w[sunny cloudy rainy snowy]
unless allowed.include?(msg.content['condition'])
  raise "Invalid condition: #{msg.content['condition']}"
end

さくら で構造化出力が必要なら、プロンプト本文で JSON 形式を文章で指示し、戻り値を別途バリデーションする経路に倒すしかありません。response_format が将来サポートされる可能性はありますが、現時点では仕様書に載っていない機能を頼った設計はできません。

ハマったこと 2: tools の挙動はモデルごとに違う

gpt-oss-120b で tools が動いたのでモデルを差し替えたら、with_tool 周りで違う方向に転びました。同じ with_tool 呼び出しが、モデルごとに別の壊れ方をします。実際に当たった 2 ケースを並べます。

事前に共通の GetWeather を定義しておきます。

class GetWeather < RubyLLM::Tool
  description 'Get the current weather for a location'
  param :location, type: 'string', desc: 'City name'

  def execute(location:)
    { location: location, temperature_celsius: 22, condition: 'sunny' }
  end
end

ケース A: llm-jp-3.1-8x13b-instruct4with_tool(...).ask(...) が 400

gpt-oss-120b ではこのコードで、期待通りツールが使用されます。

chat(model: 'gpt-oss-120b').with_tool(GetWeather)
  .ask('東京の今の天気を教えて。get_weather ツールがあれば使って')
# => 200 OK、tool_calls が返って get_weather が呼ばれ、最終応答が得られる

これを llm-jp-3.1-8x13b-instruct4 に差し替えると vLLM のエラーらしき応答とともに RubyLLM::BadRequestError を発生します。

irb(main):012> chat(model: "llm-jp-3.1-8x13b-instruct4")
                  .with_tool(GetWeather)
                  .ask("東京の今の天気を教えて。get_weather ツールがあれば使って")
/home/yuichi/.rbenv/versions/4.0.2/lib/ruby/gems/4.0.0/gems/ruby_llm-1.15.0/lib/ruby_llm/error.rb:76:in 'RubyLLM::ErrorMiddleware.parse_error':
  "auto" tool choice requires --enable-auto-tool-choice and --tool-call-parser to be set
  (RubyLLM::BadRequestError)

エラーメッセージは "auto" としか言っていませんが、choice: :required を明示しても同じエラーで落ちます。

irb(main):050> chat(model: "llm-jp-3.1-8x13b-instruct4")
                  .with_tool(GetWeather, choice: :required)
                  .ask("東京の今の天気を簡潔に教えて。get_weather ツールがあれば使って")
                  .content
/home/yuichi/.rbenv/versions/4.0.2/lib/ruby/gems/4.0.0/gems/ruby_llm-1.15.0/lib/ruby_llm/error.rb:76:in 'RubyLLM::ErrorMiddleware.parse_error':
  "auto" tool choice requires --enable-auto-tool-choice and --tool-call-parser to be set
  (RubyLLM::BadRequestError)

唯一通るのは choice: :none です(前述のケース B の llm-jp 行を参照)。実質、llm-jp-3.1-8x13b-instruct4 ではツールを選択できないことになります。tools パラメータ自体は受理するため with_tool(...) 構文上は通りますが、none 以外の tool_choice がすべて 400 で拒否されるため、ツールを「呼ばないでくれ」と指示する経路だけ残っているのと同じです。

これは さくら 側で vLLM のフラグ(--enable-auto-tool-choice / --tool-call-parser)を付与すれば直る話か気になりますが、調べた限り根本的にはモデル側の非対応のようでした。

ケース B: tool_choice: :none の効き方がモデルごとに違う

with_tool(GetWeather, choice: :none) で「ツールがあっても呼ばないでほしい」と指示したときの挙動が、モデル間で揃っていません。同じ呼び出しで、none が尊重されるモデル、none を無視して裏でツールを実行するモデル、none を無視した上に内部 XML を content に漏らすモデルが共存します。

llm-jp-3.1-8x13b-instruct4

上記の通りツールの選択に対応していないので GetWeather#execute を呼ばずに自然文だけを返します。

irb(main):013> chat(model: "llm-jp-3.1-8x13b-instruct4")
                  .with_tool(GetWeather, choice: :none)
                  .ask("東京の今の天気を簡潔に教えて。get_weather ツールがあれば使って")
                  .content
=> "申し訳ありませんが、私はリアルタイムのデータにアクセスすることができませんので、
    現在の東京の天気について具体的な情報を提供することはできません。…"

gpt-oss-120b

GetWeather#execute が実際に呼ばれており、その結果を踏まえて自然文が組み立てられています。choice: :none の意図は守られていません。

# さくら gpt-oss-120b: 自然文は返ってくるが、GetWeather#execute は呼ばれている
chat(model: 'gpt-oss-120b').with_tool(GetWeather, choice: :none)
  .ask('東京の今の天気を教えて。get_weather ツールがあれば使って').content
# => "現在の東京の天気は **晴れ** で、気温は約 22℃ です。"
#    (GetWeather が返す { temperature_celsius: 22, condition: 'sunny' } が反映されている)

Qwen3-Coder-30B-A3B-Instruct

内部の tool 呼び出し表現がそのまま content に流れてきます。

irb> chat(model: 'Qwen3-Coder-30B-A3B-Instruct')
       .with_tool(GetWeather, choice: :none)
       .ask('東京の今の天気を教えて。get_weather ツールがあれば使って')
       .content
=> "<tool_call>\n<function=get_weather>\n<parameter=city>\n東京\n</parameter>\n</function>\n</tool_call>"

公式 OpenAI(gpt-4o-mini

none を尊重し、ツール実行なしの自然文を返します。

irb> chat(model: 'gpt-4o-mini')
       .with_tool(GetWeather, choice: :none)
       .ask('東京の今の天気を教えて。get_weather ツールがあれば使って')
       .content
# => "では、東京の現在の天気を取得します。少々お待ちください。"
#    (GetWeather#execute は呼ばれない)

整理すると次のようになります。

モデル none Tool#execute content
さくら llm-jp-3.1-8x13b-instruct4 あり なし 自然文(リアルタイムデータ非対応の旨)
さくら gpt-oss-120b なし あり 自然文(execute 戻り値が反映)
さくら Qwen3-Coder-30B-A3B-Instruct なし なし <tool_call> XML 漏れ
OpenAI gpt-4o-mini あり なし 自然文

特にやっかいなのは gpt-oss-120b です。content だけ見ると「none が効いて自然文が返った」と読めてしまうため、ツール実行カウンタや chat.messages の中間メッセージ(tool_calls を持つ assistant メッセージ)を実機で確認しない限り、設計時の前提が崩れていることに気付けません。

なぜそうなるのか

ケース A はツール選択に対応していないモデルのため、推論エンジンのエラーメッセージ --enable-auto-tool-choice and --tool-call-parser to be set が返されたようです。

ケース B は、tool_choice: none 自体は仕様レベルでは尊重される建前なのに、観測上はモデルごとに違う挙動が出る、という現象です。API応答でツール呼び出しが返され、これを RubyLLM はそれを通常の tool 実行ループとして扱い、GetWeather#execute を呼び、その結果を踏まえた自然文を返します。Qwen3でも同じくツール呼び出しを生成しますが、さくら側でパーサーが有効化されていないのか、OpenAI 仕様の tool_calls 配列に変換されず XML 文字列のまま返却されるようです。RubyLLM はtool 実行ループとして扱いません。

両ケースに共通するのは、「OpenAI 互換」が保証するのは API レイヤの形であり、推論エンジンの起動オプションやチャットテンプレート、モデル側の tools 追従度までは吸収しない、ということです。同じ with_tool(...).ask(...) が、さくら の中でもモデルごとに別の壊れ方をします。

対処

with_tool がどのモデルでも均質に動く」という前提も「tool_choice: :none で抑制できる」という前提も さくらでは持てません。none を尊重するモデル(llm-jp)と無視するモデル(gpt-oss-120bQwen3-Coder)が混在しているため、設計上は最低限次の 2 つを組み込みます。

(1) tools を使う前に、対象モデルで tool_choice の各値を実機で叩いて挙動を確認するTool#execute にカウンタを仕込み、content の表面だけでなく実行されたかを確認します。

class GetWeather < RubyLLM::Tool
  description 'Get the current weather for a location'
  param :location, type: 'string', desc: 'City name'

  @@call_count = 0
  def self.call_count; @@call_count; end
  def self.reset!; @@call_count = 0; end

  def execute(location:)
    self.class.class_variable_set(:@@call_count, @@call_count + 1)
    { location: location, temperature_celsius: 22, condition: 'sunny' }
  end
end

%i[auto required none].each do |choice|
  GetWeather.reset!
  chat = RubyLLM.chat(model: 'gpt-oss-120b', provider: :openai, assume_model_exists: true)
  args = choice == :auto ? {} : { choice: choice }
  chat.with_tool(GetWeather, **args).ask('東京の天気')
  puts "gpt-oss-120b / #{choice}: GetWeather#execute called #{GetWeather.call_count} time(s)"
rescue RubyLLM::BadRequestError => e
  puts "gpt-oss-120b / #{choice}: NG (#{e.message[0, 80]})"
end
# 例: gpt-oss-120b では :none を渡しても execute が呼ばれることが分かる
# gpt-oss-120b / auto: GetWeather#execute called 1 time(s)
# gpt-oss-120b / required: GetWeather#execute called 1 time(s)
# gpt-oss-120b / none: GetWeather#execute called 1 time(s)

(2) ツールを呼ばせたくない場面で tool_choice: :none を信頼しないnone を無視するモデルが混じっている以上、モデル切り替えに耐えられる設計にするには、ツールを呼ばせたくない経路は with_tool 自体を渡さない別の chat オブジェクトにわけるしかありません。

# tools が必要な経路
chat_with_tool = RubyLLM.chat(model: 'gpt-oss-120b', provider: :openai, assume_model_exists: true)
chat_with_tool.with_tool(GetWeather).ask('東京の天気を教えて')

# tools を絶対に呼ばせたくない経路(with_tool を呼ばない)
chat_plain = RubyLLM.chat(model: 'gpt-oss-120b', provider: :openai, assume_model_exists: true)
chat_plain.ask('東京の天気を会話で教えて')

まとめ

「OpenAI 互換」が保証するのは「同じクライアントで叩ける」段階までで、振る舞いの一致は別問題です。本記事で挙げた 2 件はそれぞれ性質が違います。

  • with_schema の件は API 全体の差: response_format が さくら 公式 OpenAPI APIドキュメントに記載がなく、実際にどのモデルでも schema 強制は効きません。RubyLLM 側のサイレントフォールバックと組み合わさって、Hash 期待のコードが例外なく String を受け取ります。戻り値型のアサートで防ぎます。
  • tools の件は 推論エンジン側の差: さくら では tool_choice の各値の通用性がモデル単位で違います。llm-jp-3.1-8x13b-instruct4 はツールを選択できないので、 none のみ通します。gpt-oss-120bauto が通る代わりに none を無視してツールを実行します。Qwen3-Coder-30B-A3B-Instructnone 無視に加えて Qwen 内部 XML を content に漏らします。with_tool の挙動を全モデル横断で揃った前提で設計せず、モデルごとに tool_choice の各値を実機検証し、ツールを呼ばせたくない経路は with_tool 自体を渡さない別 chat に分けます。

OpenAI 互換クライアントを さくら や他の互換 API に向ける運用では、「どのレイヤの互換性なのか」を切り分けて、戻り値型と通用範囲をコードの中で明示的に確認する設計が必要です。検証用の probe スクリプト(bundle exec bin/probe <feature> --provider sakura)と検証ログ(tmp/probe_results/)はリポジトリに置いてあるので、再検証時に diff すれば差分が機械的に拾えます。

参考

タケユー・ウェブ株式会社

Discussion