zuplo 7.7.10 → 7.7.11

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.
@@ -70,9 +70,9 @@ settings file:
70
70
  - **Global:** `~/.claude/settings.json`
71
71
  - **Project-specific:** `.claude/settings.json` in your project directory
72
72
 
73
- Each approach below shows the `env` block to add to that file. Restart Claude
74
- Code after saving it, and all your Claude Code LLM requests route through the AI
75
- Gateway.
73
+ Each approach below shows the `env` and `modelOverrides` blocks to add to that
74
+ file. Restart Claude Code after saving it, and all your Claude Code LLM requests
75
+ route through the AI Gateway.
76
76
 
77
77
  :::note
78
78
 
@@ -87,7 +87,7 @@ With this approach the gateway calls Anthropic with the API key you saved on the
87
87
  [provider](../managing-providers.mdx). Claude Code presents only the app's API
88
88
  key, and Anthropic bills the account that owns the provider key.
89
89
 
90
- Add the following environment configuration to the settings file:
90
+ Add the following configuration to the settings file:
91
91
 
92
92
  ```json
93
93
  {
@@ -99,7 +99,13 @@ Add the following environment configuration to the settings file:
99
99
  "ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-5",
100
100
  "ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-5",
101
101
  "ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4-5",
102
- "ANTHROPIC_DEFAULT_FABLE_MODEL": "anthropic/claude-fable-5"
102
+ "ANTHROPIC_DEFAULT_FABLE_MODEL": "anthropic/claude-fable-5-1"
103
+ },
104
+ "modelOverrides": {
105
+ "claude-opus-5": "anthropic/claude-opus-5",
106
+ "claude-sonnet-5": "anthropic/claude-sonnet-5",
107
+ "claude-haiku-4-5-20251001": "anthropic/claude-haiku-4-5-20251001",
108
+ "claude-fable-5-1": "anthropic/claude-fable-5-1"
103
109
  }
104
110
  }
105
111
  ```
@@ -183,7 +189,7 @@ from Claude Code instead, as described below.
183
189
 
184
190
  ### Configure Claude Code
185
191
 
186
- Add the following environment configuration to the settings file. Don't set
192
+ Add the following configuration to the settings file. Don't set
187
193
  `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY` here or in your shell: either one
188
194
  replaces your `claude.ai` login, and the gateway would then forward that value
189
195
  to Anthropic as the credential.
@@ -198,7 +204,13 @@ to Anthropic as the credential.
198
204
  "ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-5",
199
205
  "ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-5",
200
206
  "ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4-5",
201
- "ANTHROPIC_DEFAULT_FABLE_MODEL": "anthropic/claude-fable-5"
207
+ "ANTHROPIC_DEFAULT_FABLE_MODEL": "anthropic/claude-fable-5-1"
208
+ },
209
+ "modelOverrides": {
210
+ "claude-opus-5": "anthropic/claude-opus-5",
211
+ "claude-sonnet-5": "anthropic/claude-sonnet-5",
212
+ "claude-haiku-4-5-20251001": "anthropic/claude-haiku-4-5-20251001",
213
+ "claude-fable-5-1": "anthropic/claude-fable-5-1"
202
214
  }
203
215
  }
