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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +288 -0
- data/bin/aispec +6 -0
- data/lib/aispec/cli.rb +211 -0
- data/lib/aispec/configuration.rb +32 -0
- data/lib/aispec/core/assertion.rb +70 -0
- data/lib/aispec/core/assertions/deterministic.rb +147 -0
- data/lib/aispec/core/assertions/judge.rb +82 -0
- data/lib/aispec/core/contract.rb +105 -0
- data/lib/aispec/core/providers/anthropic.rb +77 -0
- data/lib/aispec/core/providers/base.rb +48 -0
- data/lib/aispec/core/providers/mock.rb +58 -0
- data/lib/aispec/core/providers/ollama.rb +64 -0
- data/lib/aispec/core/providers/open_router.rb +76 -0
- data/lib/aispec/core/providers/openai.rb +90 -0
- data/lib/aispec/core/providers/vllm.rb +86 -0
- data/lib/aispec/core/reporters/base.rb +34 -0
- data/lib/aispec/core/reporters/console.rb +44 -0
- data/lib/aispec/core/reporters/html.rb +148 -0
- data/lib/aispec/core/reporters/json.rb +47 -0
- data/lib/aispec/core/reporters/junit.rb +52 -0
- data/lib/aispec/core/reporters/markdown.rb +47 -0
- data/lib/aispec/core/runner.rb +83 -0
- data/lib/aispec/core/statistics/calculator.rb +120 -0
- data/lib/aispec/plugin.rb +24 -0
- data/lib/aispec/version.rb +5 -0
- data/lib/aispec.rb +34 -0
- metadata +128 -0
|
@@ -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)
|