Open History to see the last commit
1import { createHash } from "node:crypto";
2import * as Binding from "alchemy/Binding";
3import * as Output from "alchemy/Output";
4import { defaultProviderMode } from "alchemy/ProviderMode";
5import type { RuntimeContext } from "alchemy/RuntimeContext";
6import type { LanguageModel } from "effect/ai/LanguageModel";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Redacted from "effect/Redacted";
11import type { AIGateway } from "./AIGateway.ts";
12import {
13 backendEnvKey,
14 backendSecret,
15 backendString,
16 bindBackendEnvironment,
17} from "./BackendConnection.ts";
18import { Credential, validateCredential } from "./Credential.ts";
19import { scopeIdentity, usesInjectedCredentials } from "./CredentialScope.ts";
20import { FunctionEnvironment } from "./FunctionEnvironment.ts";
21import { makeLanguageModelLayer, type LanguageModelOptions } from "./LanguageModel.ts";
22
23export interface QueryAIGatewayClient {
24 /** Bare gateway root for SDKs that handle their own routing. */
25 baseUrl: Effect.Effect<string, never, RuntimeContext>;
26 /** OpenAI Chat Completions base URL, ending in /v1. */
27 chatBaseUrl: Effect.Effect<string, never, RuntimeContext>;
28 /** OpenAI Responses base URL, ending in /openai/v1. */
29 responsesBaseUrl: Effect.Effect<string, never, RuntimeContext>;
30 /** Native Anthropic SDK base URL, ending in /anthropic (without /v1). */
31 anthropicBaseUrl: Effect.Effect<string, never, RuntimeContext>;
32 /** Redacted branch-service bearer credential with ai_gateway:invoke access. */
33 token: Effect.Effect<Redacted.Redacted<string>, never, RuntimeContext>;
34 /** Effect AI using Chat Completions, with no inference during deployment. */
35 model(options: LanguageModelOptions): Layer.Layer<LanguageModel, never, RuntimeContext>;
36}
37
38/**
39 * Obtain an Effect AI LanguageModel or SDK-compatible endpoint and credential
40 * effects; no model calls happen during deployment. QueryAIGatewayHttp uses
41 * the same-branch Function's injected grant, otherwise a tracked branch credential.
42 * An explicit credential overrides injection. The injected grant remains available
43 * to the whole Function process; a binding is not a permission sandbox.
44 *
45 * ### Configure a model client
46 * **Example:** OpenAI-compatible chat configuration
47 * ```typescript
48 * const ai = yield* Neon.QueryAIGateway(gateway);
49 * // Inside the request handler:
50 * const baseURL = yield* ai.chatBaseUrl;
51 * const apiKey = yield* ai.token;
52 * ```
53 *
54 * ### Use Effect AI
55 * **Example:** Generate text in a request handler
56 * ```typescript
57 * const ai = yield* Neon.QueryAIGateway(gateway);
58 * const model = ai.model({ model: "gpt-5-mini" });
59 * // Inside a Function or Worker handler:
60 * const reply = yield* LanguageModel.generateText({ prompt: "Say hello." }).pipe(
61 * Effect.provide(model),
62 * );
63 * ```
64 *
65 * `model` uses Chat Completions, including for Claude models. For Responses-only
66 * models or native Anthropic features, use `responsesBaseUrl` or
67 * `anthropicBaseUrl` with the corresponding SDK and the redacted `token`.
68 *
69 * @binding
70 * @product AI Gateway
71 * @category AI Gateway
72 */
73export interface QueryAIGateway extends Binding.Service<
74 QueryAIGateway,
75 "Neon.QueryAIGateway",
76 (gateway: AIGateway) => Effect.Effect<QueryAIGatewayClient>
77> {}
78export const QueryAIGateway = Binding.Service<QueryAIGateway>("Neon.QueryAIGateway");
79
80const client = (
81 baseUrl: QueryAIGatewayClient["baseUrl"],
82 token: QueryAIGatewayClient["token"],
83): QueryAIGatewayClient => {
84 const chatBaseUrl = baseUrl.pipe(Effect.map((base) => `${base.replace(/\/$/, "")}/v1`));
85 return {
86 baseUrl,
87 token,
88 chatBaseUrl,
89 responsesBaseUrl: baseUrl.pipe(Effect.map((base) => `${base.replace(/\/$/, "")}/openai/v1`)),
90 anthropicBaseUrl: baseUrl.pipe(Effect.map((base) => `${base.replace(/\/$/, "")}/anthropic`)),
91 model: (options) =>
92 makeLanguageModelLayer({
93 ...options,
94 client: { chatBaseUrl, token },
95 }),
96 };
97};
98
99/** HTTP client with injected same-branch or managed scoped credentials. */
100export const QueryAIGatewayHttp = Layer.effect(
101 QueryAIGateway,
102 Effect.gen(function* () {
103 const environment = yield* Effect.serviceOption(FunctionEnvironment);
104 const createCredential = yield* Credential;
105 return Effect.fn(function* (gateway: AIGateway) {
106 const urlKey = backendEnvKey(gateway.FQN, "AI_GATEWAY_URL");
107 const tokenKey = backendEnvKey(gateway.FQN, "AI_GATEWAY_TOKEN");
108 const injectedKey = backendEnvKey(gateway.FQN, "AI_GATEWAY_INJECTED");
109 if (!globalThis.__ALCHEMY_RUNTIME__) {
110 const host = yield* Binding.Host;
111 if (!host)
112 return yield* Effect.die(
113 new Error("QueryAIGatewayHttp requires a Function or Worker host"),
114 );
115 const mode = host.Mode ?? (yield* defaultProviderMode);
116 const injected = !gateway.credential && usesInjectedCredentials(host, gateway.Props, mode);
117 const env: Record<string, Output.Output<string | Redacted.Redacted<string>>> = {
118 [urlKey]: gateway.baseUrl,
119 [injectedKey]: Output.literal(injected ? "yes" : "no"),
120 };
121 if (!injected) {
122 const scopeId = scopeIdentity(gateway.Props) ?? gateway.FQN;
123 const credentialId = yield* Effect.sync(() =>
124 createHash("sha256")
125 .update(`${host.FQN}:${scopeId}:ai_gateway:invoke`)
126 .digest("hex")
127 .slice(0, 24),
128 );
129 const credential =
130 gateway.credential ??
131 (yield* createCredential(`AIGateway${credentialId}`, {
132 ...(gateway.Props.branch !== undefined
133 ? { branch: gateway.Props.branch }
134 : { project: gateway.Props.project }),
135 scopes: ["ai_gateway:invoke"],
136 }));
137 env[tokenKey] = Output.all(
138 credential.projectId,
139 credential.branchId,
140 credential.scopes,
141 gateway.projectId,
142 gateway.branchId,
143 credential.apiToken,
144 ).pipe(
145 Output.mapEffect(
146 ([projectId, branchId, scopes, targetProjectId, targetBranchId, token]) =>
147 validateCredential(
148 { projectId, branchId, scopes },
149 { projectId: targetProjectId, branchId: targetBranchId },
150 "ai_gateway:invoke",
151 ).pipe(Effect.as(token), Effect.orDie),
152 ),
153 );
154 }
155 yield* bindBackendEnvironment(`Neon.QueryAIGateway:${gateway.FQN}`, env);
156 }
157 const baseUrl = backendString(urlKey);
158 const token = Effect.gen(function* () {
159 if ((yield* backendString(injectedKey)) !== "yes") return yield* backendSecret(tokenKey);
160 const expected = (yield* baseUrl).replace(/\/$/, "");
161 const env = Option.getOrUndefined(environment);
162 if (env?.NEON_AI_GATEWAY_BASE_URL?.replace(/\/$/, "") !== expected)
163 return yield* Effect.die(
164 new Error("AI Gateway injection does not match the target branch"),
165 );
166 if (!env.NEON_AI_GATEWAY_TOKEN)
167 return yield* Effect.die(new Error("Neon did not inject an AI Gateway credential"));
168 return Redacted.make(env.NEON_AI_GATEWAY_TOKEN);
169 });
170 return client(baseUrl, token);
171 });
172 }),
173);