multiwoven-integrations 0.41.5 → 0.41.7
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/lib/multiwoven/integrations/core/base_connector.rb +21 -1
- data/lib/multiwoven/integrations/core/constants.rb +2 -0
- data/lib/multiwoven/integrations/core/streaming_http_client.rb +11 -0
- data/lib/multiwoven/integrations/rollout.rb +1 -1
- data/lib/multiwoven/integrations/source/aisquared/client.rb +5 -0
- data/lib/multiwoven/integrations/source/aisquared/config/models.json +0 -93
- data/lib/multiwoven/integrations/source/anthropic/client.rb +13 -2
- data/lib/multiwoven/integrations/source/open_ai/client.rb +251 -17
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f793087689ddbeffd46af5a259164bf8272a3fb76da668ebb7c3c030c508d369
|
|
4
|
+
data.tar.gz: 29239ae80de183b2ac1b206d46c430dc3963a905c07a9539e6172cfb44061583
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a82e41aadb466f5f07679e15e58ca7631af83163d3b81209b676f6f23e131ffe5e21d1692aaf4a0fecb8fd948fa1897a10fa61d1b9bfea28ccc22ea913dd054d
|
|
7
|
+
data.tar.gz: e35088755b3f939e133804257d0ff0d8f8bb4c0dc9ae4968c164a7326c60c14e026966eafa2fc5e93191759ab365dbc023051e0c0b08b9d4b7d31e72c2b5db8b
|
|
@@ -21,7 +21,12 @@ module Multiwoven
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def model_catalog
|
|
24
|
-
@model_catalog ||=
|
|
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
|
|
@@ -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?
|
|
@@ -1,51 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"models": [
|
|
3
|
-
{
|
|
4
|
-
"id": "bolt-instruct-1b",
|
|
5
|
-
"name": "Bolt-Instruct-1B",
|
|
6
|
-
"model_type": "llm",
|
|
7
|
-
"type": "completion",
|
|
8
|
-
"tasks": [
|
|
9
|
-
"Text Classification",
|
|
10
|
-
"Guardrails"
|
|
11
|
-
],
|
|
12
|
-
"context_window": 32000,
|
|
13
|
-
"max_output": 4096,
|
|
14
|
-
"pricing": {
|
|
15
|
-
"input": 0.0,
|
|
16
|
-
"output": 0.0,
|
|
17
|
-
"cached_read": 0.0,
|
|
18
|
-
"cached_write": 0.0,
|
|
19
|
-
"unit": "per_1m_tokens"
|
|
20
|
-
},
|
|
21
|
-
"capabilities": [
|
|
22
|
-
"Documents"
|
|
23
|
-
]
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
"id": "bolt-instruct-7b",
|
|
27
|
-
"name": "Bolt-Instruct-7B",
|
|
28
|
-
"model_type": "llm",
|
|
29
|
-
"type": "completion",
|
|
30
|
-
"tasks": [
|
|
31
|
-
"Text Generation",
|
|
32
|
-
"Routing",
|
|
33
|
-
"Orchestration"
|
|
34
|
-
],
|
|
35
|
-
"context_window": 32000,
|
|
36
|
-
"max_output": 8192,
|
|
37
|
-
"pricing": {
|
|
38
|
-
"input": 0.0,
|
|
39
|
-
"output": 0.0,
|
|
40
|
-
"cached_read": 0.0,
|
|
41
|
-
"cached_write": 0.0,
|
|
42
|
-
"unit": "per_1m_tokens"
|
|
43
|
-
},
|
|
44
|
-
"capabilities": [
|
|
45
|
-
"Documents",
|
|
46
|
-
"Data analyst"
|
|
47
|
-
]
|
|
48
|
-
},
|
|
49
3
|
{
|
|
50
4
|
"id": "bolt-instruct-32b",
|
|
51
5
|
"name": "Bolt-Instruct-32B",
|
|
@@ -68,53 +22,6 @@
|
|
|
68
22
|
"Documents",
|
|
69
23
|
"Data analyst"
|
|
70
24
|
]
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
"id": "bolt-embedding-large",
|
|
74
|
-
"name": "Bolt-Embedding-Large",
|
|
75
|
-
"model_type": "embedding",
|
|
76
|
-
"type": "embedding",
|
|
77
|
-
"tasks": [
|
|
78
|
-
"Text Embeddings",
|
|
79
|
-
"Indexing",
|
|
80
|
-
"Retrieval"
|
|
81
|
-
],
|
|
82
|
-
"context_window": 8192,
|
|
83
|
-
"max_output": null,
|
|
84
|
-
"pricing": {
|
|
85
|
-
"input": 0.0,
|
|
86
|
-
"output": null,
|
|
87
|
-
"cached_read": null,
|
|
88
|
-
"cached_write": null,
|
|
89
|
-
"unit": "per_1m_tokens"
|
|
90
|
-
},
|
|
91
|
-
"capabilities": [
|
|
92
|
-
"Documents"
|
|
93
|
-
]
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
"id": "bolt-vision-9b",
|
|
97
|
-
"name": "Bolt-Vision-9B",
|
|
98
|
-
"model_type": "vision",
|
|
99
|
-
"type": "completion",
|
|
100
|
-
"tasks": [
|
|
101
|
-
"Image-to-Text",
|
|
102
|
-
"OCR",
|
|
103
|
-
"Document Parsing"
|
|
104
|
-
],
|
|
105
|
-
"context_window": 32000,
|
|
106
|
-
"max_output": 8192,
|
|
107
|
-
"pricing": {
|
|
108
|
-
"input": 0.0,
|
|
109
|
-
"output": 0.0,
|
|
110
|
-
"cached_read": 0.0,
|
|
111
|
-
"cached_write": 0.0,
|
|
112
|
-
"unit": "per_1m_tokens"
|
|
113
|
-
},
|
|
114
|
-
"capabilities": [
|
|
115
|
-
"Documents",
|
|
116
|
-
"Image analysis"
|
|
117
|
-
]
|
|
118
25
|
}
|
|
119
26
|
]
|
|
120
27
|
}
|
|
@@ -67,7 +67,7 @@ module Multiwoven::Integrations::Source
|
|
|
67
67
|
send_request(
|
|
68
68
|
url: url,
|
|
69
69
|
http_method: http_method,
|
|
70
|
-
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:
|
|
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 =
|
|
10
|
-
|
|
11
|
-
|
|
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 =
|
|
64
|
-
|
|
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
|
|
116
|
+
def post_openai_stream(connection_config, payload, url)
|
|
76
117
|
send_streaming_request(
|
|
77
|
-
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
|
-
|
|
86
|
-
|
|
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(
|
|
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.
|
|
4
|
+
version: 0.41.7
|
|
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-
|
|
11
|
+
date: 2026-09-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|