dsh-lcx-codex 0.4.2 → 0.4.3-pre.13
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/README.md +75 -224
- package/THIRD_PARTY_NOTICES.md +64 -0
- package/cordis.patch.yml +3 -20
- package/lib/auxiliary-usage.js +63 -0
- package/lib/client.js +1398 -167
- package/lib/compact-v2.js +218 -199
- package/lib/dsh-compat.js +294 -100
- package/lib/dsh-responses.js +512 -277
- package/lib/grok-native-search.js +391 -0
- package/lib/index.js +1066 -758
- package/lib/invocation-policy-scope.js +261 -0
- package/lib/json-store.js +57 -31
- package/lib/native-checkpoint.js +520 -194
- package/lib/pi-responses-runtime.js +1571 -0
- package/lib/responses-request.js +109 -121
- package/lib/responses-stream.js +1280 -447
- package/lib/route.js +425 -369
- package/lib/search-accounting.js +86 -0
- package/lib/search-usage.js +86 -0
- package/lib/service-mutex.js +73 -64
- package/lib/token-budget.js +176 -108
- package/lib/transport.js +308 -68
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/search-media.d.ts +16 -0
- package/lib/types/index.d.ts +83 -0
- package/lib/web-run-output.js +189 -18
- package/lib/web-search-alpha.js +1067 -163
- package/lib/web-search-capability.js +80 -65
- package/lib/web-search-hosted.js +321 -33
- package/lib/web-search-ref-store.js +145 -60
- package/package.json +112 -32
- package/ARCHITECTURE.md +0 -117
- package/CHANGELOG.md +0 -224
- package/README_EN.md +0 -277
- package/assets/dsh-lcx-codex-banner.jpg +0 -0
- package/lib/legacy-v3.js +0 -20
- package/lib/responses-replay.js +0 -68
- package/scripts/probe-alpha.mjs +0 -43
- package/scripts/validate-dsh-schema.mjs +0 -31
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
2
|
+
const count = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
3
|
+
export const zeroBuckets = () => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
|
|
4
|
+
export const isSearchTool = (name) => name === 'web_search' || name === 'websearch_gpt_advanced';
|
|
5
|
+
export function auxiliaryUsageOf(event, toolName) {
|
|
6
|
+
if (!object(event) || event.type !== 'tool/result' || !object(event.data?.meta))
|
|
7
|
+
return [];
|
|
8
|
+
// Only our owned search results may contribute auxiliary billing.
|
|
9
|
+
// Official ToolMessageSource carries only callId; resolve its name from tool/call.
|
|
10
|
+
if (!isSearchTool(toolName))
|
|
11
|
+
return [];
|
|
12
|
+
const raw = event.data.meta.auxiliaryUsage;
|
|
13
|
+
if (!Array.isArray(raw))
|
|
14
|
+
return [];
|
|
15
|
+
const ids = new Set(), result = [];
|
|
16
|
+
for (const item of raw) {
|
|
17
|
+
if (!object(item) || typeof item.requestId !== 'string' || !item.requestId
|
|
18
|
+
|| typeof item.provider !== 'string' || !item.provider || typeof item.model !== 'string' || !item.model
|
|
19
|
+
|| !object(item.usage) || ids.has(item.requestId))
|
|
20
|
+
continue;
|
|
21
|
+
const u = item.usage;
|
|
22
|
+
if (![u.inputTokens, u.outputTokens, u.totalTokens, u.cacheReadTokens, u.cacheWriteTokens].every(count)
|
|
23
|
+
|| u.inputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens !== u.totalTokens)
|
|
24
|
+
continue;
|
|
25
|
+
ids.add(item.requestId);
|
|
26
|
+
result.push(item);
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
export function addUsage(base, records) {
|
|
31
|
+
if (!records.length)
|
|
32
|
+
return base;
|
|
33
|
+
const next = { ...base };
|
|
34
|
+
for (const { usage: u } of records) {
|
|
35
|
+
next.uncachedInputTokens += u.inputTokens;
|
|
36
|
+
next.outputTokens += u.outputTokens;
|
|
37
|
+
next.cacheReadTokens += u.cacheReadTokens;
|
|
38
|
+
next.cacheWriteTokens += u.cacheWriteTokens;
|
|
39
|
+
}
|
|
40
|
+
if (!Object.values(next).every(count))
|
|
41
|
+
throw new Error('LCX search usage exceeds safe counters');
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
export function mergeBuckets(base, extra) {
|
|
45
|
+
if (!object(base) || Object.values(extra).every(n => n === 0))
|
|
46
|
+
return base;
|
|
47
|
+
const next = { ...base };
|
|
48
|
+
for (const key of Object.keys(extra)) {
|
|
49
|
+
// Keep unavailable host buckets unavailable rather than inventing zero.
|
|
50
|
+
if (count(base[key]))
|
|
51
|
+
next[key] = base[key] + extra[key];
|
|
52
|
+
}
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
55
|
+
export function addTurnUsage(base, records) {
|
|
56
|
+
if (!object(base) || !records.length || !count(base.totalTokens))
|
|
57
|
+
return base;
|
|
58
|
+
const next = mergeBuckets(base, addUsage(zeroBuckets(), records));
|
|
59
|
+
next.totalTokens = base.totalTokens + records.reduce((n, r) => n + r.usage.totalTokens, 0);
|
|
60
|
+
// Auxiliary responses do not always disclose a reasoning subset.
|
|
61
|
+
delete next.reasoningTokens;
|
|
62
|
+
if (Array.isArray(base.routes)) {
|
|
63
|
+
const routes = new Map();
|
|
64
|
+
for (const r of [...base.routes, ...records])
|
|
65
|
+
routes.set(`${r.provider}\0${r.model}`, { provider: r.provider, model: r.model });
|
|
66
|
+
next.routes = [...routes.values()];
|
|
67
|
+
}
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
/** The metadata is presentation/accounting state; it never changes request messages. */
|
|
71
|
+
export function aggregateContextOf(event) {
|
|
72
|
+
if (!object(event) || !['assistant/message', 'assistant/attempt'].includes(event.type) || !object(event.data))
|
|
73
|
+
return;
|
|
74
|
+
const chunks = Array.isArray(event.data.stream) ? event.data.stream.filter((e) => e.type === 'chunk').map((e) => e.chunk) : [];
|
|
75
|
+
const sample = event.data.usage ?? chunks.findLast((c) => c?.type === 'usage')?.usage;
|
|
76
|
+
if (!object(sample))
|
|
77
|
+
return;
|
|
78
|
+
const replay = chunks.findLast((c) => c?.type === 'finish')?.replayState;
|
|
79
|
+
const mark = replay?.response?.lcxUsage;
|
|
80
|
+
if (mark?.version === 1 && ['request', 'aggregate'].includes(mark.inputTokenScope))
|
|
81
|
+
return mark.inputTokenScope === 'aggregate';
|
|
82
|
+
// Read the already-installed local candidate without rewriting its logs.
|
|
83
|
+
if (sample.inputTokenScope === 'aggregate' || sample.inputTokenScope === 'request')
|
|
84
|
+
return sample.inputTokenScope === 'aggregate';
|
|
85
|
+
return replay?.grokNative?.kind === 'xai-responses-native-search' && replay.grokNative.version === 3;
|
|
86
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { SessionSeq } from '@deepseek-ai/dsh-session';
|
|
3
|
+
import { addUsage, aggregateContextOf, auxiliaryUsageOf, isSearchTool, zeroBuckets } from './search-accounting.js';
|
|
4
|
+
const n = z.number().int().nonnegative();
|
|
5
|
+
const schema = z.object({ auxiliary: z.object({ uncachedInputTokens: n, outputTokens: n, cacheReadTokens: n, cacheWriteTokens: n }).strict(), aggregateContext: z.boolean() }).strict();
|
|
6
|
+
/** Separate, durable extension: the host's own usage projection remains primary-call billing. */
|
|
7
|
+
export const searchUsageProjection = {
|
|
8
|
+
key: 'lcxSearchUsage', stateVersion: 2, stateSchema: schema.extend({ pending: z.record(z.string(), z.string()) }),
|
|
9
|
+
init: () => ({ auxiliary: zeroBuckets(), aggregateContext: false, pending: {} }),
|
|
10
|
+
apply(state, event) {
|
|
11
|
+
if (event.type === 'tool/call' && isSearchTool(event.data.name))
|
|
12
|
+
return { ...state, pending: { ...state.pending, [event.data.callId]: event.data.name } };
|
|
13
|
+
const callId = event.type === 'tool/result' ? event.data.message.source.callId : undefined;
|
|
14
|
+
const auxiliary = addUsage(state.auxiliary, auxiliaryUsageOf(event, callId ? state.pending[callId] : undefined));
|
|
15
|
+
const aggregateContext = aggregateContextOf(event) ?? state.aggregateContext;
|
|
16
|
+
if (callId && Object.hasOwn(state.pending, callId)) {
|
|
17
|
+
const pending = { ...state.pending };
|
|
18
|
+
delete pending[callId];
|
|
19
|
+
return { auxiliary, aggregateContext, pending };
|
|
20
|
+
}
|
|
21
|
+
if (event.type === 'turn/end' && Object.keys(state.pending).length)
|
|
22
|
+
return { auxiliary, aggregateContext, pending: {} };
|
|
23
|
+
return auxiliary === state.auxiliary && aggregateContext === state.aggregateContext ? state : { ...state, auxiliary, aggregateContext };
|
|
24
|
+
},
|
|
25
|
+
wire: { viewSchema: schema, view: ({ auxiliary, aggregateContext }) => ({ auxiliary, aggregateContext }) },
|
|
26
|
+
};
|
|
27
|
+
const installed = new WeakMap();
|
|
28
|
+
/** Own a reversible adapter on the public measure method, never a DSH file or private fold. */
|
|
29
|
+
export function installSearchMeasurement(meter) {
|
|
30
|
+
const existing = installed.get(meter);
|
|
31
|
+
if (existing) {
|
|
32
|
+
existing.refs++;
|
|
33
|
+
let active = true;
|
|
34
|
+
return () => { if (active) {
|
|
35
|
+
active = false;
|
|
36
|
+
release(meter);
|
|
37
|
+
} };
|
|
38
|
+
}
|
|
39
|
+
const original = meter.measure, descriptor = Object.getOwnPropertyDescriptor(meter, 'measure');
|
|
40
|
+
const cursors = new WeakMap();
|
|
41
|
+
function measure(session, header) {
|
|
42
|
+
const value = original.call(this, session, header);
|
|
43
|
+
let state = cursors.get(session) ?? { seq: 0, aggregate: false };
|
|
44
|
+
while (state.seq < session.seq) {
|
|
45
|
+
const e = session.eventAt(SessionSeq(state.seq++));
|
|
46
|
+
if (e?.type === 'assistant/message')
|
|
47
|
+
state.aggregate = aggregateContextOf(e) ?? false;
|
|
48
|
+
}
|
|
49
|
+
cursors.set(session, state);
|
|
50
|
+
if (!state.aggregate || value.baseline.kind !== 'usage')
|
|
51
|
+
return value;
|
|
52
|
+
// The official measure already prices retained images/files and surface replacements.
|
|
53
|
+
// Only discard its unsuitable aggregate anchor; keep its current surface and node prices.
|
|
54
|
+
const tools = (header ?? session.requestHeader())?.tools;
|
|
55
|
+
const toolTokens = !tools?.length ? 0 : Math.ceil(JSON.stringify(tools).length / 4) + 4;
|
|
56
|
+
const tokens = value.surfaceTokens + toolTokens;
|
|
57
|
+
return Object.freeze({ ...value, baseline: Object.freeze({ kind: 'estimated', tokens }), surfaceDeltaTokens: 0, totalTokens: tokens });
|
|
58
|
+
}
|
|
59
|
+
Object.defineProperty(meter, 'measure', { configurable: true, writable: true, value: measure });
|
|
60
|
+
installed.set(meter, { refs: 1, release() {
|
|
61
|
+
if (Object.getOwnPropertyDescriptor(meter, 'measure')?.value !== measure)
|
|
62
|
+
return;
|
|
63
|
+
if (descriptor)
|
|
64
|
+
Object.defineProperty(meter, 'measure', descriptor);
|
|
65
|
+
else
|
|
66
|
+
delete meter.measure;
|
|
67
|
+
} });
|
|
68
|
+
let active = true;
|
|
69
|
+
return () => { if (active) {
|
|
70
|
+
active = false;
|
|
71
|
+
release(meter);
|
|
72
|
+
} };
|
|
73
|
+
}
|
|
74
|
+
function release(meter) {
|
|
75
|
+
const record = installed.get(meter);
|
|
76
|
+
if (record && --record.refs === 0) {
|
|
77
|
+
record.release();
|
|
78
|
+
installed.delete(meter);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function installSearchUsage(ctx) {
|
|
82
|
+
ctx.inject(['sessionProjections'], c => { c.sessionProjections.register(searchUsageProjection); });
|
|
83
|
+
ctx.inject(['tokenMeter'], c => {
|
|
84
|
+
c.effect(() => installSearchMeasurement(c.tokenMeter), 'lcx search context measurement');
|
|
85
|
+
});
|
|
86
|
+
}
|
package/lib/service-mutex.js
CHANGED
|
@@ -1,72 +1,81 @@
|
|
|
1
1
|
function abortReason(signal) {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
if (signal?.reason instanceof Error)
|
|
3
|
+
return signal.reason;
|
|
4
|
+
return new DOMException("The operation was aborted", "AbortError");
|
|
4
5
|
}
|
|
5
|
-
|
|
6
6
|
export class ServiceMutex {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
7
|
+
locked = false;
|
|
8
|
+
closed = false;
|
|
9
|
+
closeReason;
|
|
10
|
+
queue = [];
|
|
11
|
+
idleWaiters = [];
|
|
12
|
+
constructor() { }
|
|
13
|
+
acquire(signal) {
|
|
14
|
+
if (this.closed)
|
|
15
|
+
return Promise.reject(this.closeReason ?? new Error("service mutex closed"));
|
|
16
|
+
if (signal?.aborted)
|
|
17
|
+
return Promise.reject(abortReason(signal));
|
|
18
|
+
if (!this.locked) {
|
|
19
|
+
this.locked = true;
|
|
20
|
+
return Promise.resolve(() => this.release());
|
|
21
|
+
}
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const waiter = { resolve, reject, signal };
|
|
24
|
+
if (signal) {
|
|
25
|
+
const onAbort = () => {
|
|
26
|
+
const index = this.queue.indexOf(waiter);
|
|
27
|
+
if (index >= 0)
|
|
28
|
+
this.queue.splice(index, 1);
|
|
29
|
+
signal.removeEventListener("abort", onAbort);
|
|
30
|
+
reject(abortReason(signal));
|
|
31
|
+
};
|
|
32
|
+
waiter.onAbort = onAbort;
|
|
33
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
34
|
+
}
|
|
35
|
+
this.queue.push(waiter);
|
|
36
|
+
});
|
|
21
37
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
release() {
|
|
39
|
+
while (this.queue.length > 0) {
|
|
40
|
+
const waiter = this.queue.shift();
|
|
41
|
+
if (!waiter)
|
|
42
|
+
continue;
|
|
43
|
+
if (waiter.signal && waiter.onAbort)
|
|
44
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
45
|
+
if (waiter.signal?.aborted) {
|
|
46
|
+
waiter.reject(abortReason(waiter.signal));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
waiter.resolve(() => this.release());
|
|
50
|
+
return;
|
|
30
51
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
release() {
|
|
38
|
-
while (this.queue.length > 0) {
|
|
39
|
-
const waiter = this.queue.shift()
|
|
40
|
-
if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener('abort', waiter.onAbort)
|
|
41
|
-
if (waiter.signal?.aborted) {
|
|
42
|
-
waiter.reject(abortReason(waiter.signal))
|
|
43
|
-
continue
|
|
44
|
-
}
|
|
45
|
-
waiter.resolve(() => this.release())
|
|
46
|
-
return
|
|
52
|
+
this.locked = false;
|
|
53
|
+
const idle = this.idleWaiters.splice(0);
|
|
54
|
+
for (const resolve of idle)
|
|
55
|
+
resolve();
|
|
47
56
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
57
|
+
async run(signal, task) {
|
|
58
|
+
const release = await this.acquire(signal);
|
|
59
|
+
try {
|
|
60
|
+
return await task();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
release();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
close(reason = new Error("service mutex closing")) {
|
|
67
|
+
if (!this.closed) {
|
|
68
|
+
this.closed = true;
|
|
69
|
+
this.closeReason = reason;
|
|
70
|
+
const queued = this.queue.splice(0);
|
|
71
|
+
for (const waiter of queued) {
|
|
72
|
+
if (waiter.signal && waiter.onAbort)
|
|
73
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
74
|
+
waiter.reject(reason);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!this.locked)
|
|
78
|
+
return Promise.resolve();
|
|
79
|
+
return new Promise((resolve) => this.idleWaiters.push(resolve));
|
|
68
80
|
}
|
|
69
|
-
if (!this.locked) return Promise.resolve()
|
|
70
|
-
return new Promise((resolve) => this.idleWaiters.push(resolve))
|
|
71
|
-
}
|
|
72
81
|
}
|
package/lib/token-budget.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const ASCII_WORD = /[A-Za-z0-9]/u
|
|
10
|
-
const STRUCTURAL = /[\p{P}\p{S}]/u
|
|
11
|
-
const DATA_URI = /^data:[^;,\s]+(?:;[^,\s]*)?;base64,[A-Za-z0-9+/\s]+={0,2}$/u
|
|
12
|
-
const ENCODED_TEXT = /^[A-Za-z0-9+/_-]{512,}={0,2}$/u
|
|
13
|
-
|
|
1
|
+
export const PORTABLE_BUDGET_ERROR_CODE = "LCX_PORTABLE_BUDGET_EXCEEDED";
|
|
2
|
+
const MAX_BUDGET_INPUT_CHARS = 2_000_000;
|
|
3
|
+
const CONSERVATIVE_IMAGE_TOKEN_COST = 2_048;
|
|
4
|
+
const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
5
|
+
const ASCII_WORD = /[A-Za-z0-9]/u;
|
|
6
|
+
const STRUCTURAL = /[\p{P}\p{S}]/u;
|
|
7
|
+
const DATA_URI = /^data:[^;,\s]+(?:;[^,\s]*)?;base64,[A-Za-z0-9+/\s]+={0,2}$/u;
|
|
8
|
+
const ENCODED_TEXT = /^[A-Za-z0-9+/_-]{512,}={0,2}$/u;
|
|
14
9
|
/** @param {string} value */
|
|
15
|
-
function looksEncodedText(value) {
|
|
16
|
-
|
|
10
|
+
function looksEncodedText(value) {
|
|
11
|
+
return DATA_URI.test(value) || ENCODED_TEXT.test(value);
|
|
12
|
+
}
|
|
17
13
|
/**
|
|
18
14
|
* A small conservative fallback, deliberately not a tokenizer. The baseline
|
|
19
15
|
* preserves legacy /4 while CJK and structural characters cost more.
|
|
@@ -21,84 +17,133 @@ function looksEncodedText(value) { return DATA_URI.test(value) || ENCODED_TEXT.t
|
|
|
21
17
|
* @returns {number | undefined}
|
|
22
18
|
*/
|
|
23
19
|
export function estimateTextTokens(value) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
20
|
+
if (typeof value !== "string")
|
|
21
|
+
return undefined;
|
|
22
|
+
if (value.length > MAX_BUDGET_INPUT_CHARS || looksEncodedText(value))
|
|
23
|
+
return Math.max(1, value.length);
|
|
24
|
+
let weighted = 0;
|
|
25
|
+
let asciiRun = 0;
|
|
26
|
+
const flushAscii = () => {
|
|
27
|
+
if (asciiRun > 0)
|
|
28
|
+
weighted += Math.ceil(asciiRun / 4);
|
|
29
|
+
asciiRun = 0;
|
|
30
|
+
};
|
|
31
|
+
for (const char of value) {
|
|
32
|
+
if (ASCII_WORD.test(char)) {
|
|
33
|
+
asciiRun += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
flushAscii();
|
|
37
|
+
if (/\s/u.test(char))
|
|
38
|
+
continue;
|
|
39
|
+
if (CJK.test(char) || STRUCTURAL.test(char))
|
|
40
|
+
weighted += 1;
|
|
41
|
+
else
|
|
42
|
+
weighted += Math.ceil(char.length / 2);
|
|
43
|
+
}
|
|
44
|
+
flushAscii();
|
|
45
|
+
return Math.max(1, Math.ceil(value.length / 4), weighted);
|
|
38
46
|
}
|
|
39
|
-
|
|
40
47
|
/**
|
|
41
48
|
* @param {unknown} value
|
|
42
49
|
* @returns {value is UnknownRecord}
|
|
43
50
|
*/
|
|
44
|
-
function isObject(value) {
|
|
51
|
+
function isObject(value) {
|
|
52
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
}
|
|
45
54
|
/**
|
|
46
55
|
* @param {unknown} value
|
|
47
56
|
* @param {Set<object>} [seen]
|
|
48
57
|
* @returns {boolean}
|
|
49
58
|
*/
|
|
50
59
|
function containsOpaqueValue(value, seen = new Set()) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
if (value === null || typeof value !== "object")
|
|
61
|
+
return false;
|
|
62
|
+
if (ArrayBuffer.isView(value) ||
|
|
63
|
+
value instanceof ArrayBuffer ||
|
|
64
|
+
seen.has(value))
|
|
65
|
+
return true;
|
|
66
|
+
seen.add(value);
|
|
67
|
+
return Object.values(value).some((entry) => containsOpaqueValue(entry, seen));
|
|
55
68
|
}
|
|
56
69
|
/** @param {unknown} value */
|
|
57
|
-
function safeJson(value) {
|
|
70
|
+
function safeJson(value) {
|
|
71
|
+
try {
|
|
72
|
+
if (containsOpaqueValue(value))
|
|
73
|
+
return undefined;
|
|
74
|
+
const encoded = JSON.stringify(value);
|
|
75
|
+
return typeof encoded === "string" ? encoded : undefined;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
58
81
|
/** @param {unknown} value */
|
|
59
|
-
function safeScalar(value) {
|
|
60
|
-
|
|
82
|
+
function safeScalar(value) {
|
|
83
|
+
return typeof value === "string" ||
|
|
84
|
+
typeof value === "number" ||
|
|
85
|
+
typeof value === "boolean"
|
|
86
|
+
? String(value)
|
|
87
|
+
: undefined;
|
|
88
|
+
}
|
|
61
89
|
/**
|
|
62
90
|
* @param {unknown} content
|
|
63
91
|
* @returns {unknown[] | undefined}
|
|
64
92
|
*/
|
|
65
93
|
function visibleContent(content) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
if (typeof content === "string")
|
|
95
|
+
return [{ type: "text", text: content }];
|
|
96
|
+
if (!Array.isArray(content))
|
|
97
|
+
return undefined;
|
|
98
|
+
const parts = [];
|
|
99
|
+
for (const part of content) {
|
|
100
|
+
if (!isObject(part) || typeof part.type !== "string")
|
|
101
|
+
return undefined;
|
|
102
|
+
if (["text", "input_text", "output_text"].includes(part.type)) {
|
|
103
|
+
if (typeof part.text !== "string")
|
|
104
|
+
return undefined;
|
|
105
|
+
parts.push({ type: part.type, text: part.text });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (part.type === "reasoning") {
|
|
109
|
+
if (typeof part.text !== "string")
|
|
110
|
+
return undefined;
|
|
111
|
+
parts.push({ type: "reasoning", text: part.text });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (part.type === "tool-call") {
|
|
115
|
+
const id = safeScalar(part.id);
|
|
116
|
+
const name = safeScalar(part.name);
|
|
117
|
+
const argumentsText = safeJson(part.arguments);
|
|
118
|
+
if (id === undefined || name === undefined || argumentsText === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
parts.push({ type: "tool-call", id, name, arguments: argumentsText });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (part.type === "tool-result") {
|
|
124
|
+
const toolCallId = safeScalar(part.toolCallId);
|
|
125
|
+
const toolName = safeScalar(part.toolName ?? part.name ?? "unknown");
|
|
126
|
+
const nested = visibleContent(part.content);
|
|
127
|
+
if (toolCallId === undefined ||
|
|
128
|
+
toolName === undefined ||
|
|
129
|
+
nested === undefined)
|
|
130
|
+
return undefined;
|
|
131
|
+
parts.push({
|
|
132
|
+
type: "tool-result",
|
|
133
|
+
toolCallId,
|
|
134
|
+
toolName,
|
|
135
|
+
content: nested,
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (["image", "input_image", "output_image", "dsh_image_attachment"].includes(part.type)) {
|
|
140
|
+
parts.push({ type: part.type, image: true });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
75
144
|
}
|
|
76
|
-
|
|
77
|
-
if (typeof part.text !== 'string') return undefined
|
|
78
|
-
parts.push({ type: 'reasoning', text: part.text })
|
|
79
|
-
continue
|
|
80
|
-
}
|
|
81
|
-
if (part.type === 'tool-call') {
|
|
82
|
-
const id = safeScalar(part.id); const name = safeScalar(part.name); const argumentsText = safeJson(part.arguments)
|
|
83
|
-
if (id === undefined || name === undefined || argumentsText === undefined) return undefined
|
|
84
|
-
parts.push({ type: 'tool-call', id, name, arguments: argumentsText })
|
|
85
|
-
continue
|
|
86
|
-
}
|
|
87
|
-
if (part.type === 'tool-result') {
|
|
88
|
-
const toolCallId = safeScalar(part.toolCallId); const toolName = safeScalar(part.toolName ?? part.name ?? 'unknown'); const nested = visibleContent(part.content)
|
|
89
|
-
if (toolCallId === undefined || toolName === undefined || nested === undefined) return undefined
|
|
90
|
-
parts.push({ type: 'tool-result', toolCallId, toolName, content: nested })
|
|
91
|
-
continue
|
|
92
|
-
}
|
|
93
|
-
if (['image', 'input_image', 'output_image', 'dsh_image_attachment'].includes(part.type)) {
|
|
94
|
-
parts.push({ type: part.type, image: true })
|
|
95
|
-
continue
|
|
96
|
-
}
|
|
97
|
-
return undefined
|
|
98
|
-
}
|
|
99
|
-
return parts
|
|
145
|
+
return parts;
|
|
100
146
|
}
|
|
101
|
-
|
|
102
147
|
/**
|
|
103
148
|
* Project an item to model-visible fields only. Opaque provider state, raw
|
|
104
149
|
* binary, and replay/session metadata are intentionally excluded.
|
|
@@ -106,21 +151,39 @@ function visibleContent(content) {
|
|
|
106
151
|
* @returns {unknown | undefined}
|
|
107
152
|
*/
|
|
108
153
|
export function modelVisibleBudgetView(item) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
if (!isObject(item))
|
|
155
|
+
return undefined;
|
|
156
|
+
if (item.type === "function_call") {
|
|
157
|
+
const callId = safeScalar(item.call_id);
|
|
158
|
+
const name = safeScalar(item.name);
|
|
159
|
+
const argumentsText = safeJson(item.arguments);
|
|
160
|
+
return callId === undefined ||
|
|
161
|
+
name === undefined ||
|
|
162
|
+
argumentsText === undefined
|
|
163
|
+
? undefined
|
|
164
|
+
: {
|
|
165
|
+
type: "function_call",
|
|
166
|
+
call_id: callId,
|
|
167
|
+
name,
|
|
168
|
+
arguments: argumentsText,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (item.type === "function_call_output") {
|
|
172
|
+
const callId = safeScalar(item.call_id);
|
|
173
|
+
const output = typeof item.output === "string" ? item.output : safeJson(item.output);
|
|
174
|
+
return callId === undefined || output === undefined
|
|
175
|
+
? undefined
|
|
176
|
+
: { type: "function_call_output", call_id: callId, output };
|
|
177
|
+
}
|
|
178
|
+
if (item.type !== undefined && item.type !== "message")
|
|
179
|
+
return undefined;
|
|
180
|
+
if (!["developer", "system", "user", "assistant"].includes(String(item.role ?? "")))
|
|
181
|
+
return undefined;
|
|
182
|
+
const content = visibleContent(item.content);
|
|
183
|
+
return content === undefined
|
|
184
|
+
? undefined
|
|
185
|
+
: { role: String(item.role), content };
|
|
122
186
|
}
|
|
123
|
-
|
|
124
187
|
/**
|
|
125
188
|
* Images have provider/model-dependent costs. Count each known image block with
|
|
126
189
|
* a fixed conservative surcharge without inspecting base64 or attachment data.
|
|
@@ -128,38 +191,43 @@ export function modelVisibleBudgetView(item) {
|
|
|
128
191
|
* @returns {number}
|
|
129
192
|
*/
|
|
130
193
|
function imageTokenCost(value) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
194
|
+
if (Array.isArray(value)) {
|
|
195
|
+
let total = 0;
|
|
196
|
+
for (const entry of value)
|
|
197
|
+
total += imageTokenCost(entry);
|
|
198
|
+
return total;
|
|
199
|
+
}
|
|
200
|
+
if (!isObject(value))
|
|
201
|
+
return 0;
|
|
202
|
+
let total = value.image === true ? CONSERVATIVE_IMAGE_TOKEN_COST : 0;
|
|
203
|
+
for (const entry of Object.values(value))
|
|
204
|
+
total += imageTokenCost(entry);
|
|
205
|
+
return total;
|
|
140
206
|
}
|
|
141
|
-
|
|
142
207
|
/**
|
|
143
208
|
* @param {unknown} item
|
|
144
209
|
* @returns {number | undefined}
|
|
145
210
|
*/
|
|
146
211
|
export function estimateBudgetItem(item) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
212
|
+
const view = modelVisibleBudgetView(item);
|
|
213
|
+
if (view === undefined)
|
|
214
|
+
return undefined;
|
|
215
|
+
const encoded = safeJson(view);
|
|
216
|
+
const textCost = estimateTextTokens(encoded);
|
|
217
|
+
return textCost === undefined ? undefined : textCost + imageTokenCost(view);
|
|
152
218
|
}
|
|
153
|
-
|
|
154
219
|
/** @param {unknown} maxChars */
|
|
155
220
|
export function portableTokenCeiling(maxChars) {
|
|
156
|
-
|
|
221
|
+
return typeof maxChars === "number" &&
|
|
222
|
+
Number.isSafeInteger(maxChars) &&
|
|
223
|
+
maxChars > 0
|
|
224
|
+
? Math.ceil(maxChars / 4)
|
|
225
|
+
: undefined;
|
|
157
226
|
}
|
|
158
|
-
|
|
159
227
|
/** @param {string} message */
|
|
160
228
|
export function portableBudgetError(message) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
229
|
+
/** @type {Error & { code?: string }} */
|
|
230
|
+
const error = new Error(message);
|
|
231
|
+
error.code = PORTABLE_BUDGET_ERROR_CODE;
|
|
232
|
+
return error;
|
|
165
233
|
}
|