refactor(ai): improve error handling and stop reason types

- Add 'aborted' as a distinct stop reason separate from 'error'
- Change AssistantMessage.error to errorMessage for clarity
- Update error event to include reason field ('error' | 'aborted')
- Map provider-specific safety/refusal reasons to 'error' stop reason
- Reorganize utility functions into utils/ directory
- Rename agent.ts to agent-loop.ts for better clarity
- Fix error handling in all providers to properly distinguish abort from error
This commit is contained in:
Mario Zechner 2025-09-18 19:57:13 +02:00
parent 293a6e878d
commit 2296dc4052
22 changed files with 703 additions and 139 deletions

View file

@ -4,8 +4,6 @@ import type {
MessageCreateParamsStreaming,
MessageParam,
} from "@anthropic-ai/sdk/resources/messages.js";
import { AssistantMessageEventStream } from "../event-stream.js";
import { parseStreamingJson } from "../json-parse.js";
import { calculateCost } from "../models.js";
import type {
Api,
@ -22,7 +20,9 @@ import type {
ToolCall,
ToolResultMessage,
} from "../types.js";
import { validateToolArguments } from "../validation.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { validateToolArguments } from "../utils/validation.js";
import { transformMessages } from "./transorm-messages.js";
export interface AnthropicOptions extends StreamOptions {
@ -196,13 +196,17 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unkown error ocurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
for (const block of output.content) delete (block as any).index;
output.stopReason = "error";
output.error = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", error: output.error, partial: output });
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
@ -466,7 +470,7 @@ function mapStopReason(reason: Anthropic.Messages.StopReason): StopReason {
case "tool_use":
return "toolUse";
case "refusal":
return "safety";
return "error";
case "pause_turn": // Stop is good enough -> resubmit
return "stop";
case "stop_sequence":

View file

@ -7,7 +7,6 @@ import {
GoogleGenAI,
type Part,
} from "@google/genai";
import { AssistantMessageEventStream } from "../event-stream.js";
import { calculateCost } from "../models.js";
import type {
Api,
@ -22,7 +21,8 @@ import type {
Tool,
ToolCall,
} from "../types.js";
import { validateToolArguments } from "../validation.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { validateToolArguments } from "../utils/validation.js";
import { transformMessages } from "./transorm-messages.js";
export interface GoogleOptions extends StreamOptions {
@ -226,12 +226,21 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unkown error ocurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
output.stopReason = "error";
output.error = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", error: output.error, partial: output });
for (const block of output.content) delete (block as any).index;
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
@ -424,7 +433,7 @@ function mapStopReason(reason: FinishReason): StopReason {
case FinishReason.SAFETY:
case FinishReason.IMAGE_SAFETY:
case FinishReason.RECITATION:
return "safety";
return "error";
case FinishReason.FINISH_REASON_UNSPECIFIED:
case FinishReason.OTHER:
case FinishReason.LANGUAGE:

View file

@ -7,8 +7,6 @@ import type {
ChatCompletionContentPartText,
ChatCompletionMessageParam,
} from "openai/resources/chat/completions.js";
import { AssistantMessageEventStream } from "../event-stream.js";
import { parseStreamingJson } from "../json-parse.js";
import { calculateCost } from "../models.js";
import type {
AssistantMessage,
@ -22,7 +20,9 @@ import type {
Tool,
ToolCall,
} from "../types.js";
import { validateToolArguments } from "../validation.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { validateToolArguments } from "../utils/validation.js";
import { transformMessages } from "./transorm-messages.js";
export interface OpenAICompletionsOptions extends StreamOptions {
@ -231,13 +231,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unkown error ocurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
return output;
} catch (error) {
output.stopReason = "error";
output.error = error instanceof Error ? error.message : String(error);
stream.push({ type: "error", error: output.error, partial: output });
for (const block of output.content) delete (block as any).index;
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
@ -413,7 +417,7 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"]): Sto
case "tool_calls":
return "toolUse";
case "content_filter":
return "safety";
return "error";
default: {
const _exhaustive: never = reason;
throw new Error(`Unhandled stop reason: ${_exhaustive}`);

View file

@ -10,8 +10,6 @@ import type {
ResponseOutputMessage,
ResponseReasoningItem,
} from "openai/resources/responses/responses.js";
import { AssistantMessageEventStream } from "../event-stream.js";
import { parseStreamingJson } from "../json-parse.js";
import { calculateCost } from "../models.js";
import type {
Api,
@ -26,7 +24,9 @@ import type {
Tool,
ToolCall,
} from "../types.js";
import { validateToolArguments } from "../validation.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { validateToolArguments } from "../utils/validation.js";
import { transformMessages } from "./transorm-messages.js";
// OpenAI Responses-specific options
@ -268,17 +268,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
}
// Handle errors
else if (event.type === "error") {
output.stopReason = "error";
output.error = `Code ${event.code}: ${event.message}` || "Unknown error";
stream.push({ type: "error", error: output.error, partial: output });
stream.end();
return output;
throw new Error(`Error Code ${event.code}: ${event.message}` || "Unknown error");
} else if (event.type === "response.failed") {
output.stopReason = "error";
output.error = "Unknown error";
stream.push({ type: "error", error: output.error, partial: output });
stream.end();
return output;
throw new Error("Unknown error");
}
}
@ -286,12 +278,17 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unkown error ocurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
output.stopReason = "error";
output.error = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", error: output.error, partial: output });
for (const block of output.content) delete (block as any).index;
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();