Skip to content
Closed
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
8 changes: 7 additions & 1 deletion src/providers/agenty/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ProviderFetch } from "../provider-runtime.ts";
import type { AgentyActionName } from "./actions.ts";

import { compactObject, optionalRecord, optionalString, requiredRecord } from "../../core/cast.ts";
import { readBoundedResponseBytes } from "../../core/request.ts";
import { providerFetch, ProviderRequestError, providerUserAgent } from "../provider-runtime.ts";

export const agentyApiBaseUrl = "https://api.agenty.com/v2";
Expand Down Expand Up @@ -581,7 +582,12 @@ async function uploadAgentyTransitFile(context: AgentyRuntimeContext, response:
}

const mimeType = response.headers.get("content-type") ?? "application/octet-stream";
const upload = await context.transitFiles.create(new File([await response.arrayBuffer()], name, { type: mimeType }));
const bytes = await readBoundedResponseBytes(response, {
maxBytes: context.transitFiles.maxBytes,
fieldName: "Agenty file output",
createError: (message) => new ProviderRequestError(413, message),
});
const upload = await context.transitFiles.create(new File([Uint8Array.from(bytes)], name, { type: mimeType }));
Comment on lines +585 to +590

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== locate helper and call sites ==\n'
rg -n "readBoundedResponseBytes|assertMaxBytes|response.body\\?\\.cancel|Content-Length|maxBytes" src/providers src -g'*.ts'

printf '\n== helper definition context ==\n'
helper_file=$(rg -l "function readBoundedResponseBytes|const readBoundedResponseBytes|async function readBoundedResponseBytes" src -g'*.ts' | head -n 1)
echo "helper_file=$helper_file"
if [ -n "${helper_file:-}" ]; then
  nl -ba "$helper_file" | sed -n '1,260p'
fi

printf '\n== tests referencing bounded bytes helper ==\n'
rg -n "readBoundedResponseBytes|Content-Length|413|cancel\\(" src test tests -g'*.ts' -g'*.tsx'

Repository: oomol-lab/open-connector

Length of output: 49634


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== direct helper file search ==\n'
fd -a "readBoundedResponseBytes" src -t f

Repository: oomol-lab/open-connector

Length of output: 195


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

path = Path("src/core/request.ts")
lines = path.read_text().splitlines()
for start, end in [(60, 130), (470, 520)]:
    print(f"\n== {path} lines {start}-{end} ==")
    for i in range(start, min(end, len(lines)) + 1):
        print(f"{i:4d}: {lines[i-1]}")
PY

printf '\n== tests mentioning readBoundedResponseBytes or Content-Length ==\n'
python3 - <<'PY'
from pathlib import Path
import re

for path in Path("src").rglob("*.test.ts"):
    text = path.read_text(errors="ignore")
    if "readBoundedResponseBytes" in text or "Content-Length" in text or "content-length" in text:
        print(path)
PY

Repository: oomol-lab/open-connector

Length of output: 4119


Cancel the body before the early Content-Length 413

src/core/request.ts#L83-L91 rejects oversized responses before touching response.body, so the underlying request can stay open. Cancel the body before throwing and add a regression test for this path.

