From 93d4e1ca0e926f56457645b8e5b07d5b02b8be1f Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Fri, 10 Jul 2026 23:19:07 +0530 Subject: [PATCH] feat(llm): add Responses API client --- lib/llm/responses_client.rb | 209 +++++++++++++++++++++++++ spec/lib/llm/responses_client_spec.rb | 215 ++++++++++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 lib/llm/responses_client.rb create mode 100644 spec/lib/llm/responses_client_spec.rb diff --git a/lib/llm/responses_client.rb b/lib/llm/responses_client.rb new file mode 100644 index 000000000..ff36970f3 --- /dev/null +++ b/lib/llm/responses_client.rb @@ -0,0 +1,209 @@ +require 'openai' +require 'ruby_llm/tool' + +class Llm::ResponsesClient + DEFAULT_STORE = false + DEFAULT_TEXT_FORMAT = { type: 'text' }.freeze + SYSTEM_ROLES = %w[system developer].freeze + + def initialize(api_key:, api_base: nil, client: nil) + @client = client || OpenAI::Client.new(access_token: api_key, uri_base: normalized_api_base(api_base)) + end + + def create(model:, messages:, reasoning_effort: nil, schema: nil, tools: [], metadata: {}, store: DEFAULT_STORE, **options) + payload = build_payload( + model: model, + messages: messages, + reasoning_effort: reasoning_effort, + schema: schema, + tools: tools, + metadata: metadata, + store: store, + options: options + ) + return payload if payload[:error] + + build_response(@client.json_post(path: '/responses', parameters: payload), request_messages: messages) + end + + def build_payload(model:, messages:, reasoning_effort: nil, schema: nil, tools: [], metadata: {}, store: DEFAULT_STORE, options: {}) + input_messages = conversation_messages(messages) + return no_conversation_payload(messages) if input_messages.empty? + + payload = { + model: model, + input: input_messages, + store: store, + text: { format: text_format(schema) } + } + + instructions = instructions_from(messages) + payload[:instructions] = instructions if instructions.present? + payload[:reasoning] = { effort: reasoning_effort } if reasoning_effort.present? + payload[:tools] = normalize_tools(tools) if tools.present? + payload[:metadata] = metadata if metadata.present? + + payload.merge!(options.compact) + payload + end + + def self.extract_output_text(response) + Array(response['output']).filter_map do |item| + next unless item['type'] == 'message' + + Array(item['content']).filter_map do |content| + content['text'] if content['type'] == 'output_text' + end.join + end.join + end + + def self.extract_function_calls(response) + Array(response['output']).filter_map do |item| + next unless item['type'] == 'function_call' + + { + 'id' => item['id'], + 'call_id' => item['call_id'], + 'name' => item['name'], + 'arguments' => parse_arguments(item['arguments']), + 'status' => item['status'] + } + end + end + + def self.usage_from(response) + usage = response['usage'] || {} + { + 'prompt_tokens' => usage['input_tokens'], + 'completion_tokens' => usage['output_tokens'], + 'total_tokens' => usage['total_tokens'], + 'reasoning_tokens' => usage.dig('output_tokens_details', 'reasoning_tokens') + }.compact + end + + def self.parse_arguments(arguments) + return arguments unless arguments.is_a?(String) + + JSON.parse(arguments) + rescue JSON::ParserError + arguments + end + + private + + def build_response(response, request_messages:) + { + message: self.class.extract_output_text(response), + usage: self.class.usage_from(response), + response_id: response['id'], + model: response['model'], + status: response['status'], + function_calls: self.class.extract_function_calls(response), + raw_response: response, + request_messages: request_messages + } + end + + def conversation_messages(messages) + messages.reject { |message| SYSTEM_ROLES.include?(message[:role].to_s) }.map do |message| + { + role: message[:role].to_s, + content: format_content(message[:content]) + } + end + end + + def instructions_from(messages) + messages.filter_map do |message| + message[:content] if SYSTEM_ROLES.include?(message[:role].to_s) + end.join("\n\n") + end + + def format_content(content) + return content unless content.is_a?(Array) + + content.map do |part| + normalized_part = part.deep_symbolize_keys + case normalized_part[:type] + when 'text' + { type: 'input_text', text: normalized_part[:text] } + when 'image_url' + { type: 'input_image', image_url: normalized_part.dig(:image_url, :url) || normalized_part[:image_url] } + else + normalized_part + end + end + end + + def text_format(schema) + return DEFAULT_TEXT_FORMAT unless schema + + schema_payload = normalize_schema_payload(schema) + { + type: 'json_schema', + name: schema_payload[:name], + schema: schema_payload[:schema], + strict: schema_payload[:strict] + } + end + + def normalize_schema_payload(schema) + schema_instance = schema.is_a?(Class) ? schema.new : schema + raw_schema = schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance + raw_schema = raw_schema.deep_symbolize_keys + schema_def = (raw_schema[:schema] || raw_schema).deep_dup + strict = raw_schema.key?(:strict) ? raw_schema[:strict] : schema_def.delete(:strict) + + { + name: sanitize_schema_name(raw_schema[:name] || 'response'), + schema: schema_def, + strict: strict.nil? || strict + } + end + + def normalize_tools(tools) + tools.map do |tool| + if tool.is_a?(Hash) + normalized_tool = tool.deep_symbolize_keys + function = normalized_tool[:function] + next normalized_tool unless function + + next { + type: 'function', + name: function[:name], + description: function[:description], + parameters: function[:parameters], + strict: function.fetch(:strict, true) + }.compact + end + + tool_instance = tool.is_a?(Class) ? tool.new : tool + { + type: 'function', + name: tool_instance.name, + description: tool_instance.description, + parameters: tool_instance.params_schema || RubyLLM::Tool::SchemaDefinition.from_parameters(tool_instance.parameters)&.json_schema, + strict: true + }.compact + end + end + + def no_conversation_payload(messages) + { + error: 'No conversation messages provided', + error_code: 400, + request_messages: messages + } + end + + def sanitize_schema_name(name) + sanitized = name.to_s.gsub(/[^a-zA-Z0-9_-]/, '_') + sanitized.presence || 'response' + end + + def normalized_api_base(api_base) + endpoint = api_base.presence || 'https://api.openai.com' + endpoint = endpoint.chomp('/') + endpoint.delete_suffix('/v1') + end +end diff --git a/spec/lib/llm/responses_client_spec.rb b/spec/lib/llm/responses_client_spec.rb new file mode 100644 index 000000000..61c120b2f --- /dev/null +++ b/spec/lib/llm/responses_client_spec.rb @@ -0,0 +1,215 @@ +require 'rails_helper' + +RSpec.describe Llm::ResponsesClient do + let(:openai_client) { instance_double(OpenAI::Client) } + let(:client) { described_class.new(api_key: 'test-key', api_base: 'https://api.openai.com/v1', client: openai_client) } + + let(:response_body) do + { + 'id' => 'resp_123', + 'status' => 'completed', + 'model' => 'gpt-5.6-terra', + 'output' => [ + { + 'type' => 'message', + 'content' => [ + { 'type' => 'output_text', 'text' => 'Hello' } + ] + }, + { + 'type' => 'function_call', + 'id' => 'fc_123', + 'call_id' => 'call_123', + 'name' => 'lookup_faq', + 'arguments' => '{"query":"billing"}', + 'status' => 'completed' + } + ], + 'usage' => { + 'input_tokens' => 10, + 'output_tokens' => 5, + 'total_tokens' => 15, + 'output_tokens_details' => { 'reasoning_tokens' => 2 } + } + } + end + + before do + allow(openai_client).to receive(:json_post).and_return(response_body) + end + + describe '#create' do + it 'sends a Responses API payload with reasoning effort and store disabled' do + messages = [ + { role: 'system', content: 'Answer as a support assistant.' }, + { role: 'user', content: 'Hi' } + ] + + result = client.create( + model: 'gpt-5.6-terra', + messages: messages, + reasoning_effort: 'medium', + metadata: { feature: 'assistant' } + ) + + expect(openai_client).to have_received(:json_post).with( + path: '/responses', + parameters: { + model: 'gpt-5.6-terra', + input: [{ role: 'user', content: 'Hi' }], + store: false, + text: { format: { type: 'text' } }, + instructions: 'Answer as a support assistant.', + reasoning: { effort: 'medium' }, + metadata: { feature: 'assistant' } + } + ) + expect(result).to include( + message: 'Hello', + response_id: 'resp_123', + model: 'gpt-5.6-terra', + status: 'completed', + request_messages: messages + ) + expect(result[:usage]).to eq( + 'prompt_tokens' => 10, + 'completion_tokens' => 5, + 'total_tokens' => 15, + 'reasoning_tokens' => 2 + ) + expect(result[:function_calls]).to contain_exactly( + 'id' => 'fc_123', + 'call_id' => 'call_123', + 'name' => 'lookup_faq', + 'arguments' => { 'query' => 'billing' }, + 'status' => 'completed' + ) + end + + it 'returns a local error when no conversation messages are present' do + result = client.create( + model: 'gpt-5.6-terra', + messages: [{ role: 'system', content: 'Only instructions' }], + reasoning_effort: 'low' + ) + + expect(openai_client).not_to have_received(:json_post) + expect(result).to eq( + error: 'No conversation messages provided', + error_code: 400, + request_messages: [{ role: 'system', content: 'Only instructions' }] + ) + end + end + + describe '#build_payload' do + class TestResponsesSchema + def to_json_schema + { + name: 'captain.response', + schema: { + type: 'object', + properties: { + response: { type: 'string' } + }, + required: ['response'], + additionalProperties: false + }, + strict: true + } + end + end + + it 'uses Responses text.format for structured outputs' do + payload = client.build_payload( + model: 'gpt-5.6-terra', + messages: [{ role: 'user', content: 'Summarize' }], + schema: TestResponsesSchema + ) + + expect(payload[:text]).to eq( + format: { + type: 'json_schema', + name: 'captain_response', + schema: { + type: 'object', + properties: { + response: { type: 'string' } + }, + required: ['response'], + additionalProperties: false + }, + strict: true + } + ) + end + + it 'normalizes chat-style function tools to Responses function tools' do + payload = client.build_payload( + model: 'gpt-5.6-terra', + messages: [{ role: 'user', content: 'Find docs' }], + tools: [ + { + type: 'function', + function: { + name: 'lookup_faq', + description: 'Search FAQs', + parameters: { + type: 'object', + properties: { + query: { type: 'string' } + }, + required: ['query'] + } + } + } + ] + ) + + expect(payload[:tools]).to eq( + [ + { + type: 'function', + name: 'lookup_faq', + description: 'Search FAQs', + parameters: { + type: 'object', + properties: { + query: { type: 'string' } + }, + required: ['query'] + }, + strict: true + } + ] + ) + end + + it 'normalizes multimodal chat content to Responses input blocks' do + payload = client.build_payload( + model: 'gpt-5.6-terra', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'What is in this image?' }, + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } } + ] + } + ] + ) + + expect(payload[:input]).to eq( + [ + { + role: 'user', + content: [ + { type: 'input_text', text: 'What is in this image?' }, + { type: 'input_image', image_url: 'https://example.com/image.png' } + ] + } + ] + ) + end + end +end