204
216
  ```
@@ -246,23 +258,27 @@ it currently serves, and use those ids.
246
258
  None of Claude Code's built-in model names carry a provider prefix, so each of
247
259
  the model variables above must be set to a prefixed reference:
248
260
 
249
- - `ANTHROPIC_MODEL` — the main model. Required: without it Claude Code sends its
250
- built-in default model unprefixed and the first request fails.
261
+ - `ANTHROPIC_MODEL` — the startup model. Set it to a prefixed reference so the
262
+ gateway can route the first request.
251
263
  - `ANTHROPIC_SMALL_FAST_MODEL` — the model for background tasks.
252
264
  - `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`,
253
265
  `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL` — what the
254
266
  `opus`/`sonnet`/`haiku`/`fable` aliases resolve to, so `claude --model opus`
255
267
  and `/model sonnet` keep working. In these examples,
256
- `ANTHROPIC_DEFAULT_FABLE_MODEL` maps `fable` to `anthropic/claude-fable-5`.
268
+ `ANTHROPIC_DEFAULT_FABLE_MODEL` maps `fable` to `anthropic/claude-fable-5-1`.
269
+
270
+ Claude Code's `/model` picker can select either an alias or an exact model ID.
271
+ `ANTHROPIC_DEFAULT_*_MODEL` maps aliases to gateway model names;
272
+ [`modelOverrides`](https://code.claude.com/docs/en/model-config#override-model-ids-per-version)
273
+ maps exact IDs. The examples configure both so either selection works through
274
+ the gateway.
257
275
 
258
276
  :::caution{title='HTTP 400: model must use "providerName/model"'}
259
277
 
260
- If Claude Code fails immediately with
261
- `The request body model must use "providerName/model"`, the request carried a
262
- model name without a provider prefix. The tier alias variables
263
- (`ANTHROPIC_DEFAULT_*_MODEL`) alone don't prevent this — they don't cover Claude
264
- Code's built-in default model. Always set `ANTHROPIC_MODEL` to a prefixed model
265
- reference.
278
+ This error means the selected model lacks its provider prefix. At startup, check
279
+ `ANTHROPIC_MODEL`. After choosing a picker entry, check its `modelOverrides`
280
+ entry, or type `/model fable` to use the configured alias. Restart Claude Code
281
+ after editing the settings file.
266
282
 
267
283
  :::
268
284
 
@@ -0,0 +1,165 @@
1
+ ---
2
+ title: GitHub Copilot
3
+ sidebar_label: GitHub Copilot
4
+ description:
5
+ Route GitHub Copilot in VS Code and the Copilot CLI through an AI Gateway app
6
+ so the gateway authenticates, meters, and routes every Copilot model request.
7
+ ---
8
+
9
+ [GitHub Copilot](https://github.com/features/copilot) can use your own model
10
+ endpoint instead of GitHub's hosted models — the
11
+ [Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models)
12
+ through environment variables, and VS Code through its
13
+ [Custom Endpoint provider](https://code.visualstudio.com/docs/agent-customization/language-models#_add-a-custom-endpoint-model).
14
+ Point that endpoint at an AI Gateway app, and every Copilot chat and agent
15
+ request is authenticated with the app's key, metered against the app's budgets,
16
+ and limited to the models you allow. Developers keep using Copilot exactly as
17
+ before; the app's page in the Zuplo Portal shows their requests, tokens, and
18
+ spend as they work.
19
+
20
+ ## Before you start
21
+
22
+ <Stepper>
23
+
24
+ 1. Create a [provider](../managing-providers.mdx) for the models Copilot will
25
+ use, such as OpenAI or Anthropic
26
+
27
+ 2. [Create a team](../managing-teams.mdx) for the developers using Copilot
28
+
29
+ 3. Create an [app](../managing-apps.mdx) for Copilot and assign it to the team
30
+
31
+ 4. Copy the **API URL** and **API Key** shown at the top of the app page
32
+
33
+ </Stepper>
34
+
35
+ The gateway names models as `providerName/model`, where `providerName` is the
36
+ name you gave the provider. The examples below use `openai` and `anthropic`;
37
+ substitute your own provider names.
38
+
39
+ ## Copilot CLI
40
+
41
+ The CLI reads its provider from four environment variables. Set the base URL to
42
+ the app's API URL plus `/v1`, and name a model that supports tool calls, such as
43
+ GPT-4.1:
44
+
45
+ ```bash
46
+ export COPILOT_PROVIDER_BASE_URL="https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1"
47
+ export COPILOT_PROVIDER_API_KEY="<your-ai-gateway-app-api-key>"
48
+ export COPILOT_PROVIDER_TYPE="openai"
49
+ export COPILOT_MODEL="openai/gpt-4.1-mini"
50
+ ```
51
+
52
+ Start `copilot` from the same shell. Every request in the session now passes
53
+ through the gateway.
54
+
55
+ For a Claude model, use the `anthropic` provider type with the app's API URL
56
+ without `/v1` — the CLI appends `/v1/messages` itself — and pass the key as a
57
+ bearer token, which is the header the gateway reads:
58
+
59
+ ```bash
60
+ export COPILOT_PROVIDER_BASE_URL="https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e"
61
+ export COPILOT_PROVIDER_BEARER_TOKEN="<your-ai-gateway-app-api-key>"
62
+ export COPILOT_PROVIDER_TYPE="anthropic"
63
+ export COPILOT_MODEL="anthropic/claude-sonnet-4-6"
64
+ ```
65
+
66
+ The CLI speaks Chat Completions and the Messages API and sets a `temperature` on
67
+ every request. GPT-4.1, GPT-4o, Claude Sonnet 4.6, and Claude Haiku 4.5 all
68
+ accept that; OpenAI's GPT-5.x reasoning models and Claude Sonnet 5 don't, so use
69
+ them from VS Code instead.
70
+
71
+ ## VS Code
72
+
73
+ Add the gateway as a Custom Endpoint provider, then describe your models in the
74
+ configuration file VS Code opens.
75
+
76
+ <Stepper>
77
+
78
+ 1. Run **Chat: Manage Language Models** from the Command Palette
79
+
80
+ 2. Select **Add Models**, then **Custom Endpoint**
81
+
82
+ 3. Enter a name for the group, such as `Zuplo AI Gateway`, and paste the app's
83
+ API key
84
+
85
+ 4. Select **Chat Completions** as the API type
86
+
87
+ 5. Replace the `models` array in the `chatLanguageModels.json` file that opens
88
+ with the models you want in the picker
89
+
90
+ </Stepper>
91
+
92
+ This configuration offers a GPT model and a Claude model from one app:
93
+
94
+ ```json
95
+ [
96
+ {
97
+ "name": "Zuplo AI Gateway",
98
+ "vendor": "customendpoint",
99
+ "apiKey": "${input:chat.lm.secret.321f64a2}",
100
+ "models": [
101
+ {
102
+ "id": "openai/gpt-5.6",
103
+ "name": "GPT-5.6 (Zuplo)",
104
+ "apiType": "responses",
105
+ "url": "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1/responses",
106
+ "toolCalling": true,
107
+ "vision": true,
108
+ "maxInputTokens": 128000,
109
+ "maxOutputTokens": 16000,
110
+ "requestHeaders": { "Authorization": "Bearer ${apiKey}" }
111
+ },
112
+ {
113
+ "id": "anthropic/claude-sonnet-5",
114
+ "name": "Claude Sonnet 5 (Zuplo)",
115
+ "apiType": "messages",
116
+ "url": "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1/messages",
117
+ "toolCalling": true,
118
+ "vision": true,
119
+ "maxInputTokens": 200000,
120
+ "maxOutputTokens": 64000,
121
+ "requestHeaders": { "Authorization": "Bearer ${apiKey}" }
122
+ }
123
+ ]
124
+ }
125
+ ]
126
+ ```
127
+
128
+ Three lines carry the integration:
129
+
130
+ - **`apiKey`** is a reference to the key you pasted, which VS Code keeps in its
131
+ secret storage and reads at request time. Leave it as written; to change the
132
+ key, use **Chat: Manage Language Models**.
133
+ - **`requestHeaders`** sends the key as `Authorization: Bearer <key>`, the
134
+ header the gateway's
135
+ [Authentication policy](../../policies/ai-gateway-auth-v2-inbound.mdx) reads.
136
+ VS Code's default header depends on the API type, so set it on every model.
137
+ - **`apiType`** and the `url` path select the API the model speaks: `responses`
138
+ for OpenAI's reasoning models such as GPT-5.x, `messages` for Claude through
139
+ the gateway's native Messages passthrough, and `chat-completions` with a
140
+ `/v1/chat/completions` URL for other chat models. Set it on each model so
141
+ models with different APIs share one entry in the picker.
142
+
143
+ Save the file and pick a model from the chat model picker.
144
+
145
+ ## What routes through the gateway
146
+
147
+ Chat and agent requests, on both surfaces. Copilot's inline code completions
148
+ stay on GitHub's infrastructure. The app's
149
+ [Model Filtering](../../policies/ai-gateway-model-filtering-v2-inbound.mdx)
150
+ policy decides which models Copilot may use.
151
+
152
+ ## Troubleshooting
153
+
154
+ Copilot reports gateway errors as a retry failure, such as
155
+ `502 token expired or invalid: 401`. The gateway's own response is in the
156
+ **GitHub Copilot Chat** channel of VS Code's Output panel, or in the CLI's error
157
+ text.
158
+
159
+ | The gateway responds | Cause | Fix |
160
+ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
161
+ | `401` `Header configured by options.authHeader is missing` | The key was sent as `x-api-key` | VS Code: add `requestHeaders`. CLI: use `COPILOT_PROVIDER_BEARER_TOKEN` |
162
+ | `401` `Invalid Authorization Scheme` | VS Code's `apiKey` was replaced with the key itself, so the key sent was empty | Re-enter the key with **Chat: Manage Language Models** |
163
+ | `404` | The `url` stops at the app ID | Append `/v1/responses`, `/v1/messages`, or `/v1/chat/completions` |
164
+ | `Function tools with reasoning_effort are not supported` | A GPT-5.x model on Chat Completions | VS Code: `"apiType": "responses"`. CLI: use a GPT-4.1 or Claude model |
165
+ | `'temperature' does not support 0` or `temperature is deprecated` | The CLI's temperature setting on a GPT-5.x or Claude Sonnet 5 model | Use GPT-4.1, GPT-4o, Claude Sonnet 4.6, or Claude Haiku 4.5 |
@@ -355,34 +355,23 @@ have both actions, the `warn` value must be less than the `block` value. The
355
355
  policy does not treat entries whose `budgetBy` is `app` as expression budget
356
356
  rules.
357
357
 
358
- ### Supported expressions
359
-
360
- Expressions read one string or safe integer from request-time data. Use these
361
- canonical forms when writing application configuration:
362
-
363
- | Source | Canonical expression example |
364
- | --------------------------- | ------------------------------------------- |
365
- | Request URL or method | `request.url`, `request.method` |
366
- | One request header | `request.headers.get("x-customer-id")` |
367
- | Authenticated subject | `request.user.sub` |
368
- | User data | `request.user.data.profile.organizationId` |
369
- | Query parameter | `request.query.customerId` |
370
- | Query parameter (map form) | `request.searchParams.customerId` |
371
- | Route parameter | `request.params.productId` |
372
- | Custom context data | `context.custom.account.id` |
373
- | Route data | `context.route.path` |
374
- | Incoming request data | `context.incomingRequestProperties.country` |
375
- | Request or context identity | `request.user.sub`, `context.requestId` |
376
- | A key requiring brackets | `request.user.data["team-id"]` |
377
-
378
- The `expression` field contains the complete expression. Do not wrap it in an
379
- interpolation marker such as `${...}`. Expressions use values from the request
380
- and context, including values set by earlier policies. Place policies that set
381
- these values before Semantic Cache. You can change an expression in the
358
+ ### Expressions
359
+
360
+ An expression selects one value from the request or its context, such as
361
+ `request.headers.get("x-user-id")` or `request.user.data["team-id"]`. Every
362
+ distinct value gets its own budget.
363
+
364
+ The expression grammar, the selectable properties, what a value must be, and
365
+ what happens when an expression does not resolve are documented once, on the
366
+ [AI Gateway Metering](/docs/policies/ai-gateway-metering-v2-inbound) policy
367
+ page. The same grammar applies here.
368
+
369
+ Two things are specific to application configuration. A policy that sets a value
370
+ an expression reads must run before Semantic Cache, which can answer without
371
+ reaching the rest of the chain. And you can change an expression in the
382
372
  application configuration without rebuilding or redeploying the gateway.
383
373
 
384
- JSON encoding and expression syntax are separate. When writing raw JSON, escape
385
- the double quotes required by a bracket segment:
374
+ When writing raw JSON, escape the double quotes a bracket segment needs:
386
375
 
387
376
  ```json