📍 Affects 7 files
  • src/providers/agenty/runtime.ts#L585-L590 (this comment)
  • src/providers/feishu_app_bot/executors.ts#L1145-L1151
  • src/providers/fuxin/executors.ts#L357-L369
  • src/providers/gemini/runtime.ts#L674-L678
  • src/providers/gladia/runtime.ts#L180-L185
  • src/providers/googledrive/executors.ts#L511-L516
  • src/providers/klangio/runtime.ts#L222-L235
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/agenty/runtime.ts` around lines 585 - 590, Update the
oversized-response handling in src/core/request.ts around lines 83-91 to cancel
response.body before throwing the early Content-Length 413 error, and add a
regression test covering this path. The affected response consumers at
src/providers/agenty/runtime.ts lines 585-590,
src/providers/feishu_app_bot/executors.ts lines 1145-1151,
src/providers/fuxin/executors.ts lines 357-369, src/providers/gemini/runtime.ts
lines 674-678, src/providers/gladia/runtime.ts lines 180-185,
src/providers/googledrive/executors.ts lines 511-516, and
src/providers/klangio/runtime.ts lines 222-235 require no direct changes; they
are corrected by the shared request-layer fix.

return {
name,
mimetype: mimeType,
Expand Down
21 changes: 18 additions & 3 deletions src/providers/docsend_2_pdf/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { Docsend2PdfActionName } from "./actions.ts";

import { Buffer } from "node:buffer";
import { compactObject, optionalBoolean, optionalInteger, optionalString } from "../../core/cast.ts";
import { assertPublicHttpUrl } from "../../core/request.ts";
import { assertPublicHttpUrl, readBoundedResponseBytes } from "../../core/request.ts";
import {
defineProviderExecutors,
defineProviderProxy,
Expand Down Expand Up @@ -53,7 +53,8 @@ export const proxy: ProviderProxyExecutor = defineProviderProxy({

async function convert(input: Record<string, unknown>, context: Docsend2PdfContext): Promise<unknown> {
const returnPdfBase64 = optionalBoolean(input.returnPdfBase64) ?? false;
if (!returnPdfBase64 && !context.transitFiles) {
const transitFiles = context.transitFiles;
if (!returnPdfBase64 && !transitFiles) {
throw new ProviderRequestError(
400,
"Transit file storage is not enabled; set returnPdfBase64=true to return PDF bytes inline.",
Expand All @@ -77,7 +78,21 @@ async function convert(input: Record<string, unknown>, context: Docsend2PdfConte
throw new ProviderRequestError(502, `Docsend2pdf convert returned unexpected content type ${contentType}`);
}

const bytes = Buffer.from(await response.arrayBuffer());
let bytes: Buffer;
if (returnPdfBase64) {
bytes = Buffer.from(await response.arrayBuffer());
} else {
if (!transitFiles) {
throw new ProviderRequestError(400, "Transit file storage is not enabled.");
}
bytes = Buffer.from(
await readBoundedResponseBytes(response, {
maxBytes: transitFiles.maxBytes,
fieldName: "Docsend2pdf converted PDF",
createError: (message) => new ProviderRequestError(413, message),
}),
);
}
const outputName = normalizePdfName(optionalString(input.outputName) ?? readFilename(response));
const pdf = returnPdfBase64
? {
Expand Down
74 changes: 60 additions & 14 deletions src/providers/elevenlabs/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
optionalString,
requiredRecord,
} from "../../core/cast.ts";
import { readBoundedResponseBytes } from "../../core/request.ts";
import {
defineApiKeyProviderExecutors,
defineProviderProxy,
Expand Down Expand Up @@ -341,7 +342,16 @@ async function elevenlabsTextToSpeech(input: Record<string, unknown>, context: E
throw await createElevenlabsError(response, "execute");
}

const bytes = Buffer.from(await response.arrayBuffer());
if (!context.transitFiles) {
throw new ProviderRequestError(500, "text_to_speech requires transit file storage");
}
Comment on lines +345 to +347

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate transit storage before making the upstream request.

The check is too late: text_to_speech and create_sound_effect can consume ElevenLabs credits before inevitably failing with a local 500. Move each precondition ahead of its context.fetcher call; this also avoids an unnecessary history-audio request.

  • src/providers/elevenlabs/executors.ts#L345-L347: move the check before the TTS POST at Line 328.
  • src/providers/elevenlabs/executors.ts#L433-L435: move the check before the sound-effect POST at Line 417.
  • src/providers/elevenlabs/executors.ts#L466-L468: move the check before the history-audio GET at Line 456.
📍 Affects 1 file
  • src/providers/elevenlabs/executors.ts#L345-L347 (this comment)
  • src/providers/elevenlabs/executors.ts#L433-L435
  • src/providers/elevenlabs/executors.ts#L466-L468
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/elevenlabs/executors.ts` around lines 345 - 347, Move the
context.transitFiles precondition checks before the corresponding
context.fetcher calls in the text_to_speech, create_sound_effect, and
history-audio execution paths. Update all three sites in
src/providers/elevenlabs/executors.ts:345-347, :433-435, and :466-468, placing
each check before the TTS POST, sound-effect POST, and history-audio GET
respectively while preserving the existing ProviderRequestError behavior.

