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 +4 -4
- data/CHANGELOG.md +142 -1
- data/README.md +112 -24
- data/lib/generators/spectre/templates/spectre_initializer.rb +19 -4
- data/lib/spectre/claude/completions.rb +207 -0
- data/lib/spectre/claude.rb +8 -0
- data/lib/spectre/errors.rb +7 -0
- data/lib/spectre/gemini/completions.rb +120 -0
- data/lib/spectre/gemini/embeddings.rb +44 -0
- data/lib/spectre/gemini.rb +8 -0
- data/lib/spectre/ollama/completions.rb +135 -0
- data/lib/spectre/ollama/embeddings.rb +59 -0
- data/lib/spectre/ollama.rb +9 -0
- data/lib/spectre/openai/completions.rb +4 -4
- data/lib/spectre/openai/embeddings.rb +2 -2
- data/lib/spectre/version.rb +1 -1
- data/lib/spectre.rb +81 -9
- metadata +14 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '03910c4dd38bf7a272fab91c0e9d1431d0f9bdc593abe62035271e9e07f22e89'
|
4
|
+
data.tar.gz: 0f1e927a42785d2f4735e4adf9140efad6815247fca6ce6270ac10ef286ae217
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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 |
|
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
|
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
|
-
###
|
40
|
+
### 🔧 Configuration
|
41
41
|
|
42
|
-
First, you’ll need to generate 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
|
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.
|
53
|
-
|
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
|
-
###
|
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
|
-
###
|
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,
|
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
|
-
**
|
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
|
-
|
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,
|
7
|
-
config.
|
8
|
-
|
9
|
-
config.
|
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,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,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
|
@@ -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]
|
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,
|
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
|
-
#
|
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)
|
data/lib/spectre/version.rb
CHANGED
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 :
|
91
|
+
attr_accessor :config
|
40
92
|
|
41
93
|
def setup
|
42
|
-
|
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[
|
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?(
|
54
|
-
raise ArgumentError, "Invalid
|
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:
|
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:
|
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: []
|