effect-vercel 0.3.0 → 0.5.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/README.md +53 -23
- package/dist/AiGateway.d.ts +44 -0
- package/dist/{ai-gateway.js → AiGateway.js} +5 -106
- package/dist/AiGateway.js.map +1 -0
- package/dist/AiGatewayCredentials.d.ts +45 -0
- package/dist/AiGatewayCredentials.js +94 -0
- package/dist/AiGatewayCredentials.js.map +1 -0
- package/dist/Functions.d.ts +14 -0
- package/dist/Functions.js +34 -0
- package/dist/Functions.js.map +1 -0
- package/dist/Oidc.d.ts +23 -0
- package/dist/Oidc.js +59 -0
- package/dist/Oidc.js.map +1 -0
- package/dist/{runtime-cache.d.ts → RuntimeCache.d.ts} +5 -8
- package/dist/{runtime-cache.js → RuntimeCache.js} +6 -12
- package/dist/RuntimeCache.js.map +1 -0
- package/package.json +9 -6
- package/src/AiGateway.ts +1 -1
- package/src/AiGatewayCredentials.ts +53 -55
- package/src/Functions.ts +34 -0
- package/src/Oidc.ts +75 -0
- package/src/{VercelRuntimeCache.ts → RuntimeCache.ts} +2 -2
- package/dist/VercelOidc-DCkdXW3E.js +0 -70
- package/dist/VercelOidc-DCkdXW3E.js.map +0 -1
- package/dist/ai-gateway.d.ts +0 -87
- package/dist/ai-gateway.js.map +0 -1
- package/dist/oidc.d.ts +0 -27
- package/dist/oidc.js +0 -2
- package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
- package/dist/runtime-cache.js.map +0 -1
- package/src/VercelOidc.ts +0 -86
- package/src/ai-gateway.ts +0 -2
- package/src/oidc.ts +0 -1
- package/src/runtime-cache.ts +0 -1
package/README.md
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
# effect-vercel
|
|
2
2
|
|
|
3
|
-
[Effect](https://effect.website) (v4) integrations for Vercel. Each
|
|
3
|
+
[Effect](https://effect.website) (v4) integrations for Vercel. Each module is its own entry
|
|
4
|
+
point, imported as a namespace like Effect's own modules:
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
|
6
|
+
```ts
|
|
7
|
+
import * as AiGateway from "effect-vercel/AiGateway"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
| Module | What |
|
|
11
|
+
| ------------------------------------ | ------------------------------------------------------------------------------- |
|
|
12
|
+
| `effect-vercel/AiGateway` | [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) for Effect AI |
|
|
13
|
+
| `effect-vercel/AiGatewayCredentials` | How AI Gateway requests authenticate |
|
|
14
|
+
| `effect-vercel/Functions` | Bindings for `@vercel/functions`: `waitUntil` |
|
|
15
|
+
| `effect-vercel/Oidc` | The OIDC token Vercel issues to a deployment |
|
|
16
|
+
| `effect-vercel/RuntimeCache` | [Runtime Cache](https://vercel.com/docs/runtime-cache) for Effect `Persistence` |
|
|
10
17
|
|
|
11
18
|
## Install
|
|
12
19
|
|
|
13
20
|
```sh
|
|
14
21
|
pnpm add effect-vercel effect
|
|
15
|
-
pnpm add @effect/ai-anthropic # only for effect-vercel/
|
|
16
|
-
pnpm add @vercel/functions # only for effect-vercel/
|
|
22
|
+
pnpm add @effect/ai-anthropic # only for effect-vercel/AiGateway
|
|
23
|
+
pnpm add @vercel/functions # only for effect-vercel/Functions and RuntimeCache
|
|
17
24
|
```
|
|
18
25
|
|
|
19
26
|
## AI Gateway
|
|
@@ -26,7 +33,7 @@ dialect and what the provider's schemas expect.
|
|
|
26
33
|
### Usage
|
|
27
34
|
|
|
28
35
|
```ts
|
|
29
|
-
import
|
|
36
|
+
import * as AiGateway from "effect-vercel/AiGateway"
|
|
30
37
|
import { AnthropicLanguageModel } from "@effect/ai-anthropic"
|
|
31
38
|
import { Effect, Layer } from "effect"
|
|
32
39
|
import { LanguageModel } from "effect/ai"
|
|
@@ -56,6 +63,8 @@ To pick the source yourself, use `AiGateway.layerWithoutCredentials` and provide
|
|
|
56
63
|
`AiGatewayCredentials` layer:
|
|
57
64
|
|
|
58
65
|
```ts
|
|
66
|
+
import * as AiGatewayCredentials from "effect-vercel/AiGatewayCredentials"
|
|
67
|
+
|
|
59
68
|
AnthropicLanguageModel.layer({ model: "google/gemini-2.5-flash" }).pipe(
|
|
60
69
|
Layer.provide(AiGateway.layerWithoutCredentials),
|
|
61
70
|
Layer.provide([AiGatewayCredentials.layerFromApiKey(key), FetchHttpClient.layer]),
|
|
@@ -64,9 +73,9 @@ AnthropicLanguageModel.layer({ model: "google/gemini-2.5-flash" }).pipe(
|
|
|
64
73
|
|
|
65
74
|
| Layer | Source |
|
|
66
75
|
| ------------------------------------------- | --------------------------------------------------------- |
|
|
67
|
-
| `AiGatewayCredentials.layer` | The default: `layerFromEnv`, then `
|
|
76
|
+
| `AiGatewayCredentials.layer` | The default: `layerFromEnv`, then `layerFromOidc`. |
|
|
68
77
|
| `AiGatewayCredentials.layerFromEnv` | `AI_GATEWAY_API_KEY`. Local dev, CI, anywhere off Vercel. |
|
|
69
|
-
| `AiGatewayCredentials.
|
|
78
|
+
| `AiGatewayCredentials.layerFromOidc` | The Vercel OIDC token (see [OIDC](#oidc)). |
|
|
70
79
|
| `AiGatewayCredentials.layerFromApiKey(key)` | A fixed key. Tests, custom wiring. |
|
|
71
80
|
|
|
72
81
|
The credential is resolved on every request, so a rotating token is always current.
|
|
@@ -96,31 +105,52 @@ Vercel issues each deployment an OIDC token. Use it for the AI Gateway, or excha
|
|
|
96
105
|
cloud credentials (AWS `AssumeRoleWithWebIdentity`, GCP workload identity federation).
|
|
97
106
|
|
|
98
107
|
```ts
|
|
99
|
-
import
|
|
108
|
+
import * as Oidc from "effect-vercel/Oidc"
|
|
100
109
|
import { Effect, Redacted } from "effect"
|
|
101
110
|
|
|
102
111
|
const program = Effect.gen(function* () {
|
|
103
|
-
const token = yield*
|
|
112
|
+
const token = yield* Oidc.ambientToken
|
|
104
113
|
// Redacted.value(token) is the web identity token
|
|
105
114
|
})
|
|
106
115
|
```
|
|
107
116
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
- `VercelOidc.find` is the same but returns `Option.none()` instead of failing, for fallback
|
|
112
|
-
chains.
|
|
117
|
+
`Oidc.ambientToken` reads the `x-vercel-oidc-token` header of the current request
|
|
118
|
+
(Functions), else `VERCEL_OIDC_TOKEN` (builds, `vercel env pull`). Empty values count as
|
|
119
|
+
unset. It fails with an `OidcError` when neither is set.
|
|
113
120
|
|
|
114
|
-
|
|
121
|
+
It reads the token on every run, since it rotates per request. It does not refresh an
|
|
115
122
|
expired local token; re-run `vercel env pull`.
|
|
116
123
|
|
|
124
|
+
## Functions
|
|
125
|
+
|
|
126
|
+
`Functions.waitUntil(fiber)` keeps the Function running until `fiber` ends, as `waitUntil`
|
|
127
|
+
from `@vercel/functions` does for a promise. Use it to finish work after the response is sent.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import * as Functions from "effect-vercel/Functions"
|
|
131
|
+
import { Effect } from "effect"
|
|
132
|
+
|
|
133
|
+
const handler = Effect.gen(function* () {
|
|
134
|
+
yield* Effect.forkDetach(sendReceipt).pipe(Effect.tap(Functions.waitUntil))
|
|
135
|
+
return new Response("OK")
|
|
136
|
+
})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- Fork with `Effect.forkDetach`, or into a scope that outlives the request. A child fiber is
|
|
140
|
+
interrupted when the request fiber ends, whatever `waitUntil` says.
|
|
141
|
+
- The fiber keeps the request's services. Acquire resources it needs inside its own
|
|
142
|
+
`Effect.scoped`, since the request's resources may be released first.
|
|
143
|
+
- Failures are not reported. Handle or log them in the forked effect.
|
|
144
|
+
- Vercel stops the work when the Function reaches its maximum duration.
|
|
145
|
+
- Off Vercel, `waitUntil` does nothing and the fiber runs in the background.
|
|
146
|
+
|
|
117
147
|
## Runtime Cache
|
|
118
148
|
|
|
119
|
-
`
|
|
149
|
+
`RuntimeCache.layer` provides Effect's `Persistence`, stored in the Vercel Runtime Cache.
|
|
120
150
|
Use it with `PersistedCache` to share results across Function instances in a region.
|
|
121
151
|
|
|
122
152
|
```ts
|
|
123
|
-
import
|
|
153
|
+
import * as RuntimeCache from "effect-vercel/RuntimeCache"
|
|
124
154
|
import { Effect, Schema } from "effect"
|
|
125
155
|
import { Persistable, PersistedCache } from "effect/persistence"
|
|
126
156
|
|
|
@@ -136,14 +166,14 @@ const program = Effect.gen(function* () {
|
|
|
136
166
|
timeToLive: () => "1 hour",
|
|
137
167
|
})
|
|
138
168
|
return yield* users.get(new GetUser({ id: 1 }))
|
|
139
|
-
}).pipe(Effect.scoped, Effect.provide(
|
|
169
|
+
}).pipe(Effect.scoped, Effect.provide(RuntimeCache.layer))
|
|
140
170
|
```
|
|
141
171
|
|
|
142
172
|
- Off Vercel, `@vercel/functions` falls back to an in-memory cache, so the layer works in
|
|
143
173
|
local dev and tests.
|
|
144
174
|
- Entries are keyed by `[storeId, primaryKey]`, hashed with SHA-256. Only the hash reaches
|
|
145
175
|
Vercel. `getCache`'s default hash is 32-bit and can collide, so
|
|
146
|
-
`
|
|
176
|
+
`RuntimeCache.makeBacking(cache)` expects a cache with a stronger `keyHashFunction` if
|
|
147
177
|
you build your own.
|
|
148
178
|
- Each entry is tagged `effect-persistence:<sha256 of storeId>`. `clear` expires that tag.
|
|
149
179
|
- TTLs round up to whole seconds.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { AiGatewayCredentials, t as AiGatewayCredentials_d_exports } from "./AiGatewayCredentials.js";
|
|
2
|
+
import { AnthropicClient } from "@effect/ai-anthropic";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
5
|
+
//#region src/AiGateway.d.ts
|
|
6
|
+
/** Base URL of the Vercel AI Gateway. */
|
|
7
|
+
export declare const AI_GATEWAY_URL = "https://ai-gateway.vercel.sh";
|
|
8
|
+
/**
|
|
9
|
+
* Builds the Anthropic client against the gateway, authenticating each
|
|
10
|
+
* request with `credentials`. A credential failure fails that request as an
|
|
11
|
+
* `HttpClientError`, which Effect AI surfaces as a network `AiError`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const make: (credentials: typeof AiGatewayCredentials.Service) => Effect.Effect<AnthropicClient.Service, never, HttpClient.HttpClient>;
|
|
14
|
+
/**
|
|
15
|
+
* `AnthropicClient` backed by the gateway, authenticating with the
|
|
16
|
+
* `AiGatewayCredentials` you provide (see that module's `layerFrom*` layers).
|
|
17
|
+
* Needs an `HttpClient` too.
|
|
18
|
+
*/
|
|
19
|
+
export declare const layerWithoutCredentials: Layer.Layer<AnthropicClient.AnthropicClient, never, AiGatewayCredentials | HttpClient.HttpClient>;
|
|
20
|
+
/**
|
|
21
|
+
* `layerWithoutCredentials` with the default `AiGatewayCredentials.layer`:
|
|
22
|
+
* `AI_GATEWAY_API_KEY`, else the Vercel OIDC token. Needs an `HttpClient`.
|
|
23
|
+
* Building never fails; without a usable credential, the requests fail.
|
|
24
|
+
*/
|
|
25
|
+
export declare const layer: Layer.Layer<AnthropicClient.AnthropicClient, never, HttpClient.HttpClient>;
|
|
26
|
+
/**
|
|
27
|
+
* The Effect provider writes `"cache_control": null` on every content block
|
|
28
|
+
* it has no cache setting for. Anthropic accepts that; the gateway rejects
|
|
29
|
+
* the request with `messages.0.content: Invalid input`. Remove the null keys.
|
|
30
|
+
*/
|
|
31
|
+
export declare function dropNullCacheControl(request: HttpClientRequest.HttpClientRequest): HttpClientRequest.HttpClientRequest;
|
|
32
|
+
/**
|
|
33
|
+
* The gateway's Anthropic-shaped responses leave out keys that Anthropic
|
|
34
|
+
* always sends and the Effect provider's schema requires: the cache and
|
|
35
|
+
* service-tier usage fields, the `signature` of a thinking block from a
|
|
36
|
+
* non-Anthropic model, and `type`/`request_id` on error envelopes, whose
|
|
37
|
+
* `error.type` may also be a gateway-specific value. Fill them with the values
|
|
38
|
+
* Anthropic uses when there is nothing to report. Streaming responses are not
|
|
39
|
+
* JSON and pass through untouched.
|
|
40
|
+
*/
|
|
41
|
+
export declare function fillMissingResponseKeys(response: HttpClientResponse.HttpClientResponse): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>;
|
|
42
|
+
export declare function patchResponseBody(body: Record<string, unknown>): Record<string, unknown>;
|
|
43
|
+
//#endregion
|
|
44
|
+
//# sourceMappingURL=AiGateway.d.ts.map
|
|
@@ -1,99 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as find, r as hints$1 } from "./VercelOidc-DCkdXW3E.js";
|
|
1
|
+
import { AiGatewayCredentials, formatHeaders, layer as layer$1 } from "./AiGatewayCredentials.js";
|
|
3
2
|
import { AnthropicClient } from "@effect/ai-anthropic";
|
|
4
|
-
import {
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
5
4
|
import { HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
6
|
-
//#region src/AiGatewayCredentials.ts
|
|
7
|
-
/**
|
|
8
|
-
* How gateway requests authenticate.
|
|
9
|
-
*
|
|
10
|
-
* `AiGatewayCredentials` holds an effect that produces the credential for one
|
|
11
|
-
* request. It runs per request rather than once at startup: on Vercel the OIDC
|
|
12
|
-
* token arrives on the request context and rotates, so it must not be cached.
|
|
13
|
-
* `layer` is the default; pick a `layerFrom*` layer to use one source only.
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* ```ts
|
|
17
|
-
* import { AiGatewayCredentials } from "effect-vercel/ai-gateway"
|
|
18
|
-
*
|
|
19
|
-
* AiGatewayCredentials.layer // AI_GATEWAY_API_KEY, else the Vercel OIDC token
|
|
20
|
-
* AiGatewayCredentials.layerFromEnv // AI_GATEWAY_API_KEY only
|
|
21
|
-
* AiGatewayCredentials.layerFromVercelOidc // the Vercel OIDC token only
|
|
22
|
-
* AiGatewayCredentials.layerFromApiKey("…") // a fixed value
|
|
23
|
-
* ```
|
|
24
|
-
*/
|
|
25
|
-
var AiGatewayCredentials_exports = /* @__PURE__ */ __exportAll({
|
|
26
|
-
API_KEY_ENV: () => API_KEY_ENV,
|
|
27
|
-
AiGatewayCredentials: () => AiGatewayCredentials,
|
|
28
|
-
AiGatewayCredentialsError: () => AiGatewayCredentialsError,
|
|
29
|
-
formatHeaders: () => formatHeaders,
|
|
30
|
-
layer: () => layer$1,
|
|
31
|
-
layerFromApiKey: () => layerFromApiKey,
|
|
32
|
-
layerFromEnv: () => layerFromEnv,
|
|
33
|
-
layerFromVercelOidc: () => layerFromVercelOidc
|
|
34
|
-
});
|
|
35
|
-
/** The environment variable `layerFromEnv` reads. */
|
|
36
|
-
const API_KEY_ENV = "AI_GATEWAY_API_KEY";
|
|
37
|
-
/** No credential could be produced for a gateway request. */
|
|
38
|
-
var AiGatewayCredentialsError = class extends Data.TaggedError("AiGatewayCredentialsError") {};
|
|
39
|
-
var AiGatewayCredentials = class extends Context.Service()("effect-vercel/ai-gateway/AiGatewayCredentials") {};
|
|
40
|
-
/** The request headers that carry `credentials`. */
|
|
41
|
-
const formatHeaders = (credentials) => ({
|
|
42
|
-
"x-api-key": Redacted.value(credentials.token),
|
|
43
|
-
"ai-gateway-auth-method": credentials.method
|
|
44
|
-
});
|
|
45
|
-
const hints = {
|
|
46
|
-
"api-key": [],
|
|
47
|
-
env: [`Set ${API_KEY_ENV} to an AI Gateway API key.`],
|
|
48
|
-
"vercel-oidc": hints$1,
|
|
49
|
-
default: [`Set ${API_KEY_ENV} to an AI Gateway API key.`, "Or run on Vercel / `vercel env pull` so an OIDC token is available."]
|
|
50
|
-
};
|
|
51
|
-
const apiKeyLookup = Config.option(Config.Redacted(API_KEY_ENV)).pipe(Effect.map(Option.map((token) => ({
|
|
52
|
-
method: "api-key",
|
|
53
|
-
token
|
|
54
|
-
}))), Effect.catchTag("ConfigError", (cause) => new AiGatewayCredentialsError({
|
|
55
|
-
message: `Failed to read ${API_KEY_ENV}.`,
|
|
56
|
-
source: "env",
|
|
57
|
-
hints: hints.env,
|
|
58
|
-
cause
|
|
59
|
-
})));
|
|
60
|
-
const vercelOidcLookup = find.pipe(Effect.map(Option.map((token) => ({
|
|
61
|
-
method: "oidc",
|
|
62
|
-
token
|
|
63
|
-
}))), Effect.mapError((cause) => new AiGatewayCredentialsError({
|
|
64
|
-
message: cause.message,
|
|
65
|
-
source: "vercel-oidc",
|
|
66
|
-
hints: cause.hints,
|
|
67
|
-
cause
|
|
68
|
-
})));
|
|
69
|
-
const firstOf = (lookups) => Effect.gen(function* () {
|
|
70
|
-
for (const lookup of lookups) {
|
|
71
|
-
const found = yield* lookup;
|
|
72
|
-
if (Option.isSome(found)) return found;
|
|
73
|
-
}
|
|
74
|
-
return Option.none();
|
|
75
|
-
});
|
|
76
|
-
const toLayer = (lookup, source, message) => Layer.succeed(AiGatewayCredentials)(Effect.flatMap(lookup, (found) => Option.isSome(found) ? Effect.succeed(found.value) : Effect.fail(new AiGatewayCredentialsError({
|
|
77
|
-
message,
|
|
78
|
-
source,
|
|
79
|
-
hints: hints[source]
|
|
80
|
-
}))));
|
|
81
|
-
/** A fixed API key. */
|
|
82
|
-
const layerFromApiKey = (apiKey) => Layer.succeed(AiGatewayCredentials)(Effect.succeed({
|
|
83
|
-
method: "api-key",
|
|
84
|
-
token: Redacted.isRedacted(apiKey) ? apiKey : Redacted.make(apiKey)
|
|
85
|
-
}));
|
|
86
|
-
/** `AI_GATEWAY_API_KEY`: local dev, CI, or anywhere off Vercel. */
|
|
87
|
-
const layerFromEnv = toLayer(apiKeyLookup, "env", `${API_KEY_ENV} is not set.`);
|
|
88
|
-
/** The Vercel OIDC token (see `VercelOidc.find`). Not refreshed when it expires locally. */
|
|
89
|
-
const layerFromVercelOidc = toLayer(vercelOidcLookup, "vercel-oidc", "No Vercel OIDC token is available.");
|
|
90
|
-
/**
|
|
91
|
-
* The default: `layerFromEnv`, then `layerFromVercelOidc`. Same order as
|
|
92
|
-
* Vercel's own SDK, so it works locally with an API key and on Vercel without
|
|
93
|
-
* one.
|
|
94
|
-
*/
|
|
95
|
-
const layer$1 = toLayer(firstOf([apiKeyLookup, vercelOidcLookup]), "default", "No AI Gateway credential found.");
|
|
96
|
-
//#endregion
|
|
97
5
|
//#region src/AiGateway.ts
|
|
98
6
|
/**
|
|
99
7
|
* Vercel AI Gateway as an Anthropic Messages API client for Effect AI.
|
|
@@ -112,7 +20,7 @@ const layer$1 = toLayer(firstOf([apiKeyLookup, vercelOidcLookup]), "default", "N
|
|
|
112
20
|
*
|
|
113
21
|
* @example
|
|
114
22
|
* ```ts
|
|
115
|
-
* import
|
|
23
|
+
* import * as AiGateway from "effect-vercel/AiGateway"
|
|
116
24
|
* import { AnthropicLanguageModel } from "@effect/ai-anthropic"
|
|
117
25
|
* import { Layer } from "effect"
|
|
118
26
|
* import { FetchHttpClient } from "effect/http"
|
|
@@ -123,15 +31,6 @@ const layer$1 = toLayer(firstOf([apiKeyLookup, vercelOidcLookup]), "default", "N
|
|
|
123
31
|
* )
|
|
124
32
|
* ```
|
|
125
33
|
*/
|
|
126
|
-
var AiGateway_exports = /* @__PURE__ */ __exportAll({
|
|
127
|
-
AI_GATEWAY_URL: () => AI_GATEWAY_URL,
|
|
128
|
-
dropNullCacheControl: () => dropNullCacheControl,
|
|
129
|
-
fillMissingResponseKeys: () => fillMissingResponseKeys,
|
|
130
|
-
layer: () => layer,
|
|
131
|
-
layerWithoutCredentials: () => layerWithoutCredentials,
|
|
132
|
-
make: () => make,
|
|
133
|
-
patchResponseBody: () => patchResponseBody
|
|
134
|
-
});
|
|
135
34
|
/** Base URL of the Vercel AI Gateway. */
|
|
136
35
|
const AI_GATEWAY_URL = "https://ai-gateway.vercel.sh";
|
|
137
36
|
/**
|
|
@@ -250,6 +149,6 @@ function isJson(contentType) {
|
|
|
250
149
|
return contentType.startsWith("application/json");
|
|
251
150
|
}
|
|
252
151
|
//#endregion
|
|
253
|
-
export {
|
|
152
|
+
export { AI_GATEWAY_URL, dropNullCacheControl, fillMissingResponseKeys, layer, layerWithoutCredentials, make, patchResponseBody };
|
|
254
153
|
|
|
255
|
-
//# sourceMappingURL=
|
|
154
|
+
//# sourceMappingURL=AiGateway.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AiGateway.js","names":["AiGatewayCredentials.AiGatewayCredentials","AiGatewayCredentials.layer","AiGatewayCredentials.formatHeaders"],"sources":["../src/AiGateway.ts"],"sourcesContent":["/**\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 * as AiGateway from \"effect-vercel/AiGateway\"\n * import { AnthropicLanguageModel } from \"@effect/ai-anthropic\"\n * import { Layer } from \"effect\"\n * import { FetchHttpClient } from \"effect/http\"\n *\n * const Model = AnthropicLanguageModel.layer({ model: \"anthropic/claude-sonnet-4.5\" }).pipe(\n * Layer.provide(AiGateway.layer),\n * Layer.provide(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/http\"\nimport * as AiGatewayCredentials from \"./AiGatewayCredentials.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 AiGatewayCredentials.AiGatewayCredentials.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, authenticating with the\n * `AiGatewayCredentials` you provide (see that module's `layerFrom*` layers).\n * Needs an `HttpClient` too.\n */\nexport const layerWithoutCredentials: Layer.Layer<\n AnthropicClient.AnthropicClient,\n never,\n AiGatewayCredentials.AiGatewayCredentials | HttpClient.HttpClient\n> = Layer.effect(AnthropicClient.AnthropicClient)(\n Effect.flatMap(Effect.service(AiGatewayCredentials.AiGatewayCredentials), make),\n)\n\n/**\n * `layerWithoutCredentials` with the default `AiGatewayCredentials.layer`:\n * `AI_GATEWAY_API_KEY`, else the Vercel OIDC token. Needs an `HttpClient`.\n * Building never fails; without a usable credential, the requests fail.\n */\nexport const layer: Layer.Layer<AnthropicClient.AnthropicClient, never, HttpClient.HttpClient> =\n layerWithoutCredentials.pipe(Layer.provide(AiGatewayCredentials.layer))\n\nfunction authenticate(credentials: typeof AiGatewayCredentials.AiGatewayCredentials.Service) {\n return (request: HttpClientRequest.HttpClientRequest) =>\n credentials.pipe(\n Effect.map((resolved) =>\n HttpClientRequest.setHeaders(request, AiGatewayCredentials.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,45 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Redacted } from "effect";
|
|
2
|
+
declare namespace AiGatewayCredentials_d_exports {
|
|
3
|
+
export { API_KEY_ENV, AiGatewayCredentials, AiGatewayCredentialsError, Resolved, Source, formatHeaders, layer, layerFromApiKey, layerFromEnv, layerFromOidc };
|
|
4
|
+
}
|
|
5
|
+
/** The environment variable `layerFromEnv` reads. */
|
|
6
|
+
export declare const API_KEY_ENV = "AI_GATEWAY_API_KEY";
|
|
7
|
+
/** Which layer produced or failed to produce a credential. */
|
|
8
|
+
export type Source = "api-key" | "env" | "oidc" | "default";
|
|
9
|
+
/**
|
|
10
|
+
* A credential ready to put on a request. `method` is what the gateway
|
|
11
|
+
* records as the auth method; it matches the values Vercel's own SDK sends.
|
|
12
|
+
*/
|
|
13
|
+
export interface Resolved {
|
|
14
|
+
readonly method: "api-key" | "oidc";
|
|
15
|
+
readonly token: Redacted.Redacted<string>;
|
|
16
|
+
}
|
|
17
|
+
declare const AiGatewayCredentialsError_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 & {
|
|
18
|
+
readonly _tag: "AiGatewayCredentialsError";
|
|
19
|
+
} & Readonly<A>;
|
|
20
|
+
/** No credential could be produced for a gateway request. */
|
|
21
|
+
export declare class AiGatewayCredentialsError extends AiGatewayCredentialsError_base<{
|
|
22
|
+
readonly message: string;
|
|
23
|
+
readonly source: Source;
|
|
24
|
+
readonly hints: ReadonlyArray<string>;
|
|
25
|
+
readonly cause?: unknown;
|
|
26
|
+
}> {}
|
|
27
|
+
declare const AiGatewayCredentials_base: Context.ServiceClass<AiGatewayCredentials, "effect-vercel/AiGatewayCredentials", Effect.Effect<Resolved, AiGatewayCredentialsError, never>>;
|
|
28
|
+
export declare class AiGatewayCredentials extends AiGatewayCredentials_base {}
|
|
29
|
+
/** The request headers that carry `credentials`. */
|
|
30
|
+
export declare const formatHeaders: (credentials: Resolved) => Record<string, string>;
|
|
31
|
+
/** A fixed API key. */
|
|
32
|
+
export declare const layerFromApiKey: (apiKey: string | Redacted.Redacted<string>) => Layer.Layer<AiGatewayCredentials>;
|
|
33
|
+
/** `AI_GATEWAY_API_KEY`: local dev, CI, or anywhere off Vercel. */
|
|
34
|
+
export declare const layerFromEnv: Layer.Layer<AiGatewayCredentials>;
|
|
35
|
+
/** The Vercel OIDC token (see `Oidc.ambientToken`). */
|
|
36
|
+
export declare const layerFromOidc: Layer.Layer<AiGatewayCredentials>;
|
|
37
|
+
/**
|
|
38
|
+
* The default: `layerFromEnv`, then `layerFromOidc`. Same order as
|
|
39
|
+
* Vercel's own SDK, so it works locally with an API key and on Vercel without
|
|
40
|
+
* one. Falls back only when the key is unset, not when it can't be read.
|
|
41
|
+
*/
|
|
42
|
+
export declare const layer: Layer.Layer<AiGatewayCredentials>;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { AiGatewayCredentials_d_exports as t };
|
|
45
|
+
//# sourceMappingURL=AiGatewayCredentials.d.ts.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ambientToken, hints as hints$1 } from "./Oidc.js";
|
|
2
|
+
import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect";
|
|
3
|
+
//#region src/AiGatewayCredentials.ts
|
|
4
|
+
/**
|
|
5
|
+
* How gateway requests authenticate.
|
|
6
|
+
*
|
|
7
|
+
* `AiGatewayCredentials` holds an effect that produces the credential for one
|
|
8
|
+
* request. It runs per request rather than once at startup: on Vercel the OIDC
|
|
9
|
+
* token arrives on the request context and rotates, so it must not be cached.
|
|
10
|
+
* `layer` is the default; pick a `layerFrom*` layer to use one source only.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import * as AiGatewayCredentials from "effect-vercel/AiGatewayCredentials"
|
|
15
|
+
*
|
|
16
|
+
* AiGatewayCredentials.layer // AI_GATEWAY_API_KEY, else the Vercel OIDC token
|
|
17
|
+
* AiGatewayCredentials.layerFromEnv // AI_GATEWAY_API_KEY only
|
|
18
|
+
* AiGatewayCredentials.layerFromOidc // the Vercel OIDC token only
|
|
19
|
+
* AiGatewayCredentials.layerFromApiKey("…") // a fixed value
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
/** The environment variable `layerFromEnv` reads. */
|
|
23
|
+
const API_KEY_ENV = "AI_GATEWAY_API_KEY";
|
|
24
|
+
/** No credential could be produced for a gateway request. */
|
|
25
|
+
var AiGatewayCredentialsError = class extends Data.TaggedError("AiGatewayCredentialsError") {};
|
|
26
|
+
var AiGatewayCredentials = class extends Context.Service()("effect-vercel/AiGatewayCredentials") {};
|
|
27
|
+
/** The request headers that carry `credentials`. */
|
|
28
|
+
const formatHeaders = (credentials) => ({
|
|
29
|
+
"x-api-key": Redacted.value(credentials.token),
|
|
30
|
+
"ai-gateway-auth-method": credentials.method
|
|
31
|
+
});
|
|
32
|
+
const hints = {
|
|
33
|
+
"api-key": [],
|
|
34
|
+
env: [`Set ${API_KEY_ENV} to an AI Gateway API key.`],
|
|
35
|
+
oidc: hints$1,
|
|
36
|
+
default: [`Set ${API_KEY_ENV} to an AI Gateway API key.`, "Or run on Vercel / `vercel env pull` so an OIDC token is available."]
|
|
37
|
+
};
|
|
38
|
+
/** `AI_GATEWAY_API_KEY`, or `None` when unset. Fails only when it can't be read. */
|
|
39
|
+
const envApiKey = Config.option(Config.Redacted(API_KEY_ENV)).pipe(Effect.mapError((cause) => new AiGatewayCredentialsError({
|
|
40
|
+
message: `Failed to read ${API_KEY_ENV}.`,
|
|
41
|
+
source: "env",
|
|
42
|
+
hints: hints.env,
|
|
43
|
+
cause
|
|
44
|
+
})));
|
|
45
|
+
const fromEnv = Effect.flatMap(envApiKey, Option.match({
|
|
46
|
+
onNone: () => Effect.fail(new AiGatewayCredentialsError({
|
|
47
|
+
message: `${API_KEY_ENV} is not set.`,
|
|
48
|
+
source: "env",
|
|
49
|
+
hints: hints.env
|
|
50
|
+
})),
|
|
51
|
+
onSome: (token) => Effect.succeed({
|
|
52
|
+
method: "api-key",
|
|
53
|
+
token
|
|
54
|
+
})
|
|
55
|
+
}));
|
|
56
|
+
const fromOidc = ambientToken.pipe(Effect.map((token) => ({
|
|
57
|
+
method: "oidc",
|
|
58
|
+
token
|
|
59
|
+
})), Effect.mapError((cause) => new AiGatewayCredentialsError({
|
|
60
|
+
message: cause.message,
|
|
61
|
+
source: "oidc",
|
|
62
|
+
hints: cause.hints,
|
|
63
|
+
cause
|
|
64
|
+
})));
|
|
65
|
+
/** A fixed API key. */
|
|
66
|
+
const layerFromApiKey = (apiKey) => Layer.succeed(AiGatewayCredentials)(Effect.succeed({
|
|
67
|
+
method: "api-key",
|
|
68
|
+
token: Redacted.isRedacted(apiKey) ? apiKey : Redacted.make(apiKey)
|
|
69
|
+
}));
|
|
70
|
+
/** `AI_GATEWAY_API_KEY`: local dev, CI, or anywhere off Vercel. */
|
|
71
|
+
const layerFromEnv = Layer.succeed(AiGatewayCredentials)(fromEnv);
|
|
72
|
+
/** The Vercel OIDC token (see `Oidc.ambientToken`). */
|
|
73
|
+
const layerFromOidc = Layer.succeed(AiGatewayCredentials)(fromOidc);
|
|
74
|
+
/**
|
|
75
|
+
* The default: `layerFromEnv`, then `layerFromOidc`. Same order as
|
|
76
|
+
* Vercel's own SDK, so it works locally with an API key and on Vercel without
|
|
77
|
+
* one. Falls back only when the key is unset, not when it can't be read.
|
|
78
|
+
*/
|
|
79
|
+
const layer = Layer.succeed(AiGatewayCredentials)(Effect.flatMap(envApiKey, Option.match({
|
|
80
|
+
onNone: () => Effect.mapError(fromOidc, (cause) => new AiGatewayCredentialsError({
|
|
81
|
+
message: "No AI Gateway credential found.",
|
|
82
|
+
source: "default",
|
|
83
|
+
hints: hints.default,
|
|
84
|
+
cause
|
|
85
|
+
})),
|
|
86
|
+
onSome: (token) => Effect.succeed({
|
|
87
|
+
method: "api-key",
|
|
88
|
+
token
|
|
89
|
+
})
|
|
90
|
+
})));
|
|
91
|
+
//#endregion
|
|
92
|
+
export { API_KEY_ENV, AiGatewayCredentials, AiGatewayCredentialsError, formatHeaders, layer, layerFromApiKey, layerFromEnv, layerFromOidc };
|
|
93
|
+
|
|
94
|
+
//# sourceMappingURL=AiGatewayCredentials.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AiGatewayCredentials.js","names":["Oidc.hints"],"sources":["../src/AiGatewayCredentials.ts"],"sourcesContent":["/**\n * How gateway requests authenticate.\n *\n * `AiGatewayCredentials` holds an effect that produces the credential for one\n * request. It runs per request rather than once at startup: on Vercel the OIDC\n * token arrives on the request context and rotates, so it must not be cached.\n * `layer` is the default; pick a `layerFrom*` layer to use one source only.\n *\n * @example\n * ```ts\n * import * as AiGatewayCredentials from \"effect-vercel/AiGatewayCredentials\"\n *\n * AiGatewayCredentials.layer // AI_GATEWAY_API_KEY, else the Vercel OIDC token\n * AiGatewayCredentials.layerFromEnv // AI_GATEWAY_API_KEY only\n * AiGatewayCredentials.layerFromOidc // the Vercel OIDC token only\n * AiGatewayCredentials.layerFromApiKey(\"…\") // a fixed value\n * ```\n */\n\nimport { Config, Context, Data, Effect, Layer, Option, Redacted } from \"effect\"\nimport * as Oidc from \"./Oidc.js\"\n\n/** The environment variable `layerFromEnv` reads. */\nexport const API_KEY_ENV = \"AI_GATEWAY_API_KEY\"\n\n/** Which layer produced or failed to produce a credential. */\nexport type Source = \"api-key\" | \"env\" | \"oidc\" | \"default\"\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 AiGatewayCredentialsError 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 AiGatewayCredentials extends Context.Service<\n AiGatewayCredentials,\n Effect.Effect<Resolved, AiGatewayCredentialsError>\n>()(\"effect-vercel/AiGatewayCredentials\") {}\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 oidc: Oidc.hints,\n default: [\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/** `AI_GATEWAY_API_KEY`, or `None` when unset. Fails only when it can't be read. */\nconst envApiKey = Config.option(Config.Redacted(API_KEY_ENV)).pipe(\n Effect.mapError(\n (cause) =>\n new AiGatewayCredentialsError({\n message: `Failed to read ${API_KEY_ENV}.`,\n source: \"env\",\n hints: hints.env,\n cause,\n }),\n ),\n)\n\nconst fromEnv: Effect.Effect<Resolved, AiGatewayCredentialsError> = Effect.flatMap(\n envApiKey,\n Option.match({\n onNone: () =>\n Effect.fail(\n new AiGatewayCredentialsError({\n message: `${API_KEY_ENV} is not set.`,\n source: \"env\",\n hints: hints.env,\n }),\n ),\n onSome: (token) => Effect.succeed({ method: \"api-key\" as const, token }),\n }),\n)\n\nconst fromOidc: Effect.Effect<Resolved, AiGatewayCredentialsError> = Oidc.ambientToken.pipe(\n Effect.map((token) => ({ method: \"oidc\" as const, token })),\n Effect.mapError(\n (cause) =>\n new AiGatewayCredentialsError({\n message: cause.message,\n source: \"oidc\",\n hints: cause.hints,\n cause,\n }),\n ),\n)\n\n/** A fixed API key. */\nexport const layerFromApiKey = (\n apiKey: string | Redacted.Redacted<string>,\n): Layer.Layer<AiGatewayCredentials> =>\n Layer.succeed(AiGatewayCredentials)(\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 layerFromEnv: Layer.Layer<AiGatewayCredentials> =\n Layer.succeed(AiGatewayCredentials)(fromEnv)\n\n/** The Vercel OIDC token (see `Oidc.ambientToken`). */\nexport const layerFromOidc: Layer.Layer<AiGatewayCredentials> =\n Layer.succeed(AiGatewayCredentials)(fromOidc)\n\n/**\n * The default: `layerFromEnv`, then `layerFromOidc`. Same order as\n * Vercel's own SDK, so it works locally with an API key and on Vercel without\n * one. Falls back only when the key is unset, not when it can't be read.\n */\nexport const layer: Layer.Layer<AiGatewayCredentials> = Layer.succeed(AiGatewayCredentials)(\n Effect.flatMap(\n envApiKey,\n Option.match({\n onNone: () =>\n Effect.mapError(\n fromOidc,\n (cause) =>\n new AiGatewayCredentialsError({\n message: \"No AI Gateway credential found.\",\n source: \"default\",\n hints: hints.default,\n cause,\n }),\n ),\n onSome: (token) => Effect.succeed({ method: \"api-key\" as const, token }),\n }),\n ),\n)\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAa,cAAc;;AAe3B,IAAa,4BAAb,cAA+C,KAAK,YAAY,2BAA2B,CAAC,CAKzF,CAAC;AAEJ,IAAa,uBAAb,cAA0C,QAAQ,QAGhD,CAAC,CAAC,oCAAoC,CAAC,CAAC,CAAC;;AAG3C,MAAa,iBAAiB,iBAAmD;CAC/E,aAAa,SAAS,MAAM,YAAY,KAAK;CAC7C,0BAA0B,YAAY;AACxC;AAEA,MAAM,QAA+C;CACnD,WAAW,CAAC;CACZ,KAAK,CAAC,OAAO,YAAY,2BAA2B;CACpD,MAAMA;CACN,SAAS,CACP,OAAO,YAAY,6BACnB,qEACF;AACF;;AAGA,MAAM,YAAY,OAAO,OAAO,OAAO,SAAS,WAAW,CAAC,CAAC,CAAC,KAC5D,OAAO,UACJ,UACC,IAAI,0BAA0B;CAC5B,SAAS,kBAAkB,YAAY;CACvC,QAAQ;CACR,OAAO,MAAM;CACb;AACF,CAAC,CACL,CACF;AAEA,MAAM,UAA8D,OAAO,QACzE,WACA,OAAO,MAAM;CACX,cACE,OAAO,KACL,IAAI,0BAA0B;EAC5B,SAAS,GAAG,YAAY;EACxB,QAAQ;EACR,OAAO,MAAM;CACf,CAAC,CACH;CACF,SAAS,UAAU,OAAO,QAAQ;EAAE,QAAQ;EAAoB;CAAM,CAAC;AACzE,CAAC,CACH;AAEA,MAAM,WAAA,aAAiF,KACrF,OAAO,KAAK,WAAW;CAAE,QAAQ;CAAiB;AAAM,EAAE,GAC1D,OAAO,UACJ,UACC,IAAI,0BAA0B;CAC5B,SAAS,MAAM;CACf,QAAQ;CACR,OAAO,MAAM;CACb;AACF,CAAC,CACL,CACF;;AAGA,MAAa,mBACX,WAEA,MAAM,QAAQ,oBAAoB,CAAC,CACjC,OAAO,QAAQ;CACb,QAAQ;CACR,OAAO,SAAS,WAAW,MAAM,IAAI,SAAS,SAAS,KAAK,MAAM;AACpE,CAAC,CACH;;AAGF,MAAa,eACX,MAAM,QAAQ,oBAAoB,CAAC,CAAC,OAAO;;AAG7C,MAAa,gBACX,MAAM,QAAQ,oBAAoB,CAAC,CAAC,QAAQ;;;;;;AAO9C,MAAa,QAA2C,MAAM,QAAQ,oBAAoB,CAAC,CACzF,OAAO,QACL,WACA,OAAO,MAAM;CACX,cACE,OAAO,SACL,WACC,UACC,IAAI,0BAA0B;EAC5B,SAAS;EACT,QAAQ;EACR,OAAO,MAAM;EACb;CACF,CAAC,CACL;CACF,SAAS,UAAU,OAAO,QAAQ;EAAE,QAAQ;EAAoB;CAAM,CAAC;AACzE,CAAC,CACH,CACF"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect, Fiber } from "effect";
|
|
2
|
+
//#region src/Functions.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Keeps the Function running until `fiber` ends, as `waitUntil` from
|
|
5
|
+
* `@vercel/functions` does for a promise. Off Vercel it does nothing.
|
|
6
|
+
*
|
|
7
|
+
* The fiber must outlive the request fiber, so fork it with
|
|
8
|
+
* `Effect.forkDetach` or into a scope that outlives the request. A child
|
|
9
|
+
* fiber is interrupted when the request fiber ends. Failures are not
|
|
10
|
+
* reported, so handle them in the forked effect.
|
|
11
|
+
*/
|
|
12
|
+
export declare const waitUntil: <A, E>(fiber: Fiber.Fiber<A, E>) => Effect.Effect<void>;
|
|
13
|
+
//#endregion
|
|
14
|
+
//# sourceMappingURL=Functions.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { waitUntil as waitUntil$1 } from "@vercel/functions";
|
|
3
|
+
//#region src/Functions.ts
|
|
4
|
+
/**
|
|
5
|
+
* Effect bindings for `@vercel/functions`.
|
|
6
|
+
*
|
|
7
|
+
* `waitUntil` keeps the Function running until a fiber ends, so work forked
|
|
8
|
+
* during a request can finish after the response is sent.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import * as Functions from "effect-vercel/Functions"
|
|
13
|
+
* import { Effect } from "effect"
|
|
14
|
+
*
|
|
15
|
+
* const handler = Effect.gen(function* () {
|
|
16
|
+
* yield* Effect.forkDetach(sendReceipt).pipe(Effect.tap(Functions.waitUntil))
|
|
17
|
+
* return new Response("OK")
|
|
18
|
+
* })
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Keeps the Function running until `fiber` ends, as `waitUntil` from
|
|
23
|
+
* `@vercel/functions` does for a promise. Off Vercel it does nothing.
|
|
24
|
+
*
|
|
25
|
+
* The fiber must outlive the request fiber, so fork it with
|
|
26
|
+
* `Effect.forkDetach` or into a scope that outlives the request. A child
|
|
27
|
+
* fiber is interrupted when the request fiber ends. Failures are not
|
|
28
|
+
* reported, so handle them in the forked effect.
|
|
29
|
+
*/
|
|
30
|
+
const waitUntil = (fiber) => Effect.sync(() => waitUntil$1(new Promise((resolve) => fiber.addObserver(() => resolve()))));
|
|
31
|
+
//#endregion
|
|
32
|
+
export { waitUntil };
|
|
33
|
+
|
|
34
|
+
//# sourceMappingURL=Functions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Functions.js","names":["vercelWaitUntil"],"sources":["../src/Functions.ts"],"sourcesContent":["/**\n * Effect bindings for `@vercel/functions`.\n *\n * `waitUntil` keeps the Function running until a fiber ends, so work forked\n * during a request can finish after the response is sent.\n *\n * @example\n * ```ts\n * import * as Functions from \"effect-vercel/Functions\"\n * import { Effect } from \"effect\"\n *\n * const handler = Effect.gen(function* () {\n * yield* Effect.forkDetach(sendReceipt).pipe(Effect.tap(Functions.waitUntil))\n * return new Response(\"OK\")\n * })\n * ```\n */\n\nimport { waitUntil as vercelWaitUntil } from \"@vercel/functions\"\nimport { Effect, type Fiber } from \"effect\"\n\n/**\n * Keeps the Function running until `fiber` ends, as `waitUntil` from\n * `@vercel/functions` does for a promise. Off Vercel it does nothing.\n *\n * The fiber must outlive the request fiber, so fork it with\n * `Effect.forkDetach` or into a scope that outlives the request. A child\n * fiber is interrupted when the request fiber ends. Failures are not\n * reported, so handle them in the forked effect.\n */\nexport const waitUntil = <A, E>(fiber: Fiber.Fiber<A, E>): Effect.Effect<void> =>\n Effect.sync(() =>\n vercelWaitUntil(new Promise<void>((resolve) => fiber.addObserver(() => resolve()))),\n )\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAa,aAAmB,UAC9B,OAAO,WACLA,YAAgB,IAAI,SAAe,YAAY,MAAM,kBAAkB,QAAQ,CAAC,CAAC,CAAC,CACpF"}
|
package/dist/Oidc.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect, Redacted } from "effect";
|
|
2
|
+
//#region src/Oidc.d.ts
|
|
3
|
+
/** The environment variable read when no request carries a token. */
|
|
4
|
+
export declare const TOKEN_ENV = "VERCEL_OIDC_TOKEN";
|
|
5
|
+
declare const OidcError_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 & {
|
|
6
|
+
readonly _tag: "VercelOidcError";
|
|
7
|
+
} & Readonly<A>;
|
|
8
|
+
/** No usable OIDC token. */
|
|
9
|
+
export declare class OidcError extends OidcError_base<{
|
|
10
|
+
readonly message: string;
|
|
11
|
+
readonly hints: ReadonlyArray<string>;
|
|
12
|
+
readonly cause?: unknown;
|
|
13
|
+
}> {}
|
|
14
|
+
/** How to make a token available. */
|
|
15
|
+
export declare const hints: ReadonlyArray<string>;
|
|
16
|
+
/**
|
|
17
|
+
* The ambient token: the request header, else `VERCEL_OIDC_TOKEN`. Empty
|
|
18
|
+
* values count as unset. Fails when neither is set. Does not refresh an
|
|
19
|
+
* expired local token; re-run `vercel env pull`.
|
|
20
|
+
*/
|
|
21
|
+
export declare const ambientToken: Effect.Effect<Redacted.Redacted<string>, OidcError>;
|
|
22
|
+
//#endregion
|
|
23
|
+
//# sourceMappingURL=Oidc.d.ts.map
|
package/dist/Oidc.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Config, Data, Effect, Redacted } from "effect";
|
|
2
|
+
//#region src/Oidc.ts
|
|
3
|
+
/**
|
|
4
|
+
* The OIDC token Vercel issues to a deployment.
|
|
5
|
+
*
|
|
6
|
+
* `ambientToken` reads the token the platform provides. In Functions it
|
|
7
|
+
* arrives on each request as the `x-vercel-oidc-token` header and rotates, so
|
|
8
|
+
* it is read on every run, never cached. In builds and after `vercel env pull`
|
|
9
|
+
* it is `VERCEL_OIDC_TOKEN`. Use it to authenticate to the AI Gateway, or
|
|
10
|
+
* exchange it for cloud credentials (AWS `AssumeRoleWithWebIdentity`, GCP
|
|
11
|
+
* workload identity federation).
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import * as Oidc from "effect-vercel/Oidc"
|
|
16
|
+
* import { Effect, Redacted } from "effect"
|
|
17
|
+
*
|
|
18
|
+
* const program = Effect.gen(function* () {
|
|
19
|
+
* const token = yield* Oidc.ambientToken
|
|
20
|
+
* // pass Redacted.value(token) as the web identity token
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
/** The environment variable read when no request carries a token. */
|
|
25
|
+
const TOKEN_ENV = "VERCEL_OIDC_TOKEN";
|
|
26
|
+
/** No usable OIDC token. */
|
|
27
|
+
var OidcError = class extends Data.TaggedError("VercelOidcError") {};
|
|
28
|
+
/** How to make a token available. */
|
|
29
|
+
const hints = ["Run on Vercel, where each request carries an OIDC token.", `Or run \`vercel env pull\` locally to populate ${TOKEN_ENV}.`];
|
|
30
|
+
/**
|
|
31
|
+
* Vercel exposes the current request's context on a well-known global symbol.
|
|
32
|
+
* In Functions, the request's headers live there. `@vercel/oidc` reads the
|
|
33
|
+
* same place.
|
|
34
|
+
*/
|
|
35
|
+
const REQUEST_CONTEXT = Symbol.for("@vercel/request-context");
|
|
36
|
+
/** The `x-vercel-oidc-token` header of the current request, if set. */
|
|
37
|
+
const requestToken = () => {
|
|
38
|
+
const token = globalThis[REQUEST_CONTEXT]?.get?.()?.headers?.["x-vercel-oidc-token"];
|
|
39
|
+
return token === "" ? void 0 : token;
|
|
40
|
+
};
|
|
41
|
+
const envToken = Config.NonEmptyString(TOKEN_ENV).pipe(Config.map(Redacted.make));
|
|
42
|
+
/**
|
|
43
|
+
* The ambient token: the request header, else `VERCEL_OIDC_TOKEN`. Empty
|
|
44
|
+
* values count as unset. Fails when neither is set. Does not refresh an
|
|
45
|
+
* expired local token; re-run `vercel env pull`.
|
|
46
|
+
*/
|
|
47
|
+
const ambientToken = Effect.suspend(() => {
|
|
48
|
+
const token = requestToken();
|
|
49
|
+
if (token !== void 0) return Effect.succeed(Redacted.make(token));
|
|
50
|
+
return Effect.mapError(envToken, (cause) => new OidcError({
|
|
51
|
+
message: "No Vercel OIDC token is available.",
|
|
52
|
+
hints,
|
|
53
|
+
cause
|
|
54
|
+
}));
|
|
55
|
+
});
|
|
56
|
+
//#endregion
|
|
57
|
+
export { OidcError, TOKEN_ENV, ambientToken, hints };
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=Oidc.js.map
|