const bytes = Buffer.from(
await readBoundedResponseBytes(response, {
maxBytes: context.transitFiles.maxBytes,
fieldName: "ElevenLabs text-to-speech audio",
createError: (message) => new ProviderRequestError(413, message),
}),
);
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
const extension = inferElevenlabsAudioExtension(contentType, outputFormat);
const name = `elevenlabs-tts-${String(input.voiceId)}.${extension}`;
Expand All @@ -356,6 +366,10 @@ async function elevenlabsTextToSpeech(input: Record<string, unknown>, context: E
}

async function elevenlabsTextToSpeechWithTimestamps(input: Record<string, unknown>, context: ElevenlabsRuntimeContext) {
if (!context.transitFiles) {
throw new ProviderRequestError(500, "text_to_speech_with_timestamps requires transit file storage");
}

const requestPayload = buildTextToSpeechBody(input);
const outputFormat = optionalString(input.outputFormat) ?? "mp3_44100_128";
const modelId = optionalString(input.modelId);
Expand All @@ -369,6 +383,7 @@ async function elevenlabsTextToSpeechWithTimestamps(input: Record<string, unknow
optimize_streaming_latency: numberQueryValue(input.optimizeStreamingLatency),
}),
body: requestPayload,
maxResponseBytes: Math.ceil((context.transitFiles.maxBytes * 4) / 3) + 4 * 1024 * 1024,
},
context.apiKey,
context.fetcher,
Expand All @@ -378,15 +393,16 @@ async function elevenlabsTextToSpeechWithTimestamps(input: Record<string, unknow
const contentType = inferElevenlabsContentType(outputFormat);
const extension = inferElevenlabsAudioExtension(contentType, outputFormat);
const name = `elevenlabs-tts-timestamps-${String(input.voiceId)}.${extension}`;
const audioBytes = decodeRequiredBase64(payload.audio_base64, "audio_base64");
if (audioBytes.byteLength > context.transitFiles.maxBytes) {
throw new ProviderRequestError(
413,
`ElevenLabs text-to-speech audio exceeds ${context.transitFiles.maxBytes} bytes`,
);
}

return compactObject({
file: await storeElevenlabsFile(
context,
name,
contentType,
decodeRequiredBase64(payload.audio_base64, "audio_base64"),
"text_to_speech_with_timestamps",
),
file: await storeElevenlabsFile(context, name, contentType, audioBytes, "text_to_speech_with_timestamps"),
alignment: normalizeCharacterAlignment(payload.alignment),
normalizedAlignment: normalizeCharacterAlignment(payload.normalized_alignment),
voiceId: String(input.voiceId),
Expand Down Expand Up @@ -414,7 +430,16 @@ async function createElevenlabsSoundEffect(input: Record<string, unknown>, conte
throw await createElevenlabsError(response, "execute");
}

const bytes = Buffer.from(await response.arrayBuffer());
if (!context.transitFiles) {
throw new ProviderRequestError(500, "create_sound_effect requires transit file storage");
}
const bytes = Buffer.from(
await readBoundedResponseBytes(response, {
maxBytes: context.transitFiles.maxBytes,
fieldName: "ElevenLabs sound effect audio",
createError: (message) => new ProviderRequestError(413, message),
}),
);
const contentType = response.headers.get("content-type") ?? inferElevenlabsContentType(outputFormat);
const extension = inferElevenlabsAudioExtension(contentType, outputFormat);
const name = `elevenlabs-sound-effect.${extension}`;
Expand All @@ -438,7 +463,16 @@ async function getElevenlabsAudioFromHistoryItem(input: Record<string, unknown>,
throw await createElevenlabsError(response, "execute");
}

const bytes = Buffer.from(await response.arrayBuffer());
if (!context.transitFiles) {
throw new ProviderRequestError(500, "get_audio_from_history_item requires transit file storage");
}
const bytes = Buffer.from(
await readBoundedResponseBytes(response, {
maxBytes: context.transitFiles.maxBytes,
fieldName: "ElevenLabs history audio",
createError: (message) => new ProviderRequestError(413, message),
}),
);
const contentType = response.headers.get("content-type") ?? "audio/mpeg";
const extension = inferElevenlabsAudioExtension(contentType, "mp3_44100_128");
const name = `elevenlabs-history-${historyItemId}.${extension}`;
Expand Down Expand Up @@ -514,6 +548,7 @@ type ElevenlabsRequestInput = {
query?: Record<string, string | string[] | undefined>;
body?: Record<string, unknown>;
mode?: "validate" | "execute";
maxResponseBytes?: number;
};

async function requestElevenlabsJson<T>(
Expand All @@ -533,7 +568,7 @@ async function requestElevenlabsJson<T>(
throw await createElevenlabsError(response, input.mode ?? "execute");
}

return readElevenlabsJson<T>(response);
return readElevenlabsJson<T>(response, input.maxResponseBytes);
}

function buildElevenlabsUrl(
Expand Down Expand Up @@ -586,10 +621,21 @@ function elevenlabsBinaryJsonHeaders(apiKey: string) {
};
}

async function readElevenlabsJson<T>(response: Response) {
async function readElevenlabsJson<T>(response: Response, maxBytes?: number) {
try {
return (await response.json()) as T;
} catch {
if (maxBytes === undefined) {
return (await response.json()) as T;
}
const bytes = await readBoundedResponseBytes(response, {
maxBytes,
fieldName: "ElevenLabs JSON response",
createError: (message) => new ProviderRequestError(413, message),
});
return JSON.parse(new TextDecoder().decode(bytes)) as T;
} catch (error) {
if (error instanceof ProviderRequestError) {
throw error;
}
throw new ProviderRequestError(502, "elevenlabs returned invalid JSON");
}
}
Expand Down
9 changes: 7 additions & 2 deletions src/providers/feishu_app_bot/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { FeishuActionRuntimeContext } from "../feishu/shared/client.ts";

import { Buffer } from "node:buffer";
import { compactObject, optionalBoolean, optionalRecord, optionalString, requiredString } from "../../core/cast.ts";
import { assertPublicHttpUrl } from "../../core/request.ts";
import { assertPublicHttpUrl, readBoundedResponseBytes } from "../../core/request.ts";
import { createFeishuApplicationActionHandlers } from "../feishu/shared/application-runtime.ts";
import { createFeishuBaseAdvancedActionHandlers } from "../feishu/shared/base-advanced-runtime.ts";
import { createFeishuBaseActionHandlers } from "../feishu/shared/base-runtime.ts";
Expand Down Expand Up @@ -1142,8 +1142,13 @@ async function uploadFeishuMediaTransitFile(input: {

const mimeType = normalizeMimeType(response.headers.get("content-type")) ?? "application/octet-stream";
const fileName = input.preferredFileName ?? buildFeishuTransitFileName(input.idValue, mimeType);
const bytes = await readBoundedResponseBytes(response, {
maxBytes: input.context.transitFiles.maxBytes,
fieldName: `Feishu ${input.actionName} output`,
createError: (message) => new ProviderRequestError(413, message),
});
const upload = await input.context.transitFiles.create(
new File([await response.arrayBuffer()], fileName, { type: mimeType }),
new File([Uint8Array.from(bytes)], fileName, { type: mimeType }),
);

return {
Expand Down
12 changes: 9 additions & 3 deletions src/providers/fuxin/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
optionalString,
requiredString,
} from "../../core/cast.ts";
import { assertPublicHttpUrl } from "../../core/request.ts";
import { assertPublicHttpUrl, readBoundedResponseBytes } from "../../core/request.ts";
import {
defineProviderExecutors,
normalizeProviderProxyEndpoint,
Expand Down Expand Up @@ -354,13 +354,19 @@ async function fuxinDownloadFile(input: Record<string, unknown>, context: FuxinA
signal: context.signal,
});

const bytes = await response.arrayBuffer();
const bytes = await readBoundedResponseBytes(response, {
maxBytes: context.transitFiles.maxBytes,
fieldName: "Foxit file download",
createError: (message) => new ProviderRequestError(413, message),
});
const mimeType = normalizeMimeType(response.headers.get("content-type")) ?? fuxinBinaryMimeTypeFallback;
const resolvedFileName =
fileName ??
readDispositionFileName(response.headers.get("content-disposition")) ??
buildDefaultFileName("foxit-download", mimeType);
const upload = await context.transitFiles.create(new File([bytes], resolvedFileName, { type: mimeType }));
const upload = await context.transitFiles.create(
new File([Uint8Array.from(bytes)], resolvedFileName, { type: mimeType }),
);

return {
file: {
Expand Down
14 changes: 11 additions & 3 deletions src/providers/gemini/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ApiKeyProviderContext } from "../provider-runtime.ts";

import { compactObject, optionalInteger, optionalNumber, optionalRecord, optionalString } from "../../core/cast.ts";
import { jsonObject } from "../../core/request.ts";
import { jsonObject, readBoundedResponseBytes } from "../../core/request.ts";
import { providerUserAgent, ProviderRequestError } from "../provider-runtime.ts";

export const geminiApiBaseUrl = "https://generativelanguage.googleapis.com/v1beta";
Expand Down Expand Up @@ -642,8 +642,12 @@ async function fetchGemini(

async function downloadMediaBytes(
url: string,
context: Pick<GeminiRuntimeContext, "apiKey" | "fetcher" | "signal">,
context: Pick<GeminiRuntimeContext, "apiKey" | "fetcher" | "signal" | "transitFiles">,
): Promise<GeminiDownloadedMedia> {
if (!context.transitFiles) {
throw new ProviderRequestError(502, "gemini media actions require server-side file transit");
}

const headers: Record<string, string> = {
"user-agent": providerUserAgent,
};
Expand All @@ -667,7 +671,11 @@ async function downloadMediaBytes(
}

return {
bytes: new Uint8Array(await response.arrayBuffer()),
bytes: await readBoundedResponseBytes(response, {
maxBytes: context.transitFiles.maxBytes,
fieldName: "Gemini media download",
createError: (message) => new ProviderRequestError(413, message),
}),
mimeType: response.headers.get("content-type") ?? undefined,
};
}
Expand Down
8 changes: 6 additions & 2 deletions src/providers/gladia/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,12 @@ async function downloadTranscriptionAudio(
optionalString(input.fileName) ??
readContentDispositionFileName(response.headers.get("content-disposition")) ??
`gladia-${id}${extensionFromMimeType(mimeType)}`;
const body = await response.arrayBuffer();
const upload = await context.transitFiles.create(new File([body], name, { type: mimeType }));
const body = await readBoundedResponseBytes(response, {
maxBytes: context.transitFiles.maxBytes,
fieldName: "Gladia transcription audio",
createError: (message) => new ProviderRequestError(413, message),
});
const upload = await context.transitFiles.create(new File([Uint8Array.from(body)], name, { type: mimeType }));

return {
id,
Expand Down
8 changes: 7 additions & 1 deletion src/providers/googledrive/executors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CredentialValidators, ProviderExecutors } from "../../core/types.t
import type { OAuthProviderContext } from "../provider-runtime.ts";

import { randomUUID } from "node:crypto";
import { readBoundedResponseBytes } from "../../core/request.ts";
import { defineOAuthProviderExecutors, ProviderRequestError } from "../provider-runtime.ts";
import {
createComment,
Expand Down Expand Up @@ -507,7 +508,12 @@ async function exportFile(input: Record<string, unknown>, context: ActionContext
const mimeType = response.headers.get("content-type") ?? requestedMimeType;
const extension = extensionForExportMimeType(mimeType);
const name = `${fileId}${extension}`;
const upload = await context.transitFiles.create(new File([await response.arrayBuffer()], name, { type: mimeType }));
const bytes = await readBoundedResponseBytes(response, {
maxBytes: context.transitFiles.maxBytes,
fieldName: "Google Drive export",
createError: (message) => new ProviderRequestError(413, message),
});
const upload = await context.transitFiles.create(new File([Uint8Array.from(bytes)], name, { type: mimeType }));

return {
fileId,
Expand Down
10 changes: 8 additions & 2 deletions src/providers/klangio/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,14 +219,20 @@ async function downloadKlangioFile(input: {
throw createKlangioError(response, payload);
}

const bytes = await response.arrayBuffer();
const bytes = await readBoundedResponseBytes(response, {
maxBytes: input.context.transitFiles.maxBytes,
fieldName: `Klangio ${input.actionName} file`,
createError: (message) => new ProviderRequestError(413, message),
});
if (bytes.byteLength === 0) {
throw new ProviderRequestError(502, `Klangio ${input.actionName} response did not include file bytes`);
}

const contentType = normalizeMimeType(response.headers.get("content-type")) ?? input.fallbackMimeType;
const name = appendExtensionIfMissing(input.fileName, extensionForMimeType(contentType));
const upload = await input.context.transitFiles.create(new File([bytes], name, { type: contentType }));
const upload = await input.context.transitFiles.create(
new File([Uint8Array.from(bytes)], name, { type: contentType }),
);

return {
file: {
Expand Down
Loading
Loading