llm_meta_client 1.0.2 β†’ 1.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5dde8e658e36a04b651c91bfcbd34d23b639449c4781529cd5c061e49e52cc53
4
- data.tar.gz: 56d464e06df9afb9a92ef5dcafee8d806158fb1c333dc5a44800a1737095e68b
3
+ metadata.gz: 2fcc6377f3293f8ecd13b81cd79ea63891c18f55dfa05ee225a151f7e1fa5b84
4
+ data.tar.gz: 35c4cba209aed5989b43606715205a7e2b85c669fa7dd71d65a33f4dd47a293a
5
5
  SHA512:
6
- metadata.gz: 3a5442c238211a0432a26e54cd7923c1782d11178679a694cb2ac60ddb3bbd02365431bf0bbba61c9f505e65d1c3b8f60303a23dbb4752813fda580bfb997a4f
7
- data.tar.gz: 955f0bb38816e24504041962e6b2a4cd9531ea89ad805c503624de927c9339383276c146dad1b346f64521f0cd3dfed985d2b5707676efdad27e998eb3441f60
6
+ metadata.gz: c959d77e7d3b8c9f5070bf2f63a74e83ba1082391694788e094c09629e76883dcf0142336af7742dd08fa46c0d36ec20ad31ad85b558ea4199cb8b7e3c4345fc
7
+ data.tar.gz: 651d88ddb211fd11234daf2ecee22e368d8886d27e43b94c1786d4d3980cedfb078a562ee0cd0ad06a48140c7fb07cae779326237e604b21a819d4ceb663d815
data/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2026-05-10
9
+
10
+ ### Added
11
+
12
+ - Tool-call streaming end-to-end. `ServerQuery#stream` now accepts `tool_ids:` and yields a `tool_calls` event when the LLM decides to invoke MCP tools. Turn 1 (tool selection) runs synchronously; turn 2 (the follow-up after tool execution) is streamed.
13
+ - Scaffold renders a separate "πŸ›  Tool calls" bubble during streaming via the new `_tool_call_message.html.erb` partial. The Stimulus controller inserts it before the streaming bubble when `event: tool_calls` arrives, and removes it once the assistant message is saved (the saved bubble's combined markdown contains the tool-call section).
14
+ - `Chat#stream_assistant_response` accepts `tool_ids:` and threads them through. Persistence is unchanged β€” the saved `Message.response` includes a markdown "Tool calls" section appended to the response text, matching the existing synchronous shape.
15
+ - When `tool_ids` is non-empty, the system prompt is augmented with an instruction to explain tool errors rather than fail silently. Models that ignore the instruction are caught by a server-side fallback (see below).
16
+
17
+ ### Changed
18
+
19
+ - Streaming error messages now parse `error` + `message` from the response body so users see context (e.g. "Rate limit exceeded β€” check your provider plan…") instead of a bare HTTP code. Mid-stream `event: error` payloads with codes like `rate_limit` get the same friendlier treatment.
20
+
21
+ ### Notes
22
+
23
+ - Requires `llm_meta_server` with the matching tool-streaming additions: `LlmRbFacade.stream!` accepting `tools:` + `on_tool_calls:` and an `Api::ChatStreamsController` that emits the `tool_calls` SSE event. Server-side fixes that ship alongside this release: an Anthropic-tool-only-response rehydrator (Claude tool-only completions weren't surfacing through `Session#functions`), and a sink injection that emits MCP `isError: true` payloads as text deltas before turn 2 (Gemini sometimes returns nothing after a tool error and would otherwise leave the bubble blank).
24
+
25
+ ## [1.2.0] - 2026-05-10
26
+
27
+ ### Added
28
+
29
+ - End-to-end SSE streaming for chat completions:
30
+ - `ServerQuery#stream` consumes SSE from the new `chat_streams` endpoint on `llm_meta_server` and yields parsed events. Returns the assembled content; absorbs upstream `done` markers and raises `ServerError` on upstream `error` events.
31
+ - `Chat#stream_assistant_response` and `Chat#finalize_streamed_response` for streaming generation with persistence at stream close (assistant message saved only on success β€” disconnects mid-stream don't persist).
32
+ - Scaffold now generates `ChatStreamsController`, `_streaming_message.html.erb` partial, `message_stream_controller.js` Stimulus controller, and a shared `_chat_sidebar.html.erb` partial. Routes add `resource :stream` nested under `chats`.
33
+ - Streaming bubble swaps to the host-rendered `_message` partial on save, so any markdown / syntax-highlighting customization in the host's `_message.html.erb` applies post-stream.
34
+ - `event: title` SSE event includes a turbo_stream snippet that updates the chat-sidebar in place when a brand-new chat gets its auto-generated title.
35
+
36
+ ### Changed
37
+
38
+ - `ServerQuery` error messages parse the JSON response body from `llm_meta_server` and surface friendlier text (rate limits, auth errors, upstream unavailable) instead of bare `HTTP <code>`.
39
+ - Streaming endpoint v1 does not pass `tool_ids`. The synchronous `#call` path is unchanged and still supports tool calls.
40
+
41
+ ### Notes
42
+
43
+ - The streaming endpoint requires `llm_meta_server` with the matching `chat_streams` route. SSE delivery through reverse proxies needs `proxy_buffering off` (nginx) or `flushpackets=on` + `SetEnv no-gzip 1` (Apache).
44
+
45
+ ## [1.1.1] - 2026-04-22
46
+
47
+ ### Added
48
+
49
+ - `ServerQuery#call` now surfaces tool calls from the LLM server response. When the response includes a `tool_calls` array, a markdown-formatted "Tool calls" section (name + JSON args) is appended to the returned content (separated by a horizontal rule). This lets host apps display which tools the LLM invoked without any schema or view changes; existing markdown renderers pick it up automatically. Previously, tool calls were silently dropped.
50
+
51
+ ## [1.1.0] - 2026-04-22
52
+
53
+ ### Changed
54
+
55
+ - Widen `prompt_navigator` dependency constraint from `~> 1.0` to `>= 1.0, < 3.0` so host apps can opt into `prompt_navigator` 2.0 (which requires Ruby 3.4.9+ and adds `PromptExecution.delete_set!`). Existing hosts on `prompt_navigator` 1.x keep resolving unchanged.
56
+
8
57
  ## [1.0.2] - 2026-03-27
9
58
 
10
59
  ### Added
@@ -19,6 +19,7 @@ module LlmMetaClient
19
19
 
20
20
  def create_controllers
21
21
  template "app/controllers/chats_controller.rb"
22
+ template "app/controllers/chat_streams_controller.rb"
22
23
  template "app/controllers/prompts_controller.rb"
23
24
  template "app/controllers/api/mcp_servers_controller.rb"
24
25
  end
@@ -29,6 +30,9 @@ module LlmMetaClient
29
30
  template "app/views/chats/create.turbo_stream.erb"
30
31
  template "app/views/chats/update.turbo_stream.erb"
31
32
  template "app/views/chats/_message.html.erb"
33
+ template "app/views/chats/_streaming_message.html.erb"
34
+ template "app/views/chats/_tool_call_message.html.erb"
35
+ template "app/views/chats/_chat_sidebar.html.erb"
32
36
  template "app/views/chats/_messages_list.html.erb"
33
37
  template "app/views/shared/_family_field.html.erb"
34
38
  template "app/views/shared/_api_key_field.html.erb"
@@ -46,6 +50,7 @@ module LlmMetaClient
46
50
  template "app/javascript/controllers/chat_title_edit_controller.js"
47
51
  template "app/javascript/controllers/tool_selector_controller.js"
48
52
  template "app/javascript/controllers/generation_settings_controller.js"
53
+ template "app/javascript/controllers/message_stream_controller.js"
49
54
  copy_file "app/javascript/popover.js"
50
55
  end
51
56
 
@@ -73,6 +78,7 @@ module LlmMetaClient
73
78
  patch :update_title
74
79
  get :download_csv
75
80
  end
81
+ resource :stream, only: [ :show ], controller: "chat_streams"
76
82
  end
77
83
  resources :prompts, only: [ :show ]
78
84
 
@@ -0,0 +1,101 @@
1
+ class ChatStreamsController < ApplicationController
2
+ include ActionController::Live
3
+
4
+ skip_before_action :authenticate_user!, raise: false
5
+ skip_before_action :verify_authenticity_token
6
+
7
+ def show
8
+ response.headers["Content-Type"] = "text/event-stream"
9
+ response.headers["Cache-Control"] = "no-cache"
10
+ response.headers["X-Accel-Buffering"] = "no"
11
+
12
+ chat = find_chat
13
+ prompt_execution = PromptNavigator::PromptExecution.find_by!(execution_id: params[:execution_id])
14
+ unless chat.messages.exists?(prompt_navigator_prompt_execution_id: prompt_execution.id)
15
+ raise ActiveRecord::RecordNotFound
16
+ end
17
+
18
+ jwt_token = current_user.id_token if user_signed_in?
19
+ generation_settings = parse_generation_settings(params[:generation_settings_json])
20
+ tool_ids = Array(params[:tool_ids]).reject(&:blank?)
21
+
22
+ assembled = chat.stream_assistant_response(prompt_execution, jwt_token, tool_ids: tool_ids, generation_settings: generation_settings) do |event|
23
+ if event[:event] == "tool_calls"
24
+ tool_calls = event[:data]["tool_calls"] || []
25
+ forward(event: "tool_calls", data: {
26
+ tool_calls: tool_calls,
27
+ html: view_context.render(partial: "chats/tool_call_message", locals: { tool_calls: tool_calls })
28
+ })
29
+ else
30
+ forward(event)
31
+ end
32
+ end
33
+
34
+ if assembled.present?
35
+ assistant_message = chat.finalize_streamed_response(prompt_execution, assembled, jwt_token)
36
+ if assistant_message
37
+ forward(event: "saved", data: {
38
+ message_id: assistant_message.id,
39
+ execution_id: prompt_execution.execution_id,
40
+ html: view_context.render(partial: "chats/message", locals: { message: assistant_message })
41
+ })
42
+ end
43
+
44
+ title_before = chat.title
45
+ chat.generate_title(prompt_execution.prompt, jwt_token)
46
+ if chat.reload.title.present? && chat.title != title_before
47
+ forward(event: "title", data: {
48
+ title: chat.title,
49
+ chat_uuid: chat.uuid,
50
+ turbo_stream: render_sidebar_update(chat)
51
+ })
52
+ end
53
+ end
54
+
55
+ forward(event: "done", data: {})
56
+ rescue ActionController::Live::ClientDisconnected
57
+ Rails.logger.info "[ChatStream] client disconnected"
58
+ rescue ActiveRecord::RecordNotFound
59
+ forward(event: "error", data: { code: "not_found", message: "Chat or prompt execution not found" }) rescue nil
60
+ rescue StandardError => e
61
+ Rails.logger.error "[ChatStream] #{e.class}: #{e.message}"
62
+ forward(event: "error", data: { code: e.class.name, message: e.message }) rescue nil
63
+ ensure
64
+ response.stream.close
65
+ end
66
+
67
+ private
68
+
69
+ def find_chat
70
+ scope = user_signed_in? ? current_user.chats : Chat.where(user_id: nil)
71
+ scope.find_by!(uuid: params[:chat_id])
72
+ end
73
+
74
+ def forward(event)
75
+ name = event[:event]
76
+ payload = event[:data].to_json
77
+ if name.nil? || name == "message"
78
+ response.stream.write "data: #{payload}\n\n"
79
+ else
80
+ response.stream.write "event: #{name}\ndata: #{payload}\n\n"
81
+ end
82
+ end
83
+
84
+ def parse_generation_settings(raw)
85
+ return {} if raw.blank?
86
+ parsed = JSON.parse(raw)
87
+ parsed.is_a?(Hash) ? parsed.symbolize_keys : {}
88
+ rescue JSON::ParserError
89
+ {}
90
+ end
91
+
92
+ def render_sidebar_update(chat)
93
+ initialize_chat(user_signed_in? ? current_user.chats : nil)
94
+ add_chat(chat)
95
+ view_context.turbo_stream.replace(
96
+ "chat-sidebar",
97
+ partial: "chats/chat_sidebar",
98
+ locals: { chat: chat }
99
+ ).to_s
100
+ end
101
+ end
@@ -70,9 +70,10 @@ class ChatsController < ApplicationController
70
70
  initialize_history @chat&.ordered_by_descending_prompt_executions
71
71
 
72
72
  if params[:message].present?
73
- # Validate generation settings before proceeding
73
+ # Validate generation settings before proceeding (raises if invalid).
74
+ # The streaming controller re-parses them from the URL.
74
75
  begin
75
- generation_settings = generation_settings_param
76
+ generation_settings_param
76
77
  rescue InvalidGenerationSettingsError => e
77
78
  @error_message = e.message
78
79
  respond_to do |format|
@@ -92,15 +93,11 @@ class ChatsController < ApplicationController
92
93
  # Set active message UUID for highlighting in UI
93
94
  set_active_message_uuid(@prompt_execution&.execution_id || params.dig(:chat, :branch_from_uuid))
94
95
 
95
- # Send to LLM and get assistant response
96
- begin
97
- @assistant_message = @chat.add_assistant_response(@prompt_execution, jwt_token, tool_ids: tool_ids_param, generation_settings: generation_settings)
98
- # Generate chat title from the user's prompt (only if title is not yet set)
99
- @chat.generate_title(params[:message], jwt_token)
100
- rescue StandardError => e
101
- Rails.logger.error "Error in chat response: #{e.class} - #{e.message}\n#{e.backtrace&.join("\n")}"
102
- @error_message = "An error occurred while getting the response. Please try again."
103
- end
96
+ # The assistant response is streamed by ChatStreamsController (SSE).
97
+ # The streaming bubble is rendered by create.turbo_stream.erb and opens
98
+ # the EventSource on connect; persistence + title gen happen at stream close.
99
+ @generation_settings_json = params[:generation_settings_json]
100
+ @tool_ids = Array(params[:tool_ids]).reject(&:blank?)
104
101
  end
105
102
 
106
103
  # Return turbo stream to render both messages
@@ -167,9 +164,10 @@ class ChatsController < ApplicationController
167
164
  initialize_history @chat&.ordered_by_descending_prompt_executions
168
165
 
169
166
  if params[:message].present?
170
- # Validate generation settings before proceeding
167
+ # Validate generation settings before proceeding (raises if invalid).
168
+ # The streaming controller re-parses them from the URL.
171
169
  begin
172
- generation_settings = generation_settings_param
170
+ generation_settings_param
173
171
  rescue InvalidGenerationSettingsError => e
174
172
  @error_message = e.message
175
173
  respond_to do |format|
@@ -189,13 +187,10 @@ class ChatsController < ApplicationController
189
187
  # Set active message UUID for highlighting in UI
190
188
  set_active_message_uuid(@prompt_execution&.execution_id || params.dig(:chat, :branch_from_uuid))
191
189
 
192
- # Send to LLM and get assistant response
193
- begin
194
- @assistant_message = @chat.add_assistant_response(@prompt_execution, jwt_token, tool_ids: tool_ids_param, generation_settings: generation_settings)
195
- rescue StandardError => e
196
- Rails.logger.error "Error in chat response: #{e.class} - #{e.message}\n#{e.backtrace&.join("\n")}"
197
- @error_message = "An error occurred while getting the response. Please try again."
198
- end
190
+ # The assistant response is streamed by ChatStreamsController (SSE).
191
+ # See create action for details.
192
+ @generation_settings_json = params[:generation_settings_json]
193
+ @tool_ids = Array(params[:tool_ids]).reject(&:blank?)
199
194
  end
200
195
 
201
196
  # Return turbo stream to render both messages
@@ -207,10 +202,6 @@ class ChatsController < ApplicationController
207
202
 
208
203
  private
209
204
 
210
- def tool_ids_param
211
- params[:tool_ids].presence || []
212
- end
213
-
214
205
  ALLOWED_GENERATION_KEYS = %w[temperature top_k top_p max_tokens repeat_penalty].freeze
215
206
 
216
207
  class InvalidGenerationSettingsError < StandardError; end
@@ -0,0 +1,124 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="message-stream"
4
+ // Opens an EventSource on connect, appends each delta to the content target,
5
+ // closes on `done` / `error`.
6
+ export default class extends Controller {
7
+ static targets = ["content"]
8
+ static values = { url: String }
9
+
10
+ connect() {
11
+ this.completed = false
12
+ this.source = new EventSource(this.urlValue)
13
+ this.source.addEventListener("message", (e) => this.#onDelta(e))
14
+ this.source.addEventListener("done", () => this.#onDone())
15
+ this.source.addEventListener("title", (e) => this.#onTitle(e))
16
+ this.source.addEventListener("saved", (e) => this.#onSaved(e))
17
+ this.source.addEventListener("tool_calls", (e) => this.#onToolCalls(e))
18
+ this.source.addEventListener("error", (e) => this.#onError(e))
19
+ }
20
+
21
+ disconnect() {
22
+ this.#close()
23
+ }
24
+
25
+ #onDelta(event) {
26
+ let delta
27
+ try { delta = JSON.parse(event.data).delta } catch { return }
28
+ if (!delta) return
29
+ this.contentTarget.append(delta)
30
+ this.#scrollToBottom()
31
+ }
32
+
33
+ #onTitle(event) {
34
+ try {
35
+ const data = JSON.parse(event.data)
36
+ if (data.turbo_stream && window.Turbo) {
37
+ window.Turbo.renderStreamMessage(data.turbo_stream)
38
+ }
39
+ } catch {}
40
+ }
41
+
42
+ #onSaved(event) {
43
+ try {
44
+ const data = JSON.parse(event.data)
45
+ this.element.dataset.savedExecutionId = data.execution_id
46
+ if (data.html) this.#swapInRenderedMessage(data.html)
47
+ // The saved bubble's content already includes any tool calls section in
48
+ // markdown; remove the transient tool-call bubbles so reload and live look
49
+ // the same.
50
+ this.#removeTransientToolCallBubbles()
51
+ } catch {}
52
+ }
53
+
54
+ #onToolCalls(event) {
55
+ try {
56
+ const data = JSON.parse(event.data)
57
+ if (!data.html) return
58
+ const wrapper = document.createElement("template")
59
+ wrapper.innerHTML = data.html.trim()
60
+ const bubble = wrapper.content.firstElementChild
61
+ if (!bubble) return
62
+ bubble.classList.add("tool-call-streaming")
63
+ this.element.parentNode.insertBefore(bubble, this.element)
64
+ this.#scrollToBottom()
65
+ } catch {}
66
+ }
67
+
68
+ #removeTransientToolCallBubbles() {
69
+ document.querySelectorAll(".tool-call-streaming").forEach((el) => el.remove())
70
+ }
71
+
72
+ // Swap the streaming bubble's role + content with the host-rendered _message
73
+ // partial output so any markdown / syntax highlighting / partial customizations
74
+ // applied on reload also apply right after the stream finishes. We don't
75
+ // replace the whole element β€” that would disconnect this controller and
76
+ // close the EventSource before `title` / `done` arrive.
77
+ #swapInRenderedMessage(html) {
78
+ const doc = new DOMParser().parseFromString(html, "text/html")
79
+ const newBubble = doc.querySelector(".message")
80
+ if (!newBubble) return
81
+
82
+ const newRole = newBubble.querySelector(".message-role")
83
+ const oldRole = this.element.querySelector(".message-role")
84
+ if (newRole && oldRole) oldRole.innerHTML = newRole.innerHTML
85
+
86
+ const newContent = newBubble.querySelector(".message-content")
87
+ if (newContent) this.contentTarget.innerHTML = newContent.innerHTML
88
+
89
+ this.element.classList.remove("streaming")
90
+ if (newBubble.id) this.element.id = newBubble.id
91
+ }
92
+
93
+ #onDone() {
94
+ this.completed = true
95
+ this.#close()
96
+ }
97
+
98
+ #onError(event) {
99
+ // EventSource fires onerror whenever the connection closes β€” including
100
+ // immediately after a clean `event: done`. Suppress those.
101
+ if (this.completed) {
102
+ this.#close()
103
+ return
104
+ }
105
+ let message = "Stream interrupted."
106
+ try { if (event.data) message = JSON.parse(event.data).message || message } catch {}
107
+ const errEl = document.createElement("p")
108
+ errEl.className = "stream-error"
109
+ errEl.textContent = `[error] ${message}`
110
+ this.contentTarget.appendChild(errEl)
111
+ this.#close()
112
+ }
113
+
114
+ #close() {
115
+ if (this.source && this.source.readyState !== EventSource.CLOSED) {
116
+ this.source.close()
117
+ }
118
+ }
119
+
120
+ #scrollToBottom() {
121
+ const chatMessages = document.getElementById("chat-messages")
122
+ if (chatMessages) chatMessages.scrollTop = chatMessages.scrollHeight
123
+ }
124
+ }
@@ -68,6 +68,37 @@ class Chat < ApplicationRecord
68
68
  new_message
