/** * Typed API client. * * One place knows the base URL, the bearer token and the error envelope. Every * call returns parsed JSON or throws `ApiError`, so components never have to * inspect status codes. */ import type { ApiErrorBody } from "@agy/contracts"; import { getRealtimeClient } from "@/lib/ws"; /** * Resolves the API base URL. * In a browser: * - If NEXT_PUBLIC_API_URL is configured and not pointing to localhost on a remote host, use it. * - Otherwise default to relative path "" so requests go directly to the current origin (e.g. /api/*) * behind reverse proxies (Caddy), preventing ERR_CONNECTION_REFUSED on clients. * In SSR / Node / tests: * - Fall back to NEXT_PUBLIC_API_URL or "http://localhost:8000". */ export function getApiBaseUrl(): string { const configured = process.env.NEXT_PUBLIC_API_URL?.trim(); if (typeof window !== "undefined") { const isLocalhost = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"; if (!configured || (!isLocalhost && configured.includes("localhost"))) { return ""; } return configured; } return configured || "http://localhost:8000"; } export const API_BASE_URL = getApiBaseUrl(); export class ApiError extends Error { readonly status: number; readonly code: string; readonly details?: unknown; /** * The server's correlation id for this failure. It is also the * `X-Request-ID` response header, and it identifies one row in the platform * error console — so a bug report that includes it is immediately actionable. */ readonly requestId?: string; constructor( status: number, code: string, message: string, details?: unknown, requestId?: string, ) { super(message); this.name = "ApiError"; this.status = status; this.code = code; this.details = details; this.requestId = requestId; } } const TOKEN_STORAGE_KEY = "agy.access_token"; export function getStoredToken(): string | null { if (typeof window === "undefined") return null; try { return window.localStorage.getItem(TOKEN_STORAGE_KEY); } catch { return null; } } export function setStoredToken(token: string | null): void { if (typeof window === "undefined") return; try { if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token); else window.localStorage.removeItem(TOKEN_STORAGE_KEY); } catch { /* storage disabled — the session simply will not survive a reload */ } } export interface RequestOptions { method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; body?: unknown; token?: string | null; signal?: AbortSignal; } export async function apiRequest(path: string, options: RequestOptions = {}): Promise { const { method = "GET", body, signal } = options; // Liveness and readiness probes stay on plain HTTP: they answer during a DB // outage and are used by deploy orchestration (and the browser status check), // so they must not depend on the authenticated socket. Every application call // travels over the WebSocket as an RPC instead (see lib/ws.ts) — one // connection for both live feeds and request/response. if (path === "/health" || path === "/health/ready") { return fetchHealth(path, method, body, signal); } const client = getRealtimeClient(); return client.rpc(method, path, body === undefined ? null : body, { signal }); } /** Plain-HTTP implementation kept only for the two health probes. */ async function fetchHealth( path: string, method: string, body: unknown, signal?: AbortSignal, ): Promise { const headers: Record = { Accept: "application/json" }; if (body !== undefined) headers["Content-Type"] = "application/json"; let response: Response; try { response = await fetch(`${getApiBaseUrl()}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal, cache: "no-store", }); } catch (error) { throw new ApiError(0, "network_error", "ارتباط با سرور برقرار نشد.", error); } if (response.status === 204) return undefined as T; const text = await response.text(); const payload = text ? (JSON.parse(text) as unknown) : null; if (!response.ok) { const envelope = payload as ApiErrorBody | null; const code = envelope?.error?.code ?? "http_error"; const message = envelope?.error?.message ?? `خطای سرور (${response.status})`; const requestId = envelope?.error?.request_id ?? response.headers.get("X-Request-ID") ?? undefined; throw new ApiError(response.status, code, message, envelope?.error?.details, requestId); } return payload as T; } export const api = { health: () => apiRequest<{ status: string }>("/health"), readiness: () => apiRequest("/health/ready"), signup: (body: { email: string; password: string; name?: string }) => apiRequest("/api/auth/signup", { method: "POST", body, }), login: (body: { email: string; password: string }) => apiRequest("/api/auth/login", { method: "POST", body, }), logout: () => apiRequest("/api/auth/logout", { method: "POST" }), me: () => apiRequest("/api/auth/me"), listOrganizations: () => apiRequest("/api/organizations"), createOrganization: (name: string) => apiRequest("/api/organizations", { method: "POST", body: { name }, }), listMembers: (orgId: string) => apiRequest(`/api/organizations/${orgId}/members`), createInvite: (orgId: string, body: { email: string; role: import("@agy/contracts").Role }) => apiRequest(`/api/organizations/${orgId}/invites`, { method: "POST", body, }), updateMember: ( orgId: string, memberId: string, body: { role?: import("@agy/contracts").Role; status?: import("@agy/contracts").MembershipStatus; }, ) => apiRequest( `/api/organizations/${orgId}/members/${memberId}`, { method: "PATCH", body }, ), removeMember: (orgId: string, memberId: string) => apiRequest(`/api/organizations/${orgId}/members/${memberId}`, { method: "DELETE", }), listAudit: (orgId: string) => apiRequest(`/api/organizations/${orgId}/audit`), // --- projects ------------------------------------------------------------- listProjects: (orgId: string) => apiRequest(`/api/organizations/${orgId}/projects`), createProject: (orgId: string, body: { name: string; description?: string }) => apiRequest(`/api/organizations/${orgId}/projects`, { method: "POST", body, }), getProject: (orgId: string, projectId: string) => apiRequest( `/api/organizations/${orgId}/projects/${projectId}`, ), // --- repository ----------------------------------------------------------- ensureRepo: (orgId: string, projectId: string) => apiRequest( `/api/organizations/${orgId}/projects/${projectId}/repo/ensure`, { method: "POST" }, ), getRepo: (orgId: string, projectId: string) => apiRequest( `/api/organizations/${orgId}/projects/${projectId}/repo`, ), getRepoStatus: (orgId: string, projectId: string) => apiRequest( `/api/organizations/${orgId}/projects/${projectId}/repo/status`, ), // --- preview domains ------------------------------------------------------ listPreviewDomains: (orgId: string, projectId: string) => apiRequest( `/api/organizations/${orgId}/projects/${projectId}/preview/domains`, ), createPreviewDomain: ( orgId: string, projectId: string, body: { subdomain: string; port?: number }, ) => apiRequest( `/api/organizations/${orgId}/projects/${projectId}/preview/domains`, { method: "POST", body }, ), deletePreviewDomain: (orgId: string, projectId: string, domainId: string) => apiRequest( `/api/organizations/${orgId}/projects/${projectId}/preview/domains/${domainId}`, { method: "DELETE" }, ), // --- workforce ------------------------------------------------------------ listAgentTemplates: (orgId: string) => apiRequest( `/api/organizations/${orgId}/agent-templates`, ), listRoutingChoices: (orgId: string) => apiRequest( `/api/organizations/${orgId}/routing-choices`, ), listEmployees: (orgId: string) => apiRequest(`/api/organizations/${orgId}/employees`), createEmployee: ( orgId: string, body: { name: string; role_key: import("@agy/contracts").RoleKey; template_id?: string | null; project_id?: string | null; routing_policy_id?: string | null; }, ) => apiRequest(`/api/organizations/${orgId}/employees`, { method: "POST", body, }), getEmployee: (orgId: string, employeeId: string) => apiRequest( `/api/organizations/${orgId}/employees/${employeeId}`, ), updateEmployee: ( orgId: string, employeeId: string, body: { name?: string; status?: import("@agy/contracts").EmployeeStatus; project_id?: string | null; routing_policy_id?: string | null; }, ) => apiRequest( `/api/organizations/${orgId}/employees/${employeeId}`, { method: "PATCH", body }, ), addPromptVersion: (orgId: string, employeeId: string, content: string) => apiRequest( `/api/organizations/${orgId}/employees/${employeeId}/prompt-versions`, { method: "POST", body: { content } }, ), // --- manager chat --------------------------------------------------------- listThreads: (orgId: string) => apiRequest(`/api/organizations/${orgId}/threads`), createThread: (orgId: string, body: { title?: string; project_id?: string | null }) => apiRequest(`/api/organizations/${orgId}/threads`, { method: "POST", body, }), getThread: (orgId: string, threadId: string) => apiRequest( `/api/organizations/${orgId}/threads/${threadId}`, ), postMessage: (orgId: string, threadId: string, body: { content: string; employee_id?: string | null }) => apiRequest( `/api/organizations/${orgId}/threads/${threadId}/messages`, { method: "POST", body }, ), // --- tasks & runs --------------------------------------------------------- listTasks: (orgId: string, status?: string) => apiRequest( `/api/organizations/${orgId}/tasks${status ? `?status=${status}` : ""}`, ), createTask: ( orgId: string, body: { title: string; description?: string; project_id?: string | null; thread_id?: string | null; assigned_employee_id?: string | null; }, ) => apiRequest(`/api/organizations/${orgId}/tasks`, { method: "POST", body, }), getTask: (orgId: string, taskId: string) => apiRequest( `/api/organizations/${orgId}/tasks/${taskId}`, ), startRun: (orgId: string, taskId: string, employeeId?: string | null) => apiRequest( `/api/organizations/${orgId}/tasks/${taskId}/runs`, { method: "POST", body: { employee_id: employeeId ?? null } }, ), getRun: (orgId: string, runId: string) => apiRequest(`/api/organizations/${orgId}/runs/${runId}`), cancelRun: (orgId: string, runId: string) => apiRequest( `/api/organizations/${orgId}/runs/${runId}/cancel`, { method: "POST" }, ), listPendingApprovals: (orgId: string) => apiRequest( `/api/organizations/${orgId}/approvals/pending`, ), // --- billing -------------------------------------------------------------- billingSummary: (orgId: string) => apiRequest( `/api/organizations/${orgId}/billing/summary`, ), billingUsageEvents: (orgId: string, limit = 100) => apiRequest( `/api/organizations/${orgId}/billing/usage-events?limit=${limit}`, ), billingInvoices: (orgId: string) => apiRequest( `/api/organizations/${orgId}/billing/invoices`, ), billingPayments: (orgId: string) => apiRequest( `/api/organizations/${orgId}/billing/payments`, ), billingAddCredit: (orgId: string, amount: number) => apiRequest( `/api/organizations/${orgId}/billing/add-credit`, { method: "POST", body: { amount } }, ), billingCheckout: (orgId: string, amount: number) => apiRequest<{ payment_id: string; checkout_url: string; amount: number }>( `/api/organizations/${orgId}/billing/checkout`, { method: "POST", body: { amount } }, ), // --- tools & approvals ---------------------------------------------------- listTools: (orgId: string) => apiRequest(`/api/organizations/${orgId}/tools`), setToolPermission: (orgId: string, employeeId: string, toolKey: string, allowed: boolean) => apiRequest( `/api/organizations/${orgId}/employees/${employeeId}/tools/${encodeURIComponent(toolKey)}`, { method: "PUT", body: { allowed } }, ), decideApproval: (orgId: string, toolCallId: string, approve: boolean, note = "") => apiRequest( `/api/organizations/${orgId}/approvals/${toolCallId}/decide`, { method: "POST", body: { approve, note } }, ), // --- sandbox -------------------------------------------------------------- // Every call here is metered into the usage ledger server-side. The client // never sends a timeout or an output cap: limits come from policy only. sandboxTemplates: (orgId: string) => apiRequest( `/api/organizations/${orgId}/sandbox/templates`, ), sandboxLimits: (orgId: string, projectId?: string) => apiRequest( `/api/organizations/${orgId}/sandbox/limits${ projectId ? `?project_id=${encodeURIComponent(projectId)}` : "" }`, ), sandboxWorkspaces: (orgId: string, projectId: string) => apiRequest( `/api/organizations/${orgId}/sandbox/workspaces?project_id=${encodeURIComponent(projectId)}`, ), sandboxCreateWorkspace: (orgId: string, body: { project_id: string; template_id: string }) => apiRequest( `/api/organizations/${orgId}/sandbox/workspaces`, { method: "POST", body }, ), sandboxDestroyWorkspace: (orgId: string, workspaceId: string) => apiRequest( `/api/organizations/${orgId}/sandbox/workspaces/${workspaceId}`, { method: "DELETE" }, ), sandboxStartSession: (orgId: string, workspaceId: string) => apiRequest( `/api/organizations/${orgId}/sandbox/sessions`, { method: "POST", body: { workspace_id: workspaceId } }, ), sandboxStopSession: (orgId: string, sessionId: string) => apiRequest( `/api/organizations/${orgId}/sandbox/sessions/${sessionId}/stop`, { method: "POST" }, ), sandboxRunCommand: (orgId: string, sessionId: string, command: string) => apiRequest( `/api/organizations/${orgId}/sandbox/sessions/${sessionId}/commands`, { method: "POST", body: { command } }, ), sandboxCommands: (orgId: string, sessionId: string) => apiRequest( `/api/organizations/${orgId}/sandbox/sessions/${sessionId}/commands`, ), sandboxReadFile: (orgId: string, sessionId: string, path: string) => apiRequest( `/api/organizations/${orgId}/sandbox/sessions/${sessionId}/files/read`, { method: "POST", body: { path } }, ), sandboxWriteFile: (orgId: string, sessionId: string, path: string, content: string) => apiRequest( `/api/organizations/${orgId}/sandbox/sessions/${sessionId}/files/write`, { method: "POST", body: { path, content } }, ), sandboxSnapshot: (orgId: string, sessionId: string, name: string) => apiRequest( `/api/organizations/${orgId}/sandbox/sessions/${sessionId}/snapshots`, { method: "POST", body: { name } }, ), sandboxPreviewUrl: (orgId: string, sessionId: string, port: number) => apiRequest( `/api/organizations/${orgId}/sandbox/sessions/${sessionId}/preview-url?port=${port}`, { method: "POST" }, ), // --- platform owner ------------------------------------------------------- // agy accounts: OAuth login instead of API keys. agyAccounts: () => apiRequest("/api/admin/agy/accounts"), agyStartLogin: () => apiRequest<{ login_id: string; auth_url: string }>( "/api/admin/agy/accounts/login/start", { method: "POST" }, ), agyCompleteLogin: (body: { login_id: string; callback_url: string }) => apiRequest( "/api/admin/agy/accounts/login/complete", { method: "POST", body }, ), agySetAccountActive: (accountId: string, active: boolean) => apiRequest( `/api/admin/agy/accounts/${accountId}/${active ? "activate" : "deactivate"}`, { method: "POST" }, ), agyDeleteAccount: (accountId: string) => apiRequest(`/api/admin/agy/accounts/${accountId}`, { method: "DELETE" }), agyTestAccount: (accountId: string) => apiRequest<{ ok: boolean; detail: string; model_internal_name: string; input_tokens: number; output_tokens: number; }>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }), // Live model catalog of one account — fetched from Google on every call, so // the model dropdown always matches that subscription's entitlement along with // its supported reasoning efforts. agyAccountModels: async (accountId: string) => { const raw = await apiRequest<(import("@agy/contracts").AgyAccountModel | string)[]>( `/api/admin/agy/accounts/${accountId}/models`, ); return raw.map((item): import("@agy/contracts").AgyAccountModel => { if (typeof item === "string") { return { id: item, display_name: item, supports_thinking: false, supported_efforts: [], }; } return item; }); }, adminModels: () => apiRequest("/api/admin/models"), adminCreateModel: (body: { internal_name: string; display_name_for_admin?: string; capabilities: import("@agy/contracts").Capability[]; input_price_per_1m?: number; output_price_per_1m?: number; reasoning_effort?: string; }) => apiRequest("/api/admin/models", { method: "POST", body, }), adminRoutingPolicies: () => apiRequest("/api/admin/routing-policies"), adminCreateRoutingPolicy: (body: { name: string; mode: import("@agy/contracts").RoutingMode; description?: string; is_default?: boolean; }) => apiRequest("/api/admin/routing-policies", { method: "POST", body, }), adminRateCards: () => apiRequest("/api/admin/rate-cards"), adminUpsertRateCard: (body: { unit: import("@agy/contracts").RateUnit; name?: string; customer_price: number; internal_cost_estimate?: number; is_active?: boolean; }) => apiRequest("/api/admin/rate-cards", { method: "PUT", body, }), adminOrganizations: () => apiRequest("/api/admin/organizations"), adminUsage: () => apiRequest("/api/admin/usage"), adminValidateModelChoice: (mode: import("@agy/contracts").RoutingMode) => apiRequest( `/api/admin/validate-model-choice?mode=${encodeURIComponent(mode)}`, { method: "POST" }, ), // --- error console -------------------------------------------------------- // The console exists so a failure that already happened can be diagnosed // without reproducing it. Grouped by fingerprint, so a bug that fired a // thousand times is one line. adminErrorSummary: () => apiRequest("/api/admin/errors/summary"), adminErrorGroups: (query: import("@agy/contracts").ErrorQuery = {}) => apiRequest( `/api/admin/errors/groups${errorQueryString(query)}`, ), adminErrors: (query: import("@agy/contracts").ErrorQuery = {}) => apiRequest( `/api/admin/errors${errorQueryString(query)}`, ), adminError: (errorId: string) => apiRequest( `/api/admin/errors/${encodeURIComponent(errorId)}`, ), adminResolveError: (errorId: string, note = "") => apiRequest( `/api/admin/errors/${encodeURIComponent(errorId)}/resolve`, { method: "POST", body: { note } }, ), adminResolveErrorGroup: (fingerprint: string, note = "") => apiRequest( `/api/admin/errors/groups/${encodeURIComponent(fingerprint)}/resolve`, { method: "POST", body: { note } }, ), adminReopenErrorGroup: (fingerprint: string) => apiRequest( `/api/admin/errors/groups/${encodeURIComponent(fingerprint)}/reopen`, { method: "POST" }, ), }; /** Serialise the console's filters, dropping anything unset. */ function errorQueryString(query: import("@agy/contracts").ErrorQuery): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null && value !== "") { params.set(key, String(value)); } } const encoded = params.toString(); return encoded ? `?${encoded}` : ""; }