Skip to content

Commit df4d195

Browse files
authored
feat: support account scoped ai gateway (#7979)
* feat: support account scoped ai gateway * update dep * fix lint: remove unnecessary optional chain on accounts * fix package lock * update tests
1 parent b527545 commit df4d195

File tree

8 files changed

+1344
-3220
lines changed

8 files changed

+1344
-3220
lines changed

package-lock.json

Lines changed: 1192 additions & 3214 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
},
5959
"dependencies": {
6060
"@fastify/static": "9.0.0",
61-
"@netlify/ai": "0.3.8",
61+
"@netlify/ai": "0.4.0",
6262
"@netlify/api": "14.0.17",
6363
"@netlify/blobs": "10.7.0",
6464
"@netlify/build": "35.8.5",

src/commands/dev-exec/dev-exec.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,23 @@ export const devExec = async (cmd: string, options: OptionValues, command: BaseC
1515
const withEnvelopeEnvVars = await getEnvelopeEnv({ api, context: options.context, env: cachedConfig.env, siteInfo })
1616
const env = await getDotEnvVariables({ devConfig: { ...config.dev }, env: withEnvelopeEnvVars, site })
1717

18-
const { capabilities, siteUrl } = await getSiteInformation({
18+
const { accountId, capabilities, siteUrl } = await getSiteInformation({
1919
offline: false,
2020
api,
2121
site,
2222
siteInfo,
2323
})
2424

2525
if (!capabilities.aiGatewayDisabled) {
26-
await setupAIGateway({ api, env, siteID: site.id, siteURL: siteUrl })
26+
const resolvedAccountId = accountId ?? command.netlify.accounts[0]?.id
27+
await setupAIGateway({
28+
api,
29+
env,
30+
siteID: site.id,
31+
siteURL: siteUrl,
32+
accountID: resolvedAccountId,
33+
siteHasDeploy: !!siteInfo.published_deploy,
34+
})
2735

2836
const aiGatewayEnv = env.AI_GATEWAY as (typeof env)[string] | undefined
2937
if (aiGatewayEnv) {

src/commands/dev/dev.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,15 @@ export const dev = async (options: OptionValues, command: BaseCommand) => {
158158
})
159159

160160
if (!options.offline && !options.offlineEnv && !capabilities.aiGatewayDisabled) {
161-
await setupAIGateway({ api, env, siteID: site.id, siteURL: siteUrl })
161+
const resolvedAccountId = accountId ?? command.netlify.accounts[0]?.id
162+
await setupAIGateway({
163+
api,
164+
env,
165+
siteID: site.id,
166+
siteURL: siteUrl,
167+
accountID: resolvedAccountId,
168+
siteHasDeploy: !!siteInfo.published_deploy,
169+
})
162170

163171
const aiGatewayEnv = env.AI_GATEWAY as (typeof env)[string] | undefined
164172
if (aiGatewayEnv) {

src/commands/functions/functions-serve.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@ export const functionsServe = async (options: OptionValues, command: BaseCommand
4040
})
4141

4242
if (!options.offline && !capabilities.aiGatewayDisabled) {
43-
await setupAIGateway({ api, env, siteID: site.id, siteURL: siteUrl })
43+
const resolvedAccountId = accountId ?? command.netlify.accounts[0]?.id
44+
await setupAIGateway({
45+
api,
46+
env,
47+
siteID: site.id,
48+
siteURL: siteUrl,
49+
accountID: resolvedAccountId,
50+
siteHasDeploy: !!siteInfo.published_deploy,
51+
})
4452
} else if (!options.offline && capabilities.aiGatewayDisabled) {
4553
log(`${NETLIFYDEVLOG} AI Gateway is disabled for this account`)
4654
}

src/commands/serve/serve.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,15 @@ export const serve = async (options: OptionValues, command: BaseCommand) => {
6767
})
6868

6969
if (!options.offline && !capabilities.aiGatewayDisabled) {
70-
await setupAIGateway({ api, env, siteID: site.id, siteURL: siteUrl })
70+
const resolvedAccountId = accountId ?? command.netlify.accounts[0]?.id
71+
await setupAIGateway({
72+
api,
73+
env,
74+
siteID: site.id,
75+
siteURL: siteUrl,
76+
accountID: resolvedAccountId,
77+
siteHasDeploy: !!siteInfo.published_deploy,
78+
})
7179

7280
const aiGatewayEnv = env.AI_GATEWAY as (typeof env)[string] | undefined
7381
if (aiGatewayEnv) {

tests/integration/commands/dev/ai-gateway.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { withMockApi } from '../../utils/mock-api.js'
55
import { withSiteBuilder } from '../../utils/site-builder.js'
66
import {
77
assertAIGatewayValue,
8+
createAccountScopedAIGatewayTestData,
89
createAIGatewayCheckFunction,
910
createAIGatewayDisabledTestData,
1011
createAIGatewayTestData,
@@ -263,6 +264,92 @@ describe.concurrent('AI Gateway Integration', () => {
263264
})
264265
})
265266

267+
test('should fall back to account-scoped token when site has no published deploy', async (t) => {
268+
await withSiteBuilder(t, async (builder) => {
269+
const { siteInfo, accountAIGatewayToken, routes } = createAccountScopedAIGatewayTestData()
270+
const checkFunction = createAIGatewayCheckFunction()
271+
272+
await builder
273+
.withContentFile({
274+
path: checkFunction.path,
275+
content: checkFunction.content,
276+
})
277+
.build()
278+
279+
await withMockApi(routes, async ({ apiUrl }) => {
280+
await withDevServer(
281+
{
282+
cwd: builder.directory,
283+
offline: false,
284+
env: {
285+
NETLIFY_API_URL: apiUrl,
286+
NETLIFY_SITE_ID: siteInfo.id,
287+
NETLIFY_AUTH_TOKEN: 'fake-token',
288+
},
289+
},
290+
async (server) => {
291+
const response = await fetch(`${server.url}${checkFunction.urlPath}`)
292+
const result = (await response.json()) as { hasAIGateway: boolean; aiGatewayValue: string | null }
293+
294+
t.expect(response.status).toBe(200)
295+
assertAIGatewayValue(t, result, accountAIGatewayToken.token, accountAIGatewayToken.url)
296+
},
297+
)
298+
})
299+
})
300+
})
301+
302+
test('should fall back to account-scoped token when site-scoped token fails', async (t) => {
303+
await withSiteBuilder(t, async (builder) => {
304+
const { siteInfo: baseSiteInfo } = createAIGatewayTestData()
305+
const siteInfo = { ...baseSiteInfo }
306+
const checkFunction = createAIGatewayCheckFunction()
307+
308+
const accountAIGatewayToken = {
309+
token: 'account-fallback-token-789',
310+
url: 'https://ai.netlify.com/.netlify/ai/',
311+
}
312+
313+
const routes = [
314+
{ path: 'sites/test-site-id', response: siteInfo },
315+
{ path: 'sites/test-site-id/service-instances', response: [] },
316+
{ path: 'accounts', response: [{ id: 'account-id-123', slug: siteInfo.account_slug }] },
317+
{ path: 'accounts/test-account/env', response: [] },
318+
{ path: 'sites/test-site-id/ai-gateway/token', status: 404, response: { message: 'Not Found' } },
319+
{ path: 'accounts/account-id-123/ai-gateway/token', response: accountAIGatewayToken },
320+
{ path: 'ai-gateway/providers', response: { providers: {} } },
321+
]
322+
323+
await builder
324+
.withContentFile({
325+
path: checkFunction.path,
326+
content: checkFunction.content,
327+
})
328+
.build()
329+
330+
await withMockApi(routes, async ({ apiUrl }) => {
331+
await withDevServer(
332+
{
333+
cwd: builder.directory,
334+
offline: false,
335+
env: {
336+
NETLIFY_API_URL: apiUrl,
337+
NETLIFY_SITE_ID: siteInfo.id,
338+
NETLIFY_AUTH_TOKEN: 'fake-token',
339+
},
340+
},
341+
async (server) => {
342+
const response = await fetch(`${server.url}${checkFunction.urlPath}`)
343+
const result = (await response.json()) as { hasAIGateway: boolean; aiGatewayValue: string | null }
344+
345+
t.expect(response.status).toBe(200)
346+
assertAIGatewayValue(t, result, accountAIGatewayToken.token, accountAIGatewayToken.url)
347+
},
348+
)
349+
})
350+
})
351+
})
352+
266353
test('should work with staging environment URLs', async (t) => {
267354
await withSiteBuilder(t, async (builder) => {
268355
const { siteInfo: baseSiteInfo, aiGatewayToken: baseToken } = createAIGatewayTestData()

tests/integration/utils/ai-gateway-helpers.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const createAIGatewayTestData = () => {
66
id: 'test-site-id',
77
name: 'site-name',
88
ssl_url: 'https://test-site.netlify.app',
9+
published_deploy: { id: 'deploy-123' },
910
}
1011

1112
const aiGatewayToken = {
@@ -84,6 +85,32 @@ export const createMockApiFailureRoutes = (siteInfo: {
8485
{ path: 'ai-gateway/providers', status: 404, response: { message: 'Not Found' } },
8586
]
8687

88+
export const createAccountScopedAIGatewayTestData = () => {
89+
const siteInfo = {
90+
account_slug: 'test-account',
91+
id: 'test-site-id',
92+
name: 'site-name',
93+
ssl_url: 'https://test-site.netlify.app',
94+
// No published_deploy — triggers account-scoped fallback
95+
}
96+
97+
const accountAIGatewayToken = {
98+
token: 'account-ai-gateway-token-456',
99+
url: 'https://ai.netlify.com/.netlify/ai/',
100+
}
101+
102+
const routes = [
103+
{ path: 'sites/test-site-id', response: siteInfo },
104+
{ path: 'sites/test-site-id/service-instances', response: [] },
105+
{ path: 'accounts', response: [{ id: 'account-id-123', slug: siteInfo.account_slug }] },
106+
{ path: 'accounts/test-account/env', response: [] },
107+
{ path: 'accounts/account-id-123/ai-gateway/token', response: accountAIGatewayToken },
108+
{ path: 'ai-gateway/providers', response: { providers: {} } },
109+
]
110+
111+
return { siteInfo, accountAIGatewayToken, routes }
112+
}
113+
87114
export const createAIGatewayDisabledTestData = () => {
88115
const siteInfo = {
89116
account_slug: 'test-account',

0 commit comments

Comments
 (0)