69
69
  end
70
70
 
71
+ # Stream the assistant response from the LLM. Yields each parsed SSE event.
72
+ # Returns the assembled content (with markdown "Tool calls" section appended
73
+ # if tools fired). Caller is responsible for persistence.
74
+ def stream_assistant_response(prompt_execution, jwt_token, tool_ids: [], generation_settings: {}, &block)
75
+ summarized_context, prompt = build_streaming_context(prompt_execution, jwt_token, with_tools: tool_ids.any?)
76
+ LlmMetaClient::ServerQuery.new.stream(
77
+ jwt_token,
78
+ prompt_execution.llm_uuid,
79
+ prompt_execution.model,
80
+ summarized_context,
81
+ prompt,
82
+ tool_ids: tool_ids,
83
+ generation_settings: generation_settings,
84
+ &block
85
+ )
86
+ end
87
+
88
+ # Persist the streamed assistant response. Skips persistence if content is blank.
89
+ def finalize_streamed_response(prompt_execution, content, jwt_token)
90
+ return nil if content.blank?
91
+
92
+ prompt_execution.update!(
93
+ llm_platform: resolve_llm_type(prompt_execution.llm_uuid, jwt_token),
94
+ response: content
95
+ )
96
+ messages.create!(
97
+ role: "assistant",
98
+ prompt_navigator_prompt_execution: prompt_execution
99
+ )
100
+ end
101
+
71
102
  # Get all messages in order
