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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/bin/laiya +20 -0
  4. data/context/chatgpt-codex.md +43 -0
  5. data/context/getting-started.md +49 -0
  6. data/context/http-api.md +49 -0
  7. data/context/index.yaml +26 -0
  8. data/context/providers-and-models.md +104 -0
  9. data/examples/chatgpt/authentication.rb +45 -0
  10. data/examples/chatgpt/readme.md +107 -0
  11. data/examples/chatgpt/service.rb +34 -0
  12. data/guides/chatgpt-codex/readme.md +43 -0
  13. data/guides/getting-started/readme.md +49 -0
  14. data/guides/http-api/readme.md +49 -0
  15. data/guides/index.md +14 -0
  16. data/guides/links.yaml +8 -0
  17. data/guides/providers-and-models/readme.md +104 -0
  18. data/lib/laiya/configuration/builder.rb +40 -0
  19. data/lib/laiya/configuration.rb +152 -0
  20. data/lib/laiya/environment/application.rb +53 -0
  21. data/lib/laiya/models/catalog.rb +108 -0
  22. data/lib/laiya/models/discover.rb +110 -0
  23. data/lib/laiya/provider/codex/authentication.rb +185 -0
  24. data/lib/laiya/provider/codex/request.rb +136 -0
  25. data/lib/laiya/provider/codex/response.rb +117 -0
  26. data/lib/laiya/provider/codex/responses.rb +93 -0
  27. data/lib/laiya/provider/codex/server_sent_events.rb +78 -0
  28. data/lib/laiya/provider/codex.rb +298 -0
  29. data/lib/laiya/provider/interface.rb +19 -0
  30. data/lib/laiya/provider/ollama.rb +22 -0
  31. data/lib/laiya/provider/openai.rb +92 -0
  32. data/lib/laiya/provider.rb +10 -0
  33. data/lib/laiya/router.rb +85 -0
  34. data/lib/laiya/service/application.rb +50 -0
  35. data/lib/laiya/version.rb +8 -0
  36. data/lib/laiya/web/application.rb +48 -0
  37. data/lib/laiya/web.rb +6 -0
  38. data/lib/laiya.rb +19 -0
  39. data/license.md +21 -0
  40. data/readme.md +68 -0
  41. data/releases.md +7 -0
  42. data.tar.gz.sig +0 -0
  43. metadata +162 -0
  44. 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