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,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async"
|
|
7
|
+
require "async/http"
|
|
8
|
+
require "base64"
|
|
9
|
+
require "fileutils"
|
|
10
|
+
require "json"
|
|
11
|
+
require "tempfile"
|
|
12
|
+
require "time"
|
|
13
|
+
require "uri"
|
|
14
|
+
|
|
15
|
+
module Laiya
|
|
16
|
+
module Provider
|
|
17
|
+
class Codex < Interface
|
|
18
|
+
# Loads and refreshes the local Codex CLI ChatGPT credentials.
|
|
19
|
+
class Authentication
|
|
20
|
+
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
21
|
+
ISSUER = "https://auth.openai.com"
|
|
22
|
+
DEFAULT_CODEX_HOME = File.expand_path("~/.codex")
|
|
23
|
+
REFRESH_AFTER = 7 * 24 * 60 * 60
|
|
24
|
+
EXPIRY_SAFETY_MARGIN = 5 * 60
|
|
25
|
+
|
|
26
|
+
# Raised when Codex credentials are missing, invalid, or cannot be refreshed.
|
|
27
|
+
class Error < StandardError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Initialize credential loading for a Codex home directory.
|
|
31
|
+
# @option :codex_home [String] The directory containing `auth.json`.
|
|
32
|
+
# @option :client [Interface(:call) | Nil] An optional OAuth HTTP client.
|
|
33
|
+
# @option :endpoint [String | Async::HTTP::Endpoint] The OAuth issuer endpoint.
|
|
34
|
+
def initialize(codex_home: ENV.fetch("CODEX_HOME", DEFAULT_CODEX_HOME), client: nil, endpoint: ISSUER)
|
|
35
|
+
@codex_home = codex_home
|
|
36
|
+
@auth_path = File.join(codex_home, "auth.json")
|
|
37
|
+
@endpoint = Async::HTTP::Endpoint[endpoint]
|
|
38
|
+
@client = client || Async::HTTP::Client.new(@endpoint)
|
|
39
|
+
@owns_client = client.nil?
|
|
40
|
+
@semaphore = Async::Semaphore.new(1)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Load valid credentials and refresh them when requested or stale.
|
|
44
|
+
# @option :refresh [Boolean] Force a credential refresh.
|
|
45
|
+
# @returns [Hash] The access token, account ID, and optional residency.
|
|
46
|
+
# @raises [Laiya::Provider::Codex::Authentication::Error] If the credentials cannot be used.
|
|
47
|
+
def credentials(refresh: false)
|
|
48
|
+
@semaphore.acquire do
|
|
49
|
+
with_auth_lock do
|
|
50
|
+
document = read_auth
|
|
51
|
+
validate_auth(document)
|
|
52
|
+
tokens = document.fetch("tokens")
|
|
53
|
+
|
|
54
|
+
if refresh || stale?(tokens, document)
|
|
55
|
+
document = refresh_auth(document)
|
|
56
|
+
tokens = document.fetch("tokens")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
account = account_id(tokens)
|
|
60
|
+
raise Error, "Codex ChatGPT account ID is missing" unless account
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
access_token: tokens.fetch("access_token"),
|
|
64
|
+
account_id: account,
|
|
65
|
+
residency: residency(tokens.fetch("access_token")),
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Close the OAuth client when this object created it.
|
|
72
|
+
def close
|
|
73
|
+
@client.close if @owns_client
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def with_auth_lock
|
|
79
|
+
FileUtils.mkdir_p(@codex_home, mode: 0o700)
|
|
80
|
+
File.open("#{@auth_path}.laiya.lock", File::RDWR|File::CREAT, 0o600) do |lock|
|
|
81
|
+
lock.flock(File::LOCK_EX)
|
|
82
|
+
return yield
|
|
83
|
+
ensure
|
|
84
|
+
lock.flock(File::LOCK_UN)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read_auth
|
|
89
|
+
JSON.parse(File.read(@auth_path))
|
|
90
|
+
rescue Errno::ENOENT
|
|
91
|
+
raise Error, "Codex auth.json is missing; log in with `codex login` and use file credential storage"
|
|
92
|
+
rescue JSON::ParserError
|
|
93
|
+
raise Error, "Codex auth.json could not be parsed"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_auth(document)
|
|
97
|
+
unless document["auth_mode"] == "chatgpt" && document["tokens"].is_a?(Hash)
|
|
98
|
+
raise Error, "Codex auth.json does not contain a ChatGPT login"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
unless document.dig("tokens", "access_token") && document.dig("tokens", "refresh_token")
|
|
102
|
+
raise Error, "Codex ChatGPT credentials are incomplete"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def stale?(tokens, document)
|
|
107
|
+
if expires_at = jwt_claims(tokens["access_token"])&.fetch("exp", nil)
|
|
108
|
+
return expires_at.to_i <= Time.now.to_i + EXPIRY_SAFETY_MARGIN
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
last_refresh = Time.parse(document["last_refresh"]) rescue nil
|
|
112
|
+
return true unless last_refresh
|
|
113
|
+
|
|
114
|
+
Time.now - last_refresh >= REFRESH_AFTER
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def refresh_auth(document)
|
|
118
|
+
tokens = document.fetch("tokens")
|
|
119
|
+
form = URI.encode_www_form({
|
|
120
|
+
grant_type: "refresh_token",
|
|
121
|
+
refresh_token: tokens.fetch("refresh_token"),
|
|
122
|
+
client_id: CLIENT_ID,
|
|
123
|
+
})
|
|
124
|
+
headers = {"content-type" => "application/x-www-form-urlencoded", "accept" => "application/json"}
|
|
125
|
+
request = Protocol::HTTP::Request["POST", "/oauth/token", headers, [form]]
|
|
126
|
+
|
|
127
|
+
response = @client.call(request)
|
|
128
|
+
unless response.status == 200
|
|
129
|
+
status = response.status
|
|
130
|
+
response.close
|
|
131
|
+
raise Error, "Codex credential refresh failed with HTTP #{status}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
refreshed = JSON.parse(response.read)
|
|
136
|
+
ensure
|
|
137
|
+
response.close
|
|
138
|
+
end
|
|
139
|
+
tokens["access_token"] = refreshed.fetch("access_token")
|
|
140
|
+
tokens["refresh_token"] = refreshed["refresh_token"] || tokens.fetch("refresh_token")
|
|
141
|
+
document["last_refresh"] = Time.now.utc.iso8601
|
|
142
|
+
save_auth(document)
|
|
143
|
+
|
|
144
|
+
return document
|
|
145
|
+
rescue JSON::ParserError, KeyError
|
|
146
|
+
raise Error, "Codex credential refresh returned an invalid response"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def save_auth(document)
|
|
150
|
+
Tempfile.create("auth.json", @codex_home) do |file|
|
|
151
|
+
file.chmod(0o600)
|
|
152
|
+
file.write(JSON.pretty_generate(document))
|
|
153
|
+
file.flush
|
|
154
|
+
file.fsync
|
|
155
|
+
File.rename(file.path, @auth_path)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def account_id(tokens)
|
|
160
|
+
return tokens["account_id"] if tokens["account_id"]
|
|
161
|
+
|
|
162
|
+
claims = jwt_claims(tokens["id_token"])
|
|
163
|
+
claims&.fetch("chatgpt_account_id", nil) || claims&.dig("https://api.openai.com/auth", "chatgpt_account_id")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def residency(access_token)
|
|
167
|
+
claims = jwt_claims(access_token)
|
|
168
|
+
value = claims&.fetch("chatgpt_compute_residency", nil) || claims&.dig("https://api.openai.com/auth", "chatgpt_compute_residency")
|
|
169
|
+
value unless value.nil? || value == "no_constraint"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def jwt_claims(token)
|
|
173
|
+
return unless token.is_a?(String)
|
|
174
|
+
|
|
175
|
+
payload = token.split(".")[1]
|
|
176
|
+
return unless payload
|
|
177
|
+
|
|
178
|
+
JSON.parse(Base64.urlsafe_decode64(payload))
|
|
179
|
+
rescue ArgumentError, JSON::ParserError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
module Provider
|
|
10
|
+
class Codex < Interface
|
|
11
|
+
# Converts OpenAI Chat Completions requests into Codex Responses input.
|
|
12
|
+
module Request
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Transform a Chat Completions payload into a Codex Responses request.
|
|
16
|
+
# @parameter payload [Hash] The Chat Completions request object.
|
|
17
|
+
# @returns [Hash] The Codex Responses request payload.
|
|
18
|
+
# @raises [ArgumentError, KeyError] If the request contains unsupported input.
|
|
19
|
+
def transform(payload)
|
|
20
|
+
messages = payload.fetch("messages")
|
|
21
|
+
raise ArgumentError, "messages must be an array" unless messages.is_a?(Array)
|
|
22
|
+
|
|
23
|
+
instructions = []
|
|
24
|
+
input = []
|
|
25
|
+
|
|
26
|
+
messages.each do |message|
|
|
27
|
+
raise ArgumentError, "each message must be an object" unless message.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
role = message.fetch("role", "user")
|
|
30
|
+
|
|
31
|
+
case role
|
|
32
|
+
when "system", "developer"
|
|
33
|
+
instructions << text(message.fetch("content", ""))
|
|
34
|
+
when "tool"
|
|
35
|
+
input << {
|
|
36
|
+
type: "function_call_output",
|
|
37
|
+
call_id: message.fetch("tool_call_id"),
|
|
38
|
+
output: text(message.fetch("content", "")),
|
|
39
|
+
}
|
|
40
|
+
else
|
|
41
|
+
raise ArgumentError, "unsupported message role: #{role}" unless %w[user assistant].include?(role)
|
|
42
|
+
|
|
43
|
+
content = message.fetch("content", "")
|
|
44
|
+
input << {role: role, content: text(content)} unless text(content).empty?
|
|
45
|
+
|
|
46
|
+
if role == "assistant"
|
|
47
|
+
Array(message["tool_calls"]).each do |tool_call|
|
|
48
|
+
function = tool_call.fetch("function")
|
|
49
|
+
input << {
|
|
50
|
+
type: "function_call",
|
|
51
|
+
call_id: tool_call.fetch("id"),
|
|
52
|
+
name: function.fetch("name"),
|
|
53
|
+
arguments: function.fetch("arguments", "{}"),
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
result = {
|
|
61
|
+
model: payload.fetch("model"),
|
|
62
|
+
input: input,
|
|
63
|
+
store: false,
|
|
64
|
+
stream: true,
|
|
65
|
+
include: ["reasoning.encrypted_content"],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
unless instructions.empty?
|
|
69
|
+
result[:instructions] = instructions.join("\n\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
%w[parallel_tool_calls temperature top_p].each do |key|
|
|
73
|
+
result[key.to_sym] = payload[key] if payload.key?(key)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if max_tokens = payload["max_completion_tokens"] || payload["max_tokens"]
|
|
77
|
+
result[:max_output_tokens] = max_tokens
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if effort = payload["reasoning_effort"]
|
|
81
|
+
result[:reasoning] = {effort: effort, summary: "auto"}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if response_format = transform_response_format(payload["response_format"])
|
|
85
|
+
result[:text] = {format: response_format}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Convert an OpenAI response format into Codex text formatting options.
|
|
92
|
+
# @parameter format [Hash | Nil] The requested response format.
|
|
93
|
+
# @returns [Hash | Nil] The corresponding Codex format, if provided.
|
|
94
|
+
# @raises [ArgumentError, KeyError] If the format is invalid or unsupported.
|
|
95
|
+
def transform_response_format(format)
|
|
96
|
+
return unless format
|
|
97
|
+
raise ArgumentError, "response_format must be an object" unless format.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
case format["type"]
|
|
100
|
+
when "text"
|
|
101
|
+
{type: "text"}
|
|
102
|
+
when "json_object"
|
|
103
|
+
{type: "json_object"}
|
|
104
|
+
when "json_schema"
|
|
105
|
+
schema = format.fetch("json_schema")
|
|
106
|
+
{
|
|
107
|
+
type: "json_schema",
|
|
108
|
+
name: schema.fetch("name"),
|
|
109
|
+
schema: schema.fetch("schema"),
|
|
110
|
+
strict: schema["strict"],
|
|
111
|
+
}.compact
|
|
112
|
+
else
|
|
113
|
+
raise ArgumentError, "unsupported response_format"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Extract text from supported message content shapes.
|
|
118
|
+
# @parameter content [String | Array | Nil] The message content.
|
|
119
|
+
# @returns [String] The concatenated text content.
|
|
120
|
+
# @raises [ArgumentError] If the content type is unsupported.
|
|
121
|
+
def text(content)
|
|
122
|
+
return "" if content.nil?
|
|
123
|
+
return content if content.is_a?(String)
|
|
124
|
+
|
|
125
|
+
if content.is_a?(Array)
|
|
126
|
+
return content.filter_map do |part|
|
|
127
|
+
part["text"] if part.is_a?(Hash) && %w[text input_text].include?(part["type"])
|
|
128
|
+
end.join
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
raise ArgumentError, "only text message content is currently supported"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
|
|
9
|
+
module Laiya
|
|
10
|
+
module Provider
|
|
11
|
+
class Codex < Interface
|
|
12
|
+
# Converts Codex Responses API results into OpenAI Chat Completions.
|
|
13
|
+
module Response
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Convert a completed Responses object to Chat Completions format.
|
|
17
|
+
# @parameter response [Hash] The completed Codex response.
|
|
18
|
+
# @option :requested_model [String | Nil] The model requested by the client.
|
|
19
|
+
# @returns [Hash] A Chat Completions response object.
|
|
20
|
+
def completed(response, requested_model: nil)
|
|
21
|
+
output = response.fetch("output", [])
|
|
22
|
+
content = []
|
|
23
|
+
tool_calls = []
|
|
24
|
+
|
|
25
|
+
output.each do |item|
|
|
26
|
+
case item["type"]
|
|
27
|
+
when "message"
|
|
28
|
+
Array(item["content"]).each do |part|
|
|
29
|
+
content << part["text"] if part["type"] == "output_text" && part["text"]
|
|
30
|
+
content << part["refusal"] if part["type"] == "refusal" && part["refusal"]
|
|
31
|
+
end
|
|
32
|
+
when "function_call"
|
|
33
|
+
tool_calls << {
|
|
34
|
+
id: item["call_id"] || item["id"],
|
|
35
|
+
type: "function",
|
|
36
|
+
function: {
|
|
37
|
+
name: item["name"],
|
|
38
|
+
arguments: json_arguments(item["arguments"]),
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
message = {role: "assistant", content: content.empty? ? nil : content.join}
|
|
45
|
+
message[:tool_calls] = tool_calls unless tool_calls.empty?
|
|
46
|
+
usage = usage(response["usage"])
|
|
47
|
+
result = {
|
|
48
|
+
id: completion_id(response["id"]),
|
|
49
|
+
object: "chat.completion",
|
|
50
|
+
created: response["created_at"] || Time.now.to_i,
|
|
51
|
+
model: response["model"] || requested_model,
|
|
52
|
+
choices: [{index: 0, message: message, finish_reason: tool_calls.empty? ? "stop" : "tool_calls"}],
|
|
53
|
+
}
|
|
54
|
+
result[:usage] = usage if usage
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build a Chat Completions streaming chunk.
|
|
60
|
+
# @parameter response_id [String | Nil] The Codex response ID.
|
|
61
|
+
# @parameter model [String | Nil] The response model name.
|
|
62
|
+
# @parameter created [Integer | Nil] The creation timestamp.
|
|
63
|
+
# @parameter delta [Hash] The incremental assistant message.
|
|
64
|
+
# @option :finish_reason [String | Nil] The terminal reason, if final.
|
|
65
|
+
# @option :usage [Hash | Nil] Token usage for the response.
|
|
66
|
+
# @returns [Hash] A Chat Completions chunk.
|
|
67
|
+
def stream_chunk(response_id:, model:, created:, delta:, finish_reason: nil, usage: nil)
|
|
68
|
+
chunk = {
|
|
69
|
+
id: completion_id(response_id),
|
|
70
|
+
object: "chat.completion.chunk",
|
|
71
|
+
created: created || Time.now.to_i,
|
|
72
|
+
model: model,
|
|
73
|
+
choices: [{index: 0, delta: delta, finish_reason: finish_reason}],
|
|
74
|
+
}
|
|
75
|
+
chunk[:usage] = usage if usage
|
|
76
|
+
return chunk
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convert Codex input and output token counts to OpenAI usage fields.
|
|
80
|
+
# @parameter value [Hash | Nil] Codex usage information.
|
|
81
|
+
# @returns [Hash | Nil] OpenAI-compatible usage details.
|
|
82
|
+
def usage(value)
|
|
83
|
+
return unless value.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
input_tokens = value["input_tokens"]
|
|
86
|
+
output_tokens = value["output_tokens"]
|
|
87
|
+
return unless input_tokens && output_tokens
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
prompt_tokens: input_tokens,
|
|
91
|
+
completion_tokens: output_tokens,
|
|
92
|
+
total_tokens: value["total_tokens"] || input_tokens + output_tokens,
|
|
93
|
+
prompt_tokens_details: {cached_tokens: value.dig("input_tokens_details", "cached_tokens") || 0},
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Encode function arguments as a JSON string.
|
|
98
|
+
# @parameter arguments [String | Object | Nil] The function arguments.
|
|
99
|
+
# @returns [String] The serialized function arguments.
|
|
100
|
+
def json_arguments(arguments)
|
|
101
|
+
return arguments if arguments.is_a?(String)
|
|
102
|
+
|
|
103
|
+
JSON.dump(arguments || {})
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Produce an OpenAI-style completion ID.
|
|
107
|
+
# @parameter response_id [String | Nil] An optional Codex response ID.
|
|
108
|
+
# @returns [String] A `chatcmpl-` ID.
|
|
109
|
+
def completion_id(response_id)
|
|
110
|
+
return "chatcmpl-#{response_id}" if response_id
|
|
111
|
+
|
|
112
|
+
"chatcmpl-#{SecureRandom.hex(12)}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
class Codex < Interface
|
|
9
|
+
# Applies the Codex-specific settings while preserving the Responses API payload.
|
|
10
|
+
module Responses
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Apply stateless and streaming options to a native Responses payload.
|
|
14
|
+
# @parameter payload [Hash] The Responses request object.
|
|
15
|
+
# @returns [Hash] A copied request configured for the Codex backend.
|
|
16
|
+
# @raises [ArgumentError] If the request refers to stored prior state.
|
|
17
|
+
def prepare(payload)
|
|
18
|
+
if payload["previous_response_id"]
|
|
19
|
+
raise ArgumentError, "Codex stateless mode requires the client to send full input history"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
result = payload.dup
|
|
23
|
+
result["store"] = false
|
|
24
|
+
result["stream"] = true
|
|
25
|
+
|
|
26
|
+
include_items = Array(result["include"])
|
|
27
|
+
include_items << "reasoning.encrypted_content" unless include_items.include?("reasoning.encrypted_content")
|
|
28
|
+
result["include"] = include_items
|
|
29
|
+
|
|
30
|
+
return result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Rebuilds output items from streamed events when the terminal Codex
|
|
34
|
+
# response omits them (some Codex responses return an empty output array).
|
|
35
|
+
class Collector
|
|
36
|
+
# Initialize an empty streamed-output collector.
|
|
37
|
+
def initialize
|
|
38
|
+
@items = {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Accumulate one Codex Responses streaming event.
|
|
42
|
+
# @parameter event [Hash] The event to record.
|
|
43
|
+
def accept(event)
|
|
44
|
+
case event["type"]
|
|
45
|
+
when "response.output_item.added"
|
|
46
|
+
@items[event.fetch("output_index", @items.length)] = duplicate(event.fetch("item"))
|
|
47
|
+
when "response.output_text.delta", "response.refusal.delta"
|
|
48
|
+
append_text(event)
|
|
49
|
+
when "response.function_call_arguments.delta"
|
|
50
|
+
append_arguments(event)
|
|
51
|
+
when "response.output_item.done"
|
|
52
|
+
@items[event.fetch("output_index", @items.length)] = duplicate(event.fetch("item"))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fill missing output items in a terminal response from collected events.
|
|
57
|
+
# @parameter response [Hash] The completed Responses object.
|
|
58
|
+
# @returns [Hash] The response with reconstructed output items.
|
|
59
|
+
def complete(response)
|
|
60
|
+
unless response["output"].is_a?(Array) && !response["output"].empty?
|
|
61
|
+
response["output"] = @items.sort_by{|index, _item| index}.map(&:last)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return response
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def append_text(event)
|
|
70
|
+
index = event.fetch("output_index", 0)
|
|
71
|
+
item = @items[index] ||= {"type" => "message", "role" => "assistant", "content" => []}
|
|
72
|
+
content_index = event.fetch("content_index", 0)
|
|
73
|
+
part = item.fetch("content")
|
|
74
|
+
part[content_index] ||= {"type" => event["type"] == "response.refusal.delta" ? "refusal" : "output_text", "text" => ""}
|
|
75
|
+
key = event["type"] == "response.refusal.delta" ? "refusal" : "text"
|
|
76
|
+
part[content_index][key] = part[content_index].fetch(key, "") + event.fetch("delta", "")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def append_arguments(event)
|
|
80
|
+
index = event.fetch("output_index", 0)
|
|
81
|
+
item = @items[index] ||= {"type" => "function_call", "arguments" => ""}
|
|
82
|
+
item["arguments"] = item.fetch("arguments", "") + event.fetch("delta", "")
|
|
83
|
+
item["id"] ||= event["item_id"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def duplicate(value)
|
|
87
|
+
JSON.parse(JSON.dump(value))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
module Provider
|
|
10
|
+
class Codex < Interface
|
|
11
|
+
# Incrementally decodes Codex Responses API server-sent events.
|
|
12
|
+
module ServerSentEvents
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Decode an HTTP body as server-sent events.
|
|
16
|
+
# @parameter body [Enumerable(String)] The streamed event body.
|
|
17
|
+
# @yields {|event| ...} Each decoded event.
|
|
18
|
+
# @parameter event [Hash] The event data.
|
|
19
|
+
# @returns [Enumerator | Nil] An enumerator without a block, otherwise `nil`.
|
|
20
|
+
def each(body, &block)
|
|
21
|
+
return enum_for(__method__, body) unless block
|
|
22
|
+
|
|
23
|
+
buffer = String.new
|
|
24
|
+
data = []
|
|
25
|
+
event_name = nil
|
|
26
|
+
|
|
27
|
+
body.each do |chunk|
|
|
28
|
+
buffer << chunk
|
|
29
|
+
while newline = buffer.index("\n")
|
|
30
|
+
line = buffer.slice!(0, newline + 1).delete_suffix("\n").delete_suffix("\r")
|
|
31
|
+
|
|
32
|
+
if line.empty?
|
|
33
|
+
yield_event(event_name, data, &block) unless data.empty?
|
|
34
|
+
data.clear
|
|
35
|
+
event_name = nil
|
|
36
|
+
elsif line.start_with?("data:")
|
|
37
|
+
data << line.delete_prefix("data:").sub(/\A /, "")
|
|
38
|
+
elsif line.start_with?("event:")
|
|
39
|
+
event_name = line.delete_prefix("event:").strip
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
unless buffer.empty?
|
|
45
|
+
line = buffer.delete_suffix("\r")
|
|
46
|
+
data << line.delete_prefix("data:").sub(/\A /, "") if line.start_with?("data:")
|
|
47
|
+
event_name = line.delete_prefix("event:").strip if line.start_with?("event:")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
yield_event(event_name, data, &block) unless data.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Collect all decoded events from a body.
|
|
54
|
+
# @parameter body [Enumerable(String)] The event body.
|
|
55
|
+
# @returns [Array(Hash)] The decoded events.
|
|
56
|
+
def parse(body)
|
|
57
|
+
events = []
|
|
58
|
+
each(body) {|event| events << event}
|
|
59
|
+
return events
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Parse event data and yield a non-terminal event.
|
|
63
|
+
# @parameter event_name [String | Nil] The optional event name.
|
|
64
|
+
# @parameter data [Array(String)] The event data lines.
|
|
65
|
+
# @yields {|event| ...} The parsed event object.
|
|
66
|
+
# @parameter event [Hash] The decoded JSON data.
|
|
67
|
+
def yield_event(event_name, data)
|
|
68
|
+
joined = data.join("\n")
|
|
69
|
+
return if joined == "[DONE]"
|
|
70
|
+
|
|
71
|
+
event = JSON.parse(joined)
|
|
72
|
+
event["type"] ||= event_name if event_name
|
|
73
|
+
yield event
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|