aispec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ begin
5
+ require "rexml/document"
6
+ rescue LoadError
7
+ # REXML fallback handled in valid_xml assertion
8
+ end
9
+
10
+ module AISpec
11
+ module Core
12
+ module Assertions
13
+ module Deterministic
14
+ def self.register_all!
15
+ # Contains
16
+ Assertion.register(:contains) do |response, _context, options|
17
+ expected = options[:value] || options[:target] || options[:raw]
18
+ text = response[:output].to_s
19
+ match = text.include?(expected.to_s)
20
+ [match, match ? "Output contains '#{expected}'" : "Output does not contain '#{expected}'"]
21
+ end
22
+
23
+ # Not Contains
24
+ Assertion.register(:not_contains) do |response, _context, options|
25
+ expected = options[:value] || options[:target]
26
+ text = response[:output].to_s
27
+ match = !text.include?(expected.to_s)
28
+ [match, match ? "Output does not contain '#{expected}'" : "Output contains forbidden text '#{expected}'"]
29
+ end
30
+
31
+ # Starts With
32
+ Assertion.register(:starts_with) do |response, _context, options|
33
+ prefix = options[:value] || options[:target]
34
+ text = response[:output].to_s.strip
35
+ match = text.start_with?(prefix.to_s)
36
+ [match, match ? "Output starts with '#{prefix}'" : "Output does not start with '#{prefix}'"]
37
+ end
38
+
39
+ # Ends With
40
+ Assertion.register(:ends_with) do |response, _context, options|
41
+ suffix = options[:value] || options[:target]
42
+ text = response[:output].to_s.strip
43
+ match = text.end_with?(suffix.to_s)
44
+ [match, match ? "Output ends with '#{suffix}'" : "Output does not end with '#{suffix}'"]
45
+ end
46
+
47
+ # Regex
48
+ Assertion.register(:regex) do |response, _context, options|
49
+ pattern = options[:pattern] || options[:value] || options[:regex]
50
+ regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
51
+ text = response[:output].to_s
52
+ match = !!(text =~ regex)
53
+ [match, match ? "Output matches regex /#{pattern}/" : "Output failed to match regex /#{pattern}/"]
54
+ end
55
+
56
+ # JSON
57
+ Assertion.register(:json) do |response, _context, options|
58
+ text = response[:output].to_s.strip
59
+ # Clean markdown codeblocks if present
60
+ cleaned = text.sub(/^```json\s*/i, "").sub(/^```\s*/, "").sub(/\s*```$/, "").strip
61
+ cleaned = $1 if cleaned =~ /(\{[\s\S]*\}|\[[\s\S]*\])/
62
+
63
+ begin
64
+ parsed = JSON.parse(cleaned)
65
+ if options[:schema]
66
+ # If schema is given, verify top level key requirements if simple hash
67
+ if options[:schema].is_a?(Array)
68
+ missing_keys = options[:schema].map(&:to_s) - parsed.keys.map(&:to_s)
69
+ [missing_keys.empty?, missing_keys.empty? ? "JSON satisfies schema keys" : "JSON missing schema keys: #{missing_keys.join(', ')}"]
70
+ else
71
+ [true, "Valid JSON"]
72
+ end
73
+ else
74
+ [true, "Output is valid JSON"]
75
+ end
76
+ rescue JSON::ParserError => e
77
+ [false, "Output is not valid JSON: #{e.message}"]
78
+ end
79
+ end
80
+
81
+ # Valid Markdown
82
+ Assertion.register(:valid_markdown) do |response, _context, _options|
83
+ text = response[:output].to_s
84
+ # Basic markdown check (unclosed code fences detection)
85
+ fence_count = text.scan(/^```/).length
86
+ valid = fence_count.even?
87
+ [valid, valid ? "Valid Markdown structural formatting" : "Invalid Markdown (unmatched code block fences)"]
88
+ end
89
+
90
+ # Valid XML
91
+ Assertion.register(:valid_xml) do |response, _context, _options|
92
+ text = response[:output].to_s.strip
93
+ if defined?(REXML::Document)
94
+ begin
95
+ REXML::Document.new(text)
96
+ [true, "Output is valid XML"]
97
+ rescue REXML::ParseException => e
98
+ [false, "Invalid XML: #{e.message}"]
99
+ end
100
+ else
101
+ valid = text =~ /\A\s*<[a-zA-Z_][\s\S]*>\s*\z/
102
+ [!!valid, valid ? "Output is valid XML structure" : "Invalid XML structure"]
103
+ end
104
+ end
105
+
106
+ # Max Latency
107
+ Assertion.register(:max_latency) do |response, _context, options|
108
+ limit = (options[:value] || options[:max] || options[:limit] || 5000).to_f
109
+ actual = (response[:latency_ms] || 0.0).to_f
110
+ passed = actual <= limit
111
+ [passed, passed ? "Latency #{actual.round(1)}ms <= #{limit.round(1)}ms" : "Latency #{actual.round(1)}ms exceeded max #{limit.round(1)}ms"]
112
+ end
113
+
114
+ # Cost
115
+ Assertion.register(:cost) do |response, _context, options|
116
+ max_cost = (options[:max] || options[:value] || 0.05).to_f
117
+ actual_cost = (response[:cost] || 0.0).to_f
118
+ passed = actual_cost <= max_cost
119
+ [passed, passed ? "Cost $#{sprintf('%.4f', actual_cost)} <= $#{sprintf('%.4f', max_cost)}" : "Cost $#{sprintf('%.4f', actual_cost)} exceeded max $#{sprintf('%.4f', max_cost)}"]
120
+ end
121
+
122
+ # Must Not Reveal System Prompt
123
+ Assertion.register(:must_not_reveal_system_prompt) do |response, context, _options|
124
+ sys_prompt = context[:system_prompt].to_s.strip
125
+ text = response[:output].to_s
126
+ if sys_prompt.empty?
127
+ [true, "No system prompt leak detected"]
128
+ else
129
+ match = text.include?(sys_prompt)
130
+ [!match, match ? "System prompt leaked in output" : "No system prompt leak detected"]
131
+ end
132
+ end
133
+
134
+ # Tool Called
135
+ Assertion.register(:tool_called) do |response, _context, options|
136
+ expected = (options[:value] || options[:name] || options[:tool]).to_s
137
+ tools = Array(response[:tools_called]).map(&:to_s)
138
+ called = tools.include?(expected)
139
+ [called, called ? "Tool '#{expected}' was called" : "Tool '#{expected}' was not called"]
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ AISpec::Core::Assertions::Deterministic.register_all!
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AISpec
4
+ module Core
5
+ module Assertions
6
+ module Judge
7
+ def self.register!
8
+ Assertion.register(:judge) do |response, context, options|
9
+ judge_prompt = options[:prompt] || options[:criteria] || "Evaluate if the response fulfills the contract requirements."
10
+ threshold = (options[:threshold] || options[:min_score] || 0.80).to_f
11
+
12
+ target_output = response[:output].to_s
13
+ input_context = context[:input] ? "Input: #{context[:input]}\n" : ""
14
+
15
+ evaluation_instructions = <<~SYSTEM
16
+ You are an impartial AI behavioral evaluator judge.
17
+ Evaluate the following AI response against the criteria below.
18
+
19
+ Criteria:
20
+ #{judge_prompt}
21
+
22
+ #{input_context}
23
+ AI Response to evaluate:
24
+ \"\"\"
25
+ #{target_output}
26
+ \"\"\"
27
+
28
+ Respond ONLY in valid JSON format with:
29
+ {
30
+ "score": <float between 0.0 and 1.0>,
31
+ "reason": "<brief justification>"
32
+ }
33
+ SYSTEM
34
+
35
+ judge_provider_name = options[:provider] || AISpec.configuration.judge_provider
36
+ judge_model = options[:model] || AISpec.configuration.judge_model
37
+
38
+ provider = Providers::Base.get(judge_provider_name).new(model: judge_model)
39
+ eval_result = provider.generate(evaluation_instructions)
40
+
41
+ begin
42
+ cleaned = eval_result[:output].to_s.sub(/^```json\s*/i, "").sub(/^```\s*/, "").sub(/\s*```$/, "").strip
43
+ parsed = JSON.parse(cleaned)
44
+ score = (parsed["score"] || parsed[:score] || 0.0).to_f
45
+ reason = parsed["reason"] || parsed[:reason] || "No explanation provided"
46
+ passed = score >= threshold
47
+
48
+ [
49
+ passed,
50
+ "Judge score #{sprintf('%.2f', score)} (threshold #{threshold}): #{reason}",
51
+ { score: score, threshold: threshold, reason: reason }
52
+ ]
53
+ rescue JSON::ParserError => _e
54
+ # Heuristic fallback if judge didn't output strict JSON
55
+ match = eval_result[:output].to_s.match(/(?:score|rating)?\s*[:=]?\s*(0\.\d+|1\.0|\d+)/i)
56
+ score = match ? match[1].to_f : 0.5
57
+ score = score / 10.0 if score > 1.0
58
+ passed = score >= threshold
59
+ [
60
+ passed,
61
+ "Judge score #{sprintf('%.2f', score)} (threshold #{threshold})",
62
+ { score: score, threshold: threshold }
63
+ ]
64
+ end
65
+ end
66
+
67
+ # Alias for confidence / semantic quality
68
+ Assertion.register(:confidence) do |response, context, options|
69
+ threshold = (options[:min] || options[:threshold] || 0.8).to_f
70
+ options_with_judge = options.merge(
71
+ prompt: "Evaluate confidence and factual accuracy of the response.",
72
+ threshold: threshold
73
+ )
74
+ Assertion.evaluate(:judge, response, context, options_with_judge)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ AISpec::Core::Assertions::Judge.register!
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module AISpec
6
+ module Core
7
+ class Contract
8
+ attr_reader :name, :model, :provider_name, :prompt_template, :dataset_path, :dataset_items, :assertions, :raw_hash, :file_path
9
+
10
+ def self.load_file(path)
11
+ raise ArgumentError, "Contract file not found: #{path}" unless File.exist?(path)
12
+
13
+ hash = YAML.safe_load(File.read(path), symbolize_names: true, aliases: true) || {}
14
+ new(hash, file_path: path)
15
+ end
16
+
17
+ def self.from_hash(hash)
18
+ new(hash)
19
+ end
20
+
21
+ def initialize(hash, file_path: nil)
22
+ @raw_hash = hash
23
+ @file_path = file_path
24
+ @name = hash[:name] || (file_path ? File.basename(file_path, ".*") : "unnamed-contract")
25
+
26
+ model_config = hash[:model] || {}
27
+ if model_config.is_a?(Hash)
28
+ @provider_name = (model_config[:provider] || AISpec.configuration.default_provider).to_s
29
+ @model = (model_config[:model] || AISpec.configuration.default_model).to_s
30
+ else
31
+ @provider_name = AISpec.configuration.default_provider.to_s
32
+ @model = model_config.to_s
33
+ end
34
+
35
+ @prompt_template = hash[:prompt] || "{{input}}"
36
+
37
+ dataset_config = hash[:dataset]
38
+ if dataset_config.is_a?(Hash)
39
+ @dataset_path = dataset_config[:path]
40
+ @dataset_items = dataset_config[:items] || []
41
+ elsif dataset_config.is_a?(Array)
42
+ @dataset_items = dataset_config
43
+ else
44
+ @dataset_items = []
45
+ end
46
+
47
+ load_external_dataset! if @dataset_path && @file_path
48
+
49
+ raw_contracts = hash[:contracts] || hash[:assertions] || []
50
+ @assertions = parse_assertions(raw_contracts)
51
+ end
52
+
53
+ private
54
+
55
+ def load_external_dataset!
56
+ full_path = File.expand_path(@dataset_path, File.dirname(@file_path))
57
+ return unless File.exist?(full_path)
58
+
59
+ data = YAML.safe_load(File.read(full_path), symbolize_names: true, aliases: true)
60
+ if data.is_a?(Array)
61
+ @dataset_items += data
62
+ elsif data.is_a?(Hash) && data[:items].is_a?(Array)
63
+ @dataset_items += data[:items]
64
+ end
65
+ end
66
+
67
+ def parse_assertions(raw_contracts)
68
+ raw_contracts.map do |item|
69
+ normalize_assertion_definition(item)
70
+ end.compact
71
+ end
72
+
73
+ def normalize_assertion_definition(item)
74
+ if item.is_a?(Hash)
75
+ type = (item[:type] || item[:name] || item[:assertion]).to_sym
76
+ { type: type, options: item }
77
+ elsif item.is_a?(String)
78
+ parse_string_assertion(item)
79
+ elsif item.is_a?(Symbol)
80
+ { type: item, options: {} }
81
+ end
82
+ end
83
+
84
+ def parse_string_assertion(str)
85
+ s = str.strip
86
+ if s =~ /^(latency|max_latency)\s*(<|<=)\s*(\d+(?:\.\d+)?)\s*(ms|s)?$/i
87
+ val = $3.to_f
88
+ val *= 1000 if $4.to_s.downcase == "s"
89
+ { type: :max_latency, options: { value: val } }
90
+ elsif s =~ /^cost\s*(<|<=)\s*\$?(\d+(?:\.\d+)?)$/i
91
+ { type: :cost, options: { max: $2.to_f } }
92
+ elsif s =~ /^confidence\s*(>=|>)\s*(\d+(?:\.\d+)?)$/i
93
+ { type: :confidence, options: { min: $2.to_f } }
94
+ elsif s =~ /^must_return_valid_json$/i || s == "json"
95
+ { type: :json, options: {} }
96
+ elsif s =~ /^must_cite_sources$/i
97
+ { type: :citation_format, options: {} }
98
+ else
99
+ # Fallback to symbol type
100
+ { type: s.to_sym, options: { raw: str } }
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module AISpec
8
+ module Core
9
+ module Providers
10
+ class Anthropic < Base
11
+ BASE_URI = "https://api.anthropic.com/v1/messages"
12
+
13
+ def initialize(model: "claude-3-5-sonnet-20241022", api_key: nil, **options)
14
+ super(model: model, **options)
15
+ @api_key = api_key || ENV["ANTHROPIC_API_KEY"]
16
+ end
17
+
18
+ def generate(prompt, system_prompt: nil, temperature: 0.7, max_tokens: 1024, **_params)
19
+ return fallback_mock_if_no_key(prompt) unless @api_key && !@api_key.empty?
20
+
21
+ payload = {
22
+ model: @model || "claude-3-5-sonnet-20241022",
23
+ max_tokens: max_tokens,
24
+ messages: [{ role: "user", content: prompt }],
25
+ temperature: temperature
26
+ }
27
+ payload[:system] = system_prompt if system_prompt
28
+
29
+ uri = URI.parse(BASE_URI)
30
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+
32
+ req = Net::HTTP::Post.new(uri.path, {
33
+ "Content-Type" => "application/json",
34
+ "x-api-key" => @api_key,
35
+ "anthropic-version" => "2023-06-01"
36
+ })
37
+ req.body = JSON.generate(payload)
38
+
39
+ http = Net::HTTP.new(uri.host, uri.port)
40
+ http.use_ssl = true
41
+ http.read_timeout = AISpec.configuration.timeout
42
+
43
+ res = http.request(req)
44
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
45
+
46
+ if res.is_a?(Net::HTTPSuccess)
47
+ data = JSON.parse(res.body)
48
+ output = data.dig("content", 0, "text") || ""
49
+ input_tokens = data.dig("usage", "input_tokens") || 0
50
+ output_tokens = data.dig("usage", "output_tokens") || 0
51
+ total_tokens = input_tokens + output_tokens
52
+
53
+ cost = (input_tokens * 0.000003) + (output_tokens * 0.000015)
54
+
55
+ build_response(
56
+ output: output,
57
+ latency_ms: elapsed_ms,
58
+ tokens: total_tokens,
59
+ cost: cost,
60
+ raw: data
61
+ )
62
+ else
63
+ raise "Anthropic API Error (#{res.code}): #{res.body}"
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def fallback_mock_if_no_key(prompt)
70
+ Mock.new(model: @model).generate(prompt)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ AISpec::Core::Providers::Base.register("anthropic", AISpec::Core::Providers::Anthropic)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AISpec
4
+ module Core
5
+ module Providers
6
+ class Base
7
+ @registry = {}
8
+
9
+ class << self
10
+ attr_reader :registry
11
+
12
+ def register(name, provider_class)
13
+ @registry[name.to_s.downcase] = provider_class
14
+ end
15
+
16
+ def get(name)
17
+ key = name.to_s.downcase
18
+ @registry[key] || Mock
19
+ end
20
+ end
21
+
22
+ attr_reader :model, :options
23
+
24
+ def initialize(model: nil, **options)
25
+ @model = model
26
+ @options = options
27
+ end
28
+
29
+ def generate(prompt, **params)
30
+ raise NotImplementedError, "#{self.class.name} must implement #generate"
31
+ end
32
+
33
+ protected
34
+
35
+ def build_response(output:, latency_ms:, tokens: 0, cost: 0.0, tools_called: [], raw: {})
36
+ {
37
+ output: output,
38
+ latency_ms: latency_ms,
39
+ tokens: tokens,
40
+ cost: cost,
41
+ tools_called: tools_called,
42
+ raw: raw
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AISpec
4
+ module Core
5
+ module Providers
6
+ class Mock < Base
7
+ attr_accessor :canned_response, :canned_latency_ms, :canned_cost, :canned_tools
8
+
9
+ def initialize(model: "mock-model", canned_response: nil, canned_latency_ms: 120.0, canned_cost: 0.001, canned_tools: [], **options)
10
+ super(model: model, **options)
11
+ @canned_response = canned_response
12
+ @canned_latency_ms = canned_latency_ms
13
+ @canned_cost = canned_cost
14
+ @canned_tools = canned_tools
15
+ end
16
+
17
+ def generate(prompt, **params)
18
+ output = @canned_response || params[:canned_response] || default_mock_output(prompt)
19
+ latency = params[:latency_ms] || @canned_latency_ms
20
+ cost = params[:cost] || @canned_cost
21
+ tools = params[:tools_called] || @canned_tools
22
+ tokens = (output.to_s.split.length * 1.3).to_i
23
+
24
+ build_response(
25
+ output: output,
26
+ latency_ms: latency,
27
+ tokens: tokens,
28
+ cost: cost,
29
+ tools_called: tools,
30
+ raw: { mock: true }
31
+ )
32
+ end
33
+
34
+ private
35
+
36
+ def default_mock_output(prompt)
37
+ if prompt =~ /judge|evaluat/i
38
+ JSON.generate({
39
+ score: 0.92,
40
+ reason: "Response is accurate, empathetic, and concise."
41
+ })
42
+ elsif prompt =~ /JSON/i
43
+ JSON.generate({
44
+ status: "success",
45
+ message: "Refund processed successfully.",
46
+ amount: 50.0,
47
+ sources: ["https://policy.example.com/refunds"]
48
+ })
49
+ else
50
+ "Thank you for contacting support! Your refund of $50.00 has been processed according to our policy [1]. Sources: https://support.example.com/faq"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ AISpec::Core::Providers::Base.register("mock", AISpec::Core::Providers::Mock)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module AISpec
8
+ module Core
9
+ module Providers
10
+ class Ollama < Base
11
+ def initialize(model: "llama3", host: nil, **options)
12
+ super(model: model, **options)
13
+ @host = host || ENV["OLLAMA_HOST"] || "http://localhost:11434"
14
+ end
15
+
16
+ def generate(prompt, system_prompt: nil, **_params)
17
+ uri = URI.parse("#{@host.sub(/\/$/, '')}/api/generate")
18
+
19
+ payload = {
20
+ model: @model || "llama3",
21
+ prompt: prompt,
22
+ stream: false
23
+ }
24
+ payload[:system] = system_prompt if system_prompt
25
+
26
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+
28
+ req = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
29
+ req.body = JSON.generate(payload)
30
+
31
+ http = Net::HTTP.new(uri.host, uri.port)
32
+ http.use_ssl = uri.scheme == "https"
33
+ http.read_timeout = AISpec.configuration.timeout
34
+
35
+ begin
36
+ res = http.request(req)
37
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
38
+
39
+ if res.is_a?(Net::HTTPSuccess)
40
+ data = JSON.parse(res.body)
41
+ output = data["response"] || ""
42
+ eval_count = data["eval_count"] || (output.split.length * 1.3).to_i
43
+
44
+ build_response(
45
+ output: output,
46
+ latency_ms: elapsed_ms,
47
+ tokens: eval_count,
48
+ cost: 0.0, # Local execution cost $0.00
49
+ raw: data
50
+ )
51
+ else
52
+ raise "Ollama Error (#{res.code}): #{res.body}"
53
+ end
54
+ rescue Errno::ECONNREFUSED
55
+ # Fallback to mock if local Ollama daemon is offline during dev/test
56
+ Mock.new(model: @model).generate(prompt)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ AISpec::Core::Providers::Base.register("ollama", AISpec::Core::Providers::Ollama)
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module AISpec
8
+ module Core
9
+ module Providers
10
+ class OpenRouter < Base
11
+ BASE_URI = "https://openrouter.ai/api/v1/chat/completions"
12
+
13
+ def initialize(model: "openai/gpt-4o-mini", api_key: nil, **options)
14
+ super(model: model, **options)
15
+ @api_key = api_key || ENV["OPENROUTER_API_KEY"]
16
+ end
17
+
18
+ def generate(prompt, system_prompt: nil, temperature: 0.7, **_params)
19
+ return fallback_mock_if_no_key(prompt) unless @api_key && !@api_key.empty?
20
+
21
+ messages = []
22
+ messages << { role: "system", content: system_prompt } if system_prompt
23
+ messages << { role: "user", content: prompt }
24
+
25
+ payload = {
26
+ model: @model || "openai/gpt-4o-mini",
27
+ messages: messages,
28
+ temperature: temperature
29
+ }
30
+
31
+ uri = URI.parse(BASE_URI)
32
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+
34
+ req = Net::HTTP::Post.new(uri.path, {
35
+ "Content-Type" => "application/json",
36
+ "Authorization" => "Bearer #{@api_key}",
37
+ "HTTP-Referer" => "https://github.com/wilburhimself/aispec",
38
+ "X-Title" => "AISpec Framework"
39
+ })
40
+ req.body = JSON.generate(payload)
41
+
42
+ http = Net::HTTP.new(uri.host, uri.port)
43
+ http.use_ssl = true
44
+ http.read_timeout = AISpec.configuration.timeout
45
+
46
+ res = http.request(req)
47
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
48
+
49
+ if res.is_a?(Net::HTTPSuccess)
50
+ data = JSON.parse(res.body)
51
+ output = data.dig("choices", 0, "message", "content") || ""
52
+ tokens = data.dig("usage", "total_tokens") || 0
53
+
54
+ build_response(
55
+ output: output,
56
+ latency_ms: elapsed_ms,
57
+ tokens: tokens,
58
+ cost: 0.0005,
59
+ raw: data
60
+ )
61
+ else
62
+ raise "OpenRouter API Error (#{res.code}): #{res.body}"
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def fallback_mock_if_no_key(prompt)
69
+ Mock.new(model: @model).generate(prompt)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ AISpec::Core::Providers::Base.register("openrouter", AISpec::Core::Providers::OpenRouter)