lex-llm 0.1.1

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 (135) hide show
  1. checksums.yaml +7 -0
  2. data/.github/CODEOWNERS +7 -0
  3. data/.github/dependabot.yml +18 -0
  4. data/.github/workflows/ci.yml +16 -0
  5. data/.gitignore +19 -0
  6. data/.rubocop.yml +42 -0
  7. data/CHANGELOG.md +15 -0
  8. data/Gemfile +50 -0
  9. data/LICENSE +21 -0
  10. data/README.md +279 -0
  11. data/lex-llm.gemspec +43 -0
  12. data/lib/generators/lex_llm/agent/agent_generator.rb +36 -0
  13. data/lib/generators/lex_llm/agent/templates/agent.rb.tt +6 -0
  14. data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
  15. data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +256 -0
  16. data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +38 -0
  17. data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +21 -0
  18. data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
  19. data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  20. data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
  21. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  22. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  23. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  24. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  25. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  26. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  27. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  28. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  29. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  30. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  31. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  32. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  33. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  34. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  35. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  36. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  37. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  38. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  39. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
  40. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
  41. data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +28 -0
  42. data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
  43. data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +25 -0
  44. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  45. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
  46. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  47. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
  48. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  49. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  50. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -0
  51. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  52. data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +7 -0
  53. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  54. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  55. data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +15 -0
  56. data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +38 -0
  57. data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +17 -0
  58. data/lib/generators/lex_llm/generator_helpers.rb +214 -0
  59. data/lib/generators/lex_llm/install/install_generator.rb +109 -0
  60. data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
  61. data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +3 -0
  62. data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +7 -0
  63. data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +19 -0
  64. data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +39 -0
  65. data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +21 -0
  66. data/lib/generators/lex_llm/install/templates/initializer.rb.tt +20 -0
  67. data/lib/generators/lex_llm/install/templates/message_model.rb.tt +4 -0
  68. data/lib/generators/lex_llm/install/templates/model_model.rb.tt +3 -0
  69. data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +3 -0
  70. data/lib/generators/lex_llm/schema/schema_generator.rb +26 -0
  71. data/lib/generators/lex_llm/schema/templates/schema.rb.tt +2 -0
  72. data/lib/generators/lex_llm/tool/templates/tool.rb.tt +9 -0
  73. data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +13 -0
  74. data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +13 -0
  75. data/lib/generators/lex_llm/tool/tool_generator.rb +96 -0
  76. data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +19 -0
  77. data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
  78. data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  79. data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  80. data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
  81. data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +122 -0
  82. data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
  83. data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
  84. data/lib/legion/extensions/llm/provider_settings.rb +49 -0
  85. data/lib/legion/extensions/llm/transport/fleet_lane.rb +70 -0
  86. data/lib/legion/extensions/llm.rb +50 -0
  87. data/lib/lex_llm/active_record/acts_as.rb +180 -0
  88. data/lib/lex_llm/active_record/acts_as_legacy.rb +503 -0
  89. data/lib/lex_llm/active_record/chat_methods.rb +468 -0
  90. data/lib/lex_llm/active_record/message_methods.rb +131 -0
  91. data/lib/lex_llm/active_record/model_methods.rb +76 -0
  92. data/lib/lex_llm/active_record/payload_helpers.rb +26 -0
  93. data/lib/lex_llm/active_record/tool_call_methods.rb +15 -0
  94. data/lib/lex_llm/agent.rb +365 -0
  95. data/lib/lex_llm/aliases.json +436 -0
  96. data/lib/lex_llm/aliases.rb +38 -0
  97. data/lib/lex_llm/attachment.rb +223 -0
  98. data/lib/lex_llm/chat.rb +351 -0
  99. data/lib/lex_llm/chunk.rb +6 -0
  100. data/lib/lex_llm/configuration.rb +81 -0
  101. data/lib/lex_llm/connection.rb +130 -0
  102. data/lib/lex_llm/content.rb +77 -0
  103. data/lib/lex_llm/context.rb +29 -0
  104. data/lib/lex_llm/embedding.rb +29 -0
  105. data/lib/lex_llm/error.rb +112 -0
  106. data/lib/lex_llm/image.rb +105 -0
  107. data/lib/lex_llm/message.rb +107 -0
  108. data/lib/lex_llm/mime_type.rb +71 -0
  109. data/lib/lex_llm/model/info.rb +113 -0
  110. data/lib/lex_llm/model/modalities.rb +22 -0
  111. data/lib/lex_llm/model/pricing.rb +48 -0
  112. data/lib/lex_llm/model/pricing_category.rb +46 -0
  113. data/lib/lex_llm/model/pricing_tier.rb +33 -0
  114. data/lib/lex_llm/model.rb +7 -0
  115. data/lib/lex_llm/models.json +57241 -0
  116. data/lib/lex_llm/models.rb +506 -0
  117. data/lib/lex_llm/models_schema.json +168 -0
  118. data/lib/lex_llm/moderation.rb +56 -0
  119. data/lib/lex_llm/provider.rb +278 -0
  120. data/lib/lex_llm/railtie.rb +35 -0
  121. data/lib/lex_llm/routing/lane_key.rb +51 -0
  122. data/lib/lex_llm/routing/model_offering.rb +169 -0
  123. data/lib/lex_llm/routing.rb +7 -0
  124. data/lib/lex_llm/stream_accumulator.rb +203 -0
  125. data/lib/lex_llm/streaming.rb +175 -0
  126. data/lib/lex_llm/thinking.rb +49 -0
  127. data/lib/lex_llm/tokens.rb +47 -0
  128. data/lib/lex_llm/tool.rb +254 -0
  129. data/lib/lex_llm/tool_call.rb +25 -0
  130. data/lib/lex_llm/transcription.rb +35 -0
  131. data/lib/lex_llm/utils.rb +91 -0
  132. data/lib/lex_llm/version.rb +5 -0
  133. data/lib/lex_llm.rb +95 -0
  134. data/lib/tasks/lex_llm.rake +23 -0
  135. metadata +349 -0
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Assembles streaming responses from LLMs into complete messages.
5
+ class StreamAccumulator
6
+ attr_reader :content, :model_id, :tool_calls
7
+
8
+ def initialize
9
+ @content = +''
10
+ @thinking_text = +''
11
+ @thinking_signature = nil
12
+ @tool_calls = {}
13
+ @input_tokens = nil
14
+ @output_tokens = nil
15
+ @cached_tokens = nil
16
+ @cache_creation_tokens = nil
17
+ @thinking_tokens = nil
18
+ @inside_think_tag = false
19
+ @pending_think_tag = +''
20
+ @latest_tool_call_id = nil
21
+ end
22
+
23
+ def add(chunk)
24
+ LexLLM.logger.debug { chunk.inspect } if LexLLM.config.log_stream_debug
25
+ @model_id ||= chunk.model_id
26
+
27
+ handle_chunk_content(chunk)
28
+ append_thinking_from_chunk(chunk)
29
+ count_tokens chunk
30
+ LexLLM.logger.debug { inspect } if LexLLM.config.log_stream_debug
31
+ end
32
+
33
+ def to_message(response)
34
+ Message.new(
35
+ role: :assistant,
36
+ content: content.empty? ? nil : content,
37
+ thinking: Thinking.build(
38
+ text: @thinking_text.empty? ? nil : @thinking_text,
39
+ signature: @thinking_signature
40
+ ),
41
+ tokens: Tokens.build(
42
+ input: @input_tokens,
43
+ output: @output_tokens,
44
+ cached: @cached_tokens,
45
+ cache_creation: @cache_creation_tokens,
46
+ thinking: @thinking_tokens
47
+ ),
48
+ model_id: model_id,
49
+ tool_calls: tool_calls_from_stream,
50
+ raw: response
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def tool_calls_from_stream
57
+ tool_calls.transform_values do |tc|
58
+ arguments = if tc.arguments.is_a?(String) && !tc.arguments.empty?
59
+ Legion::JSON.parse(tc.arguments, symbolize_names: false)
60
+ elsif tc.arguments.is_a?(String)
61
+ {}
62
+ else
63
+ tc.arguments
64
+ end
65
+
66
+ ToolCall.new(
67
+ id: tc.id,
68
+ name: tc.name,
69
+ arguments: arguments,
70
+ thought_signature: tc.thought_signature
71
+ )
72
+ end
73
+ end
74
+
75
+ def accumulate_tool_calls(new_tool_calls) # rubocop:disable Metrics/PerceivedComplexity
76
+ LexLLM.logger.debug { "Accumulating tool calls: #{new_tool_calls}" } if LexLLM.config.log_stream_debug
77
+ new_tool_calls.each_value do |tool_call|
78
+ if tool_call.id
79
+ tool_call_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
80
+ tool_call_arguments = tool_call.arguments
81
+ if tool_call_arguments.nil? || (tool_call_arguments.respond_to?(:empty?) && tool_call_arguments.empty?)
82
+ tool_call_arguments = +''
83
+ end
84
+ @tool_calls[tool_call.id] = ToolCall.new(
85
+ id: tool_call_id,
86
+ name: tool_call.name,
87
+ arguments: tool_call_arguments,
88
+ thought_signature: tool_call.thought_signature
89
+ )
90
+ @latest_tool_call_id = tool_call.id
91
+ else
92
+ existing = @tool_calls[@latest_tool_call_id]
93
+ if existing
94
+ fragment = tool_call.arguments
95
+ fragment = '' if fragment.nil?
96
+ existing.arguments << fragment
97
+ if tool_call.thought_signature && existing.thought_signature.nil?
98
+ existing.thought_signature = tool_call.thought_signature
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ def find_tool_call(tool_call_id)
106
+ if tool_call_id.nil?
107
+ @tool_calls[@latest_tool_call]
108
+ else
109
+ @latest_tool_call_id = tool_call_id
110
+ @tool_calls[tool_call_id]
111
+ end
112
+ end
113
+
114
+ def count_tokens(chunk)
115
+ @input_tokens = chunk.input_tokens if chunk.input_tokens
116
+ @output_tokens = chunk.output_tokens if chunk.output_tokens
117
+ @cached_tokens = chunk.cached_tokens if chunk.cached_tokens
118
+ @cache_creation_tokens = chunk.cache_creation_tokens if chunk.cache_creation_tokens
119
+ @thinking_tokens = chunk.thinking_tokens if chunk.thinking_tokens
120
+ end
121
+
122
+ def handle_chunk_content(chunk)
123
+ return accumulate_tool_calls(chunk.tool_calls) if chunk.tool_call?
124
+
125
+ content_text = chunk.content || ''
126
+ if content_text.is_a?(String)
127
+ append_text_with_thinking(content_text)
128
+ else
129
+ @content << content_text.to_s
130
+ end
131
+ end
132
+
133
+ def append_text_with_thinking(text)
134
+ content_chunk, thinking_chunk = extract_think_tags(text)
135
+ @content << content_chunk
136
+ @thinking_text << thinking_chunk if thinking_chunk
137
+ end
138
+
139
+ def append_thinking_from_chunk(chunk)
140
+ thinking = chunk.thinking
141
+ return unless thinking
142
+
143
+ @thinking_text << thinking.text.to_s if thinking.text
144
+ @thinking_signature ||= thinking.signature # rubocop:disable Naming/MemoizedInstanceVariableName
145
+ end
146
+
147
+ def extract_think_tags(text)
148
+ start_tag = '<think>'
149
+ end_tag = '</think>'
150
+ remaining = @pending_think_tag + text
151
+ @pending_think_tag = +''
152
+
153
+ output = +''
154
+ thinking = +''
155
+
156
+ until remaining.empty?
157
+ remaining = if @inside_think_tag
158
+ consume_think_content(remaining, end_tag, thinking)
159
+ else
160
+ consume_non_think_content(remaining, start_tag, output)
161
+ end
162
+ end
163
+
164
+ [output, thinking.empty? ? nil : thinking]
165
+ end
166
+
167
+ def consume_think_content(remaining, end_tag, thinking)
168
+ end_index = remaining.index(end_tag)
169
+ if end_index
170
+ thinking << remaining.slice(0, end_index)
171
+ @inside_think_tag = false
172
+ remaining.slice((end_index + end_tag.length)..) || +''
173
+ else
174
+ suffix_len = longest_suffix_prefix(remaining, end_tag)
175
+ thinking << remaining.slice(0, remaining.length - suffix_len)
176
+ @pending_think_tag = remaining.slice(-suffix_len, suffix_len)
177
+ +''
178
+ end
179
+ end
180
+
181
+ def consume_non_think_content(remaining, start_tag, output)
182
+ start_index = remaining.index(start_tag)
183
+ if start_index
184
+ output << remaining.slice(0, start_index)
185
+ @inside_think_tag = true
186
+ remaining.slice((start_index + start_tag.length)..) || +''
187
+ else
188
+ suffix_len = longest_suffix_prefix(remaining, start_tag)
189
+ output << remaining.slice(0, remaining.length - suffix_len)
190
+ @pending_think_tag = remaining.slice(-suffix_len, suffix_len)
191
+ +''
192
+ end
193
+ end
194
+
195
+ def longest_suffix_prefix(text, tag)
196
+ max = [text.length, tag.length - 1].min
197
+ max.downto(1) do |len|
198
+ return len if text.end_with?(tag[0, len])
199
+ end
200
+ 0
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Handles streaming responses from AI providers.
5
+ module Streaming
6
+ module_function
7
+
8
+ def stream_response(connection, payload, additional_headers = {}, &block)
9
+ accumulator = StreamAccumulator.new
10
+
11
+ response = connection.post stream_url, payload do |req|
12
+ req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
13
+ if faraday_1?
14
+ req.options[:on_data] = handle_stream do |chunk|
15
+ accumulator.add chunk
16
+ block.call chunk
17
+ end
18
+ else
19
+ req.options.on_data = handle_stream do |chunk|
20
+ accumulator.add chunk
21
+ block.call chunk
22
+ end
23
+ end
24
+ end
25
+
26
+ message = accumulator.to_message(response)
27
+ LexLLM.logger.debug { "Stream completed: #{message.content}" }
28
+ message
29
+ end
30
+
31
+ def handle_stream(&block)
32
+ build_on_data_handler do |data|
33
+ block.call(build_chunk(data)) if data.is_a?(Hash)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def faraday_1?
40
+ Faraday::VERSION.start_with?('1')
41
+ end
42
+
43
+ def build_on_data_handler(&)
44
+ buffer = +''
45
+ parser = EventStreamParser::Parser.new
46
+
47
+ FaradayHandlers.build(
48
+ faraday_v1: faraday_1?,
49
+ on_chunk: ->(chunk, env) { process_stream_chunk(chunk, parser, env, &) },
50
+ on_failed_response: ->(chunk, env) { handle_failed_response(chunk, buffer, env) }
51
+ )
52
+ end
53
+
54
+ def process_stream_chunk(chunk, parser, env, &)
55
+ LexLLM.logger.debug { "Received chunk: #{chunk}" } if LexLLM.config.log_stream_debug
56
+
57
+ if error_chunk?(chunk)
58
+ handle_error_chunk(chunk, env)
59
+ elsif json_error_payload?(chunk)
60
+ handle_json_error_chunk(chunk, env)
61
+ else
62
+ yield handle_sse(chunk, parser, env, &)
63
+ end
64
+ end
65
+
66
+ def error_chunk?(chunk)
67
+ chunk.start_with?('event: error')
68
+ end
69
+
70
+ def json_error_payload?(chunk)
71
+ chunk.lstrip.start_with?('{') && chunk.include?('"error"')
72
+ end
73
+
74
+ def handle_json_error_chunk(chunk, env)
75
+ parse_error_from_json(chunk, env, 'Failed to parse JSON error chunk')
76
+ end
77
+
78
+ def handle_error_chunk(chunk, env)
79
+ error_data = chunk.split("\n")[1].delete_prefix('data: ')
80
+ parse_error_from_json(error_data, env, 'Failed to parse error chunk')
81
+ end
82
+
83
+ def handle_failed_response(chunk, buffer, env)
84
+ buffer << chunk
85
+ error_data = Legion::JSON.parse(buffer, symbolize_names: false)
86
+ handle_parsed_error(error_data, env)
87
+ rescue Legion::JSON::ParseError
88
+ LexLLM.logger.debug { "Accumulating error chunk: #{chunk}" }
89
+ end
90
+
91
+ def handle_sse(chunk, parser, env, &)
92
+ parser.feed(chunk) do |type, data|
93
+ case type.to_sym
94
+ when :error
95
+ handle_error_event(data, env)
96
+ else
97
+ yield handle_data(data, env, &) unless data == '[DONE]'
98
+ end
99
+ end
100
+ end
101
+
102
+ def handle_data(data, env)
103
+ parsed = Legion::JSON.parse(data, symbolize_names: false)
104
+ return parsed unless parsed.is_a?(Hash) && parsed.key?('error')
105
+
106
+ handle_parsed_error(parsed, env)
107
+ rescue Legion::JSON::ParseError => e
108
+ LexLLM.logger.debug { "Failed to parse data chunk: #{e.message}" }
109
+ end
110
+
111
+ def handle_error_event(data, env)
112
+ parse_error_from_json(data, env, 'Failed to parse error event')
113
+ end
114
+
115
+ def parse_streaming_error(data)
116
+ error_data = Legion::JSON.parse(data, symbolize_names: false)
117
+ [500, error_data['message'] || 'Unknown streaming error']
118
+ rescue Legion::JSON::ParseError => e
119
+ LexLLM.logger.debug { "Failed to parse streaming error: #{e.message}" }
120
+ [500, "Failed to parse error: #{data}"]
121
+ end
122
+
123
+ def handle_parsed_error(parsed_data, env)
124
+ status, _message = parse_streaming_error(parsed_data.to_json)
125
+ error_response = build_stream_error_response(parsed_data, env, status)
126
+ ErrorMiddleware.parse_error(provider: self, response: error_response)
127
+ end
128
+
129
+ def parse_error_from_json(data, env, error_message)
130
+ parsed_data = Legion::JSON.parse(data, symbolize_names: false)
131
+ handle_parsed_error(parsed_data, env)
132
+ rescue Legion::JSON::ParseError => e
133
+ LexLLM.logger.debug { "#{error_message}: #{e.message}" }
134
+ end
135
+
136
+ def build_stream_error_response(parsed_data, env, status)
137
+ error_status = status || env&.status || 500
138
+
139
+ if faraday_1?
140
+ Struct.new(:body, :status).new(parsed_data, error_status)
141
+ else
142
+ env.merge(body: parsed_data, status: error_status)
143
+ end
144
+ end
145
+
146
+ # Builds Faraday on_data handlers for different major versions.
147
+ module FaradayHandlers
148
+ module_function
149
+
150
+ def build(faraday_v1:, on_chunk:, on_failed_response:)
151
+ if faraday_v1
152
+ v1_on_data(on_chunk)
153
+ else
154
+ v2_on_data(on_chunk, on_failed_response)
155
+ end
156
+ end
157
+
158
+ def v1_on_data(on_chunk)
159
+ proc do |chunk, _size|
160
+ on_chunk.call(chunk, nil)
161
+ end
162
+ end
163
+
164
+ def v2_on_data(on_chunk, on_failed_response)
165
+ proc do |chunk, _bytes, env|
166
+ if env&.status == 200
167
+ on_chunk.call(chunk, env)
168
+ else
169
+ on_failed_response.call(chunk, env)
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Represents provider thinking output.
5
+ class Thinking
6
+ attr_reader :text, :signature
7
+
8
+ def initialize(text: nil, signature: nil)
9
+ @text = text
10
+ @signature = signature
11
+ end
12
+
13
+ def self.build(text: nil, signature: nil)
14
+ text = nil if text.is_a?(String) && text.empty?
15
+ signature = nil if signature.is_a?(String) && signature.empty?
16
+
17
+ return nil if text.nil? && signature.nil?
18
+
19
+ new(text: text, signature: signature)
20
+ end
21
+
22
+ def pretty_print(printer)
23
+ printer.object_group(self) do
24
+ printer.breakable
25
+ printer.text 'text='
26
+ printer.pp text
27
+ printer.comma_breakable
28
+ printer.text 'signature='
29
+ printer.pp(signature ? '[REDACTED]' : nil)
30
+ end
31
+ end
32
+ end
33
+
34
+ class Thinking
35
+ # Normalized config for thinking across providers.
36
+ class Config
37
+ attr_reader :effort, :budget
38
+
39
+ def initialize(effort: nil, budget: nil)
40
+ @effort = effort.is_a?(Symbol) ? effort.to_s : effort
41
+ @budget = budget
42
+ end
43
+
44
+ def enabled?
45
+ !effort.nil? || !budget.nil?
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Represents token usage for a response.
5
+ class Tokens
6
+ attr_reader :input, :output, :cached, :cache_creation, :thinking
7
+
8
+ # rubocop:disable Metrics/ParameterLists
9
+ def initialize(input: nil, output: nil, cached: nil, cache_creation: nil, thinking: nil, reasoning: nil)
10
+ @input = input
11
+ @output = output
12
+ @cached = cached
13
+ @cache_creation = cache_creation
14
+ @thinking = thinking || reasoning
15
+ end
16
+ # rubocop:enable Metrics/ParameterLists
17
+
18
+ # rubocop:disable Metrics/ParameterLists
19
+ def self.build(input: nil, output: nil, cached: nil, cache_creation: nil, thinking: nil, reasoning: nil)
20
+ return nil if [input, output, cached, cache_creation, thinking, reasoning].all?(&:nil?)
21
+
22
+ new(
23
+ input: input,
24
+ output: output,
25
+ cached: cached,
26
+ cache_creation: cache_creation,
27
+ thinking: thinking,
28
+ reasoning: reasoning
29
+ )
30
+ end
31
+ # rubocop:enable Metrics/ParameterLists
32
+
33
+ def to_h
34
+ {
35
+ input_tokens: input,
36
+ output_tokens: output,
37
+ cached_tokens: cached,
38
+ cache_creation_tokens: cache_creation,
39
+ thinking_tokens: thinking
40
+ }.compact
41
+ end
42
+
43
+ def reasoning
44
+ thinking
45
+ end
46
+ end
47
+ end