388
377
  {
@@ -390,58 +379,14 @@ the double quotes required by a bracket segment:
390
379
  }
391
380
  ```
392
381
 
393
- After a JSON parser decodes this value, the expression is
382
+ After a JSON parser decodes that value the expression is
394
383
  `request.user.data["team-id"]`; the backslashes are not part of its identity.
395
- Code that creates application configuration should build an ordinary string and
396
- let its JSON serializer handle the transport escaping:
384
+ Code that builds application configuration should create an ordinary string and
385
+ let its JSON serializer add the transport escaping.
397
386
 
398
- ```ts
399
- const expression = 'request.user.data["team-id"]';
400
- const body = JSON.stringify({ expression });
401
- ```
402
-
403
- Header expressions are terminal. Header names are case-insensitive; use
404
- lowercase in stored expressions. The parser accepts the canonical
405
- `request.headers.get("content-type")` form and the equivalent
406
- `request.headers.content-type` and `request.headers["content-type"]` forms. The
407
- canonical form keeps header access visually distinct from an ordinary object
408
- property.
409
-
410
- A dot property starts with an ASCII letter, `_`, or `$`, followed by those
411
- characters, ASCII digits, or hyphens. Canonical rendering uses quoted brackets
412
- for keys containing `$` or `-`, or any other non-identifier character. For
413
- example, `context.custom.team-id` is accepted and its canonical form is
414
- `context.custom["team-id"]`. Single- and double-quoted bracket properties are
415
- accepted. Whitespace immediately inside brackets is accepted but is not
416
- canonical.
417
-
418
- The selectable data model is:
419
-
420
- | Root | Selectable properties |
421
- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
422
- | `request` | `url`, `method`, `headers`, `user.sub`, `user.data`, `query`, `searchParams`, `params`, and the scalar Fetch request metadata `bodyUsed`, `cache`, `credentials`, `destination`, `integrity`, `keepalive`, `mode`, `redirect`, `referrer`, and `referrerPolicy` |
423
- | `context` | `contextId`, `requestId`, `custom`, `route`, and `incomingRequestProperties` |
424
-
425
- Expressions cannot read the request body, call logging or policy methods, or
426
- access properties outside this table.
427
-
428
- Expressions have these limits:
429
-
430
- - The expression is at most 1,024 UTF-8 bytes and contains well-formed Unicode.
431
- - Quoted keys are non-empty and do not contain double quotes, backslashes,
432
- control characters, U+2028, or U+2029. String escapes are not supported.
433
- - Canonical expressions contain no syntax whitespace. Spaces inside a quoted key
434
- are part of the key.
435
- - Wildcards, recursive descent, filters, numeric bracket indexes, calls other
436
- than the terminal header `.get("name")`, function arguments, and roots other
437
- than `request` and `context` are not supported. For example,
438
- `request.url.substring(5, 1)` is not a supported expression.
439
- - Traversal reads own properties from plain objects and keys from declared Maps.
440
- It does not traverse arrays, class instances, inherited properties, or the
441
- property names `__proto__`, `constructor`, and `prototype`.
442
-
443
- An authentication policy can populate `request.user`. A custom policy earlier in
444
- the chain can derive a value and place it in a request header:
387
+ An expression selects one value and cannot combine several. To budget by a
388
+ composite value, compute it in a custom policy earlier in the chain and store it
389
+ somewhere an expression can read:
445
390
 
446
391
  ```ts
