diff --git a/docs/_core_features/chat.md b/docs/_core_features/chat.md index 456d24ad..90803bfd 100644 --- a/docs/_core_features/chat.md +++ b/docs/_core_features/chat.md @@ -466,6 +466,9 @@ puts response.content # => {"name" => "Alice", "age" => 30} puts response.content.class # => Hash ``` +> RubyLLM::Schema classes automatically use their class name (e.g., `PersonSchema`) as the schema name in API requests, which can help the model better understand the expected output structure. +{: .note } + ### Using Manual JSON Schemas If you prefer not to use RubyLLM::Schema, you can provide a JSON Schema directly: @@ -496,6 +499,33 @@ puts response.content > **OpenAI Requirement:** When using manual JSON schemas with OpenAI, you must include `additionalProperties: false` in your schema objects. RubyLLM::Schema handles this automatically. {: .warning } +#### Custom Schema Names + +By default, schemas are named 'response' in API requests. You can provide a custom name that can influence model behavior and aid debugging: + +```ruby +# Provide a custom name with the full format +person_schema = { + name: 'PersonSchema', + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + }, + required: ['name', 'age'], + additionalProperties: false + } +} + +chat = RubyLLM.chat +response = chat.with_schema(person_schema).ask("Generate a person") +``` + +Custom schema names are useful for: +- **Influencing model behavior** - Descriptive names can help the model better understand the expected output structure +- **Debugging and logging** - Identifying which schema was used in API requests + ### Complex Nested Schemas Structured output supports complex nested objects and arrays: diff --git a/lib/ruby_llm/chat.rb b/lib/ruby_llm/chat.rb index da123d02..fdb0415d 100644 --- a/lib/ruby_llm/chat.rb +++ b/lib/ruby_llm/chat.rb @@ -89,7 +89,7 @@ def with_schema(schema) # Accept both RubyLLM::Schema instances and plain JSON schemas @schema = if schema_instance.respond_to?(:to_json_schema) - schema_instance.to_json_schema[:schema] + schema_instance.to_json_schema else schema_instance end diff --git a/lib/ruby_llm/providers/gemini/chat.rb b/lib/ruby_llm/providers/gemini/chat.rb index 3233ce93..284c4b1d 100644 --- a/lib/ruby_llm/providers/gemini/chat.rb +++ b/lib/ruby_llm/providers/gemini/chat.rb @@ -76,6 +76,9 @@ def parse_completion_response(response) def convert_schema_to_gemini(schema) return nil unless schema + # Extract inner schema if wrapper format (e.g., from RubyLLM::Schema.to_json_schema) + schema = schema[:schema] || schema + schema = normalize_any_of(schema) if schema[:anyOf] build_base_schema(schema).tap do |result| diff --git a/lib/ruby_llm/providers/openai/chat.rb b/lib/ruby_llm/providers/openai/chat.rb index 68522811..250a67a9 100644 --- a/lib/ruby_llm/providers/openai/chat.rb +++ b/lib/ruby_llm/providers/openai/chat.rb @@ -22,13 +22,21 @@ def render_payload(messages, tools:, temperature:, model:, stream: false, schema payload[:tools] = tools.map { |_, tool| tool_for(tool) } if tools.any? if schema - strict = schema[:strict] != false + # OpenAI's response_format requires a 'name' for the json_schema. + # Custom names are useful for debugging, logging, and clarity when using multiple schemas. + # + # Supports two formats: + # 1. Full: { name: 'custom_schema_name', schema: { type: 'object', properties: {...} } } + # 2. Schema only: { type: 'object', properties: {...} } - name defaults to 'response' + schema_name = schema[:name] || 'response' + schema_def = schema[:schema] || schema + strict = schema.fetch(:strict, true) payload[:response_format] = { type: 'json_schema', json_schema: { - name: 'response', - schema: schema, + name: schema_name, + schema: schema_def, strict: strict } } diff --git a/spec/fixtures/vcr_cassettes/chat_with_schema_with_gemini_gemini-2_5-flash_accepts_rubyllm_schema_class_instances_and_returns_structured_output.yml b/spec/fixtures/vcr_cassettes/chat_with_schema_with_gemini_gemini-2_5-flash_accepts_rubyllm_schema_class_instances_and_returns_structured_output.yml new file mode 100644 index 00000000..8b3f1e92 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_with_schema_with_gemini_gemini-2_5-flash_accepts_rubyllm_schema_class_instances_and_returns_structured_output.yml @@ -0,0 +1,82 @@ +--- +http_interactions: +- request: + method: post + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + body: + encoding: UTF-8 + string: '{"contents":[{"role":"user","parts":[{"text":"Generate a person named + Carol who is 32 years old"}]}],"generationConfig":{"responseMimeType":"application/json","responseSchema":{"type":"OBJECT","properties":{"name":{"type":"STRING"},"age":{"type":"NUMBER"}},"required":["name","age"]}}}' + headers: + User-Agent: + - Faraday v2.14.0 + X-Goog-Api-Key: + - "" + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Date: + - Fri, 24 Oct 2025 23:50:20 GMT + Server: + - scaffolding on HTTPServer2 + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Server-Timing: + - gfet4t7; dur=996 + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: | + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "{\n \"name\": \"Carol\",\n \"age\": 32\n}" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 13, + "candidatesTokenCount": 19, + "totalTokenCount": 96, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 13 + } + ], + "thoughtsTokenCount": 64 + }, + "modelVersion": "gemini-2.5-flash", + "responseId": "vBD8aNaDFNTQz7IP-tu8OQ" + } + recorded_at: Fri, 24 Oct 2025 23:50:20 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/chat_with_schema_with_openai_gpt-4_1-nano_accepts_rubyllm_schema_class_instances_and_returns_structured_output.yml b/spec/fixtures/vcr_cassettes/chat_with_schema_with_openai_gpt-4_1-nano_accepts_rubyllm_schema_class_instances_and_returns_structured_output.yml new file mode 100644 index 00000000..23a89bf0 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_with_schema_with_openai_gpt-4_1-nano_accepts_rubyllm_schema_class_instances_and_returns_structured_output.yml @@ -0,0 +1,117 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4.1-nano","messages":[{"role":"user","content":"Generate + a person named Alice who is 28 years old"}],"stream":false,"response_format":{"type":"json_schema","json_schema":{"name":"PersonSchemaClass","schema":{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"number"}},"required":["name","age"],"additionalProperties":false,"strict":true,"$defs":{}},"strict":true}}}' + headers: + User-Agent: + - Faraday v2.14.0 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 24 Oct 2025 23:50:19 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '708' + Openai-Project: + - proj_FwwKCZJVE4Cq8VD0ojnE99VR + Openai-Version: + - '2020-10-01' + X-Envoy-Upstream-Service-Time: + - '722' + X-Ratelimit-Limit-Requests: + - '30000' + X-Ratelimit-Limit-Tokens: + - '150000000' + X-Ratelimit-Remaining-Requests: + - '29999' + X-Ratelimit-Remaining-Tokens: + - '149999985' + X-Ratelimit-Reset-Requests: + - 2ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + X-Openai-Proxy-Wasm: + - v0.1 + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-CULpyuW7owwTWNZBhmQJCUg03cbwj", + "object": "chat.completion", + "created": 1761349818, + "model": "gpt-4.1-nano-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"name\":\"Alice\",\"age\":28}", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 66, + "completion_tokens": 9, + "total_tokens": 75, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_950f36939b" + } + recorded_at: Fri, 24 Oct 2025 23:50:19 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/ruby_llm/chat_schema_spec.rb b/spec/ruby_llm/chat_schema_spec.rb index 2ba4d62c..5404808c 100644 --- a/spec/ruby_llm/chat_schema_spec.rb +++ b/spec/ruby_llm/chat_schema_spec.rb @@ -2,6 +2,12 @@ require 'spec_helper' +# Define a test schema class for testing RubyLLM::Schema instances +class PersonSchemaClass < RubyLLM::Schema + string :name + number :age +end + RSpec.describe RubyLLM::Chat do include_context 'with configured RubyLLM' @@ -63,6 +69,19 @@ expect(response2.content).to be_a(String) expect(response2.content).to include('Ruby') end + + it 'accepts RubyLLM::Schema class instances and returns structured output' do + skip 'Model does not support structured output' unless chat.model.structured_output? + + response = chat + .with_schema(PersonSchemaClass) + .ask('Generate a person named Alice who is 28 years old') + + # Content should already be parsed as a Hash when schema is used + expect(response.content).to be_a(Hash) + expect(response.content['name']).to eq('Alice') + expect(response.content['age']).to eq(28) + end end end @@ -86,6 +105,19 @@ expect(response.content['name']).to eq('Jane') expect(response.content['age']).to eq(25) end + + it 'accepts RubyLLM::Schema class instances and returns structured output' do + skip 'Model does not support structured output' unless chat.model.structured_output? + + response = chat + .with_schema(PersonSchemaClass) + .ask('Generate a person named Carol who is 32 years old') + + # Content should already be parsed as a Hash when schema is used + expect(response.content).to be_a(Hash) + expect(response.content['name']).to eq('Carol') + expect(response.content['age']).to eq(32) + end end end diff --git a/spec/ruby_llm/providers/gemini/chat_spec.rb b/spec/ruby_llm/providers/gemini/chat_spec.rb index 719f1bae..5cdbd91c 100644 --- a/spec/ruby_llm/providers/gemini/chat_spec.rb +++ b/spec/ruby_llm/providers/gemini/chat_spec.rb @@ -13,6 +13,27 @@ end describe '#convert_schema_to_gemini' do + it 'extracts inner schema from wrapper format' do + # Simulate what RubyLLM::Schema.to_json_schema returns + schema = { + name: 'PersonSchema', + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + } + } + } + + result = test_obj.send(:convert_schema_to_gemini, schema) + + # Should extract the inner schema and convert it + expect(result[:type]).to eq('OBJECT') + expect(result[:properties][:name][:type]).to eq('STRING') + expect(result[:properties][:age][:type]).to eq('INTEGER') + end + it 'converts simple string schema' do schema = { type: 'string' } result = test_obj.send(:convert_schema_to_gemini, schema) diff --git a/spec/ruby_llm/providers/open_ai/chat_spec.rb b/spec/ruby_llm/providers/open_ai/chat_spec.rb index cbf4b029..a911be24 100644 --- a/spec/ruby_llm/providers/open_ai/chat_spec.rb +++ b/spec/ruby_llm/providers/open_ai/chat_spec.rb @@ -33,4 +33,111 @@ expect(message.cache_creation_tokens).to eq(0) end end + + describe '.render_payload' do + let(:model) { instance_double(RubyLLM::Model::Info, id: 'gpt-4o') } + let(:messages) { [RubyLLM::Message.new(role: :user, content: 'Hello')] } + + before do + allow(described_class).to receive(:format_messages).and_return([{ role: 'user', content: 'Hello' }]) + end + + context 'with schema' do + it 'defaults schema name to "response" for plain schema' do + schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + } + } + + payload = described_class.render_payload( + messages, + tools: {}, + temperature: nil, + model: model, + stream: false, + schema: schema + ) + + expect(payload[:response_format][:json_schema][:name]).to eq('response') + expect(payload[:response_format][:json_schema][:schema]).to eq(schema) + expect(payload[:response_format][:json_schema][:strict]).to be(true) + end + + it 'uses custom schema name when provided in full format' do + schema = { + name: 'PersonSchema', + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + } + } + } + + payload = described_class.render_payload( + messages, + tools: {}, + temperature: nil, + model: model, + stream: false, + schema: schema + ) + + expect(payload[:response_format][:json_schema][:name]).to eq('PersonSchema') + expect(payload[:response_format][:json_schema][:schema]).to eq(schema[:schema]) + expect(payload[:response_format][:json_schema][:strict]).to be(true) + end + + it 'respects explicit strict: false for both formats' do + # Full format with strict: false + schema_full = { + name: 'PersonSchema', + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + } + }, + strict: false + } + + payload = described_class.render_payload( + messages, + tools: {}, + temperature: nil, + model: model, + stream: false, + schema: schema_full + ) + + expect(payload[:response_format][:json_schema][:strict]).to be(false) + + # Plain format with strict: false + schema_plain = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + }, + strict: false + } + + payload = described_class.render_payload( + messages, + tools: {}, + temperature: nil, + model: model, + stream: false, + schema: schema_plain + ) + + expect(payload[:response_format][:json_schema][:strict]).to be(false) + end + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0b60aa31..33fcf027 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -8,6 +8,7 @@ require 'bundler/setup' require 'fileutils' require 'ruby_llm' +require 'ruby_llm/schema' require 'webmock/rspec' require 'active_support' require 'active_support/core_ext'