zuplo 7.8.9 → 7.8.10

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.
@@ -363,6 +363,120 @@ long-context and prompt-caching betas and drops every other value, because Azure
363
363
  rejects an unrecognized beta by failing the whole request. A client asking for a
364
364
  different beta still gets an answer, without that feature.
365
365
 
366
+ ## Call models with the Foundry SDKs
367
+
368
+ The [OpenAI](#call-openai-compatible-models) and
369
+ [Anthropic](#call-claude-models-on-the-messages-api) clients above are the
370
+ primary path. If you already hold one of Microsoft's own Foundry clients, two of
371
+ them reach the gateway unchanged apart from the base URL and credential. For the
372
+ Vercel AI SDK's Azure provider, see the
373
+ [Azure AI section](./integrations/ai-sdk.mdx#azure-ai) of the AI SDK page.
374
+
375
+ ### Azure AI Inference (@azure-rest/ai-inference)
376
+
377
+ Construct `ModelClient` with your app's URL plus `/v1` and an
378
+ `AzureKeyCredential` holding the app's API key, then call the operation path.
379
+ The client sends the key on `Authorization: Bearer`—the header the gateway
380
+ reads—as well as on `api-key`, which it ignores, so the client authenticates
381
+ as-is:
382
+
383
+ ```ts
384
+ import ModelClient, { isUnexpected } from "@azure-rest/ai-inference";
385
+ import { AzureKeyCredential } from "@azure/core-auth";
386
+
387
+ const appKey = process.env.ZUPLO_APP_API_KEY;
388
+ if (!appKey) {
389
+ throw new Error("Set ZUPLO_APP_API_KEY to the app's API key");
390
+ }
391
+
392
+ const client = ModelClient(
393
+ "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1",
394
+ new AzureKeyCredential(appKey),
395
+ );
396
+
397
+ const response = await client.path("/chat/completions").post({
398
+ body: {
399
+ model: "azureai/my-gpt",
400
+ messages: [{ role: "user", content: "Say hi" }],
401
+ max_tokens: 16,
402
+ },
403
+ });
404
+
405
+ if (isUnexpected(response)) {
406
+ throw new Error(JSON.stringify(response.body));
407
+ }
408
+ ```
409
+
410
+ Embeddings work the same way through `client.path("/embeddings")`. To stream,
411
+ add `stream: true`, read the response with `asNodeStream()`, and parse the body
412
+ with `createSseStream` from `@azure/core-sse`:
413
+
414
+ ```ts
415
+ import { createSseStream } from "@azure/core-sse";
416
+
417
+ const stream = await client
418
+ .path("/chat/completions")
419
+ .post({
420
+ body: {
421
+ model: "azureai/my-gpt",
422
+ messages: [{ role: "user", content: "Say hi" }],
423
+ max_tokens: 16,
424
+ stream: true,
425
+ },
426
+ })
427
+ .asNodeStream();
428
+
429
+ if (!stream.body) {
430
+ throw new Error("The streaming response carried no body");
431
+ }
432
+
433
+ for await (const event of createSseStream(stream.body)) {
434
+ if (event.data === "[DONE]") break;
435
+ // Each event.data is a chat.completion.chunk.
436
+ }
437
+ ```
438
+
439
+ The client appends an `?api-version=...` query string, which the gateway
440
+ ignores.
441
+
442
+ ### Foundry projects (@azure/ai-projects)
443
+
444
+ `AIProjectClient` takes your app's URL as the `endpoint` and a
445
+ `TokenCredential`. The gateway is the credential boundary, so the credential
446
+ yields the app's API key—no Microsoft Entra ID token is involved. Call
447
+ `getOpenAIClient({ baseURL })` with your app's URL plus `/v1` to get an `openai`
448
+ client for chat, Responses, and embeddings:
449
+
450
+ ```ts
451
+ import { AIProjectClient } from "@azure/ai-projects";
452
+
453
+ const appKey = process.env.ZUPLO_APP_API_KEY;
454
+ if (!appKey) {
455
+ throw new Error("Set ZUPLO_APP_API_KEY to the app's API key");
456
+ }
457
+
458
+ const appUrl =
459
+ "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e";
460
+
461
+ const project = new AIProjectClient(appUrl, {
462
+ getToken: async () => ({
463
+ token: appKey,
464
+ expiresOnTimestamp: Date.now() + 60 * 60 * 1000,
465
+ }),
466
+ });
467
+
468
+ const client = project.getOpenAIClient({ baseURL: `${appUrl}/v1` });
469
+
470
+ const completion = await client.chat.completions.create({
471
+ model: "azureai/my-gpt",
472
+ messages: [{ role: "user", content: "Say hi" }],
473
+ });
474
+ ```
475
+
476
+ The `baseURL` is required. Without it the client defaults to
477
+ `{endpoint}/openai/v1`, and the gateway needs `v1` immediately after the app
478
+ ID—so the default base returns a `404`.
479
+
366
480
  ## What the model field reports in responses
367
481
 
368
482
  The `model` field in a response doesn't always say what you might expect, and
@@ -452,6 +566,24 @@ catalog, but a model only works once you deploy it in your resource, and model
452
566
  availability varies by region and resource kind. Deploy the model, then map your
453
567
  deployment name to it.
454
568
 
569
+ **The `AzureOpenAI` class returns a `404`.** The `AzureOpenAI` class from the
570
+ `openai` package addresses Azure's own paths: model-scoped operations such as
571
+ chat completions and embeddings go to `/openai/deployments/{model}/…`, and the
572
+ rest, such as Responses, go to `/openai/…`. The gateway serves neither, and the
573
+ class sends the key as `api-key`, a header the gateway doesn't read. Use the
574
+ plain `OpenAI` class with your app's URL plus `/v1` instead, as in
575
+ [Call OpenAI-compatible models](#call-openai-compatible-models). To keep using
576
+ the `AzureOpenAI` class as-is, install the
577
+ [SDK path shim](./cookbooks/sdk-path-shim.mdx)—a custom policy you copy into
578
+ your gateway that rewrites its deployment path and moves the `api-key` header
579
+ for you.
580
+
581
+ **An `@azure/ai-projects` request fails with a `404` naming `/openai/v1`.**
582
+ `AIProjectClient.getOpenAIClient()` defaults its base URL to
583
+ `{endpoint}/openai/v1`, but the gateway needs `v1` immediately after the app ID.
584
+ Pass `baseURL` set to your app's URL plus `/v1`—see
585
+ [Call models with the Foundry SDKs](#call-models-with-the-foundry-sdks).
586
+
455
587
  ## Next steps
456
588
 
457
589
  - [AI Providers](./providers.mdx)—the capability matrix across every supported
@@ -0,0 +1,457 @@
1
+ ---
2
+ title: "Cookbook: Point provider-native SDKs at the gateway"
3
+ sidebar_label: SDK path shim
4
+ description:
5
+ Install a custom inbound policy that rewrites four provider-native SDK URL
6
+ grammars onto the gateway's own endpoints, so a client that hard-codes an
7
+ Azure or Vertex path reaches the AI Gateway by changing only its endpoint and
8
+ credential.
9
+ ---
10
+
11
+ The AI Gateway serves every provider through the
12
+ [Universal API](../universal-api.mdx): OpenAI-shaped requests on
13
+ `/v1/chat/completions`, Anthropic-shaped ones on `/v1/messages`, and so on. Four
14
+ provider-native SDKs still refuse to talk to it—the `AzureOpenAI` class,
15
+ `@ai-sdk/azure` with `useDeploymentBasedUrls`,
16
+ `@ai-sdk/google-vertex/anthropic`, and `@anthropic-ai/vertex-sdk`—but not
17
+ because the gateway can't handle their requests. It already speaks each client's
18
+ body dialect. They fail only because each one hard-codes a provider-native URL
19
+ grammar—a deployment in the path, a `{model}:rawPredict` suffix—that the gateway
20
+ doesn't serve. A URL rewrite is all they need.
21
+
22
+ This recipe is a custom [inbound policy](../custom-policies.mdx) that does that
23
+ rewrite. Copy it into your own gateway and you own it: it's example code, not a
24
+ runtime feature, and these clients aren't among the supported clients documented
25
+ on the [Azure AI](../azure-ai.mdx) or [Vertex AI](../vertex-ai.mdx) pages. Model
26
+ references don't change—they stay the gateway's usual `providerName/deployment`
27
+ for Azure (`azureai/my-gpt`) and `providerName/publisher/model` for Vertex
28
+ (`vertexai/anthropic/claude-haiku-4-5@20251001`).
29
+
30
+ ## The policy
31
+
32
+ Copy this module into your gateway as `modules/sdk-path-shim-inbound.ts`. It
33
+ rewrites the request path for the grammars it recognizes and leaves everything
34
+ else untouched; the header comment lists every path it maps, and
35
+ `tests/sdk-shim.test.ts` in zuplo/core is its executable half.
36
+
37
+ {/* prettier-ignore */}
38
+ ```ts title="modules/sdk-path-shim-inbound.ts"
39
+ import type { ZuploContext } from "@zuplo/runtime";
40
+ import { ZuploRequest } from "@zuplo/runtime";
41
+
42
+ /**
43
+ * SDK path shim: rewrites the URL grammars of four vendor SDKs onto the AI
44
+ * Gateway's own endpoints, so a client that hard-codes a provider-native path
45
+ * can be pointed at the gateway by changing its endpoint and credential only.
46
+ *
47
+ * Copy this module into your gateway, register it in `policies.json` as a
48
+ * `custom-code-inbound` policy, and list it FIRST in the catch-all route's
49
+ * inbound policies — before `ai-gateway-configuration-loader-v2-inbound`. The
50
+ * loader answers 404 to any path whose segment after the app id is not `v1`
51
+ * before it loads the app, so a shim placed in an app's stored chain would
52
+ * never run for these paths. (This fixture mounts it on a sibling route,
53
+ * `/sdk-shim/:app_id/(.*)`, only because `tests/template-parity.test.ts` pins
54
+ * `ai.oas.json` to the scaffolder template byte for byte.)
55
+ *
56
+ * | Client | Path it sends | Rewritten to |
57
+ * | ----------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------ |
58
+ * | `AzureOpenAI` (from `openai`) | `/openai/deployments/{d}/chat/completions?api-version=…` | `/v1/chat/completions` |
59
+ * | same | `/openai/deployments/{d}/embeddings` | `/v1/embeddings` |
60
+ * | same | `/openai/responses[/{id}[/input_items]]` | `/v1/responses[/…]` |
61
+ * | `@ai-sdk/azure`, `useDeploymentBasedUrls` | `/v1/deployments/{d}/{chat/completions,embeddings,responses}` | `/v1/{operation}` |
62
+ * | `@ai-sdk/google-vertex/anthropic` | `/{model}:rawPredict` or `:streamRawPredict` | `/v1/messages`, `model` restored to the body |
63
+ * | `@anthropic-ai/vertex-sdk` | `/projects/…/publishers/anthropic/models/{model}:rawPredict` | `/v1/messages`, `model` restored to the body |
64
+ * | same | `…/publishers/anthropic/models/count-tokens:rawPredict` | `/v1/messages/count_tokens` |
65
+ *
66
+ * The deployment `{d}` is a gateway model reference, so it may contain one
67
+ * slash (`azureai/my-gpt`) and never more; a Vertex `{model}` has two
68
+ * (`vertexai/anthropic/claude-…`). The rules are bounded to those shapes, so a
69
+ * path with extra segments in front of an operation is not recognized and
70
+ * keeps its 404. The deployment is dropped from the path because the body
71
+ * already carries `model`. The Vertex clients strip `model` from the body and
72
+ * put it in the URL, so the shim moves it back; `anthropic_version`, which
73
+ * they add, is accepted by the gateway.
74
+ *
75
+ * Credential: an `api-key` header — what the Azure clients send — becomes
76
+ * `Authorization: Bearer` when no Authorization header is present, which is
77
+ * the header `ai-gateway-auth-v2-inbound` reads. Query strings such as
78
+ * `api-version` are left alone; the gateway ignores them.
79
+ *
80
+ * Anything the shim does not recognize passes through with the path
81
+ * unchanged, so canonical `/v1/...` and Bedrock `/model/...` requests keep
82
+ * working on the same route, and unsupported operations still get the
83
+ * gateway's own 404.
84
+ *
85
+ * This module is printed VERBATIM in the docs cookbook
86
+ * `docs/ai-gateway/cookbooks/sdk-path-shim.mdx` (zuplo/docs). It is example
87
+ * code a customer copies into their own gateway, not a runtime feature, and
88
+ * `tests/sdk-shim.test.ts` is what keeps it working: change the two together.
89
+ */
90
+
91
+ /**
92
+ * Azure's two deployment-in-the-path grammars; the operation is captured.
93
+ *
94
+ * The deployment is bounded to one segment or `<assignment>/<name>` on
95
+ * purpose: an unbounded `.+` would let
96
+ * `/openai/deployments/d/audio/speech/chat/completions` masquerade as a chat
97
+ * completion instead of keeping the 404 an unsupported operation deserves.
98
+ */
99
+ const AZURE_DEPLOYMENT_PATHS = [
100
+ /^\/openai\/deployments\/[^/:]+(?:\/[^/:]+)?\/(chat\/completions|embeddings)$/,
101
+ /^\/v1\/deployments\/[^/:]+(?:\/[^/:]+)?\/(chat\/completions|embeddings|responses)$/,
102
+ ];
103
+
104
+ /** `AzureOpenAI` endpoints Azure serves without a deployment segment. */
105
+ const AZURE_BARE_PATH = /^\/openai\/(responses(?:\/.*)?)$/;
106
+
107
+ /**
108
+ * Vertex's Anthropic publisher surface, with or without the
109
+ * `projects/{p}/locations/{l}/publishers/anthropic/models` prefix, and with an
110
+ * optional `/v1` in front for a client whose `baseURL` already ends in `/v1`.
111
+ * The model is one to three segments (`<assignment>/<publisher>/<model>`),
112
+ * bounded for the same reason as the Azure deployment above.
113
+ */
114
+ const VERTEX_ANTHROPIC_PATH =
115
+ /^(?:\/v1)?(?:\/projects\/[^/]+\/locations\/[^/]+\/publishers\/anthropic\/models)?\/([^/:]+(?:\/[^/:]+){0,2}):(rawPredict|streamRawPredict)$/;
116
+
117
+ /**
118
+ * Client headers that describe the request body's bytes: its length, its
119
+ * encoding, and any checksum. Dropped when the shim re-serializes the body,
120
+ * because they would then describe bytes that no longer exist. The list matches
121
+ * what the gateway itself strips before an upstream call
122
+ * (`llm-translation/utils/forwarded-headers.ts`).
123
+ */
124
+ const BODY_DESCRIBING_HEADERS = [
125
+ "content-length",
126
+ "content-encoding",
127
+ "content-md5",
128
+ "content-digest",
129
+ "digest",
130
+ "repr-digest",
131
+ ];
132
+
133
+ interface Rewrite {
134
+ path: string;
135
+ /** Set when the model was addressed in the URL and must go back in the body. */
136
+ modelFromPath?: string;
137
+ }
138
+
139
+ /**
140
+ * Map the part of the path after the app id onto a gateway operation, or
141
+ * return null when it is not one of the shimmed grammars.
142
+ */
143
+ export function rewriteOperationPath(rest: string): Rewrite | null {
144
+ for (const grammar of AZURE_DEPLOYMENT_PATHS) {
145
+ const match = grammar.exec(rest);
146
+ if (match) {
147
+ return { path: `/v1/${match[1]}` };
148
+ }
149
+ }
150
+
151
+ const bare = AZURE_BARE_PATH.exec(rest);
152
+ if (bare) {
153
+ return { path: `/v1/${bare[1]}` };
154
+ }
155
+
156
+ const vertex = VERTEX_ANTHROPIC_PATH.exec(rest);
157
+ if (vertex) {
158
+ let model: string;
159
+ try {
160
+ model = decodeURIComponent(vertex[1]);
161
+ } catch {
162
+ // A malformed percent escape is not a model reference. Leave the path
163
+ // alone so the gateway answers its own 404 rather than a 500 from here.
164
+ return null;
165
+ }
166
+ if (model === "count-tokens") {
167
+ return { path: "/v1/messages/count_tokens" };
168
+ }
169
+ return { path: "/v1/messages", modelFromPath: model };
170
+ }
171
+
172
+ return null;
173
+ }
174
+
175
+ export default async function sdkPathShim(
176
+ request: ZuploRequest,
177
+ context: ZuploContext
178
+ ): Promise<ZuploRequest> {
179
+ const appId = request.params.app_id;
180
+ if (typeof appId !== "string" || appId === "") {
181
+ return request;
182
+ }
183
+
184
+ const url = new URL(request.url);
185
+ const segments = url.pathname.split("/");
186
+ const appIndex = segments.indexOf(appId);
187
+ if (appIndex === -1) {
188
+ return request;
189
+ }
190
+ const rest = `/${segments.slice(appIndex + 1).join("/")}`;
191
+ const rewrite = rewriteOperationPath(rest);
192
+
193
+ // The loader requires the app id as the FIRST segment, so the rewritten path
194
+ // is always `/{app_id}/…` whatever prefix this route was mounted on.
195
+ const path = `/${appId}${rewrite?.path ?? rest}`;
196
+
197
+ const headers = new Headers(request.headers);
198
+ const apiKey = headers.get("api-key");
199
+ const movesKey = apiKey !== null && !headers.has("authorization");
200
+ if (movesKey) {
201
+ headers.set("authorization", `Bearer ${apiKey}`);
202
+ headers.delete("api-key");
203
+ }
204
+
205
+ // Nothing to do for a canonical request on the main route: hand it on
206
+ // untouched, body unread, so the shim costs it nothing.
207
+ if (path === url.pathname && !movesKey) {
208
+ return request;
209
+ }
210
+
211
+ // Only a Vertex rewrite needs the body — `model` was moved into the URL and
212
+ // has to go back. Every other request keeps the body stream it arrived
213
+ // with; nothing is buffered.
214
+ let body: BodyInit | null = request.body;
215
+ if (
216
+ rewrite?.modelFromPath !== undefined &&
217
+ request.method !== "GET" &&
218
+ request.method !== "HEAD"
219
+ ) {
220
+ // Bytes rather than text, so a body the shim ends up not changing (one
221
+ // that already names its model, or is not JSON) goes on exactly as it
222
+ // arrived, and the client's headers keep describing it.
223
+ const bytes = await request.arrayBuffer();
224
+ body = bytes;
225
+ let parsed: unknown;
226
+ try {
227
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
228
+ } catch {
229
+ parsed = undefined;
230
+ }
231
+ if (typeof parsed === "object" && parsed !== null) {
232
+ const record = parsed as Record<string, unknown>;
233
+ if (record.model === undefined) {
234
+ record.model = rewrite.modelFromPath;
235
+ body = JSON.stringify(record);
236
+ // These are new bytes, so drop every header that described the old
237
+ // ones — not just the length.
238
+ for (const name of BODY_DESCRIBING_HEADERS) {
239
+ headers.delete(name);
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ if (path !== url.pathname) {
246
+ context.log.debug(
247
+ { from: url.pathname, to: path, policy: "sdk-path-shim-inbound" },
248
+ "SDK path shim rewrote the request path"
249
+ );
250
+ }
251
+ url.pathname = path;
252
+
253
+ return new ZuploRequest(url.toString(), {
254
+ method: request.method,
255
+ headers,
256
+ body,
257
+ params: request.params,
258
+ });
259
+ }
260
+ ```
261
+
262
+ ## Install it in your gateway
263
+
264
+ The shim has to run **before** the gateway's configuration loader, so it goes on
265
+ the catch-all route, not in an app's policy chain. The loader answers a `404` to
266
+ any path whose segment after the app id isn't `v1`—before it loads the app—so a
267
+ policy in an app's stored chain never sees a `/openai/deployments/...` or
268
+ `:rawPredict` request. Mounting the shim first on the route is what gives it the
269
+ chance to rewrite the path into a `/v1/...` shape the loader accepts.
270
+
271
+ Three steps, all in your gateway project:
272
+
273
+ <Stepper>
274
+
275
+ 1. **Copy the module**
276
+
277
+ Save the policy above as `modules/sdk-path-shim-inbound.ts`.
278
+
279
+ 2. **Register the policy**
280
+
281
+ Add this entry to the `policies` array in `config/policies.json`:
282
+
283
+ ```json title="config/policies.json (one entry in the policies array)"
284
+ {
285
+ "name": "sdk-path-shim-inbound",
286
+ "policyType": "custom-code-inbound",
287
+ "handler": {
288
+ "export": "default",
289
+ "module": "$import(./modules/sdk-path-shim-inbound)"
290
+ }
291
+ }
292
+ ```
293
+
294
+ 3. **Put it first on the catch-all route**
295
+
296
+ In `config/ai.oas.json`, list `sdk-path-shim-inbound` **first** in the
297
+ inbound policies of the AI Gateway catch-all route `/:app_id/(.*)`, ahead of
298
+ `ai-gateway-configuration-loader-v2-inbound`:
299
+
300
+ ```json title="config/ai.oas.json (the catch-all route's policies)"
301
+ "policies": {
302
+ "inbound": [
303
+ "sdk-path-shim-inbound",
304
+ "ai-gateway-configuration-loader-v2-inbound",
305
+ "ai-gateway-configuration-executor-v2-inbound"
306
+ ]
307
+ }
308
+ ```
309
+
310
+ </Stepper>
311
+
312
+ The order isn't optional: the shim must run before the loader, for the reason
313
+ above.
314
+
315
+ ## What each client needs
316
+
317
+ With the shim mounted, each client reaches the gateway by changing only its
318
+ endpoint and credential. What each one needs on its side:
319
+
320
+ ### The `AzureOpenAI` class (`openai` package)
321
+
322
+ Construct it with your app's URL as `endpoint` and the app key as `apiKey`—
323
+ nothing else changes:
324
+
325
+ ```ts
326
+ import { AzureOpenAI } from "openai";
327
+
328
+ const client = new AzureOpenAI({
329
+ endpoint:
330
+ "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e",
331
+ apiVersion: "2024-10-21",
332
+ apiKey: process.env.ZUPLO_APP_API_KEY,
333
+ });
334
+ ```
335
+
336
+ Chat completions, embeddings, and Responses (the client's `/openai/responses`
337
+ path) all work. The shim strips the `/openai/deployments/{deployment}` and bare
338
+ `/openai` prefixes, and moves the `api-key` header the class sends onto
339
+ `Authorization: Bearer`.
340
+
341
+ ### `@ai-sdk/azure` with `useDeploymentBasedUrls`
342
+
343
+ The [AI SDK Azure provider](../integrations/ai-sdk.mdx#azure-ai) reaches the
344
+ gateway without the shim as long as `useDeploymentBasedUrls` is off. Turn it on
345
+ and the provider moves the deployment into the path; the shim rewrites it back:
346
+
347
+ ```ts
348
+ import { createAzure } from "@ai-sdk/azure";
349
+
350
+ const azure = createAzure({
351
+ baseURL:
352
+ "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1",
353
+ apiKey: process.env.ZUPLO_APP_API_KEY,
354
+ useDeploymentBasedUrls: true,
355
+ });
356
+ ```
357
+
358
+ Here `apiKey` works—the shim maps the `api-key` header the provider sends onto
359
+ `Authorization: Bearer`—and `tokenProvider` works too. `azure.chat(id)`, the
360
+ bare `azure(id)` (Responses), and `azure.textEmbeddingModel(id)` all reach the
361
+ gateway.
362
+
363
+ ### `@ai-sdk/google-vertex/anthropic`
364
+
365
+ Point the provider's `baseURL` at your app and return the app key from
366
+ `generateAuthToken`:
367
+
368
+ ```ts
369
+ import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic";
370
+
371
+ const appKey = process.env.ZUPLO_APP_API_KEY;
372
+ if (!appKey) {
373
+ throw new Error("Set ZUPLO_APP_API_KEY to the app's API key");
374
+ }
375
+
376
+ const vertexAnthropic = createVertexAnthropic({
377
+ baseURL:
378
+ "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e",
379
+ generateAuthToken: async () => appKey,
380
+ });
381
+ ```
382
+
383
+ `generateAuthToken` is a public option, and it matters: without it the provider
384
+ mints a Google access token, which is the wrong credential for the gateway. Its
385
+ return type is `Promise<string | null>`, so read the key into a checked variable
386
+ first rather than returning the environment lookup directly. The shim maps the
387
+ provider's `/{model}:rawPredict` and `:streamRawPredict` paths onto
388
+ `/v1/messages` and puts `model` back in the body. The `anthropic_version` field
389
+ the provider adds is accepted by the gateway.
390
+
391
+ ### `@anthropic-ai/vertex-sdk` (`AnthropicVertex`)
392
+
393
+ This client resolves Google credentials before every call and has no option to
394
+ skip that, so you override the credential rather than replace it. On a machine
395
+ with Application Default Credentials, let it mint a Google token and override
396
+ the header with the app key:
397
+
398
+ ```ts
399
+ import { AnthropicVertex } from "@anthropic-ai/vertex-sdk";
400
+
401
+ const client = new AnthropicVertex({
402
+ region: "global",
403
+ projectId: "your-project",
404
+ baseURL:
405
+ "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e",
406
+ defaultHeaders: {
407
+ Authorization: `Bearer ${process.env.ZUPLO_APP_API_KEY}`,
408
+ },
409
+ });
410
+ ```
411
+
412
+ Without ADC on the machine, pass an `authClient` whose `getRequestHeaders()`
413
+ returns `new Headers({ authorization: "Bearer <app key>" })` instead—a
414
+ structural stand-in for a google-auth-library client, which is what the fixture
415
+ test uses. Either way the shim sees a bearer app key.
416
+
417
+ `region` and `projectId` are required by the SDK but only end up in the URL,
418
+ which the shim discards, so any values do. Messages, `countTokens` (the client's
419
+ `count-tokens:rawPredict` path, mapped to `/v1/messages/count_tokens`), and
420
+ streaming all work.
421
+
422
+ ## What keeps working
423
+
424
+ The shim is safe to mount on the main route because it rewrites only the
425
+ grammars it recognizes:
426
+
427
+ - **Unrecognized paths are never rewritten.** A canonical `/v1/...` request, a
428
+ Bedrock `/model/...` request, and any operation the gateway doesn't serve keep
429
+ their path—so your existing clients keep working, and an unsupported operation
430
+ still gets the gateway's own `404` rather than a rewrite to something else.
431
+ The credential move below is the one thing that applies regardless of path: a
432
+ request carrying only `api-key` reaches the loader with that key moved to
433
+ `Authorization: Bearer`, which is where the gateway reads it anyway.
434
+ - **It never replaces an existing `Authorization` header.** The
435
+ `api-key`→`Authorization: Bearer` move happens only when no `Authorization`
436
+ header is present. Path and body rewrites still apply to such a request.
437
+ - **Query strings are left alone.** Parameters such as `api-version` pass
438
+ through untouched; the gateway ignores them.
439
+
440
+ ## What it doesn't cover
441
+
442
+ Two Gemini-native clients are out of reach: `@ai-sdk/google-vertex`'s Gemini
443
+ entry and `@google/genai`. They speak Gemini's native request _and response_
444
+ format, which no URL rewrite can bridge—the gateway would have to translate the
445
+ bodies, not just the path. Use an OpenAI client, or `@ai-sdk/openai-compatible`,
446
+ for Gemini and Model Garden models, as the
447
+ [Vertex AI page](../vertex-ai.mdx#call-the-models) explains.
448
+
449
+ ## Next steps
450
+
451
+ - [Custom Policies](../custom-policies.mdx): the full custom-policy quickstart.
452
+ - [Using Azure AI](../azure-ai.mdx): the supported Azure clients and how the
453
+ gateway prices deployments.
454
+ - [Using Vertex AI](../vertex-ai.mdx): Claude on Vertex, and why the Gemini SDKs
455
+ need an OpenAI client.
456
+ - [AI SDK](../integrations/ai-sdk.mdx): the Vercel AI SDK providers the gateway
457
+ supports directly.
@@ -53,7 +53,8 @@ configure.
53
53
  Each provider package appends its own operation path to `baseURL`, so pick the
54
54
  model factory that targets an endpoint the gateway serves:
55
55
  `/v1/chat/completions` for OpenAI-compatible requests, `/v1/messages` for
56
- Anthropic, `/v1/embeddings`, and `/v1/responses` for OpenAI only. The examples
56
+ Anthropic, `/v1/embeddings` for every provider with embedding models, and
57
+ `/v1/responses` for OpenAI and Azure OpenAI-compatible deployments. The examples
57
58
  below use the right factory for each provider.
58
59
 
59
60
  ### OpenAI
@@ -161,6 +162,131 @@ const { text } = await generateText({
161
162
  });
162
163
  ```
163
164
 
165
+ ### Azure AI
166
+
167
+ Use `@ai-sdk/azure`. Set `baseURL` to your app's URL plus `/v1`, and pass the
168
+ app's API key through `tokenProvider`—not `apiKey`. The provider sends `apiKey`
169
+ as the `api-key` header, which the gateway doesn't read, so the request fails
170
+ with a `401`; `tokenProvider` puts the same key on `Authorization: Bearer`,
171
+ which the gateway does read. Leave `useDeploymentBasedUrls` off—it moves the
172
+ deployment into the path as `/deployments/{id}/chat/completions`, which isn't a
173
+ gateway endpoint and returns a `404`. To keep `useDeploymentBasedUrls: true`—or
174
+ to authenticate with `apiKey` rather than `tokenProvider`—install the
175
+ [SDK path shim](../cookbooks/sdk-path-shim.mdx), a custom policy that rewrites
176
+ the deployment path and maps the `api-key` header.
177
+
178
+ Reference models by deployment name, as `providerName/deploymentName`—see
179
+ [Azure serves deployments, not model names](../azure-ai.mdx#azure-serves-deployments-not-model-names).
180
+
181
+ ```typescript
182
+ import { createAzure } from "@ai-sdk/azure";
183
+ import { generateText } from "ai";
184
+
185
+ const appKey = process.env.ZUPLO_AI_GATEWAY_API_KEY;
186
+ if (!appKey) {
187
+ throw new Error("Set ZUPLO_AI_GATEWAY_API_KEY to the app's API key");
188
+ }
189
+
190
+ const azure = createAzure({
191
+ baseURL:
192
+ "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1",
193
+ tokenProvider: async () => appKey,
194
+ });
195
+
196
+ const { text } = await generateText({
197
+ model: azure.chat("azureai/my-gpt"),
198
+ prompt: "Write a one-sentence bedtime story about a unicorn.",
199
+ });
200
+ ```
201
+
202
+ Call `azure.chat(id)` for chat completions and `azure.textEmbeddingModel(id)`
203
+ for embeddings. The bare `azure(id)` callable targets the Responses API, which
204
+ Azure serves for its OpenAI-compatible deployments, so that form works too.
205
+
206
+ For a Claude deployment on a Foundry resource, either call
207
+ `azure.chat("azureai/my-claude")`—the gateway translates the chat completion to
208
+ the Messages API—or use `@ai-sdk/anthropic` with `authToken` for the native
209
+ Messages API:
210
+
211
+ ```typescript
212
+ import { createAnthropic } from "@ai-sdk/anthropic";
213
+ import { generateText } from "ai";
214
+
215
+ const anthropic = createAnthropic({
216
+ authToken: process.env.ZUPLO_AI_GATEWAY_API_KEY,
217
+ baseURL:
218
+ "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1",
219
+ });
220
+
221
+ const { text } = await generateText({
222
+ model: anthropic("azureai/my-claude"),
223
+ prompt: "Write a one-sentence bedtime story about a unicorn.",
224
+ });
225
+ ```
226
+
227
+ ### Vertex AI
228
+
229
+ The `@ai-sdk/google-vertex` provider posts to Vertex's own publisher paths—
230
+ `{model}:generateContent` for Gemini, and `{model}:rawPredict` for Claude on its
231
+ `/anthropic` entry—neither of which the AI Gateway serves, so both return a
232
+ `404`. Use the
233
+ [OpenAI-compatible provider](https://ai-sdk.dev/providers/openai-compatible-providers)
234
+ for Gemini and Model Garden models, and `@ai-sdk/anthropic` for Claude. For
235
+ Claude, the [SDK path shim](../cookbooks/sdk-path-shim.mdx) can instead repoint
236
+ `@ai-sdk/google-vertex/anthropic` onto `/v1/messages`; the Gemini entry can't be
237
+ repointed.
238
+
239
+ Every Vertex model reference has two slashes, `providerName/publisher/model`—see
240
+ [Model references include the publisher prefix](../vertex-ai.mdx#model-references-include-the-publisher-prefix).
241
+
242
+ ```typescript
243
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
244
+ import { generateText } from "ai";
245
+
246
+ const gateway = createOpenAICompatible({
247
+ name: "zuplo-ai-gateway",
248
+ apiKey: process.env.ZUPLO_AI_GATEWAY_API_KEY,
249
+ baseURL:
250
+ "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1",
251
+ });
252
+
253
+ const { text } = await generateText({
254
+ model: gateway("vertexai/google/gemini-3.7-flash"),
255
+ prompt: "Write a one-sentence bedtime story about a unicorn.",
256
+ });
257
+ ```
258
+
259
+ The same provider embeds through `textEmbeddingModel`, which the gateway
260
+ translates onto Vertex's embedding API:
261
+
262
+ ```typescript
263
+ import { embedMany } from "ai";
264
+
265
+ const { embeddings } = await embedMany({
266
+ model: gateway.textEmbeddingModel("vertexai/google/gemini-embedding-2"),
267
+ values: ["first document", "second document"],
268
+ });
269
+ ```
270
+
271
+ Claude models serve the native Messages API on Vertex, so use
272
+ `@ai-sdk/anthropic` with `authToken`:
273
+
274
+ ```typescript
275
+ import { createAnthropic } from "@ai-sdk/anthropic";
276
+ import { generateText } from "ai";
277
+
278
+ const anthropic = createAnthropic({
279
+ authToken: process.env.ZUPLO_AI_GATEWAY_API_KEY,
280
+ baseURL:
281
+ "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1",
282
+ });
283
+
284
+ const { text } = await generateText({
285
+ model: anthropic("vertexai/anthropic/claude-haiku-4-5@20251001"),
286
+ prompt: "Write a one-sentence bedtime story about a unicorn.",
287
+ });
288
+ ```
289
+
164
290
  ## Provider options the gateway doesn't forward
165
291
 
166
292
  On the OpenAI-shaped endpoints (`/v1/chat/completions`, `/v1/embeddings`, and
@@ -358,6 +358,12 @@ const response = await client.chat.completions.create({
358
358
  Streaming works the same way—set `stream: true` and read the
359
359
  `chat.completion.chunk` events.
360
360
 
361
+ For the Vercel AI SDK, use `@ai-sdk/openai-compatible` for Gemini and Model
362
+ Garden models and `@ai-sdk/anthropic` for Claude—see the
363
+ [Vertex AI section](./integrations/ai-sdk.mdx#vertex-ai) of the AI SDK page. The
364
+ `@ai-sdk/google-vertex` provider doesn't work; see
365
+ [Troubleshooting](#troubleshooting).
366
+
361
367
  ### Embeddings
362
368
 
363
369
  Embeddings use the same client and the same `/v1/embeddings` endpoint as any
@@ -474,6 +480,32 @@ doesn't serve the model is the separate `400` described above.
474
480
  require the project to turn on data sharing for the `anthropic` publisher. The
475
481
  error names the `setPublisherModelConfig` setting to change.
476
482
 
483
+ **A request fails when you use `@ai-sdk/google-vertex`.** The provider posts to
484
+ Vertex's own publisher paths—`{model}:generateContent` for Gemini, and
485
+ `{model}:rawPredict` on its `/anthropic` entry—which the gateway doesn't serve,
486
+ so both return a `404`. Use `@ai-sdk/openai-compatible` for Gemini and Model
487
+ Garden models and `@ai-sdk/anthropic` for Claude—see the
488
+ [AI SDK Vertex section](./integrations/ai-sdk.mdx#vertex-ai). For its
489
+ `/anthropic` (Claude) entry specifically, the
490
+ [SDK path shim](./cookbooks/sdk-path-shim.mdx) can repoint
491
+ `@ai-sdk/google-vertex/anthropic` onto `/v1/messages`; the Gemini entry can't be
492
+ repointed that way.
493
+
494
+ **A request fails when you use `@anthropic-ai/vertex-sdk`.** The
495
+ `AnthropicVertex` client rewrites the request onto Vertex's
496
+ `publishers/anthropic/models/{model}:rawPredict` path, which the gateway doesn't
497
+ serve. Use the plain `Anthropic` client from `@anthropic-ai/sdk` with
498
+ `authToken`—see
499
+ [Call Claude models on the Messages API](#call-claude-models-on-the-messages-api).
500
+ To keep using `AnthropicVertex` instead, install the
501
+ [SDK path shim](./cookbooks/sdk-path-shim.mdx), which rewrites that publisher
502
+ path onto `/v1/messages`.
503
+
504
+ **A request fails when you use `@google/genai`.** With `vertexai: true` the
505
+ client posts to Gemini's native `{model}:generateContent` path, and it also
506
+ misreads a two-slash model reference as `publisher/model`. Use an OpenAI client
507
+ on `/v1/chat/completions`—see [Call the models](#call-the-models).
508
+
477
509
  ## Next steps
478
510
 
479
511
  - [AI Providers](./providers.mdx)—the capability matrix across every supported
@@ -202,6 +202,16 @@ policies skip passthrough requests so a cache hit cannot bypass upstream
202
202
  authentication. ZuploDemo is excluded because it must continue to use the
203
203
  gateway's deployment credential.
204
204
 
205
+ When the primary provider is OpenAI and the passed-through credential is a
206
+ ChatGPT login token, such as the one Codex CLI sends after `codex login`, the
207
+ gateway sends `/v1/responses` requests to the ChatGPT Codex backend at
208
+ `https://chatgpt.com/backend-api/codex`. OpenAI only accepts those tokens there;
209
+ the public API rejects them. Requests that pass through an OpenAI platform API
210
+ key keep going to `https://api.openai.com`. The ChatGPT backend only speaks
211
+ streaming. For a request with `stream: false`, the gateway streams from the
212
+ backend and returns the completed response as one JSON body, the same shape
213
+ `https://api.openai.com` returns for a non-streaming request.
214
+
205
215
  A missing, empty, or invalid dedicated key fails authentication; the gateway
206
216
  never retries with `Authorization` as the Zuplo key. A missing or empty
207
217
  `Authorization` also returns 401 in this mode.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zuplo",
3
- "version": "7.8.9",
3
+ "version": "7.8.10",
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.8.9",
36
- "@zuplo/core": "7.8.9",
37
- "@zuplo/runtime": "7.8.9",
38
- "@zuplo/test": "7.8.9"
35
+ "@zuplo/cli": "7.8.10",
36
+ "@zuplo/core": "7.8.10",
37
+ "@zuplo/runtime": "7.8.10",
38
+ "@zuplo/test": "7.8.10"
39
39
  }
40
40
  }