zuplo 7.6.7 → 7.6.9

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.
@@ -35,6 +35,11 @@ the AI Gateway.
35
35
  ```
36
36
  ANTHROPIC_AUTH_TOKEN=<your-ai-gateway-app-api-key>
37
37
  ANTHROPIC_BASE_URL=https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e
38
+ ANTHROPIC_MODEL=anthropic/claude-sonnet-5
39
+ ANTHROPIC_SMALL_FAST_MODEL=anthropic/claude-haiku-4-5
40
+ ANTHROPIC_DEFAULT_OPUS_MODEL=anthropic/claude-opus-5
41
+ ANTHROPIC_DEFAULT_SONNET_MODEL=anthropic/claude-sonnet-5
42
+ ANTHROPIC_DEFAULT_HAIKU_MODEL=anthropic/claude-haiku-4-5
38
43
  ```
39
44
 
40
45
  ### Using settings.json
@@ -43,7 +48,12 @@ ANTHROPIC_BASE_URL=https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d28
43
48
  {
44
49
  "env": {
45
50
  "ANTHROPIC_AUTH_TOKEN": "<your-ai-gateway-app-api-key>",
46
- "ANTHROPIC_BASE_URL": "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e"
51
+ "ANTHROPIC_BASE_URL": "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e",
52
+ "ANTHROPIC_MODEL": "anthropic/claude-sonnet-5",
53
+ "ANTHROPIC_SMALL_FAST_MODEL": "anthropic/claude-haiku-4-5",
54
+ "ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-5",
55
+ "ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-5",
56
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4-5"
47
57
  }
48
58
  }
49
59
  ```
@@ -58,16 +68,47 @@ Claude Code appends `/v1/messages` to the base URL itself, so
58
68
  Restart Claude and it will switch to using your new AI Gateway configuration and
59
69
  all your Claude Code LLM requests will route through the AI Gateway.
60
70
 
61
- ### Choosing a model
62
-
63
- The gateway routes models named as `providerName/model`, where `providerName` is
64
- the provider name configured in your gateway. Claude Code's default model names
65
- don't carry that prefix, so set the model explicitly:
71
+ ### Model names include your provider's name
72
+
73
+ The gateway routes every request by a model reference of the form
74
+ `providerName/model`, where `providerName` is the name of the provider **you
75
+ configured** in your gateway not a fixed vendor id. A single gateway can
76
+ contain several providers that serve Anthropic models — Anthropic itself, Google
77
+ Vertex AI, AWS Bedrock, or multiple keyed instances of the same vendor — so the
78
+ gateway never guesses a provider for a bare model name like `claude-sonnet-5`.
79
+
80
+ The examples on this page assume the provider is named `anthropic`. Use the name
81
+ of your own provider: a provider named `my-anthropic` routes
82
+ `my-anthropic/claude-sonnet-5`, and a Bedrock provider named `bedrock` routes
83
+ `bedrock/anthropic.claude-sonnet-5` (the model part is always the id the
84
+ provider itself understands).
85
+
86
+ The model ids in these examples reflect what was current when this page was
87
+ written, and ids differ across providers — a Bedrock provider serves different
88
+ ids than Anthropic directly, and availability can vary by region. Check
89
+ [your provider's page](../managing-providers.mdx) in the portal for the models
90
+ it currently serves, and use those ids.
91
+
92
+ None of Claude Code's built-in model names carry a provider prefix, so each of
93
+ the model variables above must be set to a prefixed reference:
94
+
95
+ - `ANTHROPIC_MODEL` — the main model. Required: without it Claude Code sends its
96
+ built-in default model unprefixed and the first request fails.
97
+ - `ANTHROPIC_SMALL_FAST_MODEL` — the model for background tasks.
98
+ - `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`,
99
+ `ANTHROPIC_DEFAULT_HAIKU_MODEL` — what the `opus`/`sonnet`/`haiku` aliases
100
+ resolve to, so `claude --model opus` and `/model sonnet` keep working.
101
+
102
+ :::caution{title='HTTP 400: model must use "providerName/model"'}
103
+
104
+ If Claude Code fails immediately with
105
+ `The request body model must use "providerName/model"`, the request carried a
106
+ model name without a provider prefix. The tier alias variables
107
+ (`ANTHROPIC_DEFAULT_*_MODEL`) alone don't prevent this — they don't cover Claude
108
+ Code's built-in default model. Always set `ANTHROPIC_MODEL` to a prefixed model
109
+ reference.
66
110
 
67
- ```
68
- ANTHROPIC_MODEL=anthropic/claude-sonnet-4-5-20250929
69
- ANTHROPIC_SMALL_FAST_MODEL=anthropic/claude-haiku-4-5
70
- ```
111
+ :::
71
112
 
72
113
  The app's
73
114
  [Model Filtering](../../policies/ai-gateway-model-filtering-v2-inbound.mdx)
@@ -118,13 +118,13 @@ at $80, block at $100. The warn amount has to be the lower of the two.
118
118
  The **Overview** tab charts daily and monthly usage only. Hourly and weekly rows
119
119
  are enforced, but no meter on that tab tracks them.
120
120
 
121
- :::note
121
+ :::note{title="Budget rule periods follow the UTC calendar"}
122
122
 
123
- Periods are anchored to your gateway, not the calendar. Every period rolls over
124
- relative to the time (UTC) the gateway was created, so a daily period resets at
125
- that hour rather than at midnight, and a monthly one on that day of the month.
126
- Teams and apps created later use the same anchor. Changing a limit mid-period
127
- doesn't reset the period's usage.
123
+ Hourly periods reset at the top of each hour, daily at 00:00 UTC, weekly on
124
+ Monday at 00:00 UTC, and monthly on the first of the month. A rule added
125
+ mid-period gets the full allowance for the rest of that period—the limit isn't
126
+ prorated—and resets at the next boundary. Changing a limit mid-period doesn't
127
+ reset the period's usage. Custom anchors aren't configurable.
128
128
 
129
129
  :::
130
130
 
@@ -184,6 +184,56 @@ App rules are stored in `options.budgetRules` on the app's own Budgets and Costs
184
184
  policy entry. For the app rule schema, see the
185
185
  [Budgets and Costs policy reference](../policies/ai-gateway-metering-v2-inbound.mdx).
186
186
 
187
+ This rule set budgets each user, each organization, and the app as a whole:
188
+
189
+ ```json
190
+ {
191
+ "budgetRules": [
192
+ {
193
+ "budgetBy": "expression",
194
+ "expression": "request.headers.get(\"x-user\")",
195
+ "meters": [
196
+ {
197
+ "meter": "tokens",
198
+ "period": "hourly",
199
+ "value": 20000,
200
+ "action": "block"
201
+ }
202
+ ]
203
+ },
204
+ {
205
+ "budgetBy": "expression",
206
+ "expression": "request.headers.get(\"x-organization\")",
207
+ "meters": [
208
+ { "meter": "cost", "period": "daily", "value": 50, "action": "block" }
209
+ ]
210
+ },
211
+ {
212
+ "budgetBy": "app",
213
+ "meters": [
214
+ {
215
+ "meter": "cost",
216
+ "period": "monthly",
217
+ "value": 2000,
218
+ "action": "warn"
219
+ },
220
+ {
221
+ "meter": "cost",
222
+ "period": "monthly",
223
+ "value": 2500,
224
+ "action": "block"
225
+ }
226
+ ]
227
+ }
228
+ ]
229
+ }
230
+ ```
231
+
232
+ Every distinct `x-user` value gets its own 20,000 tokens per hour, resetting at
233
+ the top of the hour. Every distinct `x-organization` value gets its own $50 per
234
+ day, resetting at 00:00 UTC. The `app` rule caps the whole app at $2,500 per
235
+ month, warning at $2,000, resetting on the first of the month.
236
+
187
237
  ## Setting team and gateway limits
188
238
 
189
239
  Team and gateway limits cover the node's own usage and all descendant usage.
@@ -174,11 +174,16 @@ will need to do the following:
174
174
  new OAuthProtectedResourcePlugin({
175
175
  authorizationServers: ["https://your-auth0-domain.us.auth0.com"],
176
176
  resourceName: "My MCP OAuth Resource",
177
+ scopesSupported: ["openid", "profile", "email", "offline_access"],
177
178
  }),
178
179
  );
179
180
  }
180
181
  ```
