ruby_llm-responses_api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b83e9dbc3e924cceb9a1468e0da1271950bdbeb20d3bc0f3f2f859e3d17da565
4
+ data.tar.gz: 6ffbb8e1792cc333fb375b35a588f11d4d65d54a74182b95a0a3482ea716f39f
5
+ SHA512:
6
+ metadata.gz: 1178f162885b3fa9e7f084f183568789a3d08b3be0e27550f2aa8f7e069376a9a2165fc62f5a26ce8a8ec72ba4aebc4a5957ed9f83ab19ba9edc0b29eae0f82e
7
+ data.tar.gz: 7f922f72564bc21cee14d11e7a6d220a79519eb09906e6e1e17321d6e5db9bf424c9b18b1b8219d6a85eee096be9ec498c4da04d251bf10cfb190b1b5cc25fcb
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2025-01-03
9
+
10
+ ### Added
11
+
12
+ - Initial release of the RubyLLM Responses API provider
13
+ - Core chat completion support with Responses API format
14
+ - Streaming support with typed event handling
15
+ - Function calling (tool use) support
16
+ - Built-in tools support:
17
+ - Web Search (`web_search_preview`)
18
+ - Code Interpreter (`code_interpreter`)
19
+ - File Search (`file_search`)
20
+ - Image Generation (`image_generation`)
21
+ - MCP (Model Context Protocol) (`mcp`)
22
+ - Computer Use (`computer_use_preview`)
23
+ - Stateful conversation support via `previous_response_id` and `store`
24
+ - Background mode for long-running tasks
25
+ - Response polling and cancellation
26
+ - Message extension to support `response_id`
27
+ - Model capabilities for GPT-4o, GPT-4.1, and O-series models
28
+ - Media handling for images, PDFs, and audio
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Chris Hasinski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # RubyLLM Responses API
2
+
3
+ A [RubyLLM](https://github.com/crmne/ruby_llm) provider for OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses).
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem 'ruby_llm-responses_api'
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```ruby
14
+ require 'ruby_llm-responses_api'
15
+
16
+ RubyLLM.configure do |config|
17
+ config.openai_api_key = ENV['OPENAI_API_KEY']
18
+ end
19
+
20
+ chat = RubyLLM.chat(model: 'gpt-4o-mini', provider: :openai_responses)
21
+ response = chat.ask("Hello!")
22
+ puts response.content
23
+ ```
24
+
25
+ All standard RubyLLM features work as expected (streaming, tools, vision, structured output).
26
+
27
+ ## Stateful Conversations
28
+
29
+ Conversations automatically chain via `previous_response_id`:
30
+
31
+ ```ruby
32
+ chat = RubyLLM.chat(model: 'gpt-4o-mini', provider: :openai_responses)
33
+ chat.ask("My name is Alice.")
34
+ chat.ask("What's my name?") # => "Your name is Alice."
35
+ ```
36
+
37
+ ## Rails Persistence
38
+
39
+ For conversations that survive app restarts, add a migration:
40
+
41
+ ```ruby
42
+ class AddResponseIdToMessages < ActiveRecord::Migration[7.0]
43
+ def change
44
+ add_column :messages, :response_id, :string
45
+ end
46
+ end
47
+ ```
48
+
49
+ Then use normally:
50
+
51
+ ```ruby
52
+ # Day 1
53
+ chat = Chat.create!(model_id: 'gpt-4o-mini', provider: :openai_responses)
54
+ chat.ask("My name is Alice.")
55
+
56
+ # Day 2 (after restart)
57
+ chat = Chat.find(1)
58
+ chat.ask("What's my name?") # => "Alice"
59
+ ```
60
+
61
+ ## Built-in Tools
62
+
63
+ The Responses API provides built-in tools that don't require custom implementation.
64
+
65
+ ### Web Search
66
+
67
+ ```ruby
68
+ chat.with_params(tools: [{ type: 'web_search_preview' }])
69
+ chat.ask("Latest news about Ruby 3.4?")
70
+ ```
71
+
72
+ ### Code Interpreter
73
+
74
+ Execute Python code in a sandbox:
75
+
76
+ ```ruby
77
+ chat.with_params(tools: [{ type: 'code_interpreter' }])
78
+ chat.ask("Calculate the first 20 Fibonacci numbers and plot them")
79
+ ```
80
+
81
+ ### File Search
82
+
83
+ Search through uploaded files (requires vector store setup):
84
+
85
+ ```ruby
86
+ chat.with_params(tools: [{ type: 'file_search', vector_store_ids: ['vs_abc123'] }])
87
+ chat.ask("What does the documentation say about authentication?")
88
+ ```
89
+
90
+ ### Combining Tools
91
+
92
+ ```ruby
93
+ chat.with_params(tools: [
94
+ { type: 'web_search_preview' },
95
+ { type: 'code_interpreter' }
96
+ ])
97
+ chat.ask("Find the latest Bitcoin price and plot a chart")
98
+ ```
99
+
100
+ ## Why Use the Responses API?
101
+
102
+ - **Built-in tools** - Web search, code execution, file search without custom implementation
103
+ - **Stateful conversations** - OpenAI stores context server-side via `previous_response_id`
104
+ - **Simpler multi-turn** - No need to send full message history on each request
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAIResponses
6
+ # Extends RubyLLM's ActiveRecord MessageMethods to support response_id persistence
7
+ # for stateful conversations with the OpenAI Responses API.
8
+ #
9
+ # This is automatically applied when Rails loads ActiveRecord.
10
+ # Just add a migration: add_column :messages, :response_id, :string
11
+ #
12
+ module MessageMethodsExtension
13
+ # Override to_llm to include response_id for Responses API support
14
+ def to_llm
15
+ cached = has_attribute?(:cached_tokens) ? self[:cached_tokens] : nil
16
+ cache_creation = has_attribute?(:cache_creation_tokens) ? self[:cache_creation_tokens] : nil
17
+ resp_id = has_attribute?(:response_id) ? self[:response_id] : nil
18
+
19
+ RubyLLM::Message.new(
20
+ role: role.to_sym,
21
+ content: extract_content,
22
+ tool_calls: extract_tool_calls,
23
+ tool_call_id: extract_tool_call_id,
24
+ input_tokens: input_tokens,
25
+ output_tokens: output_tokens,
26
+ cached_tokens: cached,
27
+ cache_creation_tokens: cache_creation,
28
+ model_id: model_association&.model_id,
29
+ response_id: resp_id
30
+ )
31
+ end
32
+ end
33
+
34
+ # Extends RubyLLM's ActiveRecord ChatMethods to persist response_id
35
+ module ChatMethodsExtension
36
+ # Override persist_message_completion to also save response_id
37
+ def persist_message_completion(message)
38
+ super
39
+
40
+ # After the parent saves, update response_id if the column exists and message has one
41
+ return unless message
42
+ return unless message.respond_to?(:response_id) && message.response_id
43
+ return unless @message.has_attribute?(:response_id)
44
+
45
+ @message.update_column(:response_id, message.response_id)
46
+ end
47
+ end
48
+
49
+ @active_record_extensions_applied = false
50
+
51
+ # Apply ActiveRecord extensions for response_id persistence.
52
+ # Called automatically when ActiveRecord loads, or can be called manually.
53
+ def self.apply_active_record_extensions!
54
+ return if @active_record_extensions_applied
55
+
56
+ require 'ruby_llm/active_record/message_methods'
57
+ require 'ruby_llm/active_record/chat_methods'
58
+
59
+ RubyLLM::ActiveRecord::MessageMethods.prepend(MessageMethodsExtension)
60
+ RubyLLM::ActiveRecord::ChatMethods.prepend(ChatMethodsExtension)
61
+
62
+ @active_record_extensions_applied = true
63
+ rescue LoadError, NameError
64
+ # RubyLLM ActiveRecord support not available, skip silently
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ # Auto-apply extensions when ActiveRecord is loaded in Rails
72
+ if defined?(ActiveSupport.on_load)
73
+ ActiveSupport.on_load(:active_record) do
74
+ RubyLLM::Providers::OpenAIResponses.apply_active_record_extensions!
75
+ end
76
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAIResponses
6
+ # Background mode support for the OpenAI Responses API.
7
+ # Handles async responses with polling for long-running tasks.
8
+ module Background
9
+ module_function
10
+
11
+ # Status constants
12
+ QUEUED = 'queued'
13
+ IN_PROGRESS = 'in_progress'
14
+ COMPLETED = 'completed'
15
+ FAILED = 'failed'
16
+ CANCELLED = 'cancelled'
17
+ INCOMPLETE = 'incomplete'
18
+
19
+ TERMINAL_STATUSES = [COMPLETED, FAILED, CANCELLED, INCOMPLETE].freeze
20
+ PENDING_STATUSES = [QUEUED, IN_PROGRESS].freeze
21
+
22
+ # Add background mode to payload
23
+ # @param payload [Hash] The request payload
24
+ # @param background [Boolean] Whether to run in background mode
25
+ # @return [Hash] Updated payload
26
+ def apply_background_mode(payload, background: false)
27
+ payload[:background] = background if background
28
+ payload
29
+ end
30
+
31
+ # Check if response is still pending
32
+ # @param response [Hash] The API response
33
+ # @return [Boolean]
34
+ def pending?(response)
35
+ status = response['status']
36
+ PENDING_STATUSES.include?(status)
37
+ end
38
+
39
+ # Check if response is complete (terminal state)
40
+ # @param response [Hash] The API response
41
+ # @return [Boolean]
42
+ def complete?(response)
43
+ status = response['status']
44
+ TERMINAL_STATUSES.include?(status)
45
+ end
46
+
47
+ # Check if response was successful
48
+ # @param response [Hash] The API response
49
+ # @return [Boolean]
50
+ def successful?(response)
51
+ response['status'] == COMPLETED
52
+ end
53
+
54
+ # Check if response failed
55
+ # @param response [Hash] The API response
56
+ # @return [Boolean]
57
+ def failed?(response)
58
+ response['status'] == FAILED
59
+ end
60
+
61
+ # Get response status
62
+ # @param response [Hash] The API response
63
+ # @return [String] The status
64
+ def status(response)
65
+ response['status']
66
+ end
67
+
68
+ # Get error information if failed
69
+ # @param response [Hash] The API response
70
+ # @return [Hash, nil] Error information
71
+ def error_info(response)
72
+ response['error']
73
+ end
74
+
75
+ # URL to retrieve a response by ID
76
+ # @param response_id [String] The response ID
77
+ # @return [String] The URL path
78
+ def retrieve_url(response_id)
79
+ "responses/#{response_id}"
80
+ end
81
+
82
+ # URL to cancel a response
83
+ # @param response_id [String] The response ID
84
+ # @return [String] The URL path
85
+ def cancel_url(response_id)
86
+ "responses/#{response_id}/cancel"
87
+ end
88
+
89
+ # URL to list input items for a response
90
+ # @param response_id [String] The response ID
91
+ # @return [String] The URL path
92
+ def input_items_url(response_id)
93
+ "responses/#{response_id}/input_items"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # OpenAI Responses API provider for RubyLLM.
6
+ # Implements the new Responses API which provides built-in tools,
7
+ # stateful conversations, background mode, and MCP support.
8
+ #
9
+ # This base file defines the class structure before modules are loaded
10
+ # to avoid "superclass mismatch" errors.
11
+ class OpenAIResponses < Provider
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAIResponses
6
+ # Built-in tools support for the OpenAI Responses API.
7
+ # Provides configuration helpers and result parsing for:
8
+ # - Web Search
9
+ # - File Search
10
+ # - Code Interpreter
11
+ # - Image Generation
12
+ # - MCP (Model Context Protocol)
13
+ module BuiltInTools
14
+ module_function
15
+
16
+ # Web Search tool configuration
17
+ # @param search_context_size [String, nil] 'low', 'medium', or 'high'
18
+ # @param user_location [Hash, nil] { type: 'approximate', city: '...', country: '...' }
19
+ def web_search(search_context_size: nil, user_location: nil)
20
+ tool = { type: 'web_search_preview' }
21
+ tool[:search_context_size] = search_context_size if search_context_size
22
+ tool[:user_location] = user_location if user_location
23
+ tool
24
+ end
25
+
26
+ # File Search tool configuration
27
+ # @param vector_store_ids [Array<String>] IDs of vector stores to search
28
+ # @param max_num_results [Integer, nil] Maximum results to return
29
+ # @param ranking_options [Hash, nil] Ranking configuration
30
+ def file_search(vector_store_ids:, max_num_results: nil, ranking_options: nil)
31
+ tool = {
32
+ type: 'file_search',
33
+ vector_store_ids: Array(vector_store_ids)
34
+ }
35
+ tool[:max_num_results] = max_num_results if max_num_results
36
+ tool[:ranking_options] = ranking_options if ranking_options
37
+ tool
38
+ end
39
+
40
+ # Code Interpreter tool configuration
41
+ # @param container_type [String] 'auto' or specific container type
42
+ def code_interpreter(container_type: 'auto')
43
+ {
44
+ type: 'code_interpreter',
45
+ container: { type: container_type }
46
+ }
47
+ end
48
+
49
+ # Image Generation tool configuration
50
+ # @param partial_images [Integer, nil] Number of partial images during streaming
51
+ def image_generation(partial_images: nil)
52
+ tool = { type: 'image_generation' }
53
+ tool[:partial_images] = partial_images if partial_images
54
+ tool
55
+ end
56
+
57
+ # MCP (Model Context Protocol) tool configuration
58
+ # @param server_label [String] Label for the MCP server
59
+ # @param server_url [String] URL of the MCP server
60
+ # @param require_approval [String] 'never', 'always', or specific tool patterns
61
+ # @param allowed_tools [Array<String>, nil] List of allowed tool names
62
+ # @param headers [Hash, nil] Additional headers for the MCP server
63
+ def mcp(server_label:, server_url:, require_approval: 'never', allowed_tools: nil, headers: nil)
64
+ tool = {
65
+ type: 'mcp',
66
+ server_label: server_label,
67
+ server_url: server_url,
68
+ require_approval: require_approval
69
+ }
70
+ tool[:allowed_tools] = allowed_tools if allowed_tools
71
+ tool[:headers] = headers if headers
72
+ tool
73
+ end
74
+
75
+ # Computer Use tool configuration (preview)
76
+ # @param display_width [Integer] Display width in pixels
77
+ # @param display_height [Integer] Display height in pixels
78
+ # @param environment [String] 'browser' or 'mac' or 'windows' or 'ubuntu'
79
+ def computer_use(display_width:, display_height:, environment: 'browser')
80
+ {
81
+ type: 'computer_use_preview',
82
+ display_width: display_width,
83
+ display_height: display_height,
84
+ environment: environment
85
+ }
86
+ end
87
+
88
+ # Parse web search results from output
89
+ # @param output [Array] Response output array
90
+ # @return [Array<Hash>] Parsed search results with citations
91
+ def parse_web_search_results(output)
92
+ output
93
+ .select { |item| item['type'] == 'web_search_call' }
94
+ .map do |item|
95
+ {
96
+ id: item['id'],
97
+ status: item['status'],
98
+ results: parse_citations(item)
99
+ }
100
+ end
101
+ end
102
+
103
+ # Parse file search results from output
104
+ # @param output [Array] Response output array
105
+ # @return [Array<Hash>] Parsed file search results
106
+ def parse_file_search_results(output)
107
+ output
108
+ .select { |item| item['type'] == 'file_search_call' }
109
+ .map do |item|
110
+ {
111
+ id: item['id'],
112
+ status: item['status'],
113
+ results: item['results'] || []
114
+ }
115
+ end
116
+ end
117
+
118
+ # Parse code interpreter results from output
119
+ # @param output [Array] Response output array
120
+ # @return [Array<Hash>] Parsed code interpreter results
121
+ def parse_code_interpreter_results(output)
122
+ output
123
+ .select { |item| item['type'] == 'code_interpreter_call' }
124
+ .map do |item|
125
+ {
126
+ id: item['id'],
127
+ code: item['code'],
128
+ results: item['results'] || [],
129
+ container_id: item['container_id']
130
+ }
131
+ end
132
+ end
133
+
134
+ # Parse image generation results from output
135
+ # @param output [Array] Response output array
136
+ # @return [Array<Hash>] Parsed image generation results
137
+ def parse_image_generation_results(output)
138
+ output
139
+ .select { |item| item['type'] == 'image_generation_call' }
140
+ .map do |item|
141
+ {
142
+ id: item['id'],
143
+ status: item['status'],
144
+ result: item['result']
145
+ }
146
+ end
147
+ end
148
+
149
+ # Extract all citations from message content
150
+ # @param content [Array] Message content array
151
+ # @return [Array<Hash>] All citations/annotations
152
+ def extract_citations(content)
153
+ return [] unless content.is_a?(Array)
154
+
155
+ content
156
+ .select { |c| c['type'] == 'output_text' }
157
+ .flat_map { |c| c['annotations'] || [] }
158
+ .map do |annotation|
159
+ {
160
+ type: annotation['type'],
161
+ text: annotation['text'],
162
+ url: annotation['url'],
163
+ title: annotation['title'],
164
+ start_index: annotation['start_index'],
165
+ end_index: annotation['end_index']
166
+ }.compact
167
+ end
168
+ end
169
+
170
+ private_class_method def parse_citations(item)
171
+ return [] unless item['results']
172
+
173
+ item['results'].map do |result|
174
+ {
175
+ url: result['url'],
176
+ title: result['title'],
177
+ snippet: result['snippet']
178
+ }.compact
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end