collavre_openclaw 0.2.0 → 0.2.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 +4 -4
- data/app/services/collavre_openclaw/ai_client_extension.rb +3 -4
- data/app/services/collavre_openclaw/connection_manager.rb +192 -0
- data/app/services/collavre_openclaw/em_reactor.rb +52 -0
- data/app/services/collavre_openclaw/openclaw_adapter.rb +240 -162
- data/app/services/collavre_openclaw/proactive_message_handler.rb +220 -0
- data/app/services/collavre_openclaw/websocket_client.rb +554 -0
- data/lib/collavre_openclaw/configuration.rb +16 -0
- data/lib/collavre_openclaw/engine.rb +14 -0
- data/lib/collavre_openclaw/errors.rb +6 -0
- data/lib/collavre_openclaw/version.rb +1 -1
- data/lib/collavre_openclaw.rb +1 -0
- metadata +34 -1
|
@@ -4,7 +4,10 @@ require "json"
|
|
|
4
4
|
module CollavreOpenclaw
|
|
5
5
|
class OpenclawAdapter
|
|
6
6
|
# Adapter for OpenClaw AI Gateway
|
|
7
|
-
#
|
|
7
|
+
#
|
|
8
|
+
# Supports two transport modes:
|
|
9
|
+
# 1. WebSocket (primary) - via faye-websocket + EventMachine
|
|
10
|
+
# 2. HTTP (fallback) - via Faraday POST /v1/chat/completions
|
|
8
11
|
#
|
|
9
12
|
# Session mapping:
|
|
10
13
|
# Collavre Topic → OpenClaw Session (1:1)
|
|
@@ -17,37 +20,29 @@ module CollavreOpenclaw
|
|
|
17
20
|
@context = context
|
|
18
21
|
end
|
|
19
22
|
|
|
20
|
-
def chat(messages,
|
|
23
|
+
def chat(messages, &block)
|
|
21
24
|
unless @user&.gateway_url.present?
|
|
22
25
|
Rails.logger.error("[CollavreOpenclaw] No Gateway URL configured for user #{@user&.id}")
|
|
23
26
|
yield "Error: OpenClaw Gateway URL not configured" if block_given?
|
|
24
27
|
return nil
|
|
25
28
|
end
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Rails.logger.info("[CollavreOpenclaw] Sending request to #{api_endpoint} (session: #{session_key})")
|
|
34
|
-
|
|
35
|
-
# Make streaming request to OpenClaw
|
|
36
|
-
stream_response(payload) do |chunk|
|
|
37
|
-
response_content << chunk
|
|
38
|
-
yield chunk if block_given?
|
|
39
|
-
end
|
|
30
|
+
unless @user&.llm_api_key.present?
|
|
31
|
+
Rails.logger.error("[CollavreOpenclaw] No API key configured for user #{@user&.id}")
|
|
32
|
+
yield "Error: OpenClaw API key not configured or decryption failed. " \
|
|
33
|
+
"Please re-enter the API key in AI agent settings." if block_given?
|
|
34
|
+
return nil
|
|
35
|
+
end
|
|
40
36
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
nil
|
|
37
|
+
# Try WebSocket first, fall back to HTTP
|
|
38
|
+
if websocket_available?
|
|
39
|
+
chat_via_websocket(messages, &block)
|
|
40
|
+
else
|
|
41
|
+
chat_via_http(messages, &block)
|
|
47
42
|
end
|
|
48
43
|
end
|
|
49
44
|
|
|
50
|
-
# Get the callback URL for this user
|
|
45
|
+
# Get the callback URL for this user (kept for backward compatibility)
|
|
51
46
|
def callback_url
|
|
52
47
|
return nil unless @user
|
|
53
48
|
|
|
@@ -70,13 +65,172 @@ module CollavreOpenclaw
|
|
|
70
65
|
|
|
71
66
|
private
|
|
72
67
|
|
|
68
|
+
# ─────────────────────────────────────────────
|
|
69
|
+
# WebSocket transport
|
|
70
|
+
# ─────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def websocket_available?
|
|
73
|
+
# Resolve via the module namespace to trigger Rails autoloading.
|
|
74
|
+
# `defined?` alone does not autoload in dev/test (lazy mode).
|
|
75
|
+
CollavreOpenclaw::ConnectionManager &&
|
|
76
|
+
CollavreOpenclaw::WebsocketClient &&
|
|
77
|
+
true
|
|
78
|
+
rescue NameError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def chat_via_websocket(messages, &block)
|
|
83
|
+
response_content = +""
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
client = ConnectionManager.instance.connection_for(@user)
|
|
87
|
+
message_text = format_message_for_ws(messages)
|
|
88
|
+
|
|
89
|
+
Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key})")
|
|
90
|
+
|
|
91
|
+
client.chat_send(
|
|
92
|
+
session_key: session_key,
|
|
93
|
+
message: message_text
|
|
94
|
+
) do |event|
|
|
95
|
+
case event[:state]
|
|
96
|
+
when "delta"
|
|
97
|
+
if event[:text].present?
|
|
98
|
+
response_content << event[:text]
|
|
99
|
+
yield event[:text] if block_given?
|
|
100
|
+
end
|
|
101
|
+
when "final"
|
|
102
|
+
# If no deltas were streamed, final contains the full text
|
|
103
|
+
if response_content.blank? && event[:text].present?
|
|
104
|
+
response_content << event[:text]
|
|
105
|
+
yield event[:text] if block_given?
|
|
106
|
+
end
|
|
107
|
+
when "error"
|
|
108
|
+
error_msg = event[:text] || "Unknown error"
|
|
109
|
+
yield "OpenClaw Error: #{error_msg}" if block_given?
|
|
110
|
+
when "aborted"
|
|
111
|
+
# User or system aborted
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
response_content.presence
|
|
116
|
+
rescue CollavreOpenclaw::ConnectionError,
|
|
117
|
+
CollavreOpenclaw::TimeoutError => e
|
|
118
|
+
Rails.logger.warn("[CollavreOpenclaw] WebSocket failed, falling back to HTTP: #{e.message}")
|
|
119
|
+
chat_via_http(messages, &block)
|
|
120
|
+
rescue CollavreOpenclaw::ChatError, CollavreOpenclaw::RpcError => e
|
|
121
|
+
Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
|
|
122
|
+
error_msg = "OpenClaw Error: #{e.message}"
|
|
123
|
+
yield error_msg if block_given?
|
|
124
|
+
nil
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
|
|
127
|
+
"#{e.backtrace.first(5).join("\n")}")
|
|
128
|
+
Rails.logger.info("[CollavreOpenclaw] Falling back to HTTP")
|
|
129
|
+
chat_via_http(messages, &block)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Format messages for WebSocket chat.send (single message string).
|
|
134
|
+
# Gateway manages session history, so we only send the latest user message
|
|
135
|
+
# with optional context prefix on the FIRST message only.
|
|
136
|
+
def format_message_for_ws(messages)
|
|
137
|
+
formatted = Array(messages)
|
|
138
|
+
|
|
139
|
+
# Extract the last user message
|
|
140
|
+
last_user = formatted.reverse.find do |m|
|
|
141
|
+
role = m[:role] || m["role"]
|
|
142
|
+
role.to_s == "user"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return "" unless last_user
|
|
146
|
+
|
|
147
|
+
text = extract_message_text(last_user)
|
|
148
|
+
|
|
149
|
+
# Only prepend creative context on the first message in a session.
|
|
150
|
+
# If there are prior assistant replies, the Gateway already has context.
|
|
151
|
+
if first_message_in_session?(formatted)
|
|
152
|
+
context_prefix = build_context_prefix(formatted)
|
|
153
|
+
if context_prefix.present?
|
|
154
|
+
return "#{context_prefix}\n\n#{text}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
text.to_s
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Returns true when this looks like the first exchange in a session
|
|
162
|
+
# (no prior assistant messages in the conversation history).
|
|
163
|
+
def first_message_in_session?(messages)
|
|
164
|
+
messages.none? do |m|
|
|
165
|
+
role = (m[:role] || m["role"]).to_s
|
|
166
|
+
# "model" is used by some providers (e.g. Gemini) as an alias for "assistant"
|
|
167
|
+
role == "assistant" || role == "model"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Build a context prefix from system/context messages if present.
|
|
172
|
+
# This includes creative tree markdown and other context that the Gateway
|
|
173
|
+
# wouldn't have from its own agent config.
|
|
174
|
+
def build_context_prefix(messages)
|
|
175
|
+
# Find the first "user" message that looks like creative context
|
|
176
|
+
# (typically starts with "Creative:\n")
|
|
177
|
+
context_msg = messages.find do |m|
|
|
178
|
+
role = m[:role] || m["role"]
|
|
179
|
+
text = extract_message_text(m)
|
|
180
|
+
role.to_s == "user" && text&.start_with?("Creative:")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
return nil unless context_msg
|
|
184
|
+
|
|
185
|
+
extract_message_text(context_msg)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def extract_message_text(message)
|
|
189
|
+
parts = message[:parts] || message["parts"]
|
|
190
|
+
if parts
|
|
191
|
+
Array(parts).filter_map { |p| p[:text] || p["text"] }.join("\n")
|
|
192
|
+
else
|
|
193
|
+
message[:text] || message["text"] || message[:content] || message["content"]
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# ─────────────────────────────────────────────
|
|
198
|
+
# HTTP transport (fallback)
|
|
199
|
+
# ─────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def chat_via_http(messages, &block)
|
|
202
|
+
response_content = +""
|
|
203
|
+
|
|
204
|
+
begin
|
|
205
|
+
payload = build_payload(messages)
|
|
206
|
+
|
|
207
|
+
Rails.logger.info("[CollavreOpenclaw] Sending via HTTP to #{api_endpoint} (session: #{session_key})")
|
|
208
|
+
|
|
209
|
+
stream_response(payload) do |chunk|
|
|
210
|
+
response_content << chunk
|
|
211
|
+
yield chunk if block_given?
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
response_content.presence
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
Rails.logger.error("[CollavreOpenclaw] HTTP chat error: #{e.message}\n" \
|
|
217
|
+
"#{e.backtrace.first(5).join("\n")}")
|
|
218
|
+
error_msg = "OpenClaw Error: #{e.message}"
|
|
219
|
+
yield error_msg if block_given?
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
73
224
|
def api_endpoint
|
|
74
|
-
# OpenClaw uses /v1/chat/completions (OpenAI-compatible)
|
|
75
225
|
uri = URI.parse(@user.gateway_url)
|
|
76
226
|
uri.path = "/v1/chat/completions"
|
|
77
227
|
uri.to_s
|
|
78
228
|
end
|
|
79
229
|
|
|
230
|
+
# ─────────────────────────────────────────────
|
|
231
|
+
# Session key
|
|
232
|
+
# ─────────────────────────────────────────────
|
|
233
|
+
|
|
80
234
|
# Build stable session key based on Topic (not nonce)
|
|
81
235
|
# Same Topic = Same Session = Shared context between users
|
|
82
236
|
# Format: agent:<agent_id>:collavre:<user_id>:creative:<id>:topic:<id>
|
|
@@ -92,9 +246,11 @@ module CollavreOpenclaw
|
|
|
92
246
|
parts.join(":")
|
|
93
247
|
end
|
|
94
248
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
249
|
+
# ─────────────────────────────────────────────
|
|
250
|
+
# HTTP payload building
|
|
251
|
+
# ─────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
def build_payload(messages)
|
|
98
254
|
agent_id = extract_agent_id_from_email
|
|
99
255
|
model_value = agent_id.present? ? "openclaw:#{agent_id}" : "openclaw"
|
|
100
256
|
|
|
@@ -104,113 +260,21 @@ module CollavreOpenclaw
|
|
|
104
260
|
stream: true
|
|
105
261
|
}
|
|
106
262
|
|
|
107
|
-
# Add system prompt as first message if present
|
|
108
263
|
if @system_prompt.present?
|
|
109
264
|
payload[:messages].unshift({ role: "system", content: @system_prompt })
|
|
110
265
|
end
|
|
111
266
|
|
|
112
|
-
# Add tools if provided (convert to OpenAI function calling format)
|
|
113
|
-
if tools.present?
|
|
114
|
-
payload[:tools] = format_tools(tools)
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# Build user context with callback information
|
|
118
267
|
payload[:user] = build_user_context
|
|
119
|
-
|
|
120
268
|
payload
|
|
121
269
|
end
|
|
122
270
|
|
|
123
|
-
# Convert tools to OpenAI function calling format
|
|
124
|
-
# Accepts either:
|
|
125
|
-
# - Array of tool names (strings): ["meta_tool", "search"]
|
|
126
|
-
# - Array of OpenAI-format tool objects (already formatted)
|
|
127
|
-
def format_tools(tools)
|
|
128
|
-
Array(tools).filter_map do |tool|
|
|
129
|
-
if tool.is_a?(String)
|
|
130
|
-
# Tool name - fetch from MCP and convert to OpenAI format
|
|
131
|
-
convert_tool_name_to_openai_format(tool)
|
|
132
|
-
elsif tool.is_a?(Hash)
|
|
133
|
-
# Already a hash - check if it's OpenAI format or needs conversion
|
|
134
|
-
if tool[:type] == "function" || tool["type"] == "function"
|
|
135
|
-
# Already OpenAI format
|
|
136
|
-
tool
|
|
137
|
-
else
|
|
138
|
-
# MCP format - convert to OpenAI format
|
|
139
|
-
convert_mcp_tool_to_openai_format(tool)
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
end.compact
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# Convert a tool name to OpenAI function format by fetching from MCP
|
|
146
|
-
def convert_tool_name_to_openai_format(tool_name)
|
|
147
|
-
return nil unless defined?(::Tools::MetaToolService)
|
|
148
|
-
|
|
149
|
-
result = ::Tools::MetaToolService.new.call(action: "get", tool_name: tool_name, query: nil, arguments: nil)
|
|
150
|
-
return nil if result[:error] || result[:tool].nil?
|
|
151
|
-
|
|
152
|
-
convert_mcp_tool_to_openai_format(result[:tool])
|
|
153
|
-
rescue StandardError => e
|
|
154
|
-
Rails.logger.warn("[CollavreOpenclaw] Failed to fetch tool #{tool_name}: #{e.message}")
|
|
155
|
-
nil
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Convert MCP tool format to OpenAI function format
|
|
159
|
-
# MCP format: { name:, description:, params: [...], return_type: }
|
|
160
|
-
# OpenAI format: { type: "function", function: { name:, description:, parameters: { type: "object", properties:, required: } } }
|
|
161
|
-
def convert_mcp_tool_to_openai_format(mcp_tool)
|
|
162
|
-
name = mcp_tool[:name] || mcp_tool["name"]
|
|
163
|
-
description = mcp_tool[:description] || mcp_tool["description"]
|
|
164
|
-
params = mcp_tool[:params] || mcp_tool["params"] || mcp_tool[:parameters] || mcp_tool["parameters"] || []
|
|
165
|
-
|
|
166
|
-
properties = {}
|
|
167
|
-
required = []
|
|
168
|
-
|
|
169
|
-
Array(params).each do |param|
|
|
170
|
-
param_name = (param[:name] || param["name"]).to_s
|
|
171
|
-
param_type = param[:type] || param["type"] || "string"
|
|
172
|
-
param_desc = param[:description] || param["description"]
|
|
173
|
-
param_required = param[:required] || param["required"]
|
|
174
|
-
|
|
175
|
-
# Convert Ruby/MCP types to JSON Schema types
|
|
176
|
-
json_type = case param_type.to_s.downcase
|
|
177
|
-
when "integer", "int" then "integer"
|
|
178
|
-
when "number", "float", "decimal" then "number"
|
|
179
|
-
when "boolean", "bool" then "boolean"
|
|
180
|
-
when "array" then "array"
|
|
181
|
-
when "object", "hash" then "object"
|
|
182
|
-
else "string"
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
properties[param_name] = { type: json_type }
|
|
186
|
-
properties[param_name][:description] = param_desc if param_desc.present?
|
|
187
|
-
|
|
188
|
-
required << param_name if param_required
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
{
|
|
192
|
-
type: "function",
|
|
193
|
-
function: {
|
|
194
|
-
name: name,
|
|
195
|
-
description: description || "",
|
|
196
|
-
parameters: {
|
|
197
|
-
type: "object",
|
|
198
|
-
properties: properties,
|
|
199
|
-
required: required
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
end
|
|
204
|
-
|
|
205
271
|
def build_user_context
|
|
206
272
|
context_data = {}
|
|
207
273
|
|
|
208
|
-
# Extract IDs from context
|
|
209
274
|
creative_id = extract_id(@context, :creative) || @context[:creative_id]
|
|
210
275
|
comment_id = extract_id(@context, :comment) || @context[:comment_id]
|
|
211
276
|
topic_id = @context[:thread_id] || @context[:topic_id]
|
|
212
277
|
|
|
213
|
-
# Create pending callback with nonce for secure async responses
|
|
214
278
|
callback = callback_url
|
|
215
279
|
if callback.present? && creative_id.present?
|
|
216
280
|
pending = PendingCallback.create_for_request(
|
|
@@ -227,10 +291,9 @@ module CollavreOpenclaw
|
|
|
227
291
|
context_data[:comment_id] = comment_id if comment_id
|
|
228
292
|
context_data[:topic_id] = topic_id if topic_id
|
|
229
293
|
|
|
230
|
-
Rails.logger.info("[CollavreOpenclaw] Created pending callback
|
|
294
|
+
Rails.logger.info("[CollavreOpenclaw] Created pending callback nonce: #{pending.nonce[0..8]}...")
|
|
231
295
|
end
|
|
232
296
|
|
|
233
|
-
# Return as JSON string (OpenAI user field format)
|
|
234
297
|
if context_data.any?
|
|
235
298
|
"collavre:#{JSON.generate(context_data)}"
|
|
236
299
|
else
|
|
@@ -238,18 +301,11 @@ module CollavreOpenclaw
|
|
|
238
301
|
end
|
|
239
302
|
end
|
|
240
303
|
|
|
241
|
-
# Format messages with sender attribution for multi-user context
|
|
242
304
|
def format_messages(messages)
|
|
243
305
|
Array(messages).map do |msg|
|
|
244
306
|
role = msg[:role] || msg["role"]
|
|
245
|
-
|
|
246
|
-
content = if parts
|
|
247
|
-
Array(parts).map { |p| p[:text] || p["text"] }.compact.join("\n")
|
|
248
|
-
else
|
|
249
|
-
msg[:text] || msg["text"] || msg[:content] || msg["content"]
|
|
250
|
-
end
|
|
307
|
+
content = extract_message_text(msg)
|
|
251
308
|
|
|
252
|
-
# Add sender attribution for user messages (multi-user support)
|
|
253
309
|
sender_name = msg[:sender_name] || msg["sender_name"]
|
|
254
310
|
if sender_name.present? && normalize_role(role) == "user"
|
|
255
311
|
content = "[#{sender_name}]: #{content}"
|
|
@@ -267,18 +323,17 @@ module CollavreOpenclaw
|
|
|
267
323
|
end
|
|
268
324
|
end
|
|
269
325
|
|
|
326
|
+
# ─────────────────────────────────────────────
|
|
327
|
+
# HTTP streaming
|
|
328
|
+
# ─────────────────────────────────────────────
|
|
329
|
+
|
|
270
330
|
def build_headers
|
|
271
331
|
headers = {
|
|
272
332
|
"Content-Type" => "application/json",
|
|
273
333
|
"Accept" => "text/event-stream",
|
|
274
334
|
"x-openclaw-session-key" => session_key
|
|
275
335
|
}
|
|
276
|
-
|
|
277
|
-
# Add Authorization header if API key is configured
|
|
278
|
-
if @user&.llm_api_key.present?
|
|
279
|
-
headers["Authorization"] = "Bearer #{@user.llm_api_key}"
|
|
280
|
-
end
|
|
281
|
-
|
|
336
|
+
headers["Authorization"] = "Bearer #{@user.llm_api_key}" if @user&.llm_api_key.present?
|
|
282
337
|
headers
|
|
283
338
|
end
|
|
284
339
|
|
|
@@ -294,32 +349,38 @@ module CollavreOpenclaw
|
|
|
294
349
|
response = connection.post do |req|
|
|
295
350
|
req.url api_endpoint
|
|
296
351
|
request_headers.each { |k, v| req.headers[k] = v }
|
|
297
|
-
|
|
298
352
|
req.body = payload.to_json
|
|
299
353
|
|
|
300
|
-
req.options.on_data = proc do |chunk, _size,
|
|
301
|
-
|
|
302
|
-
|
|
354
|
+
req.options.on_data = proc do |chunk, _size, env|
|
|
355
|
+
if env&.status.nil? || (env.status >= 200 && env.status < 300)
|
|
356
|
+
buffer << chunk
|
|
357
|
+
process_sse_buffer(buffer, &block)
|
|
358
|
+
else
|
|
359
|
+
buffer << chunk
|
|
360
|
+
end
|
|
303
361
|
end
|
|
304
362
|
end
|
|
305
363
|
|
|
306
|
-
|
|
364
|
+
unless response.status >= 200 && response.status < 300
|
|
365
|
+
error_body = buffer.presence || response.body
|
|
366
|
+
raise parse_error_message(response.status, error_body)
|
|
367
|
+
end
|
|
368
|
+
|
|
307
369
|
process_sse_buffer(buffer, final: true, &block)
|
|
308
370
|
|
|
309
|
-
# Handle non-streaming response
|
|
310
371
|
if response.headers["content-type"]&.include?("application/json")
|
|
311
372
|
handle_json_response(response.body, &block)
|
|
312
373
|
end
|
|
313
374
|
|
|
314
375
|
response
|
|
315
|
-
rescue Faraday::TimeoutError
|
|
376
|
+
rescue Faraday::TimeoutError
|
|
316
377
|
retries += 1
|
|
317
378
|
if retries <= max_retries
|
|
318
|
-
Rails.logger.warn("[CollavreOpenclaw]
|
|
319
|
-
sleep(1 * retries)
|
|
379
|
+
Rails.logger.warn("[CollavreOpenclaw] Timed out, retrying (#{retries}/#{max_retries})...")
|
|
380
|
+
sleep(1 * retries)
|
|
320
381
|
retry
|
|
321
382
|
end
|
|
322
|
-
raise "OpenClaw request timed out after #{max_retries + 1} attempts
|
|
383
|
+
raise "OpenClaw request timed out after #{max_retries + 1} attempts"
|
|
323
384
|
rescue Faraday::ConnectionFailed => e
|
|
324
385
|
retries += 1
|
|
325
386
|
if retries <= max_retries
|
|
@@ -331,6 +392,25 @@ module CollavreOpenclaw
|
|
|
331
392
|
end
|
|
332
393
|
end
|
|
333
394
|
|
|
395
|
+
def parse_error_message(status, body)
|
|
396
|
+
detail = ""
|
|
397
|
+
begin
|
|
398
|
+
json = JSON.parse(body, symbolize_names: true) if body.present?
|
|
399
|
+
detail = json[:error] || json[:message] || json.to_s if json
|
|
400
|
+
rescue JSON::ParserError
|
|
401
|
+
detail = body.to_s.truncate(200)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
case status
|
|
405
|
+
when 401 then "Authentication failed (HTTP 401). Check your API key. #{detail}"
|
|
406
|
+
when 403 then "Access forbidden (HTTP 403). #{detail}"
|
|
407
|
+
when 404 then "Gateway endpoint not found (HTTP 404). Check your Gateway URL."
|
|
408
|
+
when 429 then "Rate limited (HTTP 429). #{detail}"
|
|
409
|
+
when 500..599 then "Gateway server error (HTTP #{status}). #{detail}"
|
|
410
|
+
else "HTTP #{status}: #{detail}"
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
334
414
|
def handle_json_response(body, &block)
|
|
335
415
|
json = JSON.parse(body, symbolize_names: true)
|
|
336
416
|
content = json.dig(:choices, 0, :message, :content)
|
|
@@ -356,23 +436,22 @@ module CollavreOpenclaw
|
|
|
356
436
|
line = line.strip
|
|
357
437
|
next if line.empty? || line.start_with?(":")
|
|
358
438
|
|
|
359
|
-
|
|
360
|
-
data = line.sub(/^data:\s*/, "")
|
|
361
|
-
next if data == "[DONE]"
|
|
439
|
+
next unless line.start_with?("data:")
|
|
362
440
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
441
|
+
data = line.sub(/^data:\s*/, "")
|
|
442
|
+
next if data == "[DONE]"
|
|
443
|
+
|
|
444
|
+
begin
|
|
445
|
+
json = JSON.parse(data, symbolize_names: true)
|
|
446
|
+
content = extract_content(json)
|
|
447
|
+
yield content if content.present?
|
|
448
|
+
rescue JSON::ParserError
|
|
449
|
+
yield data if data.present?
|
|
370
450
|
end
|
|
371
451
|
end
|
|
372
452
|
end
|
|
373
453
|
|
|
374
454
|
def extract_content(json)
|
|
375
|
-
# OpenAI streaming format
|
|
376
455
|
json.dig(:choices, 0, :delta, :content) ||
|
|
377
456
|
json.dig(:choices, 0, :message, :content) ||
|
|
378
457
|
json[:content] ||
|
|
@@ -381,12 +460,16 @@ module CollavreOpenclaw
|
|
|
381
460
|
|
|
382
461
|
def build_connection
|
|
383
462
|
Faraday.new do |builder|
|
|
384
|
-
builder.options.timeout = CollavreOpenclaw.config.read_timeout
|
|
385
|
-
builder.options.open_timeout = CollavreOpenclaw.config.open_timeout
|
|
463
|
+
builder.options.timeout = CollavreOpenclaw.config.read_timeout
|
|
464
|
+
builder.options.open_timeout = CollavreOpenclaw.config.open_timeout
|
|
386
465
|
builder.adapter Faraday.default_adapter
|
|
387
466
|
end
|
|
388
467
|
end
|
|
389
468
|
|
|
469
|
+
# ─────────────────────────────────────────────
|
|
470
|
+
# Helpers
|
|
471
|
+
# ─────────────────────────────────────────────
|
|
472
|
+
|
|
390
473
|
def extract_id(context, key)
|
|
391
474
|
value = context[key] || context[key.to_s]
|
|
392
475
|
return nil unless value
|
|
@@ -395,12 +478,8 @@ module CollavreOpenclaw
|
|
|
395
478
|
value[:id] || value["id"]
|
|
396
479
|
end
|
|
397
480
|
|
|
398
|
-
# Extract agent_id from user email
|
|
399
|
-
# e.g., "ai-agent@collavre.com" -> "ai-agent"
|
|
400
481
|
def extract_agent_id_from_email
|
|
401
482
|
return nil unless @user&.email.present?
|
|
402
|
-
|
|
403
|
-
# Extract local part (before @) from email
|
|
404
483
|
@user.email.split("@").first
|
|
405
484
|
end
|
|
406
485
|
|
|
@@ -415,7 +494,6 @@ module CollavreOpenclaw
|
|
|
415
494
|
result = { host: host }
|
|
416
495
|
result[:protocol] = options[:protocol] || "https"
|
|
417
496
|
result[:port] = options[:port] if options[:port].present?
|
|
418
|
-
|
|
419
497
|
result
|
|
420
498
|
end
|
|
421
499
|
end
|