@jingyi0605/codingns4dsh 0.2.1-rc.2 → 0.2.1-rc.4
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/README.en.md +6 -4
- package/README.md +6 -6
- package/data/build/dist/bootstrap/dsh-connection-adapter.d.ts +1 -1
- package/data/build/dist/bootstrap/index.d.ts +1 -1
- package/data/build/dist/client/bundle.js +5224 -290
- package/data/build/dist/client/bundle.js.map +1 -1
- package/data/build/dist/client/client.magpie-model-tag.js +155 -5
- package/data/build/dist/client/client.magpie-model-tag.js.map +1 -1
- package/data/build/dist/client/client.native-workspace-store.js +30 -4
- package/data/build/dist/client/client.native-workspace-store.js.map +1 -1
- package/data/build/dist/client/client.transport.js +747 -48
- package/data/build/dist/client/client.transport.js.map +1 -1
- package/data/build/dist/client/desktop-assistant.js +953 -185
- package/data/build/dist/client/feature-requirement.d.ts +43 -0
- package/data/build/dist/client/feature-requirement.d.ts.map +1 -0
- package/data/build/dist/client/feature-requirement.js +105 -0
- package/data/build/dist/client/feature-requirement.js.map +1 -0
- package/data/build/dist/client/features/index.d.ts +1 -0
- package/data/build/dist/client/features/index.d.ts.map +1 -1
- package/data/build/dist/client/features/index.js +3 -0
- package/data/build/dist/client/features/index.js.map +1 -1
- package/data/build/dist/client/features/magpie-management-controls.d.ts +46 -0
- package/data/build/dist/client/features/magpie-management-controls.d.ts.map +1 -0
- package/data/build/dist/client/features/magpie-management-controls.js +794 -0
- package/data/build/dist/client/features/magpie-management-controls.js.map +1 -0
- package/data/build/dist/client/features/magpie-management-page.d.ts +129 -0
- package/data/build/dist/client/features/magpie-management-page.d.ts.map +1 -0
- package/data/build/dist/client/features/magpie-management-page.js +1279 -0
- package/data/build/dist/client/features/magpie-management-page.js.map +1 -0
- package/data/build/dist/client/features/magpie-management.d.ts +7 -0
- package/data/build/dist/client/features/magpie-management.d.ts.map +1 -0
- package/data/build/dist/client/features/magpie-management.js +154 -0
- package/data/build/dist/client/features/magpie-management.js.map +1 -0
- package/data/build/dist/client/features/magpie-mark.d.ts +10 -0
- package/data/build/dist/client/features/magpie-mark.d.ts.map +1 -0
- package/data/build/dist/client/features/magpie-mark.js +21 -0
- package/data/build/dist/client/features/magpie-mark.js.map +1 -0
- package/data/build/dist/client/features/magpie-provider-logo.d.ts +11 -0
- package/data/build/dist/client/features/magpie-provider-logo.d.ts.map +1 -0
- package/data/build/dist/client/features/magpie-provider-logo.js +35 -0
- package/data/build/dist/client/features/magpie-provider-logo.js.map +1 -0
- package/data/build/dist/client/features/subscription-quota-groups.d.ts +193 -0
- package/data/build/dist/client/features/subscription-quota-groups.d.ts.map +1 -0
- package/data/build/dist/client/features/subscription-quota-groups.js +548 -0
- package/data/build/dist/client/features/subscription-quota-groups.js.map +1 -0
- package/data/build/dist/client/git-management.d.ts.map +1 -1
- package/data/build/dist/client/git-management.js +11 -9
- package/data/build/dist/client/git-management.js.map +1 -1
- package/data/build/dist/client/git-panel-styles.js +1 -0
- package/data/build/dist/client/git-panel-styles.js.map +1 -1
- package/data/build/dist/client/index.d.ts +1 -1
- package/data/build/dist/client/index.d.ts.map +1 -1
- package/data/build/dist/client/index.js +1 -1
- package/data/build/dist/client/index.js.map +1 -1
- package/data/build/dist/client/locale.d.ts.map +1 -1
- package/data/build/dist/client/locale.js +7 -0
- package/data/build/dist/client/locale.js.map +1 -1
- package/data/build/dist/client/locales/magpie.d.ts +4 -0
- package/data/build/dist/client/locales/magpie.d.ts.map +1 -0
- package/data/build/dist/client/locales/magpie.js +644 -0
- package/data/build/dist/client/locales/magpie.js.map +1 -0
- package/data/build/dist/client/locales/usage.d.ts.map +1 -1
- package/data/build/dist/client/locales/usage.js +52 -0
- package/data/build/dist/client/locales/usage.js.map +1 -1
- package/data/build/dist/client/magpie-model-tag.d.ts +9 -1
- package/data/build/dist/client/magpie-model-tag.d.ts.map +1 -1
- package/data/build/dist/client/magpie-model-tag.js +15 -3
- package/data/build/dist/client/magpie-model-tag.js.map +1 -1
- package/data/build/dist/client/provider-icons.d.ts +8 -0
- package/data/build/dist/client/provider-icons.d.ts.map +1 -1
- package/data/build/dist/client/provider-icons.js +122 -2
- package/data/build/dist/client/provider-icons.js.map +1 -1
- package/data/build/dist/client/settings-bridge.d.ts +1 -0
- package/data/build/dist/client/settings-bridge.d.ts.map +1 -1
- package/data/build/dist/client/settings-bridge.js +29 -10
- package/data/build/dist/client/settings-bridge.js.map +1 -1
- package/data/build/dist/client/settings-section.d.ts.map +1 -1
- package/data/build/dist/client/settings-section.js +21 -3
- package/data/build/dist/client/settings-section.js.map +1 -1
- package/data/build/dist/client/style.css +224 -221
- package/data/build/dist/client/subscription-slot.d.ts +22 -1
- package/data/build/dist/client/subscription-slot.d.ts.map +1 -1
- package/data/build/dist/client/subscription-slot.js +374 -25
- package/data/build/dist/client/subscription-slot.js.map +1 -1
- package/data/build/dist/dsh-capabilities/client/global-panel-adapter.d.ts +27 -0
- package/data/build/dist/dsh-capabilities/client/global-panel-adapter.d.ts.map +1 -0
- package/data/build/dist/dsh-capabilities/client/global-panel-adapter.js +109 -0
- package/data/build/dist/dsh-capabilities/client/global-panel-adapter.js.map +1 -0
- package/data/build/dist/dsh-capabilities/host/session-title-adapter.d.ts +2 -1
- package/data/build/dist/dsh-capabilities/host/session-title-adapter.d.ts.map +1 -1
- package/data/build/dist/dsh-capabilities/host/session-title-adapter.js +32 -2
- package/data/build/dist/dsh-capabilities/host/session-title-adapter.js.map +1 -1
- package/data/build/dist/dsh-capabilities/matrix.d.ts.map +1 -1
- package/data/build/dist/dsh-capabilities/matrix.js +12 -11
- package/data/build/dist/dsh-capabilities/matrix.js.map +1 -1
- package/data/build/dist/dsh-capabilities/routes.d.ts.map +1 -1
- package/data/build/dist/dsh-capabilities/routes.js +12 -9
- package/data/build/dist/dsh-capabilities/routes.js.map +1 -1
- package/data/build/dist/dsh-capabilities/types.d.ts +1 -1
- package/data/build/dist/dsh-capabilities/types.d.ts.map +1 -1
- package/data/build/dist/dsh-capabilities/types.js.map +1 -1
- package/data/build/dist/host/cli-adapters/acp-cli-driver.d.ts +27 -0
- package/data/build/dist/host/cli-adapters/acp-cli-driver.d.ts.map +1 -1
- package/data/build/dist/host/cli-adapters/acp-cli-driver.js +88 -13
- package/data/build/dist/host/cli-adapters/acp-cli-driver.js.map +1 -1
- package/data/build/dist/host/cli-adapters/codex-driver.d.ts +10 -0
- package/data/build/dist/host/cli-adapters/codex-driver.d.ts.map +1 -1
- package/data/build/dist/host/cli-adapters/codex-driver.js +32 -1
- package/data/build/dist/host/cli-adapters/codex-driver.js.map +1 -1
- package/data/build/dist/host/cli-adapters/command-code-acp-loader.d.ts +6 -5
- package/data/build/dist/host/cli-adapters/command-code-acp-loader.d.ts.map +1 -1
- package/data/build/dist/host/cli-adapters/command-code-acp-loader.js +72 -12
- package/data/build/dist/host/cli-adapters/command-code-acp-loader.js.map +1 -1
- package/data/build/dist/host/cli-adapters/command-code-driver.d.ts.map +1 -1
- package/data/build/dist/host/cli-adapters/command-code-driver.js +26 -1
- package/data/build/dist/host/cli-adapters/command-code-driver.js.map +1 -1
- package/data/build/dist/host/cli-adapters/dsh-message-projector.d.ts.map +1 -1
- package/data/build/dist/host/cli-adapters/dsh-message-projector.js +15 -2
- package/data/build/dist/host/cli-adapters/dsh-message-projector.js.map +1 -1
- package/data/build/dist/host/cli-adapters/feature.d.ts +13 -0
- package/data/build/dist/host/cli-adapters/feature.d.ts.map +1 -1
- package/data/build/dist/host/cli-adapters/feature.js +438 -6
- package/data/build/dist/host/cli-adapters/feature.js.map +1 -1
- package/data/build/dist/host/cli-adapters/grok-driver.js +5 -0
- package/data/build/dist/host/cli-adapters/grok-driver.js.map +1 -1
- package/data/build/dist/host/cli-adapters/magpie-detection.d.ts +72 -0
- package/data/build/dist/host/cli-adapters/magpie-detection.d.ts.map +1 -0
- package/data/build/dist/host/cli-adapters/magpie-detection.js +312 -0
- package/data/build/dist/host/cli-adapters/magpie-detection.js.map +1 -0
- package/data/build/dist/host/cli-adapters/magpie-management.d.ts +50 -0
- package/data/build/dist/host/cli-adapters/magpie-management.d.ts.map +1 -0
- package/data/build/dist/host/cli-adapters/magpie-management.js +1343 -0
- package/data/build/dist/host/cli-adapters/magpie-management.js.map +1 -0
- package/data/build/dist/host/cli-adapters/magpie-update.d.ts +59 -0
- package/data/build/dist/host/cli-adapters/magpie-update.d.ts.map +1 -0
- package/data/build/dist/host/cli-adapters/magpie-update.js +249 -0
- package/data/build/dist/host/cli-adapters/magpie-update.js.map +1 -0
- package/data/build/dist/host/cli-adapters/magpie-usage.d.ts +70 -8
- package/data/build/dist/host/cli-adapters/magpie-usage.d.ts.map +1 -1
- package/data/build/dist/host/cli-adapters/magpie-usage.js +1075 -125
- package/data/build/dist/host/cli-adapters/magpie-usage.js.map +1 -1
- package/data/build/dist/host/cli-adapters/magpie-web.d.ts +69 -0
- package/data/build/dist/host/cli-adapters/magpie-web.d.ts.map +1 -0
- package/data/build/dist/host/cli-adapters/magpie-web.js +485 -0
- package/data/build/dist/host/cli-adapters/magpie-web.js.map +1 -0
- package/data/build/dist/host/cli-adapters/process-utils.d.ts +2 -0
- package/data/build/dist/host/cli-adapters/process-utils.d.ts.map +1 -1
- package/data/build/dist/host/cli-adapters/process-utils.js +8 -0
- package/data/build/dist/host/cli-adapters/process-utils.js.map +1 -1
- package/data/build/dist/host/cli-adapters/session-performance-projection.d.ts +6 -0
- package/data/build/dist/host/cli-adapters/session-performance-projection.d.ts.map +1 -1
- package/data/build/dist/host/cli-adapters/session-performance-projection.js +25 -0
- package/data/build/dist/host/cli-adapters/session-performance-projection.js.map +1 -1
- package/data/build/dist/host/dsh-gateway-feature.d.ts.map +1 -1
- package/data/build/dist/host/dsh-gateway-feature.js +26 -3
- package/data/build/dist/host/dsh-gateway-feature.js.map +1 -1
- package/data/build/dist/host/features/host-status.d.ts +25 -0
- package/data/build/dist/host/features/host-status.d.ts.map +1 -1
- package/data/build/dist/host/features/host-status.js +60 -6
- package/data/build/dist/host/features/host-status.js.map +1 -1
- package/data/build/dist/host/features/lan-access-dsh.d.ts.map +1 -1
- package/data/build/dist/host/features/lan-access-dsh.js +61 -10
- package/data/build/dist/host/features/lan-access-dsh.js.map +1 -1
- package/data/build/dist/host/features/session-translation-protection.d.ts.map +1 -1
- package/data/build/dist/host/features/session-translation-protection.js +2 -1
- package/data/build/dist/host/features/session-translation-protection.js.map +1 -1
- package/data/build/dist/host/lan-access-dsh.d.ts.map +1 -1
- package/data/build/dist/host/lan-access-dsh.js +6 -2
- package/data/build/dist/host/lan-access-dsh.js.map +1 -1
- package/data/build/dist/host/modules/pwa/pwa-session-notifications.js +2 -1
- package/data/build/dist/host/modules/pwa/pwa-session-notifications.js.map +1 -1
- package/data/build/dist/host/rpc.d.ts +15 -2
- package/data/build/dist/host/rpc.d.ts.map +1 -1
- package/data/build/dist/host/rpc.js +47 -6
- package/data/build/dist/host/rpc.js.map +1 -1
- package/data/build/dist/host/settings.d.ts.map +1 -1
- package/data/build/dist/host/settings.js +5 -0
- package/data/build/dist/host/settings.js.map +1 -1
- package/data/build/dist/shared/contracts/config.d.ts +18 -0
- package/data/build/dist/shared/contracts/config.d.ts.map +1 -1
- package/data/build/dist/shared/contracts/config.js +8 -0
- package/data/build/dist/shared/contracts/config.js.map +1 -1
- package/data/build/dist/shared/contracts/errors.d.ts +2 -2
- package/data/build/dist/shared/contracts/feature.d.ts +7 -0
- package/data/build/dist/shared/contracts/feature.d.ts.map +1 -1
- package/data/build/dist/shared/contracts/magpie-management.d.ts +414 -0
- package/data/build/dist/shared/contracts/magpie-management.d.ts.map +1 -0
- package/data/build/dist/shared/contracts/magpie-management.js +2 -0
- package/data/build/dist/shared/contracts/magpie-management.js.map +1 -0
- package/data/build/dist/shared/contracts/subscription.d.ts +77 -1
- package/data/build/dist/shared/contracts/subscription.d.ts.map +1 -1
- package/data/build/dist/shared/contracts/subscription.js.map +1 -1
- package/data/build/dist/shared/contracts/version.d.ts +4 -4
- package/data/build/dist/shared/contracts/version.js +3 -3
- package/data/build/dist/shared/index.d.ts +3 -1
- package/data/build/dist/shared/index.d.ts.map +1 -1
- package/data/build/dist/shared/index.js +2 -1
- package/data/build/dist/shared/index.js.map +1 -1
- package/data/build/dist/shared/magpie-provider-id.d.ts +12 -0
- package/data/build/dist/shared/magpie-provider-id.d.ts.map +1 -0
- package/data/build/dist/shared/magpie-provider-id.js +16 -0
- package/data/build/dist/shared/magpie-provider-id.js.map +1 -0
- package/data/build/dist/shared/session-title.d.ts.map +1 -1
- package/data/build/dist/shared/session-title.js +10 -4
- package/data/build/dist/shared/session-title.js.map +1 -1
- package/package.json +29 -29
- package/profile/README.md +2 -2
- package/profile/package.json +3 -3
- package/profile/version.json +3 -3
- package/version.json +3 -3
|
@@ -0,0 +1,1343 @@
|
|
|
1
|
+
import { isMagpieProviderId, magpieProviderId } from '../../shared/magpie-provider-id.js';
|
|
2
|
+
import { MagpieWebService } from './magpie-web.js';
|
|
3
|
+
const MAX_PROVIDERS = 100;
|
|
4
|
+
const MAX_GROUPS = 200;
|
|
5
|
+
const MAX_ACCOUNTS = 500;
|
|
6
|
+
const MAX_PROVIDER_ICON_FETCHES = 64;
|
|
7
|
+
const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
8
|
+
const GROUP_ID = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u;
|
|
9
|
+
const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,299}$/u;
|
|
10
|
+
const ENDPOINT_FIELDS = ['chat', 'responses', 'anthropic', 'gemini'];
|
|
11
|
+
const ENDPOINT_FIELD_SET = new Set(ENDPOINT_FIELDS);
|
|
12
|
+
const ROUTING_POLICIES = new Set(['smart', 'order', 'rotate', 'usage', 'pace', 'weight', 'manual']);
|
|
13
|
+
/** `provider.MemberEfforts`: the reasoning levels a group may offer its Agents. */
|
|
14
|
+
const MEMBER_LEVELS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
15
|
+
/** How a group picks each turn's level: the Agent's own request, or a classifier's pick. */
|
|
16
|
+
const GROUP_EFFORTS = new Set(['', 'auto']);
|
|
17
|
+
const STICKINESS_POLICIES = new Set(['auto', 'session', 'turn', 'off', 'strict']);
|
|
18
|
+
const ACCOUNT_AGENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
19
|
+
/**
|
|
20
|
+
* IDs Magpie refuses a provider, whatever its spelling (`provider.Rename`): the gateway itself,
|
|
21
|
+
* the prefix its routing groups carry, and the destination of model IDs that name none.
|
|
22
|
+
*/
|
|
23
|
+
const RESERVED_PROVIDER_IDS = new Set(['magpie', 'group', 'auto']);
|
|
24
|
+
/**
|
|
25
|
+
* Fields a provider save does not write back: `was` is the IDs Magpie remembers the provider by,
|
|
26
|
+
* and `id` is read back under the ID a rename gives it.
|
|
27
|
+
*/
|
|
28
|
+
const PROVIDER_SAVE_KEEPS = new Set(['was', 'id']);
|
|
29
|
+
const PROVIDER_VIEW_FIELDS = new Set([
|
|
30
|
+
'host', 'key', 'keyList', 'balanceToken', 'account', 'ready', 'chosen', 'speaks', 'models', 'exposed', 'unlisted',
|
|
31
|
+
'noModels', 'groups', 'off', 'agents', 'sponsored',
|
|
32
|
+
]);
|
|
33
|
+
const PROVIDER_MUTABLE_FIELDS = new Set(['name', 'chat', 'responses', 'anthropic', 'gemini', 'models', 'chosen', 'off']);
|
|
34
|
+
const GROUP_VIEW_FIELDS = new Set(['id', 'ready', 'memberInfo', 'holds', 'offers', 'shared', 'patterns', 'ref']);
|
|
35
|
+
/** Every field `/api/groups/save` writes; one left out of the body comes back as Magpie's default. */
|
|
36
|
+
const GROUP_WRITTEN_FIELDS = [
|
|
37
|
+
'name', 'members', 'match', 'routing', 'affinity', 'pick', 'unlists', 'fast', 'off', 'weights',
|
|
38
|
+
'sink', 'firstToken', 'patience', 'context', 'family', 'levels', 'effort', 'classifier', 'rules', 'from',
|
|
39
|
+
];
|
|
40
|
+
const LOGIN_CONTAINER_FIELDS = new Set(['accounts', 'logins', 'data', 'users', 'usage', 'providers', 'items']);
|
|
41
|
+
const MANAGEMENT_CACHE_TTL_MS = 15_000;
|
|
42
|
+
/** Provider, account, and routing-group panels use the authenticated Magpie Web API only. */
|
|
43
|
+
export class MagpieManagementService {
|
|
44
|
+
webApi;
|
|
45
|
+
now;
|
|
46
|
+
mutationTail = Promise.resolve();
|
|
47
|
+
snapshotCache;
|
|
48
|
+
snapshotRefresh;
|
|
49
|
+
providerLogoCache = new Map();
|
|
50
|
+
constructor(options = {}) {
|
|
51
|
+
this.webApi = options.webApi;
|
|
52
|
+
this.now = options.now ?? Date.now;
|
|
53
|
+
}
|
|
54
|
+
async readSnapshot(signal) {
|
|
55
|
+
const cached = this.snapshotCache;
|
|
56
|
+
if (cached !== undefined) {
|
|
57
|
+
if (this.now() - cached.loadedAt < MANAGEMENT_CACHE_TTL_MS)
|
|
58
|
+
return cached.state.snapshot;
|
|
59
|
+
this.refreshSnapshot();
|
|
60
|
+
return cached.state.snapshot;
|
|
61
|
+
}
|
|
62
|
+
const state = await this.readWebState(signal);
|
|
63
|
+
this.remember(state);
|
|
64
|
+
return state.snapshot;
|
|
65
|
+
}
|
|
66
|
+
async updateProvider(input, signal) {
|
|
67
|
+
const update = readProviderUpdate(input);
|
|
68
|
+
return this.enqueueMutation(async () => {
|
|
69
|
+
const before = await this.readWebState(signal);
|
|
70
|
+
const current = before.rawProviders?.find(item => item.id === update.id);
|
|
71
|
+
const currentRecord = before.snapshot.providers.find(item => item.id === update.id);
|
|
72
|
+
if (before.snapshot.providerStatus === 'unavailable' || !before.providerWritesSafe || current === undefined || currentRecord === undefined) {
|
|
73
|
+
throw new Error('Magpie provider data is unavailable');
|
|
74
|
+
}
|
|
75
|
+
if (update.models !== undefined && !currentRecord.modelsEditable) {
|
|
76
|
+
throw new TypeError('Magpie provider models contain entries this Host cannot safely edit');
|
|
77
|
+
}
|
|
78
|
+
if (update.on !== undefined && currentRecord.state === 'unknown')
|
|
79
|
+
throw new Error('Magpie provider availability cannot be verified');
|
|
80
|
+
if (update.endpointField !== undefined && !currentRecord.endpoints.some(endpoint => endpoint.field === update.endpointField && endpoint.editable)) {
|
|
81
|
+
throw new TypeError('Only an endpoint already present on this provider can be edited');
|
|
82
|
+
}
|
|
83
|
+
const newId = update.newId !== undefined && update.newId !== currentRecord.id ? update.newId : undefined;
|
|
84
|
+
if (newId !== undefined) {
|
|
85
|
+
if (!currentRecord.idEditable)
|
|
86
|
+
throw new TypeError('Magpie stored this provider under an ID it will not write again, so it cannot be renamed');
|
|
87
|
+
// Magpie answers a rename to a taken ID by saving over that provider, so the ID has to be
|
|
88
|
+
// free here: in the snapshot, and among the IDs Magpie remembers the providers by.
|
|
89
|
+
if (before.snapshot.providers.some(provider => provider.id !== currentRecord.id
|
|
90
|
+
&& (provider.id === newId || magpieProviderAliasIds(before.rawProviders ?? [], provider.id).includes(newId)))) {
|
|
91
|
+
throw new TypeError('Another Magpie provider already answers to this ID');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const hasConfigChange = providerHasConfigChange(update, currentRecord);
|
|
95
|
+
const hasStateChange = update.on !== undefined && currentRecord.state !== (update.on ? 'on' : 'off');
|
|
96
|
+
if (!hasConfigChange && !hasStateChange && newId === undefined) {
|
|
97
|
+
return { changed: 'provider', id: update.id, snapshot: before.snapshot };
|
|
98
|
+
}
|
|
99
|
+
const protectedBefore = providerConfigFingerprint(current);
|
|
100
|
+
if (hasConfigChange || newId !== undefined) {
|
|
101
|
+
await this.requestApi('/api/provider/save', {
|
|
102
|
+
method: 'POST', body: providerSavePayload(currentRecord, update, newId), ...signalOption(signal),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (hasStateChange) {
|
|
106
|
+
await this.requestApi(`/api/provider/${update.on ? 'on' : 'off'}`, {
|
|
107
|
+
method: 'POST', body: { id: newId ?? update.id }, ...signalOption(signal),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const after = await this.readWebState(signal);
|
|
111
|
+
const savedId = newId ?? update.id;
|
|
112
|
+
const saved = after.snapshot.providers.find(item => item.id === savedId);
|
|
113
|
+
const savedRaw = after.rawProviders?.find(item => item.id === savedId);
|
|
114
|
+
this.remember(after);
|
|
115
|
+
if (saved === undefined || savedRaw === undefined || !providerUpdateMatches(update, saved)
|
|
116
|
+
|| !providerRecordMatchesUpdate(currentRecord, update, saved)
|
|
117
|
+
|| providerConfigFingerprint(savedRaw) !== protectedBefore
|
|
118
|
+
|| !otherProvidersUnchanged(before.rawProviders ?? [], after.rawProviders ?? [], savedId, update.id)) {
|
|
119
|
+
throw new Error('Magpie provider update could not be verified without changing protected settings');
|
|
120
|
+
}
|
|
121
|
+
return { changed: 'provider', id: savedId, snapshot: after.snapshot };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async updateGroup(input, signal) {
|
|
125
|
+
const update = readGroupUpdate(input);
|
|
126
|
+
return this.enqueueMutation(async () => {
|
|
127
|
+
const before = await this.readWebState(signal);
|
|
128
|
+
if (before.snapshot.groupStatus !== 'available')
|
|
129
|
+
throw new Error('Magpie routing-group data is unavailable');
|
|
130
|
+
const current = before.rawGroups?.find(item => normalizeGroupId(item.id) === update.id);
|
|
131
|
+
const currentRecord = before.snapshot.groups.find(item => item.id === update.id);
|
|
132
|
+
if (current === undefined || currentRecord === undefined)
|
|
133
|
+
throw new Error('Magpie routing group was not found');
|
|
134
|
+
if ((update.routing ?? currentRecord.routing) === 'manual' && (update.pick ?? currentRecord.pick) === null) {
|
|
135
|
+
throw new TypeError('A manual routing group needs the model it sends to');
|
|
136
|
+
}
|
|
137
|
+
if ((update.effort ?? currentRecord.effort) === 'auto' && (update.classifier ?? currentRecord.classifier ?? '') === '') {
|
|
138
|
+
throw new TypeError('A group that picks each turn\'s reasoning needs the model that rates it');
|
|
139
|
+
}
|
|
140
|
+
const protectedBefore = groupConfigFingerprint(current);
|
|
141
|
+
await this.requestApi('/api/groups/save', {
|
|
142
|
+
method: 'POST', body: groupSavePayload(current, currentRecord, update), ...signalOption(signal),
|
|
143
|
+
});
|
|
144
|
+
const after = await this.readWebState(signal);
|
|
145
|
+
const saved = after.snapshot.groups.find(item => item.id === update.id);
|
|
146
|
+
const savedRaw = after.rawGroups?.find(item => normalizeGroupId(item.id) === update.id);
|
|
147
|
+
this.remember(after);
|
|
148
|
+
if (saved === undefined || savedRaw === undefined || !groupUpdateMatches(update, saved)
|
|
149
|
+
|| groupConfigFingerprint(savedRaw) !== protectedBefore
|
|
150
|
+
|| !otherGroupsUnchanged(before.rawGroups ?? [], after.rawGroups ?? [], update.id)) {
|
|
151
|
+
throw new Error('Magpie routing-group update could not be verified without changing protected settings');
|
|
152
|
+
}
|
|
153
|
+
return { changed: 'group', id: update.id, snapshot: after.snapshot };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async updateAccount(input, signal) {
|
|
157
|
+
const update = readAccountUpdate(input);
|
|
158
|
+
return this.enqueueMutation(async () => {
|
|
159
|
+
const before = await this.readWebState(signal);
|
|
160
|
+
if (before.snapshot.accountStatus !== 'available')
|
|
161
|
+
throw new Error('Magpie account data is unavailable');
|
|
162
|
+
if (before.snapshot.accountApiStatus !== 'available')
|
|
163
|
+
throw new Error('Magpie Web account API is unavailable');
|
|
164
|
+
const current = findAccount(before.snapshot.accounts, update.agent, update.user);
|
|
165
|
+
if (current === undefined)
|
|
166
|
+
throw new Error('Magpie account was not found');
|
|
167
|
+
if (current.on === null)
|
|
168
|
+
throw new Error('Magpie account routing status cannot be verified');
|
|
169
|
+
if (current.on === update.on)
|
|
170
|
+
return { changed: 'account', id: `${update.agent}/${update.user}`, snapshot: before.snapshot };
|
|
171
|
+
if (!update.on && before.snapshot.accounts.filter(account => account.agent.toLocaleLowerCase() === update.agent.toLocaleLowerCase() && account.on === true).length <= 1) {
|
|
172
|
+
throw new Error('Magpie requires at least one account to remain in routing rotation');
|
|
173
|
+
}
|
|
174
|
+
await this.requestApi(`/api/login/${update.on ? 'on' : 'off'}`, {
|
|
175
|
+
method: 'POST', body: { Agent: update.agent, User: update.user }, ...signalOption(signal),
|
|
176
|
+
});
|
|
177
|
+
const after = await this.readWebState(signal);
|
|
178
|
+
const saved = findAccount(after.snapshot.accounts, update.agent, update.user);
|
|
179
|
+
this.remember(after);
|
|
180
|
+
if (after.snapshot.accountStatus !== 'available' || saved?.on !== update.on
|
|
181
|
+
|| !otherAccountsUnchanged(before.snapshot.accounts, after.snapshot.accounts, update.agent, update.user)) {
|
|
182
|
+
throw new Error('Magpie account change could not be verified after saving');
|
|
183
|
+
}
|
|
184
|
+
return { changed: 'account', id: `${update.agent}/${update.user}`, snapshot: after.snapshot };
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
enqueueMutation(operation) {
|
|
188
|
+
const next = this.mutationTail.then(operation, operation);
|
|
189
|
+
this.mutationTail = next.then(() => undefined, () => undefined);
|
|
190
|
+
return next;
|
|
191
|
+
}
|
|
192
|
+
async readWebState(signal) {
|
|
193
|
+
signal?.throwIfAborted();
|
|
194
|
+
const [providerResult, groupResult, quotaResult, loginUsageResult] = await Promise.all([
|
|
195
|
+
this.readApi('/api/providers', signal),
|
|
196
|
+
this.readApi('/api/groups', signal),
|
|
197
|
+
this.readApi('/api/usage/quotas', signal),
|
|
198
|
+
this.readApi('/api/login/usage', signal),
|
|
199
|
+
]);
|
|
200
|
+
// Logos come from the same provider rows, so they are inlined before the snapshot is
|
|
201
|
+
// remembered: a cached snapshot and the mutation results both carry them already.
|
|
202
|
+
const providers = providerResult.ok ? parseMagpieWebProviders(providerResult.value) : null;
|
|
203
|
+
const parsedProviders = providers === null
|
|
204
|
+
? null
|
|
205
|
+
: { ...providers, records: await this.attachProviderLogos(providers.records, signal) };
|
|
206
|
+
const parsedGroups = groupResult.ok ? parseMagpieWebGroups(groupResult.value) : null;
|
|
207
|
+
const parsedAccounts = providerResult.ok || quotaResult.ok || loginUsageResult.ok
|
|
208
|
+
? parseMagpieWebAccounts(quotaResult.ok ? quotaResult.value : null, loginUsageResult.ok ? loginUsageResult.value : null, providerResult.ok ? providerResult.value : null)
|
|
209
|
+
: null;
|
|
210
|
+
const apiAvailable = providerResult.ok || groupResult.ok || quotaResult.ok || loginUsageResult.ok;
|
|
211
|
+
const snapshot = {
|
|
212
|
+
capturedAt: new Date(this.now()).toISOString(),
|
|
213
|
+
availabilityIssue: apiAvailable ? null : 'web-api-unavailable',
|
|
214
|
+
providerStatus: parsedProviders === null ? 'unavailable' : parsedProviders.partial ? 'partial' : 'available',
|
|
215
|
+
providers: parsedProviders?.records ?? [],
|
|
216
|
+
accountStatus: parsedAccounts === null ? 'unavailable' : 'available',
|
|
217
|
+
accounts: parsedAccounts ?? [],
|
|
218
|
+
accountApiStatus: loginUsageResult.ok ? 'available' : 'unavailable',
|
|
219
|
+
groupStatus: parsedGroups === null ? 'unavailable' : 'available',
|
|
220
|
+
groups: parsedGroups?.groups ?? [],
|
|
221
|
+
};
|
|
222
|
+
return {
|
|
223
|
+
snapshot,
|
|
224
|
+
rawProviders: parsedProviders?.raw ?? null,
|
|
225
|
+
providerWritesSafe: parsedProviders?.writesSafe ?? false,
|
|
226
|
+
rawGroups: parsedGroups?.raw ?? null,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
refreshSnapshot() {
|
|
230
|
+
if (this.snapshotRefresh !== undefined)
|
|
231
|
+
return;
|
|
232
|
+
const refresh = this.readWebState();
|
|
233
|
+
this.snapshotRefresh = refresh;
|
|
234
|
+
void refresh.then((state) => this.remember(state), () => undefined).finally(() => {
|
|
235
|
+
if (this.snapshotRefresh === refresh)
|
|
236
|
+
this.snapshotRefresh = undefined;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
remember(state) {
|
|
240
|
+
this.snapshotCache = { state, loadedAt: this.now() };
|
|
241
|
+
}
|
|
242
|
+
async readApi(path, signal) {
|
|
243
|
+
if (this.webApi === undefined)
|
|
244
|
+
return { ok: false };
|
|
245
|
+
try {
|
|
246
|
+
return { ok: true, value: await this.webApi.requestJson(path, { ...signalOption(signal) }) };
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
signal?.throwIfAborted();
|
|
250
|
+
return { ok: false };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async requestApi(path, options) {
|
|
254
|
+
if (this.webApi === undefined)
|
|
255
|
+
throw new Error('Magpie Web API is unavailable');
|
|
256
|
+
return this.webApi.requestJson(path, options);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Inline each provider's Magpie icon, as the subscription page does for its provider cards.
|
|
260
|
+
*
|
|
261
|
+
* Provider rows carry the same `icon` field `/api/usage/quotas` does (`claude-color`,
|
|
262
|
+
* `file:<name>`), so the three views show one logo from one source. A missing icon only
|
|
263
|
+
* costs that row its logo: the Client still falls back to its own icon table.
|
|
264
|
+
*/
|
|
265
|
+
async attachProviderLogos(records, signal) {
|
|
266
|
+
const requestAssetDataUrl = this.webApi?.requestAssetDataUrl;
|
|
267
|
+
if (typeof requestAssetDataUrl !== 'function')
|
|
268
|
+
return records;
|
|
269
|
+
const icons = [...new Set(records
|
|
270
|
+
.map(record => record.icon)
|
|
271
|
+
.filter((icon) => icon !== undefined))].slice(0, MAX_PROVIDER_ICON_FETCHES);
|
|
272
|
+
if (icons.length === 0)
|
|
273
|
+
return records;
|
|
274
|
+
const logos = new Map();
|
|
275
|
+
await Promise.all(icons.map(async (icon) => {
|
|
276
|
+
const cached = this.providerLogoCache.get(icon);
|
|
277
|
+
const logo = cached === undefined ? await this.readProviderLogoDataUrl(icon, signal) : cached;
|
|
278
|
+
if (cached === undefined)
|
|
279
|
+
this.providerLogoCache.set(icon, logo);
|
|
280
|
+
logos.set(icon, logo);
|
|
281
|
+
}));
|
|
282
|
+
return records.map((record) => {
|
|
283
|
+
const logoDataUrl = record.icon === undefined ? null : logos.get(record.icon) ?? null;
|
|
284
|
+
return logoDataUrl === null ? record : { ...record, logoDataUrl };
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async readProviderLogoDataUrl(icon, signal) {
|
|
288
|
+
const requestAssetDataUrl = this.webApi?.requestAssetDataUrl;
|
|
289
|
+
if (typeof requestAssetDataUrl !== 'function')
|
|
290
|
+
return null;
|
|
291
|
+
for (const path of magpieIconAssetPaths(icon)) {
|
|
292
|
+
try {
|
|
293
|
+
const value = await requestAssetDataUrl.call(this.webApi, path, signal === undefined ? {} : { signal });
|
|
294
|
+
if (value !== null && value !== '')
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
signal?.throwIfAborted();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
export function parseMagpieWebProviders(value) {
|
|
305
|
+
const root = asRecord(value);
|
|
306
|
+
if (root === null || !Array.isArray(root.providers) || root.providers.length > MAX_PROVIDERS)
|
|
307
|
+
return null;
|
|
308
|
+
const raw = [];
|
|
309
|
+
const records = [];
|
|
310
|
+
let partial = false;
|
|
311
|
+
let writesSafe = true;
|
|
312
|
+
for (const item of root.providers) {
|
|
313
|
+
const record = asRecord(item);
|
|
314
|
+
if (record === null) {
|
|
315
|
+
partial = true;
|
|
316
|
+
writesSafe = false;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
raw.push(record);
|
|
320
|
+
const parsed = readWebProvider(record);
|
|
321
|
+
if (parsed === null) {
|
|
322
|
+
partial = true;
|
|
323
|
+
writesSafe = false;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
records.push(parsed.record);
|
|
327
|
+
partial ||= parsed.partial;
|
|
328
|
+
}
|
|
329
|
+
if (new Set(records.map(record => record.id)).size !== records.length)
|
|
330
|
+
return null;
|
|
331
|
+
return { records, raw, partial, writesSafe };
|
|
332
|
+
}
|
|
333
|
+
export function parseMagpieWebGroups(value) {
|
|
334
|
+
const root = asRecord(value);
|
|
335
|
+
if (root === null || !Array.isArray(root.groups) || root.groups.length > MAX_GROUPS)
|
|
336
|
+
return null;
|
|
337
|
+
const raw = [];
|
|
338
|
+
const groups = [];
|
|
339
|
+
for (const item of root.groups) {
|
|
340
|
+
const record = asRecord(item);
|
|
341
|
+
const parsed = record === null ? null : readWebGroup(record);
|
|
342
|
+
if (record === null || parsed === null)
|
|
343
|
+
return null;
|
|
344
|
+
raw.push(record);
|
|
345
|
+
groups.push(parsed);
|
|
346
|
+
}
|
|
347
|
+
if (new Set(groups.map(group => group.id)).size !== groups.length)
|
|
348
|
+
return null;
|
|
349
|
+
return { groups, raw };
|
|
350
|
+
}
|
|
351
|
+
export function parseMagpieWebAccounts(quotaValue, loginUsageValue, providerValue = null) {
|
|
352
|
+
const quotaRows = quotaValue === null ? null : readWebRows(quotaValue, MAX_ACCOUNTS);
|
|
353
|
+
const loginRows = loginUsageValue === null ? null : readLoginUsageRecords(loginUsageValue);
|
|
354
|
+
const providerRows = providerValue === null ? null : readProviderAccountRecords(providerValue);
|
|
355
|
+
if (quotaRows === null && loginRows === null && providerRows === null)
|
|
356
|
+
return null;
|
|
357
|
+
const accounts = new Map();
|
|
358
|
+
for (const record of [...(providerRows ?? []), ...(loginRows ?? []), ...(quotaRows ?? [])]) {
|
|
359
|
+
const account = readWebAccount(record);
|
|
360
|
+
if (account === null)
|
|
361
|
+
continue;
|
|
362
|
+
const key = `${account.agent.toLocaleLowerCase()}\u0000${account.user.toLocaleLowerCase()}`;
|
|
363
|
+
const before = accounts.get(key);
|
|
364
|
+
accounts.set(key, before === undefined ? account : mergeAccount(before, account));
|
|
365
|
+
if (accounts.size > MAX_ACCOUNTS)
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
return [...accounts.values()];
|
|
369
|
+
}
|
|
370
|
+
export function readProviderUpdate(value) {
|
|
371
|
+
const record = asRecord(value);
|
|
372
|
+
if (record === null || typeof record.id !== 'string' || !PROVIDER_ID.test(record.id))
|
|
373
|
+
throw new TypeError('Invalid Magpie provider update');
|
|
374
|
+
assertAllowedKeys(record, ['id', 'newId', 'name', 'endpointField', 'endpointUrl', 'models', 'on']);
|
|
375
|
+
const newId = record.newId === undefined || record.newId === record.id ? undefined : readProviderId(record.newId);
|
|
376
|
+
const name = optionalUpdateText(record.name, 80, 'provider name');
|
|
377
|
+
const endpointField = record.endpointField;
|
|
378
|
+
const endpointUrl = optionalUpdateUrl(record.endpointUrl);
|
|
379
|
+
const on = optionalUpdateBoolean(record.on, 'provider availability');
|
|
380
|
+
if (endpointField !== undefined && (typeof endpointField !== 'string' || !ENDPOINT_FIELD_SET.has(endpointField))) {
|
|
381
|
+
throw new TypeError('Invalid Magpie endpoint field');
|
|
382
|
+
}
|
|
383
|
+
const canonicalEndpointUrl = endpointField === 'anthropic' && endpointUrl?.endsWith('/v1') === true
|
|
384
|
+
? endpointUrl.slice(0, -3)
|
|
385
|
+
: endpointUrl;
|
|
386
|
+
if ((endpointField === undefined) !== (canonicalEndpointUrl === undefined))
|
|
387
|
+
throw new TypeError('Endpoint field and URL must be updated together');
|
|
388
|
+
const models = record.models === undefined ? undefined : readModelList(record.models, record.id, false);
|
|
389
|
+
if (newId === undefined && name === undefined && endpointUrl === undefined && models === undefined && on === undefined)
|
|
390
|
+
throw new TypeError('Provider update has no changes');
|
|
391
|
+
return {
|
|
392
|
+
id: record.id,
|
|
393
|
+
...(newId === undefined ? {} : { newId }),
|
|
394
|
+
...(name === undefined ? {} : { name }),
|
|
395
|
+
...(endpointField === undefined || canonicalEndpointUrl === undefined ? {} : { endpointField: endpointField, endpointUrl: canonicalEndpointUrl }),
|
|
396
|
+
...(models === undefined ? {} : { models }),
|
|
397
|
+
...(on === undefined ? {} : { on }),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Read the ID a provider is renamed to, in the spelling Magpie itself writes it: lower-case
|
|
402
|
+
* letters, digits and single dashes, its `provider.Slug`. Magpie answers any other spelling by
|
|
403
|
+
* refusing the save, and it refuses some IDs even when they are spelled that way.
|
|
404
|
+
*/
|
|
405
|
+
function readProviderId(value) {
|
|
406
|
+
const text = typeof value === 'string' ? value.trim().toLocaleLowerCase() : '';
|
|
407
|
+
if (text === '' || text.length > 64 || text !== magpieProviderId(text))
|
|
408
|
+
throw new TypeError('Invalid Magpie provider ID');
|
|
409
|
+
if (RESERVED_PROVIDER_IDS.has(text))
|
|
410
|
+
throw new TypeError('Magpie reserves this provider ID');
|
|
411
|
+
return text;
|
|
412
|
+
}
|
|
413
|
+
/** The IDs a provider answers to: Magpie's `was`, the ones it had before a rename. */
|
|
414
|
+
function magpieProviderAliasIds(rawProviders, providerId) {
|
|
415
|
+
const aliases = readStringList(rawProviders.find(item => item.id === providerId)?.was, 100);
|
|
416
|
+
return (aliases ?? []).map(alias => alias.toLocaleLowerCase());
|
|
417
|
+
}
|
|
418
|
+
export function readGroupUpdate(value) {
|
|
419
|
+
const record = asRecord(value);
|
|
420
|
+
if (record === null || typeof record.id !== 'string' || !GROUP_ID.test(record.id))
|
|
421
|
+
throw new TypeError('Invalid Magpie routing-group update');
|
|
422
|
+
assertAllowedKeys(record, ['id', 'name', 'models', 'routing', 'stays', 'pick', 'unlists', 'fast', 'context', 'levels', 'effort', 'classifier']);
|
|
423
|
+
const name = optionalUpdateText(record.name, 120, 'group name');
|
|
424
|
+
const models = record.models === undefined ? undefined : readModelList(record.models, null, true);
|
|
425
|
+
const routing = optionalEnum(record.routing, ROUTING_POLICIES, 'routing policy');
|
|
426
|
+
const stays = optionalEnum(record.stays, STICKINESS_POLICIES, 'stickiness policy');
|
|
427
|
+
const pick = optionalMemberId(record.pick, 'manual routing pick');
|
|
428
|
+
const unlists = optionalUpdateBoolean(record.unlists, 'group listing');
|
|
429
|
+
const fast = record.fast === undefined ? undefined : readMemberList(record.fast);
|
|
430
|
+
const context = record.context === undefined ? undefined : readGroupContext(record.context);
|
|
431
|
+
if (record.context !== undefined && context === null)
|
|
432
|
+
throw new TypeError('Invalid group context window');
|
|
433
|
+
const levels = record.levels === undefined ? undefined : readGroupLevels(record.levels);
|
|
434
|
+
if (record.levels !== undefined && levels === null)
|
|
435
|
+
throw new TypeError('Invalid group reasoning levels');
|
|
436
|
+
const effort = optionalEnum(record.effort, GROUP_EFFORTS, 'group effort policy');
|
|
437
|
+
const classifier = record.classifier === undefined ? undefined : optionalClassifier(record.classifier);
|
|
438
|
+
if (record.pick !== undefined && routing !== undefined && routing !== 'manual') {
|
|
439
|
+
throw new TypeError('Only a manual routing group sends to a picked model');
|
|
440
|
+
}
|
|
441
|
+
if ([name, models, routing, stays, pick, unlists, fast, context, levels, effort, classifier].every(item => item === undefined)) {
|
|
442
|
+
throw new TypeError('Routing-group update has no changes');
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
id: record.id,
|
|
446
|
+
...(name === undefined ? {} : { name }),
|
|
447
|
+
...(models === undefined ? {} : { models }),
|
|
448
|
+
...(routing === undefined ? {} : { routing }),
|
|
449
|
+
...(stays === undefined ? {} : { stays }),
|
|
450
|
+
...(pick === undefined ? {} : { pick }),
|
|
451
|
+
...(unlists === undefined ? {} : { unlists }),
|
|
452
|
+
...(fast === undefined ? {} : { fast }),
|
|
453
|
+
...(context === undefined || context === null ? {} : { context }),
|
|
454
|
+
...(levels === undefined || levels === null ? {} : { levels }),
|
|
455
|
+
...(effort === undefined ? {} : { effort }),
|
|
456
|
+
...(classifier === undefined ? {} : { classifier }),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
export function readAccountUpdate(value) {
|
|
460
|
+
const record = asRecord(value);
|
|
461
|
+
if (record === null)
|
|
462
|
+
throw new TypeError('Invalid Magpie account update');
|
|
463
|
+
assertAllowedKeys(record, ['agent', 'user', 'on']);
|
|
464
|
+
const agent = safeText(record.agent, 64);
|
|
465
|
+
const user = safeText(record.user, 180);
|
|
466
|
+
if (agent === null || !ACCOUNT_AGENT.test(agent) || user === null || typeof record.on !== 'boolean') {
|
|
467
|
+
throw new TypeError('Invalid Magpie account update');
|
|
468
|
+
}
|
|
469
|
+
return { agent, user, on: record.on };
|
|
470
|
+
}
|
|
471
|
+
function readWebProvider(record) {
|
|
472
|
+
const id = safeText(record.id, 64);
|
|
473
|
+
if (id === null || !PROVIDER_ID.test(id))
|
|
474
|
+
return null;
|
|
475
|
+
let partial = false;
|
|
476
|
+
const name = safeText(record.name, 180) ?? id;
|
|
477
|
+
if (record.name !== name)
|
|
478
|
+
partial = true;
|
|
479
|
+
// The icon is display-only, so an unusable value leaves the row without one rather than
|
|
480
|
+
// downgrading a provider whose settings were all read exactly.
|
|
481
|
+
const icon = optionalQuotaIcon(record.icon);
|
|
482
|
+
const endpoints = [];
|
|
483
|
+
for (const field of ENDPOINT_FIELDS) {
|
|
484
|
+
const value = record[field];
|
|
485
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
486
|
+
continue;
|
|
487
|
+
const url = safeEndpoint(value);
|
|
488
|
+
if (url === null) {
|
|
489
|
+
partial = true;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
endpoints.push({ field, url, editable: isPlainEndpoint(value) });
|
|
493
|
+
}
|
|
494
|
+
const chosen = readStringList(record.chosen, 2_000);
|
|
495
|
+
let modelsEditable = Array.isArray(record.models) && record.models.length <= 10_000;
|
|
496
|
+
if (record.models !== undefined && !Array.isArray(record.models)) {
|
|
497
|
+
modelsEditable = false;
|
|
498
|
+
partial = true;
|
|
499
|
+
}
|
|
500
|
+
if (Array.isArray(record.models) && record.models.length > 10_000) {
|
|
501
|
+
modelsEditable = false;
|
|
502
|
+
partial = true;
|
|
503
|
+
}
|
|
504
|
+
if (record.models === undefined)
|
|
505
|
+
partial = true;
|
|
506
|
+
if (record.chosen !== undefined && chosen === null) {
|
|
507
|
+
modelsEditable = false;
|
|
508
|
+
partial = true;
|
|
509
|
+
}
|
|
510
|
+
const chosenSet = chosen === null ? null : new Set(chosen);
|
|
511
|
+
const rawModels = Array.isArray(record.models) && record.models.length <= 10_000 ? record.models : [];
|
|
512
|
+
const models = [];
|
|
513
|
+
for (const value of rawModels) {
|
|
514
|
+
const model = asRecord(value);
|
|
515
|
+
const localId = safeText(model?.id, 300);
|
|
516
|
+
if (model === null || localId === null || !MODEL_ID.test(localId)) {
|
|
517
|
+
modelsEditable = false;
|
|
518
|
+
partial = true;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const fullId = localId.startsWith(`${id}/`) ? localId : `${id}/${localId}`;
|
|
522
|
+
const exposed = chosenSet === null
|
|
523
|
+
? model.given === true || model.on === true
|
|
524
|
+
: chosenSet.has(localId) || chosenSet.has(fullId);
|
|
525
|
+
models.push({ id: fullId, name: optionalSafeText(model.name, 200), exposed });
|
|
526
|
+
}
|
|
527
|
+
const status = typeof record.off === 'boolean' ? record.off ? 'off' : 'on' : 'unknown';
|
|
528
|
+
const key = asRecord(record.key);
|
|
529
|
+
const parsedFallback = readStringList(record.fallback, 100);
|
|
530
|
+
if (record.fallback !== undefined && parsedFallback === null)
|
|
531
|
+
partial = true;
|
|
532
|
+
const accountRecord = asRecord(record.account);
|
|
533
|
+
const rawAccountAgent = accountRecord?.agent ?? accountRecord?.Agent;
|
|
534
|
+
const accountAgentText = safeText(rawAccountAgent, 64);
|
|
535
|
+
const accountAgent = accountAgentText !== null && ACCOUNT_AGENT.test(accountAgentText) ? accountAgentText : null;
|
|
536
|
+
if (rawAccountAgent !== undefined && rawAccountAgent !== null && accountAgent === null)
|
|
537
|
+
partial = true;
|
|
538
|
+
const normalized = {
|
|
539
|
+
id,
|
|
540
|
+
idEditable: isMagpieProviderId(id),
|
|
541
|
+
name,
|
|
542
|
+
...icon,
|
|
543
|
+
accountAgent,
|
|
544
|
+
preset: optionalSafeText(record.preset, 100),
|
|
545
|
+
protocol: endpoints[0]?.field ?? null,
|
|
546
|
+
host: safeHost(optionalSafeText(record.host, 300)),
|
|
547
|
+
website: safeWebUrl(record.website),
|
|
548
|
+
state: status,
|
|
549
|
+
account: optionalSafeText(record.account, 180)
|
|
550
|
+
?? optionalSafeText(accountRecord?.name ?? accountRecord?.Name, 180)
|
|
551
|
+
?? accountAgent,
|
|
552
|
+
keyMasked: optionalSafeText(key?.masked, 80),
|
|
553
|
+
endpoints,
|
|
554
|
+
models,
|
|
555
|
+
modelsEditable,
|
|
556
|
+
modelCount: Array.isArray(record.models) && record.models.length <= 10_000 ? optionalInteger(rawModels.length) : null,
|
|
557
|
+
exposedModelCount: optionalInteger(record.exposed) ?? optionalInteger(chosen?.length ?? null),
|
|
558
|
+
fallback: parsedFallback ?? [],
|
|
559
|
+
routing: optionalSafeText(record.routing, 64),
|
|
560
|
+
affinity: optionalSafeText(record.affinity, 64),
|
|
561
|
+
maxConcurrency: optionalNumber(record.maxConcurrency),
|
|
562
|
+
};
|
|
563
|
+
return { record: normalized, partial };
|
|
564
|
+
}
|
|
565
|
+
function readWebGroup(record) {
|
|
566
|
+
const rawId = safeText(record.id, 128);
|
|
567
|
+
const id = rawId === null ? null : normalizeGroupId(rawId);
|
|
568
|
+
const name = safeText(record.name, 120);
|
|
569
|
+
if (id === null || !GROUP_ID.test(id) || name === null)
|
|
570
|
+
return null;
|
|
571
|
+
const membersValue = record.members === undefined ? [] : record.members;
|
|
572
|
+
if (!Array.isArray(membersValue) || membersValue.length > 2_000)
|
|
573
|
+
return null;
|
|
574
|
+
const memberInfo = Array.isArray(record.memberInfo) && record.memberInfo.length <= 2_000
|
|
575
|
+
? record.memberInfo.flatMap(value => {
|
|
576
|
+
const item = asRecord(value);
|
|
577
|
+
const itemId = safeText(item?.id ?? item?.of, 512);
|
|
578
|
+
return item === null || itemId === null ? [] : [[itemId, item]];
|
|
579
|
+
})
|
|
580
|
+
: [];
|
|
581
|
+
const infoById = new Map(memberInfo);
|
|
582
|
+
const members = [];
|
|
583
|
+
for (const value of membersValue) {
|
|
584
|
+
const memberId = safeText(value, 512);
|
|
585
|
+
if (memberId === null || !(MODEL_ID.test(memberId) || memberId.startsWith('group/')))
|
|
586
|
+
return null;
|
|
587
|
+
const info = infoById.get(memberId);
|
|
588
|
+
members.push({
|
|
589
|
+
id: memberId,
|
|
590
|
+
provider: optionalSafeText(info?.provider, 64) ?? providerFromModelId(memberId),
|
|
591
|
+
name: optionalSafeText(info?.name, 180),
|
|
592
|
+
model: optionalSafeText(info?.model, 300) ?? modelFromMemberId(memberId),
|
|
593
|
+
on: readBooleanOrCount(info?.on),
|
|
594
|
+
ready: optionalBoolean(info?.ready),
|
|
595
|
+
byPattern: false,
|
|
596
|
+
...(typeof info?.canFast === 'boolean' ? { canFast: info.canFast } : {}),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
const patterns = readGroupPatterns(record.match, record.patterns);
|
|
600
|
+
if (patterns === null)
|
|
601
|
+
return null;
|
|
602
|
+
const usedBy = readStringList(record.usedBy ?? record.agents, 500);
|
|
603
|
+
if (record.usedBy !== undefined || record.agents !== undefined) {
|
|
604
|
+
if (usedBy === null)
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
// The member a manual group sends to, and the members sent in their vendor's fast mode.
|
|
608
|
+
// Both use the same member-id spelling `members` uses, so the editor can compare them.
|
|
609
|
+
const rawPick = optionalSafeText(record.pick, 512);
|
|
610
|
+
const pick = rawPick === null ? null : normalizeMemberId(rawPick);
|
|
611
|
+
if (rawPick !== null && pick === null)
|
|
612
|
+
return null;
|
|
613
|
+
const fast = record.fast === undefined ? [] : readStringList(record.fast, 2_000);
|
|
614
|
+
if (fast === null)
|
|
615
|
+
return null;
|
|
616
|
+
if (fast.some(value => normalizeMemberId(value) === null))
|
|
617
|
+
return null;
|
|
618
|
+
// The window Agents are told the group takes, and the levels it offers. Magpie's own editor
|
|
619
|
+
// reads an absent context as 0 (the largest member's) and an absent levels list as "shared".
|
|
620
|
+
const context = record.context === undefined ? 0 : readGroupContext(record.context);
|
|
621
|
+
if (context === null)
|
|
622
|
+
return null;
|
|
623
|
+
const levels = readGroupLevels(record.levels);
|
|
624
|
+
if (levels === null)
|
|
625
|
+
return null;
|
|
626
|
+
const sharedLevels = readGroupLevels(record.shared) ?? [];
|
|
627
|
+
// An empty effort is Magpie's "as the Agent asks", not a missing value: `safeText` would
|
|
628
|
+
// treat it as absent, so the raw string is checked against the two values it may hold.
|
|
629
|
+
const rawEffort = record.effort === undefined || record.effort === null ? '' : record.effort;
|
|
630
|
+
const effort = typeof rawEffort === 'string' && GROUP_EFFORTS.has(rawEffort.trim()) ? rawEffort.trim() : null;
|
|
631
|
+
if (effort === null)
|
|
632
|
+
return null;
|
|
633
|
+
const rawClassifier = optionalSafeText(record.classifier, 512);
|
|
634
|
+
const classifier = rawClassifier === null ? null : normalizeMemberId(rawClassifier);
|
|
635
|
+
if (rawClassifier !== null && classifier === null)
|
|
636
|
+
return null;
|
|
637
|
+
return {
|
|
638
|
+
id,
|
|
639
|
+
name,
|
|
640
|
+
routing: optionalSafeText(record.routing, 64),
|
|
641
|
+
stays: optionalSafeText(record.affinity ?? record.stays, 64),
|
|
642
|
+
members,
|
|
643
|
+
usedBy: usedBy ?? [],
|
|
644
|
+
patterns,
|
|
645
|
+
pick,
|
|
646
|
+
unlists: optionalBoolean(record.unlists),
|
|
647
|
+
fast,
|
|
648
|
+
context,
|
|
649
|
+
levels,
|
|
650
|
+
sharedLevels,
|
|
651
|
+
effort,
|
|
652
|
+
classifier,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Read a group's context window: `0` the largest member's, `-1` the smallest, else that many
|
|
657
|
+
* tokens. Anything else is a shape this Host cannot write back, so the group stays uneditable.
|
|
658
|
+
*/
|
|
659
|
+
function readGroupContext(value) {
|
|
660
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < -1)
|
|
661
|
+
return null;
|
|
662
|
+
return value;
|
|
663
|
+
}
|
|
664
|
+
/** Read a reasoning-level list; a list holding a level Magpie does not name cannot round-trip. */
|
|
665
|
+
function readGroupLevels(value) {
|
|
666
|
+
if (value === undefined || value === null)
|
|
667
|
+
return [];
|
|
668
|
+
const levels = readStringList(value, MEMBER_LEVELS.length);
|
|
669
|
+
if (levels === null)
|
|
670
|
+
return null;
|
|
671
|
+
if (levels.some(level => !MEMBER_LEVELS.includes(level)))
|
|
672
|
+
return null;
|
|
673
|
+
return [...new Set(levels)];
|
|
674
|
+
}
|
|
675
|
+
/** Accept a member id Magpie itself would: a model, a group, or a model at a reasoning level. */
|
|
676
|
+
function normalizeMemberId(value) {
|
|
677
|
+
if (MODEL_ID.test(value) || value.startsWith('group/'))
|
|
678
|
+
return value;
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
function readWebAccount(record, fallbackAgent) {
|
|
682
|
+
const agent = safeText(record.agent ?? record.Agent ?? record.provider ?? record.Provider ?? fallbackAgent, 64);
|
|
683
|
+
const user = safeText(record.user ?? record.User ?? record.email ?? record.account, 180);
|
|
684
|
+
if (agent === null || !ACCOUNT_AGENT.test(agent) || user === null)
|
|
685
|
+
return null;
|
|
686
|
+
const windows = readAccountWindows(record.windows ?? record.Windows);
|
|
687
|
+
if (windows === null)
|
|
688
|
+
return null;
|
|
689
|
+
return {
|
|
690
|
+
agent,
|
|
691
|
+
user,
|
|
692
|
+
alias: optionalSafeText(record.alias, 180),
|
|
693
|
+
plan: optionalSafeText(record.plan, 120),
|
|
694
|
+
active: optionalBoolean(record.active ?? record.Active),
|
|
695
|
+
on: optionalBoolean(record.on ?? record.On),
|
|
696
|
+
balance: optionalSafeText(record.balance, 180),
|
|
697
|
+
windows,
|
|
698
|
+
asOf: optionalSafeText(record.asOf ?? record.as_of, 64),
|
|
699
|
+
readAt: optionalSafeText(record.readAt ?? record.read_at, 64),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
function readLoginUsageRecords(value) {
|
|
703
|
+
if (!Array.isArray(value) && asRecord(value) === null)
|
|
704
|
+
return null;
|
|
705
|
+
const rows = [];
|
|
706
|
+
const visit = (item, inheritedAgent, depth) => {
|
|
707
|
+
if (depth > 5 || rows.length > MAX_ACCOUNTS)
|
|
708
|
+
return;
|
|
709
|
+
if (Array.isArray(item)) {
|
|
710
|
+
for (const child of item)
|
|
711
|
+
visit(child, inheritedAgent, depth + 1);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const record = asRecord(item);
|
|
715
|
+
if (record === null)
|
|
716
|
+
return;
|
|
717
|
+
if (safeText(record.user ?? record.User ?? record.email ?? record.account, 180) !== null) {
|
|
718
|
+
rows.push(inheritedAgent === undefined || record.agent !== undefined || record.Agent !== undefined || record.provider !== undefined
|
|
719
|
+
? record
|
|
720
|
+
: { ...record, agent: inheritedAgent });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
for (const [key, child] of Object.entries(record)) {
|
|
724
|
+
const agent = ACCOUNT_AGENT.test(key) ? key : inheritedAgent;
|
|
725
|
+
if (LOGIN_CONTAINER_FIELDS.has(key) || asRecord(child) !== null || Array.isArray(child))
|
|
726
|
+
visit(child, agent, depth + 1);
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
visit(value, undefined, 0);
|
|
730
|
+
return rows.length > MAX_ACCOUNTS ? null : rows;
|
|
731
|
+
}
|
|
732
|
+
function readProviderAccountRecords(value) {
|
|
733
|
+
const root = asRecord(value);
|
|
734
|
+
if (root === null || !Array.isArray(root.providers) || root.providers.length > MAX_PROVIDERS)
|
|
735
|
+
return null;
|
|
736
|
+
const rows = [];
|
|
737
|
+
for (const value of root.providers) {
|
|
738
|
+
const provider = asRecord(value);
|
|
739
|
+
const providerId = safeText(provider?.id, 64);
|
|
740
|
+
if (provider === null || providerId === null)
|
|
741
|
+
continue;
|
|
742
|
+
const account = asRecord(provider.account);
|
|
743
|
+
if (account === null || account.logins === undefined || account.logins === null)
|
|
744
|
+
continue;
|
|
745
|
+
const agent = safeText(account.agent ?? account.Agent, 64) ?? providerId;
|
|
746
|
+
if (!ACCOUNT_AGENT.test(agent))
|
|
747
|
+
continue;
|
|
748
|
+
const logins = readLoginUsageRecords({ [agent]: account.logins });
|
|
749
|
+
if (logins === null)
|
|
750
|
+
return null;
|
|
751
|
+
for (const login of logins) {
|
|
752
|
+
rows.push(login);
|
|
753
|
+
if (rows.length > MAX_ACCOUNTS)
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return rows;
|
|
758
|
+
}
|
|
759
|
+
function readWebRows(value, limit) {
|
|
760
|
+
const root = asRecord(value);
|
|
761
|
+
const rows = Array.isArray(value) ? value : Array.isArray(root?.data) ? root.data : null;
|
|
762
|
+
if (rows === null || rows.length > limit)
|
|
763
|
+
return null;
|
|
764
|
+
const records = rows.map(asRecord);
|
|
765
|
+
return records.some(record => record === null) ? null : records;
|
|
766
|
+
}
|
|
767
|
+
function readAccountWindows(value) {
|
|
768
|
+
if (value === undefined || value === null)
|
|
769
|
+
return [];
|
|
770
|
+
if (!Array.isArray(value) || value.length > 100)
|
|
771
|
+
return null;
|
|
772
|
+
const windows = [];
|
|
773
|
+
for (const item of value) {
|
|
774
|
+
const record = asRecord(item);
|
|
775
|
+
const name = safeText(record?.name ?? record?.Name, 100);
|
|
776
|
+
if (record === null || name === null)
|
|
777
|
+
return null;
|
|
778
|
+
windows.push({
|
|
779
|
+
name,
|
|
780
|
+
used: optionalNumber(record.used ?? record.Used),
|
|
781
|
+
remaining: optionalNumber(record.remaining ?? record.Remaining),
|
|
782
|
+
resetsAt: optionalSafeText(record.resetsAt ?? record.resets_at, 64),
|
|
783
|
+
display: optionalSafeText(record.display, 120),
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
return windows;
|
|
787
|
+
}
|
|
788
|
+
function mergeAccount(left, right) {
|
|
789
|
+
return {
|
|
790
|
+
agent: left.agent,
|
|
791
|
+
user: left.user,
|
|
792
|
+
alias: right.alias ?? left.alias,
|
|
793
|
+
plan: right.plan ?? left.plan,
|
|
794
|
+
active: right.active ?? left.active,
|
|
795
|
+
on: right.on ?? left.on,
|
|
796
|
+
balance: right.balance ?? left.balance,
|
|
797
|
+
windows: right.windows.length > 0 ? right.windows : left.windows,
|
|
798
|
+
asOf: right.asOf ?? left.asOf,
|
|
799
|
+
readAt: right.readAt ?? left.readAt,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
function providerHasConfigChange(update, current) {
|
|
803
|
+
return update.name !== undefined && update.name !== current.name
|
|
804
|
+
|| update.endpointField !== undefined && update.endpointUrl !== undefined
|
|
805
|
+
&& current.endpoints.find(item => item.field === update.endpointField)?.url !== update.endpointUrl
|
|
806
|
+
|| update.models !== undefined && !sameStringOrder(update.models, current.models.filter(model => model.exposed).map(model => model.id));
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* The body of a provider save. A rename goes in as `from`, the ID the provider has now, plus the
|
|
810
|
+
* new `id`: Magpie saves the other fields under the ID it had and then gives it `id`, carrying
|
|
811
|
+
* the routing groups and Agent configs that name its models along with it.
|
|
812
|
+
*
|
|
813
|
+
* The base URLs go back exactly as Magpie returned them: a save that left one out is read as a
|
|
814
|
+
* provider without that API and is refused with "a provider needs a base URL".
|
|
815
|
+
*/
|
|
816
|
+
function providerSavePayload(current, update, newId) {
|
|
817
|
+
const payload = { id: current.id };
|
|
818
|
+
if (newId !== undefined) {
|
|
819
|
+
payload.id = newId;
|
|
820
|
+
payload.from = current.id;
|
|
821
|
+
}
|
|
822
|
+
if (update.name !== undefined)
|
|
823
|
+
payload.name = update.name;
|
|
824
|
+
for (const endpoint of current.endpoints) {
|
|
825
|
+
payload[endpoint.field] = endpoint.field === update.endpointField && update.endpointUrl !== undefined ? update.endpointUrl : endpoint.url;
|
|
826
|
+
}
|
|
827
|
+
if (update.models !== undefined) {
|
|
828
|
+
// Magpie appends a pick to the provider it is named under, which for a rename is its new ID.
|
|
829
|
+
const providerId = newId ?? current.id;
|
|
830
|
+
payload.models = update.models.map(model => localModelId(providerId, model.startsWith(`${current.id}/`) ? `${providerId}/${model.slice(current.id.length + 1)}` : model));
|
|
831
|
+
}
|
|
832
|
+
return payload;
|
|
833
|
+
}
|
|
834
|
+
function groupSavePayload(raw, current, update) {
|
|
835
|
+
const payload = {};
|
|
836
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
837
|
+
if (GROUP_VIEW_FIELDS.has(key))
|
|
838
|
+
continue;
|
|
839
|
+
const clone = safeJsonClone(value);
|
|
840
|
+
if (clone !== undefined)
|
|
841
|
+
payload[key] = clone;
|
|
842
|
+
}
|
|
843
|
+
// A save rewrites `members` and `match` whole, so a group whose patterns were never fully
|
|
844
|
+
// readable must not be saved: the unreadable ones would be dropped without saying so.
|
|
845
|
+
if (!groupPatternsReadable(raw))
|
|
846
|
+
throw new TypeError('Magpie routing group patterns cannot be read back safely');
|
|
847
|
+
const models = update.models ?? editableGroupModels(current);
|
|
848
|
+
const routing = update.routing ?? current.routing ?? 'smart';
|
|
849
|
+
// Magpie names a group it found its own way (`group/fast-pool`) and a user's group by its
|
|
850
|
+
// bare id. Saving under the normalized id would re-key or re-create the group, so the id
|
|
851
|
+
// goes back exactly as it was read.
|
|
852
|
+
payload.id = typeof raw.id === 'string' ? raw.id : current.id;
|
|
853
|
+
payload.name = update.name ?? current.name;
|
|
854
|
+
payload.members = models.filter(model => !isGroupPattern(model));
|
|
855
|
+
payload.match = models.filter(isGroupPattern);
|
|
856
|
+
payload.routing = routing;
|
|
857
|
+
payload.affinity = update.stays ?? current.stays ?? 'auto';
|
|
858
|
+
// Every other field a group save writes goes back as it was read: a save that left one out
|
|
859
|
+
// would reset it to Magpie's default, and a manual group's pick would come back empty.
|
|
860
|
+
payload.pick = routing === 'manual' ? update.pick ?? current.pick ?? '' : '';
|
|
861
|
+
payload.unlists = update.unlists ?? current.unlists ?? false;
|
|
862
|
+
payload.fast = (update.fast ?? current.fast).filter(member => payload.members.includes(member));
|
|
863
|
+
payload.off = (readStringList(raw.off, 2_000) ?? []).filter(member => payload.members.includes(member));
|
|
864
|
+
payload.weights = Object.fromEntries(Object.entries(asRecord(raw.weights) ?? {})
|
|
865
|
+
.filter(([member, weight]) => payload.members.includes(member) && optionalInteger(weight) !== null));
|
|
866
|
+
// The window Agents are told the group takes, and the levels and per-turn effort it offers.
|
|
867
|
+
payload.context = update.context ?? current.context;
|
|
868
|
+
payload.levels = update.levels === undefined ? current.levels : [...update.levels];
|
|
869
|
+
payload.effort = update.effort ?? current.effort;
|
|
870
|
+
// Magpie clears the classifier whenever the effort is left to the Agent, and needs one as
|
|
871
|
+
// soon as a turn's level is picked.
|
|
872
|
+
payload.classifier = payload.effort === 'auto'
|
|
873
|
+
? update.classifier ?? current.classifier ?? ''
|
|
874
|
+
: update.classifier ?? '';
|
|
875
|
+
for (const key of ['sink', 'firstToken', 'patience', 'family', 'rules']) {
|
|
876
|
+
if (raw[key] !== undefined)
|
|
877
|
+
payload[key] = safeJsonClone(raw[key]);
|
|
878
|
+
}
|
|
879
|
+
return payload;
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Fingerprint what a provider save must leave alone: everything but the fields it writes
|
|
883
|
+
* (`providerSavePayload`) and the two it keeps of its own. A rename is read back as such rather
|
|
884
|
+
* than as an untouched field, so it is the one ID change this Host accepts.
|
|
885
|
+
*/
|
|
886
|
+
function providerConfigFingerprint(record) {
|
|
887
|
+
const viewOrMutable = new Set([...PROVIDER_VIEW_FIELDS, ...PROVIDER_MUTABLE_FIELDS, ...PROVIDER_SAVE_KEEPS]);
|
|
888
|
+
const preserved = Object.fromEntries(Object.entries(record).filter(([key]) => !viewOrMutable.has(key)));
|
|
889
|
+
const key = asRecord(record.key);
|
|
890
|
+
const keyList = Array.isArray(record.keyList)
|
|
891
|
+
? record.keyList.flatMap(value => {
|
|
892
|
+
const item = asRecord(value);
|
|
893
|
+
return item === null ? [] : [{ id: item.id, masked: item.masked, active: item.active, on: item.on }];
|
|
894
|
+
})
|
|
895
|
+
: record.keyList;
|
|
896
|
+
return stableJson({
|
|
897
|
+
preserved,
|
|
898
|
+
key: key === null ? null : { set: key.set, masked: key.masked, optional: key.optional },
|
|
899
|
+
keyList,
|
|
900
|
+
balanceToken: safeBalanceTokenStatus(record.balanceToken),
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Fingerprint what a group save must leave alone.
|
|
905
|
+
*
|
|
906
|
+
* Every field a save writes is covered by `groupUpdateMatches` instead: a save now writes
|
|
907
|
+
* `pick`, `unlists`, `fast`, `off`, and `weights` as well, and Magpie itself fills `off` with
|
|
908
|
+
* the members whose provider is switched off. What is left here is everything this Host has
|
|
909
|
+
* not verified as safe to write.
|
|
910
|
+
*/
|
|
911
|
+
function groupConfigFingerprint(record) {
|
|
912
|
+
const savedWithTheRest = new Set([
|
|
913
|
+
...GROUP_VIEW_FIELDS, ...GROUP_WRITTEN_FIELDS,
|
|
914
|
+
// `patterns` is read back into `match`; saving both would contradict itself.
|
|
915
|
+
'patterns',
|
|
916
|
+
]);
|
|
917
|
+
return stableJson(Object.fromEntries(Object.entries(record).filter(([key]) => !savedWithTheRest.has(key))));
|
|
918
|
+
}
|
|
919
|
+
function providerRecordFingerprint(record) {
|
|
920
|
+
const parsed = readWebProvider(record);
|
|
921
|
+
return stableJson({ record: parsed?.record ?? null, protected: providerConfigFingerprint(record) });
|
|
922
|
+
}
|
|
923
|
+
function groupRecordFingerprint(record) {
|
|
924
|
+
const parsed = readWebGroup(record);
|
|
925
|
+
return stableJson({ record: parsed, protected: groupConfigFingerprint(record) });
|
|
926
|
+
}
|
|
927
|
+
function otherProvidersUnchanged(before, after, id, previousId = id) {
|
|
928
|
+
// A rename moves one provider between two IDs: neither is another provider whose change would
|
|
929
|
+
// be a side effect of this save.
|
|
930
|
+
const own = new Set([id, previousId]);
|
|
931
|
+
const beforeOthers = before.filter(item => !own.has(String(item.id)));
|
|
932
|
+
const afterOthers = after.filter(item => !own.has(String(item.id)));
|
|
933
|
+
if (beforeOthers.length !== afterOthers.length)
|
|
934
|
+
return false;
|
|
935
|
+
const afterById = new Map(afterOthers.map(item => [String(item.id), item]));
|
|
936
|
+
return beforeOthers.every(item => {
|
|
937
|
+
const saved = afterById.get(String(item.id));
|
|
938
|
+
return saved !== undefined && providerRecordFingerprint(item) === providerRecordFingerprint(saved);
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
function otherGroupsUnchanged(before, after, id) {
|
|
942
|
+
const beforeOthers = before.filter(item => normalizeGroupId(item.id) !== id);
|
|
943
|
+
const afterOthers = after.filter(item => normalizeGroupId(item.id) !== id);
|
|
944
|
+
if (beforeOthers.length !== afterOthers.length)
|
|
945
|
+
return false;
|
|
946
|
+
const afterById = new Map(afterOthers.map(item => [normalizeGroupId(item.id), item]));
|
|
947
|
+
return beforeOthers.every(item => {
|
|
948
|
+
const saved = afterById.get(normalizeGroupId(item.id));
|
|
949
|
+
return saved !== undefined && groupRecordFingerprint(item) === groupRecordFingerprint(saved);
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
function otherAccountsUnchanged(before, after, agent, user) {
|
|
953
|
+
const accountKey = (account) => `${account.agent.toLocaleLowerCase()}\u0000${account.user.toLocaleLowerCase()}`;
|
|
954
|
+
const target = `${agent.toLocaleLowerCase()}\u0000${user.toLocaleLowerCase()}`;
|
|
955
|
+
const previous = new Map(before.filter(account => accountKey(account) !== target).map(account => [accountKey(account), stableJson(account)]));
|
|
956
|
+
const current = new Map(after.filter(account => accountKey(account) !== target).map(account => [accountKey(account), stableJson(account)]));
|
|
957
|
+
return previous.size === current.size && [...previous].every(([key, value]) => current.get(key) === value);
|
|
958
|
+
}
|
|
959
|
+
function providerUpdateMatches(update, saved) {
|
|
960
|
+
// A rename is read back under the new ID; every other field of the record then stands as it is.
|
|
961
|
+
if (update.newId !== undefined && saved.id !== update.newId)
|
|
962
|
+
return false;
|
|
963
|
+
if (update.name !== undefined && update.name !== saved.name)
|
|
964
|
+
return false;
|
|
965
|
+
if (update.on !== undefined && (update.on ? 'on' : 'off') !== saved.state)
|
|
966
|
+
return false;
|
|
967
|
+
if (update.endpointField !== undefined && update.endpointUrl !== undefined
|
|
968
|
+
&& saved.endpoints.find(item => item.field === update.endpointField)?.url !== update.endpointUrl)
|
|
969
|
+
return false;
|
|
970
|
+
if (update.models !== undefined && !sameRenamedModels(update.models, update.id, update.newId ?? update.id, saved.models.filter(model => model.exposed).map(model => model.id)))
|
|
971
|
+
return false;
|
|
972
|
+
return true;
|
|
973
|
+
}
|
|
974
|
+
function providerRecordMatchesUpdate(before, update, saved) {
|
|
975
|
+
if (saved.name !== (update.name ?? before.name))
|
|
976
|
+
return false;
|
|
977
|
+
if (saved.state !== (update.on === undefined ? before.state : update.on ? 'on' : 'off'))
|
|
978
|
+
return false;
|
|
979
|
+
const models = update.models ?? before.models.filter(model => model.exposed).map(model => model.id);
|
|
980
|
+
if (!sameRenamedModels(models, update.id, update.newId ?? update.id, saved.models.filter(model => model.exposed).map(model => model.id)))
|
|
981
|
+
return false;
|
|
982
|
+
return ENDPOINT_FIELDS.every(field => {
|
|
983
|
+
const expected = field === update.endpointField && update.endpointUrl !== undefined
|
|
984
|
+
? update.endpointUrl
|
|
985
|
+
: before.endpoints.find(endpoint => endpoint.field === field)?.url;
|
|
986
|
+
return expected === saved.endpoints.find(endpoint => endpoint.field === field)?.url;
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
/** Model IDs as a renamed provider now spells them: `<id>/<model>` under the ID it was given. */
|
|
990
|
+
function sameRenamedModels(expected, fromId, toId, saved) {
|
|
991
|
+
const renamed = expected.map(model => model.startsWith(`${fromId}/`) && fromId !== toId ? `${toId}/${model.slice(fromId.length + 1)}` : model);
|
|
992
|
+
return sameStringOrder(renamed, saved);
|
|
993
|
+
}
|
|
994
|
+
function groupUpdateMatches(update, saved) {
|
|
995
|
+
if (update.name !== undefined && update.name !== saved.name)
|
|
996
|
+
return false;
|
|
997
|
+
if (update.models !== undefined && !sameStringOrder(update.models, editableGroupModels(saved)))
|
|
998
|
+
return false;
|
|
999
|
+
for (const key of ['routing', 'stays']) {
|
|
1000
|
+
if (update[key] !== undefined && update[key] !== saved[key])
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
if (update.pick !== undefined && update.pick !== saved.pick)
|
|
1004
|
+
return false;
|
|
1005
|
+
if (update.unlists !== undefined && update.unlists !== saved.unlists)
|
|
1006
|
+
return false;
|
|
1007
|
+
if (update.fast !== undefined && !sameStringSet(update.fast, saved.fast))
|
|
1008
|
+
return false;
|
|
1009
|
+
if (update.context !== undefined && update.context !== saved.context)
|
|
1010
|
+
return false;
|
|
1011
|
+
if (update.levels !== undefined && !sameStringSet(update.levels, saved.levels))
|
|
1012
|
+
return false;
|
|
1013
|
+
if (update.effort !== undefined && update.effort !== saved.effort)
|
|
1014
|
+
return false;
|
|
1015
|
+
// Magpie clears the classifier itself when no turn's level is picked, so an emptied one is
|
|
1016
|
+
// compared against the group's own cleared value rather than against what was sent.
|
|
1017
|
+
if (update.classifier !== undefined && update.classifier !== (saved.classifier ?? ''))
|
|
1018
|
+
return false;
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
function sameStringSet(left, right) {
|
|
1022
|
+
return left.length === right.length && left.every(item => right.includes(item));
|
|
1023
|
+
}
|
|
1024
|
+
function editableGroupModels(group) {
|
|
1025
|
+
return [...group.members.filter(member => !member.byPattern).map(member => member.id), ...group.patterns];
|
|
1026
|
+
}
|
|
1027
|
+
function findAccount(accounts, agent, user) {
|
|
1028
|
+
return accounts.find(account => account.agent.toLocaleLowerCase() === agent.toLocaleLowerCase()
|
|
1029
|
+
&& account.user.toLocaleLowerCase() === user.toLocaleLowerCase());
|
|
1030
|
+
}
|
|
1031
|
+
function readGroupPatterns(matchValue, patternsValue) {
|
|
1032
|
+
if (matchValue !== undefined) {
|
|
1033
|
+
const values = readStringList(matchValue, 500);
|
|
1034
|
+
if (values === null)
|
|
1035
|
+
return null;
|
|
1036
|
+
return validatePatterns(values);
|
|
1037
|
+
}
|
|
1038
|
+
if (patternsValue === undefined)
|
|
1039
|
+
return [];
|
|
1040
|
+
if (!Array.isArray(patternsValue) || patternsValue.length > 500)
|
|
1041
|
+
return null;
|
|
1042
|
+
const patterns = patternsValue.flatMap(value => {
|
|
1043
|
+
if (typeof value === 'string')
|
|
1044
|
+
return [value];
|
|
1045
|
+
const record = asRecord(value);
|
|
1046
|
+
const pattern = safeText(record?.pattern, 300);
|
|
1047
|
+
return pattern === null ? [] : [pattern];
|
|
1048
|
+
});
|
|
1049
|
+
if (patterns.length !== patternsValue.length)
|
|
1050
|
+
return null;
|
|
1051
|
+
return validatePatterns(patterns);
|
|
1052
|
+
}
|
|
1053
|
+
function validatePatterns(values) {
|
|
1054
|
+
const unique = [];
|
|
1055
|
+
for (const value of values) {
|
|
1056
|
+
if (!isGroupPattern(value))
|
|
1057
|
+
return null;
|
|
1058
|
+
if (!unique.includes(value))
|
|
1059
|
+
unique.push(value);
|
|
1060
|
+
}
|
|
1061
|
+
return unique;
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Whether every pattern Magpie returned for this group is one this Host would write back.
|
|
1065
|
+
*
|
|
1066
|
+
* Patterns live in the saved `match` list and `patterns` (the model rows they match now). A
|
|
1067
|
+
* `null` `members` list, or a pattern spelling outside the allowed set, cannot be round-tripped:
|
|
1068
|
+
* a save built from the readable half would quietly remove the rest.
|
|
1069
|
+
*/
|
|
1070
|
+
function groupPatternsReadable(raw) {
|
|
1071
|
+
const match = readGroupPatterns(raw.match, raw.patterns);
|
|
1072
|
+
if (match === null)
|
|
1073
|
+
return false;
|
|
1074
|
+
const current = raw.match !== undefined
|
|
1075
|
+
? readStringList(raw.match, 500)
|
|
1076
|
+
: Array.isArray(raw.patterns)
|
|
1077
|
+
? raw.patterns.flatMap(item => {
|
|
1078
|
+
const record = asRecord(item);
|
|
1079
|
+
const pattern = safeText(record?.pattern ?? item, 300);
|
|
1080
|
+
return pattern === null ? [] : [pattern];
|
|
1081
|
+
})
|
|
1082
|
+
: [];
|
|
1083
|
+
return current !== null && current.length === match.length;
|
|
1084
|
+
}
|
|
1085
|
+
function isGroupPattern(value) {
|
|
1086
|
+
return (value.includes('*') || value.startsWith('re:'))
|
|
1087
|
+
&& /^[A-Za-z0-9_*./:+|()\[\]{}?^-]+$/u.test(value)
|
|
1088
|
+
&& value.includes('/');
|
|
1089
|
+
}
|
|
1090
|
+
function normalizeGroupId(value) {
|
|
1091
|
+
const id = typeof value === 'string' ? value : '';
|
|
1092
|
+
return id.startsWith('group/') ? id.slice('group/'.length) : id;
|
|
1093
|
+
}
|
|
1094
|
+
function safeEndpoint(value) {
|
|
1095
|
+
if (value.length > 2_048 || /[\u0000-\u001f\u007f]/u.test(value))
|
|
1096
|
+
return null;
|
|
1097
|
+
try {
|
|
1098
|
+
const url = new URL(value);
|
|
1099
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.hostname === '')
|
|
1100
|
+
return null;
|
|
1101
|
+
url.username = '';
|
|
1102
|
+
url.password = '';
|
|
1103
|
+
url.search = '';
|
|
1104
|
+
url.hash = '';
|
|
1105
|
+
return `${url.origin}${url.pathname.replace(/\/$/u, '')}`;
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
function safeWebUrl(value) {
|
|
1112
|
+
if (typeof value !== 'string' || value.length > 2_048)
|
|
1113
|
+
return null;
|
|
1114
|
+
try {
|
|
1115
|
+
const url = new URL(value);
|
|
1116
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : null;
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
function isPlainEndpoint(value) {
|
|
1123
|
+
try {
|
|
1124
|
+
const url = new URL(value);
|
|
1125
|
+
return url.username === '' && url.password === '' && url.search === '' && url.hash === '';
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
function safeHost(value) {
|
|
1132
|
+
if (value === null || value.length > 300 || /[\u0000-\u001f\u007f]/u.test(value))
|
|
1133
|
+
return null;
|
|
1134
|
+
try {
|
|
1135
|
+
const url = new URL(value.includes('://') ? value : `https://${value}`);
|
|
1136
|
+
return `${url.hostname}${url.port === '' ? '' : `:${url.port}`}`;
|
|
1137
|
+
}
|
|
1138
|
+
catch {
|
|
1139
|
+
return safeText(value, 300);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
function safeBalanceTokenStatus(value) {
|
|
1143
|
+
const record = asRecord(value);
|
|
1144
|
+
return record === null ? null : { takes: record.takes, set: record.set };
|
|
1145
|
+
}
|
|
1146
|
+
function readModelList(value, providerId, allowPatterns) {
|
|
1147
|
+
if (!Array.isArray(value) || value.length > 200)
|
|
1148
|
+
throw new TypeError('Model list must be an array of at most 200 items');
|
|
1149
|
+
const models = [];
|
|
1150
|
+
for (const item of value) {
|
|
1151
|
+
if (typeof item !== 'string' || item.length > 300 || /[\u0000-\u0020\u007f,]/u.test(item))
|
|
1152
|
+
throw new TypeError('Invalid Magpie model id');
|
|
1153
|
+
const isPattern = allowPatterns && isGroupPattern(item);
|
|
1154
|
+
if (isPattern) {
|
|
1155
|
+
if (!item.includes('/'))
|
|
1156
|
+
throw new TypeError('Invalid routing-group model pattern');
|
|
1157
|
+
}
|
|
1158
|
+
else if (!MODEL_ID.test(item) || !item.includes('/') || providerId !== null && !item.startsWith(`${providerId}/`)) {
|
|
1159
|
+
throw new TypeError('Magpie model id is outside the allowed provider');
|
|
1160
|
+
}
|
|
1161
|
+
if (!models.includes(item))
|
|
1162
|
+
models.push(item);
|
|
1163
|
+
}
|
|
1164
|
+
if (allowPatterns && models.length === 0)
|
|
1165
|
+
throw new TypeError('Routing groups need at least one model or pattern');
|
|
1166
|
+
return models;
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* A group's classifier model. Magpie clears it with an empty string whenever no rule asks for an
|
|
1170
|
+
* intent and the effort is left to the Agent, so an empty value must be writable too.
|
|
1171
|
+
*/
|
|
1172
|
+
function optionalClassifier(value) {
|
|
1173
|
+
if (value === '')
|
|
1174
|
+
return '';
|
|
1175
|
+
const member = optionalMemberId(value, 'group classifier');
|
|
1176
|
+
if (member === undefined)
|
|
1177
|
+
throw new TypeError('Invalid group classifier');
|
|
1178
|
+
return member;
|
|
1179
|
+
}
|
|
1180
|
+
/** A group member id as Magpie names it: a model, a group, or a model at a reasoning level. */
|
|
1181
|
+
function optionalMemberId(value, label) {
|
|
1182
|
+
if (value === undefined)
|
|
1183
|
+
return undefined;
|
|
1184
|
+
const text = safeText(value, 512);
|
|
1185
|
+
if (text === null || !(MODEL_ID.test(text) || text.startsWith('group/')))
|
|
1186
|
+
throw new TypeError(`Invalid ${label}`);
|
|
1187
|
+
return text;
|
|
1188
|
+
}
|
|
1189
|
+
function readMemberList(value) {
|
|
1190
|
+
if (!Array.isArray(value) || value.length > 2_000)
|
|
1191
|
+
throw new TypeError('Group member list must be an array of at most 2000 entries');
|
|
1192
|
+
const members = value.map(item => safeText(item, 512));
|
|
1193
|
+
if (members.some(item => item === null || !(MODEL_ID.test(item) || item.startsWith('group/')))) {
|
|
1194
|
+
throw new TypeError('Invalid Magpie group member');
|
|
1195
|
+
}
|
|
1196
|
+
return [...new Set(members)];
|
|
1197
|
+
}
|
|
1198
|
+
function localModelId(providerId, value) {
|
|
1199
|
+
return value.startsWith(`${providerId}/`) ? value.slice(providerId.length + 1) : value;
|
|
1200
|
+
}
|
|
1201
|
+
function providerFromModelId(value) {
|
|
1202
|
+
if (value.startsWith('group/'))
|
|
1203
|
+
return null;
|
|
1204
|
+
const separator = value.indexOf('/');
|
|
1205
|
+
return separator <= 0 ? null : value.slice(0, separator);
|
|
1206
|
+
}
|
|
1207
|
+
function modelFromMemberId(value) {
|
|
1208
|
+
return value.startsWith('group/') ? value.slice('group/'.length) : value.slice(value.indexOf('/') + 1);
|
|
1209
|
+
}
|
|
1210
|
+
function optionalUpdateText(value, maxLength, label) {
|
|
1211
|
+
if (value === undefined)
|
|
1212
|
+
return undefined;
|
|
1213
|
+
const text = safeText(value, maxLength);
|
|
1214
|
+
if (text === null)
|
|
1215
|
+
throw new TypeError(`Invalid ${label}`);
|
|
1216
|
+
return text;
|
|
1217
|
+
}
|
|
1218
|
+
function optionalUpdateBoolean(value, label) {
|
|
1219
|
+
if (value === undefined)
|
|
1220
|
+
return undefined;
|
|
1221
|
+
if (typeof value !== 'boolean')
|
|
1222
|
+
throw new TypeError(`Invalid ${label}`);
|
|
1223
|
+
return value;
|
|
1224
|
+
}
|
|
1225
|
+
function optionalUpdateUrl(value) {
|
|
1226
|
+
if (value === undefined)
|
|
1227
|
+
return undefined;
|
|
1228
|
+
if (typeof value !== 'string' || value.length > 2_048 || /[\u0000-\u0020\u007f]/u.test(value))
|
|
1229
|
+
throw new TypeError('Invalid provider endpoint URL');
|
|
1230
|
+
const safe = safeEndpoint(value);
|
|
1231
|
+
if (safe === null || safe !== value.replace(/\/$/u, ''))
|
|
1232
|
+
throw new TypeError('Provider endpoint must be an HTTP(S) URL without credentials, query, or fragment');
|
|
1233
|
+
return value.replace(/\/$/u, '');
|
|
1234
|
+
}
|
|
1235
|
+
function optionalEnum(value, allowed, label) {
|
|
1236
|
+
if (value === undefined)
|
|
1237
|
+
return undefined;
|
|
1238
|
+
if (typeof value !== 'string' || !allowed.has(value))
|
|
1239
|
+
throw new TypeError(`Invalid ${label}`);
|
|
1240
|
+
return value;
|
|
1241
|
+
}
|
|
1242
|
+
function sameStringOrder(left, right) {
|
|
1243
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
1244
|
+
}
|
|
1245
|
+
function assertAllowedKeys(record, allowed) {
|
|
1246
|
+
const permitted = new Set(allowed);
|
|
1247
|
+
if (Object.keys(record).some(key => !permitted.has(key)))
|
|
1248
|
+
throw new TypeError('Magpie management update contains an unsupported field');
|
|
1249
|
+
}
|
|
1250
|
+
function readStringList(value, limit) {
|
|
1251
|
+
if (!Array.isArray(value) || value.length > limit)
|
|
1252
|
+
return null;
|
|
1253
|
+
const items = value.map(item => safeText(item, 512));
|
|
1254
|
+
return items.some(item => item === null) ? null : items;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Accept one Magpie icon reference: a built-in icon name or a `file:` asset name.
|
|
1258
|
+
*
|
|
1259
|
+
* `/api/providers` and `/api/usage/quotas` carry the same shape; anything else is ignored so
|
|
1260
|
+
* the value can only ever reach the fixed asset routes in `magpieIconAssetPaths`.
|
|
1261
|
+
*/
|
|
1262
|
+
function optionalQuotaIcon(value) {
|
|
1263
|
+
const icon = safeText(value, 300);
|
|
1264
|
+
if (icon === null)
|
|
1265
|
+
return {};
|
|
1266
|
+
if (/^file:[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/u.test(icon))
|
|
1267
|
+
return { icon };
|
|
1268
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(icon) ? { icon } : {};
|
|
1269
|
+
}
|
|
1270
|
+
function magpieIconAssetPaths(icon) {
|
|
1271
|
+
if (/^file:[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/u.test(icon)) {
|
|
1272
|
+
return [`/api/icons/${encodeURIComponent(icon.slice('file:'.length))}`];
|
|
1273
|
+
}
|
|
1274
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(icon))
|
|
1275
|
+
return [];
|
|
1276
|
+
return [`/icons/${icon}.svg`, `/icons/${icon}.png`];
|
|
1277
|
+
}
|
|
1278
|
+
function safeJsonClone(value, depth = 0) {
|
|
1279
|
+
if (depth > 8)
|
|
1280
|
+
return undefined;
|
|
1281
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number')
|
|
1282
|
+
return value;
|
|
1283
|
+
if (typeof value === 'string')
|
|
1284
|
+
return value.length <= 8_192 ? value : undefined;
|
|
1285
|
+
if (Array.isArray(value)) {
|
|
1286
|
+
if (value.length > 2_000)
|
|
1287
|
+
return undefined;
|
|
1288
|
+
const values = value.map(item => safeJsonClone(item, depth + 1));
|
|
1289
|
+
return values.some(item => item === undefined) ? undefined : values;
|
|
1290
|
+
}
|
|
1291
|
+
const record = asRecord(value);
|
|
1292
|
+
if (record === null || Object.keys(record).length > 200)
|
|
1293
|
+
return undefined;
|
|
1294
|
+
const result = {};
|
|
1295
|
+
for (const [key, item] of Object.entries(record)) {
|
|
1296
|
+
if (key.length > 128)
|
|
1297
|
+
return undefined;
|
|
1298
|
+
const cloned = safeJsonClone(item, depth + 1);
|
|
1299
|
+
if (cloned === undefined)
|
|
1300
|
+
return undefined;
|
|
1301
|
+
result[key] = cloned;
|
|
1302
|
+
}
|
|
1303
|
+
return result;
|
|
1304
|
+
}
|
|
1305
|
+
function stableJson(value) {
|
|
1306
|
+
const normalize = (item) => {
|
|
1307
|
+
if (Array.isArray(item))
|
|
1308
|
+
return item.map(normalize);
|
|
1309
|
+
const record = asRecord(item);
|
|
1310
|
+
return record === null ? item : Object.fromEntries(Object.keys(record).sort().map(key => [key, normalize(record[key])]));
|
|
1311
|
+
};
|
|
1312
|
+
return JSON.stringify(normalize(value));
|
|
1313
|
+
}
|
|
1314
|
+
function safeText(value, maxLength) {
|
|
1315
|
+
if (typeof value !== 'string')
|
|
1316
|
+
return null;
|
|
1317
|
+
const text = value.trim();
|
|
1318
|
+
return text !== '' && text.length <= maxLength && !/[\u0000-\u001f\u007f]/u.test(text) ? text : null;
|
|
1319
|
+
}
|
|
1320
|
+
function optionalSafeText(value, maxLength) {
|
|
1321
|
+
return value === undefined || value === null ? null : safeText(value, maxLength);
|
|
1322
|
+
}
|
|
1323
|
+
function optionalBoolean(value) {
|
|
1324
|
+
return typeof value === 'boolean' ? value : null;
|
|
1325
|
+
}
|
|
1326
|
+
function readBooleanOrCount(value) {
|
|
1327
|
+
if (typeof value === 'boolean')
|
|
1328
|
+
return value;
|
|
1329
|
+
return typeof value === 'number' && Number.isFinite(value) ? value > 0 : null;
|
|
1330
|
+
}
|
|
1331
|
+
function optionalNumber(value) {
|
|
1332
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
1333
|
+
}
|
|
1334
|
+
function optionalInteger(value) {
|
|
1335
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
1336
|
+
}
|
|
1337
|
+
function signalOption(signal) {
|
|
1338
|
+
return signal === undefined ? {} : { signal };
|
|
1339
|
+
}
|
|
1340
|
+
function asRecord(value) {
|
|
1341
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
|
|
1342
|
+
}
|
|
1343
|
+
//# sourceMappingURL=magpie-management.js.map
|