@openvulcan/dsh-vulcancode 0.0.0-stage → 0.4.3
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 +126 -2
- package/assets/openvulcan-icon.svg +33 -0
- package/cordis.patch.yml +19 -0
- package/node_modules/@openvulcan/dsh-decision-guard/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-decision-guard/README.md +14 -0
- package/node_modules/@openvulcan/dsh-decision-guard/assets/decision-guard-icon.png +0 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/calls.d.ts +40 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/calls.js +150 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/client.js +2250 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/decide.d.ts +90 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/decide.js +108 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/index.d.ts +129 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/index.js +572 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/intent.d.ts +22 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/intent.js +77 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/panel.d.ts +158 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/panel.js +340 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/records.d.ts +133 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/records.js +186 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/review.d.ts +55 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/review.js +202 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/session-state.d.ts +62 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/session-state.js +155 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/version.d.ts +2 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/version.js +4 -0
- package/node_modules/@openvulcan/dsh-decision-guard/package.json +68 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/assets/account-icon.png +0 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/lib/client.js +573 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/lib/index.d.ts +92 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/lib/index.js +194 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/package.json +57 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/assets/answers-icon.png +0 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/assets/prompts/answer-mode-interactive.md +79 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/assets/prompts/answer-mode-standard.md +1 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/answer-model.d.ts +258 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/answer-model.js +698 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/client.js +671 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/fences.d.ts +29 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/fences.js +70 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/index.d.ts +19 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/index.js +52 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/limits.d.ts +9 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/limits.js +9 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/prompt.d.ts +22 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/prompt.js +39 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/reply-format.d.ts +20 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/reply-format.js +17 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/session-state.d.ts +66 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/session-state.js +118 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/package.json +59 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/assets/luaskills-icon.svg +1 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/client.js +949 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/constants.d.ts +46 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/constants.js +51 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/engine.d.ts +55 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/engine.js +149 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/hub.d.ts +49 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/hub.js +116 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/index.d.ts +26 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/index.js +276 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/protocol.d.ts +65 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/protocol.js +2 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/runtime.d.ts +111 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/runtime.js +281 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/sdk-install.d.ts +26 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/sdk-install.js +87 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/settings.d.ts +79 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/settings.js +165 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/skills.d.ts +34 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/skills.js +61 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/tools.d.ts +59 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/tools.js +107 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/worker.d.ts +10 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/worker.js +140 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/package.json +57 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/assets/markdown-memory-guide.md +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/assets/memory-curator/SKILL.md +31 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/assets/memory-icon.png +0 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/client.js +1090 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/index.d.ts +30 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/index.js +240 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/inject.d.ts +30 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/inject.js +46 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/constants.d.ts +23 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/constants.js +25 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/document.d.ts +108 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/document.js +373 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/errors.d.ts +14 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/errors.js +31 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/index.d.ts +196 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/index.js +204 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/operations.d.ts +92 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/operations.js +472 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/package.d.ts +126 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/package.js +553 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/registry.d.ts +56 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/registry.js +182 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/scope.d.ts +13 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/scope.js +24 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/search.d.ts +65 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/search.js +227 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/snapshot.d.ts +34 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/snapshot.js +171 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/store.d.ts +72 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/store.js +250 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/timestamp.d.ts +6 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/timestamp.js +12 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/tokens.d.ts +8 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/tokens.js +38 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/page.d.ts +132 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/page.js +235 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/project.d.ts +14 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/project.js +27 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/settings.d.ts +29 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/settings.js +62 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/package.json +63 -0
- package/node_modules/@openvulcan/dsh-vulcan-reasoning/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-reasoning/lib/client.js +509 -0
- package/node_modules/@openvulcan/dsh-vulcan-reasoning/lib/index.d.ts +6 -0
- package/node_modules/@openvulcan/dsh-vulcan-reasoning/lib/index.js +8 -0
- package/node_modules/@openvulcan/dsh-vulcan-reasoning/package.json +42 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/assets/style-icon.png +0 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/lib/client.js +1121 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/lib/index.d.ts +34 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/lib/index.js +42 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/package.json +51 -0
- package/node_modules/@openvulcan/openvulcan-account/LICENSE +21 -0
- package/node_modules/@openvulcan/openvulcan-account/lib/account.d.ts +140 -0
- package/node_modules/@openvulcan/openvulcan-account/lib/account.js +347 -0
- package/node_modules/@openvulcan/openvulcan-account/lib/index.d.ts +1 -0
- package/node_modules/@openvulcan/openvulcan-account/lib/index.js +1 -0
- package/node_modules/@openvulcan/openvulcan-account/package.json +29 -0
- package/node_modules/@openvulcan/vulcan-decision/LICENSE +21 -0
- package/node_modules/@openvulcan/vulcan-decision/README.md +23 -0
- package/node_modules/@openvulcan/vulcan-decision/bin/vulcan-decision.js +4 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/cli.d.ts +5 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/cli.js +267 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/cloud/edge.d.ts +211 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/cloud/edge.js +594 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/download.d.ts +36 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/download.js +99 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/engine.d.ts +37 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/engine.js +39 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/hardware.d.ts +105 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/hardware.js +207 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/home.d.ts +26 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/home.js +54 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/index.d.ts +17 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/index.js +17 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/engine.d.ts +54 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/engine.js +157 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/models.d.ts +126 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/models.js +261 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/packs.d.ts +102 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/packs.js +195 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/worker.d.ts +61 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/worker.js +123 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/protocol.d.ts +74 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/protocol.js +31 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/render.d.ts +15 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/render.js +33 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/version.d.ts +2 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/version.js +4 -0
- package/node_modules/@openvulcan/vulcan-decision/package.json +40 -0
- package/node_modules/@openvulcan/vulcan-decision/python/vulcan_decision_worker.py +363 -0
- package/node_modules/@openvulcan/vulcan-decision/runtime-packs/onnx-cpu.json +627 -0
- package/node_modules/@openvulcan/vulcan-decision/runtime-packs/onnx-cuda.json +423 -0
- package/node_modules/@openvulcan/vulcan-decision/runtime-packs/torch-cpu.json +1047 -0
- package/node_modules/@openvulcan/vulcan-decision/runtime-packs/torch-cu128.json +647 -0
- package/package.json +55 -4
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cloud backend: OpenVulcan's decision endpoints, as VulcanCode uses them.
|
|
3
|
+
*
|
|
4
|
+
* The signed-in device asks its auth host for the models it may use (`edge-models`; the server
|
|
5
|
+
* decides, nothing is hard-coded) and, per model, for a short-lived access grant (`edge-access`):
|
|
6
|
+
* an EdDSA token valid on every endpoint of that model, the account's site and the endpoint list.
|
|
7
|
+
* Calls go straight to one endpoint's `POST /v1/judge` with `Authorization: Bearer` and
|
|
8
|
+
* `X-OpenVulcan-Site`.
|
|
9
|
+
*
|
|
10
|
+
* The endpoint is chosen by measurement, never per request: each endpoint's public `/v1/status` is
|
|
11
|
+
* fetched three times in a row, endpoint after endpoint, at start, every 10 minutes and at most
|
|
12
|
+
* every 30 seconds after a failure. The score is the median round trip plus the reported median
|
|
13
|
+
* compute time (round trip alone when any eligible endpoint has not measured compute yet), and a
|
|
14
|
+
* new endpoint wins only when it scores below 80% of the current one. A user lock always uses its
|
|
15
|
+
* endpoint and never fails over. A request that fails is an error, never an allow, and is never
|
|
16
|
+
* retried on another endpoint.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { DecisionError, DECISIONS, POLICY_VERSION } from '../protocol.js';
|
|
20
|
+
import { renderCall } from '../render.js';
|
|
21
|
+
import { AccountError, HttpError, requestJson, retryAfter, postJson } from '@openvulcan/openvulcan-account';
|
|
22
|
+
/** The sample calls a request test can send, besides a custom command. */
|
|
23
|
+
export const TEST_PRESETS = ['ordinary', 'destructive', 'database_reset', 'oversized'];
|
|
24
|
+
/** Renew the grant once less than this remains. */
|
|
25
|
+
const GRANT_RENEW_MS = 2 * 60_000;
|
|
26
|
+
export const PROBE_INTERVAL_MS = 10 * 60_000;
|
|
27
|
+
const PROBE_AFTER_FAILURE_MS = 30_000;
|
|
28
|
+
const PROBE_ATTEMPTS = 3;
|
|
29
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
30
|
+
const JUDGE_TIMEOUT_MS = 15_000;
|
|
31
|
+
/** How long a model list answers "which model is the default". */
|
|
32
|
+
const MODELS_TTL_MS = 10 * 60_000;
|
|
33
|
+
/** A challenger must score below this percentage of the current endpoint to replace it. */
|
|
34
|
+
const SWITCH_PERCENT = 80;
|
|
35
|
+
export const TEST_ATTEMPTS = 5;
|
|
36
|
+
export const MAX_TEST_COMMAND = 65_536;
|
|
37
|
+
const OVERSIZED_DIGITS = 120_000;
|
|
38
|
+
const SITE = /^[a-z0-9-]{1,32}$/;
|
|
39
|
+
const IDENTIFIER = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
40
|
+
/** The policy options a request test sends: the cloud policy's defaults. */
|
|
41
|
+
const TEST_SETTINGS = { cross_project_writes: 'allow', database_reset: 'confirm', project_reset: 'confirm' };
|
|
42
|
+
export class EdgeClient {
|
|
43
|
+
account;
|
|
44
|
+
fetchImpl;
|
|
45
|
+
grant;
|
|
46
|
+
grantKey;
|
|
47
|
+
grantError;
|
|
48
|
+
modelList;
|
|
49
|
+
probes = new Map();
|
|
50
|
+
probedFor;
|
|
51
|
+
probedAt = 0;
|
|
52
|
+
lastFailureProbe = 0;
|
|
53
|
+
selected;
|
|
54
|
+
probing;
|
|
55
|
+
constructor(account, fetchImpl = fetch) {
|
|
56
|
+
this.account = account;
|
|
57
|
+
this.fetchImpl = fetchImpl;
|
|
58
|
+
}
|
|
59
|
+
/** The account's credential; an account that cannot supply one means "not signed in". */
|
|
60
|
+
async credential() {
|
|
61
|
+
try {
|
|
62
|
+
return await this.account.credential();
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error instanceof AccountError)
|
|
66
|
+
throw new DecisionError('not_signed_in', error.message);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Models this account may use, sorted; the first is the default. */
|
|
71
|
+
async models() {
|
|
72
|
+
const credential = await this.credential();
|
|
73
|
+
const answer = await this.call(() => requestJson(`${credential.auth_origin}/api/v1/vulcancode/edge-models`, credential.access_token, { method: 'GET' }));
|
|
74
|
+
if (!Array.isArray(answer.models))
|
|
75
|
+
throw new DecisionError('model_failed', 'OpenVulcan answered a malformed model list');
|
|
76
|
+
const models = [...new Set(answer.models.flatMap(model => typeof model?.model_id === 'string' && IDENTIFIER.test(model.model_id) ? [model.model_id] : []))].sort();
|
|
77
|
+
this.modelList = { key: credential.authorization_id, at: Date.now(), models };
|
|
78
|
+
return models;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The model to use: the saved one, or else the first one OpenVulcan lists for this account.
|
|
82
|
+
* @throws DecisionError when nothing is saved and the account has no model.
|
|
83
|
+
*/
|
|
84
|
+
async resolveModel(saved) {
|
|
85
|
+
if (saved !== undefined && saved !== '')
|
|
86
|
+
return saved;
|
|
87
|
+
const credential = await this.credential();
|
|
88
|
+
const cached = this.modelList;
|
|
89
|
+
const models = cached !== undefined && cached.key === credential.authorization_id && Date.now() - cached.at < MODELS_TTL_MS ? cached.models : await this.models();
|
|
90
|
+
if (models[0] === undefined)
|
|
91
|
+
throw new DecisionError('backend_unavailable', 'OpenVulcan offers no cloud decision model to this account');
|
|
92
|
+
return models[0];
|
|
93
|
+
}
|
|
94
|
+
/** The endpoint table of `model` with the last measurements, without measuring. */
|
|
95
|
+
snapshot(model, lock) {
|
|
96
|
+
const locked = lock === undefined || lock === '' ? undefined : lock;
|
|
97
|
+
const base = { mode: locked === undefined ? 'auto' : 'locked', ...locked === undefined ? {} : { lockedEndpointId: locked } };
|
|
98
|
+
const grant = this.grant;
|
|
99
|
+
if (grant === undefined || grant.model_id !== model) {
|
|
100
|
+
const error = this.grantError !== undefined && this.grantError.model === model ? this.grantError.message : undefined;
|
|
101
|
+
return { ...base, ...model === undefined ? {} : { modelId: model }, ...error === undefined ? {} : { error }, endpoints: [] };
|
|
102
|
+
}
|
|
103
|
+
const withCompute = this.compareCompute();
|
|
104
|
+
const measured = this.probedFor === grant.model_id;
|
|
105
|
+
return {
|
|
106
|
+
...base,
|
|
107
|
+
modelId: grant.model_id,
|
|
108
|
+
site: grant.site,
|
|
109
|
+
...this.selected === undefined ? {} : { selectedEndpointId: this.selected },
|
|
110
|
+
...measured ? { probedAtMs: this.probedAt, nextProbeAtMs: this.probedAt + PROBE_INTERVAL_MS } : {},
|
|
111
|
+
endpoints: [...grant.endpoints].sort(byPriority).map((endpoint) => {
|
|
112
|
+
const probe = measured ? this.probes.get(endpoint.endpoint_id) : undefined;
|
|
113
|
+
const score = probe === undefined ? undefined : scoreOf(probe, withCompute);
|
|
114
|
+
const status = probe?.status;
|
|
115
|
+
return {
|
|
116
|
+
endpointId: endpoint.endpoint_id, endpoint: endpoint.endpoint, priority: endpoint.priority, online: endpoint.online,
|
|
117
|
+
...status?.region === undefined ? {} : { region: status.region },
|
|
118
|
+
...status === undefined ? {} : { ready: status.ready, acceptsSite: status.sites.includes(grant.site) },
|
|
119
|
+
...probe?.rtt_ms === undefined ? {} : { rttMs: probe.rtt_ms },
|
|
120
|
+
...status?.compute_ms == null ? {} : { computeP50Ms: status.compute_ms.p50 },
|
|
121
|
+
...score === undefined ? {} : { scoreMs: score },
|
|
122
|
+
...probe?.error === undefined ? {} : { error: probe.error },
|
|
123
|
+
};
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Forget grants, model lists and measurements, after a sign-out or account change. */
|
|
128
|
+
reset() {
|
|
129
|
+
this.grant = undefined;
|
|
130
|
+
this.grantKey = undefined;
|
|
131
|
+
this.grantError = undefined;
|
|
132
|
+
this.modelList = undefined;
|
|
133
|
+
this.probes.clear();
|
|
134
|
+
this.probedFor = undefined;
|
|
135
|
+
this.probedAt = 0;
|
|
136
|
+
this.selected = undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Measure every endpoint of `model` now and update the selection.
|
|
140
|
+
* @param lock the endpoint id the user locked, which is never replaced.
|
|
141
|
+
*/
|
|
142
|
+
async probe(model, lock) {
|
|
143
|
+
const grant = await this.access(model);
|
|
144
|
+
await this.measureOnce(grant);
|
|
145
|
+
return this.snapshot(model, lock);
|
|
146
|
+
}
|
|
147
|
+
/** Measure `model` in the background when its last measurement is older than the interval. */
|
|
148
|
+
refreshIfStale(model) {
|
|
149
|
+
if (this.probing !== undefined)
|
|
150
|
+
return;
|
|
151
|
+
if (this.probedFor === model && Date.now() - this.probedAt < PROBE_INTERVAL_MS)
|
|
152
|
+
return;
|
|
153
|
+
void this.access(model).then(grant => this.measureOnce(grant)).catch(() => { });
|
|
154
|
+
}
|
|
155
|
+
/** Check that `endpointId` serves `model`, for a lock the user is about to save. */
|
|
156
|
+
async checkEndpoint(model, endpointId) {
|
|
157
|
+
const grant = await this.access(model);
|
|
158
|
+
if (!grant.endpoints.some(endpoint => endpoint.endpoint_id === endpointId)) {
|
|
159
|
+
throw new DecisionError('invalid_request', `${endpointId} does not serve ${model}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Judge `inputs` with `model` on the selected (or locked) endpoint.
|
|
164
|
+
* @throws DecisionError for every failure; a failure never carries a decision.
|
|
165
|
+
*/
|
|
166
|
+
async judge(model, inputs, lock) {
|
|
167
|
+
let grant = await this.access(model);
|
|
168
|
+
this.refreshIfStale(model);
|
|
169
|
+
const endpoint = this.choose(grant, lock);
|
|
170
|
+
try {
|
|
171
|
+
return { results: await this.send(grant, endpoint, inputs), endpoint: endpoint.endpoint_id };
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error instanceof HttpError && error.status === 401) {
|
|
175
|
+
// The grant may have been rotated out; renew it once and retry once.
|
|
176
|
+
grant = await this.access(model, true);
|
|
177
|
+
const again = this.choose(grant, lock);
|
|
178
|
+
return { results: await this.send(grant, again, inputs), endpoint: again.endpoint_id };
|
|
179
|
+
}
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Send {@link TEST_ATTEMPTS} real judgements of one sample call to one endpoint, one after
|
|
185
|
+
* another, and time each. The grant is fetched first so its call is not timed. Nothing changes
|
|
186
|
+
* the selection.
|
|
187
|
+
*/
|
|
188
|
+
async requestTest(model, endpointId, input) {
|
|
189
|
+
const command = testCommand(input);
|
|
190
|
+
let grant = await this.access(model);
|
|
191
|
+
let endpoint = grant.endpoints.find(item => item.endpoint_id === endpointId);
|
|
192
|
+
if (endpoint === undefined)
|
|
193
|
+
throw new DecisionError('invalid_request', `${endpointId} does not serve ${model}`);
|
|
194
|
+
const rendered = renderCall({ tool: 'shell_command', arguments: { command } });
|
|
195
|
+
const attempts = [];
|
|
196
|
+
for (let index = 0; index < TEST_ATTEMPTS; index++) {
|
|
197
|
+
const requestId = `permission-judge-test-${randomUUID()}`;
|
|
198
|
+
const body = { request_id: requestId, inputs: [{ settings: TEST_SETTINGS, rendered }] };
|
|
199
|
+
let reply = await this.post(grant, endpoint, body);
|
|
200
|
+
let renewed = false;
|
|
201
|
+
if (reply.status === 401) {
|
|
202
|
+
grant = await this.access(model, true);
|
|
203
|
+
endpoint = grant.endpoints.find(item => item.endpoint_id === endpointId) ?? endpoint;
|
|
204
|
+
reply = await this.post(grant, endpoint, body);
|
|
205
|
+
renewed = true;
|
|
206
|
+
}
|
|
207
|
+
attempts.push(testAttempt(requestId, reply, renewed));
|
|
208
|
+
if (reply.status === undefined)
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
const times = attempts.filter(attempt => attempt.answer !== undefined && attempt.error === undefined).map(attempt => attempt.totalMs).sort((a, b) => a - b);
|
|
212
|
+
return {
|
|
213
|
+
modelId: model, endpointId, endpoint: endpoint.endpoint, site: grant.site, renderedChars: rendered.length, attempts,
|
|
214
|
+
succeeded: times.length,
|
|
215
|
+
...times.length === 0 ? {} : { minMs: times[0], medianMs: times[Math.floor((times.length - 1) / 2)], maxMs: times[times.length - 1] },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async post(grant, endpoint, body) {
|
|
219
|
+
const started = performance.now();
|
|
220
|
+
try {
|
|
221
|
+
const response = await this.fetchImpl(`${endpoint.endpoint}/v1/judge`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
redirect: 'error',
|
|
224
|
+
signal: AbortSignal.timeout(JUDGE_TIMEOUT_MS),
|
|
225
|
+
headers: {
|
|
226
|
+
'Authorization': `Bearer ${grant.access_token}`,
|
|
227
|
+
'X-OpenVulcan-Site': grant.site,
|
|
228
|
+
'Content-Type': 'application/json',
|
|
229
|
+
'Accept': 'application/json',
|
|
230
|
+
},
|
|
231
|
+
body: JSON.stringify(body),
|
|
232
|
+
});
|
|
233
|
+
const text = await response.text();
|
|
234
|
+
return { status: response.status, text, headers: response.headers, totalMs: performance.now() - started };
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
return { text: '', totalMs: performance.now() - started, failure: error.message };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async send(grant, endpoint, inputs) {
|
|
241
|
+
const requestId = `permission-judge-${randomUUID()}`;
|
|
242
|
+
const reply = await this.post(grant, endpoint, { request_id: requestId, inputs });
|
|
243
|
+
if (reply.status === undefined) {
|
|
244
|
+
this.afterFailure(grant);
|
|
245
|
+
throw new DecisionError('backend_unavailable', `${endpoint.endpoint_id} cannot be reached: ${reply.failure}`);
|
|
246
|
+
}
|
|
247
|
+
if (reply.status < 200 || reply.status >= 300) {
|
|
248
|
+
const code = endpointErrorCode(reply.text);
|
|
249
|
+
if (reply.status === 401)
|
|
250
|
+
throw new HttpError(401, code, 'the access grant was refused');
|
|
251
|
+
if (reply.status === 429) {
|
|
252
|
+
const wait = retryAfter(reply.headers?.get('Retry-After') ?? null);
|
|
253
|
+
throw new DecisionError('rate_limited', `${endpoint.endpoint_id} is rate limiting this account`, wait);
|
|
254
|
+
}
|
|
255
|
+
if (reply.status >= 500 || code === 'site_not_accepted')
|
|
256
|
+
this.afterFailure(grant);
|
|
257
|
+
throw new DecisionError(reply.status >= 500 ? 'backend_unavailable' : 'model_failed', `${endpoint.endpoint_id} answered HTTP ${reply.status}${code === undefined ? '' : ` (${code})`}`);
|
|
258
|
+
}
|
|
259
|
+
return parseJudgeEnvelope(reply.text, requestId, inputs.length).results;
|
|
260
|
+
}
|
|
261
|
+
/** The current grant for `model`, renewed when it is about to expire or `renew` is set. */
|
|
262
|
+
async access(model, renew = false) {
|
|
263
|
+
const credential = await this.credential();
|
|
264
|
+
const key = `${credential.authorization_id}\n${model}`;
|
|
265
|
+
if (!renew && this.grant !== undefined && this.grantKey === key && Date.parse(this.grant.expires_at) - Date.now() > GRANT_RENEW_MS)
|
|
266
|
+
return this.grant;
|
|
267
|
+
let answer;
|
|
268
|
+
try {
|
|
269
|
+
answer = await this.call(() => postJson(`${credential.auth_origin}/api/v1/vulcancode/edge-access`, credential.access_token, { model_id: model, client_id: credential.authorization_id }), model);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
this.grantError = { model, message: error.message };
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
const grant = parseGrant(answer, model);
|
|
276
|
+
if (this.grantKey !== key) {
|
|
277
|
+
this.probes.clear();
|
|
278
|
+
this.selected = undefined;
|
|
279
|
+
this.probedFor = undefined;
|
|
280
|
+
this.probedAt = 0;
|
|
281
|
+
}
|
|
282
|
+
this.grant = grant;
|
|
283
|
+
this.grantKey = key;
|
|
284
|
+
this.grantError = undefined;
|
|
285
|
+
return grant;
|
|
286
|
+
}
|
|
287
|
+
choose(grant, lock) {
|
|
288
|
+
if (lock !== undefined && lock !== '') {
|
|
289
|
+
const locked = grant.endpoints.find(endpoint => endpoint.endpoint_id === lock);
|
|
290
|
+
if (locked === undefined)
|
|
291
|
+
throw new DecisionError('backend_unavailable', `the locked endpoint ${lock} is not offered for ${grant.model_id}`);
|
|
292
|
+
return locked;
|
|
293
|
+
}
|
|
294
|
+
const selected = grant.endpoints.find(endpoint => endpoint.endpoint_id === this.selected);
|
|
295
|
+
if (selected !== undefined)
|
|
296
|
+
return selected;
|
|
297
|
+
// Before any measurement, the deployment's own preference.
|
|
298
|
+
return [...grant.endpoints].sort(byPriority)[0];
|
|
299
|
+
}
|
|
300
|
+
measureOnce(grant) {
|
|
301
|
+
this.probing ??= this.measure(grant).finally(() => {
|
|
302
|
+
this.probing = undefined;
|
|
303
|
+
});
|
|
304
|
+
return this.probing;
|
|
305
|
+
}
|
|
306
|
+
afterFailure(grant) {
|
|
307
|
+
if (Date.now() - this.lastFailureProbe < PROBE_AFTER_FAILURE_MS)
|
|
308
|
+
return;
|
|
309
|
+
this.lastFailureProbe = Date.now();
|
|
310
|
+
this.measureOnce(grant).catch(() => { });
|
|
311
|
+
}
|
|
312
|
+
async measure(grant) {
|
|
313
|
+
const results = [];
|
|
314
|
+
for (const endpoint of [...grant.endpoints].sort(byPriority))
|
|
315
|
+
results.push(await this.probeEndpoint(endpoint, grant));
|
|
316
|
+
// A grant renewed for another account or model meanwhile makes these results stale.
|
|
317
|
+
if (this.grant?.model_id !== grant.model_id)
|
|
318
|
+
return;
|
|
319
|
+
this.probes = new Map(results.map(result => [result.endpoint_id, result]));
|
|
320
|
+
this.probedFor = grant.model_id;
|
|
321
|
+
this.probedAt = Date.now();
|
|
322
|
+
this.selected = selectEndpoint(grant.endpoints, results, this.selected);
|
|
323
|
+
}
|
|
324
|
+
async probeEndpoint(endpoint, grant) {
|
|
325
|
+
const rtts = [];
|
|
326
|
+
let status;
|
|
327
|
+
let failure;
|
|
328
|
+
for (let attempt = 0; attempt < PROBE_ATTEMPTS; attempt++) {
|
|
329
|
+
const started = performance.now();
|
|
330
|
+
try {
|
|
331
|
+
const response = await this.fetchImpl(`${endpoint.endpoint}/v1/status`, {
|
|
332
|
+
redirect: 'error', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), headers: { 'Cache-Control': 'no-store' },
|
|
333
|
+
});
|
|
334
|
+
const body = await response.json();
|
|
335
|
+
if (!response.ok) {
|
|
336
|
+
failure = `HTTP ${response.status}`;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const parsed = parseStatus(body);
|
|
340
|
+
if (parsed === undefined) {
|
|
341
|
+
failure = 'malformed status';
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
rtts.push(performance.now() - started);
|
|
345
|
+
status = parsed;
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
failure = error.message;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const at = Date.now();
|
|
352
|
+
if (rtts.length === 0 || status === undefined)
|
|
353
|
+
return { endpoint_id: endpoint.endpoint_id, excluded: 'unreachable', ...failure === undefined ? {} : { error: failure }, at };
|
|
354
|
+
const sorted = rtts.sort((a, b) => a - b);
|
|
355
|
+
const rtt = Math.round(sorted[Math.floor((sorted.length - 1) / 2)]);
|
|
356
|
+
const base = { endpoint_id: endpoint.endpoint_id, rtt_ms: rtt, status, at };
|
|
357
|
+
if (status.endpoint_id !== endpoint.endpoint_id)
|
|
358
|
+
return { ...base, excluded: 'wrong_endpoint' };
|
|
359
|
+
if (status.model_id !== grant.model_id)
|
|
360
|
+
return { ...base, excluded: 'wrong_model' };
|
|
361
|
+
if (!status.sites.includes(grant.site))
|
|
362
|
+
return { ...base, excluded: 'site_not_served' };
|
|
363
|
+
if (!status.ready)
|
|
364
|
+
return { ...base, excluded: 'not_ready' };
|
|
365
|
+
return base;
|
|
366
|
+
}
|
|
367
|
+
compareCompute() {
|
|
368
|
+
const usable = [...this.probes.values()].filter(probe => probe.excluded === undefined);
|
|
369
|
+
return usable.length > 0 && usable.every(probe => probe.status?.compute_ms != null);
|
|
370
|
+
}
|
|
371
|
+
/** Map account-host failures to decision errors. */
|
|
372
|
+
async call(work, model) {
|
|
373
|
+
try {
|
|
374
|
+
return await work();
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
if (error instanceof DecisionError)
|
|
378
|
+
throw error;
|
|
379
|
+
if (error instanceof HttpError) {
|
|
380
|
+
if (error.status === 401)
|
|
381
|
+
throw new DecisionError('not_signed_in', 'OpenVulcan refused the sign-in; sign in again');
|
|
382
|
+
if (error.status === 429)
|
|
383
|
+
throw new DecisionError('rate_limited', error.message, error.retryAfterMs);
|
|
384
|
+
if (error.status === 404 && error.code === 'model_unavailable' && model !== undefined) {
|
|
385
|
+
throw new DecisionError('backend_unavailable', `OpenVulcan does not currently enable the cloud model ${model}`);
|
|
386
|
+
}
|
|
387
|
+
if (error.status === 404)
|
|
388
|
+
throw new DecisionError('invalid_request', error.message);
|
|
389
|
+
throw new DecisionError('backend_unavailable', `OpenVulcan refused: ${error.message}`);
|
|
390
|
+
}
|
|
391
|
+
throw new DecisionError('backend_unavailable', `OpenVulcan cannot be reached: ${error.message}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** The command a request test sends. */
|
|
396
|
+
export function testCommand(input) {
|
|
397
|
+
if (input.kind === 'custom') {
|
|
398
|
+
if (typeof input.command !== 'string' || input.command.trim() === '')
|
|
399
|
+
throw new DecisionError('invalid_request', 'the command is empty');
|
|
400
|
+
if (input.command.length > MAX_TEST_COMMAND)
|
|
401
|
+
throw new DecisionError('invalid_request', `the command is longer than ${MAX_TEST_COMMAND} characters`);
|
|
402
|
+
return input.command;
|
|
403
|
+
}
|
|
404
|
+
switch (input.preset) {
|
|
405
|
+
case 'ordinary': return 'git status';
|
|
406
|
+
case 'destructive': return 'rm -rf ~';
|
|
407
|
+
case 'database_reset': return 'psql -d app -c \'DROP SCHEMA public CASCADE\'';
|
|
408
|
+
case 'oversized': {
|
|
409
|
+
const hex = '0123456789abcdef';
|
|
410
|
+
let digits = '';
|
|
411
|
+
for (let i = 0; i < OVERSIZED_DIGITS; i++)
|
|
412
|
+
digits += hex[(i * 7 + Math.floor(i / 16)) % 16];
|
|
413
|
+
return `echo ${digits} > payload.hex`;
|
|
414
|
+
}
|
|
415
|
+
default: throw new DecisionError('invalid_request', `unknown preset ${String(input.preset)}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function testAttempt(requestId, reply, tokenRenewed) {
|
|
419
|
+
const totalMs = Math.round(reply.totalMs);
|
|
420
|
+
const base = { requestId, totalMs, tokenRenewed };
|
|
421
|
+
if (reply.status === undefined)
|
|
422
|
+
return { ...base, errorCode: 'unreachable', error: reply.failure ?? 'no answer' };
|
|
423
|
+
const timing = parseServerTiming(reply.headers?.get('Server-Timing') ?? null);
|
|
424
|
+
const timed = {
|
|
425
|
+
...base, httpStatus: reply.status,
|
|
426
|
+
...timing.edge === undefined ? {} : { endpointMs: timing.edge, networkMs: Math.max(totalMs - timing.edge, 0) },
|
|
427
|
+
...timing.node === undefined ? {} : { nodeMs: timing.node },
|
|
428
|
+
...timing.queue === undefined ? {} : { queueMs: timing.queue },
|
|
429
|
+
...timing.compute === undefined ? {} : { computeMs: timing.compute },
|
|
430
|
+
};
|
|
431
|
+
if (reply.status < 200 || reply.status >= 300) {
|
|
432
|
+
const code = endpointErrorCode(reply.text);
|
|
433
|
+
return { ...timed, ...code === undefined ? {} : { errorCode: code }, error: `HTTP ${reply.status}` };
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
return { ...timed, answer: parseJudgeEnvelope(reply.text, requestId, 1) };
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
return { ...timed, errorCode: 'malformed_answer', error: error.message };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/** The `edge`, `node`, `queue` and `compute` durations of a `Server-Timing` header. */
|
|
443
|
+
export function parseServerTiming(header) {
|
|
444
|
+
const timing = {};
|
|
445
|
+
for (const metric of (header ?? '').split(',')) {
|
|
446
|
+
const [name, ...params] = metric.split(';').map(part => part.trim());
|
|
447
|
+
if (name !== 'edge' && name !== 'node' && name !== 'queue' && name !== 'compute')
|
|
448
|
+
continue;
|
|
449
|
+
const dur = params.find(param => param.startsWith('dur='));
|
|
450
|
+
const value = dur === undefined ? Number.NaN : Number(dur.slice(4));
|
|
451
|
+
if (Number.isFinite(value) && value >= 0)
|
|
452
|
+
timing[name] = value;
|
|
453
|
+
}
|
|
454
|
+
return timing;
|
|
455
|
+
}
|
|
456
|
+
function endpointErrorCode(text) {
|
|
457
|
+
try {
|
|
458
|
+
const error = JSON.parse(text).error;
|
|
459
|
+
return typeof error === 'string' ? error : undefined;
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function byPriority(a, b) {
|
|
466
|
+
return a.priority - b.priority || a.endpoint_id.localeCompare(b.endpoint_id);
|
|
467
|
+
}
|
|
468
|
+
function scoreOf(probe, withCompute) {
|
|
469
|
+
if (probe.excluded !== undefined || probe.rtt_ms === undefined)
|
|
470
|
+
return undefined;
|
|
471
|
+
return probe.rtt_ms + (withCompute ? probe.status?.compute_ms?.p50 ?? 0 : 0);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* The endpoint to use after a measurement: the lowest score (ties by priority, then id), unless
|
|
475
|
+
* the current one is still usable and the best is not 20% better.
|
|
476
|
+
*/
|
|
477
|
+
export function selectEndpoint(endpoints, probes, current) {
|
|
478
|
+
const usable = probes.filter(probe => probe.excluded === undefined && probe.rtt_ms !== undefined);
|
|
479
|
+
if (usable.length === 0)
|
|
480
|
+
return current;
|
|
481
|
+
const withCompute = usable.every(probe => probe.status?.compute_ms != null);
|
|
482
|
+
const ranked = usable
|
|
483
|
+
.map(probe => ({ probe, score: scoreOf(probe, withCompute), endpoint: endpoints.find(endpoint => endpoint.endpoint_id === probe.endpoint_id) }))
|
|
484
|
+
.filter((item) => item.endpoint !== undefined)
|
|
485
|
+
.sort((a, b) => a.score - b.score || byPriority(a.endpoint, b.endpoint));
|
|
486
|
+
const best = ranked[0];
|
|
487
|
+
if (best === undefined)
|
|
488
|
+
return current;
|
|
489
|
+
const kept = ranked.find(item => item.endpoint.endpoint_id === current);
|
|
490
|
+
if (kept !== undefined && !(best.score * 100 < kept.score * SWITCH_PERCENT))
|
|
491
|
+
return current;
|
|
492
|
+
return best.endpoint.endpoint_id;
|
|
493
|
+
}
|
|
494
|
+
export function parseGrant(value, model) {
|
|
495
|
+
const grant = value;
|
|
496
|
+
const fail = (what) => new DecisionError('model_failed', `OpenVulcan answered a malformed access grant: ${what}`);
|
|
497
|
+
if (typeof grant !== 'object' || grant === null)
|
|
498
|
+
throw fail('not an object');
|
|
499
|
+
if (grant.model_id !== model)
|
|
500
|
+
throw fail('another model');
|
|
501
|
+
if (typeof grant.site !== 'string' || !SITE.test(grant.site))
|
|
502
|
+
throw fail('site');
|
|
503
|
+
if (typeof grant.access_token !== 'string' || grant.access_token === '')
|
|
504
|
+
throw fail('token');
|
|
505
|
+
if (typeof grant.expires_at !== 'string' || Number.isNaN(Date.parse(grant.expires_at)))
|
|
506
|
+
throw fail('expiry');
|
|
507
|
+
if (!Array.isArray(grant.endpoints) || grant.endpoints.length === 0)
|
|
508
|
+
throw fail('no endpoints');
|
|
509
|
+
const ids = new Set();
|
|
510
|
+
const endpoints = grant.endpoints.map((endpoint) => {
|
|
511
|
+
if (typeof endpoint.endpoint_id !== 'string' || !IDENTIFIER.test(endpoint.endpoint_id) || ids.has(endpoint.endpoint_id))
|
|
512
|
+
throw fail('endpoint id');
|
|
513
|
+
ids.add(endpoint.endpoint_id);
|
|
514
|
+
const origin = typeof endpoint.endpoint === 'string' ? endpoint.endpoint.replace(/\/$/, '') : '';
|
|
515
|
+
if (!isEndpointOrigin(origin))
|
|
516
|
+
throw fail(`endpoint ${endpoint.endpoint_id}`);
|
|
517
|
+
return {
|
|
518
|
+
endpoint_id: endpoint.endpoint_id, endpoint: origin,
|
|
519
|
+
priority: typeof endpoint.priority === 'number' ? endpoint.priority : 0, online: endpoint.online === true,
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
return { model_id: model, site: grant.site, endpoints, access_token: grant.access_token, expires_at: grant.expires_at };
|
|
523
|
+
}
|
|
524
|
+
/** A bare https origin, or http on a loopback address. */
|
|
525
|
+
export function isEndpointOrigin(value) {
|
|
526
|
+
let url;
|
|
527
|
+
try {
|
|
528
|
+
url = new URL(value);
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
if (url.origin !== value)
|
|
534
|
+
return false;
|
|
535
|
+
if (url.protocol === 'https:')
|
|
536
|
+
return true;
|
|
537
|
+
return url.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname);
|
|
538
|
+
}
|
|
539
|
+
export function parseStatus(value) {
|
|
540
|
+
const status = value;
|
|
541
|
+
if (typeof status !== 'object' || status === null || typeof status.endpoint_id !== 'string' || typeof status.model_id !== 'string'
|
|
542
|
+
|| !Array.isArray(status.sites) || typeof status.ready !== 'boolean') {
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
const compute = status.compute_ms;
|
|
546
|
+
return {
|
|
547
|
+
endpoint_id: status.endpoint_id, model_id: status.model_id,
|
|
548
|
+
...typeof status.region === 'string' ? { region: status.region } : {},
|
|
549
|
+
sites: status.sites.filter((site) => typeof site === 'string'),
|
|
550
|
+
ready: status.ready,
|
|
551
|
+
compute_ms: compute !== null && typeof compute === 'object' && Number.isFinite(compute.p50) && Number.isFinite(compute.p95)
|
|
552
|
+
? { p50: compute.p50, p95: compute.p95 } : null,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/** Check a `/v1/judge` answer against the request before anyone acts on it. */
|
|
556
|
+
export function parseJudgeAnswer(text, requestId, count) {
|
|
557
|
+
return parseJudgeEnvelope(text, requestId, count).results;
|
|
558
|
+
}
|
|
559
|
+
/** {@link parseJudgeAnswer} with the answer's region, model and checkpoint. */
|
|
560
|
+
export function parseJudgeEnvelope(text, requestId, count) {
|
|
561
|
+
const fail = (what) => new DecisionError('model_failed', `the endpoint answered ${what}`);
|
|
562
|
+
let answer;
|
|
563
|
+
try {
|
|
564
|
+
answer = JSON.parse(text);
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
throw fail('invalid JSON');
|
|
568
|
+
}
|
|
569
|
+
if (answer.request_id !== requestId)
|
|
570
|
+
throw fail('another request id');
|
|
571
|
+
if (typeof answer.checkpoint_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(answer.checkpoint_sha256))
|
|
572
|
+
throw fail('no checkpoint');
|
|
573
|
+
if (answer.policy_version !== POLICY_VERSION)
|
|
574
|
+
throw fail(`policy ${String(answer.policy_version)}`);
|
|
575
|
+
if (!Array.isArray(answer.results) || answer.results.length !== count)
|
|
576
|
+
throw fail('the wrong number of results');
|
|
577
|
+
const results = answer.results.map((item) => {
|
|
578
|
+
if (!DECISIONS.includes(item.label))
|
|
579
|
+
throw fail('no decision');
|
|
580
|
+
const logits = Array.isArray(item.logits) && item.logits.length === 3 && item.logits.every(Number.isFinite) ? item.logits : undefined;
|
|
581
|
+
return {
|
|
582
|
+
decision: item.label,
|
|
583
|
+
...logits === undefined ? {} : { logits },
|
|
584
|
+
...typeof item.input_tokens === 'number' ? { input_tokens: item.input_tokens } : {},
|
|
585
|
+
model_evaluated: item.model_evaluated !== false,
|
|
586
|
+
...typeof item.reason_code === 'string' ? { reason_code: item.reason_code } : {},
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
return {
|
|
590
|
+
...typeof answer.region === 'string' ? { region: answer.region } : {},
|
|
591
|
+
...typeof answer.model === 'string' ? { model: answer.model } : {},
|
|
592
|
+
checkpointSha256: answer.checkpoint_sha256, policyVersion: answer.policy_version, results,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned downloads: every file has a known size and SHA-256, is fetched through a partial file
|
|
3
|
+
* that resumes after an interruption, and is refused unless both match.
|
|
4
|
+
*
|
|
5
|
+
* The `cn` source rewrites the official hosts to mirrors reachable from mainland China. A mirror
|
|
6
|
+
* serves the same bytes, so the same pins apply; a failing mirror is reported, never silently
|
|
7
|
+
* replaced by the official host.
|
|
8
|
+
*/
|
|
9
|
+
/** Where runtime downloads come from. */
|
|
10
|
+
export declare const DOWNLOAD_SOURCES: readonly ["official", "cn"];
|
|
11
|
+
export type DownloadSource = typeof DOWNLOAD_SOURCES[number];
|
|
12
|
+
/** The URL to fetch `url` from with `source`. */
|
|
13
|
+
export declare function sourceUrl(url: string, source: DownloadSource): string;
|
|
14
|
+
/** One file with its pins. */
|
|
15
|
+
export interface PinnedFile {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly url: string;
|
|
18
|
+
readonly bytes: number;
|
|
19
|
+
readonly sha256: string;
|
|
20
|
+
}
|
|
21
|
+
export interface FetchOptions {
|
|
22
|
+
readonly source?: DownloadSource;
|
|
23
|
+
readonly signal?: AbortSignal;
|
|
24
|
+
/** Called with the number of new bytes as they arrive. */
|
|
25
|
+
readonly onBytes?: (bytes: number) => void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Make `dest` hold exactly `file`: kept when it already matches, otherwise fetched (resuming a
|
|
29
|
+
* `.part` file left by an earlier attempt) and verified.
|
|
30
|
+
* @throws Error on HTTP failure or when the size or digest does not match.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fetchPinned(file: PinnedFile, dest: string, options?: FetchOptions): Promise<void>;
|
|
33
|
+
/** Whether `local` is a file of the pinned size and digest. */
|
|
34
|
+
export declare function matches(local: string, file: Pick<PinnedFile, 'bytes' | 'sha256'>): Promise<boolean>;
|
|
35
|
+
/** SHA-256 of a file, as lowercase hex. */
|
|
36
|
+
export declare function sha256Of(file: string): Promise<string>;
|