181
182
 
183
+ `scopesSupported` lists the scopes MCP clients request. Replace the example
184
+ values with the permissions your Auth0 API defines; keep `offline_access` if
185
+ clients need refresh tokens.
186
+
182
187
  See the
183
188
  [OAuth Protected Resource Plugin docs](../programmable-api/oauth-protected-resource-plugin)
184
189
  for more details.
@@ -50,6 +50,19 @@ Next, you'll need to configure the scopes for your authorization server.
50
50
  tools").
51
51
  5. Check **Set as a default scope** and click **Create**.
52
52
 
53
+ :::caution{title="Reserved Okta scopes break MCP clients"}
54
+
55
+ Every Okta custom authorization server also publishes `device_sso` and
56
+ `interclient_access` in its `scopes_supported` metadata. The **Include in public
57
+ metadata** toggle for those two scopes is disabled, so you can't remove them. An
58
+ MCP client that builds its scope list from the authorization server's metadata
59
+ requests them together with `mcp:access`, and Okta rejects the authorization
60
+ request with `illegal_scope_combination`. To keep clients off that list, set
61
+ `scopesSupported` on the `OAuthProtectedResourcePlugin` in
62
+ [Configure OAuth on Zuplo](#configure-oauth-on-zuplo) below.
63
+
64
+ :::
65
+
53
66
  ## Create an OAuth Client Application
54
67
 
55
68
  Next, you'll need to create an OAuth client application for your MCP server.
@@ -102,7 +115,10 @@ Now create a rule for this policy:
102
115
  **Device Authorization**
103
116
  - **User is**: Select **Any user assigned the app**
104
117
  - **Scopes requested**: Select **The following scopes** and choose the scope
105
- you created for the authorization server (that is, `mcp:access`)
118
+ you created for the authorization server (that is, `mcp:access`) and
119
+ `offline_access`, so clients can obtain refresh tokens. Okta rejects a
120
+ request that includes a scope no rule allows, so this list must cover every
121
+ scope you put in `scopesSupported` later in this guide.
106
122
  4. In the **THEN AND** section:
107
123
  - **Use this inline hook**: None (disabled)
108
124
  - **Access token lifetime is**: Set to desired value (for example, 1 hour)
@@ -128,7 +144,7 @@ will need to do the following:
128
144
  "options": {
129
145
  "oAuthResourceMetadataEnabled": true,
130
146
  "audience": "https://my-gateway.zuplo.dev/mcp",
131
- "issuer": "https://your-okta-domain.okta.com/oauth2/your-auth-server-id"
147
+ "issuerUrl": "https://your-okta-domain.okta.com/oauth2/your-auth-server-id"
132
148
  }
133
149
  }
