pilotswarm 0.5.41 → 0.5.43
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/mcp/README.md +25 -1
- package/mcp/dist/src/auth.d.ts.map +1 -1
- package/mcp/dist/src/auth.js +5 -3
- package/mcp/dist/src/auth.js.map +1 -1
- package/mcp/dist/src/resources/models.d.ts.map +1 -1
- package/mcp/dist/src/resources/models.js +21 -14
- package/mcp/dist/src/resources/models.js.map +1 -1
- package/mcp/dist/src/server.d.ts.map +1 -1
- package/mcp/dist/src/server.js +2 -0
- package/mcp/dist/src/server.js.map +1 -1
- package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -1
- package/mcp/dist/src/tools/agent-packages.js +28 -16
- package/mcp/dist/src/tools/agent-packages.js.map +1 -1
- package/mcp/dist/src/tools/models.d.ts.map +1 -1
- package/mcp/dist/src/tools/models.js +55 -47
- package/mcp/dist/src/tools/models.js.map +1 -1
- package/mcp/dist/src/tools/providers.d.ts +18 -0
- package/mcp/dist/src/tools/providers.d.ts.map +1 -0
- package/mcp/dist/src/tools/providers.js +284 -0
- package/mcp/dist/src/tools/providers.js.map +1 -0
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +10 -1
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/mcp/dist/src/tools/system.js +2 -2
- package/mcp/dist/src/tools/system.js.map +1 -1
- package/mcp/dist/src/util/respond.d.ts.map +1 -1
- package/mcp/dist/src/util/respond.js +6 -0
- package/mcp/dist/src/util/respond.js.map +1 -1
- package/package.json +3 -3
- package/tui/README.md +8 -5
- package/tui/src/agents-cli.js +80 -78
- package/tui/src/app.js +119 -17
- package/tui/src/node-sdk-transport.js +150 -347
- package/ui/core/src/controller.js +1079 -23
- package/ui/core/src/history.js +25 -1
- package/ui/core/src/reducer.js +649 -1
- package/ui/core/src/selectors.js +906 -6
- package/ui/core/src/state.js +163 -3
- package/ui/react/src/components.js +146 -4
- package/ui/react/src/web-app.js +1942 -125
- package/web/README.md +4 -4
- package/web/api/router.js +33 -0
- package/web/dist/assets/{abnfDiagram-VRR7QNED-kpxu_PEa.js → abnfDiagram-VRR7QNED-BqKIaTz5.js} +1 -1
- package/web/dist/assets/{arc-BnVQmtB5.js → arc-DCSwKNAt.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CDynQ_6_.js → architectureDiagram-ZJ3FMSHR-B6j7Bv5B.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-BUpYaSDb.js → blockDiagram-677ZJIJ3-BT1iegLO.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-ZL8eskDB.js → c4Diagram-LMCZKHZV-DXEt-_ny.js} +1 -1
- package/web/dist/assets/channel-BLVoqfUa.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-o0Nsyp1T.js → chunk-2Q5K7J3B-B144-8I2.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-CH5CSNRP.js → chunk-32BRIVSS-CMHrtYOC.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-C9wwtPWL.js → chunk-5VM5RSS4-4r8OmbGE.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG--F9yFbhA.js → chunk-EX3LRPZG-R_j3wosN.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-BPIstyWL.js → chunk-JWPE2WC7-BKlFEYXC.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-5Dm2u-0k.js → chunk-MOJQB5TN-C2S4TPSa.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-Dbtf9iQK.js → chunk-RYQCIY6F-Y2kpzPD7.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-DrZEBJ5l.js → chunk-V7JOEXUC-kAOP_3Kc.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-AqAUynqK.js → chunk-VR4S4FIN-B2Au1EfR.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-DQlFaqCy.js → chunk-XXDRQBXY-CZfTG6DD.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-BgaYclQI.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-BgaYclQI.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-RAtPG1Ou.js → cose-bilkent-JH36ORCC--tNLpMEb.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CbeHaUWZ.js → cynefin-VYW2F7L2-BeIRFyvX.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-B6paqF5Y.js → cynefinDiagram-TSTJHNR4-Bm5lb_xz.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-DziTBO2K.js → dagre-VKFMJZFB-Cz1i_B0y.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-CPRUiLcr.js → diagram-FQU43EPY-BF0j7c1T.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-CIMVdcL1.js → diagram-G47NLZAW-C2QWJ-Y4.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-CHPFllxR.js → diagram-NH7WQ7WH-BiNeThAA.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-DwS6unBJ.js → diagram-OA4YK3LP-Cq7_37rx.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-DKWgkObI.js → diagram-WEI45ONY-Cl0GjIma.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-zAVWV6-D.js → ebnfDiagram-CCIWWBDH-C-dTpVrf.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-D0tYX4h0.js → erDiagram-Q63AITRT-RPSg6Lws.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-Cfq4I7R0.js → flowDiagram-23GEKE2U-CslnZm0D.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-wgBAHr5I.js → ganttDiagram-NO4QXBWP-dzr6nimS.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BTIPX5PR.js → gitGraphDiagram-IHSO6WYX-UpjDHGP0.js} +1 -1
- package/web/dist/assets/index-C-ujPKpy.css +1 -0
- package/web/dist/assets/index-Cf_e477H.js +24 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-CRLiQ87D.js → infoDiagram-FWYZ7A6U-R4qLdwe7.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CwzwUis1.js → ishikawaDiagram-FXEZZL3T-Cst7PjW4.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-B9pqMeFw.js → journeyDiagram-5HDEW3XC-CugDTBMp.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-CZ5Xky4p.js → kanban-definition-HUTT4EX6-D6Yf2Lgg.js} +1 -1
- package/web/dist/assets/{linear-BLZ2eH4a.js → linear-hUntimCu.js} +1 -1
- package/web/dist/assets/{mermaid.core-CPN1IVjz.js → mermaid.core-BhKlcGBI.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-MdC8d2tc.js → mindmap-definition-LN4V7U3C-DKVBRUj4.js} +1 -1
- package/web/dist/assets/{papaparse.min-CtrVJupf.js → papaparse.min-DbTWBfDP.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-B2DqAc-A.js → pegDiagram-2B236MQR-DeilQyRr.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-C3QAoXZh.js → pieDiagram-ENE6RG2P-CSVE3nUJ.js} +1 -1
- package/web/dist/assets/pilotswarm-DaQpAPI0.js +97 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-BoUr5qFH.js → quadrantDiagram-ABIIQ3AL-Di-WUa_j.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-bZzE4KFN.js → railroadDiagram-RFXS5EU6-jBlJSP-m.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-B2ENAqLS.js → requirementDiagram-TGXJPOKE-BlLuScOr.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-AJZ3h46Z.js → sankeyDiagram-HTMAVEWB-D76NurlB.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-QUf0VVE7.js → sequenceDiagram-DBY2YBRQ-BGPIUJo0.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-Cv9RcUy6.js → sizeCapture-X5ZJPWSS-DKmGUrSI.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-hDiye45t.js → stateDiagram-2N3HPSRC-DAYHO_nW.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-jZoHf_Ku.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-hSk_OF85.js → swimlanes-5IMT3BWC-CiRowk4M.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-C0ms7UKB.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-ccOZEJ5e.js → timeline-definition-FHXFAJF6-iN7kQL24.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-Bk5DLH_V.js → vennDiagram-L72KCM5P-C6TtJ36b.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-C20AX8ot.js → wardleyDiagram-EHGQE667-CBiv7EU-.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-DaHqmi-R.js → xychartDiagram-FW5EYKEG-zXUejHih.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/runtime.js +220 -2
- package/web/server.js +35 -7
- package/web/dist/assets/channel-Bi5A6sHR.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-D8dCxI54.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-D8dCxI54.js +0 -1
- package/web/dist/assets/index-OHj-gGHp.js +0 -24
- package/web/dist/assets/index-eTH1FBtN.css +0 -1
- package/web/dist/assets/pilotswarm-CF1nmpMy.js +0 -97
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Bu4ufvaU.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTFePb1Y.js +0 -8
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UI_COMMANDS, FOCUS_REGIONS, INSPECTOR_TABS, cycleValue } from "./commands.js";
|
|
2
|
-
import { canvasKey as canvasPrefKey } from "./state.js";
|
|
2
|
+
import { BUDGET_SERIES_DAYS, BUDGET_SERIES_RANGES, canvasKey as canvasPrefKey } from "./state.js";
|
|
3
3
|
import { parseAgentSourceLink } from "./repo-links.js";
|
|
4
4
|
import { importPackageFilesFromLink, readImportedPackageName } from "./repo-import.js";
|
|
5
5
|
import {
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
selectFilesView,
|
|
45
45
|
selectInspector,
|
|
46
46
|
selectOutboxOverlayLines,
|
|
47
|
+
selectAdminConsole,
|
|
47
48
|
selectSessionRows,
|
|
48
49
|
selectSelectedFileBrowserItem,
|
|
49
50
|
selectVisibleSessionRows,
|
|
@@ -211,6 +212,19 @@ function groupModelsByProvider(models = []) {
|
|
|
211
212
|
return groups;
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Where the model picker lands.
|
|
217
|
+
*
|
|
218
|
+
* Never on a model that cannot run: when the preferred one (usually the
|
|
219
|
+
* default) is blocked, land on the first usable model instead, so the very
|
|
220
|
+
* first Enter does something.
|
|
221
|
+
*/
|
|
222
|
+
function pickerSelectedIndex(items, preferredModel) {
|
|
223
|
+
let index = items.findIndex((item) => item.qualifiedName === preferredModel && !item.disabled);
|
|
224
|
+
if (index < 0) index = items.findIndex((item) => !item.disabled);
|
|
225
|
+
return index < 0 ? 0 : index;
|
|
226
|
+
}
|
|
227
|
+
|
|
214
228
|
function normalizeReasoningEfforts(values) {
|
|
215
229
|
if (!Array.isArray(values)) return [];
|
|
216
230
|
const out = [];
|
|
@@ -1370,6 +1384,93 @@ const SESSION_NAV_SETTLE_MS = 140;
|
|
|
1370
1384
|
// still expands freely while you are in the session; re-entering starts bounded.
|
|
1371
1385
|
const HISTORY_REENTRY_MAX_EVENTS = 1_500;
|
|
1372
1386
|
|
|
1387
|
+
// ── Provider budgets ────────────────────────────────────────────────
|
|
1388
|
+
//
|
|
1389
|
+
// The model: docs/proposals/providers-and-budgets.md. A provider is a name
|
|
1390
|
+
// with a credential and a budget; a session runs `provider:model` and that
|
|
1391
|
+
// provider is charged. There are no pools, no payers and no access lists —
|
|
1392
|
+
// what a person may spend is decided by QUANTITY (limits, and an allowance
|
|
1393
|
+
// that divides each limit), never by identity.
|
|
1394
|
+
//
|
|
1395
|
+
// The surface reads this out of `state.budget` through
|
|
1396
|
+
// `selectProviderTable`. The one helper here is for the model PICKER, which
|
|
1397
|
+
// cannot: it reads the namespace fresh when it opens, because a provider
|
|
1398
|
+
// created a minute ago has to be offerable without a page load.
|
|
1399
|
+
|
|
1400
|
+
const PICKER_PERIOD_TITLE = { day: "Daily", week: "Weekly", month: "Monthly" };
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* The message the server wrote, unchanged.
|
|
1404
|
+
*
|
|
1405
|
+
* A provider refusal is written for the person who hit it and names the
|
|
1406
|
+
* remedy — "the cluster default must be a shared provider", "there is no
|
|
1407
|
+
* provider named carol-ghcp". Replacing it with a generic sentence throws
|
|
1408
|
+
* away the only part of the answer anyone can act on. Only a failure with
|
|
1409
|
+
* nothing to say at all gets a fallback.
|
|
1410
|
+
*/
|
|
1411
|
+
function budgetRefusalMessage(error) {
|
|
1412
|
+
const message = typeof error?.message === "string" ? error.message.trim() : "";
|
|
1413
|
+
if (message) return message;
|
|
1414
|
+
const raw = String(error ?? "").trim();
|
|
1415
|
+
return raw || "The request failed.";
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* What the model picker prints beside a provider, so the choice is made
|
|
1420
|
+
* knowing. The state names are the ones `selectProviderTable` uses, so a
|
|
1421
|
+
* component can style both from one set of classes.
|
|
1422
|
+
*
|
|
1423
|
+
* A provider with no STATUS row gets "unknown", never a confident "no
|
|
1424
|
+
* limits": one is a claim about the budget and the other is an admission
|
|
1425
|
+
* that it was not read, and only one of them is safe to act on.
|
|
1426
|
+
*/
|
|
1427
|
+
function providerPickerNote(provider, statusRow, now = Date.now()) {
|
|
1428
|
+
if (provider?.hasCredential === false) {
|
|
1429
|
+
return { state: "no_credential", text: "no credential — nothing can run on it" };
|
|
1430
|
+
}
|
|
1431
|
+
const held = provider?.holdIndefinite === true
|
|
1432
|
+
|| (provider?.holdUntilUtc ? Date.parse(provider.holdUntilUtc) > now : false);
|
|
1433
|
+
if (held) {
|
|
1434
|
+
return { state: "hold", text: "on hold — new turns wait until it is released" };
|
|
1435
|
+
}
|
|
1436
|
+
if (!statusRow) return { state: "unknown", text: "budget unknown" };
|
|
1437
|
+
const rules = Array.isArray(statusRow.rules) ? statusRow.rules : [];
|
|
1438
|
+
if (rules.length === 0) return { state: "no_limits", text: "no limit" };
|
|
1439
|
+
// The binding one: what is already stopping turns, else what is nearest
|
|
1440
|
+
// to stopping them. Ranking by percentage alone put a comfortable weekly
|
|
1441
|
+
// limit above a daily one that was already full.
|
|
1442
|
+
const scored = rules.map((rule) => {
|
|
1443
|
+
const limitTokens = Number(rule?.limitTokens) || 0;
|
|
1444
|
+
const usedTokens = Number(rule?.usedTokens) || 0;
|
|
1445
|
+
const ceilingTokens = rule?.ceilingTokens == null ? null : Number(rule.ceilingTokens);
|
|
1446
|
+
const yourUsedTokens = Number(rule?.yourUsedTokens) || 0;
|
|
1447
|
+
return {
|
|
1448
|
+
periodLabel: PICKER_PERIOD_TITLE[rule?.period] || String(rule?.period || ""),
|
|
1449
|
+
pct: limitTokens > 0 ? Math.floor((usedTokens / limitTokens) * 100) : 0,
|
|
1450
|
+
atLimit: limitTokens > 0 && usedTokens >= limitTokens,
|
|
1451
|
+
atCeiling: ceilingTokens != null && ceilingTokens > 0 && yourUsedTokens >= ceilingTokens,
|
|
1452
|
+
};
|
|
1453
|
+
});
|
|
1454
|
+
const worst = [...scored].sort((a, b) => (
|
|
1455
|
+
((a.atLimit || a.atCeiling) ? 0 : 1) - ((b.atLimit || b.atCeiling) ? 0 : 1) || b.pct - a.pct
|
|
1456
|
+
))[0];
|
|
1457
|
+
if (worst.atLimit) {
|
|
1458
|
+
return {
|
|
1459
|
+
state: "at_limit",
|
|
1460
|
+
text: `at its ${worst.periodLabel.toLowerCase()} limit — a session here waits for the reset`,
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
// The provider still has room; this person does not. Different remedy,
|
|
1464
|
+
// so different words: raising the limit changes nothing here.
|
|
1465
|
+
if (worst.atCeiling) {
|
|
1466
|
+
return {
|
|
1467
|
+
state: "at_limit",
|
|
1468
|
+
text: `your share of its ${worst.periodLabel.toLowerCase()} limit is used up`,
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
return { state: "under_limit", text: `${worst.pct}% of its ${worst.periodLabel.toLowerCase()} limit used` };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1373
1474
|
export class PilotSwarmUiController {
|
|
1374
1475
|
constructor({ store, transport }) {
|
|
1375
1476
|
this.store = store;
|
|
@@ -2443,6 +2544,10 @@ export class PilotSwarmUiController {
|
|
|
2443
2544
|
}
|
|
2444
2545
|
if (canvasSeed.length) this.dispatch({ type: "canvas/seed", entries: canvasSeed });
|
|
2445
2546
|
this.dispatch({ type: "sessions/loaded", sessions });
|
|
2547
|
+
// A row that says only "waiting" says nothing anyone can act on. Which
|
|
2548
|
+
// limit, whose allowance, which hold, which missing name — that lives
|
|
2549
|
+
// in the provider reads, so take them while the list holds a wait.
|
|
2550
|
+
this.refreshBudgetPausesIfStale(sessions).catch(() => {});
|
|
2446
2551
|
this.refreshOpenSessionOwnerFilterModal();
|
|
2447
2552
|
const selected = this.getState().sessions.activeSessionId;
|
|
2448
2553
|
const syncedIds = new Set();
|
|
@@ -2940,6 +3045,7 @@ export class PilotSwarmUiController {
|
|
|
2940
3045
|
async openAdminConsole() {
|
|
2941
3046
|
this.dispatch({ type: "admin/visibility", visible: true });
|
|
2942
3047
|
await this.refreshAdminProfile().catch(() => {});
|
|
3048
|
+
void this.refreshAdminModelProviders().catch(() => {});
|
|
2943
3049
|
void this.refreshAdminAgentPackages().catch(() => {});
|
|
2944
3050
|
}
|
|
2945
3051
|
|
|
@@ -3010,10 +3116,328 @@ export class PilotSwarmUiController {
|
|
|
3010
3116
|
this.dispatch({ type: "admin/ghcpKey/setSystemTarget", value: Boolean(value) });
|
|
3011
3117
|
}
|
|
3012
3118
|
|
|
3119
|
+
async refreshAdminModelProviders() {
|
|
3120
|
+
const required = ["listProviders", "getModelDefaults"];
|
|
3121
|
+
if (required.some((name) => typeof this.transport[name] !== "function")) {
|
|
3122
|
+
this.dispatch({ type: "admin/modelProviders/loadFailed", error: "Model provider management is not available on this transport." });
|
|
3123
|
+
return null;
|
|
3124
|
+
}
|
|
3125
|
+
this.dispatch({ type: "admin/modelProviders/loading" });
|
|
3126
|
+
try {
|
|
3127
|
+
const [providerResult, modelResult, defaults] = await Promise.all([
|
|
3128
|
+
this.transport.listProviders(),
|
|
3129
|
+
typeof this.transport.getModelsByProvider === "function"
|
|
3130
|
+
? this.transport.getModelsByProvider()
|
|
3131
|
+
: this.transport.listModels(),
|
|
3132
|
+
this.transport.getModelDefaults(),
|
|
3133
|
+
]);
|
|
3134
|
+
const models = Array.isArray(modelResult)
|
|
3135
|
+
&& modelResult.some((entry) => Array.isArray(entry?.models))
|
|
3136
|
+
? modelResult.flatMap((group) => (group.models || []).map((model) => ({
|
|
3137
|
+
...model,
|
|
3138
|
+
providerId: group.providerId,
|
|
3139
|
+
providerType: group.type || model.providerType,
|
|
3140
|
+
})))
|
|
3141
|
+
: modelResult;
|
|
3142
|
+
const providers = Array.isArray(providerResult)
|
|
3143
|
+
? providerResult
|
|
3144
|
+
: (Array.isArray(providerResult?.providers) ? providerResult.providers : []);
|
|
3145
|
+
this.dispatch({
|
|
3146
|
+
type: "admin/modelProviders/loaded",
|
|
3147
|
+
providers,
|
|
3148
|
+
models: Array.isArray(models) ? models : [],
|
|
3149
|
+
defaults,
|
|
3150
|
+
});
|
|
3151
|
+
return { providers, models, defaults };
|
|
3152
|
+
} catch (error) {
|
|
3153
|
+
this.dispatch({ type: "admin/modelProviders/loadFailed", error: error?.message || String(error) });
|
|
3154
|
+
return null;
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
async _runAdminModelProviderMutation(pending, run) {
|
|
3159
|
+
this.dispatch({ type: "admin/modelProviders/mutationPending", pending });
|
|
3160
|
+
try {
|
|
3161
|
+
const result = await run();
|
|
3162
|
+
this.dispatch({ type: "admin/modelProviders/mutationDone" });
|
|
3163
|
+
await this.refreshAdminModelProviders();
|
|
3164
|
+
return { ok: true, result: result ?? null, error: null };
|
|
3165
|
+
} catch (error) {
|
|
3166
|
+
const message = error?.message || String(error);
|
|
3167
|
+
this.dispatch({ type: "admin/modelProviders/mutationFailed", error: message });
|
|
3168
|
+
return { ok: false, result: null, error: message };
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
async createAdminProvider({ name, type, credentials, baseUrl = null, shared = false } = {}) {
|
|
3173
|
+
const op = shared ? "createProvider" : "createMyProvider";
|
|
3174
|
+
if (typeof this.transport[op] !== "function") {
|
|
3175
|
+
this.dispatch({ type: "admin/modelProviders/mutationFailed", error: "Provider creation is not available on this transport." });
|
|
3176
|
+
return { ok: false, result: null, error: "Provider creation is not available on this transport." };
|
|
3177
|
+
}
|
|
3178
|
+
return this._runAdminModelProviderMutation("create", () => this.transport[op]({
|
|
3179
|
+
name, type, credentials, baseUrl,
|
|
3180
|
+
}));
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
async deleteAdminProvider(name, { shared = false } = {}) {
|
|
3184
|
+
const op = shared ? "deleteProvider" : "deleteMyProvider";
|
|
3185
|
+
if (typeof this.transport[op] !== "function") {
|
|
3186
|
+
return { ok: false, result: null, error: "Provider deletion is not available on this transport." };
|
|
3187
|
+
}
|
|
3188
|
+
return this._runAdminModelProviderMutation("delete", () => this.transport[op](name));
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
async setAdminProviderSystemUse(provider, enabled) {
|
|
3192
|
+
if (typeof this.transport.setProviderSystemUse !== "function") {
|
|
3193
|
+
return { ok: false, result: null, error: "System-use controls are not available on this transport." };
|
|
3194
|
+
}
|
|
3195
|
+
return this._runAdminModelProviderMutation("systemUse", () => this.transport.setProviderSystemUse({
|
|
3196
|
+
provider, enabled: enabled === true,
|
|
3197
|
+
}));
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
async setAdminModelDefault(scope, choice = null) {
|
|
3201
|
+
if (typeof this.transport.setModelDefault !== "function") {
|
|
3202
|
+
return { ok: false, result: null, error: "Model defaults are not available on this transport." };
|
|
3203
|
+
}
|
|
3204
|
+
return this._runAdminModelProviderMutation(`${scope}Default`, () => this.transport.setModelDefault({
|
|
3205
|
+
scope,
|
|
3206
|
+
provider: choice?.provider ?? null,
|
|
3207
|
+
model: choice?.qualifiedName ?? choice?.model ?? null,
|
|
3208
|
+
reasoningEffort: choice?.reasoningEffort ?? choice?.defaultReasoningEffort ?? null,
|
|
3209
|
+
contextTier: choice?.contextTier ?? choice?.defaultContextTier ?? null,
|
|
3210
|
+
}));
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
async setAdminSystemModelDefault(choice = null, { restartDisposition = null } = {}) {
|
|
3214
|
+
if (typeof this.transport.setSystemModelDefault !== "function") {
|
|
3215
|
+
return { ok: false, result: null, error: "The system model default is not available on this transport." };
|
|
3216
|
+
}
|
|
3217
|
+
return this._runAdminModelProviderMutation("systemDefault", () => this.transport.setSystemModelDefault({
|
|
3218
|
+
provider: choice?.provider ?? null,
|
|
3219
|
+
model: choice?.qualifiedName ?? choice?.model ?? null,
|
|
3220
|
+
reasoningEffort: choice?.reasoningEffort ?? choice?.defaultReasoningEffort ?? null,
|
|
3221
|
+
contextTier: choice?.contextTier ?? choice?.defaultContextTier ?? null,
|
|
3222
|
+
restartExisting: restartDisposition ? { disposition: restartDisposition } : false,
|
|
3223
|
+
}));
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
async setAdminSystemAgentModel(agentId, choice) {
|
|
3227
|
+
if (typeof this.transport.setSystemSessionModel !== "function") {
|
|
3228
|
+
return { ok: false, result: null, error: "System-agent overrides are not available on this transport." };
|
|
3229
|
+
}
|
|
3230
|
+
return this._runAdminModelProviderMutation("systemOverride", () => this.transport.setSystemSessionModel({
|
|
3231
|
+
agentId,
|
|
3232
|
+
provider: choice?.provider,
|
|
3233
|
+
model: choice?.qualifiedName ?? choice?.model,
|
|
3234
|
+
reasoningEffort: choice?.reasoningEffort ?? choice?.defaultReasoningEffort ?? null,
|
|
3235
|
+
contextTier: choice?.contextTier ?? choice?.defaultContextTier ?? null,
|
|
3236
|
+
}));
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
async clearAdminSystemAgentModel(agentId) {
|
|
3240
|
+
if (typeof this.transport.clearSystemSessionModel !== "function") {
|
|
3241
|
+
return { ok: false, result: null, error: "System-agent overrides are not available on this transport." };
|
|
3242
|
+
}
|
|
3243
|
+
return this._runAdminModelProviderMutation("systemOverride", () => this.transport.clearSystemSessionModel(agentId));
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
beginAdminCreateProvider({ shared = false } = {}) {
|
|
3247
|
+
const providers = this.getState().admin?.modelProviders || {};
|
|
3248
|
+
const typeId = (providers.models || []).find((model) => /github/i.test(model?.providerId || ""))?.providerId
|
|
3249
|
+
|| providers.models?.[0]?.providerId
|
|
3250
|
+
|| "github-copilot";
|
|
3251
|
+
const taken = new Set((providers.providers || []).map((provider) => String(provider?.name || "").toLowerCase()));
|
|
3252
|
+
const subject = String(this.getState().admin?.profile?.subject || "").replace(/[^A-Za-z0-9]/gu, "").slice(-10).toLowerCase();
|
|
3253
|
+
const base = subject ? `ghcp-${subject}` : "my-ghcp";
|
|
3254
|
+
let name = base;
|
|
3255
|
+
let suffix = 2;
|
|
3256
|
+
while (taken.has(name.toLowerCase())) {
|
|
3257
|
+
name = `${base}-${suffix}`;
|
|
3258
|
+
suffix += 1;
|
|
3259
|
+
}
|
|
3260
|
+
this.dispatch({ type: "admin/modelProviders/createBegin", name, typeId, shared });
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
beginAdminCreateGithubProvider() {
|
|
3264
|
+
this.beginAdminCreateProvider({ shared: false });
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
cycleAdminProviderCreateType() {
|
|
3268
|
+
const providers = selectAdminConsole(this.getState()).modelProviders;
|
|
3269
|
+
const create = this.getState().admin?.modelProviders?.create;
|
|
3270
|
+
if (!create?.editing || create.saving || create.stage !== "name") return;
|
|
3271
|
+
const types = providers.providerTypes || [];
|
|
3272
|
+
if (!types.length) return;
|
|
3273
|
+
const index = Math.max(0, types.findIndex((type) => type.id === create.typeId));
|
|
3274
|
+
this.dispatch({ type: "admin/modelProviders/createType", typeId: types[(index + 1) % types.length].id });
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
setAdminProviderCreateDraft(draft, cursorIndex = String(draft || "").length) {
|
|
3278
|
+
this.dispatch({ type: "admin/modelProviders/createDraft", draft, cursorIndex });
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
insertAdminProviderCreateText(text) {
|
|
3282
|
+
const create = this.getState().admin?.modelProviders?.create;
|
|
3283
|
+
if (!create?.editing || create.saving) return;
|
|
3284
|
+
const sanitized = String(text || "").replace(/\r?\n/gu, "");
|
|
3285
|
+
const next = insertPromptTextAtCursor(create.draft || "", create.cursorIndex || 0, sanitized);
|
|
3286
|
+
this.setAdminProviderCreateDraft(next.prompt, next.cursor);
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
deleteAdminProviderCreateChar() {
|
|
3290
|
+
const create = this.getState().admin?.modelProviders?.create;
|
|
3291
|
+
if (!create?.editing || create.saving) return;
|
|
3292
|
+
const next = deletePromptCharBackward(create.draft || "", create.cursorIndex || 0);
|
|
3293
|
+
this.setAdminProviderCreateDraft(next.prompt, next.cursor);
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
moveAdminProviderCreateCursor(delta) {
|
|
3297
|
+
const create = this.getState().admin?.modelProviders?.create;
|
|
3298
|
+
if (!create?.editing || create.saving) return;
|
|
3299
|
+
const draft = create.draft || "";
|
|
3300
|
+
this.setAdminProviderCreateDraft(draft, clampPromptCursor(draft, (create.cursorIndex || 0) + delta));
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
moveAdminProviderCreateCursorToBoundary(kind) {
|
|
3304
|
+
const create = this.getState().admin?.modelProviders?.create;
|
|
3305
|
+
if (!create?.editing || create.saving) return;
|
|
3306
|
+
const draft = create.draft || "";
|
|
3307
|
+
this.setAdminProviderCreateDraft(draft, kind === "start" ? 0 : draft.length);
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
advanceAdminProviderCreate() {
|
|
3311
|
+
const create = this.getState().admin?.modelProviders?.create;
|
|
3312
|
+
if (!create?.editing || create.saving || create.stage !== "name") return;
|
|
3313
|
+
const name = String(create.draft || "").trim();
|
|
3314
|
+
if (!name || name.length > 64 || !/^[A-Za-z0-9._-]+$/u.test(name)) {
|
|
3315
|
+
this.dispatch({ type: "admin/modelProviders/createFailed", error: "Use 1-64 letters, numbers, dots, dashes, or underscores for the provider name." });
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
this.dispatch({ type: "admin/modelProviders/createCredential", name });
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
async saveAdminProviderCreate() {
|
|
3322
|
+
const create = this.getState().admin?.modelProviders?.create;
|
|
3323
|
+
if (!create?.editing || create.saving || create.stage !== "credential") return;
|
|
3324
|
+
const credential = String(create.draft || "").trim();
|
|
3325
|
+
if (!credential) {
|
|
3326
|
+
this.dispatch({ type: "admin/modelProviders/createFailed", error: "Enter a provider credential." });
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
const input = {
|
|
3330
|
+
name: create.name,
|
|
3331
|
+
type: create.typeId,
|
|
3332
|
+
credentials: /github/i.test(create.typeId) ? { githubToken: credential } : { apiKey: credential },
|
|
3333
|
+
shared: create.shared === true,
|
|
3334
|
+
};
|
|
3335
|
+
// Clear the credential from shared state before any network await.
|
|
3336
|
+
this.dispatch({ type: "admin/modelProviders/createSaving" });
|
|
3337
|
+
const outcome = await this.createAdminProvider(input);
|
|
3338
|
+
this.dispatch(outcome?.ok
|
|
3339
|
+
? { type: "admin/modelProviders/createEnd" }
|
|
3340
|
+
: { type: "admin/modelProviders/createFailed", error: outcome?.error });
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
cancelAdminProviderCreate() {
|
|
3344
|
+
this.dispatch({ type: "admin/modelProviders/createEnd" });
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
setAdminModelProviderSelection({ focus, providerName, agentId } = {}) {
|
|
3348
|
+
this.dispatch({ type: "admin/modelProviders/select", focus, providerName, agentId });
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
setAdminModelProviderPage(page) {
|
|
3352
|
+
if (page === "shared" && !this.getState().admin?.profile?.isAdmin) return;
|
|
3353
|
+
this.dispatch({ type: "admin/modelProviders/page", page });
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
stepAdminModelProviderSelection(delta) {
|
|
3357
|
+
const view = selectAdminConsole(this.getState()).modelProviders;
|
|
3358
|
+
const focus = view.selection?.focus === "agents" ? "agents" : "providers";
|
|
3359
|
+
const rows = focus === "agents"
|
|
3360
|
+
? view.systemAgentRoutes
|
|
3361
|
+
: (view.page === "shared" ? view.sharedProviders : view.myProviders);
|
|
3362
|
+
if (!rows.length) return;
|
|
3363
|
+
const selectedId = focus === "agents" ? view.selection?.agentId : view.selection?.providerName;
|
|
3364
|
+
let index = rows.findIndex((row) => (focus === "agents" ? row.agentId : row.name) === selectedId);
|
|
3365
|
+
if (index < 0) index = 0;
|
|
3366
|
+
const next = rows[(index + delta + rows.length) % rows.length];
|
|
3367
|
+
this.setAdminModelProviderSelection(focus === "agents"
|
|
3368
|
+
? { focus, agentId: next.agentId }
|
|
3369
|
+
: { focus, providerName: next.name });
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
async toggleSelectedAdminProviderSystemUse() {
|
|
3373
|
+
const view = selectAdminConsole(this.getState()).modelProviders;
|
|
3374
|
+
const provider = view.myProviders.find((row) => row.name === view.selection?.providerName);
|
|
3375
|
+
if (!provider || !this.getState().admin?.profile?.isAdmin) return;
|
|
3376
|
+
return this.setAdminProviderSystemUse(provider.name, !provider.systemUseEnabled);
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
async deleteSelectedAdminProvider() {
|
|
3380
|
+
const providers = selectAdminConsole(this.getState()).modelProviders;
|
|
3381
|
+
const selected = [...providers.myProviders, ...providers.sharedProviders]
|
|
3382
|
+
.find((provider) => provider.name === providers.selection?.providerName);
|
|
3383
|
+
if (!selected) return;
|
|
3384
|
+
return this.deleteAdminProvider(selected.name, { shared: selected.class === "shared" });
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
requestDeleteSelectedAdminProvider() {
|
|
3388
|
+
const providers = selectAdminConsole(this.getState()).modelProviders;
|
|
3389
|
+
const selected = [...providers.myProviders, ...providers.sharedProviders]
|
|
3390
|
+
.find((provider) => provider.name === providers.selection?.providerName);
|
|
3391
|
+
if (!selected) return;
|
|
3392
|
+
this.dispatch({
|
|
3393
|
+
type: "ui/modal",
|
|
3394
|
+
modal: {
|
|
3395
|
+
type: "confirm",
|
|
3396
|
+
title: "Delete Model Provider",
|
|
3397
|
+
message: `Delete ${selected.name}? Sessions using it will wait until that provider name exists again.`,
|
|
3398
|
+
confirmLabel: "Delete Provider",
|
|
3399
|
+
action: "deleteAdminProvider",
|
|
3400
|
+
previousFocus: FOCUS_REGIONS.ADMIN,
|
|
3401
|
+
},
|
|
3402
|
+
});
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
async cycleAdminModelDefault(scope, { restartDisposition = null } = {}) {
|
|
3406
|
+
const providers = selectAdminConsole(this.getState()).modelProviders;
|
|
3407
|
+
const choices = scope === "user" ? providers.userChoices
|
|
3408
|
+
: scope === "cluster" ? providers.clusterChoices : providers.systemChoices;
|
|
3409
|
+
const current = scope === "user" ? providers.mySessionDefault?.configured?.model
|
|
3410
|
+
: scope === "cluster" ? providers.clusterSessionDefault?.configured?.model
|
|
3411
|
+
: providers.systemSessionDefault?.configured?.model;
|
|
3412
|
+
const values = [null, ...choices];
|
|
3413
|
+
const index = Math.max(0, values.findIndex((choice) => choice?.qualifiedName === current));
|
|
3414
|
+
const next = values[(index + 1) % values.length];
|
|
3415
|
+
if (scope === "system") return this.setAdminSystemModelDefault(next, { restartDisposition });
|
|
3416
|
+
return this.setAdminModelDefault(scope, next);
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
async cycleSelectedAdminSystemAgentOverride() {
|
|
3420
|
+
const providers = selectAdminConsole(this.getState()).modelProviders;
|
|
3421
|
+
const route = providers.systemAgentRoutes.find((row) => row.agentId === providers.selection?.agentId)
|
|
3422
|
+
|| providers.systemAgentRoutes[0];
|
|
3423
|
+
if (!route) return;
|
|
3424
|
+
const values = [null, ...providers.systemChoices];
|
|
3425
|
+
const current = route.override?.model || null;
|
|
3426
|
+
const index = Math.max(0, values.findIndex((choice) => choice?.qualifiedName === current));
|
|
3427
|
+
const next = values[(index + 1) % values.length];
|
|
3428
|
+
return next
|
|
3429
|
+
? this.setAdminSystemAgentModel(route.agentId, next)
|
|
3430
|
+
: this.clearAdminSystemAgentModel(route.agentId);
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3013
3433
|
// ── Agent packages (Admin → Agents) ──────────────────────────
|
|
3014
3434
|
|
|
3015
3435
|
setAdminSection(section) {
|
|
3016
3436
|
this.dispatch({ type: "admin/section", section });
|
|
3437
|
+
if (section === "providers") {
|
|
3438
|
+
const providers = this.getState().admin?.modelProviders;
|
|
3439
|
+
if (!providers?.fetchedAt) void this.refreshAdminModelProviders().catch(() => {});
|
|
3440
|
+
}
|
|
3017
3441
|
if (section === "packages") {
|
|
3018
3442
|
const pkgs = this.getState().admin?.packages;
|
|
3019
3443
|
if (!pkgs?.fetchedAt) void this.refreshAdminAgentPackages().catch(() => {});
|
|
@@ -3217,6 +3641,7 @@ export class PilotSwarmUiController {
|
|
|
3217
3641
|
case "republish":
|
|
3218
3642
|
await this.transport.republishAgentPackageVersion(
|
|
3219
3643
|
name, arg, selector?.scope === "shared" ? "user" : "shared",
|
|
3644
|
+
selector,
|
|
3220
3645
|
);
|
|
3221
3646
|
break;
|
|
3222
3647
|
case "pin":
|
|
@@ -3471,6 +3896,468 @@ export class PilotSwarmUiController {
|
|
|
3471
3896
|
}
|
|
3472
3897
|
}
|
|
3473
3898
|
|
|
3899
|
+
// ─── Providers & Budgets (the coin button) ─────────────────
|
|
3900
|
+
//
|
|
3901
|
+
// docs/proposals/providers-and-budgets-meters.md is the build spec: one
|
|
3902
|
+
// table over one read, plus the day-by-day chart under the row someone
|
|
3903
|
+
// selected. Nothing here asks whether the viewer may do a thing. Every
|
|
3904
|
+
// call carries the signed-in person down to the `cms_provider_*`
|
|
3905
|
+
// procedures, the DATABASE refuses what they may not do, and it writes the
|
|
3906
|
+
// refusal in words meant for them — which are passed straight through,
|
|
3907
|
+
// because they name the remedy and nothing this file could invent would.
|
|
3908
|
+
|
|
3909
|
+
/**
|
|
3910
|
+
* Open the surface and load it.
|
|
3911
|
+
*
|
|
3912
|
+
* `provider` lets one call land on the provider that stopped something —
|
|
3913
|
+
* the paused line's link, where the sentence is above the table and the
|
|
3914
|
+
* row it names is in it.
|
|
3915
|
+
*/
|
|
3916
|
+
async openBudget({ provider = undefined } = {}) {
|
|
3917
|
+
this.dispatch({
|
|
3918
|
+
type: "ui/budgetOpen",
|
|
3919
|
+
open: true,
|
|
3920
|
+
...(provider === undefined ? {} : { provider }),
|
|
3921
|
+
});
|
|
3922
|
+
await this.loadProviderTable();
|
|
3923
|
+
// A provider opened AT a name shows that name's chart straight away.
|
|
3924
|
+
const opened = this.getState().budget || {};
|
|
3925
|
+
if (opened.selectedProvider) {
|
|
3926
|
+
await Promise.all([
|
|
3927
|
+
this.loadProviderSeries(opened.selectedProvider, opened.selectedScope || "*"),
|
|
3928
|
+
this.loadProviderSystemUsage(opened.selectedProvider, opened.selectedScope || "*"),
|
|
3929
|
+
]);
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
/** Close the surface and return to the standard workspace. */
|
|
3934
|
+
closeBudget() {
|
|
3935
|
+
this.dispatch({ type: "ui/budgetOpen", open: false });
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
/**
|
|
3939
|
+
* The "show overall usage" tick.
|
|
3940
|
+
*
|
|
3941
|
+
* Both pairs of numbers are already in the grid, so this changes what the
|
|
3942
|
+
* cells print and costs no request. Omit `overall` to flip it.
|
|
3943
|
+
*/
|
|
3944
|
+
setBudgetOverall(overall) {
|
|
3945
|
+
const before = this.getState().budget?.overall === true;
|
|
3946
|
+
this.dispatch({ type: "budget/overall", ...(typeof overall === "boolean" ? { overall } : {}) });
|
|
3947
|
+
const budget = this.getState().budget || {};
|
|
3948
|
+
// The cells already hold both pairs, so the TABLE costs no request.
|
|
3949
|
+
// The chart does: its bars are one scope or the other, and leaving
|
|
3950
|
+
// the old ones up relabels one person's spend as everyone's.
|
|
3951
|
+
if (budget.overall === before || !budget.selectedProvider) return undefined;
|
|
3952
|
+
return this.loadProviderSeries(budget.selectedProvider, budget.selectedScope || "*");
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
async setBudgetRangeDays(days) {
|
|
3956
|
+
const rangeDays = Number(days);
|
|
3957
|
+
if (!BUDGET_SERIES_RANGES.includes(rangeDays)) return;
|
|
3958
|
+
const before = this.getState().budget?.rangeDays ?? BUDGET_SERIES_DAYS;
|
|
3959
|
+
this.dispatch({ type: "budget/rangeDays", rangeDays });
|
|
3960
|
+
const budget = this.getState().budget || {};
|
|
3961
|
+
if (rangeDays === before || !budget.selectedProvider) return;
|
|
3962
|
+
await Promise.all([
|
|
3963
|
+
this.loadProviderSeries(budget.selectedProvider, budget.selectedScope || "*"),
|
|
3964
|
+
this.loadProviderSystemUsage(budget.selectedProvider, budget.selectedScope || "*"),
|
|
3965
|
+
]);
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
/**
|
|
3969
|
+
* Select a row: a provider, or one of its per-model limits.
|
|
3970
|
+
*
|
|
3971
|
+
* `scope` is '*' for the provider itself, or a qualified model reference
|
|
3972
|
+
* for a model row — and the chart follows it, so standing on a model row
|
|
3973
|
+
* shows that model's days rather than the whole provider's. Selecting the
|
|
3974
|
+
* row you are already on clears it: a provider collapses, a model row
|
|
3975
|
+
* falls back to the provider above it.
|
|
3976
|
+
*
|
|
3977
|
+
* The model rows are already in state — expanding shows rows the table
|
|
3978
|
+
* has. Only the chart is a read.
|
|
3979
|
+
*/
|
|
3980
|
+
async selectBudgetProvider(provider, scope = "*") {
|
|
3981
|
+
this.dispatch({ type: "budget/selectProvider", provider, scope });
|
|
3982
|
+
const budget = this.getState().budget || {};
|
|
3983
|
+
if (!budget.selectedProvider) return;
|
|
3984
|
+
await Promise.all([
|
|
3985
|
+
this.loadProviderSeries(budget.selectedProvider, budget.selectedScope || "*"),
|
|
3986
|
+
this.loadProviderSystemUsage(budget.selectedProvider, budget.selectedScope || "*"),
|
|
3987
|
+
]);
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
/**
|
|
3991
|
+
* The table: every provider in the viewer's namespace with its used and
|
|
3992
|
+
* quota figures for all three periods, plus what is waiting.
|
|
3993
|
+
*
|
|
3994
|
+
* Two reads, not five. The grid is one call by design — the numbers in a
|
|
3995
|
+
* row and the numbers in the row under it come from the same statement, so
|
|
3996
|
+
* they cannot be a refresh apart. The paused list is separate because it
|
|
3997
|
+
* also feeds the session rows, which say why a session stopped whether or
|
|
3998
|
+
* not this surface is open.
|
|
3999
|
+
*
|
|
4000
|
+
* A read that FAILS is recorded as a failure, never as empty data. The two
|
|
4001
|
+
* look identical on screen if you let them — "this provider has no limits"
|
|
4002
|
+
* and "we could not find out what its limits are" — and only one of them
|
|
4003
|
+
* is safe to act on. Whatever was already on screen stays there
|
|
4004
|
+
* underneath, marked old, rather than being blanked.
|
|
4005
|
+
*/
|
|
4006
|
+
async loadProviderTable() {
|
|
4007
|
+
if (!this._budgetReadable()) return;
|
|
4008
|
+
this.dispatch({ type: "budget/loading" });
|
|
4009
|
+
const [grid, paused] = await Promise.all([
|
|
4010
|
+
this._readBudgetPart(() => this.transport.getProviderUsageGrid()),
|
|
4011
|
+
this._readBudgetPart(() => this.transport.listPausedSessions()),
|
|
4012
|
+
]);
|
|
4013
|
+
this._settleBudgetRead([
|
|
4014
|
+
["grid", "the provider table", grid, (v) => v?.rows || []],
|
|
4015
|
+
["paused", "the paused sessions", paused, (v) => v?.sessions || []],
|
|
4016
|
+
]);
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
/**
|
|
4020
|
+
* The day-by-day chart under one provider.
|
|
4021
|
+
*
|
|
4022
|
+
* Kept apart from the table's read so a failed chart never blanks the
|
|
4023
|
+
* numbers above it, and so selecting a row does not re-read every
|
|
4024
|
+
* provider's meters.
|
|
4025
|
+
*/
|
|
4026
|
+
/**
|
|
4027
|
+
* Re-read the table AND the chart under it.
|
|
4028
|
+
*
|
|
4029
|
+
* They are two requests, and re-reading only the first left the chart
|
|
4030
|
+
* saying "No token usage" beside a row reporting usage — which is the
|
|
4031
|
+
* no-data-versus-not-looked-again confusion this screen exists to
|
|
4032
|
+
* prevent. Both the Refresh button and the background poll come here.
|
|
4033
|
+
*/
|
|
4034
|
+
async refreshProviderTable() {
|
|
4035
|
+
await this.loadProviderTable();
|
|
4036
|
+
const budget = this.getState().budget || {};
|
|
4037
|
+
if (budget.selectedProvider) {
|
|
4038
|
+
await Promise.all([
|
|
4039
|
+
this.loadProviderSeries(budget.selectedProvider, budget.selectedScope || "*"),
|
|
4040
|
+
this.loadProviderSystemUsage(budget.selectedProvider, budget.selectedScope || "*"),
|
|
4041
|
+
]);
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
|
|
4045
|
+
async loadProviderSeries(provider, scope = "*") {
|
|
4046
|
+
const name = String(provider ?? "").trim();
|
|
4047
|
+
if (!name || typeof this.transport.getProviderUsage !== "function") return;
|
|
4048
|
+
// '*' means the whole provider. Anything else is one model, and the
|
|
4049
|
+
// report is filtered to it — the same filter the model row's own
|
|
4050
|
+
// numbers came from, so the chart and the row agree.
|
|
4051
|
+
const model = scope && scope !== "*" ? scope : null;
|
|
4052
|
+
// The chart must be drawn from the SAME slice of spend as the row
|
|
4053
|
+
// above it, or the two contradict each other under one heading. Two
|
|
4054
|
+
// filters do that, and both were missing:
|
|
4055
|
+
//
|
|
4056
|
+
// chargeClass "user" — the meters a cell reads are never moved for
|
|
4057
|
+
// system spend, so a chart that summed every class drew a provider
|
|
4058
|
+
// as over its limit while the cell said 15%.
|
|
4059
|
+
//
|
|
4060
|
+
// mine — with the tick off a cell holds YOUR spend against YOUR
|
|
4061
|
+
// share, so an unfiltered chart put the whole fleet's bars under a
|
|
4062
|
+
// heading that said "your usage", above a line that was your
|
|
4063
|
+
// personal share of the limit.
|
|
4064
|
+
const overall = this.getState().budget?.overall === true;
|
|
4065
|
+
const rangeDays = this.getState().budget?.rangeDays ?? BUDGET_SERIES_DAYS;
|
|
4066
|
+
this.dispatch({ type: "budget/series/loading", provider: name, scope, rangeDays });
|
|
4067
|
+
try {
|
|
4068
|
+
const report = await this.transport.getProviderUsage({
|
|
4069
|
+
days: rangeDays,
|
|
4070
|
+
provider: name,
|
|
4071
|
+
...(model ? { model } : {}),
|
|
4072
|
+
chargeClass: "user",
|
|
4073
|
+
...(overall ? {} : { mine: true }),
|
|
4074
|
+
dimension: "provider",
|
|
4075
|
+
});
|
|
4076
|
+
this.dispatch({
|
|
4077
|
+
type: "budget/series/loaded",
|
|
4078
|
+
provider: name,
|
|
4079
|
+
scope,
|
|
4080
|
+
rangeDays,
|
|
4081
|
+
days: Array.isArray(report?.daily) ? report.daily : [],
|
|
4082
|
+
});
|
|
4083
|
+
} catch (error) {
|
|
4084
|
+
this.dispatch({
|
|
4085
|
+
type: "budget/series/failed",
|
|
4086
|
+
provider: name, scope, rangeDays, error: budgetRefusalMessage(error),
|
|
4087
|
+
});
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
/** Admin-only machinery spend, kept separate from user limits. */
|
|
4092
|
+
async loadProviderSystemUsage(provider, scope = "*") {
|
|
4093
|
+
const role = this.getState().auth?.authorization?.role;
|
|
4094
|
+
const isAdmin = role === "admin" || role === "anonymous"
|
|
4095
|
+
|| this.getState().admin?.profile?.isAdmin === true;
|
|
4096
|
+
const name = String(provider ?? "").trim();
|
|
4097
|
+
if (!isAdmin || !name || typeof this.transport.getProviderUsage !== "function") return;
|
|
4098
|
+
const model = scope && scope !== "*" ? scope : null;
|
|
4099
|
+
const rangeDays = this.getState().budget?.rangeDays ?? BUDGET_SERIES_DAYS;
|
|
4100
|
+
this.dispatch({ type: "budget/systemUsage/loading", provider: name, scope, rangeDays });
|
|
4101
|
+
try {
|
|
4102
|
+
const report = await this.transport.getProviderUsage({
|
|
4103
|
+
days: rangeDays,
|
|
4104
|
+
provider: name,
|
|
4105
|
+
...(model ? { model } : {}),
|
|
4106
|
+
chargeClass: "system",
|
|
4107
|
+
dimension: "model",
|
|
4108
|
+
limit: 100,
|
|
4109
|
+
});
|
|
4110
|
+
this.dispatch({ type: "budget/systemUsage/loaded", provider: name, scope, rangeDays, report });
|
|
4111
|
+
} catch (error) {
|
|
4112
|
+
this.dispatch({
|
|
4113
|
+
type: "budget/systemUsage/failed",
|
|
4114
|
+
provider: name, scope, rangeDays, error: budgetRefusalMessage(error),
|
|
4115
|
+
});
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
/**
|
|
4120
|
+
* Why the waiting sessions are waiting, for the session list.
|
|
4121
|
+
*
|
|
4122
|
+
* The catalog row carries a status of "waiting" and nothing else; the
|
|
4123
|
+
* reason is published by `listPausedSessions`, and the session list joins
|
|
4124
|
+
* the two (see budgetPauseForSession). So take them while a wait is on
|
|
4125
|
+
* screen — once a minute, and never while the Budget surface is open,
|
|
4126
|
+
* which runs its own refresh on the same cadence.
|
|
4127
|
+
*
|
|
4128
|
+
* A failure is no news: the reasons already on screen stay as they are.
|
|
4129
|
+
*/
|
|
4130
|
+
async refreshBudgetPausesIfStale(sessions) {
|
|
4131
|
+
if (typeof this.transport.listPausedSessions !== "function") return;
|
|
4132
|
+
const state = this.getState();
|
|
4133
|
+
if (state.ui?.budgetOpen) return;
|
|
4134
|
+
// A scheduled session is "waiting" on its own clock, not on a budget,
|
|
4135
|
+
// and on a busy fleet those are most of the waits. Skipping them keeps
|
|
4136
|
+
// this from becoming a permanent background poll on a portal where
|
|
4137
|
+
// nothing is actually stopped.
|
|
4138
|
+
const waiting = (Array.isArray(sessions) ? sessions : [])
|
|
4139
|
+
.some((row) => row && !row.isGroup && row.status === "waiting" && row.cronActive !== true);
|
|
4140
|
+
// Nothing is waiting, and there is nothing on screen left to clear.
|
|
4141
|
+
if (!waiting && (state.budget?.paused || []).length === 0) return;
|
|
4142
|
+
const now = Date.now();
|
|
4143
|
+
if (now - (this.budgetPauseReadAt || 0) < 60_000) return;
|
|
4144
|
+
this.budgetPauseReadAt = now;
|
|
4145
|
+
await this.refreshPausedSessions({ quiet: true }).catch(() => {});
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
/**
|
|
4149
|
+
* The paused list alone. One call, because the session rows need the
|
|
4150
|
+
* reasons whether or not the table is on screen.
|
|
4151
|
+
*
|
|
4152
|
+
* `quiet` is for the background poll: a minute-by-minute read that nobody
|
|
4153
|
+
* asked for must not put an error banner on a table the reader is not even
|
|
4154
|
+
* looking at. The reasons already on screen stay as they are, and the next
|
|
4155
|
+
* poll tries again.
|
|
4156
|
+
*/
|
|
4157
|
+
async refreshPausedSessions({ quiet = false } = {}) {
|
|
4158
|
+
if (typeof this.transport.listPausedSessions !== "function") {
|
|
4159
|
+
if (quiet) return;
|
|
4160
|
+
this.dispatch({
|
|
4161
|
+
type: "budget/loadFailed",
|
|
4162
|
+
error: "Providers and budgets are not available on this deployment.",
|
|
4163
|
+
});
|
|
4164
|
+
return;
|
|
4165
|
+
}
|
|
4166
|
+
const paused = await this._readBudgetPart(() => this.transport.listPausedSessions());
|
|
4167
|
+
if (paused.error && quiet) return;
|
|
4168
|
+
this._settleBudgetRead([
|
|
4169
|
+
["paused", "the paused sessions", paused, (v) => v?.sessions || []],
|
|
4170
|
+
]);
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4173
|
+
/** An older server has none of these routes. Say so; do not draw zeros. */
|
|
4174
|
+
_budgetReadable() {
|
|
4175
|
+
if (typeof this.transport.getProviderUsageGrid === "function") return true;
|
|
4176
|
+
this.dispatch({
|
|
4177
|
+
type: "budget/loadFailed",
|
|
4178
|
+
error: "Providers and budgets are not available on this deployment.",
|
|
4179
|
+
});
|
|
4180
|
+
return false;
|
|
4181
|
+
}
|
|
4182
|
+
|
|
4183
|
+
/** One read, kept apart from its siblings so one failure cannot take them all. */
|
|
4184
|
+
async _readBudgetPart(run) {
|
|
4185
|
+
try {
|
|
4186
|
+
return { value: await run(), error: null };
|
|
4187
|
+
} catch (error) {
|
|
4188
|
+
return { value: null, error: budgetRefusalMessage(error) };
|
|
4189
|
+
}
|
|
4190
|
+
}
|
|
4191
|
+
|
|
4192
|
+
/**
|
|
4193
|
+
* Turn the reads into state.
|
|
4194
|
+
*
|
|
4195
|
+
* Whatever came back is written; whatever failed is named. A partial
|
|
4196
|
+
* failure is worth the two dispatches: the parts that arrived are worth
|
|
4197
|
+
* having, and the part that did not arrive has to say so rather than
|
|
4198
|
+
* showing the last numbers as if they were current.
|
|
4199
|
+
*/
|
|
4200
|
+
_settleBudgetRead(parts) {
|
|
4201
|
+
const loaded = {};
|
|
4202
|
+
const failures = [];
|
|
4203
|
+
for (const [key, label, read, pick] of parts) {
|
|
4204
|
+
if (read.error) failures.push({ label, error: read.error });
|
|
4205
|
+
else loaded[key] = pick(read.value);
|
|
4206
|
+
}
|
|
4207
|
+
if (Object.keys(loaded).length === 0) {
|
|
4208
|
+
// Everything failed, which nearly always means one cause — signed
|
|
4209
|
+
// out, server down. Two copies of one sentence is not two facts,
|
|
4210
|
+
// so the server's own words go up once.
|
|
4211
|
+
this.dispatch({ type: "budget/loadFailed", error: failures[0]?.error || "The read failed." });
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
this.dispatch({ type: "budget/loaded", ...loaded });
|
|
4215
|
+
if (failures.length === 0) return;
|
|
4216
|
+
const message = failures.map((f) => `${f.label} could not be read: ${f.error}`).join(" ");
|
|
4217
|
+
// WHICH half failed decides what is marked. `budget/loadFailed` sets
|
|
4218
|
+
// the TABLE's error, and firing it when the paused list alone failed
|
|
4219
|
+
// put a red "these numbers are from an earlier read" over numbers
|
|
4220
|
+
// that had just been read successfully — while the genuinely old
|
|
4221
|
+
// waiting line underneath carried no mark at all. Exactly backwards.
|
|
4222
|
+
const gridFailed = parts.some(([key, , read]) => key === "grid" && read.error);
|
|
4223
|
+
this.dispatch(gridFailed
|
|
4224
|
+
? { type: "budget/loadFailed", error: message }
|
|
4225
|
+
: { type: "budget/pausedFailed", error: message });
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
/**
|
|
4229
|
+
* Every change to a provider, in one shape: run it, re-read the table, and
|
|
4230
|
+
* hand the caller back what happened.
|
|
4231
|
+
*
|
|
4232
|
+
* Nothing throws. A sheet gets `{ok, error, code, result}` and renders the
|
|
4233
|
+
* refusal where the person is looking; the same message also goes to the
|
|
4234
|
+
* status line for anyone who has already closed the sheet.
|
|
4235
|
+
*/
|
|
4236
|
+
async _runBudgetChange(run) {
|
|
4237
|
+
try {
|
|
4238
|
+
const result = await run();
|
|
4239
|
+
await this.loadProviderTable();
|
|
4240
|
+
const after = this.getState().budget || {};
|
|
4241
|
+
// A limit that changed moves the chart's dashed line, so the chart
|
|
4242
|
+
// is re-read with the table rather than left describing the old one.
|
|
4243
|
+
if (after.selectedProvider) {
|
|
4244
|
+
await this.loadProviderSeries(after.selectedProvider, after.selectedScope || "*");
|
|
4245
|
+
}
|
|
4246
|
+
return { ok: true, error: null, code: null, result: result ?? null };
|
|
4247
|
+
} catch (error) {
|
|
4248
|
+
const message = budgetRefusalMessage(error);
|
|
4249
|
+
this.dispatch({ type: "ui/status", text: message });
|
|
4250
|
+
// A refusal is often the world having moved — the provider was
|
|
4251
|
+
// already deleted, the limit already gone. Re-reading here is
|
|
4252
|
+
// what stops the table keeping a row the server says is not
|
|
4253
|
+
// there, with a chart under it saying "no usage".
|
|
4254
|
+
await this.loadProviderTable().catch(() => {});
|
|
4255
|
+
return { ok: false, error: message, code: error?.code || null, result: null };
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
/** A change this deployment cannot make at all, reported like any other refusal. */
|
|
4260
|
+
_refuseBudgetChange(message) {
|
|
4261
|
+
this.dispatch({ type: "ui/status", text: message });
|
|
4262
|
+
return { ok: false, error: message, code: "UNSUPPORTED", result: null };
|
|
4263
|
+
}
|
|
4264
|
+
|
|
4265
|
+
/**
|
|
4266
|
+
* Create a provider. `shared` makes one anyone may spend from (admins
|
|
4267
|
+
* only); otherwise it is the caller's own, on their own credentials, and
|
|
4268
|
+
* nobody else sees it. The name is permanent — sessions refer to it.
|
|
4269
|
+
*/
|
|
4270
|
+
async createProvider({ name, type, credentials, baseUrl = null, shared = false } = {}) {
|
|
4271
|
+
const op = shared ? "createProvider" : "createMyProvider";
|
|
4272
|
+
if (typeof this.transport[op] !== "function") {
|
|
4273
|
+
return this._refuseBudgetChange("Providers and budgets are not available on this deployment.");
|
|
4274
|
+
}
|
|
4275
|
+
return this._runBudgetChange(() => this.transport[op]({ name, type, credentials, baseUrl }));
|
|
4276
|
+
}
|
|
4277
|
+
|
|
4278
|
+
/**
|
|
4279
|
+
* Delete a provider. Which door it goes through follows the provider's
|
|
4280
|
+
* own class, read from the table already on screen: a shared provider is
|
|
4281
|
+
* an admin's to remove, a personal one is its owner's. The result carries
|
|
4282
|
+
* `waitingSessions` — how many sessions now wait on a name that no longer
|
|
4283
|
+
* resolves — which the sheet says out loud before it is confirmed.
|
|
4284
|
+
*/
|
|
4285
|
+
async deleteProvider(name, { shared = null } = {}) {
|
|
4286
|
+
const known = (this.getState().budget?.grid || [])
|
|
4287
|
+
.find((row) => row?.providerName === name && row?.rowKind !== "model");
|
|
4288
|
+
const isShared = shared === null ? known?.class === "shared" : shared === true;
|
|
4289
|
+
const op = isShared ? "deleteProvider" : "deleteMyProvider";
|
|
4290
|
+
if (typeof this.transport[op] !== "function") {
|
|
4291
|
+
return this._refuseBudgetChange("Providers and budgets are not available on this deployment.");
|
|
4292
|
+
}
|
|
4293
|
+
return this._runBudgetChange(() => this.transport[op](name));
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
/**
|
|
4297
|
+
* Save one limit. The same (period, scope) replaces what was there, and
|
|
4298
|
+
* the limit counts from what the period has ALREADY spent — it never
|
|
4299
|
+
* resets anything. The result's `seededTokens` is that already-counted
|
|
4300
|
+
* number, which the editor uses to warn that saving pauses sessions now.
|
|
4301
|
+
*/
|
|
4302
|
+
async setProviderLimit({ provider, period, model = null, tokens } = {}) {
|
|
4303
|
+
if (typeof this.transport.setProviderLimit !== "function") {
|
|
4304
|
+
return this._refuseBudgetChange("Providers and budgets are not available on this deployment.");
|
|
4305
|
+
}
|
|
4306
|
+
return this._runBudgetChange(() => this.transport.setProviderLimit({ name: provider, period, model, tokens }));
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4309
|
+
/** Drop one limit. Removing one only ever grants room; usage history is kept. */
|
|
4310
|
+
async removeProviderLimit({ provider, period, model = null } = {}) {
|
|
4311
|
+
if (typeof this.transport.removeProviderLimit !== "function") {
|
|
4312
|
+
return this._refuseBudgetChange("Providers and budgets are not available on this deployment.");
|
|
4313
|
+
}
|
|
4314
|
+
return this._runBudgetChange(() => this.transport.removeProviderLimit({ name: provider, period, model }));
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
/**
|
|
4318
|
+
* The share of each of a shared provider's limits one person may use.
|
|
4319
|
+
* 100 means no per-person ceiling. It is derived live from the limit as
|
|
4320
|
+
* it stands, so there is nothing to re-stamp when a limit changes.
|
|
4321
|
+
*/
|
|
4322
|
+
async setProviderAllowance({ provider, pct } = {}) {
|
|
4323
|
+
if (typeof this.transport.setProviderAllowance !== "function") {
|
|
4324
|
+
return this._refuseBudgetChange("Providers and budgets are not available on this deployment.");
|
|
4325
|
+
}
|
|
4326
|
+
return this._runBudgetChange(() => this.transport.setProviderAllowance({ name: provider, pct }));
|
|
4327
|
+
}
|
|
4328
|
+
|
|
4329
|
+
/**
|
|
4330
|
+
* Place or release a hold. A hold stops new turns against a provider
|
|
4331
|
+
* until it is released, independently of every limit. With neither an end
|
|
4332
|
+
* time nor `release` it has no end.
|
|
4333
|
+
*/
|
|
4334
|
+
async setProviderHold({ provider, untilUtc = null, release = false } = {}) {
|
|
4335
|
+
if (typeof this.transport.setProviderHold !== "function") {
|
|
4336
|
+
return this._refuseBudgetChange("Providers and budgets are not available on this deployment.");
|
|
4337
|
+
}
|
|
4338
|
+
return this._runBudgetChange(() => this.transport.setProviderHold({ name: provider, untilUtc, release }));
|
|
4339
|
+
}
|
|
4340
|
+
|
|
4341
|
+
/**
|
|
4342
|
+
* The cluster default tuple: what system sessions run, and what anyone
|
|
4343
|
+
* who set no default of their own gets. It must name a SHARED provider,
|
|
4344
|
+
* and the model half must belong to it — the database refuses anything
|
|
4345
|
+
* else, in words.
|
|
4346
|
+
*/
|
|
4347
|
+
async setClusterDefault(tuple) {
|
|
4348
|
+
if (typeof this.transport.setClusterDefault !== "function") {
|
|
4349
|
+
return this._refuseBudgetChange("Providers and budgets are not available on this deployment.");
|
|
4350
|
+
}
|
|
4351
|
+
return this._runBudgetChange(() => this.transport.setClusterDefault(tuple));
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
/** The caller's own prefill for new sessions. A null tuple clears it. */
|
|
4355
|
+
async setMyDefault(tuple) {
|
|
4356
|
+
if (typeof this.transport.setMyDefault !== "function") {
|
|
4357
|
+
return this._refuseBudgetChange("Providers and budgets are not available on this deployment.");
|
|
4358
|
+
}
|
|
4359
|
+
return this._runBudgetChange(() => this.transport.setMyDefault(tuple));
|
|
4360
|
+
}
|
|
3474
4361
|
async ensureExecutionHistory(sessionId, { force = false } = {}) {
|
|
3475
4362
|
if (!sessionId || typeof this.transport.getExecutionHistory !== "function") return null;
|
|
3476
4363
|
const current = this.getState().executionHistory?.bySessionId?.[sessionId] || null;
|
|
@@ -5350,6 +6237,21 @@ export class PilotSwarmUiController {
|
|
|
5350
6237
|
this.dispatch({ type: "ui/status", text: "Select a context window and press Enter" });
|
|
5351
6238
|
}
|
|
5352
6239
|
|
|
6240
|
+
/**
|
|
6241
|
+
* The model step of the create flow.
|
|
6242
|
+
*
|
|
6243
|
+
* What it lists is PROVIDERS, one group each, with the models their type
|
|
6244
|
+
* offers — so a provider an admin or a user created a minute ago is on
|
|
6245
|
+
* the list without anyone restarting anything, and the entry carries its
|
|
6246
|
+
* budget state inline ("42% of its Daily limit used", "AT ITS DAILY
|
|
6247
|
+
* LIMIT") so the choice is made knowing.
|
|
6248
|
+
*
|
|
6249
|
+
* An empty namespace REFUSES rather than falling back to the whole
|
|
6250
|
+
* catalog: a list of models nothing can pay for is a list of dead ends.
|
|
6251
|
+
* A deployment that has no provider operations at all — or one whose
|
|
6252
|
+
* provider read failed, which is not the same thing as having none — is
|
|
6253
|
+
* a different case and keeps exactly today's behaviour.
|
|
6254
|
+
*/
|
|
5353
6255
|
async openModelPicker(sessionOptions = {}) {
|
|
5354
6256
|
if (typeof this.transport.listModels !== "function") {
|
|
5355
6257
|
await this.openSessionAgentPicker(sessionOptions);
|
|
@@ -5362,6 +6264,170 @@ export class PilotSwarmUiController {
|
|
|
5362
6264
|
return;
|
|
5363
6265
|
}
|
|
5364
6266
|
|
|
6267
|
+
const namespace = await this._resolveProviderNamespace();
|
|
6268
|
+
const built = namespace?.error
|
|
6269
|
+
? { refusal: namespace.error }
|
|
6270
|
+
: namespace
|
|
6271
|
+
? this._buildProviderPickerGroups(namespace, models, sessionOptions)
|
|
6272
|
+
: await this._buildCatalogPickerGroups(models, sessionOptions);
|
|
6273
|
+
if (built.refusal) {
|
|
6274
|
+
this.dispatch({ type: "ui/status", text: built.refusal });
|
|
6275
|
+
return;
|
|
6276
|
+
}
|
|
6277
|
+
const { items, groups, selectedIndex } = built;
|
|
6278
|
+
this.dispatch({
|
|
6279
|
+
type: "ui/modal",
|
|
6280
|
+
modal: {
|
|
6281
|
+
type: "modelPicker",
|
|
6282
|
+
title: sessionOptions?.mode === "switchModel" ? "Switch model for session" : "Select model for new session",
|
|
6283
|
+
items,
|
|
6284
|
+
groups,
|
|
6285
|
+
selectedIndex,
|
|
6286
|
+
previousFocus: this.getState().ui.focusRegion,
|
|
6287
|
+
sessionOptions,
|
|
6288
|
+
},
|
|
6289
|
+
});
|
|
6290
|
+
this.dispatch({ type: "ui/status", text: "Select a model and press Enter" });
|
|
6291
|
+
}
|
|
6292
|
+
|
|
6293
|
+
/**
|
|
6294
|
+
* The providers this viewer can spend from, with their limits and the
|
|
6295
|
+
* default tuples. Null means the deployment predates runtime-provider
|
|
6296
|
+
* APIs. Once those APIs exist, a failed namespace read is authoritative:
|
|
6297
|
+
* fail closed rather than offering static type names with no proven
|
|
6298
|
+
* credential or ownership.
|
|
6299
|
+
*/
|
|
6300
|
+
async _resolveProviderNamespace() {
|
|
6301
|
+
if (typeof this.transport.listProviders !== "function") return null;
|
|
6302
|
+
try {
|
|
6303
|
+
const [list, status, defaults] = await Promise.all([
|
|
6304
|
+
this.transport.listProviders(),
|
|
6305
|
+
typeof this.transport.getProviderStatus === "function"
|
|
6306
|
+
? this.transport.getProviderStatus().catch(() => null)
|
|
6307
|
+
: null,
|
|
6308
|
+
typeof this.transport.getDefaults === "function"
|
|
6309
|
+
? this.transport.getDefaults().catch(() => null)
|
|
6310
|
+
: null,
|
|
6311
|
+
]);
|
|
6312
|
+
const providers = Array.isArray(list?.providers) ? list.providers : null;
|
|
6313
|
+
if (!providers) return { error: "Runtime provider availability could not be verified. Refresh and try again." };
|
|
6314
|
+
return {
|
|
6315
|
+
providers,
|
|
6316
|
+
status: Array.isArray(status?.providers) ? status.providers : [],
|
|
6317
|
+
statusKnown: Array.isArray(status?.providers),
|
|
6318
|
+
defaults: defaults || null,
|
|
6319
|
+
};
|
|
6320
|
+
} catch {
|
|
6321
|
+
return { error: "Runtime provider availability could not be verified. Refresh and try again." };
|
|
6322
|
+
}
|
|
6323
|
+
}
|
|
6324
|
+
|
|
6325
|
+
/**
|
|
6326
|
+
* One group per provider, models re-qualified under the provider's own
|
|
6327
|
+
* name.
|
|
6328
|
+
*
|
|
6329
|
+
* The catalog's `providerId` is the TYPE ("azure-openai"); a provider is
|
|
6330
|
+
* an instance of that type with a name of its own ("azure-prod"), and a
|
|
6331
|
+
* session runs `<provider>:<model>`. A deployment that never created a
|
|
6332
|
+
* second instance sees no change at all, because the seed names each
|
|
6333
|
+
* provider after the config entry it came from.
|
|
6334
|
+
*/
|
|
6335
|
+
_buildProviderPickerGroups(namespace, models, sessionOptions) {
|
|
6336
|
+
const usable = namespace.providers.filter((row) => row.usableByMe !== false);
|
|
6337
|
+
if (usable.length === 0) {
|
|
6338
|
+
return {
|
|
6339
|
+
refusal: "No provider can pay for a session yet — add one of your own, or ask an admin for a shared one.",
|
|
6340
|
+
};
|
|
6341
|
+
}
|
|
6342
|
+
const modelsByType = new Map();
|
|
6343
|
+
for (const model of models) {
|
|
6344
|
+
const typeId = model?.providerId;
|
|
6345
|
+
if (!typeId) continue;
|
|
6346
|
+
if (!modelsByType.has(typeId)) modelsByType.set(typeId, []);
|
|
6347
|
+
modelsByType.get(typeId).push(model);
|
|
6348
|
+
}
|
|
6349
|
+
const statusByName = new Map(namespace.status.map((row) => [row.name, row]));
|
|
6350
|
+
const now = Date.now();
|
|
6351
|
+
// Your own default first, then the cluster's, then your own
|
|
6352
|
+
// providers, then the shared ones: what a plain Enter charges should
|
|
6353
|
+
// be what the deployment would have chosen anyway.
|
|
6354
|
+
const ranked = [...usable].sort((a, b) => {
|
|
6355
|
+
const rank = (row) => (row.isMyDefault === true ? 0
|
|
6356
|
+
: row.isClusterDefault === true ? 1
|
|
6357
|
+
: row.class === "personal" ? 2 : 3);
|
|
6358
|
+
return rank(a) - rank(b) || String(a.name).localeCompare(String(b.name));
|
|
6359
|
+
});
|
|
6360
|
+
const preferredModel = sessionOptions?.model
|
|
6361
|
+
|| namespace.defaults?.mine?.model
|
|
6362
|
+
|| namespace.defaults?.cluster?.model
|
|
6363
|
+
|| (typeof this.transport.getDefaultModel === "function" ? this.transport.getDefaultModel() : undefined);
|
|
6364
|
+
|
|
6365
|
+
const items = [];
|
|
6366
|
+
const groups = ranked
|
|
6367
|
+
.map((provider) => {
|
|
6368
|
+
const room = providerPickerNote(provider, statusByName.get(provider.name) || null, now);
|
|
6369
|
+
// A provider with no credential cannot pay for anything. Say
|
|
6370
|
+
// so on the row rather than hiding it: the person who has to
|
|
6371
|
+
// fix it is often the one looking at the list.
|
|
6372
|
+
const disabledReason = provider.hasCredential === false
|
|
6373
|
+
? `${provider.name} has no credential`
|
|
6374
|
+
: null;
|
|
6375
|
+
return {
|
|
6376
|
+
providerId: provider.name,
|
|
6377
|
+
providerType: provider.typeId,
|
|
6378
|
+
providerClass: provider.class,
|
|
6379
|
+
isClusterDefault: provider.isClusterDefault === true,
|
|
6380
|
+
isMyDefault: provider.isMyDefault === true,
|
|
6381
|
+
hasCredential: provider.hasCredential !== false,
|
|
6382
|
+
budgetState: room.state,
|
|
6383
|
+
budgetNote: room.text,
|
|
6384
|
+
models: (modelsByType.get(provider.name) || modelsByType.get(provider.typeId) || []).map((model) => {
|
|
6385
|
+
const qualifiedName = model.providerId === provider.name && model.qualifiedName
|
|
6386
|
+
? model.qualifiedName
|
|
6387
|
+
: `${provider.name}:${model.modelName}`;
|
|
6388
|
+
const modelDisabledReason = disabledReason
|
|
6389
|
+
|| (model.credentialAvailable === false
|
|
6390
|
+
? `${provider.name} has no usable credential or endpoint`
|
|
6391
|
+
: null);
|
|
6392
|
+
const item = {
|
|
6393
|
+
id: qualifiedName,
|
|
6394
|
+
qualifiedName,
|
|
6395
|
+
modelName: model.modelName || qualifiedName,
|
|
6396
|
+
providerId: provider.name,
|
|
6397
|
+
providerType: provider.typeId,
|
|
6398
|
+
providerClass: provider.class,
|
|
6399
|
+
description: model.description || "",
|
|
6400
|
+
cost: model.cost || null,
|
|
6401
|
+
supportedReasoningEfforts: normalizeReasoningEfforts(model.supportedReasoningEfforts),
|
|
6402
|
+
defaultReasoningEffort: model.defaultReasoningEffort || null,
|
|
6403
|
+
supportedContextTiers: normalizeContextTiers(model.supportedContextTiers),
|
|
6404
|
+
defaultContextTier: model.defaultContextTier || null,
|
|
6405
|
+
contextWindowSizes: model.contextWindowSizes || null,
|
|
6406
|
+
isDefault: preferredModel === qualifiedName,
|
|
6407
|
+
budgetState: room.state,
|
|
6408
|
+
budgetNote: room.text,
|
|
6409
|
+
disabledReason: modelDisabledReason,
|
|
6410
|
+
disabled: Boolean(modelDisabledReason),
|
|
6411
|
+
};
|
|
6412
|
+
items.push(item);
|
|
6413
|
+
return item;
|
|
6414
|
+
}),
|
|
6415
|
+
};
|
|
6416
|
+
})
|
|
6417
|
+
.filter((group) => group.models.length > 0);
|
|
6418
|
+
|
|
6419
|
+
if (items.length === 0) {
|
|
6420
|
+
return { refusal: "No provider in your namespace offers a model to run." };
|
|
6421
|
+
}
|
|
6422
|
+
return { items, groups, selectedIndex: pickerSelectedIndex(items, preferredModel) };
|
|
6423
|
+
}
|
|
6424
|
+
|
|
6425
|
+
/**
|
|
6426
|
+
* The catalog list, exactly as it was before providers existed. Kept for
|
|
6427
|
+
* deployments whose server predates the provider operations. Provider API
|
|
6428
|
+
* failures never take this path.
|
|
6429
|
+
*/
|
|
6430
|
+
async _buildCatalogPickerGroups(models, sessionOptions) {
|
|
5365
6431
|
const defaultModel = typeof this.transport.getDefaultModel === "function"
|
|
5366
6432
|
? this.transport.getDefaultModel()
|
|
5367
6433
|
: undefined;
|
|
@@ -5399,6 +6465,7 @@ export class PilotSwarmUiController {
|
|
|
5399
6465
|
contextWindowSizes: model.contextWindowSizes || null,
|
|
5400
6466
|
isDefault: defaultModel === model.qualifiedName,
|
|
5401
6467
|
ghcpKeyMissing,
|
|
6468
|
+
disabledReason: ghcpKeyMissing ? "needs GitHub key" : null,
|
|
5402
6469
|
disabled: ghcpKeyMissing,
|
|
5403
6470
|
};
|
|
5404
6471
|
items.push(item);
|
|
@@ -5407,26 +6474,11 @@ export class PilotSwarmUiController {
|
|
|
5407
6474
|
}))
|
|
5408
6475
|
.filter((group) => group.models.length > 0);
|
|
5409
6476
|
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
if (selectedIndex < 0) selectedIndex = items.findIndex((model) => !model.disabled);
|
|
5416
|
-
if (selectedIndex < 0) selectedIndex = 0;
|
|
5417
|
-
this.dispatch({
|
|
5418
|
-
type: "ui/modal",
|
|
5419
|
-
modal: {
|
|
5420
|
-
type: "modelPicker",
|
|
5421
|
-
title: sessionOptions?.mode === "switchModel" ? "Switch model for session" : "Select model for new session",
|
|
5422
|
-
items,
|
|
5423
|
-
groups,
|
|
5424
|
-
selectedIndex,
|
|
5425
|
-
previousFocus: this.getState().ui.focusRegion,
|
|
5426
|
-
sessionOptions,
|
|
5427
|
-
},
|
|
5428
|
-
});
|
|
5429
|
-
this.dispatch({ type: "ui/status", text: "Select a model and press Enter" });
|
|
6477
|
+
return {
|
|
6478
|
+
items,
|
|
6479
|
+
groups,
|
|
6480
|
+
selectedIndex: pickerSelectedIndex(items, sessionOptions?.model || defaultModel),
|
|
6481
|
+
};
|
|
5430
6482
|
}
|
|
5431
6483
|
|
|
5432
6484
|
async _resolveGhcpUserKeySet() {
|
|
@@ -6767,6 +7819,8 @@ export class PilotSwarmUiController {
|
|
|
6767
7819
|
await this.deleteSelectedArtifact({ confirmed: true });
|
|
6768
7820
|
} else if (modal.action === "deleteMarkedArtifacts") {
|
|
6769
7821
|
await this.deleteMarkedArtifacts({ confirmed: true });
|
|
7822
|
+
} else if (modal.action === "deleteAdminProvider") {
|
|
7823
|
+
await this.deleteSelectedAdminProvider();
|
|
6770
7824
|
}
|
|
6771
7825
|
return;
|
|
6772
7826
|
}
|
|
@@ -6822,7 +7876,7 @@ export class PilotSwarmUiController {
|
|
|
6822
7876
|
this.dispatch({
|
|
6823
7877
|
type: "ui/status",
|
|
6824
7878
|
text: item.ghcpKeyMissing
|
|
6825
|
-
? "This model needs a GitHub Copilot
|
|
7879
|
+
? "This model needs a GitHub Copilot provider — add your own in Admin Console first"
|
|
6826
7880
|
: "This model is not available",
|
|
6827
7881
|
});
|
|
6828
7882
|
return;
|
|
@@ -9113,7 +10167,9 @@ export class PilotSwarmUiController {
|
|
|
9113
10167
|
await this.refreshAdminProfile();
|
|
9114
10168
|
return;
|
|
9115
10169
|
case UI_COMMANDS.ADMIN_SHOW_GHCP:
|
|
9116
|
-
|
|
10170
|
+
// Compatibility command retained for older hosts; the visible
|
|
10171
|
+
// destination is now Model Providers.
|
|
10172
|
+
this.setAdminSection("providers");
|
|
9117
10173
|
return;
|
|
9118
10174
|
case UI_COMMANDS.ADMIN_SHOW_PACKAGES:
|
|
9119
10175
|
this.setAdminSection("packages");
|