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,298 @@
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 "json"
8
+ require "protocol/http/body/streamable"
9
+ require "securerandom"
10
+
11
+ require_relative "interface"
12
+ require_relative "codex/authentication"
13
+ require_relative "codex/request"
14
+ require_relative "codex/response"
15
+ require_relative "codex/responses"
16
+ require_relative "codex/server_sent_events"
17
+
18
+ module Laiya
19
+ module Provider
20
+ # Adapts OpenAI Chat Completions HTTP to the ChatGPT Codex Responses backend.
21
+ #
22
+ # This experimental integration uses the Codex-specific backend, not the public
23
+ # OpenAI Platform API. Responses API tool calls are returned to the HTTP client;
24
+ # this provider never executes them.
25
+ class Codex < Interface
26
+ DEFAULT_ENDPOINT = "https://chatgpt.com/backend-api/codex"
27
+ USER_AGENT = "laiya-codex/0.0.0"
28
+
29
+ # Initialize the experimental ChatGPT Codex API adapter.
30
+ # @option :authentication [Interface(:credentials) | Nil] A credential source.
31
+ # @option :codex_home [String] The Codex home directory containing `auth.json`.
32
+ # @option :endpoint [String | Async::HTTP::Endpoint] The Codex backend endpoint.
33
+ # @option :client [Interface(:call) | Nil] An optional HTTP client.
34
+ def initialize(authentication: nil, codex_home: ENV.fetch("CODEX_HOME", Authentication::DEFAULT_CODEX_HOME), endpoint: DEFAULT_ENDPOINT, client: nil, **client_options)
35
+ @endpoint = Async::HTTP::Endpoint[endpoint]
36
+ @authentication = authentication || Authentication.new(codex_home: codex_home)
37
+ @client = client || Async::HTTP::Client.new(@endpoint, **client_options)
38
+ @owns_client = client.nil?
39
+ end
40
+
41
+ attr :endpoint
42
+
43
+ # Convert supported Chat Completions requests or proxy Responses requests.
44
+ # @parameter request [Protocol::HTTP::Request] The incoming OpenAI-compatible request.
45
+ # @returns [Protocol::HTTP::Response] The translated or proxied response.
46
+ def call(request)
47
+ path = request.path.split("?", 2).first
48
+ unless request.method == "POST" && %w[/v1/chat/completions /v1/responses].include?(path)
49
+ return error_response(404, "Only /v1/chat/completions and /v1/responses are supported", "not_found_error")
50
+ end
51
+
52
+ payload = JSON.parse(request.read)
53
+ return error_response(400, "Expected a JSON object", "invalid_request_error") unless payload.is_a?(Hash)
54
+
55
+ responses_api = path == "/v1/responses"
56
+ if responses_api
57
+ transformed = Responses.prepare(payload)
58
+ else
59
+ if payload["n"] && payload["n"] != 1
60
+ return error_response(400, "Codex Chat Completions supports n=1 only", "invalid_request_error")
61
+ end
62
+
63
+ if chat_tools?(payload)
64
+ return error_response(400, "Codex tool turns require the /v1/responses endpoint", "invalid_request_error")
65
+ end
66
+
67
+ transformed = Request.transform(payload)
68
+ end
69
+
70
+ credentials = @authentication.credentials
71
+ upstream = request_codex(transformed, request, credentials)
72
+
73
+ if upstream.status == 401
74
+ upstream.close
75
+ credentials = @authentication.credentials(refresh: true)
76
+ upstream = request_codex(transformed, request, credentials)
77
+ end
78
+
79
+ unless upstream.status >= 200 && upstream.status < 300
80
+ return upstream
81
+ end
82
+
83
+ if responses_api && payload["stream"]
84
+ return upstream
85
+ elsif responses_api
86
+ return responses_response(upstream)
87
+ end
88
+
89
+ if payload["stream"]
90
+ return streaming_response(upstream, request, payload, transformed[:model])
91
+ end
92
+
93
+ return completion_response(upstream, transformed[:model])
94
+ rescue JSON::ParserError, KeyError, ArgumentError, TypeError => error
95
+ error_response(400, error.message, "invalid_request_error")
96
+ rescue Authentication::Error
97
+ error_response(502, "Codex authentication failed", "server_error")
98
+ rescue StandardError
99
+ error_response(502, "Codex provider request failed", "server_error")
100
+ end
101
+
102
+ # Close the HTTP client and authentication source when owned.
103
+ def close
104
+ @client.close if @owns_client
105
+ @authentication.close if @authentication.respond_to?(:close)
106
+ end
107
+
108
+ private
109
+
110
+ def request_codex(payload, request, credentials)
111
+ headers = Protocol::HTTP::Headers[
112
+ "accept" => "text/event-stream",
113
+ "content-type" => "application/json",
114
+ "authorization" => "Bearer #{credentials.fetch(:access_token)}",
115
+ "openai-beta" => "responses=experimental",
116
+ "originator" => "laiya",
117
+ "user-agent" => USER_AGENT,
118
+ "session-id" => session_id(request),
119
+ ]
120
+
121
+ if account_id = credentials[:account_id]
122
+ headers["chatgpt-account-id"] = account_id
123
+ end
124
+
125
+ if residency = credentials[:residency]
126
+ headers["x-openai-internal-codex-residency"] = residency
127
+ end
128
+
129
+ codex_request = Protocol::HTTP::Request[
130
+ "POST",
131
+ "#{@endpoint.path.split("?", 2).first.sub(/\/+\z/, "")}/responses",
132
+ headers,
133
+ [JSON.dump(payload)],
134
+ ]
135
+
136
+ return @client.call(codex_request)
137
+ end
138
+
139
+ def completion_response(upstream, model)
140
+ completed = nil
141
+ collector = Responses::Collector.new
142
+ ServerSentEvents.each(upstream.body) do |event|
143
+ collector.accept(event)
144
+ case event["type"]
145
+ when "response.completed", "response.done"
146
+ completed = collector.complete(event["response"] || event)
147
+ when "response.failed", "error"
148
+ message = event.dig("response", "error", "message") || event.dig("error", "message") || "Codex response failed"
149
+ return error_response(502, message, "server_error")
150
+ end
151
+ end
152
+
153
+ unless completed
154
+ return error_response(502, "Codex ended without a completed response", "server_error")
155
+ end
156
+
157
+ body = JSON.dump(Response.completed(completed, requested_model: model))
158
+ return Protocol::HTTP::Response[200, {"content-type" => "application/json"}, [body]]
159
+ ensure
160
+ upstream.close
161
+ end
162
+
163
+ def responses_response(upstream)
164
+ completed = nil
165
+ collector = Responses::Collector.new
166
+ ServerSentEvents.each(upstream.body) do |event|
167
+ collector.accept(event)
168
+ case event["type"]
169
+ when "response.completed", "response.done"
170
+ completed = collector.complete(event["response"] || event)
171
+ when "response.failed", "error"
172
+ message = event.dig("response", "error", "message") || event.dig("error", "message") || "Codex response failed"
173
+ return error_response(502, message, "server_error")
174
+ end
175
+ end
176
+
177
+ unless completed
178
+ return error_response(502, "Codex ended without a completed response", "server_error")
179
+ end
180
+
181
+ return Protocol::HTTP::Response[200, {"content-type" => "application/json"}, [JSON.dump(completed)]]
182
+ ensure
183
+ upstream.close
184
+ end
185
+
186
+ def streaming_response(upstream, request, payload, model)
187
+ body = Protocol::HTTP::Body::Streamable.response(request) do |stream|
188
+ stream_codex_response(upstream, stream, payload, model)
189
+ end
190
+
191
+ return Protocol::HTTP::Response[
192
+ 200,
193
+ {"content-type" => "text/event-stream", "cache-control" => "no-cache"},
194
+ body,
195
+ ]
196
+ end
197
+
198
+ def stream_codex_response(upstream, stream, payload, model)
199
+ state = {
200
+ id: nil,
201
+ model: model,
202
+ created: Time.now.to_i,
203
+ tools: {},
204
+ has_tools: false,
205
+ role_sent: false,
206
+ completed: false,
207
+ }
208
+
209
+ ServerSentEvents.each(upstream.body) do |event|
210
+ stream_chunks(event, state, payload).each do |chunk|
211
+ stream.write("data: #{JSON.dump(chunk)}\n\n")
212
+ end
213
+ end
214
+
215
+ unless state[:completed]
216
+ stream.write("data: #{JSON.dump(error: {message: "Codex ended without a completed response", type: "server_error"})}\n\n")
217
+ end
218
+
219
+ stream.write("data: [DONE]\n\n")
220
+ ensure
221
+ upstream.close
222
+ stream.close
223
+ end
224
+
225
+ def stream_chunks(event, state, payload)
226
+ case event["type"]
227
+ when "response.created", "response.in_progress"
228
+ response = event["response"] || {}
229
+ state[:id] = response["id"] || state[:id]
230
+ state[:model] = response["model"] || state[:model]
231
+ state[:created] = response["created_at"] || state[:created]
232
+ return [] if state[:role_sent]
233
+ state[:role_sent] = true
234
+ return [Response.stream_chunk(**stream_metadata(state), delta: {role: "assistant"})]
235
+ when "response.output_text.delta"
236
+ return [Response.stream_chunk(**stream_metadata(state), delta: {content: event["delta"]})]
237
+ when "response.refusal.delta"
238
+ return [Response.stream_chunk(**stream_metadata(state), delta: {refusal: event["delta"]})]
239
+ when "response.output_item.added"
240
+ item = event["item"] || {}
241
+ return [] unless item["type"] == "function_call"
242
+
243
+ index = event["output_index"] || state[:tools].length
244
+ state[:tools][item["id"] || item["call_id"]] = index
245
+ state[:has_tools] = true
246
+ tool_call = {index: index, id: item["call_id"] || item["id"], type: "function", function: {name: item["name"], arguments: ""}}
247
+ return [Response.stream_chunk(**stream_metadata(state), delta: {tool_calls: [tool_call]})]
248
+ when "response.function_call_arguments.delta"
249
+ index = state[:tools][event["item_id"]] || event["output_index"] || 0
250
+ tool_call = {index: index, function: {arguments: event["delta"]}}
251
+ return [Response.stream_chunk(**stream_metadata(state), delta: {tool_calls: [tool_call]})]
252
+ when "response.completed", "response.done"
253
+ response = event["response"] || event
254
+ state[:id] = response["id"] || state[:id]
255
+ state[:model] = response["model"] || state[:model]
256
+ state[:created] = response["created_at"] || state[:created]
257
+ state[:has_tools] ||= Array(response["output"]).any?{|item| item["type"] == "function_call"}
258
+ state[:completed] = true
259
+ finish_reason = state[:has_tools] ? "tool_calls" : "stop"
260
+ chunks = [Response.stream_chunk(**stream_metadata(state), delta: {}, finish_reason: finish_reason)]
261
+
262
+ if payload.dig("stream_options", "include_usage") && usage = Response.usage(response["usage"])
263
+ chunks << {id: state[:id], object: "chat.completion.chunk", created: state[:created], model: state[:model], choices: [], usage: usage}
264
+ end
265
+
266
+ return chunks
267
+ when "response.failed", "error"
268
+ message = event.dig("response", "error", "message") || event.dig("error", "message") || "Codex response failed"
269
+ return [{error: {message: message, type: "server_error"}}]
270
+ end
271
+
272
+ return []
273
+ end
274
+
275
+ def stream_metadata(state)
276
+ {response_id: state[:id], model: state[:model], created: state[:created]}
277
+ end
278
+
279
+ def session_id(request)
280
+ Array(request.headers["session-id"]).first || Array(request.headers["x-session-id"]).first || SecureRandom.uuid
281
+ end
282
+
283
+ def chat_tools?(payload)
284
+ return true if payload["tools"] && !payload["tools"].empty?
285
+ return true if payload["tool_choice"]
286
+
287
+ Array(payload["messages"]).any? do |message|
288
+ message.is_a?(Hash) && (message["role"] == "tool" || message["tool_calls"])
289
+ end
290
+ end
291
+
292
+ def error_response(status, message, type)
293
+ body = {error: {message: message, type: type, param: nil, code: nil}}
294
+ Protocol::HTTP::Response[status, {"content-type" => "application/json"}, [JSON.dump(body)]]
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Laiya
7
+ module Provider
8
+ # Base interface for OpenAI-compatible HTTP providers.
9
+ class Interface
10
+ # Forward an HTTP request to the provider.
11
+ # @parameter request [Protocol::HTTP::Request] The request to forward.
12
+ # @returns [Protocol::HTTP::Response] The upstream response.
13
+ # @raises [NotImplementedError] When a subclass does not implement this method.
14
+ def call(request)
15
+ raise NotImplementedError
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "openai"
7
+
8
+ module Laiya
9
+ module Provider
10
+ # Proxies Ollama's OpenAI-compatible HTTP API.
11
+ class Ollama < OpenAI
12
+ DEFAULT_ENDPOINT = "http://localhost:11434"
13
+
14
+ # Initialize a provider with Ollama's local endpoint and API-key default.
15
+ # @option :endpoint [String | Async::HTTP::Endpoint] The Ollama HTTP endpoint.
16
+ # @option :api_key [String | Nil] The optional bearer token.
17
+ def initialize(endpoint: DEFAULT_ENDPOINT, api_key: "ollama", **options)
18
+ super(endpoint: endpoint, api_key: api_key, **options)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,92 @@
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_relative "interface"
8
+
9
+ module Laiya
10
+ module Provider
11
+ # Forwards OpenAI-compatible HTTP requests to the OpenAI API.
12
+ class OpenAI < Interface
13
+ DEFAULT_ENDPOINT = "https://api.openai.com"
14
+ HOP_BY_HOP_HEADERS = %w[
15
+ connection
16
+ keep-alive
17
+ proxy-authenticate
18
+ proxy-authorization
19
+ te
20
+ trailer
21
+ transfer-encoding
22
+ upgrade
23
+ ].freeze
24
+
25
+ # Initialize a provider for OpenAI or another compatible endpoint.
26
+ # @option :api_key [String | Nil] The bearer token for the upstream service.
27
+ # @option :endpoint [String | Async::HTTP::Endpoint] The upstream HTTP endpoint.
28
+ # @option :client [Interface(:call) | Nil] An optional externally managed HTTP client.
29
+ # @option :organization [String | Nil] An optional OpenAI organization ID.
30
+ # @option :project [String | Nil] An optional OpenAI project ID.
31
+ def initialize(api_key: ENV["OPENAI_API_KEY"], endpoint: DEFAULT_ENDPOINT, client: nil, organization: nil, project: nil, **client_options)
32
+ @endpoint = Async::HTTP::Endpoint[endpoint]
33
+ @api_key = api_key
34
+ @organization = organization
35
+ @project = project
36
+ @client = client || Async::HTTP::Client.new(@endpoint, **client_options)
37
+ @owns_client = client.nil?
38
+ end
39
+
40
+ attr :endpoint
41
+
42
+ # Fetch the upstream model-list response.
43
+ # @returns [Protocol::HTTP::Response] The raw model-list response.
44
+ # Fetch the upstream OpenAI-compatible model list as a raw HTTP response.
45
+ def models
46
+ self.call(Protocol::HTTP::Request["GET", "/v1/models"])
47
+ end
48
+
49
+ # Forward a request without transforming its body.
50
+ # @parameter request [Protocol::HTTP::Request] The request to send upstream.
51
+ # @returns [Protocol::HTTP::Response] The unmodified upstream response.
52
+ def call(request)
53
+ headers = forwarded_headers(request.headers)
54
+ headers["authorization"] = "Bearer #{@api_key}" if @api_key
55
+ headers["openai-organization"] = @organization if @organization
56
+ headers["openai-project"] = @project if @project
57
+
58
+ upstream_request = Protocol::HTTP::Request.new(
59
+ @endpoint.scheme,
60
+ @endpoint.authority,
61
+ request.method,
62
+ request.path,
63
+ request.version,
64
+ headers,
65
+ request.body,
66
+ request.protocol,
67
+ request.interim_response,
68
+ )
69
+
70
+ return @client.call(upstream_request)
71
+ end
72
+
73
+ # Close the HTTP client when this provider created it.
74
+ def close
75
+ @client.close if @owns_client
76
+ end
77
+
78
+ private
79
+
80
+ def forwarded_headers(headers)
81
+ forwarded = headers.dup
82
+ connection_headers = Array(headers["connection"]).flat_map{|value| value.split(",")}.map(&:strip)
83
+
84
+ (HOP_BY_HOP_HEADERS + connection_headers + ["host"]).uniq.each do |name|
85
+ forwarded.delete(name)
86
+ end
87
+
88
+ return forwarded
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Laiya
7
+ # Provider interfaces and built-in provider implementations.
8
+ module Provider
9
+ end
10
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "json"
7
+
8
+ module Laiya
9
+ # Selects a configured provider for each OpenAI-compatible HTTP request.
10
+ class Router
11
+ MODEL_ENDPOINTS = %w[
12
+ /v1/chat/completions
13
+ /v1/completions
14
+ /v1/embeddings
15
+ /v1/responses
16
+ /v1/images/generations
17
+ ].freeze
18
+
19
+ # Initialize a router for the given configuration.
20
+ # @parameter configuration [Laiya::Configuration] The provider and model routes.
21
+ def initialize(configuration)
22
+ @configuration = configuration
23
+ end
24
+
25
+ # Route an HTTP request to its configured provider.
26
+ # @parameter request [Protocol::HTTP::Request] The incoming request.
27
+ # @returns [Protocol::HTTP::Response] The provider response or a routing error.
28
+ def call(request)
29
+ provider = provider_for_model(model_for(request))
30
+
31
+ unless provider
32
+ return Protocol::HTTP::Response[
33
+ 404,
34
+ {"content-type" => "application/json"},
35
+ [JSON.dump(error: {message: "No provider is configured for this request", type: "not_found_error"})],
36
+ ]
37
+ end
38
+
39
+ return provider.call(request)
40
+ end
41
+
42
+ # Return the configured model catalog, if present.
43
+ def models_response
44
+ return if @configuration.models.empty?
45
+ models = @configuration.models.each(default_provider: @configuration.default_provider).to_a
46
+
47
+ Protocol::HTTP::Response[
48
+ 200,
49
+ {"content-type" => "application/json"},
50
+ [JSON.dump(object: "list", data: models)],
51
+ ]
52
+ end
53
+
54
+ # Close configured providers that support closing.
55
+ def close
56
+ @configuration.providers.values.uniq.each do |provider|
57
+ provider.close if provider.respond_to?(:close)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def provider_for_model(model)
64
+ return @configuration.provider_for_model(model)
65
+ end
66
+
67
+ def model_for(request)
68
+ return unless request.method == "POST" && MODEL_ENDPOINTS.include?(request.path.split("?", 2).first)
69
+ return unless request.headers["content-type"]&.start_with?("application/json")
70
+ return unless request.body
71
+
72
+ request.buffered!
73
+
74
+ begin
75
+ body = request.body.chunks.join
76
+ payload = JSON.parse(body)
77
+ payload["model"] if payload.is_a?(Hash)
78
+ rescue JSON::ParserError, TypeError
79
+ nil
80
+ ensure
81
+ request.rewind!
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,50 @@
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/service"
8
+ require_relative "../web/application"
9
+
10
+ module Laiya
11
+ # @namespace
12
+ module Service
13
+ # Runs the Laiya API application on Async::HTTP::Server.
14
+ class Application < Async::Service::Managed::Service
15
+ # Bind the service endpoint when the service starts.
16
+ # @returns [Nil] The result of the superclass start hook.
17
+ def start
18
+ super
19
+
20
+ @endpoint = @evaluator.endpoint
21
+ @bound_endpoint = Sync{@endpoint.bound}
22
+ end
23
+
24
+ # Close the bound endpoint and API application when stopping.
25
+ # @returns [Nil] The result of the superclass stop hook.
26
+ def stop
27
+ @bound_endpoint&.close
28
+ @application&.close
29
+
30
+ super
31
+ end
32
+
33
+ # Run one Async HTTP server instance.
34
+ # @parameter instance [Object] The managed service instance.
35
+ # @parameter evaluator [Object] The service environment evaluator.
36
+ # @returns [Object] The running HTTP server task.
37
+ def run(instance, evaluator)
38
+ @application = evaluator.application
39
+ server = Async::HTTP::Server.new(
40
+ @application,
41
+ @bound_endpoint,
42
+ protocol: @endpoint.protocol,
43
+ scheme: @endpoint.scheme,
44
+ )
45
+
46
+ return server.run
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Laiya
7
+ VERSION = "0.0.2"
8
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "json"
7
+ require "protocol/http/middleware"
8
+ require_relative "../router"
9
+
10
+ module Laiya
11
+ # @namespace
12
+ module Web
13
+ # OpenAI-compatible HTTP API application.
14
+ class Application < Protocol::HTTP::Middleware
15
+ # Initialize the API middleware with a configuration.
16
+ # @parameter configuration [Laiya::Configuration] Provider and model routing settings.
17
+ def initialize(configuration:)
18
+ @configuration = configuration
19
+ @router = Laiya::Router.new(configuration)
20
+ end
21
+
22
+ attr :configuration
23
+
24
+ # Handle a model-list request or proxy a request to its provider.
25
+ # @parameter request [Protocol::HTTP::Request] The incoming HTTP request.
26
+ # @returns [Protocol::HTTP::Response] The API response.
27
+ def call(request)
28
+ if request.method == "GET" && request.path == "/v1/models" && (response = @router.models_response)
29
+ return response
30
+ end
31
+
32
+ return @router.call(request)
33
+ rescue StandardError
34
+ return Protocol::HTTP::Response[
35
+ 502,
36
+ {"content-type" => "application/json"},
37
+ [JSON.dump(error: {message: "Upstream provider request failed", type: "server_error"})],
38
+ ]
39
+ end
40
+
41
+ # Close all providers owned by the application.
42
+ def close
43
+ @router.close
44
+ end
45
+
46
+ end
47
+ end
48
+ end
data/lib/laiya/web.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "web/application"
data/lib/laiya.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "laiya/version"
7
+ require_relative "laiya/models/discover"
8
+ require_relative "laiya/configuration"
9
+ require_relative "laiya/provider"
10
+ require_relative "laiya/provider/interface"
11
+ require_relative "laiya/provider/openai"
12
+ require_relative "laiya/provider/codex"
13
+ require_relative "laiya/provider/ollama"
14
+ require_relative "laiya/router"
15
+ require_relative "laiya/web/application"
16
+
17
+ # @namespace
18
+ module Laiya
19
+ end
data/license.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright, 2026, by Samuel Williams.
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.