@genesislcap/ai-assistant 15.14.0 → 15.14.2
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 +37 -1
- package/dist/ai-assistant.d.ts +55 -0
- package/dist/chat-driver.cjs +285 -9
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +282 -9
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +101 -3
- package/dist/dts/chat-driver-node.d.ts +1 -1
- package/dist/dts/chat-driver-node.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +54 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
- package/dist/esm/chat-driver-node.js +9 -1
- package/dist/esm/components/chat-driver/chat-driver.js +154 -22
- package/dist/esm/components/chat-driver/chat-driver.test.js +210 -1
- package/dist/esm/state/debug-event-log.js +1 -1
- package/dist/esm/state/debug-event-log.test.js +2 -0
- package/docs/migration-GENC-1506.md +209 -0
- package/package.json +17 -17
- package/src/chat-driver-node.ts +10 -0
- package/src/components/chat-driver/chat-driver.test.ts +294 -0
- package/src/components/chat-driver/chat-driver.ts +215 -9
- package/src/state/debug-event-log.test.ts +2 -0
- package/src/state/debug-event-log.ts +1 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Migration Guide — GENC-1506 (the provider refused: a named, non-retried failure)
|
|
2
|
+
|
|
3
|
+
> **Additive — no migration required.** Nothing was removed, renamed, or given a new meaning. Every
|
|
4
|
+
> existing event name, public member, template `part` and failure reason behaves exactly as before.
|
|
5
|
+
> Upgrade, and the visible change is that a provider refusing your account produces one clear
|
|
6
|
+
> sentence instead of a generic apology — and stops costing doomed requests to discover.
|
|
7
|
+
>
|
|
8
|
+
> **There is deliberately no new UI.** Unlike GENC-1464 this adds no banner, no blocked state and no
|
|
9
|
+
> latch. See [Why no banner](#why-no-banner).
|
|
10
|
+
>
|
|
11
|
+
> All surfaces below are `@beta`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why
|
|
16
|
+
|
|
17
|
+
When Anthropic refuses a request because the **account** cannot be served — its credit is gone, a
|
|
18
|
+
usage cap is reached, or the key is dead — nothing in the stack knew what that meant. The refusal fell
|
|
19
|
+
out of the transport as an untyped `Error`, landed in the chat driver's transient-retry catch, was
|
|
20
|
+
re-issued up to `MAX_SETUP_TRANSPORT_RETRIES` times against a wall no retry can clear, and the user
|
|
21
|
+
finally got:
|
|
22
|
+
|
|
23
|
+
> Sorry, something went wrong on my end. Please try again in a moment.
|
|
24
|
+
|
|
25
|
+
Wrong twice over: nothing went wrong on our end, and trying again will not help.
|
|
26
|
+
|
|
27
|
+
This is **not** the AI-spend budget wall (GENC-1464). That one is *us* declining to spend more on a
|
|
28
|
+
caller's behalf, and an administrator can raise the cap. This one is the *vendor* declining to serve
|
|
29
|
+
us at all, and nothing in our own system can clear it. The two were being conflated in code — see
|
|
30
|
+
[The 402 collision](#the-402-collision).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## What was added
|
|
35
|
+
|
|
36
|
+
### `@genesislcap/foundation-ai`
|
|
37
|
+
|
|
38
|
+
| Addition | Kind | Notes |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `ProviderRefusedError` | exported class | Fields: `vendorLabel`, `kind`, `upstreamStatus?`, `upstreamType?`, `detail?` |
|
|
41
|
+
| `ProviderRefusalKind` | exported type | `'spend' \| 'auth'` |
|
|
42
|
+
| `DEFAULT_PROVIDER_REFUSED_MESSAGE` | exported const | The one sentence shown to the user |
|
|
43
|
+
| `PROVIDER_REFUSED_CODE` | exported const | `'PROVIDER_REFUSED'` — the proxy's wire code |
|
|
44
|
+
| `'provider-refused'` | new `TurnFailureReason` member | Rides `ChatDriverResult.failureReason` and the `tool-loop-end` detail |
|
|
45
|
+
| `'provider_refused'` | new `SubAgentFailureReason` member | Terminal for the parent turn too |
|
|
46
|
+
| `ChatDriverResult.providerRefused` | new optional field on the `done` arm | `{ vendorLabel, kind, upstreamStatus?, upstreamType? }` |
|
|
47
|
+
|
|
48
|
+
### `@genesislcap/ai-assistant`
|
|
49
|
+
|
|
50
|
+
| Addition | Kind |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `ChatDriverConfig.providerRefusedMessage` | new optional field — overrides the sentence |
|
|
53
|
+
| `DEFAULT_PROVIDER_REFUSED_MESSAGE`, `PROVIDER_REFUSED_CODE`, `ProviderRefusedError` | re-exported from the `./chat-driver` (Node) entry |
|
|
54
|
+
|
|
55
|
+
The chat driver gained a typed branch that sits **before** the transient-retry catch. It does not
|
|
56
|
+
retry; it appends the sentence to the transcript (or calls `failSubAgent('provider_refused')` when
|
|
57
|
+
running as a sub-agent) and ends the turn with `failureReason: 'provider-refused'`.
|
|
58
|
+
|
|
59
|
+
### Across the sub-agent boundary
|
|
60
|
+
|
|
61
|
+
`getSubAgentFailure()` now carries a `providerRefused` payload alongside the existing `budget` one, and
|
|
62
|
+
`invokeSubAgent()` latches the parent's terminal state from it.
|
|
63
|
+
|
|
64
|
+
Both halves are needed for a reason that is easy to miss: **a sub-agent is a separate driver instance**,
|
|
65
|
+
so anything the child sets on itself is invisible to the parent. The refusal reaches the parent only as a
|
|
66
|
+
tool result. Without the latch that result is ordinary, the parent issues another model call straight into
|
|
67
|
+
the same wall, and — if that call happens to succeed — the turn can finish with no `failureReason` at all,
|
|
68
|
+
reporting a clean turn over a dead account. Without the payload the parent can report `provider-refused`
|
|
69
|
+
but not which vendor or which kind, which is the only signal distinguishing "top up" from "rotate the
|
|
70
|
+
key".
|
|
71
|
+
|
|
72
|
+
First attribution wins (`??=`), matching `budgetWallDetail`: batched delegations can pair an attributable
|
|
73
|
+
child refusal with an unattributable one, and a plain assignment would let the later `undefined` erase
|
|
74
|
+
what the earlier child knew.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## One error, two kinds
|
|
79
|
+
|
|
80
|
+
`kind` distinguishes the operator's fix while the user's experience stays identical:
|
|
81
|
+
|
|
82
|
+
- **`spend`** — the account cannot pay: credit balance at zero, a workspace/organisation usage cap, or
|
|
83
|
+
a billing/payment-information problem. Someone must top up or raise a cap.
|
|
84
|
+
- **`auth`** — the credential cannot be used: revoked, expired or malformed key (`401`), or a key
|
|
85
|
+
without access to the requested model (`403`). Someone must rotate a key or grant access.
|
|
86
|
+
|
|
87
|
+
Both produce the **same sentence**. The kind reaches `ChatDriverResult.providerRefused.kind`, the
|
|
88
|
+
`turn.error` debug entry, and the transport error — never the transcript. That is deliberate: the copy
|
|
89
|
+
is cause-free (see below), so `kind` is the only place the distinction survives, and a host's own
|
|
90
|
+
alerting is what it exists for.
|
|
91
|
+
|
|
92
|
+
Two designs were rejected. A type named for spend would *lie* about an expired key, and a debug
|
|
93
|
+
timeline reading `provider-spend-refused` for an auth fault misleads the next engineer. Two separate
|
|
94
|
+
error types would duplicate a branch, a message and a wire code to express one outcome.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## The copy, and the constraints on it
|
|
99
|
+
|
|
100
|
+
> AI requests are currently being refused by the provider. This isn't related to your account or your
|
|
101
|
+
> usage and needs no action from you — please contact your support team so it can be restored.
|
|
102
|
+
|
|
103
|
+
It names **no cause**, and a rewrite has to keep it that way. Three constraints:
|
|
104
|
+
|
|
105
|
+
1. **It must not sound like a cap.** Users can often see their remaining AI spend in the same UI, so
|
|
106
|
+
*allowance / limit / quota / budget / usage / credit / balance* risk a visible self-contradiction —
|
|
107
|
+
a message saying there is nothing left, beside a figure showing there is. "or your usage" goes
|
|
108
|
+
further than avoiding the words: it pre-empts the user checking that figure and concluding the
|
|
109
|
+
message is wrong.
|
|
110
|
+
2. **It must not imply the bill went unpaid.** *Billing / payment / funds / top up* all read that way.
|
|
111
|
+
3. **It must not promise self-healing.** Clearing this needs a human, so "currently" not
|
|
112
|
+
"temporarily", and "so it can be restored" signals that someone must act.
|
|
113
|
+
|
|
114
|
+
Vendor-neutral and deployment-neutral, so it is already safe for a white-labelled host. Override with
|
|
115
|
+
`ChatDriverConfig.providerRefusedMessage` if you want your own wording.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Why no banner
|
|
120
|
+
|
|
121
|
+
GENC-1464 gave the budget wall a latched banner, a locked composer and per-vendor blocking state. This
|
|
122
|
+
deliberately reuses **none** of it (Matt, 2026-08-18): a provider refusal is a distinctly separate
|
|
123
|
+
condition from a user's own spend allowance and must not borrow its UI.
|
|
124
|
+
|
|
125
|
+
Consequences, both accepted:
|
|
126
|
+
|
|
127
|
+
- **The composer stays live.** A user can send again straight into the same wall. With the
|
|
128
|
+
classification in place that costs exactly **one** refused request per send rather than the 3–18 it
|
|
129
|
+
cost before, and the sentence is self-explanatory.
|
|
130
|
+
- **Nothing survives a reload**, because there is no latched state — and nothing needs to. There is
|
|
131
|
+
also no pre-flight available even in principle: a refused account rejects `count_tokens` with the
|
|
132
|
+
same error, so there is no free way to ask "can this account spend?".
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## The 402 collision
|
|
137
|
+
|
|
138
|
+
`isBudgetRejection` treats **any** `402` as our own budget verdict, and `402` is also Anthropic's
|
|
139
|
+
documented `billing_error` status. A relayed upstream billing failure was therefore reported as the
|
|
140
|
+
*user's* spend cap — locking their composer and sending them to an administrator to raise a limit that
|
|
141
|
+
had headroom.
|
|
142
|
+
|
|
143
|
+
Closed by **ordering plus positive detection**, not by loosening the budget predicate:
|
|
144
|
+
`providerRefusalOf` runs first in `postWithRetry`, and every trigger in it is a positive signal, so a
|
|
145
|
+
bare `402` with no provider envelope still falls through to the budget branch exactly as before. Both
|
|
146
|
+
directions are pinned by tests in `post-with-retry.test.ts`.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Detection: measured, not assumed
|
|
151
|
+
|
|
152
|
+
The classifier is built from **verbatim captures** of the real Anthropic API (2026-08-18), and the
|
|
153
|
+
fixtures in `provider-refused.test.ts` are those responses. This matters because the obvious design —
|
|
154
|
+
classify on the documented `402 billing_error` — would have fixed **nothing**:
|
|
155
|
+
|
|
156
|
+
| Condition | Actual response |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| Credit balance at zero | `400` + `invalid_request_error` + "Your credit balance is too low to access the Anthropic API…" |
|
|
159
|
+
| Workspace usage cap | `400` + `invalid_request_error` + "You have reached your specified workspace API usage limits…" |
|
|
160
|
+
| Invalid key | `401` + `authentication_error` + "API key is invalid." (`request_id` is **null**) |
|
|
161
|
+
|
|
162
|
+
**Reading the `kind` off an explicit-code response.** When the proxy stamps `PROVIDER_REFUSED`, the kind
|
|
163
|
+
is resolved from a stated `kind` in **either** wire position (top level on a JSON body, under `details` on
|
|
164
|
+
an err frame), then from the relayed provider envelope's own type, and only then defaults to `spend`.
|
|
165
|
+
Reading the top level alone reported every auth refusal through our own proxy as `spend` — it stamps the
|
|
166
|
+
code with the envelope in `details` and no top-level `kind` — which sends an operator to top up an account
|
|
167
|
+
when the fix is a credential rotation. Invisible on screen, since the copy is identical either way.
|
|
168
|
+
|
|
169
|
+
Both spend refusals arrive in the **generic** bucket with no distinct type, no dedicated header and
|
|
170
|
+
nothing structural — so a small list of independent phrase signals (`credit balance`, `usage limit`, …)
|
|
171
|
+
is the only handle available. `auth` needs no prose: status plus `error.type` is enough.
|
|
172
|
+
|
|
173
|
+
False positives are near-impossible for a non-obvious reason: on a refused account the spend check runs
|
|
174
|
+
*before* request validation, so a genuine schema `400` is only observable on a healthy account, where
|
|
175
|
+
its message quotes field paths and matches nothing in the list.
|
|
176
|
+
|
|
177
|
+
**Gemini is deliberately not classified.** Google surfaces a spend cap as `429 RESOURCE_EXHAUSTED` —
|
|
178
|
+
the same status *and message* as an ordinary rate limit, and `429` is retryable — so a prose probe
|
|
179
|
+
there would break the ladder. The structural discriminator that would work
|
|
180
|
+
(`details[].QuotaFailure.violations[].quotaId`) is only partly observed. `providerRefusalOf` dispatches
|
|
181
|
+
on vendor, so adding Gemini later is a branch, not a rewrite.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Also in this change: the retry ladder honours the server
|
|
186
|
+
|
|
187
|
+
Found while measuring the Gemini rate limit, and folded in because it lives in the same function.
|
|
188
|
+
|
|
189
|
+
`postWithRetry`'s ladder was `base * 2^attempt` — 31s across five retries — and read neither
|
|
190
|
+
`Retry-After` nor Google's in-body `RetryInfo`. A real rate-limited key asked for **53s**, so a
|
|
191
|
+
*legitimate* rate limit burned every retry inside the window we had been told to wait, then failed
|
|
192
|
+
opaquely.
|
|
193
|
+
|
|
194
|
+
Now: the wait is `max(our ladder, what the server asked)`, bounded twice — `MAX_SERVER_REQUESTED_BACKOFF_MS`
|
|
195
|
+
(60s) per attempt, and `MAX_TOTAL_SERVER_REQUESTED_BACKOFF_MS` (120s) across the whole sequence. An
|
|
196
|
+
explicit `x-should-retry: false` stops the ladder even on a retryable status. An **absent**
|
|
197
|
+
`x-should-retry` means "no opinion" and changes nothing — never read as `false`.
|
|
198
|
+
|
|
199
|
+
The sequence bound is not belt-and-braces. The per-attempt cap alone is no bound at all: the ladder
|
|
200
|
+
tops out at 16s, so a 60s hint wins on *every* attempt, and five of them park a turn for five minutes
|
|
201
|
+
with no timer running through any of them (the attempt's own timeout is cleared before each backoff, so
|
|
202
|
+
a caller abort is the only way out). Past the budget we stop honouring hints and fall back to the
|
|
203
|
+
ladder — declining a hint never stops the retrying. Only the *excess over the ladder* is charged, so
|
|
204
|
+
the ladder's own growth cannot exhaust the allowance on time we were always going to spend. Worst case
|
|
205
|
+
is now ~151s rather than 300s, and the 53s case the module was written for still fits twice over.
|
|
206
|
+
|
|
207
|
+
> **This is the one part of the change that affects healthy traffic.** Some failures that used to give
|
|
208
|
+
> up after 31s will now wait longer before succeeding or failing. That is correct and it is what the
|
|
209
|
+
> server asked for, but it is a timing change on a shared path, so review it on its own terms.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genesislcap/ai-assistant",
|
|
3
3
|
"description": "Genesis AI Assistant micro-frontend",
|
|
4
|
-
"version": "15.14.
|
|
4
|
+
"version": "15.14.2",
|
|
5
5
|
"license": "SEE LICENSE IN license.txt",
|
|
6
6
|
"main": "dist/esm/index.js",
|
|
7
7
|
"types": "dist/ai-assistant.d.ts",
|
|
@@ -73,26 +73,26 @@
|
|
|
73
73
|
}
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@genesislcap/foundation-testing": "15.14.
|
|
77
|
-
"@genesislcap/genx": "15.14.
|
|
78
|
-
"@genesislcap/rollup-builder": "15.14.
|
|
79
|
-
"@genesislcap/ts-builder": "15.14.
|
|
80
|
-
"@genesislcap/uvu-playwright-builder": "15.14.
|
|
81
|
-
"@genesislcap/vite-builder": "15.14.
|
|
82
|
-
"@genesislcap/webpack-builder": "15.14.
|
|
76
|
+
"@genesislcap/foundation-testing": "15.14.2",
|
|
77
|
+
"@genesislcap/genx": "15.14.2",
|
|
78
|
+
"@genesislcap/rollup-builder": "15.14.2",
|
|
79
|
+
"@genesislcap/ts-builder": "15.14.2",
|
|
80
|
+
"@genesislcap/uvu-playwright-builder": "15.14.2",
|
|
81
|
+
"@genesislcap/vite-builder": "15.14.2",
|
|
82
|
+
"@genesislcap/webpack-builder": "15.14.2",
|
|
83
83
|
"@types/dompurify": "^3.0.5",
|
|
84
84
|
"@types/marked": "^5.0.2",
|
|
85
85
|
"esbuild": "0.25.12"
|
|
86
86
|
},
|
|
87
87
|
"dependencies": {
|
|
88
|
-
"@genesislcap/foundation-ai": "15.14.
|
|
89
|
-
"@genesislcap/foundation-logger": "15.14.
|
|
90
|
-
"@genesislcap/foundation-notifications": "15.14.
|
|
91
|
-
"@genesislcap/foundation-redux": "15.14.
|
|
92
|
-
"@genesislcap/foundation-ui": "15.14.
|
|
93
|
-
"@genesislcap/foundation-utils": "15.14.
|
|
94
|
-
"@genesislcap/rapid-design-system": "15.14.
|
|
95
|
-
"@genesislcap/web-core": "15.14.
|
|
88
|
+
"@genesislcap/foundation-ai": "15.14.2",
|
|
89
|
+
"@genesislcap/foundation-logger": "15.14.2",
|
|
90
|
+
"@genesislcap/foundation-notifications": "15.14.2",
|
|
91
|
+
"@genesislcap/foundation-redux": "15.14.2",
|
|
92
|
+
"@genesislcap/foundation-ui": "15.14.2",
|
|
93
|
+
"@genesislcap/foundation-utils": "15.14.2",
|
|
94
|
+
"@genesislcap/rapid-design-system": "15.14.2",
|
|
95
|
+
"@genesislcap/web-core": "15.14.2",
|
|
96
96
|
"dompurify": "^3.3.1",
|
|
97
97
|
"marked": "^17.0.3"
|
|
98
98
|
},
|
|
@@ -105,5 +105,5 @@
|
|
|
105
105
|
"access": "public"
|
|
106
106
|
},
|
|
107
107
|
"customElements": "dist/custom-elements.json",
|
|
108
|
-
"gitHead": "
|
|
108
|
+
"gitHead": "bd1cb4a789548d9c7c6777f8adb7bce3f43c3317"
|
|
109
109
|
}
|
package/src/chat-driver-node.ts
CHANGED
|
@@ -43,6 +43,16 @@ export {
|
|
|
43
43
|
// GENC-1461, create PR #1644 review.
|
|
44
44
|
SUPPORTED_ANTHROPIC_MODEL_IDS,
|
|
45
45
|
SUPPORTED_GEMINI_MODEL_IDS,
|
|
46
|
+
// The provider wall (GENC-1506). Re-exported for the same anti-drift reason as the model lists
|
|
47
|
+
// above, and it matters more here because the other end of this contract is in ANOTHER REPO:
|
|
48
|
+
// genesis-create's ai-service stamps `PROVIDER_REFUSED_CODE` onto its proxy responses and shows
|
|
49
|
+
// `DEFAULT_PROVIDER_REFUSED_MESSAGE` on its own generation surfaces. Hand-copying either would
|
|
50
|
+
// drift silently — a renamed code stops classifying, and a second copy of the sentence survives
|
|
51
|
+
// the first reword. `ProviderRefusedError` is here so a headless caller can `instanceof` it
|
|
52
|
+
// against the same bundled instance the driver throws from.
|
|
53
|
+
DEFAULT_PROVIDER_REFUSED_MESSAGE,
|
|
54
|
+
PROVIDER_REFUSED_CODE,
|
|
55
|
+
ProviderRefusedError,
|
|
46
56
|
isObservableAIProviderRegistry,
|
|
47
57
|
// Request pricing. Re-exported HERE, not left to a direct `@genesislcap/foundation-ai`
|
|
48
58
|
// import, for the same reason as everything above it: a headless host that adds its own
|
|
@@ -13,6 +13,8 @@ import type {
|
|
|
13
13
|
} from '@genesislcap/foundation-ai';
|
|
14
14
|
import {
|
|
15
15
|
BudgetExhaustedError,
|
|
16
|
+
DEFAULT_PROVIDER_REFUSED_MESSAGE,
|
|
17
|
+
ProviderRefusedError,
|
|
16
18
|
DEFAULT_BUDGET_EXHAUSTED_MESSAGE,
|
|
17
19
|
isChatToolCallUnknown,
|
|
18
20
|
MalformedFunctionCallError,
|
|
@@ -2504,6 +2506,29 @@ const budgetExhaustedProvider = (): AIProvider => ({
|
|
|
2504
2506
|
},
|
|
2505
2507
|
});
|
|
2506
2508
|
|
|
2509
|
+
/** A provider the vendor refused outright (GENC-1506 — terminal, must not retry). */
|
|
2510
|
+
const providerRefusedProvider = (kind: 'spend' | 'auth' = 'spend'): AIProvider => ({
|
|
2511
|
+
chat: async (): Promise<ChatMessage> => {
|
|
2512
|
+
// Both fixtures are the real captured shapes (2026-08-18), matched to their kind — an `auth`
|
|
2513
|
+
// fixture carrying a credit-balance message would read as a classification bug to the next person.
|
|
2514
|
+
throw kind === 'auth'
|
|
2515
|
+
? new ProviderRefusedError(
|
|
2516
|
+
'Anthropic',
|
|
2517
|
+
'auth',
|
|
2518
|
+
401,
|
|
2519
|
+
'authentication_error',
|
|
2520
|
+
'API key is invalid.',
|
|
2521
|
+
)
|
|
2522
|
+
: new ProviderRefusedError(
|
|
2523
|
+
'Anthropic',
|
|
2524
|
+
'spend',
|
|
2525
|
+
400,
|
|
2526
|
+
'invalid_request_error',
|
|
2527
|
+
'Your credit balance is too low to access the Anthropic API.',
|
|
2528
|
+
);
|
|
2529
|
+
},
|
|
2530
|
+
});
|
|
2531
|
+
|
|
2507
2532
|
/** A provider that throws a generic error (the sendMessage catch-all → 'exception'). */
|
|
2508
2533
|
const throwingProvider = (): AIProvider => ({
|
|
2509
2534
|
chat: async (): Promise<ChatMessage> => {
|
|
@@ -2628,6 +2653,121 @@ outcome('a clean turn leaves the legacy shape byte-unchanged (no failureReason)'
|
|
|
2628
2653
|
|
|
2629
2654
|
// ── Budget exhaustion (GENC-1464) ──────────────────────────────────────────────
|
|
2630
2655
|
|
|
2656
|
+
outcome('provider-refused surfaces at both seams', async () => {
|
|
2657
|
+
await assertSurfacesReason('provider-refused', providerRefusedProvider(), 'provider-refused');
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
outcome('a provider refusal is terminal — no retry, cause-free copy, kind in the log', async () => {
|
|
2661
|
+
// GENC-1506. The measured bug this pins: the refusal used to arrive as an untyped transport error,
|
|
2662
|
+
// get re-issued MAX_SETUP_TRANSPORT_RETRIES times against a wall that cannot move, and then surface
|
|
2663
|
+
// as "something went wrong on my end" — wrong twice over, since nothing went wrong on our end and
|
|
2664
|
+
// trying again cannot help.
|
|
2665
|
+
clearMetaEventRegistry();
|
|
2666
|
+
let calls = 0;
|
|
2667
|
+
const provider: AIProvider = {
|
|
2668
|
+
chat: async (): Promise<ChatMessage> => {
|
|
2669
|
+
calls += 1;
|
|
2670
|
+
throw new ProviderRefusedError(
|
|
2671
|
+
'Anthropic',
|
|
2672
|
+
'spend',
|
|
2673
|
+
400,
|
|
2674
|
+
'invalid_request_error',
|
|
2675
|
+
'credit too low',
|
|
2676
|
+
);
|
|
2677
|
+
},
|
|
2678
|
+
getStatus: async () => ({ provider: 'anthropic', model: 'test-model' }),
|
|
2679
|
+
};
|
|
2680
|
+
const config = agent({
|
|
2681
|
+
name: 'Static',
|
|
2682
|
+
toolDefinitions: [def('noop')],
|
|
2683
|
+
toolHandlers: { noop: async () => 'ok' },
|
|
2684
|
+
});
|
|
2685
|
+
const driver = makeDriver(config, provider, 'outcome-provider-refused', outcomeBus);
|
|
2686
|
+
|
|
2687
|
+
const result: ChatDriverResult = await driver.sendMessage('go');
|
|
2688
|
+
|
|
2689
|
+
assert.is(calls, 1, 'a provider refusal is not retried — exactly one model call');
|
|
2690
|
+
assert.is(
|
|
2691
|
+
result.reason === 'done' ? result.failureReason : undefined,
|
|
2692
|
+
'provider-refused',
|
|
2693
|
+
'the turn ends with the provider-refused failure reason',
|
|
2694
|
+
);
|
|
2695
|
+
|
|
2696
|
+
// The kind reaches the CALLER, which is what lets ai-service's Sentry alert say whether an operator
|
|
2697
|
+
// must top up an account or rotate a key. It is the only place that distinction survives, because
|
|
2698
|
+
// the user-facing sentence is deliberately cause-free.
|
|
2699
|
+
const refused = result.reason === 'done' ? result.providerRefused : undefined;
|
|
2700
|
+
assert.is(refused?.kind, 'spend');
|
|
2701
|
+
assert.is(refused?.vendorLabel, 'Anthropic');
|
|
2702
|
+
assert.is(refused?.upstreamStatus, 400);
|
|
2703
|
+
assert.is(refused?.upstreamType, 'invalid_request_error');
|
|
2704
|
+
|
|
2705
|
+
const last = driver.getHistory().at(-1);
|
|
2706
|
+
assert.ok(last?.role === 'assistant', 'turn ends with an assistant message');
|
|
2707
|
+
// Identity, not a substring: the sentence is provisional copy shared with ai-service via one
|
|
2708
|
+
// exported const, and this is what stops a second copy drifting in.
|
|
2709
|
+
assert.is(last!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
2710
|
+
assert.not.ok(
|
|
2711
|
+
last!.content.includes('something went wrong'),
|
|
2712
|
+
'must not fall through to the generic apology',
|
|
2713
|
+
);
|
|
2714
|
+
// The copy must not leak the cause, whatever the kind — a user who can see remaining spend must not
|
|
2715
|
+
// be told about a limit, and we must not imply the bill went unpaid.
|
|
2716
|
+
for (const banned of ['credit', 'billing', 'balance', 'quota', 'limit']) {
|
|
2717
|
+
assert.not.ok(
|
|
2718
|
+
last!.content.toLowerCase().includes(banned),
|
|
2719
|
+
`copy must not mention "${banned}"`,
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
const err = getMetaEvents('outcome-provider-refused').find((e) => e.type === 'turn.error');
|
|
2724
|
+
assert.is((err?.detail as { reason?: string })?.reason, 'provider-refused');
|
|
2725
|
+
assert.is((err?.detail as { kind?: string })?.kind, 'spend');
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
outcome('an auth refusal shows the SAME sentence but logs a different kind', async () => {
|
|
2729
|
+
// The whole justification for one type with two kinds: identical user experience, distinct
|
|
2730
|
+
// diagnostics. If these ever diverge in the transcript, the copy decision has been undone.
|
|
2731
|
+
clearMetaEventRegistry();
|
|
2732
|
+
const config = agent({
|
|
2733
|
+
name: 'Static',
|
|
2734
|
+
toolDefinitions: [def('noop')],
|
|
2735
|
+
toolHandlers: { noop: async () => 'ok' },
|
|
2736
|
+
});
|
|
2737
|
+
const driver = makeDriver(
|
|
2738
|
+
config,
|
|
2739
|
+
providerRefusedProvider('auth'),
|
|
2740
|
+
'outcome-refused-auth',
|
|
2741
|
+
outcomeBus,
|
|
2742
|
+
);
|
|
2743
|
+
|
|
2744
|
+
const result: ChatDriverResult = await driver.sendMessage('go');
|
|
2745
|
+
|
|
2746
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
|
|
2747
|
+
assert.is(result.reason === 'done' ? result.providerRefused?.kind : undefined, 'auth');
|
|
2748
|
+
assert.is(driver.getHistory().at(-1)!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2751
|
+
outcome('a host override replaces the provider-refusal sentence verbatim', async () => {
|
|
2752
|
+
const config = agent({
|
|
2753
|
+
name: 'Static',
|
|
2754
|
+
toolDefinitions: [def('noop')],
|
|
2755
|
+
toolHandlers: { noop: async () => 'ok' },
|
|
2756
|
+
});
|
|
2757
|
+
const driver = new ChatDriver(makeRegistry(providerRefusedProvider()), {
|
|
2758
|
+
maxToolIterations: 50,
|
|
2759
|
+
maxFoldOperations: 5,
|
|
2760
|
+
sessionKey: 'outcome-refused-override',
|
|
2761
|
+
providerRefusedMessage: 'Bespoke wording for a white-labelled host.',
|
|
2762
|
+
});
|
|
2763
|
+
driver.applyAgent(config);
|
|
2764
|
+
|
|
2765
|
+
await driver.sendMessage('go');
|
|
2766
|
+
|
|
2767
|
+
assert.is(driver.getHistory().at(-1)!.content, 'Bespoke wording for a white-labelled host.');
|
|
2768
|
+
driver.dispose();
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2631
2771
|
outcome('budget-exhausted surfaces at both seams', async () => {
|
|
2632
2772
|
await assertSurfacesReason('budget', budgetExhaustedProvider(), 'budget-exhausted');
|
|
2633
2773
|
});
|
|
@@ -3005,6 +3145,160 @@ const walledWorker = (): AgentConfig =>
|
|
|
3005
3145
|
toolHandlers: { finish: async () => 'x' },
|
|
3006
3146
|
});
|
|
3007
3147
|
|
|
3148
|
+
/**
|
|
3149
|
+
* The child-refused / parent-fine provider, mirroring `parentOkChildWalled` — the parent's turn call
|
|
3150
|
+
* succeeds and delegates, and only the child's provider refuses.
|
|
3151
|
+
*/
|
|
3152
|
+
const parentOkChildRefused = (
|
|
3153
|
+
kind: 'spend' | 'auth' = 'spend',
|
|
3154
|
+
): { parentCalls: () => number; provider: AIProvider } => {
|
|
3155
|
+
let parentCalls = 0;
|
|
3156
|
+
return {
|
|
3157
|
+
parentCalls: () => parentCalls,
|
|
3158
|
+
provider: {
|
|
3159
|
+
chat: async (
|
|
3160
|
+
_h: ChatMessage[],
|
|
3161
|
+
_u: string,
|
|
3162
|
+
options?: ChatRequestOptions,
|
|
3163
|
+
): Promise<ChatMessage> => {
|
|
3164
|
+
const names = (options?.tools ?? []).map((t) => t.name);
|
|
3165
|
+
if (names.includes('delegate')) {
|
|
3166
|
+
parentCalls += 1;
|
|
3167
|
+
return callsTool('delegate', `d${parentCalls}`);
|
|
3168
|
+
}
|
|
3169
|
+
throw kind === 'auth'
|
|
3170
|
+
? new ProviderRefusedError(
|
|
3171
|
+
'Anthropic',
|
|
3172
|
+
'auth',
|
|
3173
|
+
401,
|
|
3174
|
+
'authentication_error',
|
|
3175
|
+
'API key is invalid.',
|
|
3176
|
+
)
|
|
3177
|
+
: new ProviderRefusedError(
|
|
3178
|
+
'Anthropic',
|
|
3179
|
+
'spend',
|
|
3180
|
+
400,
|
|
3181
|
+
'invalid_request_error',
|
|
3182
|
+
'credit too low',
|
|
3183
|
+
);
|
|
3184
|
+
},
|
|
3185
|
+
},
|
|
3186
|
+
};
|
|
3187
|
+
};
|
|
3188
|
+
|
|
3189
|
+
outcome(
|
|
3190
|
+
'a sub-agent provider refusal ends the parent turn without another model call',
|
|
3191
|
+
async () => {
|
|
3192
|
+
/*
|
|
3193
|
+
* The seam a review caught, and it is genuinely counter-intuitive: the child is a SEPARATE driver
|
|
3194
|
+
* instance, so the flag it sets on itself is invisible to the parent. The refusal reaches the parent
|
|
3195
|
+
* only as a tool result, and without an `invokeSubAgent` branch that result was ordinary — so the
|
|
3196
|
+
* parent issued another model call straight into the same wall, and if that call had happened to
|
|
3197
|
+
* succeed the turn could have finished with no `failureReason` at all, reporting a clean turn over a
|
|
3198
|
+
* dead account. `parentCalls() === 1` is the assertion that pins it.
|
|
3199
|
+
*/
|
|
3200
|
+
clearMetaEventRegistry();
|
|
3201
|
+
const { parentCalls, provider } = parentOkChildRefused();
|
|
3202
|
+
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
3203
|
+
const driver = makeDriver(parent, provider, 'outcome-subagent-refused', outcomeBus);
|
|
3204
|
+
|
|
3205
|
+
const result: ChatDriverResult = await driver.sendMessage('go');
|
|
3206
|
+
|
|
3207
|
+
assert.is(parentCalls(), 1, 'the parent never calls the model again after the refusal');
|
|
3208
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
|
|
3209
|
+
assert.is(driver.getHistory().at(-1)!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
3210
|
+
},
|
|
3211
|
+
);
|
|
3212
|
+
|
|
3213
|
+
outcome("a sub-agent refusal carries the CHILD's kind and vendor to the parent", async () => {
|
|
3214
|
+
// The other half of the same finding. The parent can only report what the child bubbled, so without a
|
|
3215
|
+
// payload on the failure the `providerRefused` diagnostic promised on `ChatDriverResult` would be
|
|
3216
|
+
// absent — and with cause-free user copy that payload is the ONLY place an operator learns whether to
|
|
3217
|
+
// top up an account or rotate a credential.
|
|
3218
|
+
clearMetaEventRegistry();
|
|
3219
|
+
const { provider } = parentOkChildRefused('auth');
|
|
3220
|
+
const parent = delegatingParent(walledWorker(), () => undefined);
|
|
3221
|
+
const driver = makeDriver(parent, provider, 'outcome-subagent-refused-kind', outcomeBus);
|
|
3222
|
+
|
|
3223
|
+
const result: ChatDriverResult = await driver.sendMessage('go');
|
|
3224
|
+
|
|
3225
|
+
const refused = result.reason === 'done' ? result.providerRefused : undefined;
|
|
3226
|
+
assert.is(refused?.kind, 'auth', "the child's kind survives the hop");
|
|
3227
|
+
assert.is(refused?.vendorLabel, 'Anthropic');
|
|
3228
|
+
assert.is(refused?.upstreamStatus, 401);
|
|
3229
|
+
});
|
|
3230
|
+
|
|
3231
|
+
outcome("a GRANDCHILD's refusal keeps its kind and vendor across two hops", async () => {
|
|
3232
|
+
/*
|
|
3233
|
+
* The third finding, and the one a single-hop test cannot reach. An INTERMEDIATE sub-agent latches its
|
|
3234
|
+
* grandchild's payload correctly via `invokeSubAgent`, but then has to hand it on through
|
|
3235
|
+
* `failSubAgent` — and passing only the reason there still stopped the top-level turn (the reason
|
|
3236
|
+
* travels) while arriving with no vendor and no kind. The turn looked handled and the diagnostics were
|
|
3237
|
+
* gone, which is the failure mode this payload exists to prevent.
|
|
3238
|
+
*
|
|
3239
|
+
* Three tiers: boss -> middle -> worker. Only the worker's turn is refused; every other model call
|
|
3240
|
+
* succeeds, so nothing but the forwarding can carry the detail to the top.
|
|
3241
|
+
*/
|
|
3242
|
+
clearMetaEventRegistry();
|
|
3243
|
+
const worker = agent({
|
|
3244
|
+
name: 'worker',
|
|
3245
|
+
toolDefinitions: [def('finish')],
|
|
3246
|
+
toolHandlers: { finish: async () => 'x' },
|
|
3247
|
+
});
|
|
3248
|
+
const middle = agent({
|
|
3249
|
+
name: 'middle',
|
|
3250
|
+
subAgents: [worker],
|
|
3251
|
+
toolDefinitions: [def('sub_delegate')],
|
|
3252
|
+
toolHandlers: {
|
|
3253
|
+
sub_delegate: async (_args, ctx) => {
|
|
3254
|
+
const o = await ctx.requestSubAgent!('worker', { task: 'deeper' });
|
|
3255
|
+
return o.ok ? 'ok' : `failed: ${o.reason}`;
|
|
3256
|
+
},
|
|
3257
|
+
},
|
|
3258
|
+
});
|
|
3259
|
+
const boss = agent({
|
|
3260
|
+
name: 'boss',
|
|
3261
|
+
subAgents: [middle],
|
|
3262
|
+
toolDefinitions: [def('delegate')],
|
|
3263
|
+
toolHandlers: {
|
|
3264
|
+
delegate: async (_args, ctx) => {
|
|
3265
|
+
const o = await ctx.requestSubAgent!('middle', { task: 'do it' });
|
|
3266
|
+
return o.ok ? 'ok' : `failed: ${o.reason}`;
|
|
3267
|
+
},
|
|
3268
|
+
},
|
|
3269
|
+
});
|
|
3270
|
+
|
|
3271
|
+
// Routed by the tool surface each tier is given, so only the deepest turn throws.
|
|
3272
|
+
const provider: AIProvider = {
|
|
3273
|
+
chat: async (
|
|
3274
|
+
_h: ChatMessage[],
|
|
3275
|
+
_u: string,
|
|
3276
|
+
options?: ChatRequestOptions,
|
|
3277
|
+
): Promise<ChatMessage> => {
|
|
3278
|
+
const names = (options?.tools ?? []).map((t) => t.name);
|
|
3279
|
+
if (names.includes('delegate')) return callsTool('delegate', 'd1');
|
|
3280
|
+
if (names.includes('sub_delegate')) return callsTool('sub_delegate', 's1');
|
|
3281
|
+
throw new ProviderRefusedError(
|
|
3282
|
+
'Anthropic',
|
|
3283
|
+
'auth',
|
|
3284
|
+
401,
|
|
3285
|
+
'authentication_error',
|
|
3286
|
+
'API key is invalid.',
|
|
3287
|
+
);
|
|
3288
|
+
},
|
|
3289
|
+
};
|
|
3290
|
+
const driver = makeDriver(boss, provider, 'outcome-refused-grandchild', outcomeBus);
|
|
3291
|
+
|
|
3292
|
+
const result: ChatDriverResult = await driver.sendMessage('go');
|
|
3293
|
+
|
|
3294
|
+
assert.is(result.reason === 'done' ? result.failureReason : undefined, 'provider-refused');
|
|
3295
|
+
const refused = result.reason === 'done' ? result.providerRefused : undefined;
|
|
3296
|
+
assert.is(refused?.kind, 'auth', "the grandchild's kind survives BOTH hops");
|
|
3297
|
+
assert.is(refused?.vendorLabel, 'Anthropic');
|
|
3298
|
+
assert.is(refused?.upstreamStatus, 401);
|
|
3299
|
+
assert.is(driver.getHistory().at(-1)!.content, DEFAULT_PROVIDER_REFUSED_MESSAGE);
|
|
3300
|
+
});
|
|
3301
|
+
|
|
3008
3302
|
outcome('a sub-agent budget wall ends the parent turn without another model call', async () => {
|
|
3009
3303
|
clearMetaEventRegistry();
|
|
3010
3304
|
const { parentCalls, provider } = parentOkChildWalled();
|