reploid 0.0.0-stage → 0.1.1
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/apps/poolday/config/chat-files.json +27 -0
- package/apps/poolday/config/partition-generation.js +21 -0
- package/apps/poolday/config/partition-policy.json +74 -0
- package/apps/poolday/config/partition-releases.json +3 -0
- package/apps/poolday/config/work-profile.json +122 -0
- package/apps/poolday/host/chat-view.js +47 -0
- package/apps/poolday/host/earning/client.js +199 -0
- package/apps/poolday/host/earning/contribution.js +168 -0
- package/apps/poolday/host/partition-release.js +33 -0
- package/apps/poolday/host/work-partition-artifact-source.js +37 -0
- package/apps/poolday/host/work-partition-capsule.js +39 -0
- package/apps/poolday/host/work-partition-plan.js +167 -0
- package/apps/poolday/host/work-partition-tokenizer-guard.js +21 -0
- package/apps/poolday/infrastructure/model-storage-lock.js +22 -0
- package/apps/poolday/infrastructure/pack-release-storage.js +52 -0
- package/apps/poolday/pool/pack-operation.js +161 -0
- package/apps/poolday/pool/pack-release-policy.js +44 -0
- package/apps/poolday/pool/peer-pack-data-channel.js +217 -0
- package/apps/poolday/providers/work-adapter.js +67 -0
- package/apps/poolday/providers/work-device.js +33 -0
- package/apps/poolday/providers/work-generation-completion.js +55 -0
- package/apps/poolday/providers/work-provider.js +209 -0
- package/apps/poolday/providers/work-resident-provider.js +113 -0
- package/deploy/artifacts/doppler-gpu-0.7.1.tgz +0 -0
- package/docs/fleet-service.md +214 -0
- package/docs/native-participant.md +542 -0
- package/native-delivery.json +1174 -0
- package/package.json +29 -4
- package/packages/reploid/src/artifacts/binary.js +25 -0
- package/packages/reploid/src/artifacts/canonical-json.js +23 -0
- package/packages/reploid/src/artifacts/custody/exchange.js +402 -0
- package/packages/reploid/src/artifacts/custody/piece-acquisition.js +70 -0
- package/packages/reploid/src/artifacts/custody/runtime.js +404 -0
- package/packages/reploid/src/artifacts/custody/supply-budget.js +19 -0
- package/packages/reploid/src/artifacts/custody-certificate.js +123 -0
- package/packages/reploid/src/artifacts/delivery-attestation.js +64 -0
- package/packages/reploid/src/artifacts/generation-terminal.js +36 -0
- package/packages/reploid/src/artifacts/identity.js +91 -0
- package/packages/reploid/src/artifacts/participant-proofs.js +159 -0
- package/packages/reploid/src/artifacts/receipt.js +131 -0
- package/packages/reploid/src/artifacts/signatures.js +94 -0
- package/packages/reploid/src/artifacts/whole-model-receipt.js +42 -0
- package/packages/reploid/src/cancellation.js +44 -0
- package/packages/reploid/src/config/defaults.json +110 -0
- package/packages/reploid/src/config/index.js +209 -0
- package/packages/reploid/src/config/request-overrides.json +9 -0
- package/packages/reploid/src/config/transport-defaults.json +7 -0
- package/packages/reploid/src/hashing.js +30 -0
- package/packages/reploid/src/measurement/capacity-utilization.js +321 -0
- package/packages/reploid/src/measurement/index.js +5 -0
- package/packages/reploid/src/measurement/readiness-records.js +310 -0
- package/packages/reploid/src/mesh/computer-identity.js +50 -0
- package/packages/reploid/src/mesh/computer-identity.policy.json +8 -0
- package/packages/reploid/src/mesh/contribution.js +141 -0
- package/packages/reploid/src/mesh/generation-recovery.js +58 -0
- package/packages/reploid/src/mesh/gpu-signature-shader.js +27 -0
- package/packages/reploid/src/mesh/gpu-signature.js +136 -0
- package/packages/reploid/src/mesh/gpu-signature.policy.json +11 -0
- package/packages/reploid/src/mesh/index.js +66 -0
- package/packages/reploid/src/mesh/partitions/automatic-ordered-partitions.js +540 -0
- package/packages/reploid/src/mesh/partitions/automatic-partitions.js +298 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-evidence.js +282 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-queue-policy.json +6 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-queue.js +112 -0
- package/packages/reploid/src/mesh/partitions/partition-canonical-contract.js +167 -0
- package/packages/reploid/src/mesh/partitions/partition-canonical-execution.js +168 -0
- package/packages/reploid/src/mesh/partitions/partition-chat-canonical.js +49 -0
- package/packages/reploid/src/mesh/partitions/partition-chat.js +125 -0
- package/packages/reploid/src/mesh/partitions/partition-contract.js +192 -0
- package/packages/reploid/src/mesh/partitions/partition-device-admission.js +96 -0
- package/packages/reploid/src/mesh/partitions/partition-discovery.js +96 -0
- package/packages/reploid/src/mesh/partitions/partition-entry-publication.js +183 -0
- package/packages/reploid/src/mesh/partitions/partition-entry.js +434 -0
- package/packages/reploid/src/mesh/partitions/partition-generation-request.js +33 -0
- package/packages/reploid/src/mesh/partitions/partition-grants.js +277 -0
- package/packages/reploid/src/mesh/partitions/partition-link.js +103 -0
- package/packages/reploid/src/mesh/partitions/partition-network.js +109 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-chat.js +290 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-peer.js +415 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-publication.js +100 -0
- package/packages/reploid/src/mesh/partitions/partition-peer.js +314 -0
- package/packages/reploid/src/mesh/partitions/partition-placement.js +143 -0
- package/packages/reploid/src/mesh/partitions/partition-preparation-progress.js +25 -0
- package/packages/reploid/src/mesh/partitions/partition-receiver-policy.json +7 -0
- package/packages/reploid/src/mesh/partitions/partition-reservations.js +75 -0
- package/packages/reploid/src/mesh/partitions/partition-route-envelope-policy.json +16 -0
- package/packages/reploid/src/mesh/partitions/partition-route-envelope.js +74 -0
- package/packages/reploid/src/mesh/partitions/partition-route-execution.js +409 -0
- package/packages/reploid/src/mesh/partitions/partition-route-network.js +179 -0
- package/packages/reploid/src/mesh/partitions/partition-route-ranking.js +268 -0
- package/packages/reploid/src/mesh/partitions/partition-runner.js +319 -0
- package/packages/reploid/src/mesh/partitions/partition-stage-assignment.js +109 -0
- package/packages/reploid/src/mesh/partitions/partition-stage-commit.js +103 -0
- package/packages/reploid/src/mesh/partitions/partition-step-receiver.js +291 -0
- package/packages/reploid/src/mesh/partitions/partition-summary.js +122 -0
- package/packages/reploid/src/mesh/partitions/preparation-owner.js +40 -0
- package/packages/reploid/src/mesh/partitions/resident-error-codes.json +10 -0
- package/packages/reploid/src/mesh/partitions/resident-partition.js +333 -0
- package/packages/reploid/src/mesh/partitions/resident-route.js +46 -0
- package/packages/reploid/src/mesh/partitions/signed-resident-factory.js +59 -0
- package/packages/reploid/src/mesh/peer-generation.js +896 -0
- package/packages/reploid/src/mesh/peer-identity.js +87 -0
- package/packages/reploid/src/mesh/placement-beliefs.js +107 -0
- package/packages/reploid/src/mesh/preparation-progress.js +46 -0
- package/packages/reploid/src/mesh/provider-hardware.js +11 -0
- package/packages/reploid/src/mesh/remote-generation-requests.js +53 -0
- package/packages/reploid/src/mesh/swarm-coordination.js +140 -0
- package/packages/reploid/src/mesh/web-access-network.js +112 -0
- package/packages/reploid/src/mesh/web-access-policy.json +17 -0
- package/packages/reploid/src/retrieval/citations.js +156 -0
- package/packages/reploid/src/retrieval/claim-support.js +113 -0
- package/packages/reploid/src/retrieval/costs.js +101 -0
- package/packages/reploid/src/retrieval/fixed-provider.js +34 -0
- package/packages/reploid/src/retrieval/index.js +20 -0
- package/packages/reploid/src/retrieval/lexical-support.js +27 -0
- package/packages/reploid/src/retrieval/model-reviewer.js +67 -0
- package/packages/reploid/src/retrieval/page-fetch.json +10 -0
- package/packages/reploid/src/retrieval/page-text.js +182 -0
- package/packages/reploid/src/retrieval/passage.js +37 -0
- package/packages/reploid/src/retrieval/policy.json +14 -0
- package/packages/reploid/src/retrieval/retrieval.js +23 -0
- package/packages/reploid/src/retrieval/review-queue.js +76 -0
- package/packages/reploid/src/retrieval/sources.js +263 -0
- package/packages/reploid/src/retrieval/support-review.js +155 -0
- package/packages/reploid/src/retrieval/text.js +21 -0
- package/packages/reploid/src/rules/contribution.rules.json +8 -0
- package/packages/reploid/src/rules/routing.rules.json +24 -0
- package/packages/reploid/src/transport/assignment.js +571 -0
- package/packages/reploid/src/transport/bounded-channel-writer.js +46 -0
- package/packages/reploid/src/transport/index.js +11 -0
- package/packages/reploid/src/transport/introduction-admission.js +24 -0
- package/packages/reploid/src/transport/introduction-bootstrap.js +112 -0
- package/packages/reploid/src/transport/introduction-policy.json +12 -0
- package/packages/reploid/src/transport/introduction-records.js +90 -0
- package/packages/reploid/src/transport/introductions.js +209 -0
- package/packages/reploid/src/transport/partition-data-channel.js +421 -0
- package/packages/reploid/src/transport/partition-errors.json +16 -0
- package/packages/reploid/src/transport/retry-policy.js +36 -0
- package/packages/reploid/src/transport/room.js +502 -0
- package/packages/reploid/src/transport/sdp-fingerprint.js +5 -0
- package/packages/reploid/src/transport/signaling.js +571 -0
- package/packages/reploid/src/transport/swarm.js +1247 -0
- package/packages/reploid/src/transport/tool-offer-channel.js +137 -0
- package/server/browser-roots.js +42 -0
- package/server/browser-web-worker.js +94 -0
- package/server/chat-completions.js +45 -0
- package/server/fleet/browser-bridge.js +145 -0
- package/server/fleet/contract.js +42 -0
- package/server/fleet/credit-evidence.js +103 -0
- package/server/fleet/credit-recovery.js +175 -0
- package/server/fleet/credits.js +178 -0
- package/server/fleet/execution-admission.js +32 -0
- package/server/fleet/native-host.js +817 -0
- package/server/fleet/placement.js +132 -0
- package/server/fleet/requester.js +346 -0
- package/server/fleet/service.js +311 -0
- package/server/fleet/terminal-policy.js +83 -0
- package/server/fleet/text-api.js +94 -0
- package/server/hashing.js +35 -0
- package/server/http-request-owner.js +49 -0
- package/server/messages.js +57 -0
- package/server/native-canonical-epochs.js +90 -0
- package/server/native-change-log.js +40 -0
- package/server/native-earning.js +203 -0
- package/server/native-generation-journal.js +46 -0
- package/server/native-model-config.js +57 -0
- package/server/native-model-files.js +382 -0
- package/server/native-model-origin.js +54 -0
- package/server/native-model-origins.json +12 -0
- package/server/native-node.js +85 -0
- package/server/native-owner/controls.js +162 -0
- package/server/native-owner/monitor.js +251 -0
- package/server/native-owner/policy.js +273 -0
- package/server/native-owner/rate.js +92 -0
- package/server/native-participant.js +866 -0
- package/server/native-readiness-log.js +30 -0
- package/server/native-release-storage.js +64 -0
- package/server/native-shader-cache.js +369 -0
- package/server/native-supply-budget.js +66 -0
- package/server/native-whole-earning.js +67 -0
- package/server/native-whole-model-fill.js +182 -0
- package/server/native-whole-model.js +130 -0
- package/server/public-web-worker.js +66 -0
- package/server/responses.js +92 -0
- package/server/search/errors.js +47 -0
- package/server/search/page-fetch.js +185 -0
- package/server/text-api.js +83 -0
- package/server/text-recovery.js +45 -0
- package/server/text-request-options.js +23 -0
- package/server/text-requester.js +231 -0
- package/server/token-auth.js +9 -0
- package/server/whole-model-requester.js +99 -0
- package/shared/config/chat-models.json +4685 -0
- package/shared/config/doppler-execution-contracts.js +32 -0
- package/shared/config/doppler-execution-contracts.json +43 -0
- package/shared/config/doppler-generation-contract.js +223 -0
- package/shared/config/doppler-local-models.js +79 -0
- package/shared/config/doppler-package.json +7 -0
- package/shared/config/model-manifests/a0dc69e917133a82780823d17fc771c0f6b882d7c86c571deeaef45be2ff108b.json +6434 -0
- package/shared/config/model-manifests/e985e3cdad60d0cb57d84b436161e66ab5b3f6589a168de6f00b4610e5f1fb59.json +16534 -0
- package/shared/config/model-manifests/fa6eb7bbc3ba63571f8cede780b877d9272bde948697af93dd3d33837cb46137.json +7175 -0
- package/shared/config/model-pieces/qwen-0-8b-pieces.json +1506 -0
- package/shared/config/model-pieces/qwen-2b-pieces.json +4800 -0
- package/shared/config/model-pieces/qwen35b-q4_0-pieces.json +28880 -0
- package/shared/config/reploid-library.json +49 -0
- package/shared/config/swarm-bootstrap.json +21 -0
- package/shared/infrastructure/doppler-runtime-service.js +261 -0
- package/shared/pool/executable-pack.js +87 -0
- package/shared/pool/pack-operation-adapters.js +124 -0
- package/shared/pool/pack-operation-policy.js +75 -0
- package/shared/pool/pool-config.json +2280 -0
- package/shared/vendor/reploid/adapters/doppler-session.js +372 -0
- package/shared/vendor/reploid/artifacts/binary.js +25 -0
- package/shared/vendor/reploid/artifacts/canonical-json.js +23 -0
- package/shared/vendor/reploid/artifacts/delivery-attestation.js +64 -0
- package/shared/vendor/reploid/artifacts/identity.js +91 -0
- package/shared/vendor/reploid/artifacts/manifest-identity.js +17 -0
- package/shared/vendor/reploid/artifacts/participant-proofs.js +159 -0
- package/shared/vendor/reploid/artifacts/signatures.js +94 -0
- package/shared/vendor/reploid/config/defaults.json +110 -0
- package/shared/vendor/reploid/config/index.js +209 -0
- package/shared/vendor/reploid/config/request-overrides.json +9 -0
- package/shared/vendor/reploid/hashing.js +30 -0
- package/shared/vendor/reploid/mesh/generation-context-cache.js +97 -0
- package/shared/vendor/reploid/mesh/partitions/partition-attempt-evidence.js +282 -0
- package/shared/vendor/reploid/mesh/partitions/partition-contract.js +192 -0
- package/shared/vendor/reploid/mesh/partitions/partition-receiver-policy.json +7 -0
- package/shared/vendor/reploid/mesh/partitions/partition-route-ranking.js +268 -0
- package/shared/vendor/reploid/mesh/preparation-progress.js +46 -0
- package/shared/vendor/reploid/mesh/provider-hardware.js +11 -0
- package/shared/vendor/reploid/rules/routing.rules.json +24 -0
- package/shared/vendor/reploid/transport/partition-errors.json +16 -0
- package/README.md +0 -3
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Bounded server-side page fetch for cited search results. Only http(s) to public addresses:
|
|
3
|
+
* every hop's addresses are checked inside the socket's own DNS lookup, so a rebinding answer cannot reach a
|
|
4
|
+
* private network, and IP-literal hosts are checked before connecting. Redirects are followed manually and
|
|
5
|
+
* re-checked. Content type is checked before the body is read; raw and decompressed bytes are capped.
|
|
6
|
+
* PDF is declared unsupported rather than extracted.
|
|
7
|
+
*/
|
|
8
|
+
import dns from 'node:dns';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import https from 'node:https';
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import zlib from 'node:zlib';
|
|
13
|
+
import { PageFetchError } from './errors.js';
|
|
14
|
+
import { sha256Digest } from '../hashing.js';
|
|
15
|
+
// The limits device-side extractors share (packages/reploid/src/retrieval/page-fetch.json).
|
|
16
|
+
import policy from '../../packages/reploid/src/retrieval/page-fetch.json' with { type: 'json' };
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_ALLOWED_TYPES = Object.freeze([...policy.contentTypes]);
|
|
19
|
+
const UNSUPPORTED_TYPES = new Set(policy.unsupportedContentTypes);
|
|
20
|
+
const BLOCKED_LOOKUP = 'EREPLOIDBLOCKEDADDRESS';
|
|
21
|
+
|
|
22
|
+
const IPV4_BLOCKS = [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
|
|
23
|
+
['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
|
24
|
+
['224.0.0.0', 4], ['240.0.0.0', 4]].map(([base, bits]) => [ipv4Number(base), bits]);
|
|
25
|
+
|
|
26
|
+
function ipv4Number(address) {
|
|
27
|
+
return address.split('.').reduce((value, part) => value * 256 + Number(part), 0);
|
|
28
|
+
}
|
|
29
|
+
function blockedIpv4(address) {
|
|
30
|
+
const value = ipv4Number(address);
|
|
31
|
+
return IPV4_BLOCKS.some(([base, bits]) => Math.floor(value / 2 ** (32 - bits)) === Math.floor(base / 2 ** (32 - bits)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Eight 16-bit groups, with an embedded dotted IPv4 tail expanded. */
|
|
35
|
+
function ipv6Groups(address) {
|
|
36
|
+
let text = address.toLowerCase().split('%')[0];
|
|
37
|
+
const dotted = text.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
38
|
+
if (dotted) {
|
|
39
|
+
const value = ipv4Number(dotted[1]);
|
|
40
|
+
text = text.slice(0, -dotted[1].length) + `${(value >>> 16).toString(16)}:${(value & 0xffff).toString(16)}`;
|
|
41
|
+
}
|
|
42
|
+
const [head, tail] = text.split('::');
|
|
43
|
+
const left = head ? head.split(':') : [], right = tail ? tail.split(':') : [];
|
|
44
|
+
const groups = text.includes('::') ? [...left, ...Array(8 - left.length - right.length).fill('0'), ...right] : left;
|
|
45
|
+
return groups.map(group => parseInt(group, 16));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True for loopback, private, link-local, shared, multicast, reserved and documentation addresses. */
|
|
49
|
+
export function isBlockedAddress(address) {
|
|
50
|
+
const family = net.isIP(String(address || '').split('%')[0]);
|
|
51
|
+
if (family === 4) return blockedIpv4(address);
|
|
52
|
+
if (family !== 6) return true;
|
|
53
|
+
const groups = ipv6Groups(address);
|
|
54
|
+
if (groups.length !== 8 || groups.some(group => !Number.isInteger(group))) return true;
|
|
55
|
+
const embedded = () => `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`;
|
|
56
|
+
if (groups.slice(0, 7).every(group => group === 0) && groups[7] <= 1) return true; // :: and ::1
|
|
57
|
+
if (groups.slice(0, 5).every(group => group === 0) && (groups[5] === 0xffff || groups[5] === 0)) return blockedIpv4(embedded()); // IPv4-mapped/compatible
|
|
58
|
+
if (groups[0] === 0x64 && groups[1] === 0xff9b && groups.slice(2, 6).every(group => group === 0)) return blockedIpv4(embedded()); // NAT64
|
|
59
|
+
if ((groups[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
|
|
60
|
+
if ((groups[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
|
61
|
+
if ((groups[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast
|
|
62
|
+
if (groups[0] === 0x2001 && groups[1] === 0x0db8) return true; // documentation
|
|
63
|
+
if (groups[0] === 0x0100 && groups.slice(1, 4).every(group => group === 0)) return true; // discard-only
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A dns.lookup-compatible function that refuses blocked answers, for the socket to use itself. */
|
|
68
|
+
export function guardedLookup(base = dns.lookup, isBlocked = isBlockedAddress) {
|
|
69
|
+
return (hostname, options, callback) => {
|
|
70
|
+
if (typeof options === 'function') { callback = options; options = {}; }
|
|
71
|
+
const settings = typeof options === 'number' ? { family: options } : options || {};
|
|
72
|
+
base(hostname, { ...settings, all: true }, (error, addresses) => {
|
|
73
|
+
if (error) { callback(error); return; }
|
|
74
|
+
const rows = (Array.isArray(addresses) ? addresses : [{ address: addresses, family: net.isIP(addresses) }]).filter(row => row?.address);
|
|
75
|
+
if (!rows.length || rows.some(row => isBlocked(row.address))) {
|
|
76
|
+
callback(Object.assign(new Error(`${hostname} resolves to a blocked address`), { code: BLOCKED_LOOKUP })); return;
|
|
77
|
+
}
|
|
78
|
+
if (settings.all) callback(null, rows);
|
|
79
|
+
else callback(null, rows[0].address, rows[0].family || net.isIP(rows[0].address));
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const mimeOf = value => String(value || '').split(';')[0].trim().toLowerCase();
|
|
85
|
+
const charsetOf = value => String(value || '').match(/charset\s*=\s*["']?([\w.:-]+)/i)?.[1] || null;
|
|
86
|
+
|
|
87
|
+
function decoder(encoding) {
|
|
88
|
+
const name = String(encoding || '').trim().toLowerCase();
|
|
89
|
+
if (!name || name === 'identity') return null;
|
|
90
|
+
if (name === 'gzip' || name === 'x-gzip') return zlib.createGunzip();
|
|
91
|
+
if (name === 'deflate') return zlib.createInflate();
|
|
92
|
+
if (name === 'br') return zlib.createBrotliDecompress();
|
|
93
|
+
throw new PageFetchError('fetch-failed', { detail: `Unsupported content encoding ${name}` });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createPageFetcher({ maxBytes = policy.maxBytes, timeoutMs = policy.timeoutMs, maxRedirects = policy.maxRedirects,
|
|
97
|
+
allowedTypes = DEFAULT_ALLOWED_TYPES,
|
|
98
|
+
lookup = dns.lookup, allowPrivateAddresses = false, isBlocked = isBlockedAddress,
|
|
99
|
+
userAgent = 'ReploidSourceFetcher/1 (+https://github.com/clocksmith/reploid)' } = {}) {
|
|
100
|
+
// allowPrivateAddresses is for tests and local development only.
|
|
101
|
+
const blocked = allowPrivateAddresses ? () => false : isBlocked, resolve = guardedLookup(lookup, blocked);
|
|
102
|
+
const allowed = new Set(allowedTypes.map(type => type.toLowerCase()));
|
|
103
|
+
|
|
104
|
+
function target(value) {
|
|
105
|
+
let url;
|
|
106
|
+
try { url = new URL(value); } catch { throw new PageFetchError('invalid-url'); }
|
|
107
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.href.length > policy.maxUrlCharacters) throw new PageFetchError('invalid-url');
|
|
108
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
109
|
+
if (net.isIP(host) && blocked(host)) throw new PageFetchError('blocked-address', { detail: host });
|
|
110
|
+
return url;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One request; resolves { response } with the body unread. */
|
|
114
|
+
function request(url, signal) {
|
|
115
|
+
return new Promise((done, fail) => {
|
|
116
|
+
const client = url.protocol === 'https:' ? https : http;
|
|
117
|
+
const outgoing = client.request(url, { method: 'GET', lookup: resolve, signal, agent: false,
|
|
118
|
+
headers: { 'User-Agent': userAgent, Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9', 'Accept-Encoding': 'gzip, deflate, br' } });
|
|
119
|
+
outgoing.on('response', response => done(response));
|
|
120
|
+
outgoing.on('error', error => fail(error));
|
|
121
|
+
outgoing.end();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readBody(response, signal) {
|
|
126
|
+
return new Promise((done, fail) => {
|
|
127
|
+
const declared = Number(response.headers['content-length']);
|
|
128
|
+
if (Number.isFinite(declared) && declared > maxBytes) { response.destroy(); fail(new PageFetchError('too-large', { detail: `${declared} bytes` })); return; }
|
|
129
|
+
let stream = response, raw = 0, size = 0;
|
|
130
|
+
const chunks = [];
|
|
131
|
+
const stop = error => { response.destroy(); stream.destroy?.(); fail(error); };
|
|
132
|
+
try { const decode = decoder(response.headers['content-encoding']); if (decode) stream = response.pipe(decode); }
|
|
133
|
+
catch (error) { stop(error); return; }
|
|
134
|
+
response.on('data', chunk => { raw += chunk.length; if (raw > maxBytes) stop(new PageFetchError('too-large', { detail: 'Transfer exceeds the byte limit' })); });
|
|
135
|
+
stream.on('data', chunk => {
|
|
136
|
+
size += chunk.length;
|
|
137
|
+
if (size > maxBytes) { stop(new PageFetchError('too-large', { detail: 'Decoded body exceeds the byte limit' })); return; }
|
|
138
|
+
chunks.push(chunk);
|
|
139
|
+
});
|
|
140
|
+
stream.on('end', () => done(Buffer.concat(chunks)));
|
|
141
|
+
stream.on('error', error => fail(signal.aborted ? signal.reason : new PageFetchError('fetch-failed', { detail: error.code || 'decode error' })));
|
|
142
|
+
response.on('error', error => fail(signal.aborted ? signal.reason : new PageFetchError('fetch-failed', { detail: error.code || 'read error' })));
|
|
143
|
+
// A connection that closes before the body ends (including on timeout) settles as a failure.
|
|
144
|
+
stream.on('close', () => { if (!stream.readableEnded) fail(signal.aborted ? signal.reason : new PageFetchError('fetch-failed', { detail: 'connection closed' })); });
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function fetchPage(value, { signal = null } = {}) {
|
|
149
|
+
const controller = new AbortController();
|
|
150
|
+
const timer = setTimeout(() => controller.abort(new PageFetchError('timeout', { detail: `${timeoutMs} ms` })), timeoutMs);
|
|
151
|
+
const forward = () => controller.abort(new PageFetchError('cancelled'));
|
|
152
|
+
if (signal?.aborted) forward(); else signal?.addEventListener('abort', forward, { once: true });
|
|
153
|
+
try {
|
|
154
|
+
let url = target(value);
|
|
155
|
+
for (let hop = 0; ; hop++) {
|
|
156
|
+
let response;
|
|
157
|
+
try { response = await request(url, controller.signal); }
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
160
|
+
if (error.code === BLOCKED_LOOKUP) throw new PageFetchError('blocked-address', { detail: url.hostname });
|
|
161
|
+
throw new PageFetchError('fetch-failed', { detail: error.code || error.message });
|
|
162
|
+
}
|
|
163
|
+
const status = response.statusCode;
|
|
164
|
+
if (status >= 300 && status < 400 && response.headers.location) {
|
|
165
|
+
response.resume();
|
|
166
|
+
if (hop >= maxRedirects) throw new PageFetchError('too-many-redirects', { status });
|
|
167
|
+
url = target(new URL(response.headers.location, url).href);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (status < 200 || status >= 300) { response.resume(); throw new PageFetchError('http-error', { status }); }
|
|
171
|
+
const contentType = String(response.headers['content-type'] || ''), mime = mimeOf(contentType);
|
|
172
|
+
if (UNSUPPORTED_TYPES.has(mime)) { response.destroy(); throw new PageFetchError('unsupported-content-type', { status, detail: 'PDF text extraction is not supported' }); }
|
|
173
|
+
if (!allowed.has(mime)) { response.destroy(); throw new PageFetchError('unsupported-content-type', { status, detail: mime ? `${mime} is not supported` : 'The page has no content type' }); }
|
|
174
|
+
let body;
|
|
175
|
+
try { body = await readBody(response, controller.signal); }
|
|
176
|
+
catch (error) { if (controller.signal.aborted) throw controller.signal.reason; throw error; }
|
|
177
|
+
return { url: url.href, status, contentType, charset: charsetOf(contentType), bytes: body.length,
|
|
178
|
+
byteSha256: sha256Digest(body), body };
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
clearTimeout(timer); signal?.removeEventListener('abort', forward);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return Object.freeze({ fetchPage, maxBytes, timeoutMs });
|
|
185
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { availablePartitionRoute, currentPartitionRoute } from '../apps/poolday/host/partition-release.js';
|
|
2
|
+
/** One authenticated HTTP surface over a borrowed requester. Production execution
|
|
3
|
+
* must be wrapped by the accounting owner; test execution is explicitly unbilled. */
|
|
4
|
+
import { createChatCompletions } from './chat-completions.js';
|
|
5
|
+
import { createResponses } from './responses.js';
|
|
6
|
+
import { createTextRecovery, TEXT_RECOVERY_PATH } from './text-recovery.js';
|
|
7
|
+
import { partitionAdapterIdentities } from '../packages/reploid/src/mesh/partitions/partition-contract.js';
|
|
8
|
+
import policy from '../apps/poolday/config/partition-policy.json' with { type: 'json' };
|
|
9
|
+
|
|
10
|
+
export const TEXT_API_PATHS = Object.freeze(['/v1/chat/completions', '/v1/responses', '/v1/models', TEXT_RECOVERY_PATH]);
|
|
11
|
+
export function createTextApi({ participant, authenticate, authorize, accounting, production = true, allowUnbilledTest = false }) {
|
|
12
|
+
if (!participant || typeof authenticate !== 'function' || typeof authorize !== 'function') throw Error('Text API requires requester authentication and authorization');
|
|
13
|
+
if (typeof accounting?.run !== 'function' && (production || !allowUnbilledTest)) throw Error('Text API requires accounted execution; unbilled tests must be explicit');
|
|
14
|
+
if (accounting && typeof accounting.run !== 'function') throw Error('Invalid text API accounting port');
|
|
15
|
+
const assertRoute = request => {
|
|
16
|
+
if (request.model.partition && !currentPartitionRoute(request.model, participant.getState().models || [])) {
|
|
17
|
+
throw Object.assign(Error('Selected distributed route is unavailable or not qualified for public use'), { status: 503 });
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const execute = (request, controls) => { assertRoute(request); return participant.generate(request, controls); };
|
|
21
|
+
const requester = { ...participant,
|
|
22
|
+
getState() {
|
|
23
|
+
const state = participant.getState();
|
|
24
|
+
return { ...state, models: (state.models || []).filter(model => !model.partition || availablePartitionRoute(model)) };
|
|
25
|
+
},
|
|
26
|
+
generate(request, controls) {
|
|
27
|
+
assertRoute(request);
|
|
28
|
+
return accounting ? accounting.run(request, controls, (nextRequest = request, nextControls = controls) => execute(nextRequest, nextControls))
|
|
29
|
+
: execute(request, controls);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const options = { participant: requester, authenticate, authorize };
|
|
33
|
+
const completions = createChatCompletions(options), responses = createResponses(options);
|
|
34
|
+
const recovery = createTextRecovery({ participant, authenticate, authorize, accounting });
|
|
35
|
+
const listing = new Set(); let closed = false;
|
|
36
|
+
const json = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
async handle(req, res) {
|
|
39
|
+
let path;
|
|
40
|
+
try { path = new URL(req.url || '/', 'http://localhost').pathname; }
|
|
41
|
+
catch { return json(res, 400, { error: { message: 'Invalid API route' } }); }
|
|
42
|
+
if (path === '/v1/chat/completions') return completions.handle(req, res);
|
|
43
|
+
if (path === '/v1/responses') return responses.handle(req, res);
|
|
44
|
+
if (path === TEXT_RECOVERY_PATH) return recovery.handle(req, res);
|
|
45
|
+
if (path !== '/v1/models') return json(res, 404, { error: { message: 'Unsupported API route' } });
|
|
46
|
+
if (closed) return json(res, 503, { error: { message: 'Text API is closed' } });
|
|
47
|
+
if (listing.size >= policy.inputLimits.maxConcurrentAttempts) return json(res, 429, { error: { message: 'Model listing admission unavailable' } });
|
|
48
|
+
if (req.method !== 'GET') { res.writeHead(405, { allow: 'GET' }); res.end(); return; }
|
|
49
|
+
const controller = new AbortController(), { signal } = controller;
|
|
50
|
+
listing.add(controller);
|
|
51
|
+
const cancel = () => controller.abort(Error('Model listing disconnected'));
|
|
52
|
+
const timer = setTimeout(() => controller.abort(Error('Model listing deadline exceeded')), policy.controlChannel.timeoutMs);
|
|
53
|
+
req.once('aborted', cancel); res.once('close', cancel);
|
|
54
|
+
try {
|
|
55
|
+
const principal = await authenticate(req, { signal }); signal.throwIfAborted();
|
|
56
|
+
if (typeof principal?.subject !== 'string' || !principal.subject || principal.subject.length > 512) return json(res, 401, { error: { message: 'Authentication required' } });
|
|
57
|
+
if (await authorize({ action: 'models.list', principal: Object.freeze({ subject: principal.subject }), signal }) !== true) {
|
|
58
|
+
return json(res, 403, { error: { message: 'Model listing denied' } });
|
|
59
|
+
}
|
|
60
|
+
signal.throwIfAborted();
|
|
61
|
+
const rows = (requester.getState().models || []).filter(model => !model.canonical
|
|
62
|
+
&& (model.partition ? availablePartitionRoute(model) : ['ready', 'busy'].includes(model.availability) && model.providerIds?.length));
|
|
63
|
+
const variants = new Map();
|
|
64
|
+
for (const model of rows) {
|
|
65
|
+
const value = { id: model.selectionId || model.id, object: 'model', owned_by: 'reploid',
|
|
66
|
+
model_identity: model.identity, adapter_identities: partitionAdapterIdentities(model) };
|
|
67
|
+
if (variants.has(value.id) && JSON.stringify(variants.get(value.id)) !== JSON.stringify(value)) {
|
|
68
|
+
return json(res, 409, { error: { message: 'Model id has ambiguous execution identity' } });
|
|
69
|
+
}
|
|
70
|
+
variants.set(value.id, value);
|
|
71
|
+
}
|
|
72
|
+
json(res, 200, { object: 'list', data: [...variants.values()] });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (!res.destroyed && !res.writableEnded) json(res, 503, { error: { message: error.message } });
|
|
75
|
+
} finally { clearTimeout(timer); listing.delete(controller); req.removeListener('aborted', cancel); res.removeListener('close', cancel); }
|
|
76
|
+
},
|
|
77
|
+
async close() {
|
|
78
|
+
closed = true;
|
|
79
|
+
for (const controller of listing) controller.abort(Error('Text API closing'));
|
|
80
|
+
await Promise.all([completions.close(), responses.close(), recovery.close()]);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHttpRequestOwner } from './http-request-owner.js';
|
|
2
|
+
/** Authenticated recovery of an existing accepted request; this never generates. */
|
|
3
|
+
import { readBody, fail, object } from './text-requester.js';
|
|
4
|
+
import policy from '../apps/poolday/config/partition-policy.json' with { type: 'json' };
|
|
5
|
+
|
|
6
|
+
export const TEXT_RECOVERY_PATH = '/v1/reploid/recover';
|
|
7
|
+
export function createTextRecovery({ participant, accounting, authenticate, authorize }) {
|
|
8
|
+
const requests = createHttpRequestOwner({ timeoutMs: policy.controlChannel.timeoutMs });
|
|
9
|
+
const json = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
|
|
10
|
+
return {
|
|
11
|
+
async handle(req, res) {
|
|
12
|
+
if (req.method !== 'POST') { res.writeHead(405, { allow: 'POST' }); res.end(); return; }
|
|
13
|
+
if (requests.closed || typeof accounting?.recover !== 'function' || typeof participant.recoverGeneration !== 'function') {
|
|
14
|
+
return json(res, 503, { error: { message: 'Request recovery unavailable' } });
|
|
15
|
+
}
|
|
16
|
+
if (requests.size >= policy.inputLimits.maxConcurrentAttempts) return json(res, 429, { error: { message: 'Recovery admission unavailable' } });
|
|
17
|
+
const scope = requests.begin(req, res, {
|
|
18
|
+
disconnectError: () => Error('Recovery disconnected'), deadlineError: () => Error('Recovery deadline exceeded')
|
|
19
|
+
});
|
|
20
|
+
const { signal } = scope;
|
|
21
|
+
try {
|
|
22
|
+
if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] || '')) throw fail(415, 'application/json required');
|
|
23
|
+
const principal = await authenticate(req, { signal }); signal.throwIfAborted();
|
|
24
|
+
if (!object(principal) || typeof principal.subject !== 'string' || !principal.subject || principal.subject.length > 512) throw fail(401, 'Authentication required');
|
|
25
|
+
const input = await readBody(req, signal, body => {
|
|
26
|
+
if (!object(body) || Object.keys(body).some(key => !['reservationId', 'messages', 'providerId'].includes(key))
|
|
27
|
+
|| typeof body.reservationId !== 'string' || !body.reservationId || body.reservationId.length > 512
|
|
28
|
+
|| !Array.isArray(body.messages) || !body.messages.length) throw fail(400, 'Expected accepted reservationId and original messages');
|
|
29
|
+
return body;
|
|
30
|
+
});
|
|
31
|
+
if (await authorize({ action: 'chat.recover', principal: Object.freeze({ subject: principal.subject }),
|
|
32
|
+
reservationId: input.reservationId, signal }) !== true) throw fail(403, 'Request recovery denied');
|
|
33
|
+
signal.throwIfAborted();
|
|
34
|
+
const result = await accounting.recover(input, { principal: { subject: principal.subject }, signal },
|
|
35
|
+
(request, reference, controls) => participant.recoverGeneration(request, reference, controls));
|
|
36
|
+
signal.throwIfAborted(); json(res, 200, result);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (!res.destroyed && !res.writableEnded) json(res, error.status || 502, { error: { message: error.message } });
|
|
39
|
+
} finally {
|
|
40
|
+
scope.finish();
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
close: () => requests.close(() => Error('Recovery closed'), () => Error('Recovery cleanup remains unsettled'))
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { fail, object, freeze } from './text-requester.js';
|
|
2
|
+
|
|
3
|
+
/** Optional private-pool binding; it narrows the host's disclosure authority. */
|
|
4
|
+
export function parseRequesterBinding(value) {
|
|
5
|
+
if (value === undefined) return undefined;
|
|
6
|
+
if (!object(value) || Object.keys(value).some(key => !['pool_id', 'expected_model_identity', 'expected_adapter_identities', 'expected_plan_identity', 'allowed_recipients', 'include_execution', 'conversation_id'].includes(key))
|
|
7
|
+
|| typeof value.pool_id !== 'string' || !value.pool_id || value.pool_id.length > 256
|
|
8
|
+
|| typeof value.expected_model_identity !== 'string' || !value.expected_model_identity || value.expected_model_identity.length > 512
|
|
9
|
+
|| value.include_execution !== true || !Array.isArray(value.allowed_recipients) || !value.allowed_recipients.length
|
|
10
|
+
|| value.allowed_recipients.length > 32 || value.allowed_recipients.some(id => typeof id !== 'string' || !id || id.length > 512)
|
|
11
|
+
|| new Set(value.allowed_recipients).size !== value.allowed_recipients.length) {
|
|
12
|
+
throw fail(400, 'Require exact model identity and permitted recipient identities', 'reploid');
|
|
13
|
+
}
|
|
14
|
+
if (value.conversation_id !== undefined && (typeof value.conversation_id !== 'string'
|
|
15
|
+
|| !value.conversation_id || value.conversation_id.length > 128 || !/^[A-Za-z0-9._:-]+$/u.test(value.conversation_id))) {
|
|
16
|
+
throw fail(400, 'Invalid conversation_id', 'reploid');
|
|
17
|
+
}
|
|
18
|
+
if (value.expected_adapter_identities !== undefined && (!Array.isArray(value.expected_adapter_identities)
|
|
19
|
+
|| value.expected_adapter_identities.length > 1 || value.expected_adapter_identities.some(id => !/^sha256:[a-f0-9]{64}$/u.test(id)))) throw fail(400, 'Invalid adapter identities', 'reploid');
|
|
20
|
+
if (value.expected_plan_identity != null && (typeof value.expected_plan_identity !== 'string' || !value.expected_plan_identity
|
|
21
|
+
|| value.expected_plan_identity.length > 256)) throw fail(400, 'Invalid plan identity', 'reploid');
|
|
22
|
+
return freeze(structuredClone(value));
|
|
23
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHttpRequestOwner } from './http-request-owner.js';
|
|
2
|
+
import { canonicalPartitionJson, partitionAdapterIdentities } from '../packages/reploid/src/mesh/partitions/partition-contract.js';
|
|
3
|
+
import { requireGenerationCapability, tokenUsage } from '../packages/reploid/src/mesh/partitions/partition-generation-request.js';
|
|
4
|
+
/** Text-only HTTP projection of admitted whole-model and partition routes. No model fallback. */
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import policy from '../apps/poolday/config/partition-policy.json' with { type: 'json' };
|
|
7
|
+
|
|
8
|
+
export const fail = (status, message, param = null) => Object.assign(new Error(message), { status, param });
|
|
9
|
+
export const assert = (ok, message) => { if (!ok) throw fail(502, message); };
|
|
10
|
+
export const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
export const freeze = value => { if (object(value) || Array.isArray(value)) { for (const item of Object.values(value)) freeze(item); Object.freeze(value); } return value; };
|
|
12
|
+
const json = (res, status, value) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(value)); };
|
|
13
|
+
const errorBody = error => ({ error: { message: error.message, type: error.status < 500 ? 'invalid_request_error' : 'server_error',
|
|
14
|
+
param: error.param || null, code: error.status === 401 ? 'unauthorized' : error.status === 403 ? 'permission_denied'
|
|
15
|
+
: error.code === 'CONTEXT_LENGTH_EXCEEDED' ? 'context_length_exceeded' : null } });
|
|
16
|
+
// Protocol adapters borrowing the same requester share its HTTP admission budget.
|
|
17
|
+
// Each adapter still owns only its own request cancellation/settlement.
|
|
18
|
+
const requesterAdmission = new WeakMap();
|
|
19
|
+
|
|
20
|
+
export async function readBody(req, signal, parse) {
|
|
21
|
+
const chunks = []; let bytes = 0;
|
|
22
|
+
const aborted = () => req.destroy();
|
|
23
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
24
|
+
try {
|
|
25
|
+
for await (const chunk of req) {
|
|
26
|
+
signal.throwIfAborted(); bytes += chunk.length;
|
|
27
|
+
if (bytes > policy.controlChannel.maxControlBytes) throw fail(413, 'Request body limit exceeded');
|
|
28
|
+
chunks.push(chunk);
|
|
29
|
+
}
|
|
30
|
+
try { return parse(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
31
|
+
catch (error) { if (error.status) throw error; throw fail(400, 'Invalid JSON'); }
|
|
32
|
+
} finally { signal.removeEventListener('abort', aborted); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Explicit opt-in: the host authenticates callers AND approves each exact disclosure.
|
|
36
|
+
* The participant and its model/cleanup owners are borrowed, never replaced or closed here.
|
|
37
|
+
*/
|
|
38
|
+
export function createTextRequester({ participant, authenticate, authorize }, protocol) {
|
|
39
|
+
if (!participant || typeof participant.generate !== 'function' || typeof participant.getState !== 'function'
|
|
40
|
+
|| typeof authenticate !== 'function' || typeof authorize !== 'function') throw Error('Text requester requires an actual requester and explicit authentication/authorization ports');
|
|
41
|
+
const formatError = protocol.errorBody || errorBody;
|
|
42
|
+
const requests = createHttpRequestOwner({ timeoutMs: policy.controlChannel.timeoutMs });
|
|
43
|
+
if (!requesterAdmission.has(participant)) requesterAdmission.set(participant, new Set());
|
|
44
|
+
const admitted = requesterAdmission.get(participant);
|
|
45
|
+
const handle = async (req, res) => {
|
|
46
|
+
let pathname;
|
|
47
|
+
try { pathname = new URL(req.url || '/', 'http://localhost').pathname; }
|
|
48
|
+
catch { json(res, 400, formatError(fail(400, 'Invalid API route'))); return; }
|
|
49
|
+
if (pathname !== protocol.path) {
|
|
50
|
+
json(res, 404, formatError(fail(404, 'Unsupported API route'))); return;
|
|
51
|
+
}
|
|
52
|
+
if (req.method !== 'POST') { res.writeHead(405, { allow: 'POST' }); res.end(); return; }
|
|
53
|
+
if (requests.closed || admitted.size >= policy.inputLimits.maxConcurrentAttempts) {
|
|
54
|
+
json(res, requests.closed ? 503 : 429, formatError(fail(requests.closed ? 503 : 429, 'Requester admission unavailable'))); return;
|
|
55
|
+
}
|
|
56
|
+
const scope = requests.begin(req, res, {
|
|
57
|
+
disconnectError: () => Error('HTTP requester disconnected'),
|
|
58
|
+
deadlineError: () => fail(408, 'Request admission deadline exceeded'),
|
|
59
|
+
onFinish: item => admitted.delete(item)
|
|
60
|
+
});
|
|
61
|
+
const { controller, signal } = scope;
|
|
62
|
+
admitted.add(scope.item);
|
|
63
|
+
let streaming = false, emitted = '', sequence = 0, observedUsage = null, disclosureApproved = false, acceptingCallbacks = true;
|
|
64
|
+
let approvedRecipients = [];
|
|
65
|
+
const id = protocol.idPrefix + randomUUID().replaceAll('-', ''), created = Math.floor(Date.now() / 1000);
|
|
66
|
+
let threadId = randomUUID().replaceAll('-', '');
|
|
67
|
+
const attemptId = randomUUID().replaceAll('-', '');
|
|
68
|
+
let model, output;
|
|
69
|
+
const write = data => {
|
|
70
|
+
signal.throwIfAborted();
|
|
71
|
+
if (!streaming) {
|
|
72
|
+
res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache' }); streaming = true;
|
|
73
|
+
}
|
|
74
|
+
if (!res.write(data) && res.writableLength > policy.controlChannel.maxBufferedBytes) {
|
|
75
|
+
controller.abort(Error('HTTP stream output buffer exceeded')); signal.throwIfAborted();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
protocol.validateRequest?.(req);
|
|
80
|
+
if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] || '')) throw fail(415, 'application/json required');
|
|
81
|
+
const authenticated = await authenticate(req, { signal }); signal.throwIfAborted();
|
|
82
|
+
if (!object(authenticated) || typeof authenticated.subject !== 'string' || !authenticated.subject || authenticated.subject.length > 512) throw fail(401, 'Authentication required');
|
|
83
|
+
const principal = freeze({ subject: authenticated.subject });
|
|
84
|
+
const input = await readBody(req, signal, protocol.parse); signal.throwIfAborted();
|
|
85
|
+
if (input.reploid?.conversation_id) threadId = input.reploid.conversation_id;
|
|
86
|
+
const requirePrivateScope = () => {
|
|
87
|
+
if (input.reploid && (participant.roomId !== input.reploid.pool_id
|
|
88
|
+
|| !['private-pool', 'local-only'].includes(participant.executionClass))) throw fail(403, 'Requester is not in the permitted private pool');
|
|
89
|
+
};
|
|
90
|
+
requirePrivateScope();
|
|
91
|
+
const models = participant.getState().models || [];
|
|
92
|
+
let eligible = models.filter(value => (value.selectionId === input.model || value.id === input.model && !partitionAdapterIdentities(value).length) && !value.canonical
|
|
93
|
+
&& (value.partition || ['ready', 'busy'].includes(value.availability) && value.providerIds?.length));
|
|
94
|
+
if (!eligible.length) throw fail(503, 'No ready route for this exact model variant', 'model');
|
|
95
|
+
if (input.reploid) {
|
|
96
|
+
eligible = eligible.filter(value => value.identity === input.reploid.expected_model_identity
|
|
97
|
+
&& (input.reploid.expected_adapter_identities === undefined || canonicalPartitionJson(partitionAdapterIdentities(value)) === canonicalPartitionJson(input.reploid.expected_adapter_identities))
|
|
98
|
+
&& (!input.reploid.expected_plan_identity || value.partition?.planId === input.reploid.expected_plan_identity));
|
|
99
|
+
if (!eligible.length) throw fail(409, 'Requested model identity changed');
|
|
100
|
+
}
|
|
101
|
+
if (new Set(eligible.map(value => canonicalPartitionJson([value.identity, partitionAdapterIdentities(value)]))).size !== 1) throw fail(409, 'Model id has ambiguous execution identity', 'model');
|
|
102
|
+
eligible.sort((a, b) => Number(!!a.partition) - Number(!!b.partition));
|
|
103
|
+
model = freeze(structuredClone(eligible[0]));
|
|
104
|
+
assert(typeof model.identity === 'string' && model.identity.length > 0, 'Requester model identity missing');
|
|
105
|
+
if (input.reploid && input.reploid.expected_model_identity !== model.identity) throw fail(409, 'Requested model identity changed');
|
|
106
|
+
const requested = requireGenerationCapability(model, input.generationRequest);
|
|
107
|
+
const request = freeze({ threadId, attemptId, model, messages: input.messages, ...(requested ? {generationRequest:requested} : {}) });
|
|
108
|
+
if (await authorize({ action: 'chat.execute', principal, request, signal }) !== true) throw fail(403, 'Request execution denied');
|
|
109
|
+
requirePrivateScope();
|
|
110
|
+
signal.throwIfAborted(); scope.clearDeadline();
|
|
111
|
+
res.setHeader('x-request-id', id); res.setHeader('x-reploid-attempt-id', attemptId);
|
|
112
|
+
res.setHeader('x-reploid-model-identity', model.identity);
|
|
113
|
+
if (requested) res.setHeader('x-reploid-token-usage', requested.usage);
|
|
114
|
+
output = protocol.output({ id, created, model: model.id, input, write, end: value => res.end(value), json: value => json(res, 200, value) });
|
|
115
|
+
const result = await participant.generate(request, {
|
|
116
|
+
signal, principal, onState() {},
|
|
117
|
+
onReservation(reservationId) {
|
|
118
|
+
if (!res.headersSent && typeof reservationId === 'string' && reservationId.length > 0 && reservationId.length <= 512) {
|
|
119
|
+
res.setHeader('x-reploid-reservation-id', reservationId);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
onUsage(event) {
|
|
123
|
+
try {
|
|
124
|
+
signal.throwIfAborted();
|
|
125
|
+
assert(acceptingCallbacks, 'Requester callback after completion');
|
|
126
|
+
assert(disclosureApproved, 'Recipient disclosure must precede output');
|
|
127
|
+
assert(requested && !observedUsage && sequence === 0 && event?.threadId === threadId && event.attemptId === attemptId
|
|
128
|
+
&& event.schema === requested.usage && event.outputTokens === 0, 'Unbound or out-of-order provider token usage');
|
|
129
|
+
observedUsage = tokenUsage(event.inputTokens, event.outputTokens, requested);
|
|
130
|
+
if (input.stream) output.usage?.(observedUsage);
|
|
131
|
+
} catch (error) { controller.abort(error); throw error; }
|
|
132
|
+
},
|
|
133
|
+
async requestApproval(preview) {
|
|
134
|
+
assert(acceptingCallbacks, 'Requester callback after completion');
|
|
135
|
+
disclosureApproved = false;
|
|
136
|
+
try {
|
|
137
|
+
signal.throwIfAborted();
|
|
138
|
+
preview = freeze(structuredClone(preview));
|
|
139
|
+
const recipients = [...new Set([...(preview.recipients || []), ...(preview.input?.recipients || []),
|
|
140
|
+
...(preview.route?.stages?.map(stage => stage.participantId) || []),
|
|
141
|
+
...[preview.participantA, preview.participantB, preview.recipientIdentity || preview.providerId].filter(Boolean)])];
|
|
142
|
+
if (input.reploid && (!recipients.length || recipients.some(id => !input.reploid.allowed_recipients.includes(id)))) {
|
|
143
|
+
throw fail(403, 'Actual route recipients exceed the permitted private pool');
|
|
144
|
+
}
|
|
145
|
+
if (input.reploid && ((input.reploid.expected_plan_identity && preview.planId !== input.reploid.expected_plan_identity)
|
|
146
|
+
|| canonicalPartitionJson(preview.adapterIdentities || []) !== canonicalPartitionJson(partitionAdapterIdentities(model)))) throw fail(409, 'Disclosure execution identity changed');
|
|
147
|
+
assert(preview?.threadId === threadId && preview.attemptId === attemptId
|
|
148
|
+
&& preview.modelId === model.id && preview.modelIdentity === model.identity
|
|
149
|
+
&& canonicalPartitionJson(preview.input?.messages ?? null) === canonicalPartitionJson(request.messages)
|
|
150
|
+
&& canonicalPartitionJson(preview.input?.generationRequest ?? null) === canonicalPartitionJson(requested ?? null),
|
|
151
|
+
'Disclosure preview differs from the admitted request');
|
|
152
|
+
const approved = await authorize({ action: 'chat.disclose', principal, request,
|
|
153
|
+
preview, signal });
|
|
154
|
+
signal.throwIfAborted();
|
|
155
|
+
if (approved !== true) throw fail(403, 'Recipient disclosure denied');
|
|
156
|
+
requirePrivateScope();
|
|
157
|
+
assert(acceptingCallbacks, 'Requester callback after completion');
|
|
158
|
+
disclosureApproved = true;
|
|
159
|
+
approvedRecipients = recipients;
|
|
160
|
+
return true;
|
|
161
|
+
} catch (error) { controller.abort(error); throw error; }
|
|
162
|
+
},
|
|
163
|
+
onDelta(event) {
|
|
164
|
+
try {
|
|
165
|
+
signal.throwIfAborted();
|
|
166
|
+
assert(acceptingCallbacks, 'Requester callback after completion');
|
|
167
|
+
assert(disclosureApproved, 'Recipient disclosure must precede output');
|
|
168
|
+
assert(event?.threadId === threadId && event.attemptId === attemptId && event.sequence === sequence++
|
|
169
|
+
&& typeof event.text === 'string', 'Requester delta identity or sequence mismatch');
|
|
170
|
+
assert(!model.partition || !requested || observedUsage, 'Provider token usage must precede text');
|
|
171
|
+
emitted += event.text;
|
|
172
|
+
assert(emitted.length <= policy.inputLimits.maxOutputCharacters, 'Output text limit exceeded');
|
|
173
|
+
if (input.stream) output.delta(event.text);
|
|
174
|
+
} catch (error) { controller.abort(error); throw error; }
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
acceptingCallbacks = false;
|
|
178
|
+
signal.throwIfAborted();
|
|
179
|
+
assert(disclosureApproved, 'Recipient disclosure must precede completion');
|
|
180
|
+
requirePrivateScope();
|
|
181
|
+
assert(result?.threadId === threadId && result.attemptId === attemptId && result.modelId === model.id
|
|
182
|
+
&& result.modelIdentity === model.identity, 'Requester result identity mismatch');
|
|
183
|
+
if (!model.partition) assert(canonicalPartitionJson(result.adapterIdentities || []) === canonicalPartitionJson(partitionAdapterIdentities(model)), 'Requester adapter identity mismatch');
|
|
184
|
+
assert(typeof result.content === 'string' && result.content.length <= policy.inputLimits.maxOutputCharacters, 'Invalid requester text result');
|
|
185
|
+
assert(!sequence || emitted === result.content, 'Requester final text differs from streamed text');
|
|
186
|
+
const reason = result.execution?.stopReason;
|
|
187
|
+
assert(['length', 'max-tokens', 'stop', 'eos', 'eos-token', 'stop-token', 'stop-sequence', 'context-limit'].includes(reason), 'Requester termination reason unavailable');
|
|
188
|
+
const finishReason = ['length', 'max-tokens', 'context-limit'].includes(reason) ? 'length' : 'stop';
|
|
189
|
+
let usage;
|
|
190
|
+
if (!model.partition && result.usage !== undefined) {
|
|
191
|
+
assert(result.usage.schema === 'raw-model-tokens/v1'
|
|
192
|
+
&& Number.isSafeInteger(result.usage.inputTokens) && result.usage.inputTokens >= 0
|
|
193
|
+
&& Number.isSafeInteger(result.usage.outputTokens) && result.usage.outputTokens >= 0
|
|
194
|
+
&& Number.isSafeInteger(result.usage.inputTokens + result.usage.outputTokens), 'Invalid whole-model token usage');
|
|
195
|
+
usage = Object.freeze({ schema: result.usage.schema, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens });
|
|
196
|
+
}
|
|
197
|
+
if (requested) {
|
|
198
|
+
usage = tokenUsage(result.usage?.inputTokens, result.usage?.outputTokens, requested);
|
|
199
|
+
assert(model.partition
|
|
200
|
+
? observedUsage && usage.inputTokens === observedUsage.inputTokens && result.usage.schema === usage.schema
|
|
201
|
+
&& usage.outputTokens === (result.execution?.stepSummary?.count ?? result.execution?.steps?.length)
|
|
202
|
+
: !observedUsage && result.usage?.schema === usage.schema
|
|
203
|
+
&& usage.inputTokens === result.execution?.completion?.promptTokenCount
|
|
204
|
+
&& usage.outputTokens === result.execution?.completion?.generatedTokenCount,
|
|
205
|
+
'Provider final token usage differs from input observation');
|
|
206
|
+
}
|
|
207
|
+
if (input.stream && !sequence) {
|
|
208
|
+
assert(!model.partition || !requested || observedUsage, 'Provider token usage must precede text');
|
|
209
|
+
output.delta(result.content);
|
|
210
|
+
}
|
|
211
|
+
const evidence = input.reploid?.include_execution ? { schema: 'reploid.http-execution/v1',
|
|
212
|
+
threadId, attemptId, poolId: participant.roomId, executionClass: participant.executionClass,
|
|
213
|
+
modelIdentity: model.identity, adapterIdentities: partitionAdapterIdentities(model), planIdentity: model.partition?.planId || null, recipients: approvedRecipients,
|
|
214
|
+
execution: structuredClone(result.execution) } : undefined;
|
|
215
|
+
output.complete(result.content, finishReason, reason, usage, evidence);
|
|
216
|
+
} catch (cause) {
|
|
217
|
+
const error = cause instanceof Error ? cause : fail(502, 'Requester failed');
|
|
218
|
+
controller.abort(error);
|
|
219
|
+
if (!res.destroyed && !res.writableEnded) {
|
|
220
|
+
const body = formatError(error);
|
|
221
|
+
if (streaming) output.error(body.error);
|
|
222
|
+
else json(res, error.status || 502, body);
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
acceptingCallbacks = false;
|
|
226
|
+
scope.finish();
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
return Object.freeze({ handle, getState: () => ({ closed: requests.closed, active: requests.size, scope: 'http-requests' }),
|
|
230
|
+
close: () => requests.close(() => Error('Text requester server closing'), () => Error('HTTP requester cleanup remains unsettled')) });
|
|
231
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Compare already-normalized tokens; do not coerce absent or non-string input. */
|
|
4
|
+
export const tokensEqual = (left, right) => {
|
|
5
|
+
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
|
6
|
+
const leftBytes = Buffer.from(left);
|
|
7
|
+
const rightBytes = Buffer.from(right);
|
|
8
|
+
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
9
|
+
};
|