multiwoven-integrations 0.41.4 → 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: 963d92ed88b8d216eb70bca2c9d8426015bf204f2d3605ecde34faaecdee9c4a
4
- data.tar.gz: e870ca09f3af22c5264182e710b0d193713e35b4f612d40b68523d88cb7936a5
3
+ metadata.gz: 9997be9180829657c2c1961957ac5af3ba80f9085ec53610b2dc9426105c5324
4
+ data.tar.gz: 0f92f5434027f6b19e0f83f785f3e791da95b42e22834c66e9fbe9011543d411
5
5
  SHA512:
6
- metadata.gz: 5236cfaa98e79b3337e487c6675bb4db875d3021861e834b99227cfcb2491eff21ef145b0bf97c820e11f559f4834af0bf2d97fd3fd9a5d49a3d8f82480db205
7
- data.tar.gz: 1464cf6d9f6f01dca18ec1b17603673f8887df372e7bbac0235d03ee20f845fade7219728f3c06b28e5ae085d8737af6ad6a827fddaad03ed45c4915aeb3c998
6
+ metadata.gz: 1ad58e7cbaefab9d122e5775404b3ac7095e1b6deaaea45093f8c23f46a2d6f2f5c9a98bd73df77b1c5d5e06211ae49bd1e50c114ce322b290e40b5a5995d564
7
+ data.tar.gz: c9ae0e891adbb23830fb35342f28f84186e433477e0eb488f49b99c45f5e365b1d3953f66a333b5be24db82f133f216ed1735de1d13696d7c5a227286076c619
@@ -7,6 +7,8 @@ module Multiwoven
7
7
  include Utils
8
8
  include Constants
9
9
 
10
+ MAX_ERROR_MESSAGE_LENGTH = 500
11
+
10
12
  def connector_spec
11
13
  @connector_spec ||= ConnectorSpecification.from_json(keys_to_symbols(read_json(CONNECTOR_SPEC_PATH)).to_json)
12
14
  end
@@ -19,7 +21,12 @@ module Multiwoven
19
21
  end
20
22
 
21
23
  def model_catalog
22
- @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
23
30
  rescue Dry::Struct::Error => e
24
31
  Integrations::Service.logger.error("#{self.class}: unusable model catalog: #{e.message}")
25
32
  @model_catalog = ModelCatalog.new(models: [])
@@ -70,6 +77,21 @@ module Multiwoven
70
77
 
71
78
  private
72
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
+
73
95
  def read_json(file_path)
74
96
  path = Object.const_source_location(self.class.to_s)[0]
75
97
  connector_folder = File.dirname(path)
@@ -86,10 +108,56 @@ module Multiwoven
86
108
  end
87
109
 
88
110
  def failure_status(error)
89
- message = error&.message || "failed"
111
+ message = case error
112
+ when Exception then error.message
113
+ when String then error.presence
114
+ else error&.to_s
115
+ end
116
+ message = "failed" if message.blank?
117
+
90
118
  ConnectionStatus.new(status: ConnectionStatusType["failed"], message: message).to_multiwoven_message
91
119
  end
92
120
 
121
+ def failure_status_from_response(response)
122
+ failure_status(http_error_message(response))
123
+ end
124
+
125
+ def http_error_message(response)
126
+ return "failed" if response.nil?
127
+
128
+ body = response.body.to_s
129
+ return readable_error_body(body, response) if body.blank?
130
+
131
+ json_error_message(JSON.parse(body)).presence || readable_error_body(body, response)
132
+ rescue StandardError
133
+ readable_error_body(body.to_s, response)
134
+ end
135
+
136
+ def json_error_message(payload)
137
+ return nil unless payload.is_a?(Hash)
138
+
139
+ error = payload["error"] || payload["errors"]
140
+ error = error.first if error.is_a?(Array)
141
+ description = payload["error_description"].presence
142
+
143
+ detailed_error_message(error, description) || payload["message"].presence || description
144
+ end
145
+
146
+ def detailed_error_message(error, description)
147
+ case error
148
+ when Hash then error["message"].presence || error["detail"].presence
149
+ when String then description || error.presence
150
+ end
151
+ end
152
+
153
+ def readable_error_body(body, response)
154
+ text = body.to_s.scrub.strip
155
+ return text.truncate(MAX_ERROR_MESSAGE_LENGTH) if text.present? && !text.start_with?("<")
156
+
157
+ code = response.respond_to?(:code) ? response.code.to_s : nil
158
+ code.present? ? "HTTP #{code}" : "failed"
159
+ end
160
+
93
161
  def auth_headers(access_token)
