@arnilo/prism 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +32 -20
- package/dist/agent-loops.d.ts +8 -1
- package/dist/agent-loops.js +57 -11
- package/dist/agents.js +70 -17
- package/dist/checkpoints.d.ts +11 -0
- package/dist/checkpoints.js +144 -0
- package/dist/compaction.js +9 -1
- package/dist/content.d.ts +102 -0
- package/dist/content.js +410 -0
- package/dist/contracts.d.ts +142 -2
- package/dist/event-multiplexer.d.ts +23 -0
- package/dist/event-multiplexer.js +136 -0
- package/dist/execution-policy.d.ts +28 -0
- package/dist/execution-policy.js +24 -0
- package/dist/index.d.ts +17 -5
- package/dist/index.js +11 -4
- package/dist/input.js +11 -1
- package/dist/leases.d.ts +8 -0
- package/dist/leases.js +111 -0
- package/dist/node/agent-definitions.js +3 -5
- package/dist/node/config.d.ts +1 -0
- package/dist/node/config.js +5 -3
- package/dist/node/contribution-discovery.js +5 -8
- package/dist/node/session-store-jsonl.js +8 -5
- package/dist/node/settings.js +2 -2
- package/dist/node/trust.js +2 -4
- package/dist/observability.d.ts +3 -0
- package/dist/observability.js +18 -0
- package/dist/providers/media.d.ts +42 -0
- package/dist/providers/media.js +116 -0
- package/dist/providers/openai-compatible.js +18 -119
- package/dist/providers/openai-primitives.d.ts +9 -0
- package/dist/providers/openai-primitives.js +129 -0
- package/dist/providers/transport.d.ts +40 -0
- package/dist/providers/transport.js +221 -0
- package/dist/redaction.js +40 -13
- package/dist/resources.d.ts +5 -0
- package/dist/resources.js +4 -0
- package/dist/structured-output.d.ts +11 -0
- package/dist/structured-output.js +59 -0
- package/dist/testing/persistence-schema.d.ts +102 -0
- package/dist/testing/persistence-schema.js +457 -0
- package/dist/testing/provider-conformance.js +10 -1
- package/dist/testing/run-ledger-conformance.d.ts +33 -0
- package/dist/testing/run-ledger-conformance.js +172 -0
- package/dist/testing/session-store-conformance.d.ts +16 -0
- package/dist/testing/session-store-conformance.js +73 -0
- package/dist/tools.d.ts +17 -0
- package/dist/tools.js +29 -2
- package/docs/agent-events.md +13 -4
- package/docs/agent-loops.md +10 -4
- package/docs/agent-session-runtime.md +1 -0
- package/docs/cli-rpc.md +3 -0
- package/docs/coding-agent-tools.md +242 -0
- package/docs/coding-security.md +84 -0
- package/docs/credential-storage.md +177 -0
- package/docs/credentials-and-redaction.md +2 -1
- package/docs/database-persistence.md +44 -2
- package/docs/host-security.md +15 -1
- package/docs/index.md +28 -11
- package/docs/input-and-prompt-assembly.md +6 -5
- package/docs/mcp-tools.md +139 -0
- package/docs/middleware-hooks.md +2 -0
- package/docs/migration.md +21 -28
- package/docs/model-registry.md +5 -3
- package/docs/multimodal-content.md +148 -0
- package/docs/observability.md +163 -0
- package/docs/performance.md +40 -1
- package/docs/persistence-credentials-multimodality-primitives.md +303 -0
- package/docs/postgres-persistence.md +141 -0
- package/docs/provider-conformance.md +17 -0
- package/docs/provider-layer.md +1 -1
- package/docs/provider-primitives.md +281 -0
- package/docs/providers/kimi.md +1 -0
- package/docs/providers/neuralwatt.md +1 -0
- package/docs/providers/openai-compatible.md +2 -1
- package/docs/providers/openai.md +8 -1
- package/docs/providers/opencode-go.md +1 -0
- package/docs/providers/openrouter.md +1 -0
- package/docs/providers/zai.md +1 -0
- package/docs/public-contracts.md +9 -2
- package/docs/release-and-install.md +209 -23
- package/docs/resource-loading.md +14 -4
- package/docs/review-coverage-2026-07-14.md +260 -0
- package/docs/run-ledger-conformance.md +96 -0
- package/docs/runs-and-usage.md +2 -0
- package/docs/session-store-conformance.md +16 -0
- package/docs/session-stores-and-branching.md +1 -0
- package/docs/settings-auth-trust-security.md +2 -1
- package/docs/sqlite-persistence.md +122 -0
- package/docs/structured-output.md +9 -0
- package/docs/tool-conformance.md +1 -0
- package/docs/tool-execution-primitives.md +374 -0
- package/docs/tools.md +40 -1
- package/docs/workflow-orchestration-primitives.md +565 -0
- package/docs/workflow-tui-primitives.md +5 -0
- package/docs/workflows.md +219 -0
- package/package.json +34 -5
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ContentBlock, ImageContent, Message, ModelConfig, ResourceLoadContext, ResourceLoader } from "./contracts.js";
|
|
2
|
+
/** Known model input capability tags for `ModelCapabilities.input`. */
|
|
3
|
+
export declare const MODEL_INPUT_CAPABILITIES: readonly ["text", "image", "audio", "file", "document"];
|
|
4
|
+
export type ModelInputCapability = (typeof MODEL_INPUT_CAPABILITIES)[number];
|
|
5
|
+
/** Default per-item media byte ceiling (10 MB; aligns with coding-agent image bounds). */
|
|
6
|
+
export declare const DEFAULT_MAX_MEDIA_ITEM_BYTES = 10000000;
|
|
7
|
+
/** Default total media byte budget per request assembly. */
|
|
8
|
+
export declare const DEFAULT_MAX_MEDIA_REQUEST_BYTES: number;
|
|
9
|
+
/** Default decoded audio duration ceiling. */
|
|
10
|
+
export declare const DEFAULT_MAX_AUDIO_DURATION_MS: number;
|
|
11
|
+
/** Default URL fetch timeout for media resolution. */
|
|
12
|
+
export declare const DEFAULT_MEDIA_FETCH_TIMEOUT_MS = 30000;
|
|
13
|
+
/** Default maximum media-bearing blocks per request assembly. */
|
|
14
|
+
export declare const DEFAULT_MAX_MEDIA_ITEMS_PER_REQUEST = 32;
|
|
15
|
+
export interface AudioContent {
|
|
16
|
+
readonly type: "audio";
|
|
17
|
+
readonly mediaType: string;
|
|
18
|
+
readonly name?: string;
|
|
19
|
+
/** Base64-encoded audio bytes. */
|
|
20
|
+
readonly data?: string;
|
|
21
|
+
readonly url?: string;
|
|
22
|
+
readonly resourceUri?: string;
|
|
23
|
+
readonly durationMs?: number;
|
|
24
|
+
readonly transcript?: string;
|
|
25
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
26
|
+
}
|
|
27
|
+
export interface FileContent {
|
|
28
|
+
readonly type: "file";
|
|
29
|
+
readonly mediaType: string;
|
|
30
|
+
readonly name?: string;
|
|
31
|
+
/** Base64-encoded file bytes. */
|
|
32
|
+
readonly data?: string;
|
|
33
|
+
readonly url?: string;
|
|
34
|
+
readonly resourceUri?: string;
|
|
35
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
36
|
+
}
|
|
37
|
+
export interface DocumentContent {
|
|
38
|
+
readonly type: "document";
|
|
39
|
+
readonly mediaType: string;
|
|
40
|
+
readonly name?: string;
|
|
41
|
+
/** Base64-encoded document bytes. */
|
|
42
|
+
readonly data?: string;
|
|
43
|
+
readonly url?: string;
|
|
44
|
+
readonly resourceUri?: string;
|
|
45
|
+
readonly transcript?: string;
|
|
46
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
47
|
+
}
|
|
48
|
+
export type MediaContentBlock = AudioContent | FileContent | DocumentContent | ImageContent;
|
|
49
|
+
export interface MediaContentBounds {
|
|
50
|
+
readonly maxItemBytes?: number;
|
|
51
|
+
readonly maxRequestBytes?: number;
|
|
52
|
+
readonly maxItems?: number;
|
|
53
|
+
readonly maxAudioDurationMs?: number;
|
|
54
|
+
readonly fetchTimeoutMs?: number;
|
|
55
|
+
}
|
|
56
|
+
export interface SsrfPolicy {
|
|
57
|
+
/** When true (default), deny private/link-local/metadata hostnames and IPs. */
|
|
58
|
+
readonly denyPrivateHosts?: boolean;
|
|
59
|
+
/** Optional hostname allow-list. When set, only listed hosts are permitted. */
|
|
60
|
+
readonly allowedHostnames?: readonly string[];
|
|
61
|
+
}
|
|
62
|
+
export interface MediaMimePolicy {
|
|
63
|
+
/** Reject when magic bytes disagree with declared media type. Default `true`. */
|
|
64
|
+
readonly strictMagicValidation?: boolean;
|
|
65
|
+
}
|
|
66
|
+
export interface ResolveMediaContentOptions {
|
|
67
|
+
readonly bounds?: MediaContentBounds;
|
|
68
|
+
readonly ssrf?: SsrfPolicy;
|
|
69
|
+
readonly mime?: MediaMimePolicy;
|
|
70
|
+
readonly loader?: ResourceLoader;
|
|
71
|
+
readonly loadContext?: ResourceLoadContext;
|
|
72
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
73
|
+
readonly signal?: AbortSignal;
|
|
74
|
+
}
|
|
75
|
+
export interface ResolvedMediaContent {
|
|
76
|
+
readonly mediaType: string;
|
|
77
|
+
readonly bytes: Uint8Array;
|
|
78
|
+
readonly name?: string;
|
|
79
|
+
readonly durationMs?: number;
|
|
80
|
+
readonly transcript?: string;
|
|
81
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
82
|
+
}
|
|
83
|
+
export declare class UnsupportedModalityError extends Error {
|
|
84
|
+
readonly modality: ModelInputCapability;
|
|
85
|
+
readonly provider: string;
|
|
86
|
+
readonly model: string;
|
|
87
|
+
constructor(modality: ModelInputCapability, model: ModelConfig);
|
|
88
|
+
}
|
|
89
|
+
export declare class MediaContentError extends Error {
|
|
90
|
+
readonly code: "ambiguous_source" | "missing_source" | "item_too_large" | "request_too_large" | "too_many_items" | "audio_too_long" | "invalid_base64" | "ssrf_denied" | "fetch_failed" | "fetch_timeout" | "resource_required" | "mime_mismatch" | "unsupported_url_scheme";
|
|
91
|
+
constructor(code: MediaContentError["code"], message: string);
|
|
92
|
+
}
|
|
93
|
+
export declare function contentBlockInputModality(block: ContentBlock): ModelInputCapability | undefined;
|
|
94
|
+
export declare function collectMessageContentBlocks(messages: readonly Message[]): ContentBlock[];
|
|
95
|
+
export declare function assertModelSupportsContentBlocks(model: ModelConfig, blocks: readonly ContentBlock[]): void;
|
|
96
|
+
export declare function assertMessagesSupportModelCapabilities(model: ModelConfig, messages: readonly Message[]): void;
|
|
97
|
+
export declare function assertMediaBlocksWithinBounds(blocks: readonly MediaContentBlock[], bounds?: MediaContentBounds): void;
|
|
98
|
+
export declare function assertSsrfAllowedUrl(url: string, policy?: SsrfPolicy): void;
|
|
99
|
+
export declare function sniffMediaMimeType(bytes: Uint8Array): string | undefined;
|
|
100
|
+
export declare function assertDeclaredMediaTypeMatches(declaredMediaType: string, bytes: Uint8Array, policy?: MediaMimePolicy): void;
|
|
101
|
+
export declare function resolveMediaContentBlock(block: MediaContentBlock, options?: ResolveMediaContentOptions): Promise<ResolvedMediaContent>;
|
|
102
|
+
export declare function loadBoundedBinaryResource(loader: ResourceLoader, uri: string, context: ResourceLoadContext | undefined, maxBytes: number, signal?: AbortSignal): Promise<Uint8Array>;
|
package/dist/content.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { assertPermission } from "./security.js";
|
|
2
|
+
/** Known model input capability tags for `ModelCapabilities.input`. */
|
|
3
|
+
export const MODEL_INPUT_CAPABILITIES = ["text", "image", "audio", "file", "document"];
|
|
4
|
+
/** Default per-item media byte ceiling (10 MB; aligns with coding-agent image bounds). */
|
|
5
|
+
export const DEFAULT_MAX_MEDIA_ITEM_BYTES = 10_000_000;
|
|
6
|
+
/** Default total media byte budget per request assembly. */
|
|
7
|
+
export const DEFAULT_MAX_MEDIA_REQUEST_BYTES = 32 * 1024 * 1024;
|
|
8
|
+
/** Default decoded audio duration ceiling. */
|
|
9
|
+
export const DEFAULT_MAX_AUDIO_DURATION_MS = 5 * 60 * 1000;
|
|
10
|
+
/** Default URL fetch timeout for media resolution. */
|
|
11
|
+
export const DEFAULT_MEDIA_FETCH_TIMEOUT_MS = 30_000;
|
|
12
|
+
/** Default maximum media-bearing blocks per request assembly. */
|
|
13
|
+
export const DEFAULT_MAX_MEDIA_ITEMS_PER_REQUEST = 32;
|
|
14
|
+
export class UnsupportedModalityError extends Error {
|
|
15
|
+
modality;
|
|
16
|
+
provider;
|
|
17
|
+
model;
|
|
18
|
+
constructor(modality, model) {
|
|
19
|
+
super(`Model ${model.provider}/${model.model} does not support ${modality} input`);
|
|
20
|
+
this.name = "UnsupportedModalityError";
|
|
21
|
+
this.modality = modality;
|
|
22
|
+
this.provider = model.provider;
|
|
23
|
+
this.model = model.model;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class MediaContentError extends Error {
|
|
27
|
+
code;
|
|
28
|
+
constructor(code, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "MediaContentError";
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function contentBlockInputModality(block) {
|
|
35
|
+
switch (block.type) {
|
|
36
|
+
case "image":
|
|
37
|
+
return "image";
|
|
38
|
+
case "audio":
|
|
39
|
+
return "audio";
|
|
40
|
+
case "file":
|
|
41
|
+
return "file";
|
|
42
|
+
case "document":
|
|
43
|
+
return "document";
|
|
44
|
+
default:
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function collectMessageContentBlocks(messages) {
|
|
49
|
+
return messages.flatMap((message) => [...message.content]);
|
|
50
|
+
}
|
|
51
|
+
export function assertModelSupportsContentBlocks(model, blocks) {
|
|
52
|
+
const supported = model.capabilities?.input;
|
|
53
|
+
if (!supported?.length)
|
|
54
|
+
return;
|
|
55
|
+
for (const block of blocks) {
|
|
56
|
+
const modality = contentBlockInputModality(block);
|
|
57
|
+
if (modality && !supported.includes(modality)) {
|
|
58
|
+
throw new UnsupportedModalityError(modality, model);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function assertMessagesSupportModelCapabilities(model, messages) {
|
|
63
|
+
assertModelSupportsContentBlocks(model, collectMessageContentBlocks(messages));
|
|
64
|
+
}
|
|
65
|
+
export function assertMediaBlocksWithinBounds(blocks, bounds = {}) {
|
|
66
|
+
const maxItems = bounds.maxItems ?? DEFAULT_MAX_MEDIA_ITEMS_PER_REQUEST;
|
|
67
|
+
const maxItemBytes = bounds.maxItemBytes ?? DEFAULT_MAX_MEDIA_ITEM_BYTES;
|
|
68
|
+
const maxRequestBytes = bounds.maxRequestBytes ?? DEFAULT_MAX_MEDIA_REQUEST_BYTES;
|
|
69
|
+
const maxAudioDurationMs = bounds.maxAudioDurationMs ?? DEFAULT_MAX_AUDIO_DURATION_MS;
|
|
70
|
+
if (blocks.length > maxItems) {
|
|
71
|
+
throw new MediaContentError("too_many_items", `Media item count ${blocks.length} exceeded ${maxItems}`);
|
|
72
|
+
}
|
|
73
|
+
let requestBytes = 0;
|
|
74
|
+
for (const block of blocks) {
|
|
75
|
+
const inlineBytes = estimateInlineMediaBytes(block);
|
|
76
|
+
if (inlineBytes > maxItemBytes) {
|
|
77
|
+
throw new MediaContentError("item_too_large", `Media item exceeded ${maxItemBytes} bytes`);
|
|
78
|
+
}
|
|
79
|
+
requestBytes += inlineBytes;
|
|
80
|
+
if (requestBytes > maxRequestBytes) {
|
|
81
|
+
throw new MediaContentError("request_too_large", `Media request budget exceeded ${maxRequestBytes} bytes`);
|
|
82
|
+
}
|
|
83
|
+
if (block.type === "audio" && block.durationMs !== undefined && block.durationMs > maxAudioDurationMs) {
|
|
84
|
+
throw new MediaContentError("audio_too_long", `Audio duration ${block.durationMs}ms exceeded ${maxAudioDurationMs}ms`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function assertSsrfAllowedUrl(url, policy = {}) {
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = new URL(url);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new MediaContentError("ssrf_denied", "Media URL is not a valid absolute URL");
|
|
95
|
+
}
|
|
96
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
97
|
+
throw new MediaContentError("unsupported_url_scheme", `Media URL scheme ${parsed.protocol} is not allowed`);
|
|
98
|
+
}
|
|
99
|
+
if (parsed.username || parsed.password) {
|
|
100
|
+
throw new MediaContentError("ssrf_denied", "Media URL must not embed credentials");
|
|
101
|
+
}
|
|
102
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
103
|
+
if (policy.allowedHostnames?.length) {
|
|
104
|
+
if (!policy.allowedHostnames.some((allowed) => hostname === allowed.toLowerCase())) {
|
|
105
|
+
throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allow-listed`);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (policy.denyPrivateHosts === false)
|
|
110
|
+
return;
|
|
111
|
+
if (hostname === "localhost"
|
|
112
|
+
|| hostname.endsWith(".localhost")
|
|
113
|
+
|| hostname.endsWith(".local")
|
|
114
|
+
|| hostname === "metadata"
|
|
115
|
+
|| hostname === "metadata.google.internal"
|
|
116
|
+
|| hostname === "instance-data") {
|
|
117
|
+
throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allowed`);
|
|
118
|
+
}
|
|
119
|
+
if (isBlockedIp(hostname)) {
|
|
120
|
+
throw new MediaContentError("ssrf_denied", `Media URL host ${hostname} is not allowed`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function sniffMediaMimeType(bytes) {
|
|
124
|
+
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d]))
|
|
125
|
+
return "application/pdf";
|
|
126
|
+
if (startsWithAscii(bytes, 0, "%PDF"))
|
|
127
|
+
return "application/pdf";
|
|
128
|
+
if (startsWithAscii(bytes, 0, "RIFF") && startsWithAscii(bytes, 8, "WAVE"))
|
|
129
|
+
return "audio/wav";
|
|
130
|
+
if (startsWithAscii(bytes, 0, "RIFF") && startsWithAscii(bytes, 8, "WEBP"))
|
|
131
|
+
return "image/webp";
|
|
132
|
+
if (startsWithAscii(bytes, 0, "OggS"))
|
|
133
|
+
return "audio/ogg";
|
|
134
|
+
if (startsWithAscii(bytes, 0, "fLaC"))
|
|
135
|
+
return "audio/flac";
|
|
136
|
+
if (startsWithAscii(bytes, 0, "ID3"))
|
|
137
|
+
return "audio/mpeg";
|
|
138
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0)
|
|
139
|
+
return "audio/mpeg";
|
|
140
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
141
|
+
return "image/png";
|
|
142
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff]))
|
|
143
|
+
return "image/jpeg";
|
|
144
|
+
if (startsWithAscii(bytes, 0, "GIF8"))
|
|
145
|
+
return "image/gif";
|
|
146
|
+
if (startsWithAscii(bytes, 0, "PK\u0003\u0004"))
|
|
147
|
+
return "application/zip";
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
export function assertDeclaredMediaTypeMatches(declaredMediaType, bytes, policy = {}) {
|
|
151
|
+
if (policy.strictMagicValidation === false)
|
|
152
|
+
return;
|
|
153
|
+
const sniffed = sniffMediaMimeType(bytes);
|
|
154
|
+
if (!sniffed)
|
|
155
|
+
return;
|
|
156
|
+
if (!mediaTypesCompatible(declaredMediaType, sniffed)) {
|
|
157
|
+
throw new MediaContentError("mime_mismatch", `Declared media type ${declaredMediaType} does not match sniffed type ${sniffed}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export async function resolveMediaContentBlock(block, options = {}) {
|
|
161
|
+
const bounds = options.bounds ?? {};
|
|
162
|
+
const maxItemBytes = bounds.maxItemBytes ?? DEFAULT_MAX_MEDIA_ITEM_BYTES;
|
|
163
|
+
const maxAudioDurationMs = bounds.maxAudioDurationMs ?? DEFAULT_MAX_AUDIO_DURATION_MS;
|
|
164
|
+
assertMediaBlocksWithinBounds([block], bounds);
|
|
165
|
+
const source = mediaSourceKind(block);
|
|
166
|
+
let bytes;
|
|
167
|
+
let mediaType = declaredMediaType(block);
|
|
168
|
+
if (source === "data") {
|
|
169
|
+
bytes = decodeBase64Bounded(readDataField(block), maxItemBytes);
|
|
170
|
+
}
|
|
171
|
+
else if (source === "resource") {
|
|
172
|
+
if (!options.loader) {
|
|
173
|
+
throw new MediaContentError("resource_required", "ResourceLoader is required to resolve resourceUri media");
|
|
174
|
+
}
|
|
175
|
+
bytes = await loadBoundedBinaryResource(options.loader, readResourceUri(block), options.loadContext, maxItemBytes, options.signal);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
bytes = await fetchBoundedMediaUrl(readUrlField(block), {
|
|
179
|
+
maxBytes: maxItemBytes,
|
|
180
|
+
timeoutMs: bounds.fetchTimeoutMs ?? DEFAULT_MEDIA_FETCH_TIMEOUT_MS,
|
|
181
|
+
ssrf: options.ssrf,
|
|
182
|
+
fetch: options.fetch,
|
|
183
|
+
signal: options.signal,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
assertDeclaredMediaTypeMatches(mediaType, bytes, options.mime);
|
|
187
|
+
const sniffed = sniffMediaMimeType(bytes);
|
|
188
|
+
if (sniffed && (mediaType === "application/octet-stream" || !mediaType)) {
|
|
189
|
+
mediaType = sniffed;
|
|
190
|
+
}
|
|
191
|
+
const durationMs = block.type === "audio" ? block.durationMs : undefined;
|
|
192
|
+
if (durationMs !== undefined && durationMs > maxAudioDurationMs) {
|
|
193
|
+
throw new MediaContentError("audio_too_long", `Audio duration ${durationMs}ms exceeded ${maxAudioDurationMs}ms`);
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
mediaType,
|
|
197
|
+
bytes,
|
|
198
|
+
name: readName(block),
|
|
199
|
+
durationMs,
|
|
200
|
+
transcript: readTranscript(block),
|
|
201
|
+
metadata: "metadata" in block ? block.metadata : undefined,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
export async function loadBoundedBinaryResource(loader, uri, context, maxBytes, signal) {
|
|
205
|
+
await assertPermission(context?.permission, {
|
|
206
|
+
kind: "resource",
|
|
207
|
+
action: "load",
|
|
208
|
+
target: uri,
|
|
209
|
+
metadata: context?.metadata,
|
|
210
|
+
});
|
|
211
|
+
signal?.throwIfAborted();
|
|
212
|
+
const resource = await loader.load(uri, { ...context, signal });
|
|
213
|
+
const bytes = resource.data ?? (resource.text !== undefined ? new TextEncoder().encode(resource.text) : undefined);
|
|
214
|
+
if (!bytes)
|
|
215
|
+
throw new MediaContentError("missing_source", `Resource ${uri} has no data or text`);
|
|
216
|
+
if (bytes.byteLength > maxBytes) {
|
|
217
|
+
throw new MediaContentError("item_too_large", `Resource ${uri} exceeded ${maxBytes} bytes`);
|
|
218
|
+
}
|
|
219
|
+
return bytes;
|
|
220
|
+
}
|
|
221
|
+
function mediaSourceKind(block) {
|
|
222
|
+
const sources = [
|
|
223
|
+
readDataField(block) ? "data" : undefined,
|
|
224
|
+
readResourceUri(block) ? "resource" : undefined,
|
|
225
|
+
readUrlField(block) ? "url" : undefined,
|
|
226
|
+
].filter((value) => value !== undefined);
|
|
227
|
+
if (sources.length === 0) {
|
|
228
|
+
throw new MediaContentError("missing_source", `Media block ${block.type} requires data, url, or resourceUri`);
|
|
229
|
+
}
|
|
230
|
+
if (sources.length > 1) {
|
|
231
|
+
throw new MediaContentError("ambiguous_source", `Media block ${block.type} must provide only one of data, url, or resourceUri`);
|
|
232
|
+
}
|
|
233
|
+
return sources[0];
|
|
234
|
+
}
|
|
235
|
+
function readDataField(block) {
|
|
236
|
+
return "data" in block ? block.data : undefined;
|
|
237
|
+
}
|
|
238
|
+
function readUrlField(block) {
|
|
239
|
+
return "url" in block ? block.url : undefined;
|
|
240
|
+
}
|
|
241
|
+
function readResourceUri(block) {
|
|
242
|
+
return "resourceUri" in block ? block.resourceUri : undefined;
|
|
243
|
+
}
|
|
244
|
+
function readName(block) {
|
|
245
|
+
return "name" in block ? block.name : undefined;
|
|
246
|
+
}
|
|
247
|
+
function readTranscript(block) {
|
|
248
|
+
return "transcript" in block ? block.transcript : undefined;
|
|
249
|
+
}
|
|
250
|
+
function declaredMediaType(block) {
|
|
251
|
+
if ("mediaType" in block && block.mediaType)
|
|
252
|
+
return block.mediaType;
|
|
253
|
+
if (block.type === "image" && block.mimeType)
|
|
254
|
+
return block.mimeType;
|
|
255
|
+
return "application/octet-stream";
|
|
256
|
+
}
|
|
257
|
+
function estimateInlineMediaBytes(block) {
|
|
258
|
+
const data = readDataField(block);
|
|
259
|
+
if (data)
|
|
260
|
+
return estimateBase64DecodedBytes(data);
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
function estimateBase64DecodedBytes(data) {
|
|
264
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
265
|
+
return Math.max(0, Math.floor((data.length * 3) / 4) - padding);
|
|
266
|
+
}
|
|
267
|
+
function decodeBase64Bounded(data, maxBytes) {
|
|
268
|
+
const estimated = estimateBase64DecodedBytes(data);
|
|
269
|
+
if (estimated > maxBytes) {
|
|
270
|
+
throw new MediaContentError("item_too_large", `Inline media exceeded ${maxBytes} bytes`);
|
|
271
|
+
}
|
|
272
|
+
let bytes;
|
|
273
|
+
try {
|
|
274
|
+
bytes = Uint8Array.from(atob(data), (char) => char.charCodeAt(0));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
throw new MediaContentError("invalid_base64", "Media data is not valid base64");
|
|
278
|
+
}
|
|
279
|
+
if (bytes.byteLength > maxBytes) {
|
|
280
|
+
throw new MediaContentError("item_too_large", `Inline media exceeded ${maxBytes} bytes`);
|
|
281
|
+
}
|
|
282
|
+
return bytes;
|
|
283
|
+
}
|
|
284
|
+
async function fetchBoundedMediaUrl(url, options) {
|
|
285
|
+
assertSsrfAllowedUrl(url, options.ssrf);
|
|
286
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
287
|
+
if (!fetchFn) {
|
|
288
|
+
throw new MediaContentError("fetch_failed", "No fetch implementation available for media URL resolution");
|
|
289
|
+
}
|
|
290
|
+
const timeoutController = new AbortController();
|
|
291
|
+
const timeout = setTimeout(() => timeoutController.abort(), options.timeoutMs);
|
|
292
|
+
const signal = options.signal
|
|
293
|
+
? AbortSignal.any([options.signal, timeoutController.signal])
|
|
294
|
+
: timeoutController.signal;
|
|
295
|
+
try {
|
|
296
|
+
const response = await fetchFn(url, { signal, redirect: "error" });
|
|
297
|
+
if (!response.ok) {
|
|
298
|
+
throw new MediaContentError("fetch_failed", `Media fetch failed with status ${response.status}`);
|
|
299
|
+
}
|
|
300
|
+
const reader = response.body?.getReader();
|
|
301
|
+
if (!reader) {
|
|
302
|
+
throw new MediaContentError("fetch_failed", "Media fetch returned no response body");
|
|
303
|
+
}
|
|
304
|
+
const chunks = [];
|
|
305
|
+
let total = 0;
|
|
306
|
+
try {
|
|
307
|
+
while (true) {
|
|
308
|
+
const { done, value } = await reader.read();
|
|
309
|
+
if (done)
|
|
310
|
+
break;
|
|
311
|
+
if (!value)
|
|
312
|
+
continue;
|
|
313
|
+
total += value.byteLength;
|
|
314
|
+
if (total > options.maxBytes) {
|
|
315
|
+
throw new MediaContentError("item_too_large", `Fetched media exceeded ${options.maxBytes} bytes`);
|
|
316
|
+
}
|
|
317
|
+
chunks.push(value);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
try {
|
|
322
|
+
await reader.cancel();
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// Reader may already be closed after abort/complete.
|
|
326
|
+
}
|
|
327
|
+
reader.releaseLock();
|
|
328
|
+
}
|
|
329
|
+
const bytes = new Uint8Array(total);
|
|
330
|
+
let offset = 0;
|
|
331
|
+
for (const chunk of chunks) {
|
|
332
|
+
bytes.set(chunk, offset);
|
|
333
|
+
offset += chunk.byteLength;
|
|
334
|
+
}
|
|
335
|
+
return bytes;
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error instanceof MediaContentError)
|
|
339
|
+
throw error;
|
|
340
|
+
if (timeoutController.signal.aborted) {
|
|
341
|
+
throw new MediaContentError("fetch_timeout", `Media fetch timed out after ${options.timeoutMs}ms`);
|
|
342
|
+
}
|
|
343
|
+
if (options.signal?.aborted)
|
|
344
|
+
throw options.signal.reason ?? new Error("Media fetch aborted");
|
|
345
|
+
throw new MediaContentError("fetch_failed", error instanceof Error ? error.message : "Media fetch failed");
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
clearTimeout(timeout);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function mediaTypesCompatible(declared, sniffed) {
|
|
352
|
+
const normalizedDeclared = declared.toLowerCase();
|
|
353
|
+
const normalizedSniffed = sniffed.toLowerCase();
|
|
354
|
+
if (normalizedDeclared === normalizedSniffed)
|
|
355
|
+
return true;
|
|
356
|
+
if (normalizedDeclared === "application/octet-stream")
|
|
357
|
+
return true;
|
|
358
|
+
if (normalizedDeclared.startsWith("text/") && normalizedSniffed.startsWith("text/"))
|
|
359
|
+
return true;
|
|
360
|
+
if (normalizedDeclared === "audio/mpeg" && normalizedSniffed === "audio/mpeg")
|
|
361
|
+
return true;
|
|
362
|
+
if (normalizedDeclared === "audio/mp3" && normalizedSniffed === "audio/mpeg")
|
|
363
|
+
return true;
|
|
364
|
+
if (normalizedDeclared === "application/zip" && normalizedSniffed === "application/zip")
|
|
365
|
+
return true;
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
function isBlockedIp(hostname) {
|
|
369
|
+
if (hostname.includes(":"))
|
|
370
|
+
return isBlockedIpv6(hostname);
|
|
371
|
+
const parts = hostname.split(".").map((part) => Number(part));
|
|
372
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
const [a, b] = parts;
|
|
376
|
+
if (a === 127 || a === 10 || a === 0)
|
|
377
|
+
return true;
|
|
378
|
+
if (a === 169 && b === 254)
|
|
379
|
+
return true;
|
|
380
|
+
if (a === 192 && b === 168)
|
|
381
|
+
return true;
|
|
382
|
+
if (a === 172 && b !== undefined && b >= 16 && b <= 31)
|
|
383
|
+
return true;
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
function isBlockedIpv6(hostname) {
|
|
387
|
+
const normalized = hostname.toLowerCase();
|
|
388
|
+
if (normalized === "::1")
|
|
389
|
+
return true;
|
|
390
|
+
if (normalized.startsWith("fc") || normalized.startsWith("fd"))
|
|
391
|
+
return true;
|
|
392
|
+
if (normalized.startsWith("fe80:"))
|
|
393
|
+
return true;
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
function startsWith(bytes, prefix) {
|
|
397
|
+
if (bytes.length < prefix.length)
|
|
398
|
+
return false;
|
|
399
|
+
return prefix.every((value, index) => bytes[index] === value);
|
|
400
|
+
}
|
|
401
|
+
function startsWithAscii(bytes, offset, text) {
|
|
402
|
+
if (bytes.length < offset + text.length)
|
|
403
|
+
return false;
|
|
404
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
405
|
+
if (bytes[offset + index] !== text.charCodeAt(index))
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
//# sourceMappingURL=content.js.map
|