@manny-est/node-red-flowpilot 0.5.1 → 0.6.0-beta.1

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.
@@ -0,0 +1,388 @@
1
+ const https = require("https");
2
+ const http = require("http");
3
+
4
+ const ANTHROPIC_API_BASE = "https://api.anthropic.com";
5
+ const ANTHROPIC_VERSION = "2023-06-01";
6
+ const DEFAULT_MAX_TOKENS = 8192;
7
+
8
+ // ---- HTTP helpers ----
9
+
10
+ function postJson(urlString, headers, body, timeoutMs) {
11
+ return new Promise((resolve, reject) => {
12
+ let url;
13
+ try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
14
+
15
+ const payload = JSON.stringify(body);
16
+ const transport = url.protocol === "https:" ? https : http;
17
+ const req = transport.request({
18
+ method: "POST",
19
+ hostname: url.hostname,
20
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
21
+ path: url.pathname + url.search,
22
+ headers: Object.assign({ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, headers || {})
23
+ }, (res) => {
24
+ let data = "";
25
+ res.setEncoding("utf8");
26
+ res.on("data", chunk => { data += chunk; });
27
+ res.on("end", () => {
28
+ let parsed = null;
29
+ try { parsed = data ? JSON.parse(data) : null; } catch (err) {
30
+ // Never echo the raw upstream body — see the matching comment in
31
+ // provider-openai-compatible.js (ADR-007, the SSRF mitigation).
32
+ reject(new Error("Provider returned a non-JSON response (status " + res.statusCode + ")."));
33
+ return;
34
+ }
35
+ if (res.statusCode < 200 || res.statusCode >= 300) {
36
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : ("status " + res.statusCode);
37
+ reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
38
+ return;
39
+ }
40
+ resolve(parsed);
41
+ });
42
+ });
43
+ req.on("error", reject);
44
+ req.setTimeout(timeoutMs || 180000, () => {
45
+ req.destroy(new Error("Provider request timed out after " + (timeoutMs || 180000) + "ms — increase the request timeout in Settings → Behavior for slower hardware."));
46
+ });
47
+ req.write(payload);
48
+ req.end();
49
+ });
50
+ }
51
+
52
+ function getJson(urlString, headers, timeoutMs) {
53
+ return new Promise((resolve, reject) => {
54
+ let url;
55
+ try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
56
+
57
+ const transport = url.protocol === "https:" ? https : http;
58
+ const req = transport.request({
59
+ method: "GET",
60
+ hostname: url.hostname,
61
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
62
+ path: url.pathname + url.search,
63
+ headers: headers || {}
64
+ }, (res) => {
65
+ let data = "";
66
+ res.setEncoding("utf8");
67
+ res.on("data", chunk => { data += chunk; });
68
+ res.on("end", () => {
69
+ let parsed = null;
70
+ try { parsed = data ? JSON.parse(data) : null; } catch (err) {
71
+ reject(new Error("Provider returned a non-JSON response (status " + res.statusCode + ")."));
72
+ return;
73
+ }
74
+ if (res.statusCode < 200 || res.statusCode >= 300) {
75
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : ("status " + res.statusCode);
76
+ reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
77
+ return;
78
+ }
79
+ resolve(parsed);
80
+ });
81
+ });
82
+ req.on("error", reject);
83
+ req.setTimeout(timeoutMs || 30000, () => {
84
+ req.destroy(new Error("Timeout"));
85
+ });
86
+ req.end();
87
+ });
88
+ }
89
+
90
+ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDelta) {
91
+ return new Promise((resolve, reject) => {
92
+ let url;
93
+ try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
94
+
95
+ const payload = JSON.stringify(body);
96
+ const transport = url.protocol === "https:" ? https : http;
97
+ const startedAt = Date.now();
98
+ let firstTokenAt = null;
99
+ let usage = null;
100
+ let full = "";
101
+
102
+ const req = transport.request({
103
+ method: "POST",
104
+ hostname: url.hostname,
105
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
106
+ path: url.pathname + url.search,
107
+ headers: Object.assign({ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, headers || {})
108
+ }, (res) => {
109
+ res.setEncoding("utf8");
110
+ if (res.statusCode < 200 || res.statusCode >= 300) {
111
+ res.on("data", () => {});
112
+ res.on("end", () => { reject(new Error("Provider request failed (status " + res.statusCode + ").")); });
113
+ return;
114
+ }
115
+
116
+ let sseBuf = "";
117
+ res.on("data", (chunk) => {
118
+ sseBuf += chunk;
119
+ const lines = sseBuf.split("\n");
120
+ sseBuf = lines.pop();
121
+ lines.forEach((line) => {
122
+ line = line.trim();
123
+ if (!line.startsWith("data:")) { return; }
124
+ const dataStr = line.slice(5).trim();
125
+ if (!dataStr) { return; }
126
+ let evt;
127
+ try { evt = JSON.parse(dataStr); } catch (e) { return; }
128
+
129
+ // Anthropic SSE event types used here:
130
+ // message_start: carries input_tokens
131
+ // content_block_delta: text_delta or thinking_delta
132
+ // message_delta: carries output_tokens
133
+ if (evt.type === "message_start" && evt.message && evt.message.usage) {
134
+ usage = { prompt_tokens: evt.message.usage.input_tokens || 0, completion_tokens: 0 };
135
+ } else if (evt.type === "content_block_delta" && evt.delta) {
136
+ if (evt.delta.type === "text_delta" && evt.delta.text) {
137
+ if (firstTokenAt === null) { firstTokenAt = Date.now(); }
138
+ full += evt.delta.text;
139
+ onDelta(evt.delta.text);
140
+ } else if (evt.delta.type === "thinking_delta" && evt.delta.thinking && onReasoningDelta) {
141
+ onReasoningDelta(evt.delta.thinking);
142
+ }
143
+ } else if (evt.type === "message_delta" && evt.usage) {
144
+ if (usage) { usage.completion_tokens = evt.usage.output_tokens || 0; }
145
+ else { usage = { prompt_tokens: 0, completion_tokens: evt.usage.output_tokens || 0 }; }
146
+ }
147
+ });
148
+ });
149
+ res.on("end", () => {
150
+ resolve({ content: full, ttftMs: firstTokenAt !== null ? firstTokenAt - startedAt : null, totalMs: Date.now() - startedAt, usage: usage });
151
+ });
152
+ });
153
+
154
+ req.on("error", reject);
155
+ req.setTimeout(timeoutMs || 180000, () => {
156
+ req.destroy(new Error("Provider request timed out after " + (timeoutMs || 180000) + "ms — increase the request timeout in Settings → Behavior for slower hardware."));
157
+ });
158
+ req.write(payload);
159
+ req.end();
160
+ });
161
+ }
162
+
163
+ // ---- Message format conversion ----
164
+
165
+ // OpenAI {type:"function", function:{name,description,parameters}} →
166
+ // Anthropic {name, description, input_schema}
167
+ function toAnthropicTool(tool) {
168
+ const fn = tool && tool.function;
169
+ if (!fn) { return null; }
170
+ return {
171
+ name: fn.name,
172
+ description: fn.description || "",
173
+ input_schema: fn.parameters || { type: "object", properties: {} }
174
+ };
175
+ }
176
+
177
+ // Anthropic {type:"tool_use", id, name, input} →
178
+ // OpenAI {id, type:"function", function:{name, arguments:string}}
179
+ function toOpenAiToolCall(block) {
180
+ return {
181
+ id: block.id,
182
+ type: "function",
183
+ function: {
184
+ name: block.name,
185
+ arguments: JSON.stringify(block.input || {})
186
+ }
187
+ };
188
+ }
189
+
190
+ // Convert an OpenAI-shaped messages array to Anthropic format.
191
+ // Returns { system: string, messages: [...] }
192
+ //
193
+ // - role:"system" messages are extracted and concatenated into top-level system param
194
+ // - role:"assistant" + tool_calls converted to Anthropic tool_use content blocks
195
+ // - role:"tool" results converted and grouped into user messages with tool_result blocks
196
+ // - role:"user" and plain role:"assistant" pass through unchanged
197
+ function convertMessages(messages) {
198
+ const systemParts = [];
199
+ const converted = [];
200
+
201
+ for (let i = 0; i < messages.length; i++) {
202
+ const msg = messages[i];
203
+
204
+ if (msg.role === "system") {
205
+ if (typeof msg.content === "string" && msg.content) {
206
+ systemParts.push(msg.content);
207
+ }
208
+ continue;
209
+ }
210
+
211
+ // Group consecutive tool result messages into one user message
212
+ if (msg.role === "tool") {
213
+ const toolResults = [];
214
+ while (i < messages.length && messages[i].role === "tool") {
215
+ toolResults.push({
216
+ type: "tool_result",
217
+ tool_use_id: messages[i].tool_call_id,
218
+ content: messages[i].content || ""
219
+ });
220
+ i++;
221
+ }
222
+ i--; // outer loop will increment
223
+ converted.push({ role: "user", content: toolResults });
224
+ continue;
225
+ }
226
+
227
+ // Assistant with tool_calls → Anthropic tool_use content blocks
228
+ if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
229
+ const content = [];
230
+ if (msg.content) { content.push({ type: "text", text: msg.content }); }
231
+ msg.tool_calls.forEach(function (tc) {
232
+ let input = {};
233
+ try { input = JSON.parse(tc.function.arguments || "{}"); } catch (e) {}
234
+ content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: input });
235
+ });
236
+ converted.push({ role: "assistant", content: content });
237
+ continue;
238
+ }
239
+
240
+ converted.push({ role: msg.role, content: msg.content });
241
+ }
242
+
243
+ return { system: systemParts.join("\n\n"), messages: converted };
244
+ }
245
+
246
+ function anthropicHeaders(settings) {
247
+ return {
248
+ "x-api-key": settings.apiKey || "",
249
+ "anthropic-version": ANTHROPIC_VERSION
250
+ };
251
+ }
252
+
253
+ function resolveBaseUrl(settings) {
254
+ return String(settings.baseUrl || ANTHROPIC_API_BASE).replace(/\/+$/, "") || ANTHROPIC_API_BASE;
255
+ }
256
+
257
+ // ---- chat ----
258
+
259
+ async function chat(settings, messages, options) {
260
+ if (!settings.model) { throw new Error("Model is required."); }
261
+ const baseUrl = resolveBaseUrl(settings);
262
+ const { system, messages: anthropicMessages } = convertMessages(messages);
263
+ const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
264
+
265
+ const body = {
266
+ model: settings.model,
267
+ messages: anthropicMessages,
268
+ max_tokens: DEFAULT_MAX_TOKENS,
269
+ temperature: temperature,
270
+ stream: false
271
+ };
272
+ if (system) { body.system = system; }
273
+ if (options && Array.isArray(options.tools) && options.tools.length) {
274
+ body.tools = options.tools.map(toAnthropicTool).filter(Boolean);
275
+ body.tool_choice = {
276
+ type: options.toolChoice === "required" ? "any" : "auto"
277
+ };
278
+ }
279
+
280
+ const startedAt = Date.now();
281
+ const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000);
282
+ const totalMs = Date.now() - startedAt;
283
+
284
+ const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
285
+ const textContent = contentArray.filter(function (b) { return b.type === "text"; }).map(function (b) { return b.text; }).join("");
286
+ const toolUseBlocks = contentArray.filter(function (b) { return b.type === "tool_use"; });
287
+ const toolCalls = toolUseBlocks.length ? toolUseBlocks.map(toOpenAiToolCall) : null;
288
+ const usage = (response && response.usage)
289
+ ? { prompt_tokens: response.usage.input_tokens, completion_tokens: response.usage.output_tokens }
290
+ : null;
291
+
292
+ return {
293
+ raw: response,
294
+ content: textContent || (toolCalls ? "" : "[No assistant message returned by provider]"),
295
+ toolCalls: toolCalls,
296
+ timing: { totalMs: totalMs },
297
+ usage: usage
298
+ };
299
+ }
300
+
301
+ // ---- chatStream ----
302
+
303
+ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
304
+ if (!settings.model) { throw new Error("Model is required."); }
305
+ const baseUrl = resolveBaseUrl(settings);
306
+ const { system, messages: anthropicMessages } = convertMessages(messages);
307
+ const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
308
+
309
+ const body = {
310
+ model: settings.model,
311
+ messages: anthropicMessages,
312
+ max_tokens: DEFAULT_MAX_TOKENS,
313
+ temperature: temperature,
314
+ stream: true
315
+ };
316
+ if (system) { body.system = system; }
317
+
318
+ const result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
319
+
320
+ return {
321
+ content: result.content || "",
322
+ timing: { ttftMs: result.ttftMs, totalMs: result.totalMs },
323
+ usage: result.usage
324
+ };
325
+ }
326
+
327
+ // ---- listModels ----
328
+ // Tries GET /v1/models; falls back to a hardcoded list if that endpoint
329
+ // is unavailable (non-standard proxy) or returns an error.
330
+ async function listModels(settings) {
331
+ const baseUrl = resolveBaseUrl(settings);
332
+ try {
333
+ const response = await getJson(baseUrl + "/v1/models", anthropicHeaders(settings), settings.requestTimeoutMs || 30000);
334
+ const data = response && Array.isArray(response.data) ? response.data : [];
335
+ const models = data.map(function (m) { return m && m.id; }).filter(function (id) { return typeof id === "string" && id; });
336
+ if (models.length) { return { models: models }; }
337
+ throw new Error("Empty model list from provider");
338
+ } catch (err) {
339
+ return {
340
+ models: [
341
+ "claude-opus-4-8",
342
+ "claude-sonnet-5",
343
+ "claude-haiku-4-5-20251001"
344
+ ],
345
+ error: err.message
346
+ };
347
+ }
348
+ }
349
+
350
+ // ---- probeTools ----
351
+ async function probeTools(settings) {
352
+ if (!settings.model) { throw new Error("Model is required."); }
353
+ const baseUrl = resolveBaseUrl(settings);
354
+
355
+ try {
356
+ const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), {
357
+ model: settings.model,
358
+ messages: [{ role: "user", content: "Call the \"ping\" tool now with no arguments." }],
359
+ system: "You are being tested for tool/function-calling support.",
360
+ tools: [{
361
+ name: "ping",
362
+ description: "Respond to a connectivity probe. Takes no arguments.",
363
+ input_schema: { type: "object", properties: {}, additionalProperties: false }
364
+ }],
365
+ tool_choice: { type: "auto" },
366
+ max_tokens: 128,
367
+ temperature: 0,
368
+ stream: false
369
+ }, settings.requestTimeoutMs || 30000);
370
+
371
+ const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
372
+ const hasToolUse = contentArray.some(function (b) { return b.type === "tool_use" && b.name === "ping"; });
373
+ return { supportsTools: hasToolUse };
374
+ } catch (err) {
375
+ return { supportsTools: false, error: err.message };
376
+ }
377
+ }
378
+
379
+ // ---- detectReasoning ----
380
+ // Extended thinking responses include content blocks with type:"thinking".
381
+ // Standard non-thinking responses never include them.
382
+ function detectReasoning(rawResponse) {
383
+ const contentArray = (rawResponse && Array.isArray(rawResponse.content)) ? rawResponse.content : [];
384
+ const isReasoningModel = contentArray.some(function (b) { return b && b.type === "thinking" && b.thinking; });
385
+ return { isReasoningModel: isReasoningModel };
386
+ }
387
+
388
+ module.exports = { chat, chatStream, probeTools, listModels, detectReasoning };
@@ -33,12 +33,18 @@ function postJson(urlString, headers, body, timeoutMs) {
33
33
  try {
34
34
  parsed = data ? JSON.parse(data) : null;
35
35
  } catch (err) {
36
- reject(new Error(`Provider returned non-JSON response (${res.statusCode}): ${data.slice(0, 500)}`));
36
+ // Never echo the raw upstream body — a security boundary, not just
37
+ // tidiness. This request may be the provider-confirmation check
38
+ // hitting a baseUrl for the first time (SSRF mitigation, ADR-007);
39
+ // an attacker-controlled target (internal service, cloud metadata)
40
+ // must not be able to get its response body reflected back to the
41
+ // caller through a FlowPilot error message.
42
+ reject(new Error(`Provider returned a non-JSON response (status ${res.statusCode}).`));
37
43
  return;
38
44
  }
39
45
 
40
46
  if (res.statusCode < 200 || res.statusCode >= 300) {
41
- const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
47
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : `status ${res.statusCode}`;
42
48
  reject(new Error(`Provider request failed (${res.statusCode}): ${msg}`));
43
49
  return;
44
50
  }
@@ -86,12 +92,13 @@ function getJson(urlString, headers, timeoutMs) {
86
92
  try {
87
93
  parsed = data ? JSON.parse(data) : null;
88
94
  } catch (err) {
89
- reject(new Error(`Provider returned non-JSON response (${res.statusCode}): ${data.slice(0, 500)}`));
95
+ // See postJson above never echo the raw upstream body.
96
+ reject(new Error(`Provider returned a non-JSON response (status ${res.statusCode}).`));
90
97
  return;
91
98
  }
92
99
 
93
100
  if (res.statusCode < 200 || res.statusCode >= 300) {
94
- const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
101
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : `status ${res.statusCode}`;
95
102
  reject(new Error(`Provider request failed (${res.statusCode}): ${msg}`));
96
103
  return;
97
104
  }
@@ -169,6 +176,12 @@ async function chat(settings, messages, options) {
169
176
  body.tools = options.tools;
170
177
  body.tool_choice = options.toolChoice || "auto";
171
178
  }
179
+ if (options && options.responseFormat) {
180
+ body.response_format = options.responseFormat;
181
+ }
182
+ if (options && Number.isInteger(options.maxTokens) && options.maxTokens > 0) {
183
+ body.max_tokens = options.maxTokens;
184
+ }
172
185
 
173
186
  const startedAt = Date.now();
174
187
  const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, body, settings.requestTimeoutMs || 180000);
@@ -184,6 +197,8 @@ async function chat(settings, messages, options) {
184
197
  raw: response,
185
198
  content: content || (toolCalls ? "" : "[No assistant message returned by provider]"),
186
199
  toolCalls: toolCalls,
200
+ finishReason: (response && response.choices && response.choices[0] &&
201
+ response.choices[0].finish_reason) || null,
187
202
  timing: { totalMs },
188
203
  usage: (response && response.usage) || null
189
204
  };
@@ -279,20 +294,22 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
279
294
  const startedAt = Date.now();
280
295
  let firstTokenAt = null;
281
296
  let usage = null;
297
+ let finishReason = null;
282
298
 
283
299
  const req = transport.request(options, (res) => {
284
300
  res.setEncoding("utf8");
285
301
 
286
302
  if (res.statusCode < 200 || res.statusCode >= 300) {
287
- let errData = "";
288
- res.on("data", chunk => { errData += chunk; });
303
+ // Drain the body but never reflect it — see postJson's comment above.
304
+ res.on("data", () => {});
289
305
  res.on("end", () => {
290
- reject(new Error(`Provider request failed (${res.statusCode}): ${errData.slice(0, 500)}`));
306
+ reject(new Error(`Provider request failed (status ${res.statusCode}).`));
291
307
  });
292
308
  return;
293
309
  }
294
310
 
295
311
  let sseBuf = "";
312
+ let sawValidSseData = false;
296
313
  let full = "";
297
314
  // When onReasoningDelta is provided, intercept <think>...</think> from
298
315
  // delta.content in addition to the dedicated delta.reasoning_content field
@@ -322,7 +339,12 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
322
339
  return; // ignore malformed/partial SSE chunk
323
340
  }
324
341
 
342
+ sawValidSseData = true;
343
+
325
344
  if (evt && evt.usage) { usage = evt.usage; }
345
+ if (evt && evt.choices && evt.choices[0] && evt.choices[0].finish_reason) {
346
+ finishReason = evt.choices[0].finish_reason;
347
+ }
326
348
 
327
349
  const deltaObj = evt && evt.choices && evt.choices[0] && evt.choices[0].delta;
328
350
  if (deltaObj) {
@@ -347,12 +369,17 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
347
369
  });
348
370
  res.on("end", () => {
349
371
  if (thinkSplitter) { thinkSplitter.finish(); }
372
+ if (!sawValidSseData) {
373
+ reject(new Error(`Provider returned a non-SSE response (status ${res.statusCode}).`));
374
+ return;
375
+ }
350
376
  const endedAt = Date.now();
351
377
  resolve({
352
378
  content: full,
353
379
  ttftMs: firstTokenAt !== null ? firstTokenAt - startedAt : null,
354
380
  totalMs: endedAt - startedAt,
355
- usage: usage
381
+ usage: usage,
382
+ finishReason: finishReason
356
383
  });
357
384
  });
358
385
  });
