diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index e9cc438..7419e39 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -141,7 +141,7 @@ export class AntigravityExecutor extends BaseExecutor { const fallbackCount = this.getFallbackCount(); let lastError = null; let lastStatus = 0; - const MAX_AUTO_RETRIES = 2; + const MAX_AUTO_RETRIES = 3; const retryAttemptsByUrl = {}; // Track retry attempts per URL for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 38902d4..af55ea9 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -99,9 +99,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`); - // Translate request + // Translate request (pass reqLogger for intermediate logging) let translatedBody = body; - translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider); + translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger); // Extract toolNameMap for response translation (Claude OAuth) const toolNameMap = translatedBody._toolNameMap; @@ -149,8 +149,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred providerHeaders = result.headers; finalBody = result.transformedBody; - // Log converted request - reqLogger.logConvertedRequest(providerUrl, providerHeaders, finalBody); + // Log target request (final request to provider) + reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); } catch (error) { trackPendingRequest(model, provider, connectionId, false); diff --git a/open-sse/translator/helpers/geminiHelper.js b/open-sse/translator/helpers/geminiHelper.js index 52e1790..28376dc 100644 --- a/open-sse/translator/helpers/geminiHelper.js +++ b/open-sse/translator/helpers/geminiHelper.js @@ -96,82 +96,289 @@ export function generateProjectId() { return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`; } +// Helper: Walk recursively through object/array and collect all paths for a given key +function walkAndCollectPaths(obj, currentPath, targetKey, paths) { + if (!obj || typeof obj !== "object") return; + + if (Array.isArray(obj)) { + obj.forEach((item, index) => { + const newPath = currentPath ? `${currentPath}[${index}]` : `[${index}]`; + walkAndCollectPaths(item, newPath, targetKey, paths); + }); + } else { + for (const [key, value] of Object.entries(obj)) { + const newPath = currentPath ? `${currentPath}.${key}` : key; + + if (key === targetKey) { + paths.push(newPath); + } + + if (value && typeof value === "object") { + walkAndCollectPaths(value, newPath, targetKey, paths); + } + } + } +} + +// Helper: Get value at path +function getAtPath(obj, path) { + const parts = path.split("."); + let current = obj; + for (const part of parts) { + if (!current || typeof current !== "object") return undefined; + current = current[part]; + } + return current; +} + +// Helper: Set value at path +function setAtPath(obj, path, value) { + const parts = path.split("."); + let current = obj; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!current[part]) current[part] = {}; + current = current[part]; + } + + current[parts[parts.length - 1]] = value; +} + +// Helper: Delete a key at a specific path in nested object +function deleteAtPath(obj, path) { + const parts = path.split("."); + let current = obj; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!current[part]) return; + current = current[part]; + } + + const lastKey = parts[parts.length - 1]; + delete current[lastKey]; +} + +// Convert const to enum +function convertConstToEnum(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.const !== undefined && !obj.enum) { + obj.enum = [obj.const]; + delete obj.const; + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + convertConstToEnum(value); + } + } +} + +// Convert enum values to strings (Gemini requires string enum values) +function convertEnumValuesToStrings(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.enum && Array.isArray(obj.enum)) { + obj.enum = obj.enum.map(v => String(v)); + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + convertEnumValuesToStrings(value); + } + } +} + +// Merge allOf schemas +function mergeAllOf(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.allOf && Array.isArray(obj.allOf)) { + const merged = {}; + + for (const item of obj.allOf) { + if (item.properties) { + if (!merged.properties) merged.properties = {}; + Object.assign(merged.properties, item.properties); + } + if (item.required && Array.isArray(item.required)) { + if (!merged.required) merged.required = []; + for (const req of item.required) { + if (!merged.required.includes(req)) { + merged.required.push(req); + } + } + } + } + + delete obj.allOf; + if (merged.properties) obj.properties = { ...obj.properties, ...merged.properties }; + if (merged.required) obj.required = [...(obj.required || []), ...merged.required]; + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + mergeAllOf(value); + } + } +} + +// Select best schema from anyOf/oneOf +function selectBest(items) { + let bestIdx = 0; + let bestScore = -1; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + let score = 0; + const type = item.type; + + if (type === "object" || item.properties) { + score = 3; + } else if (type === "array" || item.items) { + score = 2; + } else if (type && type !== "null") { + score = 1; + } + + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } + + return bestIdx; +} + +// Flatten anyOf/oneOf +function flattenAnyOfOneOf(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) { + const nonNullSchemas = obj.anyOf.filter(s => s && s.type !== "null"); + if (nonNullSchemas.length > 0) { + const bestIdx = selectBest(nonNullSchemas); + const selected = nonNullSchemas[bestIdx]; + delete obj.anyOf; + Object.assign(obj, selected); + } + } + + if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) { + const nonNullSchemas = obj.oneOf.filter(s => s && s.type !== "null"); + if (nonNullSchemas.length > 0) { + const bestIdx = selectBest(nonNullSchemas); + const selected = nonNullSchemas[bestIdx]; + delete obj.oneOf; + Object.assign(obj, selected); + } + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + flattenAnyOfOneOf(value); + } + } +} + +// Flatten type arrays +function flattenTypeArrays(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.type && Array.isArray(obj.type)) { + const nonNullTypes = obj.type.filter(t => t !== "null"); + obj.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string"; + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + flattenTypeArrays(value); + } + } +} + // Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively +// Reference: CLIProxyAPI/internal/util/gemini_schema.go export function cleanJSONSchemaForAntigravity(schema) { if (!schema || typeof schema !== "object") return schema; - // Handle anyOf/oneOf - extract the first non-null schema - if (schema.anyOf && Array.isArray(schema.anyOf)) { - const nonNullSchema = schema.anyOf.find(s => s.type !== "null" && s.type !== null); - if (nonNullSchema) { - const baseSchema = { ...nonNullSchema }; - // Copy other properties from parent schema (except unsupported ones) - for (const [key, value] of Object.entries(schema)) { - if (!UNSUPPORTED_SCHEMA_CONSTRAINTS.includes(key)) { - baseSchema[key] = value; - } - } - return cleanJSONSchemaForAntigravity(baseSchema); - } - } + // Deep clone to avoid mutating original + let cleaned = JSON.parse(JSON.stringify(schema)); - if (schema.oneOf && Array.isArray(schema.oneOf)) { - const nonNullSchema = schema.oneOf.find(s => s.type !== "null" && s.type !== null); - if (nonNullSchema) { - const baseSchema = { ...nonNullSchema }; - // Copy other properties from parent schema (except unsupported ones) - for (const [key, value] of Object.entries(schema)) { - if (!UNSUPPORTED_SCHEMA_CONSTRAINTS.includes(key)) { - baseSchema[key] = value; - } - } - return cleanJSONSchemaForAntigravity(baseSchema); - } - } + // Phase 1: Convert and prepare + convertConstToEnum(cleaned); + convertEnumValuesToStrings(cleaned); - const cleaned = Array.isArray(schema) ? [] : {}; + // Phase 2: Flatten complex structures + mergeAllOf(cleaned); + flattenAnyOfOneOf(cleaned); + flattenTypeArrays(cleaned); - for (const [key, value] of Object.entries(schema)) { - if (UNSUPPORTED_SCHEMA_CONSTRAINTS.includes(key)) continue; + // Phase 3: Remove all unsupported keywords at ALL levels + for (const keyword of UNSUPPORTED_SCHEMA_CONSTRAINTS) { + const paths = []; + walkAndCollectPaths(cleaned, "", keyword, paths); - // Handle type array like ["string", "null"] - Gemini only supports single type - if (key === "type" && Array.isArray(value)) { - const nonNullType = value.find(t => t !== "null") || "string"; - cleaned[key] = nonNullType; - continue; + // Sort by depth (deepest first) to avoid path invalidation + paths.sort((a, b) => b.split(".").length - a.split(".").length); + + for (const path of paths) { + deleteAtPath(cleaned, path); + } + } + + // Phase 4: Cleanup required fields recursively + function cleanupRequired(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.required && Array.isArray(obj.required) && obj.properties) { + const validRequired = obj.required.filter(field => + Object.prototype.hasOwnProperty.call(obj.properties, field) + ); + if (validRequired.length === 0) { + delete obj.required; + } else { + obj.required = validRequired; + } } - if (value && typeof value === "object") { - cleaned[key] = cleanJSONSchemaForAntigravity(value); - } else { - cleaned[key] = value; + // Recurse into nested objects + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + cleanupRequired(value); + } } } - // Cleanup required fields - only keep fields that exist in properties - if (cleaned.required && Array.isArray(cleaned.required) && cleaned.properties) { - const validRequired = cleaned.required.filter(field => - Object.prototype.hasOwnProperty.call(cleaned.properties, field) - ); - if (validRequired.length === 0) { - delete cleaned.required; - } else { - cleaned.required = validRequired; + cleanupRequired(cleaned); + + // Phase 5: Add placeholder for empty object schemas (Antigravity requirement) + function addPlaceholders(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.type === "object") { + if (!obj.properties || Object.keys(obj.properties).length === 0) { + obj.properties = { + reason: { + type: "string", + description: "Brief explanation of why you are calling this tool" + } + }; + obj.required = ["reason"]; + } + } + + // Recurse into nested objects + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + addPlaceholders(value); + } } } - // Add placeholder for empty object schemas (Antigravity requirement) - if (cleaned.type === "object") { - if (!cleaned.properties || Object.keys(cleaned.properties).length === 0) { - cleaned.properties = { - reason: { - type: "string", - description: "Brief explanation of why you are calling this tool" - } - }; - cleaned.required = ["reason"]; - } - } + addPlaceholders(cleaned); return cleaned; } diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 7555f5f..6bc9e97 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -44,7 +44,7 @@ function ensureInitialized() { } // Translate request: source -> openai -> target -export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null) { +export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null, reqLogger = null) { ensureInitialized(); let result = body; @@ -64,6 +64,8 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream const toOpenAI = requestRegistry.get(`${sourceFormat}:${FORMATS.OPENAI}`); if (toOpenAI) { result = toOpenAI(model, result, stream, credentials); + // Log OpenAI intermediate format + reqLogger?.logOpenAIRequest?.(result); } } @@ -98,6 +100,7 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) { } let results = [chunk]; + let openaiResults = null; // Store OpenAI intermediate results // Step 1: target -> openai (if target is not openai) if (targetFormat !== FORMATS.OPENAI) { @@ -107,6 +110,7 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) { const converted = toOpenAI(chunk, state); if (converted) { results = Array.isArray(converted) ? converted : [converted]; + openaiResults = results; // Store OpenAI intermediate } } } @@ -126,6 +130,11 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) { } } + // Attach OpenAI intermediate results for logging + if (openaiResults && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) { + results._openaiIntermediate = openaiResults; + } + return results; } diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 2474051..eb91d47 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -8,86 +8,33 @@ import { v4 as uuidv4 } from "uuid"; /** * Convert OpenAI messages to Kiro format + * Rules: system/tool/user -> user role, merge consecutive same roles */ function convertMessages(messages, tools, model) { let history = []; let currentMessage = null; - let systemPrompt = ""; - - const toolResultsMap = new Map(); - for (const msg of messages) { - if (msg.role === "tool" && msg.tool_call_id) { - const content = typeof msg.content === "string" ? msg.content : - (Array.isArray(msg.content) ? msg.content.map(c => c.text || "").join("\n") : ""); - toolResultsMap.set(msg.tool_call_id, content); - } - - if (msg.role === "user" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_result" && block.tool_use_id) { - const content = Array.isArray(block.content) - ? block.content.map(c => c.text || "").join("\n") - : (typeof block.content === "string" ? block.content : ""); - toolResultsMap.set(block.tool_use_id, content); - } - } - } - } + let pendingUserContent = []; + let pendingAssistantContent = []; + let pendingToolResults = []; + let currentRole = null; - for (const msg of messages) { - const role = msg.role; - - if (role === "tool") continue; - - const content = typeof msg.content === "string" ? msg.content : - (Array.isArray(msg.content) ? msg.content.map(c => c.text || "").join("\n") : ""); - - if (role === "system") { - systemPrompt += (systemPrompt ? "\n" : "") + content; - continue; - } - - if (role === "user") { - let finalContent = content; - let toolResults = []; - - // Check if this user message contains tool_result blocks - if (Array.isArray(msg.content)) { - const toolResultBlocks = msg.content.filter(c => c.type === "tool_result"); - if (toolResultBlocks.length > 0) { - toolResults = toolResultBlocks.map(block => { - const text = Array.isArray(block.content) - ? block.content.map(c => c.text || "").join("\n") - : (typeof block.content === "string" ? block.content : ""); - - return { - toolUseId: block.tool_use_id, - status: "success", - content: [{ text: text }] - }; - }); - - // Set simple content when tool results exist - finalContent = content || "Continue"; - } - } - + const flushPending = () => { + if (currentRole === "user") { + const content = pendingUserContent.join("\n\n").trim() || "continue"; const userMsg = { userInputMessage: { - content: finalContent, - modelId: "", + content: content, + modelId: "" } }; - - // Add tool results to userInputMessageContext - if (toolResults.length > 0) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.toolResults = toolResults; + + if (pendingToolResults.length > 0) { + userMsg.userInputMessage.userInputMessageContext = { + toolResults: pendingToolResults + }; } - + // Add tools to first user message if (tools && tools.length > 0 && history.length === 0) { if (!userMsg.userInputMessage.userInputMessageContext) { @@ -112,13 +59,79 @@ function convertMessages(messages, tools, model) { }; }); } - - currentMessage = userMsg; + history.push(userMsg); + currentMessage = userMsg; + pendingUserContent = []; + pendingToolResults = []; + } else if (currentRole === "assistant") { + const content = pendingAssistantContent.join("\n\n").trim() || "..."; + const assistantMsg = { + assistantResponseMessage: { + content: content + } + }; + history.push(assistantMsg); + pendingAssistantContent = []; } + }; - if (role === "assistant") { - // Extract text content and tool uses separately from content array + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + let role = msg.role; + + // Normalize: system/tool -> user + if (role === "system" || role === "tool") { + role = "user"; + } + + // If role changes, flush pending + if (role !== currentRole && currentRole !== null) { + flushPending(); + } + currentRole = role; + + if (role === "user") { + // Extract content + let content = ""; + if (typeof msg.content === "string") { + content = msg.content; + } else if (Array.isArray(msg.content)) { + const textParts = msg.content + .filter(c => c.type === "text" || c.text) + .map(c => c.text || ""); + content = textParts.join("\n"); + + // Check for tool_result blocks + const toolResultBlocks = msg.content.filter(c => c.type === "tool_result"); + if (toolResultBlocks.length > 0) { + toolResultBlocks.forEach(block => { + const text = Array.isArray(block.content) + ? block.content.map(c => c.text || "").join("\n") + : (typeof block.content === "string" ? block.content : ""); + + pendingToolResults.push({ + toolUseId: block.tool_use_id, + status: "success", + content: [{ text: text }] + }); + }); + } + } + + // Handle tool role (from normalized) + if (msg.role === "tool") { + const toolContent = typeof msg.content === "string" ? msg.content : ""; + pendingToolResults.push({ + toolUseId: msg.tool_call_id, + status: "success", + content: [{ text: toolContent }] + }); + } else if (content) { + pendingUserContent.push(content); + } + } else if (role === "assistant") { + // Extract text content and tool uses let textContent = ""; let toolUses = []; @@ -132,43 +145,54 @@ function convertMessages(messages, tools, model) { textContent = msg.content.trim(); } - // Fallback for OpenAI tool_calls format if (msg.tool_calls && msg.tool_calls.length > 0) { toolUses = msg.tool_calls; } - const assistantMsg = { - assistantResponseMessage: { - content: textContent || "Call tools" - } - }; - - if (toolUses.length > 0) { - assistantMsg.assistantResponseMessage.toolUses = toolUses.map(tc => { - if (tc.function) { - // OpenAI format - return { - toolUseId: tc.id || uuidv4(), - name: tc.function.name, - input: typeof tc.function.arguments === "string" - ? JSON.parse(tc.function.arguments) - : (tc.function.arguments || {}) - }; - } else { - // Anthropic format - return { - toolUseId: tc.id || uuidv4(), - name: tc.name, - input: tc.input || {} - }; - } - }); + if (textContent) { + pendingAssistantContent.push(textContent); + } + + // Store tool uses in last assistant message + if (toolUses.length > 0) { + if (pendingAssistantContent.length === 0) { + // pendingAssistantContent.push("Call tools"); + } + + // Flush to create assistant message with toolUses + flushPending(); + + const lastMsg = history[history.length - 1]; + if (lastMsg?.assistantResponseMessage) { + lastMsg.assistantResponseMessage.toolUses = toolUses.map(tc => { + if (tc.function) { + return { + toolUseId: tc.id || uuidv4(), + name: tc.function.name, + input: typeof tc.function.arguments === "string" + ? JSON.parse(tc.function.arguments) + : (tc.function.arguments || {}) + }; + } else { + return { + toolUseId: tc.id || uuidv4(), + name: tc.name, + input: tc.input || {} + }; + } + }); + } + + currentRole = null; } - - history.push(assistantMsg); } } + // Flush remaining + if (currentRole !== null) { + flushPending(); + } + // If last message in history is userInputMessage, use it as currentMessage if (history.length > 0 && history[history.length - 1].userInputMessage) { currentMessage = history.pop(); @@ -199,24 +223,8 @@ function convertMessages(messages, tools, model) { item.userInputMessage.modelId = model; } }); - - // Merge consecutive user messages (Kiro requires alternating user/assistant) - const mergedHistory = []; - for (let i = 0; i < history.length; i++) { - const current = history[i]; - - if (current.userInputMessage && - mergedHistory.length > 0 && - mergedHistory[mergedHistory.length - 1].userInputMessage) { - const prev = mergedHistory[mergedHistory.length - 1]; - prev.userInputMessage.content += "\n\n" + current.userInputMessage.content; - } else { - mergedHistory.push(current); - } - } - history = mergedHistory; - return { history, currentMessage, systemPrompt }; + return { history, currentMessage }; } /** @@ -229,15 +237,11 @@ function buildKiroPayload(model, body, stream, credentials) { const temperature = body.temperature; const topP = body.top_p; - const { history, currentMessage, systemPrompt } = convertMessages(messages, tools, model); + const { history, currentMessage } = convertMessages(messages, tools, model); const profileArn = credentials?.providerSpecificData?.profileArn || ""; let finalContent = currentMessage?.userInputMessage?.content || ""; - if (systemPrompt) { - finalContent = `[System: ${systemPrompt}]\n\n${finalContent}`; - } - const timestamp = new Date().toISOString(); finalContent = `[Context: Current time is ${timestamp}]\n\n${finalContent}`; diff --git a/open-sse/translator/request/openai-to-kiro.old.js b/open-sse/translator/request/openai-to-kiro.old.js new file mode 100644 index 0000000..2474051 --- /dev/null +++ b/open-sse/translator/request/openai-to-kiro.old.js @@ -0,0 +1,278 @@ +/** + * OpenAI to Kiro Request Translator + * Converts OpenAI Chat Completions format to Kiro/AWS CodeWhisperer format + */ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; +import { v4 as uuidv4 } from "uuid"; + +/** + * Convert OpenAI messages to Kiro format + */ +function convertMessages(messages, tools, model) { + let history = []; + let currentMessage = null; + let systemPrompt = ""; + + const toolResultsMap = new Map(); + + for (const msg of messages) { + if (msg.role === "tool" && msg.tool_call_id) { + const content = typeof msg.content === "string" ? msg.content : + (Array.isArray(msg.content) ? msg.content.map(c => c.text || "").join("\n") : ""); + toolResultsMap.set(msg.tool_call_id, content); + } + + if (msg.role === "user" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_result" && block.tool_use_id) { + const content = Array.isArray(block.content) + ? block.content.map(c => c.text || "").join("\n") + : (typeof block.content === "string" ? block.content : ""); + toolResultsMap.set(block.tool_use_id, content); + } + } + } + } + + for (const msg of messages) { + const role = msg.role; + + if (role === "tool") continue; + + const content = typeof msg.content === "string" ? msg.content : + (Array.isArray(msg.content) ? msg.content.map(c => c.text || "").join("\n") : ""); + + if (role === "system") { + systemPrompt += (systemPrompt ? "\n" : "") + content; + continue; + } + + if (role === "user") { + let finalContent = content; + let toolResults = []; + + // Check if this user message contains tool_result blocks + if (Array.isArray(msg.content)) { + const toolResultBlocks = msg.content.filter(c => c.type === "tool_result"); + if (toolResultBlocks.length > 0) { + toolResults = toolResultBlocks.map(block => { + const text = Array.isArray(block.content) + ? block.content.map(c => c.text || "").join("\n") + : (typeof block.content === "string" ? block.content : ""); + + return { + toolUseId: block.tool_use_id, + status: "success", + content: [{ text: text }] + }; + }); + + // Set simple content when tool results exist + finalContent = content || "Continue"; + } + } + + const userMsg = { + userInputMessage: { + content: finalContent, + modelId: "", + } + }; + + // Add tool results to userInputMessageContext + if (toolResults.length > 0) { + if (!userMsg.userInputMessage.userInputMessageContext) { + userMsg.userInputMessage.userInputMessageContext = {}; + } + userMsg.userInputMessage.userInputMessageContext.toolResults = toolResults; + } + + // Add tools to first user message + if (tools && tools.length > 0 && history.length === 0) { + if (!userMsg.userInputMessage.userInputMessageContext) { + userMsg.userInputMessage.userInputMessageContext = {}; + } + userMsg.userInputMessage.userInputMessageContext.tools = tools.map(t => { + const name = t.function?.name || t.name; + let description = t.function?.description || t.description || ""; + + if (!description.trim()) { + description = `Tool: ${name}`; + } + + return { + toolSpecification: { + name, + description, + inputSchema: { + json: t.function?.parameters || t.parameters || t.input_schema || {} + } + } + }; + }); + } + + currentMessage = userMsg; + history.push(userMsg); + } + + if (role === "assistant") { + // Extract text content and tool uses separately from content array + let textContent = ""; + let toolUses = []; + + if (Array.isArray(msg.content)) { + const textBlocks = msg.content.filter(c => c.type === "text"); + textContent = textBlocks.map(b => b.text).join("\n").trim(); + + const toolUseBlocks = msg.content.filter(c => c.type === "tool_use"); + toolUses = toolUseBlocks; + } else if (typeof msg.content === "string") { + textContent = msg.content.trim(); + } + + // Fallback for OpenAI tool_calls format + if (msg.tool_calls && msg.tool_calls.length > 0) { + toolUses = msg.tool_calls; + } + + const assistantMsg = { + assistantResponseMessage: { + content: textContent || "Call tools" + } + }; + + if (toolUses.length > 0) { + assistantMsg.assistantResponseMessage.toolUses = toolUses.map(tc => { + if (tc.function) { + // OpenAI format + return { + toolUseId: tc.id || uuidv4(), + name: tc.function.name, + input: typeof tc.function.arguments === "string" + ? JSON.parse(tc.function.arguments) + : (tc.function.arguments || {}) + }; + } else { + // Anthropic format + return { + toolUseId: tc.id || uuidv4(), + name: tc.name, + input: tc.input || {} + }; + } + }); + } + + history.push(assistantMsg); + } + } + + // If last message in history is userInputMessage, use it as currentMessage + if (history.length > 0 && history[history.length - 1].userInputMessage) { + currentMessage = history.pop(); + } + + const firstHistoryItem = history[0]; + if (firstHistoryItem?.userInputMessage?.userInputMessageContext?.tools && + !currentMessage?.userInputMessage?.userInputMessageContext?.tools) { + if (!currentMessage.userInputMessage.userInputMessageContext) { + currentMessage.userInputMessage.userInputMessageContext = {}; + } + currentMessage.userInputMessage.userInputMessageContext.tools = + firstHistoryItem.userInputMessage.userInputMessageContext.tools; + } + + // Clean up history for Kiro API compatibility + history.forEach(item => { + if (item.userInputMessage?.userInputMessageContext?.tools) { + delete item.userInputMessage.userInputMessageContext.tools; + } + + if (item.userInputMessage?.userInputMessageContext && + Object.keys(item.userInputMessage.userInputMessageContext).length === 0) { + delete item.userInputMessage.userInputMessageContext; + } + + if (item.userInputMessage && !item.userInputMessage.modelId) { + item.userInputMessage.modelId = model; + } + }); + + // Merge consecutive user messages (Kiro requires alternating user/assistant) + const mergedHistory = []; + for (let i = 0; i < history.length; i++) { + const current = history[i]; + + if (current.userInputMessage && + mergedHistory.length > 0 && + mergedHistory[mergedHistory.length - 1].userInputMessage) { + const prev = mergedHistory[mergedHistory.length - 1]; + prev.userInputMessage.content += "\n\n" + current.userInputMessage.content; + } else { + mergedHistory.push(current); + } + } + history = mergedHistory; + + return { history, currentMessage, systemPrompt }; +} + +/** + * Build Kiro payload from OpenAI format + */ +function buildKiroPayload(model, body, stream, credentials) { + const messages = body.messages || []; + const tools = body.tools || []; + const maxTokens = 32000; + const temperature = body.temperature; + const topP = body.top_p; + + const { history, currentMessage, systemPrompt } = convertMessages(messages, tools, model); + + const profileArn = credentials?.providerSpecificData?.profileArn || ""; + + let finalContent = currentMessage?.userInputMessage?.content || ""; + if (systemPrompt) { + finalContent = `[System: ${systemPrompt}]\n\n${finalContent}`; + } + + const timestamp = new Date().toISOString(); + finalContent = `[Context: Current time is ${timestamp}]\n\n${finalContent}`; + + const payload = { + conversationState: { + chatTriggerType: "MANUAL", + conversationId: uuidv4(), + currentMessage: { + userInputMessage: { + content: finalContent, + modelId: model, + origin: "AI_EDITOR", + ...(currentMessage?.userInputMessage?.userInputMessageContext && { + userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext + }) + } + }, + history: history + } + }; + + if (profileArn) { + payload.profileArn = profileArn; + } + + if (maxTokens || temperature !== undefined || topP !== undefined) { + payload.inferenceConfig = {}; + if (maxTokens) payload.inferenceConfig.maxTokens = maxTokens; + if (temperature !== undefined) payload.inferenceConfig.temperature = temperature; + if (topP !== undefined) payload.inferenceConfig.topP = topP; + } + + return payload; +} + +register(FORMATS.OPENAI, FORMATS.KIRO, buildKiroPayload, null); + +export { buildKiroPayload }; diff --git a/open-sse/utils/requestLogger.js b/open-sse/utils/requestLogger.js index 40f06d9..59bb522 100644 --- a/open-sse/utils/requestLogger.js +++ b/open-sse/utils/requestLogger.js @@ -95,11 +95,13 @@ function createNoOpLogger() { sessionPath: null, logClientRawRequest() {}, logRawRequest() {}, - logConvertedRequest() {}, - logRawResponse() {}, + logOpenAIRequest() {}, + logTargetRequest() {}, + logProviderResponse() {}, + appendProviderChunk() {}, + appendOpenAIChunk() {}, logConvertedResponse() {}, - logStreamChunk() {}, - logStreamComplete() {}, + appendConvertedChunk() {}, logError() {} }; } @@ -125,7 +127,7 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { // 1. Log client raw request (before any conversion) logClientRawRequest(endpoint, body, headers = {}) { - writeJsonFile(sessionPath, "1_client_raw_request.json", { + writeJsonFile(sessionPath, "1_req_client.json", { timestamp: new Date().toISOString(), endpoint, headers: maskSensitiveHeaders(headers), @@ -135,16 +137,24 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { // 2. Log raw request from client (after initial conversion like responsesApi) logRawRequest(body, headers = {}) { - writeJsonFile(sessionPath, "2_raw_request.json", { + writeJsonFile(sessionPath, "2_req_source.json", { timestamp: new Date().toISOString(), headers: maskSensitiveHeaders(headers), body }); }, - // 3. Log converted request to send to provider - logConvertedRequest(url, headers, body) { - writeJsonFile(sessionPath, "3_converted_request.json", { + // 3. Log OpenAI intermediate format (source → openai) + logOpenAIRequest(body) { + writeJsonFile(sessionPath, "3_req_openai.json", { + timestamp: new Date().toISOString(), + body + }); + }, + + // 4. Log target format request (openai → target) + logTargetRequest(url, headers, body) { + writeJsonFile(sessionPath, "4_req_target.json", { timestamp: new Date().toISOString(), url, headers: maskSensitiveHeaders(headers), @@ -152,9 +162,9 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { }); }, - // 4. Log provider response (for non-streaming or error) + // 5. Log provider response (for non-streaming or error) logProviderResponse(status, statusText, headers, body) { - const filename = "4_provider_response.json"; + const filename = "5_res_provider.json"; writeJsonFile(sessionPath, filename, { timestamp: new Date().toISOString(), status, @@ -164,30 +174,41 @@ export async function createRequestLogger(sourceFormat, targetFormat, model) { }); }, - // 4. Append streaming chunk to provider response + // 5. Append streaming chunk to provider response appendProviderChunk(chunk) { if (!fs || !sessionPath) return; try { - const filePath = path.join(sessionPath, "4_provider_response.txt"); + const filePath = path.join(sessionPath, "5_res_provider.txt"); fs.appendFileSync(filePath, chunk); } catch (err) { // Ignore append errors } }, - // 5. Log converted response to client (for non-streaming) + // 6. Append OpenAI intermediate chunks (target → openai) + appendOpenAIChunk(chunk) { + if (!fs || !sessionPath) return; + try { + const filePath = path.join(sessionPath, "6_res_openai.txt"); + fs.appendFileSync(filePath, chunk); + } catch (err) { + // Ignore append errors + } + }, + + // 7. Log converted response to client (for non-streaming) logConvertedResponse(body) { - writeJsonFile(sessionPath, "5_converted_response.json", { + writeJsonFile(sessionPath, "7_res_client.json", { timestamp: new Date().toISOString(), body }); }, - // 5. Append streaming chunk to converted response + // 7. Append streaming chunk to converted response appendConvertedChunk(chunk) { if (!fs || !sessionPath) return; try { - const filePath = path.join(sessionPath, "5_converted_response.txt"); + const filePath = path.join(sessionPath, "7_res_client.txt"); fs.appendFileSync(filePath, chunk); } catch (err) { // Ignore append errors diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 307072d..d3bd428 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -236,8 +236,17 @@ export function createSSEStream(options = {}) { const extracted = extractUsage(parsed); if (extracted) state.usage = extracted; - // Translate and emit + // Translate: targetFormat -> openai -> sourceFormat const translated = translateResponse(targetFormat, sourceFormat, parsed, state); + + // Log OpenAI intermediate chunks (if available) + if (translated?._openaiIntermediate) { + for (const item of translated._openaiIntermediate) { + const openaiOutput = formatSSE(item, FORMATS.OPENAI); + reqLogger?.appendOpenAIChunk?.(openaiOutput); + } + } + if (translated?.length > 0) { for (const item of translated) { const output = formatSSE(item, sourceFormat); @@ -277,6 +286,15 @@ export function createSSEStream(options = {}) { const parsed = parseSSELine(buffer.trim()); if (parsed && !parsed.done) { const translated = translateResponse(targetFormat, sourceFormat, parsed, state); + + // Log OpenAI intermediate chunks + if (translated?._openaiIntermediate) { + for (const item of translated._openaiIntermediate) { + const openaiOutput = formatSSE(item, FORMATS.OPENAI); + reqLogger?.appendOpenAIChunk?.(openaiOutput); + } + } + if (translated?.length > 0) { for (const item of translated) { const output = formatSSE(item, sourceFormat); @@ -289,6 +307,15 @@ export function createSSEStream(options = {}) { // Flush remaining events (only once at stream end) const flushed = translateResponse(targetFormat, sourceFormat, null, state); + + // Log OpenAI intermediate chunks for flushed events + if (flushed?._openaiIntermediate) { + for (const item of flushed._openaiIntermediate) { + const openaiOutput = formatSSE(item, FORMATS.OPENAI); + reqLogger?.appendOpenAIChunk?.(openaiOutput); + } + } + if (flushed?.length > 0) { for (const item of flushed) { const output = formatSSE(item, sourceFormat); diff --git a/package.json b/package.json index 96404ee..9d29a13 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "next start" }, "dependencies": { + "@monaco-editor/react": "^4.7.0", "bcryptjs": "^3.0.3", "express": "^5.2.1", "fs": "^0.0.1-security", @@ -16,6 +17,7 @@ "https-proxy-agent": "^7.0.6", "jose": "^6.1.3", "lowdb": "^7.0.1", + "monaco-editor": "^0.55.1", "next": "^15.2.0", "node-machine-id": "^1.1.12", "open": "^10.1.0", diff --git a/src/app/(dashboard)/dashboard/translator/page.js b/src/app/(dashboard)/dashboard/translator/page.js new file mode 100644 index 0000000..bc7ef33 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/page.js @@ -0,0 +1,499 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Card, Button, Select } from "@/shared/components"; +import dynamic from "next/dynamic"; + +// Dynamically import Monaco Editor (client-side only) +const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); + +const PROVIDERS = [ + { value: "antigravity", label: "Antigravity" }, + { value: "gemini-cli", label: "Gemini CLI" }, + { value: "claude", label: "Claude" }, + { value: "codex", label: "Codex" }, + { value: "github", label: "GitHub" }, + { value: "qwen", label: "Qwen" }, + { value: "iflow", label: "iFlow AI" }, + { value: "kiro", label: "Kiro AI" }, + { value: "openai", label: "OpenAI" }, + { value: "anthropic", label: "Anthropic" }, + { value: "gemini", label: "Gemini" }, + { value: "openrouter", label: "OpenRouter" }, + { value: "glm", label: "GLM" }, + { value: "kimi", label: "Kimi" }, + { value: "minimax", label: "MiniMax" }, +]; + +const STEPS = [ + { id: 1, name: "Client Request", file: "1_req_client.json" }, + { id: 2, name: "Source Format", file: "2_req_source.json" }, + { id: 3, name: "OpenAI Intermediate", file: "3_req_openai.json" }, + { id: 4, name: "Target Format", file: "4_req_target.json" }, + { id: 5, name: "Provider Response", file: "5_res_provider.txt" }, +]; + +export default function TranslatorPage() { + const [provider, setProvider] = useState("antigravity"); + const [steps, setSteps] = useState({ + 1: "", + 2: "", + 3: "", + 4: "", + 5: "", + }); + const [expanded, setExpanded] = useState({ + 1: true, + 2: false, + 3: false, + 4: false, + 5: false, + }); + const [loading, setLoading] = useState({}); + + const toggleExpand = (stepId) => { + setExpanded({ ...expanded, [stepId]: !expanded[stepId] }); + }; + + const handleSendToProvider = async () => { + setLoading({ ...loading, "send-provider": true }); + try { + const step4Content = steps[4]; + if (!step4Content) { + alert("Please load or generate step 4 content first"); + return; + } + + const body = JSON.parse(step4Content); + + // Get credentials (you may need to prompt user or use stored credentials) + const credentials = { + accessToken: prompt("Enter access token (or leave empty):") || undefined, + apiKey: prompt("Enter API key (or leave empty):") || undefined + }; + + const res = await fetch("/api/translator/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + body, + credentials + }) + }); + + const data = await res.json(); + + if (data.success) { + // Update step 5 with provider response + setSteps({ ...steps, 5: data.body }); + + // Save step 5 + await fetch("/api/translator/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: "5_res_provider.txt", + content: data.body + }) + }); + + // Expand step 5 + setExpanded({ ...expanded, 4: false, 5: true }); + + alert(`Request sent! Status: ${data.status} ${data.statusText}`); + } else { + alert(data.error || "Failed to send request"); + } + } catch (err) { + alert("Error sending request: " + err.message); + } + setLoading({ ...loading, "send-provider": false }); + }; + + const handleLoad = async (stepId) => { + setLoading({ ...loading, [`load-${stepId}`]: true }); + try { + const step = STEPS.find(s => s.id === stepId); + const res = await fetch(`/api/translator/load?file=${step.file}`); + const data = await res.json(); + if (data.success) { + setSteps({ ...steps, [stepId]: data.content }); + } else { + alert(data.error || "Failed to load file"); + } + } catch (err) { + alert("Error loading file: " + err.message); + } + setLoading({ ...loading, [`load-${stepId}`]: false }); + }; + + const handleLean = (stepId) => { + try { + const content = steps[stepId]; + if (!content) return; + + const obj = JSON.parse(content); + const leaned = leanJSON(obj); + setSteps({ ...steps, [stepId]: JSON.stringify(leaned, null, 2) }); + } catch (err) { + alert("Error parsing JSON: " + err.message); + } + }; + + const handleFormat = (stepId) => { + try { + const content = steps[stepId]; + if (!content) return; + + const obj = JSON.parse(content); + setSteps({ ...steps, [stepId]: JSON.stringify(obj, null, 2) }); + } catch (err) { + alert("Error parsing JSON: " + err.message); + } + }; + + const handleCopy = async (stepId) => { + try { + const content = steps[stepId]; + if (!content) return; + + await navigator.clipboard.writeText(content); + alert("Copied to clipboard!"); + } catch (err) { + alert("Error copying: " + err.message); + } + }; + + const handleUpdate = async (stepId) => { + setLoading({ ...loading, [`update-${stepId}`]: true }); + try { + const step = STEPS.find(s => s.id === stepId); + const res = await fetch("/api/translator/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: step.file, + content: steps[stepId] + }) + }); + const data = await res.json(); + if (data.success) { + alert("File saved successfully"); + } else { + alert(data.error || "Failed to save file"); + } + } catch (err) { + alert("Error saving file: " + err.message); + } + setLoading({ ...loading, [`update-${stepId}`]: false }); + }; + + const handleSubmit = async (stepId) => { + setLoading({ ...loading, [`submit-${stepId}`]: true }); + try { + // 1. Save current step + const currentStep = STEPS.find(s => s.id === stepId); + await fetch("/api/translator/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: currentStep.file, + content: steps[stepId] + }) + }); + + // Step 4: Send to provider instead of translate + if (stepId === 4) { + const res = await fetch("/api/translator/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + body: JSON.parse(steps[4]) + }) + }); + + if (!res.ok) { + const errorData = await res.json(); + alert(errorData.error || "Failed to send request"); + setLoading({ ...loading, [`submit-${stepId}`]: false }); + return; + } + + // Read streaming response + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + fullResponse += chunk; + } + + // Save to step 5 + setSteps({ ...steps, 5: fullResponse }); + + await fetch("/api/translator/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: "5_res_provider.txt", + content: fullResponse + }) + }); + + setExpanded({ ...expanded, [stepId]: false, 5: true }); + alert("Request sent to provider successfully!"); + } else { + // Steps 1-3: Translate to next step + const res = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: stepId, + provider, + body: JSON.parse(steps[stepId]) + }) + }); + const data = await res.json(); + + if (data.success) { + const nextStepId = stepId + 1; + const nextContent = JSON.stringify(data.result, null, 2); + + setSteps({ ...steps, [nextStepId]: nextContent }); + + const nextStep = STEPS.find(s => s.id === nextStepId); + await fetch("/api/translator/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: nextStep.file, + content: nextContent + }) + }); + + setExpanded({ ...expanded, [stepId]: false, [nextStepId]: true }); + } else { + alert(data.error || "Translation failed"); + } + } + } catch (err) { + alert("Error: " + err.message); + } + setLoading({ ...loading, [`submit-${stepId}`]: false }); + }; + + return ( +
+
+
+

Translator Debug

+

+ Debug translation flow between formats +

+
+
+ + {/* Provider Selector */} + +
+
+ +