spectre_ai 2.0.0 → 2.1.1
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 +143 -0
- data/README.md +72 -0
- data/lib/spectre/claude/completions.rb +12 -4
- data/lib/spectre/gemini/completions.rb +12 -4
- data/lib/spectre/ollama/completions.rb +11 -5
- data/lib/spectre/openai/completions.rb +12 -4
- data/lib/spectre/openrouter/completions.rb +113 -0
- data/lib/spectre/openrouter/embeddings.rb +54 -0
- data/lib/spectre/openrouter.rb +9 -0
- data/lib/spectre/version.rb +1 -1
- data/lib/spectre.rb +17 -2
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83d3a297e011e019679dcb12edb0c00f3bb73c6dc599378923627ef257ff6f1f
|
|
4
|
+
data.tar.gz: 79def4a06049ed718bf0c66ffc7f0a29016d8a0831ab0941cc9c02f0b451ab03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 18cea546ff80840b1cb6087b35305a6efdf17fd8c6b6bea565157336958b02105822bb5725d7bb8e23a170362105506143992feb52ba6834986ced9fe87ab21d
|
|
7
|
+
data.tar.gz: 2397f388fbab927a41b959c92364f2cbbc50e8d6cebe1ed4a0ea33b631fa0d2e635687d349f523d5f20d88f414d176f9b297163337ed3632232cb520f04040f9
|
data/CHANGELOG.md
CHANGED
|
@@ -280,3 +280,146 @@ Key Benefits:\
|
|
|
280
280
|
### Behavior Notes
|
|
281
281
|
|
|
282
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.
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# Changelog for Version 2.1.0
|
|
286
|
+
|
|
287
|
+
**Release Date:** [12th Nov 2025]
|
|
288
|
+
|
|
289
|
+
### New Provider: OpenRouter
|
|
290
|
+
|
|
291
|
+
- Added Spectre::Openrouter provider with:
|
|
292
|
+
- Chat Completions via `https://openrouter.ai/api/v1/chat/completions` (OpenAI-compatible interface).
|
|
293
|
+
- Embeddings via `https://openrouter.ai/api/v1/embeddings`.
|
|
294
|
+
- Provider configuration: `Spectre.setup { |c| c.openrouter { |o| o.api_key = ENV['OPENROUTER_API_KEY']; o.referer = 'https://your.app' ; o.app_title = 'Your App' } }`.
|
|
295
|
+
- Optional headers supported: `HTTP-Referer` and `X-Title` (as recommended by OpenRouter).
|
|
296
|
+
- Finish reasons handled per OpenRouter docs: `stop`, `tool_calls`/`function_call`, `length`/`model_length`, `content_filter`, `error`.
|
|
297
|
+
- Refusal handling (raises an error if the model returns a refusal).
|
|
298
|
+
|
|
299
|
+
### Structured Outputs (json_schema)
|
|
300
|
+
|
|
301
|
+
- OpenRouter completions support OpenAI-style `response_format: { type: 'json_schema', json_schema: ... }`.
|
|
302
|
+
- Note for schema authors: many OpenRouter-backed providers require a strict schema:
|
|
303
|
+
- Include a non-empty `required` array listing all keys in `properties`.
|
|
304
|
+
- Consider `strict: true` and `additionalProperties: false` for best adherence.
|
|
305
|
+
|
|
306
|
+
### Tests
|
|
307
|
+
|
|
308
|
+
- Added RSpec tests for `Spectre::Openrouter::Completions` and `Spectre::Openrouter::Embeddings` covering:
|
|
309
|
+
- Success responses, error propagation, JSON parse errors.
|
|
310
|
+
- Finish reasons and refusal handling.
|
|
311
|
+
|
|
312
|
+
# Changelog for Version 2.1.1
|
|
313
|
+
|
|
314
|
+
**Release Date:** [15th Dec 2025]
|
|
315
|
+
|
|
316
|
+
### Enhancements: Extra generation options for Completions
|
|
317
|
+
|
|
318
|
+
- You can now pass additional generation options (e.g., `temperature`, `top_p`, `presence_penalty`) directly as keyword arguments to all `Completions.create` methods.
|
|
319
|
+
- For OpenAI, OpenRouter, Gemini, and Claude these extra kwargs are forwarded into the request body automatically.
|
|
320
|
+
- For Ollama, pass extra kwargs at the top level just like other providers. Spectre maps them into `body[:options]` internally (including `max_tokens`). The legacy `ollama: { options: ... }` is now ignored.
|
|
321
|
+
|
|
322
|
+
### Notes and exclusions
|
|
323
|
+
|
|
324
|
+
- Control/network keys are not forwarded: `read_timeout`, `open_timeout`.
|
|
325
|
+
- `max_tokens` remains supported:
|
|
326
|
+
- OpenAI/OpenRouter/Gemini/Claude: stays a top‑level request body field.
|
|
327
|
+
- Ollama: forwarded into `:options` along with other generation kwargs.
|
|
328
|
+
- Claude: `tool_choice` is NOT auto‑forwarded from extra kwargs; pass it explicitly via the dedicated parameter if needed.
|
|
329
|
+
|
|
330
|
+
### Examples
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
# OpenAI
|
|
334
|
+
Spectre::Openai::Completions.create(
|
|
335
|
+
messages: [ { role: 'user', content: 'Hi' } ],
|
|
336
|
+
model: 'gpt-4o-mini',
|
|
337
|
+
temperature: 0.1,
|
|
338
|
+
top_p: 0.9,
|
|
339
|
+
max_tokens: 512
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# OpenRouter
|
|
343
|
+
Spectre::Openrouter::Completions.create(
|
|
344
|
+
messages: [ { role: 'user', content: 'Hi' } ],
|
|
345
|
+
model: 'openai/gpt-4o-mini',
|
|
346
|
+
temperature: 0.1,
|
|
347
|
+
presence_penalty: 0.2,
|
|
348
|
+
max_tokens: 256
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Gemini (OpenAI‑compatible endpoint)
|
|
352
|
+
Spectre::Gemini::Completions.create(
|
|
353
|
+
messages: [ { role: 'user', content: 'Hi' } ],
|
|
354
|
+
model: 'gemini-2.5-flash',
|
|
355
|
+
temperature: 0.1,
|
|
356
|
+
max_tokens: 256
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Claude
|
|
360
|
+
Spectre::Claude::Completions.create(
|
|
361
|
+
messages: [ { role: 'user', content: 'Hi' } ],
|
|
362
|
+
model: 'claude-opus-4-1',
|
|
363
|
+
temperature: 0.1,
|
|
364
|
+
max_tokens: 512,
|
|
365
|
+
tool_choice: { type: 'auto' } # pass explicitly when needed
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Ollama — pass options at top level; Spectre maps them to body[:options]
|
|
369
|
+
Spectre::Ollama::Completions.create(
|
|
370
|
+
messages: [ { role: 'user', content: 'Hi' } ],
|
|
371
|
+
model: 'llama3.1:8b',
|
|
372
|
+
temperature: 0.1,
|
|
373
|
+
max_tokens: 256, # forwarded into body[:options]
|
|
374
|
+
path: 'api/chat' # optional: override endpoint path
|
|
375
|
+
)
|
|
376
|
+
## Note: `ollama: { options: ... }` is ignored; use top-level kwargs instead.
|
|
377
|
+
```
|
|
378
|
+
- Request body formation (max_tokens, tools, response_format.json_schema).
|
|
379
|
+
|
|
380
|
+
### OpenRouter: Plugins support in chat completions
|
|
381
|
+
|
|
382
|
+
- Added pass-through support for OpenRouter Plugins in chat completions.
|
|
383
|
+
- You can pass the `plugins` array directly to `Spectre::Openrouter::Completions.create`, and it will be included in the request body.
|
|
384
|
+
|
|
385
|
+
Example:
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
Spectre::Openrouter::Completions.create(
|
|
389
|
+
messages: [ { role: 'user', content: 'Heal my response if needed' } ],
|
|
390
|
+
model: 'openai/gpt-4o-mini',
|
|
391
|
+
plugins: [ { id: 'response-healing' } ],
|
|
392
|
+
temperature: 0.2,
|
|
393
|
+
max_tokens: 256
|
|
394
|
+
)
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Docs: https://openrouter.ai/docs/guides/features/plugins/overview
|
|
398
|
+
|
|
399
|
+
### Breaking Changes
|
|
400
|
+
|
|
401
|
+
- Unified `max_tokens` option across providers:
|
|
402
|
+
- Now accepted only as a top-level argument: `... Completions.create(messages: ..., max_tokens: 256)`.
|
|
403
|
+
- Removed support for provider-scoped forms like `openai: { max_tokens: ... }`, `openrouter: { max_tokens: ... }`, `claude: { max_tokens: ... }`, `gemini: { max_tokens: ... }`.
|
|
404
|
+
|
|
405
|
+
### Usage Examples
|
|
406
|
+
|
|
407
|
+
- OpenRouter (completions):
|
|
408
|
+
```ruby
|
|
409
|
+
Spectre.setup do |c|
|
|
410
|
+
c.default_llm_provider = :openrouter
|
|
411
|
+
c.openrouter { |o| o.api_key = ENV['OPENROUTER_API_KEY'] }
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
Spectre::Openrouter::Completions.create(
|
|
415
|
+
messages: [ { role: 'user', content: 'Hello!' } ],
|
|
416
|
+
model: 'openai/gpt-4o-mini',
|
|
417
|
+
max_tokens: 256
|
|
418
|
+
)
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
- OpenRouter (embeddings):
|
|
422
|
+
```ruby
|
|
423
|
+
Spectre::Openrouter::Embeddings.create('some text', model: 'text-embedding-3-small')
|
|
424
|
+
```
|
|
425
|
+
|
data/README.md
CHANGED
|
@@ -224,6 +224,78 @@ Spectre.provider_module::Completions.create(
|
|
|
224
224
|
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
+
#### Passing extra generation options (temperature, top_p, etc.)
|
|
228
|
+
|
|
229
|
+
You can pass common generation options directly as keyword arguments to `Completions.create`.
|
|
230
|
+
|
|
231
|
+
- OpenAI/OpenRouter/Gemini/Claude: extra kwargs are forwarded into the request body (e.g., `temperature`, `top_p`, `presence_penalty`).
|
|
232
|
+
- Ollama: pass extra kwargs the same way (top-level). Spectre will put them into `body[:options]` internally (including `max_tokens`). The `ollama: { options: ... }` hash is no longer used.
|
|
233
|
+
- Excluded control keys: `read_timeout`, `open_timeout` are never forwarded.
|
|
234
|
+
- Provider differences for `max_tokens`:
|
|
235
|
+
- OpenAI/OpenRouter/Gemini/Claude: `max_tokens` is a top‑level field in the request body.
|
|
236
|
+
- Ollama: `max_tokens` is forwarded into `body[:options]`.
|
|
237
|
+
- Claude: `tool_choice` is not auto‑forwarded; provide it explicitly via the `tool_choice:` parameter when needed.
|
|
238
|
+
|
|
239
|
+
Examples:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
# OpenAI
|
|
243
|
+
Spectre::Openai::Completions.create(
|
|
244
|
+
messages: [ { role: 'user', content: 'Hi' } ],
|
|
245
|
+
model: 'gpt-4o-mini',
|
|
246
|
+
temperature: 0.1,
|
|
247
|
+
top_p: 0.9,
|
|
248
|
+
max_tokens: 512
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# OpenRouter
|
|
252
|
+
Spectre::Openrouter::Completions.create(
|
|
253
|
+
messages: [ { role: 'user', content: 'Hi' } ],
|
|
254
|
+
model: 'openai/gpt-4o-mini',
|
|
255
|
+
temperature: 0.1,
|
|
256
|
+
presence_penalty: 0.2,
|
|
257
|
+
max_tokens: 256
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# OpenRouter with Plugins
|
|
261
|
+
# Docs: https://openrouter.ai/docs/guides/features/plugins/overview
|
|
262
|
+
Spectre::Openrouter::Completions.create(
|
|
263
|
+
messages: [ { role: 'user', content: 'Heal my response if needed' } ],
|
|
264
|
+
model: 'openai/gpt-4o-mini',
|
|
265
|
+
plugins: [ { id: 'response-healing' } ],
|
|
266
|
+
temperature: 0.2,
|
|
267
|
+
max_tokens: 256
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Gemini (OpenAI‑compatible endpoint)
|
|
271
|
+
Spectre::Gemini::Completions.create(
|
|
272
|
+
messages: [ { role: 'user', content: 'Hi' } ],
|
|
273
|
+
model: 'gemini-2.5-flash',
|
|
274
|
+
temperature: 0.1,
|
|
275
|
+
max_tokens: 256
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Claude
|
|
279
|
+
Spectre::Claude::Completions.create(
|
|
280
|
+
messages: [ { role: 'user', content: 'Hi' } ],
|
|
281
|
+
model: 'claude-opus-4-1',
|
|
282
|
+
temperature: 0.1,
|
|
283
|
+
max_tokens: 512,
|
|
284
|
+
tool_choice: { type: 'auto' } # pass explicitly when needed
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Ollama — pass options at top level (Spectre maps them to body[:options])
|
|
288
|
+
Spectre::Ollama::Completions.create(
|
|
289
|
+
messages: [ { role: 'user', content: 'Hi' } ],
|
|
290
|
+
model: 'llama3.1:8b',
|
|
291
|
+
temperature: 0.1, # forwarded into body[:options]
|
|
292
|
+
max_tokens: 256, # forwarded into body[:options]
|
|
293
|
+
path: 'api/chat' # optional: override endpoint path
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Note: `ollama: { options: ... }` is ignored; use top-level kwargs instead.
|
|
297
|
+
```
|
|
298
|
+
|
|
227
299
|
**Using a JSON Schema for Structured Output**
|
|
228
300
|
|
|
229
301
|
For cases where you need structured output (e.g., for returning specific fields or formatted responses), you can pass a `json_schema` parameter. The schema ensures that the completion conforms to a predefined structure:
|
|
@@ -21,7 +21,8 @@ module Spectre
|
|
|
21
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
22
|
# @param tools [Array<Hash>, nil] An optional array of tool definitions for function calling
|
|
23
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.
|
|
24
|
+
# @param args [Hash, nil] optional arguments like read_timeout and open_timeout. Provide max_tokens at the top level only.
|
|
25
|
+
# Any additional kwargs (e.g., temperature:, top_p:) will be forwarded into the request body.
|
|
25
26
|
# @return [Hash] The parsed response including any tool calls or content
|
|
26
27
|
# @raise [APIKeyNotConfiguredError] If the API key is not set
|
|
27
28
|
# @raise [RuntimeError] For general API errors or unexpected issues
|
|
@@ -43,8 +44,10 @@ module Spectre
|
|
|
43
44
|
'anthropic-version' => ANTHROPIC_VERSION
|
|
44
45
|
})
|
|
45
46
|
|
|
46
|
-
max_tokens = args
|
|
47
|
-
|
|
47
|
+
max_tokens = args[:max_tokens] || 1024
|
|
48
|
+
# Forward extra args (like temperature) into the body, excluding control/network keys
|
|
49
|
+
forwarded = args.reject { |k, _| [:read_timeout, :open_timeout, :max_tokens, :tool_choice].include?(k) }
|
|
50
|
+
request.body = generate_body(messages, model, json_schema, max_tokens, tools, tool_choice, forwarded).to_json
|
|
48
51
|
response = http.request(request)
|
|
49
52
|
|
|
50
53
|
unless response.is_a?(Net::HTTPSuccess)
|
|
@@ -83,7 +86,7 @@ module Spectre
|
|
|
83
86
|
# @param max_tokens [Integer] The maximum number of tokens for the completion
|
|
84
87
|
# @param tools [Array<Hash>, nil] An optional array of tool definitions for function calling
|
|
85
88
|
# @return [Hash] The body for the API request
|
|
86
|
-
def self.generate_body(messages, model, json_schema, max_tokens, tools, tool_choice)
|
|
89
|
+
def self.generate_body(messages, model, json_schema, max_tokens, tools, tool_choice, forwarded)
|
|
87
90
|
system_prompts, chat_messages = partition_system_and_chat(messages)
|
|
88
91
|
|
|
89
92
|
body = {
|
|
@@ -125,6 +128,11 @@ module Spectre
|
|
|
125
128
|
body[:tools] = tools if tools && !body.key?(:tools)
|
|
126
129
|
body[:tool_choice] = tool_choice if tool_choice
|
|
127
130
|
|
|
131
|
+
# Merge any extra forwarded options (e.g., temperature, top_p)
|
|
132
|
+
if forwarded && !forwarded.empty?
|
|
133
|
+
body.merge!(forwarded.transform_keys(&:to_sym))
|
|
134
|
+
end
|
|
135
|
+
|
|
128
136
|
body
|
|
129
137
|
end
|
|
130
138
|
|
|
@@ -18,7 +18,8 @@ module Spectre
|
|
|
18
18
|
# @param model [String] The model to be used for generating completions, defaults to DEFAULT_MODEL
|
|
19
19
|
# @param json_schema [Hash, nil] An optional JSON schema to enforce structured output (OpenAI-compatible "response_format")
|
|
20
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.
|
|
21
|
+
# @param args [Hash, nil] optional arguments like read_timeout and open_timeout. Provide max_tokens at the top level only.
|
|
22
|
+
# Any additional kwargs (e.g., temperature:, top_p:) will be forwarded into the request body.
|
|
22
23
|
# @return [Hash] The parsed response including any function calls or content
|
|
23
24
|
# @raise [APIKeyNotConfiguredError] If the API key is not set
|
|
24
25
|
# @raise [RuntimeError] For general API errors or unexpected issues
|
|
@@ -39,8 +40,10 @@ module Spectre
|
|
|
39
40
|
'Authorization' => "Bearer #{api_key}"
|
|
40
41
|
})
|
|
41
42
|
|
|
42
|
-
max_tokens = args
|
|
43
|
-
|
|
43
|
+
max_tokens = args[:max_tokens]
|
|
44
|
+
# Forward extra args (like temperature) into the body, excluding control/network keys
|
|
45
|
+
forwarded = args.reject { |k, _| [:read_timeout, :open_timeout, :max_tokens].include?(k) }
|
|
46
|
+
request.body = generate_body(messages, model, json_schema, max_tokens, tools, forwarded).to_json
|
|
44
47
|
response = http.request(request)
|
|
45
48
|
|
|
46
49
|
unless response.is_a?(Net::HTTPSuccess)
|
|
@@ -75,7 +78,7 @@ module Spectre
|
|
|
75
78
|
end
|
|
76
79
|
|
|
77
80
|
# Helper method to generate the request body (OpenAI-compatible)
|
|
78
|
-
def self.generate_body(messages, model, json_schema, max_tokens, tools)
|
|
81
|
+
def self.generate_body(messages, model, json_schema, max_tokens, tools, forwarded)
|
|
79
82
|
body = {
|
|
80
83
|
model: model,
|
|
81
84
|
messages: messages
|
|
@@ -85,6 +88,11 @@ module Spectre
|
|
|
85
88
|
body[:response_format] = { type: 'json_schema', json_schema: json_schema } if json_schema
|
|
86
89
|
body[:tools] = tools if tools
|
|
87
90
|
|
|
91
|
+
# Merge any extra forwarded options (e.g., temperature, top_p)
|
|
92
|
+
if forwarded && !forwarded.empty?
|
|
93
|
+
body.merge!(forwarded.transform_keys(&:to_sym))
|
|
94
|
+
end
|
|
95
|
+
|
|
88
96
|
body
|
|
89
97
|
end
|
|
90
98
|
|
|
@@ -17,9 +17,9 @@ module Spectre
|
|
|
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
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.
|
|
21
|
-
#
|
|
22
|
-
# @param
|
|
20
|
+
# @param args [Hash, nil] optional arguments like read_timeout and open_timeout.
|
|
21
|
+
# Any additional top-level kwargs (e.g., temperature:, max_tokens:) will be forwarded into body[:options], same as other providers forward into body.
|
|
22
|
+
# @param path [String, nil] Top-level path override for the Ollama API endpoint, defaults to API_PATH
|
|
23
23
|
# @return [Hash] The parsed response including any function calls or content
|
|
24
24
|
# @raise [HostNotConfiguredError] If the API host is not set in the provider configuration.
|
|
25
25
|
# @raise [APIKeyNotConfiguredError] If the API key is not set
|
|
@@ -32,7 +32,7 @@ module Spectre
|
|
|
32
32
|
|
|
33
33
|
validate_messages!(messages)
|
|
34
34
|
|
|
35
|
-
path = args
|
|
35
|
+
path = args[:path] || API_PATH
|
|
36
36
|
uri = URI.join(api_host, path)
|
|
37
37
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
38
38
|
http.use_ssl = true if uri.scheme == 'https'
|
|
@@ -44,7 +44,13 @@ module Spectre
|
|
|
44
44
|
'Authorization' => "Bearer #{api_key}"
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
# Forward extra top-level args (like temperature, max_tokens) into body[:options],
|
|
48
|
+
# excluding control/network keys and the request path override.
|
|
49
|
+
forwarded = args.reject { |k, _| [:read_timeout, :open_timeout, :path].include?(k) }
|
|
50
|
+
options = nil
|
|
51
|
+
if forwarded && !forwarded.empty?
|
|
52
|
+
options = forwarded.transform_keys(&:to_sym)
|
|
53
|
+
end
|
|
48
54
|
request.body = generate_body(messages, model, json_schema, tools, options).to_json
|
|
49
55
|
response = http.request(request)
|
|
50
56
|
|
|
@@ -17,7 +17,8 @@ module Spectre
|
|
|
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
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.
|
|
20
|
+
# @param args [Hash, nil] optional arguments like read_timeout and open_timeout. Provide max_tokens at the top level only.
|
|
21
|
+
# Any additional kwargs (e.g., temperature:, top_p:) will be forwarded into the request body.
|
|
21
22
|
# @return [Hash] The parsed response including any function calls or content
|
|
22
23
|
# @raise [APIKeyNotConfiguredError] If the API key is not set
|
|
23
24
|
# @raise [RuntimeError] For general API errors or unexpected issues
|
|
@@ -38,8 +39,10 @@ module Spectre
|
|
|
38
39
|
'Authorization' => "Bearer #{api_key}"
|
|
39
40
|
})
|
|
40
41
|
|
|
41
|
-
max_tokens = args
|
|
42
|
-
|
|
42
|
+
max_tokens = args[:max_tokens]
|
|
43
|
+
# Forward extra args (like temperature) into the body, excluding control/network keys
|
|
44
|
+
forwarded = args.reject { |k, _| [:read_timeout, :open_timeout, :max_tokens].include?(k) }
|
|
45
|
+
request.body = generate_body(messages, model, json_schema, max_tokens, tools, forwarded).to_json
|
|
43
46
|
response = http.request(request)
|
|
44
47
|
|
|
45
48
|
unless response.is_a?(Net::HTTPSuccess)
|
|
@@ -82,7 +85,7 @@ module Spectre
|
|
|
82
85
|
# @param max_tokens [Integer, nil] The maximum number of tokens for the completion
|
|
83
86
|
# @param tools [Array<Hash>, nil] An optional array of tool definitions for function calling
|
|
84
87
|
# @return [Hash] The body for the API request
|
|
85
|
-
def self.generate_body(messages, model, json_schema, max_tokens, tools)
|
|
88
|
+
def self.generate_body(messages, model, json_schema, max_tokens, tools, forwarded)
|
|
86
89
|
body = {
|
|
87
90
|
model: model,
|
|
88
91
|
messages: messages
|
|
@@ -92,6 +95,11 @@ module Spectre
|
|
|
92
95
|
body[:response_format] = { type: 'json_schema', json_schema: json_schema } if json_schema
|
|
93
96
|
body[:tools] = tools if tools # Add the tools to the request body if provided
|
|
94
97
|
|
|
98
|
+
# Merge any extra forwarded options (e.g., temperature, top_p)
|
|
99
|
+
if forwarded && !forwarded.empty?
|
|
100
|
+
body.merge!(forwarded.transform_keys(&:to_sym))
|
|
101
|
+
end
|
|
102
|
+
|
|
95
103
|
body
|
|
96
104
|
end
|
|
97
105
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Spectre
|
|
8
|
+
module Openrouter
|
|
9
|
+
class Completions
|
|
10
|
+
API_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
|
11
|
+
DEFAULT_MODEL = 'openai/gpt-4o-mini'
|
|
12
|
+
DEFAULT_TIMEOUT = 60
|
|
13
|
+
|
|
14
|
+
# 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
|
|
18
|
+
# @param json_schema [Hash, nil] An optional JSON schema to enforce structured output (OpenAI-compatible)
|
|
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. Provide max_tokens at the top level only.
|
|
21
|
+
# Any additional kwargs (e.g., temperature:, top_p:) will be forwarded into the request body.
|
|
22
|
+
# @return [Hash] The parsed response including any tool 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
|
+
cfg = Spectre.openrouter_configuration
|
|
27
|
+
api_key = cfg&.api_key
|
|
28
|
+
raise APIKeyNotConfiguredError, 'API key is not configured' unless api_key
|
|
29
|
+
|
|
30
|
+
validate_messages!(messages)
|
|
31
|
+
|
|
32
|
+
uri = URI(API_URL)
|
|
33
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
34
|
+
http.use_ssl = true
|
|
35
|
+
http.read_timeout = args.fetch(:read_timeout, DEFAULT_TIMEOUT)
|
|
36
|
+
http.open_timeout = args.fetch(:open_timeout, DEFAULT_TIMEOUT)
|
|
37
|
+
|
|
38
|
+
headers = {
|
|
39
|
+
'Content-Type' => 'application/json',
|
|
40
|
+
'Authorization' => "Bearer #{api_key}"
|
|
41
|
+
}
|
|
42
|
+
headers['HTTP-Referer'] = cfg.referer if cfg.respond_to?(:referer) && cfg.referer
|
|
43
|
+
headers['X-Title'] = cfg.app_title if cfg.respond_to?(:app_title) && cfg.app_title
|
|
44
|
+
|
|
45
|
+
request = Net::HTTP::Post.new(uri.path, headers)
|
|
46
|
+
|
|
47
|
+
max_tokens = args[:max_tokens]
|
|
48
|
+
# Forward extra args into body, excluding control/network keys
|
|
49
|
+
forwarded = args.reject { |k, _| [:read_timeout, :open_timeout, :max_tokens].include?(k) }
|
|
50
|
+
request.body = generate_body(messages, model, json_schema, max_tokens, tools, forwarded).to_json
|
|
51
|
+
response = http.request(request)
|
|
52
|
+
|
|
53
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
54
|
+
raise "OpenRouter API Error: #{response.code} - #{response.message}: #{response.body}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
parsed_response = JSON.parse(response.body)
|
|
58
|
+
handle_response(parsed_response)
|
|
59
|
+
rescue JSON::ParserError => e
|
|
60
|
+
raise "JSON Parse Error: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def self.validate_messages!(messages)
|
|
66
|
+
unless messages.is_a?(Array) && messages.all? { |msg| msg.is_a?(Hash) }
|
|
67
|
+
raise ArgumentError, 'Messages must be an array of message hashes.'
|
|
68
|
+
end
|
|
69
|
+
raise ArgumentError, 'Messages cannot be empty.' if messages.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.generate_body(messages, model, json_schema, max_tokens, tools, forwarded)
|
|
73
|
+
body = {
|
|
74
|
+
model: model,
|
|
75
|
+
messages: messages
|
|
76
|
+
}
|
|
77
|
+
body[:max_tokens] = max_tokens if max_tokens
|
|
78
|
+
body[:response_format] = { type: 'json_schema', json_schema: json_schema } if json_schema
|
|
79
|
+
body[:tools] = tools if tools
|
|
80
|
+
if forwarded && !forwarded.empty?
|
|
81
|
+
body.merge!(forwarded.transform_keys(&:to_sym))
|
|
82
|
+
end
|
|
83
|
+
body
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Handle OpenRouter finish reasons
|
|
87
|
+
# https://openrouter.ai/docs/api-reference/overview#finish-reason
|
|
88
|
+
def self.handle_response(response)
|
|
89
|
+
message = response.dig('choices', 0, 'message') || {}
|
|
90
|
+
finish_reason = response.dig('choices', 0, 'finish_reason')
|
|
91
|
+
|
|
92
|
+
if message['refusal']
|
|
93
|
+
raise "Refusal: #{message['refusal']}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
case finish_reason
|
|
97
|
+
when 'stop'
|
|
98
|
+
return { content: message['content'] }
|
|
99
|
+
when 'tool_calls', 'function_call'
|
|
100
|
+
return { tool_calls: message['tool_calls'], content: message['content'] }
|
|
101
|
+
when 'length', 'model_length'
|
|
102
|
+
raise 'Incomplete response: The completion was cut off due to token limit.'
|
|
103
|
+
when 'content_filter'
|
|
104
|
+
raise "Content filtered: The model's output was blocked due to policy violations."
|
|
105
|
+
when 'error'
|
|
106
|
+
raise "Model returned finish_reason=error: #{response.inspect}"
|
|
107
|
+
else
|
|
108
|
+
raise "Unexpected finish_reason: #{finish_reason}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Spectre
|
|
8
|
+
module Openrouter
|
|
9
|
+
class Embeddings
|
|
10
|
+
API_URL = 'https://openrouter.ai/api/v1/embeddings'
|
|
11
|
+
DEFAULT_MODEL = 'text-embedding-3-small' # OpenRouter proxies OpenAI and others; user can override with provider/model
|
|
12
|
+
DEFAULT_TIMEOUT = 60
|
|
13
|
+
|
|
14
|
+
# Generate embeddings for a given text
|
|
15
|
+
#
|
|
16
|
+
# @param text [String] the text input for which embeddings are to be generated
|
|
17
|
+
# @param model [String] the model to be used for generating embeddings, defaults to DEFAULT_MODEL
|
|
18
|
+
# @param args [Hash] optional arguments like read_timeout and open_timeout
|
|
19
|
+
# @return [Array<Float>] the generated embedding vector
|
|
20
|
+
# @raise [APIKeyNotConfiguredError] if the API key is not set
|
|
21
|
+
# @raise [RuntimeError] for general API errors or unexpected issues
|
|
22
|
+
def self.create(text, model: DEFAULT_MODEL, **args)
|
|
23
|
+
cfg = Spectre.openrouter_configuration
|
|
24
|
+
api_key = cfg&.api_key
|
|
25
|
+
raise APIKeyNotConfiguredError, 'API key is not configured' unless api_key
|
|
26
|
+
|
|
27
|
+
uri = URI(API_URL)
|
|
28
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
29
|
+
http.use_ssl = true
|
|
30
|
+
http.read_timeout = args.fetch(:read_timeout, DEFAULT_TIMEOUT)
|
|
31
|
+
http.open_timeout = args.fetch(:open_timeout, DEFAULT_TIMEOUT)
|
|
32
|
+
|
|
33
|
+
headers = {
|
|
34
|
+
'Content-Type' => 'application/json',
|
|
35
|
+
'Authorization' => "Bearer #{api_key}"
|
|
36
|
+
}
|
|
37
|
+
headers['HTTP-Referer'] = cfg.referer if cfg.respond_to?(:referer) && cfg.referer
|
|
38
|
+
headers['X-Title'] = cfg.app_title if cfg.respond_to?(:app_title) && cfg.app_title
|
|
39
|
+
|
|
40
|
+
request = Net::HTTP::Post.new(uri.path, headers)
|
|
41
|
+
request.body = { model: model, input: text }.to_json
|
|
42
|
+
response = http.request(request)
|
|
43
|
+
|
|
44
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
45
|
+
raise "OpenRouter API Error: #{response.code} - #{response.message}: #{response.body}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
JSON.parse(response.body).dig('data', 0, 'embedding')
|
|
49
|
+
rescue JSON::ParserError => e
|
|
50
|
+
raise "JSON Parse Error: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/spectre/version.rb
CHANGED
data/lib/spectre.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "spectre/openai"
|
|
|
7
7
|
require "spectre/ollama"
|
|
8
8
|
require "spectre/claude"
|
|
9
9
|
require "spectre/gemini"
|
|
10
|
+
require "spectre/openrouter"
|
|
10
11
|
require "spectre/logging"
|
|
11
12
|
require 'spectre/prompt'
|
|
12
13
|
require 'spectre/errors'
|
|
@@ -16,8 +17,8 @@ module Spectre
|
|
|
16
17
|
openai: Spectre::Openai,
|
|
17
18
|
ollama: Spectre::Ollama,
|
|
18
19
|
claude: Spectre::Claude,
|
|
19
|
-
gemini: Spectre::Gemini
|
|
20
|
-
|
|
20
|
+
gemini: Spectre::Gemini,
|
|
21
|
+
openrouter: Spectre::Openrouter
|
|
21
22
|
}.freeze
|
|
22
23
|
|
|
23
24
|
def self.included(base)
|
|
@@ -66,6 +67,11 @@ module Spectre
|
|
|
66
67
|
yield @providers[:gemini] if block_given?
|
|
67
68
|
end
|
|
68
69
|
|
|
70
|
+
def openrouter
|
|
71
|
+
@providers[:openrouter] ||= OpenrouterConfiguration.new
|
|
72
|
+
yield @providers[:openrouter] if block_given?
|
|
73
|
+
end
|
|
74
|
+
|
|
69
75
|
def provider_configuration
|
|
70
76
|
providers[default_llm_provider] || raise("No configuration found for provider: #{default_llm_provider}")
|
|
71
77
|
end
|
|
@@ -87,6 +93,11 @@ module Spectre
|
|
|
87
93
|
attr_accessor :api_key
|
|
88
94
|
end
|
|
89
95
|
|
|
96
|
+
class OpenrouterConfiguration
|
|
97
|
+
# OpenRouter additionally recommends setting Referer and X-Title headers
|
|
98
|
+
attr_accessor :api_key, :referer, :app_title
|
|
99
|
+
end
|
|
100
|
+
|
|
90
101
|
class << self
|
|
91
102
|
attr_accessor :config
|
|
92
103
|
|
|
@@ -120,6 +131,10 @@ module Spectre
|
|
|
120
131
|
config.providers[:gemini]
|
|
121
132
|
end
|
|
122
133
|
|
|
134
|
+
def openrouter_configuration
|
|
135
|
+
config.providers[:openrouter]
|
|
136
|
+
end
|
|
137
|
+
|
|
123
138
|
private
|
|
124
139
|
|
|
125
140
|
def validate_llm_provider!
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: spectre_ai
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ilya Klapatok
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2025-
|
|
12
|
+
date: 2025-12-15 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: rspec-rails
|
|
@@ -67,6 +67,9 @@ files:
|
|
|
67
67
|
- lib/spectre/openai.rb
|
|
68
68
|
- lib/spectre/openai/completions.rb
|
|
69
69
|
- lib/spectre/openai/embeddings.rb
|
|
70
|
+
- lib/spectre/openrouter.rb
|
|
71
|
+
- lib/spectre/openrouter/completions.rb
|
|
72
|
+
- lib/spectre/openrouter/embeddings.rb
|
|
70
73
|
- lib/spectre/prompt.rb
|
|
71
74
|
- lib/spectre/searchable.rb
|
|
72
75
|
- lib/spectre/version.rb
|