@@ -368,7 +395,7 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
368
395
  });
369
396
  }
370
397
 
371
- async function chatStream(settings, messages, onDelta, onReasoningDelta) {
398
+ async function chatStream(settings, messages, onDelta, onReasoningDelta, options) {
372
399
  const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
373
400
  if (!baseUrl) throw new Error("Provider base URL is required.");
374
401
  if (!settings.model) throw new Error("Model is required.");
@@ -384,18 +411,28 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
384
411
  // final SSE chunk carrying token usage (no delta) before [DONE]. Providers
385
412
  // that don't support it just ignore the option; postStream treats a
386
413
  // missing usage field as null either way.
387
- const result = await postStream(`${baseUrl}/v1/chat/completions`, headers, {
414
+ const body = {
388
415
  model: settings.model,
389
416
  messages,
390
417
  temperature,
391
418
  stream: true,
392
419
  stream_options: { include_usage: true }
393
- }, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
420
+ };
421
+ if (options && options.responseFormat) {
422
+ body.response_format = options.responseFormat;
423
+ }
424
+ if (options && Number.isInteger(options.maxTokens) && options.maxTokens > 0) {
425
+ body.max_tokens = options.maxTokens;
426
+ }
427
+
428
+ const result = await postStream(`${baseUrl}/v1/chat/completions`, headers,
429
+ body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
394
430
 
395
431
  return {
396
432
  content: result.content || "",
397
433
  timing: { ttftMs: result.ttftMs, totalMs: result.totalMs },
398
- usage: result.usage
434
+ usage: result.usage,
435
+ finishReason: result.finishReason
399
436
  };
400
437
  }
401
438
 
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+
3
+ // ---------------------------------------------------------------------
4
+ // The provider-confirmation gate's own pass/fail criterion (ADR-007, the
5
+ // SSRF mitigation). Deliberately NOT "HTTP 200 with a JSON body" — an
6
+ // internal admin panel or a cloud metadata endpoint can trivially return
7
+ // that. Requires an actually provider-shaped response: a well-formed
8
+ // OpenAI-compatible chat-completion object (choices[].message) or Anthropic
9
+ // message (content[]), or a valid OpenAI-style /v1/models list (data[] of
10
+ // {id}). Anything else — including a bare 200, an HTML error page, or JSON
11
+ // that merely happens to parse but isn't shaped like either — fails the
12
+ // check, and the provider stays unconfirmed.
13
+ // ---------------------------------------------------------------------
14
+
15
+ function isChatShaped(providerType, raw) {
16
+ if (providerType === "anthropic") {
17
+ return Array.isArray(raw.content);
18
+ }
19
+ return Array.isArray(raw.choices) && raw.choices.length > 0 &&
20
+ raw.choices[0] && typeof raw.choices[0] === "object" &&
21
+ raw.choices[0].message && typeof raw.choices[0].message === "object";
22
+ }
23
+
24
+ function isModelsListShaped(raw) {
25
+ return Array.isArray(raw.data) && raw.data.length > 0 &&
26
+ raw.data.every(function (m) { return m && typeof m.id === "string" && m.id; });
27
+ }
28
+
29
+ function isProviderShapedResponse(providerType, raw) {
30
+ if (!raw || typeof raw !== "object") { return false; }
31
+ return isChatShaped(providerType, raw) || isModelsListShaped(raw);
32
+ }
33
+
34
+ module.exports = { isProviderShapedResponse };