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,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
|
data/lib/laiya/router.rb
ADDED
|
@@ -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,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
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.
|