Spaces:
Paused
Paused
Download src/lib/server/textGeneration/utils/prepareFiles.ts from Mike0021/chat-ui-agent-demo: direct link, hf CLI and curl.
- Browser
- Download file 25.6 kB
-
https://huggingface.co/spaces/Mike0021/chat-ui-agent-demo/resolve/main/src/lib/server/textGeneration/utils/prepareFiles.ts
- Command line
-
hf download hf://spaces/Mike0021/chat-ui-agent-demo/src/lib/server/textGeneration/utils/prepareFiles.ts
-
curl -L -o prepareFiles.ts https://huggingface.co/spaces/Mike0021/chat-ui-agent-demo/resolve/main/src/lib/server/textGeneration/utils/prepareFiles.ts
25.6 kB
| import type { MessageFile } from "$lib/types/Message"; | |
| import type { EndpointMessage } from "$lib/server/endpoints/endpoints"; | |
| import type { OpenAI } from "openai"; | |
| import { TEXT_MIME_ALLOWLIST } from "$lib/constants/mime"; | |
| import type { makeImageProcessor } from "$lib/server/endpoints/images"; | |
| import { | |
| MessageToolUpdateType, | |
| MessageUpdateType, | |
| type MessageToolCallUpdate, | |
| type MessageToolErrorUpdate, | |
| type MessageToolResultUpdate, | |
| type MessageUpdate, | |
| } from "$lib/types/MessageUpdate"; | |
| import { ToolResultStatus } from "$lib/types/Tool"; | |
| import { isValidJsonObject } from "$lib/server/textGeneration/mcp/toolInvocation"; | |
| type ChatMessageParam = OpenAI.Chat.Completions.ChatCompletionMessageParam; | |
| /** | |
| * Assistant message extended with the reasoning echo field consumed by | |
| * preserved-thinking backends (e.g. Kimi K2/K3). Providers that don't know the | |
| * field ignore it. | |
| */ | |
| type AssistantReplayMessage = ChatMessageParam & { reasoning_content?: string }; | |
| /** Cap replayed tool outputs so old turns can't flood the context window. */ | |
| const MAX_REPLAYED_TOOL_OUTPUT_CHARS = 8000; | |
| /** | |
| * Cumulative cap on the WHOLE outgoing history, not just the part replay | |
| * expands. Every message is charged against it — system, user, and plain | |
| * assistant turns included — because a long conversation that already fills a | |
| * context window would otherwise still be handed another budget's worth of | |
| * replay on top and overflow a window it previously fit. | |
| * | |
| * Nothing is ever dropped: messages that can't degrade are charged and kept, | |
| * which can drive the budget negative. All the budget decides is how far back | |
| * the richer replayed shape extends before turns fall back to the flat | |
| * {role, content} form the request used before replay existed. Turns are | |
| * charged newest-first, so recent history keeps its tool calls and reasoning. | |
| * | |
| * Not counted: the preprompt and tool schemas, which callers prepend after | |
| * this function returns — see PROMPT_OVERHEAD_TOKENS. | |
| * | |
| * This is only the ceiling. When the model's context window is known it is | |
| * lowered to fit (see budgetCharsFor), because on a small-context model a flat | |
| * history that fit could otherwise be expanded into an overflow. | |
| */ | |
| const HISTORY_BUDGET_CHARS = 100_000; | |
| /** | |
| * Characters assumed per token when converting a context window into a | |
| * character budget. Deliberately below the usual ~4 for English prose: a low | |
| * ratio yields a smaller budget, so mis-estimating errs toward sending less | |
| * rather than toward a request the model rejects outright. | |
| */ | |
| const CHARS_PER_TOKEN = 3; | |
| /** | |
| * Held back from the context window for the preprompt and tool schemas, which | |
| * run to thousands of tokens when several MCP servers are selected. The reply | |
| * is reserved separately, from the model's own configured limit. | |
| */ | |
| const PROMPT_OVERHEAD_TOKENS = 4_000; | |
| /** | |
| * Reply allowance when the model configures no explicit limit. Models that do | |
| * configure one are reserved that instead — a model set to emit up to 98k | |
| * tokens needs 98k held back, and a flat constant would let history plus reply | |
| * exceed the window even though each fits on its own. | |
| */ | |
| const DEFAULT_OUTPUT_TOKENS = 4_096; | |
| /** | |
| * How many characters of history this request may spend. | |
| * | |
| * With no context length reported (self-hosted backends, or a router that | |
| * omits it) this is the flat ceiling, i.e. the behaviour before models | |
| * reported one. Otherwise the window bounds it, so replay can never push a | |
| * request past what the model accepts. A window smaller than the reserve | |
| * yields 0: everything degrades to the flat pre-replay shape, which is the | |
| * most that model could ever have taken anyway. | |
| */ | |
| function budgetCharsFor(contextLengthTokens?: number, maxOutputTokens?: number): number { | |
| if (!contextLengthTokens || contextLengthTokens <= 0) return HISTORY_BUDGET_CHARS; | |
| const outputReserve = | |
| maxOutputTokens && maxOutputTokens > 0 ? maxOutputTokens : DEFAULT_OUTPUT_TOKENS; | |
| const usableTokens = Math.max(0, contextLengthTokens - outputReserve - PROMPT_OVERHEAD_TOKENS); | |
| return Math.min(HISTORY_BUDGET_CHARS, usableTokens * CHARS_PER_TOKEN); | |
| } | |
| /** | |
| * Nominal size charged for one image part instead of its encoded length. A | |
| * data URL runs to hundreds of thousands of characters while the image costs | |
| * the model on the order of a thousand tokens, so charging the encoding would | |
| * let a single attachment flatten every replayable turn behind it. | |
| */ | |
| const IMAGE_COST_CHARS = 4_000; | |
| const IMAGE_COST_PLACEHOLDER = "i".repeat(IMAGE_COST_CHARS); | |
| /** Approximate the context a message (or list of them) occupies. */ | |
| function historyCost(value: unknown): number { | |
| return JSON.stringify(value, (key, inner) => | |
| key === "url" && typeof inner === "string" && inner.startsWith("data:") | |
| ? IMAGE_COST_PLACEHOLDER | |
| : inner | |
| ).length; | |
| } | |
| /** | |
| * Normalize a persisted update uuid into a provider-safe tool_call_id. | |
| * Mistral-family chat templates require exactly nine alphanumeric characters, | |
| * a shape every other provider also accepts; the persisted uuid is only a | |
| * correlation key, so the id just has to pair calls with results consistently. | |
| */ | |
| function toToolCallId(uuid: string, used: Set<string>): string { | |
| const alnum = uuid.replace(/[^a-zA-Z0-9]/g, "") || "toolcall0"; | |
| let candidate = (alnum + "0".repeat(9)).slice(0, 9); | |
| for (let salt = 1; used.has(candidate); salt += 1) { | |
| const suffix = String(salt); | |
| candidate = (alnum + "0".repeat(9)).slice(0, 9 - suffix.length) + suffix; | |
| } | |
| used.add(candidate); | |
| return candidate; | |
| } | |
| const isToolCallUpdate = (u: MessageUpdate): u is MessageToolCallUpdate => | |
| u.type === MessageUpdateType.Tool && u.subtype === MessageToolUpdateType.Call; | |
| const isToolResultUpdate = (u: MessageUpdate): u is MessageToolResultUpdate => | |
| u.type === MessageUpdateType.Tool && u.subtype === MessageToolUpdateType.Result; | |
| const isToolErrorUpdate = (u: MessageUpdate): u is MessageToolErrorUpdate => | |
| u.type === MessageUpdateType.Tool && u.subtype === MessageToolUpdateType.Error; | |
| /** | |
| * Whether a historical message's own producer (its persisted | |
| * `routerMetadata.model`, set for messages generated through the "omni" | |
| * router alias) matches the model about to consume the request. A message | |
| * with no routerMetadata was produced by whatever single model the | |
| * conversation is pinned to — the common case — and is always treated as | |
| * same-producer since there is nothing to contradict. Reasoning is | |
| * conditioned on the producing model's own prior thoughts; attaching one | |
| * model's reasoning_content to a different model's turn is unverified and | |
| * skipped rather than risked. Tool call/result replay is protocol-neutral | |
| * (a `tool` message is just data) and is never gated by this. | |
| */ | |
| function reasoningProducerMatches( | |
| message: EndpointMessage, | |
| currentProducerModel?: string | |
| ): boolean { | |
| const producer = message.routerMetadata?.model; | |
| return !producer || !currentProducerModel || producer === currentProducerModel; | |
| } | |
| /** | |
| * Split `<think>` blocks out of assistant text, merging them with the | |
| * separately persisted `message.reasoning` when present. Parts are returned | |
| * individually so replay can pair round reasoning back to its tool round. | |
| * Parts are filtered by whether they're non-blank but kept byte-exact | |
| * otherwise: vendors documenting preserved thinking can require the | |
| * reasoning payload sent back unmodified, so trimming must only decide | |
| * whether a part counts as empty, never change what gets echoed. | |
| */ | |
| function splitReasoning( | |
| content: string, | |
| storedReasoning?: string | |
| ): { visible: string; parts: string[] } { | |
| const thinkParts: string[] = []; | |
| const visible = content.replace(/<think>([\s\S]*?)(?:<\/think>|$)/g, (_match, inner: string) => { | |
| thinkParts.push(inner); | |
| return ""; | |
| }); | |
| const parts = [storedReasoning ?? "", ...thinkParts].filter((part) => part.trim().length > 0); | |
| return { visible: visible.trim(), parts }; | |
| } | |
| /** Strip `<think>` blocks without collecting them; used for the degraded | |
| * fallback shape so a budget cutoff never leaks raw reasoning as visible | |
| * content, including to models whose vendor requires historical thoughts | |
| * to be stripped (e.g. Gemma) regardless of the replay budget. */ | |
| function stripThink(content: string): string { | |
| return content.replace(/<think>[\s\S]*?(?:<\/think>|$)/g, "").trim(); | |
| } | |
| /** | |
| * Rebuild a past assistant turn from its persisted tool updates so follow-up | |
| * requests see the tool calls and their outputs instead of a flat text | |
| * summary. Rounds are inferred from update order — a Call update arriving | |
| * after any Result/Error starts a new round, matching how the live loop emits | |
| * one batch of calls per completion round. Each call's `tool_call_id` is | |
| * always the normalized id from toToolCallId, even though the original | |
| * provider-issued id may also be persisted (see MessageToolCallUpdate): | |
| * emitting it unconditionally keeps one code path and satisfies every | |
| * provider's id-shape requirements, including Mistral-family templates. | |
| */ | |
| function replayAssistantTurn( | |
| message: EndpointMessage, | |
| includeReasoning: boolean | |
| ): AssistantReplayMessage[] { | |
| const updates = message.updates ?? []; | |
| const { visible, parts } = splitReasoning(message.content, message.reasoning); | |
| // `parts` holds every recovered reasoning block across the whole turn, and | |
| // `remainingVisible` the full visible text: when tools ran, the | |
| // FinalAnswer handler merges the pre-tool stream into content, so earlier | |
| // rounds' think blocks and preamble text survive there too, concatenated | |
| // in chronological order. Rounds whose Call update persisted its own | |
| // `reasoning`/`content` (written by the live loop since those fields | |
| // existed) reclaim their piece below; whatever remains belongs to the | |
| // final answer. | |
| const remainingParts = [...parts]; | |
| let remainingVisible = visible; | |
| // null, not an empty-content message, when a turn was interrupted before | |
| // producing any final text or reasoning (e.g. aborted mid-tool-call): an | |
| // empty trailing `{role: "assistant", content: ""}` with nothing else | |
| // attached represents an assistant turn that never happened, and strict | |
| // providers can reject it outright. | |
| const buildFinalMessage = (): AssistantReplayMessage | null => { | |
| const reasoning = remainingParts.join("\n"); | |
| const content = remainingVisible.trim(); | |
| const hasReasoning = includeReasoning && reasoning.length > 0; | |
| if (content.length === 0 && !hasReasoning) return null; | |
| return { | |
| role: "assistant", | |
| content, | |
| ...(hasReasoning ? { reasoning_content: reasoning } : {}), | |
| }; | |
| }; | |
| const callUpdates = updates.filter(isToolCallUpdate); | |
| if (callUpdates.length === 0) { | |
| const finalMessage = buildFinalMessage(); | |
| return finalMessage ? [finalMessage] : []; | |
| } | |
| const outputsByUuid = new Map<string, string>(); | |
| const rounds: MessageToolCallUpdate[][] = []; | |
| let round: MessageToolCallUpdate[] = []; | |
| let roundHasOutcome = false; | |
| for (const update of updates) { | |
| if (isToolCallUpdate(update)) { | |
| if (roundHasOutcome && round.length > 0) { | |
| rounds.push(round); | |
| round = []; | |
| roundHasOutcome = false; | |
| } | |
| round.push(update); | |
| } else if (isToolResultUpdate(update)) { | |
| const result = update.result; | |
| const firstOutput = | |
| result.status === ToolResultStatus.Success ? result.outputs[0] : undefined; | |
| outputsByUuid.set( | |
| update.uuid, | |
| result.status === ToolResultStatus.Success | |
| ? typeof firstOutput?.text === "string" | |
| ? firstOutput.text | |
| : JSON.stringify(firstOutput ?? "") | |
| : `Error: ${result.message}` | |
| ); | |
| roundHasOutcome = true; | |
| } else if (isToolErrorUpdate(update)) { | |
| outputsByUuid.set(update.uuid, `Error: ${update.message}`); | |
| roundHasOutcome = true; | |
| } | |
| } | |
| if (round.length > 0) { | |
| rounds.push(round); | |
| } | |
| const usedIds = new Set<string>(); | |
| const idByUuid = new Map(callUpdates.map((u) => [u.uuid, toToolCallId(u.uuid, usedIds)])); | |
| const replayed: AssistantReplayMessage[] = []; | |
| for (const callsInRound of rounds) { | |
| // Reasoning and preamble text persisted on the round's Call update go | |
| // back on this round's message and are deduped out of the final | |
| // answer's blocks. Kept byte-exact (trim only tests for emptiness): see | |
| // splitReasoning for why reasoning fidelity matters; the same | |
| // unmodified-echo principle is applied to preamble text for symmetry | |
| // and so the dedup below matches reliably. | |
| const roundReasoning = includeReasoning | |
| ? (callsInRound.find((u) => u.reasoning?.trim())?.reasoning ?? "") | |
| : ""; | |
| if (roundReasoning) { | |
| const exact = remainingParts.indexOf(roundReasoning); | |
| if (exact !== -1) { | |
| remainingParts.splice(exact, 1); | |
| } else if (remainingParts.length > 0 && roundReasoning.includes(remainingParts[0])) { | |
| // Positional fallback only, never a scan of the whole array: parts | |
| // are chronologically ordered and rounds are processed oldest-first, | |
| // so the earliest still-unconsumed part is the only one that can be | |
| // attributed to this round when exact match fails (e.g. formatting | |
| // drift). A LATER part merely being a substring of this round's | |
| // reasoning is coincidence, not evidence it belongs here — removing | |
| // it would silently delete an unrelated (and possibly the final | |
| // answer's own) reasoning block. | |
| remainingParts.splice(0, 1); | |
| } | |
| } | |
| // Visible text streamed before this round's calls (e.g. "Let me check | |
| // that."): rounds are processed oldest-first, matching the | |
| // chronological order text was concatenated into `message.content`, so | |
| // removing a matched prefix keeps the remainder correctly ordered for | |
| // the final message. Trimmed on both sides (persistence trims too) | |
| // because `remainingVisible` comes from splitReasoning trim-normalized; | |
| // visible text has no byte-exactness requirement, unlike reasoning. | |
| const roundContent = (callsInRound.find((u) => u.content?.trim())?.content ?? "").trim(); | |
| if (roundContent && remainingVisible.startsWith(roundContent)) { | |
| // Prefix-only, deliberately: rounds consume the visible text in | |
| // chronological order, so a streamed preamble is always the next | |
| // prefix. A persisted preamble that is NOT a prefix was never merged | |
| // into stored content (content arriving in the same delta as the | |
| // first tool_calls entry is suppressed from the stream), so a deeper | |
| // indexOf match could only hit identical text belonging to the final | |
| // answer — removing that would reorder the conversation. The failure | |
| // mode of not matching is mild duplication, which is safer. | |
| remainingVisible = remainingVisible.slice(roundContent.length).trimStart(); | |
| } | |
| // `content` is included only when a preamble was actually persisted | |
| // (messages recorded before this field existed have none); omitted | |
| // otherwise since some OpenAI-compatible backends reject empty text | |
| // next to tool_calls with a 400. | |
| // Arguments prefer the persisted raw JSON string the model actually | |
| // sent (argumentsRaw): the sanitized fallback only keeps top-level | |
| // primitive params (nested values and file payloads are deliberately | |
| // kept out of storage), so it can under-represent the real call. | |
| // Legacy updates without argumentsRaw, and any argumentsRaw that | |
| // somehow isn't valid JSON (toolInvocation.ts already guards this at | |
| // write time, but a replayed value must never be trusted blindly at | |
| // its own read boundary — belt and suspenders against a future write | |
| // path or manipulated data), fall back to the sanitized form. | |
| replayed.push({ | |
| role: "assistant", | |
| tool_calls: callsInRound.map((u) => ({ | |
| id: idByUuid.get(u.uuid) ?? u.uuid, | |
| type: "function" as const, | |
| function: { | |
| name: u.call.name, | |
| arguments: | |
| u.argumentsRaw && isValidJsonObject(u.argumentsRaw) | |
| ? u.argumentsRaw | |
| : JSON.stringify(u.call.parameters ?? {}), | |
| }, | |
| })), | |
| ...(roundContent.trim().length > 0 ? { content: roundContent } : {}), | |
| ...(roundReasoning ? { reasoning_content: roundReasoning } : {}), | |
| }); | |
| for (const u of callsInRound) { | |
| // A call with no persisted outcome means the run was aborted | |
| // mid-execution; say so instead of fabricating an empty success. | |
| const output = outputsByUuid.has(u.uuid) | |
| ? (outputsByUuid.get(u.uuid) ?? "") | |
| : "Error: interrupted before a result was recorded"; | |
| replayed.push({ | |
| role: "tool", | |
| tool_call_id: idByUuid.get(u.uuid) ?? u.uuid, | |
| content: | |
| output.length > MAX_REPLAYED_TOOL_OUTPUT_CHARS | |
| ? output.slice(0, MAX_REPLAYED_TOOL_OUTPUT_CHARS) + "\n[...truncated]" | |
| : output, | |
| }); | |
| } | |
| } | |
| const finalMessage = buildFinalMessage(); | |
| if (finalMessage) replayed.push(finalMessage); | |
| return replayed; | |
| } | |
| /** | |
| * Prepare chat messages for OpenAI-compatible multimodal payloads. | |
| * - Processes images via the provided imageProcessor (resize/convert) when multimodal is enabled. | |
| * - Injects text-file content into the user message text. | |
| * - Leaves messages untouched when no files or multimodal disabled. | |
| * - Historical assistant `<think>` blocks are always stripped from outgoing | |
| * content, whether or not any reasoning option below is set: raw think | |
| * markup must never be replayed as visible text to any model. | |
| * - With `replayToolHistory`, expands past assistant turns into their | |
| * assistant/tool message pairs (from persisted updates) and re-attaches | |
| * reasoning as `reasoning_content` instead of inline `<think>` text. | |
| * - With `attachReasoning`, only the reasoning half: assistant turns stay | |
| * flat but carry `reasoning_content`, for tool-less requests where replayed | |
| * tool messages would be undefined behavior (no `tools` in the request). | |
| * Callers gate it on the model's reasoning capability; with | |
| * `replayToolHistory` it defaults to on unless explicitly disabled. | |
| * - `currentProducerModel` additionally gates reasoning_content per message: | |
| * a message routed (via the router alias) to a different model than the | |
| * one about to consume this request has its reasoning suppressed, since | |
| * reasoning is conditioned on the producing model's own prior thoughts. | |
| * Messages with no routerMetadata (the common pinned-model case) are | |
| * unaffected. Tool call/result replay is protocol-neutral and always | |
| * proceeds regardless of producer. | |
| */ | |
| export async function prepareMessagesWithFiles( | |
| messages: EndpointMessage[], | |
| imageProcessor: ReturnType<typeof makeImageProcessor>, | |
| isMultimodal: boolean, | |
| options?: { | |
| replayToolHistory?: boolean; | |
| attachReasoning?: boolean; | |
| currentProducerModel?: string; | |
| /** | |
| * The consuming model's context window in tokens, when known. Bounds how | |
| * much history is sent; omitting it keeps the flat default ceiling. | |
| */ | |
| contextLengthTokens?: number; | |
| /** | |
| * The reply allowance this request will ask for (the model's configured | |
| * max_tokens). Reserved from the window alongside prompt overhead. | |
| */ | |
| maxOutputTokens?: number; | |
| } | |
| ): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam[]> { | |
| type ReplayCandidate = { replay: AssistantReplayMessage[]; flat: ChatMessageParam }; | |
| const prepared = await Promise.all( | |
| messages.map(async (message): Promise<ChatMessageParam[] | ReplayCandidate> => { | |
| if (message.from === "user" && message.files && message.files.length > 0) { | |
| const { imageParts, textContent } = await prepareFiles( | |
| imageProcessor, | |
| message.files, | |
| isMultimodal | |
| ); | |
| let messageText = message.content; | |
| if (textContent.length > 0) { | |
| messageText = textContent + "\n\n" + message.content; | |
| } | |
| if (imageParts.length > 0 && isMultimodal) { | |
| const parts = [{ type: "text" as const, text: messageText }, ...imageParts]; | |
| return [{ role: message.from, content: parts }]; | |
| } | |
| return [{ role: message.from, content: messageText }]; | |
| } | |
| if (message.from === "assistant") { | |
| const wantsReasoning = | |
| (options?.replayToolHistory | |
| ? (options?.attachReasoning ?? true) | |
| : Boolean(options?.attachReasoning)) && | |
| reasoningProducerMatches(message, options?.currentProducerModel); | |
| if (options?.replayToolHistory) { | |
| // The budget-fallback `flat` must still strip <think>, not just | |
| // drop the reasoning_content/tool-replay enrichments: the raw | |
| // string leaks reasoning as visible content to every model that | |
| // falls back to it, including ones (e.g. Gemma) whose vendor | |
| // requires historical thoughts to be stripped regardless of the | |
| // replay budget. | |
| const flat: ChatMessageParam = { | |
| role: "assistant", | |
| content: stripThink(message.content), | |
| }; | |
| return { | |
| replay: replayAssistantTurn(message, wantsReasoning), | |
| flat, | |
| }; | |
| } | |
| const { visible, parts } = splitReasoning(message.content, message.reasoning); | |
| const reasoning = parts.join("\n"); | |
| if (!wantsReasoning || reasoning.length === 0) { | |
| // Either nothing to attach, or attachment is disabled/gated: | |
| // either way `visible` (think-stripped) is the correct shape, | |
| // never the raw `message.content` — but a turn interrupted | |
| // before any visible text (or one whose only content was | |
| // reasoning this call is gated from attaching) must not replay | |
| // as a phantom `{role: assistant, content: ""}` with nothing | |
| // else attached; omit it entirely instead. | |
| return visible.length > 0 ? [{ role: "assistant", content: visible }] : []; | |
| } | |
| // Candidate, not a plain array, so the reasoning payload goes | |
| // through the same newest-first budget as tool replay. The | |
| // fallback keeps the same stripped `visible` text and just | |
| // drops reasoning_content, for the same reason as above. | |
| return { | |
| replay: [{ role: "assistant", content: visible, reasoning_content: reasoning }], | |
| flat: { role: "assistant", content: visible }, | |
| }; | |
| } | |
| return [{ role: message.from, content: message.content }]; | |
| }) | |
| ); | |
| // Spend the replay budget newest-first so recent turns keep their full | |
| // tool history and older ones degrade to the pre-replay flat shape. The | |
| // degradation is monotonic: once any turn falls back to flat, every older | |
| // turn does too, so the model never sees rich history for a stale turn | |
| // while the turn it is continuing from is plain prose. | |
| // Two passes, because the budget caps the whole request and the messages that | |
| // can't degrade aren't all at the newest end. Charging them as they're | |
| // reached would let a huge older user turn be counted only after every newer | |
| // turn had already been granted its replay — the total would still overrun. | |
| // | |
| // Pass 1 establishes the floor: what this request costs with no enrichment | |
| // at all, which is also the exact shape it had before replay existed. | |
| const flatForms: ChatMessageParam[][] = prepared.map((entry) => { | |
| if (Array.isArray(entry)) return entry; | |
| // Same phantom-turn guard as the replay and plain branches: an | |
| // interrupted turn whose stripped content is empty must be omitted, | |
| // not sent as {role: "assistant", content: ""}. | |
| const flatContent = typeof entry.flat.content === "string" ? entry.flat.content : ""; | |
| return flatContent.trim().length > 0 ? [entry.flat] : []; | |
| }); | |
| const floor = flatForms.reduce((total, form) => total + historyCost(form), 0); | |
| // Pass 2 spends whatever is left on upgrading turns to their replayed shape, | |
| // paying only the difference over the floor. A history that already exceeds | |
| // the cap leaves nothing to spend, so every turn keeps its flat form and the | |
| // request is no larger than it used to be. | |
| let budget = budgetCharsFor(options?.contextLengthTokens, options?.maxOutputTokens) - floor; | |
| const resolved: ChatMessageParam[][] = [...flatForms]; | |
| for (let i = prepared.length - 1; i >= 0; i -= 1) { | |
| const entry = prepared[i]; | |
| if (Array.isArray(entry)) continue; | |
| const upgrade = historyCost(entry.replay) - historyCost(flatForms[i]); | |
| // Monotonic: the first turn that doesn't fit stops the walk, so the model | |
| // never sees rich history for a stale turn while the turn it is | |
| // continuing from is plain prose. | |
| if (upgrade > budget) break; | |
| budget -= upgrade; | |
| resolved[i] = entry.replay; | |
| } | |
| return resolved.flat(); | |
| } | |
| async function prepareFiles( | |
| imageProcessor: ReturnType<typeof makeImageProcessor>, | |
| files: MessageFile[], | |
| isMultimodal: boolean | |
| ): Promise<{ | |
| imageParts: OpenAI.Chat.Completions.ChatCompletionContentPartImage[]; | |
| textContent: string; | |
| }> { | |
| const imageFiles = files.filter((file) => file.mime.startsWith("image/")); | |
| const textFiles = files.filter((file) => { | |
| const mime = (file.mime || "").toLowerCase(); | |
| const [fileType, fileSubtype] = mime.split("/"); | |
| return TEXT_MIME_ALLOWLIST.some((allowed) => { | |
| const [type, subtype] = allowed.toLowerCase().split("/"); | |
| const typeOk = type === "*" || type === fileType; | |
| const subOk = subtype === "*" || subtype === fileSubtype; | |
| return typeOk && subOk; | |
| }); | |
| }); | |
| let imageParts: OpenAI.Chat.Completions.ChatCompletionContentPartImage[] = []; | |
| if (isMultimodal && imageFiles.length > 0) { | |
| const processedFiles = await Promise.all(imageFiles.map(imageProcessor)); | |
| imageParts = processedFiles.map((file) => ({ | |
| type: "image_url" as const, | |
| image_url: { | |
| url: `data:${file.mime};base64,${file.image.toString("base64")}`, | |
| detail: "auto", | |
| }, | |
| })); | |
| } | |
| let textContent = ""; | |
| if (textFiles.length > 0) { | |
| const textParts = await Promise.all( | |
| textFiles.map(async (file) => { | |
| const content = Buffer.from(file.value, "base64").toString("utf-8"); | |
| return `<document name="${file.name}" type="${file.mime}">\n${content}\n</document>`; | |
| }) | |
| ); | |
| textContent = textParts.join("\n\n"); | |
| } | |
| return { imageParts, textContent }; | |
| } | |