effect-ai-vercel-gateway 0.1.0
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/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +257 -0
- package/dist/index.js.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/package.json +59 -0
- package/src/AiGateway.ts +194 -0
- package/src/Credentials.ts +161 -0
- package/src/index.ts +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Duailibe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# effect-ai-vercel-gateway
|
|
2
|
+
|
|
3
|
+
[Vercel AI Gateway](https://vercel.com/docs/ai-gateway) as an `AnthropicClient` for
|
|
4
|
+
[Effect AI](https://effect.website) (`@effect/ai-anthropic`, Effect v4).
|
|
5
|
+
|
|
6
|
+
The gateway serves the Anthropic Messages API and routes to any model it lists, named
|
|
7
|
+
`provider/model`. This package points Effect AI's Anthropic provider at the gateway, handles
|
|
8
|
+
authentication per request, and smooths over the small differences between the gateway's
|
|
9
|
+
dialect and what the provider's schemas expect.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm add effect-ai-vercel-gateway effect @effect/ai-anthropic
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { AiGateway, Credentials } from "effect-ai-vercel-gateway"
|
|
21
|
+
import { AnthropicLanguageModel } from "@effect/ai-anthropic"
|
|
22
|
+
import { Effect, Layer } from "effect"
|
|
23
|
+
import { LanguageModel } from "effect/unstable/ai"
|
|
24
|
+
import { FetchHttpClient } from "effect/unstable/http"
|
|
25
|
+
|
|
26
|
+
const Model = AnthropicLanguageModel.layer({ model: "google/gemini-2.5-flash" }).pipe(
|
|
27
|
+
Layer.provide(AiGateway.layer),
|
|
28
|
+
Layer.provide([Credentials.fromChain(), FetchHttpClient.layer]),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const program = LanguageModel.generateText({ prompt: "Say hi" }).pipe(
|
|
32
|
+
Effect.map((r) => r.text),
|
|
33
|
+
Effect.provide(Model),
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`AiGateway.layer` provides `AnthropicClient` and requires `Credentials` (below) and an
|
|
38
|
+
`HttpClient`.
|
|
39
|
+
|
|
40
|
+
Any Effect AI feature that works with the Anthropic provider (tools, structured output,
|
|
41
|
+
streaming, thinking) works through the gateway, for every model the gateway offers.
|
|
42
|
+
|
|
43
|
+
## Credentials
|
|
44
|
+
|
|
45
|
+
`Credentials` is a service holding an effect that resolves the credential on every request,
|
|
46
|
+
so a rotating token is always current. Pick a layer for where it comes from:
|
|
47
|
+
|
|
48
|
+
| Layer | Source |
|
|
49
|
+
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
50
|
+
| `Credentials.fromApiKey(key)` | A fixed key. Tests, custom wiring. |
|
|
51
|
+
| `Credentials.fromEnv()` | `AI_GATEWAY_API_KEY`. Local dev, CI, anywhere off Vercel. |
|
|
52
|
+
| `Credentials.fromVercelOidc()` | The OIDC token Vercel issues to the deployment: the `x-vercel-oidc-token` header of the current request (Functions), else `VERCEL_OIDC_TOKEN` (builds, `vercel env pull`). |
|
|
53
|
+
| `Credentials.fromChain()` | `fromEnv`, then `fromVercelOidc`. Same order as Vercel's own SDK. |
|
|
54
|
+
|
|
55
|
+
Each request carries `x-api-key` and `ai-gateway-auth-method` (`api-key` or `oidc`). A
|
|
56
|
+
missing credential fails the request with an `AiError` wrapping a `CredentialsError` that
|
|
57
|
+
names the source it tried and how to fix it.
|
|
58
|
+
|
|
59
|
+
Environment variables are read through Effect's `Config`, so a `ConfigProvider` can redirect
|
|
60
|
+
them. The OIDC token is not refreshed when it expires locally; re-run `vercel env pull`.
|
|
61
|
+
|
|
62
|
+
## Dialect fixes
|
|
63
|
+
|
|
64
|
+
Two adjustments are applied to traffic with the gateway:
|
|
65
|
+
|
|
66
|
+
- Requests: `"cache_control": null` is removed from content blocks. The Effect provider emits it,
|
|
67
|
+
Anthropic accepts it, the gateway rejects it.
|
|
68
|
+
- Responses: keys Anthropic always sends but the gateway omits are filled in with Anthropic's
|
|
69
|
+
"nothing to report" values, so the provider's schemas decode. These are the cache and
|
|
70
|
+
service-tier usage fields, `signature` on thinking blocks from non-Anthropic models, and
|
|
71
|
+
`type`/`request_id` on error envelopes (unknown `error.type` values map to `api_error`).
|
|
72
|
+
|
|
73
|
+
Streaming responses pass through untouched.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
pnpm install
|
|
79
|
+
pnpm test
|
|
80
|
+
pnpm check # tsc
|
|
81
|
+
pnpm lint # oxlint
|
|
82
|
+
pnpm fmt # oxfmt
|
|
83
|
+
pnpm build # tsdown -> dist/
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { AnthropicClient } from "@effect/ai-anthropic";
|
|
2
|
+
import { Context, Effect, Layer, Redacted } from "effect";
|
|
3
|
+
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
|
|
4
|
+
declare namespace Credentials_d_exports {
|
|
5
|
+
export { API_KEY_ENV, Credentials, CredentialsError, OIDC_TOKEN_ENV, Resolved, Source, formatHeaders, fromApiKey, fromChain, fromEnv, fromVercelOidc };
|
|
6
|
+
}
|
|
7
|
+
/** The environment variable `fromEnv` reads. */
|
|
8
|
+
declare const API_KEY_ENV = "AI_GATEWAY_API_KEY";
|
|
9
|
+
/** The environment variable `fromVercelOidc` falls back to. */
|
|
10
|
+
declare const OIDC_TOKEN_ENV = "VERCEL_OIDC_TOKEN";
|
|
11
|
+
/** Which `from*` layer produced or failed to produce a credential. */
|
|
12
|
+
type Source = "api-key" | "env" | "vercel-oidc" | "chain";
|
|
13
|
+
/**
|
|
14
|
+
* A credential ready to put on a request. `method` is what the gateway
|
|
15
|
+
* records as the auth method; it matches the values Vercel's own SDK sends.
|
|
16
|
+
*/
|
|
17
|
+
interface Resolved {
|
|
18
|
+
readonly method: "api-key" | "oidc";
|
|
19
|
+
readonly token: Redacted.Redacted<string>;
|
|
20
|
+
}
|
|
21
|
+
declare const CredentialsError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
22
|
+
readonly _tag: "AiGatewayCredentialsError";
|
|
23
|
+
} & Readonly<A>;
|
|
24
|
+
/** No credential could be produced for a gateway request. */
|
|
25
|
+
declare class CredentialsError extends CredentialsError_base<{
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly source: Source;
|
|
28
|
+
readonly hints: ReadonlyArray<string>;
|
|
29
|
+
readonly cause?: unknown;
|
|
30
|
+
}> {}
|
|
31
|
+
declare const Credentials_base: Context.ServiceClass<Credentials, "effect-ai-vercel-gateway/Credentials", Effect.Effect<Resolved, CredentialsError, never>>;
|
|
32
|
+
declare class Credentials extends Credentials_base {}
|
|
33
|
+
/** The request headers that carry `credentials`. */
|
|
34
|
+
declare const formatHeaders: (credentials: Resolved) => Record<string, string>;
|
|
35
|
+
/** A fixed API key. */
|
|
36
|
+
declare const fromApiKey: (apiKey: string | Redacted.Redacted<string>) => Layer.Layer<Credentials>;
|
|
37
|
+
/** `AI_GATEWAY_API_KEY`: local dev, CI, or anywhere off Vercel. */
|
|
38
|
+
declare const fromEnv: () => Layer.Layer<Credentials>;
|
|
39
|
+
/**
|
|
40
|
+
* The OIDC token Vercel issues to the deployment: the `x-vercel-oidc-token`
|
|
41
|
+
* header of the current request in Functions, else `VERCEL_OIDC_TOKEN` in
|
|
42
|
+
* builds and after `vercel env pull`. Not refreshed when it expires locally.
|
|
43
|
+
*/
|
|
44
|
+
declare const fromVercelOidc: () => Layer.Layer<Credentials>;
|
|
45
|
+
/** `fromEnv`, then `fromVercelOidc`. The usual choice for an app deployed to Vercel. */
|
|
46
|
+
declare const fromChain: () => Layer.Layer<Credentials>;
|
|
47
|
+
declare namespace AiGateway_d_exports {
|
|
48
|
+
export { AI_GATEWAY_URL, dropNullCacheControl, fillMissingResponseKeys, layer, make, patchResponseBody };
|
|
49
|
+
}
|
|
50
|
+
/** Base URL of the Vercel AI Gateway. */
|
|
51
|
+
declare const AI_GATEWAY_URL = "https://ai-gateway.vercel.sh";
|
|
52
|
+
/**
|
|
53
|
+
* Builds the Anthropic client against the gateway, authenticating each
|
|
54
|
+
* request with `credentials`. A credential failure fails that request as an
|
|
55
|
+
* `HttpClientError`, which Effect AI surfaces as a network `AiError`.
|
|
56
|
+
*/
|
|
57
|
+
declare const make: (credentials: typeof Credentials.Service) => Effect.Effect<AnthropicClient.Service, never, HttpClient.HttpClient>;
|
|
58
|
+
/**
|
|
59
|
+
* `AnthropicClient` backed by the gateway. Needs `Credentials` (see the
|
|
60
|
+
* `Credentials` module's `from*` layers) and an `HttpClient`. Building never
|
|
61
|
+
* fails; without a usable credential, the requests fail.
|
|
62
|
+
*/
|
|
63
|
+
declare const layer: Layer.Layer<AnthropicClient.AnthropicClient, never, Credentials | HttpClient.HttpClient>;
|
|
64
|
+
/**
|
|
65
|
+
* The Effect provider writes `"cache_control": null` on every content block
|
|
66
|
+
* it has no cache setting for. Anthropic accepts that; the gateway rejects
|
|
67
|
+
* the request with `messages.0.content: Invalid input`. Remove the null keys.
|
|
68
|
+
*/
|
|
69
|
+
declare function dropNullCacheControl(request: HttpClientRequest.HttpClientRequest): HttpClientRequest.HttpClientRequest;
|
|
70
|
+
/**
|
|
71
|
+
* The gateway's Anthropic-shaped responses leave out keys that Anthropic
|
|
72
|
+
* always sends and the Effect provider's schema requires: the cache and
|
|
73
|
+
* service-tier usage fields, the `signature` of a thinking block from a
|
|
74
|
+
* non-Anthropic model, and `type`/`request_id` on error envelopes, whose
|
|
75
|
+
* `error.type` may also be a gateway-specific value. Fill them with the values
|
|
76
|
+
* Anthropic uses when there is nothing to report. Streaming responses are not
|
|
77
|
+
* JSON and pass through untouched.
|
|
78
|
+
*/
|
|
79
|
+
declare function fillMissingResponseKeys(response: HttpClientResponse.HttpClientResponse): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>;
|
|
80
|
+
declare function patchResponseBody(body: Record<string, unknown>): Record<string, unknown>;
|
|
81
|
+
//#endregion
|
|
82
|
+
export { AiGateway_d_exports as AiGateway, Credentials_d_exports as Credentials };
|
|
83
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
+
import { AnthropicClient } from "@effect/ai-anthropic";
|
|
3
|
+
import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect";
|
|
4
|
+
import { HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
|
|
5
|
+
//#region src/Credentials.ts
|
|
6
|
+
/**
|
|
7
|
+
* How gateway requests authenticate.
|
|
8
|
+
*
|
|
9
|
+
* `Credentials` holds an effect that produces the credential for one request.
|
|
10
|
+
* It runs per request rather than once at startup: on Vercel the OIDC token
|
|
11
|
+
* arrives on the request context and rotates, so it must not be cached. Pick
|
|
12
|
+
* a `from*` layer for where the credential comes from.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { Credentials } from "effect-ai-vercel-gateway"
|
|
17
|
+
*
|
|
18
|
+
* Credentials.fromChain() // AI_GATEWAY_API_KEY, else the Vercel OIDC token
|
|
19
|
+
* Credentials.fromEnv() // AI_GATEWAY_API_KEY only
|
|
20
|
+
* Credentials.fromVercelOidc() // the Vercel OIDC token only
|
|
21
|
+
* Credentials.fromApiKey("…") // a fixed value
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
var Credentials_exports = /* @__PURE__ */ __exportAll({
|
|
25
|
+
API_KEY_ENV: () => API_KEY_ENV,
|
|
26
|
+
Credentials: () => Credentials,
|
|
27
|
+
CredentialsError: () => CredentialsError,
|
|
28
|
+
OIDC_TOKEN_ENV: () => OIDC_TOKEN_ENV,
|
|
29
|
+
formatHeaders: () => formatHeaders,
|
|
30
|
+
fromApiKey: () => fromApiKey,
|
|
31
|
+
fromChain: () => fromChain,
|
|
32
|
+
fromEnv: () => fromEnv,
|
|
33
|
+
fromVercelOidc: () => fromVercelOidc
|
|
34
|
+
});
|
|
35
|
+
/** The environment variable `fromEnv` reads. */
|
|
36
|
+
const API_KEY_ENV = "AI_GATEWAY_API_KEY";
|
|
37
|
+
/** The environment variable `fromVercelOidc` falls back to. */
|
|
38
|
+
const OIDC_TOKEN_ENV = "VERCEL_OIDC_TOKEN";
|
|
39
|
+
/** No credential could be produced for a gateway request. */
|
|
40
|
+
var CredentialsError = class extends Data.TaggedError("AiGatewayCredentialsError") {};
|
|
41
|
+
var Credentials = class extends Context.Service()("effect-ai-vercel-gateway/Credentials") {};
|
|
42
|
+
/** The request headers that carry `credentials`. */
|
|
43
|
+
const formatHeaders = (credentials) => ({
|
|
44
|
+
"x-api-key": Redacted.value(credentials.token),
|
|
45
|
+
"ai-gateway-auth-method": credentials.method
|
|
46
|
+
});
|
|
47
|
+
const hints = {
|
|
48
|
+
"api-key": [],
|
|
49
|
+
env: [`Set ${API_KEY_ENV} to an AI Gateway API key.`],
|
|
50
|
+
"vercel-oidc": ["Run on Vercel, where each request carries an OIDC token.", `Or run \`vercel env pull\` locally to populate ${OIDC_TOKEN_ENV}.`],
|
|
51
|
+
chain: [`Set ${API_KEY_ENV} to an AI Gateway API key.`, "Or run on Vercel / `vercel env pull` so an OIDC token is available."]
|
|
52
|
+
};
|
|
53
|
+
const fromConfig = (name, method, source) => Config.option(Config.Redacted(name)).pipe(Effect.map(Option.map((token) => ({
|
|
54
|
+
method,
|
|
55
|
+
token
|
|
56
|
+
}))), Effect.catchTag("ConfigError", (cause) => new CredentialsError({
|
|
57
|
+
message: `Failed to read ${name}.`,
|
|
58
|
+
source,
|
|
59
|
+
hints: hints[source],
|
|
60
|
+
cause
|
|
61
|
+
})));
|
|
62
|
+
/**
|
|
63
|
+
* Vercel exposes the current request's context on a well-known global symbol.
|
|
64
|
+
* In Functions, the `x-vercel-oidc-token` header of the request lives there.
|
|
65
|
+
* Mirrors `@vercel/oidc`'s `getVercelOidcTokenSync`, minus the dev-time
|
|
66
|
+
* refresh that package also offers.
|
|
67
|
+
*/
|
|
68
|
+
const REQUEST_CONTEXT = Symbol.for("@vercel/request-context");
|
|
69
|
+
const fromRequestContext = Effect.sync(() => {
|
|
70
|
+
const token = globalThis[REQUEST_CONTEXT]?.get?.()?.headers?.["x-vercel-oidc-token"];
|
|
71
|
+
return token === void 0 ? Option.none() : Option.some({
|
|
72
|
+
method: "oidc",
|
|
73
|
+
token: Redacted.make(token)
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
const firstOf = (lookups) => Effect.gen(function* () {
|
|
77
|
+
for (const lookup of lookups) {
|
|
78
|
+
const found = yield* lookup;
|
|
79
|
+
if (Option.isSome(found)) return found;
|
|
80
|
+
}
|
|
81
|
+
return Option.none();
|
|
82
|
+
});
|
|
83
|
+
const require = (lookup, source, message) => Layer.succeed(Credentials)(Effect.flatMap(lookup, (found) => Option.isSome(found) ? Effect.succeed(found.value) : Effect.fail(new CredentialsError({
|
|
84
|
+
message,
|
|
85
|
+
source,
|
|
86
|
+
hints: hints[source]
|
|
87
|
+
}))));
|
|
88
|
+
const apiKeyLookup = fromConfig(API_KEY_ENV, "api-key", "env");
|
|
89
|
+
const vercelOidcLookup = firstOf([fromRequestContext, fromConfig(OIDC_TOKEN_ENV, "oidc", "vercel-oidc")]);
|
|
90
|
+
/** A fixed API key. */
|
|
91
|
+
const fromApiKey = (apiKey) => Layer.succeed(Credentials)(Effect.succeed({
|
|
92
|
+
method: "api-key",
|
|
93
|
+
token: Redacted.isRedacted(apiKey) ? apiKey : Redacted.make(apiKey)
|
|
94
|
+
}));
|
|
95
|
+
/** `AI_GATEWAY_API_KEY`: local dev, CI, or anywhere off Vercel. */
|
|
96
|
+
const fromEnv = () => require(apiKeyLookup, "env", `${API_KEY_ENV} is not set.`);
|
|
97
|
+
/**
|
|
98
|
+
* The OIDC token Vercel issues to the deployment: the `x-vercel-oidc-token`
|
|
99
|
+
* header of the current request in Functions, else `VERCEL_OIDC_TOKEN` in
|
|
100
|
+
* builds and after `vercel env pull`. Not refreshed when it expires locally.
|
|
101
|
+
*/
|
|
102
|
+
const fromVercelOidc = () => require(vercelOidcLookup, "vercel-oidc", "No Vercel OIDC token is available.");
|
|
103
|
+
/** `fromEnv`, then `fromVercelOidc`. The usual choice for an app deployed to Vercel. */
|
|
104
|
+
const fromChain = () => require(firstOf([apiKeyLookup, vercelOidcLookup]), "chain", "No AI Gateway credential found.");
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/AiGateway.ts
|
|
107
|
+
/**
|
|
108
|
+
* Vercel AI Gateway as an Anthropic Messages API client for Effect AI.
|
|
109
|
+
*
|
|
110
|
+
* The gateway serves the Anthropic Messages API at its root URL and accepts
|
|
111
|
+
* any model it lists, named `provider/model` (for example
|
|
112
|
+
* `anthropic/claude-sonnet-4.5` or `google/gemini-2.5-flash`). Effect AI's
|
|
113
|
+
* Anthropic provider only needs the base URL and the credential swapped, plus
|
|
114
|
+
* two small dialect fixes (see `dropNullCacheControl` and
|
|
115
|
+
* `fillMissingResponseKeys`), so this module is the one place the gateway is
|
|
116
|
+
* configured.
|
|
117
|
+
*
|
|
118
|
+
* Structured output (`output_config.format`) works for every provider behind
|
|
119
|
+
* the gateway, so the Anthropic provider's default of native structured
|
|
120
|
+
* output holds for models it does not recognise.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* import { AiGateway, Credentials } from "effect-ai-vercel-gateway"
|
|
125
|
+
* import { AnthropicLanguageModel } from "@effect/ai-anthropic"
|
|
126
|
+
* import { Layer } from "effect"
|
|
127
|
+
* import { FetchHttpClient } from "effect/unstable/http"
|
|
128
|
+
*
|
|
129
|
+
* const Model = AnthropicLanguageModel.layer({ model: "anthropic/claude-sonnet-4.5" }).pipe(
|
|
130
|
+
* Layer.provide(AiGateway.layer),
|
|
131
|
+
* Layer.provide([Credentials.fromChain(), FetchHttpClient.layer]),
|
|
132
|
+
* )
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
var AiGateway_exports = /* @__PURE__ */ __exportAll({
|
|
136
|
+
AI_GATEWAY_URL: () => AI_GATEWAY_URL,
|
|
137
|
+
dropNullCacheControl: () => dropNullCacheControl,
|
|
138
|
+
fillMissingResponseKeys: () => fillMissingResponseKeys,
|
|
139
|
+
layer: () => layer,
|
|
140
|
+
make: () => make,
|
|
141
|
+
patchResponseBody: () => patchResponseBody
|
|
142
|
+
});
|
|
143
|
+
/** Base URL of the Vercel AI Gateway. */
|
|
144
|
+
const AI_GATEWAY_URL = "https://ai-gateway.vercel.sh";
|
|
145
|
+
/**
|
|
146
|
+
* Builds the Anthropic client against the gateway, authenticating each
|
|
147
|
+
* request with `credentials`. A credential failure fails that request as an
|
|
148
|
+
* `HttpClientError`, which Effect AI surfaces as a network `AiError`.
|
|
149
|
+
*/
|
|
150
|
+
const make = (credentials) => AnthropicClient.make({
|
|
151
|
+
apiUrl: AI_GATEWAY_URL,
|
|
152
|
+
transformClient: (client) => client.pipe(HttpClient.mapRequestEffect(authenticate(credentials)), HttpClient.mapRequest(dropNullCacheControl), HttpClient.transformResponse(Effect.flatMap(fillMissingResponseKeys)))
|
|
153
|
+
});
|
|
154
|
+
/**
|
|
155
|
+
* `AnthropicClient` backed by the gateway. Needs `Credentials` (see the
|
|
156
|
+
* `Credentials` module's `from*` layers) and an `HttpClient`. Building never
|
|
157
|
+
* fails; without a usable credential, the requests fail.
|
|
158
|
+
*/
|
|
159
|
+
const layer = Layer.effect(AnthropicClient.AnthropicClient)(Effect.flatMap(Effect.service(Credentials), make));
|
|
160
|
+
function authenticate(credentials) {
|
|
161
|
+
return (request) => credentials.pipe(Effect.map((resolved) => HttpClientRequest.setHeaders(request, formatHeaders(resolved))), Effect.mapError((error) => new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({
|
|
162
|
+
request,
|
|
163
|
+
cause: error,
|
|
164
|
+
description: "AI Gateway credential unavailable"
|
|
165
|
+
}) })));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The Effect provider writes `"cache_control": null` on every content block
|
|
169
|
+
* it has no cache setting for. Anthropic accepts that; the gateway rejects
|
|
170
|
+
* the request with `messages.0.content: Invalid input`. Remove the null keys.
|
|
171
|
+
*/
|
|
172
|
+
function dropNullCacheControl(request) {
|
|
173
|
+
const body = request.body;
|
|
174
|
+
if (body._tag !== "Uint8Array" || !isJson(body.contentType)) return request;
|
|
175
|
+
const json = JSON.parse(new TextDecoder().decode(body.body));
|
|
176
|
+
return HttpClientRequest.setBody(request, HttpBody.jsonUnsafe(withoutNullCacheControl(json), body.contentType));
|
|
177
|
+
}
|
|
178
|
+
function withoutNullCacheControl(value) {
|
|
179
|
+
if (Array.isArray(value)) return value.map(withoutNullCacheControl);
|
|
180
|
+
if (value === null || typeof value !== "object") return value;
|
|
181
|
+
const result = {};
|
|
182
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
183
|
+
if (key === "cache_control" && entry === null) continue;
|
|
184
|
+
result[key] = withoutNullCacheControl(entry);
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The gateway's Anthropic-shaped responses leave out keys that Anthropic
|
|
190
|
+
* always sends and the Effect provider's schema requires: the cache and
|
|
191
|
+
* service-tier usage fields, the `signature` of a thinking block from a
|
|
192
|
+
* non-Anthropic model, and `type`/`request_id` on error envelopes, whose
|
|
193
|
+
* `error.type` may also be a gateway-specific value. Fill them with the values
|
|
194
|
+
* Anthropic uses when there is nothing to report. Streaming responses are not
|
|
195
|
+
* JSON and pass through untouched.
|
|
196
|
+
*/
|
|
197
|
+
function fillMissingResponseKeys(response) {
|
|
198
|
+
if (!isJson(response.headers["content-type"] ?? "")) return Effect.succeed(response);
|
|
199
|
+
return Effect.map(response.text, (text) => {
|
|
200
|
+
const body = JSON.parse(text);
|
|
201
|
+
return HttpClientResponse.fromWeb(response.request, new Response(JSON.stringify(patchResponseBody(body)), {
|
|
202
|
+
status: response.status,
|
|
203
|
+
headers: response.headers
|
|
204
|
+
}));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function patchResponseBody(body) {
|
|
208
|
+
if (body.type === "message") {
|
|
209
|
+
body.usage = {
|
|
210
|
+
cache_creation: null,
|
|
211
|
+
cache_creation_input_tokens: null,
|
|
212
|
+
cache_read_input_tokens: null,
|
|
213
|
+
inference_geo: null,
|
|
214
|
+
service_tier: null,
|
|
215
|
+
...body.usage ?? {}
|
|
216
|
+
};
|
|
217
|
+
if (Array.isArray(body.content)) body.content = body.content.map((block) => isRecord(block) && block.type === "thinking" ? {
|
|
218
|
+
signature: "",
|
|
219
|
+
...block
|
|
220
|
+
} : block);
|
|
221
|
+
return body;
|
|
222
|
+
}
|
|
223
|
+
if (isRecord(body.error)) {
|
|
224
|
+
const error = ANTHROPIC_ERROR_TYPES.has(String(body.error.type)) ? body.error : {
|
|
225
|
+
...body.error,
|
|
226
|
+
type: "api_error"
|
|
227
|
+
};
|
|
228
|
+
return {
|
|
229
|
+
type: "error",
|
|
230
|
+
request_id: null,
|
|
231
|
+
...body,
|
|
232
|
+
error
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return body;
|
|
236
|
+
}
|
|
237
|
+
const ANTHROPIC_ERROR_TYPES = /* @__PURE__ */ new Set([
|
|
238
|
+
"invalid_request_error",
|
|
239
|
+
"authentication_error",
|
|
240
|
+
"billing_error",
|
|
241
|
+
"permission_error",
|
|
242
|
+
"not_found_error",
|
|
243
|
+
"rate_limit_error",
|
|
244
|
+
"timeout_error",
|
|
245
|
+
"api_error",
|
|
246
|
+
"overloaded_error"
|
|
247
|
+
]);
|
|
248
|
+
function isRecord(value) {
|
|
249
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
250
|
+
}
|
|
251
|
+
function isJson(contentType) {
|
|
252
|
+
return contentType.startsWith("application/json");
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
255
|
+
export { AiGateway_exports as AiGateway, Credentials_exports as Credentials };
|
|
256
|
+
|
|
257
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["holder","Credentials.Credentials","Credentials.formatHeaders"],"sources":["../src/Credentials.ts","../src/AiGateway.ts"],"sourcesContent":["/**\n * How gateway requests authenticate.\n *\n * `Credentials` holds an effect that produces the credential for one request.\n * It runs per request rather than once at startup: on Vercel the OIDC token\n * arrives on the request context and rotates, so it must not be cached. Pick\n * a `from*` layer for where the credential comes from.\n *\n * @example\n * ```ts\n * import { Credentials } from \"effect-ai-vercel-gateway\"\n *\n * Credentials.fromChain() // AI_GATEWAY_API_KEY, else the Vercel OIDC token\n * Credentials.fromEnv() // AI_GATEWAY_API_KEY only\n * Credentials.fromVercelOidc() // the Vercel OIDC token only\n * Credentials.fromApiKey(\"…\") // a fixed value\n * ```\n */\n\nimport { Config, Context, Data, Effect, Layer, Option, Redacted } from \"effect\"\n\n/** The environment variable `fromEnv` reads. */\nexport const API_KEY_ENV = \"AI_GATEWAY_API_KEY\"\n\n/** The environment variable `fromVercelOidc` falls back to. */\nexport const OIDC_TOKEN_ENV = \"VERCEL_OIDC_TOKEN\"\n\n/** Which `from*` layer produced or failed to produce a credential. */\nexport type Source = \"api-key\" | \"env\" | \"vercel-oidc\" | \"chain\"\n\n/**\n * A credential ready to put on a request. `method` is what the gateway\n * records as the auth method; it matches the values Vercel's own SDK sends.\n */\nexport interface Resolved {\n readonly method: \"api-key\" | \"oidc\"\n readonly token: Redacted.Redacted<string>\n}\n\n/** No credential could be produced for a gateway request. */\nexport class CredentialsError extends Data.TaggedError(\"AiGatewayCredentialsError\")<{\n readonly message: string\n readonly source: Source\n readonly hints: ReadonlyArray<string>\n readonly cause?: unknown\n}> {}\n\nexport class Credentials extends Context.Service<\n Credentials,\n Effect.Effect<Resolved, CredentialsError>\n>()(\"effect-ai-vercel-gateway/Credentials\") {}\n\n/** The request headers that carry `credentials`. */\nexport const formatHeaders = (credentials: Resolved): Record<string, string> => ({\n \"x-api-key\": Redacted.value(credentials.token),\n \"ai-gateway-auth-method\": credentials.method,\n})\n\nconst hints: Record<Source, ReadonlyArray<string>> = {\n \"api-key\": [],\n env: [`Set ${API_KEY_ENV} to an AI Gateway API key.`],\n \"vercel-oidc\": [\n \"Run on Vercel, where each request carries an OIDC token.\",\n `Or run \\`vercel env pull\\` locally to populate ${OIDC_TOKEN_ENV}.`,\n ],\n chain: [\n `Set ${API_KEY_ENV} to an AI Gateway API key.`,\n \"Or run on Vercel / `vercel env pull` so an OIDC token is available.\",\n ],\n}\n\n/**\n * Where a credential may come from. `None` means the place is not configured,\n * so a chain can move on; a failure means it is configured but unusable.\n */\ntype Lookup = Effect.Effect<Option.Option<Resolved>, CredentialsError>\n\nconst fromConfig = (name: string, method: Resolved[\"method\"], source: Source): Lookup =>\n Config.option(Config.Redacted(name)).pipe(\n Effect.map(Option.map((token) => ({ method, token }))),\n Effect.catchTag(\n \"ConfigError\",\n (cause) =>\n new CredentialsError({\n message: `Failed to read ${name}.`,\n source,\n hints: hints[source],\n cause,\n }),\n ),\n )\n\n/**\n * Vercel exposes the current request's context on a well-known global symbol.\n * In Functions, the `x-vercel-oidc-token` header of the request lives there.\n * Mirrors `@vercel/oidc`'s `getVercelOidcTokenSync`, minus the dev-time\n * refresh that package also offers.\n */\nconst REQUEST_CONTEXT = Symbol.for(\"@vercel/request-context\")\n\ntype RequestContext = { readonly headers?: Record<string, string | undefined> }\n\nconst fromRequestContext: Lookup = Effect.sync(() => {\n const holder = globalThis as typeof globalThis & {\n [REQUEST_CONTEXT]?: { get?: () => RequestContext | undefined }\n }\n const token = holder[REQUEST_CONTEXT]?.get?.()?.headers?.[\"x-vercel-oidc-token\"]\n return token === undefined\n ? Option.none()\n : Option.some({ method: \"oidc\" as const, token: Redacted.make(token) })\n})\n\nconst firstOf = (lookups: ReadonlyArray<Lookup>): Lookup =>\n Effect.gen(function* () {\n for (const lookup of lookups) {\n const found = yield* lookup\n if (Option.isSome(found)) return found\n }\n return Option.none()\n })\n\nconst require = (lookup: Lookup, source: Source, message: string) =>\n Layer.succeed(Credentials)(\n Effect.flatMap(lookup, (found) =>\n Option.isSome(found)\n ? Effect.succeed(found.value)\n : Effect.fail(new CredentialsError({ message, source, hints: hints[source] })),\n ),\n )\n\nconst apiKeyLookup = fromConfig(API_KEY_ENV, \"api-key\", \"env\")\n\nconst vercelOidcLookup = firstOf([\n fromRequestContext,\n fromConfig(OIDC_TOKEN_ENV, \"oidc\", \"vercel-oidc\"),\n])\n\n/** A fixed API key. */\nexport const fromApiKey = (apiKey: string | Redacted.Redacted<string>): Layer.Layer<Credentials> =>\n Layer.succeed(Credentials)(\n Effect.succeed({\n method: \"api-key\",\n token: Redacted.isRedacted(apiKey) ? apiKey : Redacted.make(apiKey),\n }),\n )\n\n/** `AI_GATEWAY_API_KEY`: local dev, CI, or anywhere off Vercel. */\nexport const fromEnv = (): Layer.Layer<Credentials> =>\n require(apiKeyLookup, \"env\", `${API_KEY_ENV} is not set.`)\n\n/**\n * The OIDC token Vercel issues to the deployment: the `x-vercel-oidc-token`\n * header of the current request in Functions, else `VERCEL_OIDC_TOKEN` in\n * builds and after `vercel env pull`. Not refreshed when it expires locally.\n */\nexport const fromVercelOidc = (): Layer.Layer<Credentials> =>\n require(vercelOidcLookup, \"vercel-oidc\", \"No Vercel OIDC token is available.\")\n\n/** `fromEnv`, then `fromVercelOidc`. The usual choice for an app deployed to Vercel. */\nexport const fromChain = (): Layer.Layer<Credentials> =>\n require(firstOf([apiKeyLookup, vercelOidcLookup]), \"chain\", \"No AI Gateway credential found.\")\n","/**\n * Vercel AI Gateway as an Anthropic Messages API client for Effect AI.\n *\n * The gateway serves the Anthropic Messages API at its root URL and accepts\n * any model it lists, named `provider/model` (for example\n * `anthropic/claude-sonnet-4.5` or `google/gemini-2.5-flash`). Effect AI's\n * Anthropic provider only needs the base URL and the credential swapped, plus\n * two small dialect fixes (see `dropNullCacheControl` and\n * `fillMissingResponseKeys`), so this module is the one place the gateway is\n * configured.\n *\n * Structured output (`output_config.format`) works for every provider behind\n * the gateway, so the Anthropic provider's default of native structured\n * output holds for models it does not recognise.\n *\n * @example\n * ```ts\n * import { AiGateway, Credentials } from \"effect-ai-vercel-gateway\"\n * import { AnthropicLanguageModel } from \"@effect/ai-anthropic\"\n * import { Layer } from \"effect\"\n * import { FetchHttpClient } from \"effect/unstable/http\"\n *\n * const Model = AnthropicLanguageModel.layer({ model: \"anthropic/claude-sonnet-4.5\" }).pipe(\n * Layer.provide(AiGateway.layer),\n * Layer.provide([Credentials.fromChain(), FetchHttpClient.layer]),\n * )\n * ```\n */\n\nimport { AnthropicClient } from \"@effect/ai-anthropic\"\nimport { Effect, Layer } from \"effect\"\nimport {\n HttpBody,\n HttpClient,\n HttpClientError,\n HttpClientRequest,\n HttpClientResponse,\n} from \"effect/unstable/http\"\nimport * as Credentials from \"./Credentials.js\"\n\n/** Base URL of the Vercel AI Gateway. */\nexport const AI_GATEWAY_URL = \"https://ai-gateway.vercel.sh\"\n\n/**\n * Builds the Anthropic client against the gateway, authenticating each\n * request with `credentials`. A credential failure fails that request as an\n * `HttpClientError`, which Effect AI surfaces as a network `AiError`.\n */\nexport const make = (credentials: typeof Credentials.Credentials.Service) =>\n AnthropicClient.make({\n apiUrl: AI_GATEWAY_URL,\n transformClient: (client) =>\n client.pipe(\n HttpClient.mapRequestEffect(authenticate(credentials)),\n HttpClient.mapRequest(dropNullCacheControl),\n HttpClient.transformResponse(Effect.flatMap(fillMissingResponseKeys)),\n ),\n })\n\n/**\n * `AnthropicClient` backed by the gateway. Needs `Credentials` (see the\n * `Credentials` module's `from*` layers) and an `HttpClient`. Building never\n * fails; without a usable credential, the requests fail.\n */\nexport const layer: Layer.Layer<\n AnthropicClient.AnthropicClient,\n never,\n Credentials.Credentials | HttpClient.HttpClient\n> = Layer.effect(AnthropicClient.AnthropicClient)(\n Effect.flatMap(Effect.service(Credentials.Credentials), make),\n)\n\nfunction authenticate(credentials: typeof Credentials.Credentials.Service) {\n return (request: HttpClientRequest.HttpClientRequest) =>\n credentials.pipe(\n Effect.map((resolved) =>\n HttpClientRequest.setHeaders(request, Credentials.formatHeaders(resolved)),\n ),\n Effect.mapError(\n (error) =>\n new HttpClientError.HttpClientError({\n reason: new HttpClientError.TransportError({\n request,\n cause: error,\n description: \"AI Gateway credential unavailable\",\n }),\n }),\n ),\n )\n}\n\n/**\n * The Effect provider writes `\"cache_control\": null` on every content block\n * it has no cache setting for. Anthropic accepts that; the gateway rejects\n * the request with `messages.0.content: Invalid input`. Remove the null keys.\n */\nexport function dropNullCacheControl(\n request: HttpClientRequest.HttpClientRequest,\n): HttpClientRequest.HttpClientRequest {\n const body = request.body\n if (body._tag !== \"Uint8Array\" || !isJson(body.contentType)) {\n return request\n }\n const json: unknown = JSON.parse(new TextDecoder().decode(body.body))\n return HttpClientRequest.setBody(\n request,\n HttpBody.jsonUnsafe(withoutNullCacheControl(json), body.contentType),\n )\n}\n\nfunction withoutNullCacheControl(value: unknown): unknown {\n if (Array.isArray(value)) return value.map(withoutNullCacheControl)\n if (value === null || typeof value !== \"object\") return value\n const result: Record<string, unknown> = {}\n for (const [key, entry] of Object.entries(value)) {\n if (key === \"cache_control\" && entry === null) continue\n result[key] = withoutNullCacheControl(entry)\n }\n return result\n}\n\n/**\n * The gateway's Anthropic-shaped responses leave out keys that Anthropic\n * always sends and the Effect provider's schema requires: the cache and\n * service-tier usage fields, the `signature` of a thinking block from a\n * non-Anthropic model, and `type`/`request_id` on error envelopes, whose\n * `error.type` may also be a gateway-specific value. Fill them with the values\n * Anthropic uses when there is nothing to report. Streaming responses are not\n * JSON and pass through untouched.\n */\nexport function fillMissingResponseKeys(\n response: HttpClientResponse.HttpClientResponse,\n): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> {\n if (!isJson(response.headers[\"content-type\"] ?? \"\")) {\n return Effect.succeed(response)\n }\n return Effect.map(response.text, (text) => {\n const body = JSON.parse(text) as Record<string, unknown>\n return HttpClientResponse.fromWeb(\n response.request,\n new Response(JSON.stringify(patchResponseBody(body)), {\n status: response.status,\n headers: response.headers,\n }),\n )\n })\n}\n\nexport function patchResponseBody(body: Record<string, unknown>): Record<string, unknown> {\n if (body.type === \"message\") {\n const usage = (body.usage ?? {}) as Record<string, unknown>\n body.usage = {\n cache_creation: null,\n cache_creation_input_tokens: null,\n cache_read_input_tokens: null,\n inference_geo: null,\n service_tier: null,\n ...usage,\n }\n if (Array.isArray(body.content)) {\n body.content = body.content.map((block: unknown) =>\n isRecord(block) && block.type === \"thinking\" ? { signature: \"\", ...block } : block,\n )\n }\n return body\n }\n if (isRecord(body.error)) {\n const error = ANTHROPIC_ERROR_TYPES.has(String(body.error.type))\n ? body.error\n : { ...body.error, type: \"api_error\" }\n return { type: \"error\", request_id: null, ...body, error }\n }\n return body\n}\n\nconst ANTHROPIC_ERROR_TYPES = new Set([\n \"invalid_request_error\",\n \"authentication_error\",\n \"billing_error\",\n \"permission_error\",\n \"not_found_error\",\n \"rate_limit_error\",\n \"timeout_error\",\n \"api_error\",\n \"overloaded_error\",\n])\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n}\n\nfunction isJson(contentType: string): boolean {\n return contentType.startsWith(\"application/json\")\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __exportAll = (all, no_symbols) => {
|
|
4
|
+
let target = {};
|
|
5
|
+
for (var name in all) __defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true
|
|
8
|
+
});
|
|
9
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10
|
+
return target;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { __exportAll as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "effect-ai-vercel-gateway",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vercel AI Gateway client for Effect AI (@effect/ai-anthropic)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai-gateway",
|
|
7
|
+
"anthropic",
|
|
8
|
+
"effect",
|
|
9
|
+
"effect-ai",
|
|
10
|
+
"vercel"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/duailibe/effect-ai-vercel-gateway#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/duailibe/effect-ai-vercel-gateway/issues"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Lucas Duailibe <lucasds@gmail.com>",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/duailibe/effect-ai-vercel-gateway.git"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./dist/index.js",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@effect/ai-anthropic": "4.0.0-rc.115",
|
|
37
|
+
"@effect/language-service": "^0.87.2",
|
|
38
|
+
"@effect/vitest": "4.0.0-rc.115",
|
|
39
|
+
"@types/node": "^24",
|
|
40
|
+
"effect": "4.0.0-rc.115",
|
|
41
|
+
"oxfmt": "^0.68.0",
|
|
42
|
+
"oxlint": "^1.83.0",
|
|
43
|
+
"tsdown": "^0.23.0",
|
|
44
|
+
"typescript": "^5.9",
|
|
45
|
+
"vitest": "^5.0.1"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@effect/ai-anthropic": "^4.0.0-rc.112",
|
|
49
|
+
"effect": "^4.0.0-rc.112"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsdown",
|
|
53
|
+
"check": "tsc --noEmit",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"lint": "oxlint",
|
|
56
|
+
"fmt": "oxfmt",
|
|
57
|
+
"fmt:check": "oxfmt --check"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/AiGateway.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel AI Gateway as an Anthropic Messages API client for Effect AI.
|
|
3
|
+
*
|
|
4
|
+
* The gateway serves the Anthropic Messages API at its root URL and accepts
|
|
5
|
+
* any model it lists, named `provider/model` (for example
|
|
6
|
+
* `anthropic/claude-sonnet-4.5` or `google/gemini-2.5-flash`). Effect AI's
|
|
7
|
+
* Anthropic provider only needs the base URL and the credential swapped, plus
|
|
8
|
+
* two small dialect fixes (see `dropNullCacheControl` and
|
|
9
|
+
* `fillMissingResponseKeys`), so this module is the one place the gateway is
|
|
10
|
+
* configured.
|
|
11
|
+
*
|
|
12
|
+
* Structured output (`output_config.format`) works for every provider behind
|
|
13
|
+
* the gateway, so the Anthropic provider's default of native structured
|
|
14
|
+
* output holds for models it does not recognise.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { AiGateway, Credentials } from "effect-ai-vercel-gateway"
|
|
19
|
+
* import { AnthropicLanguageModel } from "@effect/ai-anthropic"
|
|
20
|
+
* import { Layer } from "effect"
|
|
21
|
+
* import { FetchHttpClient } from "effect/unstable/http"
|
|
22
|
+
*
|
|
23
|
+
* const Model = AnthropicLanguageModel.layer({ model: "anthropic/claude-sonnet-4.5" }).pipe(
|
|
24
|
+
* Layer.provide(AiGateway.layer),
|
|
25
|
+
* Layer.provide([Credentials.fromChain(), FetchHttpClient.layer]),
|
|
26
|
+
* )
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { AnthropicClient } from "@effect/ai-anthropic"
|
|
31
|
+
import { Effect, Layer } from "effect"
|
|
32
|
+
import {
|
|
33
|
+
HttpBody,
|
|
34
|
+
HttpClient,
|
|
35
|
+
HttpClientError,
|
|
36
|
+
HttpClientRequest,
|
|
37
|
+
HttpClientResponse,
|
|
38
|
+
} from "effect/unstable/http"
|
|
39
|
+
import * as Credentials from "./Credentials.js"
|
|
40
|
+
|
|
41
|
+
/** Base URL of the Vercel AI Gateway. */
|
|
42
|
+
export const AI_GATEWAY_URL = "https://ai-gateway.vercel.sh"
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Builds the Anthropic client against the gateway, authenticating each
|
|
46
|
+
* request with `credentials`. A credential failure fails that request as an
|
|
47
|
+
* `HttpClientError`, which Effect AI surfaces as a network `AiError`.
|
|
48
|
+
*/
|
|
49
|
+
export const make = (credentials: typeof Credentials.Credentials.Service) =>
|
|
50
|
+
AnthropicClient.make({
|
|
51
|
+
apiUrl: AI_GATEWAY_URL,
|
|
52
|
+
transformClient: (client) =>
|
|
53
|
+
client.pipe(
|
|
54
|
+
HttpClient.mapRequestEffect(authenticate(credentials)),
|
|
55
|
+
HttpClient.mapRequest(dropNullCacheControl),
|
|
56
|
+
HttpClient.transformResponse(Effect.flatMap(fillMissingResponseKeys)),
|
|
57
|
+
),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `AnthropicClient` backed by the gateway. Needs `Credentials` (see the
|
|
62
|
+
* `Credentials` module's `from*` layers) and an `HttpClient`. Building never
|
|
63
|
+
* fails; without a usable credential, the requests fail.
|
|
64
|
+
*/
|
|
65
|
+
export const layer: Layer.Layer<
|
|
66
|
+
AnthropicClient.AnthropicClient,
|
|
67
|
+
never,
|
|
68
|
+
Credentials.Credentials | HttpClient.HttpClient
|
|
69
|
+
> = Layer.effect(AnthropicClient.AnthropicClient)(
|
|
70
|
+
Effect.flatMap(Effect.service(Credentials.Credentials), make),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
function authenticate(credentials: typeof Credentials.Credentials.Service) {
|
|
74
|
+
return (request: HttpClientRequest.HttpClientRequest) =>
|
|
75
|
+
credentials.pipe(
|
|
76
|
+
Effect.map((resolved) =>
|
|
77
|
+
HttpClientRequest.setHeaders(request, Credentials.formatHeaders(resolved)),
|
|
78
|
+
),
|
|
79
|
+
Effect.mapError(
|
|
80
|
+
(error) =>
|
|
81
|
+
new HttpClientError.HttpClientError({
|
|
82
|
+
reason: new HttpClientError.TransportError({
|
|
83
|
+
request,
|
|
84
|
+
cause: error,
|
|
85
|
+
description: "AI Gateway credential unavailable",
|
|
86
|
+
}),
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The Effect provider writes `"cache_control": null` on every content block
|
|
94
|
+
* it has no cache setting for. Anthropic accepts that; the gateway rejects
|
|
95
|
+
* the request with `messages.0.content: Invalid input`. Remove the null keys.
|
|
96
|
+
*/
|
|
97
|
+
export function dropNullCacheControl(
|
|
98
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
99
|
+
): HttpClientRequest.HttpClientRequest {
|
|
100
|
+
const body = request.body
|
|
101
|
+
if (body._tag !== "Uint8Array" || !isJson(body.contentType)) {
|
|
102
|
+
return request
|
|
103
|
+
}
|
|
104
|
+
const json: unknown = JSON.parse(new TextDecoder().decode(body.body))
|
|
105
|
+
return HttpClientRequest.setBody(
|
|
106
|
+
request,
|
|
107
|
+
HttpBody.jsonUnsafe(withoutNullCacheControl(json), body.contentType),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function withoutNullCacheControl(value: unknown): unknown {
|
|
112
|
+
if (Array.isArray(value)) return value.map(withoutNullCacheControl)
|
|
113
|
+
if (value === null || typeof value !== "object") return value
|
|
114
|
+
const result: Record<string, unknown> = {}
|
|
115
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
116
|
+
if (key === "cache_control" && entry === null) continue
|
|
117
|
+
result[key] = withoutNullCacheControl(entry)
|
|
118
|
+
}
|
|
119
|
+
return result
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The gateway's Anthropic-shaped responses leave out keys that Anthropic
|
|
124
|
+
* always sends and the Effect provider's schema requires: the cache and
|
|
125
|
+
* service-tier usage fields, the `signature` of a thinking block from a
|
|
126
|
+
* non-Anthropic model, and `type`/`request_id` on error envelopes, whose
|
|
127
|
+
* `error.type` may also be a gateway-specific value. Fill them with the values
|
|
128
|
+
* Anthropic uses when there is nothing to report. Streaming responses are not
|
|
129
|
+
* JSON and pass through untouched.
|
|
130
|
+
*/
|
|
131
|
+
export function fillMissingResponseKeys(
|
|
132
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
133
|
+
): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> {
|
|
134
|
+
if (!isJson(response.headers["content-type"] ?? "")) {
|
|
135
|
+
return Effect.succeed(response)
|
|
136
|
+
}
|
|
137
|
+
return Effect.map(response.text, (text) => {
|
|
138
|
+
const body = JSON.parse(text) as Record<string, unknown>
|
|
139
|
+
return HttpClientResponse.fromWeb(
|
|
140
|
+
response.request,
|
|
141
|
+
new Response(JSON.stringify(patchResponseBody(body)), {
|
|
142
|
+
status: response.status,
|
|
143
|
+
headers: response.headers,
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function patchResponseBody(body: Record<string, unknown>): Record<string, unknown> {
|
|
150
|
+
if (body.type === "message") {
|
|
151
|
+
const usage = (body.usage ?? {}) as Record<string, unknown>
|
|
152
|
+
body.usage = {
|
|
153
|
+
cache_creation: null,
|
|
154
|
+
cache_creation_input_tokens: null,
|
|
155
|
+
cache_read_input_tokens: null,
|
|
156
|
+
inference_geo: null,
|
|
157
|
+
service_tier: null,
|
|
158
|
+
...usage,
|
|
159
|
+
}
|
|
160
|
+
if (Array.isArray(body.content)) {
|
|
161
|
+
body.content = body.content.map((block: unknown) =>
|
|
162
|
+
isRecord(block) && block.type === "thinking" ? { signature: "", ...block } : block,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
return body
|
|
166
|
+
}
|
|
167
|
+
if (isRecord(body.error)) {
|
|
168
|
+
const error = ANTHROPIC_ERROR_TYPES.has(String(body.error.type))
|
|
169
|
+
? body.error
|
|
170
|
+
: { ...body.error, type: "api_error" }
|
|
171
|
+
return { type: "error", request_id: null, ...body, error }
|
|
172
|
+
}
|
|
173
|
+
return body
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const ANTHROPIC_ERROR_TYPES = new Set([
|
|
177
|
+
"invalid_request_error",
|
|
178
|
+
"authentication_error",
|
|
179
|
+
"billing_error",
|
|
180
|
+
"permission_error",
|
|
181
|
+
"not_found_error",
|
|
182
|
+
"rate_limit_error",
|
|
183
|
+
"timeout_error",
|
|
184
|
+
"api_error",
|
|
185
|
+
"overloaded_error",
|
|
186
|
+
])
|
|
187
|
+
|
|
188
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
189
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isJson(contentType: string): boolean {
|
|
193
|
+
return contentType.startsWith("application/json")
|
|
194
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How gateway requests authenticate.
|
|
3
|
+
*
|
|
4
|
+
* `Credentials` holds an effect that produces the credential for one request.
|
|
5
|
+
* It runs per request rather than once at startup: on Vercel the OIDC token
|
|
6
|
+
* arrives on the request context and rotates, so it must not be cached. Pick
|
|
7
|
+
* a `from*` layer for where the credential comes from.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { Credentials } from "effect-ai-vercel-gateway"
|
|
12
|
+
*
|
|
13
|
+
* Credentials.fromChain() // AI_GATEWAY_API_KEY, else the Vercel OIDC token
|
|
14
|
+
* Credentials.fromEnv() // AI_GATEWAY_API_KEY only
|
|
15
|
+
* Credentials.fromVercelOidc() // the Vercel OIDC token only
|
|
16
|
+
* Credentials.fromApiKey("…") // a fixed value
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect"
|
|
21
|
+
|
|
22
|
+
/** The environment variable `fromEnv` reads. */
|
|
23
|
+
export const API_KEY_ENV = "AI_GATEWAY_API_KEY"
|
|
24
|
+
|
|
25
|
+
/** The environment variable `fromVercelOidc` falls back to. */
|
|
26
|
+
export const OIDC_TOKEN_ENV = "VERCEL_OIDC_TOKEN"
|
|
27
|
+
|
|
28
|
+
/** Which `from*` layer produced or failed to produce a credential. */
|
|
29
|
+
export type Source = "api-key" | "env" | "vercel-oidc" | "chain"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A credential ready to put on a request. `method` is what the gateway
|
|
33
|
+
* records as the auth method; it matches the values Vercel's own SDK sends.
|
|
34
|
+
*/
|
|
35
|
+
export interface Resolved {
|
|
36
|
+
readonly method: "api-key" | "oidc"
|
|
37
|
+
readonly token: Redacted.Redacted<string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** No credential could be produced for a gateway request. */
|
|
41
|
+
export class CredentialsError extends Data.TaggedError("AiGatewayCredentialsError")<{
|
|
42
|
+
readonly message: string
|
|
43
|
+
readonly source: Source
|
|
44
|
+
readonly hints: ReadonlyArray<string>
|
|
45
|
+
readonly cause?: unknown
|
|
46
|
+
}> {}
|
|
47
|
+
|
|
48
|
+
export class Credentials extends Context.Service<
|
|
49
|
+
Credentials,
|
|
50
|
+
Effect.Effect<Resolved, CredentialsError>
|
|
51
|
+
>()("effect-ai-vercel-gateway/Credentials") {}
|
|
52
|
+
|
|
53
|
+
/** The request headers that carry `credentials`. */
|
|
54
|
+
export const formatHeaders = (credentials: Resolved): Record<string, string> => ({
|
|
55
|
+
"x-api-key": Redacted.value(credentials.token),
|
|
56
|
+
"ai-gateway-auth-method": credentials.method,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const hints: Record<Source, ReadonlyArray<string>> = {
|
|
60
|
+
"api-key": [],
|
|
61
|
+
env: [`Set ${API_KEY_ENV} to an AI Gateway API key.`],
|
|
62
|
+
"vercel-oidc": [
|
|
63
|
+
"Run on Vercel, where each request carries an OIDC token.",
|
|
64
|
+
`Or run \`vercel env pull\` locally to populate ${OIDC_TOKEN_ENV}.`,
|
|
65
|
+
],
|
|
66
|
+
chain: [
|
|
67
|
+
`Set ${API_KEY_ENV} to an AI Gateway API key.`,
|
|
68
|
+
"Or run on Vercel / `vercel env pull` so an OIDC token is available.",
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where a credential may come from. `None` means the place is not configured,
|
|
74
|
+
* so a chain can move on; a failure means it is configured but unusable.
|
|
75
|
+
*/
|
|
76
|
+
type Lookup = Effect.Effect<Option.Option<Resolved>, CredentialsError>
|
|
77
|
+
|
|
78
|
+
const fromConfig = (name: string, method: Resolved["method"], source: Source): Lookup =>
|
|
79
|
+
Config.option(Config.Redacted(name)).pipe(
|
|
80
|
+
Effect.map(Option.map((token) => ({ method, token }))),
|
|
81
|
+
Effect.catchTag(
|
|
82
|
+
"ConfigError",
|
|
83
|
+
(cause) =>
|
|
84
|
+
new CredentialsError({
|
|
85
|
+
message: `Failed to read ${name}.`,
|
|
86
|
+
source,
|
|
87
|
+
hints: hints[source],
|
|
88
|
+
cause,
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Vercel exposes the current request's context on a well-known global symbol.
|
|
95
|
+
* In Functions, the `x-vercel-oidc-token` header of the request lives there.
|
|
96
|
+
* Mirrors `@vercel/oidc`'s `getVercelOidcTokenSync`, minus the dev-time
|
|
97
|
+
* refresh that package also offers.
|
|
98
|
+
*/
|
|
99
|
+
const REQUEST_CONTEXT = Symbol.for("@vercel/request-context")
|
|
100
|
+
|
|
101
|
+
type RequestContext = { readonly headers?: Record<string, string | undefined> }
|
|
102
|
+
|
|
103
|
+
const fromRequestContext: Lookup = Effect.sync(() => {
|
|
104
|
+
const holder = globalThis as typeof globalThis & {
|
|
105
|
+
[REQUEST_CONTEXT]?: { get?: () => RequestContext | undefined }
|
|
106
|
+
}
|
|
107
|
+
const token = holder[REQUEST_CONTEXT]?.get?.()?.headers?.["x-vercel-oidc-token"]
|
|
108
|
+
return token === undefined
|
|
109
|
+
? Option.none()
|
|
110
|
+
: Option.some({ method: "oidc" as const, token: Redacted.make(token) })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const firstOf = (lookups: ReadonlyArray<Lookup>): Lookup =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
for (const lookup of lookups) {
|
|
116
|
+
const found = yield* lookup
|
|
117
|
+
if (Option.isSome(found)) return found
|
|
118
|
+
}
|
|
119
|
+
return Option.none()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const require = (lookup: Lookup, source: Source, message: string) =>
|
|
123
|
+
Layer.succeed(Credentials)(
|
|
124
|
+
Effect.flatMap(lookup, (found) =>
|
|
125
|
+
Option.isSome(found)
|
|
126
|
+
? Effect.succeed(found.value)
|
|
127
|
+
: Effect.fail(new CredentialsError({ message, source, hints: hints[source] })),
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const apiKeyLookup = fromConfig(API_KEY_ENV, "api-key", "env")
|
|
132
|
+
|
|
133
|
+
const vercelOidcLookup = firstOf([
|
|
134
|
+
fromRequestContext,
|
|
135
|
+
fromConfig(OIDC_TOKEN_ENV, "oidc", "vercel-oidc"),
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
/** A fixed API key. */
|
|
139
|
+
export const fromApiKey = (apiKey: string | Redacted.Redacted<string>): Layer.Layer<Credentials> =>
|
|
140
|
+
Layer.succeed(Credentials)(
|
|
141
|
+
Effect.succeed({
|
|
142
|
+
method: "api-key",
|
|
143
|
+
token: Redacted.isRedacted(apiKey) ? apiKey : Redacted.make(apiKey),
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
/** `AI_GATEWAY_API_KEY`: local dev, CI, or anywhere off Vercel. */
|
|
148
|
+
export const fromEnv = (): Layer.Layer<Credentials> =>
|
|
149
|
+
require(apiKeyLookup, "env", `${API_KEY_ENV} is not set.`)
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The OIDC token Vercel issues to the deployment: the `x-vercel-oidc-token`
|
|
153
|
+
* header of the current request in Functions, else `VERCEL_OIDC_TOKEN` in
|
|
154
|
+
* builds and after `vercel env pull`. Not refreshed when it expires locally.
|
|
155
|
+
*/
|
|
156
|
+
export const fromVercelOidc = (): Layer.Layer<Credentials> =>
|
|
157
|
+
require(vercelOidcLookup, "vercel-oidc", "No Vercel OIDC token is available.")
|
|
158
|
+
|
|
159
|
+
/** `fromEnv`, then `fromVercelOidc`. The usual choice for an app deployed to Vercel. */
|
|
160
|
+
export const fromChain = (): Layer.Layer<Credentials> =>
|
|
161
|
+
require(firstOf([apiKeyLookup, vercelOidcLookup]), "chain", "No AI Gateway credential found.")
|
package/src/index.ts
ADDED