mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
fix(api): harden batch and file endpoints for auth and recovery
Reject invalid API keys even when authentication is optional and add OPTIONS handlers for batch and file routes to support CORS preflights. Also recover orphaned batches stuck in finalizing after restarts, include finalizing in pending batch queries, preserve multipart upload content handling, and fetch remote vision images as data URIs for Anthropic requests.
This commit is contained in:
@@ -58,19 +58,27 @@ export function stopBatchProcessor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark any in_progress batches as failed on startup.
|
||||
* Mark any in_progress/finalizing batches as failed on startup.
|
||||
* These were orphaned by a server crash or restart and cannot be safely resumed.
|
||||
*/
|
||||
function recoverOrphanedBatches() {
|
||||
try {
|
||||
const pending = getPendingBatches();
|
||||
for (const batch of pending) {
|
||||
if (batch.status === "in_progress") {
|
||||
console.warn(`[BATCH] Failing orphaned in_progress batch ${batch.id} (server restarted)`);
|
||||
if (batch.status === "in_progress" || batch.status === "finalizing") {
|
||||
const interruptedPhase =
|
||||
batch.status === "finalizing" ? "during finalization" : "while processing requests";
|
||||
console.warn(
|
||||
`[BATCH] Failing orphaned ${batch.status} batch ${batch.id} (server restarted)`
|
||||
);
|
||||
updateBatch(batch.id, {
|
||||
status: "failed",
|
||||
failedAt: Math.floor(Date.now() / 1000),
|
||||
errors: [{ message: "Batch interrupted by server restart and cannot be resumed" }],
|
||||
errors: [
|
||||
{
|
||||
message: `Batch interrupted ${interruptedPhase} by server restart and cannot be resumed`,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (batch.inputFileId) {
|
||||
updateFileStatus(batch.inputFileId, "processed");
|
||||
@@ -90,8 +98,8 @@ export async function processPendingBatches() {
|
||||
} else if (batch.status === "cancelling") {
|
||||
await cancelBatch(batch);
|
||||
}
|
||||
// in_progress: currently being processed by processBatchItems running in background;
|
||||
// orphaned in_progress batches are handled by recoverOrphanedBatches() at startup.
|
||||
// in_progress/finalizing batches are either actively being worked by the current process
|
||||
// or will be failed by recoverOrphanedBatches() on the next startup.
|
||||
}
|
||||
|
||||
// Cleanup task: delete files for batches completed more than completionWindow ago
|
||||
|
||||
@@ -1982,7 +1982,7 @@ async function handleRoundRobinCombo({
|
||||
}
|
||||
}
|
||||
|
||||
if (isProviderBreakerOpenResponse(result, errorBody as any)) {
|
||||
if (isProviderBreakerOpenResponse(result, errorBody as Record<string, unknown> | null)) {
|
||||
lastError = errorText || String(result.status);
|
||||
if (!lastStatus) lastStatus = result.status;
|
||||
if (offset > 0) fallbackCount++;
|
||||
|
||||
@@ -40,6 +40,18 @@ export async function getApiKeyRequestScope(request: Request): Promise<ApiKeyReq
|
||||
}
|
||||
}
|
||||
|
||||
if (apiKey && !(await isValidApiKey(apiKey))) {
|
||||
return {
|
||||
apiKey: null,
|
||||
apiKeyId: null,
|
||||
apiKeyMetadata: null,
|
||||
rejection: NextResponse.json(
|
||||
{ error: { message: "Invalid API key", type: "invalid_request_error" } },
|
||||
{ status: 401, headers: CORS_HEADERS }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const apiKeyMetadata = await getApiKeyMetadata(apiKey);
|
||||
return {
|
||||
apiKey,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { getBatch, updateBatch } from "@/lib/localDb";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
|
||||
@@ -34,6 +34,10 @@ function formatBatchResponse(batch: any) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const scope = await getApiKeyRequestScope(request);
|
||||
if (scope.rejection) return scope.rejection;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { getBatch } from "@/lib/localDb";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
|
||||
@@ -34,6 +34,10 @@ function formatBatchResponse(batch: any) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const scope = await getApiKeyRequestScope(request);
|
||||
if (scope.rejection) return scope.rejection;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { createBatch, getFile, listBatches } from "@/lib/localDb";
|
||||
import { v1BatchCreateSchema } from "@/shared/validation/schemas";
|
||||
import { NextResponse } from "next/server";
|
||||
@@ -35,6 +35,10 @@ function formatBatchResponse(batch: any) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const scope = await getApiKeyRequestScope(request);
|
||||
if (scope.rejection) return scope.rejection;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { getFile, getFileContent } from "@/lib/localDb";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const scope = await getApiKeyRequestScope(request);
|
||||
if (scope.rejection) return scope.rejection;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { getFile, deleteFile, formatFileResponse } from "@/lib/localDb";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const scope = await getApiKeyRequestScope(request);
|
||||
if (scope.rejection) return scope.rejection;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { createFile, listFiles, formatFileResponse } from "@/lib/localDb";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleCorsOptions();
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const scope = await getApiKeyRequestScope(request);
|
||||
if (scope.rejection) return scope.rejection;
|
||||
@@ -38,16 +42,7 @@ export async function POST(request: Request) {
|
||||
const bytes = file.size;
|
||||
const filename = file.name;
|
||||
const mimeType = file.type;
|
||||
|
||||
// Stream the upload into memory in chunks to avoid allocating a large contiguous ArrayBuffer
|
||||
const reader = file.stream().getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
const content = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
||||
const content = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
let expiresAt: number | undefined;
|
||||
if (expiresAfterAnchor === "created_at" && expiresAfterSeconds) {
|
||||
|
||||
@@ -171,7 +171,9 @@ export function listBatches(apiKeyId?: string, limit: number = 20, after?: strin
|
||||
export function getPendingBatches(): BatchRecord[] {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT * FROM batches WHERE status IN ('validating', 'in_progress', 'cancelling')")
|
||||
.prepare(
|
||||
"SELECT * FROM batches WHERE status IN ('validating', 'in_progress', 'finalizing', 'cancelling')"
|
||||
)
|
||||
.all();
|
||||
return rows.map((row) => parseBatchRow(row));
|
||||
}
|
||||
|
||||
@@ -110,6 +110,34 @@ export function resolveImageAsDataUri(imageUrl: string): string {
|
||||
return `data:image/png;base64,${imageUrl}`;
|
||||
}
|
||||
|
||||
async function fetchRemoteImageAsDataUri(imageUrl: string, signal: AbortSignal): Promise<string> {
|
||||
const response = await fetch(imageUrl, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vision image fetch error ${response.status}`);
|
||||
}
|
||||
|
||||
const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return `data:${mediaType};base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
async function normalizeVisionImageInput(
|
||||
imageInput: string,
|
||||
isAnthropic: boolean,
|
||||
signal: AbortSignal
|
||||
): Promise<string> {
|
||||
const normalizedImage = resolveImageAsDataUri(imageInput);
|
||||
|
||||
if (
|
||||
isAnthropic &&
|
||||
(normalizedImage.startsWith("http://") || normalizedImage.startsWith("https://"))
|
||||
) {
|
||||
return fetchRemoteImageAsDataUri(normalizedImage, signal);
|
||||
}
|
||||
|
||||
return normalizedImage;
|
||||
}
|
||||
|
||||
export interface VisionModelConfig {
|
||||
model: string;
|
||||
prompt: string;
|
||||
@@ -137,9 +165,12 @@ export async function callVisionModel(
|
||||
|
||||
try {
|
||||
// Extract model name from provider/model format
|
||||
const modelName = config.model.includes("/")
|
||||
? config.model.split("/")[1]
|
||||
: config.model;
|
||||
const modelName = config.model.includes("/") ? config.model.split("/")[1] : config.model;
|
||||
const normalizedImageInput = await normalizeVisionImageInput(
|
||||
imageDataUri,
|
||||
isAnthropic,
|
||||
controller.signal
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
|
||||
@@ -148,9 +179,9 @@ export async function callVisionModel(
|
||||
const anthropicBaseUrl = process.env.ANTHROPIC_API_URL || "https://api.anthropic.com";
|
||||
|
||||
// Parse data URI to extract media type and base64 data
|
||||
const matches = imageDataUri.match(/^data:([^;]+);base64,(.+)$/);
|
||||
const matches = normalizedImageInput.match(/^data:([^;]+);base64,(.+)$/);
|
||||
let mediaType = "image/png";
|
||||
let base64Data = imageDataUri;
|
||||
let base64Data = normalizedImageInput;
|
||||
|
||||
if (matches) {
|
||||
mediaType = matches[1];
|
||||
@@ -209,7 +240,7 @@ export async function callVisionModel(
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: imageDataUri,
|
||||
url: normalizedImageInput,
|
||||
detail: "low",
|
||||
},
|
||||
},
|
||||
@@ -239,7 +270,9 @@ export async function callVisionModel(
|
||||
};
|
||||
|
||||
if (anthropicData.error) {
|
||||
throw new Error(`Vision API error: ${anthropicData.error.message || JSON.stringify(anthropicData.error)}`);
|
||||
throw new Error(
|
||||
`Vision API error: ${anthropicData.error.message || JSON.stringify(anthropicData.error)}`
|
||||
);
|
||||
}
|
||||
|
||||
const textContent = anthropicData.content?.find((c) => c.type === "text");
|
||||
@@ -257,7 +290,9 @@ export async function callVisionModel(
|
||||
};
|
||||
|
||||
if (openaiData.error) {
|
||||
throw new Error(`Vision API error: ${openaiData.error.message || JSON.stringify(openaiData.error)}`);
|
||||
throw new Error(
|
||||
`Vision API error: ${openaiData.error.message || JSON.stringify(openaiData.error)}`
|
||||
);
|
||||
}
|
||||
|
||||
const content = openaiData.choices?.[0]?.message?.content;
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-batch-api-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-secret-123";
|
||||
|
||||
const {
|
||||
createFile,
|
||||
@@ -25,7 +26,11 @@ const { getDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
const { initBatchProcessor, stopBatchProcessor, processPendingBatches } =
|
||||
await import("../../open-sse/services/batchProcessor.ts");
|
||||
const batchesRoute = await import("../../src/app/api/v1/batches/route.ts");
|
||||
const batchByIdRoute = await import("../../src/app/api/v1/batches/[id]/route.ts");
|
||||
const batchCancelRoute = await import("../../src/app/api/v1/batches/[id]/cancel/route.ts");
|
||||
const filesRoute = await import("../../src/app/api/v1/files/route.ts");
|
||||
const fileByIdRoute = await import("../../src/app/api/v1/files/[id]/route.ts");
|
||||
const fileContentRoute = await import("../../src/app/api/v1/files/[id]/content/route.ts");
|
||||
|
||||
test("Batch API and Processing", async () => {
|
||||
// 0. Setup environment, mock provider and API key
|
||||
@@ -654,6 +659,43 @@ test("Batch cleanup honors output_expires_after for output artifacts", async ()
|
||||
assert.equal(getFile(errorFile.id), null);
|
||||
});
|
||||
|
||||
test("Batch processor fails orphaned finalizing batches during startup recovery", async () => {
|
||||
const apiKey = await createApiKey("Finalizing Recovery Key", "test-machine");
|
||||
const inputFile = createFile({
|
||||
bytes: 2,
|
||||
filename: "finalizing.jsonl",
|
||||
purpose: "batch",
|
||||
content: Buffer.from("{}"),
|
||||
apiKeyId: apiKey.id,
|
||||
});
|
||||
|
||||
const batch = createBatch({
|
||||
endpoint: "/v1/chat/completions",
|
||||
completionWindow: "24h",
|
||||
inputFileId: inputFile.id,
|
||||
apiKeyId: apiKey.id,
|
||||
});
|
||||
|
||||
updateBatch(batch.id, {
|
||||
status: "finalizing",
|
||||
finalizingAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
initBatchProcessor();
|
||||
|
||||
try {
|
||||
const recoveredBatch = getBatch(batch.id);
|
||||
assert.strictEqual(recoveredBatch?.status, "failed");
|
||||
assert.match(
|
||||
String(recoveredBatch?.errors?.[0]?.message || ""),
|
||||
/interrupted during finalization/i
|
||||
);
|
||||
assert.strictEqual(getFile(inputFile.id)?.status, "processed");
|
||||
} finally {
|
||||
stopBatchProcessor();
|
||||
}
|
||||
});
|
||||
|
||||
test("Batch list route rejects missing API key when REQUIRE_API_KEY is enabled", async () => {
|
||||
const previous = process.env.REQUIRE_API_KEY;
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
@@ -688,6 +730,78 @@ test("Files list route rejects invalid API key when REQUIRE_API_KEY is enabled",
|
||||
}
|
||||
});
|
||||
|
||||
test("Files upload route rejects invalid API key even when auth is optional", async () => {
|
||||
const previous = process.env.REQUIRE_API_KEY;
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set("purpose", "batch");
|
||||
formData.set(
|
||||
"file",
|
||||
new File([Buffer.from('{"ok":true}\n')], "input.jsonl", { type: "application/json" })
|
||||
);
|
||||
|
||||
const response = await filesRoute.POST(
|
||||
new Request("http://localhost/api/v1/files", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer invalid-test-key" },
|
||||
body: formData,
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.strictEqual(response.status, 401);
|
||||
assert.strictEqual(json.error.message, "Invalid API key");
|
||||
} finally {
|
||||
process.env.REQUIRE_API_KEY = previous ?? "false";
|
||||
}
|
||||
});
|
||||
|
||||
test("Files upload route stores multipart content", async () => {
|
||||
const fileContent = '{"custom_id":"req-1"}\n';
|
||||
const formData = new FormData();
|
||||
formData.set("purpose", "batch");
|
||||
formData.set(
|
||||
"file",
|
||||
new File([Buffer.from(fileContent)], "upload.jsonl", { type: "application/json" })
|
||||
);
|
||||
|
||||
const response = await filesRoute.POST(
|
||||
new Request("http://localhost/api/v1/files", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.ok(json.id);
|
||||
assert.strictEqual(getFileContent(json.id)?.toString(), fileContent);
|
||||
});
|
||||
|
||||
test("Files and batches routes expose explicit CORS preflight handlers", async () => {
|
||||
const routes = [
|
||||
batchesRoute,
|
||||
batchByIdRoute,
|
||||
batchCancelRoute,
|
||||
filesRoute,
|
||||
fileByIdRoute,
|
||||
fileContentRoute,
|
||||
];
|
||||
|
||||
for (const route of routes) {
|
||||
assert.strictEqual(typeof route.OPTIONS, "function");
|
||||
const response = await route.OPTIONS();
|
||||
assert.strictEqual(response.status, 204);
|
||||
assert.strictEqual(response.headers.get("Access-Control-Allow-Origin"), "*");
|
||||
assert.match(
|
||||
String(response.headers.get("Access-Control-Allow-Headers") || ""),
|
||||
/Authorization/i
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Batch Cancel API", async () => {
|
||||
const apiKey = await createApiKey("Cancel Test Key", "test-machine");
|
||||
|
||||
|
||||
@@ -56,8 +56,7 @@ test("callVisionModel throws on HTTP error", async () => {
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
await callVisionModel("data:image/png;base64,iVBORw0KGgo", config),
|
||||
async () => await callVisionModel("data:image/png;base64,iVBORw0KGgo", config),
|
||||
/Vision API error 500/
|
||||
);
|
||||
} finally {
|
||||
@@ -168,10 +167,7 @@ test("callVisionModel passes custom API key", async () => {
|
||||
|
||||
await callVisionModel("data:image/png;base64,iVBORw0KGgo", config, "sk-custom-key");
|
||||
|
||||
assert.strictEqual(
|
||||
capturedHeaders["Authorization"],
|
||||
"Bearer sk-custom-key"
|
||||
);
|
||||
assert.strictEqual(capturedHeaders["Authorization"], "Bearer sk-custom-key");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
@@ -210,22 +206,75 @@ test("callVisionModel uses correct request body format", async () => {
|
||||
assert.ok(Array.isArray(capturedBody.messages));
|
||||
assert.strictEqual((capturedBody.messages as unknown[]).length, 1);
|
||||
|
||||
const message = (capturedBody.messages as Array<{role: string; content: unknown[]}>)[0];
|
||||
const message = (capturedBody.messages as Array<{ role: string; content: unknown[] }>)[0];
|
||||
assert.strictEqual(message.role, "user");
|
||||
assert.ok(Array.isArray(message.content));
|
||||
assert.strictEqual(message.content.length, 2);
|
||||
|
||||
// First content is image_url
|
||||
const imagePart = message.content[0] as {type: string; image_url: {url: string; detail: string}};
|
||||
const imagePart = message.content[0] as {
|
||||
type: string;
|
||||
image_url: { url: string; detail: string };
|
||||
};
|
||||
assert.strictEqual(imagePart.type, "image_url");
|
||||
assert.strictEqual(imagePart.image_url.url, imageUri);
|
||||
assert.strictEqual(imagePart.image_url.detail, "low");
|
||||
|
||||
// Second content is text prompt
|
||||
const textPart = message.content[1] as {type: string; text: string};
|
||||
const textPart = message.content[1] as { type: string; text: string };
|
||||
assert.strictEqual(textPart.type, "text");
|
||||
assert.strictEqual(textPart.text, "What is in this image?");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("callVisionModel fetches remote images before Anthropic requests", async () => {
|
||||
const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
|
||||
globalThis.fetch = async (url: URL | RequestInfo, init?: RequestInit) => {
|
||||
const requestUrl = String(url);
|
||||
fetchCalls.push({ url: requestUrl, init });
|
||||
|
||||
if (requestUrl === "https://cdn.example.com/cat.png") {
|
||||
return new Response(Buffer.from("cat-image-bytes"), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/png" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
content: [{ type: "text", text: "A cat sitting on a chair" }],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const config: VisionModelConfig = {
|
||||
model: "anthropic/claude-3-haiku",
|
||||
prompt: "Describe this image",
|
||||
timeoutMs: 30000,
|
||||
maxImages: 10,
|
||||
};
|
||||
|
||||
const result = await callVisionModel("https://cdn.example.com/cat.png", config, "sk-ant");
|
||||
|
||||
assert.strictEqual(result, "A cat sitting on a chair");
|
||||
assert.strictEqual(fetchCalls.length, 2);
|
||||
assert.strictEqual(fetchCalls[0].url, "https://cdn.example.com/cat.png");
|
||||
assert.strictEqual(fetchCalls[1].url, "https://api.anthropic.com/v1/messages");
|
||||
|
||||
const anthropicBody = JSON.parse(fetchCalls[1].init?.body as string);
|
||||
const imageSource = anthropicBody.messages[0].content[0].source;
|
||||
assert.strictEqual(imageSource.type, "base64");
|
||||
assert.strictEqual(imageSource.media_type, "image/png");
|
||||
assert.strictEqual(imageSource.data, Buffer.from("cat-image-bytes").toString("base64"));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user