mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
refactor(open-sse): translator DRY + schema enums, bug fixes, dead code cleanup
- Bug B1-B7: media UI m.kind||m.type, serviceKinds, gemini mediaPriority, schema kind, models/info lookup by kind - Dead code D1-D6: safeParseJSON, drop PROVIDER_ENDPOINTS, orphan fetcher, GITHUB_CONFIG derive, getProviderConfig internal, legacy kiro file - Translator concerns: toOpenAIUsage, toOpenAIFinish (gemini/kiro/ollama + fix kiro tool finish), thinking effort maps - Reorg helpers/ → concerns/ (logic) + formats/ (per-format) + schema/ (pure enums: roles/blocks/finishReasons/defaults) - Wire ~280 hardcoded role/block/finish/default literals to schema enums across 20+ files - collapseTextParts + extractTextContent dedup - Normalize translator fn names to openaiToXRequest / xToOpenAIResponse - Golden tests lock behavior; 0 regression (byte-for-byte providers/alias, 26=26 known fails) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
// Claude helper functions for translator
|
||||
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
||||
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
|
||||
import { adjustMaxTokens } from "./maxTokens.js";
|
||||
import { applyCloaking } from "../../utils/claudeCloaking.js";
|
||||
import { deriveSessionId } from "../../utils/sessionManager.js";
|
||||
import { PROVIDERS } from "../../providers/index.js";
|
||||
|
||||
// Check if message has valid non-empty content
|
||||
export function hasValidContent(msg) {
|
||||
if (typeof msg.content === "string" && msg.content.trim()) return true;
|
||||
if (Array.isArray(msg.content)) {
|
||||
return msg.content.some(block =>
|
||||
(block.type === CLAUDE_BLOCK.TEXT && block.text?.trim()) ||
|
||||
block.type === CLAUDE_BLOCK.TOOL_USE ||
|
||||
block.type === CLAUDE_BLOCK.TOOL_RESULT
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fix tool_use/tool_result ordering for Claude API
|
||||
// 1. Assistant message with tool_use: remove text AFTER tool_use (Claude doesn't allow)
|
||||
// 2. Merge consecutive same-role messages
|
||||
export function fixToolUseOrdering(messages) {
|
||||
if (messages.length <= 1) return messages;
|
||||
|
||||
// Pass 1: Fix assistant messages with tool_use - remove text after tool_use
|
||||
for (const msg of messages) {
|
||||
if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) {
|
||||
const hasToolUse = msg.content.some(b => b.type === CLAUDE_BLOCK.TOOL_USE);
|
||||
if (hasToolUse) {
|
||||
// Keep only: thinking blocks + tool_use blocks (remove text blocks after tool_use)
|
||||
const newContent = [];
|
||||
let foundToolUse = false;
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === CLAUDE_BLOCK.TOOL_USE) {
|
||||
foundToolUse = true;
|
||||
newContent.push(block);
|
||||
} else if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) {
|
||||
newContent.push(block);
|
||||
} else if (!foundToolUse) {
|
||||
// Keep text blocks BEFORE tool_use
|
||||
newContent.push(block);
|
||||
}
|
||||
// Skip text blocks AFTER tool_use
|
||||
}
|
||||
|
||||
msg.content = newContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Merge consecutive same-role messages
|
||||
const merged = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const last = merged[merged.length - 1];
|
||||
|
||||
if (last && last.role === msg.role) {
|
||||
// Merge content arrays
|
||||
const lastContent = Array.isArray(last.content) ? last.content : [{ type: CLAUDE_BLOCK.TEXT, text: last.content }];
|
||||
const msgContent = Array.isArray(msg.content) ? msg.content : [{ type: CLAUDE_BLOCK.TEXT, text: msg.content }];
|
||||
|
||||
// Put tool_result first, then other content
|
||||
const toolResults = [...lastContent.filter(b => b.type === CLAUDE_BLOCK.TOOL_RESULT), ...msgContent.filter(b => b.type === CLAUDE_BLOCK.TOOL_RESULT)];
|
||||
const otherContent = [...lastContent.filter(b => b.type !== CLAUDE_BLOCK.TOOL_RESULT), ...msgContent.filter(b => b.type !== CLAUDE_BLOCK.TOOL_RESULT)];
|
||||
|
||||
last.content = [...toolResults, ...otherContent];
|
||||
} else {
|
||||
// Ensure content is array
|
||||
const content = Array.isArray(msg.content) ? msg.content : [{ type: CLAUDE_BLOCK.TEXT, text: msg.content }];
|
||||
merged.push({ role: msg.role, content: [...content] });
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Models that reject thinking.type "adaptive" (only Sonnet/Opus support it)
|
||||
const ADAPTIVE_THINKING_UNSUPPORTED = /haiku/i;
|
||||
|
||||
// Normalize a native Claude passthrough body to match Anthropic Messages API spec.
|
||||
// Newer Cowork/Claude Code clients emit beta-only shapes that OAuth endpoints reject:
|
||||
// 1. thinking.type "adaptive" → unsupported on Haiku
|
||||
// 2. role "system" messages (mid-conversation-system beta) → only top-level system is allowed
|
||||
export function normalizeClaudePassthrough(body, model = "") {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
|
||||
// 1. Downgrade adaptive thinking for models that don't support it
|
||||
if (body.thinking?.type === "adaptive" && ADAPTIVE_THINKING_UNSUPPORTED.test(model)) {
|
||||
body.thinking = { type: "enabled", budget_tokens: 10000 };
|
||||
}
|
||||
|
||||
// 2. Hoist mid-conversation system messages into the top-level system field
|
||||
if (Array.isArray(body.messages)) {
|
||||
const systemBlocks = [];
|
||||
const messages = [];
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === ROLE.SYSTEM) {
|
||||
const text = typeof msg.content === "string"
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
|
||||
: "";
|
||||
if (text.trim()) systemBlocks.push({ type: CLAUDE_BLOCK.TEXT, text });
|
||||
continue;
|
||||
}
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
if (systemBlocks.length > 0) {
|
||||
const existing = Array.isArray(body.system)
|
||||
? body.system
|
||||
: typeof body.system === "string" && body.system.trim()
|
||||
? [{ type: "text", text: body.system }]
|
||||
: [];
|
||||
body.system = [...existing, ...systemBlocks];
|
||||
body.messages = messages;
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// Prepare request for Claude format endpoints
|
||||
// - Cleanup cache_control
|
||||
// - Filter empty messages
|
||||
// - Add thinking block for Anthropic endpoint (provider === "claude")
|
||||
// - Fix tool_use/tool_result ordering
|
||||
// - Apply cloaking (billing header + fake user ID) for OAuth tokens
|
||||
export function prepareClaudeRequest(body, provider = null, apiKey = null, connectionId = null) {
|
||||
// quirk: MiniMax's Claude-compatible endpoint rejects Anthropic's output_config (400 invalid params)
|
||||
if (PROVIDERS[provider]?.quirks?.dropOutputConfig) {
|
||||
delete body.output_config;
|
||||
}
|
||||
|
||||
// 1. System: remove all cache_control, add only to last block with ttl 1h
|
||||
if (body.system && Array.isArray(body.system)) {
|
||||
body.system = body.system.map((block, i) => {
|
||||
const { cache_control, ...rest } = block;
|
||||
if (i === body.system.length - 1) {
|
||||
return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } };
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Messages: process in optimized passes
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
const len = body.messages.length;
|
||||
let filtered = [];
|
||||
|
||||
// Pass 1: remove cache_control + filter empty messages
|
||||
for (let i = 0; i < len; i++) {
|
||||
const msg = body.messages[i];
|
||||
|
||||
// Remove cache_control from content blocks
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
delete block.cache_control;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep final assistant even if empty, otherwise check valid content
|
||||
const isFinalAssistant = i === len - 1 && msg.role === "assistant";
|
||||
if (isFinalAssistant || hasValidContent(msg)) {
|
||||
filtered.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1.5: Fix tool_use/tool_result ordering
|
||||
// Each tool_use must have tool_result in the NEXT message (not same message with other content)
|
||||
filtered = fixToolUseOrdering(filtered);
|
||||
|
||||
body.messages = filtered;
|
||||
|
||||
// Check if thinking is enabled AND last message is from user
|
||||
const lastMessage = filtered[filtered.length - 1];
|
||||
const lastMessageIsUser = lastMessage?.role === "user";
|
||||
const thinkingEnabled = body.thinking?.type === "enabled" && lastMessageIsUser;
|
||||
|
||||
// Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic
|
||||
let lastAssistantProcessed = false;
|
||||
for (let i = filtered.length - 1; i >= 0; i--) {
|
||||
const msg = filtered[i];
|
||||
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
// Add cache_control to last non-thinking block of first (from end) assistant with content
|
||||
// thinking/redacted_thinking blocks do not support cache_control
|
||||
if (!lastAssistantProcessed && msg.content.length > 0) {
|
||||
for (let j = msg.content.length - 1; j >= 0; j--) {
|
||||
const block = msg.content[j];
|
||||
if (block.type !== CLAUDE_BLOCK.THINKING && block.type !== CLAUDE_BLOCK.REDACTED_THINKING) {
|
||||
block.cache_control = { type: "ephemeral" };
|
||||
break;
|
||||
}
|
||||
}
|
||||
lastAssistantProcessed = true;
|
||||
}
|
||||
|
||||
// Handle thinking blocks for Anthropic endpoint only
|
||||
if (provider === "claude" || provider?.startsWith("anthropic-compatible")) {
|
||||
let hasToolUse = false;
|
||||
let hasThinking = false;
|
||||
|
||||
// Always replace signature for all thinking blocks
|
||||
for (const block of msg.content) {
|
||||
if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) {
|
||||
block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE;
|
||||
hasThinking = true;
|
||||
}
|
||||
if (block.type === CLAUDE_BLOCK.TOOL_USE) hasToolUse = true;
|
||||
}
|
||||
|
||||
// Add thinking block if thinking enabled + has tool_use but no thinking
|
||||
if (thinkingEnabled && !hasThinking && hasToolUse) {
|
||||
msg.content.unshift({
|
||||
type: CLAUDE_BLOCK.THINKING,
|
||||
thinking: ".",
|
||||
signature: DEFAULT_THINKING_CLAUDE_SIGNATURE
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tools: filter built-in tools for non-Anthropic providers, then handle cache_control
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
// Strip built-in tools (e.g. web_search_20250305) for providers that don't support them
|
||||
if (provider !== "claude") {
|
||||
body.tools = body.tools.filter(tool => !tool.type || tool.type === "function");
|
||||
}
|
||||
|
||||
body.tools = body.tools.map((tool, i) => {
|
||||
const { cache_control, ...rest } = tool;
|
||||
if (i === body.tools.length - 1) {
|
||||
return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } };
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
|
||||
// Remove tools array and tool_choice if empty after filtering
|
||||
if (body.tools.length === 0) {
|
||||
delete body.tools;
|
||||
delete body.tool_choice;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply cloaking for OAuth tokens (billing header + fake user ID)
|
||||
// session_id in user_id must match X-Claude-Code-Session-Id for fingerprint consistency
|
||||
if ((provider === "claude" || provider?.startsWith("anthropic-compatible")) && apiKey) {
|
||||
const sessionId = connectionId ? deriveSessionId(connectionId) : null;
|
||||
body = applyCloaking(body, apiKey, sessionId);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// Gemini helper functions for translator
|
||||
|
||||
import { safeParseJSON } from "../concerns/json.js";
|
||||
import { OPENAI_BLOCK } from "../schema/index.js";
|
||||
|
||||
// Unsupported JSON Schema constraints that should be removed for Antigravity
|
||||
export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
|
||||
// Basic constraints (not supported by Gemini API)
|
||||
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
|
||||
"pattern", "minItems", "maxItems", "format",
|
||||
// Claude rejects these in VALIDATED mode
|
||||
"default", "examples",
|
||||
// JSON Schema meta keywords
|
||||
"$schema", "$defs", "definitions", "const", "$ref", "$comment",
|
||||
// Object validation keywords (not supported)
|
||||
"additionalProperties", "propertyNames", "patternProperties", "enumDescriptions",
|
||||
// Complex schema keywords (handled by flattenAnyOfOneOf/mergeAllOf)
|
||||
"anyOf", "oneOf", "allOf", "not",
|
||||
// Dependency keywords (not supported)
|
||||
"dependencies", "dependentSchemas", "dependentRequired",
|
||||
// Other unsupported keywords
|
||||
"title", "if", "then", "else", "contentMediaType", "contentEncoding",
|
||||
// UI/Styling properties (from Cursor tools - NOT JSON Schema standard)
|
||||
"cornerRadius", "fillColor", "fontFamily", "fontSize", "fontWeight",
|
||||
"gap", "padding", "strokeColor", "strokeThickness", "textColor"
|
||||
];
|
||||
|
||||
// Default safety settings
|
||||
export const DEFAULT_SAFETY_SETTINGS = [
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" }
|
||||
];
|
||||
|
||||
// Convert OpenAI content to Gemini parts
|
||||
export function convertOpenAIContentToParts(content) {
|
||||
const parts = [];
|
||||
|
||||
if (typeof content === "string") {
|
||||
parts.push({ text: content });
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (item.type === OPENAI_BLOCK.TEXT) {
|
||||
parts.push({ text: item.text });
|
||||
} else if (item.type === OPENAI_BLOCK.IMAGE_URL && item.image_url?.url?.startsWith("data:")) {
|
||||
const url = item.image_url.url;
|
||||
const commaIndex = url.indexOf(",");
|
||||
if (commaIndex !== -1) {
|
||||
const mimePart = url.substring(5, commaIndex); // skip "data:"
|
||||
const data = url.substring(commaIndex + 1);
|
||||
const mimeType = mimePart.split(";")[0];
|
||||
|
||||
parts.push({
|
||||
inlineData: { mime_type: mimeType, data: data }
|
||||
});
|
||||
}
|
||||
} else if (item.type === OPENAI_BLOCK.IMAGE_URL && item.image_url?.url && (item.image_url.url.startsWith("http://") || item.image_url.url.startsWith("https://"))) {
|
||||
parts.push({
|
||||
fileData: { fileUri: item.image_url.url, mimeType: "image/*" }
|
||||
});
|
||||
} else if (item.type === OPENAI_BLOCK.INPUT_AUDIO && item.input_audio?.data) {
|
||||
const format = item.input_audio.format || "wav";
|
||||
const mimeType = format === "mp3" ? "audio/mpeg" : `audio/${format}`;
|
||||
parts.push({
|
||||
inlineData: { mime_type: mimeType, data: item.input_audio.data }
|
||||
});
|
||||
} else if (item.type === OPENAI_BLOCK.AUDIO_URL && item.audio_url?.url?.startsWith("data:")) {
|
||||
const url = item.audio_url.url;
|
||||
const commaIndex = url.indexOf(",");
|
||||
if (commaIndex !== -1) {
|
||||
const mimePart = url.substring(5, commaIndex);
|
||||
const data = url.substring(commaIndex + 1);
|
||||
const mimeType = mimePart.split(";")[0];
|
||||
parts.push({
|
||||
inlineData: { mime_type: mimeType, data: data }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Extract text content from OpenAI content
|
||||
export function extractTextContent(content, separator = "") {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter(c => c.type === OPENAI_BLOCK.TEXT).map(c => c.text).join(separator);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Try parse JSON safely (null fallback on parse error; re-export keeps legacy API)
|
||||
export function tryParseJSON(str) {
|
||||
return safeParseJSON(str, null);
|
||||
}
|
||||
|
||||
// Generate request ID
|
||||
export function generateRequestId() {
|
||||
return `agent-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
// Generate session ID (binary-compatible format: UUID + timestamp)
|
||||
export function generateSessionId() {
|
||||
return crypto.randomUUID() + Date.now().toString();
|
||||
}
|
||||
|
||||
// Generate project ID
|
||||
export function generateProjectId() {
|
||||
const adjectives = ["useful", "bright", "swift", "calm", "bold"];
|
||||
const nouns = ["fuze", "wave", "spark", "flow", "core"];
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`;
|
||||
}
|
||||
|
||||
// Helper: Remove unsupported keywords recursively from object/array
|
||||
// Also strips all vendor extension fields (x- prefixed) not supported by Gemini
|
||||
function removeUnsupportedKeywords(obj, keywords) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
removeUnsupportedKeywords(item, keywords);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (keywords.includes(key) || key.startsWith("x-")) {
|
||||
delete obj[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = obj[key];
|
||||
if (value && typeof value === "object") {
|
||||
removeUnsupportedKeywords(value, keywords);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 + explicit type:"string")
|
||||
function convertEnumValuesToStrings(obj) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (obj.enum && Array.isArray(obj.enum)) {
|
||||
obj.enum = obj.enum.map(v => String(v));
|
||||
// Gemini API requires type:"string" when enum is present — without it returns 400
|
||||
if (!obj.type) {
|
||||
obj.type = "string";
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Infer missing type=object when properties exist (Gemini requires explicit type)
|
||||
function ensureObjectType(obj) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
if (obj.properties && !obj.type) obj.type = "object";
|
||||
for (const v of Object.values(obj)) if (v && typeof v === "object") ensureObjectType(v);
|
||||
}
|
||||
|
||||
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
|
||||
export function cleanJSONSchemaForAntigravity(schema) {
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
|
||||
// Mutate directly (schema is only used once per request)
|
||||
let cleaned = schema;
|
||||
|
||||
// Phase 1: Convert and prepare
|
||||
convertConstToEnum(cleaned);
|
||||
convertEnumValuesToStrings(cleaned);
|
||||
|
||||
// Phase 2: Flatten complex structures
|
||||
mergeAllOf(cleaned);
|
||||
flattenAnyOfOneOf(cleaned);
|
||||
flattenTypeArrays(cleaned);
|
||||
|
||||
// Phase 2.5: Infer missing type=object when properties exist (Gemini requirement)
|
||||
ensureObjectType(cleaned);
|
||||
|
||||
// Phase 3: Remove all unsupported keywords at ALL levels (including inside arrays)
|
||||
removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested objects
|
||||
for (const value of Object.values(obj)) {
|
||||
if (value && typeof value === "object") {
|
||||
cleanupRequired(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addPlaceholders(cleaned);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConfig.js";
|
||||
|
||||
/**
|
||||
* Adjust max_tokens based on request context
|
||||
* @param {object} body - Request body
|
||||
* @returns {number} Adjusted max_tokens
|
||||
*/
|
||||
export function adjustMaxTokens(body) {
|
||||
let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS;
|
||||
|
||||
// Auto-increase for tool calling to prevent truncated arguments
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
||||
if (maxTokens < DEFAULT_MIN_TOKENS) {
|
||||
maxTokens = DEFAULT_MIN_TOKENS;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure max_tokens > thinking.budget_tokens (Claude API requirement)
|
||||
// Claude API requires strictly greater, so add buffer instead of using DEFAULT_MAX_TOKENS
|
||||
// which could equal budget_tokens when budget_tokens >= 64000
|
||||
if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) {
|
||||
maxTokens = body.thinking.budget_tokens + 1024;
|
||||
}
|
||||
|
||||
return maxTokens;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// OpenAI helper functions for translator
|
||||
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK, VALID_OPENAI_CONTENT_TYPES, VALID_OPENAI_MESSAGE_TYPES } from "../schema/index.js";
|
||||
|
||||
// Re-export valid-type lists (moved to schema/blocks.js) to keep existing importers working.
|
||||
export { VALID_OPENAI_CONTENT_TYPES, VALID_OPENAI_MESSAGE_TYPES };
|
||||
|
||||
// Filter messages to OpenAI standard format
|
||||
// Remove: thinking, redacted_thinking, signature, and other non-OpenAI blocks
|
||||
export function filterToOpenAIFormat(body) {
|
||||
if (!body.messages || !Array.isArray(body.messages)) return body;
|
||||
|
||||
body.messages = body.messages.map(msg => {
|
||||
// Normalize developer role to system (many providers don't support developer)
|
||||
if (msg.role === ROLE.DEVELOPER) msg = { ...msg, role: ROLE.SYSTEM };
|
||||
|
||||
// Keep tool messages as-is (OpenAI format)
|
||||
if (msg.role === ROLE.TOOL) return msg;
|
||||
|
||||
// Keep assistant messages with tool_calls as-is
|
||||
if (msg.role === ROLE.ASSISTANT && msg.tool_calls) return msg;
|
||||
|
||||
// Handle string content
|
||||
if (typeof msg.content === "string") return msg;
|
||||
|
||||
// Handle array content
|
||||
if (Array.isArray(msg.content)) {
|
||||
const filteredContent = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
// Skip thinking blocks
|
||||
if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) continue;
|
||||
|
||||
// Only keep valid OpenAI content types
|
||||
if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) {
|
||||
// Remove signature field if exists
|
||||
const { signature, cache_control, ...cleanBlock } = block;
|
||||
filteredContent.push(cleanBlock);
|
||||
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
|
||||
// Convert tool_use to tool_calls format (handled separately)
|
||||
continue;
|
||||
} else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) {
|
||||
// Keep tool_result but clean it
|
||||
const { signature, cache_control, ...cleanBlock } = block;
|
||||
filteredContent.push(cleanBlock);
|
||||
}
|
||||
}
|
||||
|
||||
// If all content was filtered, add empty text
|
||||
if (filteredContent.length === 0) {
|
||||
filteredContent.push({ type: OPENAI_BLOCK.TEXT, text: "" });
|
||||
}
|
||||
|
||||
return { ...msg, content: filteredContent };
|
||||
}
|
||||
|
||||
return msg;
|
||||
});
|
||||
|
||||
// Filter out messages with only empty text (but NEVER filter tool messages)
|
||||
body.messages = body.messages.filter(msg => {
|
||||
// Always keep tool messages
|
||||
if (msg.role === ROLE.TOOL) return true;
|
||||
// Always keep assistant messages with tool_calls
|
||||
if (msg.role === ROLE.ASSISTANT && msg.tool_calls) return true;
|
||||
|
||||
if (typeof msg.content === "string") return msg.content.trim() !== "";
|
||||
if (Array.isArray(msg.content)) {
|
||||
return msg.content.some(b =>
|
||||
(b.type === OPENAI_BLOCK.TEXT && b.text?.trim()) ||
|
||||
b.type !== OPENAI_BLOCK.TEXT
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Remove empty tools array (some providers like QWEN reject it)
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length === 0) {
|
||||
delete body.tools;
|
||||
}
|
||||
|
||||
// Normalize tools to OpenAI format (from Claude, Gemini, etc.)
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
||||
body.tools = body.tools.map(tool => {
|
||||
// Already OpenAI format
|
||||
if (tool.type === OPENAI_BLOCK.FUNCTION && tool.function) return tool;
|
||||
|
||||
// Claude format: {name, description, input_schema}
|
||||
if (tool.name && (tool.input_schema || tool.description)) {
|
||||
return {
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: String(tool.description || ""),
|
||||
parameters: tool.input_schema || { type: "object", properties: {} }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Gemini format: {functionDeclarations: [{name, description, parameters}]}
|
||||
if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) {
|
||||
return tool.functionDeclarations.map(fn => ({
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
function: {
|
||||
name: fn.name,
|
||||
description: String(fn.description || ""),
|
||||
parameters: fn.parameters || { type: "object", properties: {} }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return tool;
|
||||
}).flat();
|
||||
}
|
||||
|
||||
// Normalize tool_choice to OpenAI format
|
||||
if (body.tool_choice && typeof body.tool_choice === "object") {
|
||||
const choice = body.tool_choice;
|
||||
// Claude format: {type: "auto|any|tool", name?: "..."}
|
||||
if (choice.type === "auto") {
|
||||
body.tool_choice = "auto";
|
||||
} else if (choice.type === "any") {
|
||||
body.tool_choice = "required";
|
||||
} else if (choice.type === "tool" && choice.name) {
|
||||
body.tool_choice = { type: OPENAI_BLOCK.FUNCTION, function: { name: choice.name } };
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM } from "../schema/index.js";
|
||||
|
||||
/**
|
||||
* Normalize Responses API input to array format.
|
||||
* Accepts string or array, returns array of message items.
|
||||
* An empty array is treated like an empty string — providers require at least one user
|
||||
* message, so we inject a placeholder rather than forwarding an empty messages[].
|
||||
* @param {string|Array} input - raw input from Responses API body
|
||||
* @returns {Array|null} normalized array or null if invalid
|
||||
*/
|
||||
export function normalizeResponsesInput(input) {
|
||||
if (typeof input === "string") {
|
||||
const text = input.trim() === "" ? "..." : input;
|
||||
return [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text }] }];
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
// Empty input[] would produce messages:[] which all providers reject (#389)
|
||||
if (input.length === 0) {
|
||||
return [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "..." }] }];
|
||||
}
|
||||
return input;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI Responses API format to standard chat completions format
|
||||
* Responses API uses: { input: [...], instructions: "..." }
|
||||
* Chat API uses: { messages: [...] }
|
||||
*/
|
||||
export function convertResponsesApiFormat(body) {
|
||||
if (!body.input) return body;
|
||||
|
||||
const result = { ...body };
|
||||
result.messages = [];
|
||||
|
||||
// Convert instructions to system message
|
||||
if (body.instructions) {
|
||||
result.messages.push({ role: ROLE.SYSTEM, content: body.instructions });
|
||||
}
|
||||
|
||||
// Group items by conversation turn
|
||||
let currentAssistantMsg = null;
|
||||
let pendingToolCalls = [];
|
||||
let pendingToolResults = [];
|
||||
|
||||
const inputItems = normalizeResponsesInput(body.input);
|
||||
if (!inputItems) return body;
|
||||
|
||||
for (const item of inputItems) {
|
||||
// Determine item type - Droid CLI sends role-based items without 'type' field
|
||||
// Fallback: if no type but has role property, treat as message
|
||||
const itemType = item.type || (item.role ? RESPONSES_ITEM.MESSAGE : null);
|
||||
|
||||
if (itemType === RESPONSES_ITEM.MESSAGE) {
|
||||
// Flush any pending assistant message with tool calls
|
||||
if (currentAssistantMsg) {
|
||||
result.messages.push(currentAssistantMsg);
|
||||
currentAssistantMsg = null;
|
||||
}
|
||||
// Flush pending tool results
|
||||
if (pendingToolResults.length > 0) {
|
||||
for (const tr of pendingToolResults) {
|
||||
result.messages.push(tr);
|
||||
}
|
||||
pendingToolResults = [];
|
||||
}
|
||||
|
||||
// Convert content: input_text → text, output_text → text, input_image → image_url
|
||||
const content = Array.isArray(item.content)
|
||||
? item.content.map(c => {
|
||||
if (c.type === RESPONSES_ITEM.INPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text };
|
||||
if (c.type === RESPONSES_ITEM.OUTPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text };
|
||||
if (c.type === RESPONSES_ITEM.INPUT_IMAGE) {
|
||||
const url = c.image_url || c.file_id || "";
|
||||
return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url, detail: c.detail || "auto" } };
|
||||
}
|
||||
return c;
|
||||
})
|
||||
: item.content;
|
||||
result.messages.push({ role: item.role, content });
|
||||
}
|
||||
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) {
|
||||
// Start or append to assistant message with tool_calls
|
||||
if (!currentAssistantMsg) {
|
||||
currentAssistantMsg = {
|
||||
role: ROLE.ASSISTANT,
|
||||
content: null,
|
||||
tool_calls: []
|
||||
};
|
||||
}
|
||||
// Skip items with empty/missing name — upstream APIs reject nameless tool calls (#444)
|
||||
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue;
|
||||
currentAssistantMsg.tool_calls.push({
|
||||
id: item.call_id,
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
function: {
|
||||
name: item.name,
|
||||
arguments: item.arguments
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) {
|
||||
// Flush assistant message first if exists
|
||||
if (currentAssistantMsg) {
|
||||
result.messages.push(currentAssistantMsg);
|
||||
currentAssistantMsg = null;
|
||||
}
|
||||
// Add tool result
|
||||
pendingToolResults.push({
|
||||
role: ROLE.TOOL,
|
||||
tool_call_id: item.call_id,
|
||||
content: typeof item.output === "string" ? item.output : JSON.stringify(item.output)
|
||||
});
|
||||
}
|
||||
else if (itemType === RESPONSES_ITEM.REASONING) {
|
||||
// Skip reasoning items - they are for display only
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
if (currentAssistantMsg) {
|
||||
result.messages.push(currentAssistantMsg);
|
||||
}
|
||||
if (pendingToolResults.length > 0) {
|
||||
for (const tr of pendingToolResults) {
|
||||
result.messages.push(tr);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup Responses API specific fields
|
||||
delete result.input;
|
||||
delete result.instructions;
|
||||
delete result.include;
|
||||
delete result.prompt_cache_key;
|
||||
delete result.store;
|
||||
delete result.reasoning;
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user