fix(builder): Defer graph validation to backend (#10556)

- Resolves #10553

### Changes 🏗️

- Remove frontend graph validation in `useAgentGraph:saveAndRun(..)`
  - Remove now unused `ajv` dependency
- Implement graph validation error propagation (backend->frontend)
  - Add `GraphValidationError` type in frontend and backend
  - Add `GraphModel.validate_graph_get_errors(..)` method
  - Fix error handling & propagation in frontend API request logic

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Saving & running a graph with missing required inputs gives a
node-specific error
- [x] Saving & running a graph with missing node credential inputs
succeeds with passed-in credentials
This commit is contained in:
Reinier van der Leer
2025-08-06 00:43:34 +01:00
committed by GitHub
parent b935638240
commit fa2d968458
11 changed files with 313 additions and 261 deletions

View File

@@ -416,6 +416,10 @@ class GraphModel(Graph):
for_run: bool = False,
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
):
"""
Validate graph structure and raise `ValueError` on issues.
For structured error reporting, use `validate_graph_get_errors`.
"""
self._validate_graph(self, for_run, nodes_input_masks)
for sub_graph in self.sub_graphs:
self._validate_graph(sub_graph, for_run, nodes_input_masks)
@@ -425,15 +429,58 @@ class GraphModel(Graph):
graph: BaseGraph,
for_run: bool = False,
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
):
def is_tool_pin(name: str) -> bool:
return name.startswith("tools_^_")
) -> None:
errors = GraphModel._validate_graph_get_errors(
graph, for_run, nodes_input_masks
)
if errors:
# Just raise the first error for backward compatibility
first_error = next(iter(errors.values()))
first_field_error = next(iter(first_error.values()))
raise ValueError(first_field_error)
def sanitize(name):
sanitized_name = name.split("_#_")[0].split("_@_")[0].split("_$_")[0]
if is_tool_pin(sanitized_name):
return "tools"
return sanitized_name
def validate_graph_get_errors(
self,
for_run: bool = False,
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
) -> dict[str, dict[str, str]]:
"""
Validate graph and return structured errors per node.
Returns: dict[node_id, dict[field_name, error_message]]
"""
return {
**self._validate_graph_get_errors(self, for_run, nodes_input_masks),
**{
node_id: error
for sub_graph in self.sub_graphs
for node_id, error in self._validate_graph_get_errors(
sub_graph, for_run, nodes_input_masks
).items()
},
}
@staticmethod
def _validate_graph_get_errors(
graph: BaseGraph,
for_run: bool = False,
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
) -> dict[str, dict[str, str]]:
"""
Validate graph and return structured errors per node.
Returns: dict[node_id, dict[field_name, error_message]]
"""
# First, check for structural issues with the graph
try:
GraphModel._validate_graph_structure(graph)
except ValueError:
# If structural validation fails, we can't provide per-node errors
# so we re-raise as is
raise
# Collect errors per node
node_errors: dict[str, dict[str, str]] = defaultdict(dict)
# Validate smart decision maker nodes
nodes_block = {
@@ -442,7 +489,7 @@ class GraphModel(Graph):
if (block := get_block(node.block_id)) is not None
}
input_links = defaultdict(list)
input_links: dict[str, list[Link]] = defaultdict(list)
for link in graph.links:
input_links[link.sink_id].append(link)
@@ -450,17 +497,22 @@ class GraphModel(Graph):
# Nodes: required fields are filled or connected and dependencies are satisfied
for node in graph.nodes:
if (block := nodes_block.get(node.id)) is None:
# For invalid blocks, we still raise immediately as this is a structural issue
raise ValueError(f"Invalid block {node.block_id} for node #{node.id}")
node_input_mask = (
nodes_input_masks.get(node.id, {}) if nodes_input_masks else {}
)
provided_inputs = set(
[sanitize(name) for name in node.input_default]
+ [sanitize(link.sink_name) for link in input_links.get(node.id, [])]
[_sanitize_pin_name(name) for name in node.input_default]
+ [
_sanitize_pin_name(link.sink_name)
for link in input_links.get(node.id, [])
]
+ ([name for name in node_input_mask] if node_input_mask else [])
)
InputSchema = block.input_schema
for name in (required_fields := InputSchema.get_required_fields()):
if (
name not in provided_inputs
@@ -477,18 +529,16 @@ class GraphModel(Graph):
]
)
):
raise ValueError(
f"Node {block.name} #{node.id} required input missing: `{name}`"
)
node_errors[node.id][name] = "This field is required"
if (
block.block_type == BlockType.INPUT
and (input_key := node.input_default.get("name"))
and is_credentials_field_name(input_key)
):
raise ValueError(
f"Agent input node uses reserved name '{input_key}'; "
"'credentials' and `*_credentials` are reserved input names"
node_errors[node.id]["name"] = (
f"'{input_key}' is a reserved input name: "
"'credentials' and `*_credentials` are reserved"
)
# Get input schema properties and check dependencies
@@ -538,10 +588,15 @@ class GraphModel(Graph):
# Check for missing dependencies when dependent field is present
missing_deps = [dep for dep in dependencies if not has_value(node, dep)]
if missing_deps and (field_has_value or field_is_required):
raise ValueError(
f"Node {block.name} #{node.id}: Field `{field_name}` requires [{', '.join(missing_deps)}] to be set"
)
node_errors[node.id][
field_name
] = f"Requires {', '.join(missing_deps)} to be set"
return node_errors
@staticmethod
def _validate_graph_structure(graph: BaseGraph):
"""Validate graph structure (links, connections, etc.)"""
node_map = {v.id: v for v in graph.nodes}
def is_static_output_block(nid: str) -> bool:
@@ -567,7 +622,7 @@ class GraphModel(Graph):
f"{prefix}, {node.block_id} is invalid block id, available blocks: {blocks}"
)
sanitized_name = sanitize(name)
sanitized_name = _sanitize_pin_name(name)
vals = node.input_default
if i == 0:
fields = (
@@ -581,7 +636,7 @@ class GraphModel(Graph):
if block.block_type not in [BlockType.AGENT]
else vals.get("input_schema", {}).get("properties", {}).keys()
)
if sanitized_name not in fields and not is_tool_pin(name):
if sanitized_name not in fields and not _is_tool_pin(name):
fields_msg = f"Allowed fields: {fields}"
raise ValueError(f"{prefix}, `{name}` invalid, {fields_msg}")
@@ -618,6 +673,17 @@ class GraphModel(Graph):
)
def _is_tool_pin(name: str) -> bool:
return name.startswith("tools_^_")
def _sanitize_pin_name(name: str) -> str:
sanitized_name = name.split("_#_")[0].split("_@_")[0].split("_$_")[0]
if _is_tool_pin(sanitized_name):
return "tools"
return sanitized_name
class GraphMeta(Graph):
user_id: str