447
392
  import { ZuploContext, ZuploRequest } from "@zuplo/runtime";
@@ -463,19 +408,6 @@ The corresponding expression is `request.headers.get("x-budget-customer")`.
463
408
  Configuration code should create that ordinary string and pass the enclosing
464
409
  object to `JSON.stringify`; the serializer adds the JSON transport escaping.
465
410
 
466
- The expression chooses one value; it does not concatenate values, run
467
- JavaScript, or evaluate conditions. Compute composite values in an earlier
468
- policy and store the result in a supported request or context property.
469
-
470
- The selected value must be a string or a safe integer. The runtime converts the
471
- value to well-formed NFC Unicode and ignores it when it exceeds 256 UTF-8 bytes
472
- or contains control characters, U+2028, or U+2029. A missing or invalid value
473
- does not contribute to the rule for that request.
474
-
475
- Use consistent spelling for each expression. Different spellings, such as single
476
- quotes and double quotes, identify different budget rules even when they select
477
- the same value.
478
-
479
411
  Invalid budget rules and unsupported expressions are logged and skipped. Other
480
412
  valid rules continue to apply.
481
413
 
@@ -68,6 +68,162 @@ value of its expression. An action of `"warn"` notifies without blocking. An
68
68
  action of `"block"` activates the configured quota fallback or returns
69
69
  `429 Too Many Requests` when usage reaches the value.
70
70
 
