From cc4e9e030f0056cbf6b1598da718b1948e38394b Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 27 Jun 2025 15:42:48 +1000 Subject: [PATCH] FIX: normalize keys in structured output (#1468) * FIX: normalize keys in structured output Previously we did not validate the hash passed in to structured outputs which could either be string based or symbol base Specifically this broke structured outputs for Gemini in some specific cases. * comment out flake --- lib/completions/llm.rb | 8 ++++++- spec/lib/completions/endpoints/gemini_spec.rb | 18 +++++++++++++++ .../ai_helper/ai_composer_helper_spec.rb | 22 ++++++++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/completions/llm.rb b/lib/completions/llm.rb index 8eb85ce0..a8a9cc9c 100644 --- a/lib/completions/llm.rb +++ b/lib/completions/llm.rb @@ -80,7 +80,7 @@ module DiscourseAi tokens: 800_000, endpoint: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash", - display_name: "Gemini 2.5 Pro", + display_name: "Gemini 2.5 Flash", input_cost: 0.30, output_cost: 2.50, }, @@ -379,6 +379,12 @@ module DiscourseAi model_params[:temperature] = temperature if temperature model_params[:top_p] = top_p if top_p + + # internals expect symbolized keys, so we normalize here + response_format = + JSON.parse(response_format.to_json, symbolize_names: true) if response_format && + response_format.is_a?(Hash) + model_params[:response_format] = response_format if response_format model_params.merge!(extra_model_params) if extra_model_params diff --git a/spec/lib/completions/endpoints/gemini_spec.rb b/spec/lib/completions/endpoints/gemini_spec.rb index 4217f0e9..3a6543ea 100644 --- a/spec/lib/completions/endpoints/gemini_spec.rb +++ b/spec/lib/completions/endpoints/gemini_spec.rb @@ -612,6 +612,7 @@ RSpec.describe DiscourseAi::Completions::Endpoints::Gemini do ).to_return(status: 200, body: response) structured_response = nil + llm.generate("Hello", response_format: schema, user: user) do |partial| structured_response = partial end @@ -626,6 +627,23 @@ RSpec.describe DiscourseAi::Completions::Endpoints::Gemini do schema.dig(:json_schema, :schema).except(:additionalProperties), ) expect(parsed.dig(:generationConfig, :responseMimeType)).to eq("application/json") + + structured_response = nil + # once more but this time lets have the schema as string keys + llm.generate("Hello", response_format: schema.as_json, user: user) do |partial| + structured_response = partial + end + + expect(structured_response.read_buffered_property(:key)).to eq("Hello!\n there") + expect(structured_response.read_buffered_property(:num)).to eq(42) + + parsed = JSON.parse(req_body, symbolize_names: true) + + # Verify that schema is passed following Gemini API specs. + expect(parsed.dig(:generationConfig, :responseSchema)).to eq( + schema.dig(:json_schema, :schema).except(:additionalProperties), + ) + expect(parsed.dig(:generationConfig, :responseMimeType)).to eq("application/json") end end diff --git a/spec/system/ai_helper/ai_composer_helper_spec.rb b/spec/system/ai_helper/ai_composer_helper_spec.rb index b3539c17..7cd5f319 100644 --- a/spec/system/ai_helper/ai_composer_helper_spec.rb +++ b/spec/system/ai_helper/ai_composer_helper_spec.rb @@ -80,7 +80,27 @@ RSpec.describe "AI Composer helper", type: :system, js: true do expect(ai_helper_menu).to have_custom_prompt_button_enabled end - it "replaces the composed message with AI generated content" do + xit "replaces the composed message with AI generated content" do + # TODO: @keegan - this is a flake + # Failure/Error: super + + # Playwright::TimeoutError: + # Timeout 11000ms exceeded. + # Call log: + # - attempting click action + # - 2 × waiting for element to be visible, enabled and stable + # - - element is not enabled + # - - retrying click action + # - - waiting 20ms + # - 2 × waiting for element to be visible, enabled and stable + # - - element is not enabled + # - - retrying click action + # - - waiting 100ms + # - 21 × waiting for element to be visible, enabled and stable + # - - element is not enabled + # - - retrying click action + # - - waiting 500ms + trigger_composer_helper(input) ai_helper_menu.fill_custom_prompt(custom_prompt_input)