feat: file upload retry, secure file tokens, UI polish
- Add HMAC-based file token auth for secure AI model file access - Add file upload retry with exponential backoff (max 3 retries) - File endpoint now requires session auth or signed token - Support assistant role messages in chat input - Optimistic UI for attachments on message send - Verify topic ownership before allowing messages - Switch web scraping to Firecrawl API - Agent profile page layout fixes (proper flex overflow) - Add quick switcher (Ctrl+K) to sidenav - Clean up longcat.ts and stale comments
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { auth } from "~~/lib/auth";
|
||||
import { verifyFileToken } from "~~/server/utils/file-token";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const key = getRouterParam(event, 'key');
|
||||
@@ -6,6 +8,34 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing file key' });
|
||||
}
|
||||
|
||||
const query = getQuery(event);
|
||||
const exp = query.exp ? Number(query.exp) : undefined;
|
||||
const sig = query.sig as string | undefined;
|
||||
|
||||
let authorized = false;
|
||||
|
||||
// Path 1: HMAC token (for AI model access)
|
||||
if (exp && sig && process.env.BETTER_AUTH_SECRET) {
|
||||
authorized = verifyFileToken(key, exp, sig, process.env.BETTER_AUTH_SECRET);
|
||||
}
|
||||
|
||||
// Path 2: Session auth (for client-side access)
|
||||
if (!authorized) {
|
||||
try {
|
||||
const sessionData = await auth.api.getSession(event);
|
||||
if (sessionData) {
|
||||
event.context.user = sessionData.user;
|
||||
authorized = true;
|
||||
}
|
||||
} catch {
|
||||
// No valid session
|
||||
}
|
||||
}
|
||||
|
||||
if (!authorized) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const s3 = new S3Client({
|
||||
@@ -24,14 +54,12 @@ export default defineEventHandler(async (event) => {
|
||||
Key: key,
|
||||
}));
|
||||
|
||||
// 3. Set the correct headers so the browser knows what it's receiving
|
||||
setHeaders(event, {
|
||||
'Content-Type': response.ContentType || 'application/octet-stream',
|
||||
'Content-Length': response.ContentLength?.toString() || '',
|
||||
'Cache-Control': 'public, max-age=3600', // Optional: cache for 1 hour
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
});
|
||||
|
||||
// 4. Return the body as a stream directly to the client
|
||||
return response.Body;
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NoSuchKey') {
|
||||
|
||||
@@ -44,6 +44,11 @@ export default defineEventHandler(async (event) => {
|
||||
},
|
||||
with: {
|
||||
parts: true,
|
||||
attachments: {
|
||||
with: {
|
||||
file: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -160,8 +165,18 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
let prompt = firstMessage.content!;
|
||||
|
||||
if (firstMessage.attachments.length > 0) {
|
||||
for (const attachment of firstMessage.attachments) {
|
||||
if (attachment.file.mimeType.startsWith('image/')) {
|
||||
prompt += `\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const abortController = addPendingRename(topicId);
|
||||
event.waitUntil(autoRename(topicId, abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, firstMessage.content!, userId));
|
||||
event.waitUntil(autoRename(topicId, abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, prompt, userId));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as z from 'zod';
|
||||
import { type MessageEntity } from '~/composables/useChat';
|
||||
import { promises as fs } from 'fs';
|
||||
import { glob } from 'glob';
|
||||
import { isLoopFinished, type ModelMessage, rerank, type RerankingModel, streamText, type StreamTextTransform, Tool, tool } from "ai";
|
||||
import { isLoopFinished, type ModelMessage, rerank, type RerankingModel, streamText, type StreamTextTransform, type Tool, tool } from "ai";
|
||||
import { generations, messageParts, messages, toolCalls, ToolCallType } from "~~/drizzle/schema";
|
||||
import { topicEvents } from "~~/server/utils/events";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -13,6 +13,7 @@ import { eq } from "drizzle-orm";
|
||||
import { buildFocusedMessageTree, buildMessageTree, marshallMessages } from "~~/utils/message";
|
||||
import path from "path";
|
||||
import { isRerankingProvider } from "~~/server/utils/ai-provider";
|
||||
import { generateFileToken } from "~~/server/utils/file-token";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
@@ -237,7 +238,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
agentmessage.generationId = generation.id;
|
||||
// @ts-ignore
|
||||
// @ts-ignore - doesnt exist on the type but yeah it does now
|
||||
agentmessage.generation = generation;
|
||||
|
||||
return agentmessage;
|
||||
@@ -311,7 +312,13 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const topicMessages = marshallMessages(topic.agent, buildFocusedMessageTree(topicMessageTree));
|
||||
const fileTokenSecret = process.env.BETTER_AUTH_SECRET!;
|
||||
const topicMessages = marshallMessages(topic.agent, buildFocusedMessageTree(topicMessageTree), {
|
||||
signFileUrl: (fileKey) => {
|
||||
const { exp, sig } = generateFileToken(fileKey, fileTokenSecret);
|
||||
return `exp=${exp}&sig=${sig}`;
|
||||
},
|
||||
});
|
||||
if (topicMessages.ok === false) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
@@ -353,7 +360,7 @@ const formatPartId = (partType: string, existingId: string) => {
|
||||
};
|
||||
|
||||
const formatToolCallId = (nativeId: string) => {
|
||||
return `veridian__tool-${nativeId}-${nanoid()}`;
|
||||
return `veridian__tool-${nativeId.slice(0, 16)}-${nanoid()}`;
|
||||
};
|
||||
|
||||
const evalPython = async (code: string) => {
|
||||
@@ -528,10 +535,20 @@ const { listDirectoryTool, globTool, readFileTool, readFilesTool, fetchUrlTool,
|
||||
content: z.string(),
|
||||
}),
|
||||
execute: async ({ url }) => {
|
||||
const response = await fetch(url);
|
||||
const content = await response.text();
|
||||
const response = await fetch('https://api.firecrawl.dev/v2/scrape', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + process.env.FIRECRAWL_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
}),
|
||||
});
|
||||
const content = await response.json();
|
||||
console.log(content);
|
||||
return {
|
||||
content,
|
||||
content: content.data.markdown,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -629,7 +646,7 @@ async function generateResponse(
|
||||
glob: globTool,
|
||||
readFile: readFileTool,
|
||||
readFiles: readFilesTool,
|
||||
// fetchUrl: fetchUrlTool,
|
||||
fetchUrl: fetchUrlTool,
|
||||
python: pythonTool,
|
||||
bash: bashTool,
|
||||
};
|
||||
@@ -657,13 +674,14 @@ async function generateResponse(
|
||||
const response = streamText({
|
||||
model: model.gateway(model.model.externalId),
|
||||
messages,
|
||||
allowSystemInMessages: true,
|
||||
providerOptions: {
|
||||
openrouter: {
|
||||
debug: {
|
||||
echo_upstream_body: true,
|
||||
},
|
||||
user: userId,
|
||||
}
|
||||
},
|
||||
},
|
||||
experimental_transform: streamTransoforms,
|
||||
stopWhen: isLoopFinished(),
|
||||
@@ -959,7 +977,7 @@ async function generateResponse(
|
||||
throw new Error('Failed to insert message part');
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-ignore - doesnt exist on the type but yeah it does now
|
||||
part.toolCall = toolCall;
|
||||
|
||||
await topicEvents.emit(topicId, {
|
||||
@@ -1005,6 +1023,11 @@ async function generateResponse(
|
||||
},
|
||||
})
|
||||
.where(eq(toolCalls.id, dbToolCallId));
|
||||
if (token.providerMetadata) await db.update(messageParts)
|
||||
.set({
|
||||
providerOptions: token.providerMetadata,
|
||||
})
|
||||
.where(eq(messageParts.toolCallId, dbToolCallId));
|
||||
|
||||
await topicEvents.emit(topicId, {
|
||||
type: 'tool-call-delta',
|
||||
@@ -1049,6 +1072,7 @@ async function generateResponse(
|
||||
topicId,
|
||||
messageId: message.id,
|
||||
toolCallId: dbToolCallId,
|
||||
providerOptions: token.providerMetadata,
|
||||
type: 'tool-call',
|
||||
content: null,
|
||||
finished: false,
|
||||
@@ -1060,7 +1084,7 @@ async function generateResponse(
|
||||
throw new Error('Failed to insert message part');
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-ignore - doesnt exist on the type but yeah it does now
|
||||
part.toolCall = toolCall;
|
||||
|
||||
await topicEvents.emit(topicId, {
|
||||
@@ -1097,6 +1121,11 @@ async function generateResponse(
|
||||
status: 'failed',
|
||||
error: { type: ToolCallType.Text, value: 'Tool returned invalid output' }
|
||||
}).where(eq(toolCalls.id, dbToolCallId));
|
||||
if (token.providerMetadata) await db.update(messageParts)
|
||||
.set({
|
||||
providerOptions: token.providerMetadata,
|
||||
})
|
||||
.where(eq(messageParts.toolCallId, dbToolCallId));
|
||||
|
||||
await topicEvents.emit(topicId, {
|
||||
type: 'tool-call-delta',
|
||||
@@ -1125,6 +1154,11 @@ async function generateResponse(
|
||||
value: outputValue,
|
||||
},
|
||||
}).where(eq(toolCalls.id, dbToolCallId));
|
||||
if (token.providerMetadata) await db.update(messageParts)
|
||||
.set({
|
||||
providerOptions: token.providerMetadata,
|
||||
})
|
||||
.where(eq(messageParts.toolCallId, dbToolCallId));
|
||||
|
||||
await topicEvents.emit(topicId, {
|
||||
type: 'tool-call-delta',
|
||||
@@ -1176,6 +1210,11 @@ async function generateResponse(
|
||||
value: outputValue as string,
|
||||
}
|
||||
}).where(eq(toolCalls.id, existingDbToolCallId));
|
||||
if (token.providerMetadata) await db.update(messageParts)
|
||||
.set({
|
||||
providerOptions: token.providerMetadata,
|
||||
})
|
||||
.where(eq(messageParts.toolCallId, existingDbToolCallId));
|
||||
|
||||
await topicEvents.emit(topicId, {
|
||||
type: 'tool-call-delta',
|
||||
@@ -1220,6 +1259,7 @@ async function generateResponse(
|
||||
topicId,
|
||||
messageId: message.id,
|
||||
toolCallId: dbToolCallId,
|
||||
providerOptions: token.providerMetadata,
|
||||
type: 'tool-call',
|
||||
content: null,
|
||||
finished: false,
|
||||
@@ -1250,12 +1290,8 @@ async function generateResponse(
|
||||
|
||||
case 'finish': {
|
||||
let tps;
|
||||
if (ttft !== undefined && token.totalUsage.outputTokens !== undefined) {
|
||||
const tokenStreamStart = requestStart! + ttft;
|
||||
// this is the *real* request duration, excluding the
|
||||
// TTFT
|
||||
const requestDuration = performance.now() - tokenStreamStart;
|
||||
|
||||
if (requestStart !== undefined && token.totalUsage.outputTokens !== undefined) {
|
||||
const requestDuration = performance.now() - requestStart;
|
||||
tps = token.totalUsage.outputTokens / (requestDuration / 1000);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { modelMessageSchema } from 'ai';
|
||||
import * as z from 'zod';
|
||||
import { attachments, messages } from '~~/drizzle/schema';
|
||||
import { attachments, messageParts, messages } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -9,6 +9,21 @@ export default defineEventHandler(async (event) => {
|
||||
const topicId = getRouterParam(event, 'topicId')!;
|
||||
const userId = event.context.user!.id;
|
||||
|
||||
const topic = await db.query.topics.findFirst({
|
||||
where: {
|
||||
id: topicId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!topic) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Not Found',
|
||||
message: 'Topic not found',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await readValidatedBody(event, z.object({
|
||||
message: z.intersection(
|
||||
z.object({
|
||||
@@ -19,6 +34,7 @@ export default defineEventHandler(async (event) => {
|
||||
),
|
||||
}).safeParse);
|
||||
if (!result.success) {
|
||||
console.log(result.error);
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Bad Request',
|
||||
@@ -28,49 +44,110 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const { message } = result.data;
|
||||
|
||||
await db.insert(messages).values({
|
||||
// @ts-ignore - drizzle bug
|
||||
id: message.id,
|
||||
userId,
|
||||
topicId,
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
});
|
||||
|
||||
for (const fileId of message.fileIds || []) {
|
||||
await db.insert(attachments).values({
|
||||
userId,
|
||||
topicId,
|
||||
messageId: message.id,
|
||||
fileId,
|
||||
});
|
||||
}
|
||||
|
||||
const usermessage = await db.query.messages.findFirst({
|
||||
where: {
|
||||
id: message.id,
|
||||
},
|
||||
with: {
|
||||
attachments: {
|
||||
with: {
|
||||
file: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!usermessage) {
|
||||
if (['user', 'assistant'].includes(message.role) === false) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to insert message',
|
||||
message: 'Failed to insert message',
|
||||
statusCode: 400,
|
||||
statusMessage: 'Bad Request',
|
||||
message: 'Invalid role',
|
||||
});
|
||||
}
|
||||
topicEvents.emit(topicId, { type: 'MESSAGE_CREATED', payload: usermessage });
|
||||
|
||||
return {
|
||||
ok: true
|
||||
};
|
||||
switch (message.role) {
|
||||
case 'user': {
|
||||
await db.insert(messages).values({
|
||||
// @ts-ignore - drizzle bug
|
||||
id: message.id,
|
||||
userId,
|
||||
topicId,
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
});
|
||||
|
||||
for (const fileId of message.fileIds || []) {
|
||||
await db.insert(attachments).values({
|
||||
userId,
|
||||
topicId,
|
||||
messageId: message.id,
|
||||
fileId,
|
||||
});
|
||||
}
|
||||
|
||||
const usermessage = await db.query.messages.findFirst({
|
||||
where: {
|
||||
id: message.id,
|
||||
},
|
||||
with: {
|
||||
attachments: {
|
||||
with: {
|
||||
file: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!usermessage) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to insert message',
|
||||
message: 'Failed to insert message',
|
||||
});
|
||||
}
|
||||
topicEvents.emit(topicId, { type: 'MESSAGE_CREATED', payload: usermessage });
|
||||
|
||||
return {
|
||||
ok: true
|
||||
};
|
||||
}
|
||||
case 'assistant': {
|
||||
const [dbmessage] = await db.insert(messages).values({
|
||||
// @ts-ignore - drizzle bug
|
||||
id: message.id,
|
||||
userId,
|
||||
topicId,
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
}).returning();
|
||||
|
||||
if (!dbmessage) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to insert message',
|
||||
message: 'Failed to insert message',
|
||||
});
|
||||
}
|
||||
|
||||
const [part] = await db.insert(messageParts).values({
|
||||
userId,
|
||||
topicId,
|
||||
messageId: dbmessage.id,
|
||||
type: 'text',
|
||||
content: message.content,
|
||||
providerOptions: null,
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
lastUpdatedAt: new Date(),
|
||||
}).returning();
|
||||
|
||||
// TODO: transaction
|
||||
if (!part) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to insert message part',
|
||||
message: 'Failed to insert message part',
|
||||
});
|
||||
}
|
||||
|
||||
topicEvents.emit(topicId, { type: 'MESSAGE_CREATED', payload: dbmessage });
|
||||
topicEvents.emit(topicId, { type: 'text-start', payload: { messageId: dbmessage.id, part } });
|
||||
topicEvents.emit(topicId, { type: 'text-end', payload: { messageId: dbmessage.id, partId: part.id, lastUpdatedAt: new Date(), content: message.content } });
|
||||
|
||||
return {
|
||||
ok: true
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -39,7 +39,22 @@ export default defineEventHandler(async (event) => {
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
});
|
||||
|
||||
const key = `veridian__uploads/${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.]/g, '_')}-${event.context.user!.id}`
|
||||
const fileParts = file.name.split('.');
|
||||
if (fileParts.length < 2) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid file name',
|
||||
data: {
|
||||
code: 'INVALID_FILE_NAME',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const fileExt = fileParts.pop()!;
|
||||
const fileName = fileParts.join('.').replace(/[^a-zA-Z0-9.]/g, '_');
|
||||
|
||||
const key = `veridian__uploads/${Date.now()}-${fileName}-${event.context.user!.id}.${fileExt}`;
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
ACL: 'public-read',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const DEFAULT_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
export function generateFileToken(
|
||||
fileKey: string,
|
||||
secret: string,
|
||||
expiresInMs = DEFAULT_EXPIRY_MS,
|
||||
): { exp: number; sig: string } {
|
||||
const exp = Date.now() + expiresInMs;
|
||||
const payload = `${fileKey}:${exp}`;
|
||||
const sig = createHmac('sha256', secret).update(payload).digest('hex');
|
||||
return { exp, sig };
|
||||
}
|
||||
|
||||
export function verifyFileToken(
|
||||
fileKey: string,
|
||||
exp: number,
|
||||
sig: string,
|
||||
secret: string,
|
||||
): boolean {
|
||||
if (Date.now() > exp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = `${fileKey}:${exp}`;
|
||||
const expected = createHmac('sha256', secret).update(payload).digest('hex');
|
||||
|
||||
const sigBuffer = Buffer.from(sig, 'hex');
|
||||
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||
|
||||
if (sigBuffer.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(sigBuffer, expectedBuffer);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { type ToolSet, type TextStreamPart, type ToolCallPart } from 'ai';
|
||||
|
||||
export function createLongcatTransformer<TOOLS extends ToolSet>(): (options: {
|
||||
tools: TOOLS;
|
||||
stopStream: () => void;
|
||||
}) => TransformStream<TextStreamPart<TOOLS>, TextStreamPart<TOOLS>> {
|
||||
let buffer = '';
|
||||
let hasToolCallInStep = false;
|
||||
let lastChunkId: string | undefined;
|
||||
let lastChunkType: 'text' | 'reasoning' | undefined;
|
||||
let step = 0;
|
||||
|
||||
return (_opts) => {
|
||||
return new TransformStream<TextStreamPart<TOOLS>, TextStreamPart<TOOLS>>({
|
||||
transform(chunk, controller) {
|
||||
if (chunk.type === 'finish-step' || chunk.type === 'finish') {
|
||||
step++;
|
||||
|
||||
if (hasToolCallInStep) {
|
||||
// We clone the chunk and overwrite the finishReason.
|
||||
// This tricks the SDK into thinking the model requested a tool natively.
|
||||
const modifiedChunk = {
|
||||
...chunk,
|
||||
finishReason: 'tool-calls' as const,
|
||||
};
|
||||
|
||||
// Reset for the next potential step
|
||||
if (chunk.type === 'finish-step') {
|
||||
hasToolCallInStep = false;
|
||||
}
|
||||
|
||||
controller.enqueue(modifiedChunk);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.type === 'text-start' || chunk.type === 'reasoning-start') {
|
||||
lastChunkId = chunk.id;
|
||||
lastChunkType = chunk.type.split('-')[1] as 'text' | 'reasoning';
|
||||
}
|
||||
|
||||
// We only care about text chunks
|
||||
if (chunk.type !== 'text-delta' && chunk.type !== 'reasoning-delta') {
|
||||
controller.enqueue(chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += chunk.text;
|
||||
|
||||
// Check if we have a full tool call in the buffer
|
||||
const pattern = /<longcat_tool_call>([\s\S]*?)<\/longcat_tool_call>/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(buffer)) !== null) {
|
||||
console.log("longcat tool call found at index", match.index);
|
||||
|
||||
// 1. Enqueue any text that appeared BEFORE the tool call
|
||||
const textBefore = buffer.substring(lastIndex, match.index);
|
||||
if (textBefore) {
|
||||
controller.enqueue({ type: chunk.type, text: textBefore, id: lastChunkId ?? chunk.type.includes('reasoning') ? `reasoning-${step}` : `text-${step}` });
|
||||
}
|
||||
|
||||
// 2. Parse the XML content
|
||||
const content = match[1]!.trim();
|
||||
const toolNameMatch = content.match(/^([^\s<]+)/);
|
||||
|
||||
if (toolNameMatch) {
|
||||
hasToolCallInStep = true;
|
||||
|
||||
const toolName = toolNameMatch[1];
|
||||
const args: Record<string, any> = {};
|
||||
const argRegex = /<longcat_arg_key>(.*?)<\/longcat_arg_key>\s*<longcat_arg_value>(.*?)<\/longcat_arg_value>/gs;
|
||||
|
||||
let argMatch;
|
||||
while ((argMatch = argRegex.exec(content)) !== null) {
|
||||
args[argMatch[1]!.trim()] = argMatch[2]!.trim();
|
||||
}
|
||||
|
||||
// 3. EMIT A TOOL CALL PART
|
||||
// This is the "magic" - the SDK will see this and act as if the LLM
|
||||
// called a native tool.
|
||||
const toolCallId = `lc-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
||||
controller.enqueue({
|
||||
type: 'tool-call',
|
||||
// @ts-ignore
|
||||
id: toolCallId,
|
||||
toolCallId,
|
||||
toolName,
|
||||
input: args,
|
||||
dynamic: true,
|
||||
});
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
// Keep the remaining buffer (unclosed tags) for the next chunk
|
||||
buffer = buffer.substring(lastIndex);
|
||||
|
||||
// If there's no open tag starting, we can flush the buffer as text
|
||||
if (!buffer.includes('<longcat_tool_call>')) {
|
||||
if (buffer) {
|
||||
controller.enqueue({ type: chunk.type, text: buffer, id: lastChunkId ?? chunk.type.includes('reasoning') ? `reasoning-${step}` : `text-${step}` });
|
||||
buffer = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
if (buffer && lastChunkId && lastChunkType) {
|
||||
controller.enqueue({ type: `${lastChunkType}-delta`, text: buffer, id: lastChunkId });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,11 @@ export default {
|
||||
}
|
||||
|
||||
return Ok({
|
||||
gateway: createOpenAI({ name: 'ClosedRouter', apiKey, baseURL }),
|
||||
gateway: createOpenAI({
|
||||
name: 'ClosedRouter',
|
||||
apiKey,
|
||||
baseURL
|
||||
}),
|
||||
streamTransformer: undefined,
|
||||
textTransformer: undefined,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user