ruby_llm-providers-typesafe 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/.flayignore +1 -0
- data/.github/workflows/ci.yml +32 -0
- data/.github/workflows/gitleaks.yml +22 -0
- data/.github/workflows/release.yml +33 -0
- data/.overcommit.yml +31 -0
- data/.rspec +2 -0
- data/.rubocop.yml +35 -0
- data/Archspec.rb +23 -0
- data/LICENSE +21 -0
- data/README.md +84 -0
- data/lib/ruby_llm/providers/typesafe/system_one/evaluations.rb +95 -0
- data/lib/ruby_llm/providers/typesafe/system_one/models.rb +35 -0
- data/lib/ruby_llm/providers/typesafe/system_one/rerank.rb +57 -0
- data/lib/ruby_llm/providers/typesafe/system_one.rb +19 -0
- data/lib/ruby_llm/providers/typesafe.rb +68 -0
- data/lib/ruby_llm/typesafe/evaluation.rb +75 -0
- data/lib/ruby_llm/typesafe/questions.rb +74 -0
- data/lib/ruby_llm/typesafe.rb +47 -0
- data/models.json +62 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_rerank_typesafe_jev_latest_orders_documents_by_relevance.yml +49 -0
- data/spec/fixtures/vcr_cassettes/rubyllm_typesafe_evaluation_typesafe_jev_latest_answers_noul_choice_and_score_questions.yml +46 -0
- data/spec/ruby_llm/models_spec.rb +11 -0
- data/spec/ruby_llm/providers/typesafe/system_one_spec.rb +162 -0
- data/spec/ruby_llm/providers/typesafe_spec.rb +58 -0
- data/spec/ruby_llm/rerank_request_spec.rb +38 -0
- data/spec/ruby_llm/rerank_spec.rb +23 -0
- data/spec/ruby_llm/typesafe/evaluation_spec.rb +27 -0
- data/spec/ruby_llm/typesafe_spec.rb +60 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/support/models.rb +9 -0
- data/spec/support/rubyllm_configuration.rb +14 -0
- data/spec/support/vcr_configuration.rb +16 -0
- metadata +91 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Typesafe
|
|
5
|
+
# Collects the questions for one evaluation. Typesafe.evaluate yields
|
|
6
|
+
# one to its block. Each question gets an id you choose; its answer
|
|
7
|
+
# comes back under the same id. Ids are for your code and never reach
|
|
8
|
+
# the model, so the instructions must carry the full meaning.
|
|
9
|
+
class Questions
|
|
10
|
+
# One question: its +type+ (:noul, :choice, or :score), its
|
|
11
|
+
# +instructions+, and its +criteria+.
|
|
12
|
+
Question = Struct.new(:type, :instructions, :criteria, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def initialize # :nodoc:
|
|
15
|
+
@questions = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Adds a yes/no question. Its answer is the probability that the
|
|
19
|
+
# answer is yes. +yes:+ and +no:+ optionally describe what each
|
|
20
|
+
# answer means.
|
|
21
|
+
#
|
|
22
|
+
# q.noul :urgent, "Does this convey urgency?", yes: "Explicitly time-sensitive"
|
|
23
|
+
#
|
|
24
|
+
def noul(id, instructions, yes: nil, no: nil)
|
|
25
|
+
add id, Question.new(type: :noul, instructions: instructions, criteria: { yes: yes, no: no })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Adds a question that picks one of +options+. Pass a Hash of option
|
|
29
|
+
# to description, or an Array of options that need no description.
|
|
30
|
+
# The answer names the option with the same key you passed.
|
|
31
|
+
#
|
|
32
|
+
# q.choice :team, "Which team should handle this?", billing: "Payments", technical: "Bugs"
|
|
33
|
+
# q.choice :language, "Which language is this written in?", %i[english spanish other]
|
|
34
|
+
#
|
|
35
|
+
def choice(id, instructions, options = nil, **described)
|
|
36
|
+
options = options.is_a?(Array) ? options.to_h { |option| [option, nil] } : (options || {}).merge(described)
|
|
37
|
+
raise ArgumentError, "choice #{id.inspect} needs at least two options" if options.size < 2
|
|
38
|
+
|
|
39
|
+
add id, Question.new(type: :choice, instructions: instructions, criteria: options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Adds a question that rates the state on ordered +levels+, lowest
|
|
43
|
+
# first. Each level describes a concrete situation. The answer is a
|
|
44
|
+
# probability-weighted position between the first and last level.
|
|
45
|
+
#
|
|
46
|
+
# q.score :frustration, "How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"]
|
|
47
|
+
#
|
|
48
|
+
def score(id, instructions, levels)
|
|
49
|
+
raise ArgumentError, "score #{id.inspect} needs at least two levels" if Array(levels).size < 2
|
|
50
|
+
|
|
51
|
+
add id, Question.new(type: :score, instructions: instructions, criteria: Array(levels))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns whether no questions have been added.
|
|
55
|
+
def empty?
|
|
56
|
+
@questions.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the questions as a Hash of id to Question.
|
|
60
|
+
def to_h
|
|
61
|
+
@questions.dup
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def add(id, question)
|
|
67
|
+
raise ArgumentError, "question #{id.inspect} is already defined" if @questions.key?(id)
|
|
68
|
+
|
|
69
|
+
@questions[id] = question
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm'
|
|
4
|
+
require_relative 'typesafe/questions'
|
|
5
|
+
require_relative 'typesafe/evaluation'
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
# Typed judgments from TypeSafe's System One models. Ask yes/no, choice,
|
|
9
|
+
# and score questions about a state and get probabilities back:
|
|
10
|
+
#
|
|
11
|
+
# evaluation = RubyLLM::Typesafe.evaluate("Help! My payouts have been failing for 3 days.") do |q|
|
|
12
|
+
# q.noul :urgent, "Does this convey urgency?"
|
|
13
|
+
# q.choice :team, "Which team should handle this?",
|
|
14
|
+
# billing: "Payments, invoicing, refunds",
|
|
15
|
+
# technical: "Bugs, outages, integrations"
|
|
16
|
+
# q.score :frustration, "How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"]
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# evaluation[:urgent].probability # => 0.92
|
|
20
|
+
# evaluation[:team].option # => :technical
|
|
21
|
+
# evaluation[:frustration].level # => "Very angry"
|
|
22
|
+
#
|
|
23
|
+
module Typesafe
|
|
24
|
+
# The model evaluate uses when none is given.
|
|
25
|
+
DEFAULT_MODEL = 'jev-latest'
|
|
26
|
+
|
|
27
|
+
# Evaluates +state+ against the questions the block adds and returns an
|
|
28
|
+
# Evaluation. +state+ is a String, or a Hash or Array for structured
|
|
29
|
+
# data. The block receives a Questions builder. Every question runs in
|
|
30
|
+
# parallel against the same state and cannot see the other answers.
|
|
31
|
+
# +context:+ takes a RubyLLM::Context to use its configuration.
|
|
32
|
+
#
|
|
33
|
+
# RubyLLM::Typesafe.evaluate(ticket, model: "jev-1.13.0") { |q| q.noul :spam, "Is this spam?" }
|
|
34
|
+
#
|
|
35
|
+
def self.evaluate(state, model: DEFAULT_MODEL, context: nil)
|
|
36
|
+
raise ArgumentError, 'evaluate requires a block that adds questions' unless block_given?
|
|
37
|
+
|
|
38
|
+
questions = Questions.new
|
|
39
|
+
yield questions
|
|
40
|
+
raise ArgumentError, 'evaluate requires at least one question' if questions.empty?
|
|
41
|
+
|
|
42
|
+
config = context&.config || RubyLLM.config
|
|
43
|
+
model, provider = Models.resolve(model, provider: :typesafe, config: config)
|
|
44
|
+
provider.evaluate(state, questions, model: model)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/models.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "jev-latest",
|
|
4
|
+
"name": "jev-latest",
|
|
5
|
+
"provider": "typesafe",
|
|
6
|
+
"family": null,
|
|
7
|
+
"created_at": "2026-09-10 18:38:01 UTC",
|
|
8
|
+
"context_window": null,
|
|
9
|
+
"max_output_tokens": null,
|
|
10
|
+
"knowledge_cutoff": null,
|
|
11
|
+
"modalities": {
|
|
12
|
+
"input": [
|
|
13
|
+
"text"
|
|
14
|
+
],
|
|
15
|
+
"output": [
|
|
16
|
+
"rerank"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"capabilities": [],
|
|
20
|
+
"pricing": {
|
|
21
|
+
"text_tokens": {
|
|
22
|
+
"standard": {
|
|
23
|
+
"input_per_million": 0.042,
|
|
24
|
+
"output_per_million": 0
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"metadata": {
|
|
29
|
+
"description": "The latest iteration of TypeSafe's System One Model: Jev"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "jev-preview",
|
|
34
|
+
"name": "jev-preview",
|
|
35
|
+
"provider": "typesafe",
|
|
36
|
+
"family": null,
|
|
37
|
+
"created_at": "2026-09-10 18:39:06 UTC",
|
|
38
|
+
"context_window": null,
|
|
39
|
+
"max_output_tokens": null,
|
|
40
|
+
"knowledge_cutoff": null,
|
|
41
|
+
"modalities": {
|
|
42
|
+
"input": [
|
|
43
|
+
"text"
|
|
44
|
+
],
|
|
45
|
+
"output": [
|
|
46
|
+
"rerank"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"capabilities": [],
|
|
50
|
+
"pricing": {
|
|
51
|
+
"text_tokens": {
|
|
52
|
+
"standard": {
|
|
53
|
+
"input_per_million": 0.042,
|
|
54
|
+
"output_per_million": 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"metadata": {
|
|
59
|
+
"description": "A preview version of `jev-latest`: should be better in most ways"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
http_interactions:
|
|
3
|
+
- request:
|
|
4
|
+
method: post
|
|
5
|
+
uri: "<TYPESAFE_API_BASE>/v1/systemone"
|
|
6
|
+
body:
|
|
7
|
+
encoding: UTF-8
|
|
8
|
+
string: '{"state":{"query":"What is the capital of the United States?","documents":["Carson
|
|
9
|
+
City is the capital of Nevada.","Washington, D.C. is the capital of the United
|
|
10
|
+
States."]},"model":"jev-latest","questions":{"document_0":{"type":"noul","instructions":"Does
|
|
11
|
+
`documents[0]` help answer `query`?","criteria":{"true":"The document contains
|
|
12
|
+
information that answers or directly addresses the query.","false":"The document
|
|
13
|
+
is off topic or only shares words with the query."}},"document_1":{"type":"noul","instructions":"Does
|
|
14
|
+
`documents[1]` help answer `query`?","criteria":{"true":"The document contains
|
|
15
|
+
information that answers or directly addresses the query.","false":"The document
|
|
16
|
+
is off topic or only shares words with the query."}}}}'
|
|
17
|
+
headers:
|
|
18
|
+
User-Agent:
|
|
19
|
+
- Faraday v2.14.4
|
|
20
|
+
Authorization:
|
|
21
|
+
- Bearer <AUTH_TOKEN>
|
|
22
|
+
Content-Type:
|
|
23
|
+
- application/json
|
|
24
|
+
Accept-Encoding:
|
|
25
|
+
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
|
26
|
+
Accept:
|
|
27
|
+
- "*/*"
|
|
28
|
+
response:
|
|
29
|
+
status:
|
|
30
|
+
code: 200
|
|
31
|
+
message: OK
|
|
32
|
+
headers:
|
|
33
|
+
Date:
|
|
34
|
+
- Fri, 18 Sep 2026 12:23:44 GMT
|
|
35
|
+
Server:
|
|
36
|
+
- istio-envoy
|
|
37
|
+
Content-Length:
|
|
38
|
+
- '164'
|
|
39
|
+
Content-Type:
|
|
40
|
+
- application/json
|
|
41
|
+
X-Typesafe-Request-Id:
|
|
42
|
+
- req_01a0b47903987843bd07f5fc60a186b8
|
|
43
|
+
X-Envoy-Upstream-Service-Time:
|
|
44
|
+
- '88'
|
|
45
|
+
body:
|
|
46
|
+
encoding: UTF-8
|
|
47
|
+
string: '{"model":"jev-1.13.0","answers":{"document_0":{"type":"noul","noul":0.03},"document_1":{"type":"noul","noul":0.99}},"usage":{"input_tokens":430,"output_tokens":40}}'
|
|
48
|
+
recorded_at: Fri, 18 Sep 2026 12:23:44 GMT
|
|
49
|
+
recorded_with: VCR 6.4.0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
http_interactions:
|
|
3
|
+
- request:
|
|
4
|
+
method: post
|
|
5
|
+
uri: "<TYPESAFE_API_BASE>/v1/systemone"
|
|
6
|
+
body:
|
|
7
|
+
encoding: UTF-8
|
|
8
|
+
string: '{"state":"Help! My payouts have been failing for 3 days.","model":"jev-latest","questions":{"urgent":{"type":"noul","instructions":"Does
|
|
9
|
+
this convey urgency?"},"team":{"type":"choice","instructions":"Which team
|
|
10
|
+
should handle this?","criteria":{"billing":"Payments, invoicing, refunds","technical":"Bugs,
|
|
11
|
+
outages, integrations","sales":"Pricing, upgrades, new accounts"}},"frustration":{"type":"score","instructions":"How
|
|
12
|
+
frustrated is the customer?","criteria":["Calm","Frustrated","Very angry"]}}}'
|
|
13
|
+
headers:
|
|
14
|
+
User-Agent:
|
|
15
|
+
- Faraday v2.14.4
|
|
16
|
+
Authorization:
|
|
17
|
+
- Bearer <AUTH_TOKEN>
|
|
18
|
+
Content-Type:
|
|
19
|
+
- application/json
|
|
20
|
+
Accept-Encoding:
|
|
21
|
+
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
|
22
|
+
Accept:
|
|
23
|
+
- "*/*"
|
|
24
|
+
response:
|
|
25
|
+
status:
|
|
26
|
+
code: 200
|
|
27
|
+
message: OK
|
|
28
|
+
headers:
|
|
29
|
+
Date:
|
|
30
|
+
- Fri, 18 Sep 2026 12:23:43 GMT
|
|
31
|
+
Server:
|
|
32
|
+
- istio-envoy
|
|
33
|
+
Content-Length:
|
|
34
|
+
- '405'
|
|
35
|
+
Content-Type:
|
|
36
|
+
- application/json
|
|
37
|
+
X-Typesafe-Request-Id:
|
|
38
|
+
- req_01a0b479007b7d67ae86981d477509c0
|
|
39
|
+
X-Envoy-Upstream-Service-Time:
|
|
40
|
+
- '100'
|
|
41
|
+
body:
|
|
42
|
+
encoding: UTF-8
|
|
43
|
+
string: '{"model":"jev-1.13.0","answers":{"urgent":{"type":"noul","noul":0.95},"team":{"type":"choice","choice":"billing","confidence":0.77,"probabilities":{"technical":0.15,"billing":0.85,"sales":0.0}},"frustration":{"type":"score","score":1.05,"confidence":0.93,"legend":{"0":"Calm","1":"Frustrated","2":"Very
|
|
44
|
+
angry"},"probabilities":{"0":0.0,"1":0.95,"2":0.05}}},"usage":{"input_tokens":402,"output_tokens":70}}'
|
|
45
|
+
recorded_at: Fri, 18 Sep 2026 12:23:44 GMT
|
|
46
|
+
recorded_with: VCR 6.4.0
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Models do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
it 'accepts versioned model ids that the listing leaves out' do
|
|
9
|
+
expect(RubyLLM::Providers::Typesafe.assume_models_exist?).to be(true)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Providers::Typesafe::SystemOne do
|
|
6
|
+
subject(:protocol) { described_class.new(provider, model) }
|
|
7
|
+
|
|
8
|
+
let(:config) do
|
|
9
|
+
RubyLLM::Configuration.new.tap { |provider_config| provider_config.typesafe_api_key = 'test-key' }
|
|
10
|
+
end
|
|
11
|
+
let(:provider) { RubyLLM::Providers::Typesafe.new(config) }
|
|
12
|
+
let(:model) do
|
|
13
|
+
RubyLLM::Model.new(id: 'jev-latest', provider: 'typesafe',
|
|
14
|
+
pricing: { text_tokens: { standard: { input_per_million: 0.042, output_per_million: 0 } } })
|
|
15
|
+
end
|
|
16
|
+
let(:response) { Struct.new(:body) }
|
|
17
|
+
|
|
18
|
+
def questions(&)
|
|
19
|
+
RubyLLM::Typesafe::Questions.new.tap(&)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
describe '#render_evaluation_payload' do
|
|
23
|
+
it 'renders each question type in the wire vocabulary' do
|
|
24
|
+
payload = protocol.render_evaluation_payload(
|
|
25
|
+
{ ticket: 'Payouts failing' },
|
|
26
|
+
questions do |q|
|
|
27
|
+
q.noul :urgent, 'Does this convey urgency?', yes: 'Time-sensitive', no: 'Not urgent'
|
|
28
|
+
q.choice :team, 'Which team?', billing: 'Payments', technical: nil
|
|
29
|
+
q.score :frustration, 'How frustrated?', %w[Calm Angry]
|
|
30
|
+
end,
|
|
31
|
+
model: 'jev-latest'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
expect(payload).to eq(
|
|
35
|
+
state: { ticket: 'Payouts failing' },
|
|
36
|
+
model: 'jev-latest',
|
|
37
|
+
questions: {
|
|
38
|
+
'urgent' => { type: 'noul', instructions: 'Does this convey urgency?',
|
|
39
|
+
criteria: { true => 'Time-sensitive', false => 'Not urgent' } },
|
|
40
|
+
'team' => { type: 'choice', instructions: 'Which team?',
|
|
41
|
+
criteria: { 'billing' => 'Payments', 'technical' => nil } },
|
|
42
|
+
'frustration' => { type: 'score', instructions: 'How frustrated?', criteria: %w[Calm Angry] }
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'omits noul criteria when neither answer is described' do
|
|
48
|
+
payload = protocol.render_evaluation_payload('text', questions { |q| q.noul :spam, 'Is this spam?' },
|
|
49
|
+
model: 'jev-latest')
|
|
50
|
+
|
|
51
|
+
expect(payload[:questions]['spam']).to eq(type: 'noul', instructions: 'Is this spam?')
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '#parse_evaluation_response' do
|
|
56
|
+
let(:asked) do
|
|
57
|
+
questions do |q|
|
|
58
|
+
q.noul :urgent, 'Does this convey urgency?'
|
|
59
|
+
q.choice :team, 'Which team?', %i[billing technical]
|
|
60
|
+
q.score :frustration, 'How frustrated?', ['Calm', 'Frustrated', 'Very angry']
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
let(:body) do
|
|
64
|
+
{
|
|
65
|
+
'model' => 'jev-1.13.0',
|
|
66
|
+
'answers' => {
|
|
67
|
+
'urgent' => { 'type' => 'noul', 'noul' => 0.92 },
|
|
68
|
+
'team' => { 'type' => 'choice', 'choice' => 'technical',
|
|
69
|
+
'probabilities' => { 'billing' => 0.15, 'technical' => 0.85 }, 'confidence' => 0.82 },
|
|
70
|
+
'frustration' => { 'type' => 'score', 'score' => 1.6,
|
|
71
|
+
'legend' => { '0' => 'Calm', '1' => 'Frustrated', '2' => 'Very angry' },
|
|
72
|
+
'probabilities' => { '0' => 0.05, '1' => 0.3, '2' => 0.65 }, 'confidence' => 0.78 }
|
|
73
|
+
},
|
|
74
|
+
'usage' => { 'input_tokens' => 312, 'output_tokens' => 48 }
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
let(:evaluation) { protocol.parse_evaluation_response(response.new(body), asked) }
|
|
78
|
+
|
|
79
|
+
it 'reads a noul answer as a probability' do
|
|
80
|
+
expect(evaluation[:urgent].probability).to eq(0.92)
|
|
81
|
+
expect(evaluation[:urgent]).to be_yes
|
|
82
|
+
expect(evaluation[:urgent].yes?(threshold: 0.95)).to be(false)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'maps a choice answer back to the option keys that were asked' do
|
|
86
|
+
expect(evaluation[:team].option).to eq(:technical)
|
|
87
|
+
expect(evaluation[:team].probabilities).to eq(billing: 0.15, technical: 0.85)
|
|
88
|
+
expect(evaluation[:team].confidence).to eq(0.82)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'orders score probabilities by level' do
|
|
92
|
+
expect(evaluation[:frustration].score).to eq(1.6)
|
|
93
|
+
expect(evaluation[:frustration].probabilities).to eq([0.05, 0.3, 0.65])
|
|
94
|
+
expect(evaluation[:frustration].level).to eq('Very angry')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'reports the answering model, tokens, and input-only cost' do
|
|
98
|
+
expect(evaluation.model).to eq('jev-1.13.0')
|
|
99
|
+
expect(evaluation.tokens.input).to eq(312)
|
|
100
|
+
expect(evaluation.tokens.output).to eq(48)
|
|
101
|
+
expect(evaluation.cost.total).to be_within(1e-12).of(312 * 0.042 / 1_000_000)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe 'reranking' do
|
|
106
|
+
let(:documents) { ['Carson City is the capital of Nevada.', 'Washington, D.C. is the capital of the US.'] }
|
|
107
|
+
let(:body) do
|
|
108
|
+
{
|
|
109
|
+
'model' => 'jev-1.13.0',
|
|
110
|
+
'answers' => { 'document_0' => { 'type' => 'noul', 'noul' => 0.1 },
|
|
111
|
+
'document_1' => { 'type' => 'noul', 'noul' => 0.95 } },
|
|
112
|
+
'usage' => { 'input_tokens' => 120, 'output_tokens' => 4 }
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'asks one relevance question per document over a shared state' do
|
|
117
|
+
payload = protocol.render_rerank_payload('Capital of the US?', documents, model: 'jev-latest')
|
|
118
|
+
|
|
119
|
+
expect(payload[:state]).to eq(query: 'Capital of the US?', documents: documents)
|
|
120
|
+
expect(payload[:questions].keys).to eq(%w[document_0 document_1])
|
|
121
|
+
expect(payload[:questions]['document_1']).to include(type: 'noul',
|
|
122
|
+
instructions: 'Does `documents[1]` help answer `query`?')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'merges provider options into the payload' do
|
|
126
|
+
payload = protocol.render_rerank_payload('q', documents, model: 'jev-latest',
|
|
127
|
+
provider_options: { model: 'jev-preview' })
|
|
128
|
+
|
|
129
|
+
expect(payload[:model]).to eq('jev-preview')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'sorts documents by the probability that they are relevant' do
|
|
133
|
+
protocol.render_rerank_payload('Capital of the US?', documents, model: 'jev-latest')
|
|
134
|
+
rerank = protocol.parse_rerank_response(response.new(body), model: 'jev-latest', documents: documents)
|
|
135
|
+
|
|
136
|
+
expect(rerank.results.map(&:index)).to eq([1, 0])
|
|
137
|
+
expect(rerank.results.first.score).to eq(0.95)
|
|
138
|
+
expect(rerank.results.first.document).to eq(documents[1])
|
|
139
|
+
expect(rerank.model).to eq('jev-1.13.0')
|
|
140
|
+
expect(rerank.tokens.input).to eq(120)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'keeps only the top_n results' do
|
|
144
|
+
protocol.render_rerank_payload('Capital of the US?', documents, model: 'jev-latest', top_n: 1)
|
|
145
|
+
rerank = protocol.parse_rerank_response(response.new(body), model: 'jev-latest', documents: documents)
|
|
146
|
+
|
|
147
|
+
expect(rerank.results.map(&:index)).to eq([1])
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe '#parse_list_models_response' do
|
|
152
|
+
it 'builds rerank models priced per input token' do
|
|
153
|
+
body = { 'models' => [{ 'name' => 'jev-latest', 'description' => 'Flagship', 'release_date' => '2026-06-01' }] }
|
|
154
|
+
models = protocol.parse_list_models_response(response.new(body), 'typesafe')
|
|
155
|
+
|
|
156
|
+
expect(models.map(&:id)).to eq(['jev-latest'])
|
|
157
|
+
expect(models.first.type).to eq(:rerank)
|
|
158
|
+
expect(models.first.pricing.text_tokens.input).to eq(0.042)
|
|
159
|
+
expect(models.first.metadata).to eq(description: 'Flagship')
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Providers::Typesafe do
|
|
6
|
+
subject(:provider) { described_class.new(config) }
|
|
7
|
+
|
|
8
|
+
let(:config) do
|
|
9
|
+
RubyLLM::Configuration.new.tap do |provider_config|
|
|
10
|
+
provider_config.typesafe_api_key = 'test-key'
|
|
11
|
+
provider_config.typesafe_api_base = 'https://example.test'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'is registered with RubyLLM' do
|
|
16
|
+
expect(RubyLLM::Provider.resolve(:typesafe)).to eq(described_class)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'speaks the System One protocol' do
|
|
20
|
+
expect(described_class.protocols).to eq(system_one: described_class::SystemOne)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'declares provider configuration' do
|
|
24
|
+
expect(described_class.configuration_options).to eq(%i[typesafe_api_key typesafe_api_base])
|
|
25
|
+
expect(described_class.configuration_requirements).to eq(%i[typesafe_api_key])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'uses configured API base and bearer token' do
|
|
29
|
+
expect(provider.api_base).to eq('https://example.test')
|
|
30
|
+
expect(provider.headers).to eq('Authorization' => 'Bearer test-key')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'defaults to the public API base' do
|
|
34
|
+
config.typesafe_api_base = nil
|
|
35
|
+
|
|
36
|
+
expect(provider.api_base).to eq('https://api.typesafe.ai')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'reads the message from an error detail' do
|
|
40
|
+
response = Struct.new(:body).new(
|
|
41
|
+
{ 'detail' => { 'error_type' => 'authentication_error', 'message' => 'Cannot authenticate with the server.' } }
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
expect(provider.parse_error(response)).to eq('Cannot authenticate with the server.')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'joins validation details into the error message' do
|
|
48
|
+
response = Struct.new(:body).new({ 'detail' => [{ 'msg' => 'Field required' }, { 'msg' => 'Too short' }] })
|
|
49
|
+
|
|
50
|
+
expect(provider.parse_error(response)).to eq('Field required. Too short')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'falls back to the standard error message' do
|
|
54
|
+
response = Struct.new(:body).new({ 'error' => { 'message' => 'Invalid API key' } })
|
|
55
|
+
|
|
56
|
+
expect(provider.parse_error(response)).to eq('Invalid API key')
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Rerank do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
around do |example|
|
|
9
|
+
VCR.turned_off { example.run }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
stub_request(:post, 'https://api.typesafe.ai/v1/systemone').to_return(
|
|
14
|
+
status: 200,
|
|
15
|
+
headers: { 'Content-Type' => 'application/json' },
|
|
16
|
+
body: { model: 'jev-1.13.0',
|
|
17
|
+
answers: { 'document_0' => { type: 'noul', noul: 0.2 }, 'document_1' => { type: 'noul', noul: 0.9 } },
|
|
18
|
+
usage: { input_tokens: 100, output_tokens: 4 } }.to_json
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'ranks documents and records usage' do
|
|
23
|
+
rerank = RubyLLM.rerank('Capital of the US?', ['Carson City is in Nevada.', 'Washington, D.C.'],
|
|
24
|
+
model: 'jev-latest', provider: :typesafe)
|
|
25
|
+
|
|
26
|
+
expect(rerank.results.map(&:document)).to eq(['Washington, D.C.', 'Carson City is in Nevada.'])
|
|
27
|
+
expect(rerank.tokens.input).to eq(100)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'raises a RubyLLM error for an invalid API key' do
|
|
31
|
+
stub_request(:post, 'https://api.typesafe.ai/v1/systemone')
|
|
32
|
+
.to_return(status: 401, headers: { 'Content-Type' => 'application/json' },
|
|
33
|
+
body: { detail: 'Invalid API key' }.to_json)
|
|
34
|
+
|
|
35
|
+
expect { RubyLLM.rerank('q', %w[a b], model: 'jev-latest', provider: :typesafe) }
|
|
36
|
+
.to raise_error(RubyLLM::UnauthorizedError, 'Invalid API key')
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Rerank, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
each_model(RERANK_MODELS) do |provider, model|
|
|
9
|
+
it "#{provider}/#{model} orders documents by relevance" do
|
|
10
|
+
rerank = RubyLLM.rerank(
|
|
11
|
+
'What is the capital of the United States?',
|
|
12
|
+
['Carson City is the capital of Nevada.', 'Washington, D.C. is the capital of the United States.'],
|
|
13
|
+
model: model,
|
|
14
|
+
provider: provider,
|
|
15
|
+
assume_model_exists: true
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
expect(rerank.results.first.document).to include('Washington')
|
|
19
|
+
expect(rerank.results.first.score).to be > rerank.results.last.score
|
|
20
|
+
expect(rerank.results.map(&:index)).to contain_exactly(0, 1)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Typesafe::Evaluation, :live do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
each_model(EVALUATION_MODELS) do |provider, model|
|
|
9
|
+
it "#{provider}/#{model} answers noul, choice, and score questions" do
|
|
10
|
+
evaluation = RubyLLM::Typesafe.evaluate('Help! My payouts have been failing for 3 days.', model: model) do |q|
|
|
11
|
+
q.noul :urgent, 'Does this convey urgency?'
|
|
12
|
+
q.choice :team, 'Which team should handle this?',
|
|
13
|
+
billing: 'Payments, invoicing, refunds',
|
|
14
|
+
technical: 'Bugs, outages, integrations',
|
|
15
|
+
sales: 'Pricing, upgrades, new accounts'
|
|
16
|
+
q.score :frustration, 'How frustrated is the customer?', ['Calm', 'Frustrated', 'Very angry']
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
expect(evaluation[:urgent].probability).to be_between(0, 1)
|
|
20
|
+
expect(evaluation[:team].probabilities.keys).to contain_exactly(:billing, :technical, :sales)
|
|
21
|
+
expect(evaluation[:team].probabilities.values.sum).to be_within(0.01).of(1)
|
|
22
|
+
expect(evaluation[:frustration].score).to be_between(0, 2)
|
|
23
|
+
expect(evaluation[:frustration].probabilities.size).to eq(3)
|
|
24
|
+
expect(evaluation.tokens.input).to be_positive
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe RubyLLM::Typesafe do
|
|
6
|
+
include_context 'with configured RubyLLM'
|
|
7
|
+
|
|
8
|
+
describe '.evaluate' do
|
|
9
|
+
before do
|
|
10
|
+
stub_request(:post, 'https://api.typesafe.ai/v1/systemone')
|
|
11
|
+
.with(body: hash_including('model' => 'jev-latest', 'state' => 'Help! My payouts have been failing.'))
|
|
12
|
+
.to_return(
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { 'Content-Type' => 'application/json' },
|
|
15
|
+
body: { model: 'jev-1.13.0', answers: { urgent: { type: 'noul', noul: 0.9 } },
|
|
16
|
+
usage: { input_tokens: 20, output_tokens: 2 } }.to_json
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
around do |example|
|
|
21
|
+
VCR.turned_off { example.run }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'returns an Evaluation keyed by question id' do
|
|
25
|
+
evaluation = described_class.evaluate('Help! My payouts have been failing.') do |q|
|
|
26
|
+
q.noul :urgent, 'Does this convey urgency?'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
expect(evaluation).to be_a(RubyLLM::Typesafe::Evaluation)
|
|
30
|
+
expect(evaluation[:urgent].probability).to eq(0.9)
|
|
31
|
+
expect(evaluation.model).to eq('jev-1.13.0')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'requires a block' do
|
|
35
|
+
expect { described_class.evaluate('text') }.to raise_error(ArgumentError, /requires a block/)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'requires at least one question' do
|
|
39
|
+
expect { described_class.evaluate('text') { nil } }.to raise_error(ArgumentError, /at least one question/)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe 'questions' do
|
|
44
|
+
subject(:questions) { RubyLLM::Typesafe::Questions.new }
|
|
45
|
+
|
|
46
|
+
it 'rejects a duplicate id' do
|
|
47
|
+
questions.noul :spam, 'Is this spam?'
|
|
48
|
+
|
|
49
|
+
expect { questions.noul :spam, 'Is this spam?' }.to raise_error(ArgumentError, /already defined/)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'requires two options for a choice' do
|
|
53
|
+
expect { questions.choice :team, 'Which team?', billing: 'Payments' }.to raise_error(ArgumentError)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'requires two levels for a score' do
|
|
57
|
+
expect { questions.score :tone, 'How warm?', ['Cold'] }.to raise_error(ArgumentError)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|