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