72
103
  def ordered_messages
73
104
  messages
@@ -115,31 +146,43 @@ class Chat < ApplicationRecord
115
146
 
116
147
  # Send messages to LLM and get response
117
148
  def send_to_llm(prompt_execution, jwt_token, tool_ids: [], generation_settings: {})
118
- llm_uuid = prompt_execution.llm_uuid
119
- model = prompt_execution.model
149
+ summarized_context, prompt = build_streaming_context(prompt_execution, jwt_token, with_tools: tool_ids.any?)
150
+ LlmMetaClient::ServerQuery.new.call(
151
+ jwt_token,
152
+ prompt_execution.llm_uuid,
153
+ prompt_execution.model,
154
+ summarized_context,
155
+ prompt,
156
+ tool_ids: tool_ids,
157
+ generation_settings: generation_settings
158
+ )
159
+ end
120
160
 
121
- # Get LLM options
161
+ # Build the (summarized_context, prompt) tuple for an LLM call.
162
+ # Shared by both the synchronous and streaming paths.
163
+ def build_streaming_context(prompt_execution, jwt_token, with_tools: false)
122
164
  llm_options = LlmMetaClient::ServerResource.available_llm_options(jwt_token)
123
-
124
- # Error if no LLM is available
125
165
  raise LlmMetaClient::Exceptions::OllamaUnavailableError, "No LLM available" if llm_options.empty?
