@genesislcap/ai-assistant 15.23.3 → 15.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-assistant.api.json +419 -50
- package/dist/ai-assistant.d.ts +217 -683
- package/dist/chat-driver.cjs.map +1 -1
- package/dist/chat-driver.mjs.map +1 -1
- package/dist/custom-elements.json +310 -609
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +16 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/index.d.ts +1 -0
- package/dist/dts/components/settings-modal/index.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.d.ts +215 -0
- package/dist/dts/components/settings-modal/settings-modal.d.ts.map +1 -0
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/main/attachment-controller.d.ts +66 -0
- package/dist/dts/main/attachment-controller.d.ts.map +1 -0
- package/dist/dts/main/budget-controller.d.ts +368 -0
- package/dist/dts/main/budget-controller.d.ts.map +1 -0
- package/dist/dts/main/composer-menu-controller.d.ts +113 -0
- package/dist/dts/main/composer-menu-controller.d.ts.map +1 -0
- package/dist/dts/main/composer-resize-controller.d.ts +80 -0
- package/dist/dts/main/composer-resize-controller.d.ts.map +1 -0
- package/dist/dts/main/context-gate-controller.d.ts +197 -0
- package/dist/dts/main/context-gate-controller.d.ts.map +1 -0
- package/dist/dts/main/cost-ledger-controller.d.ts +228 -0
- package/dist/dts/main/cost-ledger-controller.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +201 -722
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.types.d.ts +25 -0
- package/dist/dts/main/main.types.d.ts.map +1 -1
- package/dist/dts/main/meta-snapshot.d.ts +50 -0
- package/dist/dts/main/meta-snapshot.d.ts.map +1 -0
- package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +18 -0
- package/dist/esm/components/settings-modal/index.js +1 -0
- package/dist/esm/components/settings-modal/settings-modal.js +436 -0
- package/dist/esm/components/settings-modal/settings-modal.template.js +15 -4
- package/dist/esm/main/attachment-controller.js +254 -0
- package/dist/esm/main/budget-controller.js +745 -0
- package/dist/esm/main/composer-menu-controller.js +234 -0
- package/dist/esm/main/composer-resize-controller.js +119 -0
- package/dist/esm/main/context-gate-controller.js +289 -0
- package/dist/esm/main/cost-ledger-controller.js +498 -0
- package/dist/esm/main/main.js +501 -2268
- package/dist/esm/main/meta-snapshot.js +108 -0
- package/dist/esm/state/persistence/session-persister.js +5 -4
- package/docs/migration-GENC-1580.md +209 -0
- package/package.json +16 -16
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
import { __decorate } from "tslib";
|
|
2
|
+
import { BUDGETED_VENDORS, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, VENDOR_LABELS, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
|
|
3
|
+
import { observable, volatile } from '@genesislcap/web-core';
|
|
4
|
+
import { formatUsd } from '../utils/format-usd';
|
|
5
|
+
import { logger } from '../utils/logger';
|
|
6
|
+
/**
|
|
7
|
+
* Closing sentence for a wall the user cannot route around. Singular/plural
|
|
8
|
+
* variants because the banner names one vendor or several.
|
|
9
|
+
*/
|
|
10
|
+
const RAISE_LIMIT_ACTION = 'Contact your administrator to raise it.';
|
|
11
|
+
const RAISE_LIMITS_ACTION = 'Contact your administrator to raise them.';
|
|
12
|
+
/**
|
|
13
|
+
* Banner copy built from the figures a 402 actually reported, or `undefined`
|
|
14
|
+
* when the proxy sent none.
|
|
15
|
+
*
|
|
16
|
+
* `undefined` is the meaningful return, not a fallback: paired with the
|
|
17
|
+
* slice's "a re-latch without a reason keeps the existing explanation" rule, it
|
|
18
|
+
* makes a figureless driver latch leave a host-supplied reason intact rather
|
|
19
|
+
* than blanking it back to the default at the exact moment the wall is hit.
|
|
20
|
+
*
|
|
21
|
+
* Exported for the unit test that pins the formatting; not part of the element
|
|
22
|
+
* API.
|
|
23
|
+
*
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export function formatBlockedReason(
|
|
27
|
+
// `Partial`, so the store's `VendorBudgetFigures` satisfies it as well as a live
|
|
28
|
+
// 402's payload: the banner composes from stored figures and the latch from the
|
|
29
|
+
// fresh ones, and both carry the same dollars. One formatter for both means a
|
|
30
|
+
// remembered wall and a just-hit one cannot word themselves differently.
|
|
31
|
+
budget, vendor) {
|
|
32
|
+
if (!budget || (budget.budgetUsd == null && budget.spentUsd == null))
|
|
33
|
+
return undefined;
|
|
34
|
+
const money = (v) => (v == null ? 'an unknown amount' : formatUsd(v));
|
|
35
|
+
const figures = `(${money(budget.spentUsd)} of ${money(budget.budgetUsd)})`;
|
|
36
|
+
// No "you have $0.00 left" clause here, deliberately: "limit is reached" already
|
|
37
|
+
// says the allowance is gone, and a remainder clamped at zero CONTRADICTS the
|
|
38
|
+
// figures beside it — spend overshoots the cap on the last allowed call, so the
|
|
39
|
+
// words would read "$0.00 left" next to numbers showing $12.34 past it.
|
|
40
|
+
// A per-vendor statement carries NO action clause, because the right action is
|
|
41
|
+
// not knowable at latch time: whether "switch vendor" or "contact your
|
|
42
|
+
// administrator" is the honest advice depends on whether any OTHER vendor still
|
|
43
|
+
// has headroom, which only the composing getter can see. The vendor-agnostic
|
|
44
|
+
// form keeps its historical trailing sentence — nothing composes onto it.
|
|
45
|
+
if (!vendor)
|
|
46
|
+
return `AI usage limit reached ${figures}. Contact your administrator to raise it.`;
|
|
47
|
+
return `${vendorDisplayName(vendor)}'s AI usage limit is reached ${figures}.`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Human-readable name for a vendor, falling back to its raw type.
|
|
51
|
+
*
|
|
52
|
+
* `'none'` needs no special case: it is the "no provider configured" sentinel,
|
|
53
|
+
* it is filtered out of {@link BudgetController.reachableVendors}, the latch
|
|
54
|
+
* refuses to attribute a wall to it, and {@link BudgetController.setVendorBlocked}
|
|
55
|
+
* rejects it at the boundary — so nothing can put it in front of this function.
|
|
56
|
+
* The `?? vendor` fallback is for a vendor type added to `AIProviderType` without
|
|
57
|
+
* a label, which would print the type rather than nothing.
|
|
58
|
+
*/
|
|
59
|
+
function vendorDisplayName(vendor) {
|
|
60
|
+
var _a;
|
|
61
|
+
return (_a = VENDOR_LABELS[vendor]) !== null && _a !== void 0 ? _a : vendor;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* `a`, `a and b`, `a, b and c`.
|
|
65
|
+
*
|
|
66
|
+
* The general joiner is kept rather than hardcoded to a pair because the list
|
|
67
|
+
* is `BUDGETED_VENDORS`-shaped, and that list is a cross-repo contract owned by
|
|
68
|
+
* the proxy (today `anthropic` and `gemini`; `openai` is refused up front with
|
|
69
|
+
* 400 UNSUPPORTED_PROVIDER and can never be walled) — a vendor added there must
|
|
70
|
+
* not require an edit here. Its upper bound is not an assertion in a comment — it is
|
|
71
|
+
* the key count of `VENDOR_LABELS`, since every element of every list passed here
|
|
72
|
+
* comes from `blockedVendors` or `reachableVendors`, both of which hold
|
|
73
|
+
* `AIProviderType` values, and the type has five members of which `'none'` is
|
|
74
|
+
* excluded from both.
|
|
75
|
+
*/
|
|
76
|
+
function formatVendorList(vendors) {
|
|
77
|
+
return joinList(vendors.map(vendorDisplayName));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `"A"`, `"A and B"`, `"A, B and C"` — over already-rendered labels, so a caller
|
|
81
|
+
* can annotate each name (the multi-vendor banner appends each vendor's figures)
|
|
82
|
+
* without duplicating the joining rules.
|
|
83
|
+
*/
|
|
84
|
+
function joinList(names) {
|
|
85
|
+
var _a;
|
|
86
|
+
if (names.length <= 1)
|
|
87
|
+
return (_a = names[0]) !== null && _a !== void 0 ? _a : '';
|
|
88
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Whole-number percentage of a budget consumed, for the meter (GENC-1464).
|
|
92
|
+
*
|
|
93
|
+
* Deliberately unclamped — spend can genuinely overshoot the cap (the last
|
|
94
|
+
* allowed call may cross it), and the meter's TEXT shows the truth; only the
|
|
95
|
+
* bar clamps, at the render site. The one special case is a `$0` cap, which the
|
|
96
|
+
* platform treats as "no spend allowed" (not "unlimited"): it has no headroom
|
|
97
|
+
* by definition, so the meter reads full rather than dividing by zero.
|
|
98
|
+
*
|
|
99
|
+
* Exported for the unit test that pins the arithmetic; not element API.
|
|
100
|
+
*
|
|
101
|
+
* @internal
|
|
102
|
+
*/
|
|
103
|
+
export function budgetPercentOf(figures) {
|
|
104
|
+
if (figures.budgetUsd <= 0)
|
|
105
|
+
return 100;
|
|
106
|
+
return Math.round((figures.spentUsd / figures.budgetUsd) * 100);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The AI-spend budget: which vendors are walled, whether that locks the composer,
|
|
110
|
+
* the banner copy that explains it, and the settings-modal meter (GENC-1464).
|
|
111
|
+
*
|
|
112
|
+
* Two invariants run through the whole file:
|
|
113
|
+
*
|
|
114
|
+
* - **Walls latch; locking derives.** A wall is one-way per vendor and nothing in
|
|
115
|
+
* here clears it, because nothing the user can do refills a budget. But
|
|
116
|
+
* {@link BudgetController.blocked} is *derived* from "is every reachable vendor
|
|
117
|
+
* walled", so a host swapping its registry to a vendor with headroom recovers
|
|
118
|
+
* the composer without un-latching anything.
|
|
119
|
+
* - **The banner may only name vendors the user could actually be routed to.**
|
|
120
|
+
* Hence {@link BudgetController.relevantBlockedVendors}, and hence the
|
|
121
|
+
* provenance mark the sweep writes.
|
|
122
|
+
*
|
|
123
|
+
* @internal
|
|
124
|
+
*/
|
|
125
|
+
export class BudgetController {
|
|
126
|
+
constructor(deps) {
|
|
127
|
+
this.deps = deps;
|
|
128
|
+
/**
|
|
129
|
+
* When budget figures last arrived — from the host's
|
|
130
|
+
* {@link BudgetController.setVendorBudget} or the wall latch's own feed — as
|
|
131
|
+
* epoch ms; `null` until the first feed.
|
|
132
|
+
*
|
|
133
|
+
* ELEMENT state, not slice state, on purpose: the reducer is idempotent by
|
|
134
|
+
* value (an unchanged re-feed must not re-render the meter rows), but "when
|
|
135
|
+
* was this fetched" must move on every feed, changed figures or not — a
|
|
136
|
+
* refresh that confirms the same dollars is still a refresh. Only the
|
|
137
|
+
* toolbar label observes this, so a stamp re-renders one span, not the rows.
|
|
138
|
+
*/
|
|
139
|
+
this.meterLastFedAt = null;
|
|
140
|
+
/**
|
|
141
|
+
* One warning per element for garbage fed to
|
|
142
|
+
* {@link BudgetController.setVendorBudget}. Once, not per call, because the
|
|
143
|
+
* documented feed re-runs after every turn — a host bug would otherwise repeat
|
|
144
|
+
* the same warning on every send for the life of the page.
|
|
145
|
+
*
|
|
146
|
+
* Not observable: nothing renders it.
|
|
147
|
+
*/
|
|
148
|
+
this.warningIssued = false;
|
|
149
|
+
}
|
|
150
|
+
get slice() {
|
|
151
|
+
var _a;
|
|
152
|
+
return (_a = this.deps.sessionRef()) === null || _a === void 0 ? void 0 : _a.store.aiAssistant;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Whether the assistant is blocked by a backend condition and cannot send.
|
|
156
|
+
*
|
|
157
|
+
* Derived, not stored — see the class note. Unknown reachability (statuses not
|
|
158
|
+
* loaded yet, or providers that report no status) fails SAFE: any wall blocks
|
|
159
|
+
* everything, and that needs no clause of its own, because `every` on the empty
|
|
160
|
+
* set is vacuously true.
|
|
161
|
+
*/
|
|
162
|
+
get blocked() {
|
|
163
|
+
const session = this.slice;
|
|
164
|
+
if (!session)
|
|
165
|
+
return false;
|
|
166
|
+
if (session.blocked)
|
|
167
|
+
return true;
|
|
168
|
+
// Byte-identical to the old stored read for every host that never adopts the
|
|
169
|
+
// per-vendor API — which is what makes this derivation non-breaking.
|
|
170
|
+
if (session.blockedVendors.length === 0)
|
|
171
|
+
return false;
|
|
172
|
+
return this.reachableVendors.every((v) => session.blockedVendors.includes(v));
|
|
173
|
+
}
|
|
174
|
+
set blocked(value) {
|
|
175
|
+
var _a;
|
|
176
|
+
(_a = this.deps.sessionRef()) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked: value });
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The distinct vendors the registry can currently reach, from the provider
|
|
180
|
+
* statuses the element loads on connect and refreshes on every
|
|
181
|
+
* observable-registry change.
|
|
182
|
+
*
|
|
183
|
+
* This is the answer to "which vendor would the next turn use" — a question
|
|
184
|
+
* that has **no** correct answer and deliberately gets no API. The provider is
|
|
185
|
+
* resolved per turn AND per agent: `activeProviderInput` may be an async
|
|
186
|
+
* function of the turn's context, an orchestrated turn picks its agent with an
|
|
187
|
+
* LLM `classify()` call, and sub-agents resolve their own providers. Any
|
|
188
|
+
* pre-turn "peek" would therefore be a guess that is wrong precisely on the
|
|
189
|
+
* multi-agent hosts per-vendor budgets exist for.
|
|
190
|
+
*
|
|
191
|
+
* Asking instead which vendors are REACHABLE is answerable, synchronous, and
|
|
192
|
+
* free — and it is enough: the composer must stay live while any reachable
|
|
193
|
+
* vendor has headroom, and must lock when none does. It also makes
|
|
194
|
+
* vendor-switch recovery a derivation rather than a mutation.
|
|
195
|
+
*/
|
|
196
|
+
get reachableVendors() {
|
|
197
|
+
var _a;
|
|
198
|
+
const seen = new Set();
|
|
199
|
+
const registry = this.deps.providerRegistry();
|
|
200
|
+
for (const entry of this.deps.providerStatuses()) {
|
|
201
|
+
const provider = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.provider;
|
|
202
|
+
if (!provider || provider === 'none')
|
|
203
|
+
continue;
|
|
204
|
+
// Reachable means "can serve the next turn", and `chat()` is OPTIONAL on
|
|
205
|
+
// `AIProvider` — the shipped `ChromeProvider` and `OpenAIProvider`
|
|
206
|
+
// implement `getStatus()` only. Counting a chat-less provider here made
|
|
207
|
+
// every consumer wrong at once: it was headroom for the `blocked`
|
|
208
|
+
// derivation (so `otherVendorAvailable: false` failed to lock the
|
|
209
|
+
// composer and each send burned another doomed 402), and it was a switch
|
|
210
|
+
// target for the banner ("Switch to Chrome to keep going" toward a
|
|
211
|
+
// provider that cannot chat).
|
|
212
|
+
//
|
|
213
|
+
// The gate is CAPABILITY, not vendor identity, so a host's custom
|
|
214
|
+
// chat-capable OpenAI provider still counts — the same distinction
|
|
215
|
+
// `validate-providers.ts` already enforces by throwing. And it drops a
|
|
216
|
+
// vendor only on positive evidence: statuses normally come from
|
|
217
|
+
// `providerRegistry.listStatuses()`, whose `name` always resolves, but a
|
|
218
|
+
// host (or test) that assigns `providerStatuses` directly may have no
|
|
219
|
+
// matching registry entry, and an unresolvable name must not silently
|
|
220
|
+
// erase a vendor the status layer vouched for.
|
|
221
|
+
const registered = registry === null || registry === void 0 ? void 0 : registry.get(entry.name);
|
|
222
|
+
if (registered && typeof registered.chat !== 'function')
|
|
223
|
+
continue;
|
|
224
|
+
seen.add(provider);
|
|
225
|
+
}
|
|
226
|
+
return [...seen];
|
|
227
|
+
}
|
|
228
|
+
/** Vendors currently walled, in the order they were walled. */
|
|
229
|
+
get blockedVendors() {
|
|
230
|
+
var _a, _b;
|
|
231
|
+
return (_b = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.blockedVendors) !== null && _b !== void 0 ? _b : [];
|
|
232
|
+
}
|
|
233
|
+
/** Whether this specific vendor's budget is walled, regardless of the others. */
|
|
234
|
+
isVendorBlocked(vendor) {
|
|
235
|
+
return this.blockedVendors.includes(vendor);
|
|
236
|
+
}
|
|
237
|
+
/** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
|
|
238
|
+
isVendorSwept(vendor) {
|
|
239
|
+
var _a, _b;
|
|
240
|
+
return ((_b = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.sweptVendors) !== null && _b !== void 0 ? _b : []).includes(vendor);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Wall (or release) one vendor's budget, optionally with banner copy for it.
|
|
244
|
+
*
|
|
245
|
+
* `'none'` is rejected with a warning rather than accepted: it is the "no
|
|
246
|
+
* provider configured" sentinel, not a vendor. Latching it would put the raw
|
|
247
|
+
* sentinel in the banner ("none's AI usage limit is reached") and could never be
|
|
248
|
+
* undone by derivation, because `'none'` never appears in
|
|
249
|
+
* {@link BudgetController.reachableVendors} — so `blocked` could never become
|
|
250
|
+
* derivable from it either.
|
|
251
|
+
*/
|
|
252
|
+
setVendorBlocked(vendor, blocked, reason) {
|
|
253
|
+
var _a;
|
|
254
|
+
if (vendor === 'none') {
|
|
255
|
+
logger.warn("FoundationAiAssistant.setVendorBlocked: 'none' is the no-provider sentinel, not a vendor — " +
|
|
256
|
+
'ignoring. Use setBlocked() for a vendor-agnostic block.');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
(_a = this.deps.sessionRef()) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setVendorBlocked({ vendor, blocked, reason });
|
|
260
|
+
}
|
|
261
|
+
warnOnce(message) {
|
|
262
|
+
if (this.warningIssued)
|
|
263
|
+
return;
|
|
264
|
+
this.warningIssued = true;
|
|
265
|
+
logger.warn(`FoundationAiAssistant.setVendorBudget: ${message}`);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Feed (or clear) one vendor's AI-spend figures for the meter. Returns `false`
|
|
269
|
+
* — warning once, not per call — for a non-budgeted vendor or a figure that is
|
|
270
|
+
* not a finite number `>= 0`, and also for "no store yet", which is a lifecycle
|
|
271
|
+
* window rather than bad input and so is deliberately not warned about.
|
|
272
|
+
*
|
|
273
|
+
* The full host-facing contract, including the intended `GET /api/budget` feed,
|
|
274
|
+
* is on the element's `setVendorBudget`.
|
|
275
|
+
*/
|
|
276
|
+
setVendorBudget(vendor, figures) {
|
|
277
|
+
if (!BUDGETED_VENDORS.includes(vendor)) {
|
|
278
|
+
this.warnOnce(`'${vendor}' is not a budgeted vendor — ignoring. Only the proxy-metered vendors ` +
|
|
279
|
+
`(${BUDGETED_VENDORS.join(', ')}) can carry budget figures.`);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
// Nullish, not `!== null`: an untyped host's missing lookup arrives as
|
|
283
|
+
// `undefined`, and dereferencing it here would throw at a boundary whose
|
|
284
|
+
// whole contract is to return false instead. `undefined` clears, like null.
|
|
285
|
+
if (figures != null) {
|
|
286
|
+
const validUsd = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
287
|
+
if (!validUsd(figures.budgetUsd) || !validUsd(figures.spentUsd)) {
|
|
288
|
+
this.warnOnce(`invalid figures for '${vendor}' — ignoring. budgetUsd and spentUsd must both be ` +
|
|
289
|
+
`finite numbers >= 0 (got budgetUsd: ${figures.budgetUsd}, spentUsd: ${figures.spentUsd}).`);
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const session = this.deps.sessionRef();
|
|
294
|
+
// No store yet (fed before the first append, or mid pop-out remount): there
|
|
295
|
+
// is nowhere to write, so say so rather than returning an accepted the
|
|
296
|
+
// caller would never retry. Not warned — this is a lifecycle window, not
|
|
297
|
+
// bad input, and the documented per-turn re-feed lands the figures as soon
|
|
298
|
+
// as the store exists.
|
|
299
|
+
if (!session)
|
|
300
|
+
return false;
|
|
301
|
+
session.actions.aiAssistant.setVendorBudget({
|
|
302
|
+
vendor: vendor,
|
|
303
|
+
// A fresh literal, never the caller's object — see the reducer, which
|
|
304
|
+
// must not adopt (and potentially freeze) state the host still owns.
|
|
305
|
+
figures: figures == null ? null : { budgetUsd: figures.budgetUsd, spentUsd: figures.spentUsd },
|
|
306
|
+
});
|
|
307
|
+
if (figures != null)
|
|
308
|
+
this.meterLastFedAt = Date.now();
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
/** "Updated HH:MM:SS" for the meter toolbar, or `undefined` before any feed. */
|
|
312
|
+
get updatedLabel() {
|
|
313
|
+
if (this.meterLastFedAt == null)
|
|
314
|
+
return undefined;
|
|
315
|
+
return `Updated ${new Date(this.meterLastFedAt).toLocaleTimeString()}`;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* The walled vendors the user can still be routed to — i.e.
|
|
319
|
+
* {@link BudgetController.blockedVendors} narrowed to the reachable set, which
|
|
320
|
+
* is the only set the banner may name.
|
|
321
|
+
*
|
|
322
|
+
* A wall the registry can no longer reach is not news: it cannot be hit, and
|
|
323
|
+
* naming it puts a vendor in front of the user that is not theirs. The concrete
|
|
324
|
+
* failure this exists to stop: a host ships Anthropic-only, Anthropic walls, the
|
|
325
|
+
* host swaps its registry to Gemini, Gemini walls — and the banner reads "AI
|
|
326
|
+
* usage limits are reached for Anthropic and Gemini" to a user who has never
|
|
327
|
+
* had an Anthropic key. The spurious second name also flips the copy onto the
|
|
328
|
+
* plural branch, so even the closing sentence is wrong.
|
|
329
|
+
*
|
|
330
|
+
* When nothing is reachable YET (the statuses are still loading — the exact
|
|
331
|
+
* window the pre-flight and the cross-tab bus latch into), the fallback keeps
|
|
332
|
+
* every wall for LOCKING (`blocked` still derives true) but names only the
|
|
333
|
+
* vendors walled by a REFUSAL, dropping the ones the `otherVendorAvailable`
|
|
334
|
+
* sweep added. A refusal is a fact about a vendor this user just used; a sweep
|
|
335
|
+
* entry is the proxy's headroom verdict about a vendor the host may not even
|
|
336
|
+
* ship — naming it here reproduced the exact failure above from the other
|
|
337
|
+
* direction ("Gemini's AI usage limit is reached" to an Anthropic-only user),
|
|
338
|
+
* and flipped the copy onto the plural branch with it. Self-corrects when the
|
|
339
|
+
* statuses land: from then on reachability, not provenance, decides.
|
|
340
|
+
*/
|
|
341
|
+
get relevantBlockedVendors() {
|
|
342
|
+
var _a, _b;
|
|
343
|
+
const walled = this.blockedVendors;
|
|
344
|
+
const reachable = this.reachableVendors;
|
|
345
|
+
if (reachable.length === 0) {
|
|
346
|
+
const swept = (_b = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.sweptVendors) !== null && _b !== void 0 ? _b : [];
|
|
347
|
+
return walled.filter((v) => !swept.includes(v));
|
|
348
|
+
}
|
|
349
|
+
return walled.filter((v) => reachable.includes(v));
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Whether the blocked banner has anything to say — a vendor-agnostic block, or
|
|
353
|
+
* a partial wall that the NEXT send would actually hit.
|
|
354
|
+
*
|
|
355
|
+
* Partial exhaustion is announced only while the ACTIVE vendor (the registry
|
|
356
|
+
* default the next send resolves to) is among the walled ones. A user who has
|
|
357
|
+
* switched to a vendor with headroom has already acted on the advice —
|
|
358
|
+
* keeping the banner up past that point nagged about a vendor they just left
|
|
359
|
+
* (GENC-1464 tester feedback), while switching BACK to the walled vendor
|
|
360
|
+
* brings the banner straight back. Two conservative fallbacks keep it
|
|
361
|
+
* fail-informative: while the statuses are still loading the active vendor is
|
|
362
|
+
* unknowable, so any reachable-and-walled vendor still announces; and a
|
|
363
|
+
* vendor-agnostic block announces always, exactly as before.
|
|
364
|
+
*/
|
|
365
|
+
get bannerVisible() {
|
|
366
|
+
if (this.blocked)
|
|
367
|
+
return true;
|
|
368
|
+
const walled = this.relevantBlockedVendors;
|
|
369
|
+
if (walled.length === 0)
|
|
370
|
+
return false;
|
|
371
|
+
const active = this.activeVendor;
|
|
372
|
+
if (!active)
|
|
373
|
+
return true;
|
|
374
|
+
return walled.includes(active);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Whether a suggestions fetch would hit a wall.
|
|
378
|
+
*
|
|
379
|
+
* Unlike a chat turn, this one CAN be resolved exactly: both suggestion paths
|
|
380
|
+
* go to the registry **default** and never to a per-agent override
|
|
381
|
+
* (`ChatDriver.getSuggestions` calls `providerRegistry.default()`, and
|
|
382
|
+
* `OrchestratingDriver.getSuggestions` just delegates to it). So the vendor a
|
|
383
|
+
* suggestions call would use is knowable, and this is the one place in the
|
|
384
|
+
* feature where that is true.
|
|
385
|
+
*/
|
|
386
|
+
get suggestionsBlocked() {
|
|
387
|
+
if (this.blocked)
|
|
388
|
+
return true;
|
|
389
|
+
const active = this.activeVendor;
|
|
390
|
+
return !!active && this.isVendorBlocked(active);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The vendor the NEXT send resolves to — the registry default's provider, as
|
|
394
|
+
* reported through `providerStatuses`. `undefined` while the statuses are
|
|
395
|
+
* still loading (or when the default reports the `'none'` sentinel), which
|
|
396
|
+
* every consumer treats conservatively.
|
|
397
|
+
*/
|
|
398
|
+
get activeVendor() {
|
|
399
|
+
var _a, _b;
|
|
400
|
+
const vendor = (_b = (_a = this.deps.providerStatuses().find((e) => e.isDefault)) === null || _a === void 0 ? void 0 : _a.status) === null || _b === void 0 ? void 0 : _b.provider;
|
|
401
|
+
return vendor && vendor !== 'none' ? vendor : undefined;
|
|
402
|
+
}
|
|
403
|
+
/** Host override for the banner copy, or `null` for the element's default. */
|
|
404
|
+
get blockedReason() {
|
|
405
|
+
var _a, _b;
|
|
406
|
+
return (_b = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.blockedReason) !== null && _b !== void 0 ? _b : null;
|
|
407
|
+
}
|
|
408
|
+
set blockedReason(value) {
|
|
409
|
+
var _a;
|
|
410
|
+
// Deliberately NOT `setBlocked({ blocked: this.blocked, reason })`: `blocked`
|
|
411
|
+
// is derived now, so re-latching with it would pass `false` for a partially
|
|
412
|
+
// walled session and clear every per-vendor latch as a side effect of setting
|
|
413
|
+
// a string.
|
|
414
|
+
(_a = this.deps.sessionRef()) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlockedReason(value);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Block or unblock the assistant, optionally with custom banner copy.
|
|
418
|
+
*
|
|
419
|
+
* Blocking writes the **vendor-agnostic** block — what a host with one budget
|
|
420
|
+
* pot means — and does not populate {@link BudgetController.blockedVendors}.
|
|
421
|
+
* Unblocking clears the per-vendor walls as well, so a host asserting "the wall
|
|
422
|
+
* is gone" after its own pre-flight cannot be silently overruled by a driver
|
|
423
|
+
* latch it never saw.
|
|
424
|
+
*/
|
|
425
|
+
setBlocked(blocked, reason) {
|
|
426
|
+
var _a;
|
|
427
|
+
(_a = this.deps.sessionRef()) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked, reason });
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Latch the backend block off a turn outcome. The single decision point for
|
|
431
|
+
* both latch sites (the `tool-loop-end` bus subscription and `send()`'s
|
|
432
|
+
* return-value check), so the rule cannot diverge between them.
|
|
433
|
+
*
|
|
434
|
+
* Idempotent and one-way **per vendor**: it never unblocks, and it never
|
|
435
|
+
* re-writes an existing wall. That guard is load-bearing, not just an
|
|
436
|
+
* optimisation — the bus fires from the driver's `finally`, i.e. BEFORE
|
|
437
|
+
* `sendMessage()` resolves, so a host subscribed to `tool-loop-end` (what the
|
|
438
|
+
* migration guide's Option C recommends) sets its own detailed reason and the
|
|
439
|
+
* return-value latch would otherwise land a moment later and blank it.
|
|
440
|
+
*
|
|
441
|
+
* The guard being per-vendor rather than global is the whole difference. A
|
|
442
|
+
* single global "already blocked, do nothing" would swallow a second vendor's
|
|
443
|
+
* wall entirely.
|
|
444
|
+
*
|
|
445
|
+
* **Vendor attribution, in precedence order:**
|
|
446
|
+
*
|
|
447
|
+
* 1. `budget.vendorLabel` — stamped by the transport that was actually refused,
|
|
448
|
+
* so it is the only source that cannot be a guess. A label the element does
|
|
449
|
+
* not recognise leaves it unclaimed by any vendor.
|
|
450
|
+
* 2. `budget.vendor` — which the driver resolved from the label where it could,
|
|
451
|
+
* and otherwise from the proxy's own `vendor` field on the 402. It is
|
|
452
|
+
* therefore NOT simply `vendorLabel` normalised, and the two can disagree.
|
|
453
|
+
* 3. `vendorHint` — the bus detail's top-level vendor, i.e. the driver's
|
|
454
|
+
* best guess at the turn's vendor, which may be the previous turn's vendor
|
|
455
|
+
* or nothing at all.
|
|
456
|
+
*
|
|
457
|
+
* A wall that names no recognised vendor falls back to the vendor-agnostic
|
|
458
|
+
* block — fail-safe, and identical to the pre-per-vendor behaviour.
|
|
459
|
+
*
|
|
460
|
+
* **`otherVendorAvailable: false` is server-known truth and outranks every
|
|
461
|
+
* local guess.** Only the proxy knows whether anything else has headroom; this
|
|
462
|
+
* element can only observe which vendors the registry can reach. So when the
|
|
463
|
+
* proxy says no, every other *budgeted* vendor is walled in the same pass. That
|
|
464
|
+
* is what makes `blocked` derive true — locking the composer on the first wall
|
|
465
|
+
* rather than leaving it live against a vendor set the banner would otherwise
|
|
466
|
+
* have advised switching to.
|
|
467
|
+
*
|
|
468
|
+
* The sweep runs over `BUDGETED_VENDORS`, NOT
|
|
469
|
+
* {@link BudgetController.reachableVendors}: reachability is loaded
|
|
470
|
+
* asynchronously, so a reachability-scoped sweep dropped the verdict entirely
|
|
471
|
+
* when the wall beat the statuses home — making it effectively un-latch, since
|
|
472
|
+
* the single wall derived `blocked` only while nothing was reachable, and the
|
|
473
|
+
* composer came back to life against a budget the proxy had already said was
|
|
474
|
+
* gone. Sweeping the metered list instead makes the verdict independent of load
|
|
475
|
+
* order. Walling a vendor the registry cannot reach costs nothing: `blocked`
|
|
476
|
+
* only asks whether every REACHABLE vendor is walled, and the banner reads
|
|
477
|
+
* {@link BudgetController.relevantBlockedVendors}.
|
|
478
|
+
*
|
|
479
|
+
* One boundary remains on the sweep: it walls only vendors the proxy meters,
|
|
480
|
+
* because a vendor it does not meter has no budget there to exhaust, so "no
|
|
481
|
+
* other vendor has budget" is not a statement about it.
|
|
482
|
+
*
|
|
483
|
+
* The sweep runs last but sits OUTSIDE the per-vendor idempotence guard, and
|
|
484
|
+
* both halves of that matter. Last, so the vendor that actually refused this
|
|
485
|
+
* turn heads the wall order the multi-vendor banner reads. Outside the guard,
|
|
486
|
+
* because the verdict can arrive on a LATER 402 for a vendor that is already
|
|
487
|
+
* walled.
|
|
488
|
+
*
|
|
489
|
+
* @param reason - the turn's failure reason; anything but `'budget-exhausted'` is ignored.
|
|
490
|
+
* @param ref - the session store to write through, captured by the caller so a
|
|
491
|
+
* teardown race cannot land the wall in a new session's store.
|
|
492
|
+
* @param budget - what the 402 reported: figures when the proxy sent any, the
|
|
493
|
+
* refusing vendor, and its `otherVendorAvailable` verdict.
|
|
494
|
+
* @param vendorHint - the `tool-loop-end` detail's top-level vendor, used only
|
|
495
|
+
* when the budget payload names none.
|
|
496
|
+
*/
|
|
497
|
+
latchFrom(reason, ref = this.deps.sessionRef(), budget, vendorHint) {
|
|
498
|
+
var _a, _b;
|
|
499
|
+
if (reason !== 'budget-exhausted')
|
|
500
|
+
return;
|
|
501
|
+
const vendor = (_b = (_a = vendorTypeOfLabel(budget === null || budget === void 0 ? void 0 : budget.vendorLabel)) !== null && _a !== void 0 ? _a : budget === null || budget === void 0 ? void 0 : budget.vendor) !== null && _b !== void 0 ? _b : vendorHint;
|
|
502
|
+
// Meter feed (GENC-1464): snap this vendor's meter to the figures the 402
|
|
503
|
+
// itself reported, the moment the wall lands. Hosts refresh the meter on
|
|
504
|
+
// `tool-loop-end`, and a walled turn may never reach one — the user then
|
|
505
|
+
// stares at the pre-turn figures next to a banner saying the budget is gone
|
|
506
|
+
// until something else ends a loop (observed live: the meter only caught up
|
|
507
|
+
// when the user cancelled the stuck interaction). Deliberately NOT under the
|
|
508
|
+
// per-vendor idempotence guard below, for the same reason as the sweep: a
|
|
509
|
+
// later 402 for an already-walled vendor carries fresher spend. Both figures
|
|
510
|
+
// are required (`VendorBudgetFigures` is total, and half a meter is a lie),
|
|
511
|
+
// and the write goes through the captured `ref` like every other write here,
|
|
512
|
+
// so a teardown race cannot land it in a new session's store.
|
|
513
|
+
let metered = false;
|
|
514
|
+
if (vendor && vendor !== 'none' && (budget === null || budget === void 0 ? void 0 : budget.budgetUsd) != null && (budget === null || budget === void 0 ? void 0 : budget.spentUsd) != null) {
|
|
515
|
+
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBudget({
|
|
516
|
+
vendor,
|
|
517
|
+
figures: { budgetUsd: budget.budgetUsd, spentUsd: budget.spentUsd },
|
|
518
|
+
});
|
|
519
|
+
this.meterLastFedAt = Date.now();
|
|
520
|
+
metered = true;
|
|
521
|
+
}
|
|
522
|
+
if (!vendor || vendor === 'none') {
|
|
523
|
+
if (!this.blocked) {
|
|
524
|
+
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else if (!this.isVendorBlocked(vendor) || this.isVendorSwept(vendor)) {
|
|
528
|
+
// The idempotence guard protects a host-supplied reason from being
|
|
529
|
+
// overwritten by this latch's boilerplate — but a SWEPT wall cannot carry
|
|
530
|
+
// one (the sweep writes no reason, and any host call clears the swept
|
|
531
|
+
// mark), so upgrading it on a first-hand refusal clobbers nothing and
|
|
532
|
+
// gains the vendor its own figures plus a place in the early-window
|
|
533
|
+
// banner (see `relevantBlockedVendors`).
|
|
534
|
+
if (this.reachableVendors.length === 0) {
|
|
535
|
+
// Not fatal — `blocked` still derives to true, so the UI is correct — but
|
|
536
|
+
// the host gets no per-vendor benefit and would otherwise have no signal.
|
|
537
|
+
logger.warn(`FoundationAiAssistant: ${vendor} hit its AI budget, but no registered provider counts ` +
|
|
538
|
+
'as reachable, so per-vendor blocking degrades to blocking everything. Two causes: no ' +
|
|
539
|
+
'provider reports a status (implement AIProvider.getStatus()), or every provider that ' +
|
|
540
|
+
'does is chat-less and so cannot serve a turn (implement chat() on at least one).');
|
|
541
|
+
}
|
|
542
|
+
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({
|
|
543
|
+
vendor,
|
|
544
|
+
blocked: true,
|
|
545
|
+
// No reason when the meter snap above already stored these figures: the
|
|
546
|
+
// banner composes the same sentence from them, so carrying a copy here
|
|
547
|
+
// would duplicate the dollars in two places that can drift apart. It would
|
|
548
|
+
// also make this latch INDISTINGUISHABLE from host copy, which the banner
|
|
549
|
+
// must preserve verbatim — and preserving boilerplate is what forced it to
|
|
550
|
+
// repeat a sentence per walled vendor instead of folding them into one.
|
|
551
|
+
// The partial-figures case still needs copy carried here: it has no meter
|
|
552
|
+
// entry to compose from, and "an unknown amount of $50.00" is worth saying.
|
|
553
|
+
reason: metered ? undefined : formatBlockedReason(budget, vendor),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
// Last, so the vendor that actually refused this turn heads the wall order the
|
|
557
|
+
// multi-vendor banner reads — and unconditionally, NOT under the per-vendor
|
|
558
|
+
// idempotence guard above, because the verdict can arrive on a later 402 for a
|
|
559
|
+
// vendor that is already walled.
|
|
560
|
+
if ((budget === null || budget === void 0 ? void 0 : budget.otherVendorAvailable) !== false)
|
|
561
|
+
return;
|
|
562
|
+
for (const other of BUDGETED_VENDORS) {
|
|
563
|
+
if (other === vendor || this.isVendorBlocked(other))
|
|
564
|
+
continue;
|
|
565
|
+
// No reason: the slice's "an omitted reason keeps the existing explanation"
|
|
566
|
+
// rule applies, and there are no figures for this vendor — only the proxy's
|
|
567
|
+
// word that it has nothing left. `swept: true` records exactly that
|
|
568
|
+
// provenance, so the unknown-reachability banner can decline to name it.
|
|
569
|
+
ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({ vendor: other, blocked: true, swept: true });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Copy shown in the blocked banner, in ascending specificity:
|
|
574
|
+
*
|
|
575
|
+
* 1. the host's `blockedReason` — a whole-banner override, unchanged;
|
|
576
|
+
* 2. copy composed from the walled vendors (their per-vendor reasons when set,
|
|
577
|
+
* else their figures);
|
|
578
|
+
* 3. the default budget-exhaustion message.
|
|
579
|
+
*
|
|
580
|
+
* The action clause is composed HERE, not stored at latch time, because whether
|
|
581
|
+
* "switch vendor" or "contact your administrator" is honest depends on whether
|
|
582
|
+
* another reachable vendor still has headroom — which changes when the registry
|
|
583
|
+
* changes, long after the wall was latched. Telling a user to switch to a vendor
|
|
584
|
+
* that is also exhausted is worse than saying nothing.
|
|
585
|
+
*
|
|
586
|
+
* **Per-vendor copy is composed however many vendors are walled**, which is what
|
|
587
|
+
* `setVendorBlocked`'s `reason` parameter promises. It used to be honoured only
|
|
588
|
+
* on the single-vendor branch, so a host that had carefully set copy for each of
|
|
589
|
+
* its vendors watched all of it vanish the moment a second one walled.
|
|
590
|
+
*/
|
|
591
|
+
get effectiveBlockedReason() {
|
|
592
|
+
var _a, _b;
|
|
593
|
+
if (this.blockedReason)
|
|
594
|
+
return this.blockedReason;
|
|
595
|
+
const walled = this.relevantBlockedVendors;
|
|
596
|
+
if (walled.length === 0)
|
|
597
|
+
return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
|
|
598
|
+
const free = this.reachableVendors.filter((v) => !walled.includes(v));
|
|
599
|
+
const action = free.length
|
|
600
|
+
? `Switch to ${formatVendorList(free)} to keep going.`
|
|
601
|
+
: walled.length > 1
|
|
602
|
+
? RAISE_LIMITS_ACTION
|
|
603
|
+
: RAISE_LIMIT_ACTION;
|
|
604
|
+
const reasons = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.blockedVendorReasons;
|
|
605
|
+
const budgets = (_b = this.slice) === null || _b === void 0 ? void 0 : _b.vendorBudgets;
|
|
606
|
+
// The wall's own figures, read from the entry the METER renders, so the two
|
|
607
|
+
// cannot state different dollars for the same vendor. A vendor with no entry
|
|
608
|
+
// (a swept wall — the proxy's word, no figures of its own) contributes a bare
|
|
609
|
+
// name rather than a gap where numbers should be.
|
|
610
|
+
const statementFor = (v) => {
|
|
611
|
+
var _a, _b;
|
|
612
|
+
return (_b = (_a = reasons === null || reasons === void 0 ? void 0 : reasons[v]) !== null && _a !== void 0 ? _a : formatBlockedReason(budgets === null || budgets === void 0 ? void 0 : budgets[v], v)) !== null && _b !== void 0 ? _b : `${vendorDisplayName(v)}'s AI usage limit is reached.`;
|
|
613
|
+
};
|
|
614
|
+
const nameWithFigures = (v) => {
|
|
615
|
+
const figures = budgets === null || budgets === void 0 ? void 0 : budgets[v];
|
|
616
|
+
return figures
|
|
617
|
+
? `${vendorDisplayName(v)} (${formatUsd(figures.spentUsd)} of ${formatUsd(figures.budgetUsd)})`
|
|
618
|
+
: vendorDisplayName(v);
|
|
619
|
+
};
|
|
620
|
+
if (walled.length === 1)
|
|
621
|
+
return `${statementFor(walled[0])} ${action}`;
|
|
622
|
+
// Several walled vendors: fold them into ONE sentence carrying each vendor's
|
|
623
|
+
// figures, rather than repeating "X's AI usage limit is reached (…)." per
|
|
624
|
+
// vendor — the same clause twice reads like a stutter, and the second copy of
|
|
625
|
+
// the boilerplate adds nothing the first did not already say.
|
|
626
|
+
//
|
|
627
|
+
// Host copy is not foldable: `setVendorBlocked` promises it survives verbatim,
|
|
628
|
+
// and it cannot be rewritten into a list item. So the moment any walled vendor
|
|
629
|
+
// carries some, the branch below lists one statement per vendor instead —
|
|
630
|
+
// falling back to composed copy for the ones that have none, so the sentence
|
|
631
|
+
// set stays complete rather than silently naming a subset.
|
|
632
|
+
if (!walled.some((v) => reasons === null || reasons === void 0 ? void 0 : reasons[v])) {
|
|
633
|
+
return `AI usage limits are reached for ${joinList(walled.map(nameWithFigures))}. ${action}`;
|
|
634
|
+
}
|
|
635
|
+
return `${walled.map(statementFor).join(' ')} ${action}`;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall,
|
|
639
|
+
* or `undefined` to let the driver compose it per wall.
|
|
640
|
+
*
|
|
641
|
+
* A set `blockedReason` still wins — a white-labelled host must not read its own
|
|
642
|
+
* explanation in the banner and shipped copy directly below it.
|
|
643
|
+
*
|
|
644
|
+
* `undefined` (the usual case) is NOT the old `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`
|
|
645
|
+
* fallback: it hands composition to the driver, which builds the bubble from
|
|
646
|
+
* EACH wall's own 402 — naming the exhausted vendor and advising a switch only
|
|
647
|
+
* when that refusal's `otherVendorAvailable` positively vouched for headroom
|
|
648
|
+
* elsewhere. That is immune to the staleness this getter used to guard against
|
|
649
|
+
* by freezing the copy: construction-time advice could outlive its truth
|
|
650
|
+
* (`getOrCreateDriver` re-runs on an agents swap and would have baked the
|
|
651
|
+
* sentence into every later bubble), but per-wall advice is derived from the
|
|
652
|
+
* refusal it annotates and dies with the turn. Forcing the default here was also
|
|
653
|
+
* what made the driver's composed bubble unreachable in every real mount — the
|
|
654
|
+
* banner advised switching while the bubble under it never did (the GENC-1464
|
|
655
|
+
* tester finding, root cause).
|
|
656
|
+
*/
|
|
657
|
+
get transcriptMessage() {
|
|
658
|
+
var _a;
|
|
659
|
+
return (_a = this.blockedReason) !== null && _a !== void 0 ? _a : undefined;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Built-in per-vendor budget meter (GENC-1464) — the money sibling of the
|
|
663
|
+
* settings modal's context-usage gate, with the same shape: not configured off,
|
|
664
|
+
* and there is data to show. "Data" here is "at least one vendor has figures",
|
|
665
|
+
* so a host that feeds nothing (or only unlimited vendors) gets no meter and no
|
|
666
|
+
* empty container.
|
|
667
|
+
*
|
|
668
|
+
* Answered from the RAW slice state rather than from
|
|
669
|
+
* {@link BudgetController.settingsRows}, whose `.length` would format every row
|
|
670
|
+
* (a `formatUsd` pair and a `budgetPercentOf` each) only to ask whether any
|
|
671
|
+
* exist. This is read several times per render pass — the getter, the section
|
|
672
|
+
* gate, the template's `when` — so it stays as cheap as the context sibling's
|
|
673
|
+
* `!= null`.
|
|
674
|
+
*/
|
|
675
|
+
get settingsUsageVisible() {
|
|
676
|
+
var _a, _b;
|
|
677
|
+
const budgets = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.vendorBudgets;
|
|
678
|
+
return (((_b = this.deps.chatConfig().ui) === null || _b === void 0 ? void 0 : _b.showBudgetUsage) !== false &&
|
|
679
|
+
budgets != null &&
|
|
680
|
+
// Keyed off BUDGETED_VENDORS, exactly as the rows are, so a key outside
|
|
681
|
+
// that list could never surface a meter with no row to show.
|
|
682
|
+
BUDGETED_VENDORS.some((vendor) => budgets[vendor] != null));
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* The meter's rows — one per vendor that currently has figures, in
|
|
686
|
+
* `BUDGETED_VENDORS` order (the proxy's own metered list) so the rendering is
|
|
687
|
+
* stable however the host ordered its feed calls. Pre-formatted; see
|
|
688
|
+
* {@link SettingsBudgetRow} for why the text is unclamped while the bar is.
|
|
689
|
+
*/
|
|
690
|
+
get settingsRows() {
|
|
691
|
+
var _a;
|
|
692
|
+
const budgets = (_a = this.slice) === null || _a === void 0 ? void 0 : _a.vendorBudgets;
|
|
693
|
+
if (!budgets)
|
|
694
|
+
return [];
|
|
695
|
+
const rows = [];
|
|
696
|
+
for (const vendor of BUDGETED_VENDORS) {
|
|
697
|
+
const figures = budgets[vendor];
|
|
698
|
+
if (!figures)
|
|
699
|
+
continue;
|
|
700
|
+
const percent = budgetPercentOf(figures);
|
|
701
|
+
rows.push({
|
|
702
|
+
vendor,
|
|
703
|
+
label: vendorDisplayName(vendor),
|
|
704
|
+
figures: `${formatUsd(figures.spentUsd)} / ${formatUsd(figures.budgetUsd)} (${percent}%)`,
|
|
705
|
+
// The same clamp the context indicator applies to its bar. No lower
|
|
706
|
+
// clamp needed: the boundary refuses negative figures.
|
|
707
|
+
barValue: Math.min(100, percent),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
return rows;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
__decorate([
|
|
714
|
+
observable
|
|
715
|
+
], BudgetController.prototype, "meterLastFedAt", void 0);
|
|
716
|
+
__decorate([
|
|
717
|
+
volatile
|
|
718
|
+
], BudgetController.prototype, "blocked", null);
|
|
719
|
+
__decorate([
|
|
720
|
+
volatile
|
|
721
|
+
], BudgetController.prototype, "reachableVendors", null);
|
|
722
|
+
__decorate([
|
|
723
|
+
volatile
|
|
724
|
+
], BudgetController.prototype, "updatedLabel", null);
|
|
725
|
+
__decorate([
|
|
726
|
+
volatile
|
|
727
|
+
], BudgetController.prototype, "relevantBlockedVendors", null);
|
|
728
|
+
__decorate([
|
|
729
|
+
volatile
|
|
730
|
+
], BudgetController.prototype, "bannerVisible", null);
|
|
731
|
+
__decorate([
|
|
732
|
+
volatile
|
|
733
|
+
], BudgetController.prototype, "suggestionsBlocked", null);
|
|
734
|
+
__decorate([
|
|
735
|
+
volatile
|
|
736
|
+
], BudgetController.prototype, "activeVendor", null);
|
|
737
|
+
__decorate([
|
|
738
|
+
volatile
|
|
739
|
+
], BudgetController.prototype, "effectiveBlockedReason", null);
|
|
740
|
+
__decorate([
|
|
741
|
+
volatile
|
|
742
|
+
], BudgetController.prototype, "settingsUsageVisible", null);
|
|
743
|
+
__decorate([
|
|
744
|
+
volatile
|
|
745
|
+
], BudgetController.prototype, "settingsRows", null);
|