@seanhogg/builderforce-sdk 0.2.0 → 0.4.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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Sean Hogg
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sean Hogg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,98 +1,363 @@
1
- # @seanhogg/builderforce-sdk
2
-
3
- Typed SDK for Builderforce LLM gateway APIs.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @seanhogg/builderforce-sdk
9
- ```
10
-
11
- ## Quick start
12
-
13
- ```ts
14
- import { BuilderforceClient } from '@seanhogg/builderforce-sdk';
15
-
16
- const client = new BuilderforceClient({
17
- apiKey: process.env.BUILDERFORCE_API_KEY!,
18
- // Optional:
19
- // baseUrl: 'https://api.builderforce.ai',
20
- // timeoutMs: 60_000,
21
- });
22
- ```
23
-
24
- ## Non-streaming chat
25
-
26
- ```ts
27
- const res = await client.chat.completions.create({
28
- useCase: 'ide.chat',
29
- stream: false,
30
- messages: [{ role: 'user', content: 'Summarize this PRD.' }],
31
- });
32
-
33
- console.log(res.choices?.[0]?.message?.content);
34
- ```
35
-
36
- ## Streaming chat
37
-
38
- ```ts
39
- const stream = await client.chat.completions.create({
40
- useCase: 'coach.chat',
41
- stream: true,
42
- messages: [{ role: 'user', content: 'Give me a weekly plan.' }],
43
- });
44
-
45
- for await (const chunk of stream) {
46
- const delta = chunk.choices?.[0]?.delta?.content ?? '';
47
- process.stdout.write(delta);
48
- }
49
- ```
50
-
51
- Or collect all streaming text:
52
-
53
- ```ts
54
- const text = await stream.toText();
55
- ```
56
-
57
- ## Models and usage
58
-
59
- ```ts
60
- const models = await client.models.list();
61
- const usage = await client.usage.get({ days: 30 });
62
- ```
63
-
64
- ## Errors
65
-
66
- SDK requests throw `BuilderforceApiError` on non-2xx responses:
67
-
68
- ```ts
69
- import { BuilderforceApiError } from '@seanhogg/builderforce-sdk';
70
-
71
- try {
72
- await client.models.list();
73
- } catch (error) {
74
- if (error instanceof BuilderforceApiError) {
75
- console.error(error.status, error.code, error.requestId, error.message);
76
- }
77
- }
78
- ```
79
-
80
- ## Auth conventions
81
-
82
- The SDK sends `Authorization: Bearer <apiKey>` automatically. The gateway accepts three credential types:
83
-
84
- | Prefix | Issued by | Best for |
85
- |---|---|---|
86
- | `bfk_*` | `POST /api/tenants/:tenantId/api-keys` (owner-only) | Tenant apps (server-to-server). Long-lived, tenant-scoped, revocable. |
87
- | `clk_*` | `POST /api/claws` (CoderClaw registration) | Self-hosted CoderClaw instances; carries optional per-claw daily token cap. |
88
- | Tenant JWT | `POST /api/auth/web/login` → `POST /api/auth/tenant-token` | Browser-side calls from a logged-in user. Short-lived. |
89
-
90
- Workforce model routing is server-side: pass `model: 'builderforce/workforce-<agentId>'` when needed.
91
-
92
- ## Use-case safety
93
-
94
- `AIUseCase` is exported for compile-time checks:
95
-
96
- ```ts
97
- import type { AIUseCase } from '@seanhogg/builderforce-sdk';
98
- ```
1
+ # @seanhogg/builderforce-sdk
2
+
3
+ Typed TypeScript SDK for the [Builderforce.ai](https://builderforce.ai) LLM gateway. OpenAI-compatible chat completions with tool calling and structured output, embeddings, model registry, and usage analytics — all behind a single tenant API key. Vendor failover (OpenRouter / Cerebras / Ollama / Claude / GPT / Gemini / Grok) is handled server-side so your code only knows about Builderforce.
4
+
5
+ - **Vanilla `fetch` / `AbortController` / `ReadableStream` / `TextDecoder`** — runs on Node 18+, Cloudflare Workers, browsers, edge runtimes.
6
+ - **Zero runtime dependencies.** ~22 kB compressed, ~100 kB unpacked.
7
+ - **Dual ESM + CJS + `.d.ts`** out of the box.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @seanhogg/builderforce-sdk
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```ts
18
+ import { BuilderforceClient } from '@seanhogg/builderforce-sdk';
19
+
20
+ const client = new BuilderforceClient({
21
+ apiKey: process.env.BUILDERFORCE_API_KEY!,
22
+ // Optional:
23
+ // baseUrl: 'https://api.builderforce.ai',
24
+ // timeoutMs: 60_000,
25
+ });
26
+
27
+ const res = await client.chat.completions.create({
28
+ messages: [{ role: 'user', content: 'Summarize this PRD in three bullets.' }],
29
+ });
30
+
31
+ console.log(res.choices?.[0]?.message?.content);
32
+ ```
33
+
34
+ When you don't pass a `model`, the gateway picks one from your plan's pool and reorders by **request shape** — presence of `tools`, `response_format`, image content blocks. When you do pass a `model`, the gateway treats it as a hint (it tries that model first, may substitute on cooldown / failure — read `_builderforce.resolvedModel` to detect substitution). See [docs/SCENARIOS.md](./docs/SCENARIOS.md) for typical request shapes per scenario.
35
+
36
+ ## Auth
37
+
38
+ The SDK sends `Authorization: Bearer <apiKey>` automatically. The gateway accepts three credential types:
39
+
40
+ | Prefix | Issued by | Best for |
41
+ |---|---|---|
42
+ | `bfk_*` | `POST /api/tenants/:tenantId/api-keys` (owner-only) | Tenant apps (server-to-server). Long-lived, tenant-scoped, revocable. |
43
+ | `clk_*` | `POST /api/claws` (CoderClaw registration) | Self-hosted CoderClaw instances; carries optional per-claw daily token cap. |
44
+ | Tenant JWT | `POST /api/auth/web/login` → `POST /api/auth/tenant-token` | Browser-side calls from a logged-in user. Short-lived. |
45
+
46
+ ## Streaming chat
47
+
48
+ ```ts
49
+ const stream = await client.chat.completions.create({
50
+ stream: true,
51
+ messages: [{ role: 'user', content: 'Outline a 3-act story.' }],
52
+ });
53
+
54
+ for await (const chunk of stream) {
55
+ const delta = chunk.choices?.[0]?.delta?.content ?? '';
56
+ process.stdout.write(delta);
57
+ }
58
+
59
+ // Or buffer to a single string:
60
+ const text = await stream.toText();
61
+ ```
62
+
63
+ ## Tool calling
64
+
65
+ Full OpenAI-compatible tool / function calling round-trip — assistant requests a tool, you execute it, you feed the result back, the assistant continues:
66
+
67
+ ```ts
68
+ import type { ToolSpec, ChatMessage } from '@seanhogg/builderforce-sdk';
69
+
70
+ const tools: ToolSpec[] = [{
71
+ type: 'function',
72
+ function: {
73
+ name: 'get_weather',
74
+ description: 'Look up current weather for a city.',
75
+ parameters: {
76
+ type: 'object',
77
+ properties: { city: { type: 'string' } },
78
+ required: ['city'],
79
+ },
80
+ },
81
+ }];
82
+
83
+ const messages: ChatMessage[] = [
84
+ { role: 'user', content: "What's the weather in Tokyo?" },
85
+ ];
86
+
87
+ // Turn 1 model decides to call the tool.
88
+ const turn1 = await client.chat.completions.create({
89
+ tools,
90
+ tool_choice: 'auto',
91
+ messages,
92
+ });
93
+
94
+ const assistantMsg = turn1.choices?.[0]?.message;
95
+ const toolCall = assistantMsg?.tool_calls?.[0];
96
+
97
+ if (toolCall) {
98
+ const args = JSON.parse(toolCall.function.arguments) as { city: string };
99
+ const weather = await fetchWeather(args.city);
100
+
101
+ // Turn 2 — feed the tool result back.
102
+ messages.push(
103
+ { role: 'assistant', content: null, tool_calls: [toolCall] },
104
+ { role: 'tool', content: JSON.stringify(weather), tool_call_id: toolCall.id },
105
+ );
106
+
107
+ const turn2 = await client.chat.completions.create({ tools, messages });
108
+ console.log(turn2.choices?.[0]?.message?.content);
109
+ }
110
+ ```
111
+
112
+ `tool_choice` accepts `'auto' | 'none' | 'required' | { type: 'function', function: { name } }`. Presence of `tools` causes the gateway to prefer tool-capable models in the failover chain.
113
+
114
+ **Dotted tool names work transparently.** Names like `governance.snapshot` or `agile.kanban.list` are accepted by the SDK and the gateway sanitizes them on the way to vendors that reject dots (e.g. Anthropic's `^[a-zA-Z0-9_-]{1,64}$` rule), then restores them on the response. Your tool registry's namespacing is preserved end-to-end.
115
+
116
+ ## Structured output (JSON mode)
117
+
118
+ Two flavours. **`json_object`** asks the model to emit valid JSON; **`json_schema`** asks the gateway to validate against a schema and retry across the failover chain when the model produces non-conforming output.
119
+
120
+ ```ts
121
+ // Loose JSON mode
122
+ const loose = await client.chat.completions.create({
123
+ response_format: { type: 'json_object' },
124
+ messages: [{ role: 'user', content: 'Parse this job posting: …' }],
125
+ });
126
+ const data = JSON.parse(loose.choices?.[0]?.message?.content ?? '{}');
127
+
128
+ // Strict schema mode (gateway-side conformance retry)
129
+ const strict = await client.chat.completions.create({
130
+ response_format: {
131
+ type: 'json_schema',
132
+ json_schema: {
133
+ name: 'SalaryEstimate',
134
+ strict: true,
135
+ schema: {
136
+ type: 'object',
137
+ properties: {
138
+ low: { type: 'number' },
139
+ median: { type: 'number' },
140
+ high: { type: 'number' },
141
+ confidence: { type: 'number', minimum: 0, maximum: 1 },
142
+ },
143
+ required: ['low', 'median', 'high', 'confidence'],
144
+ additionalProperties: false,
145
+ },
146
+ },
147
+ },
148
+ messages: [{ role: 'user', content: 'Estimate Senior SRE salary in NYC.' }],
149
+ });
150
+ const retries = strict._builderforce?.schemaRetries ?? 0;
151
+ ```
152
+
153
+ ## Vision — image + text in one message
154
+
155
+ ```ts
156
+ const desc = await client.chat.completions.create({
157
+ messages: [
158
+ {
159
+ role: 'user',
160
+ content: [
161
+ { type: 'text', text: "What's in this image?" },
162
+ { type: 'image_url', image_url: { url: 'https://example.com/cat.jpg', detail: 'high' } },
163
+ ],
164
+ },
165
+ ],
166
+ });
167
+ ```
168
+
169
+ `content` can be a plain `string` (most cases), an `Array<TextContentPart | ImageUrlContentPart>` for vision, or `null` on assistant turns that only carry `tool_calls`.
170
+
171
+ ## Embeddings
172
+
173
+ ```ts
174
+ const res = await client.embeddings.create({
175
+ input: ['First sentence.', 'Second sentence.'],
176
+ });
177
+
178
+ for (const obj of res.data) {
179
+ console.log(obj.index, obj.embedding.length);
180
+ }
181
+ ```
182
+
183
+ Wired to OpenRouter; default model `nvidia/llama-nemotron-embed-vl-1b-v2:free` (free-tier, competitive with `text-embedding-3-small` for English). Override via `model`.
184
+
185
+ ## Per-call options
186
+
187
+ Override defaults for individual calls:
188
+
189
+ ```ts
190
+ // Tight timeout for fast use cases
191
+ await client.chat.completions.create({
192
+ timeoutMs: 5_000,
193
+ messages: [...],
194
+ });
195
+
196
+ // Long-form analysis
197
+ await client.chat.completions.create({
198
+ timeoutMs: 90_000,
199
+ messages: [...],
200
+ });
201
+
202
+ // User-cancellable streaming
203
+ const ctl = new AbortController();
204
+ cancelButton.addEventListener('click', () => ctl.abort());
205
+ const stream = await client.chat.completions.create({
206
+ stream: true,
207
+ signal: ctl.signal,
208
+ messages: [...],
209
+ });
210
+
211
+ // Idempotent retries — gateway returns 409 idempotent_replay if the same
212
+ // (tenant, key) pair was used in the last 10 min, so cron retries don't double-charge.
213
+ try {
214
+ await client.chat.completions.create({
215
+ idempotencyKey: `nightly-summary:${date}:${accountId}`,
216
+ messages: [...],
217
+ });
218
+ } catch (err) {
219
+ if (err instanceof BuilderforceApiError && err.code === 'idempotent_replay') {
220
+ return null; // first attempt already ran — no-op the retry
221
+ }
222
+ throw err;
223
+ }
224
+ ```
225
+
226
+ | Option | Meaning |
227
+ |---|---|
228
+ | `timeoutMs` | Override client-level timeout for this call. Combined with `signal` (below) — whichever fires first wins. |
229
+ | `signal` | Caller's `AbortSignal` for user-cancellable generation. |
230
+ | `idempotencyKey` | Sent as `Idempotency-Key` header. Gateway 409s on replay within 10 min so retries can no-op safely. (Response-body cache replay is planned.) |
231
+
232
+ ## Metadata for billing trace-back
233
+
234
+ Attach `{ toolRunId, sessionId, userId, ... }` to any call — the gateway persists it on the same row as token counts in `llm_usage_log.metadata`, so you can join `tool_runs` ↔ `llm_usage_log` directly without round-tripping `requestId`.
235
+
236
+ ```ts
237
+ await client.chat.completions.create({
238
+ metadata: {
239
+ toolRunId: 'tr_abc',
240
+ sessionId: 'sess_xyz',
241
+ userId: 'user_42',
242
+ feature: 'cold-outreach-v3',
243
+ },
244
+ messages: [...],
245
+ });
246
+ ```
247
+
248
+ `metadata` is gateway-side only — never forwarded to upstream vendors.
249
+
250
+ ## Pre-emptive throttling
251
+
252
+ Every successful chat-completion response carries the tenant's daily-budget snapshot — both as headers and inside `_builderforce.dailyTokens`. Read either to throttle before you hit the 429 gate.
253
+
254
+ ```ts
255
+ const res = await client.chat.completions.create({ messages: [...] });
256
+ const { used, limit, remaining } = res._builderforce?.dailyTokens ?? {};
257
+ if (remaining != null && remaining < 50_000) {
258
+ // Switch to cheaper models, queue background work, page on-call, etc.
259
+ }
260
+
261
+ // Same numbers are also on the response headers:
262
+ // x-builderforce-daily-tokens-used
263
+ // x-builderforce-daily-tokens-limit
264
+ // x-builderforce-daily-tokens-remaining
265
+ ```
266
+
267
+ ## Errors
268
+
269
+ ```ts
270
+ import { BuilderforceApiError } from '@seanhogg/builderforce-sdk';
271
+
272
+ try {
273
+ await client.chat.completions.create({ ... });
274
+ } catch (error) {
275
+ if (error instanceof BuilderforceApiError) {
276
+ console.error(error.status, error.code, error.requestId, error.message);
277
+ }
278
+ }
279
+ ```
280
+
281
+ | `status` | `code` | When |
282
+ |---|---|---|
283
+ | 408 | `timeout` | SDK-side timeout fired |
284
+ | 499 | `aborted` | Caller's `AbortSignal` aborted |
285
+ | 409 | `idempotent_replay` | `Idempotency-Key` was used within the last 10 min — treat as no-op |
286
+ | 429 | `plan_token_limit_exceeded` | Tenant hit daily plan budget |
287
+ | 429 | `claw_token_limit_exceeded` | Per-claw daily cap exceeded (`clk_*` keys only) |
288
+ | 503 | (no code) | Vendor key not configured for the active plan tier |
289
+ | 401 | `missing_api_key` | Auth issues |
290
+ | 403 | (varied) | Wrong scope / wrong tenant for the URL |
291
+
292
+ `error.requestId` comes from the gateway's `x-request-id` header — quote it in support tickets. Map gateway 429s to your own 503 + alerting (it's an ops issue, not a user issue).
293
+
294
+ ## Models and usage
295
+
296
+ ```ts
297
+ const models = await client.models.list();
298
+ const usage = await client.usage.get({ days: 30 });
299
+ ```
300
+
301
+ `usage` returns aggregate spend by model, day, and user; plus `mine` (the calling user's slice) and `totals`. Pass `?detail=true&page=1&limit=100` for row-level pagination — every recorded call with its `useCase`, `metadata`, `idempotencyKey`, and token counts. Use this to reconcile your own usage table against the gateway's ledger.
302
+
303
+ ## Routing — `model` is a hint, gateway has final say
304
+
305
+ The gateway owns model selection. When you pass a `model`, the gateway treats it as a **hint** — it puts that id at the head of its candidate chain so it's tried first, but it retains the right to substitute on cooldown, vendor outage, or plan-tier mismatch. **Always read `_builderforce.resolvedModel` if you need to know what actually ran.**
306
+
307
+ ```ts
308
+ const res = await client.chat.completions.create({
309
+ model: 'openrouter/anthropic/claude-3-haiku',
310
+ messages: [...],
311
+ });
312
+
313
+ console.log(res._builderforce?.resolvedModel);
314
+ // → 'openrouter/anthropic/claude-3-haiku' on the happy path
315
+ // → some other model in the pool if Claude was on cooldown / failed
316
+ ```
317
+
318
+ Vendor prefixes (`openrouter/`, `cerebras/`, `ollama/`) explicitly route to that vendor when that model is selected. Bare ids fall back to a catalog lookup.
319
+
320
+ When `model` is unset the gateway picks from the tenant-plan pool with shape-based reordering — `tools` present → tool-capable models try first, `response_format: 'json_schema'` → structured-output models, image content blocks → vision models. Useful for callers that don't run their own model policy.
321
+
322
+ If you need *strict* control (no substitution under any condition) — e.g. for evaluations or reproducibility — see the [strict-pin pattern in SCENARIOS.md](./docs/SCENARIOS.md#strict-model-pinning-eval--reproducibility). It's a thin client-side helper that throws when `_builderforce.resolvedModel` differs from the request. The gateway's job is availability; yours is policy.
323
+
324
+ ## Multi-tenancy — one Builderforce key, many of *your* tenants
325
+
326
+ The gateway's auth model is **one `bfk_*` key per Builderforce tenant** (i.e. per app integrating with the gateway). If your app itself runs multi-tenant (you serve N customers under a single deployment), use a single `bfk_*` and identify your end-tenants via `metadata`:
327
+
328
+ ```ts
329
+ client.chat.completions.create({
330
+ metadata: {
331
+ accountId: customer.accountId, // your end-tenant
332
+ userId: activeUser.id,
333
+ viewerId: viewer?.id ?? '',
334
+ runner: 'cron|user-action|scheduled',
335
+ },
336
+ messages: [...],
337
+ });
338
+ ```
339
+
340
+ Each call's metadata persists to `llm_usage_log.metadata` JSONB — pageable via `GET /llm/v1/usage?detail=true&page=N`. You query rows by your own `accountId` to compute per-customer spend without provisioning per-customer keys.
341
+
342
+ **You do not need to mint per-end-tenant `bfk_*` keys.** The gateway bills your Builderforce tenant in aggregate; per-customer accounting lives in your usage queries.
343
+
344
+ If you need genuine isolation (separate token budgets per end-tenant, separate revocation), provision multiple `bfk_*` keys via `POST /api/tenants/:tenantId/api-keys` and route per-customer in your code. Most apps don't need this.
345
+
346
+ ## `useCase` — opaque telemetry slug
347
+
348
+ Pass any string. The gateway never reads it for routing; it's persisted to `llm_usage_log.use_case` and echoed back in `_builderforce.useCase` for confirmation. Useful for per-feature spend dashboards and reconciliation.
349
+
350
+ ```ts
351
+ client.chat.completions.create({
352
+ useCase: 'studio_storyboard', // your taxonomy, free-form
353
+ metadata: { featureKey: 'storyboard_generate', toolRunId },
354
+ messages: [...],
355
+ });
356
+
357
+ // Response carries the echo:
358
+ // { ..., _builderforce: { useCase: 'studio_storyboard', metadata: {...}, requestId: 'req_...' } }
359
+ ```
360
+
361
+ ## License
362
+
363
+ MIT — see [LICENSE](./LICENSE).