126
166
 
127
- # Build prompt and context from direct lineage via PromptExecution
128
167
  last_msg = ordered_messages.last
129
168
  pe = last_msg.prompt_navigator_prompt_execution
130
-
131
169
  prompt = { role: last_msg.role, prompt: pe.prompt }
132
170
  context = pe.build_context(limit: Rails.configuration.summarize_conversation_count)
133
171
 
134
- if context.empty?
135
- summarized_context = "No context available."
136
- else
137
- summarized_context = LlmMetaClient::ServerQuery.new.call(jwt_token, llm_uuid, model, context, "Please summarize the context")
138
- end
139
-
172
+ summarized_context =
173
+ if context.empty?
174
+ "No context available."
175
+ else
176
+ LlmMetaClient::ServerQuery.new.call(
177
+ jwt_token, prompt_execution.llm_uuid, prompt_execution.model,
178
+ context, "Please summarize the context"
179
+ )
180
+ end
140
181
  summarized_context += "Additional prompt: Responses from the assistant must consist solely of the response body."
182
+ if with_tools
183
+ summarized_context += " If a tool call returns an error, do not give up silently β€” explain the error and what likely caused it (e.g. an invalid argument value)."
184
+ end
141
185
 
142
- # Send chat request using LlmMetaClient::ServerQuery
143
- LlmMetaClient::ServerQuery.new.call(jwt_token, llm_uuid, model, summarized_context, prompt, tool_ids: tool_ids, generation_settings: generation_settings)
186
+ [ summarized_context, prompt ]
144
187
  end
