@arcfold/dsh 0.0.0-stage → 0.4.0
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/LICENSE +21 -0
- package/README.md +8 -2
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/cordis.patch.yml +138 -0
- package/package.json +70 -4
- package/patches/deepseek-0.2.2-rc.1-transport.json +65 -0
- package/patches/deepseek-0.2.2-rc.1-transport.patch +61 -0
- package/presets/arcfold.patch.yml +110 -0
- package/runtime/README.md +19 -0
- package/runtime/bootstrap.json +15 -0
- package/runtime/desktop-python.tar.gz +0 -0
- package/runtime/host/package.json +11 -0
- package/runtime/host/pnpm-workspace.yaml +9 -0
- package/runtime/initial-policy.json +94 -0
- package/runtime/profile/pnpm-workspace.yaml +7 -0
- package/scripts/exact-package-patch.mjs +106 -0
- package/scripts/patch-deepseek-transport.mjs +14 -0
- package/src/agent.mjs +56 -0
- package/src/browse-scope.mjs +14 -0
- package/src/budget.mjs +369 -0
- package/src/business.mjs +334 -0
- package/src/context-material.mjs +119 -0
- package/src/deepseek-budget-plugin.mjs +58 -0
- package/src/deepseek-provider.mjs +57 -0
- package/src/deepseek-receipt.mjs +97 -0
- package/src/deepseek-transport.mjs +97 -0
- package/src/desktop/adapter.mjs +16 -0
- package/src/desktop/bootstrap.mjs +84 -0
- package/src/desktop/configuration.mjs +117 -0
- package/src/desktop/provider.mjs +46 -0
- package/src/desktop/python-runtime.mjs +105 -0
- package/src/desktop/setup-service.mjs +81 -0
- package/src/desktop/vendor/LICENSE.deepseek +21 -0
- package/src/desktop/vendor/NOTICE.md +5 -0
- package/src/desktop/vendor/deepseek-0.2.2-rc.1.mjs +2919 -0
- package/src/evidence-ownership.mjs +51 -0
- package/src/evidence.mjs +35 -0
- package/src/goal-context.mjs +52 -0
- package/src/goal-execution-tool.mjs +74 -0
- package/src/goal-execution.mjs +333 -0
- package/src/goal-tool.mjs +56 -0
- package/src/goal-work-control.mjs +63 -0
- package/src/goal-work.mjs +257 -0
- package/src/goals.mjs +578 -0
- package/src/importer.mjs +298 -0
- package/src/index.mjs +53 -0
- package/src/learning-contracts.mjs +220 -0
- package/src/learning-defaults.mjs +98 -0
- package/src/learning-goals.mjs +165 -0
- package/src/learning-plugin.mjs +28 -0
- package/src/learning-runner.mjs +275 -0
- package/src/learning.mjs +534 -0
- package/src/legacy-export.mjs +332 -0
- package/src/legacy-import-source.mjs +356 -0
- package/src/metered-provider-host.mjs +101 -0
- package/src/metered-transport.mjs +435 -0
- package/src/method-context.mjs +57 -0
- package/src/method-lifecycle.mjs +34 -0
- package/src/method-tool.mjs +61 -0
- package/src/methods.mjs +425 -0
- package/src/model-selection.mjs +41 -0
- package/src/native-context-call.mjs +88 -0
- package/src/native-continuation.mjs +217 -0
- package/src/native-request-authority.mjs +90 -0
- package/src/openai-models.mjs +4 -0
- package/src/openai-receipt.mjs +98 -0
- package/src/openai-transport.mjs +167 -0
- package/src/openai-usage.mjs +194 -0
- package/src/opportunities.mjs +295 -0
- package/src/preparation-artifacts.mjs +213 -0
- package/src/preparation-plugin.mjs +290 -0
- package/src/runtime-command.mjs +264 -0
- package/src/runtime-config.mjs +417 -0
- package/src/runtime-guard.mjs +284 -0
- package/src/runtime-host.mjs +212 -0
- package/src/runtime-init.mjs +278 -0
- package/src/runtime-installation.mjs +518 -0
- package/src/runtime-lifecycle.mjs +227 -0
- package/src/runtime-model-settings.mjs +111 -0
- package/src/runtime-readiness.mjs +285 -0
- package/src/runtime-workspace.mjs +18 -0
- package/src/session-bridge.mjs +365 -0
- package/src/store.mjs +394 -0
- package/src/tree/LICENSE +21 -0
- package/src/tree/attribution.mjs +120 -0
- package/src/tree/clusters.mjs +61 -0
- package/src/tree/consolidation.mjs +103 -0
- package/src/tree/context.mjs +211 -0
- package/src/tree/contracts.mjs +142 -0
- package/src/tree/experience.mjs +140 -0
- package/src/tree/experiment-budget.mjs +84 -0
- package/src/tree/hooks.mjs +254 -0
- package/src/tree/host-context.mjs +107 -0
- package/src/tree/host-process.mjs +288 -0
- package/src/tree/host.mjs +37 -0
- package/src/tree/improvement.mjs +106 -0
- package/src/tree/items.mjs +294 -0
- package/src/tree/jobs.mjs +184 -0
- package/src/tree/learning.mjs +202 -0
- package/src/tree/metrics.mjs +203 -0
- package/src/tree/model-owner.mjs +254 -0
- package/src/tree/observations.mjs +73 -0
- package/src/tree/prompts.mjs +500 -0
- package/src/tree/reports.mjs +67 -0
- package/src/tree/repository.mjs +236 -0
- package/src/tree/runtime.mjs +745 -0
- package/src/tree/script-writer.mjs +149 -0
- package/src/tree/scripts.mjs +208 -0
- package/src/tree/signals.mjs +118 -0
- package/src/tree/snapshots.mjs +379 -0
- package/src/tree/token-counter.mjs +75 -0
- package/src/tree/token-counter.py +37 -0
- package/src/tree/tokenizer-resources/LICENSE +21 -0
- package/src/tree/tokenizer-resources/README.md +31 -0
- package/src/tree/tokenizer-resources/deepseek_recipe-0.2.0-cp310-abi3-macosx_11_0_arm64.whl +0 -0
- package/src/tree/tokenizer-resources/manifest.json +61 -0
- package/src/tree/tokenizer-resources/native.pyi +1042 -0
- package/src/tree/tokenizer-resources/tokenizer-NOTICE.md +26 -0
- package/src/tree/tokenizer-resources/tokenizer-v4-NOTICE.md +27 -0
- package/src/tree/tokenizer-resources/tokenizer-v4.json +267359 -0
- package/src/tree/tokenizer-resources/tokenizer.json +267359 -0
- package/src/tree/tools.mjs +99 -0
- package/src/tree/tree.mjs +202 -0
- package/src/tree/ui.mjs +285 -0
- package/src/tree/verification.mjs +143 -0
- package/src/understandings.mjs +309 -0
- package/src/web-budget-recovery.mjs +181 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const sha256 = value => createHash('sha256').update(value).digest('hex');
|
|
7
|
+
const save = (path, value) => writeFileSync(path, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
8
|
+
|
|
9
|
+
export function createExactPackagePatch(patchSpecPath, { strictReceiptParent = true } = {}) {
|
|
10
|
+
const patchSpec = JSON.parse(readFileSync(patchSpecPath, 'utf8'));
|
|
11
|
+
function exactFile(packageDir, relativePath) {
|
|
12
|
+
assert.ok(!isAbsolute(relativePath) && !relativePath.split(/[\\/]/u).includes('..'));
|
|
13
|
+
let current = packageDir;
|
|
14
|
+
for (const part of relativePath.split('/')) {
|
|
15
|
+
current = join(current, part);
|
|
16
|
+
assert.equal(lstatSync(current).isSymbolicLink(), false, `Refusing symlink: ${current}`);
|
|
17
|
+
}
|
|
18
|
+
assert.equal(lstatSync(current).isFile(), true);
|
|
19
|
+
return current;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Verify all bytes before any write. Unknown/mixed versions are never repaired. */
|
|
23
|
+
function inspect(packageDirectory) {
|
|
24
|
+
assert.equal(typeof packageDirectory, 'string');
|
|
25
|
+
const packageDir = resolve(packageDirectory);
|
|
26
|
+
assert.equal(realpathSync(packageDir), packageDir, 'Package path must not traverse a symlink');
|
|
27
|
+
const metadata = JSON.parse(readFileSync(exactFile(packageDir, 'package.json'), 'utf8'));
|
|
28
|
+
assert.equal(metadata.name, patchSpec.package); assert.equal(metadata.version, patchSpec.version);
|
|
29
|
+
const files = patchSpec.files.map(file => {
|
|
30
|
+
const bytes = readFileSync(exactFile(packageDir, file.path)), actualSha256 = sha256(bytes);
|
|
31
|
+
const state = actualSha256 === file.beforeSha256 ? 'original' : actualSha256 === file.afterSha256 ? 'patched' : 'unknown';
|
|
32
|
+
assert.notEqual(state, 'unknown', `Unrecognized package bytes: ${file.path} (${actualSha256})`);
|
|
33
|
+
return { path: file.path, actualSha256, state };
|
|
34
|
+
});
|
|
35
|
+
assert.equal(new Set(files.map(file => file.state)).size, 1, 'Mixed/partial patch state; preserve and inspect the prior receipt');
|
|
36
|
+
return { patchId: patchSpec.id, packageDir, package: metadata.name, version: metadata.version,
|
|
37
|
+
patchSpecSha256: sha256(readFileSync(patchSpecPath)), state: files[0].state, files };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function transformed(bytes, file) {
|
|
41
|
+
let text = bytes.toString('utf8');
|
|
42
|
+
assert.equal(sha256(bytes), file.beforeSha256);
|
|
43
|
+
for (const edit of file.edits) {
|
|
44
|
+
assert.equal(text.split(edit.before).length, 2, `Patch anchor must occur once: ${file.path}`);
|
|
45
|
+
text = text.replace(edit.before, edit.after);
|
|
46
|
+
}
|
|
47
|
+
assert.equal(sha256(text), file.afterSha256, `Patch output differs: ${file.path}`);
|
|
48
|
+
return text;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Back up original bytes first; emit an exclusive receipt for success or failure. */
|
|
52
|
+
function apply({ packageDir, receiptDir }) {
|
|
53
|
+
const before = inspect(packageDir);
|
|
54
|
+
const destination = resolve(receiptDir);
|
|
55
|
+
if (strictReceiptParent) assert.equal(realpathSync(dirname(destination)), dirname(destination), 'Receipt parent must not traverse a symlink');
|
|
56
|
+
assert.ok(destination !== before.packageDir && !destination.startsWith(before.packageDir + sep), 'Receipt must be outside the package');
|
|
57
|
+
const changes = before.state === 'original' ? patchSpec.files.map(file => {
|
|
58
|
+
const path = exactFile(before.packageDir, file.path), bytes = readFileSync(path);
|
|
59
|
+
return { file, path, bytes, after: transformed(bytes, file), mode: lstatSync(path).mode & 0o777 };
|
|
60
|
+
}) : [];
|
|
61
|
+
mkdirSync(destination, { mode: 0o700 }); // Existing evidence is never overwritten.
|
|
62
|
+
save(join(destination, 'before.json'), before);
|
|
63
|
+
const changedFiles = [];
|
|
64
|
+
try {
|
|
65
|
+
for (const change of changes) {
|
|
66
|
+
const backup = join(destination, 'original', change.file.path);
|
|
67
|
+
mkdirSync(dirname(backup), { recursive: true, mode: 0o700 });
|
|
68
|
+
writeFileSync(backup, change.bytes, { flag: 'wx', mode: 0o600 });
|
|
69
|
+
}
|
|
70
|
+
// Recheck all input bytes after backups and before the first replacement.
|
|
71
|
+
assert.deepEqual(inspect(before.packageDir), before);
|
|
72
|
+
for (const change of changes) {
|
|
73
|
+
assert.equal(sha256(readFileSync(change.path)), change.file.beforeSha256);
|
|
74
|
+
const temporary = join(dirname(change.path), `.${basename(change.path)}.arcfold-patch-${randomUUID()}.tmp`);
|
|
75
|
+
writeFileSync(temporary, change.after, { flag: 'wx', mode: change.mode });
|
|
76
|
+
renameSync(temporary, change.path);
|
|
77
|
+
changedFiles.push(change.file.path);
|
|
78
|
+
}
|
|
79
|
+
const after = inspect(before.packageDir); assert.equal(after.state, 'patched');
|
|
80
|
+
const report = { completed: true, applied: changes.length > 0, changedFiles, before, after };
|
|
81
|
+
save(join(destination, 'report.json'), report);
|
|
82
|
+
return report;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
save(join(destination, 'failure.json'), { completed: false, changedFiles,
|
|
85
|
+
error: { name: error.name, message: error.message }, recovery: 'Do not auto-retry or roll back a partial install; inspect the preserved original bytes and receipt.' });
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { patchSpec, inspect, apply };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function runExactPatchCLI({ inspect, apply: applyPatch }, args) {
|
|
94
|
+
let packageDir, receiptDir, apply = false;
|
|
95
|
+
for (let index = 0; index < args.length; index++) {
|
|
96
|
+
const flag = args[index];
|
|
97
|
+
if (flag === '--apply') { apply = true; continue; }
|
|
98
|
+
assert.ok(['--package-dir', '--receipt-dir'].includes(flag), `Unknown option ${flag}`);
|
|
99
|
+
const value = args[++index]; assert.ok(value && !value.startsWith('--'));
|
|
100
|
+
if (flag === '--package-dir') packageDir = value; else receiptDir = value;
|
|
101
|
+
}
|
|
102
|
+
assert.ok(packageDir, 'Pass --package-dir for the new isolated npm package; no default installation is selected');
|
|
103
|
+
if (apply) assert.ok(receiptDir, '--apply requires a new --receipt-dir');
|
|
104
|
+
else assert.equal(receiptDir, undefined, '--receipt-dir requires --apply');
|
|
105
|
+
console.log(JSON.stringify(apply ? applyPatch({ packageDir, receiptDir }) : inspect(packageDir), null, 2));
|
|
106
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createExactPackagePatch, runExactPatchCLI } from './exact-package-patch.mjs';
|
|
5
|
+
|
|
6
|
+
export const patchSpecPath = resolve(dirname(fileURLToPath(import.meta.url)), '../patches/deepseek-0.2.2-rc.1-transport.json');
|
|
7
|
+
const patch = createExactPackagePatch(patchSpecPath, { strictReceiptParent: false });
|
|
8
|
+
export const { patchSpec } = patch;
|
|
9
|
+
export const inspectDeepSeekPatch = patch.inspect;
|
|
10
|
+
export const applyDeepSeekPatch = patch.apply;
|
|
11
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
12
|
+
try { runExactPatchCLI(patch, process.argv.slice(2)); }
|
|
13
|
+
catch (error) { console.error(JSON.stringify({ completed: false, error: error.message })); process.exitCode = 1; }
|
|
14
|
+
}
|
package/src/agent.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as workControl from './goal-work-control.mjs';
|
|
2
|
+
import * as methodTool from './method-tool.mjs';
|
|
3
|
+
import * as goalTool from './goal-tool.mjs';
|
|
4
|
+
import * as goalExecution from './goal-execution-tool.mjs';
|
|
5
|
+
import { scopeOf } from '@deepseek-ai/dsh-scope';
|
|
6
|
+
|
|
7
|
+
export const name = 'arcfold-agent';
|
|
8
|
+
export const inject = ['systemPrompt', 'tools', 'agents'];
|
|
9
|
+
export function apply(ctx) {
|
|
10
|
+
// Presets are activated before the business service and before an Agent
|
|
11
|
+
// exists. React to the selected service instead of freezing an early read.
|
|
12
|
+
ctx.inject(['arcfoldTreeRuntime'], child => {
|
|
13
|
+
const tree = child.arcfoldTreeRuntime, current = scopeOf(child);
|
|
14
|
+
if (current?.session) return tree.mountMain(child, current);
|
|
15
|
+
child.on('agent/created', async ({ agent }) => {
|
|
16
|
+
if (tree.mainAgents.has(agent.session.id)) return;
|
|
17
|
+
const fiber = await agent.ctx.plugin({
|
|
18
|
+
name: 'arcfold-tree-main-session', inject: ['systemPrompt', 'tools', 'agents'],
|
|
19
|
+
apply(owner) { tree.mountMain(owner, agent); },
|
|
20
|
+
});
|
|
21
|
+
await fiber.await();
|
|
22
|
+
if (fiber.state !== 2) throw new Error('The Arcfold main-session adapter did not activate');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
ctx.inject(['arcfoldBridge', 'arcfoldMethods', 'arcfoldGoals', 'arcfoldWork'], applyLegacy);
|
|
26
|
+
}
|
|
27
|
+
function applyLegacy(ctx) {
|
|
28
|
+
// This plugin belongs ONLY to the main Arcfold preset. Auxiliary learning
|
|
29
|
+
// sessions use their own preset and cannot recursively enter this pipeline.
|
|
30
|
+
ctx.arcfoldBridge.useNativeRuntime(ctx.root);
|
|
31
|
+
ctx.plugin(methodTool);
|
|
32
|
+
ctx.plugin(goalTool);
|
|
33
|
+
ctx.plugin(goalExecution);
|
|
34
|
+
ctx.plugin(workControl);
|
|
35
|
+
ctx.tools.guard(exec => ctx.arcfoldBridge.nativeContinuationToolDenial(exec));
|
|
36
|
+
ctx.systemPrompt.section({ name: 'arcfold:collaboration', order: 50, interpolate: false,
|
|
37
|
+
text: '你是 Arcfold,帮助用户澄清和推进意图,并让已经完成的共同认识影响后续工作。当前用户原话、纠正与授权优先;个人背景有来源、时间和适用范围,不会扩大工具权限。已有方法先以目录展示;适用时单独调用 arcfold_use_methods,收到完整指导后再行动,不与其他工具同批调用。方法选择只是你对适用性的判断,不证明方法有效,不代表用户确认继续采用。已有长期目标先显示目录;确有关联时单独调用 arcfold_link_goal,读取完整定义与原件后再推进当前委托,说明本次可能的贡献。普通交流不必强行关联目标。暂停或结束追踪的目标,只有用户明确选回后才围绕它讨论,讨论不恢复追踪;对本轮真实用户原话的目标追踪解释由既有后台学习整理,属于可纠正理解,不是新执行授权;用户不必手动管理目标。当前阶段、继续、等待、修改和恢复由 Arcfold 根据真实对话统一决定,DSH 执行获准的具体工作。执行时使用宿主工具,按实际回执报告成果、失败与未知。已有答复不等于长期目标已经达成,助手建议不等于用户决定。宿主执行目标的自动续轮须有 Arcfold 对当前阶段发出的单次许可,程序通知仍不是新用户原话;nativeContinuation 保留最初委托和定义变化的原始引用。标为 native_control、actor unrecorded 的控制只证明宿主目标定义发生变化,不能当成用户新表达、长期目标控制、修复依据或新增工具权限。' });
|
|
38
|
+
ctx.systemPrompt.section({ name: 'arcfold:expression', order: 51, interpolate: false,
|
|
39
|
+
text: `面向用户表达时,保持清楚、简单、顺畅。
|
|
40
|
+
|
|
41
|
+
围绕当前任务组织内容,用准确、易懂的语言,让每句话都有作用,前后意思自然衔接。
|
|
42
|
+
|
|
43
|
+
根据任务和用户要求选择合适的详略、语气与形式。简单问题直接回答,复杂问题充分展开,清楚交代必要的依据、条件、步骤和结论的把握程度。
|
|
44
|
+
|
|
45
|
+
输出前检查:内容是否充分,表达是否精练,阅读是否顺畅。用直接、自然的方式,把意思完整地说清楚。` });
|
|
46
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
47
|
+
await ctx.arcfoldWork.awaitControl(payload.agent);
|
|
48
|
+
const decision = await next();
|
|
49
|
+
if (decision.kind !== 'reject') {
|
|
50
|
+
ctx.arcfoldWork.ownerEntered(payload.agent, decision.messages);
|
|
51
|
+
try { ctx.arcfoldWork.admit(payload.agent, decision.messages, payload.turn); }
|
|
52
|
+
catch { return { kind: 'reject' }; }
|
|
53
|
+
}
|
|
54
|
+
return ctx.arcfoldBridge.prepare(payload, decision);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { DomainError } from './store.mjs';
|
|
2
|
+
|
|
3
|
+
// An owner's display filter. This never changes stored scope or model context.
|
|
4
|
+
export function browseScope(value, fallback = 'current') {
|
|
5
|
+
if (value === undefined) return fallback;
|
|
6
|
+
if (value === 'all' || value === 'current') return value;
|
|
7
|
+
throw new DomainError('invalid_browse_scope', '请选择全部积累或与当前聊天相关的内容。');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function browseContext(row, sessionId) {
|
|
11
|
+
const local = row.scope?.conversationId;
|
|
12
|
+
return { related: !local || local === sessionId, scope: local ? 'conversation' : 'global',
|
|
13
|
+
sourceSessionId: local || row.conversationId || row.sessionId || null };
|
|
14
|
+
}
|
package/src/budget.mjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// Monetary arithmetic and ledger transitions retained from Arcfold 3.0.
|
|
2
|
+
import { Store, DomainError, jsonClone } from './store.mjs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const MICRO = 1_000_000;
|
|
6
|
+
const PRICE_SCALE = 1_000_000_000n;
|
|
7
|
+
const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
|
|
8
|
+
const now = () => new Date().toISOString();
|
|
9
|
+
|
|
10
|
+
function decimalScaled(value, scaleDigits, name) {
|
|
11
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new DomainError('invalid_budget', `${name} must be a finite nonnegative number.`, 400);
|
|
12
|
+
// String conversion produces the decimal value declared by the caller; do not
|
|
13
|
+
// multiply binary floating-point prices into cumulative monetary balances.
|
|
14
|
+
const match = String(value).match(/^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i);
|
|
15
|
+
if (!match) throw new DomainError('invalid_budget', `${name} is not a supported decimal.`, 400);
|
|
16
|
+
const fraction = match[2] ?? '', exponent = Number(match[3] ?? 0);
|
|
17
|
+
const shift = scaleDigits + exponent - fraction.length;
|
|
18
|
+
let number = BigInt(match[1] + fraction);
|
|
19
|
+
if (shift >= 0) number *= 10n ** BigInt(shift);
|
|
20
|
+
else {
|
|
21
|
+
const divisor = 10n ** BigInt(-shift);
|
|
22
|
+
if (number % divisor !== 0n) throw new DomainError('invalid_budget', `${name} has too many decimal places.`, 400);
|
|
23
|
+
number /= divisor;
|
|
24
|
+
}
|
|
25
|
+
if (number > BigInt(Number.MAX_SAFE_INTEGER)) throw new DomainError('invalid_budget', `${name} exceeds safe accounting limits.`, 400);
|
|
26
|
+
return number;
|
|
27
|
+
}
|
|
28
|
+
function amountMicro(value) {
|
|
29
|
+
const amount = decimalScaled(value, 6, 'ceilingCny');
|
|
30
|
+
if (amount < 1n) throw new DomainError('invalid_budget', 'Budget ceiling must be at least one micro-yuan.', 400);
|
|
31
|
+
return Number(amount);
|
|
32
|
+
}
|
|
33
|
+
function tokens(value, name, { positive = false } = {}) {
|
|
34
|
+
if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0)) throw new DomainError('invalid_budget', `${name} must be a ${positive ? 'positive' : 'nonnegative'} integer.`, 400);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function string(value, name, max = 512) {
|
|
38
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || value.includes('\0')) throw new DomainError('invalid_budget', `${name} is required.`, 400);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function priceValues(prices) {
|
|
42
|
+
const copy = jsonClone(prices);
|
|
43
|
+
if (!copy || typeof copy !== 'object' || Array.isArray(copy) || Object.keys(copy).sort().join(',') !== 'cachedInput,input,output') {
|
|
44
|
+
throw new DomainError('invalid_budget', 'Prices require exactly input, output and cachedInput in CNY per million tokens.', 400);
|
|
45
|
+
}
|
|
46
|
+
for (const key of ['input', 'output', 'cachedInput']) decimalScaled(copy[key], 9, `${key} price`);
|
|
47
|
+
return copy;
|
|
48
|
+
}
|
|
49
|
+
function costMicro(prompt, completion, cached, prices, reserve = false) {
|
|
50
|
+
const input = decimalScaled(prices.input, 9, 'input price');
|
|
51
|
+
const cachedInput = decimalScaled(prices.cachedInput, 9, 'cachedInput price');
|
|
52
|
+
const output = decimalScaled(prices.output, 9, 'output price');
|
|
53
|
+
const numerator = reserve
|
|
54
|
+
? BigInt(prompt) * (input > cachedInput ? input : cachedInput) + BigInt(completion) * output
|
|
55
|
+
: BigInt(prompt - cached) * input + BigInt(cached) * cachedInput + BigInt(completion) * output;
|
|
56
|
+
const result = (numerator + PRICE_SCALE - 1n) / PRICE_SCALE;
|
|
57
|
+
if (result > BigInt(Number.MAX_SAFE_INTEGER)) throw new DomainError('budget_exceeded', 'Request exceeds accounting capacity.');
|
|
58
|
+
return Number(result);
|
|
59
|
+
}
|
|
60
|
+
function safeMetadata(value) {
|
|
61
|
+
const copy = jsonClone(value);
|
|
62
|
+
if (!copy || typeof copy !== 'object' || Array.isArray(copy)) throw new DomainError('invalid_budget', 'Metadata must be an object.', 400);
|
|
63
|
+
const inspect = item => {
|
|
64
|
+
if (!item || typeof item !== 'object') return;
|
|
65
|
+
for (const [key, child] of Object.entries(item)) {
|
|
66
|
+
if (/^(authorization|api[-_]?key|secret|password|access[-_]?token|refresh[-_]?token|credentials?)$/i.test(key)) {
|
|
67
|
+
throw new DomainError('sensitive_metadata', 'Credentials cannot be stored in budget metadata.', 400);
|
|
68
|
+
}
|
|
69
|
+
inspect(child);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
inspect(copy); return copy;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function webBudgetIdentity(webAuthorization) {
|
|
76
|
+
if (!webAuthorization || typeof webAuthorization !== 'object' || Array.isArray(webAuthorization) ||
|
|
77
|
+
Object.keys(webAuthorization).sort().join(',') !== 'authorizationEvidence,authorizationRef,authorizedAt')
|
|
78
|
+
throw new DomainError('invalid_web_authorization', 'An exact explicit web authorization is required.', 400);
|
|
79
|
+
const { authorizationRef, authorizedAt, authorizationEvidence } = webAuthorization;
|
|
80
|
+
string(authorizationRef, 'webAuthorization.authorizationRef', 4096);
|
|
81
|
+
string(authorizedAt, 'webAuthorization.authorizedAt');
|
|
82
|
+
string(authorizationEvidence, 'webAuthorization.authorizationEvidence', 8000);
|
|
83
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(authorizedAt) || !Number.isFinite(Date.parse(authorizedAt)))
|
|
84
|
+
throw new DomainError('invalid_web_authorization', 'A valid explicit web authorization timestamp is required.', 400);
|
|
85
|
+
return { id: 'budget-web-' + createHash('sha256').update(authorizationRef).digest('hex'),
|
|
86
|
+
specification: { scope: 'web-unlimited', authorizationRef, authorizedAt: new Date(authorizedAt).toISOString(), authorizationEvidence } };
|
|
87
|
+
}
|
|
88
|
+
function freezeWebBudget(ledger) {
|
|
89
|
+
Object.freeze(ledger.webAuthorization);
|
|
90
|
+
Object.defineProperties(ledger, {
|
|
91
|
+
mode: { value: 'web-only', enumerable: true },
|
|
92
|
+
scope: { value: 'web-unlimited', writable: false, configurable: false, enumerable: true },
|
|
93
|
+
webAuthorization: { value: ledger.webAuthorization, writable: false, configurable: false, enumerable: true },
|
|
94
|
+
webAuthorizationId: { value: ledger.webAuthorizationId, writable: false, configurable: false, enumerable: true },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class BudgetLedger {
|
|
99
|
+
static fromWebBudgetStore(store, webAuthorization) {
|
|
100
|
+
const expected = webBudgetIdentity(webAuthorization);
|
|
101
|
+
if (!(store instanceof Store) || !store.webBudget ||
|
|
102
|
+
canonical(store.webBudgetIdentity) !== canonical(expected))
|
|
103
|
+
throw new DomainError('web_budget_identity_conflict', 'The recovered store must retain its exact authority.');
|
|
104
|
+
store.assertWebBudgetSchema();
|
|
105
|
+
const ledger = Object.create(BudgetLedger.prototype);
|
|
106
|
+
Object.assign(ledger, { store, db: store.db, filename: store.filename,
|
|
107
|
+
webAuthorization: expected.specification, webAuthorizationId: expected.id });
|
|
108
|
+
freezeWebBudget(ledger); return ledger;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
constructor(filename, options = {}) {
|
|
112
|
+
const { ceilingCny = 50, authorizationRef, scope = 'limited', webAuthorization, create = false } = options;
|
|
113
|
+
if (options.mode !== undefined && options.mode !== 'web-only')
|
|
114
|
+
throw new DomainError('invalid_budget_mode', 'Unsupported budget mode.', 400);
|
|
115
|
+
const webOnly = options.mode === 'web-only';
|
|
116
|
+
if (webOnly && (scope !== 'web-unlimited' || !Object.hasOwn(options, 'scope') ||
|
|
117
|
+
Object.hasOwn(options, 'ceilingCny') || Object.hasOwn(options, 'authorizationRef')))
|
|
118
|
+
throw new DomainError('invalid_web_only_budget', 'Web-only mode requires explicit web-unlimited scope and no finite allowance or base authorization.', 400);
|
|
119
|
+
const ceilingMicroCny = webOnly ? null : amountMicro(ceilingCny);
|
|
120
|
+
if (!webOnly) string(authorizationRef, 'authorizationRef', 4096);
|
|
121
|
+
if (!['limited', 'web-unlimited'].includes(scope)) throw new DomainError('invalid_budget_scope', 'Budget scope must be limited or web-unlimited.', 400);
|
|
122
|
+
this.scope = scope;
|
|
123
|
+
if (!webOnly) this.authorization = { ceilingMicroCny, authorizationRef };
|
|
124
|
+
if (scope === 'web-unlimited') {
|
|
125
|
+
if (!webAuthorization || typeof webAuthorization !== 'object' || Array.isArray(webAuthorization)) throw new DomainError('invalid_web_authorization', 'Unlimited web requests require separate explicit authorization.', 400);
|
|
126
|
+
const { authorizationRef: ref, authorizedAt, authorizationEvidence } = webAuthorization;
|
|
127
|
+
string(ref, 'webAuthorization.authorizationRef', 4096); string(authorizedAt, 'webAuthorization.authorizedAt'); string(authorizationEvidence, 'webAuthorization.authorizationEvidence', 8000);
|
|
128
|
+
if (ref === authorizationRef || !/^\d{4}-\d{2}-\d{2}T/.test(authorizedAt) || !Number.isFinite(Date.parse(authorizedAt))) throw new DomainError('invalid_web_authorization', 'Web authorization must identify a separate explicit decision and valid timestamp.', 400);
|
|
129
|
+
this.webAuthorization = { scope, authorizationRef: ref, authorizedAt: new Date(authorizedAt).toISOString(), authorizationEvidence };
|
|
130
|
+
this.webAuthorizationId = `budget-web-${createHash('sha256').update(ref).digest('hex')}`;
|
|
131
|
+
}
|
|
132
|
+
if (webOnly) {
|
|
133
|
+
freezeWebBudget(this);
|
|
134
|
+
this.store = new Store(filename, { mode: 'web-only-budget-v1', create,
|
|
135
|
+
webBudgetIdentity: { id: this.webAuthorizationId, specification: this.webAuthorization } });
|
|
136
|
+
this.db = this.store.db; this.filename = this.store.filename;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.store = new Store(filename, { mode: 'legacy-ledger-v2', create }); this.db = this.store.db; this.filename = this.store.filename;
|
|
140
|
+
try {
|
|
141
|
+
this.db.exec(`
|
|
142
|
+
CREATE TABLE IF NOT EXISTS budget_settings (
|
|
143
|
+
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
|
144
|
+
schema_version INTEGER NOT NULL,
|
|
145
|
+
ceiling_micro INTEGER NOT NULL CHECK(ceiling_micro>0),
|
|
146
|
+
authorization_ref TEXT NOT NULL,
|
|
147
|
+
created_at TEXT NOT NULL
|
|
148
|
+
);
|
|
149
|
+
CREATE TABLE IF NOT EXISTS budget_requests (
|
|
150
|
+
id TEXT PRIMARY KEY,
|
|
151
|
+
body_hash TEXT NOT NULL,
|
|
152
|
+
status TEXT NOT NULL CHECK(status IN ('reserved','dispatched','unknown','settled','released')),
|
|
153
|
+
reserved_micro INTEGER NOT NULL CHECK(reserved_micro>=0),
|
|
154
|
+
spent_micro INTEGER NOT NULL CHECK(spent_micro>=0),
|
|
155
|
+
body TEXT NOT NULL CHECK(json_valid(body)),
|
|
156
|
+
scope TEXT NOT NULL DEFAULT 'limited' CHECK(scope IN ('limited','web-unlimited'))
|
|
157
|
+
);
|
|
158
|
+
`);
|
|
159
|
+
this.store.transaction(() => {
|
|
160
|
+
// Serialize schema inspection and migration across simultaneous opens.
|
|
161
|
+
// ALTER only adds a default: every legacy body and amount stays intact.
|
|
162
|
+
if (!this.db.prepare('PRAGMA table_info(budget_requests)').all().some(column => column.name === 'scope')) {
|
|
163
|
+
this.db.exec("ALTER TABLE budget_requests ADD COLUMN scope TEXT NOT NULL DEFAULT 'limited' CHECK(scope IN ('limited','web-unlimited'))");
|
|
164
|
+
}
|
|
165
|
+
const settings = this.db.prepare('SELECT * FROM budget_settings WHERE singleton=1').get();
|
|
166
|
+
if (!settings) this.db.prepare('INSERT INTO budget_settings VALUES(1,1,?,?,?)').run(ceilingMicroCny, authorizationRef, now());
|
|
167
|
+
else if (settings.schema_version !== 1 || settings.ceiling_micro !== ceilingMicroCny || settings.authorization_ref !== authorizationRef) {
|
|
168
|
+
throw new DomainError('budget_configuration_conflict', 'The existing ledger ceiling and authorization cannot be changed by reopening it.');
|
|
169
|
+
}
|
|
170
|
+
if (this.webAuthorization) {
|
|
171
|
+
const existing = this.store.get('budget_authorizations', this.webAuthorizationId);
|
|
172
|
+
if (existing && canonical(existing.specification) !== canonical(this.webAuthorization)) throw new DomainError('web_authorization_conflict', 'This web authorization is already bound to different evidence.');
|
|
173
|
+
if (!existing) this.store.save('budget_authorizations', { id: this.webAuthorizationId, specification: this.webAuthorization, appliedAt: now() });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
} catch (error) { this.store.close(); throw error; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
get(requestId) {
|
|
180
|
+
this.store.assertOpen(); string(requestId, 'requestId');
|
|
181
|
+
const row = this.db.prepare('SELECT body FROM budget_requests WHERE id=?').get(requestId);
|
|
182
|
+
return row ? JSON.parse(row.body) : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
list() {
|
|
186
|
+
this.store.assertOpen();
|
|
187
|
+
return this.db.prepare('SELECT body FROM budget_requests ORDER BY id').all().map(row => JSON.parse(row.body));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
assertCurrentAuthorization() {
|
|
191
|
+
if (this.mode === 'web-only') {
|
|
192
|
+
this.store.assertOpen();
|
|
193
|
+
try { this.store.assertWebBudgetIdentity(); }
|
|
194
|
+
catch { throw new DomainError('budget_authorization_stale', 'The explicit web authorization no longer matches.'); }
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (this.scope === 'web-unlimited') {
|
|
198
|
+
const current = this.store.get('budget_authorizations', this.webAuthorizationId);
|
|
199
|
+
if (!current || canonical(current.specification) !== canonical(this.webAuthorization)) throw new DomainError('budget_authorization_stale', 'The explicit web authorization no longer matches.');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const current = this.db.prepare('SELECT ceiling_micro,authorization_ref FROM budget_settings WHERE singleton=1').get();
|
|
203
|
+
if (current.ceiling_micro !== this.authorization.ceilingMicroCny || current.authorization_ref !== this.authorization.authorizationRef) {
|
|
204
|
+
throw new DomainError('budget_authorization_stale', 'This ledger instance must be reopened with the current explicit authorization before reserving or dispatching.');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
upgradeAuthorization({ expectedCeilingCny, expectedAuthorizationRef, ceilingCny, authorizationRef, authorizedAt, authorizationEvidence } = {}) {
|
|
209
|
+
if (this.mode === 'web-only')
|
|
210
|
+
throw new DomainError('web_only_no_limited_budget', 'A web-only ledger has no finite allowance to upgrade.', 409);
|
|
211
|
+
const previous = { ceilingMicroCny: amountMicro(expectedCeilingCny), authorizationRef: string(expectedAuthorizationRef, 'expectedAuthorizationRef', 4096) };
|
|
212
|
+
const next = { ceilingMicroCny: amountMicro(ceilingCny), authorizationRef: string(authorizationRef, 'authorizationRef', 4096) };
|
|
213
|
+
string(authorizedAt, 'authorizedAt'); string(authorizationEvidence, 'authorizationEvidence', 8000);
|
|
214
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(authorizedAt) || !Number.isFinite(Date.parse(authorizedAt))) throw new DomainError('invalid_budget', 'A valid explicit authorization timestamp is required.', 400);
|
|
215
|
+
if (next.ceilingMicroCny <= previous.ceilingMicroCny || next.authorizationRef === previous.authorizationRef) throw new DomainError('invalid_budget_upgrade', 'An upgrade must strictly increase the ceiling and identify a new explicit authorization.');
|
|
216
|
+
const specification = { previous, next, authorizedAt: new Date(authorizedAt).toISOString(), authorizationEvidence };
|
|
217
|
+
const id = `budget-upgrade-${createHash('sha256').update(authorizationRef).digest('hex')}`;
|
|
218
|
+
const record = this.store.transaction(() => {
|
|
219
|
+
const current = this.db.prepare('SELECT ceiling_micro,authorization_ref FROM budget_settings WHERE singleton=1').get();
|
|
220
|
+
const existing = this.store.get('budget_authorizations', id);
|
|
221
|
+
if (existing) {
|
|
222
|
+
if (canonical(existing.specification) !== canonical(specification)) throw new DomainError('authorization_upgrade_conflict', 'This authorization is already bound to different upgrade evidence.');
|
|
223
|
+
// A historical replay never reverts a later authorization or resets totals.
|
|
224
|
+
return existing;
|
|
225
|
+
}
|
|
226
|
+
if (current.ceiling_micro !== previous.ceilingMicroCny || current.authorization_ref !== previous.authorizationRef) throw new DomainError('budget_configuration_conflict', 'The expected previous budget authorization no longer matches.');
|
|
227
|
+
if (this.db.prepare("SELECT 1 FROM budget_requests WHERE status='dispatched' LIMIT 1").get()) throw new DomainError('budget_upgrade_in_flight', 'Finish or classify active dispatched requests before changing authorization.');
|
|
228
|
+
const record = this.store.save('budget_authorizations', { id, specification, appliedAt: now() });
|
|
229
|
+
this.db.prepare('UPDATE budget_settings SET ceiling_micro=?,authorization_ref=? WHERE singleton=1').run(next.ceilingMicroCny, next.authorizationRef);
|
|
230
|
+
return record;
|
|
231
|
+
});
|
|
232
|
+
const current = this.db.prepare('SELECT ceiling_micro,authorization_ref FROM budget_settings WHERE singleton=1').get();
|
|
233
|
+
if (current.ceiling_micro === next.ceilingMicroCny && current.authorization_ref === next.authorizationRef) this.authorization = next;
|
|
234
|
+
return record;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
summary() {
|
|
238
|
+
this.store.assertOpen();
|
|
239
|
+
// One SELECT provides a coherent SQLite snapshot across settings and totals.
|
|
240
|
+
const row = this.db.prepare(`SELECT ${this.mode === 'web-only' ? 'NULL' : 'ceiling_micro'} AS ceiling,
|
|
241
|
+
COALESCE((SELECT SUM(spent_micro) FROM budget_requests WHERE scope=:scope),0) AS spent,
|
|
242
|
+
COALESCE((SELECT SUM(reserved_micro) FROM budget_requests WHERE scope=:scope),0) AS reserved,
|
|
243
|
+
(SELECT COUNT(*) FROM budget_requests WHERE scope=:scope AND status='unknown') AS unknownCount,
|
|
244
|
+
(SELECT COUNT(*) FROM budget_requests WHERE scope=:scope) AS requests,
|
|
245
|
+
COALESCE((SELECT SUM(spent_micro) FROM budget_requests),0) AS allSpent,
|
|
246
|
+
COALESCE((SELECT SUM(reserved_micro) FROM budget_requests),0) AS allReserved,
|
|
247
|
+
(SELECT COUNT(*) FROM budget_requests WHERE status='unknown') AS allUnknown,
|
|
248
|
+
(SELECT COUNT(*) FROM budget_requests) AS allRequests
|
|
249
|
+
${this.mode === 'web-only' ? '' : 'FROM budget_settings WHERE singleton=1'}`).get({ scope: this.scope });
|
|
250
|
+
const ceiling = this.scope === 'limited' ? row.ceiling : null;
|
|
251
|
+
const available = ceiling === null ? null : ceiling - row.spent - row.reserved;
|
|
252
|
+
return { scope: this.scope, ceilingCny: ceiling === null ? null : ceiling / MICRO, spentCny: row.spent / MICRO, reservedCny: row.reserved / MICRO,
|
|
253
|
+
availableCny: available === null ? null : available / MICRO, unknownCount: row.unknownCount, requests: row.requests,
|
|
254
|
+
ceilingMicroCny: ceiling, spentMicroCny: row.spent, reservedMicroCny: row.reserved, availableMicroCny: available,
|
|
255
|
+
allTime: { spentCny: row.allSpent / MICRO, reservedCny: row.allReserved / MICRO, spentMicroCny: row.allSpent, reservedMicroCny: row.allReserved, unknownCount: row.allUnknown, requests: row.allRequests } };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
write(record) {
|
|
259
|
+
// The SQL column owns scope; neither request metadata nor a later caller can
|
|
260
|
+
// change it. Old sealed writers omit the column and still default to limited.
|
|
261
|
+
const scope = this.db.prepare('SELECT scope FROM budget_requests WHERE id=?').get(record.requestId)?.scope || this.scope;
|
|
262
|
+
const saved = { ...record, updatedAt: now(), reservedCny: record.reservedMicroCny / MICRO, spentCny: record.spentMicroCny / MICRO };
|
|
263
|
+
this.db.prepare(`INSERT INTO budget_requests(id,body_hash,status,reserved_micro,spent_micro,body,scope) VALUES(?,?,?,?,?,?,?)
|
|
264
|
+
ON CONFLICT(id) DO UPDATE SET status=excluded.status,reserved_micro=excluded.reserved_micro,spent_micro=excluded.spent_micro,body=excluded.body`)
|
|
265
|
+
.run(saved.requestId, saved.bodyHash, saved.status, saved.reservedMicroCny, saved.spentMicroCny, JSON.stringify(saved), scope);
|
|
266
|
+
return jsonClone(saved);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
assertScope(requestId) {
|
|
270
|
+
const row = this.db.prepare('SELECT scope FROM budget_requests WHERE id=?').get(requestId);
|
|
271
|
+
if (row && row.scope !== this.scope) throw new DomainError('budget_scope_conflict', 'This request belongs to another fixed budget scope; it cannot be reserved or dispatched here.');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
reserve({ requestId, bodyHash, model, maxOutputTokens, inputTokenCeiling, prices, metadata = {} } = {}) {
|
|
275
|
+
string(requestId, 'requestId'); string(bodyHash, 'bodyHash', 256); string(model, 'model');
|
|
276
|
+
tokens(maxOutputTokens, 'maxOutputTokens', { positive: true }); tokens(inputTokenCeiling, 'inputTokenCeiling', { positive: true });
|
|
277
|
+
const fixedPrices = priceValues(prices), safe = safeMetadata(metadata);
|
|
278
|
+
const specification = { bodyHash, model, maxOutputTokens, inputTokenCeiling, prices: fixedPrices };
|
|
279
|
+
const reserveMicroCny = costMicro(inputTokenCeiling, maxOutputTokens, 0, fixedPrices, true);
|
|
280
|
+
return this.store.transaction(() => {
|
|
281
|
+
this.assertCurrentAuthorization();
|
|
282
|
+
const previous = this.get(requestId);
|
|
283
|
+
if (previous) {
|
|
284
|
+
this.assertScope(requestId);
|
|
285
|
+
if (canonical(previous.specification) !== canonical(specification)) throw new DomainError('reservation_conflict', 'Request identity is already bound to a different body or accounting specification.');
|
|
286
|
+
if (previous.status !== 'released') return previous;
|
|
287
|
+
if (previous.dispatchedAt || previous.spentMicroCny !== 0) throw new DomainError('invalid_budget_state', 'Only demonstrably unsent requests may reacquire a reservation.');
|
|
288
|
+
}
|
|
289
|
+
const summary = this.summary();
|
|
290
|
+
if (this.scope === 'limited' && reserveMicroCny > summary.availableMicroCny) throw new DomainError('budget_exceeded', 'The request worst-case cost exceeds the remaining authorized budget.', 409,
|
|
291
|
+
{ requestedMicroCny: reserveMicroCny, availableMicroCny: summary.availableMicroCny });
|
|
292
|
+
if (previous) return this.write({ ...previous, status: 'reserved', reservedMicroCny: reserveMicroCny,
|
|
293
|
+
reservationRestarts: (previous.reservationRestarts || 0) + 1, reacquiredAt: now() });
|
|
294
|
+
return this.write({ requestId, ...specification, specification, metadata: safe, status: 'reserved',
|
|
295
|
+
createdAt: now(), reservedMicroCny: reserveMicroCny, reservedMaximumMicroCny: reserveMicroCny, spentMicroCny: 0, dispatchedAt: null, usage: null });
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
required(requestId) {
|
|
300
|
+
const record = this.get(requestId);
|
|
301
|
+
if (!record) throw new DomainError('request_not_found', 'No reservation exists for this request.', 404);
|
|
302
|
+
return record;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
markDispatched(requestId) {
|
|
306
|
+
return this.store.transaction(() => {
|
|
307
|
+
this.assertCurrentAuthorization();
|
|
308
|
+
const record = this.required(requestId);
|
|
309
|
+
this.assertScope(requestId);
|
|
310
|
+
if (record.status !== 'reserved' || record.dispatchedAt) throw new DomainError('request_already_dispatched', 'This request cannot be dispatched again.');
|
|
311
|
+
return this.write({ ...record, status: 'dispatched', dispatchedAt: now() });
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
markUnknown(requestId, reason = 'Provider outcome is unknown.') {
|
|
316
|
+
string(reason, 'reason', 4000);
|
|
317
|
+
return this.store.transaction(() => {
|
|
318
|
+
const record = this.required(requestId);
|
|
319
|
+
if (record.status === 'settled') return record;
|
|
320
|
+
if (record.status === 'released') throw new DomainError('invalid_budget_state', 'An unsent released reservation cannot become unknown.');
|
|
321
|
+
return this.write({ ...record, status: 'unknown', unknownReason: reason, unknownAt: now() });
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
releaseUnsent(requestId, reason = 'Not dispatched.') {
|
|
326
|
+
string(reason, 'reason', 4000);
|
|
327
|
+
return this.store.transaction(() => {
|
|
328
|
+
const record = this.required(requestId);
|
|
329
|
+
if (record.status === 'released') return record;
|
|
330
|
+
if (record.status !== 'reserved' || record.dispatchedAt) throw new DomainError('already_dispatched', 'Only a demonstrably unsent reservation can be released.');
|
|
331
|
+
const releasedAt = now();
|
|
332
|
+
return this.write({ ...record, status: 'released', reservedMicroCny: 0, releaseReason: reason, releasedAt,
|
|
333
|
+
releaseHistory: [...(record.releaseHistory || []), { reason, releasedAt }] });
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
settle(requestId, usage) {
|
|
338
|
+
const result = this.store.transaction(() => {
|
|
339
|
+
const record = this.required(requestId);
|
|
340
|
+
if (!record.dispatchedAt || record.status === 'released') throw new DomainError('invalid_budget_state', 'A dispatched request is required before settlement.');
|
|
341
|
+
let normalized, invalid;
|
|
342
|
+
try {
|
|
343
|
+
if (!usage || typeof usage !== 'object' || Array.isArray(usage)) throw new Error('Complete provider usage is required.');
|
|
344
|
+
const promptTokens = tokens(usage.promptTokens, 'promptTokens');
|
|
345
|
+
const completionTokens = tokens(usage.completionTokens, 'completionTokens');
|
|
346
|
+
const cachedTokens = tokens(usage.cachedTokens ?? 0, 'cachedTokens');
|
|
347
|
+
if (cachedTokens > promptTokens || promptTokens > record.inputTokenCeiling || completionTokens > record.maxOutputTokens) throw new Error('Provider usage exceeds the reserved token bounds.');
|
|
348
|
+
normalized = { promptTokens, completionTokens, cachedTokens };
|
|
349
|
+
} catch (error) { invalid = error.message; }
|
|
350
|
+
if (record.status === 'settled') {
|
|
351
|
+
if (invalid || canonical(record.usage) !== canonical(normalized)) throw new DomainError('usage_conflict', 'A settled request cannot be assigned different usage.');
|
|
352
|
+
return { record };
|
|
353
|
+
}
|
|
354
|
+
if (invalid) {
|
|
355
|
+
return { invalid, record: this.write({ ...record, status: 'unknown', unknownAt: now(), unknownReason: 'Invalid or incomplete usage: ' + invalid }) };
|
|
356
|
+
}
|
|
357
|
+
const spentMicroCny = costMicro(normalized.promptTokens, normalized.completionTokens, normalized.cachedTokens, record.prices);
|
|
358
|
+
if (spentMicroCny > record.reservedMaximumMicroCny) {
|
|
359
|
+
return { invalid: 'Cost exceeds the reserved worst-case amount.', record: this.write({ ...record, status: 'unknown', unknownReason: 'Cost exceeds the reserved worst-case amount.' }) };
|
|
360
|
+
}
|
|
361
|
+
return { record: this.write({ ...record, status: 'settled', reservedMicroCny: 0, spentMicroCny, usage: normalized, settledAt: now(), unknownReason: null }) };
|
|
362
|
+
});
|
|
363
|
+
// Commit unknown before throwing; rolling the transaction back would hide it.
|
|
364
|
+
if (result.invalid) throw new DomainError('invalid_usage', result.invalid, 409, { requestId });
|
|
365
|
+
return result.record;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
close() { this.store.close(); }
|
|
369
|
+
}
|