71
+ ## Budget expressions
72
+
73
+ A rule with `"budgetBy": "expression"` gives every distinct value of its
74
+ expression its own budget. A daily cost budget of five dollars on
75
+ `request.headers.get("x-user-id")` gives each user five dollars a day, rather
76
+ than five dollars shared across all users.
77
+
78
+ An expression selects one value from the request or its context. It is a
79
+ selector, not code: there are no comparisons, arithmetic, string concatenation,
80
+ or method calls other than the header accessor below.
81
+
82
+ ### Supported expressions
83
+
84
+ | Expression | Example | Selects |
85
+ | ------------------------------------------ | ------------------------------------------- | --------------------------------------------------------------- |
86
+ | `request.headers.get("<name>")` | `request.headers.get("x-user-id")` | One request header. Header names are case-insensitive. |
87
+ | `request.query.<name>` | `request.query.tenant` | One query-string parameter. |
88
+ | `request.searchParams.<name>` | `request.searchParams.tenant` | One query-string parameter. |
89
+ | `request.params.<name>` | `request.params.customerId` | One path parameter from the matched route. |
90
+ | `request.user.sub` | `request.user.sub` | The authenticated consumer's subject, such as the API key name. |
91
+ | `request.user.data.<property>` | `request.user.data.organizationId` | A property of the consumer's metadata, including nested ones. |
92
+ | `request.<property>` | `request.method` | A scalar request property. |
93
+ | `context.custom.<property>` | `context.custom.tenantId` | A value your own policies or handlers put on `context.custom`. |
94
+ | `context.route.<property>` | `context.route.path` | A property of the matched route, such as `path` or `label`. |
95
+ | `context.incomingRequestProperties.<name>` | `context.incomingRequestProperties.country` | A connection property, such as `country`, `city`, or `asn`. |
96
+
97
+ The selectable scalar request properties are `bodyUsed`, `cache`, `credentials`,
98
+ `destination`, `integrity`, `keepalive`, `method`, `mode`, `redirect`,
99
+ `referrer`, `referrerPolicy`, and `url`.
100
+
101
+ Use quoted brackets for any key that is not a plain identifier:
102
+ `request.query["team-id"]`, `context.custom["tenant.id"]`.
103
+
104
+ `context.incomingRequestProperties` exposes connection data only: `country`,
105
+ `city`, `region`, `regionCode`, `continent`, `colo`, `latitude`, `longitude`,
106
+ `postalCode`, `metroCode`, `timezone`, `ip`, `asn`, `asOrganization`, and
107
+ `httpProtocol`. Client-certificate and mTLS verification fields are not
108
+ selectable. To budget per client certificate, run the mTLS Authentication policy
109
+ first and select the metadata it attaches, such as
110
+ `request.user.data.mtlsAuth.sha256Fingerprint`.
111
+
112
+ An expression is limited to 1024 bytes and 32 property segments.
113
+
114
+ ### Examples
115
+
116
+ Give every end user their own daily spend budget, keyed by a header your
117
+ application sends:
118
+
119
+ ```json
120
+ {
121
+ "budgetBy": "expression",
122
+ "expression": "request.headers.get(\"x-user-id\")",
123
+ "meters": [
124
+ { "meter": "cost", "period": "daily", "value": 5, "action": "block" }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ Give every customer of yours their own monthly token budget, keyed by metadata
130
+ on the API key that made the request:
131
+
132
+ ```json
133
+ {
134
+ "budgetBy": "expression",
135
+ "expression": "request.user.data.organizationId",
136
+ "meters": [
137
+ {
138
+ "meter": "tokens",
139
+ "period": "monthly",
140
+ "value": 8000000,
141
+ "action": "warn"
142
+ },
143
+ {
144
+ "meter": "tokens",
145
+ "period": "monthly",
146
+ "value": 10000000,
147
+ "action": "block"
148
+ }
149
+ ]
150
+ }
151
+ ```
152
+
153
+ Cap requests per tenant per hour, keyed by a path parameter on a route such as
154
+ `/tenants/:tenantId/chat`:
155
+
156
+ ```json
157
+ {
158
+ "budgetBy": "expression",
159
+ "expression": "request.params.tenantId",
160
+ "meters": [
161
+ {
162
+ "meter": "requests",
163
+ "period": "hourly",
164
+ "value": 1000,
165
+ "action": "block"
166
+ }
167
+ ]
168
+ }
169
+ ```
170
+
171
+ ### Values
172
+
173
+ An expression must resolve to a string or a safe integer. The runtime converts
174
+ the value to well-formed NFC Unicode, and ignores it when it exceeds 256 UTF-8
175
+ bytes or contains control characters, U+2028, or U+2029. Values are
176
+ case-sensitive, so `Acme` and `acme` get separate budgets, but two strings that
177
+ normalize to the same NFC text share one budget.
178
+
179
+ When an expression resolves to nothing, or to something else such as an object,
180
+ an array, or a fractional number, that rule does not count or block the request.
181
+ Other rules still apply. Pair a rule on a value you must enforce with a policy
182
+ that rejects requests missing it, such as API key authentication or request
183
+ validation.
184
+
185
+ ### Not supported
186
+
187
+ | Not supported | Use instead |
188
+ | ---------------------------------------------------- | ------------------------------------------------------------------- |
189
+ | Request body values, such as `request.body.userId` | A header, a query parameter, a path parameter, or API key metadata. |
190
+ | Whole objects, such as `request.headers` | One value, such as `request.headers.get("x-user-id")`. |
191
+ | Comparisons, `&&`, ternaries, and literals | One selector per rule. An application can have up to five rules. |
192
+ | String methods, such as `.toLowerCase()` | The value as the client sends it. |
193
+ | Wildcards and multiple keys in one expression | One rule per value you want to budget. |
194
+ | Arrays, class instances, and inherited properties | A plain value the gateway or your own policy sets. |
195
+ | Per-request identifiers, such as `context.requestId` | A value shared by many requests, such as a user or tenant. |
196
+
197
+ An unsupported expression does not fail the request. The rule is skipped, and
198
+ the request is not budgeted by it.
199
+
200
+ ### Keep the spelling stable
201
+
202
+ Each expression is stored exactly as you write it, and its budgets are tracked
203
+ under that exact text. `request.headers.get("x-user-id")` and
204
+ `request.headers["x-user-id"]` read the same header but keep separate budgets,
205
+ and editing a rule's expression starts its budgets over. Single quotes and
206
+ double quotes likewise identify different rules. Use the spellings in the table
207
+ above.
208
+
209
+ Several forms parse, so prefer the canonical one. A header is written
210
+ `request.headers.get("content-type")`, with a lowercase name; the equivalent
211
+ `request.headers.content-type` and `request.headers["content-type"]` are
212
+ accepted but keep separate budgets. A dot property starts with a letter, `_`, or
213
+ `$`; a key containing anything else takes quoted brackets, so
214
+ `context.custom.team-id` is canonically `context.custom["team-id"]`.
215
+
216
+ In raw JSON, escape the double quotes a bracket segment needs:
217
+
218
+ ```json
219
+ {
220
+ "expression": "request.user.data[\"team-id\"]"
221
+ }
222
+ ```
223
+
224
+ After a JSON parser decodes that value the expression is
225
+ `request.user.data["team-id"]`; the backslashes are not part of its identity.
226
+
71
227
  ## Team limits
72
228
 
73
229
  Application budgets apply only to that application. Parent team and gateway
@@ -3,6 +3,10 @@ requests and enforces spend, token, and request budgets. When a limit is
3
3
  exceeded, it uses the model selection's quota fallback when one is configured or
4
4
  returns `429 Too Many Requests`.
5
5
 
6
+ A budget rule covers the whole application, or gives every distinct value of an
7
+ expression its own budget, such as one budget per user with
8
+ `request.headers.get("x-user-id")`.
9
+
6
10
  Place it after Model Filtering and Fallback Model, and before policies such as
7
11
  Semantic Cache that may answer without calling a provider. Metering fails open
8
12
  by default when its service is unavailable; set `throwOnFailure` to `true` to
@@ -77,17 +77,19 @@
77
77
  "properties": {
78
78
  "budgetBy": {
79
79
  "type": "string",
80
- "enum": ["app", "expression"]
80
+ "enum": ["app", "expression"],
81
+ "description": "Budget the application as a whole (\"app\"), or give every distinct value of an expression its own budget (\"expression\")."
81
82
  },
82
83
  "expression": {
83
84
  "type": "string",
84
85
  "minLength": 1,
85
- "description": "Required when budgetBy is \"expression\"; forbidden for \"app\". Each distinct value gets its own budget."
86
+ "description": "Required when budgetBy is \"expression\"; forbidden for \"app\". Each distinct value gets its own budget. Select one value from the request or its context: request.headers.get(\"x-user-id\"), request.query.tenant, request.params.customerId, request.user.sub, request.user.data.organizationId, request.method, request.url, context.custom.tenantId, context.route.path, or context.incomingRequestProperties.country. Comparisons, arithmetic, string methods, and request body values are not supported."
86
87
  },
87
88
  "meters": {
88
89
  "type": "array",
89
90
  "minItems": 1,
90
91
  "maxItems": 24,
92
+ "description": "Thresholds this rule enforces. Add one entry per meter, period, and action you want.",
91
93
  "items": {
92
94
  "type": "object",
93
95
  "additionalProperties": false,
@@ -95,19 +97,23 @@
95
97
  "properties": {
96
98
  "meter": {
97
99
  "type": "string",
98
- "enum": ["cost", "requests", "tokens"]
100
+ "enum": ["cost", "requests", "tokens"],
101
+ "description": "What to measure: US dollars spent (\"cost\"), requests made (\"requests\"), or tokens used (\"tokens\")."
99
102
  },
100
103
  "period": {
101
104
  "type": "string",
102
- "enum": ["hourly", "daily", "weekly", "monthly"]
105
+ "enum": ["hourly", "daily", "weekly", "monthly"],
106
+ "description": "How often usage resets."
103
107
  },
104
108
  "value": {
105
109
  "type": "number",
106
- "exclusiveMinimum": 0
110
+ "exclusiveMinimum": 0,
111
+ "description": "The threshold for this meter and period. Cost is in US dollars."
107
112
  },
108
113
  "action": {
109
114
  "type": "string",
110
- "enum": ["warn", "block"]
115
+ "enum": ["warn", "block"],
116
+ "description": "What happens at the threshold: \"warn\" notifies and lets the request through, \"block\" uses the configured quota fallback model or returns 429 Too Many Requests."
111
117
  }
112
118
  }
113
119
  }
@@ -168,4 +168,38 @@ Certificates can't be issued until these records resolve. cert-manager checks
168
168
  the challenge URL from inside the cluster before it contacts the certificate
169
169
  authority.
170
170
 
171
- Next, [verify your installation](./verify.md).
171
+ ## Verify your installation
172
+
173
+ Use the [Zuplo self-hosted doctor](https://github.com/zuplo/self-hosted-doctor)
174
+ to check the installation. Doctor is a CLI that reads your installation through
175
+ your kubeconfig and probes its DNS, TLS, and HTTP endpoints. It checks the Helm
176
+ release, the `Configuration` resource, Deployment health, ingress reachability,
177
+ certificate issuance, DNS records, the management API, and the builder
178
+ configuration. It reads the installation and doesn't change it.
179
+
180
+ Download the archive for your machine from the
181
+ [releases page](https://github.com/zuplo/self-hosted-doctor/releases), verify
182
+ its checksum, extract it, and run the full suite against your current kubeconfig
183
+ context:
184
+
185
+ ```bash
186
+ ./zuplo-self-hosted-doctor verify
187
+ ```
188
+
189
+ Doctor exits `0` when no check failed, `1` when a check failed, and `3` when it
190
+ couldn't run. Each warning and failure names a diagnostic step and links to the
191
+ matching entry in [Troubleshooting](./troubleshooting.md).
192
+
193
+ To also check authenticated access to the management API, set `ZUPLO_API_KEY` in
194
+ your shell before running `verify`. Without a key, Doctor checks only that
195
+ unauthenticated requests are rejected.
196
+
197
+ The repository's README covers selecting individual checks, comparing a local
198
+ values file, private certificate authorities, and JSON output.
199
+
200
+ :::note
201
+
202
+ Doctor doesn't deploy a project. To confirm the full build-and-serve path, run
203
+ `npx zuplo deploy` against your account after Doctor passes.
204
+
205
+ :::
@@ -184,10 +184,10 @@ images, and the credentials to pull them.
184
184
  ## Get started
185
185
 
186
186
  1. Review the [requirements](./requirements.md) and run the preflight checks.
187
- 2. [Install](./install.md) the Helm chart.
188
- 3. [Verify the installation](./verify.md), including a complete deployment.
189
- 4. [Upgrade](./upgrade.md) to later chart versions when needed.
190
- 5. Use [troubleshooting](./troubleshooting.md) to resolve installation and
187
+ 2. [Install](./install.md) the Helm chart, then verify the installation with the
188
+ [Zuplo self-hosted doctor](https://github.com/zuplo/self-hosted-doctor) CLI.
189
+ 3. [Upgrade](./upgrade.md) to later chart versions when needed.
190
+ 4. Use [troubleshooting](./troubleshooting.md) to resolve installation and
191
191
  deployment errors.
192
192
 
193
193
  To discuss your specific requirements,
@@ -202,8 +202,9 @@ to HTTPS.
202
202
 
203
203
  This redirect doesn't affect certificate issuance. cert-manager creates a
204
204
  separate challenge Ingress that continues to serve on port 80. To test the
205
- ingress, use a hostname with no Ingress, as described in
206
- [Verify your install](./verify.md).
205
+ ingress, use a hostname with no Ingress. The
206
+ [Zuplo self-hosted doctor](https://github.com/zuplo/self-hosted-doctor) CLI does
207
+ this for you in its `ingress-reachable` check.
207
208
 
208
209
  ## Failed to deploy the environment but the cluster shows the build succeeding
209
210
 
@@ -41,13 +41,13 @@ installed with `helm plugin install https://github.com/databus23/helm-diff`.
41
41
 
