@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.
- package/dist/catalog/provenance.json +1 -1
- package/dist/engines/resolver.d.ts +1 -1
- package/dist/engines/resolver.js +47 -51
- package/dist/http/openai-compat/responses-streaming.d.ts +13 -0
- package/dist/http/openai-compat/responses-streaming.js +30 -0
- package/dist/http/openai-compat/streaming.d.ts +9 -0
- package/dist/http/openai-compat/streaming.js +49 -9
- package/dist/http/openai-compat/tool-call-screen.d.ts +28 -0
- package/dist/http/openai-compat/tool-call-screen.js +43 -0
- package/dist/http/routes/v1-chat.js +15 -1
- package/dist/http/routes/v1-responses.js +13 -1
- package/dist/providers/image/types.d.ts +2 -0
- package/dist/providers/llama-cpp/provider.js +20 -4
- package/dist/providers/types.d.ts +7 -1
- package/package.json +4 -4
|
@@ -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` =
|
|
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;
|
package/dist/engines/resolver.js
CHANGED
|
@@ -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,
|
|
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
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
const
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
131
|
-
const
|
|
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,
|
|
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
|
|
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
|
|
529
|
+
const rawCaptured = session.capturedToolCalls?.() ?? [];
|
|
520
530
|
if (diagnostics)
|
|
521
|
-
diagnostics.capturedToolCalls =
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
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.
|
|
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.
|
|
77
|
-
"@bendyline/gezel-catalog": "1.2.
|
|
78
|
-
"@bendyline/gezel-client": "1.2.
|
|
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",
|