@opencode-ai/ai
Advanced tools
@@ -16,3 +16,2 @@ import { Effect, Schema } from "effect"; | ||
| const ADAPTER = "anthropic-messages"; | ||
| const MEDIA_MIMES = new Set([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES]); | ||
| export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"; | ||
@@ -297,3 +296,3 @@ export const PATH = "/messages"; | ||
| const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part) { | ||
| const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES); | ||
| const media = ProviderShared.normalizeMedia(part); | ||
| if (media.mime === "application/pdf") | ||
@@ -308,2 +307,4 @@ return { | ||
| }; | ||
| if (!media.mime.startsWith("image/")) | ||
| return yield* invalid(`Anthropic Messages does not support media type ${part.mediaType}`); | ||
| return { | ||
@@ -310,0 +311,0 @@ type: "image", |
@@ -14,3 +14,2 @@ import { Effect, Schema } from "effect"; | ||
| const ADAPTER = "gemini"; | ||
| const MEDIA_MIMES = new Set(ProviderShared.MEDIA_MIMES); | ||
| // Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost. | ||
@@ -173,3 +172,3 @@ const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"; | ||
| return { text: part.text }; | ||
| const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES); | ||
| const media = ProviderShared.normalizeMedia(part); | ||
| return { inlineData: { mimeType: media.mime, data: media.base64 } }; | ||
@@ -272,3 +271,3 @@ }); | ||
| continue; | ||
| const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES); | ||
| const value = ProviderShared.normalizeToolFile(item); | ||
| media.push({ inlineData: { mimeType: value.mime, data: value.base64 } }); | ||
@@ -275,0 +274,0 @@ } |
@@ -369,3 +369,3 @@ import { Effect, Schema } from "effect"; | ||
| readonly part: MediaPart; | ||
| readonly media: ProviderShared.ValidatedMedia; | ||
| readonly media: ProviderShared.NormalizedMedia; | ||
| readonly request: LLMRequest; | ||
@@ -372,0 +372,0 @@ }) => MediaInput | undefined; |
@@ -13,3 +13,2 @@ import { Effect, Schema } from "effect"; | ||
| const NAME = "Open Responses"; | ||
| const MEDIA_MIMES = new Set([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES]); | ||
| export const PATH = "/responses"; | ||
@@ -247,10 +246,10 @@ // ============================================================================= | ||
| const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (part, request, extension) { | ||
| const media = yield* ProviderShared.validateMedia(extension.name, part, MEDIA_MIMES); | ||
| const media = ProviderShared.normalizeMedia(part); | ||
| const extended = extension.lowerMedia?.({ part, media, request }); | ||
| if (extended) | ||
| return extended; | ||
| if (media.mime === "application/pdf") { | ||
| if (!media.mime.startsWith("image/")) { | ||
| return { | ||
| type: "input_file", | ||
| filename: part.filename ?? "document.pdf", | ||
| filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"), | ||
| file_data: media.dataUrl, | ||
@@ -257,0 +256,0 @@ }; |
@@ -16,3 +16,2 @@ import { Effect, Schema } from "effect"; | ||
| const ADAPTER = "openai-chat"; | ||
| const IMAGE_MIMES = new Set(ProviderShared.IMAGE_MIMES); | ||
| const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"]); | ||
@@ -193,3 +192,5 @@ export const DEFAULT_BASE_URL = "https://api.openai.com/v1"; | ||
| const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part) { | ||
| const media = yield* ProviderShared.validateMedia("OpenAI Chat", part, IMAGE_MIMES); | ||
| const media = ProviderShared.normalizeMedia(part); | ||
| if (!media.mime.startsWith("image/")) | ||
| return yield* ProviderShared.invalidRequest(`OpenAI Chat does not support media type ${part.mediaType}`); | ||
| return { type: "image_url", image_url: { url: media.dataUrl } }; | ||
@@ -196,0 +197,0 @@ }); |
@@ -1,6 +0,5 @@ | ||
| import { Buffer } from "node:buffer"; | ||
| import { Tool } from "@opencode-ai/schema/tool"; | ||
| import { Effect, Schema, Stream } from "effect"; | ||
| import { Headers, HttpClientRequest } from "effect/unstable/http"; | ||
| import { AIError, type ContentPart, type LLMRequest, type ToolResultPart } from "../schema/index.js"; | ||
| import { AIError, type ContentPart, type LLMRequest, type MediaPart, type ToolResultPart } from "../schema/index.js"; | ||
| import { isRecord } from "../utils/record.js"; | ||
@@ -109,35 +108,9 @@ export { isRecord }; | ||
| export declare const parseToolInput: (route: string, name: string, raw: string) => Effect.Effect<unknown, AIError, never>; | ||
| export declare const IMAGE_MIMES: readonly ["image/png", "image/jpeg", "image/gif", "image/webp"]; | ||
| export declare const VIDEO_MIMES: readonly ["video/mp4", "video/webm", "video/quicktime"]; | ||
| export declare const AUDIO_MIMES: readonly ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"]; | ||
| export declare const PDF_MIMES: readonly ["application/pdf"]; | ||
| export declare const MEDIA_MIMES: readonly ["image/png", "image/jpeg", "image/gif", "image/webp", "video/mp4", "video/webm", "video/quicktime", "audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac", "application/pdf"]; | ||
| export declare const MAX_MEDIA_ENCODED_BYTES: number; | ||
| export declare const MAX_MEDIA_DECODED_BYTES: number; | ||
| export interface ValidatedMedia { | ||
| export interface NormalizedMedia { | ||
| readonly mime: string; | ||
| readonly base64: string; | ||
| readonly dataUrl: string; | ||
| readonly bytes: Uint8Array; | ||
| } | ||
| export declare const validateMedia: (route: string, part: { | ||
| readonly data: string | Uint8Array<ArrayBufferLike>; | ||
| readonly type: "media"; | ||
| readonly mediaType: string; | ||
| readonly metadata?: { | ||
| readonly [x: string]: unknown; | ||
| } | undefined; | ||
| readonly filename?: string | undefined; | ||
| }, supportedMimes: ReadonlySet<string>) => Effect.Effect<{ | ||
| mime: string; | ||
| base64: string; | ||
| dataUrl: string; | ||
| bytes: Buffer<ArrayBuffer>; | ||
| }, AIError, never>; | ||
| export declare const validateToolFile: (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) => Effect.Effect<{ | ||
| mime: string; | ||
| base64: string; | ||
| dataUrl: string; | ||
| bytes: Buffer<ArrayBuffer>; | ||
| }, AIError, never>; | ||
| export declare const normalizeMedia: (part: MediaPart) => NormalizedMedia; | ||
| export declare const normalizeToolFile: (part: Tool.FileContent) => NormalizedMedia; | ||
| export declare const trimBaseUrl: (value: string) => string; | ||
@@ -144,0 +117,0 @@ export declare const toolResultText: (part: ToolResultPart) => string; |
@@ -116,43 +116,13 @@ import { Buffer } from "node:buffer"; | ||
| export const parseToolInput = (route, name, raw) => parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`); | ||
| export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; | ||
| export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"]; | ||
| export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"]; | ||
| export const PDF_MIMES = ["application/pdf"]; | ||
| export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES]; | ||
| export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024; | ||
| export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024; | ||
| const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; | ||
| export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function* (route, part, supportedMimes) { | ||
| export const normalizeMedia = (part) => { | ||
| const mime = part.mediaType.toLowerCase(); | ||
| if (!supportedMimes.has(mime)) | ||
| return yield* invalidRequest(`${route} does not support media type ${part.mediaType}`); | ||
| let base64; | ||
| if (typeof part.data !== "string") { | ||
| if (part.data.byteLength > MAX_MEDIA_DECODED_BYTES) | ||
| return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`); | ||
| base64 = Buffer.from(part.data).toString("base64"); | ||
| const base64 = Buffer.from(part.data).toString("base64"); | ||
| return { mime, base64, dataUrl: `data:${mime};base64,${base64}` }; | ||
| } | ||
| else if (part.data.startsWith("data:")) { | ||
| const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/s.exec(part.data); | ||
| if (!match) | ||
| return yield* invalidRequest(`${route} media data URL must contain valid base64`); | ||
| if (match[1].toLowerCase() !== mime) | ||
| return yield* invalidRequest(`${route} media type ${part.mediaType} does not match data URL type ${match[1]}`); | ||
| base64 = match[2]; | ||
| } | ||
| else { | ||
| base64 = part.data; | ||
| } | ||
| if (Buffer.byteLength(base64, "utf8") > MAX_MEDIA_ENCODED_BYTES) | ||
| return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_ENCODED_BYTES} byte encoded limit`); | ||
| if (!base64 || base64.length % 4 !== 0 || !base64Pattern.test(base64)) | ||
| return yield* invalidRequest(`${route} media must contain valid base64`); | ||
| const bytes = Buffer.from(base64, "base64"); | ||
| if (bytes.byteLength > MAX_MEDIA_DECODED_BYTES) | ||
| return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`); | ||
| if (bytes.toString("base64") !== base64) | ||
| return yield* invalidRequest(`${route} media must contain canonical base64`); | ||
| return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes }; | ||
| }); | ||
| export const validateToolFile = (route, part, supportedMimes) => validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes); | ||
| if (!part.data.startsWith("data:")) | ||
| return { mime, base64: part.data, dataUrl: `data:${mime};base64,${part.data}` }; | ||
| return { mime, base64: part.data.slice(part.data.indexOf(",") + 1), dataUrl: part.data }; | ||
| }; | ||
| export const normalizeToolFile = (part) => normalizeMedia({ type: "media", mediaType: part.mime, data: part.uri, filename: part.name }); | ||
| export const trimBaseUrl = (value) => value.replace(/\/+$/, ""); | ||
@@ -159,0 +129,0 @@ export const toolResultText = (part) => { |
@@ -56,3 +56,3 @@ import { Effect, Schema } from "effect"; | ||
| if (imageFormat) { | ||
| const media = yield* ProviderShared.validateMedia("Bedrock Converse", part, new Set(Object.keys(IMAGE_FORMATS))); | ||
| const media = ProviderShared.normalizeMedia(part); | ||
| return { image: { format: imageFormat, source: { bytes: media.base64 } } }; | ||
@@ -66,3 +66,3 @@ } | ||
| return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename"); | ||
| const media = yield* ProviderShared.validateMedia("Bedrock Converse", part, new Set(Object.keys(DOCUMENT_FORMATS))); | ||
| const media = ProviderShared.normalizeMedia(part); | ||
| return documentBlock(part.filename, documentFormat, media.base64); | ||
@@ -69,0 +69,0 @@ } |
+3
-3
| { | ||
| "$schema": "https://json.schemastore.org/package.json", | ||
| "version": "0.0.0-dev-17491", | ||
| "version": "0.0.0-dev-17503", | ||
| "name": "@opencode-ai/ai", | ||
@@ -33,3 +33,3 @@ "type": "module", | ||
| "@effect/platform-node": "4.0.0-beta.101", | ||
| "@opencode-ai/http-recorder": "0.0.0-dev-17491", | ||
| "@opencode-ai/http-recorder": "0.0.0-dev-17503", | ||
| "@tsconfig/bun": "1.0.9", | ||
@@ -43,3 +43,3 @@ "@types/bun": "1.3.13", | ||
| "@smithy/util-utf8": "4.2.2", | ||
| "@opencode-ai/schema": "0.0.0-dev-17491", | ||
| "@opencode-ai/schema": "0.0.0-dev-17503", | ||
| "aws4fetch": "1.0.20", | ||
@@ -46,0 +46,0 @@ "effect": "4.0.0-beta.101", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1150671
-0.31%26567
-0.21%+ Added
- Removed