134
150
  }
@@ -136,10 +152,10 @@ will need to do the following:
136
152
 
137
153
  - Replace `my-gateway.zuplo.dev/mcp` with the audience you defined in your
138
154
  authorization server.
139
- - Replace `your-okta-domain` in the `issuer` field with your actual Okta
155
+ - Replace `your-okta-domain` in the `issuerUrl` field with your actual Okta
140
156
  domain.
141
- - Replace `your-auth-server-id` in the `issuer` field with the actual ID of
142
- your Okta authorization server.
157
+ - Replace `your-auth-server-id` in the `issuerUrl` field with the actual ID
158
+ of your Okta authorization server.
143
159
 
144
160
  2. Add the OAuth policy to the MCP Server route. For example:
145
161
 
@@ -179,19 +195,24 @@ will need to do the following:
179
195
  "https://your-okta-domain.okta.com/oauth2/your-auth-server-id",
180
196
  ],
181
197
  resourceName: "My MCP OAuth Resource",
198
+ scopesSupported: ["mcp:access", "offline_access"],
182
199
  }),
183
200
  );
184
201
  }
185
202
  ```
186
203
 
187
- - Replace `your-okta-domain` in the `issuer` field with your actual Okta
188
- domain.
189
- - Replace `your-auth-server-id` in the `issuer` field with the actual ID of
190
- your Okta authorization server.
204
+ - Replace `your-okta-domain` in the `authorizationServers` value with your
205
+ actual Okta domain.
206
+ - Replace `your-auth-server-id` in the `authorizationServers` value with the
207
+ actual ID of your Okta authorization server.
208
+ - Set `scopesSupported` to the scopes you created in
209
+ [Configure Scopes](#configure-scopes). Keep `offline_access` if clients
210
+ need refresh tokens. Add `openid`, `profile`, or `email` only if your
211
+ tokens need those claims.
191
212
 
192
213
  This plugin populates the `.well-known` routes for the MCP server auth
193
214
  metadata discovery. This enables MCP clients to automatically discover the
194
- authorization issuer endpoint. See the
215
+ authorization issuer endpoint and the scopes to request. See the
195
216
  [OAuth Protected Resource Plugin docs](../programmable-api/oauth-protected-resource-plugin)
196
217
  for more details on this runtime plugin.
197
218
 
@@ -206,3 +227,20 @@ further instructions on testing your MCP server with `curl`.
206
227
 
207
228
  If you need more help debugging, see
208
229
  [Testing OAuth on Zuplo](../handlers/mcp-server.mdx#oauth-testing).
230
+
231
+ ## Troubleshooting
232
+
233
+ ### `illegal_scope_combination` in the Okta system log
234
+
235
+ **Cause:** The MCP client requested `device_sso` or `interclient_access`
236
+ together with your custom scopes. Okta publishes those two scopes in every
237
+ custom authorization server's metadata and can't hide them, and a client that
238
+ finds no scopes in the 401 challenge or the protected resource metadata falls
239
+ back to that list. Okta rejects the combination and grants no scopes.
240
+
241
+ **Fix:** Set `scopesSupported` on the `OAuthProtectedResourcePlugin` to the
242
+ scopes you created, as shown in
243
+ [Configure OAuth on Zuplo](#configure-oauth-on-zuplo), and redeploy. The gateway
244
+ then advertises exactly those scopes in both the 401 challenge and the metadata
245
+ document, so the client never consults the authorization server's list.
246
+ Reconnect the client so it repeats discovery.
@@ -52,3 +52,37 @@ If you would like to remove the authorization header after you use one of the
52
52
  authorization policies, simply add the
53
53
  [Remove Request Headers](/docs/policies/remove-headers-inbound) policy after the
54
54
  authorization policy and set it to remove the `Authorization` header.
55
+
56
+ ## OAuth 2.0 Protected Resource Metadata
57
+
58
+ OAuth clients, including MCP clients, discover how to obtain a token for your
59
+ API through
60
+ [OAuth 2.0 Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728).
61
+ Zuplo implements it in two parts:
62
+
63
+ - The
64
+ [`OAuthProtectedResourcePlugin`](../programmable-api/oauth-protected-resource-plugin.mdx)
65
+ serves the metadata document at `/.well-known/oauth-protected-resource` and
66
+ every path beneath it. The document lists your authorization servers, a
67
+ human-readable resource name, and, when you set `scopesSupported`, the scopes
68
+ clients should request.
69
+ - The `oAuthResourceMetadataEnabled` option on the JWT authentication policies
70
+ makes the policy answer requests without a bearer token with a 401 response
71
+ that carries a `WWW-Authenticate` header. The header's `resource_metadata`
72
+ parameter points at the metadata document for that route, and its `scope`
73
+ parameter repeats `scopesSupported` when the plugin sets it.
74
+
75
+ ```http
76
+ HTTP/1.1 401 Unauthorized
77
+ WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp", scope="mcp:access offline_access"
78
+ ```
79
+
80
+ The
81
+ [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
82
+ requires clients to discover the authorization server this way, and tells them
83
+ to take the scopes they request from the challenge first and from the metadata
84
+ document second. Set `scopesSupported` whenever a policy protects an MCP server,
85
+ so clients request the scopes your resource expects instead of every scope the
86
+ authorization server advertises. See
87
+ [Advertising supported scopes](../programmable-api/oauth-protected-resource-plugin.mdx#advertising-supported-scopes)
88
+ for the full order of precedence and an Okta-specific pitfall.
@@ -412,14 +412,24 @@ enable OAuth authentication on your MCP Server:
412
412
  new OAuthProtectedResourcePlugin({
413
413
  authorizationServers: ["https://your-auth0-domain.us.auth0.com"],
414
414
  resourceName: "My MCP OAuth Resource",
415
+ scopesSupported: ["mcp:access", "offline_access"],
415
416
  }),
416
417
  );
417
418
  }
418
419
  ```
