reploid 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +534 -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 +83 -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,311 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { signAsParticipant, verifyParticipantSignature } from '../../packages/reploid/src/artifacts/participant-proofs.js';
|
|
8
|
+
import { assert, same, text } from './contract.js';
|
|
9
|
+
import { planFleetPlacement } from './placement.js';
|
|
10
|
+
import { createNativeEarningStorage } from '../native-earning.js';
|
|
11
|
+
import { openFleetCredits } from './credits.js';
|
|
12
|
+
import { canonicalRouteAttemptBinding } from '../../packages/reploid/src/mesh/partitions/partition-contract.js';
|
|
13
|
+
|
|
14
|
+
const DOMAIN = 'reploid.fleet-service-request/v1';
|
|
15
|
+
|
|
16
|
+
/** Private to enrolled signers, using TLS outside loopback. */
|
|
17
|
+
export async function startFleetService({ credits, models, placementPolicy, port = 0, listenHost = '127.0.0.1', tls = null,
|
|
18
|
+
now = Date.now, maxBodyBytes = 1048576 }) {
|
|
19
|
+
assert(['127.0.0.1', '::1', 'localhost'].includes(listenHost) || tls?.key && tls?.cert,
|
|
20
|
+
'Non-loopback fleet service requires TLS');
|
|
21
|
+
const observations = new Map(), demand = new Map(), links = new Map();
|
|
22
|
+
const invoke = async (signer, operation, payload) => {
|
|
23
|
+
const owner = credits.enrollment.byPeer.get(signer);
|
|
24
|
+
assert(owner, 'Unenrolled computer');
|
|
25
|
+
switch (operation) {
|
|
26
|
+
case 'reserve': {
|
|
27
|
+
assert(payload.consumer === signer, 'Cannot reserve another computer’s credit');
|
|
28
|
+
const model = models.find(model => model.id === payload.model.id && model.identity === payload.model.identity);
|
|
29
|
+
assert(model, 'Model is not enabled for this service');
|
|
30
|
+
if (payload.mode === 'whole-model') assert(payload.providers.length === 1 && payload.providers[0].weight === 1, 'Invalid whole-model credit share');
|
|
31
|
+
else {
|
|
32
|
+
const plan = model.plans?.find(plan => plan.id === payload.route?.route?.planId);
|
|
33
|
+
assert(plan && same(plan.creditWeights, payload.providers.map(row => row.weight)), 'Stage shares differ from the operator’s terms');
|
|
34
|
+
}
|
|
35
|
+
return credits.reserve(payload);
|
|
36
|
+
}
|
|
37
|
+
case 'dispatch':
|
|
38
|
+
return credits.dispatch(payload.id, signer);
|
|
39
|
+
case 'admit': {
|
|
40
|
+
const observation = observations.get(owner.computerId);
|
|
41
|
+
if (observation) assert(observation.preferences.enabled === true
|
|
42
|
+
&& observation.preferences.models.some(model => model.id === payload.model?.id && model.identity === payload.model?.identity),
|
|
43
|
+
'Provider owner no longer allows this model');
|
|
44
|
+
const row = credits.request(payload.id), request = row?.binding;
|
|
45
|
+
assert(row?.state === 'dispatched' && now() <= request.deadline && request.consumer === payload.consumer
|
|
46
|
+
&& request.executionId === payload.executionId && request.mode === payload.mode
|
|
47
|
+
&& request.providers.some(provider => provider.participantId === signer)
|
|
48
|
+
&& same(request.model, payload.model) && Number.isSafeInteger(payload.outputLimit)
|
|
49
|
+
&& payload.outputLimit > 0 && payload.outputLimit <= request.maxOutputTokens, 'No matching live credit admission');
|
|
50
|
+
if (request.mode === 'whole-model') assert(request.inputDigest === payload.inputDigest, 'Input differs from credit admission');
|
|
51
|
+
else assert(same(request.route, canonicalRouteAttemptBinding(payload.route))
|
|
52
|
+
&& request.providers[payload.partitionIndex]?.participantId === signer, 'Stage differs from credit admission');
|
|
53
|
+
credits.admit(payload.id, request.mode === 'whole-model' ? 0 : payload.partitionIndex);
|
|
54
|
+
return { admitted: true, deadline: request.deadline, maximum: request.maximum };
|
|
55
|
+
}
|
|
56
|
+
case 'abandonBeforeDispatch':
|
|
57
|
+
return credits.abandonBeforeDispatch(payload.id, signer) ?? { abandoned: true };
|
|
58
|
+
case 'collect':
|
|
59
|
+
case 'fence':
|
|
60
|
+
case 'recover':
|
|
61
|
+
case 'settle': {
|
|
62
|
+
const request = credits.request(payload.id)?.binding;
|
|
63
|
+
assert(request && (request.consumer === signer || request.providers.some(row => row.participantId === signer)), 'Not a participant in this request');
|
|
64
|
+
if (operation === 'collect') return credits.collect(payload.id, payload.evidence);
|
|
65
|
+
if (operation === 'fence') return credits.fence(payload.id, signer);
|
|
66
|
+
if (operation === 'recover') return credits.recover(payload.id);
|
|
67
|
+
return credits.settle(payload.id, payload.evidence);
|
|
68
|
+
}
|
|
69
|
+
case 'recoverMine': return credits.recoverFor(signer);
|
|
70
|
+
case 'providerOpenMine': return credits.providerOpenRequests(signer);
|
|
71
|
+
case 'reconcileMine': return credits.reconcileConsumer(signer, payload?.activeIds ?? []);
|
|
72
|
+
case 'snapshot': return credits.snapshot();
|
|
73
|
+
case 'computers': return { at: now(), computers: [...credits.enrollment.byId.values()].map(computer => {
|
|
74
|
+
const observation = observations.get(computer.id);
|
|
75
|
+
const online = !!observation && now() - observation.observedAt <= placementPolicy.maxObservationAgeMs;
|
|
76
|
+
return { id: computer.id, name: computer.name, participantIds: computer.providers.map(provider => provider.participantId),
|
|
77
|
+
online, observedAt: observation?.observedAt ?? null,
|
|
78
|
+
...(online ? { activeRequests: observation.activeRequests, preparing: observation.preparing === true, resident: observation.resident,
|
|
79
|
+
preferences: observation.preferences, freeGpuBytes: observation.freeGpuBytes, freeHostBytes: observation.freeHostBytes,
|
|
80
|
+
maxStorageBufferBindingSize: observation.maxStorageBufferBindingSize } : {}) };
|
|
81
|
+
}) };
|
|
82
|
+
case 'terms': {
|
|
83
|
+
const model = models.find(row => row.id === payload.id && row.identity === payload.identity);
|
|
84
|
+
assert(model, 'Model is not enabled for this service');
|
|
85
|
+
return { model: structuredClone(model), policy: credits.policy };
|
|
86
|
+
}
|
|
87
|
+
case 'request': {
|
|
88
|
+
const request = credits.request(payload.id);
|
|
89
|
+
assert(request && (request.binding.consumer === signer || request.binding.providers.some(row => row.participantId === signer)), 'Not a participant in this request');
|
|
90
|
+
return request;
|
|
91
|
+
}
|
|
92
|
+
case 'observe': {
|
|
93
|
+
assert(payload.id === owner.computerId && payload.participantId === signer, 'Cannot advertise another computer');
|
|
94
|
+
assert(Number.isSafeInteger(payload.observedAt) && Math.abs(payload.observedAt - now()) <= credits.policy.maxClockSkewMs,
|
|
95
|
+
'Capacity observation is outside the clock-skew bound');
|
|
96
|
+
assert(!observations.has(owner.computerId) || payload.observedAt >= observations.get(owner.computerId).reportedAt, 'Older observation cannot replace current capacity');
|
|
97
|
+
// Receipt time owns expiry; preserve the signed sender time without requiring synchronized clocks.
|
|
98
|
+
const observation = { ...structuredClone(payload), reportedAt: payload.observedAt, observedAt: now() };
|
|
99
|
+
planFleetPlacement({ computers: [observation], demand: [], models, links: [], policy: placementPolicy, now: now() });
|
|
100
|
+
observations.set(owner.computerId, observation);
|
|
101
|
+
return { accepted: true };
|
|
102
|
+
}
|
|
103
|
+
case 'demand':
|
|
104
|
+
assert(text(payload.id) && Array.isArray(payload.requests) && payload.requests.length <= credits.policy.maxPendingPerComputer, 'Bounded demand required');
|
|
105
|
+
planFleetPlacement({ computers: [], demand: payload.requests, models, links: [], policy: placementPolicy, now: now() });
|
|
106
|
+
demand.set(owner.computerId, { at: now(), requests: structuredClone(payload.requests) });
|
|
107
|
+
return { accepted: true };
|
|
108
|
+
case 'links':
|
|
109
|
+
assert(Array.isArray(payload) && payload.length <= credits.enrollment.byId.size && payload.every(row => row.from === owner.computerId), 'Own measured links only');
|
|
110
|
+
links.set(owner.computerId, structuredClone(payload));
|
|
111
|
+
return { accepted: true };
|
|
112
|
+
case 'plan': {
|
|
113
|
+
const workloads = new Map();
|
|
114
|
+
for (const item of demand.values()) {
|
|
115
|
+
if (now() - item.at > placementPolicy.maxObservationAgeMs) continue;
|
|
116
|
+
for (const row of item.requests) {
|
|
117
|
+
const id = `${row.id}:${row.identity}`, prior = workloads.get(id);
|
|
118
|
+
workloads.set(id, prior ? { ...row, queued: prior.queued + row.queued, since: Math.min(row.since, prior.since),
|
|
119
|
+
promptTokens: Math.max(row.promptTokens, prior.promptTokens), outputTokens: Math.max(row.outputTokens, prior.outputTokens) } : row);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return planFleetPlacement({ computers: [...observations.values()], demand: [...workloads.values()],
|
|
123
|
+
models, links: [...links.values()].flat(), policy: placementPolicy, now: now() });
|
|
124
|
+
}
|
|
125
|
+
default: throw Error('Unknown fleet operation');
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const handle = async (request, response) => {
|
|
129
|
+
try {
|
|
130
|
+
assert(request.method === 'POST' && request.url === '/v1/fleet', 'Unknown fleet endpoint');
|
|
131
|
+
const chunks = []; let length = 0;
|
|
132
|
+
for await (const chunk of request) { length += chunk.length; assert(length <= maxBodyBytes, 'Fleet request too large'); chunks.push(chunk); }
|
|
133
|
+
const envelope = JSON.parse(Buffer.concat(chunks));
|
|
134
|
+
const { signature, ...body } = envelope;
|
|
135
|
+
assert(body.schema === DOMAIN && text(body.nonce) && text(body.operation) && Number.isSafeInteger(body.at)
|
|
136
|
+
&& Math.abs(now() - body.at) <= credits.policy.maxClockSkewMs, 'Invalid or stale fleet request');
|
|
137
|
+
const registered = credits.enrollment.byPeer.get(body.participantId);
|
|
138
|
+
assert(registered && await verifyParticipantSignature(registered.publicJwk, DOMAIN, body, signature), 'Invalid fleet request signature');
|
|
139
|
+
// Mutations remain idempotent by reservation/work identity. Observations replace only the sender’s slot.
|
|
140
|
+
const result = await invoke(body.participantId, body.operation, body.payload);
|
|
141
|
+
response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
142
|
+
response.end(JSON.stringify({ result }));
|
|
143
|
+
} catch (error) {
|
|
144
|
+
response.writeHead(400, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
145
|
+
response.end(JSON.stringify({ error: error.message }));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const server = tls ? https.createServer(tls, handle) : http.createServer(handle);
|
|
149
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, listenHost, resolve); });
|
|
150
|
+
return { url: `${tls ? 'https' : 'http'}://${listenHost.includes(':') ? '[' + listenHost + ']' : listenHost}:${server.address().port}/v1/fleet`,
|
|
151
|
+
close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeIdleConnections(); }) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
155
|
+
const configuration = JSON.parse(await fs.readFile(process.argv[2], 'utf8'));
|
|
156
|
+
assert(configuration.schema === 'reploid.fleet-service/v1', 'Explicit fleet service configuration required');
|
|
157
|
+
const computers = JSON.parse(await fs.readFile(configuration.enrollmentFile, 'utf8'));
|
|
158
|
+
const models = JSON.parse(await fs.readFile(configuration.modelsFile, 'utf8'));
|
|
159
|
+
const credits = await openFleetCredits({ file: configuration.databaseFile, computers, policy: configuration.creditPolicy });
|
|
160
|
+
let service;
|
|
161
|
+
try {
|
|
162
|
+
const tls = configuration.tls ? { key: await fs.readFile(configuration.tls.keyFile), cert: await fs.readFile(configuration.tls.certFile) } : null;
|
|
163
|
+
service = await startFleetService({ credits, models, placementPolicy: configuration.placementPolicy,
|
|
164
|
+
listenHost: configuration.listenHost, port: configuration.port, tls });
|
|
165
|
+
console.log(JSON.stringify({ ready: true, url: service.url }));
|
|
166
|
+
await new Promise(resolve => { process.once('SIGINT', resolve); process.once('SIGTERM', resolve); });
|
|
167
|
+
} finally { await service?.close(); credits.close(); }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function createPinnedTlsFetch(ca) {
|
|
171
|
+
return (url, options = {}) => new Promise((resolve, reject) => {
|
|
172
|
+
const target = new URL(url);
|
|
173
|
+
const bodyBuffer = options.body ? Buffer.from(options.body) : null;
|
|
174
|
+
const req = https.request(target, {
|
|
175
|
+
method: options.method || 'POST',
|
|
176
|
+
headers: { ...options.headers, ...(bodyBuffer ? { 'content-length': String(bodyBuffer.length) } : {}) },
|
|
177
|
+
ca
|
|
178
|
+
}, res => {
|
|
179
|
+
const chunks = [];
|
|
180
|
+
res.on('data', chunk => chunks.push(chunk));
|
|
181
|
+
res.on('end', () => {
|
|
182
|
+
const textBody = Buffer.concat(chunks).toString('utf8');
|
|
183
|
+
resolve({
|
|
184
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
185
|
+
status: res.statusCode || 500,
|
|
186
|
+
json: async () => JSON.parse(textBody)
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
const onAbort = () => req.destroy(options.signal?.reason || Error('Fleet TLS request aborted'));
|
|
191
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
192
|
+
if (options.signal?.aborted) { onAbort(); return; }
|
|
193
|
+
req.on('error', reject);
|
|
194
|
+
req.on('close', () => options.signal?.removeEventListener('abort', onAbort));
|
|
195
|
+
if (bodyBuffer) req.write(bodyBuffer);
|
|
196
|
+
req.end();
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function createFleetClient({ url, identity, fetchImpl = null, ca = null, now = Date.now, outboxFile = null }) {
|
|
201
|
+
const target = new URL(url);
|
|
202
|
+
assert(target.protocol === 'https:' || target.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname),
|
|
203
|
+
'Fleet service requires HTTPS or an owned loopback tunnel');
|
|
204
|
+
const send = fetchImpl || (ca && target.protocol === 'https:' ? createPinnedTlsFetch(ca) : fetch);
|
|
205
|
+
const call = async (operation, payload) => {
|
|
206
|
+
payload = wireValue(payload);
|
|
207
|
+
const body = { schema: DOMAIN, participantId: identity.peerId, operation, payload, at: now(), nonce: randomUUID() };
|
|
208
|
+
const signature = await signAsParticipant(identity, DOMAIN, body);
|
|
209
|
+
const response = await send(url, { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
210
|
+
body: JSON.stringify({ ...body, signature }), signal: AbortSignal.timeout(30000) });
|
|
211
|
+
const answer = await response.json();
|
|
212
|
+
if (!response.ok || answer.error) throw Object.assign(Error(answer.error || 'Fleet request failed'),
|
|
213
|
+
{ code: response.status === 400 ? 'FLEET_REJECTED' : 'FLEET_UNAVAILABLE' });
|
|
214
|
+
return answer.result;
|
|
215
|
+
};
|
|
216
|
+
const storage = outboxFile ? createNativeEarningStorage(outboxFile) : null;
|
|
217
|
+
let writes = Promise.resolve();
|
|
218
|
+
const update = operation => {
|
|
219
|
+
const result = writes.then(async () => {
|
|
220
|
+
const rows = await storage.get('settlements') || [];
|
|
221
|
+
await storage.set('settlements', operation(rows));
|
|
222
|
+
});
|
|
223
|
+
writes = result.catch(() => {}); return result;
|
|
224
|
+
};
|
|
225
|
+
async function settle(id, evidence) {
|
|
226
|
+
evidence = wireValue(evidence);
|
|
227
|
+
if (storage) await update(rows => {
|
|
228
|
+
const prior = rows.find(row => row.id === id);
|
|
229
|
+
assert(!prior || same(prior.evidence, evidence), 'Conflicting pending settlement');
|
|
230
|
+
return prior ? rows : [...rows, { id, evidence: structuredClone(evidence) }];
|
|
231
|
+
});
|
|
232
|
+
let result;
|
|
233
|
+
try { result = await call('settle', { id, evidence }); }
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (storage && error.code === 'FLEET_REJECTED') {
|
|
236
|
+
await quarantine({ operation: 'settle', payload: { id, evidence }, error: error.message });
|
|
237
|
+
await update(rows => rows.filter(row => row.id !== id));
|
|
238
|
+
}
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
if (storage) await update(rows => rows.filter(row => row.id !== id));
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
const quarantine = async record => {
|
|
245
|
+
// Invalid proof is retained for diagnosis, never retried as if a transient network failure.
|
|
246
|
+
const key = `rejected:${record.operation}:${record.payload.id}`;
|
|
247
|
+
await storage.set(key, record);
|
|
248
|
+
};
|
|
249
|
+
let recoveryWrites = Promise.resolve();
|
|
250
|
+
const changeRecovery = task => {
|
|
251
|
+
const run = recoveryWrites.then(async () => {
|
|
252
|
+
if (!storage) return;
|
|
253
|
+
await storage.set('recovery', task(await storage.get('recovery') || []));
|
|
254
|
+
});
|
|
255
|
+
recoveryWrites = run.catch(() => {}); return run;
|
|
256
|
+
};
|
|
257
|
+
async function recoverable(operation, payload) {
|
|
258
|
+
payload = wireValue(payload);
|
|
259
|
+
const key = JSON.stringify({ operation, payload });
|
|
260
|
+
await changeRecovery(rows => rows.some(row => row.key === key) ? rows : [...rows, { key, operation, payload }]);
|
|
261
|
+
let result;
|
|
262
|
+
try { result = await call(operation, payload); }
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (storage && error.code === 'FLEET_REJECTED') {
|
|
265
|
+
await quarantine({ operation, payload, error: error.message });
|
|
266
|
+
await changeRecovery(rows => rows.filter(row => row.key !== key));
|
|
267
|
+
}
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
// Collected pieces are durable at the service. A fence remains retryable until final settlement.
|
|
271
|
+
if (operation === 'collect' || result.status !== 'pending') await changeRecovery(rows => rows.filter(row => row.key !== key));
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
return { call, collect: (id, evidence) => recoverable('collect', { id, evidence }),
|
|
275
|
+
fence: id => recoverable('fence', { id }), reserve: payload => call('reserve', payload), dispatch: id => call('dispatch', { id }), settle,
|
|
276
|
+
abandonBeforeDispatch: id => call('abandonBeforeDispatch', { id }),
|
|
277
|
+
async reconcile({ activeIds = null } = {}) {
|
|
278
|
+
await Promise.all([writes, recoveryWrites]);
|
|
279
|
+
const pending = storage ? await storage.get('settlements') || [] : [], outcomes = [];
|
|
280
|
+
for (const row of pending) {
|
|
281
|
+
try { outcomes.push(await settle(row.id, row.evidence)); }
|
|
282
|
+
catch (error) { outcomes.push({ id: row.id, status: 'pending', error: error.message }); }
|
|
283
|
+
}
|
|
284
|
+
const recovery = storage ? await storage.get('recovery') || [] : [];
|
|
285
|
+
for (const row of recovery) {
|
|
286
|
+
try { outcomes.push(await recoverable(row.operation, row.payload)); }
|
|
287
|
+
catch (error) { outcomes.push({ id: row.payload.id, status: 'pending', error: error.message }); }
|
|
288
|
+
}
|
|
289
|
+
if (Array.isArray(activeIds)) {
|
|
290
|
+
const consumerOutcomes = await call('reconcileMine', { activeIds });
|
|
291
|
+
for (const row of consumerOutcomes) {
|
|
292
|
+
if (!outcomes.some(value => value.id === row.id)) outcomes.push(row);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const recovered = await call('recoverMine', {});
|
|
296
|
+
return [...outcomes, ...recovered.filter(row => !outcomes.some(value => value.id === row.id))];
|
|
297
|
+
} };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Sign and retain the exact JSON bytes' value. Sparse arrays and non-finite numbers are
|
|
301
|
+
* not valid evidence; object properties explicitly left undefined are omitted before signing. */
|
|
302
|
+
function wireValue(value) {
|
|
303
|
+
const visit = (item, array = false) => {
|
|
304
|
+
assert(item !== undefined || !array, 'Undefined array entry in fleet evidence');
|
|
305
|
+
assert(typeof item !== 'number' || Number.isFinite(item), 'Non-finite fleet value');
|
|
306
|
+
if (Array.isArray(item)) for (let i = 0; i < item.length; i++) visit(item[i], true);
|
|
307
|
+
else if (item && typeof item === 'object') for (const field of Object.values(item)) visit(field);
|
|
308
|
+
};
|
|
309
|
+
visit(value);
|
|
310
|
+
return JSON.parse(JSON.stringify(value));
|
|
311
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Explicit operator acceptance of fixed whole-model terminal terms.
|
|
2
|
+
* Existing completed balances and metered rates are never reinterpreted. */
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { constants } from 'node:fs';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { canonicalize } from '../../packages/reploid/src/artifacts/canonical-json.js';
|
|
8
|
+
import { assert, creditPolicy, same, text } from './contract.js';
|
|
9
|
+
|
|
10
|
+
export async function acceptFleetTerminalPolicy({ file, previousVersion, policy: supplied, requestIds, now = Date.now }) {
|
|
11
|
+
const policy = creditPolicy(supplied);
|
|
12
|
+
assert(path.isAbsolute(file) && text(previousVersion) && policy.version !== previousVersion
|
|
13
|
+
&& policy.wholeModelTerminalCharge === 0 && Array.isArray(requestIds)
|
|
14
|
+
&& requestIds.length <= 2048 && requestIds.every(text) && new Set(requestIds).size === requestIds.length,
|
|
15
|
+
'Explicit zero-charge terminal policy and reservation scope required');
|
|
16
|
+
const handle = await fs.open(file, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
17
|
+
try { assert((await handle.stat()).isFile(), 'Existing credit database required'); } finally { await handle.close(); }
|
|
18
|
+
const db = new DatabaseSync(file);
|
|
19
|
+
let transactionOpen = false;
|
|
20
|
+
try {
|
|
21
|
+
db.exec('BEGIN IMMEDIATE'); transactionOpen = true;
|
|
22
|
+
const key = `terminal-policy:${policy.version}`;
|
|
23
|
+
const priorAcceptance = db.prepare('SELECT value FROM fleet_metadata WHERE id=?').get(key);
|
|
24
|
+
const acceptance = { schema: 'reploid.fleet-terminal-policy/v1', previousVersion, policy, requestIds };
|
|
25
|
+
if (priorAcceptance) {
|
|
26
|
+
const retained = JSON.parse(priorAcceptance.value);
|
|
27
|
+
assert(same(retained.acceptance, acceptance), 'Terminal policy acceptance scope changed');
|
|
28
|
+
db.exec('COMMIT'); transactionOpen = false; return retained;
|
|
29
|
+
}
|
|
30
|
+
const current = JSON.parse(db.prepare("SELECT value FROM fleet_metadata WHERE id='policy'").get().value);
|
|
31
|
+
const { version: _oldVersion, wholeModelTerminalCharge: _oldCharge, ...oldTerms } = current;
|
|
32
|
+
const { version: _newVersion, wholeModelTerminalCharge: _newCharge, ...newTerms } = policy;
|
|
33
|
+
assert(current.version === previousVersion && same(oldTerms, newTerms), 'Terminal migration cannot change metered terms');
|
|
34
|
+
for (const id of requestIds) {
|
|
35
|
+
const row = db.prepare('SELECT binding,state FROM fleet_requests WHERE id=?').get(id);
|
|
36
|
+
const binding = row && JSON.parse(row.binding);
|
|
37
|
+
assert(binding?.mode === 'whole-model' && ['reserved', 'dispatched', 'closing'].includes(row.state)
|
|
38
|
+
&& binding.terminalCharge == null, 'Only explicitly listed unresolved whole-model terms may change');
|
|
39
|
+
binding.terminalCharge = 0;
|
|
40
|
+
binding.terminalPolicyVersion = policy.version;
|
|
41
|
+
db.prepare('UPDATE fleet_requests SET binding=? WHERE id=?').run(JSON.stringify(binding), id);
|
|
42
|
+
}
|
|
43
|
+
const record = { acceptance, previousPolicy: current, acceptedAt: now() };
|
|
44
|
+
db.prepare('INSERT INTO fleet_metadata VALUES (?,?)').run(key, JSON.stringify(record));
|
|
45
|
+
db.prepare("UPDATE fleet_metadata SET value=? WHERE id='policy'").run(canonicalize(policy));
|
|
46
|
+
db.exec('COMMIT'); transactionOpen = false; return record;
|
|
47
|
+
} catch (error) { if (transactionOpen) db.exec('ROLLBACK'); throw error; }
|
|
48
|
+
finally { db.close(); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function acceptFleetOutputLimitPolicy({ file, previousVersion, policy: supplied, now = Date.now }) {
|
|
52
|
+
const policy = creditPolicy(supplied);
|
|
53
|
+
assert(path.isAbsolute(file) && text(previousVersion) && policy.version !== previousVersion,
|
|
54
|
+
'Explicit output-limit policy version change required');
|
|
55
|
+
const handle = await fs.open(file, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
56
|
+
try { assert((await handle.stat()).isFile(), 'Existing credit database required'); } finally { await handle.close(); }
|
|
57
|
+
const db = new DatabaseSync(file);
|
|
58
|
+
let transactionOpen = false;
|
|
59
|
+
try {
|
|
60
|
+
db.exec('BEGIN IMMEDIATE'); transactionOpen = true;
|
|
61
|
+
const key = `output-limit-policy:${policy.version}`;
|
|
62
|
+
const priorAcceptance = db.prepare('SELECT value FROM fleet_metadata WHERE id=?').get(key);
|
|
63
|
+
const acceptance = { schema: 'reploid.fleet-output-limit-policy/v1', previousVersion, policy };
|
|
64
|
+
if (priorAcceptance) {
|
|
65
|
+
const retained = JSON.parse(priorAcceptance.value);
|
|
66
|
+
assert(same(retained.acceptance, acceptance), 'Output-limit policy acceptance scope changed');
|
|
67
|
+
db.exec('COMMIT'); transactionOpen = false; return retained;
|
|
68
|
+
}
|
|
69
|
+
const current = JSON.parse(db.prepare("SELECT value FROM fleet_metadata WHERE id='policy'").get().value);
|
|
70
|
+
const { version: _oldVersion, maxOutputTokens: _oldLimit, ...oldTerms } = current;
|
|
71
|
+
const { version: _newVersion, maxOutputTokens: _newLimit, ...newTerms } = policy;
|
|
72
|
+
assert(current.version === previousVersion && same(oldTerms, newTerms),
|
|
73
|
+
'Output-limit migration cannot change credit unit, rate, debt or terminal terms');
|
|
74
|
+
const openCount = db.prepare("SELECT COUNT(*) AS count FROM fleet_requests WHERE state IN ('reserved','dispatched','closing')").get().count;
|
|
75
|
+
const heldCount = db.prepare('SELECT COUNT(*) AS count FROM fleet_accounts WHERE held != 0').get().count;
|
|
76
|
+
assert(openCount === 0 && heldCount === 0, 'Output-limit migration requires zero open reservations and holds');
|
|
77
|
+
const record = { acceptance, previousPolicy: current, acceptedAt: now() };
|
|
78
|
+
db.prepare('INSERT INTO fleet_metadata VALUES (?,?)').run(key, JSON.stringify(record));
|
|
79
|
+
db.prepare("UPDATE fleet_metadata SET value=? WHERE id='policy'").run(canonicalize(policy));
|
|
80
|
+
db.exec('COMMIT'); transactionOpen = false; return record;
|
|
81
|
+
} catch (error) { if (transactionOpen) db.exec('ROLLBACK'); throw error; }
|
|
82
|
+
finally { db.close(); }
|
|
83
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { createTextApi } from '../text-api.js';
|
|
4
|
+
import { tokensEqual } from '../token-auth.js';
|
|
5
|
+
import { createFleetBrowserBridgeHandler } from './browser-bridge.js';
|
|
6
|
+
import { assert } from './contract.js';
|
|
7
|
+
|
|
8
|
+
function previewRecipients(preview) {
|
|
9
|
+
return [...new Set([...(preview?.recipients || []), ...(preview?.input?.recipients || []),
|
|
10
|
+
...(preview?.route?.stages?.map(stage => stage.participantId) || []),
|
|
11
|
+
...[preview?.participantA, preview?.participantB, preview?.recipientIdentity || preview?.providerId].filter(Boolean)])];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Authenticated HTTP text API (/v1/models, /v1/chat/completions, /v1/responses)
|
|
15
|
+
* over an enrolled native fleet requester. Non-loopback binding requires TLS;
|
|
16
|
+
* every execution reserves and settles non-cash fleet service credits and checks
|
|
17
|
+
* disclosed provider keys against the coordinator's enrolled computers. */
|
|
18
|
+
export async function startFleetTextApi({ host, token, listenHost = '127.0.0.1', port = 0, tls = null, allowedRecipients = null, browserBridge = null }) {
|
|
19
|
+
assert(typeof token === 'string' && token.length >= 32, 'Strong API bearer secret required');
|
|
20
|
+
assert(host?.textParticipant && typeof host.runAccountedGeneration === 'function'
|
|
21
|
+
&& typeof host.enrolledRecipients === 'function' && host.identity?.peerId,
|
|
22
|
+
'Configured fleet host with service-credit accounting required');
|
|
23
|
+
const loopback = ['127.0.0.1', '::1'].includes(listenHost);
|
|
24
|
+
assert(loopback || Boolean(tls?.key && tls?.cert), 'Non-loopback fleet text API requires TLS');
|
|
25
|
+
let pinned = null;
|
|
26
|
+
if (allowedRecipients !== null && allowedRecipients !== undefined) {
|
|
27
|
+
assert(Array.isArray(allowedRecipients) && allowedRecipients.length > 0 && allowedRecipients.length <= 32
|
|
28
|
+
&& new Set(allowedRecipients).size === allowedRecipients.length
|
|
29
|
+
&& allowedRecipients.every(id => typeof id === 'string' && /^peer:[a-f0-9]{24,64}$/i.test(id)),
|
|
30
|
+
'Allowed recipients must be distinct enrolled peer identities');
|
|
31
|
+
pinned = new Set(allowedRecipients);
|
|
32
|
+
}
|
|
33
|
+
const bridge = browserBridge ? createFleetBrowserBridgeHandler({
|
|
34
|
+
host,
|
|
35
|
+
token: browserBridge.token,
|
|
36
|
+
origins: browserBridge.origins,
|
|
37
|
+
basePath: browserBridge.basePath ?? '/bridge',
|
|
38
|
+
...(browserBridge.maxBodyBytes !== undefined ? { maxBodyBytes: browserBridge.maxBodyBytes } : {}),
|
|
39
|
+
...(browserBridge.maxConcurrent !== undefined ? { maxConcurrent: browserBridge.maxConcurrent } : {})
|
|
40
|
+
}) : null;
|
|
41
|
+
const expectedHeader = `Bearer ${token}`;
|
|
42
|
+
const api = createTextApi({
|
|
43
|
+
participant: host.textParticipant,
|
|
44
|
+
production: true,
|
|
45
|
+
authenticate: async req => tokensEqual(req.headers.authorization, expectedHeader)
|
|
46
|
+
? { subject: `fleet-api:${host.identity.peerId}` } : null,
|
|
47
|
+
authorize: async check => {
|
|
48
|
+
if (host.textParticipant.executionClass !== 'private-pool') return false;
|
|
49
|
+
if (check.action === 'models.list') return true;
|
|
50
|
+
if (check.action === 'chat.execute') return host.allowsModel(check.request?.model);
|
|
51
|
+
if (check.action === 'chat.recover') return typeof host.recoverAccountedGeneration === 'function';
|
|
52
|
+
if (check.action === 'chat.disclose') {
|
|
53
|
+
const enrolled = await host.enrolledRecipients();
|
|
54
|
+
const recipients = previewRecipients(check.preview);
|
|
55
|
+
return recipients.length > 0 && recipients.every(id => enrolled.has(id) && (!pinned || pinned.has(id)));
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
},
|
|
59
|
+
accounting: {
|
|
60
|
+
run: (request, controls, execute) => host.runAccountedGeneration(request, controls, execute),
|
|
61
|
+
...(typeof host.recoverAccountedGeneration === 'function'
|
|
62
|
+
? { recover: (input, controls, recoverExecution) => host.recoverAccountedGeneration(input, controls, recoverExecution) }
|
|
63
|
+
: {})
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const handle = (req, res) => {
|
|
67
|
+
res.setHeader('cache-control', 'no-store');
|
|
68
|
+
res.setHeader('x-content-type-options', 'nosniff');
|
|
69
|
+
if (bridge) {
|
|
70
|
+
let pathname = '';
|
|
71
|
+
try { pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname; }
|
|
72
|
+
catch { pathname = ''; }
|
|
73
|
+
if (pathname === bridge.prefix || pathname.startsWith(bridge.prefix + '/')) {
|
|
74
|
+
return bridge.handle(req, res);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return api.handle(req, res);
|
|
78
|
+
};
|
|
79
|
+
const server = tls ? https.createServer(tls, handle) : http.createServer(handle);
|
|
80
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, listenHost, resolve); });
|
|
81
|
+
const address = server.address();
|
|
82
|
+
const hostText = listenHost.includes(':') ? `[${listenHost}]` : listenHost;
|
|
83
|
+
const baseUrl = `${tls ? 'https' : 'http'}://${hostText}:${address.port}`;
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
url: `${baseUrl}/v1`,
|
|
86
|
+
...(bridge ? { bridgeUrl: `${baseUrl}${bridge.prefix}` } : {}),
|
|
87
|
+
async close() {
|
|
88
|
+
await Promise.all([api.close(), bridge?.close()]);
|
|
89
|
+
server.closeIdleConnections();
|
|
90
|
+
server.closeAllConnections();
|
|
91
|
+
await new Promise(resolve => server.close(resolve));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The hosted services' one SHA-256 and canonical JSON, over
|
|
3
|
+
* node:crypto. Keys, record ids and evidence digests written by server code
|
|
4
|
+
* come from here, so retained hashes have one definition. The browser
|
|
5
|
+
* library's Web Crypto twin is packages/reploid/src/artifacts/signatures.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
/** Lowercase hex SHA-256 with no prefix: bytes as given, any other value as the UTF-8 of String(value). */
|
|
11
|
+
export const sha256Hex = (value) => createHash('sha256')
|
|
12
|
+
.update(typeof value === 'string' || ArrayBuffer.isView(value) ? value : String(value))
|
|
13
|
+
.digest('hex');
|
|
14
|
+
|
|
15
|
+
/** `sha256:`-prefixed form of sha256Hex. */
|
|
16
|
+
export const sha256Digest = (value) => `sha256:${sha256Hex(value)}`;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sorted-key JSON. Object members whose value is undefined are left out; any
|
|
20
|
+
* other undefined (array items, the top-level value) serializes as null, as
|
|
21
|
+
* JSON.stringify would.
|
|
22
|
+
*/
|
|
23
|
+
export const canonicalJson = (value) => {
|
|
24
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
25
|
+
if (value && typeof value === 'object') {
|
|
26
|
+
return `{${Object.keys(value).sort()
|
|
27
|
+
.filter((key) => value[key] !== undefined)
|
|
28
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
29
|
+
.join(',')}}`;
|
|
30
|
+
}
|
|
31
|
+
return JSON.stringify(value ?? null);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** `sha256:` digest of canonicalJson(value). */
|
|
35
|
+
export const hashCanonical = (value) => sha256Digest(canonicalJson(value));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** HTTP lifetime only. Callers retain protocol, authorization and settlement policy. */
|
|
2
|
+
export function createHttpRequestOwner({ timeoutMs }) {
|
|
3
|
+
const active = new Set();
|
|
4
|
+
let closed = false;
|
|
5
|
+
return {
|
|
6
|
+
get closed() { return closed; },
|
|
7
|
+
get size() { return active.size; },
|
|
8
|
+
begin(req, res, { disconnectError, deadlineError, onFinish = () => {} }) {
|
|
9
|
+
if (closed) throw new Error('HTTP request owner is closed');
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
let resolve;
|
|
12
|
+
const item = { controller, completion: new Promise(done => { resolve = done; }) };
|
|
13
|
+
active.add(item);
|
|
14
|
+
let finished = false;
|
|
15
|
+
const socket = req.socket ?? null;
|
|
16
|
+
const cancel = () => {
|
|
17
|
+
if (!finished && !res.writableEnded) controller.abort(disconnectError());
|
|
18
|
+
};
|
|
19
|
+
req.once('aborted', cancel);
|
|
20
|
+
res.once('close', cancel);
|
|
21
|
+
socket?.once('close', cancel);
|
|
22
|
+
const timer = setTimeout(() => controller.abort(deadlineError()), timeoutMs);
|
|
23
|
+
return {
|
|
24
|
+
controller, signal: controller.signal, item,
|
|
25
|
+
clearDeadline() { clearTimeout(timer); },
|
|
26
|
+
finish() {
|
|
27
|
+
if (finished) return;
|
|
28
|
+
finished = true;
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
req.removeListener('aborted', cancel);
|
|
31
|
+
res.removeListener('close', cancel);
|
|
32
|
+
socket?.removeListener('close', cancel);
|
|
33
|
+
active.delete(item);
|
|
34
|
+
try { onFinish(item); } finally { resolve(); }
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
async close(reason, unsettledError) {
|
|
39
|
+
closed = true;
|
|
40
|
+
for (const item of active) item.controller.abort(reason());
|
|
41
|
+
let timer;
|
|
42
|
+
try {
|
|
43
|
+
await Promise.race([Promise.all([...active].map(item => item.completion)), new Promise((_, reject) => {
|
|
44
|
+
timer = setTimeout(() => reject(unsettledError()), timeoutMs);
|
|
45
|
+
})]);
|
|
46
|
+
} finally { clearTimeout(timer); }
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Stateless Anthropic-shaped text Messages over declared local token semantics. */
|
|
2
|
+
import policy from '../apps/poolday/config/partition-policy.json' with { type: 'json' };
|
|
3
|
+
import { parseTextChatCompletion } from './chat-completions.js';
|
|
4
|
+
import { createTextRequester, fail, object, freeze } from './text-requester.js';
|
|
5
|
+
|
|
6
|
+
export function parseTextMessage(body) {
|
|
7
|
+
if (!object(body)) throw fail(400, 'A JSON object is required');
|
|
8
|
+
for (const key of Object.keys(body)) if (!['model','messages','system','max_tokens','stream'].includes(key)) throw fail(400, `Unsupported parameter: ${key}`, key);
|
|
9
|
+
if (!Number.isSafeInteger(body.max_tokens) || body.max_tokens <= 0 || body.max_tokens > policy.limits.maxTokens) {
|
|
10
|
+
throw fail(400, 'max_tokens must be a positive integer within the route budget; cache-only requests are unsupported', 'max_tokens');
|
|
11
|
+
}
|
|
12
|
+
if (!Array.isArray(body.messages) || !body.messages.length || body.messages.some((value,index) => !object(value)
|
|
13
|
+
|| !['user','assistant'].includes(value.role) || value.role === body.messages[index-1]?.role)
|
|
14
|
+
|| body.messages[0].role !== 'user' || body.messages.at(-1).role !== 'user') {
|
|
15
|
+
throw fail(400, 'Require alternating user/assistant text ending in user; assistant prefill is unsupported', 'messages');
|
|
16
|
+
}
|
|
17
|
+
if (body.system !== undefined && typeof body.system !== 'string') throw fail(400, 'system must be text', 'system');
|
|
18
|
+
const messages = [...(body.system === undefined ? [] : [{role:'system',content:body.system}]), ...body.messages];
|
|
19
|
+
return freeze({ ...parseTextChatCompletion({model:body.model,messages,stream:body.stream}), generationRequest: {
|
|
20
|
+
schema:'reploid.partition-generation-request/v1',maxOutputTokens:body.max_tokens,usage:'raw-model-tokens/v1' } });
|
|
21
|
+
}
|
|
22
|
+
const errorBody = error => ({type:'error',error:{type: error.status===401?'authentication_error':error.status===403?'permission_error'
|
|
23
|
+
:error.status===429?'rate_limit_error':error.status<500?'invalid_request_error':'api_error', message:String(error.message).slice(0,1024)}});
|
|
24
|
+
export function createMessages(options) {
|
|
25
|
+
return createTextRequester(options,{path:'/v1/messages',idPrefix:'msg_',parse:parseTextMessage,errorBody,
|
|
26
|
+
validateRequest(req) {
|
|
27
|
+
if (req.headers['anthropic-version'] !== undefined && req.headers['anthropic-version'] !== '2023-06-01') throw fail(400,'Unsupported anthropic-version');
|
|
28
|
+
if (req.headers['anthropic-beta'] !== undefined) throw fail(400,'Anthropic beta features are unsupported');
|
|
29
|
+
},
|
|
30
|
+
output({id,model,input,write,end,json}) {
|
|
31
|
+
let started=false;
|
|
32
|
+
const usage = value => ({input_tokens:value.inputTokens,output_tokens:value.outputTokens});
|
|
33
|
+
const message = (content,stopReason,counts) => ({id,type:'message',role:'assistant',model,
|
|
34
|
+
content:content===null?[]:[{type:'text',text:content}],stop_reason:stopReason,stop_sequence:null,usage:usage(counts)});
|
|
35
|
+
const frame = (type,value) => `event: ${type}\ndata: ${JSON.stringify({type,...value})}\n\n`;
|
|
36
|
+
const event = (type,value) => write(frame(type,value));
|
|
37
|
+
return {
|
|
38
|
+
usage(value) {
|
|
39
|
+
if (started) throw fail(502,'Duplicate input token observation'); started=true;
|
|
40
|
+
event('message_start',{message:message(null,null,value)});
|
|
41
|
+
event('content_block_start',{index:0,content_block:{type:'text',text:''}});
|
|
42
|
+
},
|
|
43
|
+
delta(text) { if(!started)throw fail(502,'Input token usage unavailable'); event('content_block_delta',{index:0,delta:{type:'text_delta',text}}); },
|
|
44
|
+
complete(content,finishReason,reason,counts) {
|
|
45
|
+
if (!counts) throw fail(502,'Actual provider token usage unavailable');
|
|
46
|
+
if (reason==='context-limit' || reason==='stop-sequence') throw fail(502,'Requester stopping reason lacks a supported Messages mapping');
|
|
47
|
+
const stopReason=finishReason==='length'?'max_tokens':'end_turn';
|
|
48
|
+
if (!input.stream) {json(message(content,stopReason,counts));return;}
|
|
49
|
+
event('content_block_stop',{index:0});
|
|
50
|
+
event('message_delta',{delta:{stop_reason:stopReason,stop_sequence:null},usage:{output_tokens:counts.outputTokens}});
|
|
51
|
+
end(frame('message_stop',{}));
|
|
52
|
+
},
|
|
53
|
+
error(error) {end(frame('error',{error}));}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|