spectre_ai 1.1.4 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e92299b643fbf7d928b9c45de5ad9a504528cbb246202d56cb24d36a32030030
4
- data.tar.gz: 8dc5d19040a9cac2cc929a1a91b55112d0e78fa21ca8de05bb6c3544838af9ed
3
+ metadata.gz: '03910c4dd38bf7a272fab91c0e9d1431d0f9bdc593abe62035271e9e07f22e89'
4
+ data.tar.gz: 0f1e927a42785d2f4735e4adf9140efad6815247fca6ce6270ac10ef286ae217
5
5
  SHA512:
6
- metadata.gz: 8ea61b8b0a0e23d7a5c500fd99e147942fd33538e21d38be0030aff60dda20758a87651b9460d4b4de50f986ffe068da4dd29f172dc0a25a993595eadee34fe6
7
- data.tar.gz: b4ef2e616f0143f677c635e6d999e155a1205a1ab90ac47fc2a8c322b95ee6f107522dc4c2d8a16e33a856f3387ffdb1e7ac85ca9f83b3a72960e9b8e6d52ca1
6
+ metadata.gz: 48e634fedb903de30ff0acba0b1de5b725fccb2ac0882bf8890740100312c92ae48c6c182612554cdd6968b34df5f6c7958a62caf19492ae29a42d8e084e1458
7
+ data.tar.gz: b7a382fb583431eff8715147df8f5251e30d528b0a77ecc6c2ecf68b324f57f76e9c6a2634da07f55159713d9cd58684e212b75fee7f5a7e20e56b437725dd55
data/CHANGELOG.md CHANGED
@@ -138,4 +138,145 @@ Spectre::Openai::Completions.create(
138
138
 
139
139
  * Simplified Exception Handling for Timeouts
140
140
  * Removed explicit handling of Net::OpenTimeout and Net::ReadTimeout exceptions in both Completions and Embeddings classes.
141
- * Letting these exceptions propagate ensures clearer and more consistent error messages for timeout issues.
141
+ * Letting these exceptions propagate ensures clearer and more consistent error messages for timeout issues.
142
+
143
+
144
+ # Changelog for Version 1.2.0
145
+
146
+ **Release Date:** [30th Jan 2025]
147
+
148
+ ### **New Features & Enhancements**
149
+
150
+ 1️⃣ **Unified Configuration for LLM Providers**
151
+
152
+ 🔧 Refactored the configuration system to provide a consistent interface for setting up OpenAI and Ollama within config/initializers/spectre.rb.\
153
+ • Now, developers can seamlessly switch between OpenAI and Ollama by defining a single provider configuration block.\
154
+ • Ensures better modularity and simplifies adding support for future providers (Claude, Cohere, etc.).
155
+
156
+ 🔑 **Example Configuration:**
157
+
158
+ ```ruby
159
+ Spectre.setup do |config|
160
+ config.default_llm_provider = :openai
161
+
162
+ config.openai do |openai|
163
+ openai.api_key = ENV['OPENAI_API_KEY']
164
+ end
165
+
166
+ config.ollama do |ollama|
167
+ ollama.host = ENV['OLLAMA_HOST']
168
+ ollama.api_key = ENV['OLLAMA_API_KEY']
169
+ end
170
+ end
171
+ ```
172
+
173
+ Key Improvements:\
174
+ ✅ API key validation added: Now properly checks if api_key is missing and raises APIKeyNotConfiguredError.\
175
+ ✅ Host validation added: Now checks if host is missing for Ollama and raises HostNotConfiguredError.
176
+
177
+ 2️⃣ **Added Ollama Provider Support**
178
+
179
+ 🆕 Introduced full support for Ollama, allowing users to use local LLM models efficiently.\
180
+ • Supports Ollama-based completions for generating text using local models like llama3.\
181
+ • Supports Ollama-based embeddings for generating embeddings using local models like nomic-embed-text.\
182
+ • Automatic JSON Schema Conversion: OpenAI’s json_schema format is now automatically translated into Ollama’s format key.
183
+
184
+ 3️⃣ **Differences in OpenAI Interface: max_tokens Moved to `**args`**
185
+
186
+ 💡 Refactored the OpenAI completions request so that max_tokens is now passed as a dynamic argument inside `**args` instead of a separate parameter.\
187
+ • Why? To ensure a consistent interface across different providers, making it easier to switch between them seamlessly.\
188
+ • Before:
189
+ ```ruby
190
+ Spectre.provider_module::Completions.create(messages: messages, max_tokens: 50)
191
+ ```
192
+ • After:
193
+ ```ruby
194
+ Spectre.provider_module::Completions.create(messages: messages, openai: { max_tokens: 50 })
195
+ ```
196
+
197
+ Key Benefits:\
198
+ ✅ Keeps the method signature cleaner and future-proof.\
199
+ ✅ Ensures optional parameters are handled dynamically without cluttering the main method signature.\
200
+ ✅ Improves consistency across OpenAI and Ollama providers.
201
+
202
+
203
+ # Changelog for Version 2.0.0
204
+
205
+ **Release Date:** [21st Sep 2025]
206
+
207
+ ### New Provider: Claude (Anthropic)
208
+
209
+ - Added Spectre::Claude client for chat completions using Anthropic Messages API.
210
+ - New configuration block: `Spectre.setup { |c| c.default_llm_provider = :claude; c.claude { |v| v.api_key = ENV['ANTHROPIC_API_KEY'] } }`.
211
+ - Supports `claude: { max_tokens: ... }` in args to control max tokens.
212
+
213
+ ### Structured Outputs via Tools-based JSON Schema
214
+
215
+ - Claude does not use `response_format`; instead, when `json_schema` is provided we now:
216
+ - Convert your schema into a single “virtual” tool (`tools[0]`) with `input_schema`.
217
+ - Force use of that tool by default with `tool_choice: { type: 'tool', name: <schema_name> }` (respects explicit `tool_choice` if you pass one).
218
+ - Merge your own `tools` alongside the schema tool without overriding them.
219
+ - Messages content preserves structured blocks (hashes/arrays), enabling images and other block types to be sent as-is.
220
+
221
+ ### Output Normalization (Parity with OpenAI when using json_schema)
222
+
223
+ - When a `json_schema` is provided and Claude returns a single `tool_use` with no text, we normalize the output to:
224
+ - `content: <parsed_object>` (Hash/Array), not a JSON string.
225
+ - This mirrors the behavior you get with OpenAI’s JSON schema mode, simplifying consumers.
226
+ - When no `json_schema` is provided, we return `tool_calls` (raw `tool_use` blocks) plus any text content.
227
+
228
+ ### Error Handling & Stop Reasons
229
+
230
+ - `stop_reason: 'max_tokens'` → raises `"Incomplete response: The completion was cut off due to token limit."`
231
+ - `stop_reason: 'refusal'` → raises `Spectre::Claude::RefusalError`.
232
+ - Unexpected stop reasons raise an error to make issues explicit.
233
+
234
+ ### Tools and tool_choice Support
235
+
236
+ - Pass-through for user-defined tools.
237
+ - Respect explicit `tool_choice`; only enforce schema tool when `json_schema` is present and no explicit choice is set.
238
+
239
+ ### Tests & DX
240
+
241
+ - Added a comprehensive RSpec suite for `Spectre::Claude::Completions`.
242
+ - Ensured spec loading works consistently across environments via `.rspec --require spec_helper` and consistent requires.
243
+ - Full suite passes locally (69 examples).
244
+
245
+ ### Notes
246
+
247
+ - Claude embeddings are not implemented (no native embeddings model).
248
+ - Behavior change (Claude only): when `json_schema` is used, `:content` returns a parsed object (not a JSON string). If you relied on a string, wrap with `JSON.generate` on the caller side.
249
+
250
+
251
+
252
+ # Changelog for Version 2.0.0
253
+
254
+ **Release Date:** [21st Sep 2025]
255
+
256
+ ### New Provider: Gemini (Google)
257
+
258
+ - Added Spectre::Gemini client for chat completions using Google’s OpenAI-compatible endpoint.
259
+ - Added Spectre::Gemini embeddings using Google’s OpenAI-compatible endpoint.
260
+ - New configuration block:
261
+ ```ruby
262
+ Spectre.setup do |c|
263
+ c.default_llm_provider = :gemini
264
+ c.gemini { |v| v.api_key = ENV['GEMINI_API_KEY'] }
265
+ end
266
+ ```
267
+ - Supports `gemini: { max_tokens: ... }` in args to control max tokens for completions.
268
+ - `json_schema` and `tools` are passed through in OpenAI-compatible format.
269
+
270
+ ### Core Wiring
271
+
272
+ - Added `:gemini` to VALID_LLM_PROVIDERS and provider configuration accessors.
273
+ - Updated Rails generator initializer template to include a gemini block.
274
+
275
+ ### Docs & Tests
276
+
277
+ - Updated README to include Gemini in compatibility matrix and configuration example.
278
+ - Added RSpec tests for Gemini completions and embeddings (mirroring OpenAI behavior and error handling).
279
+
280
+ ### Behavior Notes
281
+
282
+ - Gemini OpenAI-compatible chat endpoint requires that the last message in `messages` has role 'user'. Spectre raises an ArgumentError if this requirement is not met to prevent 400 INVALID_ARGUMENT errors from the API.
data/README.md CHANGED
@@ -6,14 +6,14 @@
6
6
 
7
7
  ## Compatibility
8
8
 
9
- | Feature | Compatibility |
10
- |-------------------------|---------------|
11
- | Foundation Models (LLM) | OpenAI |
12
- | Embeddings | OpenAI |
13
- | Vector Searching | MongoDB Atlas |
14
- | Prompt Templates | OpenAI |
9
+ | Feature | Compatibility |
10
+ |-------------------------|------------------------|
11
+ | Foundation Models (LLM) | OpenAI, Ollama, Claude, Gemini |
12
+ | Embeddings | OpenAI, Ollama, Gemini |
13
+ | Vector Searching | MongoDB Atlas |
14
+ | Prompt Templates | |
15
15
 
16
- **💡 Note:** We will first prioritize adding support for additional foundation models (Claude, Cohere, LLaMA, etc.), then look to add support for more vector databases (Pgvector, Pinecone, etc.). If you're looking for something a bit more extensible, we highly recommend checking out [langchainrb](https://github.com/patterns-ai-core/langchainrb).
16
+ **💡 Note:** We now support OpenAI, Ollama, Claude, and Gemini. Next, we'll add support for additional providers (e.g., Cohere) and more vector databases (Pgvector, Pinecone, etc.). If you're looking for something a bit more extensible, we highly recommend checking out [langchainrb](https://github.com/patterns-ai-core/langchainrb).
17
17
 
18
18
  ## Installation
19
19
 
@@ -37,24 +37,40 @@ gem install spectre_ai
37
37
 
38
38
  ## Usage
39
39
 
40
- ### 1. Setup
40
+ ### 🔧 Configuration
41
41
 
42
- First, you’ll need to generate the initializer to configure your OpenAI API key. Run the following command to create the initializer:
42
+ First, you’ll need to generate the initializer. Run the following command to create the initializer:
43
43
 
44
44
  ```bash
45
45
  rails generate spectre:install
46
46
  ```
47
47
 
48
- This will create a file at `config/initializers/spectre.rb`, where you can set your OpenAI API key:
48
+ This will create a file at `config/initializers/spectre.rb`, where you can set your llm provider and configure the provider-specific settings.
49
49
 
50
50
  ```ruby
51
51
  Spectre.setup do |config|
52
- config.api_key = 'your_openai_api_key'
53
- config.llm_provider = :openai
52
+ config.default_llm_provider = :openai # or :claude, :ollama, :gemini
53
+
54
+ config.openai do |openai|
55
+ openai.api_key = ENV['OPENAI_API_KEY']
56
+ end
57
+
58
+ config.ollama do |ollama|
59
+ ollama.host = ENV['OLLAMA_HOST']
60
+ ollama.api_key = ENV['OLLAMA_API_KEY']
61
+ end
62
+
63
+ config.claude do |claude|
64
+ claude.api_key = ENV['ANTHROPIC_API_KEY']
65
+ end
66
+
67
+ config.gemini do |gemini|
68
+ gemini.api_key = ENV['GEMINI_API_KEY']
69
+ end
54
70
  end
55
71
  ```
56
72
 
57
- ### 2. Enable Your Rails Model(s)
73
+ ### 📡 Embeddings & Vector Search
58
74
 
59
75
  #### For Embedding
60
76
 
@@ -146,6 +162,8 @@ This method sends the text to OpenAI’s API and returns the embedding vector. Y
146
162
  Spectre.provider_module::Embeddings.create("Your text here", model: "text-embedding-ada-002")
147
163
  ```
148
164
 
165
+ **NOTE:** Different providers have different available args for the `create` method. Please refer to the provider-specific documentation for more details.
166
+
149
167
  ### 4. Performing Vector-Based Searches
150
168
 
151
169
  Once your model is configured as searchable, you can perform vector-based searches on the stored embeddings:
@@ -168,7 +186,7 @@ This method will:
168
186
  - **custom_result_fields:** Limit the fields returned in the search results.
169
187
  - **additional_scopes:** Apply additional MongoDB filters to the search results.
170
188
 
171
- ### 5. Creating Completions
189
+ ### 💬 Chat Completions
172
190
 
173
191
  Spectre provides an interface to create chat completions using your configured LLM provider, allowing you to create dynamic responses, messages, or other forms of text.
174
192
 
@@ -182,17 +200,14 @@ messages = [
182
200
  { role: 'user', content: "Tell me a joke." }
183
201
  ]
184
202
 
185
- Spectre.provider_module::Completions.create(
186
- messages: messages
187
- )
188
-
203
+ Spectre.provider_module::Completions.create(messages: messages)
189
204
  ```
190
205
 
191
206
  This sends the request to the LLM provider’s API and returns the chat completion.
192
207
 
193
208
  **Customizing the Completion**
194
209
 
195
- You can customize the behavior by specifying additional parameters such as the model, maximum number of tokens, and any tools needed for function calls:
210
+ You can customize the behavior by specifying additional parameters such as the model, any tools needed for function calls:
196
211
 
197
212
  ```ruby
198
213
  messages = [
@@ -204,7 +219,7 @@ messages = [
204
219
  Spectre.provider_module::Completions.create(
205
220
  messages: messages,
206
221
  model: "gpt-4",
207
- max_tokens: 50
222
+ openai: { max_tokens: 50 }
208
223
  )
209
224
 
210
225
  ```
@@ -241,7 +256,78 @@ Spectre.provider_module::Completions.create(
241
256
 
242
257
  This structured format guarantees that the response adheres to the schema you’ve provided, ensuring more predictable and controlled results.
243
258
 
244
- **Using Tools for Function Calling**
259
+ **NOTE:** Provider differences for structured output:
260
+ - OpenAI: supports strict JSON Schema via `response_format.json_schema` (see JSON Schema docs: https://json-schema.org/overview/what-is-jsonschema.html).
261
+ - Claude (Anthropic): does not use `response_format`. Spectre converts your `json_schema` into a single "virtual" tool with `input_schema` and, by default, forces its use via `tool_choice` (you can override `tool_choice` explicitly). When the reply consists only of that `tool_use`, Spectre returns the parsed object in `:content` (Hash/Array), not a JSON string.
262
+ - Ollama: expects a plain JSON object in `format`. Spectre will convert OpenAI-style `{ name:, schema: }` automatically into the format Ollama expects.
263
+
264
+ #### Claude (Anthropic) specifics
265
+
266
+ - Configure:
267
+ ```ruby
268
+ Spectre.setup do |config|
269
+ config.default_llm_provider = :claude
270
+ config.claude { |c| c.api_key = ENV['ANTHROPIC_API_KEY'] }
271
+ end
272
+ ```
273
+
274
+ - Structured output with a schema:
275
+ ```ruby
276
+ json_schema = {
277
+ name: "completion_response",
278
+ schema: {
279
+ type: "object",
280
+ properties: { response: { type: "string" } },
281
+ required: ["response"],
282
+ additionalProperties: false
283
+ }
284
+ }
285
+
286
+ messages = [
287
+ { role: 'system', content: 'You are a helpful assistant.' },
288
+ { role: 'user', content: 'Say hello' }
289
+ ]
290
+
291
+ result = Spectre.provider_module::Completions.create(
292
+ messages: messages,
293
+ json_schema: json_schema,
294
+ claude: { max_tokens: 256 }
295
+ )
296
+
297
+ # When only the schema tool is used, Spectre returns a parsed object:
298
+ result[:content] # => { 'response' => 'Hello!' }
299
+ ```
300
+
301
+ - Optional: override tool selection
302
+ ```ruby
303
+ Spectre.provider_module::Completions.create(messages: messages, json_schema: json_schema, tool_choice: { type: 'auto' })
304
+ ```
305
+
306
+ - Note: Claude embeddings are not implemented (no native embeddings model).
307
+
308
+ #### Gemini (Google) specifics
309
+
310
+ - Chat completions use Google's OpenAI-compatible endpoint. Important: the messages array must end with a user message. If the last message is assistant/system or missing, the API returns 400 INVALID_ARGUMENT (e.g., "Please ensure that single turn requests end with a user role or the role field is empty."). Spectre validates this and raises an ArgumentError earlier to help you fix the history before making an API call.
311
+ - Example:
312
+
313
+ ```ruby
314
+ # Incorrect (ends with assistant)
315
+ messages = [
316
+ { role: 'system', content: 'You are a funny assistant.' },
317
+ { role: 'user', content: 'Tell me a joke.' },
318
+ { role: 'assistant', content: "Sure, here's a joke!" }
319
+ ]
320
+
321
+ # Correct (ends with user)
322
+ messages = [
323
+ { role: 'system', content: 'You are a funny assistant.' },
324
+ { role: 'user', content: 'Tell me a joke.' },
325
+ { role: 'assistant', content: "Sure, here's a joke!" },
326
+ { role: 'user', content: 'Tell me another one.' }
327
+ ]
328
+ ```
329
+
330
+ ⚙️ Function Calling (Tool Use)
245
331
 
246
332
  You can incorporate tools (function calls) in your completion to handle more complex interactions such as fetching external information via API or performing calculations. Define tools using the function call format and include them in the request:
247
333
 
@@ -321,7 +407,9 @@ else
321
407
  end
322
408
  ```
323
409
 
324
- ### 6. Creating Dynamic Prompts
410
+ **NOTE:** Completions class also supports different `**args` for different providers. Please refer to the provider-specific documentation for more details.
411
+
412
+ ### 🎭 Dynamic Prompt Rendering
325
413
 
326
414
  Spectre provides a system for creating dynamic prompts based on templates. You can define reusable prompt templates and render them with different parameters in your Rails app (think Ruby on Rails view partials).
327
415
 
@@ -424,7 +512,7 @@ Spectre.provider_module::Completions.create(
424
512
 
425
513
  ```
426
514
 
427
- ## Contributing
515
+ ## 📜 Contributing
428
516
 
429
517
  Bug reports and pull requests are welcome on GitHub at [https://github.com/hiremav/spectre](https://github.com/hiremav/spectre). This project is intended to be a safe, welcoming space for collaboration, and your contributions are greatly appreciated!
430
518
 
@@ -434,6 +522,6 @@ Bug reports and pull requests are welcome on GitHub at [https://github.com/hirem
434
522
  4. **Push** the branch (`git push origin my-new-feature`).
435
523
  5. **Create** a pull request.
436
524
 
437
- ## License
525
+ ## 📜 License
438
526
 
439
527
  This gem is available as open source under the terms of the MIT License.
@@ -3,8 +3,23 @@
3
3
  require 'spectre'
4
4
 
5
5
  Spectre.setup do |config|
6
- # Chose your LLM (openai, cohere, ollama)
7
- config.llm_provider = :openai
8
- # Set the API key for your chosen LLM
9
- config.api_key = ENV.fetch('CHATGPT_API_TOKEN')
6
+ # Chose your LLM (openai, ollama, claude, gemini)
7
+ config.default_llm_provider = :openai
8
+
9
+ config.openai do |openai|
10
+ openai.api_key = ENV['OPENAI_API_KEY']
11
+ end
12
+
13
+ config.ollama do |ollama|
14
+ ollama.host = ENV['OLLAMA_HOST']
15
+ ollama.api_key = ENV['OLLAMA_API_KEY']
16
+ end
17
+
18
+ config.claude do |claude|
19
+ claude.api_key = ENV['ANTHROPIC_API_KEY']
20
+ end
21
+
22
+ config.gemini do |gemini|
23
+ gemini.api_key = ENV['GEMINI_API_KEY']
24
+ end
10
25
  end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Spectre
8
+ module Claude
9
+ class RefusalError < StandardError; end
10
+
11
+ class Completions
12
+ API_URL = 'https://api.anthropic.com/v1/messages'
13
+ DEFAULT_MODEL = 'claude-opus-4-1'
14
+ DEFAULT_TIMEOUT = 60
15
+ ANTHROPIC_VERSION = '2023-06-01'
16
+
17
+ # Class method to generate a completion based on user messages and optional tools
18
+ #
19
+ # @param messages [Array<Hash>] The conversation messages, each with a role and content
20
+ # @param model [String] The model to be used for generating completions, defaults to DEFAULT_MODEL
21
+ # @param json_schema [Hash, nil] Optional JSON Schema; when provided, it will be converted into a tool with input_schema and forced via tool_choice unless overridden
22
+ # @param tools [Array<Hash>, nil] An optional array of tool definitions for function calling
23
+ # @param tool_choice [Hash, nil] Optional tool_choice to force a specific tool use (e.g., { type: 'tool', name: 'record_summary' })
24
+ # @param args [Hash, nil] optional arguments like read_timeout and open_timeout. For Claude, max_tokens can be passed in the claude hash.
25
+ # @return [Hash] The parsed response including any tool calls or content
26
+ # @raise [APIKeyNotConfiguredError] If the API key is not set
27
+ # @raise [RuntimeError] For general API errors or unexpected issues
28
+ def self.create(messages:, model: DEFAULT_MODEL, json_schema: nil, tools: nil, tool_choice: nil, **args)
29
+ api_key = Spectre.claude_configuration&.api_key
30
+ raise APIKeyNotConfiguredError, "API key is not configured" unless api_key
31
+
32
+ validate_messages!(messages)
33
+
34
+ uri = URI(API_URL)
35
+ http = Net::HTTP.new(uri.host, uri.port)
36
+ http.use_ssl = true
37
+ http.read_timeout = args.fetch(:read_timeout, DEFAULT_TIMEOUT)
38
+ http.open_timeout = args.fetch(:open_timeout, DEFAULT_TIMEOUT)
39
+
40
+ request = Net::HTTP::Post.new(uri.path, {
41
+ 'Content-Type' => 'application/json',
42
+ 'x-api-key' => api_key,
43
+ 'anthropic-version' => ANTHROPIC_VERSION
44
+ })
45
+
46
+ max_tokens = args.dig(:claude, :max_tokens) || 1024
47
+ request.body = generate_body(messages, model, json_schema, max_tokens, tools, tool_choice).to_json
48
+ response = http.request(request)
49
+
50
+ unless response.is_a?(Net::HTTPSuccess)
51
+ raise "Claude API Error: #{response.code} - #{response.message}: #{response.body}"
52
+ end
53
+
54
+ parsed_response = JSON.parse(response.body)
55
+
56
+ handle_response(parsed_response, schema_used: !!json_schema)
57
+ rescue JSON::ParserError => e
58
+ raise "JSON Parse Error: #{e.message}"
59
+ end
60
+
61
+ private
62
+
63
+ # Validate the structure and content of the messages array.
64
+ #
65
+ # @param messages [Array<Hash>] The array of message hashes to validate.
66
+ #
67
+ # @raise [ArgumentError] if the messages array is not in the expected format or contains invalid data.
68
+ def self.validate_messages!(messages)
69
+ unless messages.is_a?(Array) && messages.all? { |msg| msg.is_a?(Hash) }
70
+ raise ArgumentError, "Messages must be an array of message hashes."
71
+ end
72
+
73
+ if messages.empty?
74
+ raise ArgumentError, "Messages cannot be empty."
75
+ end
76
+ end
77
+
78
+ # Helper method to generate the request body for Anthropic Messages API
79
+ #
80
+ # @param messages [Array<Hash>] The conversation messages, each with a role and content
81
+ # @param model [String] The model to be used for generating completions
82
+ # @param json_schema [Hash, nil] An optional JSON schema to hint structured output
83
+ # @param max_tokens [Integer] The maximum number of tokens for the completion
84
+ # @param tools [Array<Hash>, nil] An optional array of tool definitions for function calling
85
+ # @return [Hash] The body for the API request
86
+ def self.generate_body(messages, model, json_schema, max_tokens, tools, tool_choice)
87
+ system_prompts, chat_messages = partition_system_and_chat(messages)
88
+
89
+ body = {
90
+ model: model,
91
+ max_tokens: max_tokens,
92
+ messages: chat_messages
93
+ }
94
+
95
+ # Join multiple system prompts into one. Anthropic supports a string here.
96
+ body[:system] = system_prompts.join("\n\n") unless system_prompts.empty?
97
+
98
+ # If a json_schema is provided, transform it into a "virtual" tool and force its use via tool_choice (unless already provided).
99
+ if json_schema
100
+ # Normalize schema input: accept anthropic-style { json_schema: { name:, schema:, strict: } },
101
+ # OpenAI-like { name:, schema:, strict: }, or a raw schema object.
102
+ if json_schema.is_a?(Hash) && (json_schema.key?(:json_schema) || json_schema.key?("json_schema"))
103
+ schema_payload = json_schema[:json_schema] || json_schema["json_schema"]
104
+ schema_name = (schema_payload[:name] || schema_payload["name"] || "structured_output").to_s
105
+ schema_object = schema_payload[:schema] || schema_payload["schema"] || schema_payload
106
+ else
107
+ schema_name = (json_schema.is_a?(Hash) && (json_schema[:name] || json_schema["name"])) || "structured_output"
108
+ schema_object = (json_schema.is_a?(Hash) && (json_schema[:schema] || json_schema["schema"])) || json_schema
109
+ end
110
+
111
+ schema_tool = {
112
+ name: schema_name,
113
+ description: "Return a JSON object that strictly follows the provided input_schema.",
114
+ input_schema: schema_object
115
+ }
116
+
117
+ # Merge with any user-provided tools. Prefer a single tool by default but don't drop existing tools.
118
+ existing_tools = tools || []
119
+ body[:tools] = [schema_tool] + existing_tools
120
+
121
+ # If the caller didn't specify tool_choice, force using the schema tool.
122
+ body[:tool_choice] = { type: 'tool', name: schema_name } unless tool_choice
123
+ end
124
+
125
+ body[:tools] = tools if tools && !body.key?(:tools)
126
+ body[:tool_choice] = tool_choice if tool_choice
127
+
128
+ body
129
+ end
130
+
131
+ # Normalize content for Anthropic: preserve arrays/hashes (structured blocks), stringify otherwise
132
+ def self.normalize_content(content)
133
+ case content
134
+ when Array
135
+ content
136
+ when Hash
137
+ content
138
+ else
139
+ content.to_s
140
+ end
141
+ end
142
+
143
+ # Partition system messages and convert remaining into Anthropic-compatible messages
144
+ def self.partition_system_and_chat(messages)
145
+ system_prompts = []
146
+ chat_messages = []
147
+
148
+ messages.each do |msg|
149
+ role = (msg[:role] || msg['role']).to_s
150
+ content = msg[:content] || msg['content']
151
+
152
+ case role
153
+ when 'system'
154
+ system_prompts << content.to_s
155
+ when 'user', 'assistant'
156
+ chat_messages << { role: role, content: normalize_content(content) }
157
+ else
158
+ # Unknown role, treat as user to avoid API errors
159
+ chat_messages << { role: 'user', content: normalize_content(content) }
160
+ end
161
+ end
162
+
163
+ [system_prompts, chat_messages]
164
+ end
165
+
166
+ # Handles the API response, raising errors for specific cases and returning structured content otherwise
167
+ #
168
+ # @param response [Hash] The parsed API response
169
+ # @param schema_used [Boolean] Whether the request used a JSON schema (tools-based) and needs normalization
170
+ # @return [Hash] The relevant data based on the stop_reason
171
+ def self.handle_response(response, schema_used: false)
172
+ content_blocks = response['content'] || []
173
+ stop_reason = response['stop_reason']
174
+
175
+ text_content = content_blocks.select { |b| b['type'] == 'text' }.map { |b| b['text'] }.join
176
+ tool_uses = content_blocks.select { |b| b['type'] == 'tool_use' }
177
+
178
+ if stop_reason == 'max_tokens'
179
+ raise "Incomplete response: The completion was cut off due to token limit."
180
+ end
181
+
182
+ if stop_reason == 'refusal'
183
+ raise RefusalError, "Content filtered: The model's output was blocked due to policy violations."
184
+ end
185
+
186
+ # If a json_schema was provided and Claude produced a single tool_use with no text,
187
+ # treat it as structured JSON output and return the parsed object in :content.
188
+ if schema_used && tool_uses.length == 1 && (text_content.nil? || text_content.strip.empty?)
189
+ input = tool_uses.first['input']
190
+ return({ content: input }) if input.is_a?(Hash) || input.is_a?(Array)
191
+ end
192
+
193
+ if !tool_uses.empty?
194
+ return { tool_calls: tool_uses, content: text_content }
195
+ end
196
+
197
+ # Normal end of turn
198
+ if stop_reason == 'end_turn' || stop_reason.nil?
199
+ return { content: text_content }
200
+ end
201
+
202
+ # Handle unexpected stop reasons
203
+ raise "Unexpected stop_reason: #{stop_reason}"
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectre
4
+ module Claude
5
+ # Require each specific client file here
6
+ require_relative 'claude/completions'
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectre
4
+ # Define custom error classes here
5
+ class APIKeyNotConfiguredError < StandardError; end
6
+ class HostNotConfiguredError < StandardError; end
7
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Spectre
8
+ module Gemini
9
+ class Completions
10
+ # Using Google's OpenAI-compatible endpoint
11
+ API_URL = 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions'
12
+ DEFAULT_MODEL = 'gemini-2.5-flash'
13
+ DEFAULT_TIMEOUT = 60
14
+
15
+ # Class method to generate a completion based on user messages and optional tools
16
+ #
17
+ # @param messages [Array<Hash>] The conversation messages, each with a role and content
18
+ # @param model [String] The model to be used for generating completions, defaults to DEFAULT_MODEL
19
+ # @param json_schema [Hash, nil] An optional JSON schema to enforce structured output (OpenAI-compatible "response_format")
20
+ # @param tools [Array<Hash>, nil] An optional array of tool definitions for function calling
21
+ # @param args [Hash, nil] optional arguments like read_timeout and open_timeout. For Gemini, max_tokens can be passed in the gemini hash.
22
+ # @return [Hash] The parsed response including any function calls or content
23
+ # @raise [APIKeyNotConfiguredError] If the API key is not set
24
+ # @raise [RuntimeError] For general API errors or unexpected issues
25
+ def self.create(messages:, model: DEFAULT_MODEL, json_schema: nil, tools: nil, **args)
26
+ api_key = Spectre.gemini_configuration&.api_key
27
+ raise APIKeyNotConfiguredError, "API key is not configured" unless api_key
28
+
29
+ validate_messages!(messages)
30
+
31
+ uri = URI(API_URL)
32
+ http = Net::HTTP.new(uri.host, uri.port)
33
+ http.use_ssl = true
34
+ http.read_timeout = args.fetch(:read_timeout, DEFAULT_TIMEOUT)
35
+ http.open_timeout = args.fetch(:open_timeout, DEFAULT_TIMEOUT)
36
+
37
+ request = Net::HTTP::Post.new(uri.path, {
38
+ 'Content-Type' => 'application/json',
39
+ 'Authorization' => "Bearer #{api_key}"
40
+ })
41
+
42
+ max_tokens = args.dig(:gemini, :max_tokens)
43
+ request.body = generate_body(messages, model, json_schema, max_tokens, tools).to_json
44
+ response = http.request(request)
45
+
46
+ unless response.is_a?(Net::HTTPSuccess)
47
+ raise "Gemini API Error: #{response.code} - #{response.message}: #{response.body}"
48
+ end
49
+
50
+ parsed_response = JSON.parse(response.body)
51
+
52
+ handle_response(parsed_response)
53
+ rescue JSON::ParserError => e
54
+ raise "JSON Parse Error: #{e.message}"
55
+ end
56
+
57
+ private
58
+
59
+ # Validate the structure and content of the messages array.
60
+ def self.validate_messages!(messages)
61
+ unless messages.is_a?(Array) && messages.all? { |msg| msg.is_a?(Hash) }
62
+ raise ArgumentError, "Messages must be an array of message hashes."
63
+ end
64
+
65
+ if messages.empty?
66
+ raise ArgumentError, "Messages cannot be empty."
67
+ end
68
+
69
+ # Gemini's OpenAI-compatible chat endpoint requires that single-turn
70
+ # and general requests end with a user message. If not, return a clear error.
71
+ last_role = (messages.last[:role] || messages.last['role']).to_s
72
+ unless last_role == 'user'
73
+ raise ArgumentError, "Gemini: the last message must have role 'user'. Got '#{last_role}'."
74
+ end
75
+ end
76
+
77
+ # Helper method to generate the request body (OpenAI-compatible)
78
+ def self.generate_body(messages, model, json_schema, max_tokens, tools)
79
+ body = {
80
+ model: model,
81
+ messages: messages
82
+ }
83
+
84
+ body[:max_tokens] = max_tokens if max_tokens
85
+ body[:response_format] = { type: 'json_schema', json_schema: json_schema } if json_schema
86
+ body[:tools] = tools if tools
87
+
88
+ body
89
+ end
90
+
91
+ # Handles the API response, mirroring OpenAI semantics
92
+ def self.handle_response(response)
93
+ message = response.dig('choices', 0, 'message')
94
+ finish_reason = response.dig('choices', 0, 'finish_reason')
95
+
96
+ if message && message['refusal']
97
+ raise "Refusal: #{message['refusal']}"
98
+ end
99
+
100
+ if finish_reason == 'length'
101
+ raise "Incomplete response: The completion was cut off due to token limit."
102
+ end
103
+
104
+ if finish_reason == 'content_filter'
105
+ raise "Content filtered: The model's output was blocked due to policy violations."
106
+ end
107
+
108
+ if finish_reason == 'function_call' || finish_reason == 'tool_calls'
109
+ return { tool_calls: message['tool_calls'], content: message['content'] }
110
+ end
111
+
112
+ if finish_reason == 'stop'
113
+ return { content: message['content'] }
114
+ end
115
+
116
+ raise "Unexpected finish_reason: #{finish_reason}"
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Spectre
8
+ module Gemini
9
+ class Embeddings
10
+ # Using Google's OpenAI-compatible endpoint
11
+ API_URL = 'https://generativelanguage.googleapis.com/v1beta/openai/embeddings'
12
+ DEFAULT_MODEL = 'gemini-embedding-001'
13
+ DEFAULT_TIMEOUT = 60
14
+
15
+ # Generate embeddings for text
16
+ def self.create(text, model: DEFAULT_MODEL, **args)
17
+ api_key = Spectre.gemini_configuration&.api_key
18
+ raise APIKeyNotConfiguredError, "API key is not configured" unless api_key
19
+
20
+ uri = URI(API_URL)
21
+ http = Net::HTTP.new(uri.host, uri.port)
22
+ http.use_ssl = true
23
+ http.read_timeout = args.fetch(:read_timeout, DEFAULT_TIMEOUT)
24
+ http.open_timeout = args.fetch(:open_timeout, DEFAULT_TIMEOUT)
25
+
26
+ request = Net::HTTP::Post.new(uri.path, {
27
+ 'Content-Type' => 'application/json',
28
+ 'Authorization' => "Bearer #{api_key}"
29
+ })
30
+
31
+ request.body = { model: model, input: text }.to_json
32
+ response = http.request(request)
33
+
34
+ unless response.is_a?(Net::HTTPSuccess)
35
+ raise "Gemini API Error: #{response.code} - #{response.message}: #{response.body}"
36
+ end
37
+
38
+ JSON.parse(response.body).dig('data', 0, 'embedding')
39
+ rescue JSON::ParserError => e
40
+ raise "JSON Parse Error: #{e.message}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectre
4
+ module Gemini
5
+ require_relative 'gemini/completions'
6
+ require_relative 'gemini/embeddings'
7
+ end
8
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Spectre
8
+ module Ollama
9
+ class Completions
10
+ API_PATH = 'api/chat'
11
+ DEFAULT_MODEL = 'llama3.1:8b'
12
+ DEFAULT_TIMEOUT = 60
13
+
14
+ # Class method to generate a completion based on user messages and optional tools
15
+ #
16
+ # @param messages [Array<Hash>] The conversation messages, each with a role and content
17
+ # @param model [String] The model to be used for generating completions, defaults to DEFAULT_MODEL
18
+ # @param json_schema [Hash, nil] An optional JSON schema to enforce structured output
19
+ # @param tools [Array<Hash>, nil] An optional array of tool definitions for function calling
20
+ # @param args [Hash, nil] optional arguments like read_timeout and open_timeout. You can pass in the ollama hash to specify the path and options.
21
+ # @param args.ollama.path [String, nil] The path to the Ollama API endpoint, defaults to API_PATH
22
+ # @param args.ollama.options [Hash, nil] Additional model parameters listed in the documentation for the https://github.com/ollama/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values such as temperature
23
+ # @return [Hash] The parsed response including any function calls or content
24
+ # @raise [HostNotConfiguredError] If the API host is not set in the provider configuration.
25
+ # @raise [APIKeyNotConfiguredError] If the API key is not set
26
+ # @raise [RuntimeError] For general API errors or unexpected issues
27
+ def self.create(messages:, model: DEFAULT_MODEL, json_schema: nil, tools: nil, **args)
28
+ api_host = Spectre.ollama_configuration.host
29
+ api_key = Spectre.ollama_configuration.api_key
30
+ raise HostNotConfiguredError, "Host is not configured" unless api_host
31
+ raise APIKeyNotConfiguredError, "API key is not configured" unless api_key
32
+
33
+ validate_messages!(messages)
34
+
35
+ path = args.dig(:ollama, :path) || API_PATH
36
+ uri = URI.join(api_host, path)
37
+ http = Net::HTTP.new(uri.host, uri.port)
38
+ http.use_ssl = true if uri.scheme == 'https'
39
+ http.read_timeout = args.fetch(:read_timeout, DEFAULT_TIMEOUT)
40
+ http.open_timeout = args.fetch(:open_timeout, DEFAULT_TIMEOUT)
41
+
42
+ request = Net::HTTP::Post.new(uri.path, {
43
+ 'Content-Type' => 'application/json',
44
+ 'Authorization' => "Bearer #{api_key}"
45
+ })
46
+
47
+ options = args.dig(:ollama, :options)
48
+ request.body = generate_body(messages, model, json_schema, tools, options).to_json
49
+ response = http.request(request)
50
+
51
+ unless response.is_a?(Net::HTTPSuccess)
52
+ raise "Ollama API Error: #{response.code} - #{response.message}: #{response.body}"
53
+ end
54
+
55
+ parsed_response = JSON.parse(response.body)
56
+
57
+ handle_response(parsed_response)
58
+ rescue JSON::ParserError => e
59
+ raise "JSON Parse Error: #{e.message}"
60
+ end
61
+
62
+ private
63
+
64
+ # Validate the structure and content of the messages array.
65
+ #
66
+ # @param messages [Array<Hash>] The array of message hashes to validate.
67
+ #
68
+ # @raise [ArgumentError] if the messages array is not in the expected format or contains invalid data.
69
+ def self.validate_messages!(messages)
70
+ # Check if messages is an array of hashes.
71
+ # This ensures that the input is in the correct format for message processing.
72
+ unless messages.is_a?(Array) && messages.all? { |msg| msg.is_a?(Hash) }
73
+ raise ArgumentError, "Messages must be an array of message hashes."
74
+ end
75
+
76
+ # Check if the array is empty.
77
+ # This prevents requests with no messages, which would be invalid.
78
+ if messages.empty?
79
+ raise ArgumentError, "Messages cannot be empty."
80
+ end
81
+ end
82
+
83
+ # Helper method to generate the request body
84
+ #
85
+ # @param messages [Array<Hash>] The conversation messages, each with a role and content
86
+ # @param model [String] The model to be used for generating completions
87
+ # @param json_schema [Hash, nil] An optional JSON schema to enforce structured output
88
+ # @param tools [Array<Hash>, nil] An optional array of tool definitions for function calling
89
+ # @param options [Hash, nil] Additional model parameters listed in the documentation for the https://github.com/ollama/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values such as temperature
90
+ # @return [Hash] The body for the API request
91
+ def self.generate_body(messages, model, json_schema, tools, options)
92
+ body = {
93
+ model: model,
94
+ stream: false,
95
+ messages: messages
96
+ }
97
+
98
+ # Extract schema if json_schema follows OpenAI's structure
99
+ if json_schema.is_a?(Hash) && json_schema.key?(:schema)
100
+ body[:format] = json_schema[:schema] # Use only the "schema" key
101
+ elsif json_schema.is_a?(Hash)
102
+ body[:format] = json_schema # Use the schema as-is if it doesn't follow OpenAI's structure
103
+ end
104
+
105
+ body[:tools] = tools if tools # Add the tools to the request body if provided
106
+ body[:options] = options if options
107
+
108
+ body
109
+ end
110
+
111
+ # Handles the API response, raising errors for specific cases and returning structured content otherwise
112
+ #
113
+ # @param response [Hash] The parsed API response
114
+ # @return [Hash] The relevant data based on the finish reason
115
+ def self.handle_response(response)
116
+ message = response.dig('message')
117
+ finish_reason = response.dig('done_reason')
118
+ done = response.dig('done')
119
+
120
+ # Check if the model made a function call
121
+ if message['tool_calls'] && !message['tool_calls'].empty?
122
+ return { tool_calls: message['tool_calls'], content: message['content'] }
123
+ end
124
+
125
+ # If the response finished normally, return the content
126
+ if done
127
+ return { content: message['content'] }
128
+ end
129
+
130
+ # Handle unexpected finish reasons
131
+ raise "Unexpected finish_reason: #{finish_reason}, done: #{done}, message: #{message}"
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Spectre
8
+ module Ollama
9
+ class Embeddings
10
+ API_PATH = 'api/embeddings'
11
+ DEFAULT_MODEL = 'nomic-embed-text'
12
+ PARAM_NAME = 'prompt'
13
+ DEFAULT_TIMEOUT = 60
14
+
15
+ # Class method to generate embeddings for a given text
16
+ #
17
+ # @param text [String] the text input for which embeddings are to be generated
18
+ # @param model [String] the model to be used for generating embeddings, defaults to DEFAULT_MODEL
19
+ # @param args [Hash, nil] optional arguments like read_timeout and open_timeout
20
+ # @param args.ollama.path [String, nil] the API path, defaults to API_PATH
21
+ # @param args.ollama.param_name [String, nil] the parameter key for the text input, defaults to PARAM_NAME
22
+ # @return [Array<Float>] the generated embedding vector
23
+ # @raise [HostNotConfiguredError] if the host is not set in the configuration
24
+ # @raise [APIKeyNotConfiguredError] if the API key is not set in the configuration
25
+ # @raise [RuntimeError] for API errors or invalid responses
26
+ # @raise [JSON::ParserError] if the response cannot be parsed as JSON
27
+ def self.create(text, model: DEFAULT_MODEL, **args)
28
+ api_host = Spectre.ollama_configuration.host
29
+ api_key = Spectre.ollama_configuration.api_key
30
+ raise HostNotConfiguredError, "Host is not configured" unless api_host
31
+ raise APIKeyNotConfiguredError, "API key is not configured" unless api_key
32
+
33
+ path = args.dig(:ollama, :path) || API_PATH
34
+ uri = URI.join(api_host, path)
35
+ http = Net::HTTP.new(uri.host, uri.port)
36
+ http.use_ssl = true if uri.scheme == 'https'
37
+ http.read_timeout = args.fetch(:read_timeout, DEFAULT_TIMEOUT)
38
+ http.open_timeout = args.fetch(:open_timeout, DEFAULT_TIMEOUT)
39
+
40
+ request = Net::HTTP::Post.new(uri.path, {
41
+ 'Content-Type' => 'application/json',
42
+ 'Authorization' => "Bearer #{api_key}"
43
+ })
44
+
45
+ param_name = args.dig(:ollama, :param_name) || PARAM_NAME
46
+ request.body = { model: model, param_name => text }.to_json
47
+ response = http.request(request)
48
+
49
+ unless response.is_a?(Net::HTTPSuccess)
50
+ raise "Ollama API Error: #{response.code} - #{response.message}: #{response.body}"
51
+ end
52
+
53
+ JSON.parse(response.body).dig('embedding')
54
+ rescue JSON::ParserError => e
55
+ raise "JSON Parse Error: #{e.message}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectre
4
+ module Ollama
5
+ # Require each specific client file here
6
+ require_relative 'ollama/embeddings'
7
+ require_relative 'ollama/completions'
8
+ end
9
+ end
@@ -16,14 +16,13 @@ module Spectre
16
16
  # @param messages [Array<Hash>] The conversation messages, each with a role and content
17
17
  # @param model [String] The model to be used for generating completions, defaults to DEFAULT_MODEL
18
18
  # @param json_schema [Hash, nil] An optional JSON schema to enforce structured output
19
- # @param max_tokens [Integer] The maximum number of tokens for the completion (default: 50)
20
19
  # @param tools [Array<Hash>, nil] An optional array of tool definitions for function calling
21
- # @param args [Hash] Optional arguments like timeouts
20
+ # @param args [Hash, nil] optional arguments like read_timeout and open_timeout. For OpenAI, max_tokens can be passed in the openai hash.
22
21
  # @return [Hash] The parsed response including any function calls or content
23
22
  # @raise [APIKeyNotConfiguredError] If the API key is not set
24
23
  # @raise [RuntimeError] For general API errors or unexpected issues
25
- def self.create(messages:, model: DEFAULT_MODEL, json_schema: nil, max_tokens: nil, tools: nil, **args)
26
- api_key = Spectre.api_key
24
+ def self.create(messages:, model: DEFAULT_MODEL, json_schema: nil, tools: nil, **args)
25
+ api_key = Spectre.openai_configuration.api_key
27
26
  raise APIKeyNotConfiguredError, "API key is not configured" unless api_key
28
27
 
29
28
  validate_messages!(messages)
@@ -39,6 +38,7 @@ module Spectre
39
38
  'Authorization' => "Bearer #{api_key}"
40
39
  })
41
40
 
41
+ max_tokens = args.dig(:openai, :max_tokens)
42
42
  request.body = generate_body(messages, model, json_schema, max_tokens, tools).to_json
43
43
  response = http.request(request)
44
44
 
@@ -15,12 +15,12 @@ module Spectre
15
15
  #
16
16
  # @param text [String] the text input for which embeddings are to be generated
17
17
  # @param model [String] the model to be used for generating embeddings, defaults to DEFAULT_MODEL
18
- # # @param args [Hash] Optional arguments like timeouts
18
+ # @param args [Hash] optional arguments like read_timeout and open_timeout
19
19
  # @return [Array<Float>] the generated embedding vector
20
20
  # @raise [APIKeyNotConfiguredError] if the API key is not set
21
21
  # @raise [RuntimeError] for general API errors or unexpected issues
22
22
  def self.create(text, model: DEFAULT_MODEL, **args)
23
- api_key = Spectre.api_key
23
+ api_key = Spectre.openai_configuration.api_key
24
24
  raise APIKeyNotConfiguredError, "API key is not configured" unless api_key
25
25
 
26
26
  uri = URI(API_URL)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spectre # :nodoc:all
4
- VERSION = "1.1.4"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/spectre.rb CHANGED
@@ -4,16 +4,20 @@ require "spectre/version"
4
4
  require "spectre/embeddable"
5
5
  require 'spectre/searchable'
6
6
  require "spectre/openai"
7
+ require "spectre/ollama"
8
+ require "spectre/claude"
9
+ require "spectre/gemini"
7
10
  require "spectre/logging"
8
11
  require 'spectre/prompt'
12
+ require 'spectre/errors'
9
13
 
10
14
  module Spectre
11
- class APIKeyNotConfiguredError < StandardError; end
12
-
13
15
  VALID_LLM_PROVIDERS = {
14
16
  openai: Spectre::Openai,
17
+ ollama: Spectre::Ollama,
18
+ claude: Spectre::Claude,
19
+ gemini: Spectre::Gemini
15
20
  # cohere: Spectre::Cohere,
16
- # ollama: Spectre::Ollama
17
21
  }.freeze
18
22
 
19
23
  def self.included(base)
@@ -35,25 +39,93 @@ module Spectre
35
39
  end
36
40
  end
37
41
 
42
+ class Configuration
43
+ attr_accessor :default_llm_provider, :providers
44
+
45
+ def initialize
46
+ @providers = {}
47
+ end
48
+
49
+ def openai
50
+ @providers[:openai] ||= OpenaiConfiguration.new
51
+ yield @providers[:openai] if block_given?
52
+ end
53
+
54
+ def ollama
55
+ @providers[:ollama] ||= OllamaConfiguration.new
56
+ yield @providers[:ollama] if block_given?
57
+ end
58
+
59
+ def claude
60
+ @providers[:claude] ||= ClaudeConfiguration.new
61
+ yield @providers[:claude] if block_given?
62
+ end
63
+
64
+ def gemini
65
+ @providers[:gemini] ||= GeminiConfiguration.new
66
+ yield @providers[:gemini] if block_given?
67
+ end
68
+
69
+ def provider_configuration
70
+ providers[default_llm_provider] || raise("No configuration found for provider: #{default_llm_provider}")
71
+ end
72
+ end
73
+
74
+ class OpenaiConfiguration
75
+ attr_accessor :api_key
76
+ end
77
+
78
+ class OllamaConfiguration
79
+ attr_accessor :host, :api_key
80
+ end
81
+
82
+ class ClaudeConfiguration
83
+ attr_accessor :api_key
84
+ end
85
+
86
+ class GeminiConfiguration
87
+ attr_accessor :api_key
88
+ end
89
+
38
90
  class << self
39
- attr_accessor :api_key, :llm_provider
91
+ attr_accessor :config
40
92
 
41
93
  def setup
42
- yield self
94
+ self.config ||= Configuration.new
95
+ yield config
43
96
  validate_llm_provider!
44
97
  end
45
98
 
46
99
  def provider_module
47
- VALID_LLM_PROVIDERS[llm_provider] || raise("LLM provider #{llm_provider} not supported")
100
+ VALID_LLM_PROVIDERS[config.default_llm_provider] || raise("LLM provider #{config.default_llm_provider} not supported")
101
+ end
102
+
103
+ def provider_configuration
104
+ config.provider_configuration
105
+ end
106
+
107
+ def openai_configuration
108
+ config.providers[:openai]
109
+ end
110
+
111
+ def ollama_configuration
112
+ config.providers[:ollama]
113
+ end
114
+
115
+ def claude_configuration
116
+ config.providers[:claude]
117
+ end
118
+
119
+ def gemini_configuration
120
+ config.providers[:gemini]
48
121
  end
49
122
 
50
123
  private
51
124
 
52
125
  def validate_llm_provider!
53
- unless VALID_LLM_PROVIDERS.keys.include?(llm_provider)
54
- raise ArgumentError, "Invalid llm_provider: #{llm_provider}. Must be one of: #{VALID_LLM_PROVIDERS.keys.join(', ')}"
126
+ unless VALID_LLM_PROVIDERS.keys.include?(config.default_llm_provider)
127
+ raise ArgumentError, "Invalid default_llm_provider: #{config.default_llm_provider}. Must be one of: #{VALID_LLM_PROVIDERS.keys.join(', ')}"
55
128
  end
56
129
  end
57
-
58
130
  end
59
131
  end
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spectre_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.4
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ilya Klapatok
8
8
  - Matthew Black
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2024-12-04 00:00:00.000000000 Z
12
+ date: 2025-09-24 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rspec-rails
@@ -53,8 +53,17 @@ files:
53
53
  - lib/generators/spectre/templates/rag/user.yml.erb
54
54
  - lib/generators/spectre/templates/spectre_initializer.rb
55
55
  - lib/spectre.rb
56
+ - lib/spectre/claude.rb
57
+ - lib/spectre/claude/completions.rb
56
58
  - lib/spectre/embeddable.rb
59
+ - lib/spectre/errors.rb
60
+ - lib/spectre/gemini.rb
61
+ - lib/spectre/gemini/completions.rb
62
+ - lib/spectre/gemini/embeddings.rb
57
63
  - lib/spectre/logging.rb
64
+ - lib/spectre/ollama.rb
65
+ - lib/spectre/ollama/completions.rb
66
+ - lib/spectre/ollama/embeddings.rb
58
67
  - lib/spectre/openai.rb
59
68
  - lib/spectre/openai/completions.rb
60
69
  - lib/spectre/openai/embeddings.rb
@@ -65,7 +74,7 @@ homepage: https://github.com/hiremav/spectre
65
74
  licenses:
66
75
  - MIT
67
76
  metadata: {}
68
- post_install_message:
77
+ post_install_message:
69
78
  rdoc_options: []
70
79
  require_paths:
71
80
  - lib
@@ -81,7 +90,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
81
90
  version: '0'
82
91
  requirements: []
83
92
  rubygems_version: 3.5.11
84
- signing_key:
93
+ signing_key:
85
94
  specification_version: 4
86
95
  summary: Spectre
87
96
  test_files: []