Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## Unreleased

- Added `Client#models`, which lists the model names an account can send in
`model` as `ModelCard`s (`name`, `description`, `release_date`). Typesafe
publishes this at `GET /v1/models` and both official SDKs expose it; this
client had no way to reach it.
- Providers declare a `models_path`; `lists_models?` and `models_url` follow
from it. OpenRouter publishes no decision-model list, so `client.models`
raises `ConfigurationError` there rather than guessing a URL.
- The transport contract accepts an optional `method:` keyword (`:post` for
`ask`, `:get` for `models`). Transports that do not declare it are called
unchanged and cannot be asked for a GET.

## 0.1.0 - 2026-09-18

Provider-neutral release. One `Client`, two providers behind it.
Expand Down
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ Typesafe returns an `x-typesafe-request-id` header, exposed as
`response.request_id` (nil on OpenRouter). Quote it when reporting a problem
to Typesafe.

### Listing models

```ruby
client = RubyDecisionModel::Client.new(provider: :typesafe)

client.models
# => [#<data RubyDecisionModel::ModelCard name="jev-latest",
# description="The most recent stable, official release",
# release_date="2026-02-11">, ...]
```

`GET /v1/models` returns the names this account can send in `model`. The list
carries the aliases; a versioned id such as `jev-1.13.0` is accepted by `model`
whether or not it appears. OpenRouter publishes no equivalent list for
decisions, so `client.models` raises `ConfigurationError` there.

### Options

