effect-vercel 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 +166 -0
- package/dist/VercelOidc--1oU5DP2.js +70 -0
- package/dist/VercelOidc--1oU5DP2.js.map +1 -0
- package/dist/ai-gateway.d.ts +87 -0
- package/dist/ai-gateway.js +255 -0
- package/dist/ai-gateway.js.map +1 -0
- package/dist/oidc.d.ts +27 -0
- package/dist/oidc.js +2 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/runtime-cache.d.ts +27 -0
- package/dist/runtime-cache.js +100 -0
- package/dist/runtime-cache.js.map +1 -0
- package/package.json +74 -0
- package/src/AiGateway.ts +202 -0
- package/src/AiGatewayCredentials.ts +152 -0
- package/src/VercelOidc.ts +86 -0
- package/src/VercelRuntimeCache.ts +108 -0
- package/src/ai-gateway.ts +2 -0
- package/src/oidc.ts +1 -0
- package/src/runtime-cache.ts +1 -0
package/dist/oidc.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect, Option, Redacted } from "effect";
|
|
2
|
+
declare namespace VercelOidc_d_exports {
|
|
3
|
+
export { TOKEN_ENV, VercelOidcError, find, hints, token };
|
|
4
|
+
}
|
|
5
|
+
/** The environment variable read when no request carries a token. */
|
|
6
|
+
declare const TOKEN_ENV = "VERCEL_OIDC_TOKEN";
|
|
7
|
+
declare const VercelOidcError_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 & {
|
|
8
|
+
readonly _tag: "VercelOidcError";
|
|
9
|
+
} & Readonly<A>;
|
|
10
|
+
/** No usable OIDC token. */
|
|
11
|
+
declare class VercelOidcError extends VercelOidcError_base<{
|
|
12
|
+
readonly message: string;
|
|
13
|
+
readonly hints: ReadonlyArray<string>;
|
|
14
|
+
readonly cause?: unknown;
|
|
15
|
+
}> {}
|
|
16
|
+
/** How to make a token available. */
|
|
17
|
+
declare const hints: ReadonlyArray<string>;
|
|
18
|
+
/**
|
|
19
|
+
* The token if one is available: the request header, else `VERCEL_OIDC_TOKEN`.
|
|
20
|
+
* `None` means neither is set, so a caller can fall back to something else.
|
|
21
|
+
*/
|
|
22
|
+
declare const find: Effect.Effect<Option.Option<Redacted.Redacted<string>>, VercelOidcError>;
|
|
23
|
+
/** Like `find`, but fails when no token is available. */
|
|
24
|
+
declare const token: Effect.Effect<Redacted.Redacted<string>, VercelOidcError>;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { VercelOidc_d_exports as VercelOidc };
|
|
27
|
+
//# sourceMappingURL=oidc.d.ts.map
|
package/dist/oidc.js
ADDED
|
@@ -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 };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { RuntimeCache } from "@vercel/functions";
|
|
3
|
+
import { Persistence } from "effect/unstable/persistence";
|
|
4
|
+
declare namespace VercelRuntimeCache_d_exports {
|
|
5
|
+
export { layer, layerBacking, makeBacking };
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A `BackingPersistence` over `cache`.
|
|
9
|
+
*
|
|
10
|
+
* Entries are keyed by the JSON pair `[storeId, key]`, so no two stores share
|
|
11
|
+
* a key. Each entry is tagged with a hash of the store id, since tags are
|
|
12
|
+
* joined with `,` and sent unescaped, and `clear` expires that tag. Entries
|
|
13
|
+
* are sent without a name, so keys don't reach Vercel's o11y or the request
|
|
14
|
+
* headers, where non-Latin-1 characters would make the write fail. TTLs round
|
|
15
|
+
* up to whole seconds, the cache's resolution.
|
|
16
|
+
*
|
|
17
|
+
* `getCache()` hashes keys with a 32-bit hash by default, which can collide.
|
|
18
|
+
* Pass a cache made with a stronger `keyHashFunction`, as `layerBacking` does.
|
|
19
|
+
*/
|
|
20
|
+
declare const makeBacking: (cache: RuntimeCache) => Persistence.BackingPersistence["Service"];
|
|
21
|
+
/** `BackingPersistence` over `getCache()`, with SHA-256 key hashing. */
|
|
22
|
+
declare const layerBacking: Layer.Layer<Persistence.BackingPersistence>;
|
|
23
|
+
/** `Persistence` backed by the Vercel Runtime Cache. */
|
|
24
|
+
declare const layer: Layer.Layer<Persistence.Persistence>;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { VercelRuntimeCache_d_exports as VercelRuntimeCache };
|
|
27
|
+
//# sourceMappingURL=runtime-cache.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
+
import { Duration, Effect, Layer } from "effect";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { getCache } from "@vercel/functions";
|
|
5
|
+
import { Persistence } from "effect/unstable/persistence";
|
|
6
|
+
//#region src/VercelRuntimeCache.ts
|
|
7
|
+
/**
|
|
8
|
+
* Effect `Persistence` backed by the Vercel Runtime Cache, so a
|
|
9
|
+
* `PersistedCache` shares results across Function instances in a region.
|
|
10
|
+
*
|
|
11
|
+
* Outside Vercel, `getCache` falls back to an in-memory cache, so the same
|
|
12
|
+
* layer works in local dev and tests.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { VercelRuntimeCache } from "effect-vercel/runtime-cache"
|
|
17
|
+
* import { Effect } from "effect"
|
|
18
|
+
* import { PersistedCache } from "effect/unstable/persistence"
|
|
19
|
+
*
|
|
20
|
+
* const program = Effect.gen(function* () {
|
|
21
|
+
* const users = yield* PersistedCache.make(fetchUser, {
|
|
22
|
+
* storeId: "users",
|
|
23
|
+
* timeToLive: () => "1 hour",
|
|
24
|
+
* })
|
|
25
|
+
* return yield* users.get(new GetUser({ id: 1 }))
|
|
26
|
+
* }).pipe(Effect.scoped, Effect.provide(VercelRuntimeCache.layer))
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
var VercelRuntimeCache_exports = /* @__PURE__ */ __exportAll({
|
|
30
|
+
layer: () => layer,
|
|
31
|
+
layerBacking: () => layerBacking,
|
|
32
|
+
makeBacking: () => makeBacking
|
|
33
|
+
});
|
|
34
|
+
const sha256 = (key) => createHash("sha256").update(key).digest("hex");
|
|
35
|
+
/**
|
|
36
|
+
* A `BackingPersistence` over `cache`.
|
|
37
|
+
*
|
|
38
|
+
* Entries are keyed by the JSON pair `[storeId, key]`, so no two stores share
|
|
39
|
+
* a key. Each entry is tagged with a hash of the store id, since tags are
|
|
40
|
+
* joined with `,` and sent unescaped, and `clear` expires that tag. Entries
|
|
41
|
+
* are sent without a name, so keys don't reach Vercel's o11y or the request
|
|
42
|
+
* headers, where non-Latin-1 characters would make the write fail. TTLs round
|
|
43
|
+
* up to whole seconds, the cache's resolution.
|
|
44
|
+
*
|
|
45
|
+
* `getCache()` hashes keys with a 32-bit hash by default, which can collide.
|
|
46
|
+
* Pass a cache made with a stronger `keyHashFunction`, as `layerBacking` does.
|
|
47
|
+
*/
|
|
48
|
+
const makeBacking = (cache) => Persistence.BackingPersistence.of({ make: (storeId) => Effect.sync(() => {
|
|
49
|
+
const prefixed = (key) => JSON.stringify([storeId, key]);
|
|
50
|
+
const tag = `effect-persistence:${sha256(storeId)}`;
|
|
51
|
+
const get = (key) => Effect.tryPromise({
|
|
52
|
+
try: () => cache.get(prefixed(key)),
|
|
53
|
+
catch: (cause) => new Persistence.PersistenceError({
|
|
54
|
+
message: `Failed to get key ${key}`,
|
|
55
|
+
cause
|
|
56
|
+
})
|
|
57
|
+
}).pipe(Effect.map((value) => typeof value === "object" && value !== null ? value : void 0));
|
|
58
|
+
const set = (key, value, ttl) => Effect.tryPromise({
|
|
59
|
+
try: () => cache.set(prefixed(key), value, {
|
|
60
|
+
name: "",
|
|
61
|
+
tags: [tag],
|
|
62
|
+
...ttl && { ttl: Math.max(1, Math.ceil(Duration.toSeconds(ttl))) }
|
|
63
|
+
}),
|
|
64
|
+
catch: (cause) => new Persistence.PersistenceError({
|
|
65
|
+
message: `Failed to set key ${key}`,
|
|
66
|
+
cause
|
|
67
|
+
})
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
get,
|
|
71
|
+
getMany: (keys) => Effect.forEach(keys, get, { concurrency: "unbounded" }),
|
|
72
|
+
set,
|
|
73
|
+
setMany: (entries) => Effect.forEach(new Map(entries.map(([key, value, ttl]) => [key, [value, ttl]])), ([key, [value, ttl]]) => set(key, value, ttl), {
|
|
74
|
+
concurrency: "unbounded",
|
|
75
|
+
discard: true
|
|
76
|
+
}),
|
|
77
|
+
remove: (key) => Effect.tryPromise({
|
|
78
|
+
try: () => cache.delete(prefixed(key)),
|
|
79
|
+
catch: (cause) => new Persistence.PersistenceError({
|
|
80
|
+
message: `Failed to remove key ${key}`,
|
|
81
|
+
cause
|
|
82
|
+
})
|
|
83
|
+
}),
|
|
84
|
+
clear: Effect.tryPromise({
|
|
85
|
+
try: () => cache.expireTag(tag),
|
|
86
|
+
catch: (cause) => new Persistence.PersistenceError({
|
|
87
|
+
message: `Failed to clear ${storeId}`,
|
|
88
|
+
cause
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
};
|
|
92
|
+
}) });
|
|
93
|
+
/** `BackingPersistence` over `getCache()`, with SHA-256 key hashing. */
|
|
94
|
+
const layerBacking = Layer.sync(Persistence.BackingPersistence)(() => makeBacking(getCache({ keyHashFunction: sha256 })));
|
|
95
|
+
/** `Persistence` backed by the Vercel Runtime Cache. */
|
|
96
|
+
const layer = Persistence.layer.pipe(Layer.provide(layerBacking));
|
|
97
|
+
//#endregion
|
|
98
|
+
export { VercelRuntimeCache_exports as VercelRuntimeCache };
|
|
99
|
+
|
|
100
|
+
//# sourceMappingURL=runtime-cache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime-cache.js","names":[],"sources":["../src/VercelRuntimeCache.ts"],"sourcesContent":["/**\n * Effect `Persistence` backed by the Vercel Runtime Cache, so a\n * `PersistedCache` shares results across Function instances in a region.\n *\n * Outside Vercel, `getCache` falls back to an in-memory cache, so the same\n * layer works in local dev and tests.\n *\n * @example\n * ```ts\n * import { VercelRuntimeCache } from \"effect-vercel/runtime-cache\"\n * import { Effect } from \"effect\"\n * import { PersistedCache } from \"effect/unstable/persistence\"\n *\n * const program = Effect.gen(function* () {\n * const users = yield* PersistedCache.make(fetchUser, {\n * storeId: \"users\",\n * timeToLive: () => \"1 hour\",\n * })\n * return yield* users.get(new GetUser({ id: 1 }))\n * }).pipe(Effect.scoped, Effect.provide(VercelRuntimeCache.layer))\n * ```\n */\n\nimport { createHash } from \"node:crypto\"\nimport { getCache, type RuntimeCache } from \"@vercel/functions\"\nimport { Duration, Effect, Layer } from \"effect\"\nimport { Persistence } from \"effect/unstable/persistence\"\n\nconst sha256 = (key: string) => createHash(\"sha256\").update(key).digest(\"hex\")\n\n/**\n * A `BackingPersistence` over `cache`.\n *\n * Entries are keyed by the JSON pair `[storeId, key]`, so no two stores share\n * a key. Each entry is tagged with a hash of the store id, since tags are\n * joined with `,` and sent unescaped, and `clear` expires that tag. Entries\n * are sent without a name, so keys don't reach Vercel's o11y or the request\n * headers, where non-Latin-1 characters would make the write fail. TTLs round\n * up to whole seconds, the cache's resolution.\n *\n * `getCache()` hashes keys with a 32-bit hash by default, which can collide.\n * Pass a cache made with a stronger `keyHashFunction`, as `layerBacking` does.\n */\nexport const makeBacking = (cache: RuntimeCache): Persistence.BackingPersistence[\"Service\"] =>\n Persistence.BackingPersistence.of({\n make: (storeId) =>\n Effect.sync(() => {\n const prefixed = (key: string) => JSON.stringify([storeId, key])\n const tag = `effect-persistence:${sha256(storeId)}`\n\n const get = (key: string) =>\n Effect.tryPromise({\n try: () => cache.get(prefixed(key)),\n catch: (cause) =>\n new Persistence.PersistenceError({ message: `Failed to get key ${key}`, cause }),\n }).pipe(\n Effect.map((value) =>\n typeof value === \"object\" && value !== null ? value : undefined,\n ),\n )\n\n const set = (key: string, value: object, ttl: Duration.Duration | undefined) =>\n Effect.tryPromise({\n try: () =>\n cache.set(prefixed(key), value, {\n name: \"\",\n tags: [tag],\n ...(ttl && { ttl: Math.max(1, Math.ceil(Duration.toSeconds(ttl))) }),\n }),\n catch: (cause) =>\n new Persistence.PersistenceError({ message: `Failed to set key ${key}`, cause }),\n })\n\n return {\n get,\n getMany: (keys) => Effect.forEach(keys, get, { concurrency: \"unbounded\" }),\n set,\n // Writes run concurrently, so keep only the last entry for each key.\n setMany: (entries) =>\n Effect.forEach(\n new Map(entries.map(([key, value, ttl]) => [key, [value, ttl] as const])),\n ([key, [value, ttl]]) => set(key, value, ttl),\n { concurrency: \"unbounded\", discard: true },\n ),\n remove: (key) =>\n Effect.tryPromise({\n try: () => cache.delete(prefixed(key)),\n catch: (cause) =>\n new Persistence.PersistenceError({ message: `Failed to remove key ${key}`, cause }),\n }),\n clear: Effect.tryPromise({\n try: () => cache.expireTag(tag),\n catch: (cause) =>\n new Persistence.PersistenceError({ message: `Failed to clear ${storeId}`, cause }),\n }),\n }\n }),\n })\n\n/** `BackingPersistence` over `getCache()`, with SHA-256 key hashing. */\nexport const layerBacking: Layer.Layer<Persistence.BackingPersistence> = Layer.sync(\n Persistence.BackingPersistence,\n)(() => makeBacking(getCache({ keyHashFunction: sha256 })))\n\n/** `Persistence` backed by the Vercel Runtime Cache. */\nexport const layer: Layer.Layer<Persistence.Persistence> = Persistence.layer.pipe(\n Layer.provide(layerBacking),\n)\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAM,UAAU,QAAgB,WAAW,QAAQ,CAAC,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO,KAAK;;;;;;;;;;;;;;AAe7E,MAAa,eAAe,UAC1B,YAAY,mBAAmB,GAAG,EAChC,OAAO,YACL,OAAO,WAAW;CAChB,MAAM,YAAY,QAAgB,KAAK,UAAU,CAAC,SAAS,GAAG,CAAC;CAC/D,MAAM,MAAM,sBAAsB,OAAO,OAAO;CAEhD,MAAM,OAAO,QACX,OAAO,WAAW;EAChB,WAAW,MAAM,IAAI,SAAS,GAAG,CAAC;EAClC,QAAQ,UACN,IAAI,YAAY,iBAAiB;GAAE,SAAS,qBAAqB;GAAO;EAAM,CAAC;CACnF,CAAC,CAAC,CAAC,KACD,OAAO,KAAK,UACV,OAAO,UAAU,YAAY,UAAU,OAAO,QAAQ,KAAA,CACxD,CACF;CAEF,MAAM,OAAO,KAAa,OAAe,QACvC,OAAO,WAAW;EAChB,WACE,MAAM,IAAI,SAAS,GAAG,GAAG,OAAO;GAC9B,MAAM;GACN,MAAM,CAAC,GAAG;GACV,GAAI,OAAO,EAAE,KAAK,KAAK,IAAI,GAAG,KAAK,KAAK,SAAS,UAAU,GAAG,CAAC,CAAC,EAAE;EACpE,CAAC;EACH,QAAQ,UACN,IAAI,YAAY,iBAAiB;GAAE,SAAS,qBAAqB;GAAO;EAAM,CAAC;CACnF,CAAC;CAEH,OAAO;EACL;EACA,UAAU,SAAS,OAAO,QAAQ,MAAM,KAAK,EAAE,aAAa,YAAY,CAAC;EACzE;EAEA,UAAU,YACR,OAAO,QACL,IAAI,IAAI,QAAQ,KAAK,CAAC,KAAK,OAAO,SAAS,CAAC,KAAK,CAAC,OAAO,GAAG,CAAU,CAAC,CAAC,IACvE,CAAC,KAAK,CAAC,OAAO,UAAU,IAAI,KAAK,OAAO,GAAG,GAC5C;GAAE,aAAa;GAAa,SAAS;EAAK,CAC5C;EACF,SAAS,QACP,OAAO,WAAW;GAChB,WAAW,MAAM,OAAO,SAAS,GAAG,CAAC;GACrC,QAAQ,UACN,IAAI,YAAY,iBAAiB;IAAE,SAAS,wBAAwB;IAAO;GAAM,CAAC;EACtF,CAAC;EACH,OAAO,OAAO,WAAW;GACvB,WAAW,MAAM,UAAU,GAAG;GAC9B,QAAQ,UACN,IAAI,YAAY,iBAAiB;IAAE,SAAS,mBAAmB;IAAW;GAAM,CAAC;EACrF,CAAC;CACH;AACF,CAAC,EACL,CAAC;;AAGH,MAAa,eAA4D,MAAM,KAC7E,YAAY,kBACd,CAAC,OAAO,YAAY,SAAS,EAAE,iBAAiB,OAAO,CAAC,CAAC,CAAC;;AAG1D,MAAa,QAA8C,YAAY,MAAM,KAC3E,MAAM,QAAQ,YAAY,CAC5B"}
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "effect-vercel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Effect integrations for Vercel: AI Gateway, OIDC, Runtime Cache",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai-gateway",
|
|
7
|
+
"effect",
|
|
8
|
+
"effect-ai",
|
|
9
|
+
"oidc",
|
|
10
|
+
"runtime-cache",
|
|
11
|
+
"vercel"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/duailibe/effect-vercel#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/duailibe/effect-vercel/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Lucas Duailibe <lucasds@gmail.com>",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/duailibe/effect-vercel.git"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
"./ai-gateway": "./dist/ai-gateway.js",
|
|
31
|
+
"./oidc": "./dist/oidc.js",
|
|
32
|
+
"./runtime-cache": "./dist/runtime-cache.js",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsdown",
|
|
40
|
+
"check": "tsc --noEmit",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"lint": "oxlint",
|
|
43
|
+
"fmt": "oxfmt",
|
|
44
|
+
"fmt:check": "oxfmt --check",
|
|
45
|
+
"prepublishOnly": "pnpm check && pnpm test && pnpm build"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@effect/ai-anthropic": "4.0.0-rc.112",
|
|
49
|
+
"@effect/language-service": "^0.87.2",
|
|
50
|
+
"@effect/vitest": "4.0.0-rc.112",
|
|
51
|
+
"@types/node": "^24",
|
|
52
|
+
"@vercel/functions": "^3.9.9",
|
|
53
|
+
"effect": "4.0.0-rc.112",
|
|
54
|
+
"oxfmt": "^0.68.0",
|
|
55
|
+
"oxlint": "^1.83.0",
|
|
56
|
+
"tsdown": "^0.23.0",
|
|
57
|
+
"typescript": "^5.9",
|
|
58
|
+
"vitest": "^5.0.1"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@effect/ai-anthropic": "4.0.0-rc.112",
|
|
62
|
+
"@vercel/functions": "^3.9.9",
|
|
63
|
+
"effect": "4.0.0-rc.112"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"@effect/ai-anthropic": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@vercel/functions": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"packageManager": "pnpm@10.33.0"
|
|
74
|
+
}
|
package/src/AiGateway.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
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 } from "effect-vercel/ai-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(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 AiGatewayCredentials from "./AiGatewayCredentials.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 AiGatewayCredentials.AiGatewayCredentials.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, authenticating with the
|
|
62
|
+
* `AiGatewayCredentials` you provide (see that module's `layerFrom*` layers).
|
|
63
|
+
* Needs an `HttpClient` too.
|
|
64
|
+
*/
|
|
65
|
+
export const layerWithoutCredentials: Layer.Layer<
|
|
66
|
+
AnthropicClient.AnthropicClient,
|
|
67
|
+
never,
|
|
68
|
+
AiGatewayCredentials.AiGatewayCredentials | HttpClient.HttpClient
|
|
69
|
+
> = Layer.effect(AnthropicClient.AnthropicClient)(
|
|
70
|
+
Effect.flatMap(Effect.service(AiGatewayCredentials.AiGatewayCredentials), make),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `layerWithoutCredentials` with the default `AiGatewayCredentials.layer`:
|
|
75
|
+
* `AI_GATEWAY_API_KEY`, else the Vercel OIDC token. Needs an `HttpClient`.
|
|
76
|
+
* Building never fails; without a usable credential, the requests fail.
|
|
77
|
+
*/
|
|
78
|
+
export const layer: Layer.Layer<AnthropicClient.AnthropicClient, never, HttpClient.HttpClient> =
|
|
79
|
+
layerWithoutCredentials.pipe(Layer.provide(AiGatewayCredentials.layer))
|
|
80
|
+
|
|
81
|
+
function authenticate(credentials: typeof AiGatewayCredentials.AiGatewayCredentials.Service) {
|
|
82
|
+
return (request: HttpClientRequest.HttpClientRequest) =>
|
|
83
|
+
credentials.pipe(
|
|
84
|
+
Effect.map((resolved) =>
|
|
85
|
+
HttpClientRequest.setHeaders(request, AiGatewayCredentials.formatHeaders(resolved)),
|
|
86
|
+
),
|
|
87
|
+
Effect.mapError(
|
|
88
|
+
(error) =>
|
|
89
|
+
new HttpClientError.HttpClientError({
|
|
90
|
+
reason: new HttpClientError.TransportError({
|
|
91
|
+
request,
|
|
92
|
+
cause: error,
|
|
93
|
+
description: "AI Gateway credential unavailable",
|
|
94
|
+
}),
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The Effect provider writes `"cache_control": null` on every content block
|
|
102
|
+
* it has no cache setting for. Anthropic accepts that; the gateway rejects
|
|
103
|
+
* the request with `messages.0.content: Invalid input`. Remove the null keys.
|
|
104
|
+
*/
|
|
105
|
+
export function dropNullCacheControl(
|
|
106
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
107
|
+
): HttpClientRequest.HttpClientRequest {
|
|
108
|
+
const body = request.body
|
|
109
|
+
if (body._tag !== "Uint8Array" || !isJson(body.contentType)) {
|
|
110
|
+
return request
|
|
111
|
+
}
|
|
112
|
+
const json: unknown = JSON.parse(new TextDecoder().decode(body.body))
|
|
113
|
+
return HttpClientRequest.setBody(
|
|
114
|
+
request,
|
|
115
|
+
HttpBody.jsonUnsafe(withoutNullCacheControl(json), body.contentType),
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function withoutNullCacheControl(value: unknown): unknown {
|
|
120
|
+
if (Array.isArray(value)) return value.map(withoutNullCacheControl)
|
|
121
|
+
if (value === null || typeof value !== "object") return value
|
|
122
|
+
const result: Record<string, unknown> = {}
|
|
123
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
124
|
+
if (key === "cache_control" && entry === null) continue
|
|
125
|
+
result[key] = withoutNullCacheControl(entry)
|
|
126
|
+
}
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The gateway's Anthropic-shaped responses leave out keys that Anthropic
|
|
132
|
+
* always sends and the Effect provider's schema requires: the cache and
|
|
133
|
+
* service-tier usage fields, the `signature` of a thinking block from a
|
|
134
|
+
* non-Anthropic model, and `type`/`request_id` on error envelopes, whose
|
|
135
|
+
* `error.type` may also be a gateway-specific value. Fill them with the values
|
|
136
|
+
* Anthropic uses when there is nothing to report. Streaming responses are not
|
|
137
|
+
* JSON and pass through untouched.
|
|
138
|
+
*/
|
|
139
|
+
export function fillMissingResponseKeys(
|
|
140
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
141
|
+
): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError> {
|
|
142
|
+
if (!isJson(response.headers["content-type"] ?? "")) {
|
|
143
|
+
return Effect.succeed(response)
|
|
144
|
+
}
|
|
145
|
+
return Effect.map(response.text, (text) => {
|
|
146
|
+
const body = JSON.parse(text) as Record<string, unknown>
|
|
147
|
+
return HttpClientResponse.fromWeb(
|
|
148
|
+
response.request,
|
|
149
|
+
new Response(JSON.stringify(patchResponseBody(body)), {
|
|
150
|
+
status: response.status,
|
|
151
|
+
headers: response.headers,
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function patchResponseBody(body: Record<string, unknown>): Record<string, unknown> {
|
|
158
|
+
if (body.type === "message") {
|
|
159
|
+
const usage = (body.usage ?? {}) as Record<string, unknown>
|
|
160
|
+
body.usage = {
|
|
161
|
+
cache_creation: null,
|
|
162
|
+
cache_creation_input_tokens: null,
|
|
163
|
+
cache_read_input_tokens: null,
|
|
164
|
+
inference_geo: null,
|
|
165
|
+
service_tier: null,
|
|
166
|
+
...usage,
|
|
167
|
+
}
|
|
168
|
+
if (Array.isArray(body.content)) {
|
|
169
|
+
body.content = body.content.map((block: unknown) =>
|
|
170
|
+
isRecord(block) && block.type === "thinking" ? { signature: "", ...block } : block,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
return body
|
|
174
|
+
}
|
|
175
|
+
if (isRecord(body.error)) {
|
|
176
|
+
const error = ANTHROPIC_ERROR_TYPES.has(String(body.error.type))
|
|
177
|
+
? body.error
|
|
178
|
+
: { ...body.error, type: "api_error" }
|
|
179
|
+
return { type: "error", request_id: null, ...body, error }
|
|
180
|
+
}
|
|
181
|
+
return body
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const ANTHROPIC_ERROR_TYPES = new Set([
|
|
185
|
+
"invalid_request_error",
|
|
186
|
+
"authentication_error",
|
|
187
|
+
"billing_error",
|
|
188
|
+
"permission_error",
|
|
189
|
+
"not_found_error",
|
|
190
|
+
"rate_limit_error",
|
|
191
|
+
"timeout_error",
|
|
192
|
+
"api_error",
|
|
193
|
+
"overloaded_error",
|
|
194
|
+
])
|
|
195
|
+
|
|
196
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
197
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isJson(contentType: string): boolean {
|
|
201
|
+
return contentType.startsWith("application/json")
|
|
202
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How gateway requests authenticate.
|
|
3
|
+
*
|
|
4
|
+
* `AiGatewayCredentials` holds an effect that produces the credential for one
|
|
5
|
+
* request. It runs per request rather than once at startup: on Vercel the OIDC
|
|
6
|
+
* token arrives on the request context and rotates, so it must not be cached.
|
|
7
|
+
* `layer` is the default; pick a `layerFrom*` layer to use one source only.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { AiGatewayCredentials } from "effect-vercel/ai-gateway"
|
|
12
|
+
*
|
|
13
|
+
* AiGatewayCredentials.layer // AI_GATEWAY_API_KEY, else the Vercel OIDC token
|
|
14
|
+
* AiGatewayCredentials.layerFromEnv // AI_GATEWAY_API_KEY only
|
|
15
|
+
* AiGatewayCredentials.layerFromVercelOidc // the Vercel OIDC token only
|
|
16
|
+
* AiGatewayCredentials.layerFromApiKey("…") // a fixed value
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect"
|
|
21
|
+
import * as VercelOidc from "./VercelOidc.js"
|
|
22
|
+
|
|
23
|
+
/** The environment variable `layerFromEnv` reads. */
|
|
24
|
+
export const API_KEY_ENV = "AI_GATEWAY_API_KEY"
|
|
25
|
+
|
|
26
|
+
/** Which layer produced or failed to produce a credential. */
|
|
27
|
+
export type Source = "api-key" | "env" | "vercel-oidc" | "default"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A credential ready to put on a request. `method` is what the gateway
|
|
31
|
+
* records as the auth method; it matches the values Vercel's own SDK sends.
|
|
32
|
+
*/
|
|
33
|
+
export interface Resolved {
|
|
34
|
+
readonly method: "api-key" | "oidc"
|
|
35
|
+
readonly token: Redacted.Redacted<string>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** No credential could be produced for a gateway request. */
|
|
39
|
+
export class AiGatewayCredentialsError extends Data.TaggedError("AiGatewayCredentialsError")<{
|
|
40
|
+
readonly message: string
|
|
41
|
+
readonly source: Source
|
|
42
|
+
readonly hints: ReadonlyArray<string>
|
|
43
|
+
readonly cause?: unknown
|
|
44
|
+
}> {}
|
|
45
|
+
|
|
46
|
+
export class AiGatewayCredentials extends Context.Service<
|
|
47
|
+
AiGatewayCredentials,
|
|
48
|
+
Effect.Effect<Resolved, AiGatewayCredentialsError>
|
|
49
|
+
>()("effect-vercel/ai-gateway/AiGatewayCredentials") {}
|
|
50
|
+
|
|
51
|
+
/** The request headers that carry `credentials`. */
|
|
52
|
+
export const formatHeaders = (credentials: Resolved): Record<string, string> => ({
|
|
53
|
+
"x-api-key": Redacted.value(credentials.token),
|
|
54
|
+
"ai-gateway-auth-method": credentials.method,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const hints: Record<Source, ReadonlyArray<string>> = {
|
|
58
|
+
"api-key": [],
|
|
59
|
+
env: [`Set ${API_KEY_ENV} to an AI Gateway API key.`],
|
|
60
|
+
"vercel-oidc": VercelOidc.hints,
|
|
61
|
+
default: [
|
|
62
|
+
`Set ${API_KEY_ENV} to an AI Gateway API key.`,
|
|
63
|
+
"Or run on Vercel / `vercel env pull` so an OIDC token is available.",
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Where a credential may come from. `None` means the place is not configured,
|
|
69
|
+
* so a chain can move on; a failure means it is configured but unusable.
|
|
70
|
+
*/
|
|
71
|
+
type Lookup = Effect.Effect<Option.Option<Resolved>, AiGatewayCredentialsError>
|
|
72
|
+
|
|
73
|
+
const apiKeyLookup: Lookup = Config.option(Config.redacted(API_KEY_ENV)).pipe(
|
|
74
|
+
Effect.map(Option.map((token) => ({ method: "api-key" as const, token }))),
|
|
75
|
+
Effect.catchTag(
|
|
76
|
+
"ConfigError",
|
|
77
|
+
(cause) =>
|
|
78
|
+
new AiGatewayCredentialsError({
|
|
79
|
+
message: `Failed to read ${API_KEY_ENV}.`,
|
|
80
|
+
source: "env",
|
|
81
|
+
hints: hints.env,
|
|
82
|
+
cause,
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const vercelOidcLookup: Lookup = VercelOidc.find.pipe(
|
|
88
|
+
Effect.map(Option.map((token) => ({ method: "oidc" as const, token }))),
|
|
89
|
+
Effect.mapError(
|
|
90
|
+
(cause) =>
|
|
91
|
+
new AiGatewayCredentialsError({
|
|
92
|
+
message: cause.message,
|
|
93
|
+
source: "vercel-oidc",
|
|
94
|
+
hints: cause.hints,
|
|
95
|
+
cause,
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const firstOf = (lookups: ReadonlyArray<Lookup>): Lookup =>
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
for (const lookup of lookups) {
|
|
103
|
+
const found = yield* lookup
|
|
104
|
+
if (Option.isSome(found)) return found
|
|
105
|
+
}
|
|
106
|
+
return Option.none()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const toLayer = (lookup: Lookup, source: Source, message: string) =>
|
|
110
|
+
Layer.succeed(AiGatewayCredentials)(
|
|
111
|
+
Effect.flatMap(lookup, (found) =>
|
|
112
|
+
Option.isSome(found)
|
|
113
|
+
? Effect.succeed(found.value)
|
|
114
|
+
: Effect.fail(new AiGatewayCredentialsError({ message, source, hints: hints[source] })),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
/** A fixed API key. */
|
|
119
|
+
export const layerFromApiKey = (
|
|
120
|
+
apiKey: string | Redacted.Redacted<string>,
|
|
121
|
+
): Layer.Layer<AiGatewayCredentials> =>
|
|
122
|
+
Layer.succeed(AiGatewayCredentials)(
|
|
123
|
+
Effect.succeed({
|
|
124
|
+
method: "api-key",
|
|
125
|
+
token: Redacted.isRedacted(apiKey) ? apiKey : Redacted.make(apiKey),
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
/** `AI_GATEWAY_API_KEY`: local dev, CI, or anywhere off Vercel. */
|
|
130
|
+
export const layerFromEnv: Layer.Layer<AiGatewayCredentials> = toLayer(
|
|
131
|
+
apiKeyLookup,
|
|
132
|
+
"env",
|
|
133
|
+
`${API_KEY_ENV} is not set.`,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
/** The Vercel OIDC token (see `VercelOidc.find`). Not refreshed when it expires locally. */
|
|
137
|
+
export const layerFromVercelOidc: Layer.Layer<AiGatewayCredentials> = toLayer(
|
|
138
|
+
vercelOidcLookup,
|
|
139
|
+
"vercel-oidc",
|
|
140
|
+
"No Vercel OIDC token is available.",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The default: `layerFromEnv`, then `layerFromVercelOidc`. Same order as
|
|
145
|
+
* Vercel's own SDK, so it works locally with an API key and on Vercel without
|
|
146
|
+
* one.
|
|
147
|
+
*/
|
|
148
|
+
export const layer: Layer.Layer<AiGatewayCredentials> = toLayer(
|
|
149
|
+
firstOf([apiKeyLookup, vercelOidcLookup]),
|
|
150
|
+
"default",
|
|
151
|
+
"No AI Gateway credential found.",
|
|
152
|
+
)
|