145
188
  end
@@ -0,0 +1,8 @@
1
+ <div id="chat-sidebar">
2
+ <%%= chat_list(
3
+ ->(id) { chat_path(id) },
4
+ active_uuid: chat&.uuid,
5
+ download_csv_path: ->(id) { download_csv_chat_path(id) },
6
+ download_all_csv_path: download_all_csv_chats_path
7
+ ) %>
8
+ </div>
@@ -0,0 +1,12 @@
1
+ <%% stream_url = chat_stream_path(
2
+ chat_id: chat.uuid,
3
+ execution_id: prompt_execution.execution_id,
4
+ generation_settings_json: @generation_settings_json.presence,
5
+ tool_ids: (@tool_ids.presence || nil)
6
+ ) %>
7
+ <div class="message assistant streaming"
8
+ data-controller="message-stream"
9
+ data-message-stream-url-value="<%%= stream_url %>">
10
+ <div class="message-role">πŸ€– streaming…</div>
11
+ <div class="message-content" data-message-stream-target="content"></div>
12
+ </div>
@@ -0,0 +1,22 @@
1
+ <div class="message assistant tool-call">
2
+ <div class="message-role">πŸ›  Tool calls</div>
3
+ <div class="message-content">
4
+ <ul>
5
+ <%% tool_calls.each do |tc| %>
6
+ <%% name = tc["name"] || tc[:name] || "(unknown)" %>
7
+ <%% args = tc["arguments"] || tc[:arguments] %>
8
+ <%% args_str = case args
9
+ when Hash, Array then args.to_json
10
+ when nil, "" then nil
11
+ else args.to_s
12
+ end %>
13
+ <li>
14
+ <code><%%= name %></code>
15
+ <%% if args_str %>
16
+ β€” <code><%%= args_str %></code>
17
+ <%% end %>
18
+ </li>
19
+ <%% end %>
20
+ </ul>
21
+ </div>
22
+ </div>
@@ -5,10 +5,10 @@
5
5
  <%% # User message is already shown by JavaScript on form submit %>
