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,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