419
420
 
421
+ `scopesSupported` lists the scopes MCP clients should request. The plugin
422
+ emits it as `scopes_supported` in the metadata document, and the OAuth policy
423
+ adds it as the `scope` parameter of its 401 `WWW-Authenticate` challenge.
424
+ Without it, some clients request every scope the authorization server
425
+ advertises. Replace the example values with the scopes your authorization
426
+ server defines for this resource.
427
+
420
428
  See the
421
429
  [OAuth Protected Resource Plugin docs](../programmable-api/oauth-protected-resource-plugin.mdx)
422
- for more details.
430
+ for all options and
431
+ [Advertising supported scopes](../programmable-api/oauth-protected-resource-plugin.mdx#advertising-supported-scopes)
432
+ for how clients pick scopes.
423
433
 
424
434
  ### API Key Auth
425
435
 
@@ -697,6 +707,12 @@ in the MCP Inspector UI to move to the next step.
697
707
  canonical URL of your Authorization server, and registered an OAuth policy to
698
708
  the route of your MCP server.
699
709
 
710
+ If the authorization server later rejects the authorization request with a
711
+ scope error (Okta reports `illegal_scope_combination`), the client built its
712
+ scope list from the authorization server's metadata. Set `scopesSupported` on
713
+ the plugin so the client requests only those scopes. See
714
+ [Advertising supported scopes](../programmable-api/oauth-protected-resource-plugin.mdx#advertising-supported-scopes).
715
+
700
716
  2. **Client Registration**: The MCP Inspector will try to use
701
717
  [Dynamic Client Registration](https://modelcontextprotocol.io/specification/draft/basic/authorization#dynamic-client-registration)
702
718
  to register a new client with the Authorization server. Note that not all MCP
@@ -1,21 +1,14 @@
1
1
  # AI Gateway Smart Router
2
2
 
3
3
  Use this policy to classify the last user prompt on Chat Completions, Responses,
4
- and Anthropic Messages requests. It calls a dedicated AI Gateway application
5
- (`/{classifierAppID}/v1/chat/completions`) and stores the result on
6
- `AIGatewaySmartRouter` for later policies in the same request.
4
+ and Anthropic Messages requests. Using the classification results, configure
5
+ where to route the request based on its complexity.
7
6
 
8
- When `smartRoutingEnabled` is true, it overwrites completions routing from
9
- `modelsByComplexity`. Place it **after** Model Filtering so an invalid client
10
- model is still rejected before classification runs. The classified model then
11
- replaces that selection.
7
+ When `smartRoutingEnabled` is true, it overwrites completions routing based on
8
+ the configuration in `modelsByComplexity`.
12
9
 
13
- Classification is optional. Timeouts, classifier errors, unreadable bodies, and
14
- invalid options fail open: the original request is forwarded.
15
-
16
- > **Loop prevention.** The classifier hop is an `invokeRoute` sub-request. The
17
- > policy no-ops when `context.parentContext` is set, so the classifier app can
18
- > share the same route chain without classifying its own request.
10
+ > **Classification failure handling.** If the message classification fails or
11
+ > times out, the request is forwarded to the original model.
19
12
 
20
13
  ## Required options
21
14
 
@@ -24,11 +17,12 @@ invalid options fail open: the original request is forwarded.
24
17
  - `classifierAppApiKey` — bearer token for that app. Use
25
18
  `$env(CLASSIFIER_APP_API_KEY)`.
26
19
  - `classifierModel` — `providerName/model` sent on the classifier request.
20
+ - `modelsByComplexity` — `providerName/model` for each of `low`, `medium`, and
21
+ `high`. Used for routing when `smartRoutingEnabled` is true.
27
22
 
28
- Omit `intents` and `classifierPrompt` to use the built-in taxonomy (code,
23
+ Omit `intents` and `classifierPrompt` to use the built-in dictionary (code,
29
24
  summarization, translation, qa, conversation, classification, creative_writing,
30
- agentic, document_qa, other) and the built-in system prompt. Include
31
- `{{intents}}` in a custom prompt to inject the configured intent list.
25
+ agentic, document_qa, other) and the built-in classification prompt.
32
26
 
33
27
  ## Example
34
28
 
@@ -65,30 +59,207 @@ filtering already selected a model. Filtering skips when routing is already set,
65
59
  so putting this policy first would also skip allow-list checks on the client's
66
60
  original model.
67
61
 
68
- ## Read the result from custom code
62
+ ## How classification drives routing
63
+
64
+ Complexity is classified independently of intent, as `low`, `medium`, or `high`.
65
+ Smart Router looks up `modelsByComplexity[complexity]` and applies it only when
66
+ **all** of the following hold:
67
+
68
+ - `smartRoutingEnabled` is `true`.
69
+ - The classified intent is a known one (not capped as an unknown intent).
70
+ - `modelsByComplexity` has a model configured for that complexity.
71
+ - `profile.confidence >= minConfidenceForRouting` (default `0.5`).
72
+
73
+ When routing isn't applied, read `smartRouting.reason` from the result (see
74
+ [Using classification results in custom code](#using-classification-results-in-custom-code))
75
+ to see why: `disabled`, `unknown-intent`, `no-model`, `low-confidence`,
76
+ `internal-error`, or `applied`.
77
+
78
+ ### Other advanced options
79
+
80
+ | Option | Default | Purpose |
81
+ | ------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `minConfidenceForRouting` | `0.5` | Raise it to route only on confident classifications; lower it to route more aggressively. Unknown intents are always capped just below this value, so they never qualify regardless of the setting. |
83
+ | `classifierTimeoutMs` | `8000` | How long to wait for the classifier before giving up and forwarding the request unclassified. |
84
+ | `maxPromptChars` | `8000` | Truncates the prompt sent to the classifier, to keep classifier cost and latency bounded on very long prompts. |
85
+
86
+ ## Advanced configuration
87
+
88
+ Use these options to control what the classifier evaluates and how strongly its
89
+ result influences routing.
90
+
91
+ ### Custom intents
92
+
93
+ `intents` replaces the built-in dictionary entirely — it's not additive. Provide
94
+ a non-empty list of `{ id, description }` pairs:
95
+
96
+ ```json
97
+ {
98
+ "intents": [
99
+ {
100
+ "id": "billing",
101
+ "description": "Questions about invoices, payments, or subscription plans."
102
+ },
103
+ {
104
+ "id": "support",
105
+ "description": "Troubleshooting or how-to questions about the product."
106
+ }
107
+ ]
108
+ }
109
+ ```
110
+
111
+ The `id` values become the enum the classifier model must return — the
112
+ classifier calls a strict JSON-schema chat completion, so it can only return one
113
+ of your configured ids. The `description` values are only shown to the
114
+ classifier if your prompt includes `{{intents}}` (see below).
115
+
116
+ If the classifier returns an `id` outside this list, Smart Router keeps it as an
117
+ "unknown intent" and caps its confidence just below `minConfidenceForRouting`,
118
+ so it's never eligible for routing.
119
+
120
+ ### Custom classifier prompt
121
+
122
+ `classifierPrompt` replaces the built-in classifier prompt. It accepts either a
123
+ string or an array of lines:
124
+
125
+ ```json
126
+ {
127
+ "classifierPrompt": [
128
+ "You are an intent classifier for a billing support bot.",
129
+ "Return JSON only that matches the schema.",
130
+ "Intent (pick the single most specific match):",
131
+ "{{intents}}"
132
+ ]
133
+ }
134
+ ```
135
+
136
+ Include the literal placeholder `{{intents}}` anywhere in the prompt to have it
137
+ replaced with a `- id: description` line for each configured intent (or the
138
+ built-in ones, if `intents` is also omitted). Pairing a custom `intents` list
139
+ with the built-in `classifierPrompt` (by omitting `classifierPrompt` entirely)
140
+ works out of the box, because the built-in prompt already contains
141
+ `{{intents}}`.
142
+
143
+ ## Using classification results in custom code
144
+
145
+ `AIGatewaySmartRouter.get(context)` returns the result Smart Router stored on
146
+ the request, or `undefined` if it didn't run (non-AI request, unreadable body,
147
+ or a fail-open error):
69
148
 
70
149
  ```typescript
71
150
  import { AIGatewaySmartRouter } from "@zuplo/runtime";
72
151
 
73
152
  const result = AIGatewaySmartRouter.get(context);
74
- if (result?.profile.intent === "code") {
75
- // ...
153
+ ```
154
+
155
+ `result` has this shape:
156
+
157
+ ```typescript
158
+ interface AIGatewaySmartRouterResult {
159
+ profile: {
160
+ intent: string;
161
+ complexity: "low" | "medium" | "high";
162
+ confidence: number;
163
+ reasons: string[];
164
+ };
165
+ usage: {
166
+ promptTokens: number;
167
+ completionTokens: number;
168
+ totalTokens: number;
169
+ };
170
+ classifierModel: string;
171
+ routing: { model?: string };
172
+ durationMs: number;
173
+ promptSource: "user" | "prior-user";
174
+ promptLength: number;
175
+ promptTruncated: boolean;
176
+ unknownIntent: boolean;
177
+ smartRouting: {
178
+ enabled: boolean;
179
+ applied: boolean;
180
+ reason:
181
+ | "applied"
182
+ | "disabled"
183
+ | "low-confidence"
184
+ | "no-model"
185
+ | "internal-error"
186
+ | "unknown-intent";
187
+ minConfidence: number;
188
+ };
76
189
  }
77
190
  ```
78
191
 
79
- ## How prompt text is chosen
192
+ Read it from any policy placed after Smart Router in the chain. Common uses:
193
+
194
+ - **Branch on intent or complexity** — apply a stricter rate limit, a different
195
+ DLP policy, or a longer timeout for `high` complexity or an `agentic` intent:
196
+
197
+ ```typescript
198
+ const result = AIGatewaySmartRouter.get(context);
199
+ if (result?.profile.complexity === "high") {
200
+ // e.g. apply a stricter rate limit or route to a review queue
201
+ }
202
+ ```
203
+
204
+ - **Layer business logic on top of `modelsByComplexity`** — for example, cap the
205
+ model for free-tier callers regardless of classified complexity. Read the plan
206
+ from the caller's API key metadata, not from a raw request header (the caller
207
+ controls headers and could set or omit them to bypass the cap). The built-in
208
+ API Key Auth policy puts a key's metadata on `request.user.data` — set `plan`
209
+ there when you create the key, and it lands on every request that key makes:
210
+
211
+ ```typescript
212
+ import { AIGatewayModelRouting, AIGatewaySmartRouter } from "@zuplo/runtime";
213
+
214
+ const result = AIGatewaySmartRouter.get(context);
215
+ const isFreeTier = request.user?.data.plan === "free";
216
+ if (result?.profile.complexity === "high" && isFreeTier) {
217
+ await AIGatewayModelRouting.set(context, {
218
+ completions: "openai/gpt-4o-mini",
219
+ });
220
+ }
221
+ ```
222
+
223
+ - **Observe why routing wasn't applied** — log when `smartRouting.reason` is
224
+ `low-confidence` or `unknown-intent` to tune `minConfidenceForRouting` or the
225
+ intent taxonomy:
226
+
227
+ ```typescript
228
+ const result = AIGatewaySmartRouter.get(context);
229
+ if (result && !result.smartRouting.applied) {
230
+ context.log.info(
231
+ { reason: result.smartRouting.reason },
232
+ "Smart routing skipped"
233
+ );
234
+ }
235
+ ```
236
+
237
+ - **Surface classification for debugging** — add response headers in a
238
+ non-production environment to see what the classifier returned. This runs in
239
+ an outbound policy, so return a new `Response` carrying the headers — mutating
240
+ a cloned `Headers` object alone has no effect on what the caller receives:
241
+
242
+ ```typescript
243
+ const result = AIGatewaySmartRouter.get(context);
244
+ if (!result) {
245
+ return response;
246
+ }
247
+
248
+ const headers = new Headers(response.headers);
249
+ headers.set("x-classified-intent", result.profile.intent);
250
+ headers.set("x-classified-complexity", result.profile.complexity);
80
251
 
81
- The policy reads the typed body from the route (`getFormat` / `getRequestBody`),
82
- then takes the last real user text:
252
+ return new Response(response.body, {
253
+ headers,
254
+ status: response.status,
255
+ statusText: response.statusText,
256
+ });
257
+ ```
83
258
 
84
- - `/v1/chat/completions` OpenAI chat `messages[]`, even when the downstream
85
- provider is Anthropic or Google (Zuplo translates below the policy chain).
86
- - `/v1/responses` — OpenAI Responses `input`.
87
- - `/v1/messages` — native Anthropic `messages[]`.
259
+ ## What content is evaluated
88
260
 
89
- Tool follow-up turns (`tool` / `function` roles, `tool_result` blocks,
90
- `function_call_output` items) are skipped so a prior user message is classified
91
- instead. Embeddings and other non-AI paths are skipped.
261
+ The policy reads the last user message in order to classify its intent and
262
+ complexity. Embeddings, tool messages and other non-AI paths are skipped.
92
263
 
93
264
  ## Fail-open behavior
94
265
 
@@ -1,4 +1,3 @@
1
1
  Classifies the last user prompt by calling a dedicated classifier AI Gateway
2
2
  app, stores the result on `AIGatewaySmartRouter` for later policies, and
3
- optionally routes completions by classified complexity. Classifier failures fail
4
- open so the original request still reaches the model.
3
+ optionally routes completions by classified complexity.
@@ -38,7 +38,8 @@
38
38
  "required": [
39
39
  "classifierAppID",
40
40
  "classifierAppApiKey",
41
- "classifierModel"
41
+ "classifierModel",
42
+ "modelsByComplexity"
42
43
  ],
43
44
  "examples": [
44
45
  {
@@ -57,7 +58,7 @@
57
58
  "classifierAppID": {
58
59
  "type": "string",
59
60
  "title": "Classifier App ID",
60
- "description": "The AI Gateway application id whose `/v1/chat/completions` route runs the classifier. The policy calls `/{classifierAppID}/v1/chat/completions` via `context.invokeRoute`.",
61
+ "description": "The AI Gateway application id used to run the classifier prompt and evaluate the user's request.",
61
62
  "examples": ["config_1234"]
62
63
  },
63
64
  "classifierAppApiKey": {
@@ -69,21 +70,22 @@
69
70
  "classifierModel": {
70
71
  "type": "string",
71
72
  "title": "Classifier Model",
72
- "description": "The `providerName/model` reference the classifier chat/completions request should use.",
73
+ "description": "The model (`providerName/model`) the classifier uses to evaluate the user's request. E.g. `openai/gpt-4o-mini`.",
73
74
  "pattern": "^[^/\\s]+/.+$",
74
75
  "examples": ["openai/gpt-4o-mini"]
75
76
  },
76
77
  "smartRoutingEnabled": {
77
78
  "type": "boolean",
78
79
  "title": "Smart Routing Enabled",
79
- "description": "When true, apply model routing from `modelsByComplexity` when confidence is high enough and the intent is known. Classification still runs when false.",
80
+ "description": "When true, apply model routing based on the models set in `modelsByComplexity` when confidence score meets the `minConfidenceForRouting` threshold. When set to false, classification still runs but model routing is not applied, useful for debugging or testing the classifier prompt.",
80
81
  "default": false
81
82
  },
82
83
  "modelsByComplexity": {
83
84
  "type": "object",
84
85
  "title": "Models By Complexity",
85
- "description": "Optional `providerName/model` routing by classified complexity. Used only when `smartRoutingEnabled` is true.",
86
+ "description": "Enables which model will be used based on the classifier results (`low`, `medium` and `high`). This configuration is applied only when `smartRoutingEnabled` is true and confidence score meets the `minConfidenceForRouting` threshold.",
86
87
  "additionalProperties": false,
88
+ "required": ["low", "medium", "high"],
87
89
  "properties": {
88
90
  "low": {
89
91
  "type": "string",
@@ -111,7 +113,7 @@
111
113
  "intents": {
112
114
  "type": "array",
113
115
  "title": "Intents",
114
- "description": "Labels the classifier may assign. Used to build the JSON schema enum and injected into `classifierPrompt` at `{{intents}}`. Omit to use the built-in taxonomy (code, summarization, translation, qa, conversation, classification, creative\\_writing, agentic, document\\_qa, other).",
116
+ "description": "Dictionary of intents used to classify the user message being evaluated. Omit to use the built-in dictionary (code, summarization, translation, qa, conversation, classification, creative\\_writing, agentic, document\\_qa, other).",
115
117
  "minItems": 1,
116
118
  "items": {
117
119
  "type": "object",
@@ -121,14 +123,14 @@
121
123
  "id": {
122
124
  "type": "string",
123
125
  "title": "Intent ID",
124
- "description": "Stable intent identifier written into the classifier JSON schema enum.",
126
+ "description": "Label used to classify the intent of the user message being evaluated.",
125
127
  "minLength": 1,
126
128
  "examples": ["code"]
127
129
  },
128
130
  "description": {
129
131
  "type": "string",
130
132
  "title": "Intent Description",
131
- "description": "Short description shown to the classifier for this intent.",
133
+ "description": "Short description of the intent label.",
132
134
  "examples": [
133
135
  "Write, edit, refactor, debug, or review source code."
134
136
  ]
@@ -138,7 +140,7 @@
138
140
  },
139
141
  "classifierPrompt": {
140
142
  "title": "Classifier Prompt",
141
- "description": "System prompt for the classifier. If it includes `{{intents}}`, that placeholder is replaced with the configured intent id/description list. A string or an array of lines (joined with newlines). Omit to use the built-in classifier prompt.",
143
+ "description": "Prompt used to analyze and classify the user message. Omit to use the built-in classifier prompt.",
142
144
  "oneOf": [
143
145
  {
144
146
  "type": "string",
@@ -165,7 +167,7 @@
165
167
  "classifierTimeoutMs": {
166
168
  "type": "integer",
167
169
  "title": "Classifier Timeout (ms)",
168
- "description": "How long to wait for the classifier `invokeRoute` call before skipping classification and forwarding the original request.",
170
+ "description": "Timeout configured for the classifier task. When exceeded, the request is forwarded without classification to the original model.",
169
171
  "minimum": 1,
170
172
  "maximum": 120000,
171
173
  "default": 8000,
@@ -174,7 +176,7 @@
174
176
  "maxPromptChars": {
175
177
  "type": "integer",
176
178
  "title": "Max Prompt Characters",
177
- "description": "Maximum characters of user prompt sent to the classifier. Longer prompts are truncated.",
179
+ "description": "Maximum characters of user message sent to the classifier. Longer messages get truncated.",
178
180
  "minimum": 1,
179
181
  "default": 8000,
180
182
  "x-advanced": true
@@ -13,13 +13,16 @@ more details.
13
13
 
14
14
  ## Usage
15
15
 
16
- This runtime plugin will register the `.well-known/oauth-protected-resource`
17
- route on your behalf. If you configure an
16
+ This runtime plugin registers the `.well-known/oauth-protected-resource` route
17
+ on your behalf, along with every path beneath it, such as
18
+ `/.well-known/oauth-protected-resource/mcp`. If you configure an
18
19
  [OAuth Policy](../articles/oauth-authentication.mdx) on a route with the
19
- `oAuthResourceMetadataEnabled` option set to `true`, then the OAuth policy will
20
- automatically add the necessary `WWW-Authenticate` header to 401 responses, with
21
- the `resource_metadata` parameter set to the URL of the
22
- `.well-known/oauth-protected-resource` endpoint.
20
+ `oAuthResourceMetadataEnabled` option set to `true`, the policy answers requests
21
+ that carry no bearer token with a 401 response and a `WWW-Authenticate` header.
22
+ The header's `resource_metadata` parameter is the URL of the
23
+ `.well-known/oauth-protected-resource` endpoint for that route. When you set
24
+ `scopesSupported`, the header also carries a `scope` parameter that lists those
25
+ scopes.
23
26
 
24
27
  ```ts
25
28
  import {
@@ -32,6 +35,7 @@ export function runtimeInit(runtime: RuntimeExtensions) {
32
35
  new OAuthProtectedResourcePlugin({
33
36
  authorizationServers: ["https://your-auth0-domain.us.auth0.com"],
34
37
  resourceName: "My MCP OAuth Resource",
38
+ scopesSupported: ["mcp:access", "offline_access"],
35
39
  }),
36
40
  );
37
41
  }
@@ -44,3 +48,71 @@ a human readable name for the resource.
44
48
  Note that the `.well-known/oauth-protected-resource` endpoint explicitly has a
45
49
  CORS policy of `anything-goes` since this is a public endpoint that should be
46
50
  accessible to anyone to check the server's OAuth configuration.
51
+
52
+ ## Options
53
+
54
+ Construct the plugin inside `runtimeInit`. The plugin validates its options when
55
+ you construct it, so an invalid value fails at startup with a
56
+ `ConfigurationError` instead of failing a client's first login.
57
+
58
+ | Option | Type | Description |
59
+ | ---------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
60
+ | `authorizationServers` | `string[]` | Canonical issuer URLs of the authorization servers that issue tokens for this resource. Each should comply with [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414). Emitted as `authorization_servers`. |
61
+ | `resourceName` | `string` | Human-readable name of the resource, intended for display to end users. RFC 9728 recommends setting it. Emitted as `resource_name`. |
62
+ | `scopesSupported` | `string[]` | Scopes that clients should request when they obtain an access token for this resource. Emitted as `scopes_supported`, and as the `scope` parameter of the 401 `WWW-Authenticate` header by OAuth policies that have `oAuthResourceMetadataEnabled` set. Each entry must be one OAuth scope token ([RFC 6749 section 3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3)): printable ASCII with no spaces, double quotes, or backslashes. Use one entry per scope, never a space-delimited string. An empty array is rejected; omit the option to advertise no scopes. |
63
+
64
+ ## Advertising supported scopes
65
+
66
+ An MCP client has to decide which scopes to request from the authorization
67
+ server. The
68
+ [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy)
69
+ tells clients to consider these sources in order:
70
+
71
+ 1. **The `scope` parameter of the `WWW-Authenticate` challenge** on the 401
72
+ response. Clients treat these scopes as authoritative.
73
+ 2. **The `scopes_supported` array in the protected resource metadata** document,
74
+ when the challenge has no `scope` parameter.
75
+ 3. **The `scopes_supported` array in the authorization server's own metadata.**
76
+ The specification doesn't list this source, but some clients fall back to it
77
+ when the first two are empty. It usually contains every scope the server
78
+ knows about.
79
+ 4. **No `scope` parameter at all.**
80
+
81
+ Setting `scopesSupported` fills the first two sources with the same list, so no
82
+ client reaches the third. For example, a plugin configured with an Okta
83
+ authorization server, `resourceName: "Acme MCP"`, and
84
+ `scopesSupported: ["mcp:access", "offline_access"]` serves this document at
85
+ `/.well-known/oauth-protected-resource/mcp`:
86
+
87
+ ```json
88
+ {
89
+ "resource": "https://api.example.com/mcp",
90
+ "authorization_servers": ["https://acme.okta.com/oauth2/aus1a2b3c"],
91
+ "resource_name": "Acme MCP",
92
+ "scopes_supported": ["mcp:access", "offline_access"]
93
+ }
94
+ ```
95
+
96
+ A request to `/mcp` without a bearer token receives:
97
+
98
+ ```http
99
+ HTTP/1.1 401 Unauthorized
100
+ WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp", scope="mcp:access offline_access"
101
+ ```
102
+
103
+ If you don't set `scopesSupported`, the document has no `scopes_supported` key
104
+ and the header has no `scope` parameter. Requests that carry an invalid or
105
+ expired token receive a 401 without the `WWW-Authenticate` header in either
106
+ case.
107
+
108
+ :::caution{title="Okta rejects its own default scopes"}
109
+
110
+ Okta custom authorization servers always publish `device_sso` and
111
+ `interclient_access` in their `scopes_supported` metadata. The **Include in
112
+ public metadata** toggle for those two scopes is disabled, so you can't remove
113
+ them. A client that falls back to that list requests them together with your
114
+ scopes, and Okta rejects the authorization request with
115
+ `illegal_scope_combination` and grants no scopes. Set `scopesSupported` whenever
116
+ Okta is your authorization server.
117
+
118
+ :::
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zuplo",
3
- "version": "7.6.7",
3
+ "version": "7.6.9",
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.6.7",
36
- "@zuplo/core": "7.6.7",
37
- "@zuplo/runtime": "7.6.7",
38
- "@zuplo/test": "7.6.7"
35
+ "@zuplo/cli": "7.6.9",
36
+ "@zuplo/core": "7.6.9",
37
+ "@zuplo/runtime": "7.6.9",
38
+ "@zuplo/test": "7.6.9"
39
39
  }
40
40
  }