slotlock 0.6.3 → 0.8.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/CHANGELOG.md +511 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1551
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +559 -44
- package/agent-store-backend.d.ts +34 -1
- package/agent-store-backend.js +1399 -38
- package/api-keys.d.ts +69 -3
- package/api-keys.js +217 -28
- package/application-api.d.ts +56 -3
- package/application-api.js +826 -15
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +70 -1
- package/assistant.js +478 -50
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +438 -0
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +227 -7
- package/dashboard.js +2313 -247
- package/ddl.d.ts +51 -12
- package/ddl.js +2566 -54
- package/event-feed.d.ts +30 -4
- package/event-feed.js +171 -19
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/maintenance.d.ts +10 -0
- package/maintenance.js +16 -2
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -0
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +21 -1
- package/rate-limit.js +82 -15
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +2 -0
- package/rest-api.js +68 -3
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +33 -5
- package/self-host.js +950 -22
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +373 -4
- package/store.js +1212 -118
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.js +13 -1
- package/types.d.ts +95 -1
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
package/assistant.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// Nothing the model says changes the list of tools.
|
|
6
6
|
//
|
|
7
7
|
// Request formats as the vendors' official TypeScript SDKs describe them: @anthropic-ai/sdk 1.3.0
|
|
8
|
-
// and openai 7.45.1 (read from the npm registry 2026-10-28).
|
|
8
|
+
// and openai 7.45.1 (read from the npm registry 2026-10-28). Streamed answers as Anthropic's
|
|
9
|
+
// "Streaming messages" page, the Chat Completions chunk object, @anthropic-ai/sdk 1.8.0 and openai
|
|
10
|
+
// 7.55.0 describe them, read as server-sent events by the WHATWG HTML standard (all read 2026-11-10).
|
|
9
11
|
//
|
|
10
12
|
// The API key is sealed with AES-256-GCM (WebCrypto) under a key HKDF-SHA-256-derived from
|
|
11
13
|
// SLOTLOCK_ASSISTANT_SECRET, with the tenant as additional data: a sealed key copied to another
|
|
@@ -34,6 +36,9 @@ export const SLOTLOCK_ASSISTANT_TOOL_USE = Object.freeze({
|
|
|
34
36
|
slotlock_create_event: 'propose',
|
|
35
37
|
slotlock_update_event: 'propose',
|
|
36
38
|
slotlock_delete_event: 'propose',
|
|
39
|
+
slotlock_confirm_slot: 'propose',
|
|
40
|
+
slotlock_reschedule: 'propose',
|
|
41
|
+
slotlock_cancel: 'propose',
|
|
37
42
|
});
|
|
38
43
|
for (const [operation, use] of Object.entries(SLOTLOCK_ASSISTANT_TOOL_USE)) {
|
|
39
44
|
// A read the assistant runs freely must be one the agent server calls a read.
|
|
@@ -56,6 +61,8 @@ export const SLOTLOCK_ASSISTANT_MAX_STORED_BYTES = 160_000;
|
|
|
56
61
|
const MAX_ACTIONS_BYTES = 80_000;
|
|
57
62
|
/** UTF-8 bytes of one proposed change's arguments as JSON; a larger one is not proposed. */
|
|
58
63
|
export const SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES = 16_000;
|
|
64
|
+
/** Previous secrets a deployment may keep while keys are sealed again under its current one. */
|
|
65
|
+
export const SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS = 3;
|
|
59
66
|
/** Proposed changes one conversation may have waiting at once (4 of the largest fit the bound). */
|
|
60
67
|
export const SLOTLOCK_ASSISTANT_MAX_PENDING = 4;
|
|
61
68
|
/** Characters of one tool result the model is shown. */
|
|
@@ -80,6 +87,7 @@ export function createSlotlockAssistantStore(store) {
|
|
|
80
87
|
...(actor !== undefined ? { updatedBy: actor } : {}),
|
|
81
88
|
}), by(actor)),
|
|
82
89
|
deleteSettings: (tenantRef, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteAssistantSettings({ tenantRef }), by(actor)),
|
|
90
|
+
resealKey: (tenantRef, from, to) => store.withTenant(tenantRef, (tenant) => tenant.resealAssistantKey({ tenantRef, from, to }), by(undefined)),
|
|
83
91
|
// Each read deletes the tenant's expired conversations first, for deployments whose maintenance
|
|
84
92
|
// never meets the tenant (a Worker's).
|
|
85
93
|
getConversation: (tenantRef, memberRef) => store.withTenant(tenantRef, async (tenant) => {
|
|
@@ -133,6 +141,13 @@ export function createSlotlockMemoryAssistantStore(now = Date.now) {
|
|
|
133
141
|
async deleteSettings(tenantRef) {
|
|
134
142
|
return settings.delete(tenantRef);
|
|
135
143
|
},
|
|
144
|
+
async resealKey(tenantRef, from, to) {
|
|
145
|
+
const stored = settings.get(tenantRef);
|
|
146
|
+
if (!stored || stored.sealedKey !== from || to.length === 0 || to === from)
|
|
147
|
+
return false;
|
|
148
|
+
settings.set(tenantRef, { ...stored, sealedKey: to });
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
136
151
|
async getConversation(tenantRef, memberRef) {
|
|
137
152
|
const found = live(tenantRef, memberRef);
|
|
138
153
|
if (!found)
|
|
@@ -198,6 +213,19 @@ export async function sealSlotlockAssistantKey(secret, tenantRef, apiKey) {
|
|
|
198
213
|
const sealed = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: boundTo(tenantRef) }, await sealingKey(secret), encoder.encode(apiKey));
|
|
199
214
|
return `${SEAL_VERSION}.${Buffer.from(iv).toString('base64url')}.${Buffer.from(sealed).toString('base64url')}`;
|
|
200
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* The key a seal holds, tried with each secret in turn (the current one first, then
|
|
218
|
+
* SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS), and which one opened it: 0 for the current secret. Null when
|
|
219
|
+
* none of them sealed it for this tenant.
|
|
220
|
+
*/
|
|
221
|
+
export async function openSlotlockAssistantKeyWithAny(secrets, tenantRef, sealed) {
|
|
222
|
+
for (const [secretIndex, secret] of secrets.entries()) {
|
|
223
|
+
const apiKey = await openSlotlockAssistantKey(secret, tenantRef, sealed);
|
|
224
|
+
if (apiKey !== null)
|
|
225
|
+
return { apiKey, secretIndex };
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
201
229
|
/** The key a seal holds, or null when it is not one this secret sealed for this tenant. */
|
|
202
230
|
export async function openSlotlockAssistantKey(secret, tenantRef, sealed) {
|
|
203
231
|
const [version, iv, body, ...rest] = sealed.split('.');
|
|
@@ -484,14 +512,279 @@ async function readBounded(response) {
|
|
|
484
512
|
}
|
|
485
513
|
return Buffer.concat(chunks).toString('utf8');
|
|
486
514
|
}
|
|
515
|
+
/** What an HTTP status from a model call means. */
|
|
516
|
+
function statusReason(status) {
|
|
517
|
+
return status === 401 || status === 403
|
|
518
|
+
? 'refused_key'
|
|
519
|
+
: status === 404
|
|
520
|
+
? 'not_found'
|
|
521
|
+
: status === 429
|
|
522
|
+
? 'rate_limited'
|
|
523
|
+
: status === 408 || status === 504
|
|
524
|
+
? 'timeout'
|
|
525
|
+
: 'unavailable';
|
|
526
|
+
}
|
|
527
|
+
/** The HTTP status each Anthropic error type stands for ("Claude API errors"), for one sent mid-stream. */
|
|
528
|
+
const ANTHROPIC_ERROR_STATUS = new Map([
|
|
529
|
+
['invalid_request_error', 400],
|
|
530
|
+
['authentication_error', 401],
|
|
531
|
+
['billing_error', 402],
|
|
532
|
+
['permission_error', 403],
|
|
533
|
+
['not_found_error', 404],
|
|
534
|
+
['request_too_large', 413],
|
|
535
|
+
['rate_limit_error', 429],
|
|
536
|
+
['api_error', 500],
|
|
537
|
+
['timeout_error', 504],
|
|
538
|
+
['overloaded_error', 529],
|
|
539
|
+
]);
|
|
540
|
+
/**
|
|
541
|
+
* Server-sent events as the WHATWG HTML standard interprets them: lines end at CRLF, LF or CR; a
|
|
542
|
+
* line starting with `:` is a comment; one space after the field's colon is dropped; `data` values
|
|
543
|
+
* join with LF; a blank line dispatches; other fields are ignored. Text comes in any pieces; an
|
|
544
|
+
* event the stream ends inside is never dispatched. `push` is true once `dispatch` was (stop).
|
|
545
|
+
*/
|
|
546
|
+
function sseLines(dispatch) {
|
|
547
|
+
let line = '';
|
|
548
|
+
let afterCR = false;
|
|
549
|
+
let event = '';
|
|
550
|
+
let data = '';
|
|
551
|
+
let done = false;
|
|
552
|
+
const take = (field) => {
|
|
553
|
+
if (field === '') {
|
|
554
|
+
if (data !== '')
|
|
555
|
+
done = dispatch(event || 'message', data.slice(0, -1));
|
|
556
|
+
event = '';
|
|
557
|
+
data = '';
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (field.startsWith(':'))
|
|
561
|
+
return;
|
|
562
|
+
const colon = field.indexOf(':');
|
|
563
|
+
const name = colon === -1 ? field : field.slice(0, colon);
|
|
564
|
+
const value = colon === -1 ? '' : field.slice(field.charCodeAt(colon + 1) === 0x20 ? colon + 2 : colon + 1);
|
|
565
|
+
if (name === 'event')
|
|
566
|
+
event = value;
|
|
567
|
+
else if (name === 'data')
|
|
568
|
+
data += `${value}\n`;
|
|
569
|
+
};
|
|
570
|
+
return (text) => {
|
|
571
|
+
let start = 0;
|
|
572
|
+
for (let index = 0; index < text.length && !done; index += 1) {
|
|
573
|
+
const code = text.charCodeAt(index);
|
|
574
|
+
if (code === 0x0a && afterCR) {
|
|
575
|
+
// The LF of a CRLF whose CR ended the line, perhaps in the previous piece.
|
|
576
|
+
afterCR = false;
|
|
577
|
+
start = index + 1;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
afterCR = code === 0x0d;
|
|
581
|
+
if (code !== 0x0a && code !== 0x0d)
|
|
582
|
+
continue;
|
|
583
|
+
take(line + text.slice(start, index));
|
|
584
|
+
line = '';
|
|
585
|
+
start = index + 1;
|
|
586
|
+
}
|
|
587
|
+
if (!done)
|
|
588
|
+
line += text.slice(start);
|
|
589
|
+
return done;
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* A streamed body, decoded as its bytes arrive (a character split across chunks waits for the
|
|
594
|
+
* rest) and read to `onEvent` until it answers true, within MAX_RESPONSE_BYTES. An abort cancels
|
|
595
|
+
* the read at once and is a timeout, even with events still in hand.
|
|
596
|
+
*/
|
|
597
|
+
async function readEvents(response, signal, onEvent) {
|
|
598
|
+
if (!response.body)
|
|
599
|
+
return;
|
|
600
|
+
const reader = response.body.getReader();
|
|
601
|
+
const cancel = () => {
|
|
602
|
+
reader.cancel().catch(() => undefined);
|
|
603
|
+
};
|
|
604
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
605
|
+
// UTF-8, without a leading byte order mark, as the standard decodes a stream.
|
|
606
|
+
const decoder = new TextDecoder();
|
|
607
|
+
const push = sseLines((event, data) => {
|
|
608
|
+
if (signal.aborted)
|
|
609
|
+
throw new SlotlockAssistantProviderError('timeout');
|
|
610
|
+
return onEvent(event, data);
|
|
611
|
+
});
|
|
612
|
+
let size = 0;
|
|
613
|
+
try {
|
|
614
|
+
for (;;) {
|
|
615
|
+
if (signal.aborted)
|
|
616
|
+
throw new SlotlockAssistantProviderError('timeout');
|
|
617
|
+
let chunk;
|
|
618
|
+
try {
|
|
619
|
+
chunk = await reader.read();
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
throw new SlotlockAssistantProviderError(signal.aborted ? 'timeout' : 'bad_answer');
|
|
623
|
+
}
|
|
624
|
+
if (chunk.done) {
|
|
625
|
+
if (signal.aborted)
|
|
626
|
+
throw new SlotlockAssistantProviderError('timeout');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
size += chunk.value.byteLength;
|
|
630
|
+
if (size > MAX_RESPONSE_BYTES)
|
|
631
|
+
throw new SlotlockAssistantProviderError('bad_answer');
|
|
632
|
+
if (push(decoder.decode(chunk.value, { stream: true })))
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
finally {
|
|
637
|
+
signal.removeEventListener('abort', cancel);
|
|
638
|
+
// Whatever follows the last event, or a body given up on, is not read.
|
|
639
|
+
cancel();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const parsedOr = (json, fallback) => {
|
|
643
|
+
try {
|
|
644
|
+
return JSON.parse(json);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
return fallback;
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* An Anthropic stream: content blocks by index, text from `text_delta`s, a `tool_use`'s input from
|
|
652
|
+
* its `input_json_delta` fragments, parsed once whole; ended by `message_stop`. `ping` and event
|
|
653
|
+
* types this does not know are passed over; `error` names its error as an HTTP status would.
|
|
654
|
+
*/
|
|
655
|
+
function anthropicStream(emit) {
|
|
656
|
+
const blocks = new Map();
|
|
657
|
+
let stopped = false;
|
|
658
|
+
return {
|
|
659
|
+
event(name, data) {
|
|
660
|
+
const body = parsedOr(data, undefined);
|
|
661
|
+
if (name === 'error' || (isRecord(body) && body.type === 'error')) {
|
|
662
|
+
const type = isRecord(body) && isRecord(body.error) ? body.error.type : undefined;
|
|
663
|
+
const status = typeof type === 'string' ? ANTHROPIC_ERROR_STATUS.get(type) : undefined;
|
|
664
|
+
throw new SlotlockAssistantProviderError(status === undefined ? 'unavailable' : statusReason(status));
|
|
665
|
+
}
|
|
666
|
+
if (!isRecord(body)) {
|
|
667
|
+
if (name === 'ping')
|
|
668
|
+
return false;
|
|
669
|
+
throw new SlotlockAssistantProviderError('bad_answer');
|
|
670
|
+
}
|
|
671
|
+
const index = typeof body.index === 'number' ? body.index : undefined;
|
|
672
|
+
if (body.type === 'content_block_start' && index !== undefined && isRecord(body.content_block)) {
|
|
673
|
+
const block = body.content_block;
|
|
674
|
+
if (block.type === 'text') {
|
|
675
|
+
blocks.set(index, 'text');
|
|
676
|
+
if (typeof block.text === 'string')
|
|
677
|
+
emit(block.text);
|
|
678
|
+
}
|
|
679
|
+
else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
|
|
680
|
+
blocks.set(index, { id: block.id, name: block.name, input: block.input, json: '' });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
else if (body.type === 'content_block_delta' && index !== undefined && isRecord(body.delta)) {
|
|
684
|
+
const block = blocks.get(index);
|
|
685
|
+
const delta = body.delta;
|
|
686
|
+
if (block === 'text' && delta.type === 'text_delta' && typeof delta.text === 'string')
|
|
687
|
+
emit(delta.text);
|
|
688
|
+
if (typeof block === 'object' && delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
689
|
+
block.json += delta.partial_json;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
else if (body.type === 'message_stop') {
|
|
693
|
+
stopped = true;
|
|
694
|
+
}
|
|
695
|
+
return stopped;
|
|
696
|
+
},
|
|
697
|
+
complete: () => stopped,
|
|
698
|
+
calls: () => [...blocks.entries()]
|
|
699
|
+
.sort(([a], [b]) => a - b)
|
|
700
|
+
.flatMap(([, block]) => block === 'text'
|
|
701
|
+
? []
|
|
702
|
+
: [{ id: block.id, name: block.name, input: block.json === '' ? block.input : parsedOr(block.json, null) }]),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* A Chat Completions stream: chunks whose first choice's delta carries `content` and `tool_calls`
|
|
707
|
+
* fragments, each call assembled by its `index` (id and name arrive once, arguments in pieces);
|
|
708
|
+
* complete at a `finish_reason`, ended by `data: [DONE]`. A chunk with an `error` is a failure.
|
|
709
|
+
*/
|
|
710
|
+
function openaiStream(emit) {
|
|
711
|
+
const slots = new Map();
|
|
712
|
+
let finished = false;
|
|
713
|
+
return {
|
|
714
|
+
event(name, data) {
|
|
715
|
+
if (data === '[DONE]') {
|
|
716
|
+
finished = true;
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
const body = parsedOr(data, undefined);
|
|
720
|
+
if (!isRecord(body))
|
|
721
|
+
throw new SlotlockAssistantProviderError('bad_answer');
|
|
722
|
+
if (name === 'error' || body.error)
|
|
723
|
+
throw new SlotlockAssistantProviderError('unavailable');
|
|
724
|
+
for (const choice of Array.isArray(body.choices) ? body.choices : []) {
|
|
725
|
+
if (!isRecord(choice) || (choice.index ?? 0) !== 0)
|
|
726
|
+
continue;
|
|
727
|
+
const delta = isRecord(choice.delta) ? choice.delta : {};
|
|
728
|
+
if (typeof delta.content === 'string')
|
|
729
|
+
emit(delta.content);
|
|
730
|
+
for (const [position, fragment] of (Array.isArray(delta.tool_calls) ? delta.tool_calls : []).entries()) {
|
|
731
|
+
if (!isRecord(fragment))
|
|
732
|
+
continue;
|
|
733
|
+
// A server that sends whole calls without an index sends them in order.
|
|
734
|
+
const index = typeof fragment.index === 'number' && Number.isSafeInteger(fragment.index) && fragment.index >= 0
|
|
735
|
+
? fragment.index
|
|
736
|
+
: position;
|
|
737
|
+
const slot = slots.get(index) ?? { args: '' };
|
|
738
|
+
slots.set(index, slot);
|
|
739
|
+
const fn = isRecord(fragment.function) ? fragment.function : {};
|
|
740
|
+
if (typeof fragment.id === 'string' && fragment.id !== '')
|
|
741
|
+
slot.id = fragment.id;
|
|
742
|
+
if (typeof fn.name === 'string' && fn.name !== '')
|
|
743
|
+
slot.name = fn.name;
|
|
744
|
+
if (typeof fn.arguments === 'string')
|
|
745
|
+
slot.args += fn.arguments;
|
|
746
|
+
}
|
|
747
|
+
if (typeof choice.finish_reason === 'string')
|
|
748
|
+
finished = true;
|
|
749
|
+
}
|
|
750
|
+
return false;
|
|
751
|
+
},
|
|
752
|
+
complete: () => finished,
|
|
753
|
+
calls: () => [...slots.entries()]
|
|
754
|
+
.sort(([a], [b]) => a - b)
|
|
755
|
+
.flatMap(([index, slot]) => slot.name === undefined
|
|
756
|
+
? []
|
|
757
|
+
: [{ id: slot.id ?? `call_${index}`, name: slot.name, input: parsedOr(slot.args || '{}', null) }]),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
/** Text to `onText`, within MAX_MODEL_TEXT: what it was given is the answer's text. */
|
|
761
|
+
function textSink(onText) {
|
|
762
|
+
let text = '';
|
|
763
|
+
return {
|
|
764
|
+
emit(delta) {
|
|
765
|
+
const kept = delta.slice(0, MAX_MODEL_TEXT - text.length);
|
|
766
|
+
if (kept === '')
|
|
767
|
+
return;
|
|
768
|
+
text += kept;
|
|
769
|
+
try {
|
|
770
|
+
onText(kept);
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// Showing the answer never changes it.
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
text: () => text,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
const isEventStream = (response) => (response.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase() === 'text/event-stream';
|
|
487
780
|
/** One model call. Throws SlotlockAssistantProviderError, whose message never holds the key. */
|
|
488
781
|
export async function callSlotlockAssistantModel(request) {
|
|
489
|
-
const { model } = request;
|
|
782
|
+
const { model, onText } = request;
|
|
490
783
|
const anthropic = model.provider === 'anthropic';
|
|
491
784
|
const url = anthropic ? `${model.baseUrl}/v1/messages` : `${model.baseUrl}/chat/completions`;
|
|
492
785
|
const headers = {
|
|
493
786
|
'content-type': 'application/json',
|
|
494
|
-
accept: 'application/json',
|
|
787
|
+
accept: onText ? 'text/event-stream' : 'application/json',
|
|
495
788
|
'user-agent': 'slotlock-dashboard-assistant',
|
|
496
789
|
};
|
|
497
790
|
if (anthropic) {
|
|
@@ -502,12 +795,13 @@ export async function callSlotlockAssistantModel(request) {
|
|
|
502
795
|
else if (model.apiKey) {
|
|
503
796
|
headers.authorization = `Bearer ${model.apiKey}`;
|
|
504
797
|
}
|
|
798
|
+
const body = anthropic ? anthropicBody(request) : openaiBody(request);
|
|
505
799
|
let response;
|
|
506
800
|
try {
|
|
507
801
|
response = await request.fetch(url, {
|
|
508
802
|
method: 'POST',
|
|
509
803
|
headers,
|
|
510
|
-
body: JSON.stringify(
|
|
804
|
+
body: JSON.stringify(onText ? { ...body, stream: true } : body),
|
|
511
805
|
// Never follow a redirect: it would carry the key elsewhere.
|
|
512
806
|
redirect: 'manual',
|
|
513
807
|
signal: request.signal,
|
|
@@ -520,6 +814,90 @@ export async function callSlotlockAssistantModel(request) {
|
|
|
520
814
|
}
|
|
521
815
|
throw new SlotlockAssistantProviderError(name === 'TimeoutError' || name === 'AbortError' || request.signal.aborted ? 'timeout' : 'unavailable');
|
|
522
816
|
}
|
|
817
|
+
if (!response.ok) {
|
|
818
|
+
await response.body?.cancel().catch(() => undefined);
|
|
819
|
+
throw new SlotlockAssistantProviderError(statusReason(response.status), response.status);
|
|
820
|
+
}
|
|
821
|
+
const sink = onText ? textSink(onText) : undefined;
|
|
822
|
+
if (sink && isEventStream(response)) {
|
|
823
|
+
const answer = anthropic ? anthropicStream(sink.emit) : openaiStream(sink.emit);
|
|
824
|
+
await readEvents(response, request.signal, answer.event);
|
|
825
|
+
if (!answer.complete())
|
|
826
|
+
throw new SlotlockAssistantProviderError('bad_answer');
|
|
827
|
+
return { text: sink.text(), calls: answer.calls() };
|
|
828
|
+
}
|
|
829
|
+
// One JSON answer: asked for, or sent by a server that does not stream.
|
|
830
|
+
let answer;
|
|
831
|
+
try {
|
|
832
|
+
answer = JSON.parse(await readBounded(response));
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
if (error instanceof SlotlockAssistantProviderError)
|
|
836
|
+
throw error;
|
|
837
|
+
if (request.signal.aborted)
|
|
838
|
+
throw new SlotlockAssistantProviderError('timeout');
|
|
839
|
+
throw new SlotlockAssistantProviderError('bad_answer');
|
|
840
|
+
}
|
|
841
|
+
const turn = anthropic ? readAnthropic(answer) : readOpenai(answer);
|
|
842
|
+
sink?.emit(turn.text);
|
|
843
|
+
return { text: turn.text.slice(0, MAX_MODEL_TEXT), calls: turn.calls };
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Providers the Settings form fills in (RFC 0003 A4): each one's API and the base URL it documents
|
|
847
|
+
* for it. Custom fills nothing, for any other OpenAI-compatible API. Choosing one saves nothing.
|
|
848
|
+
*/
|
|
849
|
+
export const SLOTLOCK_ASSISTANT_PRESETS = Object.freeze([
|
|
850
|
+
{ id: 'anthropic', name: 'Anthropic', provider: 'anthropic', baseUrl: 'https://api.anthropic.com' },
|
|
851
|
+
{ id: 'openai', name: 'OpenAI', provider: 'openai', baseUrl: 'https://api.openai.com/v1' },
|
|
852
|
+
{ id: 'openrouter', name: 'OpenRouter', provider: 'openai', baseUrl: 'https://openrouter.ai/api/v1' },
|
|
853
|
+
{
|
|
854
|
+
id: 'google',
|
|
855
|
+
name: 'Google',
|
|
856
|
+
provider: 'openai',
|
|
857
|
+
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
858
|
+
},
|
|
859
|
+
{ id: 'mistral', name: 'Mistral', provider: 'openai', baseUrl: 'https://api.mistral.ai/v1' },
|
|
860
|
+
{ id: 'groq', name: 'Groq', provider: 'openai', baseUrl: 'https://api.groq.com/openai/v1' },
|
|
861
|
+
{ id: 'together', name: 'Together', provider: 'openai', baseUrl: 'https://api.together.xyz/v1' },
|
|
862
|
+
{ id: 'custom', name: 'Custom', provider: 'openai', baseUrl: '' },
|
|
863
|
+
]);
|
|
864
|
+
/** Models one list keeps: a provider may offer hundreds. */
|
|
865
|
+
const MAX_LISTED_MODELS = 500;
|
|
866
|
+
/**
|
|
867
|
+
* The models a provider offers (`GET /v1/models` on the Anthropic API, `GET /models` on an
|
|
868
|
+
* OpenAI-compatible one, listed under `data` or as a bare array), by id, sorted, for the Settings
|
|
869
|
+
* form's Model field. Throws
|
|
870
|
+
* SlotlockAssistantProviderError as a model call does, never with the key in its message.
|
|
871
|
+
*/
|
|
872
|
+
export async function listSlotlockAssistantModels(args) {
|
|
873
|
+
const anthropic = args.provider === 'anthropic';
|
|
874
|
+
const headers = { accept: 'application/json', 'user-agent': 'slotlock-dashboard-assistant' };
|
|
875
|
+
if (anthropic) {
|
|
876
|
+
headers['anthropic-version'] = SLOTLOCK_ANTHROPIC_VERSION;
|
|
877
|
+
if (args.apiKey)
|
|
878
|
+
headers['x-api-key'] = args.apiKey;
|
|
879
|
+
}
|
|
880
|
+
else if (args.apiKey) {
|
|
881
|
+
headers.authorization = `Bearer ${args.apiKey}`;
|
|
882
|
+
}
|
|
883
|
+
const signal = AbortSignal.timeout(args.timeoutMs);
|
|
884
|
+
let response;
|
|
885
|
+
try {
|
|
886
|
+
response = await args.fetch(anthropic ? `${args.baseUrl}/v1/models?limit=1000` : `${args.baseUrl}/models`, {
|
|
887
|
+
method: 'GET',
|
|
888
|
+
headers,
|
|
889
|
+
// Never follow a redirect: it would carry the key elsewhere.
|
|
890
|
+
redirect: 'manual',
|
|
891
|
+
signal,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
const name = error?.name;
|
|
896
|
+
if (error?.code === 'private_address') {
|
|
897
|
+
throw new SlotlockAssistantProviderError('private_address');
|
|
898
|
+
}
|
|
899
|
+
throw new SlotlockAssistantProviderError(name === 'TimeoutError' || name === 'AbortError' || signal.aborted ? 'timeout' : 'unavailable');
|
|
900
|
+
}
|
|
523
901
|
if (!response.ok) {
|
|
524
902
|
await response.body?.cancel().catch(() => undefined);
|
|
525
903
|
const status = response.status;
|
|
@@ -529,9 +907,7 @@ export async function callSlotlockAssistantModel(request) {
|
|
|
529
907
|
? 'not_found'
|
|
530
908
|
: status === 429
|
|
531
909
|
? 'rate_limited'
|
|
532
|
-
:
|
|
533
|
-
? 'timeout'
|
|
534
|
-
: 'unavailable', status);
|
|
910
|
+
: 'unavailable', status);
|
|
535
911
|
}
|
|
536
912
|
let body;
|
|
537
913
|
try {
|
|
@@ -540,12 +916,20 @@ export async function callSlotlockAssistantModel(request) {
|
|
|
540
916
|
catch (error) {
|
|
541
917
|
if (error instanceof SlotlockAssistantProviderError)
|
|
542
918
|
throw error;
|
|
543
|
-
|
|
544
|
-
|
|
919
|
+
throw new SlotlockAssistantProviderError(signal.aborted ? 'timeout' : 'bad_answer');
|
|
920
|
+
}
|
|
921
|
+
// `{ data: [...] }` as OpenAI and Anthropic answer, or the bare array some OpenAI-compatible
|
|
922
|
+
// providers (Together) answer instead.
|
|
923
|
+
const listed = Array.isArray(body) ? body : isRecord(body) && Array.isArray(body.data) ? body.data : null;
|
|
924
|
+
if (!listed)
|
|
545
925
|
throw new SlotlockAssistantProviderError('bad_answer');
|
|
926
|
+
const ids = new Set();
|
|
927
|
+
for (const entry of listed) {
|
|
928
|
+
const id = isRecord(entry) ? entry.id : undefined;
|
|
929
|
+
if (typeof id === 'string' && SLOTLOCK_ASSISTANT_MODEL_NAME.test(id))
|
|
930
|
+
ids.add(id);
|
|
546
931
|
}
|
|
547
|
-
|
|
548
|
-
return { text: turn.text.slice(0, MAX_MODEL_TEXT), calls: turn.calls };
|
|
932
|
+
return [...ids].sort().slice(0, MAX_LISTED_MODELS);
|
|
549
933
|
}
|
|
550
934
|
/**
|
|
551
935
|
* The connection test: one request offering one read tool the model must call. True when it
|
|
@@ -577,16 +961,66 @@ const toolText = (value) => {
|
|
|
577
961
|
/**
|
|
578
962
|
* One member message: call the model, run what it reads, keep what it would write, and call it
|
|
579
963
|
* again with the results, up to `maxSteps` calls. Only the offered tools run, whatever the model
|
|
580
|
-
* names; everything else is answered `tool_not_available`.
|
|
964
|
+
* names; everything else is answered `tool_not_available`. With `onEvent` each model call streams;
|
|
965
|
+
* once `signal` aborts, no model call or tool goes on, and every call kept has its result.
|
|
581
966
|
*/
|
|
582
967
|
export async function runSlotlockAssistantTurn(options) {
|
|
583
968
|
const tools = slotlockAssistantTools();
|
|
584
969
|
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
585
970
|
const maxSteps = Math.max(1, Math.min(options.maxSteps ?? SLOTLOCK_ASSISTANT_MAX_STEPS, 20));
|
|
586
971
|
const turnSignal = AbortSignal.timeout(options.turnTimeoutMs);
|
|
972
|
+
const { onEvent, signal: stop } = options;
|
|
587
973
|
const added = [];
|
|
974
|
+
const tell = (event) => {
|
|
975
|
+
try {
|
|
976
|
+
onEvent?.(event);
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
// Showing the turn never changes it.
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
const stopped = () => {
|
|
983
|
+
added.push({ role: 'note', text: 'Stopped.' });
|
|
984
|
+
return { added };
|
|
985
|
+
};
|
|
986
|
+
const run = async (call, tool, index) => {
|
|
987
|
+
if (!tool || index >= MAX_CALLS_PER_STEP) {
|
|
988
|
+
return {
|
|
989
|
+
id: call.id,
|
|
990
|
+
name: call.name,
|
|
991
|
+
content: toolText({ error: { code: tool ? 'too_many_calls' : 'tool_not_available' } }),
|
|
992
|
+
error: true,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
if (tool.use === 'read') {
|
|
996
|
+
const outcome = await options.read(tool.name, call.input);
|
|
997
|
+
return {
|
|
998
|
+
id: call.id,
|
|
999
|
+
name: call.name,
|
|
1000
|
+
content: toolText(outcome.ok ? outcome.data : { error: { code: outcome.code, ...(outcome.details ?? {}) } }),
|
|
1001
|
+
error: !outcome.ok,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
const proposed = await options.propose(tool.name, call.input);
|
|
1005
|
+
return proposed.ok
|
|
1006
|
+
? {
|
|
1007
|
+
id: call.id,
|
|
1008
|
+
name: call.name,
|
|
1009
|
+
content: toolText({
|
|
1010
|
+
status: 'proposed',
|
|
1011
|
+
action_id: proposed.actionId,
|
|
1012
|
+
note: 'Not run. The member sees this change and may approve it; the result comes back as a dashboard note.',
|
|
1013
|
+
}),
|
|
1014
|
+
error: false,
|
|
1015
|
+
action: proposed.actionId,
|
|
1016
|
+
}
|
|
1017
|
+
: { id: call.id, name: call.name, content: toolText({ error: { code: proposed.code } }), error: true };
|
|
1018
|
+
};
|
|
588
1019
|
for (let step = 0; step < maxSteps; step += 1) {
|
|
1020
|
+
if (stop?.aborted)
|
|
1021
|
+
return stopped();
|
|
589
1022
|
let turn;
|
|
1023
|
+
let streamed = '';
|
|
590
1024
|
try {
|
|
591
1025
|
turn = await callSlotlockAssistantModel({
|
|
592
1026
|
model: options.model,
|
|
@@ -594,10 +1028,24 @@ export async function runSlotlockAssistantTurn(options) {
|
|
|
594
1028
|
entries: [...options.entries, ...added],
|
|
595
1029
|
tools,
|
|
596
1030
|
fetch: options.fetch,
|
|
597
|
-
signal: AbortSignal.any([turnSignal, AbortSignal.timeout(options.timeoutMs)]),
|
|
1031
|
+
signal: AbortSignal.any([turnSignal, AbortSignal.timeout(options.timeoutMs), ...(stop ? [stop] : [])]),
|
|
1032
|
+
...(onEvent
|
|
1033
|
+
? {
|
|
1034
|
+
onText: (delta) => {
|
|
1035
|
+
streamed += delta;
|
|
1036
|
+
tell({ type: 'text', delta });
|
|
1037
|
+
},
|
|
1038
|
+
}
|
|
1039
|
+
: {}),
|
|
598
1040
|
});
|
|
599
1041
|
}
|
|
600
1042
|
catch (error) {
|
|
1043
|
+
if (stop?.aborted) {
|
|
1044
|
+
// The text shown so far stays; calls of a reply cut short do not, as none has a result.
|
|
1045
|
+
if (streamed !== '')
|
|
1046
|
+
added.push({ role: 'assistant', text: streamed, calls: [] });
|
|
1047
|
+
return stopped();
|
|
1048
|
+
}
|
|
601
1049
|
added.push({ role: 'note', text: slotlockAssistantProviderMessage(error) });
|
|
602
1050
|
return { added, failure: error };
|
|
603
1051
|
}
|
|
@@ -611,48 +1059,28 @@ export async function runSlotlockAssistantTurn(options) {
|
|
|
611
1059
|
return { added };
|
|
612
1060
|
const results = [];
|
|
613
1061
|
for (const [index, call] of calls.entries()) {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
results.push({
|
|
617
|
-
id: call.id,
|
|
618
|
-
name: call.name,
|
|
619
|
-
content: toolText({ error: { code: tool ? 'too_many_calls' : 'tool_not_available' } }),
|
|
620
|
-
error: true,
|
|
621
|
-
});
|
|
622
|
-
continue;
|
|
623
|
-
}
|
|
624
|
-
if (tool.use === 'read') {
|
|
625
|
-
const outcome = await options.read(tool.name, call.input);
|
|
626
|
-
results.push({
|
|
627
|
-
id: call.id,
|
|
628
|
-
name: call.name,
|
|
629
|
-
content: toolText(outcome.ok ? outcome.data : { error: { code: outcome.code, ...(outcome.details ?? {}) } }),
|
|
630
|
-
error: !outcome.ok,
|
|
631
|
-
});
|
|
1062
|
+
if (stop?.aborted) {
|
|
1063
|
+
// Not run: answered, so the model is never sent a call without its result.
|
|
1064
|
+
results.push({ id: call.id, name: call.name, content: toolText({ error: { code: 'stopped' } }), error: true });
|
|
632
1065
|
continue;
|
|
633
1066
|
}
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
: {
|
|
648
|
-
id: call.id,
|
|
649
|
-
name: call.name,
|
|
650
|
-
content: toolText({ error: { code: proposed.code } }),
|
|
651
|
-
error: true,
|
|
652
|
-
});
|
|
1067
|
+
const tool = byName.get(call.name);
|
|
1068
|
+
const use = !tool ? 'unknown' : tool.use === 'read' ? 'read' : 'write';
|
|
1069
|
+
tell({ type: 'call', id: call.id, name: call.name, use });
|
|
1070
|
+
const result = await run(call, tool, index);
|
|
1071
|
+
results.push(result);
|
|
1072
|
+
tell({
|
|
1073
|
+
type: 'result',
|
|
1074
|
+
id: result.id,
|
|
1075
|
+
name: result.name,
|
|
1076
|
+
ok: !result.error,
|
|
1077
|
+
...(result.action !== undefined ? { actionId: result.action } : {}),
|
|
1078
|
+
});
|
|
653
1079
|
}
|
|
654
1080
|
added.push({ role: 'tool', results });
|
|
655
1081
|
}
|
|
1082
|
+
if (stop?.aborted)
|
|
1083
|
+
return stopped();
|
|
656
1084
|
added.push({
|
|
657
1085
|
role: 'note',
|
|
658
1086
|
text: `The assistant stopped after ${maxSteps} steps. Ask again, or more narrowly, to go on.`,
|