ask-core 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: 9ab6b3602459d44db528b10dc551037f0c7da6d54d4876aeda87fc572ddbca91
4
+ data.tar.gz: 5500b00c8fc487780f7b96a5f574fde70e6c9af20ff9ced561ef18bf7a46316f
5
+ SHA512:
6
+ metadata.gz: ab1f35dfe6939eca8df702e84a0321c3bbc88c3ce6415509f1f46f0cd3dc459167b9f21b01fdf51a31bd70247d4e3b60921962e3f8c4c1ea8612d58b41a5176b
7
+ data.tar.gz: e7f6df8a119b318362908287031051de6917fd9fc775732c45af33dec4a5ee15c6304cec2e9b6cf4a3ade75ca415b8a80213cb4ef068fb7fe9d86429ffd06f16
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
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,284 @@
1
+ # ask-core
2
+
3
+ Foundation gem for the ask-rb ecosystem. Provides the types and interfaces that every provider gem builds on.
4
+
5
+ **Zero external dependencies.** Uses only Ruby stdlib (`json`, `net/http`, `date`, `time`).
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ # In your Gemfile
11
+ gem "ask-core"
12
+ ```
13
+
14
+ ## What it provides
15
+
16
+ | Component | File | Purpose |
17
+ |---|---|---|
18
+ | `Ask::Provider` | `lib/ask/provider.rb` | Abstract base class for all LLM providers |
19
+ | `Ask::Conversation` | `lib/ask/conversation.rb` | Message container with role normalization |
20
+ | `Ask::Stream` / `Ask::Chunk` | `lib/ask/stream.rb` | Streaming primitives |
21
+ | `Ask::ModelCatalog` | `lib/ask/models.rb` | Model name to provider resolution |
22
+ | `Ask::ToolDef` | `lib/ask/tool_def.rb` | Immutable tool metadata struct |
23
+ | `Ask::Result` | `lib/ask/result.rb` | Standardized tool return value |
24
+ | `Ask::Error` | `lib/ask/errors.rb` | Structured error types |
25
+
26
+ ## Usage
27
+
28
+ ### Provider (abstract base class)
29
+
30
+ Provider gems subclass `Ask::Provider` and implement the abstract methods:
31
+
32
+ ```ruby
33
+ class MyProvider < Ask::Provider
34
+ def api_base
35
+ "https://api.example.com/v1"
36
+ end
37
+
38
+ def headers
39
+ { "Authorization" => "Bearer #{@config.api_key}" }
40
+ end
41
+
42
+ def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
43
+ # Return an Ask::Message or yield Ask::Chunks
44
+ end
45
+
46
+ def embed(text, model:)
47
+ # Return an array of floats
48
+ end
49
+
50
+ def list_models
51
+ # Return an array of Ask::ModelInfo
52
+ end
53
+
54
+ class << self
55
+ def configuration_options
56
+ [:api_key, :api_base]
57
+ end
58
+
59
+ def configuration_requirements
60
+ [:api_key]
61
+ end
62
+ end
63
+ end
64
+
65
+ # Register the provider
66
+ Ask::Provider.register(:my_provider, MyProvider)
67
+
68
+ # Resolve by name
69
+ Ask::Provider.resolve(:my_provider) # => MyProvider
70
+ ```
71
+
72
+ ### Conversation
73
+
74
+ Build and manipulate conversations with role-normalized messages:
75
+
76
+ ```ruby
77
+ conv = Ask::Conversation.new
78
+
79
+ # Convenience methods
80
+ conv.system("You are a helpful assistant.")
81
+ conv.user("What's the weather in Tokyo?")
82
+ conv.assistant("Let me check...", tool_calls: [{ name: "get_weather", arguments: { location: "Tokyo" } }])
83
+ conv.tool_result("72°F, sunny", tool_call_id: "call_123")
84
+
85
+ # Iteration
86
+ conv.each { |msg| puts "#{msg.role}: #{msg.content}" }
87
+
88
+ # Filtering by role
89
+ conv.user_messages # => [Ask::Message, ...]
90
+ conv.system_messages # => [Ask::Message, ...]
91
+
92
+ # Serialization
93
+ conv.to_a # => [{ role: :user, content: "..." }, ...]
94
+ ```
95
+
96
+ ### Messages
97
+
98
+ ```ruby
99
+ msg = Ask::Message.new(role: :user, content: "Hello")
100
+ msg.user? # => true
101
+ msg.system? # => false
102
+ msg.assistant? # => false
103
+ msg.tool? # => false
104
+
105
+ msg = Ask::Message.new(role: :assistant, tool_calls: [{ name: "f", arguments: {} }])
106
+ msg.tool_call? # => true
107
+
108
+ msg = Ask::Message.new(role: :tool, content: "result", tool_call_id: "call_1")
109
+ msg.tool_result? # => true
110
+ ```
111
+
112
+ Valid roles: `:system`, `:user`, `:assistant`, `:tool`
113
+
114
+ ### Streaming
115
+
116
+ ```ruby
117
+ stream = Ask::Stream.new
118
+
119
+ # Add chunks as they arrive from the provider
120
+ stream.add(Ask::Chunk.new(content: "Hello "))
121
+ stream.add(Ask::Chunk.new(content: "World"))
122
+ stream.add(Ask::Chunk.new(content: "", finish_reason: "stop"))
123
+ stream.finish!
124
+
125
+ # Accumulate the full response
126
+ stream.accumulated_text # => "Hello World"
127
+ stream.to_s # => "Hello World"
128
+
129
+ # Track token usage
130
+ stream.accumulated_usage # => { input_tokens: 10, output_tokens: 20 }
131
+
132
+ # Iterate
133
+ stream.each { |chunk| print chunk.content }
134
+ ```
135
+
136
+ ### Chunks
137
+
138
+ ```ruby
139
+ chunk = Ask::Chunk.new(content: "Hello")
140
+ chunk.content # => "Hello"
141
+ chunk.finished? # => false
142
+ chunk.tool_call? # => false
143
+ chunk.finish_reason # => nil (or "stop", "length", "tool_calls")
144
+
145
+ chunk = Ask::Chunk.new(tool_calls: [{ name: "get_weather" }])
146
+ chunk.tool_call? # => true
147
+
148
+ chunk = Ask::Chunk.new(usage: { input_tokens: 10, output_tokens: 20 })
149
+ chunk.usage # => { input_tokens: 10, output_tokens: 20 }
150
+ ```
151
+
152
+ ### Model Catalog
153
+
154
+ Query available models from the registry:
155
+
156
+ ```ruby
157
+ catalog = Ask::ModelCatalog.new([
158
+ Ask::ModelInfo.new(id: "gpt-4o", provider: "openai", capabilities: ["function_calling", "vision"]),
159
+ Ask::ModelInfo.new(id: "claude-sonnet-4", provider: "anthropic", capabilities: ["function_calling", "reasoning"])
160
+ ])
161
+
162
+ # Find by ID (prefers most common provider)
163
+ catalog.find("gpt-4o")
164
+
165
+ # Find with specific provider
166
+ catalog.find("gpt-4o", "openai")
167
+
168
+ # Filter by type
169
+ catalog.chat_models
170
+ catalog.embedding_models
171
+
172
+ # Filter by provider or family
173
+ catalog.by_provider("openai")
174
+ catalog.by_family("gpt")
175
+
176
+ # Singleton instance
177
+ Ask::ModelCatalog.instance
178
+ Ask::ModelCatalog.find("gpt-4o")
179
+ ```
180
+
181
+ ### ModelInfo
182
+
183
+ ```ruby
184
+ info = Ask::ModelInfo.new(
185
+ id: "gpt-4o",
186
+ provider: "openai",
187
+ capabilities: ["function_calling", "vision"],
188
+ context_window: 128_000,
189
+ pricing: { text_tokens: { standard: { input_per_million: 2.5, output_per_million: 10 } } }
190
+ )
191
+
192
+ info.supports?(:function_calling) # => true
193
+ info.chat? # => true
194
+ info.embedding? # => false
195
+ info.context_window # => 128_000
196
+ ```
197
+
198
+ ### Tool Definitions
199
+
200
+ Immutable tool metadata for provider function calling:
201
+
202
+ ```ruby
203
+ tool = Ask::ToolDef.new(
204
+ name: "get_weather",
205
+ description: "Get current weather for a location",
206
+ parameters: {
207
+ type: "object",
208
+ properties: {
209
+ location: { type: "string", description: "City name" },
210
+ unit: { type: "string", enum: ["celsius", "fahrenheit"] }
211
+ },
212
+ required: ["location"]
213
+ }
214
+ )
215
+
216
+ tool.name # => "get_weather"
217
+ tool.description # => "Get current weather for a location"
218
+
219
+ # Provider-specific format
220
+ tool.to_provider_format { |t| { type: "function", function: t.to_h } }
221
+ ```
222
+
223
+ ### Tool Results
224
+
225
+ Standardized return values from tool execution:
226
+
227
+ ```ruby
228
+ Ask::Result.success("Data processed")
229
+ Ask::Result.success(updated_record, metadata: { duration: 1.2 })
230
+ Ask::Result.failure("API returned 500", error: "Timeout")
231
+ Ask::Result.aborted("Cancelled by sibling failure")
232
+ Ask::Result.blocked("Permission denied")
233
+
234
+ result = Ask::Result.success("OK")
235
+ result.success? # => true
236
+ result.error? # => false
237
+ result.aborted? # => false
238
+ result.blocked? # => false
239
+ result.to_s # => "OK"
240
+ result.to_h # => { content: "OK", status: :success, metadata: {} }
241
+ ```
242
+
243
+ ### Error Types
244
+
245
+ ```ruby
246
+ Ask::Error # Base class (rescue Ask::Error to catch all)
247
+ Ask::ConfigurationError # Missing/incorrect configuration
248
+ Ask::UnknownProvider # Provider not registered
249
+ Ask::ModelNotFound # Model not in catalog
250
+ Ask::InvalidRole # Invalid message role
251
+ Ask::InvalidToolDefinition # Invalid tool name/definition
252
+ Ask::ProviderError # Provider API error (with status_code, response_body)
253
+ Ask::ContextLengthExceeded # Context window exceeded
254
+ Ask::RateLimitError # Rate limited
255
+ Ask::Unauthorized # Authentication failure
256
+ Ask::ServerError # 5xx server error
257
+ Ask::ServiceUnavailable # Service temporarily unavailable
258
+ Ask::UnsupportedFeature # Feature not supported by provider/model
259
+ Ask::MissingCredential # Required credential not found
260
+ Ask::InvalidCredential # Credential is invalid/expired
261
+ ```
262
+
263
+ ## Development
264
+
265
+ ```bash
266
+ bundle exec rake test
267
+ ```
268
+
269
+ ## Testing
270
+
271
+ - Uses Minitest (not RSpec) — consistent with the ask-rb ecosystem.
272
+ - Unit tests for every public method.
273
+ - Run the full suite before every commit: `bundle exec rake test`.
274
+
275
+ ## Design Principles
276
+
277
+ 1. **Zero runtime dependencies** — stdlib only. Provider gems add their own HTTP clients.
278
+ 2. **Immutable value objects** — `Message`, `ToolDef`, `Result`, `Chunk`, and `ModelInfo` are frozen after construction.
279
+ 3. **Abstract interface** — `Ask::Provider` defines the contract. Provider gems implement the wire format.
280
+ 4. **Provider registry** — providers register themselves for runtime resolution by name.
281
+
282
+ ## License
283
+
284
+ MIT
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ # A single message in a conversation. Immutable after creation.
5
+ # Valid roles are: +:system+, +:user+, +:assistant+, +:tool+.
6
+ class Message
7
+ VALID_ROLES = %i[system user assistant tool].freeze
8
+
9
+ # @return [Symbol] message role (:system, :user, :assistant, :tool)
10
+ attr_reader :role
11
+
12
+ # @return [String, nil] message text content
13
+ attr_reader :content
14
+
15
+ # @return [String, nil] optional participant name (for multi-agent scenarios)
16
+ attr_reader :name
17
+
18
+ # @return [String, nil] tool call ID this message is responding to
19
+ attr_reader :tool_call_id
20
+
21
+ # @return [Array<Hash>, nil] tool calls included in this message
22
+ attr_reader :tool_calls
23
+
24
+ # @return [Hash] arbitrary metadata attached to this message
25
+ attr_reader :metadata
26
+
27
+ def initialize(role:, content: nil, name: nil, tool_call_id: nil, tool_calls: nil, metadata: {})
28
+ @role = normalize_role!(role)
29
+ @content = content
30
+ @name = normalize_name(name)
31
+ @tool_call_id = tool_call_id
32
+ @tool_calls = tool_calls
33
+ @metadata = metadata.dup.freeze
34
+ validate!
35
+ freeze
36
+ end
37
+
38
+ # @return [Boolean] true if this message contains tool calls
39
+ def tool_call? = @tool_calls&.any? == true
40
+
41
+ # @return [Boolean] true if this is a tool result message
42
+ def tool_result? = !@tool_call_id.nil?
43
+
44
+ # @return [Boolean] true if role is :system
45
+ def system? = @role == :system
46
+
47
+ # @return [Boolean] true if role is :user
48
+ def user? = @role == :user
49
+
50
+ # @return [Boolean] true if role is :assistant
51
+ def assistant? = @role == :assistant
52
+
53
+ # @return [Boolean] true if role is :tool
54
+ def tool? = @role == :tool
55
+
56
+ # Convert to a hash suitable for provider wire format serialization.
57
+ # Omits nil-valued keys.
58
+ # @return [Hash]
59
+ def to_h
60
+ base = { role: @role }
61
+ base[:content] = @content if @content
62
+ base[:name] = @name if @name
63
+ base[:tool_call_id] = @tool_call_id if @tool_call_id
64
+ base[:tool_calls] = @tool_calls if @tool_calls
65
+ base
66
+ end
67
+
68
+ # @return [Boolean] true if role, content, name, and tool metadata all match
69
+ def ==(other)
70
+ return false unless other.is_a?(Message)
71
+
72
+ @role == other.role && @content == other.content &&
73
+ @name == other.name && @tool_call_id == other.tool_call_id &&
74
+ @tool_calls == other.tool_calls
75
+ end
76
+ alias eql? ==
77
+
78
+ def hash
79
+ [@role, @content, @name, @tool_call_id, @tool_calls].hash
80
+ end
81
+
82
+ # @return [String]
83
+ def inspect
84
+ "#<Ask::Message role=#{@role.inspect} content=#{@content && @content.length > 57 ? @content[0,57].inspect + "..." : @content.inspect}>"
85
+ end
86
+
87
+ private
88
+
89
+ def normalize_role!(role)
90
+ sym = role.to_s.downcase.to_sym
91
+ raise InvalidRole, "Invalid role: #{role.inspect}. Valid: #{VALID_ROLES.join(', ')}" unless VALID_ROLES.include?(sym)
92
+
93
+ sym
94
+ end
95
+
96
+ def normalize_name(name)
97
+ return nil if name.nil?
98
+ name.to_s.strip.empty? ? nil : name.to_s.strip
99
+ end
100
+
101
+ def validate!
102
+ # Allow nil content for assistant messages with tool calls
103
+ end
104
+ end
105
+
106
+ # Ordered collection of messages comprising a conversation with an LLM.
107
+ # Provides role normalization, serialization helpers, and Enumerable access.
108
+ #
109
+ # conv = Ask::Conversation.new
110
+ # conv << Ask::Message.new(role: :user, content: "Hello")
111
+ # conv.system("Be helpful")
112
+ # conv.to_a # => [{ role: :user, content: "Hello" }, ...]
113
+ #
114
+ class Conversation
115
+ include Enumerable
116
+
117
+ # @param messages [Array<Ask::Message>] initial messages
118
+ def initialize(messages = [])
119
+ @messages = []
120
+ messages.each { |m| self << m }
121
+ end
122
+
123
+ # Add a message by object or by attributes.
124
+ # @param message [Ask::Message, Hash, String] message or attributes
125
+ # @return [self]
126
+ def <<(message)
127
+ msg = message.is_a?(Message) ? message : build_message(message)
128
+ @messages << msg
129
+ self
130
+ end
131
+ alias add <<
132
+
133
+ # Add a system message.
134
+ # @param text [String] message content
135
+ # @return [self]
136
+ def system(text, **options)
137
+ self << Message.new(role: :system, content: text, **options)
138
+ end
139
+
140
+ # Add a user message.
141
+ # @param text [String] message content
142
+ # @return [self]
143
+ def user(text, **options)
144
+ self << Message.new(role: :user, content: text, **options)
145
+ end
146
+
147
+ # Add an assistant message.
148
+ # @param text [String, nil] message content (nil when tool_calls are present)
149
+ # @param tool_calls [Array<Hash>, nil] tool call invocations
150
+ # @return [self]
151
+ def assistant(text = nil, tool_calls: nil, **options)
152
+ self << Message.new(role: :assistant, content: text, tool_calls: tool_calls, **options)
153
+ end
154
+
155
+ # Add a tool result message.
156
+ # @param content [String] tool output
157
+ # @param tool_call_id [String] ID of the tool call this result is for
158
+ # @return [self]
159
+ def tool_result(content, tool_call_id:, **options)
160
+ self << Message.new(role: :tool, content: content, tool_call_id: tool_call_id, **options)
161
+ end
162
+
163
+ # @yield [Message] yields each message in order
164
+ # @return [Enumerator] if no block given
165
+ def each(&block)
166
+ @messages.each(&block)
167
+ end
168
+
169
+ # @return [Ask::Message, Array<Ask::Message>] last message or last n messages
170
+ def last(n = nil)
171
+ n ? @messages.last(n) : @messages.last
172
+ end
173
+
174
+ # @return [Integer] number of messages
175
+ def length = @messages.length
176
+ alias size length
177
+
178
+ # @return [Boolean] true if there are no messages
179
+ def empty? = @messages.empty?
180
+
181
+ # Remove all messages.
182
+ # @return [self]
183
+ def clear
184
+ @messages.clear
185
+ self
186
+ end
187
+
188
+ # Access message by index.
189
+ # @param index [Integer] zero-based index
190
+ # @return [Ask::Message, nil]
191
+ def [](index)
192
+ @messages[index]
193
+ end
194
+
195
+ # @return [Array<Hash>] messages as an array of hashes
196
+ def to_a
197
+ @messages.map(&:to_h)
198
+ end
199
+
200
+ # @param role [Symbol, String] role to filter by
201
+ # @return [Array<Ask::Message>] messages with the given role
202
+ def by_role(role)
203
+ @messages.select { |m| m.role == role.to_sym }
204
+ end
205
+
206
+ # @return [Array<Ask::Message>] system messages
207
+ def system_messages = by_role(:system)
208
+
209
+ # @return [Array<Ask::Message>] user messages
210
+ def user_messages = by_role(:user)
211
+
212
+ # @return [Array<Ask::Message>] assistant messages
213
+ def assistant_messages = by_role(:assistant)
214
+
215
+ # @return [Array<Ask::Message>] tool messages
216
+ def tool_messages = by_role(:tool)
217
+
218
+ # Deep copy of this conversation.
219
+ # @return [Ask::Conversation]
220
+ def dup
221
+ Conversation.new(@messages.map { |m| Message.new(**m.to_h) })
222
+ end
223
+
224
+ # @return [String]
225
+ def inspect
226
+ "#<Ask::Conversation messages=#{@messages.length}>"
227
+ end
228
+
229
+ private
230
+
231
+ def build_message(attrs)
232
+ attrs.is_a?(Hash) ? Message.new(**attrs) : Message.new(role: :user, content: attrs.to_s)
233
+ end
234
+ end
235
+ end
data/lib/ask/errors.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ # Base error class for all ask-rb errors.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a provider is not configured properly.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when a required credential is missing.
11
+ class MissingCredential < Error; end
12
+
13
+ # Raised when a credential is invalid or expired.
14
+ class InvalidCredential < Error; end
15
+
16
+ # Raised when an unknown provider is requested.
17
+ class UnknownProvider < Error; end
18
+
19
+ # Raised when a model is not found in the catalog.
20
+ class ModelNotFound < Error; end
21
+
22
+ # Raised when a message has an invalid role.
23
+ class InvalidRole < Error; end
24
+
25
+ # Raised when a tool definition is invalid.
26
+ class InvalidToolDefinition < Error; end
27
+
28
+ # Raised when the context window is exceeded.
29
+ class ContextLengthExceeded < Error; end
30
+
31
+ # Raised when the API rate limit is hit.
32
+ class RateLimitError < Error; end
33
+
34
+ # Raised when authentication fails.
35
+ class Unauthorized < Error; end
36
+
37
+ # Raised when the server returns a 5xx error.
38
+ class ServerError < Error; end
39
+
40
+ # Raised when the service is unavailable.
41
+ class ServiceUnavailable < Error; end
42
+
43
+ # Raised when a provider's API returns an unexpected response.
44
+ # @!attribute [r] status_code
45
+ # @return [Integer, nil] the HTTP status code
46
+ # @!attribute [r] response_body
47
+ # @return [String, nil] the raw response body
48
+ class ProviderError < Error
49
+ attr_reader :status_code, :response_body
50
+
51
+ def initialize(message = nil, status_code: nil, response_body: nil)
52
+ @status_code = status_code
53
+ @response_body = response_body
54
+ super(message)
55
+ end
56
+ end
57
+
58
+ # Raised when a conversation operation receives an invalid state.
59
+ class ConversationError < Error; end
60
+
61
+ # Raised when streaming encounters an error.
62
+ class StreamError < Error; end
63
+
64
+ # Raised when a feature is not supported by the selected provider/model.
65
+ class UnsupportedFeature < Error; end
66
+ end