Skip to content

Commit c4a30f1

Browse files
feat(agents): add typed API client and shared infra
Introduce the typed agents/AI-gateway API module (api.ts), attachment upload handling (attachments.ts), and the supporting types, constants, and utils the expanded agents CLI surface builds on. Models are now fetched live from the AI gateway instead of a static whitelist. No command behavior changes yet: the existing create/list/show/stop commands keep working against the new infra. Subsequent PRs rewrite those commands and add the new subcommands on top of this base. Part 1/8 of splitting #8237.
1 parent c9d1772 commit c4a30f1

6 files changed

Lines changed: 704 additions & 34 deletions

File tree

src/commands/agents/api.ts

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// TODO: Migrate to @netlify/api once these endpoints are public.
2+
// They are marked x-internal in bitballoon (#21736).
3+
// Method names mirror the bitballoon @operation_id to keep the future swap quick.
4+
5+
import type { NetlifyOptions } from '../types.js'
6+
import { parseLinkHeader } from './utils.js'
7+
import type {
8+
AgentRunner,
9+
AgentRunnerSession,
10+
AiGatewayProvidersResponse,
11+
CreateAgentRunnerPayload,
12+
CreateAgentRunnerSessionPayload,
13+
DeleteUrlResponse,
14+
DiffParams,
15+
ListAgentRunnerSessionsFilters,
16+
ListAgentRunnersFilters,
17+
PaginatedResult,
18+
UploadUrlResponse,
19+
} from './types.js'
20+
21+
const DEFAULT_PER_PAGE = 100
22+
23+
type RawResponseHandler<T> = (response: Response) => Promise<T>
24+
25+
type SearchParamValue = string | number | boolean | null | undefined
26+
27+
const buildSearchParams = (entries: Record<string, SearchParamValue>): URLSearchParams => {
28+
const params = new URLSearchParams()
29+
for (const [key, value] of Object.entries(entries)) {
30+
if (value === undefined || value === null || value === '') continue
31+
params.set(key, value.toString())
32+
}
33+
return params
34+
}
35+
36+
const readPagination = (response: Response, page: number, perPage: number): { total?: number; hasNext: boolean } => {
37+
const totalHeader = response.headers.get('Total')
38+
const total = totalHeader != null ? Number.parseInt(totalHeader, 10) : undefined
39+
const links = parseLinkHeader(response.headers.get('Link'))
40+
const hasNext = Boolean(links.next) || (total != null && page * perPage < total)
41+
return { total: Number.isFinite(total) ? total : undefined, hasNext }
42+
}
43+
44+
export const createAgentsApi = (netlify: NetlifyOptions) => {
45+
const { api, apiOpts } = netlify
46+
const baseUrl = api.basePath
47+
48+
const baseHeaders = (extra: Record<string, string> = {}): Record<string, string> => ({
49+
Authorization: `Bearer ${api.accessToken ?? ''}`,
50+
'User-Agent': apiOpts.userAgent,
51+
...extra,
52+
})
53+
54+
const throwForStatus = async (response: Response): Promise<never> => {
55+
const errorData = (await response.json().catch(() => ({}))) as { error?: string }
56+
const error = new Error(
57+
errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`,
58+
) as Error & { status?: number }
59+
error.status = response.status
60+
throw error
61+
}
62+
63+
const requestRaw = async <T>(path: string, init: RequestInit, handler: RawResponseHandler<T>): Promise<T> => {
64+
const response = await fetch(`${baseUrl}${path}`, init)
65+
if (!response.ok) await throwForStatus(response)
66+
return handler(response)
67+
}
68+
69+
const requestJson = async <T>(path: string, init: RequestInit = {}): Promise<T> =>
70+
requestRaw(path, init, async (response) => {
71+
if (response.status === 202) return undefined as T
72+
const text = await response.text()
73+
if (!text) return undefined as T
74+
return JSON.parse(text) as T
75+
})
76+
77+
const requestNoContent = (path: string, init: RequestInit = {}): Promise<void> =>
78+
requestRaw(path, init, () => Promise.resolve(undefined))
79+
80+
const jsonInit = (method: string, body?: unknown): RequestInit => ({
81+
method,
82+
headers: baseHeaders(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
83+
body: body !== undefined ? JSON.stringify(body) : undefined,
84+
})
85+
86+
const getInit = (): RequestInit => ({ method: 'GET', headers: baseHeaders() })
87+
88+
const listAgentRunners = async (
89+
siteId: string,
90+
filters: ListAgentRunnersFilters = {},
91+
): Promise<PaginatedResult<AgentRunner[]>> => {
92+
const page = filters.page ?? 1
93+
const perPage = filters.per_page ?? DEFAULT_PER_PAGE
94+
const params = buildSearchParams({ ...filters, site_id: siteId, page, per_page: perPage })
95+
const response = await fetch(`${baseUrl}/agent_runners?${params.toString()}`, getInit())
96+
if (!response.ok) await throwForStatus(response)
97+
const data = (await response.json()) as AgentRunner[]
98+
const { total, hasNext } = readPagination(response, page, perPage)
99+
return { data, total, page, perPage, hasNext }
100+
}
101+
102+
const listAgentRunnersForAccount = async (
103+
accountSlug: string,
104+
filters: ListAgentRunnersFilters = {},
105+
): Promise<PaginatedResult<AgentRunner[]>> => {
106+
const page = filters.page ?? 1
107+
const perPage = filters.per_page ?? DEFAULT_PER_PAGE
108+
const params = buildSearchParams({ ...filters, page, per_page: perPage })
109+
const response = await fetch(
110+
`${baseUrl}/${encodeURIComponent(accountSlug)}/agent_runners?${params.toString()}`,
111+
getInit(),
112+
)
113+
if (!response.ok) await throwForStatus(response)
114+
const data = (await response.json()) as AgentRunner[]
115+
const { total, hasNext } = readPagination(response, page, perPage)
116+
return { data, total, page, perPage, hasNext }
117+
}
118+
119+
const getAgentRunner = (id: string): Promise<AgentRunner> =>
120+
requestJson<AgentRunner>(`/agent_runners/${id}`, getInit())
121+
122+
const createAgentRunner = (siteId: string, payload: CreateAgentRunnerPayload): Promise<AgentRunner> => {
123+
const params = buildSearchParams({ site_id: siteId })
124+
return requestJson<AgentRunner>(`/agent_runners?${params.toString()}`, jsonInit('POST', payload))
125+
}
126+
127+
const deleteAgentRunner = (id: string): Promise<void> =>
128+
requestNoContent(`/agent_runners/${id}`, { method: 'DELETE', headers: baseHeaders() })
129+
130+
const archiveAgentRunner = (id: string): Promise<void> =>
131+
requestNoContent(`/agent_runners/${id}/archive`, { method: 'POST', headers: baseHeaders() })
132+
133+
const listAgentRunnerSessions = async (
134+
id: string,
135+
filters: ListAgentRunnerSessionsFilters = {},
136+
): Promise<AgentRunnerSession[]> => {
137+
const page = filters.page ?? 1
138+
const perPage = filters.per_page ?? DEFAULT_PER_PAGE
139+
const params = buildSearchParams({ ...filters, page, per_page: perPage })
140+
return requestJson<AgentRunnerSession[]>(`/agent_runners/${id}/sessions?${params.toString()}`, getInit())
141+
}
142+
143+
const getAgentRunnerSession = (id: string, sessionId: string): Promise<AgentRunnerSession> =>
144+
requestJson<AgentRunnerSession>(`/agent_runners/${id}/sessions/${sessionId}`, getInit())
145+
146+
const createAgentRunnerSession = (
147+
id: string,
148+
payload: CreateAgentRunnerSessionPayload,
149+
): Promise<AgentRunnerSession> =>
150+
requestJson<AgentRunnerSession>(`/agent_runners/${id}/sessions`, jsonInit('POST', payload))
151+
152+
const redeployAgentRunnerSession = (id: string, sessionId: string): Promise<AgentRunnerSession> =>
153+
requestJson<AgentRunnerSession>(`/agent_runners/${id}/sessions/${sessionId}/redeploy`, jsonInit('POST'))
154+
155+
const getAgentRunnerDiff = async (id: string, params: DiffParams = {}): Promise<PaginatedResult<string>> => {
156+
const page = params.page ?? 1
157+
const perPage = params.per_page ?? DEFAULT_PER_PAGE
158+
const stripBinary = params.strip_binary ?? true
159+
const search = buildSearchParams({ page, per_page: perPage, strip_binary: stripBinary })
160+
const response = await fetch(`${baseUrl}/agent_runners/${id}/diff?${search.toString()}`, getInit())
161+
if (!response.ok) {
162+
if (response.status === 404) return { data: '', total: 0, page, perPage, hasNext: false }
163+
await throwForStatus(response)
164+
}
165+
const body = await response.text()
166+
const { total, hasNext } = readPagination(response, page, perPage)
167+
return { data: body, total, page, perPage, hasNext }
168+
}
169+
170+
const getSessionDiff = async (id: string, sessionId: string, kind: 'result' | 'cumulative'): Promise<string> => {
171+
const response = await fetch(`${baseUrl}/agent_runners/${id}/sessions/${sessionId}/diff/${kind}`, getInit())
172+
if (response.status === 404) return ''
173+
if (!response.ok) await throwForStatus(response)
174+
return response.text()
175+
}
176+
177+
const agentRunnerPullRequest = (id: string): Promise<AgentRunner> =>
178+
requestJson<AgentRunner>(`/agent_runners/${id}/pull_request`, jsonInit('POST'))
179+
180+
const agentRunnerCommitToBranch = (id: string, targetBranch: string): Promise<AgentRunner> =>
181+
requestJson<AgentRunner>(`/agent_runners/${id}/commit`, jsonInit('POST', { target_branch: targetBranch }))
182+
183+
const agentRunnerPublishToProduction = (id: string): Promise<AgentRunner> =>
184+
requestJson<AgentRunner>(`/agent_runners/${id}/publish_to_production`, jsonInit('POST'))
185+
186+
const revertAgentRunner = (id: string, sessionId: string): Promise<AgentRunner> =>
187+
requestJson<AgentRunner>(`/agent_runners/${id}/revert`, jsonInit('POST', { session_id: sessionId }))
188+
189+
const updateAgentRunner = (id: string, payload: { title?: string; base_deploy_id?: string }): Promise<AgentRunner> =>
190+
requestJson<AgentRunner>(`/agent_runners/${id}`, jsonInit('PATCH', payload))
191+
192+
const rebaseAgentRunner = (id: string): Promise<AgentRunner> =>
193+
requestJson<AgentRunner>(`/agent_runners/${id}/rebase`, jsonInit('POST'))
194+
195+
const mergeTargetAgentRunner = (id: string): Promise<AgentRunner> =>
196+
requestJson<AgentRunner>(`/agent_runners/${id}/merge_target`, jsonInit('POST'))
197+
198+
const syncGitOriginAgentRunner = (id: string): Promise<AgentRunner> =>
199+
requestJson<AgentRunner>(`/agent_runners/${id}/sync_git_origin`, jsonInit('POST'))
200+
201+
const createAgentRunnerUploadUrl = (payload: {
202+
account_id: string
203+
filename: string
204+
content_type: string
205+
}): Promise<UploadUrlResponse> =>
206+
requestJson<UploadUrlResponse>(`/agent_runners/upload_url`, jsonInit('POST', payload))
207+
208+
const createAgentRunnerDeleteUrl = (payload: { account_id: string; file_key: string }): Promise<DeleteUrlResponse> =>
209+
requestJson<DeleteUrlResponse>(`/agent_runners/delete_url`, jsonInit('POST', payload))
210+
211+
let providersCache: AiGatewayProvidersResponse | null = null
212+
const listAiGatewayProviders = async (): Promise<AiGatewayProvidersResponse> => {
213+
if (providersCache) return providersCache
214+
// Public endpoint by design — no auth header. The provider+model list is meant
215+
// for external clients to discover the agent → provider → model relationship.
216+
const response = await fetch(`${baseUrl}/ai-gateway/providers`)
217+
if (!response.ok) await throwForStatus(response)
218+
providersCache = (await response.json()) as AiGatewayProvidersResponse
219+
return providersCache
220+
}
221+
222+
return {
223+
listAgentRunners,
224+
listAgentRunnersForAccount,
225+
getAgentRunner,
226+
createAgentRunner,
227+
updateAgentRunner,
228+
deleteAgentRunner,
229+
archiveAgentRunner,
230+
listAgentRunnerSessions,
231+
getAgentRunnerSession,
232+
createAgentRunnerSession,
233+
redeployAgentRunnerSession,
234+
getAgentRunnerDiff,
235+
getSessionResultDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'result'),
236+
getSessionCumulativeDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'cumulative'),
237+
agentRunnerPullRequest,
238+
agentRunnerCommitToBranch,
239+
agentRunnerPublishToProduction,
240+
revertAgentRunner,
241+
rebaseAgentRunner,
242+
mergeTargetAgentRunner,
243+
syncGitOriginAgentRunner,
244+
createAgentRunnerUploadUrl,
245+
createAgentRunnerDeleteUrl,
246+
listAiGatewayProviders,
247+
}
248+
}
249+
250+
export type AgentsApi = ReturnType<typeof createAgentsApi>

src/commands/agents/attachments.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import fs from 'fs/promises'
2+
import path from 'path'
3+
4+
import type { AgentsApi } from './api.js'
5+
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS_PER_REQUEST } from './constants.js'
6+
import { formatBytes, getMimeType } from './utils.js'
7+
8+
export interface UploadedAttachment {
9+
path: string
10+
filename: string
11+
fileKey: string
12+
size: number
13+
contentType: string
14+
}
15+
16+
const cleanupOrphans = async (api: AgentsApi, accountId: string, fileKeys: string[]): Promise<void> => {
17+
await Promise.allSettled(
18+
fileKeys.map(async (fileKey) => {
19+
try {
20+
const { delete_url: deleteUrl } = await api.createAgentRunnerDeleteUrl({
21+
account_id: accountId,
22+
file_key: fileKey,
23+
})
24+
await fetch(deleteUrl, { method: 'DELETE' })
25+
} catch {
26+
// Best-effort cleanup; if it fails, the orphan is the user's tenant problem.
27+
}
28+
}),
29+
)
30+
}
31+
32+
export const uploadAttachments = async (
33+
api: AgentsApi,
34+
accountId: string,
35+
filePaths: string[],
36+
): Promise<UploadedAttachment[]> => {
37+
if (filePaths.length === 0) return []
38+
if (filePaths.length > MAX_ATTACHMENTS_PER_REQUEST) {
39+
throw new Error(
40+
`Too many attachments: ${filePaths.length.toString()} given, max is ${MAX_ATTACHMENTS_PER_REQUEST.toString()}`,
41+
)
42+
}
43+
44+
const resolved = await Promise.all(
45+
filePaths.map(async (filePath) => {
46+
const absolute = path.resolve(filePath)
47+
const stat = await fs.stat(absolute).catch(() => null)
48+
if (!stat?.isFile()) {
49+
throw new Error(`Attachment not found or not a file: ${filePath}`)
50+
}
51+
if (stat.size > MAX_ATTACHMENT_SIZE_BYTES) {
52+
throw new Error(
53+
`Attachment ${filePath} is ${formatBytes(stat.size)}, exceeds the ${formatBytes(
54+
MAX_ATTACHMENT_SIZE_BYTES,
55+
)} limit`,
56+
)
57+
}
58+
const filename = path.basename(absolute)
59+
return { path: absolute, filename, size: stat.size, contentType: getMimeType(filename) }
60+
}),
61+
)
62+
63+
const uploaded: UploadedAttachment[] = []
64+
try {
65+
for (const file of resolved) {
66+
const { upload_url: uploadUrl, file_key: fileKey } = await api.createAgentRunnerUploadUrl({
67+
account_id: accountId,
68+
filename: file.filename,
69+
content_type: file.contentType,
70+
})
71+
72+
const body = await fs.readFile(file.path)
73+
const controller = new AbortController()
74+
const timeout = setTimeout(() => {
75+
controller.abort()
76+
}, 60_000)
77+
let putResponse: Response
78+
try {
79+
putResponse = await fetch(uploadUrl, {
80+
method: 'PUT',
81+
body: new Uint8Array(body),
82+
headers: { 'Content-Type': file.contentType },
83+
signal: controller.signal,
84+
})
85+
} catch (error_) {
86+
const error = error_ as Error
87+
if (error.name === 'AbortError') {
88+
throw new Error(`Upload of ${file.filename} timed out after 60s`)
89+
}
90+
throw error
91+
} finally {
92+
clearTimeout(timeout)
93+
}
94+
if (!putResponse.ok) {
95+
throw new Error(
96+
`Failed to upload ${file.filename}: HTTP ${putResponse.status.toString()} ${putResponse.statusText}`,
97+
)
98+
}
99+
uploaded.push({ ...file, fileKey })
100+
}
101+
return uploaded
102+
} catch (error) {
103+
if (uploaded.length > 0) {
104+
await cleanupOrphans(
105+
api,
106+
accountId,
107+
uploaded.map((entry) => entry.fileKey),
108+
)
109+
}
110+
throw error
111+
}
112+
}

0 commit comments

Comments
 (0)