```ruby
Expand Down Expand Up @@ -104,9 +120,9 @@ is whatever the provider returned.
### Writing a provider

Subclass `RubyDecisionModel::Providers::Base` and define `name`, `env_var`,
`default_base_url`, `endpoint_path`, `default_model`, and optionally `aliases`
and `reports_cost?`. Override `headers`, `request_body`, or `usage` when the
wire format differs. Pass an instance as `provider:`.
`default_base_url`, `endpoint_path`, `default_model`, and optionally `aliases`,
`reports_cost?`, and `models_path`. Override `headers`, `request_body`, or
`usage` when the wire format differs. Pass an instance as `provider:`.

## Questions and answers

Expand Down Expand Up @@ -167,6 +183,11 @@ that accepts `url:`, `headers:`, `body:` and returns
return is still accepted and treated as having no headers, which means no
`Retry-After` support and a nil `request_id`.

A transport that also declares a `method:` keyword (or `**`) is told which verb
to use — `:post` for `ask`, `:get` for `models`. One that does not is called
exactly as before and always POSTs, so `client.models` raises
`ConfigurationError` rather than posting to the model list.

## Errors

| Error | Meaning |
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_decision_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
require_relative "ruby_decision_model/errors"
require_relative "ruby_decision_model/questions"
require_relative "ruby_decision_model/answers"
require_relative "ruby_decision_model/model_card"
require_relative "ruby_decision_model/response"
require_relative "ruby_decision_model/providers"
require_relative "ruby_decision_model/retry_policy"
Expand Down
89 changes: 77 additions & 12 deletions lib/ruby_decision_model/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ def ask(state:, questions:)
handle_response(status, response_body, response_headers, questions)
end

# The names this account may send in `model`, as ModelCards. Providers
# that do not publish a list raise ConfigurationError.
def models
unless @provider.lists_models?
raise ConfigurationError, "#{@provider.name} does not publish a model list"
end

unless transport_accepts_method?
raise ConfigurationError,
"listing models needs a GET, and this transport takes no method: keyword"
end

status, body, headers = perform_with_retry(
url: @provider.models_url, headers: @provider.headers, body: nil, method: :get
)
handle_models_response(status, body, headers)
end

private

def resolve_provider(provider, api_key:, base_url:)
Expand All @@ -88,32 +106,77 @@ def resolve_provider(provider, api_key:, base_url:)
end
end

REQUEST_CLASSES = { post: Net::HTTP::Post, get: Net::HTTP::Get }.freeze

def default_transport
lambda do |url:, headers:, body:|
lambda do |url:, headers:, body:, method: :post|
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
http.open_timeout = @timeout
http.read_timeout = @timeout

request = Net::HTTP::Post.new(uri.request_uri)
request = REQUEST_CLASSES.fetch(method).new(uri.request_uri)
headers.each { |k, v| request[k] = v }
request.body = body
request.body = body unless body.nil?

response = http.request(request)
[response.code.to_i, response.body, response.each_header.to_h]
end
end

def perform_with_retry(url:, headers:, body:)
# The transport contract grew a `method:` keyword for the one endpoint
# that is not a POST. A transport written against the old contract is
# called the way it always was, and cannot be asked for a GET.
def transport_accepts_method?
return @transport_accepts_method unless @transport_accepts_method.nil?

parameters = @transport.respond_to?(:parameters) ? @transport.parameters : @transport.method(:call).parameters
@transport_accepts_method = parameters.any? do |kind, name|
kind == :keyrest || (%i[key keyreq].include?(kind) && name == :method)
end
end

def call_transport(url:, headers:, body:, method:)
if transport_accepts_method?
@transport.call(url: url, headers: headers, body: body, method: method)
else
@transport.call(url: url, headers: headers, body: body)
end
end

def handle_models_response(status, response_body, response_headers)
unless (200..299).cover?(status)
return handle_response(status, response_body, response_headers, {})
end

parsed = parse_json(response_body)
raise InvalidResponse, "model list was not a JSON object" unless parsed.is_a?(Hash)

listed = parsed["models"]
raise InvalidResponse, "model list had no models array" unless listed.is_a?(Array)

listed.map { |entry| model_card(entry) }
end

def model_card(entry)
raise InvalidResponse, "model list entry was not an object" unless entry.is_a?(Hash)

name = entry["name"]
raise InvalidResponse, "model list entry had no name" unless name.is_a?(String) && !name.empty?

ModelCard.new(name: name, description: entry["description"], release_date: entry["release_date"])
end

def perform_with_retry(url:, headers:, body:, method: :post)
policy = @retry_policy
started_at = @clock.call
retries = 0

loop do
begin
status, response_body, response_headers = normalize_transport_result(
@transport.call(url: url, headers: headers, body: body)
call_transport(url: url, headers: headers, body: body, method: method)
)
rescue Error
raise
Expand Down Expand Up @@ -184,13 +247,7 @@ def handle_response(status, response_body, response_headers, questions)
end

def parse_success(response_body, response_headers, questions)
raise InvalidResponse, "response body was empty" if response_body.nil? || response_body.to_s.strip.empty?

parsed = begin
JSON.parse(response_body.to_s)
rescue JSON::ParserError => e
raise InvalidResponse, "response body was not valid JSON: #{e.message}"
end
parsed = parse_json(response_body)

raise InvalidResponse, "response body was not a JSON object" unless parsed.is_a?(Hash)

Expand Down Expand Up @@ -242,6 +299,14 @@ def parse_success(response_body, response_headers, questions)
)
end

def parse_json(response_body)
raise InvalidResponse, "response body was empty" if response_body.nil? || response_body.to_s.strip.empty?

JSON.parse(response_body.to_s)
rescue JSON::ParserError => e
raise InvalidResponse, "response body was not valid JSON: #{e.message}"
end

def request_id_from(headers)
return nil unless headers.is_a?(Hash)

Expand Down
8 changes: 8 additions & 0 deletions lib/ruby_decision_model/model_card.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module RubyDecisionModel
# One entry from a provider's model list: a name the `model` field accepts,
# what it is for, and when it shipped. An alias such as "jev-latest" is
# listed the same way a versioned id is.
ModelCard = Data.define(:name, :description, :release_date)
end
14 changes: 14 additions & 0 deletions lib/ruby_decision_model/providers/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ def url
"#{base_url}#{endpoint_path}"
end

# Where this provider lists the models its `model` field accepts, or
# nil when it does not offer a list.
def models_path
nil
end

def lists_models?
!models_path.nil?
end

def models_url
"#{base_url}#{models_path}" if lists_models?
end

def api_key?
!(api_key.nil? || api_key.to_s.strip.empty?)
end
Expand Down
5 changes: 5 additions & 0 deletions lib/ruby_decision_model/providers/typesafe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ def endpoint_path
"/v1/systemone"
end

# https://docs.typesafe.ai/models#listing-models
def models_path
"/v1/models"
end

def default_model
"jev-latest"
end
Expand Down
Loading