@bendyline/gezel-inference 1.0.4 → 1.0.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@bendyline/gilde",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "kinds": [
5
5
  "chat-models",
6
6
  "image-models",
@@ -32,7 +32,7 @@ export interface ResolveEngineOptions {
32
32
  signaturePolicy?: SignaturePolicy;
33
33
  fetchImpl?: typeof fetch;
34
34
  signal?: AbortSignal;
35
- /** Pre-resolved token: `undefined` = auto-resolve; `null` = explicitly none. */
35
+ /** Pre-resolved token: `undefined` = from the environment; `null` = explicitly none. */
36
36
  token?: string | null;
37
37
  /** Override the source-pinned SHA256SUMS digest (tests). */
38
38
  expectedSha256sumsDigest?: string;
@@ -3,7 +3,7 @@ import { createReadStream, existsSync, readdirSync } from 'node:fs';
3
3
  import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { HttpStatusError, createLogger, nowIso, resolveDistributionProfile, retryTransient, } from '@bendyline/gezel';
6
- import { DUCKDB_APPLE_TEAM_ID, DUCKDB_ARCHIVE_SHA256, DUCKDB_ASSET, DUCKDB_BINARY_SHA256, DUCKDB_VERSION, DUCKDB_WINDOWS_PUBLISHER, MXC_EXECUTOR_NAME, MXC_EXECUTOR_SHA256, MXC_LICENSE_SHA256, MXC_TARBALL_INTEGRITY, MXC_TARBALL_LICENSE_ENTRY, MXC_TARBALL_SHA256, MXC_TARBALL_URL, MXC_VERIFIED_SENTINEL, MXC_VERSION, MXC_WINDOWS_PUBLISHER, duckdbAssetUrl, duckdbBinaryName, duckdbInstallDir, duckdbPlatformKey, mxcArch, mxcInstallDir, mxcTarballEntry, resolvePlatformKey, windowsHeadlessSpawnOptions, } from '@bendyline/gezel/native';
6
+ import { DUCKDB_APPLE_TEAM_ID, DUCKDB_ARCHIVE_SHA256, DUCKDB_ASSET, DUCKDB_BINARY_SHA256, DUCKDB_VERSION, DUCKDB_WINDOWS_PUBLISHER, MXC_EXECUTOR_NAME, MXC_EXECUTOR_SHA256, MXC_LICENSE_SHA256, MXC_TARBALL_INTEGRITY, MXC_TARBALL_LICENSE_ENTRY, MXC_TARBALL_SHA256, MXC_TARBALL_URL, MXC_VERIFIED_SENTINEL, MXC_VERSION, MXC_WINDOWS_PUBLISHER, duckdbAssetUrl, duckdbBinaryName, duckdbInstallDir, duckdbPlatformKey, mxcArch, mxcInstallDir, mxcTarballEntry, resolvePlatformKey, } from '@bendyline/gezel/native';
7
7
  import * as tar from 'tar';
8
8
  import { downloadWithRetry, friendlyErrors, } from '../utils/download-with-retry.js';
9
9
  import { extractZip } from '../utils/extract-zip.js';
@@ -541,14 +541,16 @@ export async function* resolveEngine(opts) {
541
541
  // `bendyline/gezel` is public, so release metadata and assets are readable
542
542
  // anonymously. A token stays OPTIONAL and is used when present purely to
543
543
  // lift GitHub's 60-request/hour unauthenticated API rate limit. Do not
544
- // reinstate a hard gate here: users who install from npm have no `gh`
545
- // login, and requiring one would make on-device engines unreachable for
546
- // every consumer outside this repo.
547
- const token = opts.token !== undefined ? opts.token : await resolveGitHubToken();
544
+ // reinstate a hard gate here: users who install from npm have no token,
545
+ // and requiring one would make on-device engines unreachable for every
546
+ // consumer outside this repo.
547
+ const auth = {
548
+ token: opts.token !== undefined ? opts.token : resolveGitHubToken(),
549
+ };
548
550
  const baseFetch = opts.fetchImpl ?? globalThis.fetch;
549
551
  const tag = `native-v${version}`;
550
552
  // ── 1. Resolve release + assets ──
551
- const release = await ghJson(`${apiBase}/repos/${REPO}/releases/tags/${tag}`, baseFetch, token);
553
+ const release = await ghJson(`${apiBase}/repos/${REPO}/releases/tags/${tag}`, baseFetch, auth);
552
554
  const archiveAsset = release.assets?.find((a) => a.name === archiveName);
553
555
  const sumsAsset = release.assets?.find((a) => a.name === 'SHA256SUMS');
554
556
  if (!archiveAsset) {
@@ -558,7 +560,7 @@ export async function* resolveEngine(opts) {
558
560
  yield* fail(`release ${tag} is missing its SHA256SUMS manifest — refusing to install unverified`);
559
561
  }
560
562
  // ── 2. SHA256SUMS: verify own digest against the source pin, then parse ──
561
- const sumsBytes = await ghBytes(sumsAsset.url, baseFetch, token);
563
+ const sumsBytes = await ghBytes(sumsAsset.url, baseFetch, auth);
562
564
  const sumsDigest = createHash('sha256').update(sumsBytes).digest('hex');
563
565
  if (!unpinned && sumsDigest !== expectedDigest) {
564
566
  yield* fail(`SHA256SUMS digest mismatch for ${tag}: this build pins ${expectedDigest.slice(0, 16)}… but the release served ${sumsDigest.slice(0, 16)}…. Refusing — the release may have been tampered with or re-cut.`);
@@ -584,7 +586,7 @@ export async function* resolveEngine(opts) {
584
586
  url: archiveAsset.url,
585
587
  destPath: archivePath,
586
588
  approxSizeBytes: archiveAsset.size ?? 0,
587
- fetchImpl: makeAuthedFetch(baseFetch, token),
589
+ fetchImpl: gitHubFetch(baseFetch, auth, 'application/octet-stream'),
588
590
  ...(opts.signal ? { signal: opts.signal } : {}),
589
591
  });
590
592
  let outcome;
@@ -765,35 +767,37 @@ function stampEnv(engine, binPath, variant, versionDir) {
765
767
  process.env.GEZEL_SQLITE_VEC_PATH = sqliteVec;
766
768
  }
767
769
  }
768
- async function resolveGitHubToken() {
769
- if (process.env.GEZEL_GITHUB_TOKEN)
770
- return process.env.GEZEL_GITHUB_TOKEN;
771
- if (process.env.GITHUB_TOKEN)
772
- return process.env.GITHUB_TOKEN;
773
- try {
774
- const { execFileSync } = await import('node:child_process');
775
- // Named host: a bare `gh auth token` answers for gh's default host, which
776
- // can be a GitHub Enterprise server whose token must not go to github.com.
777
- const out = execFileSync('gh', ['auth', 'token', '--hostname', 'github.com'], {
778
- encoding: 'utf8',
779
- stdio: ['ignore', 'pipe', 'ignore'],
780
- ...windowsHeadlessSpawnOptions(),
781
- });
782
- return out.trim() || null;
783
- }
784
- catch {
785
- return null;
786
- }
770
+ /**
771
+ * An optional token, from the environment only. This module runs inside apps
772
+ * that embed Gezel, so it never borrows the user's GitHub CLI login: `gh` is
773
+ * not its credential to use, and a stale one used to fail public downloads.
774
+ */
775
+ function resolveGitHubToken() {
776
+ return process.env.GEZEL_GITHUB_TOKEN || process.env.GITHUB_TOKEN || null;
787
777
  }
788
- function makeAuthedFetch(base, token) {
789
- return ((input, init) => base(input, {
790
- ...init,
791
- headers: {
792
- ...init?.headers,
793
- Accept: 'application/octet-stream',
794
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
795
- },
796
- }));
778
+ /**
779
+ * Fetch from GitHub with the optional token. The release is public, so a token
780
+ * GitHub rejects (expired, revoked) is dropped for the rest of the resolve and
781
+ * the request repeats anonymously instead of failing the install.
782
+ */
783
+ function gitHubFetch(base, auth, accept) {
784
+ return (async (input, init) => {
785
+ const send = () => base(input, {
786
+ ...init,
787
+ headers: {
788
+ ...init?.headers,
789
+ Accept: accept,
790
+ ...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
791
+ },
792
+ });
793
+ const res = await send();
794
+ if (res.status !== 401 || !auth.token)
795
+ return res;
796
+ await res.body?.cancel().catch(() => { });
797
+ log.warn('[engine-resolver] GitHub rejected the token from GEZEL_GITHUB_TOKEN / GITHUB_TOKEN (401); continuing anonymously');
798
+ auth.token = null;
799
+ return send();
800
+ });
797
801
  }
798
802
  /**
799
803
  * The archive itself goes through `downloadWithRetry`, but the two small
@@ -808,31 +812,23 @@ function ghRetryLog(what) {
808
812
  log.warn(`[engine-resolver] ${what} failed (${info.reason}); retrying in ${info.delayMs}ms (attempt ${info.attempt}/${info.maxAttempts})`);
809
813
  };
810
814
  }
811
- async function ghJson(url, fetchImpl, token) {
815
+ async function ghJson(url, fetchImpl, auth) {
816
+ const get = gitHubFetch(fetchImpl, auth, 'application/vnd.github+json');
812
817
  return retryTransient(async () => {
813
- const res = await fetchImpl(url, {
814
- headers: {
815
- Accept: 'application/vnd.github+json',
816
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
817
- },
818
- });
818
+ const res = await get(url);
819
819
  if (!res.ok) {
820
820
  await res.body?.cancel().catch(() => { });
821
821
  // A transient status is thrown as HttpStatusError so retryTransient
822
822
  // takes another pass; 4xx becomes the terminal EngineUnavailableError.
823
- throw new HttpStatusError(res.status, `GitHub API returned ${res.status} for ${url}${res.status === 404 ? ' (release not found — or no access while private)' : ''}`);
823
+ throw new HttpStatusError(res.status, `GitHub API returned ${res.status} for ${url}${res.status === 404 ? ' (release not found)' : ''}`);
824
824
  }
825
825
  return (await res.json());
826
826
  }, { attempts: GH_METADATA_ATTEMPTS, onRetry: ghRetryLog('GitHub release lookup') }).catch(rethrowAsEngineUnavailable);
827
827
  }
828
- async function ghBytes(assetApiUrl, fetchImpl, token) {
828
+ async function ghBytes(assetApiUrl, fetchImpl, auth) {
829
+ const get = gitHubFetch(fetchImpl, auth, 'application/octet-stream');
829
830
  return retryTransient(async () => {
830
- const res = await fetchImpl(assetApiUrl, {
831
- headers: {
832
- Accept: 'application/octet-stream',
833
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
834
- },
835
- });
831
+ const res = await get(assetApiUrl);
836
832
  if (!res.ok) {
837
833
  await res.body?.cancel().catch(() => { });
838
834
  throw new HttpStatusError(res.status, `asset download returned ${res.status} for ${assetApiUrl}`);
@@ -1,4 +1,5 @@
1
1
  import type { ExternalToolCall, ImageAttachment, LLMSession, TurnUsage } from '../../providers/types.js';
2
+ import type { ToolCallScreen } from './tool-call-screen.js';
2
3
  export type ResponsesToolKind = 'function' | 'custom';
3
4
  /**
4
5
  * Maps a provider-safe local tool name back to the identity advertised on
@@ -243,6 +244,12 @@ export interface ResponsesSerializerOptions {
243
244
  toolNamespaces?: Record<string, string>;
244
245
  echo?: ResponsesRequestEcho;
245
246
  limits?: ResponsesSerializerLimits;
247
+ /**
248
+ * Withholds calls that name an egress indicator; see tool-call-screen.ts.
249
+ * A withheld call that was already announced closes `incomplete` with no
250
+ * arguments, since Responses clients run a call from its `done` item.
251
+ */
252
+ toolCallScreen?: ToolCallScreen;
246
253
  }
247
254
  /**
248
255
  * Stateful Responses output serializer. Every returned event is an immutable
@@ -259,6 +266,7 @@ export declare class ResponsesStreamSerializer {
259
266
  private readonly toolNamespaces;
260
267
  private readonly echo;
261
268
  private readonly limits;
269
+ private readonly toolCallScreen;
262
270
  private started;
263
271
  private terminal;
264
272
  private sequenceNumber;
@@ -305,6 +313,11 @@ export declare class ResponsesStreamSerializer {
305
313
  private finalizeActive;
306
314
  private finalizeMessage;
307
315
  private finalizeTool;
316
+ /**
317
+ * Close a screened call so no client can run it. One never announced stays
318
+ * out of the output; an announced one ends `incomplete` with no arguments.
319
+ */
320
+ private withholdTool;
308
321
  private reconcileToolCalls;
309
322
  private reconcileFinalText;
310
323
  private markActiveIncomplete;
@@ -64,6 +64,7 @@ export class ResponsesStreamSerializer {
64
64
  toolNamespaces;
65
65
  echo;
66
66
  limits;
67
+ toolCallScreen;
67
68
  started = false;
68
69
  terminal = false;
69
70
  sequenceNumber = 0;
@@ -88,6 +89,7 @@ export class ResponsesStreamSerializer {
88
89
  this.toolNamespaces = options.toolNamespaces ?? {};
89
90
  this.echo = options.echo ?? {};
90
91
  this.limits = { ...DEFAULT_LIMITS, ...options.limits };
92
+ this.toolCallScreen = options.toolCallScreen;
91
93
  }
92
94
  get isTerminal() {
93
95
  return this.terminal;
@@ -162,6 +164,7 @@ export class ResponsesStreamSerializer {
162
164
  const calls = this.echo.parallelToolCalls === false ? toolCalls.slice(0, 1) : toolCalls;
163
165
  events.push(...this.reconcileToolCalls(calls));
164
166
  events.push(...this.reconcileFinalText(text));
167
+ events.push(...this.pushTextDelta(this.toolCallScreen?.notice() ?? ''));
165
168
  events.push(...this.finalizeActive());
166
169
  this.terminal = true;
167
170
  this.completedAt = this.nowSeconds();
@@ -438,6 +441,8 @@ export class ResponsesStreamSerializer {
438
441
  const events = [];
439
442
  const finalRaw = finalArguments ?? state.rawArguments;
440
443
  this.assertToolCapacity(finalRaw.length);
444
+ if (this.toolCallScreen?.blocks(state.localName, finalRaw))
445
+ return this.withholdTool(state);
441
446
  if (!state.announced)
442
447
  events.push(...this.announceTool(state));
443
448
  if (!state.item || state.outputIndex === null)
@@ -503,6 +508,27 @@ export class ResponsesStreamSerializer {
503
508
  }));
504
509
  return events;
505
510
  }
511
+ /**
512
+ * Close a screened call so no client can run it. One never announced stays
513
+ * out of the output; an announced one ends `incomplete` with no arguments.
514
+ */
515
+ withholdTool(state) {
516
+ state.completed = true;
517
+ if (!state.announced || !state.item || state.outputIndex === null)
518
+ return [];
519
+ if (state.item.type === 'function_call')
520
+ state.item.arguments = '';
521
+ else
522
+ state.item.input = '';
523
+ state.item.status = 'incomplete';
524
+ return [
525
+ this.event({
526
+ type: 'response.output_item.done',
527
+ output_index: state.outputIndex,
528
+ item: cloneOutputItem(state.item),
529
+ }),
530
+ ];
531
+ }
506
532
  reconcileToolCalls(calls) {
507
533
  const events = [];
508
534
  const matched = new Set();
@@ -519,6 +545,9 @@ export class ResponsesStreamSerializer {
519
545
  }
520
546
  continue;
521
547
  }
548
+ // Never streamed, so a withheld call need not appear at all.
549
+ if (this.toolCallScreen?.blocks(call.name, call.arguments))
550
+ continue;
522
551
  if (this.active)
523
552
  events.push(...this.finalizeActive());
524
553
  events.push(...this.openTool(call.name, call.id));
@@ -626,6 +655,7 @@ function serializerOptions(model, nowSeconds, options) {
626
655
  ...(options.toolKinds ? { toolKinds: options.toolKinds } : {}),
627
656
  ...(options.toolNamespaces ? { toolNamespaces: options.toolNamespaces } : {}),
628
657
  ...(options.limits ? { limits: options.limits } : {}),
658
+ ...(options.toolCallScreen ? { toolCallScreen: options.toolCallScreen } : {}),
629
659
  echo: {
630
660
  ...options.echo,
631
661
  ...(options.echo?.maxOutputTokens === undefined && options.lengthCapTokens !== undefined
@@ -1,5 +1,6 @@
1
1
  import type { ExternalToolCall, ImageAttachment, LLMSession, SendAndWaitOpts } from '../../providers/types.js';
2
2
  import { type StreamingDiagnostics } from './streaming-telemetry.js';
3
+ import { type ToolCallScreen } from './tool-call-screen.js';
3
4
  /**
4
5
  * One-shot, non-streaming chat completion. Subscribes to usage to fill
5
6
  * the OpenAI `usage` envelope and returns the full response in the
@@ -52,6 +53,8 @@ export interface RunNonStreamingOpts {
52
53
  sendOptions?: Omit<SendAndWaitOpts, 'attachments'>;
53
54
  /** Effective output-token cap — reaching it reports `finish_reason: 'length'`. */
54
55
  lengthCapTokens?: number;
56
+ /** Withholds calls that name an egress indicator; see tool-call-screen.ts. */
57
+ toolCallScreen?: ToolCallScreen;
55
58
  }
56
59
  export declare function runNonStreaming(session: LLMSession, prompt: string, echoModel: string, nowSeconds: () => number, opts?: RunNonStreamingOpts): Promise<NonStreamingChatResult>;
57
60
  /**
@@ -117,6 +120,12 @@ export interface RunStreamingOpts {
117
120
  streamToolCallDeltas?: boolean;
118
121
  /** Tool names supplied by the caller; gates early textual-call detection. */
119
122
  toolCallNames?: readonly string[];
123
+ /**
124
+ * Withholds calls that name an egress indicator; see tool-call-screen.ts.
125
+ * With live tool-call deltas on, each call's closing tail is held back
126
+ * until the call is screened, so the client cannot run it early.
127
+ */
128
+ toolCallScreen?: ToolCallScreen;
120
129
  }
121
130
  export interface RunStreamingResult {
122
131
  content: string;
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { createChatProgressReporter } from './chat-progress.js';
3
3
  import { noteStreamingOutbound, noteStreamingProviderActivity, } from './streaming-telemetry.js';
4
+ import { screenableArgumentsPrefix } from './tool-call-screen.js';
4
5
  import { ToolCallStreamFilter } from './tool-call-stream-filter.js';
5
6
  /**
6
7
  * Truncation detection. An engine that reports its own stop reason
@@ -111,7 +112,7 @@ function streamedJsonObjectEnd(body, start) {
111
112
  return body.length;
112
113
  }
113
114
  export async function runNonStreaming(session, prompt, echoModel, nowSeconds, opts = {}) {
114
- const { attachments, sendOptions, lengthCapTokens } = opts;
115
+ const { attachments, sendOptions, lengthCapTokens, toolCallScreen } = opts;
115
116
  // Wrapped so TypeScript's control-flow narrowing doesn't infer
116
117
  // `usage` as `never` after the post-callback null check — closures
117
118
  // that assign to a captured variable confuse the analyzer.
@@ -127,8 +128,10 @@ export async function runNonStreaming(session, prompt, echoModel, nowSeconds, op
127
128
  ...(attachments && attachments.length > 0 ? { attachments } : {}),
128
129
  }
129
130
  : undefined;
130
- const content = await session.sendAndWait(prompt, sendOpts);
131
- const captured = session.capturedToolCalls?.() ?? [];
131
+ const reply = await session.sendAndWait(prompt, sendOpts);
132
+ const rawCaptured = session.capturedToolCalls?.() ?? [];
133
+ const captured = toolCallScreen ? toolCallScreen.admit(rawCaptured) : rawCaptured;
134
+ const content = `${reply}${toolCallScreen?.notice() ?? ''}`;
132
135
  const hasToolCalls = captured.length > 0;
133
136
  return {
134
137
  id: `chatcmpl-${randomUUID()}`,
@@ -173,7 +176,7 @@ export async function runNonStreaming(session, prompt, echoModel, nowSeconds, op
173
176
  * token).
174
177
  */
175
178
  export async function runStreaming(session, prompt, echoModel, sink, nowSeconds, opts = {}) {
176
- const { attachments, sendOptions, includeReasoning = false, keepaliveIntervalMs = 0, diagnostics, suppressTextualToolCalls = false, includeUsage = false, includeProgress = false, lengthCapTokens, onContentDelta, onReasoningDelta, onToolArgsDelta, streamToolCallDeltas = false, toolCallNames = [], } = opts;
179
+ const { attachments, sendOptions, includeReasoning = false, keepaliveIntervalMs = 0, diagnostics, suppressTextualToolCalls = false, includeUsage = false, includeProgress = false, lengthCapTokens, onContentDelta, onReasoningDelta, onToolArgsDelta, streamToolCallDeltas = false, toolCallNames = [], toolCallScreen, } = opts;
177
180
  const id = `chatcmpl-${randomUUID()}`;
178
181
  if (diagnostics)
179
182
  diagnostics.responseId = id;
@@ -198,6 +201,7 @@ export async function runStreaming(session, prompt, echoModel, sink, nowSeconds,
198
201
  const usageRef = { value: null };
199
202
  let visibleContent = '';
200
203
  let reasoningContent = '';
204
+ const sendableArguments = (received) => toolCallScreen ? screenableArgumentsPrefix(received) : received;
201
205
  const liveToolCalls = [];
202
206
  let activeLiveToolCall;
203
207
  let unnamedToolArguments = '';
@@ -238,7 +242,8 @@ export async function runStreaming(session, prompt, echoModel, sink, nowSeconds,
238
242
  index: meta.index ?? nextLiveToolIndex(),
239
243
  id: meta.id ?? `call_${randomUUID()}`,
240
244
  name,
241
- arguments: argumentsChunk,
245
+ arguments: sendableArguments(argumentsChunk),
246
+ received: argumentsChunk,
242
247
  };
243
248
  liveToolCalls.push(call);
244
249
  activeLiveToolCall = call;
@@ -269,10 +274,15 @@ export async function runStreaming(session, prompt, echoModel, sink, nowSeconds,
269
274
  }, 'tool_calls');
270
275
  return call;
271
276
  };
272
- const appendLiveToolArguments = (call, chunk) => {
277
+ const appendLiveToolArguments = (call, received) => {
278
+ if (!received)
279
+ return;
280
+ call.received += received;
281
+ const next = sendableArguments(call.received);
282
+ const chunk = next.slice(call.arguments.length);
273
283
  if (!chunk)
274
284
  return;
275
- call.arguments += chunk;
285
+ call.arguments = next;
276
286
  enqueueSSE({
277
287
  data: JSON.stringify({
278
288
  id,
@@ -516,9 +526,27 @@ export async function runStreaming(session, prompt, echoModel, sink, nowSeconds,
516
526
  }),
517
527
  }, 'content');
518
528
  }
519
- const captured = session.capturedToolCalls?.() ?? [];
529
+ const rawCaptured = session.capturedToolCalls?.() ?? [];
520
530
  if (diagnostics)
521
- diagnostics.capturedToolCalls = captured.length;
531
+ diagnostics.capturedToolCalls = rawCaptured.length;
532
+ // A withheld call's live stream stops short of its closing tail, so the
533
+ // client is left holding arguments it cannot parse.
534
+ const captured = toolCallScreen ? toolCallScreen.admit(rawCaptured) : rawCaptured;
535
+ const notice = toolCallScreen?.notice();
536
+ if (notice) {
537
+ visibleContent += notice;
538
+ onContentDelta?.(notice);
539
+ await writeSSE({
540
+ data: JSON.stringify({
541
+ id,
542
+ object: 'chat.completion.chunk',
543
+ created,
544
+ model: echoModel,
545
+ choices: [{ index: 0, delta: { content: notice }, finish_reason: null }],
546
+ ...nullUsage,
547
+ }),
548
+ }, 'content');
549
+ }
522
550
  const hasToolCalls = captured.length > 0;
523
551
  if (hasToolCalls) {
524
552
  if (!streamToolCallDeltas || liveToolCalls.length === 0) {
@@ -554,6 +582,17 @@ export async function runStreaming(session, prompt, echoModel, sink, nowSeconds,
554
582
  // any suffix that did not arrive live; never replay the complete
555
583
  // arguments, which would make clients concatenate invalid JSON.
556
584
  const matchedLive = new Set();
585
+ // Take the withheld calls' streams out of the running first, so an
586
+ // admitted call of the same tool name cannot be matched onto one.
587
+ for (const call of rawCaptured) {
588
+ if (captured.includes(call))
589
+ continue;
590
+ const live = liveToolCalls.find((candidate) => !matchedLive.has(candidate) &&
591
+ (candidate.id === call.id ||
592
+ (candidate.name === call.name && call.arguments.startsWith(candidate.arguments))));
593
+ if (live)
594
+ matchedLive.add(live);
595
+ }
557
596
  for (const call of captured) {
558
597
  const live = liveToolCalls.find((candidate) => !matchedLive.has(candidate) &&
559
598
  (candidate.id === call.id || candidate.name === call.name));
@@ -565,6 +604,7 @@ export async function runStreaming(session, prompt, echoModel, sink, nowSeconds,
565
604
  id: call.id,
566
605
  name: call.name,
567
606
  arguments: call.arguments,
607
+ received: call.arguments,
568
608
  });
569
609
  await writeSSE({
570
610
  data: JSON.stringify({
@@ -0,0 +1,28 @@
1
+ import { type EgressIndicator } from '@bendyline/gezel';
2
+ import type { ExternalToolCall } from '@bendyline/gezel/local-loop';
3
+ /**
4
+ * Screens the tool calls a `/v1` turn hands back to the calling app.
5
+ *
6
+ * On these surfaces the app runs the tools, often with a real shell (Codex,
7
+ * OpenCode, Pi on a local model), so the response is the last place Gezel
8
+ * sees a call before it runs. A call naming an egress indicator (core
9
+ * `security/egress-indicators.ts`) never reaches the app as a runnable call;
10
+ * the reply says so instead.
11
+ */
12
+ export interface ToolCallScreen {
13
+ /** The calls the app may run. Records the rest. */
14
+ admit(calls: readonly ExternalToolCall[]): ExternalToolCall[];
15
+ /** True when this call must not reach the app. Records it. */
16
+ blocks(name: string, args: string): boolean;
17
+ /** A line for the visible reply once anything was blocked. */
18
+ notice(): string | undefined;
19
+ }
20
+ export declare function createToolCallScreen(onBlocked?: (tool: string, indicators: EgressIndicator[]) => void): ToolCallScreen;
21
+ /**
22
+ * The part of a live-streamed argument string that is safe to send before
23
+ * the call has been screened: everything but a trailing run of closing
24
+ * brackets and whitespace. A tool call's arguments are a JSON object, which
25
+ * must end in `}`, so a client cannot parse — and so cannot run — a call
26
+ * until the screened tail arrives. Grows monotonically as `received` grows.
27
+ */
28
+ export declare function screenableArgumentsPrefix(received: string): string;
@@ -0,0 +1,43 @@
1
+ import { describeEgressIndicators, findEgressIndicators, findEgressIndicatorsInValue, } from '@bendyline/gezel';
2
+ export function createToolCallScreen(onBlocked) {
3
+ const blocked = [];
4
+ const seen = new Set();
5
+ const blocks = (name, args) => {
6
+ const indicators = indicatorsIn(args);
7
+ if (indicators.length === 0)
8
+ return false;
9
+ // The serializer and the final reconciliation can both see one call.
10
+ const key = `${name}\u0000${args}`;
11
+ if (!seen.has(key)) {
12
+ seen.add(key);
13
+ blocked.push(`${name} (it named ${describeEgressIndicators(indicators)})`);
14
+ onBlocked?.(name, indicators);
15
+ }
16
+ return true;
17
+ };
18
+ return {
19
+ admit: (calls) => calls.filter((call) => !blocks(call.name, call.arguments)),
20
+ blocks,
21
+ notice: () => blocked.length === 0
22
+ ? undefined
23
+ : `\n\n[Gezel stopped ${blocked.length === 1 ? 'a tool call' : `${blocked.length} tool calls`} before ${blocked.length === 1 ? 'it' : 'they'} ran: ${blocked.join('; ')}.]`,
24
+ };
25
+ }
26
+ function indicatorsIn(args) {
27
+ try {
28
+ return findEgressIndicatorsInValue(JSON.parse(args));
29
+ }
30
+ catch {
31
+ return findEgressIndicators(args);
32
+ }
33
+ }
34
+ /**
35
+ * The part of a live-streamed argument string that is safe to send before
36
+ * the call has been screened: everything but a trailing run of closing
37
+ * brackets and whitespace. A tool call's arguments are a JSON object, which
38
+ * must end in `}`, so a client cannot parse — and so cannot run — a call
39
+ * until the screened tail arrives. Grows monotonically as `received` grows.
40
+ */
41
+ export function screenableArgumentsPrefix(received) {
42
+ return received.replace(/[\s}\]]+$/, '');
43
+ }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { createLogger, isLocalProvider } from '@bendyline/gezel';
2
+ import { createLogger, isLocalProvider, toolCallBlockedHistoryEntry, } from '@bendyline/gezel';
3
3
  import { Hono } from 'hono';
4
4
  import { streamSSE } from 'hono/streaming';
5
5
  import { ZodError } from 'zod';
@@ -14,6 +14,7 @@ import { engineFailure } from '../openai-compat/engine-failure.js';
14
14
  import { overlayOpenAiRequestTuning } from '../openai-compat/request-tuning.js';
15
15
  import { createStreamingDiagnostics, snapshotStreamingDiagnostics, } from '../openai-compat/streaming-telemetry.js';
16
16
  import { runNonStreaming, runStreaming } from '../openai-compat/streaming.js';
17
+ import { createToolCallScreen } from '../openai-compat/tool-call-screen.js';
17
18
  import { ChatCompletionRequestSchema, flattenTranscriptIntoPrompt, parseGezelModelRef, resolveModelTarget, translateMessagesWithPrefix, } from '../openai-compat/translate.js';
18
19
  const log = createLogger('v1-chat');
19
20
  // Match ChatManager's user-turn ceilings. Caller-owned app routes create a
@@ -402,6 +403,17 @@ export function v1ChatRoutes(ctx, opts = {}) {
402
403
  ctx.chat.recordExternalUsage?.(target.provider, u);
403
404
  });
404
405
  const appId = c.get('auth')?.appId ?? 'unauthenticated';
406
+ const toolCallScreen = createToolCallScreen((tool, indicators) => {
407
+ const entry = toolCallBlockedHistoryEntry({
408
+ tool,
409
+ surface: 'openai',
410
+ indicators,
411
+ appId,
412
+ ...(externalTurn ? { sessionId: externalTurn.sessionId } : {}),
413
+ });
414
+ log.warn(`${entry.summary} (app ${appId})`);
415
+ void ctx.history?.log(entry).catch(() => { });
416
+ });
405
417
  const logCompletion = () => {
406
418
  void ctx.history
407
419
  ?.log({
@@ -482,6 +494,7 @@ export function v1ChatRoutes(ctx, opts = {}) {
482
494
  ...(opts.keepaliveIntervalMs !== undefined
483
495
  ? { keepaliveIntervalMs: opts.keepaliveIntervalMs }
484
496
  : {}),
497
+ toolCallScreen,
485
498
  ...(opts.streamToolCallDeltas === true ? { streamToolCallDeltas: true } : {}),
486
499
  ...(opts.streamToolCallDeltas === true && externalTools
487
500
  ? { toolCallNames: externalTools.map((tool) => tool.name) }
@@ -574,6 +587,7 @@ export function v1ChatRoutes(ctx, opts = {}) {
574
587
  ...(attachments.length > 0 ? { attachments } : {}),
575
588
  sendOptions: { queue, timeoutMs: turnTimeoutMs },
576
589
  ...(lengthCapTokens !== undefined ? { lengthCapTokens } : {}),
590
+ toolCallScreen,
577
591
  });
578
592
  const choice = response.choices[0];
579
593
  await externalTurn
@@ -1,4 +1,4 @@
1
- import { createLogger } from '@bendyline/gezel';
1
+ import { createLogger, toolCallBlockedHistoryEntry } from '@bendyline/gezel';
2
2
  import { Hono } from 'hono';
3
3
  import { bodyLimit } from 'hono/body-limit';
4
4
  import { streamSSE } from 'hono/streaming';
@@ -11,6 +11,7 @@ import { resolveChatTarget, resolvePinnedModelTarget, } from '../openai-compat/c
11
11
  import { engineFailure } from '../openai-compat/engine-failure.js';
12
12
  import { runResponsesNonStreaming, runResponsesStreaming, } from '../openai-compat/responses-streaming.js';
13
13
  import { ResponsesRequestSchema, translateResponsesRequest, } from '../openai-compat/responses-translate.js';
14
+ import { createToolCallScreen } from '../openai-compat/tool-call-screen.js';
14
15
  import { flattenTranscriptIntoPrompt, parseGezelModelRef, resolveModelTarget, } from '../openai-compat/translate.js';
15
16
  const log = createLogger('v1-responses');
16
17
  const MAX_RESPONSES_BODY_BYTES = 16 * 1024 * 1024;
@@ -180,8 +181,19 @@ export function v1ResponsesRoutes(ctx, opts = {}) {
180
181
  const detachRequestAbort = () => {
181
182
  requestSignal.removeEventListener('abort', abortTurn);
182
183
  };
184
+ const toolCallScreen = createToolCallScreen((tool, indicators) => {
185
+ const entry = toolCallBlockedHistoryEntry({
186
+ tool,
187
+ surface: 'responses',
188
+ indicators,
189
+ appId,
190
+ });
191
+ log.warn(`${entry.summary} (app ${appId})`);
192
+ void ctx.history?.log(entry).catch(() => { });
193
+ });
183
194
  const runnerOptions = {
184
195
  ...(attachments.length > 0 ? { attachments } : {}),
196
+ toolCallScreen,
185
197
  ...(lengthCapTokens !== undefined ? { lengthCapTokens } : {}),
186
198
  toolBindings: translated.toolBindings,
187
199
  toolKinds: translated.toolKinds,
@@ -118,6 +118,8 @@ export interface ImageGenerationMeta {
118
118
  * forward it untouched.
119
119
  */
120
120
  img2imgSkippedReason?: string;
121
+ /** Provider limits or adjustments to the requested image settings. */
122
+ settingsNotes?: string[];
121
123
  }
122
124
  export interface ImageGenerationOutput {
123
125
  png: Buffer;
@@ -32,6 +32,13 @@ import { classifyStartupLine } from './stdout-parser.js';
32
32
  export * from '@bendyline/gezel/local-loop';
33
33
  export { isFileRepairTurn as isScenarioFileRepairTurn } from '@bendyline/gezel/local-loop';
34
34
  const log = createLogger('llama-cpp');
35
+ /**
36
+ * How long shutdown waits for the slot caches to reach disk. The app gives
37
+ * the whole daemon 15 s to quit, and a save the engine never answers must
38
+ * not spend all of it: an unsaved or half-written slot only means a fresh
39
+ * prefill next time, because a restore that fails falls back to one.
40
+ */
41
+ const SHUTDOWN_FLUSH_BUDGET_MS = 8_000;
35
42
  export class LlamaCppProvider {
36
43
  name = 'llama-cpp';
37
44
  queue;
@@ -627,12 +634,21 @@ export class LlamaCppProvider {
627
634
  // llama-server exits the slot save endpoint is gone. flushAll is
628
635
  // best-effort; failures during shutdown shouldn't block the stop.
629
636
  if (this.cacheAdapter) {
637
+ let timer;
638
+ const budget = new Promise((resolve) => {
639
+ timer = setTimeout(() => resolve('timeout'), SHUTDOWN_FLUSH_BUDGET_MS);
640
+ timer.unref?.();
641
+ });
630
642
  try {
631
- await this.cacheAdapter.flushAll();
643
+ // Best-effort persistence: a failure only means the next boot pays
644
+ // the prefill cost on resume.
645
+ const outcome = await Promise.race([this.cacheAdapter.flushAll().catch(() => 0), budget]);
646
+ if (outcome === 'timeout') {
647
+ log.warn(`slot cache save still running after ${SHUTDOWN_FLUSH_BUDGET_MS}ms at shutdown; stopping the engine`);
648
+ }
632
649
  }
633
- catch {
634
- // Best-effort persistence — failure here just means the next
635
- // boot pays the prefill cost on resume, same as before.
650
+ finally {
651
+ clearTimeout(timer);
636
652
  }
637
653
  }
638
654
  await this.supervisor?.stop();
@@ -1,4 +1,4 @@
1
- import type { CodexPermissionModeCompat, ProviderName } from '@bendyline/gezel';
1
+ import type { CodexPermissionModeCompat, EgressIndicator, ProviderName } from '@bendyline/gezel';
2
2
  import type { ActiveCraftbookStep, CompactionRequester, ExternalToolCall, ExternalToolSpec, LLMSession, ProviderSessionState, TerminalToolPolicy, WireTranscriptEntry } from '@bendyline/gezel/local-loop';
3
3
  import type { McpServerSpec } from '@bendyline/gezel/local-loop';
4
4
  import type { ResolvedModelProfile } from '../model-profile/types.js';
@@ -537,6 +537,12 @@ export interface ToolCallEvent {
537
537
  durationMs: number;
538
538
  success: boolean;
539
539
  errorMessage?: string;
540
+ /**
541
+ * Set when Gezel refused the call before it ran because it named a cloud
542
+ * metadata service, link-local address, data-drop or tunnel host, or
543
+ * namespace tool. The consumer records it in History.
544
+ */
545
+ egressIndicators?: EgressIndicator[];
540
546
  /**
541
547
  * Text returned by the tool when the provider exposes it. Producers must
542
548
  * redact known secrets before firing the event. ChatManager bounds this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/gezel-inference",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Gezel's on-device inference runtime: native engines, local model management, embeddings, knowledge search, and OpenAI-compatible routes.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -73,9 +73,9 @@
73
73
  "!dist/**/__pycache__"
74
74
  ],
75
75
  "dependencies": {
76
- "@bendyline/gezel": "1.2.9",
77
- "@bendyline/gezel-catalog": "1.2.9",
78
- "@bendyline/gezel-client": "1.2.9",
76
+ "@bendyline/gezel": "1.2.11",
77
+ "@bendyline/gezel-catalog": "1.2.11",
78
+ "@bendyline/gezel-client": "1.2.11",
79
79
  "@bendyline/gezel-knowledge": "1.2.9",
80
80
  "hono": "4.13.9",
81
81
  "tar": "7.5.21",