zuplo 7.8.9 → 7.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/codex.mdx +73 -13
- package/docs/ai-gateway/vertex-ai.mdx +32 -0
- package/docs/policies/ai-gateway-auth-v2-inbound/doc.md +10 -0
- package/docs/policies/mcp-token-exchange-inbound/doc.md +9 -7
- 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.
|
|
@@ -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
|
|
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
|
|
@@ -7,13 +7,23 @@ description: Route Codex CLI requests through a Zuplo AI Gateway app.
|
|
|
7
7
|
[Codex](https://developers.openai.com/codex) is OpenAI's coding agent for the
|
|
8
8
|
terminal. This guide routes its requests through the Zuplo AI Gateway.
|
|
9
9
|
|
|
10
|
+
Two approaches:
|
|
11
|
+
|
|
12
|
+
- **Use a provider key.** The gateway calls OpenAI with the API key saved on
|
|
13
|
+
your provider. OpenAI bills that key's account.
|
|
14
|
+
- **Use your ChatGPT subscription.** Codex stays signed in with `codex login`.
|
|
15
|
+
The gateway forwards that login to OpenAI and meters usage against your app.
|
|
16
|
+
Your ChatGPT plan pays for the tokens.
|
|
17
|
+
|
|
10
18
|
## Prerequisites
|
|
11
19
|
|
|
12
20
|
<Stepper>
|
|
13
21
|
|
|
14
|
-
1. Create
|
|
22
|
+
1. Create an OpenAI [provider](../managing-providers.mdx) in the AI Gateway.
|
|
15
23
|
|
|
16
|
-
2. [Create a team](../managing-teams.mdx).
|
|
24
|
+
2. [Create a team](../managing-teams.mdx). For the subscription approach,
|
|
25
|
+
[configure its API Key Authentication policy](#configure-the-api-key-authentication-policy)
|
|
26
|
+
now.
|
|
17
27
|
|
|
18
28
|
3. [Create an app](../managing-apps.mdx) for Codex and assign it to the team.
|
|
19
29
|
|
|
@@ -33,7 +43,14 @@ serves. Check [your provider's page](../managing-providers.mdx) in the Portal.
|
|
|
33
43
|
:::
|
|
34
44
|
|
|
35
45
|
Codex reads configuration from `~/.codex/config.toml`. Create this file (and the
|
|
36
|
-
`~/.codex` directory if needed) if it does not exist. Add the
|
|
46
|
+
`~/.codex` directory if needed) if it does not exist. Add the block for your
|
|
47
|
+
approach, then set the API key in your environment and restart Codex:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
export ZUPLO_AI_GATEWAY_API_KEY="<your-app-api-key>"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Use a provider key
|
|
37
54
|
|
|
38
55
|
```toml
|
|
39
56
|
model_provider = "zuplo"
|
|
@@ -49,13 +66,50 @@ wire_api = "responses"
|
|
|
49
66
|
- `base_url` is the app's API URL with `/v1` appended.
|
|
50
67
|
- `model` is `providerName/model`.
|
|
51
68
|
|
|
52
|
-
|
|
69
|
+
### Use your ChatGPT subscription
|
|
53
70
|
|
|
54
|
-
```
|
|
55
|
-
|
|
71
|
+
```toml
|
|
72
|
+
model_provider = "zuplo"
|
|
73
|
+
model = "openai/gpt-5.6-sol"
|
|
74
|
+
|
|
75
|
+
[model_providers.zuplo]
|
|
76
|
+
name = "Zuplo AI Gateway"
|
|
77
|
+
base_url = "https://my-gateway-main-2e18f50.zuplo.app/config_fe0a04972d2848e0a94ae4b8bcd1497e/v1"
|
|
78
|
+
wire_api = "responses"
|
|
79
|
+
requires_openai_auth = true
|
|
80
|
+
env_http_headers = { "zp-gateway-api-key" = "ZUPLO_AI_GATEWAY_API_KEY" }
|
|
56
81
|
```
|
|
57
82
|
|
|
58
|
-
|
|
83
|
+
- `requires_openai_auth = true` makes Codex send your ChatGPT login in the
|
|
84
|
+
`Authorization` header. Do not set `env_key`.
|
|
85
|
+
- `env_http_headers` sends the app's API key in the `zp-gateway-api-key` header,
|
|
86
|
+
which is where the passthrough policy reads it.
|
|
87
|
+
- `model` must be a model your ChatGPT plan can use in Codex, with your provider
|
|
88
|
+
name as the prefix.
|
|
89
|
+
|
|
90
|
+
Run `codex login` if you are not signed in. The gateway never sends your ChatGPT
|
|
91
|
+
login anywhere except OpenAI, and never sends your app key to OpenAI.
|
|
92
|
+
|
|
93
|
+
#### Configure the API Key Authentication policy
|
|
94
|
+
|
|
95
|
+
On the team's [policy template](../policy-templates.mdx), or on the app's
|
|
96
|
+
**Policies** tab, edit **API Key Authentication** in the **JSON** editor:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"credentialPassthrough": true,
|
|
101
|
+
"authHeader": "zp-gateway-api-key",
|
|
102
|
+
"authScheme": ""
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`authScheme` must be the empty string, not left blank. A blank field keeps the
|
|
107
|
+
`Bearer` default and every request fails with
|
|
108
|
+
`401 Invalid Authorization Scheme`.
|
|
109
|
+
|
|
110
|
+
The provider still needs an API key saved. Passthrough requests never use it;
|
|
111
|
+
[Fallback Model](../../policies/ai-gateway-fallback-model-v2-inbound.mdx)
|
|
112
|
+
targets do. A placeholder value works if nothing else uses it.
|
|
59
113
|
|
|
60
114
|
### Rules
|
|
61
115
|
|
|
@@ -66,6 +120,8 @@ Restart Codex. All requests now go through the gateway.
|
|
|
66
120
|
`/v1/chat/completions`, and `wire_api = "chat"` fails to load.
|
|
67
121
|
- Put this configuration in the user-level file. Codex ignores `model_provider`
|
|
68
122
|
and `model_providers` in a project-level `.codex/config.toml`.
|
|
123
|
+
- Do not use `openai_base_url` to point Codex at the gateway. That keeps Codex's
|
|
124
|
+
built-in transport, which the gateway does not support.
|
|
69
125
|
|
|
70
126
|
## Switch models
|
|
71
127
|
|
|
@@ -96,9 +152,13 @@ usage in the Zuplo Portal.
|
|
|
96
152
|
|
|
97
153
|
## Troubleshooting
|
|
98
154
|
|
|
99
|
-
| Error | Fix
|
|
100
|
-
| ------------------------------------------------------------------------------------------ |
|
|
101
|
-
| `400` `The request body model must use "providerName/model"` | Add the provider prefix to `model`. Do not use the `/model` picker.
|
|
102
|
-
| `ERROR: Missing environment variable: ZUPLO_AI_GATEWAY_API_KEY` | Export the variable in the shell that runs Codex.
|
|
103
|
-
| `401` `Authorization Failed` | The API key is wrong. Copy it from the app's **API Key** tab.
|
|
104
|
-
|
|
|
155
|
+
| Error | Fix |
|
|
156
|
+
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
|
157
|
+
| `400` `The request body model must use "providerName/model"` | Add the provider prefix to `model`. Do not use the `/model` picker. |
|
|
158
|
+
| `ERROR: Missing environment variable: ZUPLO_AI_GATEWAY_API_KEY` | Export the variable in the shell that runs Codex. |
|
|
159
|
+
| `401` `Authorization Failed` | The API key is wrong. Copy it from the app's **API Key** tab. |
|
|
160
|
+
| `401` `Header configured by options.authHeader is missing` | Subscription approach: add the `env_http_headers` line and export `ZUPLO_AI_GATEWAY_API_KEY`. |
|
|
161
|
+
| `401` `Invalid Authorization Scheme` | Set `authScheme` to the empty string in the policy JSON. |
|
|
162
|
+
| `401` `rejected the credential this app passed through from the caller` `(HTTP 401)` | Your ChatGPT login expired or is invalid. Run `codex login` again. |
|
|
163
|
+
| `401` `Missing scopes: api.responses.write` | The app is not in passthrough mode. Configure the API Key Authentication policy as above. |
|
|
164
|
+
| Errors mention `api.openai.com` or `not supported when using Codex with a ChatGPT account` | Codex is not using your provider. Set `model_provider = "zuplo"`. |
|
|
@@ -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.
|
|
@@ -284,15 +284,17 @@ Gateway authorization errors fall into three buckets:
|
|
|
284
284
|
| Upstream service response | The upstream server returned its own error page or OAuth error. | Treat the upstream response as the source of truth and fix the upstream URL, allowlist, account region, or provider configuration. |
|
|
285
285
|
|
|
286
286
|
For browser-based OAuth failures, the gateway error page shows a user-friendly
|
|
287
|
-
message
|
|
288
|
-
|
|
289
|
-
|
|
287
|
+
message, numbered next steps, and a reference block with the gateway error code,
|
|
288
|
+
request id, and time for the user to share. A technical-details section below it
|
|
289
|
+
lists the suggested fix, the underlying reason, the stage, and the upstream
|
|
290
|
+
request fields so screenshots are useful in support tickets.
|
|
290
291
|
|
|
291
292
|
If the upstream returns an HTML error response, such as an edge firewall
|
|
292
|
-
`403 Access Denied` page, the gateway
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
293
|
+
`403 Access Denied` page, the gateway keeps that upstream HTML response on the
|
|
294
|
+
error page behind a "Show the provider's response" disclosure instead of
|
|
295
|
+
replacing it with only a generic gateway message. This usually means the
|
|
296
|
+
upstream URL is not publicly reachable from the gateway or the provider has not
|
|
297
|
+
allowed this client/network/account to access the MCP endpoint.
|
|
296
298
|
|
|
297
299
|
Common examples:
|
|
298
300
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zuplo",
|
|
3
|
-
"version": "7.8.
|
|
3
|
+
"version": "7.8.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The official Zuplo CLI for local development and platform management",
|
|
6
6
|
"homepage": "https://zuplo.com/docs/cli/overview",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"zuplo": "zuplo.js"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@zuplo/cli": "7.8.
|
|
36
|
-
"@zuplo/core": "7.8.
|
|
37
|
-
"@zuplo/runtime": "7.8.
|
|
38
|
-
"@zuplo/test": "7.8.
|
|
35
|
+
"@zuplo/cli": "7.8.11",
|
|
36
|
+
"@zuplo/core": "7.8.11",
|
|
37
|
+
"@zuplo/runtime": "7.8.11",
|
|
38
|
+
"@zuplo/test": "7.8.11"
|
|
39
39
|
}
|
|
40
40
|
}
|