42
42
  ## Verify the upgrade
43
43
 
44
- Work through [Verify your install](./verify.md) again. The gateway deployments
45
- already in the cluster keep serving through the upgrade; they are rebuilt only
46
- when you deploy them.
44
+ Run the [Zuplo self-hosted doctor](https://github.com/zuplo/self-hosted-doctor)
45
+ CLI again and confirm the release history records the new revision. The gateway
46
+ deployments already in the cluster keep serving through the upgrade; they are
47
+ rebuilt only when you deploy them.
47
48
 
48
49
  ```bash
49
- kubectl wait --for=condition=Available deployment --all \
50
- -n zuplo-system --timeout=5m
50
+ ./zuplo-self-hosted-doctor verify
51
51
  helm history zuplo -n zuplo
52
52
  ```
53
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zuplo",
3
- "version": "7.7.10",
3
+ "version": "7.7.11",
4
4
  "type": "module",
5
5
  "description": "The official Zuplo CLI for local development and platform management",
6
6
  "homepage": "https://zuplo.com/docs/cli/overview",
@@ -32,9 +32,9 @@
32
32
  "zuplo": "zuplo.js"
33
33
  },
34
34
  "dependencies": {
35
- "@zuplo/cli": "7.7.10",
36
- "@zuplo/core": "7.7.10",
37
- "@zuplo/runtime": "7.7.10",
38
- "@zuplo/test": "7.7.10"
35
+ "@zuplo/cli": "7.7.11",
36
+ "@zuplo/core": "7.7.11",
37
+ "@zuplo/runtime": "7.7.11",
38
+ "@zuplo/test": "7.7.11"
39
39
  }
