Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
File size: 7,926 Bytes
7d17616 0b311bb 7d17616 0b311bb 7d17616 27da720 0b311bb 27da720 0b311bb 27da720 7d17616 0b311bb 7d17616 962191f 0b311bb 7d17616 0b311bb 7d17616 27da720 7d17616 02e4cdf 0b311bb 02e4cdf 0b311bb 02e4cdf 7d17616 962191f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | /**
* Convert backend LLM messages (litellm format) to Vercel AI SDK UIMessage format.
*/
import type { UIMessage } from 'ai';
interface LLMToolCall {
id: string;
function: { name: string; arguments: string };
}
interface LLMMessage {
role: 'user' | 'assistant' | 'tool' | 'system';
content: string | null;
tool_calls?: LLMToolCall[] | null;
tool_call_id?: string | null;
name?: string | null;
}
// Generate stable IDs based on message position to prevent duplicate renders
// when the same message is re-converted multiple times (e.g., during polling)
let uiMessageCounter = 0;
function nextId(): string {
return `msg-${++uiMessageCounter}`;
}
/**
* @param pendingApprovalIds - Set of tool_call_ids that are waiting for approval.
* When provided, matching tool calls without results will get state
* 'approval-requested' instead of 'input-available'.
* @param existingUIMessages - Current UI messages to preserve IDs when content matches.
* This prevents React from re-rendering messages with new IDs during polling.
*/
export function llmMessagesToUIMessages(
messages: LLMMessage[],
pendingApprovalIds?: Set<string>,
existingUIMessages?: UIMessage[],
): UIMessage[] {
// Build a map of tool_call_id -> tool result for pairing
const toolResults = new Map<string, { output: string; isError: boolean }>();
for (const msg of messages) {
if (msg.role === 'tool' && msg.tool_call_id) {
toolResults.set(msg.tool_call_id, {
output: msg.content || '',
isError: false,
});
}
}
const uiMessages: UIMessage[] = [];
// Helper to get existing message ID at a given position if roles match
const getExistingId = (index: number, role: 'user' | 'assistant'): string | null => {
if (!existingUIMessages || index >= existingUIMessages.length) return null;
const existing = existingUIMessages[index];
return existing.role === role ? existing.id : null;
};
for (const msg of messages) {
if (msg.role === 'system') continue;
if (msg.role === 'tool') continue; // handled via tool_calls pairing
if (msg.role === 'user') {
// Skip internal system-style nudges (doom-loop correction, compact
// hints, restore notices, etc.) — they're meant for the LLM, not
// the user. They always start with "[SYSTEM:".
if (typeof msg.content === 'string' && msg.content.trimStart().startsWith('[SYSTEM:')) {
continue;
}
// Try to reuse existing ID if the message at this position matches
const existingId = getExistingId(uiMessages.length, 'user');
uiMessages.push({
id: existingId || nextId(),
role: 'user',
parts: [{ type: 'text', text: msg.content || '' }],
});
continue;
}
if (msg.role === 'assistant') {
const parts: UIMessage['parts'] = [];
if (msg.content) {
parts.push({ type: 'text', text: msg.content });
}
if (msg.tool_calls) {
for (const tc of msg.tool_calls) {
let input: Record<string, unknown> = {};
try {
input = JSON.parse(tc.function.arguments);
} catch { /* malformed */ }
const result = toolResults.get(tc.id);
if (result) {
parts.push({
type: 'dynamic-tool',
toolCallId: tc.id,
toolName: tc.function.name,
state: 'output-available',
input,
output: result.output,
});
} else if (pendingApprovalIds?.has(tc.id)) {
parts.push({
type: 'dynamic-tool',
toolCallId: tc.id,
toolName: tc.function.name,
state: 'approval-requested',
input,
approval: { id: `approval-${tc.id}` },
});
} else {
parts.push({
type: 'dynamic-tool',
toolCallId: tc.id,
toolName: tc.function.name,
state: 'input-available',
input,
});
}
}
}
// During live streaming the SDK groups all text + tool parts between
// user messages into one assistant UIMessage (one start/finish pair per
// turn). The backend stores multiple assistant messages per turn (one
// per LLM API call), so merge consecutive assistant messages to match.
const prev = uiMessages[uiMessages.length - 1];
if (prev && prev.role === 'assistant') {
prev.parts.push(...parts);
} else {
// Try to reuse existing ID if the message at this position matches
const existingId = getExistingId(uiMessages.length, 'assistant');
const newId = existingId || nextId();
uiMessages.push({
id: newId,
role: 'assistant',
parts,
});
}
}
}
return uiMessages;
}
interface ToolPart {
type: string;
toolCallId?: string;
toolName?: string;
state?: string;
input?: unknown;
output?: unknown;
errorText?: string;
}
function joinText(parts: UIMessage['parts']): string {
return parts
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('');
}
function stringifyOutput(output: unknown): string {
if (output == null) return '';
if (typeof output === 'string') return output;
try {
return JSON.stringify(output);
} catch {
return String(output);
}
}
/**
* Reverse of llmMessagesToUIMessages — used as a fallback when we need to
* restore a session but only have the UIMessage cache (e.g. the session
* predates the backend-message cache feature).
*
* Includes every tool call the assistant made, regardless of the part's
* stored state. If we have a captured output (or errorText), we emit a
* paired role=tool result. If we don't, we leave the tool_call dangling —
* the backend's ContextManager patches those via _patch_dangling_tool_calls.
*/
export function uiMessagesToLLMMessages(uiMessages: UIMessage[]): LLMMessage[] {
const out: LLMMessage[] = [];
for (const msg of uiMessages) {
if (msg.role === 'user') {
const text = joinText(msg.parts);
if (text) out.push({ role: 'user', content: text });
continue;
}
if (msg.role === 'assistant') {
const text = joinText(msg.parts);
const toolCalls: LLMToolCall[] = [];
const pairedResults: Array<{ id: string; content: string }> = [];
for (const raw of msg.parts as ToolPart[]) {
if (!raw.type) continue;
const isTool = raw.type === 'dynamic-tool' || raw.type.startsWith('tool-');
if (!isTool) continue;
const toolCallId = raw.toolCallId;
const toolName =
raw.toolName ?? (raw.type.startsWith('tool-') ? raw.type.slice(5) : undefined);
if (!toolCallId || !toolName) continue;
toolCalls.push({
id: toolCallId,
function: {
name: toolName,
arguments: JSON.stringify(raw.input ?? {}),
},
});
// Prefer output; fall back to errorText for output-error /
// output-denied. A missing result leaves the tool_call dangling —
// the backend will patch it with a synthesized stub.
const result =
raw.output != null
? stringifyOutput(raw.output)
: typeof raw.errorText === 'string' && raw.errorText
? raw.errorText
: null;
if (result != null) {
pairedResults.push({ id: toolCallId, content: result });
}
}
if (text || toolCalls.length) {
out.push({
role: 'assistant',
content: text || null,
tool_calls: toolCalls.length ? toolCalls : null,
});
}
for (const r of pairedResults) {
out.push({ role: 'tool', content: r.content, tool_call_id: r.id });
}
}
}
return out;
}
|