6
6
  <%% # Only render assistant message here %>
7
7
 
8
- <%% # Render assistant message if available %>
9
- <%% if @assistant_message %>
8
+ <%%# Render streaming assistant placeholder; the message-stream Stimulus controller opens an EventSource and appends deltas as they arrive. %>
9
+ <%% if @prompt_execution && @error_message.blank? %>
10
10
  <%%= turbo_stream.append "messages-list" do %>
11
- <%%= render partial: "chats/message", locals: { message: @assistant_message } %>
11
+ <%%= render partial: "chats/streaming_message", locals: { chat: @chat, prompt_execution: @prompt_execution } %>
12
12
  <%% end %>
13
13
  <%% end %>
14
14
 
@@ -25,9 +25,7 @@
25
25
 
26
26
  <%% # Update chat sidebar %>
27
27
  <%%= turbo_stream.replace "chat-sidebar" do %>
28
- <div id="chat-sidebar">
29
- <%%= chat_list(->(id) { chat_path(id) }, active_uuid: @chat&.uuid, download_csv_path: ->(id) { download_csv_chat_path(id) }, download_all_csv_path: download_all_csv_chats_path) %>
30
- </div>
28
+ <%%= render partial: "chats/chat_sidebar", locals: { chat: @chat } %>
31
29
  <%% end %>
32
30
 
33
31
  <%% # Update history sidebar - replace entire content to ensure update %>
@@ -5,10 +5,10 @@
5
5
  <%% # User message is already shown by JavaScript on form submit %>
6
6
  <%% # Only render assistant message here %>
7
7
 
8
- <%% # Render assistant message if available %>
9
- <%% if @assistant_message %>
8
+ <%%# Render streaming assistant placeholder; the message-stream Stimulus controller opens an EventSource and appends deltas as they arrive. %>
9
+ <%% if @prompt_execution && @error_message.blank? %>
10
10
  <%%= turbo_stream.append "messages-list" do %>
11
- <%%= render partial: "chats/message", locals: { message: @assistant_message } %>
11
+ <%%= render partial: "chats/streaming_message", locals: { chat: @chat, prompt_execution: @prompt_execution } %>
12
12
  <%% end %>
13
13
  <%% end %>
14
14
 
@@ -1,5 +1,47 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
1
5
  module LlmMetaClient
2
6
  class ServerQuery
