multiwoven-integrations 0.41.5 → 0.41.6

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: 6c53fd23ba288ab3578c1c23581ceff400328eda9c010f05e1f5a60509fddf2f
4
- data.tar.gz: 88d651895e365873ed1abdd6bcaa88190edd1aadf217dbf0c61640d7d3fff85e
3
+ metadata.gz: 9997be9180829657c2c1961957ac5af3ba80f9085ec53610b2dc9426105c5324
4
+ data.tar.gz: 0f92f5434027f6b19e0f83f785f3e791da95b42e22834c66e9fbe9011543d411
5
5
  SHA512:
6
- metadata.gz: 0ff426493d73236e35bd1a989bb78816b3d330320f21b2c14fe933e289403115bf920fed3cd962099808dcd04747ab22b153690c184a5732d87b2e12dc2c9825
7
- data.tar.gz: 55aa60ff60a337f75934c9c77410161c18e96589edeca97985e514089abd03022faf399b056dc0cfee255ba1c735ed2be7ad9bf9b7ea2d9faecc3f5b882e9a84
6
+ metadata.gz: 1ad58e7cbaefab9d122e5775404b3ac7095e1b6deaaea45093f8c23f46a2d6f2f5c9a98bd73df77b1c5d5e06211ae49bd1e50c114ce322b290e40b5a5995d564
7
+ data.tar.gz: c9ae0e891adbb23830fb35342f28f84186e433477e0eb488f49b99c45f5e365b1d3953f66a333b5be24db82f133f216ed1735de1d13696d7c5a227286076c619
@@ -21,7 +21,12 @@ module Multiwoven
21
21
  end
22
22
 
23
23
  def model_catalog
24
- @model_catalog ||= ModelCatalog.new(models: (live_models + curated_models).uniq { |model| model[:id] })
24
+ @model_catalog ||= begin
25
+ models = (live_models + curated_models)
26
+ .select { |model| include_model?(model) }
27
+ .uniq { |model| model[:id] }
28
+ ModelCatalog.new(models: models)
29
+ end
25
30
  rescue Dry::Struct::Error => e
26
31
  Integrations::Service.logger.error("#{self.class}: unusable model catalog: #{e.message}")
27
32
  @model_catalog = ModelCatalog.new(models: [])
@@ -72,6 +77,21 @@ module Multiwoven
72
77
 
73
78
  private
74
79
 
80
+ # Only list what the connector can actually serve. No connector has an
81
+ # image-generation payload path, so those are dropped everywhere.
82
+ def include_model?(model)
83
+ !image_output_model?(model)
84
+ end
85
+
86
+ def image_output_model?(model)
87
+ (model[:type] || model["type"]).to_s == "image"
88
+ end
89
+
90
+ def embedding_model?(model)
91
+ [model[:type] || model["type"], model[:model_type] || model["model_type"]]
92
+ .any? { |value| value.to_s == "embedding" }
93
+ end
94
+
75
95
  def read_json(file_path)
76
96
  path = Object.const_source_location(self.class.to_s)[0]
77
97
  connector_folder = File.dirname(path)
@@ -75,6 +75,8 @@ module Multiwoven
75
75
  GOOGLE_SPREADSHEET_ID_REGEX = %r{/d/([-\w]{20,})/}.freeze
76
76
 
77
77
  OPEN_AI_URL = "https://api.openai.com/v1/chat/completions"
78
+ OPEN_AI_COMPLETIONS_URL = "https://api.openai.com/v1/completions"
79
+ OPEN_AI_RESPONSES_URL = "https://api.openai.com/v1/responses"
78
80
  ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
79
81
 
80
82
  # Bedrock Models
@@ -10,11 +10,22 @@ module Multiwoven
10
10
  http = configure_http(uri, config)
11
11
  request = build_request(method, uri, payload, headers)
12
12
  http.request(request) do |response|
13
+ raise_error_response(response) unless (200..299).cover?(response.code.to_i)
14
+
13
15
  response.read_body do |chunk|
14
16
  yield chunk if block_given? # Pass each response chunk
15
17
  end
16
18
  end
17
19
  end
