refactor(core): simplify location filesystem (#31545)
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultContentPart,
|
||||
type ToolContent,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -321,10 +321,10 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me
|
||||
// Tool results may carry structured text/images. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
||||
item: ToolResultContentPart,
|
||||
item: ToolContent,
|
||||
) {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
||||
const media = yield* ProviderShared.validateMedia(
|
||||
const media = yield* ProviderShared.validateToolFile(
|
||||
"Anthropic Messages",
|
||||
item,
|
||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
||||
@@ -344,7 +344,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
|
||||
// with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
|
||||
content.push({ text: item.text })
|
||||
continue
|
||||
}
|
||||
const media = yield* BedrockMedia.lower(item)
|
||||
const media = yield* BedrockMedia.lower({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
|
||||
if (!("image" in media))
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
|
||||
content.push(media)
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultContentPart,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
|
||||
@@ -262,7 +262,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
})
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
@@ -275,7 +275,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
})
|
||||
for (const item of content) {
|
||||
if (item.type === "text") continue
|
||||
const media = yield* ProviderShared.validateMedia("Gemini", item, IMAGE_MIMES)
|
||||
const media = yield* ProviderShared.validateToolFile("Gemini", item, IMAGE_MIMES)
|
||||
parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultContentPart,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
@@ -201,7 +201,7 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (
|
||||
part: Extract<MediaPart | ToolResultContentPart, { type: "media" }>,
|
||||
part: MediaPart,
|
||||
) {
|
||||
const media = yield* ProviderShared.validateMedia("OpenAI Chat", part, IMAGE_MIMES)
|
||||
return { type: "image_url" as const, image_url: { url: media.dataUrl } }
|
||||
@@ -271,13 +271,15 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
||||
const media = content.filter(
|
||||
(item): item is Extract<ToolResultContentPart, { type: "media" }> => item.type === "media",
|
||||
const files = content.filter((item) => item.type === "file")
|
||||
images.push(
|
||||
...(yield* Effect.forEach(files, (item) =>
|
||||
lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }),
|
||||
)),
|
||||
)
|
||||
images.push(...(yield* Effect.forEach(media, lowerMedia)))
|
||||
}
|
||||
return { messages, images }
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultContentPart,
|
||||
type ToolContent,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -318,10 +318,10 @@ const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function*
|
||||
// Tool results may carry structured text/images. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
|
||||
item: ToolResultContentPart,
|
||||
item: ToolContent,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
const media = yield* ProviderShared.validateMedia(
|
||||
const media = yield* ProviderShared.validateToolFile(
|
||||
"OpenAI Responses",
|
||||
item,
|
||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
||||
@@ -334,7 +334,7 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")
|
||||
// compatibility with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ToolFileContent,
|
||||
type TextPart,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
@@ -233,6 +234,9 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function*
|
||||
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
|
||||
})
|
||||
|
||||
export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
|
||||
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
|
||||
|
||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||
|
||||
export const toolResultText = (part: ToolResultPart) => {
|
||||
|
||||
@@ -39,68 +39,24 @@ export const MediaPart = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Content.Media" })
|
||||
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||
|
||||
export const ToolResultMediaPart = Schema.Struct({
|
||||
type: Schema.Literal("media"),
|
||||
mediaType: Schema.String,
|
||||
data: Schema.String,
|
||||
filename: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}).annotate({ identifier: "LLM.ToolResult.Media" })
|
||||
export type ToolResultMediaPart = Schema.Schema.Type<typeof ToolResultMediaPart>
|
||||
|
||||
export const ToolResultContentPart = Schema.Union([TextPart, ToolResultMediaPart])
|
||||
export type ToolResultContentPart = Schema.Schema.Type<typeof ToolResultContentPart>
|
||||
|
||||
export class ToolTextContent extends Schema.Class<ToolTextContent>("Tool.TextContent")({
|
||||
export const ToolTextContent = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
}).annotate({ identifier: "Tool.TextContent" })
|
||||
export type ToolTextContent = typeof ToolTextContent.Type
|
||||
|
||||
export const ToolFileSource = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("data"), data: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("url"), url: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("file"), uri: Schema.String }),
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ToolFileSource = Schema.Schema.Type<typeof ToolFileSource>
|
||||
|
||||
export class ToolFileContent extends Schema.Class<ToolFileContent>("Tool.FileContent")({
|
||||
export const ToolFileContent = Schema.Struct({
|
||||
type: Schema.Literal("file"),
|
||||
source: ToolFileSource,
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Tool.FileContent" })
|
||||
export type ToolFileContent = typeof ToolFileContent.Type
|
||||
|
||||
/** Ordered, provider-independent content shown to models and UIs after a tool succeeds. */
|
||||
export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ToolContent = Schema.Schema.Type<typeof ToolContent>
|
||||
|
||||
export const toolText = (value: ConstructorParameters<typeof ToolTextContent>[0]) => new ToolTextContent(value)
|
||||
export const toolFile = (value: ConstructorParameters<typeof ToolFileContent>[0]) => new ToolFileContent(value)
|
||||
|
||||
const inlineData = (uri: string) => {
|
||||
if (!uri.startsWith("data:")) return undefined
|
||||
const match = /^data:[^;,]+;base64,(.*)$/s.exec(uri)
|
||||
if (!match) throw new Error("Tool file data URI must contain raw base64 bytes")
|
||||
return match[1]!
|
||||
}
|
||||
|
||||
const legacyInlineData = (value: string) => {
|
||||
const data = inlineData(value)
|
||||
if (data !== undefined) return data
|
||||
if (/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return value
|
||||
throw new Error("Legacy tool-result media must contain raw base64 bytes or a base64 data URI")
|
||||
}
|
||||
|
||||
/** Convert a legacy attachment URI without guessing unknown string semantics. */
|
||||
export const toolFileSourceFromUri = (uri: string): ToolFileSource => {
|
||||
const data = inlineData(uri)
|
||||
if (data !== undefined) return { type: "data", data }
|
||||
const url = URL.parse(uri)
|
||||
if (url?.protocol === "file:") return { type: "file", uri }
|
||||
if (url?.protocol === "http:" || url?.protocol === "https:") return { type: "url", url: uri }
|
||||
throw new Error(`Unsupported tool file URI: ${uri}`)
|
||||
}
|
||||
|
||||
const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
||||
isRecord(value) &&
|
||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||
@@ -122,7 +78,7 @@ export const ToolResultValue = Object.assign(
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("content"),
|
||||
value: Schema.Array(ToolResultContentPart),
|
||||
value: Schema.Array(ToolContent),
|
||||
}),
|
||||
]).annotate({ identifier: "LLM.ToolResult" }),
|
||||
{
|
||||
@@ -147,34 +103,15 @@ export const ToolOutput = Object.assign(
|
||||
content: Schema.Array(ToolContent),
|
||||
}).annotate({ identifier: "LLM.ToolOutput" }),
|
||||
{
|
||||
make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({
|
||||
structured,
|
||||
content: content.map((item) =>
|
||||
item.type === "text"
|
||||
? toolText({ type: "text", text: item.text })
|
||||
: toolFile({ type: "file", source: item.source, mime: item.mime, name: item.name }),
|
||||
),
|
||||
}),
|
||||
make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({ structured, content }),
|
||||
fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
|
||||
switch (result.type) {
|
||||
case "json":
|
||||
return { structured: result.value, content: [] }
|
||||
case "text":
|
||||
return { structured: {}, content: [toolText({ type: "text", text: toolResultText(result.value) })] }
|
||||
return { structured: {}, content: [{ type: "text", text: toolResultText(result.value) }] }
|
||||
case "content":
|
||||
return {
|
||||
structured: {},
|
||||
content: result.value.map((item) =>
|
||||
item.type === "text"
|
||||
? toolText({ type: "text", text: item.text })
|
||||
: toolFile({
|
||||
type: "file",
|
||||
source: { type: "data", data: legacyInlineData(item.data) },
|
||||
mime: item.mediaType,
|
||||
name: item.filename,
|
||||
}),
|
||||
),
|
||||
}
|
||||
return { structured: {}, content: result.value }
|
||||
case "error":
|
||||
return undefined
|
||||
}
|
||||
@@ -183,21 +120,7 @@ export const ToolOutput = Object.assign(
|
||||
if (output.content.length === 0) return { type: "json", value: output.structured }
|
||||
if (output.content.length === 1 && output.content[0]?.type === "text")
|
||||
return { type: "text", value: output.content[0].text }
|
||||
const unsupported = output.content.find((item) => item.type === "file" && item.source.type !== "data")
|
||||
if (unsupported?.type === "file")
|
||||
return {
|
||||
type: "error",
|
||||
value: `Tool file source "${unsupported.source.type}" must be materialized to inline data before provider conversion`,
|
||||
}
|
||||
return {
|
||||
type: "content",
|
||||
value: output.content.map((item) => {
|
||||
if (item.type === "text") return { type: "text", text: item.text }
|
||||
if (item.source.type !== "data")
|
||||
throw new Error("Unmaterialized tool file source reached provider conversion")
|
||||
return { type: "media", mediaType: item.mime, data: item.source.data, filename: item.name }
|
||||
}),
|
||||
}
|
||||
return { type: "content", value: output.content }
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
ToolDefinition as ToolDefinitionClass,
|
||||
ToolOutput as ToolOutputType,
|
||||
} from "./schema"
|
||||
import { ToolDefinition, ToolFailure, ToolOutput, toolText } from "./schema"
|
||||
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema"
|
||||
|
||||
/**
|
||||
* Schema constraint for tool parameters / success values: no decoding or
|
||||
@@ -245,7 +245,7 @@ const project = (
|
||||
ToolOutput.make(
|
||||
toStructuredOutput?.(output) ?? output,
|
||||
toModelOutput?.({ callID, parameters, output }) ??
|
||||
(typeof output === "string" ? [toolText({ type: "text", text: output })] : []),
|
||||
(typeof output === "string" ? [{ type: "text", text: output }] : []),
|
||||
)
|
||||
|
||||
export { ToolFailure }
|
||||
|
||||
@@ -235,7 +235,7 @@ describe("Anthropic Messages route", () => {
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
@@ -262,7 +262,7 @@ describe("Anthropic Messages route", () => {
|
||||
id: "call_1",
|
||||
name: "screenshot",
|
||||
resultType: "content",
|
||||
result: [{ type: "media", mediaType: "image/jpeg", data: "/9j/AA==" }],
|
||||
result: [{ type: "file", uri: "data:image/jpeg;base64,/9j/AA==", mime: "image/jpeg" }],
|
||||
}),
|
||||
],
|
||||
cache: "none",
|
||||
@@ -287,7 +287,7 @@ describe("Anthropic Messages route", () => {
|
||||
id: "call_1",
|
||||
name: "fetch",
|
||||
resultType: "content",
|
||||
result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }],
|
||||
result: [{ type: "file", uri: "data:audio/mpeg;base64,AAECAw==", mime: "audio/mpeg" }],
|
||||
}),
|
||||
],
|
||||
cache: "none",
|
||||
|
||||
@@ -189,7 +189,7 @@ describe("Bedrock Converse route", () => {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Screenshot captured." },
|
||||
{ type: "media", mediaType: "image/png", data: "AAAA" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("Gemini route", () => {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "media", mediaType: "image/png", data: "AAECAw==", filename: "pixel.png" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -163,7 +163,7 @@ describe("Gemini route", () => {
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "media", mediaType: "image/jpeg", data: "data:image/jpeg;base64,/9j/" }],
|
||||
value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -238,7 +238,7 @@ describe("OpenAI Chat route", () => {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "media", mediaType: "image/png", data: "AAECAw==", filename: "pixel.png" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -285,13 +285,19 @@ describe("OpenAI Chat route", () => {
|
||||
type: "tool-result",
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAEC" }] },
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "call_2",
|
||||
name: "read",
|
||||
result: { type: "content", value: [{ type: "media", mediaType: "image/jpeg", data: "/9j/" }] },
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -321,12 +327,12 @@ describe("OpenAI Chat route", () => {
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAEC" }] },
|
||||
result: { type: "content", value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }] },
|
||||
}),
|
||||
Message.tool({
|
||||
id: "call_2",
|
||||
name: "read",
|
||||
result: { type: "content", value: [{ type: "media", mediaType: "image/webp", data: "UklG" }] },
|
||||
result: { type: "content", value: [{ type: "file", uri: "data:image/webp;base64,UklG", mime: "image/webp" }] },
|
||||
}),
|
||||
Message.system("Inspect both images."),
|
||||
],
|
||||
|
||||
@@ -377,7 +377,7 @@ describe("OpenAI Responses route", () => {
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
@@ -403,7 +403,7 @@ describe("OpenAI Responses route", () => {
|
||||
id: "call_1",
|
||||
name: "screenshot",
|
||||
resultType: "content",
|
||||
result: [{ type: "media", mediaType: "image/png", data: "AAECAw==" }],
|
||||
result: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -427,7 +427,7 @@ describe("OpenAI Responses route", () => {
|
||||
id: "call_1",
|
||||
name: "fetch",
|
||||
resultType: "content",
|
||||
result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }],
|
||||
result: [{ type: "file", uri: "data:audio/mpeg;base64,AAECAw==", mime: "audio/mpeg" }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -388,7 +388,7 @@ const runImageToolResultScenario = (context: GoldenScenarioContext) =>
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "media", mediaType: "image/png", data: image },
|
||||
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ToolChoice,
|
||||
ToolContent,
|
||||
ToolOutput,
|
||||
toolFileSourceFromUri,
|
||||
toDefinitions,
|
||||
} from "../src"
|
||||
import { Auth, LLMClient } from "../src/route"
|
||||
@@ -217,7 +216,7 @@ describe("LLMClient tools", () => {
|
||||
execute: () => Effect.succeed({ mime: "image/png", data: "AAECAw==" }),
|
||||
toStructuredOutput: (output) => ({ mime: output.mime }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{ type: "file", source: { type: "data", data: output.data }, mime: output.mime },
|
||||
{ type: "file", uri: `data:${output.mime};base64,${output.data}`, mime: output.mime },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -228,81 +227,80 @@ describe("LLMClient tools", () => {
|
||||
|
||||
expect(dispatched.output).toEqual({
|
||||
structured: { mime: "image/png" },
|
||||
content: [{ type: "file", source: { type: "data", data: "AAECAw==" }, mime: "image/png" }],
|
||||
content: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("models canonical tool files with explicit data, url, and file sources", () =>
|
||||
it.effect("models canonical tool files with URIs", () =>
|
||||
Effect.sync(() => {
|
||||
const decode = Schema.decodeUnknownSync(ToolContent)
|
||||
|
||||
expect(decode({ type: "file", source: { type: "data", data: "AAAA" }, mime: "image/png" })).toEqual({
|
||||
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
|
||||
type: "file",
|
||||
source: { type: "data", data: "AAAA" },
|
||||
uri: "data:image/png;base64,AAAA",
|
||||
mime: "image/png",
|
||||
})
|
||||
expect(
|
||||
decode({ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" }),
|
||||
decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" }),
|
||||
).toEqual({
|
||||
type: "file",
|
||||
source: { type: "url", url: "https://example.test/image.png" },
|
||||
uri: "https://example.test/image.png",
|
||||
mime: "image/png",
|
||||
})
|
||||
expect(
|
||||
decode({ type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" }),
|
||||
decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }),
|
||||
).toEqual({
|
||||
type: "file",
|
||||
source: { type: "file", uri: "file:///tmp/image.png" },
|
||||
uri: "file:///tmp/image.png",
|
||||
mime: "image/png",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("converts canonical data files deliberately and rejects unmaterialized sources", () =>
|
||||
it.effect("preserves canonical tool file URIs", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ToolOutput.toResultValue(
|
||||
ToolOutput.make({}, [{ type: "file", source: { type: "data", data: "AAAA" }, mime: "image/png" }]),
|
||||
),
|
||||
).toEqual({ type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAAA" }] })
|
||||
expect(
|
||||
ToolOutput.toResultValue(
|
||||
ToolOutput.make({}, [
|
||||
{ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" },
|
||||
]),
|
||||
ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: 'Tool file source "url" must be materialized to inline data before provider conversion',
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
|
||||
})
|
||||
expect(
|
||||
ToolOutput.toResultValue(
|
||||
ToolOutput.make({}, [
|
||||
{ type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" },
|
||||
{ type: "file", uri: "https://example.test/image.png", mime: "image/png" },
|
||||
]),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: 'Tool file source "file" must be materialized to inline data before provider conversion',
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
|
||||
})
|
||||
expect(toolFileSourceFromUri("data:image/png;base64,AAAA")).toEqual({ type: "data", data: "AAAA" })
|
||||
expect(toolFileSourceFromUri("https://example.test/image.png")).toEqual({
|
||||
type: "url",
|
||||
url: "https://example.test/image.png",
|
||||
expect(
|
||||
ToolOutput.toResultValue(
|
||||
ToolOutput.make({}, [
|
||||
{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" },
|
||||
]),
|
||||
),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
|
||||
})
|
||||
expect(toolFileSourceFromUri("file:///tmp/image.png")).toEqual({ type: "file", uri: "file:///tmp/image.png" })
|
||||
expect(() => toolFileSourceFromUri("opaque-value")).toThrow("Unsupported tool file URI")
|
||||
expect(() =>
|
||||
expect(
|
||||
ToolOutput.fromResultValue({
|
||||
type: "content",
|
||||
value: [{ type: "media", mediaType: "image/png", data: "https://example.test/image.png" }],
|
||||
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
|
||||
}),
|
||||
).toThrow("Legacy tool-result media must contain raw base64 bytes or a base64 data URI")
|
||||
).toEqual({
|
||||
structured: {},
|
||||
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles projected url files as materialization errors", () =>
|
||||
it.effect("settles projected URL files as canonical tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const remote = Tool.make({
|
||||
description: "Return a remote file.",
|
||||
@@ -310,7 +308,7 @@ describe("LLMClient tools", () => {
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
toModelOutput: () => [
|
||||
{ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" },
|
||||
{ type: "file", uri: "https://example.test/image.png", mime: "image/png" },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -319,12 +317,15 @@ describe("LLMClient tools", () => {
|
||||
LLMEvent.toolCall({ id: "call_remote", name: "remote", input: {} }),
|
||||
)
|
||||
|
||||
expect(dispatched.output).toBeUndefined()
|
||||
expect(dispatched.result).toEqual({
|
||||
type: "error",
|
||||
value: 'Tool file source "url" must be materialized to inline data before provider conversion',
|
||||
expect(dispatched.output).toEqual({
|
||||
structured: { ok: true },
|
||||
content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
|
||||
})
|
||||
expect(dispatched.events.map((event) => event.type)).toEqual(["tool-error", "tool-result"])
|
||||
expect(dispatched.result).toEqual({
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
|
||||
})
|
||||
expect(dispatched.events.map((event) => event.type)).toEqual(["tool-result"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -357,7 +358,7 @@ describe("LLMClient tools", () => {
|
||||
type: "content" as const,
|
||||
value: [
|
||||
{ type: "text" as const, text: "Screenshot captured." },
|
||||
{ type: "media" as const, mediaType: "image/png", data: "AAAA" },
|
||||
{ type: "file" as const, uri: "data:image/png;base64,AAAA", mime: "image/png" },
|
||||
],
|
||||
}),
|
||||
})
|
||||
@@ -379,7 +380,7 @@ describe("LLMClient tools", () => {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Screenshot captured." },
|
||||
{ type: "media", mediaType: "image/png", data: "AAAA" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user