From 5549de6c45588b29100b0b0da23f52c38e011717 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Mon, 1 Jun 2026 15:28:52 +0530 Subject: [PATCH] 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 the agents CLI revamp split. --- src/commands/agents/api.ts | 250 ++++++++++++++++++++++++++++ src/commands/agents/attachments.ts | 112 +++++++++++++ src/commands/agents/constants.ts | 42 +++-- src/commands/agents/types.ts | 156 ++++++++++++++++- src/commands/agents/utils.ts | 169 ++++++++++++++++++- tests/integration/utils/mock-api.ts | 9 +- 6 files changed, 704 insertions(+), 34 deletions(-) create mode 100644 src/commands/agents/api.ts create mode 100644 src/commands/agents/attachments.ts diff --git a/src/commands/agents/api.ts b/src/commands/agents/api.ts new file mode 100644 index 00000000000..255ff253ec1 --- /dev/null +++ b/src/commands/agents/api.ts @@ -0,0 +1,250 @@ +// TODO: Migrate to @netlify/api once these endpoints are public. +// They are marked x-internal in bitballoon (#21736). +// Method names mirror the bitballoon @operation_id to keep the future swap quick. + +import type { NetlifyOptions } from '../types.js' +import { parseLinkHeader } from './utils.js' +import type { + AgentRunner, + AgentRunnerSession, + AiGatewayProvidersResponse, + CreateAgentRunnerPayload, + CreateAgentRunnerSessionPayload, + DeleteUrlResponse, + DiffParams, + ListAgentRunnerSessionsFilters, + ListAgentRunnersFilters, + PaginatedResult, + UploadUrlResponse, +} from './types.js' + +const DEFAULT_PER_PAGE = 100 + +type RawResponseHandler = (response: Response) => Promise + +type SearchParamValue = string | number | boolean | null | undefined + +const buildSearchParams = (entries: Record): URLSearchParams => { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(entries)) { + if (value === undefined || value === null || value === '') continue + params.set(key, value.toString()) + } + return params +} + +const readPagination = (response: Response, page: number, perPage: number): { total?: number; hasNext: boolean } => { + const totalHeader = response.headers.get('Total') + const total = totalHeader != null ? Number.parseInt(totalHeader, 10) : undefined + const links = parseLinkHeader(response.headers.get('Link')) + const hasNext = Boolean(links.next) || (total != null && page * perPage < total) + return { total: Number.isFinite(total) ? total : undefined, hasNext } +} + +export const createAgentsApi = (netlify: NetlifyOptions) => { + const { api, apiOpts } = netlify + const baseUrl = api.basePath + + const baseHeaders = (extra: Record = {}): Record => ({ + Authorization: `Bearer ${api.accessToken ?? ''}`, + 'User-Agent': apiOpts.userAgent, + ...extra, + }) + + const throwForStatus = async (response: Response): Promise => { + const errorData = (await response.json().catch(() => ({}))) as { error?: string } + const error = new Error( + errorData.error ?? `HTTP ${response.status.toString()}: ${response.statusText}`, + ) as Error & { status?: number } + error.status = response.status + throw error + } + + const requestRaw = async (path: string, init: RequestInit, handler: RawResponseHandler): Promise => { + const response = await fetch(`${baseUrl}${path}`, init) + if (!response.ok) await throwForStatus(response) + return handler(response) + } + + const requestJson = async (path: string, init: RequestInit = {}): Promise => + requestRaw(path, init, async (response) => { + if (response.status === 202) return undefined as T + const text = await response.text() + if (!text) return undefined as T + return JSON.parse(text) as T + }) + + const requestNoContent = (path: string, init: RequestInit = {}): Promise => + requestRaw(path, init, () => Promise.resolve(undefined)) + + const jsonInit = (method: string, body?: unknown): RequestInit => ({ + method, + headers: baseHeaders(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + const getInit = (): RequestInit => ({ method: 'GET', headers: baseHeaders() }) + + const listAgentRunners = async ( + siteId: string, + filters: ListAgentRunnersFilters = {}, + ): Promise> => { + const page = filters.page ?? 1 + const perPage = filters.per_page ?? DEFAULT_PER_PAGE + const params = buildSearchParams({ ...filters, site_id: siteId, page, per_page: perPage }) + const response = await fetch(`${baseUrl}/agent_runners?${params.toString()}`, getInit()) + if (!response.ok) await throwForStatus(response) + const data = (await response.json()) as AgentRunner[] + const { total, hasNext } = readPagination(response, page, perPage) + return { data, total, page, perPage, hasNext } + } + + const listAgentRunnersForAccount = async ( + accountSlug: string, + filters: ListAgentRunnersFilters = {}, + ): Promise> => { + const page = filters.page ?? 1 + const perPage = filters.per_page ?? DEFAULT_PER_PAGE + const params = buildSearchParams({ ...filters, page, per_page: perPage }) + const response = await fetch( + `${baseUrl}/${encodeURIComponent(accountSlug)}/agent_runners?${params.toString()}`, + getInit(), + ) + if (!response.ok) await throwForStatus(response) + const data = (await response.json()) as AgentRunner[] + const { total, hasNext } = readPagination(response, page, perPage) + return { data, total, page, perPage, hasNext } + } + + const getAgentRunner = (id: string): Promise => + requestJson(`/agent_runners/${id}`, getInit()) + + const createAgentRunner = (siteId: string, payload: CreateAgentRunnerPayload): Promise => { + const params = buildSearchParams({ site_id: siteId }) + return requestJson(`/agent_runners?${params.toString()}`, jsonInit('POST', payload)) + } + + const deleteAgentRunner = (id: string): Promise => + requestNoContent(`/agent_runners/${id}`, { method: 'DELETE', headers: baseHeaders() }) + + const archiveAgentRunner = (id: string): Promise => + requestNoContent(`/agent_runners/${id}/archive`, { method: 'POST', headers: baseHeaders() }) + + const listAgentRunnerSessions = async ( + id: string, + filters: ListAgentRunnerSessionsFilters = {}, + ): Promise => { + const page = filters.page ?? 1 + const perPage = filters.per_page ?? DEFAULT_PER_PAGE + const params = buildSearchParams({ ...filters, page, per_page: perPage }) + return requestJson(`/agent_runners/${id}/sessions?${params.toString()}`, getInit()) + } + + const getAgentRunnerSession = (id: string, sessionId: string): Promise => + requestJson(`/agent_runners/${id}/sessions/${sessionId}`, getInit()) + + const createAgentRunnerSession = ( + id: string, + payload: CreateAgentRunnerSessionPayload, + ): Promise => + requestJson(`/agent_runners/${id}/sessions`, jsonInit('POST', payload)) + + const redeployAgentRunnerSession = (id: string, sessionId: string): Promise => + requestJson(`/agent_runners/${id}/sessions/${sessionId}/redeploy`, jsonInit('POST')) + + const getAgentRunnerDiff = async (id: string, params: DiffParams = {}): Promise> => { + const page = params.page ?? 1 + const perPage = params.per_page ?? DEFAULT_PER_PAGE + const stripBinary = params.strip_binary ?? true + const search = buildSearchParams({ page, per_page: perPage, strip_binary: stripBinary }) + const response = await fetch(`${baseUrl}/agent_runners/${id}/diff?${search.toString()}`, getInit()) + if (!response.ok) { + if (response.status === 404) return { data: '', total: 0, page, perPage, hasNext: false } + await throwForStatus(response) + } + const body = await response.text() + const { total, hasNext } = readPagination(response, page, perPage) + return { data: body, total, page, perPage, hasNext } + } + + const getSessionDiff = async (id: string, sessionId: string, kind: 'result' | 'cumulative'): Promise => { + const response = await fetch(`${baseUrl}/agent_runners/${id}/sessions/${sessionId}/diff/${kind}`, getInit()) + if (response.status === 404) return '' + if (!response.ok) await throwForStatus(response) + return response.text() + } + + const agentRunnerPullRequest = (id: string): Promise => + requestJson(`/agent_runners/${id}/pull_request`, jsonInit('POST')) + + const agentRunnerCommitToBranch = (id: string, targetBranch: string): Promise => + requestJson(`/agent_runners/${id}/commit`, jsonInit('POST', { target_branch: targetBranch })) + + const agentRunnerPublishToProduction = (id: string): Promise => + requestJson(`/agent_runners/${id}/publish_to_production`, jsonInit('POST')) + + const revertAgentRunner = (id: string, sessionId: string): Promise => + requestJson(`/agent_runners/${id}/revert`, jsonInit('POST', { session_id: sessionId })) + + const updateAgentRunner = (id: string, payload: { title?: string; base_deploy_id?: string }): Promise => + requestJson(`/agent_runners/${id}`, jsonInit('PATCH', payload)) + + const rebaseAgentRunner = (id: string): Promise => + requestJson(`/agent_runners/${id}/rebase`, jsonInit('POST')) + + const mergeTargetAgentRunner = (id: string): Promise => + requestJson(`/agent_runners/${id}/merge_target`, jsonInit('POST')) + + const syncGitOriginAgentRunner = (id: string): Promise => + requestJson(`/agent_runners/${id}/sync_git_origin`, jsonInit('POST')) + + const createAgentRunnerUploadUrl = (payload: { + account_id: string + filename: string + content_type: string + }): Promise => + requestJson(`/agent_runners/upload_url`, jsonInit('POST', payload)) + + const createAgentRunnerDeleteUrl = (payload: { account_id: string; file_key: string }): Promise => + requestJson(`/agent_runners/delete_url`, jsonInit('POST', payload)) + + let providersCache: AiGatewayProvidersResponse | null = null + const listAiGatewayProviders = async (): Promise => { + if (providersCache) return providersCache + // Public endpoint by design — no auth header. The provider+model list is meant + // for external clients to discover the agent → provider → model relationship. + const response = await fetch(`${baseUrl}/ai-gateway/providers`) + if (!response.ok) await throwForStatus(response) + providersCache = (await response.json()) as AiGatewayProvidersResponse + return providersCache + } + + return { + listAgentRunners, + listAgentRunnersForAccount, + getAgentRunner, + createAgentRunner, + updateAgentRunner, + deleteAgentRunner, + archiveAgentRunner, + listAgentRunnerSessions, + getAgentRunnerSession, + createAgentRunnerSession, + redeployAgentRunnerSession, + getAgentRunnerDiff, + getSessionResultDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'result'), + getSessionCumulativeDiff: (id: string, sessionId: string) => getSessionDiff(id, sessionId, 'cumulative'), + agentRunnerPullRequest, + agentRunnerCommitToBranch, + agentRunnerPublishToProduction, + revertAgentRunner, + rebaseAgentRunner, + mergeTargetAgentRunner, + syncGitOriginAgentRunner, + createAgentRunnerUploadUrl, + createAgentRunnerDeleteUrl, + listAiGatewayProviders, + } +} + +export type AgentsApi = ReturnType diff --git a/src/commands/agents/attachments.ts b/src/commands/agents/attachments.ts new file mode 100644 index 00000000000..e9dc3a832e1 --- /dev/null +++ b/src/commands/agents/attachments.ts @@ -0,0 +1,112 @@ +import fs from 'fs/promises' +import path from 'path' + +import type { AgentsApi } from './api.js' +import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS_PER_REQUEST } from './constants.js' +import { formatBytes, getMimeType } from './utils.js' + +export interface UploadedAttachment { + path: string + filename: string + fileKey: string + size: number + contentType: string +} + +const cleanupOrphans = async (api: AgentsApi, accountId: string, fileKeys: string[]): Promise => { + await Promise.allSettled( + fileKeys.map(async (fileKey) => { + try { + const { delete_url: deleteUrl } = await api.createAgentRunnerDeleteUrl({ + account_id: accountId, + file_key: fileKey, + }) + await fetch(deleteUrl, { method: 'DELETE' }) + } catch { + // Best-effort cleanup; if it fails, the orphan is the user's tenant problem. + } + }), + ) +} + +export const uploadAttachments = async ( + api: AgentsApi, + accountId: string, + filePaths: string[], +): Promise => { + if (filePaths.length === 0) return [] + if (filePaths.length > MAX_ATTACHMENTS_PER_REQUEST) { + throw new Error( + `Too many attachments: ${filePaths.length.toString()} given, max is ${MAX_ATTACHMENTS_PER_REQUEST.toString()}`, + ) + } + + const resolved = await Promise.all( + filePaths.map(async (filePath) => { + const absolute = path.resolve(filePath) + const stat = await fs.stat(absolute).catch(() => null) + if (!stat?.isFile()) { + throw new Error(`Attachment not found or not a file: ${filePath}`) + } + if (stat.size > MAX_ATTACHMENT_SIZE_BYTES) { + throw new Error( + `Attachment ${filePath} is ${formatBytes(stat.size)}, exceeds the ${formatBytes( + MAX_ATTACHMENT_SIZE_BYTES, + )} limit`, + ) + } + const filename = path.basename(absolute) + return { path: absolute, filename, size: stat.size, contentType: getMimeType(filename) } + }), + ) + + const uploaded: UploadedAttachment[] = [] + try { + for (const file of resolved) { + const { upload_url: uploadUrl, file_key: fileKey } = await api.createAgentRunnerUploadUrl({ + account_id: accountId, + filename: file.filename, + content_type: file.contentType, + }) + + const body = await fs.readFile(file.path) + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, 60_000) + let putResponse: Response + try { + putResponse = await fetch(uploadUrl, { + method: 'PUT', + body: new Uint8Array(body), + headers: { 'Content-Type': file.contentType }, + signal: controller.signal, + }) + } catch (error_) { + const error = error_ as Error + if (error.name === 'AbortError') { + throw new Error(`Upload of ${file.filename} timed out after 60s`) + } + throw error + } finally { + clearTimeout(timeout) + } + if (!putResponse.ok) { + throw new Error( + `Failed to upload ${file.filename}: HTTP ${putResponse.status.toString()} ${putResponse.statusText}`, + ) + } + uploaded.push({ ...file, fileKey }) + } + return uploaded + } catch (error) { + if (uploaded.length > 0) { + await cleanupOrphans( + api, + accountId, + uploaded.map((entry) => entry.fileKey), + ) + } + throw error + } +} diff --git a/src/commands/agents/constants.ts b/src/commands/agents/constants.ts index 03b5f94b287..0c608afedfc 100644 --- a/src/commands/agents/constants.ts +++ b/src/commands/agents/constants.ts @@ -1,27 +1,32 @@ import { chalk } from '../../utils/command-helpers.js' -/** - * Available agent types for task creation - */ export const AVAILABLE_AGENTS = [ { name: 'Claude', value: 'claude' }, { name: 'Codex', value: 'codex' }, { name: 'Gemini', value: 'gemini' }, ] as const -/** - * Valid agent task states - */ -export const AGENT_STATES = ['new', 'running', 'done', 'error', 'cancelled', 'archived'] as const +export const AGENT_TO_PROVIDER = { + claude: 'anthropic', + codex: 'openai', + gemini: 'gemini', +} as const -/** - * Valid agent session states - */ +export const AGENT_STATES = ['new', 'running', 'done', 'error', 'cancelled', 'archived'] as const export const SESSION_STATES = ['new', 'running', 'done', 'error', 'cancelled'] as const -/** - * Color mapping for agent task status display - */ +export const SESSION_MODES = [ + 'normal', + 'redeploy', + 'rebase', + 'git_sync', + 'create', + 'ask', + 'conflict_resolution', +] as const + +export const LIST_STATUS_FILTERS = ['running', 'done', 'error', 'archived'] as const + export const STATUS_COLORS = { new: chalk.blue, running: chalk.yellow, @@ -31,9 +36,14 @@ export const STATUS_COLORS = { archived: chalk.dim, } as const -/** - * Type definitions extracted from constants - */ +export const TERMINAL_AGENT_STATES = ['done', 'error', 'cancelled', 'archived'] as const +export const TERMINAL_SESSION_STATES = ['done', 'error', 'cancelled'] as const + +export const MAX_ATTACHMENT_SIZE_BYTES = 10 * 1024 * 1024 +export const MAX_ATTACHMENTS_PER_REQUEST = 20 + export type AgentState = (typeof AGENT_STATES)[number] export type SessionState = (typeof SESSION_STATES)[number] +export type SessionMode = (typeof SESSION_MODES)[number] +export type ListStatusFilter = (typeof LIST_STATUS_FILTERS)[number] export type AvailableAgent = (typeof AVAILABLE_AGENTS)[number]['value'] diff --git a/src/commands/agents/types.ts b/src/commands/agents/types.ts index 345b87101f3..93a895c3882 100644 --- a/src/commands/agents/types.ts +++ b/src/commands/agents/types.ts @@ -1,4 +1,4 @@ -import type { AgentState, SessionState, AvailableAgent } from './constants.js' +import type { AgentState, ListStatusFilter, SessionState, SessionMode, AvailableAgent } from './constants.js' export interface AgentConfig { agent?: AvailableAgent @@ -6,6 +6,13 @@ export interface AgentConfig { [key: string]: unknown } +export interface AgentRunnerUser { + id: string + full_name?: string + email?: string + avatar_url?: string +} + export interface AgentRunner { id: string site_id?: string @@ -18,11 +25,57 @@ export interface AgentRunner { branch?: string result_branch?: string current_task?: string + base_deploy_id?: string + sha?: string + + pr_url?: string + pr_branch?: string + pr_state?: string + pr_number?: number + pr_is_being_created?: boolean + pr_error?: string + + merge_commit_sha?: string + merge_commit_error?: string + merge_commit_is_being_created?: boolean + + attached_file_keys?: string[] + active_session_created_at?: string + last_session_created_at?: string + has_result_diff?: boolean + latest_session_deploy_id?: string - user?: { - id: string - full_name?: string - } + latest_session_deploy_url?: string + latest_session_deploy_screenshot_url?: string + latest_session_state?: SessionState + latest_session_mode?: SessionMode + latest_session_is_published?: boolean + + needs_git_sync?: boolean + rebase_available?: boolean + merge_target_available?: boolean + + user?: AgentRunnerUser + contributors?: AgentRunnerUser[] +} + +export interface AgentRunnerSessionUsage { + total_input_tokens?: number + total_output_tokens?: number + total_cached_input_tokens?: number + total_cached_output_tokens?: number + total_tokens?: number + total_input_microcents?: number + total_output_microcents?: number + total_cached_input_microcents?: number + total_cached_output_microcents?: number + total_tool_calls_microcents?: number + total_credits_cost?: number +} + +export interface AgentRunnerSessionStep { + title?: string + message?: string } export interface AgentRunnerSession { @@ -30,19 +83,94 @@ export interface AgentRunnerSession { agent_runner_id: string dev_server_id?: string state: SessionState + mode?: SessionMode created_at: string updated_at: string done_at?: string title?: string + current_task?: string prompt: string agent_config?: AgentConfig result?: string result_diff?: string + cumulative_diff?: string duration?: number - steps?: { - title?: string - message?: string - }[] + steps?: AgentRunnerSessionStep[] + user?: AgentRunnerUser + attached_file_keys?: string[] + result_zip_file_name?: string + is_published?: boolean + is_discarded?: boolean + commit_sha?: string + source_session_id?: string + deploy_id?: string + deploy_url?: string + usage?: AgentRunnerSessionUsage + credit_limit_exceeded?: boolean + metadata?: Record +} + +export interface CreateAgentRunnerPayload { + prompt: string + agent: AvailableAgent + model?: string + branch?: string + deploy_id?: string + parent_agent_runner_id?: string + file_keys?: string[] +} + +export interface CreateAgentRunnerSessionPayload { + prompt: string + agent?: AvailableAgent + model?: string + file_keys?: string[] +} + +export interface ListAgentRunnersFilters { + state?: ListStatusFilter + branch?: string + result_branch?: string + user_id?: string + title?: string + from?: number + to?: number + page?: number + per_page?: number +} + +export interface ListAgentRunnerSessionsFilters { + state?: SessionState + from?: number + to?: number + order_by?: 'asc' | 'desc' + include_discarded?: boolean + page?: number + per_page?: number +} + +export interface DiffParams { + page?: number + per_page?: number + strip_binary?: boolean +} + +export interface PaginatedResult { + data: T + total?: number + page: number + perPage: number + hasNext: boolean +} + +export interface UploadUrlResponse { + upload_url: string + file_key: string +} + +export interface DeleteUrlResponse { + delete_url: string + file_key: string } export interface APIError { @@ -50,3 +178,13 @@ export interface APIError { message: string error?: string } + +export interface AiGatewayProviderInfo { + token_env_var: string + url_env_var: string + models: string[] +} + +export interface AiGatewayProvidersResponse { + providers: Partial> +} diff --git a/src/commands/agents/utils.ts b/src/commands/agents/utils.ts index 308fb6fb717..5762c40ef6b 100644 --- a/src/commands/agents/utils.ts +++ b/src/commands/agents/utils.ts @@ -1,5 +1,11 @@ -import { AVAILABLE_AGENTS, STATUS_COLORS } from './constants.js' +import path from 'path' + import { chalk } from '../../utils/command-helpers.js' +import { AGENT_TO_PROVIDER, AVAILABLE_AGENTS, LIST_STATUS_FILTERS, STATUS_COLORS } from './constants.js' +import type { ListStatusFilter } from './constants.js' +import type { AgentsApi } from './api.js' +import type { AvailableAgent } from './constants.js' +import type { AgentRunnerSessionUsage } from './types.js' export const truncateText = (text: string, maxLength: number): string => { if (text.length <= maxLength) return text @@ -7,8 +13,7 @@ export const truncateText = (text: string, maxLength: number): string => { } export const formatDate = (dateString: string): string => { - const date = new Date(dateString) - return date.toLocaleString() + return new Date(dateString).toLocaleString() } export const formatDuration = (startTime: string, endTime?: string): string => { @@ -34,7 +39,16 @@ export const formatStatus = (status: string): string => { return colorFn(status.toUpperCase()) } -export const validatePrompt = (input: string): boolean | string => { +const PR_STATE_LABELS: Record = { + open: 'Open', + draft: 'Draft', + closed: 'Closed', + merged: 'Merged', +} + +export const formatPrState = (state: string): string => PR_STATE_LABELS[state.toLowerCase()] ?? state + +export const validatePrompt = (input: string): true | string => { if (!input || input.trim().length === 0) { return 'Please provide a prompt for the agent' } @@ -44,15 +58,156 @@ export const validatePrompt = (input: string): boolean | string => { return true } -export const validateAgent = (agent: string): boolean | string => { - const validAgents = AVAILABLE_AGENTS.map((a) => a.value) as string[] +export const TITLE_MAX_LENGTH = 200 + +const UNICODE_TAG_PATTERN = /[\u{E0000}-\u{E007F}]/gu +const CONTROL_CHAR_PATTERN = /\p{Cc}/gu + +export const sanitizePromptText = (text: string): string => text.replace(UNICODE_TAG_PATTERN, '') + +export const sanitizeRunnerTitle = (title: string): string => + sanitizePromptText(title).replace(CONTROL_CHAR_PATTERN, '').trim() + +export const validateRunnerTitle = (title: string): true | string => { + const sanitized = sanitizeRunnerTitle(title) + if (!sanitized) return 'A non-empty title is required' + if (sanitized.length > TITLE_MAX_LENGTH) return `Title must be ${TITLE_MAX_LENGTH.toString()} characters or fewer` + return true +} + +export const validateAgent = (agent: string): true | string => { + const validAgents = AVAILABLE_AGENTS.map((entry) => entry.value) as string[] if (!validAgents.includes(agent)) { return `Invalid agent. Available agents: ${validAgents.join(', ')}` } return true } +export const validateListStatusFilter = (status: string): true | string => { + if ((LIST_STATUS_FILTERS as readonly string[]).includes(status)) return true + return `--status accepts only ${LIST_STATUS_FILTERS.map((entry) => `"${entry}"`).join(', ')}` +} + +export const isListStatusFilter = (status: string): status is ListStatusFilter => + (LIST_STATUS_FILTERS as readonly string[]).includes(status) + +export const checkModelAvailability = async ( + api: AgentsApi, + agent: AvailableAgent, + model: string, +): Promise => { + let providers + try { + providers = await api.listAiGatewayProviders() + } catch { + return true + } + const providerName = AGENT_TO_PROVIDER[agent] + const models = providers.providers[providerName]?.models + if (!models) return true + if (models.includes(model)) return true + return `Unknown model "${model}" for agent "${agent}". Known ${providerName} models: ${models.join( + ', ', + )}. Pass through if a newer one has rolled out.` +} + export const getAgentName = (agent: string): string => { - const entry = AVAILABLE_AGENTS.find((a) => a.value === agent) + const entry = AVAILABLE_AGENTS.find((candidate) => candidate.value === agent) return entry ? entry.name : agent } + +const NETLIFY_WEB_UI = (process.env.NETLIFY_WEB_UI ?? 'https://app.netlify.com').replace(/\/+$/, '') + +export const buildAgentDashboardUrl = (siteName: string, agentId: string): string => + `${NETLIFY_WEB_UI}/projects/${siteName}/agent-runs/${agentId}` + +export const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes.toString()} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +export const formatTokenCount = (count?: number): string => { + if (count == null) return '-' + if (count < 1000) return count.toString() + if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k` + return `${(count / 1_000_000).toFixed(2)}M` +} + +export const formatUsage = (usage?: AgentRunnerSessionUsage): string[] => { + if (!usage) return [] + const lines: string[] = [] + const tokens = usage.total_tokens + if (tokens != null) { + const breakdown = [ + usage.total_input_tokens != null ? `in ${formatTokenCount(usage.total_input_tokens)}` : null, + usage.total_output_tokens != null ? `out ${formatTokenCount(usage.total_output_tokens)}` : null, + usage.total_cached_input_tokens || usage.total_cached_output_tokens + ? `cached ${formatTokenCount((usage.total_cached_input_tokens ?? 0) + (usage.total_cached_output_tokens ?? 0))}` + : null, + ].filter(Boolean) + lines.push(`Tokens: ${formatTokenCount(tokens)}${breakdown.length > 0 ? ` (${breakdown.join(', ')})` : ''}`) + } + if (usage.total_credits_cost != null) { + lines.push(`Credits: ${usage.total_credits_cost.toFixed(4)}`) + } + return lines +} + +const MIME_BY_EXT: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.log': 'text/plain', + '.json': 'application/json', + '.yaml': 'application/yaml', + '.yml': 'application/yaml', + '.toml': 'application/toml', + '.csv': 'text/csv', + '.html': 'text/html', + '.htm': 'text/html', + '.xml': 'application/xml', + '.zip': 'application/zip', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.jsx': 'text/javascript', + '.css': 'text/css', +} + +export const getMimeType = (filename: string): string => { + const ext = path.extname(filename).toLowerCase() + return MIME_BY_EXT[ext] ?? 'application/octet-stream' +} + +export const formatDiff = (diff: string): string => { + if (!diff) return '' + const lines = diff.split('\n') + return lines + .map((line) => { + if (line.startsWith('diff --git') || line.startsWith('index ')) return chalk.bold(line) + if (line.startsWith('--- ') || line.startsWith('+++ ')) return chalk.bold(line) + if (line.startsWith('@@')) return chalk.cyan(line) + if (line.startsWith('+')) return chalk.green(line) + if (line.startsWith('-')) return chalk.red(line) + return line + }) + .join('\n') +} + +export const parseLinkHeader = (linkHeader: string | null): Record => { + if (!linkHeader) return {} + const result: Record = {} + for (const part of linkHeader.split(',')) { + const match = /<([^>]+)>;\s*rel="([^"]+)"/.exec(part.trim()) + if (match) result[match[2]] = match[1] + } + return result +} diff --git a/tests/integration/utils/mock-api.ts b/tests/integration/utils/mock-api.ts index eafd4795452..24ae081ce0c 100644 --- a/tests/integration/utils/mock-api.ts +++ b/tests/integration/utils/mock-api.ts @@ -21,7 +21,7 @@ interface MockApiOptions { export interface MockApi { apiUrl: string - requests: { path: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] + requests: { path: string; originalUrl: string; body: unknown; method: string; headers: IncomingHttpHeaders }[] server: Server close: () => Promise } @@ -34,6 +34,7 @@ export interface MockApiTestContext { const addRequest = (requests: MockApi['requests'], request: express.Request) => { requests.push({ path: request.path, + originalUrl: request.originalUrl, body: request.body, method: request.method, headers: request.headers, @@ -66,6 +67,10 @@ export const startMockApi = ({ routes, silent }: MockApiOptions): Promise Promise, silent = false, ) => {