7
+ # Stream LLM responses incrementally. Yields each content delta event
8
+ # ({ event: "message", data: { "delta" => "..." } }) and any tool_calls
9
+ # event ({ event: "tool_calls", data: { "tool_calls" => [...] } }) to the
10
+ # caller's block. Upstream "done" markers are absorbed (end-of-stream is
11
+ # signaled by the block returning); upstream "error" events raise ServerError.
12
+ # Returns the final assistant content. If tool calls fired, the returned
13
+ # string mirrors the synchronous #call format (response + markdown
14
+ # "Tool calls" section appended) so persistence stays consistent.
15
+ def stream(id_token, api_key_uuid, model_id, context, user_content, tool_ids: [], generation_settings: {})
16
+ context_and_user_content = "Context:#{context}, User Prompt: #{user_content}"
17
+ debug_log "Streaming request to LLM: \n===>\n#{context_and_user_content}\n===>"
18
+
19
+ body = { prompt: context_and_user_content }
20
+ body[:tool_ids] = tool_ids if tool_ids.present?
21
+ body[:generation_settings] = generation_settings if generation_settings.present?
22
+
23
+ assembled = +""
24
+ collected_tool_calls = []
25
+ request_stream(api_key_uuid, id_token, model_id, body) do |event|
26
+ case event[:event]
27
+ when "message"
28
+ assembled << event[:data]["delta"].to_s
29
+ yield event if block_given?
30
+ when "tool_calls"
31
+ collected_tool_calls = event[:data]["tool_calls"] || []
32
+ yield event if block_given?
33
+ when "done"
34
+ # End-of-stream marker from upstream; no-op here.
35
+ when "error"
36
+ raise Exceptions::ServerError, format_stream_error(event[:data])
37
+ else
38
+ yield event if block_given?
39
+ end
40
+ end
41
+
42
+ collected_tool_calls.any? ? combine_with_tool_calls(assembled, collected_tool_calls) : assembled
43
+ end
44
+
3
45
  def call(id_token, api_key_uuid, model_id, context, user_content, tool_ids: [], generation_settings: {})
4
46
  debug_log "Context: #{context}"
5
47
  context_and_user_content = "Context:#{context}, User Prompt: #{user_content}"
@@ -7,13 +49,17 @@ module LlmMetaClient
7
49
 
8
50
  response = request(api_key_uuid, id_token, model_id, context_and_user_content, tool_ids, generation_settings)
9
51
 
10
- raise Exceptions::ServerError, "LLM server returned HTTP #{response.code}" unless response.success?
52
+ unless response.success?
53
+ raise Exceptions::ServerError, build_error_message(response.code.to_i, response.parsed_response)
54
+ end
11
55
 
12
56
  response_body = response.parsed_response
13
57
 
14
58
  raise Exceptions::InvalidResponseError, "LLM server returned non-JSON response" unless response_body.is_a?(Hash)
15
59
 
16
60
  content = response_body.dig("response", "message") || ""
61
+ tool_calls = response_body.dig("response", "tool_calls")
62
+ content = combine_with_tool_calls(content, tool_calls) if tool_calls.is_a?(Array) && tool_calls.any?
17
63
 
18
64
  raise Exceptions::EmptyResponseError, "LLM server returned empty response" if content.blank?
19
65
 
@@ -28,6 +74,28 @@ module LlmMetaClient
28
74
  Rails.logger.info(message) if Rails.env.development?
29
75
  end
30
76
 
77
+ def combine_with_tool_calls(message, tool_calls)
78
+ tool_section = format_tool_calls(tool_calls)
79
+ return tool_section if message.blank?
80
+ "#{message}\n\n---\n\n#{tool_section}"
81
+ end
82
+
83
+ def format_tool_calls(tool_calls)
84
+ lines = [ "**Tool calls**", "" ]
85
+ tool_calls.each do |tc|
86
+ name = tc["name"] || tc[:name] || "(unknown)"
87
+ args = tc["arguments"] || tc[:arguments]
88
+ args_str =
89
+ case args
90
+ when Hash, Array then args.to_json
91
+ when nil then ""
92
+ else args.to_s
93
+ end
94
+ lines << (args_str.empty? ? "- `#{name}`" : "- `#{name}` β€” `#{args_str}`")
95
+ end
96
+ lines.join("\n")
97
+ end
98
+
31
99
  def request(api_key_uuid, id_token, model_id, user_content, tool_ids, generation_settings)
32
100
  headers = { "Content-Type" => "application/json" }
33
101
  headers["Authorization"] = "Bearer #{id_token}" if id_token.present?
@@ -47,5 +115,93 @@ module LlmMetaClient
47
115
  def url(api_key_uuid, model_id)
48
116
  "#{Rails.application.config.llm_service_base_url}/api/llm_api_keys/#{api_key_uuid}/models/#{model_id}/chats"
49
117
  end
