@mccaskie-labs/workbench-ai 0.0.0-stage → 0.1.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/CHANGELOG.md +17 -0
- package/README.md +37 -2
- package/dist/ai.d.ts +17 -0
- package/dist/ai.js +318 -0
- package/dist/capture.d.ts +26 -0
- package/dist/capture.js +90 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.js +100 -0
- package/dist/fake.d.ts +85 -0
- package/dist/fake.js +215 -0
- package/dist/helpers.d.ts +11 -0
- package/dist/helpers.js +23 -0
- package/dist/http.d.ts +23 -0
- package/dist/http.js +33 -0
- package/dist/images.d.ts +4 -0
- package/dist/images.js +93 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/types.d.ts +166 -0
- package/dist/types.js +1 -0
- package/package.json +47 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @mccaskie-labs/workbench-ai
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- The build always rebuilds workbench-config first, so a stale config dist/ can't break it.
|
|
8
|
+
|
|
9
|
+
## 0.1.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Add `ai.images.generate` and `ai.images.search` (step 4.8), with image routes on `createFakeAi`'s fake Gateway. Needs `workbench-config` 0.1.2 for the images manifest.
|
|
14
|
+
|
|
15
|
+
## 0.1.0
|
|
16
|
+
|
|
17
|
+
- First version: `createAi` (`text`, `structured`, `stream`, `countTokens`, `budget`, `anthropic`), `createFakeAi`, `cachedSystem`, `cachedBlocks`, `provenance` and `WorkbenchAiError` (`docs/spec.md` §3).
|
package/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mccaskie-labs/workbench-ai
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Claude for apps built on Workbench, a private platform. Calls go through the Workbench Gateway, which holds the Anthropic key, resolves model aliases, checks the project's budget, retries transient failures and meters every call. The app holds only its Gateway key. Published publicly so Workbench projects can install it with no token; it is not supported for other uses.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @mccaskie-labs/workbench-ai @mccaskie-labs/workbench-config zod
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createAi } from '@mccaskie-labs/workbench-ai'
|
|
11
|
+
import { workbenchEnv } from '@mccaskie-labs/workbench-config'
|
|
12
|
+
|
|
13
|
+
const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })
|
|
14
|
+
const r = await ai.text({ model: 'claude-default', job: 'article.write', prompt })
|
|
15
|
+
await save({ text: r.text, model: r.model, requestId: r.requestId })
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
| Export | What it does |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `createAi({ key, env, url?, fetch? })` | The client. Throws `invalid_config` at once if the key isn't for `env` (`wbk_dev_`, `wbk_stg_`, `wbk_prd_`) |
|
|
21
|
+
| `ai.text(req)` | One reply as text. Throws `refused` on a refusal and `truncated` on `max_tokens`; never returns partial text |
|
|
22
|
+
| `ai.structured({ ...req, schema, mayLack? })` | JSON matching a zod schema, through structured outputs (`output_config.format`, not forced tool use). Asks once more for required fields left empty, except those in `mayLack`; throws `invalid_output` if they're still empty |
|
|
23
|
+
| `ai.stream(req)` | Async iterable of Anthropic stream events, then `finalMessage()`, which throws like `text` |
|
|
24
|
+
| `ai.countTokens(req)` | Input tokens for a prompt. Free |
|
|
25
|
+
| `ai.budget()` | `{ status: 'ok' \| 'warn' \| 'reached', leftUsd, monthlyUsd, spentUsd, mode, month }` for this key's project and environment, cached 60 s |
|
|
26
|
+
| `ai.images.generate({ prompt, aspectRatio, job, model?, references? })` | One image: `{ bytes, mimeType, model, requestId, costUsd }`. `model` is `image-default` (the default), `image-pro` or `image-openai`; `references` work on the Google aliases only. Needs the `images` scope |
|
|
27
|
+
| `ai.images.search({ q, job, num?, gl?, hl? })` | Serper image search: a list of `{ title, imageUrl, thumbnailUrl, width, height, source, pageUrl }` that also carries `requestId`, `costUsd` and `credits`. Needs the `search` scope |
|
|
28
|
+
| `ai.anthropic` | The official `@anthropic-ai/sdk` client pointed at the Gateway, for anything else |
|
|
29
|
+
| `createFakeAi(script)` | The real client against a scripted fake Gateway, for tests: replies in order, refusals, truncations and Gateway errors, every call recorded in `.calls`. No key, no network |
|
|
30
|
+
| `cachedSystem(stable, ...volatile)`, `cachedBlocks(...texts)` | System blocks with a prompt-cache breakpoint |
|
|
31
|
+
| `provenance(result, promptVersion)` | The record to store with an output: resolved model, request id, prompt version, cost |
|
|
32
|
+
| `WorkbenchAiError` | Every failure: `code`, `retryable`, `requestId`, `upstreamStatus`, `details` |
|
|
33
|
+
|
|
34
|
+
Every result carries the **resolved** model id (never the alias), the Gateway's request id (it matches a usage row) and the cost in US dollars. `model` is a Workbench alias (`claude-default`, `claude-best`, `claude-fast`) or an allowlisted id; anything else is refused with `model_not_allowed`. `job` (`[a-z0-9._-]{1,64}`) says what the call is for and is recorded with usage.
|
|
35
|
+
|
|
36
|
+
The package doesn't retry: the Gateway already does (3 attempts on transient failures). Error codes: `refused`, `truncated`, `invalid_output`, `unexpected_stop`, and the Gateway's `unauthorised`, `forbidden_scope`, `invalid_request`, `model_not_allowed`, `budget_exceeded`, `rate_limited`, `not_found`, `not_available`, `upstream_error`, `upstream_timeout`, `internal_error`, plus `network` and `invalid_config`.
|
|
37
|
+
|
|
38
|
+
Runs on Node 24 and the Cloudflare Workers runtime. `zod` 4 is a peer dependency.
|
package/dist/ai.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { type GatewayMeta } from "./capture.ts";
|
|
3
|
+
import type { Ai, AiOptions, TextResult } from "./types.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Claude through the Workbench Gateway (docs/spec.md §3). The app holds only
|
|
6
|
+
* its Gateway key; the Gateway resolves aliases, checks the budget, retries,
|
|
7
|
+
* and meters every call.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createAi(opts: AiOptions): Ai;
|
|
10
|
+
/** Turns a Message into a result, or throws: a refusal or a truncation is never a success (audit F18). */
|
|
11
|
+
export declare function toTextResult(message: Anthropic.Message, meta: GatewayMeta): TextResult;
|
|
12
|
+
/**
|
|
13
|
+
* Top-level fields the model left empty (`""`, `null`, `[]`), except those it
|
|
14
|
+
* may lack. Structured outputs guarantees the keys are present; this catches
|
|
15
|
+
* the empty answers it can't rule out (lifted from Imprint's `missingRequired`).
|
|
16
|
+
*/
|
|
17
|
+
export declare function missingRequired(value: unknown, mayLack: readonly string[]): string[];
|
package/dist/ai.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
|
3
|
+
import { DEFAULT_GATEWAY_URL } from "@mccaskie-labs/workbench-config";
|
|
4
|
+
import { createCapture, isGatewayMeta, REQUEST_ID_HEADER } from "./capture.js";
|
|
5
|
+
import { errorFromGateway, WorkbenchAiError } from "./errors.js";
|
|
6
|
+
import { gatewayFetch } from "./http.js";
|
|
7
|
+
import { createImages } from "./images.js";
|
|
8
|
+
const KEY_PREFIX = {
|
|
9
|
+
development: "wbk_dev_",
|
|
10
|
+
staging: "wbk_stg_",
|
|
11
|
+
production: "wbk_prd_",
|
|
12
|
+
};
|
|
13
|
+
const JOB = /^[a-z0-9._-]{1,64}$/;
|
|
14
|
+
const SUBJECT = /^[a-z0-9_-]+:[^\s]{1,128}$/;
|
|
15
|
+
const DEFAULT_MAX_TOKENS = 16000;
|
|
16
|
+
const DEFAULT_STREAM_MAX_TOKENS = 64000;
|
|
17
|
+
const BUDGET_CACHE_MS = 60_000;
|
|
18
|
+
/**
|
|
19
|
+
* Claude through the Workbench Gateway (docs/spec.md §3). The app holds only
|
|
20
|
+
* its Gateway key; the Gateway resolves aliases, checks the budget, retries,
|
|
21
|
+
* and meters every call.
|
|
22
|
+
*/
|
|
23
|
+
export function createAi(opts) {
|
|
24
|
+
const env = opts.env;
|
|
25
|
+
const prefix = KEY_PREFIX[env];
|
|
26
|
+
if (!prefix)
|
|
27
|
+
throw new WorkbenchAiError("invalid_config", `Unknown Workbench environment`);
|
|
28
|
+
if (typeof opts.key !== "string" || opts.key === "") {
|
|
29
|
+
throw new WorkbenchAiError("invalid_config", "WORKBENCH_GATEWAY_KEY is missing");
|
|
30
|
+
}
|
|
31
|
+
// Checked before any call, so a staging key in production fails at start-up
|
|
32
|
+
// rather than on the first request. The key itself is never in the message.
|
|
33
|
+
if (!opts.key.startsWith(prefix)) {
|
|
34
|
+
throw new WorkbenchAiError("invalid_config", `WORKBENCH_GATEWAY_KEY is not a ${env} key (expected the ${prefix} prefix)`);
|
|
35
|
+
}
|
|
36
|
+
const url = (opts.url ?? DEFAULT_GATEWAY_URL).replace(/\/+$/, "");
|
|
37
|
+
const baseFetch = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
38
|
+
const client = (f) => new Anthropic({
|
|
39
|
+
apiKey: opts.key,
|
|
40
|
+
// Never pick up ANTHROPIC_AUTH_TOKEN or a local `ant` profile: the Gateway key is the only credential.
|
|
41
|
+
authToken: null,
|
|
42
|
+
baseURL: `${url}/anthropic`,
|
|
43
|
+
fetch: f,
|
|
44
|
+
// The Gateway retries transient failures itself (docs/spec.md §2.3);
|
|
45
|
+
// retrying here too would multiply attempts and spend.
|
|
46
|
+
maxRetries: 0,
|
|
47
|
+
defaultHeaders: { "X-Workbench-Env": env },
|
|
48
|
+
});
|
|
49
|
+
const anthropic = client(baseFetch);
|
|
50
|
+
let budgetCache;
|
|
51
|
+
function headers(req) {
|
|
52
|
+
if (!JOB.test(req.job))
|
|
53
|
+
throw new WorkbenchAiError("invalid_request", "job must match [a-z0-9._-]{1,64}");
|
|
54
|
+
const h = { "X-Workbench-Job": req.job };
|
|
55
|
+
if (req.promptVersion !== undefined)
|
|
56
|
+
h["X-Workbench-Prompt-Version"] = req.promptVersion;
|
|
57
|
+
if (req.subject !== undefined) {
|
|
58
|
+
if (!SUBJECT.test(req.subject))
|
|
59
|
+
throw new WorkbenchAiError("invalid_request", "subject must be type:id");
|
|
60
|
+
h["X-Workbench-Subject"] = req.subject;
|
|
61
|
+
}
|
|
62
|
+
return h;
|
|
63
|
+
}
|
|
64
|
+
async function create(body, req) {
|
|
65
|
+
const cap = createCapture(baseFetch);
|
|
66
|
+
try {
|
|
67
|
+
const message = await client(cap.fetch).messages.create(body, { headers: headers(req) });
|
|
68
|
+
return { message, meta: metaOf(message, cap), cap };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
throw mapError(err, cap.requestId());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function text(req) {
|
|
75
|
+
const { message, meta } = await create(params(req, DEFAULT_MAX_TOKENS), req);
|
|
76
|
+
return toTextResult(message, meta);
|
|
77
|
+
}
|
|
78
|
+
async function structured(req) {
|
|
79
|
+
const base = params(req, DEFAULT_MAX_TOKENS);
|
|
80
|
+
const format = zodOutputFormat(req.schema);
|
|
81
|
+
const body = {
|
|
82
|
+
...base,
|
|
83
|
+
output_config: { ...(base.output_config ?? {}), format },
|
|
84
|
+
};
|
|
85
|
+
const first = await create(body, req);
|
|
86
|
+
let result = toTextResult(first.message, first.meta);
|
|
87
|
+
let parsed = parseStructured(req, result);
|
|
88
|
+
let usage = result.usage;
|
|
89
|
+
let cost = result.costUsd;
|
|
90
|
+
const missing = missingRequired(parsed.value, req.mayLack ?? []);
|
|
91
|
+
if (missing.length > 0) {
|
|
92
|
+
// Ask once more for the required fields left empty (docs/spec.md §3),
|
|
93
|
+
// replaying the first reply unchanged, thinking blocks included.
|
|
94
|
+
const again = {
|
|
95
|
+
...body,
|
|
96
|
+
messages: [
|
|
97
|
+
...body.messages,
|
|
98
|
+
{ role: "assistant", content: first.message.content },
|
|
99
|
+
{
|
|
100
|
+
role: "user",
|
|
101
|
+
content: `These required fields are empty: ${missing.join(", ")}. Reply again with the complete JSON object, filling them in.`,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
const second = await create(again, req);
|
|
106
|
+
result = toTextResult(second.message, second.meta);
|
|
107
|
+
parsed = parseStructured(req, result);
|
|
108
|
+
usage = addUsage(usage, result.usage);
|
|
109
|
+
cost += result.costUsd;
|
|
110
|
+
const still = missingRequired(parsed.value, req.mayLack ?? []);
|
|
111
|
+
if (still.length > 0) {
|
|
112
|
+
throw new WorkbenchAiError("invalid_output", `Required fields still empty after a re-ask: ${still.join(", ")}`, {
|
|
113
|
+
requestId: result.requestId,
|
|
114
|
+
model: result.model,
|
|
115
|
+
missing: still,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { data: parsed.data, model: result.model, requestId: result.requestId, usage, costUsd: round6(cost) };
|
|
120
|
+
}
|
|
121
|
+
function stream(req) {
|
|
122
|
+
const cap = createCapture(baseFetch);
|
|
123
|
+
let ms;
|
|
124
|
+
try {
|
|
125
|
+
ms = client(cap.fetch).messages.stream(params(req, DEFAULT_STREAM_MAX_TOKENS), { headers: headers(req) });
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
const e = mapError(err, cap.requestId());
|
|
129
|
+
return failedStream(e);
|
|
130
|
+
}
|
|
131
|
+
// finalMessage() must not leave an unhandled rejection if the caller only iterates.
|
|
132
|
+
const final = ms.finalMessage().then((message) => {
|
|
133
|
+
const meta = cap.streamMeta();
|
|
134
|
+
if (!meta) {
|
|
135
|
+
throw new WorkbenchAiError("internal_error", "The Gateway's stream ended without its workbench.usage event", {
|
|
136
|
+
...(cap.requestId() ? { requestId: cap.requestId() } : {}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return toTextResult(message, meta);
|
|
140
|
+
}, (err) => {
|
|
141
|
+
throw mapError(err, cap.requestId());
|
|
142
|
+
});
|
|
143
|
+
final.catch(() => { });
|
|
144
|
+
return {
|
|
145
|
+
async *[Symbol.asyncIterator]() {
|
|
146
|
+
try {
|
|
147
|
+
for await (const event of ms)
|
|
148
|
+
yield event;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
throw mapError(err, cap.requestId());
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
finalMessage: () => final,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async function countTokens(req) {
|
|
158
|
+
const cap = createCapture(baseFetch);
|
|
159
|
+
try {
|
|
160
|
+
const r = await client(cap.fetch).messages.countTokens({
|
|
161
|
+
model: req.model,
|
|
162
|
+
messages: [{ role: "user", content: req.prompt }],
|
|
163
|
+
...(req.system !== undefined ? { system: req.system } : {}),
|
|
164
|
+
}, { headers: headers(req) });
|
|
165
|
+
return { inputTokens: r.input_tokens };
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
throw mapError(err, cap.requestId());
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function budget() {
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
if (budgetCache && now - budgetCache.at < BUDGET_CACHE_MS)
|
|
174
|
+
return budgetCache.value;
|
|
175
|
+
const { response, requestId } = await gatewayFetch({ url, key: opts.key, env, fetch: baseFetch }, "/v1/usage");
|
|
176
|
+
const body = await response.json().catch(() => undefined);
|
|
177
|
+
const value = budgetFrom(body, requestId);
|
|
178
|
+
budgetCache = { at: now, value };
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
const images = createImages({ url, key: opts.key, env, fetch: baseFetch });
|
|
182
|
+
return { anthropic, text, structured, stream, countTokens, budget, images };
|
|
183
|
+
}
|
|
184
|
+
function params(req, defaultMax) {
|
|
185
|
+
const body = {
|
|
186
|
+
model: req.model,
|
|
187
|
+
max_tokens: req.maxTokens ?? defaultMax,
|
|
188
|
+
messages: [{ role: "user", content: req.prompt }],
|
|
189
|
+
};
|
|
190
|
+
if (req.system !== undefined)
|
|
191
|
+
body.system = req.system;
|
|
192
|
+
if (req.effort !== undefined)
|
|
193
|
+
body.output_config = { effort: req.effort };
|
|
194
|
+
return body;
|
|
195
|
+
}
|
|
196
|
+
function metaOf(message, cap) {
|
|
197
|
+
const meta = message.workbench;
|
|
198
|
+
if (isGatewayMeta(meta))
|
|
199
|
+
return meta;
|
|
200
|
+
const requestId = cap.requestId();
|
|
201
|
+
throw new WorkbenchAiError("internal_error", "The Gateway's reply carried no workbench usage object", {
|
|
202
|
+
...(requestId ? { requestId } : {}),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** Turns a Message into a result, or throws: a refusal or a truncation is never a success (audit F18). */
|
|
206
|
+
export function toTextResult(message, meta) {
|
|
207
|
+
const base = { requestId: meta.request_id, model: meta.model };
|
|
208
|
+
if (message.stop_reason === "refusal" || meta.refused === true) {
|
|
209
|
+
const category = message.stop_details?.category ?? null;
|
|
210
|
+
throw new WorkbenchAiError("refused", "Claude declined the request", { ...base, refusalCategory: category });
|
|
211
|
+
}
|
|
212
|
+
if (message.stop_reason === "max_tokens") {
|
|
213
|
+
throw new WorkbenchAiError("truncated", "The reply hit max_tokens before it finished", base);
|
|
214
|
+
}
|
|
215
|
+
if (message.stop_reason !== "end_turn" && message.stop_reason !== "stop_sequence") {
|
|
216
|
+
throw new WorkbenchAiError("unexpected_stop", `The reply stopped with ${String(message.stop_reason)}`, base);
|
|
217
|
+
}
|
|
218
|
+
const text = message.content
|
|
219
|
+
.filter((b) => b.type === "text")
|
|
220
|
+
.map((b) => b.text)
|
|
221
|
+
.join("");
|
|
222
|
+
return { text, model: meta.model, requestId: meta.request_id, usage: usageOf(message.usage), costUsd: meta.cost_usd };
|
|
223
|
+
}
|
|
224
|
+
function usageOf(u) {
|
|
225
|
+
return {
|
|
226
|
+
inputTokens: u.input_tokens ?? 0,
|
|
227
|
+
outputTokens: u.output_tokens ?? 0,
|
|
228
|
+
cacheReadTokens: u.cache_read_input_tokens ?? 0,
|
|
229
|
+
cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function addUsage(a, b) {
|
|
233
|
+
return {
|
|
234
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
235
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
236
|
+
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
|
|
237
|
+
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function round6(n) {
|
|
241
|
+
return Math.round(n * 1e6) / 1e6;
|
|
242
|
+
}
|
|
243
|
+
function parseStructured(req, r) {
|
|
244
|
+
let value;
|
|
245
|
+
try {
|
|
246
|
+
value = JSON.parse(r.text);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
throw new WorkbenchAiError("invalid_output", "The reply wasn't JSON", { requestId: r.requestId, model: r.model });
|
|
250
|
+
}
|
|
251
|
+
const parsed = req.schema.safeParse(value);
|
|
252
|
+
if (!parsed.success) {
|
|
253
|
+
throw new WorkbenchAiError("invalid_output", `The reply didn't match the schema: ${parsed.error.message}`, {
|
|
254
|
+
requestId: r.requestId,
|
|
255
|
+
model: r.model,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return { value, data: parsed.data };
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Top-level fields the model left empty (`""`, `null`, `[]`), except those it
|
|
262
|
+
* may lack. Structured outputs guarantees the keys are present; this catches
|
|
263
|
+
* the empty answers it can't rule out (lifted from Imprint's `missingRequired`).
|
|
264
|
+
*/
|
|
265
|
+
export function missingRequired(value, mayLack) {
|
|
266
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
267
|
+
return [];
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const [k, v] of Object.entries(value)) {
|
|
270
|
+
if (mayLack.includes(k))
|
|
271
|
+
continue;
|
|
272
|
+
if (v === null || (typeof v === "string" && v.trim() === "") || (Array.isArray(v) && v.length === 0))
|
|
273
|
+
out.push(k);
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
function mapError(err, requestId) {
|
|
278
|
+
if (err instanceof WorkbenchAiError)
|
|
279
|
+
return err;
|
|
280
|
+
if (err instanceof Anthropic.APIConnectionError) {
|
|
281
|
+
return new WorkbenchAiError("network", "Couldn't reach the Gateway", requestId ? { requestId } : {});
|
|
282
|
+
}
|
|
283
|
+
if (err instanceof Anthropic.APIError) {
|
|
284
|
+
const rid = err.headers?.get?.(REQUEST_ID_HEADER) ?? requestId;
|
|
285
|
+
return errorFromGateway(err.status, err.error, rid ?? undefined);
|
|
286
|
+
}
|
|
287
|
+
return new WorkbenchAiError("internal_error", err instanceof Error ? err.message : "Unknown failure", requestId ? { requestId } : {});
|
|
288
|
+
}
|
|
289
|
+
function failedStream(e) {
|
|
290
|
+
const final = Promise.reject(e);
|
|
291
|
+
final.catch(() => { });
|
|
292
|
+
return {
|
|
293
|
+
// eslint-disable-next-line require-yield
|
|
294
|
+
async *[Symbol.asyncIterator]() {
|
|
295
|
+
throw e;
|
|
296
|
+
},
|
|
297
|
+
finalMessage: () => final,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function budgetFrom(body, requestId) {
|
|
301
|
+
const b = body;
|
|
302
|
+
const budget = b?.budget;
|
|
303
|
+
if (!budget) {
|
|
304
|
+
throw new WorkbenchAiError("not_found", "No budget is set for this project and environment", requestId ? { requestId } : {});
|
|
305
|
+
}
|
|
306
|
+
const status = budget.status;
|
|
307
|
+
if (status !== "ok" && status !== "warn" && status !== "reached") {
|
|
308
|
+
throw new WorkbenchAiError("internal_error", "The Gateway's usage reply had no budget status", requestId ? { requestId } : {});
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
status,
|
|
312
|
+
leftUsd: Math.max(0, Number(budget.left_usd)),
|
|
313
|
+
monthlyUsd: Number(budget.monthly_usd),
|
|
314
|
+
spentUsd: Number(b.spent_usd),
|
|
315
|
+
mode: budget.mode === "shadow" ? "shadow" : "enforce",
|
|
316
|
+
month: typeof b.month === "string" ? b.month : "",
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const REQUEST_ID_HEADER = "x-workbench-request-id";
|
|
2
|
+
/** What the Gateway reports about a call: the `workbench` object, or the `workbench.usage` event. */
|
|
3
|
+
export interface GatewayMeta {
|
|
4
|
+
request_id: string;
|
|
5
|
+
/** The resolved model id, never the alias (audit F17). */
|
|
6
|
+
model: string;
|
|
7
|
+
cost_usd: number;
|
|
8
|
+
refused?: boolean;
|
|
9
|
+
alias?: string | null;
|
|
10
|
+
attempts?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Capture {
|
|
13
|
+
fetch: typeof fetch;
|
|
14
|
+
/** The request id from the last response's header, if one came back. */
|
|
15
|
+
requestId(): string | undefined;
|
|
16
|
+
/** The `workbench.usage` event, once the stream has finished. */
|
|
17
|
+
streamMeta(): GatewayMeta | undefined;
|
|
18
|
+
}
|
|
19
|
+
export declare function isGatewayMeta(value: unknown): value is GatewayMeta;
|
|
20
|
+
export declare function createCapture(base: typeof fetch): Capture;
|
|
21
|
+
/**
|
|
22
|
+
* Passes SSE events through unchanged, except `workbench.usage`, which is
|
|
23
|
+
* parsed and handed to `onMeta` instead. Event boundaries are blank lines
|
|
24
|
+
* (LF or CRLF), as the SSE spec has them.
|
|
25
|
+
*/
|
|
26
|
+
export declare function stripUsageEvent(onMeta: (meta: GatewayMeta) => void): TransformStream<Uint8Array, Uint8Array>;
|
package/dist/capture.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// A fetch wrapper that reads what the Gateway adds to an Anthropic response,
|
|
2
|
+
// so the official SDK never has to know about it (docs/spec.md §2.3):
|
|
3
|
+
//
|
|
4
|
+
// - `X-Workbench-Request-Id` on every response;
|
|
5
|
+
// - on a non-streamed reply, a `workbench` object beside the Message, which
|
|
6
|
+
// the SDK passes through untouched;
|
|
7
|
+
// - on a stream, a final `event: workbench.usage` after `message_stop`. The
|
|
8
|
+
// wrapper takes that event out of the stream before the SDK parses it.
|
|
9
|
+
//
|
|
10
|
+
// One capture is made per call, so nothing is shared between requests.
|
|
11
|
+
export const REQUEST_ID_HEADER = "x-workbench-request-id";
|
|
12
|
+
export function isGatewayMeta(value) {
|
|
13
|
+
if (typeof value !== "object" || value === null)
|
|
14
|
+
return false;
|
|
15
|
+
const v = value;
|
|
16
|
+
return typeof v["request_id"] === "string" && typeof v["model"] === "string" && typeof v["cost_usd"] === "number";
|
|
17
|
+
}
|
|
18
|
+
export function createCapture(base) {
|
|
19
|
+
let requestId;
|
|
20
|
+
let meta;
|
|
21
|
+
const wrapped = (async (input, init) => {
|
|
22
|
+
const res = await base(input, init);
|
|
23
|
+
requestId = res.headers.get(REQUEST_ID_HEADER) ?? requestId;
|
|
24
|
+
const type = res.headers.get("content-type") ?? "";
|
|
25
|
+
if (!res.body || !type.includes("text/event-stream"))
|
|
26
|
+
return res;
|
|
27
|
+
const body = res.body.pipeThrough(stripUsageEvent((m) => (meta = m)));
|
|
28
|
+
return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
fetch: wrapped,
|
|
32
|
+
requestId: () => requestId,
|
|
33
|
+
streamMeta: () => meta,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const USAGE_EVENT = "workbench.usage";
|
|
37
|
+
/**
|
|
38
|
+
* Passes SSE events through unchanged, except `workbench.usage`, which is
|
|
39
|
+
* parsed and handed to `onMeta` instead. Event boundaries are blank lines
|
|
40
|
+
* (LF or CRLF), as the SSE spec has them.
|
|
41
|
+
*/
|
|
42
|
+
export function stripUsageEvent(onMeta) {
|
|
43
|
+
const decoder = new TextDecoder();
|
|
44
|
+
const encoder = new TextEncoder();
|
|
45
|
+
let buffer = "";
|
|
46
|
+
function flushEvents(controller, final) {
|
|
47
|
+
buffer = buffer.replace(/\r\n/g, "\n");
|
|
48
|
+
let cut;
|
|
49
|
+
while ((cut = buffer.indexOf("\n\n")) !== -1) {
|
|
50
|
+
const block = buffer.slice(0, cut);
|
|
51
|
+
buffer = buffer.slice(cut + 2);
|
|
52
|
+
emit(block, controller);
|
|
53
|
+
}
|
|
54
|
+
if (final && buffer.trim() !== "") {
|
|
55
|
+
emit(buffer, controller);
|
|
56
|
+
buffer = "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function emit(block, controller) {
|
|
60
|
+
const lines = block.split("\n");
|
|
61
|
+
const event = lines.find((l) => l.startsWith("event:"))?.slice(6).trim();
|
|
62
|
+
if (event === USAGE_EVENT) {
|
|
63
|
+
const data = lines
|
|
64
|
+
.filter((l) => l.startsWith("data:"))
|
|
65
|
+
.map((l) => l.slice(5).replace(/^ /, ""))
|
|
66
|
+
.join("\n");
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(data);
|
|
69
|
+
if (isGatewayMeta(parsed))
|
|
70
|
+
onMeta(parsed);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// A malformed usage event is dropped; the caller then reports no cost
|
|
74
|
+
// rather than a wrong one.
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
controller.enqueue(encoder.encode(`${block}\n\n`));
|
|
79
|
+
}
|
|
80
|
+
return new TransformStream({
|
|
81
|
+
transform(chunk, controller) {
|
|
82
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
83
|
+
flushEvents(controller, false);
|
|
84
|
+
},
|
|
85
|
+
flush(controller) {
|
|
86
|
+
buffer += decoder.decode();
|
|
87
|
+
flushEvents(controller, true);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type AiErrorCode = "refused" | "truncated" | "invalid_output" | "unexpected_stop" | "unauthorised" | "forbidden_scope" | "invalid_request" | "model_not_allowed" | "budget_exceeded" | "rate_limited" | "not_found" | "not_available" | "upstream_error" | "upstream_timeout" | "internal_error" | "network" | "invalid_config";
|
|
2
|
+
export interface AiErrorDetails {
|
|
3
|
+
requestId?: string;
|
|
4
|
+
upstreamStatus?: number;
|
|
5
|
+
/** The resolved model, when the Gateway got as far as resolving one. */
|
|
6
|
+
model?: string;
|
|
7
|
+
/** For `invalid_output`: the required fields still missing after the re-ask. */
|
|
8
|
+
missing?: string[];
|
|
9
|
+
/** For `refused`: the refusal category Anthropic gave, if any. */
|
|
10
|
+
refusalCategory?: string | null;
|
|
11
|
+
/** For `budget_exceeded`. */
|
|
12
|
+
month?: string;
|
|
13
|
+
spentUsd?: number;
|
|
14
|
+
capUsd?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare class WorkbenchAiError extends Error {
|
|
17
|
+
readonly code: AiErrorCode;
|
|
18
|
+
readonly retryable: boolean;
|
|
19
|
+
readonly requestId?: string;
|
|
20
|
+
readonly upstreamStatus?: number;
|
|
21
|
+
readonly details: AiErrorDetails;
|
|
22
|
+
constructor(code: AiErrorCode, message: string, details?: AiErrorDetails);
|
|
23
|
+
}
|
|
24
|
+
/** Maps an HTTP status to a code, for an error body that carries none. */
|
|
25
|
+
export declare function codeForStatus(status: number | undefined): AiErrorCode;
|
|
26
|
+
/** Picks the Workbench code out of either error shape the Gateway sends. */
|
|
27
|
+
export declare function codeFromBody(body: unknown): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Builds a WorkbenchAiError from a Gateway error response: `/anthropic/*`
|
|
30
|
+
* keeps Anthropic's shape with `error.workbench_code`; the `/v1/*` paths use
|
|
31
|
+
* `{ error: { code, message, request_id, retryable, upstream_status? } }`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function errorFromGateway(status: number | undefined, body: unknown, requestId?: string): WorkbenchAiError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Every failure the package raises is a WorkbenchAiError with a stable code
|
|
2
|
+
// (docs/spec.md §3). Callers branch on `code`, never on the message. A refusal
|
|
3
|
+
// or a max_tokens truncation is an error, never a partial success (audit F18).
|
|
4
|
+
const RETRYABLE = new Set(["rate_limited", "upstream_error", "upstream_timeout", "network"]);
|
|
5
|
+
export class WorkbenchAiError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
retryable;
|
|
8
|
+
requestId;
|
|
9
|
+
upstreamStatus;
|
|
10
|
+
details;
|
|
11
|
+
constructor(code, message, details = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "WorkbenchAiError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.retryable = RETRYABLE.has(code);
|
|
16
|
+
this.details = details;
|
|
17
|
+
if (details.requestId !== undefined)
|
|
18
|
+
this.requestId = details.requestId;
|
|
19
|
+
if (details.upstreamStatus !== undefined)
|
|
20
|
+
this.upstreamStatus = details.upstreamStatus;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const KNOWN = new Set([
|
|
24
|
+
"unauthorised",
|
|
25
|
+
"forbidden_scope",
|
|
26
|
+
"invalid_request",
|
|
27
|
+
"model_not_allowed",
|
|
28
|
+
"budget_exceeded",
|
|
29
|
+
"rate_limited",
|
|
30
|
+
"not_found",
|
|
31
|
+
"not_available",
|
|
32
|
+
"upstream_error",
|
|
33
|
+
"upstream_timeout",
|
|
34
|
+
"internal_error",
|
|
35
|
+
]);
|
|
36
|
+
/** Maps an HTTP status to a code, for an error body that carries none. */
|
|
37
|
+
export function codeForStatus(status) {
|
|
38
|
+
switch (status) {
|
|
39
|
+
case 400:
|
|
40
|
+
case 413:
|
|
41
|
+
case 422:
|
|
42
|
+
return "invalid_request";
|
|
43
|
+
case 401:
|
|
44
|
+
return "unauthorised";
|
|
45
|
+
case 402:
|
|
46
|
+
return "budget_exceeded";
|
|
47
|
+
case 403:
|
|
48
|
+
return "forbidden_scope";
|
|
49
|
+
case 404:
|
|
50
|
+
return "not_found";
|
|
51
|
+
case 429:
|
|
52
|
+
return "rate_limited";
|
|
53
|
+
case 501:
|
|
54
|
+
return "not_available";
|
|
55
|
+
case 504:
|
|
56
|
+
return "upstream_timeout";
|
|
57
|
+
case undefined:
|
|
58
|
+
return "network";
|
|
59
|
+
default:
|
|
60
|
+
return status >= 500 ? "upstream_error" : "invalid_request";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Picks the Workbench code out of either error shape the Gateway sends. */
|
|
64
|
+
export function codeFromBody(body) {
|
|
65
|
+
if (typeof body !== "object" || body === null)
|
|
66
|
+
return undefined;
|
|
67
|
+
const err = body.error;
|
|
68
|
+
if (typeof err !== "object" || err === null)
|
|
69
|
+
return undefined;
|
|
70
|
+
const e = err;
|
|
71
|
+
if (typeof e.workbench_code === "string")
|
|
72
|
+
return e.workbench_code;
|
|
73
|
+
if (typeof e.code === "string")
|
|
74
|
+
return e.code;
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Builds a WorkbenchAiError from a Gateway error response: `/anthropic/*`
|
|
79
|
+
* keeps Anthropic's shape with `error.workbench_code`; the `/v1/*` paths use
|
|
80
|
+
* `{ error: { code, message, request_id, retryable, upstream_status? } }`.
|
|
81
|
+
*/
|
|
82
|
+
export function errorFromGateway(status, body, requestId) {
|
|
83
|
+
const raw = codeFromBody(body);
|
|
84
|
+
const code = raw !== undefined && KNOWN.has(raw) ? raw : codeForStatus(status);
|
|
85
|
+
const err = (typeof body === "object" && body !== null ? body.error : undefined) ?? {};
|
|
86
|
+
const message = typeof err["message"] === "string" ? err["message"] : `Gateway answered ${status ?? "nothing"}`;
|
|
87
|
+
const details = {};
|
|
88
|
+
const rid = requestId ?? (typeof err["request_id"] === "string" ? err["request_id"] : undefined);
|
|
89
|
+
if (rid)
|
|
90
|
+
details.requestId = rid;
|
|
91
|
+
if (typeof err["upstream_status"] === "number")
|
|
92
|
+
details.upstreamStatus = err["upstream_status"];
|
|
93
|
+
if (typeof err["month"] === "string")
|
|
94
|
+
details.month = err["month"];
|
|
95
|
+
if (typeof err["spent_usd"] === "number")
|
|
96
|
+
details.spentUsd = err["spent_usd"];
|
|
97
|
+
if (typeof err["cap_usd"] === "number")
|
|
98
|
+
details.capUsd = err["cap_usd"];
|
|
99
|
+
return new WorkbenchAiError(code, message, details);
|
|
100
|
+
}
|