|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const TAU_API_URL = "https://tau-api.fly.dev/v1/chat"; |
|
const DEFAULT_TAU_MODEL = "anthropic-claude-4-opus"; |
|
const ALLOWED_TAU_MODELS = [ |
|
"google-gemini-2.5-pro", |
|
"anthropic-claude-4-sonnet-thinking", |
|
"anthropic-claude-4-opus", |
|
"anthropic-claude-4-sonnet", |
|
"openai-gpt-4.1", |
|
"openai-gpt-o1", |
|
"openai-gpt-4o" |
|
]; |
|
|
|
|
|
const MODEL_MAP: Record<string, string> = { |
|
"gpt-4o": "openai-gpt-4o", |
|
"gpt-4": "openai-gpt-4.1", |
|
"gpt-3.5-turbo": "openai-gpt-o1", |
|
"claude-3-opus-20240229": "anthropic-claude-4-opus", |
|
"claude-3-sonnet-20240229": "anthropic-claude-4-sonnet", |
|
|
|
}; |
|
|
|
|
|
const TAU_API_KEY = Deno.env.get("TAU_API_KEY"); |
|
if (!TAU_API_KEY) { |
|
console.warn("TAU_API_KEY environment variable is not set. Requests to Tau API might fail if authentication is required."); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
function generateId(prefix: string = ""): string { |
|
return `${prefix}${crypto.randomUUID().replace(/-/g, '')}`; |
|
} |
|
|
|
|
|
function getCurrentTimestamp(): number { |
|
return Math.floor(Date.now() / 1000); |
|
} |
|
|
|
|
|
function parseTauStreamLine(line: string): { prefix: string | null, content: string } { |
|
console.debug(`Raw stream line received: "${line.replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`); |
|
|
|
const colonIndex = line.indexOf(':'); |
|
if (colonIndex === -1) { |
|
console.warn("Stream line missing prefix colon:", line); |
|
return { prefix: null, content: line }; |
|
} |
|
const prefix = line.substring(0, colonIndex); |
|
let content = line.substring(colonIndex + 1); |
|
|
|
|
|
if (prefix === '0' || prefix === 'g') { |
|
|
|
if (content.startsWith('"') && content.endsWith('"')) { |
|
content = content.substring(1, content.length - 1); |
|
} |
|
|
|
content = content.replace(/""/g, '"'); |
|
|
|
} |
|
|
|
|
|
console.debug(`Parsed line: Prefix: "${prefix}", Cleaned Content string (contains actual newlines): "${content.replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`); |
|
|
|
return { prefix, content }; |
|
} |
|
|
|
|
|
function tauLineToOpenAIChunk( |
|
line: string, |
|
completionId: string, |
|
createdAt: number, |
|
model: string |
|
): { sse: string | null, isDone: boolean, finishReason: string | null, usage: any | null } { |
|
const { prefix, content } = parseTauStreamLine(line); |
|
|
|
let delta: any = {}; |
|
let finishReason: string | null = null; |
|
let isDone = false; |
|
let usage: any | null = null; |
|
|
|
if (prefix === '0') { |
|
delta.content = content; |
|
console.debug(`SSE Chunk (0:): Content string being put into delta: "${content.replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`); |
|
} else if (prefix === 'g') { |
|
delta.reasoning_content = content; |
|
console.debug(`SSE Chunk (g:): Reasoning string being put into delta: "${content.replace(/\n/g, "\\n").replace(/\r{/g, "\\r")}"`); |
|
} else if (prefix === 'e' || prefix === 'd') { |
|
try { |
|
const data = JSON.parse(content); |
|
if (data.finishReason) { |
|
finishReason = data.finishReason; |
|
isDone = true; |
|
console.debug(`SSE Chunk (e/d:): Found finish reason: ${finishReason}`); |
|
} |
|
if (data.usage) { |
|
usage = { |
|
prompt_tokens: data.usage.inputTokens || 0, |
|
completion_tokens: data.usage.outputTokens || 0, |
|
total_tokens: (data.usage.inputTokens || 0) + (data.usage.outputTokens || 0), |
|
}; |
|
console.debug(`SSE Chunk (e/d:): Found usage: ${JSON.stringify(usage)}`); |
|
} |
|
} catch (e) { |
|
console.error("Failed to parse JSON from e/d prefix:", content, e); |
|
} |
|
} else if (prefix === '8') { |
|
try { |
|
const data = JSON.parse(content); |
|
if (data.usageCost && data.usageTokens) { |
|
usage = { |
|
prompt_tokens: data.usageTokens.inputTokens || 0, |
|
completion_tokens: data.usageTokens.outputTokens || 0, |
|
total_tokens: (data.usageTokens.inputTokens || 0) + (data.usageTokens.outputTokens || 0), |
|
}; |
|
console.debug(`SSE Chunk (8:): Found usage: ${JSON.stringify(usage)}`); |
|
} |
|
} catch (e) { |
|
console.error("Failed to parse JSON from 8 prefix:", content, e); |
|
} |
|
} else if (prefix === null) { |
|
return { sse: null, isDone: false, finishReason: null, usage: null }; |
|
} else { |
|
console.warn("Received unknown Tau stream prefix:", prefix, "content:", content); |
|
return { sse: null, isDone: false, finishReason: null, usage: null }; |
|
} |
|
|
|
if (Object.keys(delta).length === 0 && !finishReason && !usage) { |
|
return { sse: null, isDone: false, finishReason: null, usage: null }; |
|
} |
|
|
|
const chunk: any = { |
|
id: completionId, |
|
object: "chat.completion.chunk", |
|
created: createdAt, |
|
model: model, |
|
choices: [{ |
|
index: 0, |
|
delta: delta, // delta now contains the cleaned string with actual newlines |
|
logprobs: null, |
|
finish_reason: finishReason |
|
}] |
|
}; |
|
|
|
// JSON.stringify will automatically escape the actual newlines (\n) in delta strings to \\n |
|
const sseData = JSON.stringify(chunk); |
|
const sseString = `data: ${sseData}\n\n`; |
|
|
|
// Log the final SSE string *exactly* as it's being sent over the wire |
|
console.debug(`SSE Chunk: Final data line being sent: "${sseString.replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`); |
|
|
|
|
|
return { sse: sseString, isDone: isDone, finishReason: finishReason, usage: usage }; |
|
} |
|
|
|
// --- Endpoint Handlers --- |
|
|
|
function handleListModels(): Response { |
|
const models = ALLOWED_TAU_MODELS.map(modelName => ({ |
|
id: modelName, |
|
object: "model", |
|
created: getCurrentTimestamp(), |
|
owned_by: "tau-proxy", |
|
})); |
|
|
|
const responseBody = { |
|
object: "list", |
|
data: models, |
|
}; |
|
|
|
return new Response(JSON.stringify(responseBody, null, 2), { |
|
headers: { "Content-Type": "application/json" }, |
|
status: 200, |
|
}); |
|
} |
|
|
|
async function handleChatCompletions(request: Request): Promise<Response> { |
|
let reqBody: any; |
|
try { |
|
reqBody = await request.json(); |
|
} catch (error) { |
|
console.error("Failed to parse request body:", error);; |
|
return new Response(JSON.stringify({ error: { message: "Invalid JSON body", type: "invalid_request_error" } }), { |
|
status: 400, |
|
headers: { "Content-Type": "application/json" }, |
|
});; |
|
} |
|
|
|
if (!Array.isArray(reqBody.messages) || reqBody.messages.length === 0) { |
|
return new Response(JSON.stringify({ error: { message: "Request body must contain a non-empty 'messages' array", type: "invalid_request_error" } }), { |
|
status: 400, |
|
headers: { "Content-Type": "application/json" }, |
|
});; |
|
} |
|
|
|
const clientRequestedModel = reqBody.model; |
|
const stream = reqBody.stream === true; |
|
|
|
let tauModel = DEFAULT_TAU_MODEL; |
|
if (clientRequestedModel) { |
|
const mappedModel = MODEL_MAP[clientRequestedModel]; |
|
if (mappedModel && ALLOWED_TAU_MODELS.includes(mappedModel)) { |
|
tauModel = mappedModel; |
|
} else if (ALLOWED_TAU_MODELS.includes(clientRequestedModel)) { |
|
tauModel = clientRequestedModel; |
|
} else { |
|
console.warn(`Client requested model "${clientRequestedModel}" not found in mapping or allowed Tau models. Using default: "${DEFAULT_TAU_MODEL}"`); |
|
} |
|
} else { |
|
console.log(`No model specified by client. Using default: "${DEFAULT_TAU_MODEL}"`); |
|
} |
|
|
|
|
|
let modelAdded = false; |
|
const tauRequestMessages = reqBody.messages.map((msg: any) => { |
|
const messageId = generateId("msg_"); |
|
const createdAt = new Date().toISOString(); |
|
const role = msg.role; |
|
|
|
let parts: any[] = []; |
|
if (typeof msg.content === 'string' && msg.content.length > 0) { |
|
parts.push({ type: "text", text: msg.content }); |
|
} else if (Array.isArray(msg.content)) { |
|
parts = msg.content.filter((part: any) => part.type === 'text' && part.text && part.text.length > 0).map((part: any) => ({ type: "text", text: part.text })); |
|
if (parts.length === 0 && msg.content.length > 0) { |
|
console.warn("Unsupported non-text multimodal content in message:", msg.content); |
|
} |
|
} |
|
|
|
const tauMessage: any = { |
|
id: messageId, |
|
content: "", // As per example |
|
role: role, |
|
parts: parts, |
|
metadata: {}, // As per example |
|
createdAt: createdAt, |
|
}; |
|
|
|
if (!modelAdded && role === 'user') { |
|
tauMessage.model = tauModel; |
|
modelAdded = true; |
|
console.log(`Added model ${tauModel} to the first user message.`); |
|
} else if (role === 'assistant') { |
|
tauMessage.content = typeof msg.content === 'string' ? msg.content : ''; |
|
tauMessage.parts = []; |
|
if (typeof msg.content !== 'string' && msg.content != null) { |
|
console.warn(`Assistant message content is not a string and cannot be mapped to Tau's content field: ${typeof msg.content}`); |
|
} |
|
} |
|
|
|
return tauMessage; |
|
}); |
|
|
|
const tauRequestId = generateId("bld_"); |
|
|
|
const tauRequestBody = { |
|
id: tauRequestId, |
|
messages: tauRequestMessages, |
|
}; |
|
|
|
console.log("Sending request to Tau API:", JSON.stringify(tauRequestBody, null, 2)); |
|
|
|
const headers: HeadersInit = { |
|
"Content-Type": "application/json", |
|
}; |
|
if (TAU_API_KEY) { |
|
headers["Authorization"] = `Bearer ${TAU_API_KEY}`; |
|
} |
|
|
|
|
|
let tauResponse: Response; |
|
try { |
|
tauResponse = await fetch(TAU_API_URL, { |
|
method: "POST", |
|
headers: headers, |
|
body: JSON.stringify(tauRequestBody), |
|
}); |
|
} catch (error) { |
|
console.error("Failed to connect to Tau API:", error);; |
|
return new Response(JSON.stringify({ error: { message: `Failed to connect to upstream API: ${error.message}`, type: "upstream_error" } }), { |
|
status: 500, |
|
headers: { "Content-Type": "application/json" }, |
|
});; |
|
} |
|
|
|
if (!tauResponse.ok) { |
|
const errorBody = await tauResponse.text(); |
|
console.error(`Tau API returned status ${tauResponse.status}: ${errorBody}`);; |
|
let errorJson = null; |
|
try { |
|
errorJson = JSON.parse(errorBody); |
|
} catch (e) { /* Not JSON */ } |
|
|
|
return new Response(JSON.stringify({ |
|
error: { |
|
message: `Upstream API error: ${tauResponse.status} - ${errorBody}`, |
|
type: "upstream_error", |
|
details: errorJson |
|
} |
|
}), { |
|
status: tauResponse.status >= 400 && tauResponse.status < 500 ? 400 : 502, |
|
headers: { "Content-Type": "application/json" }, |
|
});; |
|
} |
|
|
|
// --- Handle Tau API Response --- |
|
|
|
const completionId = generateId("chatcmpl-"); |
|
const createdAt = getCurrentTimestamp(); |
|
|
|
if (stream) { |
|
// --- Streaming Response --- |
|
const reader = tauResponse.body!.getReader(); |
|
const { readable, writable } = new TransformStream(); |
|
const writer = writable.getWriter(); |
|
const encoder = new TextEncoder(); |
|
const decoder = new TextDecoder(); |
|
|
|
async function processStream() { |
|
let buffer = ""; |
|
let finished = false; |
|
|
|
try { |
|
while (!finished) { |
|
const { done, value } = await reader.read(); |
|
|
|
if (done) { |
|
finished = true; |
|
} else { |
|
buffer += decoder.decode(value, { stream: true }); |
|
} |
|
|
|
let newlineIndex; |
|
while ((newlineIndex = buffer.indexOf('\n')) !== -1) { |
|
const line = buffer.substring(0, newlineIndex); |
|
buffer = buffer.substring(newlineIndex + 1); |
|
|
|
if (line.trim() === "") continue; |
|
// parseTauStreamLine logs raw line and cleaned content |
|
|
|
const { sse, isDone, finishReason, usage } = tauLineToOpenAIChunk(line, completionId, createdAt, tauModel); |
|
|
|
if (sse) { |
|
await writer.write(encoder.encode(sse)); |
|
} |
|
|
|
if (isDone) { |
|
finished = true; |
|
} |
|
} |
|
|
|
if (finished && buffer.length > 0) { |
|
console.warn("Processing leftover buffer after stream end:", buffer);; |
|
const { sse, isDone: lastIsDone, finishReason: lastFinishReason, usage: lastChunkUsage } = tauLineToOpenAIChunk(buffer, completionId, createdAt, tauModel); |
|
if (sse) { |
|
await writer.write(encoder.encode(sse)); |
|
} |
|
finished = finished || lastIsDone; |
|
buffer = ""; |
|
} |
|
} |
|
} catch (error) { |
|
console.error("Stream processing error:", error);; |
|
try { |
|
await writer.write(encoder.encode(`data: ${JSON.stringify({ error: { message: `Stream error: ${error.message}`, type: "stream_error" } })}\n\n`)); |
|
} catch (writeError) { console.error("Failed to write error message:", writeError);; } |
|
} finally { |
|
try { |
|
await writer.write(encoder.encode("data: [DONE]\n\n")); |
|
await writer.close(); |
|
} catch (closeError) { console.error("Failed to send DONE or close stream:", closeError);; } |
|
} |
|
} |
|
|
|
processStream(); |
|
|
|
return new Response(readable, { |
|
headers: { |
|
"Content-Type": "text/event-stream", |
|
"Cache-Control": "no-cache", |
|
"Connection": "keep-alive", |
|
}, |
|
});; |
|
|
|
} else { |
|
// --- Non-Streaming Response --- |
|
let buffer = ""; |
|
const reader = tauResponse.body!.getReader(); |
|
const decoder = new TextDecoder(); |
|
|
|
let combinedContent = ""; |
|
let combinedReasoningContent = ""; |
|
let finishReason: string | null = null; |
|
let usageData: any | null = null; |
|
|
|
try { |
|
while (true) { |
|
const { done, value } = await reader.read(); |
|
buffer += decoder.decode(value, { stream: !done }); |
|
|
|
if (done) { |
|
break; |
|
} |
|
|
|
let newlineIndex; |
|
while ((newlineIndex = buffer.indexOf('\n')) !== -1) { |
|
const line = buffer.substring(0, newlineIndex); |
|
buffer = buffer.substring(newlineIndex + 1); |
|
|
|
if (line.trim() === "") continue; |
|
// parseTauStreamLine logs raw line and cleaned content |
|
|
|
const { prefix, content } = parseTauStreamLine(line); |
|
|
|
if (prefix === '0') { |
|
combinedContent += content; // Use the cleaned content |
|
console.debug(`Non-stream: Appended to combinedContent string: "${content.replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`); |
|
} else if (prefix === 'g') { |
|
combinedReasoningContent += content; // Use the cleaned content |
|
console.debug(`Non-stream: Appended to combinedReasoningContent string: "${content.replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`); |
|
} else if (prefix === 'e' || prefix === 'd') { |
|
try { |
|
const data = JSON.parse(content); |
|
if (data.finishReason) { |
|
finishReason = data.finishReason; |
|
console.debug(`Non-stream: Found finish reason: ${finishReason}`); |
|
} |
|
if (data.usage) { |
|
usageData = { |
|
prompt_tokens: data.usage.inputTokens || 0, |
|
completion_tokens: data.usage.outputTokens || 0, |
|
total_tokens: (data.usage.inputTokens || 0) + (data.usage.outputTokens || 0), |
|
}; |
|
console.debug(`Non-stream: Found usage (e/d): ${JSON.stringify(usageData)}`); |
|
} |
|
} catch (e) { |
|
console.error("Failed to parse JSON from e/d prefix (non-stream):", content, e);; |
|
} |
|
} else if (prefix === '8') { |
|
try { |
|
const data = JSON.parse(content); |
|
if (data.usageCost && data.usageTokens) { |
|
usageData = { |
|
prompt_tokens: data.usageTokens.inputTokens || 0, |
|
completion_tokens: data.usageTokens.outputTokens || 0, |
|
total_tokens: (data.usageTokens.inputTokens || 0) + (data.usageTokens.outputTokens || 0), |
|
}; |
|
console.debug(`Non-stream: Found usage (8): ${JSON.stringify(usageData)}`); |
|
} |
|
} catch (e) { |
|
console.error("Failed to parse JSON from 8 prefix (non-stream):", content, e);; |
|
} |
|
} else if (prefix === null) { |
|
console.warn("Ignoring non-stream line with no prefix:", line); |
|
} else { |
|
console.warn("Received unknown Tau non-stream prefix:", prefix, "content:", content); |
|
} |
|
} |
|
} |
|
} catch (error) { |
|
console.error("Error reading Tau API response (non-stream):", error);; |
|
return new Response(JSON.stringify({ error: { message: `Error processing upstream response: ${error.message}`, type: "upstream_error" } }), { |
|
status: 500, |
|
headers: { "Content-Type": "application/json" }, |
|
});; |
|
} |
|
|
|
// Process any remaining buffer after the loop |
|
if (buffer.length > 0) { |
|
console.warn("Non-stream buffer leftover:", buffer);; |
|
const lines = buffer.split('\n'); |
|
for(const line of lines) { |
|
if (line.trim() === "") continue; |
|
const { prefix, content } = parseTauStreamLine(line); |
|
if (prefix === '0') { |
|
combinedContent += content; // Use the cleaned content |
|
console.debug(`Non-stream: Appended leftover to combinedContent string: "${content.replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`); |
|
} else if (prefix === 'g') { |
|
combinedReasoningContent += content; // Use the cleaned content |
|
console.debug(`Non-stream: Appended leftover to combinedReasoningContent string: "${content.replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`); |
|
} else if (prefix === 'e' || prefix === 'd') { |
|
try { |
|
const data = JSON.parse(content); |
|
if (data.finishReason) finishReason = data.finishReason; |
|
if (data.usage) usageData = { prompt_tokens: data.usage.inputTokens || 0, completion_tokens: data.usage.completionTokens || 0, total_tokens: (data.usage.inputTokens || 0) + (data.usage.completionTokens || 0) }; |
|
console.debug(`Non-stream: Found leftover usage/finish (e/d): ${JSON.stringify(usageData || { finishReason })}`); |
|
} catch (e) { console.warn("Failed to parse leftover e/d:", buffer);; } |
|
} else if (prefix === '8') { |
|
try { |
|
const data = JSON.parse(content); |
|
if (data.usageCost && data.usageTokens) { usageData = { prompt_tokens: data.usageTokens.inputTokens || 0, completion_tokens: data.usageTokens.outputTokens || 0, total_tokens: (data.usageTokens.inputTokens || 0) + (data.usageTokens.outputTokens || 0) }; } |
|
console.debug(`Non-stream: Found leftover usage (8): ${JSON.stringify(usageData)}`); |
|
} catch (e) { console.warn("Failed to parse leftover 8:", buffer);; } |
|
} else if (prefix === null) { |
|
console.warn("Ignoring leftover non-stream line with no prefix:", line); |
|
} else { |
|
console.warn("Received unknown Tau non-stream leftover prefix:", prefix, "content:", content); |
|
} |
|
} |
|
} |
|
|
|
// Log the final combined string content *before* it's JSON.stringify-ed |
|
console.debug("Non-Stream: Final combinedContent string before JSON.stringify:", `"${combinedContent.replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`); |
|
console.debug("Non-Stream: Final combinedReasoningContent string before JSON.stringify:", `"${combinedReasoningContent.replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`); |
|
|
|
|
|
const responseJson: any = { |
|
id: completionId, |
|
object: "chat.completion", |
|
created: createdAt, |
|
model: tauModel, |
|
choices: [{ |
|
index: 0, |
|
message: { |
|
role: "assistant", |
|
content: combinedContent, // Use the combined, cleaned string |
|
}, |
|
logprobs: null, |
|
finish_reason: finishReason, |
|
}], |
|
usage: usageData ? { |
|
prompt_tokens: usageData.prompt_tokens, |
|
completion_tokens: usageData.completion_tokens, |
|
total_tokens: usageData.prompt_tokens + usageData.completion_tokens, |
|
} : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, |
|
system_fingerprint: "tau_api_proxy", |
|
};; |
|
|
|
console.debug("Final Non-Stream Response Body (after JSON.stringify):", JSON.stringify(responseJson, null, 2)); |
|
|
|
return new Response(JSON.stringify(responseJson, null, 2), { |
|
headers: { "Content-Type": "application/json" }, |
|
});; |
|
} |
|
} |
|
|
|
|
|
// --- Main Request Handler Dispatcher --- |
|
|
|
async function handler(request: Request): Promise<Response> { |
|
const url = new URL(request.url); |
|
const path = url.pathname; |
|
const method = request.method; |
|
|
|
console.log(`Received request: ${method} ${path}`);; |
|
|
|
if (method === "GET" && path === "/v1/models") { |
|
return handleListModels();; |
|
} else if (method === "POST" && path === "/v1/chat/completions") { |
|
return handleChatCompletions(request);; |
|
} else { |
|
return new Response("Not Found", { status: 404 });; |
|
} |
|
} |
|
|
|
|
|
// --- Start Server --- |
|
const PORT = 8000; |
|
console.log(`Listening on http://localhost:${PORT}/`);; |
|
Deno.serve({ port: PORT }, handler);; |