118
+
119
+ def stream_url(api_key_uuid, model_id)
120
+ "#{Rails.application.config.llm_service_base_url}/api/llm_api_keys/#{api_key_uuid}/models/#{model_id}/chat_streams"
121
+ end
122
+
123
+ def request_stream(api_key_uuid, id_token, model_id, body)
124
+ uri = URI(stream_url(api_key_uuid, model_id))
125
+
126
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 600) do |http|
127
+ req = Net::HTTP::Post.new(uri)
128
+ req["Content-Type"] = "application/json"
129
+ req["Accept"] = "text/event-stream"
130
+ req["Authorization"] = "Bearer #{id_token}" if id_token.present?
131
+ req.body = body.to_json
132
+
133
+ http.request(req) do |response|
134
+ unless response.is_a?(Net::HTTPSuccess)
135
+ body = JSON.parse(response.read_body.to_s) rescue nil
136
+ raise Exceptions::ServerError, build_error_message(response.code.to_i, body)
137
+ end
138
+
139
+ buffer = +""
140
+ response.read_body do |chunk|
141
+ buffer << chunk
142
+ while (boundary = buffer.index("\n\n"))
143
+ raw_event = buffer.slice!(0, boundary + 2)
144
+ parsed = parse_sse_event(raw_event)
145
+ yield parsed if parsed
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ # Format an `event: error` SSE payload from llm_meta_server into a
153
+ # user-facing string. Payload shape: { "code" => "rate_limit", "message" => "..." }
154
+ def format_stream_error(data)
155
+ code = data["code"]
156
+ message = data["message"]
157
+ case code
158
+ when "rate_limit"
159
+ suffix = message.present? ? ": #{message}" : ""
160
+ "Rate limit exceeded β€” check your provider plan or retry shortly#{suffix}"
161
+ when "api_key_required"
162
+ message.presence || "API key required for this model"
163
+ else
164
+ message.presence || "Upstream stream error"
165
+ end
166
+ end
167
+
168
+ # Turn a non-success HTTP response from llm_meta_server into a user-facing
169
+ # error string. The server returns JSON like
170
+ # { "error" => "LLM API Rate limit exceeded", "message" => "Too many requests" }
171
+ # for known error classes; fall back to a generic message otherwise.
172
+ def build_error_message(status_code, body)
173
+ if body.is_a?(Hash)
174
+ err = body["error"]
175
+ msg = body["message"]
176
+ return "#{err}: #{msg}" if err.present? && msg.present?
177
+ return err if err.present?
178
+ return msg if msg.present?
179
+ end
180
+ case status_code
181
+ when 429 then "Rate limit exceeded β€” check your provider plan or retry shortly (HTTP 429)"
182
+ when 401, 403 then "LLM service rejected the request (HTTP #{status_code}) β€” check your API key"
183
+ when 502, 503, 504 then "LLM service is unavailable (HTTP #{status_code})"
184
+ else "LLM server returned HTTP #{status_code}"
185
+ end
186
+ end
187
+
188
+ def parse_sse_event(raw)
189
+ event_name = "message"
190
+ data_lines = []
191
+ raw.each_line(chomp: true) do |line|
192
+ next if line.empty?
193
+ if line.start_with?("event:")
194
+ event_name = line.sub(/^event:\s*/, "")
195
+ elsif line.start_with?("data:")
196
+ data_lines << line.sub(/^data:\s*/, "")
197
+ end
198
+ end
199
+ return nil if data_lines.empty?
200
+
201
+ data = JSON.parse(data_lines.join("\n"))
202
+ { event: event_name, data: data }
203
+ rescue JSON::ParserError
204
+ nil
205
+ end
50
206
  end
51
207
  end
@@ -1,3 +1,3 @@
1
1
  module LlmMetaClient
2
- VERSION = "1.0.2"
2
+ VERSION = "1.3.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_meta_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dhq_boiler
@@ -47,16 +47,22 @@ dependencies:
47
47
  name: prompt_navigator
48
48
  requirement: !ruby/object:Gem::Requirement
49
49
  requirements:
50
- - - "~>"
50
+ - - ">="
51
51
  - !ruby/object:Gem::Version
52
52
  version: '1.0'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '3.0'
53
56
  type: :runtime
54
57
  prerelease: false
55
58
  version_requirements: !ruby/object:Gem::Requirement
56
59
  requirements:
57
- - - "~>"
60
+ - - ">="
58
61
  - !ruby/object:Gem::Version
59
62
  version: '1.0'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '3.0'
60
66
  - !ruby/object:Gem::Dependency
61
67
  name: chat_manager
62
68
  requirement: !ruby/object:Gem::Requirement
@@ -103,18 +109,23 @@ files:
103
109
  - lib/generators/llm_meta_client/authentication/templates/db/migrate/create_users.rb
104
110
  - lib/generators/llm_meta_client/scaffold/scaffold_generator.rb
105
111
  - lib/generators/llm_meta_client/scaffold/templates/app/controllers/api/mcp_servers_controller.rb
112
+ - lib/generators/llm_meta_client/scaffold/templates/app/controllers/chat_streams_controller.rb
106
113
  - lib/generators/llm_meta_client/scaffold/templates/app/controllers/chats_controller.rb
107
114
  - lib/generators/llm_meta_client/scaffold/templates/app/controllers/prompts_controller.rb
108
115
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_title_edit_controller.js
109
116
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chats_form_controller.js
110
117
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/generation_settings_controller.js
111
118
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/llm_selector_controller.js
119
+ - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/message_stream_controller.js
112
120
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/tool_selector_controller.js
113
121
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/popover.js
114
122
  - lib/generators/llm_meta_client/scaffold/templates/app/models/chat.rb
115
123
  - lib/generators/llm_meta_client/scaffold/templates/app/models/message.rb
124
+ - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_chat_sidebar.html.erb
116
125
  - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_message.html.erb
117
126
  - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_messages_list.html.erb
127
+ - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_streaming_message.html.erb
128
+ - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_tool_call_message.html.erb
118
129
  - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/create.turbo_stream.erb
119
130
  - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/edit.html.erb
120
131
  - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/new.html.erb
@@ -163,7 +174,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
163
174
  - !ruby/object:Gem::Version
164
175
  version: '0'
165
176
  requirements: []
166
- rubygems_version: 4.0.3
177
+ rubygems_version: 3.6.9
167
178
  specification_version: 4
168
179
  summary: A Rails Engine for integrating multiple LLM providers into your application.
169
180
  test_files: []