40
40
  }
@@ -1,279 +0,0 @@
1
- ---
2
- title: Verify Your Install
3
- sidebar_label: Verify your install
4
- ---
5
-
6
- Run the six checks in order. Each check depends on the previous one, which helps
7
- you identify the source of a failure. Checks one through five require only the
8
- installation. Check six also requires a Zuplo API key and project.
9
-
10
- ## Check the deployments
11
-
12
- Gate on readiness before testing anything over the network.
13
-
14
- ```bash
15
- kubectl wait --for=condition=Available deployment --all \
16
- -n zuplo-system --timeout=5m
17
- kubectl wait --for=condition=Available deployment --all \
18
- -n zuplo --timeout=5m
19
- ```
20
-
21
- ```
22
- deployment.apps/control-plane condition met
23
- deployment.apps/deployer condition met
24
- deployment.apps/gateway condition met
25
- deployment.apps/storage condition met
26
- deployment.apps/acme-forwarder condition met
27
- deployment.apps/custom-error-pages condition met
28
- deployment.apps/zuplo-cert-manager condition met
29
- deployment.apps/zuplo-cert-manager-cainjector condition met
30
- deployment.apps/zuplo-cert-manager-webhook condition met
31
- deployment.apps/zuplo-haproxy-ingress condition met
32
- deployment.apps/zuplo-kube-prometheus-stac-operator condition met
33
- deployment.apps/zuplo-kube-state-metrics condition met
34
- deployment.apps/zuplo-prometheus-adapter condition met
35
- ```
36
-
37
- The `control-plane`, `deployer`, `gateway`, `storage`, and `acme-forwarder`
38
- deployments are part of the Zuplo management plane. The other deployments come
39
- from subcharts.
40
-
41
- ## Check the ingress
42
-
43
- Send a request for a hostname that has no Ingress. This checks that the load
44
- balancer can reach HAProxy.
45
-
46
- ```bash
47
- LB=$(kubectl get svc zuplo-haproxy-ingress -n zuplo \
48
- -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
49
-
50
- curl -i "http://$LB/" -H 'Host: no-such-deployment.example.com'
51
- ```
52
-
53
- ```
54
- HTTP/1.1 404 Not Found
55
- content-type: text/html
56
-
57
- {
58
- "type": "https://httpproblems.com/http-status/404",
59
- "title": "Not Found",
60
- "status": 404,
61
- "detail": "This Zuplo project does not exist"
62
- }
63
- ```
64
-
65
- The expected result is `404 Not Found` from HAProxy's default backend. A timeout
66
- or refused connection means that traffic isn't reaching the ingress controller.
67
-
68
- Use a hostname without an Ingress for this check. Hosts with an Ingress redirect
69
- port 80 to HTTPS and return a `302` instead.
70
-
71
- :::note
72
-
73
- Requests for hosts with an Ingress are redirected from port 80 to 443. The
74
- separate challenge Ingress created by cert-manager continues to serve ACME
75
- challenge paths on port 80.
76
-
77
- :::
78
-
79
- ## Check the control plane
80
-
81
- The control plane turns your values into a `Configuration` resource, then
82
- creates the management API's Ingress from it.
83
-
84
- ```bash
85
- kubectl get configuration default -n zuplo-system -o yaml
86
- ```
87
-
88
- ```yaml
89
- spec:
90
- account:
91
- name: acme-corp
92
- builder:
93
- registry: us-docker.pkg.dev/acme-corp/zuplo-gateways
94
- secretName: builder-secret
95
- deployments:
96
- subdomain: api.example.com
97
- certificates:
98
- certManager:
99
- issuer: zuplo-cluster-issuer
100
- managementApi:
101
- hostname: zuplo-admin.example.com
102
- ingressControllers:
103
- - haproxy
104
- ```
105
-
106
- Confirm every value matches what you put in `zuplo-values.yaml`. A
107
- single-cluster installation has no `workers` or `authorization` entry. Those
108
- settings are for installations that distribute deployments across clusters.
109
-
110
- Then confirm the reconciler acted on the rest:
111
-
112
- ```bash
113
- kubectl get ingress -n zuplo-system
114
- kubectl get clusterissuer
115
- ```
116
-
117
- ```
118
- NAME CLASS HOSTS ADDRESS PORTS AGE
119
- gateway-haproxy haproxy zuplo-admin.example.com 203.0.113.24 80, 443 62s
120
-
121
- NAME READY STATUS AGE
122
- zuplo-cluster-issuer True The ACME account was registered with the ACME server 116s
123
- ```
124
-
125
- `READY: True` for `zuplo-cluster-issuer` means that the chart registered an
126
- account with the certificate authority. It doesn't indicate whether a
127
- certificate has been issued.
128
-
129
- :::note
130
-
131
- `Configuration` doesn't report a status. Check the Ingress and ClusterIssuer
132
- resources that it produces instead.
133
-
134
- :::
135
-
136
- ## Check certificate issuance
137
-
138
- Check that the certificate authority issued a certificate for the management API
139
- hostname:
140
-
141
- ```bash
142
- kubectl get certificate -A
143
- ```
144
-
145
- ```
146
- NAMESPACE NAME READY SECRET AGE
147
- zuplo-system gateway-cert-manager True gateway-cert-manager 4m
148
- ```
149
-
150
- `READY: True` means that the certificate authority validated your hostname and
151
- issued a certificate. If it remains `False` for more than a few minutes, see
152
- [Troubleshooting](./troubleshooting.md).
153
-
154
- cert-manager retries automatically after you fix DNS. You don't need to restart
155
- it.
156
-
157
- ## Check the management API
158
-
159
- Send an unauthenticated request to check DNS, the load balancer, HAProxy, the
160
- certificate, and authentication:
161
-
162
- ```bash
163
- curl -i https://zuplo-admin.example.com/v1/deployments
164
- ```
165
-
166
- ```
167
- HTTP/2 401
168
-
169
- {
170
- "type": "https://httpproblems.com/http-status/401",
171
- "title": "Unauthorized",
172
- "status": 401,
173
- "detail": "No Authorization Header",
174
- ...
175
- }
176
- ```
177
-
178
- The expected result is `401 Unauthorized`. It confirms that `curl` completed a
179
- TLS handshake with a trusted certificate and that the gateway requires an API
180
- key. A TLS error indicates a certificate problem. A timeout indicates a DNS or
181
- load balancer problem.
182
-
183
- Confirm the certificate is the real one rather than HAProxy's self-signed
184
- fallback:
185
-
186
- ```bash
187
- echo | openssl s_client -connect zuplo-admin.example.com:443 \
188
- -servername zuplo-admin.example.com 2>/dev/null |
189
- openssl x509 -noout -subject -issuer
190
- ```
191
-
192
- ```
193
- subject=CN=zuplo-admin.example.com
194
- issuer=C=US, O=Let's Encrypt, CN=YR2
195
- ```
196
-
197
- An issuer of `CN=kubernetes-ingress-ca` means HAProxy is serving its built-in
198
- fallback certificate because no issued certificate is available. Repeat the
199
- certificate issuance check.
200
-
201
- ## Deploy and call a project
202
-
203
- The final check builds a gateway image in your cluster, stores it in your
204
- registry, and serves it from your infrastructure.
205
-
206
- Deploy your project with the Zuplo CLI. The `project` field in `zuplo.jsonc`
207
- selects the project, and Zuplo routes the deployment to your cluster:
208
-
209
- ```bash
210
- export ZUPLO_API_KEY='<your Zuplo API key>'
211
-
212
- npx zuplo deploy
213
- ```
214
-
215
- Zuplo compiles the project, then hands the compiled bundle to your cluster's
216
- management API, which builds the container image and rolls it out.
217
-
218
- While the command runs, watch the build Job in your cluster:
219
-
220
- ```bash
221
- kubectl get jobs -n zuplo-system -w
222
- ```
223
-
224
- ```
225
- NAME STATUS COMPLETIONS DURATION AGE
226
- build-basic-main-ca9748b-ztcwzdd Running 0/1 32s 32s
227
- build-basic-main-ca9748b-ztcwzdd Complete 1/1 58s 63s
228
- ```
229
-
230
- Confirm that the deployment uses an image from your registry:
231
-
232
- ```bash
233
- kubectl get deploy -n zuplo -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.containers[0].image}{"\n"}{end}'
234
- ```
235
-
236
- ```
237
- basic-main-ca9748b us-docker.pkg.dev/acme-corp/zuplo-gateways/basic-main-ca9748b:20260806-172436
238
- ```
239
-
240
- Ask the management API what it is serving:
241
-
242
- ```bash
243
- curl -H "Authorization: Bearer $ZUPLO_API_KEY" \
244
- https://zuplo-admin.example.com/v1/deployments
245
- ```
246
-
247
- ```json
248
- {
249
- "data": [
250
- {
251
- "projectName": "basic",
252
- "deploymentName": "basic-main-ca9748b-haproxy",
253
- "deploymentUrl": "https://basic-main-ca9748b.api.example.com"
254
- }
255
- ]
256
- }
257
- ```
258
-
259
- Call the deployed API:
260
-
261
- ```bash
262
- curl https://basic-main-ca9748b.api.example.com/hello
263
- ```
264
-
265
- The first request to a deployment can fail while cert-manager issues its
266
- certificate. Retry the request for up to a minute.
267
-
268
- :::note
269
-
270
- The CLI can misreport the status of deployments to self-hosted clusters. See
271
- [Troubleshooting](./troubleshooting.md#failed-to-deploy-the-environment-but-the-cluster-shows-the-build-succeeding).
272
- Use the in-cluster checks to confirm the result.
273
-
274
- :::
275
-
276
- ## Resolve a failed check
277
-
278
- [Troubleshooting](./troubleshooting.md) lists errors from these checks and their
279
- causes.