@tokenite/sdk 2.2.0 → 2.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/README.md CHANGED
@@ -1,229 +1,229 @@
1
- # @tokenite/sdk
2
-
3
- One wallet for all your AI apps. Users store their Anthropic, OpenAI, and Google API keys in Tokenite, then grant your app metered access via OAuth. You pay nothing for AI usage.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @tokenite/sdk
9
- ```
10
-
11
- ## Quick Start
12
-
13
- ```typescript
14
- import { Tokenite, isProxyError } from '@tokenite/sdk';
15
-
16
- const tw = Tokenite({
17
- clientId: 'your-app-id',
18
- clientSecret: 'your-app-secret',
19
- redirectUri: 'https://yourapp.com/callback',
20
- });
21
-
22
- // 1. Redirect the user to the consent screen
23
- app.get('/login', (req, res) => res.redirect(tk.getAuthorizeUrl()));
24
-
25
- // 2. Exchange the OAuth code for an access token in your callback
26
- app.get('/callback', async (req, res) => {
27
- const { access_token } = await tk.exchangeCode(req.query.code as string);
28
- req.session.tokeniteToken = access_token;
29
- res.redirect('/');
30
- });
31
-
32
- // 3. Call the LLM through the proxy
33
- app.post('/chat', async (req, res) => {
34
- const result = await tk.call({
35
- accessToken: req.session.tokeniteToken,
36
- provider: 'anthropic',
37
- path: '/v1/messages',
38
- body: {
39
- model: 'claude-3-5-sonnet-latest',
40
- max_tokens: 1024,
41
- messages: [{ role: 'user', content: req.body.prompt }],
42
- },
43
- });
44
-
45
- if (isProxyError(result)) {
46
- return res.status(400).json({ error: result.error });
47
- }
48
-
49
- res.json({ model: result.model, usage: result.usage, data: result.data });
50
- });
51
- ```
52
-
53
- The same `tk.call(...)` works for OpenAI and Google — change `provider` and `path`:
54
-
55
- ```typescript
56
- await tk.call({
57
- accessToken,
58
- provider: 'openai',
59
- path: '/v1/chat/completions',
60
- body: { model: 'gpt-4o', messages: [...] },
61
- });
62
-
63
- await tk.call({
64
- accessToken,
65
- provider: 'google',
66
- path: '/v1beta/models/gemini-1.5-pro:generateContent',
67
- body: { contents: [...] },
68
- });
69
- ```
70
-
71
- ### Modal flow (single-page apps)
72
-
73
- If your app is a SPA, open the consent screen in an iframe modal:
74
-
75
- ```typescript
76
- const { code } = await tk.popup({ suggestedBudget: 5 });
77
-
78
- // Send the code to your backend, which calls tk.exchangeCode(code).
79
- // The exchange requires clientSecret and must never run in browser code.
80
- await fetch('/api/auth/exchange', {
81
- method: 'POST',
82
- body: JSON.stringify({ code }),
83
- });
84
- ```
85
-
86
- ### Managed agents (Anthropic)
87
-
88
- Tokenite proxies Anthropic's [Managed Agents](https://platform.claude.com/docs/en/managed-agents/overview) surface — the `/v1/agents`, `/v1/environments`, `/v1/sessions`, `/v1/vaults`, `/v1/files`, and `/v1/skills` endpoints — under the same BYOK model as Messages. The app points the Anthropic SDK at `tk.proxyUrl('anthropic')`; the proxy injects the right beta header per surface (`managed-agents-2026-04-01` for agents/environments/sessions/vaults, `files-api-2025-04-14` for files, `skills-2025-10-02` for skills) and forwards calls to the user's Anthropic org. Client-supplied `anthropic-beta` headers are passed through and merged with the auto-injected ones, so callers can opt into additional betas (e.g. multi-version skills) without losing the proxy-managed defaults.
89
-
90
- **Explicit consent required.** Because agent sessions run long-lived, billable server-side work on Anthropic (`$0.08/hr` session runtime on top of tokens), the proxy rejects agent-surface calls unless the app was created with `allowsManagedAgents: true`. The flag sits alongside the other app fields on creation:
91
-
92
- ```typescript
93
- await admin.apps.create({
94
- name: 'Life Coach',
95
- callbackUrl: 'https://lifecoach.ai/callback',
96
- modelStrategy: 'models',
97
- allowedModels: ['claude-opus-4-7', 'claude-sonnet-4-6'],
98
- allowsManagedAgents: true, // ← opt in
99
- });
100
- ```
101
-
102
- Without it, every agent endpoint returns `403 AGENT_SCOPE_MISSING`.
103
-
104
- ```typescript
105
- import Anthropic from '@anthropic-ai/sdk';
106
-
107
- const anthropic = new Anthropic({
108
- apiKey: accessToken,
109
- baseURL: tk.proxyUrl('anthropic'),
110
- });
111
-
112
- // Provision once per user.
113
- const agent = await anthropic.beta.agents.create({
114
- name: 'Life Coach',
115
- model: 'claude-haiku-4-5',
116
- system: 'You are a terse life coach.',
117
- tools: [{ type: 'agent_toolset_20260401' }],
118
- });
119
-
120
- // Spin up a session.
121
- const env = await anthropic.beta.environments.create({ name: 'prod' });
122
- const session = await anthropic.beta.sessions.create({
123
- agent: agent.id,
124
- environment_id: env.id,
125
- });
126
-
127
- // Send events, stream responses — all proxied.
128
- await anthropic.beta.sessions.events.post(session.id, {
129
- type: 'user.message', content: 'What should I focus on today?',
130
- });
131
-
132
- for await (const event of anthropic.beta.sessions.events.stream(session.id)) {
133
- // ...
134
- }
135
-
136
- // Archive when done. This is what triggers Tokenite to pull the final
137
- // usage totals from Anthropic and debit tokens + runtime against the budget.
138
- await anthropic.beta.sessions.archive(session.id);
139
- ```
140
-
141
- Tokenite tracks each session for attribution and auto-terminates running sessions when the user revokes the connection — otherwise Anthropic would keep billing `$0.08/hr` for the session runtime on top of tokens. The proxy bills the user's wallet at archive time using Anthropic's reported cumulative usage and runtime, plus the standard token rate from the model's pricing.
142
-
143
- Budget is checked pre-call but not enforced mid-session: a session that started under budget can run past it. Revoke is the user's hard kill switch.
144
-
145
- ### Handling the OAuth callback
146
-
147
- When Tokenite redirects back to your `redirect_uri`, the URL has one of:
148
-
149
- - `?code=...&state=...` — user approved; exchange the code for an access token.
150
- - `?error=access_denied&error_description=user_denied&state=...` — user clicked **Cancel** on the consent screen.
151
- - `?error=...&error_description=...&state=...` — any other OAuth failure.
152
- - nothing (or just `state`) — user landed on your callback path without completing a real OAuth round-trip.
153
-
154
- `parseCallback(input, options?)` turns the URL into a typed result so you can switch on it instead of hand-parsing strings. Pure function; works in any runtime (Node, Bun, edge, browser).
155
-
156
- ```typescript
157
- import { parseCallback } from '@tokenite/sdk';
158
-
159
- // In your /api/auth/callback handler:
160
- const result = parseCallback(req.url, { expectedState: sessionStoredState });
161
-
162
- if (result.ok) {
163
- const { access_token } = await tk.exchangeCode(result.code);
164
- // ... establish your app's session, redirect to /
165
- return;
166
- }
167
-
168
- switch (result.reason) {
169
- case 'user_denied':
170
- // User clicked Cancel. Don't show a scary error — render a
171
- // friendly "you cancelled" screen with a "Try again" button.
172
- return renderCancelled();
173
- case 'invalid_state':
174
- // CSRF check failed (state mismatch). Force a fresh login flow.
175
- return renderRetry('Your sign-in session expired. Please try again.');
176
- case 'missing_code':
177
- // User navigated to /callback directly (bookmark, browser back).
178
- return redirectToHome();
179
- case 'access_denied':
180
- case 'oauth_error':
181
- // Real OAuth failure. result.error + result.description have details.
182
- return renderError(result);
183
- }
184
- ```
185
-
186
- `parseCallback` accepts:
187
-
188
- - A full URL string: `parseCallback('https://yourapp.com/cb?code=…&state=…')`
189
- - A path + query: `parseCallback(req.url)` (Node's `req.url` works directly)
190
- - A query string: `parseCallback('?code=…&state=…')` or `parseCallback('code=…&state=…')`
191
- - A `URL` object: `parseCallback(new URL(...))`
192
- - A `URLSearchParams`: `parseCallback(params)`
193
-
194
- `expectedState` is optional. When you provide it, a mismatch returns `{ ok: false, reason: 'invalid_state' }` even if a `code` is present — treating a stolen code paired with a forged state as a CSRF attempt.
195
-
196
- ### Streaming responses
197
-
198
- `tk.call()` is for non-streaming requests. Streaming responses bypass the unified envelope and are forwarded as-is, so use any vendor SDK with `baseURL: tk.proxyUrl(...)`:
199
-
200
- ```typescript
201
- import Anthropic from '@anthropic-ai/sdk';
202
-
203
- const anthropic = new Anthropic({
204
- apiKey: accessToken,
205
- baseURL: tk.proxyUrl('anthropic'),
206
- });
207
-
208
- const stream = await anthropic.messages.stream({
209
- model: 'claude-3-5-sonnet-latest',
210
- max_tokens: 1024,
211
- messages: [{ role: 'user', content: 'Tell me a story.' }],
212
- });
213
-
214
- for await (const event of stream) {
215
- if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
216
- process.stdout.write(event.delta.text);
217
- }
218
- }
219
- ```
220
-
221
- ## API
222
-
1
+ # @tokenite/sdk
2
+
3
+ One wallet for all your AI apps. Users store their Anthropic, OpenAI, and Google API keys in Tokenite, then grant your app metered access via OAuth. You pay nothing for AI usage.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @tokenite/sdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { Tokenite, isProxyError } from '@tokenite/sdk';
15
+
16
+ const tw = Tokenite({
17
+ clientId: 'your-app-id',
18
+ clientSecret: 'your-app-secret',
19
+ redirectUri: 'https://yourapp.com/callback',
20
+ });
21
+
22
+ // 1. Redirect the user to the consent screen
23
+ app.get('/login', (req, res) => res.redirect(tk.getAuthorizeUrl()));
24
+
25
+ // 2. Exchange the OAuth code for an access token in your callback
26
+ app.get('/callback', async (req, res) => {
27
+ const { access_token } = await tk.exchangeCode(req.query.code as string);
28
+ req.session.tokeniteToken = access_token;
29
+ res.redirect('/');
30
+ });
31
+
32
+ // 3. Call the LLM through the proxy
33
+ app.post('/chat', async (req, res) => {
34
+ const result = await tk.call({
35
+ accessToken: req.session.tokeniteToken,
36
+ provider: 'anthropic',
37
+ path: '/v1/messages',
38
+ body: {
39
+ model: 'claude-3-5-sonnet-latest',
40
+ max_tokens: 1024,
41
+ messages: [{ role: 'user', content: req.body.prompt }],
42
+ },
43
+ });
44
+
45
+ if (isProxyError(result)) {
46
+ return res.status(400).json({ error: result.error });
47
+ }
48
+
49
+ res.json({ model: result.model, usage: result.usage, data: result.data });
50
+ });
51
+ ```
52
+
53
+ The same `tk.call(...)` works for OpenAI and Google — change `provider` and `path`:
54
+
55
+ ```typescript
56
+ await tk.call({
57
+ accessToken,
58
+ provider: 'openai',
59
+ path: '/v1/chat/completions',
60
+ body: { model: 'gpt-4o', messages: [...] },
61
+ });
62
+
63
+ await tk.call({
64
+ accessToken,
65
+ provider: 'google',
66
+ path: '/v1beta/models/gemini-1.5-pro:generateContent',
67
+ body: { contents: [...] },
68
+ });
69
+ ```
70
+
71
+ ### Modal flow (single-page apps)
72
+
73
+ If your app is a SPA, open the consent screen in an iframe modal:
74
+
75
+ ```typescript
76
+ const { code } = await tk.popup({ suggestedBudget: 5 });
77
+
78
+ // Send the code to your backend, which calls tk.exchangeCode(code).
79
+ // The exchange requires clientSecret and must never run in browser code.
80
+ await fetch('/api/auth/exchange', {
81
+ method: 'POST',
82
+ body: JSON.stringify({ code }),
83
+ });
84
+ ```
85
+
86
+ ### Managed agents (Anthropic)
87
+
88
+ Tokenite proxies Anthropic's [Managed Agents](https://platform.claude.com/docs/en/managed-agents/overview) surface — the `/v1/agents`, `/v1/environments`, `/v1/sessions`, `/v1/vaults`, `/v1/files`, and `/v1/skills` endpoints — under the same BYOK model as Messages. The app points the Anthropic SDK at `tk.proxyUrl('anthropic')`; the proxy injects the right beta header per surface (`managed-agents-2026-04-01` for agents/environments/sessions/vaults, `files-api-2025-04-14` for files, `skills-2025-10-02` for skills) and forwards calls to the user's Anthropic org. Client-supplied `anthropic-beta` headers are passed through and merged with the auto-injected ones, so callers can opt into additional betas (e.g. multi-version skills) without losing the proxy-managed defaults.
89
+
90
+ **Explicit consent required.** Because agent sessions run long-lived, billable server-side work on Anthropic (`$0.08/hr` session runtime on top of tokens), the proxy rejects agent-surface calls unless the app was created with `allowsManagedAgents: true`. The flag sits alongside the other app fields on creation:
91
+
92
+ ```typescript
93
+ await admin.apps.create({
94
+ name: 'Life Coach',
95
+ callbackUrl: 'https://lifecoach.ai/callback',
96
+ modelStrategy: 'models',
97
+ allowedModels: ['claude-opus-4-7', 'claude-sonnet-4-6'],
98
+ allowsManagedAgents: true, // ← opt in
99
+ });
100
+ ```
101
+
102
+ Without it, every agent endpoint returns `403 AGENT_SCOPE_MISSING`.
103
+
104
+ ```typescript
105
+ import Anthropic from '@anthropic-ai/sdk';
106
+
107
+ const anthropic = new Anthropic({
108
+ apiKey: accessToken,
109
+ baseURL: tk.proxyUrl('anthropic'),
110
+ });
111
+
112
+ // Provision once per user.
113
+ const agent = await anthropic.beta.agents.create({
114
+ name: 'Life Coach',
115
+ model: 'claude-haiku-4-5',
116
+ system: 'You are a terse life coach.',
117
+ tools: [{ type: 'agent_toolset_20260401' }],
118
+ });
119
+
120
+ // Spin up a session.
121
+ const env = await anthropic.beta.environments.create({ name: 'prod' });
122
+ const session = await anthropic.beta.sessions.create({
123
+ agent: agent.id,
124
+ environment_id: env.id,
125
+ });
126
+
127
+ // Send events, stream responses — all proxied.
128
+ await anthropic.beta.sessions.events.post(session.id, {
129
+ type: 'user.message', content: 'What should I focus on today?',
130
+ });
131
+
132
+ for await (const event of anthropic.beta.sessions.events.stream(session.id)) {
133
+ // ...
134
+ }
135
+
136
+ // Archive when done. This is what triggers Tokenite to pull the final
137
+ // usage totals from Anthropic and debit tokens + runtime against the budget.
138
+ await anthropic.beta.sessions.archive(session.id);
139
+ ```
140
+
141
+ Tokenite tracks each session for attribution and auto-terminates running sessions when the user revokes the connection — otherwise Anthropic would keep billing `$0.08/hr` for the session runtime on top of tokens. The proxy bills the user's wallet at archive time using Anthropic's reported cumulative usage and runtime, plus the standard token rate from the model's pricing.
142
+
143
+ Budget is checked pre-call but not enforced mid-session: a session that started under budget can run past it. Revoke is the user's hard kill switch.
144
+
145
+ ### Handling the OAuth callback
146
+
147
+ When Tokenite redirects back to your `redirect_uri`, the URL has one of:
148
+
149
+ - `?code=...&state=...` — user approved; exchange the code for an access token.
150
+ - `?error=access_denied&error_description=user_denied&state=...` — user clicked **Cancel** on the consent screen.
151
+ - `?error=...&error_description=...&state=...` — any other OAuth failure.
152
+ - nothing (or just `state`) — user landed on your callback path without completing a real OAuth round-trip.
153
+
154
+ `parseCallback(input, options?)` turns the URL into a typed result so you can switch on it instead of hand-parsing strings. Pure function; works in any runtime (Node, Bun, edge, browser).
155
+
156
+ ```typescript
157
+ import { parseCallback } from '@tokenite/sdk';
158
+
159
+ // In your /api/auth/callback handler:
160
+ const result = parseCallback(req.url, { expectedState: sessionStoredState });
161
+
162
+ if (result.ok) {
163
+ const { access_token } = await tk.exchangeCode(result.code);
164
+ // ... establish your app's session, redirect to /
165
+ return;
166
+ }
167
+
168
+ switch (result.reason) {
169
+ case 'user_denied':
170
+ // User clicked Cancel. Don't show a scary error — render a
171
+ // friendly "you cancelled" screen with a "Try again" button.
172
+ return renderCancelled();
173
+ case 'invalid_state':
174
+ // CSRF check failed (state mismatch). Force a fresh login flow.
175
+ return renderRetry('Your sign-in session expired. Please try again.');
176
+ case 'missing_code':
177
+ // User navigated to /callback directly (bookmark, browser back).
178
+ return redirectToHome();
179
+ case 'access_denied':
180
+ case 'oauth_error':
181
+ // Real OAuth failure. result.error + result.description have details.
182
+ return renderError(result);
183
+ }
184
+ ```
185
+
186
+ `parseCallback` accepts:
187
+
188
+ - A full URL string: `parseCallback('https://yourapp.com/cb?code=…&state=…')`
189
+ - A path + query: `parseCallback(req.url)` (Node's `req.url` works directly)
190
+ - A query string: `parseCallback('?code=…&state=…')` or `parseCallback('code=…&state=…')`
191
+ - A `URL` object: `parseCallback(new URL(...))`
192
+ - A `URLSearchParams`: `parseCallback(params)`
193
+
194
+ `expectedState` is optional. When you provide it, a mismatch returns `{ ok: false, reason: 'invalid_state' }` even if a `code` is present — treating a stolen code paired with a forged state as a CSRF attempt.
195
+
196
+ ### Streaming responses
197
+
198
+ `tk.call()` is for non-streaming requests. Streaming responses bypass the unified envelope and are forwarded as-is, so use any vendor SDK with `baseURL: tk.proxyUrl(...)`:
199
+
200
+ ```typescript
201
+ import Anthropic from '@anthropic-ai/sdk';
202
+
203
+ const anthropic = new Anthropic({
204
+ apiKey: accessToken,
205
+ baseURL: tk.proxyUrl('anthropic'),
206
+ });
207
+
208
+ const stream = await anthropic.messages.stream({
209
+ model: 'claude-3-5-sonnet-latest',
210
+ max_tokens: 1024,
211
+ messages: [{ role: 'user', content: 'Tell me a story.' }],
212
+ });
213
+
214
+ for await (const event of stream) {
215
+ if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
216
+ process.stdout.write(event.delta.text);
217
+ }
218
+ }
219
+ ```
220
+
221
+ ## API
222
+
223
223
  <!-- GEN:API -->
224
224
  ### `.getAuthorizeUrl(options?: AuthorizeOptions) => string`
225
225
 
226
- Build the authorization URL for a full-page redirect. Supports optional suggested budget that pre-fills the consent screen.
226
+ Build the authorization URL for a full-page redirect.
227
227
 
228
228
  ### `.popup(options?: PopupOptions) => Promise<PopupResult>`
229
229
 
@@ -248,312 +248,415 @@ The Tokenite dashboard base URL
248
248
  ### `.proxyBase`: `string`
249
249
 
250
250
  The Tokenite proxy base URL
251
- <!-- /GEN:API -->
252
-
253
- ## Types
254
-
251
+ <!-- /GEN:API -->
252
+
253
+ ## Types
254
+
255
255
  <!-- GEN:TYPES -->
256
256
  ```typescript
257
- export type TokeniteConfig = {
258
- /** Your app's client ID (from the Tokenite dashboard) */
259
- readonly clientId: string;
260
- /** Your app's client secret — only needed for server-side code exchange */
261
- readonly clientSecret?: string;
262
- /** The URL Tokenite redirects back to after authorization */
263
- readonly redirectUri: string;
264
- /** Tokenite base URL. Default: https://tokenite.ai */
265
- readonly baseUrl?: string;
266
- /** Tokenite proxy URL. Default: https://api.tokenite.ai */
267
- readonly proxyUrl?: string;
268
- };
269
-
270
- export type AuthorizeOptions = {
271
- /** Custom state parameter for CSRF protection. Auto-generated if not provided. */
272
- readonly state?: string;
273
- /** Suggested budget amount (user can override on consent screen) */
274
- readonly suggestedBudget?: number;
275
- };
276
-
277
- export type PopupOptions = {
278
- /** Suggested budget amount (user can override on consent screen) */
279
- readonly suggestedBudget?: number;
280
- /**
281
- * How to host the consent screen.
282
- *
283
- * - `'iframe'` (default) overlay an iframe modal in the current
284
- * window. Requires the dashboard to allow being framed by your
285
- * origin (`Content-Security-Policy: frame-ancestors`). Cleaner UX
286
- * but blocked by `X-Frame-Options: DENY`.
287
- * - `'window'` open a separate browser popup window via
288
- * `window.open`. Works regardless of frame policy, but the user
289
- * may be prompted by their popup blocker.
290
- */
291
- readonly mode?: 'iframe' | 'window';
292
- /** Modal/popup width in pixels. Default: 480 */
293
- readonly width?: number;
294
- /** Modal/popup height in pixels. Default: 620 */
295
- readonly height?: number;
296
- };
297
-
298
- export type PopupResult = {
299
- /**
300
- * OAuth authorization code returned by the consent screen.
301
- * Send this to your backend, which exchanges it for an access token
302
- * via `tk.exchangeCode(code)`. The exchange requires `clientSecret`
303
- * and must never run in browser code.
304
- */
305
- readonly code: string;
306
- };
307
-
308
- export type TopUpOptions = {
309
- /**
310
- * How to host the top-up screen.
311
- *
312
- * - `'popup'` (default) — open in a separate browser window via
313
- * `window.open`. The user completes the form; the SDK resolves
314
- * when Tokenite posts back the new limit.
315
- * - `'redirect'` — full-page navigation. The builder's app loses
316
- * in-memory state and must reload from the callback URL.
317
- */
318
- readonly mode?: 'popup' | 'redirect';
319
- /** Pre-fill the new-limit field. Default: 2 × current limit. */
320
- readonly suggestedAmount?: number;
321
- /** Popup width in pixels. Default: 480 */
322
- readonly width?: number;
323
- /** Popup height in pixels. Default: 560 */
324
- readonly height?: number;
325
- };
326
-
327
- export type TopUpResult =
328
- | { readonly ok: true; readonly newLimit: number; readonly remaining: number }
329
- | { readonly ok: false; readonly reason: 'cancelled' | 'popup-blocked' | 'closed' | 'redirected' };
330
-
331
- export type CallWithRecoveryOptions = {
332
- /**
333
- * What to do when the proxy returns a recoverable funding error.
334
- *
335
- * - `'popup'` (default) open Tokenite's top-up popup, then retry the
336
- * call once on success.
337
- * - `'redirect'` full-page navigation; the call does not retry (the
338
- * builder's app re-loads from the callback and re-invokes manually).
339
- * - `'throw'` — disable recovery; surface the original error.
340
- */
341
- readonly onFundingNeeded?: 'popup' | 'redirect' | 'throw';
342
- /** Override the suggested top-up amount. */
343
- readonly suggestedAmount?: number;
344
- };
345
-
346
- export type TokenResponse = {
347
- readonly access_token: string;
348
- readonly token_type: string;
349
- };
350
-
351
- export type Provider = 'anthropic' | 'openai' | 'google' | 'grok' | 'bedrock';
352
-
353
- export type ProxyCallOptions = {
354
- /** The user's Tokenite access token (returned by `tk.exchangeCode()`) */
355
- readonly accessToken: string;
356
- /** Which LLM provider to call */
357
- readonly provider: Provider;
358
- /** Path on the provider's API (e.g. `/v1/messages`, `/v1/chat/completions`) */
359
- readonly path: string;
360
- /** HTTP method. Default: `POST` */
361
- readonly method?: string;
362
- /** Request body the vendor's request shape, JSON-serialised by the SDK */
363
- readonly body: unknown;
364
- };
365
-
366
- // ─── Unified proxy response types ───
367
- //
368
- // Every non-streaming response from the Tokenite proxy returns one of
369
- // these shapes. SDK consumers can always check for `.error` to distinguish
370
- // success from failure — no vendor-specific parsing required.
371
-
372
- /** Normalised token counts (identical across all providers) */
373
- export type ProxyUsage = {
374
- /** Number of tokens in the prompt / input */
375
- readonly inputTokens: number;
376
- /** Number of tokens in the completion / output */
377
- readonly outputTokens: number;
257
+ export type TokeniteConfig = {
258
+ /** Your app's client ID (from the Tokenite dashboard) */
259
+ readonly clientId: string;
260
+ /** Your app's client secret — only needed for server-side code exchange */
261
+ readonly clientSecret?: string;
262
+ /** The URL Tokenite redirects back to after authorization */
263
+ readonly redirectUri: string;
264
+ /** Tokenite base URL. Default: https://tokenite.ai */
265
+ readonly baseUrl?: string;
266
+ /** Tokenite proxy URL. Default: https://api.tokenite.ai */
267
+ readonly proxyUrl?: string;
268
+ };
269
+
270
+ /**
271
+ * OAuth 2.0 / OIDC `prompt` parameter. Tells Tokenite whether to
272
+ * re-prompt the user even when they have an existing session and/or
273
+ * an existing grant for this app.
274
+ *
275
+ * - `'select_account'` — **recommended for "Sign in with Tokenite"
276
+ * buttons that follow a sign-out.** Interrupts the silent re-auth
277
+ * that would otherwise drop the user straight back into your app:
278
+ * instead Tokenite shows a "Continue as <email>?" confirmation card
279
+ * with a "Use a different account" option. The user's existing
280
+ * spending limit is preserved — they aren't asked to re-set it.
281
+ * - `'consent'` re-show the **full** consent screen (budget input
282
+ * and all), discarding any existing grant. Use this only when you
283
+ * want the user to actively re-set their budget; for the common
284
+ * "they signed out, now they're signing back in" case, prefer
285
+ * `'select_account'`.
286
+ * - `'login'` — request that the user re-authenticate. Today this
287
+ * behaves the same as `'consent'` on Tokenite (full session-cookie
288
+ * clear is a TODO); the consent screen still shows.
289
+ * - `'none'` never show UI; fail if interaction is required.
290
+ * Currently treated as the default (silent reauth).
291
+ *
292
+ * The OAuth 2.0 spec allows a space-separated combination
293
+ * (e.g. `'login consent'`); for that, pass the raw string. The
294
+ * union above is just for autocomplete on the common values.
295
+ */
296
+ export type OAuthPrompt = 'login' | 'consent' | 'select_account' | 'none' | (string & {});
297
+
298
+ export type AuthorizeOptions = {
299
+ /** Custom state parameter for CSRF protection. Auto-generated if not provided. */
300
+ readonly state?: string;
301
+ /** Suggested budget amount (user can override on consent screen) */
302
+ readonly suggestedBudget?: number;
303
+ /**
304
+ * OAuth `prompt` parameter. Most common use: pass `'select_account'`
305
+ * on "Sign in with Tokenite" buttons that follow a sign-out — it
306
+ * stops the silent reauth that would otherwise drop the user
307
+ * straight back into your app, and shows a "Continue as <email>?"
308
+ * confirmation card instead. See {@link OAuthPrompt}.
309
+ */
310
+ readonly prompt?: OAuthPrompt;
311
+ };
312
+
313
+ export type PopupOptions = {
314
+ /** Suggested budget amount (user can override on consent screen) */
315
+ readonly suggestedBudget?: number;
316
+ /**
317
+ * How to host the consent screen.
318
+ *
319
+ * - `'iframe'` (default) overlay an iframe modal in the current
320
+ * window. Requires the dashboard to allow being framed by your
321
+ * origin (`Content-Security-Policy: frame-ancestors`). Cleaner UX
322
+ * but blocked by `X-Frame-Options: DENY`.
323
+ * - `'window'` open a separate browser popup window via
324
+ * `window.open`. Works regardless of frame policy, but the user
325
+ * may be prompted by their popup blocker.
326
+ */
327
+ readonly mode?: 'iframe' | 'window';
328
+ /** Modal/popup width in pixels. Default: 480 */
329
+ readonly width?: number;
330
+ /** Modal/popup height in pixels. Default: 620 */
331
+ readonly height?: number;
332
+ /**
333
+ * OAuth `prompt` parameter. Most common use: pass `'select_account'`
334
+ * on "Sign in with Tokenite" buttons that follow a sign-out — it
335
+ * stops the silent reauth that would otherwise drop the user
336
+ * straight back into your app, and shows a "Continue as <email>?"
337
+ * confirmation card instead. See {@link OAuthPrompt}.
338
+ */
339
+ readonly prompt?: OAuthPrompt;
340
+ };
341
+
342
+ export type PopupResult = {
343
+ /**
344
+ * OAuth authorization code returned by the consent screen.
345
+ * Send this to your backend, which exchanges it for an access token
346
+ * via `tk.exchangeCode(code)`. The exchange requires `clientSecret`
347
+ * and must never run in browser code.
348
+ */
349
+ readonly code: string;
350
+ };
351
+
352
+ export type TopUpOptions = {
353
+ /**
354
+ * How to host the top-up screen.
355
+ *
356
+ * - `'popup'` (default) open in a separate browser window via
357
+ * `window.open`. The user completes the form; the SDK resolves
358
+ * when Tokenite posts back the new limit.
359
+ * - `'redirect'` — full-page navigation. The builder's app loses
360
+ * in-memory state and must reload from the callback URL.
361
+ */
362
+ readonly mode?: 'popup' | 'redirect';
363
+ /** Pre-fill the new-limit field. Default: 2 × current limit. */
364
+ readonly suggestedAmount?: number;
365
+ /** Popup width in pixels. Default: 480 */
366
+ readonly width?: number;
367
+ /** Popup height in pixels. Default: 560 */
368
+ readonly height?: number;
369
+ };
370
+
371
+ export type TopUpResult =
372
+ | { readonly ok: true; readonly newLimit: number; readonly remaining: number }
373
+ | { readonly ok: false; readonly reason: 'cancelled' | 'popup-blocked' | 'closed' | 'redirected' };
374
+
375
+ export type CallWithRecoveryOptions = {
376
+ /**
377
+ * What to do when the proxy returns a recoverable funding error.
378
+ *
379
+ * - `'popup'` (default) — open Tokenite's top-up popup, then retry the
380
+ * call once on success.
381
+ * - `'redirect'` — full-page navigation; the call does not retry (the
382
+ * builder's app re-loads from the callback and re-invokes manually).
383
+ * - `'throw'` — disable recovery; surface the original error.
384
+ */
385
+ readonly onFundingNeeded?: 'popup' | 'redirect' | 'throw';
386
+ /** Override the suggested top-up amount. */
387
+ readonly suggestedAmount?: number;
388
+ };
389
+
390
+ export type TokenResponse = {
391
+ readonly access_token: string;
392
+ readonly token_type: string;
393
+ };
394
+
395
+ export type Provider = 'anthropic' | 'openai' | 'google' | 'grok' | 'bedrock';
396
+
397
+ export type ProxyCallOptions = {
398
+ /** The user's Tokenite access token (returned by `tk.exchangeCode()`) */
399
+ readonly accessToken: string;
400
+ /** Which LLM provider to call */
401
+ readonly provider: Provider;
402
+ /** Path on the provider's API (e.g. `/v1/messages`, `/v1/chat/completions`) */
403
+ readonly path: string;
404
+ /** HTTP method. Default: `POST` */
405
+ readonly method?: string;
406
+ /** Request body — the vendor's request shape, JSON-serialised by the SDK */
407
+ readonly body: unknown;
408
+ };
409
+
410
+ // ─── Unified proxy response types ───
411
+ //
412
+ // Every non-streaming response from the Tokenite proxy returns one of
413
+ // these shapes. SDK consumers can always check for `.error` to distinguish
414
+ // success from failure — no vendor-specific parsing required.
415
+
416
+ /** Normalised token counts (identical across all providers) */
417
+ export type ProxyUsage = {
418
+ /** Number of tokens in the prompt / input */
419
+ readonly inputTokens: number;
420
+ /** Number of tokens in the completion / output */
421
+ readonly outputTokens: number;
422
+ };
423
+
424
+ /**
425
+ * Successful proxy response.
426
+ *
427
+ * `data` contains the original vendor response body (e.g. Anthropic's
428
+ * message object, OpenAI's chat completion, etc.). `provider`, `model`,
429
+ * and `usage` are extracted and normalised by the proxy so you don't
430
+ * need to parse vendor-specific fields.
431
+ */
432
+ export type ProxySuccess = {
433
+ /** Which LLM provider handled the request */
434
+ readonly provider: Provider;
435
+ /** The model that generated the response */
436
+ readonly model: string;
437
+ /** Normalised token usage, or null if the provider didn't report it */
438
+ readonly usage: ProxyUsage | null;
439
+ /** The original, unmodified response body from the LLM provider */
440
+ readonly data: unknown;
441
+ };
442
+
443
+ /**
444
+ * Where the error originated.
445
+ *
446
+ * - `"proxy"` — Tokenite rejected the request (auth, budget, config).
447
+ * - `"provider"` — The upstream LLM returned an error (rate limit, overload, etc.).
448
+ */
449
+ export type ErrorSource = 'proxy' | 'provider';
450
+
451
+ /**
452
+ * Error response (both proxy-level and provider-level errors share this shape).
453
+ *
454
+ * **Proxy error codes** (`source: "proxy"`):
455
+ * | Code | HTTP | Description |
456
+ * |---|---|---|
457
+ * | `TOKEN_INVALID` | 401 | Missing or invalid bearer token |
458
+ * | `TOKEN_REVOKED` | 401 | Access token was revoked by the user |
459
+ * | `TOKEN_SUSPENDED` | 403 | Access suspended by the user |
460
+ * | `TOKEN_EXPIRED` | 401 | Access token past its expiration date |
461
+ * | `BUDGET_EXCEEDED` | 402 | Spending reached the user-defined budget limit |
462
+ * | `PROVIDER_KEY_MISSING` | 402 | No API key or credits available for the provider |
463
+ * | `CREDITS_DEPLETED` | 402 | Insufficient platform credits |
464
+ * | `APP_NOT_FOUND` | 404 | Application not found |
465
+ * | `MODEL_NOT_ALLOWED` | 403 | Model not in the app's allowed models list |
466
+ *
467
+ * **Provider error codes** (`source: "provider"`):
468
+ * | Code | HTTP | Description |
469
+ * |---|---|---|
470
+ * | `RATE_LIMITED` | 429 | Upstream rate limit hit — retry after backoff |
471
+ * | `CONTEXT_LENGTH_EXCEEDED` | 400 | Request exceeds model's context window |
472
+ * | `INVALID_REQUEST` | 400 | Provider rejected the request as malformed |
473
+ * | `AUTHENTICATION_FAILED` | 401 | Provider rejected the API key |
474
+ * | `PROVIDER_OVERLOADED` | 503 | Provider temporarily overloaded |
475
+ * | `CONTENT_FILTERED` | 400 | Content blocked by safety filter |
476
+ * | `PROVIDER_TIMEOUT` | 504 | Provider did not respond in time |
477
+ * | `PROVIDER_ERROR` | 502 | Catch-all for other provider errors |
478
+ */
479
+ export type ProxyError = {
480
+ readonly error: {
481
+ /** Machine-readable error code */
482
+ readonly code: string;
483
+ /** Human-readable description */
484
+ readonly message: string;
485
+ /** Where the error originated */
486
+ readonly source: ErrorSource;
487
+ /** Optional structured context (e.g. `retryAfter`, `provider`, `providerMessage`) */
488
+ readonly details?: Record<string, unknown>;
489
+ };
490
+ };
491
+
492
+ /** Discriminated union for all non-streaming proxy responses */
493
+ export type ProxyResponse = ProxySuccess | ProxyError;
494
+
495
+ /** Type guard: returns true if the response is an error */
496
+ export const isProxyError = (response: ProxyResponse): response is ProxyError =>
497
+ 'error' in response;
498
+
499
+ /** Type guard: returns true if the response is a success */
500
+ export const isProxySuccess = (response: ProxyResponse): response is ProxySuccess =>
501
+ 'provider' in response;
502
+
503
+ // ─── Access context (tk.getAccessContext) ───
504
+
505
+ /**
506
+ * Identity of the user who holds this access token.
507
+ *
508
+ * Use `id` as the stable key for per-user state in your app — it survives
509
+ * token refreshes, re-logins, and device switches. `email` is suitable for
510
+ * display in your UI; treat it as user-controlled and re-fetch on each
511
+ * session if you cache it.
512
+ */
513
+ export type UserInfo = {
514
+ /** Stable Tokenite user id (UUID) */
515
+ readonly id: string;
516
+ /** The user's email address as registered with Tokenite */
517
+ readonly email: string;
518
+ };
519
+
520
+ /** Visual + identity metadata for a single provider */
521
+ export type ProviderInfo = {
522
+ /** Stable provider id (same value as the `Provider` union) */
523
+ readonly id: Provider;
524
+ /** Human-readable name, e.g. "Anthropic" */
525
+ readonly displayName: string;
526
+ /** Brand colour (hex string, e.g. "#d97706") */
527
+ readonly color: string;
528
+ /** Absolute URL to the provider's logo (PNG or SVG) */
529
+ readonly logoUrl: string;
530
+ /** Whether the logo is a glyph/symbol or a full wordmark */
531
+ readonly logoStyle: 'symbol' | 'wordmark';
532
+ };
533
+
534
+ /**
535
+ * A model the access token may call, scoped to the app's model strategy
536
+ * and the holder's provider keys.
537
+ *
538
+ * - `servedBy` — every provider that hosts this model (catalog fact).
539
+ * - `callableNow` — the subset the holder can run *right now* (they hold
540
+ * a key for it). Empty means the model is visible but not yet usable —
541
+ * render it disabled, or prompt the user to add a key.
542
+ *
543
+ * Pass `slug` as the `model` field in `tk.call()`.
544
+ */
545
+ export type ModelInfo = {
546
+ /** Stable Tokenite model slug — pass this as `model` in tk.call() */
547
+ readonly slug: string;
548
+ /** Human-readable name, e.g. "Claude Haiku 4.5" */
549
+ readonly displayName: string;
550
+ /** The lab that built the model */
551
+ readonly creator: 'anthropic' | 'openai' | 'google' | 'grok';
552
+ /** Capability tiers this model satisfies (cheap / fast / smart / reasoning) */
553
+ readonly tiers: readonly string[];
554
+ /** Feature capabilities, e.g. "vision", "tools", "thinking" */
555
+ readonly capabilities: readonly string[];
556
+ /** Every provider that serves this model */
557
+ readonly servedBy: readonly Provider[];
558
+ /** Providers the holder can run it through right now (subset of servedBy) */
559
+ readonly callableNow: readonly Provider[];
560
+ /** Indicative price per million tokens */
561
+ readonly pricing: {
562
+ readonly inputPerMillion: number;
563
+ readonly outputPerMillion: number;
564
+ };
565
+ };
566
+
567
+ /**
568
+ * A provider-agnostic capability bucket. Use this for a "pick a speed /
569
+ * quality" UI where the user never sees a model name.
570
+ */
571
+ export type TierInfo = {
572
+ /** Tier id: "cheap" | "fast" | "smart" | "reasoning" */
573
+ readonly id: string;
574
+ /** Whether the holder can run at least one model in this tier */
575
+ readonly reachable: boolean;
576
+ /** A representative callable model slug for this tier, or null */
577
+ readonly recommendedModel: string | null;
578
+ };
579
+
580
+ /** Summary of the app the access token belongs to */
581
+ export type AppInfo = {
582
+ readonly id: string;
583
+ readonly name: string;
584
+ readonly description: string | null;
585
+ readonly websiteUrl: string | null;
586
+ /** Absolute URL to the app's icon (PNG/SVG). null when the developer hasn't set one — render initials or a generic glyph. */
587
+ readonly iconUrl: string | null;
588
+ };
589
+
590
+ /**
591
+ * Full access context for a single access token: the app it belongs to,
592
+ * the user who holds it, and the providers it can call.
593
+ *
594
+ * `providers` lists only the providers the user has an active key for —
595
+ * exactly the set that will succeed through `tk.call()` (budget permitting).
596
+ *
597
+ * `user` identifies the human who holds the token. Use `user.id` as the
598
+ * stable key for any per-user state in your app — it survives token
599
+ * refreshes and re-logins, unlike the access token itself.
600
+ */
601
+ export type AccessContext = {
602
+ readonly app: AppInfo;
603
+ readonly user: UserInfo;
604
+ readonly providers: readonly ProviderInfo[];
605
+ /**
606
+ * Models the token may call — already filtered to the app's strategy
607
+ * and the user's keys. Render a picker from this; no need to maintain
608
+ * your own model list. Each entry's `callableNow` says whether it's
609
+ * usable now or needs a key.
610
+ */
611
+ readonly models: readonly ModelInfo[];
612
+ /**
613
+ * Provider-agnostic capability buckets. For a "pick a tier" UI where
614
+ * the user never sees a model name — `recommendedModel` gives you a
615
+ * concrete slug to pass to `tk.call()`.
616
+ */
617
+ readonly tiers: readonly TierInfo[];
378
618
  };
379
-
380
- /**
381
- * Successful proxy response.
382
- *
383
- * `data` contains the original vendor response body (e.g. Anthropic's
384
- * message object, OpenAI's chat completion, etc.). `provider`, `model`,
385
- * and `usage` are extracted and normalised by the proxy so you don't
386
- * need to parse vendor-specific fields.
387
- */
388
- export type ProxySuccess = {
389
- /** Which LLM provider handled the request */
390
- readonly provider: Provider;
391
- /** The model that generated the response */
392
- readonly model: string;
393
- /** Normalised token usage, or null if the provider didn't report it */
394
- readonly usage: ProxyUsage | null;
395
- /** The original, unmodified response body from the LLM provider */
396
- readonly data: unknown;
397
- };
398
-
399
- /**
400
- * Where the error originated.
401
- *
402
- * - `"proxy"` — Tokenite rejected the request (auth, budget, config).
403
- * - `"provider"` — The upstream LLM returned an error (rate limit, overload, etc.).
404
- */
405
- export type ErrorSource = 'proxy' | 'provider';
406
-
407
- /**
408
- * Error response (both proxy-level and provider-level errors share this shape).
409
- *
410
- * **Proxy error codes** (`source: "proxy"`):
411
- * | Code | HTTP | Description |
412
- * |---|---|---|
413
- * | `TOKEN_INVALID` | 401 | Missing or invalid bearer token |
414
- * | `TOKEN_REVOKED` | 401 | Access token was revoked by the user |
415
- * | `TOKEN_SUSPENDED` | 403 | Access suspended by the user |
416
- * | `TOKEN_EXPIRED` | 401 | Access token past its expiration date |
417
- * | `BUDGET_EXCEEDED` | 402 | Spending reached the user-defined budget limit |
418
- * | `PROVIDER_KEY_MISSING` | 402 | No API key or credits available for the provider |
419
- * | `CREDITS_DEPLETED` | 402 | Insufficient platform credits |
420
- * | `APP_NOT_FOUND` | 404 | Application not found |
421
- * | `MODEL_NOT_ALLOWED` | 403 | Model not in the app's allowed models list |
422
- *
423
- * **Provider error codes** (`source: "provider"`):
424
- * | Code | HTTP | Description |
425
- * |---|---|---|
426
- * | `RATE_LIMITED` | 429 | Upstream rate limit hit — retry after backoff |
427
- * | `CONTEXT_LENGTH_EXCEEDED` | 400 | Request exceeds model's context window |
428
- * | `INVALID_REQUEST` | 400 | Provider rejected the request as malformed |
429
- * | `AUTHENTICATION_FAILED` | 401 | Provider rejected the API key |
430
- * | `PROVIDER_OVERLOADED` | 503 | Provider temporarily overloaded |
431
- * | `CONTENT_FILTERED` | 400 | Content blocked by safety filter |
432
- * | `PROVIDER_TIMEOUT` | 504 | Provider did not respond in time |
433
- * | `PROVIDER_ERROR` | 502 | Catch-all for other provider errors |
434
- */
435
- export type ProxyError = {
436
- readonly error: {
437
- /** Machine-readable error code */
438
- readonly code: string;
439
- /** Human-readable description */
440
- readonly message: string;
441
- /** Where the error originated */
442
- readonly source: ErrorSource;
443
- /** Optional structured context (e.g. `retryAfter`, `provider`, `providerMessage`) */
444
- readonly details?: Record<string, unknown>;
445
- };
446
- };
447
-
448
- /** Discriminated union for all non-streaming proxy responses */
449
- export type ProxyResponse = ProxySuccess | ProxyError;
450
-
451
- /** Type guard: returns true if the response is an error */
452
- export const isProxyError = (response: ProxyResponse): response is ProxyError =>
453
- 'error' in response;
454
-
455
- /** Type guard: returns true if the response is a success */
456
- export const isProxySuccess = (response: ProxyResponse): response is ProxySuccess =>
457
- 'provider' in response;
458
-
459
- // ─── Access context (tk.getAccessContext) ───
460
-
461
- /**
462
- * Identity of the user who holds this access token.
463
- *
464
- * Use `id` as the stable key for per-user state in your app — it survives
465
- * token refreshes, re-logins, and device switches. `email` is suitable for
466
- * display in your UI; treat it as user-controlled and re-fetch on each
467
- * session if you cache it.
468
- */
469
- export type UserInfo = {
470
- /** Stable Tokenite user id (UUID) */
471
- readonly id: string;
472
- /** The user's email address as registered with Tokenite */
473
- readonly email: string;
474
- };
475
-
476
- /** Visual + identity metadata for a single provider */
477
- export type ProviderInfo = {
478
- /** Stable provider id (same value as the `Provider` union) */
479
- readonly id: Provider;
480
- /** Human-readable name, e.g. "Anthropic" */
481
- readonly displayName: string;
482
- /** Brand colour (hex string, e.g. "#d97706") */
483
- readonly color: string;
484
- /** Absolute URL to the provider's logo (PNG or SVG) */
485
- readonly logoUrl: string;
486
- /** Whether the logo is a glyph/symbol or a full wordmark */
487
- readonly logoStyle: 'symbol' | 'wordmark';
488
- };
489
-
490
- /** Summary of the app the access token belongs to */
491
- export type AppInfo = {
492
- readonly id: string;
493
- readonly name: string;
494
- readonly description: string | null;
495
- readonly websiteUrl: string | null;
496
- /** Absolute URL to the app's icon (PNG/SVG). null when the developer hasn't set one — render initials or a generic glyph. */
497
- readonly iconUrl: string | null;
498
- };
499
-
500
- /**
501
- * Full access context for a single access token: the app it belongs to,
502
- * the user who holds it, and the providers it can call.
503
- *
504
- * `providers` lists only the providers the user has an active key for —
505
- * exactly the set that will succeed through `tk.call()` (budget permitting).
506
- *
507
- * `user` identifies the human who holds the token. Use `user.id` as the
508
- * stable key for any per-user state in your app — it survives token
509
- * refreshes and re-logins, unlike the access token itself.
510
- */
511
- export type AccessContext = {
512
- readonly app: AppInfo;
513
- readonly user: UserInfo;
514
- readonly providers: readonly ProviderInfo[];
515
- };
516
- ```
517
- <!-- /GEN:TYPES -->
518
-
519
- ## Error Codes
520
-
521
- Non-streaming proxy responses return a unified error envelope:
522
-
523
- ```json
524
- { "error": { "code": "...", "message": "...", "source": "proxy" | "provider", "details": { } } }
525
619
  ```
526
-
527
- `source` distinguishes errors raised by the Tokenite proxy from errors forwarded from the upstream LLM. Use the `isProxyError` type guard to narrow a `ProxyResponse` before reading `error.code`.
528
-
529
- ### Proxy errors (`source: "proxy"`)
530
-
531
- | Code | Status | Meaning |
532
- |------|--------|---------|
533
- | `TOKEN_INVALID` | 401 | Missing or unrecognized access token |
534
- | `TOKEN_REVOKED` | 401 | User revoked access |
535
- | `TOKEN_EXPIRED` | 401 | Token expired (30-day lifetime) |
536
- | `TOKEN_SUSPENDED` | 403 | User temporarily suspended access |
537
- | `BUDGET_EXCEEDED` | 402 | Spending limit reached |
538
- | `PROVIDER_KEY_MISSING` | 402 | No API key or credits available for the provider |
539
- | `CREDITS_DEPLETED` | 402 | Insufficient platform credits |
540
- | `MODEL_NOT_ALLOWED` | 403 | Model not in your app's allowed list |
541
- | `AGENT_SCOPE_MISSING` | 403 | App doesn't have the managed-agents scope (set `allowsManagedAgents: true` at app creation) |
542
- | `APP_NOT_FOUND` | 404 | Application not found |
543
-
544
- ### Provider errors (`source: "provider"`)
545
-
546
- | Code | Status | Meaning |
547
- |------|--------|---------|
548
- | `RATE_LIMITED` | 429 | Upstream rate limit hit — retry after backoff |
549
- | `CONTEXT_LENGTH_EXCEEDED` | 400 | Request exceeds the model's context window |
550
- | `INVALID_REQUEST` | 400 | Provider rejected the request as malformed |
551
- | `CONTENT_FILTERED` | 400 | Content blocked by the provider's safety filter |
552
- | `AUTHENTICATION_FAILED` | 401 | Provider rejected the API key |
553
- | `PROVIDER_OVERLOADED` | 503 | Provider temporarily overloaded |
554
- | `PROVIDER_TIMEOUT` | 504 | Provider did not respond in time |
555
- | `PROVIDER_ERROR` | 502 | Catch-all for other provider errors |
556
-
557
- ## License
558
-
559
- MIT
620
+ <!-- /GEN:TYPES -->
621
+
622
+ ## Error Codes
623
+
624
+ Non-streaming proxy responses return a unified error envelope:
625
+
626
+ ```json
627
+ { "error": { "code": "...", "message": "...", "source": "proxy" | "provider", "details": { } } }
628
+ ```
629
+
630
+ `source` distinguishes errors raised by the Tokenite proxy from errors forwarded from the upstream LLM. Use the `isProxyError` type guard to narrow a `ProxyResponse` before reading `error.code`.
631
+
632
+ ### Proxy errors (`source: "proxy"`)
633
+
634
+ | Code | Status | Meaning |
635
+ |------|--------|---------|
636
+ | `TOKEN_INVALID` | 401 | Missing or unrecognized access token |
637
+ | `TOKEN_REVOKED` | 401 | User revoked access |
638
+ | `TOKEN_EXPIRED` | 401 | Token expired (30-day lifetime) |
639
+ | `TOKEN_SUSPENDED` | 403 | User temporarily suspended access |
640
+ | `BUDGET_EXCEEDED` | 402 | Spending limit reached |
641
+ | `PROVIDER_KEY_MISSING` | 402 | No API key or credits available for the provider |
642
+ | `CREDITS_DEPLETED` | 402 | Insufficient platform credits |
643
+ | `MODEL_NOT_ALLOWED` | 403 | Model not in your app's allowed list |
644
+ | `AGENT_SCOPE_MISSING` | 403 | App doesn't have the managed-agents scope (set `allowsManagedAgents: true` at app creation) |
645
+ | `APP_NOT_FOUND` | 404 | Application not found |
646
+
647
+ ### Provider errors (`source: "provider"`)
648
+
649
+ | Code | Status | Meaning |
650
+ |------|--------|---------|
651
+ | `RATE_LIMITED` | 429 | Upstream rate limit hit — retry after backoff |
652
+ | `CONTEXT_LENGTH_EXCEEDED` | 400 | Request exceeds the model's context window |
653
+ | `INVALID_REQUEST` | 400 | Provider rejected the request as malformed |
654
+ | `CONTENT_FILTERED` | 400 | Content blocked by the provider's safety filter |
655
+ | `AUTHENTICATION_FAILED` | 401 | Provider rejected the API key |
656
+ | `PROVIDER_OVERLOADED` | 503 | Provider temporarily overloaded |
657
+ | `PROVIDER_TIMEOUT` | 504 | Provider did not respond in time |
658
+ | `PROVIDER_ERROR` | 502 | Catch-all for other provider errors |
659
+
660
+ ## License
661
+
662
+ MIT