ruby_llm-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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a12203b55c41d21232f4b4581cbb8392f3b51f606f97b00aad976006de859409
4
+ data.tar.gz: 497dbb9ba1e5045ed73f449aa4c1b383ccc8c24637b11e4049ebe99b3e973cbb
5
+ SHA512:
6
+ metadata.gz: 1b45cc32a6ee79cf3cd75243c3451e6529a48076081c6eba3b12e0ebf15dfac9c1b07f24dfbf0649568412560b15fdbf7758812eb1a57283c420d5565410625f
7
+ data.tar.gz: 41d77edacaf5b014945f880c5f2e25c90e7e28c4fc611a4c899a77af1409ec9d1fb299f4cc606353e34e55492f35341e4db8c839fd1bc5079e8c4ce8aa8aaa21
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-16
11
+
12
+ ### Added
13
+
14
+ - `:typesafe` provider for RubyLLM 2 (`>= 2.0.0.rc3, < 3`) that evaluates state against TypeSafe's System One endpoint (`POST /v1/systemone`).
15
+ - `RubyLLM::Providers::TypeSafe::Schema`, a builder for batched Noul, Choice, and Score questions that plugs into `Chat#with_schema` and validates ids, instructions, and criteria before any request.
16
+ - `RubyLLM::Protocols::SystemOne`, the wire format. The latest user message is the state, or an explicit `state` from `with_provider_options`. JSON object or array messages become structured state. The typed `answers` map comes back through `Message#parsed` with the response model, token usage, and raw HTTP response.
17
+ - Packaged `models.json` catalog (`jev-latest`, `jev-preview`) refreshed from `GET /v1/models` with `rake models`.
18
+ - Readable error messages for TypeSafe's `detail` error bodies, mapped onto RubyLLM's error classes (401, 400, 422, 429, 529, 5xx) with RubyLLM's bounded retries and `Retry-After` handling.
19
+ - TypeSafe is structured output only, so plain chat, ordinary JSON Schemas, streaming, tools, and attachments raise a `RubyLLM::Error` before any request.
20
+
21
+ [Unreleased]: https://github.com/kieranklaassen/ruby_llm-typesafe/compare/v0.1.0...HEAD
22
+ [0.1.0]: https://github.com/kieranklaassen/ruby_llm-typesafe/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kieran Klaassen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,281 @@
1
+ # ruby_llm-typesafe
2
+
3
+ [![CI](https://github.com/kieranklaassen/ruby_llm-typesafe/actions/workflows/ci.yml/badge.svg)](https://github.com/kieranklaassen/ruby_llm-typesafe/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/ruby_llm-typesafe.svg)](https://rubygems.org/gems/ruby_llm-typesafe)
5
+
6
+ A [RubyLLM](https://rubyllm.com) 2 provider for [TypeSafe](https://typesafe.ai).
7
+
8
+ TypeSafe runs Jev, a System One model. Jev does not write text. You give it one piece of state (a string, or JSON your application already has) and a batch of typed questions. It answers each question with a probability your code can act on. There are three question types, which TypeSafe calls primitives:
9
+
10
+ - Noul asks whether something is true and returns the probability of yes.
11
+ - Choice picks one option from a set you define and returns a probability for each option.
12
+ - Score rates the state against ordered levels you define and returns a weighted position on that scale.
13
+
14
+ This gem adds a `:typesafe` provider to RubyLLM. Because TypeSafe only returns typed answers, the provider works through RubyLLM's structured output API and nothing else. You build the questions with `RubyLLM::Providers::TypeSafe::Schema`, pass them to `chat.with_schema`, call `ask` with the state, and read the answers from `response.parsed`. Calling `ask` without a schema, streaming, and tools raise an error before any request is sent (see [Structured output only](#structured-output-only)).
15
+
16
+ ```ruby
17
+ require 'ruby_llm-typesafe'
18
+
19
+ RubyLLM.configure do |config|
20
+ config.typesafe_api_key = ENV['TYPESAFE_API_KEY']
21
+ end
22
+
23
+ schema = RubyLLM::Providers::TypeSafe::Schema.new do |s|
24
+ s.noul :is_urgent, instructions: 'Does this convey urgency?'
25
+ s.choice :department, instructions: 'Which team should handle this?',
26
+ criteria: { billing: 'Payments, invoicing, refunds',
27
+ technical: 'Bugs, outages, integrations',
28
+ sales: 'Pricing, upgrades, new accounts' }
29
+ s.score :frustration, instructions: 'How frustrated is the customer?',
30
+ criteria: ['Calm', 'Frustrated', 'Very angry']
31
+ end
32
+
33
+ response = RubyLLM.chat(model: 'jev-latest', provider: :typesafe)
34
+ .with_schema(schema)
35
+ .ask('Help! My payouts have been failing for 3 days.')
36
+
37
+ answers = response.parsed
38
+ answers['is_urgent']['noul'] # => 0.95
39
+ answers['department']['choice'] # => "billing"
40
+ answers['department']['probabilities'] # => {"billing"=>0.87, "technical"=>0.13, "sales"=>0.0}
41
+ answers['frustration']['score'] # => 1.04 (between "Frustrated" and "Very angry")
42
+ ```
43
+
44
+ ## Installation
45
+
46
+ Add the gem to your Gemfile:
47
+
48
+ ```ruby
49
+ gem 'ruby_llm', '>= 2.0.0.rc3'
50
+ gem 'ruby_llm-typesafe'
51
+ ```
52
+
53
+ Requiring `ruby_llm-typesafe` (Bundler does this for you) registers the provider, defines its configuration options, and adds the packaged TypeSafe model catalog to RubyLLM's registry. Nothing in RubyLLM itself changes.
54
+
55
+ The gem supports RubyLLM `>= 2.0.0.rc3, < 3` on Ruby 3.1.3 and newer.
56
+
57
+ ## Configuration
58
+
59
+ Get an API key from the [TypeSafe console](https://console.typesafe.ai), keep it in your environment, and hand it to RubyLLM:
60
+
61
+ ```ruby
62
+ RubyLLM.configure do |config|
63
+ config.typesafe_api_key = ENV['TYPESAFE_API_KEY']
64
+ # config.typesafe_api_base = 'https://api.typesafe.ai' # optional proxy or regional base
65
+ end
66
+ ```
67
+
68
+ The gem sends the key only in the `Authorization: Bearer` header. Never commit it. The gem's own specs and cassettes read it from `TYPESAFE_API_KEY` at run time and scrub it before writing anything to disk.
69
+
70
+ Timeouts, proxies, retry limits, and logging come from RubyLLM's own [configuration](https://rubyllm.com/configuration/). TypeSafe requests go through the same Faraday stack as every other provider.
71
+
72
+ ## Usage
73
+
74
+ ### Build the questions
75
+
76
+ `RubyLLM::Providers::TypeSafe::Schema` is the request. Each question has an id you choose, `instructions`, and, for Choice and Score, `criteria`. TypeSafe evaluates every question independently over the same state in one request, so batch what you can.
77
+
78
+ ```ruby
79
+ schema = RubyLLM::Providers::TypeSafe::Schema.new do |s|
80
+ s.noul :is_urgent, instructions: 'Does this convey urgency?'
81
+ s.choice :department, instructions: 'Which team should handle this?', criteria: { billing: nil, technical: nil, sales: nil }
82
+ s.score :frustration, instructions: 'How frustrated is the customer?', criteria: ['Calm', 'Frustrated', 'Very angry']
83
+ end
84
+
85
+ schema.ids # => ["is_urgent", "department", "frustration"]
86
+ schema.questions # => the exact question map sent to TypeSafe
87
+ ```
88
+
89
+ Validation runs when you add a question, before any request. Duplicate or unsafe ids, missing instructions, an empty Choice map, a Score with fewer than two levels, and values that are not JSON (symbols, `NaN`, arbitrary objects) raise `ArgumentError`.
90
+
91
+ #### Noul: is this true?
92
+
93
+ A Noul returns the probability that the answer is yes, as `noul` between 0 and 1. `criteria` is optional and pins down what each side means.
94
+
95
+ ```ruby
96
+ s.noul :is_urgent, instructions: 'Does this convey urgency?',
97
+ criteria: { true => 'Explicitly time-sensitive', false => 'No urgency expressed' }
98
+ ```
99
+
100
+ ```ruby
101
+ answers['is_urgent'] # => {"type"=>"noul", "noul"=>0.95}
102
+ ```
103
+
104
+ Ask one Noul per label when several labels may apply at once. A value near 0.5 means yes and no are about equally likely. It does not mean medium intensity.
105
+
106
+ #### Choice: which one?
107
+
108
+ A Choice picks one option from `criteria`, a Hash of option to description (`nil` when the name is enough). It returns the winning `choice`, a `probabilities` distribution over every option, and a `confidence` derived from how concentrated that distribution is.
109
+
110
+ ```ruby
111
+ s.choice :department, instructions: 'Which team should handle this?',
112
+ criteria: { billing: 'Payments, invoicing, refunds',
113
+ technical: 'Bugs, outages, integrations',
114
+ sales: 'Pricing, upgrades, new accounts' }
115
+ ```
116
+
117
+ ```ruby
118
+ answers['department']
119
+ # => {"type"=>"choice", "choice"=>"billing",
120
+ # "probabilities"=>{"billing"=>0.87, "technical"=>0.13, "sales"=>0.0},
121
+ # "confidence"=>0.8}
122
+ ```
123
+
124
+ Include a no-match option when nothing may fit. The model cannot choose an option you did not list.
125
+
126
+ #### Score: how much?
127
+
128
+ A Score rates the state against an ordered Array of at least two level descriptions. It returns a probability-weighted `score` that can land between levels, the `legend` mapping level indexes back to your descriptions, `probabilities` per level, and `confidence`.
129
+
130
+ ```ruby
131
+ s.score :frustration, instructions: 'How frustrated is the customer?',
132
+ criteria: ['Calm', 'Frustrated', 'Very angry']
133
+ ```
134
+
135
+ ```ruby
136
+ answers['frustration']
137
+ # => {"type"=>"score", "score"=>1.04,
138
+ # "legend"=>{"0"=>"Calm", "1"=>"Frustrated", "2"=>"Very angry"},
139
+ # "probabilities"=>{"0"=>0.0, "1"=>0.96, "2"=>0.04},
140
+ # "confidence"=>0.94}
141
+ ```
142
+
143
+ #### Structured instructions and criteria
144
+
145
+ Instructions, Choice descriptions, Score levels, and Noul criteria all accept JSON structure (Hashes, Arrays, `nil`). Use it when a question has several parts or needs supporting data such as a taxonomy or a record:
146
+
147
+ ```ruby
148
+ s.choice :department,
149
+ instructions: { question: 'Which team should handle this message?',
150
+ focus: "Classify the customer's primary request, not every topic mentioned." },
151
+ criteria: {
152
+ billing: { what: 'Charges, invoices, refunds', not_for: 'Order tracking',
153
+ examples: ['I was charged twice', 'Where is my refund?'] },
154
+ orders: { what: 'Order status, delivery, returns', examples: ['Where is my package?'] }
155
+ }
156
+ ```
157
+
158
+ TypeSafe's [primitives](https://docs.typesafe.ai/primitives) and [advanced structure](https://docs.typesafe.ai/primitives/advanced) guides explain how to write questions the model answers well.
159
+
160
+ ### Send the state
161
+
162
+ The latest user message is the state. The gem sends plain text as a string:
163
+
164
+ ```ruby
165
+ chat = RubyLLM.chat(model: 'jev-latest', provider: :typesafe).with_schema(schema)
166
+ chat.ask('Help! My payouts have been failing for 3 days.')
167
+ ```
168
+
169
+ A message that is a JSON object or array is decoded and sent as structured state, so you can pass records, chat logs, or application state as they are:
170
+
171
+ ```ruby
172
+ chat.ask({ sender: { email: 'donotreply@payroll.example' },
173
+ message: 'Reply with your login password so we can release the funds.' }.to_json)
174
+ ```
175
+
176
+ To set the state directly, use `with_provider_options`. `provider_options` is System One's own request vocabulary and merges into the request body. `state` replaces the message-derived state, and `generate` sends the request without staging a message:
177
+
178
+ ```ruby
179
+ chat.with_provider_options(state: { ticket: ticket.as_json, history: ticket.messages.as_json }).generate
180
+ ```
181
+
182
+ Each `ask` is one independent evaluation. Earlier turns and answers stay in `chat.messages` for your own bookkeeping, but the gem sends only the latest state. To judge a conversation, pass the conversation as the state.
183
+
184
+ ### Read the answers
185
+
186
+ The response is an ordinary `RubyLLM::Message`:
187
+
188
+ ```ruby
189
+ response = chat.ask('Help! My payouts have been failing for 3 days.')
190
+
191
+ response.parsed # => the answers map, keyed by your question ids
192
+ response.model # => "jev-1.13.0", the model version that ran
193
+ response.tokens.input # => 440
194
+ response.tokens.output # => 73
195
+ response.raw # => the Faraday::Response, if you need headers or the full body
196
+ ```
197
+
198
+ `probabilities` tell you what the model thinks. `confidence` (Choice and Score only) tells you how concentrated that opinion is. Thresholds, escalation, and business rules stay in your code. Typed output guarantees the shape of an answer, not its correctness, so evaluate the model on your own data before acting on it automatically. TypeSafe's [confidence](https://docs.typesafe.ai/confidence) guide covers the distinction.
199
+
200
+ ## Structured output only
201
+
202
+ TypeSafe answers typed questions. It does not write text, so this provider supports only the path above. Everything else raises before the gem sends a request:
203
+
204
+ | Call | Raises |
205
+ |------|--------|
206
+ | `ask` without `with_schema` | `RubyLLM::Error`: `TypeSafe answers typed questions only. Build them with RubyLLM::Providers::TypeSafe::Schema and pass the schema to with_schema.` |
207
+ | `with_schema` with an ordinary JSON Schema (a Hash or `RubyLLM::Schema`) | the same `RubyLLM::Error` |
208
+ | `ask` with a block (streaming) | `RubyLLM::Error`: `TypeSafe doesn't support streaming` |
209
+ | `with_tools` | `RubyLLM::Error`: `TypeSafe doesn't support tools` |
210
+ | `with_server_tools` | `RubyLLM::UnsupportedServerToolError` |
211
+ | `ask(..., with: file)` | `RubyLLM::UnsupportedAttachmentError` |
212
+ | `embed`, `paint`, `speak`, `transcribe`, `moderate`, `rerank` | RubyLLM's usual `RubyLLM::Error`: `TypeSafe doesn't support ...` |
213
+
214
+ For example, a chat without a schema fails on `ask`, not on the network:
215
+
216
+ ```ruby
217
+ chat = RubyLLM.chat(model: 'jev-latest', provider: :typesafe)
218
+
219
+ chat.ask('Help! My payouts have been failing for 3 days.')
220
+ # RubyLLM::Error: TypeSafe answers typed questions only. Build them with
221
+ # RubyLLM::Providers::TypeSafe::Schema and pass the schema to with_schema.
222
+
223
+ chat.with_schema(schema).ask('Help!') { |chunk| print chunk.content }
224
+ # RubyLLM::Error: TypeSafe doesn't support streaming
225
+ ```
226
+
227
+ ## Error handling
228
+
229
+ HTTP failures raise RubyLLM's [standard error classes](https://rubyllm.com/error-handling/) with TypeSafe's message:
230
+
231
+ | Status | Raises |
232
+ |--------|--------|
233
+ | 400 | `RubyLLM::BadRequestError`, for example `Unknown model: no-such-model` |
234
+ | 401 | `RubyLLM::UnauthorizedError` |
235
+ | 422 | `RubyLLM::Error` naming the offending fields, for example `questions.bogus: Input tag 'nope' ...` |
236
+ | 429 | `RubyLLM::RateLimitError` |
237
+ | 529 | `RubyLLM::OverloadedError` |
238
+ | 500 | `RubyLLM::ServerError` |
239
+ | 502, 503, 504 | `RubyLLM::ServiceUnavailableError` |
240
+
241
+ ```ruby
242
+ begin
243
+ chat.ask(text)
244
+ rescue RubyLLM::RateLimitError, RubyLLM::OverloadedError
245
+ # RubyLLM has already retried; back off further or queue the job
246
+ rescue RubyLLM::Error => e
247
+ e.message # => "Unknown model: no-such-model"
248
+ e.response&.status # => 400
249
+ end
250
+ ```
251
+
252
+ TypeSafe asks clients to retry 429 and 529 with backoff. RubyLLM's transport does that and honors `Retry-After`. `config.max_retries`, `config.retry_interval`, `config.retry_backoff_factor`, and `config.retry_max_interval` control the policy.
253
+
254
+ ## Models
255
+
256
+ The gem ships a `models.json` catalog, so `RubyLLM.models.find('jev-latest')` and `RubyLLM.models.by_provider(:typesafe)` work offline. `jev-latest` is TypeSafe's flagship model. The response reports the exact version that ran in `response.model`.
257
+
258
+ ```ruby
259
+ RubyLLM.models.by_provider(:typesafe).map(&:id) # => ["jev-latest", "jev-preview"]
260
+ RubyLLM.chat(model: 'jev-preview', provider: :typesafe)
261
+ RubyLLM.chat(model: 'jev-2', provider: :typesafe, assume_model_exists: true) # not in the catalog yet
262
+ ```
263
+
264
+ `bundle exec rake models` refreshes the packaged catalog from `GET /v1/models`. The application's main RubyLLM registry wins when both carry the same model.
265
+
266
+ ## Development
267
+
268
+ ```bash
269
+ bin/setup # bundle install
270
+ cp .env.example .env # add TYPESAFE_API_KEY to record cassettes or refresh models
271
+ bundle exec rake # rubocop, flay, archspec, rspec
272
+ bundle exec rspec --tag ~live # unit specs only: no cassettes, no API key
273
+ bundle exec rake vcr:record # re-record the :live cassettes against the real API
274
+ bin/console # IRB with the provider configured from .env
275
+ ```
276
+
277
+ Specs tagged `:live` replay VCR cassettes in `spec/fixtures/vcr_cassettes`. CI never records and never needs a key. See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow and [CHANGELOG.md](CHANGELOG.md) for release notes.
278
+
279
+ ## License
280
+
281
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyLLM
6
+ module Protocols
7
+ class SystemOne < Protocol
8
+ # Renders POST v1/systemone requests and parses their answers. See
9
+ # https://docs.typesafe.ai/api for the wire contract.
10
+ module Chat
11
+ # The schema extension key that carries the question map.
12
+ QUESTIONS_KEY = 'x-typesafe'
13
+
14
+ module_function
15
+
16
+ def completion_url
17
+ 'v1/systemone'
18
+ end
19
+
20
+ def render_payload(messages, model:, schema:, state: nil, **)
21
+ {
22
+ model: model.id,
23
+ state: state.nil? ? state_from(messages) : state,
24
+ questions: questions_from(schema)
25
+ }
26
+ end
27
+
28
+ def parse_completion_body(body, raw: nil)
29
+ unless documented_body?(body)
30
+ raise Error.new('TypeSafe returned an unexpected response body; expected model, answers, and usage',
31
+ response: raw)
32
+ end
33
+
34
+ answers, usage = body.values_at('answers', 'usage')
35
+ Message.new(
36
+ role: :assistant,
37
+ content: JSON.generate(answers),
38
+ model: body['model'],
39
+ input_tokens: usage['input_tokens'],
40
+ output_tokens: usage['output_tokens'],
41
+ finish_reason: :stop,
42
+ raw: raw
43
+ )
44
+ end
45
+
46
+ # Reads the question map out of a schema normalized by
47
+ # Chat#with_schema, or +nil+ when the schema carries none.
48
+ def questions_in(schema)
49
+ return unless schema.is_a?(Hash)
50
+
51
+ definition = fetch(schema, 'schema') || schema
52
+ extension = fetch(definition, QUESTIONS_KEY)
53
+ questions = fetch(extension, 'questions')
54
+ return unless questions.is_a?(Hash) && !questions.empty?
55
+
56
+ JSON.parse(JSON.generate(questions))
57
+ end
58
+
59
+ def questions_from(schema)
60
+ questions_in(schema) || raise(
61
+ Error,
62
+ 'TypeSafe answers typed questions only. Build them with RubyLLM::Providers::TypeSafe::Schema ' \
63
+ 'and pass the schema to with_schema.'
64
+ )
65
+ end
66
+
67
+ # Each ask is one independent evaluation, so only the latest user
68
+ # turn becomes state. Earlier turns and answers stay in the transcript.
69
+ def state_from(messages)
70
+ message = messages.reverse.find { |candidate| candidate.role == :user }
71
+ if message.nil? || message.content.nil?
72
+ raise Error, 'TypeSafe needs state to evaluate. Ask with text, or set state through with_provider_options.'
73
+ end
74
+ raise UnsupportedAttachmentError, message.attachments.first.mime_type unless message.attachments.empty?
75
+
76
+ structured_state(message.content)
77
+ end
78
+
79
+ # User content that parses to a JSON object or array is sent as
80
+ # structured state. JSON scalars and everything else stay the string
81
+ # the caller wrote, since state is string, object, or array.
82
+ def structured_state(content)
83
+ return content unless content.is_a?(String) && content.lstrip.start_with?('{', '[')
84
+
85
+ parsed = JSON.parse(content)
86
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : content
87
+ rescue JSON::ParserError
88
+ content
89
+ end
90
+
91
+ def documented_body?(body)
92
+ body.is_a?(Hash) && body['model'].is_a?(String) && body['answers'].is_a?(Hash) && body['usage'].is_a?(Hash)
93
+ end
94
+
95
+ def fetch(hash, key)
96
+ return unless hash.is_a?(Hash)
97
+
98
+ hash.key?(key) ? hash[key] : hash[key.to_sym]
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class SystemOne < Protocol
6
+ # Lists the models available to the account from GET v1/models. The
7
+ # endpoint returns a name, description, and release date per model.
8
+ # Every System One model takes text state and returns structured
9
+ # answers, which is all the catalog can say about them.
10
+ module Models
11
+ MODALITIES = { input: %w[text], output: %w[text] }.freeze
12
+ CAPABILITIES = %w[structured_output].freeze
13
+
14
+ module_function
15
+
16
+ def models_url
17
+ 'v1/models'
18
+ end
19
+
20
+ def parse_list_models_response(response, slug)
21
+ cards = response.body['models'] if response.body.is_a?(Hash)
22
+ unless cards.is_a?(Array)
23
+ raise Error.new('TypeSafe returned an unexpected models list; expected { models: [...] }',
24
+ response: response)
25
+ end
26
+
27
+ cards.map { |card| model_from(card, slug) }
28
+ end
29
+
30
+ def model_from(card, slug)
31
+ Model.new(
32
+ id: card['name'],
33
+ name: card['name'],
34
+ provider: slug,
35
+ family: card['name'].to_s.split('-').first,
36
+ created_at: card['release_date'],
37
+ modalities: MODALITIES,
38
+ capabilities: CAPABILITIES,
39
+ metadata: { description: card['description'] }
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'system_one/chat'
4
+ require_relative 'system_one/models'
5
+
6
+ module RubyLLM
7
+ module Protocols
8
+ # TypeSafe's System One API. POST v1/systemone takes one state and a map
9
+ # of typed questions and returns a map of typed answers plus usage. GET
10
+ # v1/models lists the catalog. System One generates no text, so
11
+ # streaming, tools, and every non-chat operation stay unimplemented and
12
+ # fail with RubyLLM's usual "doesn't support" errors.
13
+ #
14
+ # The questions travel inside the structured-output schema that
15
+ # Chat#with_schema normalizes, under the +x-typesafe+ extension key.
16
+ # RubyLLM::Providers::TypeSafe::Schema puts them there.
17
+ class SystemOne < Protocol
18
+ include SystemOne::Chat
19
+ include SystemOne::Models
20
+
21
+ def complete(messages, tools:, **options, &block)
22
+ raise Error, "#{@provider.name} doesn't support streaming" if block
23
+ raise Error, "#{@provider.name} doesn't support tools" if tools && !tools.empty?
24
+
25
+ super(messages, tools: tools, **options)
26
+ end
27
+
28
+ # +provider_options+ is System One's own vocabulary and merges into the
29
+ # wire payload. An explicit +state+ is the one exception. It replaces
30
+ # the state derived from the latest user message instead of
31
+ # deep-merging into it.
32
+ def render(messages, schema: nil, provider_options: {}, before_request: [], **)
33
+ options = provider_options.to_h.transform_keys(&:to_sym)
34
+ payload = render_payload(messages, model: model, schema: schema, state: options.delete(:state))
35
+ payload = RubyLLM::Support::Utils.deep_merge(payload, options)
36
+ apply_before_request_hooks(payload, before_request)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class TypeSafe < Provider
6
+ class Schema
7
+ # JSON Schema for the typed answer each question type returns. The
8
+ # shapes follow https://docs.typesafe.ai/api#answer-types. A Noul
9
+ # carries one probability. A Choice carries the chosen option, a
10
+ # distribution, and confidence. A Score carries the weighted
11
+ # position, the legend, a distribution over levels, and confidence.
12
+ module Answers
13
+ PROBABILITY = { 'type' => 'number', 'minimum' => 0, 'maximum' => 1 }.freeze
14
+
15
+ module_function
16
+
17
+ def for(question)
18
+ case question['type']
19
+ when 'noul' then noul
20
+ when 'choice' then choice(question['criteria'].keys)
21
+ when 'score' then score(question['criteria'].size)
22
+ end
23
+ end
24
+
25
+ def noul
26
+ {
27
+ 'type' => 'object',
28
+ 'properties' => { 'type' => { 'const' => 'noul' }, 'noul' => PROBABILITY },
29
+ 'required' => %w[type noul]
30
+ }
31
+ end
32
+
33
+ def choice(options)
34
+ {
35
+ 'type' => 'object',
36
+ 'properties' => {
37
+ 'type' => { 'const' => 'choice' },
38
+ 'choice' => { 'type' => 'string', 'enum' => options },
39
+ 'probabilities' => probabilities(options),
40
+ 'confidence' => PROBABILITY
41
+ },
42
+ 'required' => %w[type choice probabilities confidence]
43
+ }
44
+ end
45
+
46
+ # Level descriptions may be structured, so the legend values stay
47
+ # unconstrained.
48
+ def score(level_count)
49
+ levels = Array.new(level_count, &:to_s)
50
+ {
51
+ 'type' => 'object',
52
+ 'properties' => {
53
+ 'type' => { 'const' => 'score' },
54
+ 'score' => { 'type' => 'number', 'minimum' => 0, 'maximum' => level_count - 1 },
55
+ 'legend' => { 'type' => 'object', 'properties' => levels.to_h { |level| [level, {}] },
56
+ 'required' => levels },
57
+ 'probabilities' => probabilities(levels),
58
+ 'confidence' => PROBABILITY
59
+ },
60
+ 'required' => %w[type score legend probabilities confidence]
61
+ }
62
+ end
63
+
64
+ def probabilities(keys)
65
+ {
66
+ 'type' => 'object',
67
+ 'properties' => keys.to_h { |key| [key, PROBABILITY] },
68
+ 'required' => keys
69
+ }
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'schema/answers'
5
+
6
+ module RubyLLM
7
+ module Providers
8
+ class TypeSafe < Provider
9
+ # Builds the batch of typed System One questions a chat sends to
10
+ # TypeSafe. Pass an instance to Chat#with_schema. RubyLLM sees a JSON
11
+ # Schema that describes the typed answer map, and the protocol reads
12
+ # the question payload back out of the schema's +x-typesafe+ key.
13
+ #
14
+ # schema = RubyLLM::Providers::TypeSafe::Schema.new do |s|
15
+ # s.noul :is_urgent, instructions: 'Does this convey urgency?'
16
+ # s.choice :department, instructions: 'Which team should handle this?',
17
+ # criteria: { billing: 'Payments and refunds', technical: 'Bugs and outages' }
18
+ # s.score :frustration, instructions: 'How frustrated is the customer?',
19
+ # criteria: ['Calm', 'Frustrated', 'Very angry']
20
+ # end
21
+ #
22
+ # Every question needs a unique id, +instructions+, and, for Choice and
23
+ # Score, +criteria+. Instructions and criteria accept strings or JSON
24
+ # structure (Hash, Array, nil), as documented at
25
+ # https://docs.typesafe.ai/primitives/advanced. Validation happens
26
+ # here, before any request. Invalid input raises ArgumentError.
27
+ class Schema
28
+ NAME = 'typesafe_answers'
29
+ ID_PATTERN = /\A[A-Za-z0-9_.-]+\z/
30
+ NOUL_CRITERIA_KEYS = %w[true false].freeze
31
+
32
+ # Returns the question payloads keyed by id, exactly as sent to TypeSafe.
33
+ attr_reader :questions
34
+
35
+ # Creates a schema and yields it to +block+ for adding questions.
36
+ def initialize
37
+ @questions = {}
38
+ yield self if block_given?
39
+ end
40
+
41
+ # Adds a yes/no question. Returns the probability the answer is yes.
42
+ # +criteria+ may describe what +true+ and +false+ mean. Returns +self+.
43
+ #
44
+ # s.noul :is_urgent, instructions: 'Does this convey urgency?',
45
+ # criteria: { true: 'Explicitly time-sensitive', false: 'No urgency expressed' }
46
+ #
47
+ def noul(id, instructions:, criteria: nil)
48
+ add(id, 'noul', instructions, noul_criteria(id, criteria))
49
+ end
50
+
51
+ # Adds a question that picks one option from +criteria+, a Hash of
52
+ # option to description (+nil+ when the option needs no detail).
53
+ # Returns +self+.
54
+ #
55
+ # s.choice :department, instructions: 'Which team should handle this?',
56
+ # criteria: { billing: 'Payments, invoicing, refunds', sales: nil }
57
+ #
58
+ def choice(id, instructions:, criteria:)
59
+ unless criteria.is_a?(Hash) && !criteria.empty?
60
+ raise ArgumentError, "#{id}: choice criteria must be a non-empty Hash of options"
61
+ end
62
+
63
+ options = criteria.to_h do |option, description|
64
+ [validate_key(id, option, 'choice option'), validate_description(id, description, 'choice option')]
65
+ end
66
+ add(id, 'choice', instructions, options)
67
+ end
68
+
69
+ # Adds a question that rates the state along +criteria+, an ordered
70
+ # Array of at least two level descriptions. Returns +self+.
71
+ #
72
+ # s.score :frustration, instructions: 'How frustrated is the customer?',
73
+ # criteria: ['Calm', 'Frustrated', 'Very angry']
74
+ #
75
+ def score(id, instructions:, criteria:)
76
+ unless criteria.is_a?(Array) && criteria.size >= 2
77
+ raise ArgumentError, "#{id}: score criteria must be an Array of at least two ordered levels"
78
+ end
79
+
80
+ levels = criteria.map { |level| validate_description(id, level, 'score level') }
81
+ add(id, 'score', instructions, levels)
82
+ end
83
+
84
+ # Returns the question ids in insertion order.
85
+ def ids
86
+ @questions.keys
87
+ end
88
+
89
+ # Returns whether no questions have been added.
90
+ def empty?
91
+ @questions.empty?
92
+ end
93
+
94
+ # Returns the number of questions.
95
+ def size
96
+ @questions.size
97
+ end
98
+
99
+ # Returns the structured-output schema RubyLLM hands to the provider,
100
+ # a JSON Schema for the typed answer map with the question payload
101
+ # under the +x-typesafe+ key.
102
+ def to_json_schema
103
+ raise ArgumentError, 'TypeSafe schema has no questions; add at least one noul, choice, or score' if empty?
104
+
105
+ {
106
+ 'name' => NAME,
107
+ 'description' => 'TypeSafe System One answers keyed by question id',
108
+ 'schema' => {
109
+ 'type' => 'object',
110
+ 'properties' => @questions.transform_values { |question| Answers.for(question) },
111
+ 'required' => ids,
112
+ Protocols::SystemOne::Chat::QUESTIONS_KEY => { 'questions' => deep_copy(@questions) }
113
+ }
114
+ }
115
+ end
116
+
117
+ alias to_h to_json_schema
118
+
119
+ def to_json(*args)
120
+ to_json_schema.to_json(*args)
121
+ end
122
+
123
+ private
124
+
125
+ def add(id, type, instructions, criteria)
126
+ key = validate_id(id)
127
+ question = { 'type' => type, 'instructions' => validate_instructions(key, instructions) }
128
+ question['criteria'] = criteria unless criteria.nil?
129
+ @questions[key] = deep_copy(question)
130
+ self
131
+ end
132
+
133
+ def validate_id(id)
134
+ key = id.to_s if id.is_a?(Symbol) || id.is_a?(String)
135
+ unless key&.match?(ID_PATTERN)
136
+ raise ArgumentError,
137
+ "question id #{id.inspect} must be a String or Symbol of letters, digits, '_', '.', or '-'"
138
+ end
139
+ raise ArgumentError, "question id #{key.inspect} is already defined" if @questions.key?(key)
140
+
141
+ key
142
+ end
143
+
144
+ def validate_instructions(id, instructions)
145
+ raise ArgumentError, "#{id}: instructions are required" if instructions.nil? || instructions == ''
146
+
147
+ validate_json(id, instructions, 'instructions')
148
+ end
149
+
150
+ def noul_criteria(id, criteria)
151
+ return if criteria.nil?
152
+ unless criteria.is_a?(Hash)
153
+ raise ArgumentError, "#{id}: noul criteria must be a Hash with only true and false keys"
154
+ end
155
+
156
+ criteria.to_h do |key, description|
157
+ normalized = key.to_s
158
+ unless NOUL_CRITERIA_KEYS.include?(normalized)
159
+ raise ArgumentError, "#{id}: noul criteria key #{key.inspect} must be true or false"
160
+ end
161
+
162
+ [normalized, validate_description(id, description, 'noul criteria')]
163
+ end
164
+ end
165
+
166
+ def validate_key(id, key, label)
167
+ unless (key.is_a?(String) || key.is_a?(Symbol)) && !key.to_s.empty?
168
+ raise ArgumentError, "#{id}: #{label} keys must be non-empty Strings or Symbols"
169
+ end
170
+
171
+ key.to_s
172
+ end
173
+
174
+ def validate_description(id, value, label)
175
+ return if value.nil?
176
+
177
+ validate_json(id, value, label)
178
+ end
179
+
180
+ def validate_json(id, value, label)
181
+ case value
182
+ when String, Integer, true, false, nil
183
+ value
184
+ when Float
185
+ raise ArgumentError, "#{id}: #{label} contains a non-finite number" unless value.finite?
186
+
187
+ value
188
+ when Array
189
+ value.each { |item| validate_json(id, item, label) }
190
+ when Hash
191
+ value.each do |key, item|
192
+ validate_key(id, key, label)
193
+ validate_json(id, item, label)
194
+ end
195
+ else
196
+ raise ArgumentError, "#{id}: #{label} contains #{value.class}, which is not JSON-compatible"
197
+ end
198
+ end
199
+
200
+ # A JSON round trip yields plain string-keyed data that shares
201
+ # nothing with the caller's objects.
202
+ def deep_copy(value)
203
+ JSON.parse(JSON.generate(value))
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'json'
5
+
6
+ require_relative '../protocols/system_one'
7
+ require_relative 'typesafe/schema'
8
+
9
+ module RubyLLM
10
+ module Providers
11
+ # TypeSafe API integration. Jev, TypeSafe's System One model, evaluates
12
+ # one state against typed Choice, Noul, and Score questions and returns
13
+ # calibrated probabilities instead of generated text. This provider is
14
+ # therefore structured output only. Build the questions with Schema,
15
+ # pass them to Chat#with_schema, and read the answers from
16
+ # Message#parsed.
17
+ #
18
+ # RubyLLM.configure { |config| config.typesafe_api_key = ENV['TYPESAFE_API_KEY'] }
19
+ #
20
+ # schema = RubyLLM::Providers::TypeSafe::Schema.new do |s|
21
+ # s.noul :is_urgent, instructions: 'Does this convey urgency?'
22
+ # end
23
+ #
24
+ # RubyLLM.chat(model: 'jev-latest', provider: :typesafe)
25
+ # .with_schema(schema)
26
+ # .ask('Help! My payouts have been failing for 3 days.')
27
+ # .parsed # => {"is_urgent" => {"type" => "noul", "noul" => 0.95}}
28
+ class TypeSafe < Provider
29
+ # The version of the ruby_llm-typesafe gem, as a string.
30
+ VERSION = '0.1.0'
31
+
32
+ protocol :system_one, Protocols::SystemOne
33
+
34
+ def api_base
35
+ @config.typesafe_api_base || 'https://api.typesafe.ai'
36
+ end
37
+
38
+ def headers
39
+ { 'Authorization' => "Bearer #{@config.typesafe_api_key}" }
40
+ end
41
+
42
+ # TypeSafe reports failures under +detail+, either a Hash with a
43
+ # +message+ for API usage errors or a list of offending fields for 422
44
+ # validation failures. The generic parser reads neither shape.
45
+ def parse_error(response)
46
+ body = parse_error_body(response)
47
+ detail = body['detail'] if body.is_a?(Hash)
48
+
49
+ case detail
50
+ when Hash then detail['message'] || super
51
+ when Array then detail.map { |part| validation_message(part) }.join('; ')
52
+ else super
53
+ end
54
+ end
55
+
56
+ class << self
57
+ def configuration_options
58
+ %i[typesafe_api_key typesafe_api_base]
59
+ end
60
+
61
+ def configuration_requirements
62
+ %i[typesafe_api_key]
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def validation_message(part)
69
+ return part.to_s unless part.is_a?(Hash)
70
+
71
+ location = Array(part['loc']).reject { |segment| segment == 'body' }.join('.')
72
+ message = part['msg'] || JSON.generate(part)
73
+ location.empty? ? message : "#{location}: #{message}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ RubyLLM::Provider.register :typesafe, RubyLLM::Providers::TypeSafe,
80
+ models: File.expand_path('../../../models.json', __dir__)
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hyphenated entrypoint so `gem 'ruby_llm-typesafe'` works without a `require:` option.
4
+ require_relative 'ruby_llm/providers/typesafe'
data/models.json ADDED
@@ -0,0 +1,52 @@
1
+ [
2
+ {
3
+ "id": "jev-latest",
4
+ "name": "jev-latest",
5
+ "provider": "typesafe",
6
+ "family": "jev",
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
+ "text"
17
+ ]
18
+ },
19
+ "capabilities": [
20
+ "structured_output"
21
+ ],
22
+ "pricing": {},
23
+ "metadata": {
24
+ "description": "The latest iteration of TypeSafe's System One Model: Jev"
25
+ }
26
+ },
27
+ {
28
+ "id": "jev-preview",
29
+ "name": "jev-preview",
30
+ "provider": "typesafe",
31
+ "family": "jev",
32
+ "created_at": "2026-09-10 18:39:06 UTC",
33
+ "context_window": null,
34
+ "max_output_tokens": null,
35
+ "knowledge_cutoff": null,
36
+ "modalities": {
37
+ "input": [
38
+ "text"
39
+ ],
40
+ "output": [
41
+ "text"
42
+ ]
43
+ },
44
+ "capabilities": [
45
+ "structured_output"
46
+ ],
47
+ "pricing": {},
48
+ "metadata": {
49
+ "description": "A preview version of `jev-latest`: should be better in most ways"
50
+ }
51
+ }
52
+ ]
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-typesafe
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kieran Klaassen
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 2.0.0.rc3
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 2.0.0.rc3
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
32
+ description: 'Adds a :typesafe provider to RubyLLM 2 that evaluates text or structured
33
+ state against TypeSafe''s typed Choice, Noul, and Score questions through Chat#with_schema.
34
+ Structured output only: answers are calibrated probabilities, not generated text.'
35
+ email:
36
+ - kieranklaassen@gmail.com
37
+ executables: []
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - CHANGELOG.md
42
+ - LICENSE
43
+ - README.md
44
+ - lib/ruby_llm-typesafe.rb
45
+ - lib/ruby_llm/protocols/system_one.rb
46
+ - lib/ruby_llm/protocols/system_one/chat.rb
47
+ - lib/ruby_llm/protocols/system_one/models.rb
48
+ - lib/ruby_llm/providers/typesafe.rb
49
+ - lib/ruby_llm/providers/typesafe/schema.rb
50
+ - lib/ruby_llm/providers/typesafe/schema/answers.rb
51
+ - models.json
52
+ homepage: https://github.com/kieranklaassen/ruby_llm-typesafe
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ homepage_uri: https://github.com/kieranklaassen/ruby_llm-typesafe
57
+ source_code_uri: https://github.com/kieranklaassen/ruby_llm-typesafe
58
+ changelog_uri: https://github.com/kieranklaassen/ruby_llm-typesafe/blob/main/CHANGELOG.md
59
+ documentation_uri: https://github.com/kieranklaassen/ruby_llm-typesafe#readme
60
+ bug_tracker_uri: https://github.com/kieranklaassen/ruby_llm-typesafe/issues
61
+ rubygems_mfa_required: 'true'
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.1.3
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.6.9
77
+ specification_version: 4
78
+ summary: RubyLLM provider for TypeSafe System One structured judgments.
79
+ test_files: []