20
+
21
+ private
22
+
23
+ # Without this a 4xx body is parsed as if it were an SSE stream.
24
+ def raise_error_response(response)
25
+ body = +""
26
+ response.read_body { |chunk| body << chunk }
27
+ raise "HTTP #{response.code}: #{body.strip}"
28
+ end
18
29
  end
19
30
  end
20
31
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Multiwoven
4
4
  module Integrations
5
- VERSION = "0.41.5"
5
+ VERSION = "0.41.6"
6
6
 
7
7
  ENABLED_SOURCES = %w[
8
8
  Snowflake
@@ -35,6 +35,11 @@ module Multiwoven::Integrations::Source
35
35
 
36
36
  private
37
37
 
38
+ # Every request goes to /chat, so embeddings cannot connect here.
39
+ def include_model?(model)
40
+ super && !embedding_model?(model)
41
+ end
42
+
38
43
  def lightning_embedding_url
39
44
  host = AISQUARED_BOLT_URL.to_s.strip
40
45
  raise AisquaredError, "AISQUARED_BOLT_URL is not configured" if host.empty?
@@ -67,7 +67,7 @@ module Multiwoven::Integrations::Source
67
67
  send_request(
68
68
  url: url,
69
69
  http_method: http_method,
70
- payload: JSON.parse(payload),
70
+ payload: normalize_payload(payload),
71
71
  headers: build_headers(connection_config, streaming: false),
72
72
  config: connection_config[:config]
73
73
  )
@@ -84,7 +84,7 @@ module Multiwoven::Integrations::Source
84
84
  send_streaming_request(
85
85
  url: ANTHROPIC_URL,
86
86
  http_method: HTTP_POST,
87
- payload: JSON.parse(payload),
87
+ payload: normalize_payload(payload),
88
88
  headers: build_headers(connection_config, streaming: true),
89
89
  config: connection_config[:config]
90
90
  ) do |chunk|
@@ -94,6 +94,17 @@ module Multiwoven::Integrations::Source
94
94
  handle_exception(e, { context: "ANTHROPIC:RUN_STREAM_MODEL:EXCEPTION", type: "error" })
95
95
  end
96
96
 
97
+ def normalize_payload(payload)
98
+ parsed = payload.is_a?(String) ? JSON.parse(payload) : payload.deep_dup
99
+ return parsed unless parsed.respond_to?(:deep_stringify_keys)
100
+
101
+ parsed = parsed.deep_stringify_keys
102
+ model = parsed["model"].to_s
103
+ # OpenRouter ids carry a vendor prefix, dots and a routing suffix; Anthropic ids have none.
104
+ parsed["model"] = model.split(":").first.to_s.split("/").last.to_s.tr(".", "-") if model.present?
105
+ parsed
106
+ end
107
+
97
108
  def process_response(response)
98
109
  if success?(response)
99
110
  data = JSON.parse(response.body)
@@ -6,12 +6,9 @@ module Multiwoven::Integrations::Source
6
6
  class Client < SourceConnector
7
7
  def check_connection(connection_config)
8
8
  connection_config = prepare_config(connection_config)
9
- response = send_request(
10
- url: OPEN_AI_URL,
11
- http_method: HTTP_POST,
12
- payload: JSON.parse(connection_config[:request_format]),
13
- headers: auth_headers(connection_config[:api_key]),
14
- config: connection_config[:config]
9
+ response = request_with_endpoint_fallback(
10
+ connection_config,
11
+ connection_config[:request_format]
15
12
  )
16
13
  success?(response) ? success_status : failure_status_from_response(response)
17
14
  rescue StandardError => e
@@ -60,21 +57,65 @@ module Multiwoven::Integrations::Source
60
57
  end
61
58
 
62
59
  def run_model(connection_config, payload)
63
- response = send_request(
64
- url: OPEN_AI_URL,
60
+ response = request_with_endpoint_fallback(connection_config, payload)
61
+ process_response(response)
62
+ rescue StandardError => e
63
+ handle_exception(e, { context: "OPEN AI:RUN_MODEL:EXCEPTION", type: "error" })
64
+ end
65
+
66
+ def run_model_stream(connection_config, payload)
67
+ stream_with_endpoint_fallback(connection_config, payload) do |message|
68
+ yield message if block_given?
69
+ end
70
+ rescue StandardError => e
71
+ handle_exception(e, { context: "OPEN AI:RUN_STREAM_MODEL:EXCEPTION", type: "error" })
72
+ end
73
+
74
+ # Some OpenAI models reject /v1/chat/completions and require /v1/completions or /v1/responses.
75
+ def request_with_endpoint_fallback(connection_config, payload, url: OPEN_AI_URL)
76
+ normalized = normalize_payload(payload)
77
+ response = post_openai(connection_config, adapt_payload_for_url(normalized, url), url)
78
+ return response if success?(response)
79
+
80
+ fallback_url = resolve_fallback_url(response_error_message(response))
81
+ return response if fallback_url.blank? || fallback_url == url
82
+
83
+ post_openai(connection_config, adapt_payload_for_url(normalized, fallback_url), fallback_url)
84
+ end
85
+
86
+ def stream_with_endpoint_fallback(connection_config, payload, url: OPEN_AI_URL)
87
+ normalized = normalize_payload(payload)
88
+ emitted = false
89
+
90
+ begin
91
+ post_openai_stream(connection_config, streaming_payload(normalized, url), url) do |message|
92
+ emitted = true
93
+ yield message if block_given?
94
+ end
95
+ rescue StandardError => e
96
+ fallback_url = resolve_fallback_url(e.message)
97
+ # Retrying after the consumer has seen chunks would deliver them twice.
98
+ raise if fallback_url.blank? || fallback_url == url || emitted
99
+
100
+ post_openai_stream(connection_config, streaming_payload(normalized, fallback_url), fallback_url) do |message|
101
+ yield message if block_given?
102
+ end
103
+ end
104
+ end
105
+
106
+ def post_openai(connection_config, payload, url)
107
+ send_request(
108
+ url: url,
65
109
  http_method: HTTP_POST,
66
110
  payload: payload,
67
111
  headers: auth_headers(connection_config[:api_key]),
68
112
  config: connection_config[:config]
69
113
  )
70
- process_response(response)
71
- rescue StandardError => e
72
- handle_exception(e, { context: "OPEN AI:RUN_MODEL:EXCEPTION", type: "error" })
73
114
  end
74
115
 
75
- def run_model_stream(connection_config, payload)
116
+ def post_openai_stream(connection_config, payload, url)
76
117
  send_streaming_request(
77
- url: OPEN_AI_URL,
118
+ url: url,
78
119
  http_method: HTTP_POST,
79
120
  payload: payload,
80
121
  headers: auth_headers(connection_config[:api_key]),
@@ -82,13 +123,100 @@ module Multiwoven::Integrations::Source
82
123
  ) do |chunk|
83
124
  process_streaming_response(chunk) { |message| yield message if block_given? }
84
125
  end
85
- rescue StandardError => e
86
- handle_exception(e, { context: "OPEN AI:RUN_STREAM_MODEL:EXCEPTION", type: "error" })
126
+ end
127
+
128
+ # /v1/responses renames the token cap and rejects the chat-only keys.
129
+ RESPONSES_DROP_KEYS = %w[messages max_tokens n stop response_format
130
+ frequency_penalty presence_penalty logprobs top_logprobs logit_bias].freeze
131
+ COMPLETIONS_DROP_KEYS = %w[messages response_format tools tool_choice].freeze
132
+
133
+ def resolve_fallback_url(message)
134
+ return if message.blank?
135
+
136
+ return OPEN_AI_RESPONSES_URL if message.match?(%r{v1/responses}i)
137
+ return OPEN_AI_COMPLETIONS_URL if message.match?(%r{v1/completions}i) || message.match?(/not a chat model/i)
138
+
139
+ nil
140
+ end
141
+
142
+ def response_error_message(response)
143
+ return if response.nil?
144
+
145
+ parsed = JSON.parse(response.body)
146
+ return parsed.dig("error", "message") || parsed["message"].to_s if parsed.is_a?(Hash)
147
+
148
+ parsed.to_s
149
+ rescue JSON::ParserError, TypeError
150
+ response&.body.to_s
151
+ end
152
+
153
+ def adapt_payload_for_url(payload, url)
154
+ case url
155
+ when OPEN_AI_RESPONSES_URL then responses_payload(payload)
156
+ when OPEN_AI_COMPLETIONS_URL then completions_payload(payload)
157
+ else payload
158
+ end
159
+ end
160
+
161
+ def responses_payload(payload)
162
+ payload.except(*RESPONSES_DROP_KEYS).tap do |adapted|
163
+ adapted["max_output_tokens"] ||= payload["max_tokens"] if payload["max_tokens"]
164
+ adapted["input"] ||= responses_input(payload)
165
+ end
166
+ end
167
+
168
+ def completions_payload(payload)
169
+ payload.except(*COMPLETIONS_DROP_KEYS).tap do |adapted|
170
+ adapted["prompt"] ||= extract_prompt_text(payload)
171
+ end
172
+ end
173
+
174
+ # Responses takes structured input, so roles survive instead of being flattened.
175
+ def responses_input(payload)
176
+ messages = payload["messages"] || payload[:messages]
177
+ return payload["input"] || payload["prompt"] || "" if messages.blank?
178
+
179
+ Array(messages).filter_map { |message| responses_input_message(message) }
180
+ end
181
+
182
+ def responses_input_message(message)
183
+ content = message["content"] || message[:content]
184
+ return if content.blank?
185
+
186
+ { "role" => message["role"] || message[:role] || "user", "content" => content }
187
+ end
188
+
189
+ def streaming_payload(payload, url)
190
+ adapt_payload_for_url(payload, url).merge("stream" => true)
191
+ end
192
+
193
+ def extract_prompt_text(payload)
194
+ messages = payload["messages"] || payload[:messages]
195
+ return payload["prompt"] || payload["input"] || "" if messages.blank?
196
+
197
+ Array(messages).filter_map { |message| message_text(message["content"] || message[:content]) }.join("\n")
198
+ end
199
+
200
+ def message_text(content)
201
+ return content if content.is_a?(String)
202
+ return nil if content.blank?
203
+
204
+ Array(content).filter_map { |part| part.is_a?(Hash) ? part["text"] || part[:text] : part }.join("\n").presence
205
+ end
206
+
207
+ # check_connection posts to a hardcoded chat endpoint, so embeddings cannot connect here.
208
+ def include_model?(model)
209
+ super && !embedding_model?(model)
210
+ end
211
+
212
+ def normalize_payload(payload)
213
+ parsed = payload.is_a?(String) ? JSON.parse(payload) : payload.deep_dup
214
+ parsed.respond_to?(:deep_stringify_keys) ? parsed.deep_stringify_keys : parsed
87
215
  end
88
216
 
89
217
  def process_response(response)
90
218
  if success?(response)
91
- data = JSON.parse(response.body)
219
+ data = normalize_response_data(JSON.parse(response.body))
92
220
  [RecordMessage.new(data: data, emitted_at: Time.now.to_i).to_multiwoven_message]
93
221
  else
94
222
  create_log_message("OPEN AI:RUN_MODEL", "error", "request failed: #{response.body}")
@@ -97,8 +225,80 @@ module Multiwoven::Integrations::Source
97
225
  handle_exception(e, { context: "OPEN AI:PROCESS_RESPONSE:EXCEPTION", type: "error" })
98
226
  end
99
227
 
228
+ # Completions and Responses APIs use different shapes; Model Hub catalogs and
229
+ # x-output-path expect the chat shape choices[].message.content.
230
+ def normalize_response_data(data)
231
+ return data unless data.is_a?(Hash)
232
+ return normalize_responses_api_data(data) if data["object"] == "response" || data["output"].is_a?(Array)
233
+
234
+ choices = data["choices"]
235
+ return data unless choices.is_a?(Array)
236
+
237
+ data.merge(
238
+ "choices" => choices.map { |choice| normalize_choice(choice) }
239
+ )
240
+ end
241
+
242
+ def normalize_responses_api_data(data)
243
+ return data if data.dig("choices", 0, "message", "content").present?
244
+
245
+ text = extract_responses_api_text(data["output"])
246
+ return data if text.nil?
247
+
248
+ data.merge(
249
+ "choices" => [
250
+ {
251
+ "index" => 0,
252
+ "message" => { "role" => "assistant", "content" => text },
253
+ "finish_reason" => data["status"] == "completed" ? "stop" : data["status"]
254
+ }
255
+ ]
256
+ )
257
+ end
258
+
259
+ def extract_responses_api_text(output)
260
+ return nil unless output.is_a?(Array)
261
+
262
+ texts = output.flat_map { |item| responses_api_message_texts(item) }
263
+ texts.presence&.join
264
+ end
265
+
266
+ def responses_api_message_texts(item)
267
+ return [] unless item.is_a?(Hash)
268
+ return [] unless item["type"] == "message" || item["role"] == "assistant"
269
+
270
+ Array(item["content"]).filter_map { |part| responses_api_text_part(part) }
271
+ end
272
+
273
+ def responses_api_text_part(part)
274
+ return unless part.is_a?(Hash)
275
+ return unless part["type"].nil? || part["type"].to_s.include?("text")
276
+
277
+ part["text"].presence || part["content"].presence
278
+ end
279
+
280
+ def normalize_choice(choice)
281
+ return choice unless choice.is_a?(Hash)
282
+ return choice if choice.dig("message", "content").present?
283
+
284
+ text = choice["text"]
285
+ return choice if text.nil?
286
+
287
+ choice.merge(
288
+ "message" => { "role" => choice.dig("message", "role") || "assistant", "content" => text }
289
+ )
290
+ end
291
+
292
+ # Responses SSE prefixes every frame with an "event:" line, which is not JSON.
100
293
  def extract_data_entries(chunk)
101
- chunk.split(/^data: /).map(&:strip).reject(&:empty?)
294
+ entries = chunk.to_s.split("\n").filter_map do |line|
295
+ line = line.strip
296
+ line.delete_prefix("data:").strip.presence if line.start_with?("data:")
297
+ end
298
+ return entries if entries.any?
299
+
300
+ # A non-SSE body (a JSON error payload) arrives unframed and drives the fallback.
301
+ [chunk.to_s.strip].reject(&:empty?)
102
302
  end
103
303
 
104
304
  def process_streaming_response(chunk)
@@ -110,9 +310,43 @@ module Multiwoven::Integrations::Source
110
310
 
111
311
  raise StandardError, "Error: #{data["error"]["message"]}" if data["error"] && data["error"]["message"]
112
312
 
313
+ data = normalize_streaming_data(data)
314
+ next if data.nil?
315
+
113
316
  yield [RecordMessage.new(data: data, emitted_at: Time.now.to_i).to_multiwoven_message] if block_given?
114
317
  end
115
318
  end
319
+
320
+ def normalize_streaming_data(data)
321
+ return nil unless data.is_a?(Hash)
322
+
323
+ type = data["type"].to_s
324
+ return responses_streaming_delta(data) if type.start_with?("response.")
325
+
326
+ choices = data["choices"]
327
+ return data unless choices.is_a?(Array)
328
+
329
+ data.merge("choices" => choices.map { |choice| normalize_streaming_choice(choice) })
330
+ end
331
+
332
+ def responses_streaming_delta(data)
333
+ return nil unless data["type"].to_s.include?("output_text.delta")
334
+
335
+ text = data["delta"].presence || data["text"].presence
336
+ return nil if text.blank?
337
+
338
+ { "choices" => [{ "index" => 0, "delta" => { "content" => text } }] }
339
+ end
340
+
341
+ def normalize_streaming_choice(choice)
342
+ return choice unless choice.is_a?(Hash)
343
+ return choice if choice.dig("delta", "content").present? || choice.dig("message", "content").present?
344
+
345
+ text = choice["text"]
346
+ return choice if text.nil?
347
+
348
+ choice.merge("delta" => { "content" => text })
349
+ end
116
350
  end
117
351
  end
118
352
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: multiwoven-integrations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.41.5
4
+ version: 0.41.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Subin T P
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-02 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport