laiya 0.0.2
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
- checksums.yaml.gz.sig +0 -0
- data/bin/laiya +20 -0
- data/context/chatgpt-codex.md +43 -0
- data/context/getting-started.md +49 -0
- data/context/http-api.md +49 -0
- data/context/index.yaml +26 -0
- data/context/providers-and-models.md +104 -0
- data/examples/chatgpt/authentication.rb +45 -0
- data/examples/chatgpt/readme.md +107 -0
- data/examples/chatgpt/service.rb +34 -0
- data/guides/chatgpt-codex/readme.md +43 -0
- data/guides/getting-started/readme.md +49 -0
- data/guides/http-api/readme.md +49 -0
- data/guides/index.md +14 -0
- data/guides/links.yaml +8 -0
- data/guides/providers-and-models/readme.md +104 -0
- data/lib/laiya/configuration/builder.rb +40 -0
- data/lib/laiya/configuration.rb +152 -0
- data/lib/laiya/environment/application.rb +53 -0
- data/lib/laiya/models/catalog.rb +108 -0
- data/lib/laiya/models/discover.rb +110 -0
- data/lib/laiya/provider/codex/authentication.rb +185 -0
- data/lib/laiya/provider/codex/request.rb +136 -0
- data/lib/laiya/provider/codex/response.rb +117 -0
- data/lib/laiya/provider/codex/responses.rb +93 -0
- data/lib/laiya/provider/codex/server_sent_events.rb +78 -0
- data/lib/laiya/provider/codex.rb +298 -0
- data/lib/laiya/provider/interface.rb +19 -0
- data/lib/laiya/provider/ollama.rb +22 -0
- data/lib/laiya/provider/openai.rb +92 -0
- data/lib/laiya/provider.rb +10 -0
- data/lib/laiya/router.rb +85 -0
- data/lib/laiya/service/application.rb +50 -0
- data/lib/laiya/version.rb +8 -0
- data/lib/laiya/web/application.rb +48 -0
- data/lib/laiya/web.rb +6 -0
- data/lib/laiya.rb +19 -0
- data/license.md +21 -0
- data/readme.md +68 -0
- data/releases.md +7 -0
- data.tar.gz.sig +0 -0
- metadata +162 -0
- metadata.gz.sig +3 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Providers and Models
|
|
2
|
+
|
|
3
|
+
This guide explains how to route model IDs to providers and when to discover a provider's model catalog.
|
|
4
|
+
|
|
5
|
+
## Default Provider
|
|
6
|
+
|
|
7
|
+
Use a default provider when one upstream should receive any model name:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
configuration = Laiya::Configuration.build do |builder|
|
|
11
|
+
builder.provider :ollama, Laiya::Provider::Ollama.new
|
|
12
|
+
builder.default_provider :ollama
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
With no explicit model routes or discovery setting, Laiya forwards
|
|
17
|
+
`GET /v1/models` to the default provider and passes completion requests through
|
|
18
|
+
to it.
|
|
19
|
+
|
|
20
|
+
## Discover Models from an Upstream
|
|
21
|
+
|
|
22
|
+
Providers such as Ollama expose an OpenAI-compatible `GET /v1/models` endpoint.
|
|
23
|
+
Use `models: :discover` to query it at runtime instead of copying model names
|
|
24
|
+
into configuration:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
configuration = Laiya::Configuration.build do |builder|
|
|
28
|
+
builder.provider :ollama, Laiya::Provider::Ollama.new, models: :discover
|
|
29
|
+
builder.default_provider :ollama
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`Laiya::Models::Discover` fetches and caches the provider's catalog for 60
|
|
34
|
+
seconds. Laiya uses the discovered IDs for `GET /v1/models` and routes requests
|
|
35
|
+
for those IDs to the provider. The builder itself does not make network calls.
|
|
36
|
+
|
|
37
|
+
For multiple providers, enable discovery on each provider. Distinct model IDs
|
|
38
|
+
are routed automatically. If two providers advertise the same ID, the default
|
|
39
|
+
provider wins; an explicit `builder.model` route overrides discovery:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
configuration = Laiya::Configuration.build do |builder|
|
|
43
|
+
builder.provider :openai, Laiya::Provider::OpenAI.new(
|
|
44
|
+
api_key: ENV.fetch("OPENAI_API_KEY")
|
|
45
|
+
), models: :discover
|
|
46
|
+
builder.provider :ollama, Laiya::Provider::Ollama.new, models: :discover
|
|
47
|
+
|
|
48
|
+
builder.model "llama3.2", provider: :ollama
|
|
49
|
+
builder.default_provider :openai
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Expose Model Limits
|
|
54
|
+
|
|
55
|
+
OpenAI's standard model-list format has no context-window fields. Laiya lets
|
|
56
|
+
you attach a display name and limits to an explicit model route; it publishes
|
|
57
|
+
them under its `laiya` extension field:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
configuration = Laiya::Configuration.build do |builder|
|
|
61
|
+
builder.provider :ollama, Laiya::Provider::Ollama.new, models: :discover
|
|
62
|
+
builder.model "llama3.2",
|
|
63
|
+
provider: :ollama,
|
|
64
|
+
display_name: "Llama 3.2",
|
|
65
|
+
limits: {context: 32_768, input: 28_672, output: 4_096}
|
|
66
|
+
builder.default_provider :ollama
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The `limits` keys are `context`, `input`, and `output`, and values are positive
|
|
71
|
+
token counts. OpenCode does not currently infer these custom fields from
|
|
72
|
+
`GET /v1/models`; configure its model `limit.context`, `limit.input`, and
|
|
73
|
+
`limit.output` values on each client as well. The `context` limit should match
|
|
74
|
+
the effective upstream model configuration (for Ollama, including `num_ctx`).
|
|
75
|
+
|
|
76
|
+
## Customize Discovery
|
|
77
|
+
|
|
78
|
+
Subclass `Laiya::Models::Discover` to filter models or attach provider-specific
|
|
79
|
+
metadata. Override `include_model?` to filter entries and `normalize_model` to
|
|
80
|
+
add or adjust model fields:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
class LocalModels < Laiya::Models::Discover
|
|
84
|
+
protected
|
|
85
|
+
|
|
86
|
+
def include_model?(model)
|
|
87
|
+
super && model["id"].start_with?("llama")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_model(model)
|
|
91
|
+
super.merge("laiya" => {"limits" => {"context" => 32_768, "output" => 4_096}})
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
ollama = Laiya::Provider::Ollama.new
|
|
96
|
+
configuration = Laiya::Configuration.build do |builder|
|
|
97
|
+
builder.provider :ollama, ollama, models: LocalModels.new(ollama)
|
|
98
|
+
builder.default_provider :ollama
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The model list includes configured IDs and discovered metadata. Custom discovery
|
|
103
|
+
metadata is provider-specific and is likewise exposed under Laiya's extension
|
|
104
|
+
field; standard clients may ignore it.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Laiya
|
|
7
|
+
class Configuration
|
|
8
|
+
# Mutable DSL for configuring a Laiya::Configuration.
|
|
9
|
+
class Builder
|
|
10
|
+
# Attach a DSL builder to the configuration it will update.
|
|
11
|
+
# @parameter configuration [Laiya::Configuration] The mutable configuration.
|
|
12
|
+
def initialize(configuration)
|
|
13
|
+
@configuration = configuration
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Configure a provider on the attached configuration.
|
|
17
|
+
# @parameter name [String | Symbol] The provider's routing name.
|
|
18
|
+
# @parameter instance [Interface(:call)] The provider implementation.
|
|
19
|
+
# @option :models [Symbol | Interface(:each, :find) | Nil] The model source or `:discover`.
|
|
20
|
+
def provider(name, instance, models: nil)
|
|
21
|
+
@configuration.provider(name, instance, models: models)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Configure a model route on the attached configuration.
|
|
25
|
+
# @parameter name [String | Symbol] The model ID.
|
|
26
|
+
# @parameter provider [String | Symbol] The provider name.
|
|
27
|
+
# @option :display_name [String | Nil] A human-readable model name.
|
|
28
|
+
# @option :limits [Hash | Nil] Positive context, input, and output token limits.
|
|
29
|
+
def model(name, provider:, display_name: nil, limits: nil)
|
|
30
|
+
@configuration.model(name, provider: provider, display_name: display_name, limits: limits)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Set the default provider on the attached configuration.
|
|
34
|
+
# @parameter name [String | Symbol] The provider name.
|
|
35
|
+
def default_provider(name)
|
|
36
|
+
@configuration.default_provider = name
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "models/catalog"
|
|
7
|
+
require_relative "models/discover"
|
|
8
|
+
require_relative "configuration/builder"
|
|
9
|
+
|
|
10
|
+
module Laiya
|
|
11
|
+
# Mutable provider and model routing configuration.
|
|
12
|
+
class Configuration
|
|
13
|
+
# Build a mutable configuration by evaluating a builder block.
|
|
14
|
+
# @yields {|builder| ...} The builder used to configure the new object.
|
|
15
|
+
# @parameter builder [Laiya::Configuration::Builder] The configuration DSL.
|
|
16
|
+
# @returns [Laiya::Configuration] The configured, mutable object.
|
|
17
|
+
def self.build(&block)
|
|
18
|
+
configuration = self.new
|
|
19
|
+
builder = Builder.new(configuration)
|
|
20
|
+
|
|
21
|
+
if block
|
|
22
|
+
if block.arity.zero?
|
|
23
|
+
builder.instance_eval(&block)
|
|
24
|
+
else
|
|
25
|
+
block.call(builder)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return configuration
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Initialize an empty provider and model catalog.
|
|
33
|
+
def initialize
|
|
34
|
+
@providers = {}
|
|
35
|
+
@model_routes = {}
|
|
36
|
+
@model_sources = {}
|
|
37
|
+
@model_metadata = {}
|
|
38
|
+
@models = Models::Catalog.new(
|
|
39
|
+
routes: @model_routes,
|
|
40
|
+
sources: @model_sources,
|
|
41
|
+
metadata: @model_metadata,
|
|
42
|
+
)
|
|
43
|
+
@default_provider = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr :providers
|
|
47
|
+
attr :models
|
|
48
|
+
attr :default_provider
|
|
49
|
+
|
|
50
|
+
# Register a provider and optionally a model source.
|
|
51
|
+
# @parameter name [String | Symbol] The provider's routing name.
|
|
52
|
+
# @parameter instance [Interface(:call)] The provider implementation.
|
|
53
|
+
# @option :models [Symbol | Interface(:each, :find) | Nil] The model source or `:discover`.
|
|
54
|
+
# @raises [ArgumentError] If the provider or model source is invalid.
|
|
55
|
+
def provider(name, instance, models: nil)
|
|
56
|
+
name = name.to_sym
|
|
57
|
+
raise ArgumentError, "Provider #{name.inspect} is already configured" if @providers.key?(name)
|
|
58
|
+
|
|
59
|
+
unless instance.respond_to?(:call)
|
|
60
|
+
raise ArgumentError, "Provider #{name.inspect} must respond to #call"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
case models
|
|
64
|
+
when nil, :configured
|
|
65
|
+
# Model routes will be configured separately with #model.
|
|
66
|
+
when :discover
|
|
67
|
+
@model_sources[name] = Models::Discover.new(instance)
|
|
68
|
+
else
|
|
69
|
+
unless models.respond_to?(:each) && models.respond_to?(:find)
|
|
70
|
+
raise ArgumentError, "Model source for provider #{name.inspect} must implement #each and #find"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@model_sources[name] = models
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@providers[name] = instance
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Route a model ID to a configured provider and attach optional metadata.
|
|
80
|
+
# @parameter name [String | Symbol] The model ID.
|
|
81
|
+
# @parameter provider [String | Symbol] The provider name.
|
|
82
|
+
# @option :display_name [String | Nil] A human-readable model name.
|
|
83
|
+
# @option :limits [Hash | Nil] Positive context, input, and output token limits.
|
|
84
|
+
# @raises [ArgumentError] If the provider, model ID, or limits are invalid.
|
|
85
|
+
def model(name, provider:, display_name: nil, limits: nil)
|
|
86
|
+
provider = provider.to_sym
|
|
87
|
+
unless @providers.key?(provider)
|
|
88
|
+
raise ArgumentError, "Provider #{provider.inspect} for model #{name.inspect} is not configured"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
name = name.to_s
|
|
92
|
+
raise ArgumentError, "Model #{name.inspect} is already configured" if @model_routes.key?(name)
|
|
93
|
+
|
|
94
|
+
metadata = {}
|
|
95
|
+
metadata["name"] = display_name.to_s if display_name
|
|
96
|
+
metadata["limits"] = normalize_limits(limits) if limits
|
|
97
|
+
|
|
98
|
+
@model_routes[name] = provider
|
|
99
|
+
@model_metadata[name] = metadata.freeze unless metadata.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Set the provider used when no explicit or discovered route matches.
|
|
103
|
+
# @parameter name [String | Symbol | Nil] The provider name, or `nil` to clear the default.
|
|
104
|
+
# @raises [ArgumentError] If the provider is not configured.
|
|
105
|
+
def default_provider=(name)
|
|
106
|
+
name = name&.to_sym
|
|
107
|
+
if name && !@providers.key?(name)
|
|
108
|
+
raise ArgumentError, "Default provider #{name.inspect} is not configured"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@default_provider = name
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Find the provider instance that serves a model ID.
|
|
115
|
+
# @parameter model [String | Nil] The model ID to route.
|
|
116
|
+
# @returns [Interface(:call) | Nil] The matching provider, if any.
|
|
117
|
+
def provider_for_model(model)
|
|
118
|
+
if name = @models.provider_for(model, default_provider: @default_provider)
|
|
119
|
+
return @providers.fetch(name)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Freeze configuration-owned state and finalize this object.
|
|
124
|
+
# @returns [Laiya::Configuration] This frozen configuration.
|
|
125
|
+
def freeze
|
|
126
|
+
return self if frozen?
|
|
127
|
+
|
|
128
|
+
@providers.freeze
|
|
129
|
+
@models.freeze
|
|
130
|
+
|
|
131
|
+
super
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def normalize_limits(limits)
|
|
137
|
+
unless limits.is_a?(Hash)
|
|
138
|
+
raise ArgumentError, "Model limits must be a Hash"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
allowed = %w[context input output]
|
|
142
|
+
limits.each_with_object({}) do |(key, value), normalized|
|
|
143
|
+
key = key.to_s
|
|
144
|
+
unless allowed.include?(key) && value.is_a?(Integer) && value.positive?
|
|
145
|
+
raise ArgumentError, "Invalid model limit #{key.inspect}: #{value.inspect}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
normalized[key] = value
|
|
149
|
+
end.freeze
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async/http"
|
|
7
|
+
require "async/service/managed/environment"
|
|
8
|
+
require_relative "../configuration"
|
|
9
|
+
require_relative "../provider/openai"
|
|
10
|
+
require_relative "../service/application"
|
|
11
|
+
|
|
12
|
+
module Laiya
|
|
13
|
+
# @namespace
|
|
14
|
+
module Environment
|
|
15
|
+
# Default Async::Service environment for the Laiya HTTP API.
|
|
16
|
+
module Application
|
|
17
|
+
include Async::Service::Managed::Environment
|
|
18
|
+
|
|
19
|
+
# Return the service implementation class.
|
|
20
|
+
# @returns [Class] The Laiya service class.
|
|
21
|
+
def service_class
|
|
22
|
+
Laiya::Service::Application
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Parse the HTTP bind endpoint from the environment.
|
|
26
|
+
# @returns [Async::HTTP::Endpoint] The configured endpoint.
|
|
27
|
+
def endpoint
|
|
28
|
+
Async::HTTP::Endpoint.parse(ENV.fetch("LAIYA_URL", "http://localhost:9292"))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Run one Laiya service instance.
|
|
32
|
+
# @returns [Integer] The desired service count.
|
|
33
|
+
def count
|
|
34
|
+
1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Build the default OpenAI-backed configuration.
|
|
38
|
+
# @returns [Laiya::Configuration] The service configuration.
|
|
39
|
+
def configuration
|
|
40
|
+
Laiya::Configuration.build do |builder|
|
|
41
|
+
builder.provider :openai, Laiya::Provider::OpenAI.new
|
|
42
|
+
builder.default_provider :openai
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build the HTTP application for this environment.
|
|
47
|
+
# @returns [Laiya::Web::Application] The configured API application.
|
|
48
|
+
def application
|
|
49
|
+
Laiya::Web::Application.new(configuration: self.configuration)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Laiya
|
|
7
|
+
module Models
|
|
8
|
+
# Read-only view of configured and provider-backed model catalogs.
|
|
9
|
+
class Catalog
|
|
10
|
+
# Build a catalog view over configuration-owned model data.
|
|
11
|
+
# @parameter routes [Hash(String, Symbol)] Explicit model routes.
|
|
12
|
+
# @parameter sources [Hash(Symbol, Interface(:each, :find))] Provider discovery sources.
|
|
13
|
+
# @parameter metadata [Hash(String, Hash)] Metadata for explicit model routes.
|
|
14
|
+
def initialize(routes:, sources:, metadata:)
|
|
15
|
+
@routes = routes
|
|
16
|
+
@sources = sources
|
|
17
|
+
@metadata = metadata
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr :routes
|
|
21
|
+
attr :sources
|
|
22
|
+
attr :metadata
|
|
23
|
+
|
|
24
|
+
# Check whether the catalog contains routes or discovery sources.
|
|
25
|
+
# @returns [Boolean] `true` when the catalog is empty.
|
|
26
|
+
def empty?
|
|
27
|
+
@routes.empty? && @sources.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Check whether the catalog has any provider discovery sources.
|
|
31
|
+
# @returns [Boolean] `true` when discovery is configured.
|
|
32
|
+
def discover?
|
|
33
|
+
!@sources.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Resolve the provider name for a model, using the default as fallback.
|
|
37
|
+
# @parameter model [String | Nil] The model ID to look up.
|
|
38
|
+
# @option :default_provider [Symbol | Nil] The fallback provider name.
|
|
39
|
+
# @returns [Symbol | Nil] The selected provider name.
|
|
40
|
+
def provider_for(model, default_provider: nil)
|
|
41
|
+
return @routes[model] if @routes.key?(model)
|
|
42
|
+
|
|
43
|
+
if model
|
|
44
|
+
matches = @sources.filter_map do |provider, source|
|
|
45
|
+
provider if source.find(model)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return default_provider if matches.include?(default_provider)
|
|
49
|
+
return matches.first unless matches.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return default_provider
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Enumerate explicit and discovered models without duplicate IDs.
|
|
56
|
+
# @option :default_provider [Symbol | Nil] The preferred owner of duplicate IDs.
|
|
57
|
+
# @yields {|model| ...} Each model entry in OpenAI model-list format.
|
|
58
|
+
# @parameter model [Hash] A model-list entry.
|
|
59
|
+
# @returns [Enumerator | Nil] An enumerator without a block, otherwise `nil`.
|
|
60
|
+
def each(default_provider: nil, &block)
|
|
61
|
+
return to_enum(__method__, default_provider: default_provider) unless block
|
|
62
|
+
|
|
63
|
+
models = {}
|
|
64
|
+
owners = {}
|
|
65
|
+
|
|
66
|
+
@routes.each do |id, provider|
|
|
67
|
+
models[id] = {
|
|
68
|
+
"id" => id,
|
|
69
|
+
"object" => "model",
|
|
70
|
+
"created" => 0,
|
|
71
|
+
"owned_by" => provider.to_s,
|
|
72
|
+
}.tap do |entry|
|
|
73
|
+
if metadata = @metadata[id]
|
|
74
|
+
entry["laiya"] = metadata
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
owners[id] = provider
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@sources.each do |provider, source|
|
|
81
|
+
source.each do |model|
|
|
82
|
+
id = model["id"]
|
|
83
|
+
next unless id.is_a?(String) && !id.empty?
|
|
84
|
+
next if @routes.key?(id)
|
|
85
|
+
next if owners.key?(id) && provider != default_provider
|
|
86
|
+
|
|
87
|
+
models[id] = model.merge("owned_by" => provider.to_s)
|
|
88
|
+
owners[id] = provider
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
models.each_value(&block)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Freeze the catalog and its shared configuration data.
|
|
96
|
+
# @returns [Laiya::Models::Catalog] This frozen catalog.
|
|
97
|
+
def freeze
|
|
98
|
+
return self if frozen?
|
|
99
|
+
|
|
100
|
+
@routes.freeze
|
|
101
|
+
@sources.freeze
|
|
102
|
+
@metadata.freeze
|
|
103
|
+
|
|
104
|
+
super
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async"
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module Laiya
|
|
10
|
+
# Model catalog sources.
|
|
11
|
+
module Models
|
|
12
|
+
# Discovers models from an OpenAI-compatible provider and caches its catalog.
|
|
13
|
+
# Subclasses can filter or enrich entries by overriding #include_model? and #normalize_model.
|
|
14
|
+
class Discover
|
|
15
|
+
DEFAULT_TTL = 60
|
|
16
|
+
|
|
17
|
+
# Initialize a cached model source for an upstream provider.
|
|
18
|
+
# @parameter provider [Interface(:models)] The provider to query.
|
|
19
|
+
# @option :ttl [Numeric] How long to cache the model list, in seconds.
|
|
20
|
+
def initialize(provider, ttl: DEFAULT_TTL)
|
|
21
|
+
@provider = provider
|
|
22
|
+
@ttl = ttl
|
|
23
|
+
@lock = Async::Semaphore.new(1)
|
|
24
|
+
@models = nil
|
|
25
|
+
@expires_at = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr :provider
|
|
29
|
+
|
|
30
|
+
# Enumerate the normalized, cached model list.
|
|
31
|
+
# @yields {|model| ...} Each discovered model.
|
|
32
|
+
# @parameter model [Hash] A normalized model entry.
|
|
33
|
+
# @returns [Enumerator | Nil] An enumerator without a block, otherwise `nil`.
|
|
34
|
+
def each(&block)
|
|
35
|
+
return enum_for(__method__) unless block
|
|
36
|
+
|
|
37
|
+
self.all.each(&block)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Find one model by its upstream ID.
|
|
41
|
+
# @parameter id [String] The model ID.
|
|
42
|
+
# @returns [Hash | Nil] The matching model entry, if present.
|
|
43
|
+
def find(id)
|
|
44
|
+
self.all.find{|model| model["id"] == id}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Fetch and cache the provider's normalized model catalog.
|
|
48
|
+
# @returns [Array(Hash)] The cached model entries.
|
|
49
|
+
def all
|
|
50
|
+
if @models && Process.clock_gettime(Process::CLOCK_MONOTONIC) < @expires_at
|
|
51
|
+
return @models
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@lock.acquire do
|
|
55
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
56
|
+
next @models if @models && now < @expires_at
|
|
57
|
+
|
|
58
|
+
@models = fetch_models.freeze
|
|
59
|
+
@expires_at = now + @ttl
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
return @models
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
protected
|
|
66
|
+
|
|
67
|
+
def include_model?(model)
|
|
68
|
+
model.is_a?(Hash) && model["id"].is_a?(String) && !model["id"].empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_model(model)
|
|
72
|
+
result = model.dup
|
|
73
|
+
result["object"] ||= "model"
|
|
74
|
+
result["created"] ||= 0
|
|
75
|
+
return result.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def fetch_models
|
|
81
|
+
response = @provider.models
|
|
82
|
+
unless response.status >= 200 && response.status < 300
|
|
83
|
+
status = response.status
|
|
84
|
+
response.close
|
|
85
|
+
raise Error, "Model discovery failed with HTTP #{status}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
payload = JSON.parse(response.read)
|
|
90
|
+
data = payload.fetch("data")
|
|
91
|
+
unless data.is_a?(Array)
|
|
92
|
+
raise Error, "Model discovery returned an invalid catalog"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
return data.filter_map do |model|
|
|
96
|
+
self.normalize_model(model) if self.include_model?(model)
|
|
97
|
+
end
|
|
98
|
+
rescue JSON::ParserError, KeyError
|
|
99
|
+
raise Error, "Model discovery returned an invalid catalog"
|
|
100
|
+
ensure
|
|
101
|
+
response.close
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Raised when an upstream model catalog is unavailable or invalid.
|
|
106
|
+
class Error < StandardError
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|