diff --git a/lib/ruby_llm/providers/openai/media.rb b/lib/ruby_llm/providers/openai/media.rb index fe5dd1f22..1dcaa59ca 100644 --- a/lib/ruby_llm/providers/openai/media.rb +++ b/lib/ruby_llm/providers/openai/media.rb @@ -8,7 +8,10 @@ module Media module_function def format_content(content) # rubocop:disable Metrics/PerceivedComplexity - return content.value if content.is_a?(RubyLLM::Content::Raw) + if content.is_a?(RubyLLM::Content::Raw) + value = content.value + return (value.is_a?(Hash) || value.is_a?(Array)) ? value.to_json : value + end return content.to_json if content.is_a?(Hash) || content.is_a?(Array) return content unless content.is_a?(Content) diff --git a/spec/fixtures/vcr_cassettes/activerecord_actsas_structured_output_supports_multi-turn_conversations_with_structured_responses.yml b/spec/fixtures/vcr_cassettes/activerecord_actsas_structured_output_supports_multi-turn_conversations_with_structured_responses.yml new file mode 100644 index 000000000..7f518eddf --- /dev/null +++ b/spec/fixtures/vcr_cassettes/activerecord_actsas_structured_output_supports_multi-turn_conversations_with_structured_responses.yml @@ -0,0 +1,232 @@ +--- +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":"What + country is Paris in?"}],"stream":false,"response_format":{"type":"json_schema","json_schema":{"name":"response","schema":{"type":"object","properties":{"country":{"type":"string"}},"required":["country"],"additionalProperties":false},"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: + - Tue, 09 Dec 2025 16:39:15 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '575' + Openai-Project: + - proj_TUKNckGsIoagmQc9vrsyoWDo + Openai-Version: + - '2020-10-01' + X-Envoy-Upstream-Service-Time: + - '864' + X-Ratelimit-Limit-Requests: + - '500' + X-Ratelimit-Limit-Tokens: + - '200000' + X-Ratelimit-Remaining-Requests: + - '499' + X-Ratelimit-Remaining-Tokens: + - '199991' + X-Ratelimit-Reset-Requests: + - 120ms + X-Ratelimit-Reset-Tokens: + - 2ms + 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-Ckv22SsyDsoW4S2i5Flz7nirdj70j", + "object": "chat.completion", + "created": 1765298354, + "model": "gpt-4.1-nano-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"country\":\"France\"}", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 41, + "completion_tokens": 5, + "total_tokens": 46, + "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_7f8eb7d1f9" + } + recorded_at: Tue, 09 Dec 2025 16:39:15 GMT +- 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":"What + country is Paris in?"},{"role":"assistant","content":"{\"country\":\"France\"}"},{"role":"user","content":"What + about Berlin?"}],"stream":false,"response_format":{"type":"json_schema","json_schema":{"name":"response","schema":{"type":"object","properties":{"country":{"type":"string"}},"required":["country"],"additionalProperties":false},"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: + - Tue, 09 Dec 2025 16:39:17 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '387' + Openai-Project: + - proj_TUKNckGsIoagmQc9vrsyoWDo + Openai-Version: + - '2020-10-01' + X-Envoy-Upstream-Service-Time: + - '616' + X-Ratelimit-Limit-Requests: + - '500' + X-Ratelimit-Limit-Tokens: + - '200000' + X-Ratelimit-Remaining-Requests: + - '499' + X-Ratelimit-Remaining-Tokens: + - '199980' + X-Ratelimit-Reset-Requests: + - 120ms + X-Ratelimit-Reset-Tokens: + - 6ms + 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-Ckv24AgXRDcR8Q4GfywHjxCZFZlme", + "object": "chat.completion", + "created": 1765298356, + "model": "gpt-4.1-nano-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"country\":\"Germany\"}", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 61, + "completion_tokens": 5, + "total_tokens": 66, + "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_7f8eb7d1f9" + } + recorded_at: Tue, 09 Dec 2025 16:39:17 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index a36c00b49..31c70d6f2 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -159,6 +159,30 @@ def execute(expression:) expect(saved_message.role).to eq('assistant') expect(saved_message.content_raw).to eq({ 'name' => 'Alice', 'age' => 25 }) end + + it 'supports multi-turn conversations with structured responses' do + chat = Chat.create!(model: model) + + schema = { + type: 'object', + properties: { + country: { type: 'string' } + }, + required: %w[country], + additionalProperties: false + } + + chat.with_schema(schema) + + # First turn + chat.ask('What country is Paris in?') + + # Second turn - this should not raise an error + response = chat.ask('What about Berlin?') + + expect(response.content).to be_a(Hash) + expect(response.content['country']).to eq('Germany') + end end describe 'parameter passing' do