94
162
  {
95
163
  "Accept" => "application/json",
@@ -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
@@ -123,7 +123,21 @@ module Multiwoven
123
123
 
124
124
  (acc[slug.downcase] ||= []) << normalize(raw, slug.downcase)
125
125
  end
126
- grouped.transform_values { |models| sort_models(models) }
126
+ grouped.transform_values { |models| sort_models(without_routing_variants(models)) }
127
+ end
128
+
129
+ def without_routing_variants(models)
130
+ plain, variants = models.partition { |model| !model[:id].to_s.include?(":") }
131
+ folded = variants
132
+ .sort_by { |model| [-model.dig(:pricing, :input).to_f, model[:id].to_s] }
133
+ .map do |model|
134
+ model.merge(
135
+ id: model[:id].to_s.split(":").first,
136
+ openrouter_id: model[:openrouter_id].to_s.split(":").first
137
+ )
138
+ end
139
+
140
+ (plain + folded).uniq { |model| model[:id] }
127
141
  end
128
142
 
129
143
  # Newest first: a live list has no curation.
@@ -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.4"
5
+ VERSION = "0.41.6"
6
6
 
7
7
  ENABLED_SOURCES = %w[
8
8
  Snowflake
@@ -8,7 +8,7 @@ module Multiwoven::Integrations::Source
8
8
 
9
9
  def check_connection(connection_config)
10
10
  response = make_request(lightning_embedding_url, HTTP_POST, connection_config[:request_format], connection_config)
11
- success?(response) ? success_status : failure_status(nil)
11
+ success?(response) ? success_status : failure_status_from_response(response)
12
12
  rescue StandardError => e
13
13
  handle_exception(e, { context: "AISQUARED_BOLT:CHECK_CONNECTION:EXCEPTION", type: "error" })
14
14
  failure_status(e)
@@ -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?
@@ -8,7 +8,7 @@ module Multiwoven::Integrations::Source
8
8
  def check_connection(connection_config)
9
9
  connection_config = prepare_config(connection_config)
10
10
  response = make_request(ANTHROPIC_URL, HTTP_POST, connection_config[:request_format], connection_config)
11
- success?(response) ? success_status : failure_status(nil)
11
+ success?(response) ? success_status : failure_status_from_response(response)
12
12
  rescue StandardError => e
13
13
  handle_exception(e, { context: "ANTHROPIC:CHECK_CONNECTION:EXCEPTION", type: "error" })
14
14
  failure_status(e)
@@ -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)
@@ -11,7 +11,7 @@ module Multiwoven::Integrations::Source
11
11
  if response.endpoint_status == "InService"
12
12
  success_status
13
13
  else
14
- failure_status
14
+ failure_status("Endpoint status is #{response.endpoint_status.presence || "unknown"}")
15
15
  end
16
16
  rescue StandardError => e
17
17
  ConnectionStatus.new(status: ConnectionStatusType["failed"], message: e.message).to_multiwoven_message
@@ -15,7 +15,7 @@ module Multiwoven::Integrations::Source
15
15
  if success?(response)
16
16
  success_status
17
17
  else
18
- failure_status(nil)
18
+ failure_status_from_response(response)
19
19
  end
20
20
  rescue StandardError => e
21
21
  ConnectionStatus.new(status: ConnectionStatusType["failed"], message: e.message).to_multiwoven_message
@@ -13,7 +13,7 @@ module Multiwoven::Integrations::Source
13
13
  headers: auth_headers(connection_config[:api_key]),
14
14
  config: connection_config[:config]
15
15
  )
16
- success?(response) ? success_status : failure_status(nil)
16
+ success?(response) ? success_status : failure_status_from_response(response)
17
17
  rescue StandardError => e
18
18
  handle_exception(e, { context: "#{log_context}:CHECK_CONNECTION:EXCEPTION", type: "error" })
19
19
  failure_status(e)
@@ -13,7 +13,7 @@ module Multiwoven::Integrations::Source
13
13
  headers: connection_config[:headers],
14
14
  config: connection_config[:config]
15
15
  )
16
- success?(response) ? success_status : failure_status(nil)
16
+ success?(response) ? success_status : failure_status_from_response(response)
17
17
  rescue StandardError => e
18
18
  handle_exception(e, { context: "HTTP MODEL:CHECK_CONNECTION:EXCEPTION", type: "error" })
19
19
  failure_status(e)
@@ -6,14 +6,11 @@ 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
- success?(response) ? success_status : failure_status(nil)
13
+ success?(response) ? success_status : failure_status_from_response(response)
17
14
  rescue StandardError => e
18
15
  handle_exception(e, { context: "OPEN AI:CHECK_CONNECTION:EXCEPTION", type: "error" })
19
16
  failure_status(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
@@ -51,12 +51,17 @@ module Multiwoven::Integrations::Source
51
51
  end
52
52
 
53
53
  def evaluate_deployment_status(response, deployment_id)
54
+ return failure_status_from_response(response) unless success?(response)
55
+
54
56
  response_body = JSON.parse(response.body)
55
57
  deployment_status = response_body["resources"]&.find { |res| res.dig("metadata", "id") == deployment_id }
56
58
 
57
- return failure_status unless deployment_status
59
+ return failure_status("Deployment #{deployment_id} not found") unless deployment_status
60
+
61
+ state = deployment_status.dig("entity", "status", "state")
62
+ return success_status if state == "ready"
58
63
 
59
- deployment_status.dig("entity", "status", "state") == "ready" ? success_status : failure_status
64
+ failure_status("Deployment status is #{state.presence || "unknown"}")
60
65
  end
61
66
 
62
67
  def prepare_config_and_payload(sync_config)
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.4
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