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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/.flayignore +1 -0
  3. data/.github/workflows/ci.yml +32 -0
  4. data/.github/workflows/gitleaks.yml +22 -0
  5. data/.github/workflows/release.yml +33 -0
  6. data/.overcommit.yml +31 -0
  7. data/.rspec +2 -0
  8. data/.rubocop.yml +35 -0
  9. data/Archspec.rb +23 -0
  10. data/LICENSE +21 -0
  11. data/README.md +84 -0
  12. data/lib/ruby_llm/providers/typesafe/system_one/evaluations.rb +95 -0
  13. data/lib/ruby_llm/providers/typesafe/system_one/models.rb +35 -0
  14. data/lib/ruby_llm/providers/typesafe/system_one/rerank.rb +57 -0
  15. data/lib/ruby_llm/providers/typesafe/system_one.rb +19 -0
  16. data/lib/ruby_llm/providers/typesafe.rb +68 -0
  17. data/lib/ruby_llm/typesafe/evaluation.rb +75 -0
  18. data/lib/ruby_llm/typesafe/questions.rb +74 -0
  19. data/lib/ruby_llm/typesafe.rb +47 -0
  20. data/models.json +62 -0
  21. data/spec/fixtures/vcr_cassettes/rubyllm_rerank_typesafe_jev_latest_orders_documents_by_relevance.yml +49 -0
  22. data/spec/fixtures/vcr_cassettes/rubyllm_typesafe_evaluation_typesafe_jev_latest_answers_noul_choice_and_score_questions.yml +46 -0
  23. data/spec/ruby_llm/models_spec.rb +11 -0
  24. data/spec/ruby_llm/providers/typesafe/system_one_spec.rb +162 -0
  25. data/spec/ruby_llm/providers/typesafe_spec.rb +58 -0
  26. data/spec/ruby_llm/rerank_request_spec.rb +38 -0
  27. data/spec/ruby_llm/rerank_spec.rb +23 -0
  28. data/spec/ruby_llm/typesafe/evaluation_spec.rb +27 -0
  29. data/spec/ruby_llm/typesafe_spec.rb +60 -0
  30. data/spec/spec_helper.rb +26 -0
  31. data/spec/support/models.rb +9 -0
  32. data/spec/support/rubyllm_configuration.rb +14 -0
  33. data/spec/support/vcr_configuration.rb +16 -0
  34. 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