zuplo 7.8.8 → 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.
- package/docs/ai-gateway/azure-ai.mdx +132 -0
- package/docs/ai-gateway/cookbooks/sdk-path-shim.mdx +457 -0
- package/docs/ai-gateway/integrations/ai-sdk.mdx +127 -1
- package/docs/ai-gateway/integrations/claude-code.mdx +82 -180
- package/docs/ai-gateway/integrations/claude-desktop.mdx +61 -99
- package/docs/ai-gateway/integrations/codex.mdx +63 -57
- package/docs/ai-gateway/vertex-ai.mdx +32 -0
- package/docs/articles/custom-domains.mdx +3 -2
- package/docs/articles/source-control.mdx +4 -3
- package/docs/articles/testing.mdx +5 -5
- package/docs/articles/upstream-iam-auth.mdx +2 -2
- package/docs/concepts/how-zuplo-works.mdx +2 -1
- package/docs/dev-portal/migration.mdx +2 -2
- package/docs/getting-started/dev-portal/index.mdx +2 -2
- package/docs/getting-started/gateway/add-api-key-auth/index.mdx +3 -0
- package/docs/getting-started/gateway/add-api-key-auth/local.mdx +3 -0
- package/docs/getting-started/gateway/add-rate-limiting/index.mdx +3 -0
- package/docs/getting-started/gateway/add-rate-limiting/local.mdx +3 -0
- package/docs/getting-started/gateway/agent.mdx +2 -1
- package/docs/getting-started/gateway/deploy-to-the-edge/index.mdx +3 -0
- package/docs/getting-started/gateway/deploy-to-the-edge/local.mdx +3 -0
- package/docs/getting-started/gateway/dynamic-rate-limiting/index.mdx +3 -0
- package/docs/getting-started/gateway/dynamic-rate-limiting/local.mdx +3 -0
- package/docs/getting-started/gateway/setup-a-basic-gateway/index.mdx +4 -1
- package/docs/getting-started/gateway/setup-a-basic-gateway/local.mdx +4 -1
- package/docs/getting-started/mcp-gateway/local.mdx +31 -9
- package/docs/getting-started/mcp-gateway/portal.mdx +8 -7
- package/docs/getting-started/mcp-server/local.mdx +2 -2
- package/docs/getting-started/migrate/index.mdx +2 -2
- package/docs/mcp-gateway/connect-clients/claude-desktop.mdx +21 -19
- package/docs/mcp-gateway/connect-clients/overview.mdx +2 -1
- package/docs/policies/ai-gateway-auth-v2-inbound/doc.md +10 -0
- package/package.json +5 -5
|
@@ -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.
|