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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +281 -0
- data/lib/ruby_llm/protocols/system_one/chat.rb +103 -0
- data/lib/ruby_llm/protocols/system_one/models.rb +45 -0
- data/lib/ruby_llm/protocols/system_one.rb +40 -0
- data/lib/ruby_llm/providers/typesafe/schema/answers.rb +75 -0
- data/lib/ruby_llm/providers/typesafe/schema.rb +208 -0
- data/lib/ruby_llm/providers/typesafe.rb +80 -0
- data/lib/ruby_llm-typesafe.rb +4 -0
- data/models.json +52 -0
- metadata +79 -0
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
|
+
[](https://github.com/kieranklaassen/ruby_llm-typesafe/actions/workflows/ci.yml)
|
|
4
|
+
[](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__)
|
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: []
|