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/CHANGELOG.md +31 -0
- package/dist/cli/auth.js +19 -8
- package/dist/cli/help.js +1 -1
- package/dist/cli/icp.js +181 -1
- package/dist/cli/shared.js +14 -1
- package/dist/icp.js +5 -1
- package/dist/icpDerive.d.ts +51 -0
- package/dist/icpDerive.js +146 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/llm.d.ts +4 -0
- package/dist/llm.js +89 -7
- package/dist/publicHttp.js +6 -0
- package/package.json +1 -1
- package/src/cli/auth.ts +16 -7
- package/src/cli/help.ts +1 -1
- package/src/cli/icp.ts +164 -3
- package/src/cli/shared.ts +12 -2
- package/src/icp.ts +5 -1
- package/src/icpDerive.ts +158 -0
- package/src/index.ts +13 -0
- package/src/llm.ts +84 -7
- package/src/publicHttp.ts +7 -1
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
|
|
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;
|