View File

@@ -4,21 +4,14 @@ import threading
import time
from collections import defaultdict
from concurrent.futures import Future
from typing import TYPE_CHECKING, Any, Optional, cast
from typing import TYPE_CHECKING, Any, Optional
from autogpt_libs.utils.cache import thread_cached
from pydantic import BaseModel, JsonValue
from pydantic import BaseModel, JsonValue, ValidationError
from backend.data import execution as execution_db
from backend.data import graph as graph_db
from backend.data.block import (
Block,
BlockData,
BlockInput,
BlockSchema,
BlockType,
get_block,
)
from backend.data.block import Block, BlockData, BlockInput, BlockType, get_block
from backend.data.block_cost_config import BLOCK_COSTS
from backend.data.cost import BlockCostType
from backend.data.db import prisma
@@ -39,7 +32,7 @@ from backend.data.rabbitmq import (
RabbitMQConfig,
SyncRabbitMQ,
)
from backend.util.exceptions import NotFoundError
from backend.util.exceptions import GraphValidationError, NotFoundError
from backend.util.logging import TruncatedLogger
from backend.util.mock import MockObject
from backend.util.service import get_service_client
@@ -467,47 +460,65 @@ async def _validate_node_input_credentials(
graph: GraphModel,
user_id: str,
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
):
"""Checks all credentials for all nodes of the graph"""
) -> dict[str, dict[str, str]]:
"""
Checks all credentials for all nodes of the graph and returns structured errors.
Returns:
dict[node_id, dict[field_name, error_message]]: Credential validation errors per node
"""
credential_errors: dict[str, dict[str, str]] = defaultdict(dict)
for node in graph.nodes:
block = node.block
# Find any fields of type CredentialsMetaInput
credentials_fields = cast(
type[BlockSchema], block.input_schema
).get_credentials_fields()
credentials_fields = block.input_schema.get_credentials_fields()
if not credentials_fields:
continue
for field_name, credentials_meta_type in credentials_fields.items():
if (
nodes_input_masks
and (node_input_mask := nodes_input_masks.get(node.id))
and field_name in node_input_mask
):
credentials_meta = credentials_meta_type.model_validate(
node_input_mask[field_name]
)
elif field_name in node.input_default:
credentials_meta = credentials_meta_type.model_validate(
node.input_default[field_name]
)
else:
raise ValueError(
f"Credentials absent for {block.name} node #{node.id} "
f"input '{field_name}'"
)
try:
if (
nodes_input_masks
and (node_input_mask := nodes_input_masks.get(node.id))
and field_name in node_input_mask
):
credentials_meta = credentials_meta_type.model_validate(
node_input_mask[field_name]
)
elif field_name in node.input_default:
credentials_meta = credentials_meta_type.model_validate(
node.input_default[field_name]
)
else:
# Missing credentials
credential_errors[node.id][
field_name
] = "These credentials are required"
continue
except ValidationError as e:
credential_errors[node.id][field_name] = f"Invalid credentials: {e}"
continue
# Fetch the corresponding Credentials and perform sanity checks
credentials = await get_integration_credentials_store().get_creds_by_id(
user_id, credentials_meta.id
)
if not credentials:
raise ValueError(
f"Unknown credentials #{credentials_meta.id} "
f"for node #{node.id} input '{field_name}'"
try:
# Fetch the corresponding Credentials and perform sanity checks
credentials = await get_integration_credentials_store().get_creds_by_id(
user_id, credentials_meta.id
)
except Exception as e:
# Handle any errors fetching credentials
credential_errors[node.id][
field_name
] = f"Credentials not available: {e}"
continue
if not credentials:
credential_errors[node.id][
field_name
] = f"Unknown credentials #{credentials_meta.id}"
continue
if (
credentials.provider != credentials_meta.provider
or credentials.type != credentials_meta.type
@@ -518,10 +529,12 @@ async def _validate_node_input_credentials(
f"{credentials_meta.type}<>{credentials.type};"
f"{credentials_meta.provider}<>{credentials.provider}"
)
raise ValueError(
f"Invalid credentials #{credentials.id} for node #{node.id}: "
"type/provider mismatch"
)
credential_errors[node.id][
field_name
] = "Invalid credentials: type/provider mismatch"
continue
return credential_errors
def make_node_credentials_input_map(
@@ -559,6 +572,36 @@ def make_node_credentials_input_map(
return result
async def validate_graph_with_credentials(
graph: GraphModel,
user_id: str,
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
) -> dict[str, dict[str, str]]:
"""
Validate graph including credentials and return structured errors per node.
Returns:
dict[node_id, dict[field_name, error_message]]: Validation errors per node
"""
# Get input validation errors
node_input_errors = GraphModel.validate_graph_get_errors(
graph, for_run=True, nodes_input_masks=nodes_input_masks
)
# Get credential input/availability/validation errors
node_credential_input_errors = await _validate_node_input_credentials(
graph, user_id, nodes_input_masks
)
# Merge credential errors with structural errors
for node_id, field_errors in node_credential_input_errors.items():
if node_id not in node_input_errors:
node_input_errors[node_id] = {}
node_input_errors[node_id].update(field_errors)
return node_input_errors
async def construct_node_execution_input(
graph: GraphModel,
user_id: str,
@@ -581,8 +624,17 @@ async def construct_node_execution_input(
list[tuple[str, BlockInput]]: A list of tuples, each containing the node ID and
the corresponding input data for that node.
"""
graph.validate_graph(for_run=True, nodes_input_masks=nodes_input_masks)
await _validate_node_input_credentials(graph, user_id, nodes_input_masks)
# Use new validation function that includes credentials
validation_errors = await validate_graph_with_credentials(
graph, user_id, nodes_input_masks
)
n_error_nodes = len(validation_errors)
n_errors = sum(len(errors) for errors in validation_errors.values())
if validation_errors:
raise GraphValidationError(
f"Graph validation failed: {n_errors} issues on {n_error_nodes} nodes",
node_errors=validation_errors,
)
nodes_input = []
for node in graph.starting_nodes:

View File

@@ -85,7 +85,7 @@ from backend.server.model import (
)
from backend.server.utils import get_user_id
from backend.util.cloud_storage import get_cloud_storage_handler
from backend.util.exceptions import NotFoundError
from backend.util.exceptions import GraphValidationError, NotFoundError
from backend.util.service import get_service_client
from backend.util.settings import Settings
from backend.util.virus_scanner import scan_content_safe
@@ -753,15 +753,27 @@ async def execute_graph(
detail="Insufficient balance to execute the agent. Please top up your account.",
)
graph_exec = await execution_utils.add_graph_execution(
graph_id=graph_id,
user_id=user_id,
inputs=inputs,
preset_id=preset_id,
graph_version=graph_version,
graph_credentials_inputs=credentials_inputs,
)
return ExecuteGraphResponse(graph_exec_id=graph_exec.id)
try:
graph_exec = await execution_utils.add_graph_execution(
graph_id=graph_id,
user_id=user_id,
inputs=inputs,
preset_id=preset_id,
graph_version=graph_version,
graph_credentials_inputs=credentials_inputs,
)
return ExecuteGraphResponse(graph_exec_id=graph_exec.id)
except GraphValidationError as e:
# Return structured validation errors that the frontend can parse
raise HTTPException(
status_code=400,
detail={
"type": "validation_error",
"message": e.message,
# TODO: only return node-specific errors if user has access to graph
"node_errors": e.node_errors,
},
)
@v1_router.post(

View File

@@ -31,3 +31,17 @@ class InsufficientBalanceError(ValueError):
def __str__(self):
"""Used to display the error message in the frontend, because we str() the error when sending the execution update"""
return self.message
class GraphValidationError(ValueError):
"""Structured validation error for graph validation failures"""
def __init__(
self, message: str, node_errors: dict[str, dict[str, str]] | None = None
):
super().__init__(message)
self.message = message
self.node_errors = node_errors or {}
def __str__(self):
return self.message

View File

@@ -56,7 +56,6 @@
"@tanstack/react-table": "8.21.3",
"@types/jaro-winkler": "0.2.4",
"@xyflow/react": "12.8.2",
"ajv": "8.17.1",
"boring-avatars": "1.11.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",

View File

@@ -98,9 +98,6 @@ importers:
'@xyflow/react':
specifier: 12.8.2
version: 12.8.2(@types/react@18.3.17)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
ajv:
specifier: 8.17.1
version: 8.17.1
boring-avatars:
specifier: 1.11.2
version: 1.11.2

View File

@@ -3,6 +3,7 @@ import { CustomNode } from "@/components/CustomNode";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { useToast } from "@/components/molecules/Toast/use-toast";
import {
ApiError,
Block,
BlockIOSubSchema,
BlockUIType,
@@ -23,17 +24,13 @@ import {
deepEquals,
getTypeColor,
removeEmptyStringsAndNulls,
setNestedProperty,
} from "@/lib/utils";
import { MarkerType } from "@xyflow/react";
import Ajv from "ajv";
import { default as NextLink } from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
const ajv = new Ajv({ strict: false, allErrors: true });
export default function useAgentGraph(
flowID?: GraphID,
flowVersion?: number,
@@ -610,135 +607,6 @@ export default function useAgentGraph(
getToolFuncName,
]);
const validateGraph = useCallback(
(graph?: GraphCreatable): string | null => {
let errorMessage = null;
if (!graph) {
graph = prepareSaveableGraph();
}
graph.nodes.forEach((node) => {
const block = availableBlocks.find(
(block) => block.id === node.block_id,
);
if (!block) {
console.error(
`Node ${node.id} is invalid: unknown block ID ${node.block_id}`,
);
return;
}
const inputSchema = block.inputSchema;
const validate = ajv.compile(inputSchema);
const errors: Record<string, string> = {};
const errorPrefix = `${block.name} [${node.id.split("-")[0]}]`;
// Validate values against schema using AJV
const inputData = node.input_default;
const valid = validate(inputData);
if (!valid) {
// Populate errors if validation fails
validate.errors?.forEach((error) => {
const path =
"dataPath" in error
? (error.dataPath as string)
: error.instancePath || error.params.missingProperty;
const handle = path.split(/[\/.]/)[0];
// Skip error if there's an edge connected
if (
graph.links.some(
(link) => link.sink_id == node.id && link.sink_name == handle,
)
) {
return;
}
console.warn(`Error in ${block.name} input: ${error}`, {
data: inputData,
schema: inputSchema,
});
errorMessage =
`${errorPrefix}: ` + (error.message || "Invalid input");
if (path && error.message) {
const key = path.slice(1);
setNestedProperty(
errors,
key,
error.message[0].toUpperCase() + error.message.slice(1),
);
} else if (error.keyword === "required") {
const key = error.params.missingProperty;
setNestedProperty(errors, key, "This field is required");
}
});
}
Object.entries(inputSchema.properties).forEach(([key, schema]) => {
if (schema.depends_on) {
const dependencies = schema.depends_on;
// Check if dependent field has value
const hasValue =
inputData[key] != null ||
("default" in schema && schema.default != null);
const mustHaveValue = inputSchema.required?.includes(key);
// Check for missing dependencies when dependent field is present
const missingDependencies = dependencies.filter(
(dep) =>
!inputData[dep as keyof typeof inputData] ||
String(inputData[dep as keyof typeof inputData]).trim() === "",
);
if ((hasValue || mustHaveValue) && missingDependencies.length > 0) {
setNestedProperty(
errors,
key,
`Requires ${missingDependencies.join(", ")} to be set`,
);
errorMessage = `${errorPrefix}: field ${key} requires ${missingDependencies.join(", ")} to be set`;
}
// Check if field is required when dependencies are present
const hasAllDependencies = dependencies.every(
(dep) =>
inputData[dep as keyof typeof inputData] &&
String(inputData[dep as keyof typeof inputData]).trim() !== "",
);
if (hasAllDependencies && !hasValue) {
setNestedProperty(
errors,
key,
`${key} is required when ${dependencies.join(", ")} are set`,
);
errorMessage = `${errorPrefix}: ${key} is required when ${dependencies.join(", ")} are set`;
}
}
});
// Set errors
setXYNodes((nodes) => {
return nodes.map((n) => {
if (n.id === node.id) {
return {
...n,
data: {
...n.data,
errors,
},
};
}
return n;
});
});
});
return errorMessage;
},
[prepareSaveableGraph, availableBlocks],
);
const _saveAgent = useCallback(async () => {
// FIXME: frontend IDs should be resolved better (e.g. returned from the server)
// currently this relays on block_id and position
@@ -885,14 +753,10 @@ export default function useAgentGraph(
setIsSaving(false);
}
const validationError = validateGraph(savedAgent);
if (validationError) {
toast({
title: `Graph validation failed: ${validationError}`,
variant: "destructive",
});
return;
}
// NOTE: Client-side validation is skipped here because the backend now provides
// comprehensive validation that includes credentialsInputs, which the frontend
// validation cannot access. The backend will return structured validation errors
// if there are any issues.
setIsRunning(true);
processedUpdates.current = [];
@@ -918,6 +782,40 @@ export default function useAgentGraph(
completeStep("BUILDER_RUN_AGENT");
}
} catch (error) {
// Check if this is a structured validation error from the backend
if (error instanceof ApiError && error.isGraphValidationError()) {
const errorData = error.response.detail;
// 1. Apply validation errors to the corresponding nodes.
// 2. Clear existing errors for nodes that don't have validation issues.
setXYNodes((nodes) => {
return nodes.map((node) => {
const nodeErrors = node.data.backend_id
? (errorData.node_errors[node.data.backend_id] ?? {})
: {};
return {
...node,
data: {
...node.data,
errors: nodeErrors,
},
};
});
});
// Show a general toast about validation errors
toast({
variant: "destructive",
title: errorData.message || "Graph validation failed",
description:
"Please fix the validation errors on the highlighted nodes and try again.",
});
setIsRunning(false);
setActiveExecutionID(null);
return;
}
// Generic error handling for non-validation errors
const errorMessage =
error instanceof Error ? error.message : String(error);
toast({
@@ -933,10 +831,6 @@ export default function useAgentGraph(
_saveAgent,
toast,
completeStep,
savedAgent,
prepareSaveableGraph,
nodesSyncedWithSavedAgent,
validateGraph,
api,
searchParams,
pathname,

View File

@@ -876,8 +876,7 @@ export default class BackendAPI {
// Dynamic import is required even for client-only functions because helpers.ts
// has server-only imports (like getServerSupabase) at the top level. Static imports
// would bundle server-only code into the client bundle, causing runtime errors.
const { buildClientUrl, parseErrorResponse, handleFetchError } =
await import("./helpers");
const { buildClientUrl, handleFetchError } = await import("./helpers");
const uploadUrl = buildClientUrl(path);
@@ -888,8 +887,7 @@ export default class BackendAPI {
});
if (!response.ok) {
const errorData = await parseErrorResponse(response);
throw handleFetchError(response, errorData);
throw await handleFetchError(response);
}
return await response.json();
@@ -994,12 +992,8 @@ export default class BackendAPI {
// Dynamic import is required even for client-only functions because helpers.ts
// has server-only imports (like getServerSupabase) at the top level. Static imports
// would bundle server-only code into the client bundle, causing runtime errors.
const {
buildClientUrl,
buildUrlWithQuery,
parseErrorResponse,
handleFetchError,
} = await import("./helpers");
const { buildClientUrl, buildUrlWithQuery, handleFetchError } =
await import("./helpers");
const payloadAsQuery = ["GET", "DELETE"].includes(method);
let url = buildClientUrl(path);
@@ -1018,8 +1012,7 @@ export default class BackendAPI {
});
if (!response.ok) {
const errorData = await parseErrorResponse(response);
throw handleFetchError(response, errorData);
throw await handleFetchError(response);
}
return await response.json();

View File

@@ -2,16 +2,36 @@ import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import { Key, storage } from "@/services/storage/local-storage";
import { isServerSide } from "../utils/is-server-side";
export class ApiError extends Error {
public status: number;
public response?: any;
import { GraphValidationErrorResponse } from "./types";
constructor(message: string, status: number, response?: any) {
export class ApiError<R = any> extends Error {
public status: number;
public response: R;
constructor(message: string, status: number, response: R) {
super(message);
this.name = "ApiError";
this.status = status;
this.response = response;
}
/**
* Type guard to check if this error is a structured graph validation error
*/
isGraphValidationError(): this is ApiError<GraphValidationErrorResponse> {
return (
this.response !== undefined &&
typeof this.response === "object" &&
this.response !== null &&
"detail" in this.response &&
typeof this.response.detail === "object" &&
this.response.detail !== null &&
"type" in this.response.detail &&
this.response.detail.type === "validation_error" &&
"node_errors" in this.response.detail &&
typeof this.response.detail.node_errors === "object"
);
}
}
export function buildRequestUrl(
@@ -51,22 +71,15 @@ export function buildUrlWithQuery(
return `${url}?${queryParams.toString()}`;
}
export function handleFetchError(response: Response, errorData: any): ApiError {
export async function handleFetchError(response: Response): Promise<ApiError> {
const errorMessage = await parseApiError(response);
return new ApiError(
errorData?.error || "Request failed",
errorMessage || "Request failed",
response.status,
errorData,
await response.json(),
);
}
export async function parseErrorResponse(response: Response): Promise<any> {
try {
return await response.json();
} catch {
return { error: response.statusText };
}
}
export async function getServerAuthToken(): Promise<string> {
const supabase = await getServerSupabase();
@@ -126,7 +139,7 @@ export function serializeRequestBody(
export async function parseApiError(response: Response): Promise<string> {
try {
const errorData = await response.json();
const errorData = await response.clone().json();
if (
Array.isArray(errorData.detail) &&
@@ -141,7 +154,12 @@ export async function parseApiError(response: Response): Promise<string> {
return errors.join("\n");
}
return errorData.detail || response.statusText;
if (typeof errorData.detail === "object" && errorData.detail !== null) {
if (errorData.detail.message) return errorData.detail.message;
return response.statusText; // Fallback to status text if no message
}
return errorData.detail || errorData.error || response.statusText;
} catch {
return response.statusText;
}
@@ -225,10 +243,7 @@ export async function makeAuthenticatedRequest(
// Try to parse the full response body for better error context
let responseData = null;
try {
const responseText = await response.clone().text();
if (responseText) {
responseData = JSON.parse(responseText);
}
responseData = await response.clone().json();
} catch {
// Ignore parsing errors
}

View File

@@ -4,3 +4,4 @@ export default BackendAPI;
export * from "./client";
export * from "./types";
export * from "./utils";
export { ApiError } from "./helpers";

View File

@@ -372,6 +372,15 @@ export type NodeExecutionResult = {
end_time?: Date;
};
/* Structured validation error types for graph execution */
export type GraphValidationErrorResponse = {
detail: {
type: "validation_error";
message: string;
node_errors: Record<string, Record<string, string>>;
};
};
/* *** LIBRARY *** */
/* Mirror of backend/server/v2/library/model.py:LibraryAgent */