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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '03910c4dd38bf7a272fab91c0e9d1431d0f9bdc593abe62035271e9e07f22e89'
4
- data.tar.gz: 0f1e927a42785d2f4735e4adf9140efad6815247fca6ce6270ac10ef286ae217
3
+ metadata.gz: 83d3a297e011e019679dcb12edb0c00f3bb73c6dc599378923627ef257ff6f1f
4
+ data.tar.gz: 79def4a06049ed718bf0c66ffc7f0a29016d8a0831ab0941cc9c02f0b451ab03
5
5
  SHA512:
6
- metadata.gz: 48e634fedb903de30ff0acba0b1de5b725fccb2ac0882bf8890740100312c92ae48c6c182612554cdd6968b34df5f6c7958a62caf19492ae29a42d8e084e1458
7
- data.tar.gz: b7a382fb583431eff8715147df8f5251e30d528b0a77ecc6c2ecf68b324f57f76e9c6a2634da07f55159713d9cd58684e212b75fee7f5a7e20e56b437725dd55
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. For Claude, max_tokens can be passed in the claude hash.
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.dig(:claude, :max_tokens) || 1024
47
- request.body = generate_body(messages, model, json_schema, max_tokens, tools, tool_choice).to_json
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. For Gemini, max_tokens can be passed in the gemini hash.
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.dig(:gemini, :max_tokens)
43
- request.body = generate_body(messages, model, json_schema, max_tokens, tools).to_json
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. 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
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.dig(:ollama, :path) || API_PATH
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
- options = args.dig(:ollama, :options)
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. For OpenAI, max_tokens can be passed in the openai hash.
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.dig(:openai, :max_tokens)
42
- request.body = generate_body(messages, model, json_schema, max_tokens, tools).to_json
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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spectre
4
+ module Openrouter
5
+ # Require each specific client file here
6
+ require_relative 'openrouter/embeddings'
7
+ require_relative 'openrouter/completions'
8
+ end
9
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spectre # :nodoc:all
4
- VERSION = "2.0.0"
4
+ VERSION = "2.1.1"
5
5
  end
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
- # cohere: Spectre::Cohere,
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.0.0
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-09-24 00:00:00.000000000 Z
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