Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alphaxiv/agents",
"version": "0.7.0",
"version": "0.7.1",
"license": "MIT",
"fmt": {
"lineWidth": 120
Expand Down
8 changes: 7 additions & 1 deletion mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ export type {
ModelCallReason,
} from "./src/agent.ts";

export { classifyError, createClassifiedError, ERROR_KINDS, FirstTokenTimeoutError } from "./src/errors.ts";
export {
classifyError,
createClassifiedError,
ERROR_KINDS,
FirstTokenTimeoutError,
InvalidAttachmentError,
} from "./src/errors.ts";
export type { ClassifiedError, ErrorKind } from "./src/errors.ts";

export { DEFAULT_RETRY_STRATEGY, determineRetryBehavior, resolveRetryStrategy } from "./src/retry.ts";
Expand Down
5 changes: 2 additions & 3 deletions src/adapters/anthropic/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type Anthropic from "@anthropic-ai/sdk";
import { isStructuredOutputRetryFeedback } from "../../constants.ts";
import { normalizeToolName } from "../../tool.ts";
import type { ChatItem } from "../../types.ts";
import { IMAGE_MIME_TYPES, isTextLikeMimeType } from "../shared/media.ts";
import { fetchAttachmentText, IMAGE_MIME_TYPES, isTextLikeMimeType } from "../shared/media.ts";
import { ensureToolInputObject } from "../shared/tools.ts";
import type { AnthropicToolMap } from "./utils.ts";

Expand Down Expand Up @@ -159,8 +159,7 @@ export async function getAnthropicHistory(options: {
],
});
} else if (isTextLikeMimeType(historyItem.kind)) {
const req = await fetch(historyItem.content, { signal: options.signal });
const text = await req.text();
const text = await fetchAttachmentText(historyItem.content, options.signal);

pushBuffer.push({
role: "user",
Expand Down
42 changes: 32 additions & 10 deletions src/adapters/shared/media.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { encodeBase64 } from "@std/encoding";
import { InvalidAttachmentError } from "../../errors.ts";
import { errMessage } from "../../util.ts";

const textDecoder = new TextDecoder();

export const IMAGE_MIME_TYPES = [
"image/jpeg",
Expand Down Expand Up @@ -61,35 +65,53 @@ export function getFileNameFromUrl(url: string): string | undefined {
}
}

async function fetchAttachment(url: string, signal: AbortSignal): Promise<ArrayBuffer> {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new InvalidAttachmentError(url, `Attachment ${url} responded with ${response.status}`);
}

const buffer = await response.arrayBuffer();
if (buffer.byteLength === 0) {
throw new InvalidAttachmentError(url, `Attachment ${url} is empty`);
}
return buffer;
}

export async function getContentLength(url: string, signal: AbortSignal) {
const headResponse = await fetch(url, {
method: "HEAD",
signal,
});
const contentLength = headResponse.headers.get("Content-Length");
if (contentLength) {
if (headResponse.ok && contentLength) {
return parseInt(contentLength, 10);
}

const response = await fetch(url, { signal });
return (await response.arrayBuffer()).byteLength;
return (await fetchAttachment(url, signal)).byteLength;
}

export async function fetchAttachmentText(url: string, signal: AbortSignal) {
return textDecoder.decode(await fetchAttachment(url, signal));
}

export async function fetchTextLikeFileAsTaggedText(url: string, mimeType: string, signal: AbortSignal) {
const response = await fetch(url, { signal });
const text = await response.text();
const text = await fetchAttachmentText(url, signal);
return `<file mime-type="${mimeType}">${text}</file>`;
}

export async function fetchPdfAsText(url: string, signal: AbortSignal) {
const response = await fetch(url, { signal });
const buffer = await fetchAttachment(url, signal);
const { default: parsePdf } = await import("@lino/pdf-parse");
const pdfText = await parsePdf(await response.arrayBuffer());
return pdfText.text.join("\n");
try {
const pdfText = await parsePdf(buffer);
return pdfText.text.join("\n");
} catch (error) {
throw new InvalidAttachmentError(url, `Attachment ${url} could not be parsed as a PDF: ${errMessage(error)}`);
}
}

export async function fetchRemoteFileAsDataUrl(url: string, mimeType: string, signal: AbortSignal) {
const response = await fetch(url, { signal });
const buffer = await response.arrayBuffer();
const buffer = await fetchAttachment(url, signal);
return `data:${mimeType};base64,${encodeBase64(buffer)}`;
}
49 changes: 47 additions & 2 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export const ERROR_KINDS = [
"context_overflow",
"unsupported_file_type",
"image_too_large",
"invalid_attachment",
"attachment_rejected",
"content_filtered",
"unknown",
] as const;

Expand Down Expand Up @@ -80,6 +83,7 @@ function isLikelyProviderRateLimit(text: string): boolean {
function isLikelyModelUnavailable(text: string): boolean {
const lower = text.toLowerCase();
return (
lower.includes("overloaded_error") ||
lower.includes("specified api usage limits") ||
(lower.includes("regain access on") && lower.includes("usage limits")) ||
lower.includes("model is currently overloaded") ||
Expand All @@ -95,7 +99,34 @@ function isLikelyNetworkError(text: string): boolean {
lower.includes("connection refused") ||
lower.includes("econnrefused") ||
lower.includes("econnreset") ||
lower.includes("socket hang up")
lower.includes("socket hang up") ||
lower.includes("terminated")
);
}

function isLikelyServerError(text: string): boolean {
const lower = text.toLowerCase();
return (
lower.includes("had an error processing your request") ||
lower.includes("missing finish_reason")
);
}

function isLikelyAttachmentRejected(text: string): boolean {
const lower = text.toLowerCase();
return (
lower.includes("invalid pdf structure") ||
lower.includes("does not represent a valid image") ||
lower.includes("error while downloading file")
);
}

function isLikelyContentFiltered(text: string): boolean {
const lower = text.toLowerCase();
return (
lower.includes("request blocked.") ||
lower.includes("flagged for possible") ||
lower.includes("violating our usage policy")
);
}

Expand Down Expand Up @@ -167,6 +198,12 @@ export class FirstTokenTimeoutError extends Error {
}
}

export class InvalidAttachmentError extends Error {
constructor(readonly url: string, message: string) {
super(message);
}
}

/**
* Classifies an error into a known category to determine retry behavior.
* This is the heuristic-based classifier used when adapters don't provide
Expand Down Expand Up @@ -196,7 +233,11 @@ export function classifyError(error: unknown, status?: number): ClassifiedError

let kind: ErrorKind = "unknown";

if (normalizedError.name === "AbortError") {
if (error instanceof InvalidAttachmentError) {
kind = "invalid_attachment";
} else if (isLikelyAttachmentRejected(message)) {
kind = "attachment_rejected";
} else if (normalizedError.name === "AbortError") {
kind = "aborted";
} else if (normalizedError.name === "TimeoutError") {
kind = "timeout";
Expand All @@ -207,6 +248,10 @@ export function classifyError(error: unknown, status?: number): ClassifiedError
kind = "network";
} else if (isLikelyModelUnavailable(message)) {
kind = "model_unavailable";
} else if (isLikelyContentFiltered(message)) {
kind = "content_filtered";
} else if (isLikelyServerError(message)) {
kind = "server";
} else if (isLikelyUnsupportedFileType(message)) {
kind = "unsupported_file_type";
} else if (isLikelyImageTooLarge(message)) {
Expand Down
5 changes: 5 additions & 0 deletions src/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const DETERMINISTIC_ERROR_KINDS = new Set<ErrorKind>([
"client",
"unsupported_file_type",
"image_too_large",
"invalid_attachment",
"attachment_rejected",
"content_filtered",
]);

export function isDeterministicModelError(kind: ErrorKind): boolean {
Expand Down Expand Up @@ -142,6 +145,8 @@ export function resolveRetryStrategy(strategy?: RetryStrategy): ResolvedRetryStr
function getStrategyBehavior(kind: ErrorKind, strategy: ResolvedRetryStrategy): RetryBehavior {
switch (kind) {
case "aborted":
case "invalid_attachment":
case "content_filtered":
return "no-retry";
case "timeout":
return strategy.onTimeout;
Expand Down
100 changes: 100 additions & 0 deletions tests/adapters/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { assertEquals, assertRejects } from "@std/assert";
import type OpenAI from "openai";
import { Agent } from "../../mod.ts";
import { openAICompletionsModel } from "../../src/adapters/openai_completions/adapter.ts";
import { InvalidAttachmentError } from "../../src/errors.ts";
import type { StreamItem } from "../../src/types.ts";

const MISSING_URL = "https://example.com/uploads/gone.txt";
const FAKE_PDF_URL = "https://example.com/uploads/notes.pdf";

function stubFetch() {
const calls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = ((input: string | URL | Request) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
calls.push(url);
if (url === MISSING_URL) return Promise.resolve(new Response("Not Found", { status: 404 }));
return Promise.resolve(new Response("plain text, not a PDF"));
}) as typeof fetch;

return { calls, [Symbol.dispose]: () => void (globalThis.fetch = originalFetch) };
}

function createFakeClient() {
const requests: unknown[] = [];
const client = {
chat: {
completions: {
stream(request: unknown) {
requests.push(request);
return {
async *[Symbol.asyncIterator]() {
yield { choices: [{ delta: { content: "ok" } }] };
},
// deno-lint-ignore require-await
async finalChatCompletion() {
return { usage: undefined };
},
};
},
},
},
} as unknown as Pick<OpenAI, "chat">;

return { client, requests };
}

function textModel(provider: string, client: Pick<OpenAI, "chat">) {
return openAICompletionsModel({ model: "gpt-test", provider, client, pdfSupport: { mode: "text" } });
}

Deno.test("an attachment that 404s fails the run without a model switch", async () => {
using fetchStub = stubFetch();
const primary = createFakeClient();
const fallback = createFakeClient();

const agent = new Agent({
model: [textModel("primary", primary.client), textModel("fallback", fallback.client)],
instructions: "test",
});

const items: StreamItem[] = [];
await assertRejects(async () => {
for await (const item of agent.stream([{ type: "input_file", kind: "text/plain", content: MISSING_URL }])) {
items.push(item);
}
}, InvalidAttachmentError);

assertEquals(fetchStub.calls, [MISSING_URL]);
assertEquals(primary.requests.length, 0);
assertEquals(fallback.requests.length, 0);
assertEquals(items.filter((item) => item.type === "model_switched").length, 0);
});

Deno.test("a text file served as a PDF fails the run without a model switch", async () => {
using fetchStub = stubFetch();
const primary = createFakeClient();
const fallback = createFakeClient();

const agent = new Agent({
model: [textModel("primary", primary.client), textModel("fallback", fallback.client)],
instructions: "test",
});

const items: StreamItem[] = [];
await assertRejects(
async () => {
for await (const item of agent.stream([{ type: "input_file", kind: "application/pdf", content: FAKE_PDF_URL }])) {
items.push(item);
}
},
InvalidAttachmentError,
"could not be parsed as a PDF: Invalid PDF structure",
);

assertEquals(fetchStub.calls, [FAKE_PDF_URL]);
assertEquals(primary.requests.length, 0);
assertEquals(fallback.requests.length, 0);
assertEquals(items.filter((item) => item.type === "model_switched").length, 0);
});
Loading
Loading