ai-lite 0.6.1 → 1.1.0

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.
@@ -0,0 +1,130 @@
1
+ class AiLite
2
+ class OpenAI
3
+ module Streaming
4
+ # Text deltas are yielded immediately; the return value is the final envelope.
5
+ def chat_stream(message, model: nil, instructions: nil, previous_response_id: nil, max_output_tokens: nil, debug: false, options: {}, &block)
6
+ raise ArgumentError, "chat_stream requires a block" unless block
7
+
8
+ status = "unknown"
9
+ text = +""
10
+ response_id = nil
11
+ raw = nil
12
+ callback_error = nil
13
+ begin
14
+ payload = options.reject { |key, _| key.to_s == "stream" }.merge(
15
+ model: model || self.model, input: message, stream: true,
16
+ max_output_tokens: max_output_tokens || self.max_output_tokens
17
+ )
18
+ payload[:instructions] = instructions if instructions
19
+ payload[:previous_response_id] = previous_response_id if previous_response_id
20
+ uri = URI.parse(response_endpoint)
21
+ request = Net::HTTP::Post.new(uri)
22
+ headers.each { |key, value| request[key] = value }
23
+ request["Accept"] = "text/event-stream"
24
+ request.body = JSON.generate(payload)
25
+ result = nil
26
+
27
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
28
+ http.open_timeout = timeout
29
+ http.read_timeout = timeout
30
+ # Exit the request block at the terminal event, closing the connection.
31
+ catch(:ai_lite_stream_finished) do
32
+ http.request(request) do |response|
33
+ status = response.code.to_i
34
+ unless success_status?(status)
35
+ body = +""
36
+ response.read_body { |chunk| body << chunk }
37
+ raw = body
38
+ raw = JSON.parse(body)
39
+ result = prettify_data(status: status, error: error_message(raw), raw: raw, debug: debug)
40
+ throw :ai_lite_stream_finished
41
+ end
42
+
43
+ each_stream_event(response) do |event|
44
+ raw = event
45
+ response_id = event.dig("response", "id") || response_id
46
+ case event["type"]
47
+ when "response.output_text.delta"
48
+ delta = event.fetch("delta")
49
+ text << delta
50
+ begin
51
+ block.call(delta)
52
+ rescue StandardError => e
53
+ callback_error = e
54
+ raise
55
+ end
56
+ when "response.completed", "response.failed", "response.incomplete"
57
+ raw = event.fetch("response")
58
+ error = case event["type"]
59
+ when "response.failed"
60
+ raw["error"] ? error_message(raw) : "Response failed"
61
+ when "response.incomplete"
62
+ "Response incomplete: #{raw.dig('incomplete_details', 'reason') || 'unknown reason'}"
63
+ end
64
+ content = error ? text : parse_content(extract_output_text(raw))
65
+ result = prettify_data(status: status, content: content, error: error,
66
+ response_id: response_id, raw: raw, debug: debug)
67
+ throw :ai_lite_stream_finished
68
+ when "error"
69
+ result = prettify_data(status: status, content: text, error: error_message(event),
70
+ response_id: response_id, raw: raw, debug: debug)
71
+ throw :ai_lite_stream_finished
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ result || prettify_data(status: status, content: text, response_id: response_id,
78
+ error: "Stream ended before a terminal response event", raw: raw, debug: debug)
79
+ rescue StandardError => e
80
+ raise if callback_error.equal?(e)
81
+
82
+ prettify_data(status: status, content: text.empty? ? nil : text, response_id: response_id,
83
+ error: e.message, raw: raw, debug: debug)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def each_stream_event(response)
90
+ buffer = +"".b
91
+ data = []
92
+ consume = lambda do
93
+ while (boundary = buffer.index(/[\r\n]/))
94
+ # A trailing CR might be the first byte of a CRLF split across reads.
95
+ break if buffer.getbyte(boundary) == 13 && boundary == buffer.bytesize - 1
96
+
97
+ width = buffer.byteslice(boundary, 2) == "\r\n" ? 2 : 1
98
+ line = buffer.slice!(0, boundary + width).byteslice(0, boundary)
99
+ if line.empty?
100
+ unless data.empty?
101
+ payload = data.join("\n").force_encoding(Encoding::UTF_8)
102
+ data.clear
103
+ unless payload == "[DONE]"
104
+ event = JSON.parse(payload)
105
+ raise "Invalid streaming event: expected an object" unless event.is_a?(Hash)
106
+ yield event
107
+ end
108
+ end
109
+ elsif line.start_with?("data:")
110
+ data << line.sub(/\Adata: ?/, "")
111
+ end
112
+ end
113
+ end
114
+ response.read_body do |chunk|
115
+ buffer << chunk.b
116
+ consume.call
117
+ end
118
+ # A final lone CR is a complete line delimiter at EOF.
119
+ if buffer.end_with?("\r")
120
+ buffer << "\n"
121
+ consume.call
122
+ end
123
+ end
124
+
125
+ end
126
+
127
+ include Streaming
128
+ private_constant :Streaming
129
+ end
130
+ end