fullstackgtm 0.51.0 → 0.52.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.
package/src/llm.ts CHANGED
@@ -119,6 +119,10 @@ export type LlmCallOptions = {
119
119
  * endpoint). Origin → `/v1/chat/completions` appended; a base ending in
120
120
  * `/chat/completions` is used verbatim. Threaded from `OPENAI_API_BASE_URL`. */
121
121
  openaiBaseUrl?: string;
122
+ /** Optional OpenAI-compatible streaming telemetry. Raw reasoning text is
123
+ * deliberately not exposed; only provider-authored summaries and activity. */
124
+ onReasoningSummary?: (summary: string) => void;
125
+ onReasoningActivity?: (receivedCharacters: number) => void;
122
126
  };
123
127
 
124
128
  export type LlmExtractedInsight = ExtractedCallInsight & {
@@ -448,15 +452,19 @@ export async function forcedToolCall(
448
452
  return block.input;
449
453
  }
450
454
  const openaiUrl = resolveLlmUrl(options.openaiBaseUrl, OPENAI_URL, "/v1/chat/completions");
455
+ const requestBody = {
456
+ model,
457
+ messages: [{ role: "user", content: prompt }],
458
+ tools: [{ type: "function", function: { name: toolName, parameters: schema } }],
459
+ tool_choice: { type: "function", function: { name: toolName } },
460
+ };
461
+ if (options.onReasoningSummary || options.onReasoningActivity) {
462
+ return streamOpenAiToolCall(fetchImpl, openaiUrl, requestBody, options);
463
+ }
451
464
  const response = await llmFetch(fetchImpl, openaiUrl, {
452
465
  method: "POST",
453
466
  headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
454
- body: JSON.stringify({
455
- model,
456
- messages: [{ role: "user", content: prompt }],
457
- tools: [{ type: "function", function: { name: toolName, parameters: schema } }],
458
- tool_choice: { type: "function", function: { name: toolName } },
459
- }),
467
+ body: JSON.stringify(requestBody),
460
468
  });
461
469
  const call = (response as { choices?: Array<{ message?: { tool_calls?: Array<{ function?: { arguments?: string } }> } }> })
462
470
  .choices?.[0]?.message?.tool_calls?.[0];
@@ -464,6 +472,63 @@ export async function forcedToolCall(
464
472
  return JSON.parse(call.function.arguments);
465
473
  }
466
474
 
475
+ async function streamOpenAiToolCall(
476
+ fetchImpl: typeof fetch,
477
+ url: string,
478
+ body: object,
479
+ options: LlmCallOptions,
480
+ ): Promise<unknown> {
481
+ let response: Response;
482
+ try {
483
+ response = await fetchImpl(url, {
484
+ method: "POST",
485
+ headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
486
+ body: JSON.stringify({ ...body, stream: true, reasoning: { enabled: true, exclude: false } }),
487
+ });
488
+ } catch (error) {
489
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
490
+ throw new Error(`Cannot reach ${new URL(url).hostname}${cause}. Check network access.`);
491
+ }
492
+ if (!response.ok) throw llmApiError(response, url);
493
+ if (!response.body) throw new Error("LLM API returned no streaming response body.");
494
+ const reader = response.body.getReader();
495
+ const decoder = new TextDecoder();
496
+ let buffer = "";
497
+ let argumentsJson = "";
498
+ let reasoningCharacters = 0;
499
+ let lastActivityReport = 0;
500
+ const seenSummaries = new Set<string>();
501
+ while (true) {
502
+ const { value, done } = await reader.read();
503
+ buffer += decoder.decode(value, { stream: !done });
504
+ const lines = buffer.split("\n");
505
+ buffer = lines.pop() ?? "";
506
+ for (const line of lines) {
507
+ if (!line.startsWith("data:")) continue;
508
+ const data = line.slice(5).trim();
509
+ if (!data || data === "[DONE]") continue;
510
+ let chunk: { choices?: Array<{ delta?: { reasoning?: string; reasoning_details?: Array<{ type?: string; summary?: string; text?: string }>; tool_calls?: Array<{ function?: { arguments?: string } }> } }> };
511
+ try { chunk = JSON.parse(data); } catch { continue; }
512
+ const delta = chunk.choices?.[0]?.delta;
513
+ argumentsJson += delta?.tool_calls?.[0]?.function?.arguments ?? "";
514
+ reasoningCharacters += delta?.reasoning?.length ?? 0;
515
+ for (const detail of delta?.reasoning_details ?? []) {
516
+ reasoningCharacters += detail.text?.length ?? detail.summary?.length ?? 0;
517
+ if (detail.type !== "reasoning.summary" || !detail.summary) continue;
518
+ const summary = detail.summary.replace(/\s+/g, " ").trim();
519
+ if (summary && !seenSummaries.has(summary)) { seenSummaries.add(summary); options.onReasoningSummary?.(summary); }
520
+ }
521
+ if (reasoningCharacters - lastActivityReport >= 800) {
522
+ lastActivityReport = reasoningCharacters;
523
+ options.onReasoningActivity?.(reasoningCharacters);
524
+ }
525
+ }
526
+ if (done) break;
527
+ }
528
+ if (!argumentsJson) throw new Error("OpenAI-compatible model returned no streamed tool call — try again or a different --model.");
529
+ return JSON.parse(argumentsJson);
530
+ }
531
+
467
532
  async function llmFetch(fetchImpl: typeof fetch, url: string, init: RequestInit): Promise<unknown> {
468
533
  let response: Response;
469
534
  try {
@@ -474,11 +539,23 @@ async function llmFetch(fetchImpl: typeof fetch, url: string, init: RequestInit)
474
539
  }
475
540
  if (!response.ok) {
476
541
  // Status line only — provider error bodies can reflect request content.
477
- throw new Error(`LLM API error ${response.status} ${response.statusText} from ${new URL(url).hostname}. Check the API key (\`fullstackgtm login anthropic|openai\`) and model name.`);
542
+ throw llmApiError(response, url);
478
543
  }
479
544
  return response.json();
480
545
  }
481
546
 
547
+ function llmApiError(response: Response, url: string): Error {
548
+ const host = new URL(url).hostname;
549
+ const prefix = `LLM API error ${response.status} ${response.statusText} from ${host}.`;
550
+ if ((response.status === 401 || response.status === 403) && host === "openrouter.ai") {
551
+ return new Error(`${prefix} Replace the saved key with \`fullstackgtm login openrouter\`. If OPENROUTER_API_KEY is set, update or unset it first because environment credentials override saved login.`);
552
+ }
553
+ if (response.status === 401 || response.status === 403) {
554
+ return new Error(`${prefix} Replace the API key with \`fullstackgtm login anthropic|openai\` (choose one provider), then retry.`);
555
+ }
556
+ return new Error(`${prefix} Check the model name, provider availability, and account limits.`);
557
+ }
558
+
482
559
  function truncateTranscript(transcript: string): string {
483
560
  if (transcript.length <= MAX_TRANSCRIPT_CHARS) return transcript;
484
561
  const half = MAX_TRANSCRIPT_CHARS / 2;
package/src/publicHttp.ts CHANGED
@@ -84,9 +84,15 @@ export type PublicRequestHop = (url: URL, addresses: PublicAddress[], headers: R
84
84
 
85
85
  const requestHop: PublicRequestHop = (url, addresses, headers, timeoutMs, maxBytes) => new Promise((resolve, reject) => {
86
86
  let cursor = 0;
87
- const options: RequestOptions = {
87
+ const options: RequestOptions & { autoSelectFamily?: boolean } = {
88
88
  protocol: url.protocol, hostname: url.hostname, port: url.port || undefined,
89
89
  path: `${url.pathname}${url.search}`, method: "GET", headers,
90
+ // Node 20+ enables family autoselection by default and calls custom lookup
91
+ // functions with `{ all: true }`. This transport already resolved and
92
+ // validated every address, then pins one exact address per socket attempt;
93
+ // disable the second selection layer so the callback keeps the classic
94
+ // single-address contract and can never fall back to system DNS.
95
+ autoSelectFamily: false,
90
96
  lookup: (_hostname, options, callback) => {
91
97
  const requestedFamily = typeof options === "number" ? options : options?.family;
92
98
  const eligible = requestedFamily ? addresses.filter((a) => a.family === requestedFamily) : addresses;