end
This commit is contained in:
@@ -39,7 +39,16 @@ const USAGE_EXTRACTORS = {
|
||||
},
|
||||
kiro(raw) {
|
||||
const input = n(raw.inputTokens), output = n(raw.outputTokens);
|
||||
return { promptTokens: input, completionTokens: output, totalTokens: input + output };
|
||||
// ponytail: Amazon Q (Kiro upstream) does not expose cache fields today,
|
||||
// but pass through any cache_read/cache_creation/cached_tokens if the
|
||||
// event shape grows them later so cost tracking keeps working without
|
||||
// a second pass.
|
||||
const cached = n(raw.cache_read_input_tokens) || n(raw.cachedTokens) || n(raw.cached_tokens);
|
||||
const cacheCreation = n(raw.cache_creation_input_tokens);
|
||||
const out = { promptTokens: input, completionTokens: output, totalTokens: input + output };
|
||||
if (cached > 0) out.cachedTokens = cached;
|
||||
if (cacheCreation > 0) out.cacheCreationTokens = cacheCreation;
|
||||
return out;
|
||||
},
|
||||
ollama(raw) {
|
||||
const input = n(raw.prompt_eval_count), output = n(raw.eval_count);
|
||||
|
||||
@@ -150,6 +150,33 @@ export function normalizeClaudePassthrough(body, model = "") {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Drop thinking blocks whose signature is not Claude's (combo mixes models,
|
||||
// so foreign signatures leak into history and Anthropic rejects them).
|
||||
const thinkingEnabled = body.thinking?.type === "enabled";
|
||||
if (Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content)) continue;
|
||||
let hasToolUse = false;
|
||||
let hasKeptThinking = false;
|
||||
const kept = [];
|
||||
for (const block of msg.content) {
|
||||
if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) {
|
||||
if (isValidClaudeSignature(block.signature)) {
|
||||
hasKeptThinking = true;
|
||||
kept.push(block);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === CLAUDE_BLOCK.TOOL_USE) hasToolUse = true;
|
||||
kept.push(block);
|
||||
}
|
||||
msg.content = kept;
|
||||
if (thinkingEnabled && !hasKeptThinking && hasToolUse) {
|
||||
msg.content.unshift(buildThinkingPlaceholder("claude"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
|
||||
"default", "examples",
|
||||
// JSON Schema meta keywords
|
||||
"$schema", "$defs", "definitions", "const", "$ref", "$comment",
|
||||
// Annotation keywords (rejected by Gemini/Antigravity - e.g. MCP tool schemas set these)
|
||||
"deprecated", "readOnly", "writeOnly",
|
||||
// Object validation keywords (not supported)
|
||||
"additionalProperties", "propertyNames", "patternProperties", "enumDescriptions",
|
||||
// Complex schema keywords (handled by flattenAnyOfOneOf/mergeAllOf)
|
||||
@@ -19,7 +21,7 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
|
||||
// Dependency keywords (not supported)
|
||||
"dependencies", "dependentSchemas", "dependentRequired",
|
||||
// Other unsupported keywords
|
||||
"title", "optional", "if", "then", "else", "contentMediaType", "contentEncoding",
|
||||
"title", "optional", "deprecated", "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"
|
||||
|
||||
@@ -6,42 +6,46 @@ 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) {
|
||||
// opts.preserveCacheControl: keep cache_control on content blocks (e.g. for DashScope/alicode)
|
||||
export function filterToOpenAIFormat(body, opts = {}) {
|
||||
if (!body.messages || !Array.isArray(body.messages)) return body;
|
||||
|
||||
const keepCache = !!opts.preserveCacheControl;
|
||||
|
||||
function stripBlock(block) {
|
||||
const { signature, cache_control, ...rest } = block;
|
||||
return keepCache && cache_control ? { ...rest, cache_control } : rest;
|
||||
}
|
||||
|
||||
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);
|
||||
filteredContent.push(stripBlock(block));
|
||||
} 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);
|
||||
filteredContent.push(stripBlock(block));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,9 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
|
||||
// Always normalize to clean OpenAI format when target is OpenAI
|
||||
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
|
||||
if (targetFormat === FORMATS.OPENAI) {
|
||||
result = filterToOpenAIFormat(result);
|
||||
result = filterToOpenAIFormat(result, {
|
||||
preserveCacheControl: !!PROVIDERS[provider]?.quirks?.preserveCacheControl,
|
||||
});
|
||||
}
|
||||
|
||||
// Final step: prepare request for Claude format endpoints
|
||||
|
||||
@@ -138,12 +138,14 @@ function convertContent(content) {
|
||||
|
||||
// Text with thoughtSignature = regular text after thinking
|
||||
if (part.thoughtSignature && part.text !== undefined) {
|
||||
textParts.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
|
||||
if (part.text) {
|
||||
textParts.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular text
|
||||
if (part.text !== undefined) {
|
||||
if (part.text !== undefined && part.text !== "") {
|
||||
textParts.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
|
||||
}
|
||||
|
||||
@@ -180,8 +182,22 @@ function convertContent(content) {
|
||||
}
|
||||
}
|
||||
|
||||
// Content with only functionResponses → return array of tool messages
|
||||
// Content with functionResponses — return array of tool result messages,
|
||||
// plus an assistant message for any co-located tool calls / text.
|
||||
if (toolResults.length > 0) {
|
||||
if (toolCalls.length > 0 || textParts.length > 0 || reasoningContent) {
|
||||
const assistantMsg = { role: ROLE.ASSISTANT };
|
||||
if (textParts.length > 0) {
|
||||
assistantMsg.content = collapseTextParts(textParts);
|
||||
}
|
||||
if (reasoningContent) {
|
||||
assistantMsg.reasoning_content = reasoningContent;
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
assistantMsg.tool_calls = toolCalls;
|
||||
}
|
||||
return [...toolResults, assistantMsg];
|
||||
}
|
||||
return toolResults;
|
||||
}
|
||||
|
||||
|
||||
@@ -393,9 +393,12 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
reconcileOrphanedToolResults(history, currentMessage);
|
||||
}
|
||||
|
||||
// API-key auth must never use the shared default ARN (403); OAuth/social fall back to it.
|
||||
// api_key / idc / external_idp must never use the shared default ARN (belongs
|
||||
// to another account → 403 "bearer token invalid"); OAuth/social fall back to it.
|
||||
const authMethod = credentials?.providerSpecificData?.authMethod;
|
||||
const profileArn = authMethod === "api_key"
|
||||
const accountBoundAuth =
|
||||
authMethod === "api_key" || authMethod === "idc" || authMethod === "external_idp";
|
||||
const profileArn = accountBoundAuth
|
||||
? (credentials?.providerSpecificData?.profileArn || "")
|
||||
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
|
||||
|
||||
|
||||
@@ -129,8 +129,24 @@ function fixMissingToolResponsesOpenAI(messages) {
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400)
|
||||
function systemReminderText(content) {
|
||||
const parts = Array.isArray(content)
|
||||
? content.filter(c => c?.type === CLAUDE_BLOCK.TEXT).map(c => c.text || "")
|
||||
: [typeof content === "string" ? content : ""];
|
||||
const text = parts.filter(Boolean).join("\n");
|
||||
if (!text.trim()) return "";
|
||||
return `<system-reminder>\n${text}\n</system-reminder>`;
|
||||
}
|
||||
|
||||
// Convert single Claude message - returns single message or array of messages
|
||||
function convertClaudeMessage(msg) {
|
||||
// Mid-conversation system message -> user (per Anthropic placement rules)
|
||||
if (msg.role === ROLE.SYSTEM) {
|
||||
const text = systemReminderText(msg.content);
|
||||
return text ? { role: ROLE.USER, content: text } : null;
|
||||
}
|
||||
|
||||
const role = msg.role === ROLE.USER || msg.role === ROLE.TOOL ? ROLE.USER : ROLE.ASSISTANT;
|
||||
|
||||
// Simple string content
|
||||
|
||||
@@ -35,6 +35,17 @@ function sanitizeGeminiFunctionName(name) {
|
||||
return sanitized.substring(0, 64);
|
||||
}
|
||||
|
||||
function normalizeGeminiContents(contents) {
|
||||
const out = [];
|
||||
for (const c of contents || []) {
|
||||
if (!c?.role || !Array.isArray(c.parts) || c.parts.length === 0) continue;
|
||||
const last = out.at(-1);
|
||||
if (last?.role === c.role) last.parts.push(...c.parts);
|
||||
else out.push({ ...c, parts: [...c.parts] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Core: Convert OpenAI request to Gemini format (base for all variants)
|
||||
function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG_SIGNATURE) {
|
||||
const result = {
|
||||
@@ -217,6 +228,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG
|
||||
}
|
||||
}
|
||||
|
||||
result.contents = normalizeGeminiContents(result.contents);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -299,7 +311,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
|
||||
}
|
||||
|
||||
// Wrap Claude format in Cloud Code envelope for Antigravity
|
||||
function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null) {
|
||||
function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null, signature = DEFAULT_THINKING_AG_SIGNATURE) {
|
||||
const projectId = credentials?.projectId || generateProjectId();
|
||||
|
||||
const envelope = {
|
||||
@@ -343,6 +355,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
|
||||
parts.push({ text: block.text });
|
||||
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
|
||||
parts.push({
|
||||
thoughtSignature: signature,
|
||||
functionCall: {
|
||||
id: block.id,
|
||||
name: sanitizeGeminiFunctionName(block.name),
|
||||
@@ -425,6 +438,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
|
||||
envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts };
|
||||
}
|
||||
|
||||
envelope.request.contents = normalizeGeminiContents(envelope.request.contents);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
|
||||
@@ -530,8 +530,15 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
// (the ARN doesn't belong to the key's account). So for api_key, only send a
|
||||
// profileArn that was actually resolved for this connection — never the default.
|
||||
// OAuth/social keep the default fallback (their tokens accept it).
|
||||
// api_key / idc / external_idp carry an account-specific (or token-bound)
|
||||
// profile. The shared builder-id/social default ARN belongs to a different
|
||||
// account and triggers 403 "bearer token invalid", so never fall back to it —
|
||||
// send the resolved ARN, or an empty string so CodeWhisperer uses the token's
|
||||
// own default profile. Only OAuth/social keep the shared placeholder.
|
||||
const authMethod = credentials?.providerSpecificData?.authMethod;
|
||||
const profileArn = authMethod === "api_key"
|
||||
const accountBoundAuth =
|
||||
authMethod === "api_key" || authMethod === "idc" || authMethod === "external_idp";
|
||||
const profileArn = accountBoundAuth
|
||||
? (credentials?.providerSpecificData?.profileArn || "")
|
||||
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
|
||||
|
||||
|
||||
@@ -27,6 +27,25 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
state.messageId = chunk.message?.id || `msg_${Date.now()}`;
|
||||
state.model = chunk.message?.model;
|
||||
state.toolCallIndex = 0;
|
||||
// Claude sends input_tokens + cache_read + cache_creation here; message_delta
|
||||
// later carries only the final output_tokens. Capture cache now so the
|
||||
// delta (output-only) doesn't reset it to zero.
|
||||
const startUsage = chunk.message?.usage;
|
||||
if (startUsage && typeof startUsage === "object") {
|
||||
const inputTokens = typeof startUsage.input_tokens === "number" ? startUsage.input_tokens : 0;
|
||||
const cacheReadTokens = typeof startUsage.cache_read_input_tokens === "number" ? startUsage.cache_read_input_tokens : 0;
|
||||
const cacheCreationTokens = typeof startUsage.cache_creation_input_tokens === "number" ? startUsage.cache_creation_input_tokens : 0;
|
||||
const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
|
||||
state.usage = {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: 0,
|
||||
total_tokens: promptTokens,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: 0
|
||||
};
|
||||
if (cacheReadTokens > 0) state.usage.cache_read_input_tokens = cacheReadTokens;
|
||||
if (cacheCreationTokens > 0) state.usage.cache_creation_input_tokens = cacheCreationTokens;
|
||||
}
|
||||
results.push(createChunk(state, { role: ROLE.ASSISTANT }));
|
||||
break;
|
||||
}
|
||||
@@ -103,13 +122,15 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
}
|
||||
|
||||
case "message_delta": {
|
||||
// Extract usage from message_delta event (Claude native format)
|
||||
// Normalize to OpenAI format (prompt_tokens/completion_tokens) for consistent logging
|
||||
// Extract usage from message_delta event (Claude native format).
|
||||
// Anthropic sends input/cache in message_start and only output here, so
|
||||
// fall back to cache captured in message_start when the delta omits it.
|
||||
if (chunk.usage && typeof chunk.usage === "object") {
|
||||
const inputTokens = typeof chunk.usage.input_tokens === "number" ? chunk.usage.input_tokens : 0;
|
||||
const prev = state.usage || {};
|
||||
const inputTokens = typeof chunk.usage.input_tokens === "number" ? chunk.usage.input_tokens : (prev.input_tokens || 0);
|
||||
const outputTokens = typeof chunk.usage.output_tokens === "number" ? chunk.usage.output_tokens : 0;
|
||||
const cacheReadTokens = typeof chunk.usage.cache_read_input_tokens === "number" ? chunk.usage.cache_read_input_tokens : 0;
|
||||
const cacheCreationTokens = typeof chunk.usage.cache_creation_input_tokens === "number" ? chunk.usage.cache_creation_input_tokens : 0;
|
||||
const cacheReadTokens = typeof chunk.usage.cache_read_input_tokens === "number" ? chunk.usage.cache_read_input_tokens : (prev.cache_read_input_tokens || 0);
|
||||
const cacheCreationTokens = typeof chunk.usage.cache_creation_input_tokens === "number" ? chunk.usage.cache_creation_input_tokens : (prev.cache_creation_input_tokens || 0);
|
||||
|
||||
// prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens)
|
||||
const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
|
||||
@@ -131,7 +152,14 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
const finalChunk = createChunk(state, {}, state.finishReason);
|
||||
|
||||
if (state.usage) {
|
||||
finalChunk.usage = toOpenAIUsage(chunk.usage, "claude");
|
||||
// Build OpenAI usage from the merged state (cache from message_start +
|
||||
// output from message_delta), not the delta chunk alone.
|
||||
finalChunk.usage = toOpenAIUsage({
|
||||
input_tokens: state.usage.input_tokens || 0,
|
||||
output_tokens: state.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: state.usage.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: state.usage.cache_creation_input_tokens
|
||||
}, "claude");
|
||||
}
|
||||
|
||||
results.push(finalChunk);
|
||||
|
||||
@@ -25,7 +25,10 @@ function emitFunctionCall(functionCall, state) {
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
function: { name: fcName, arguments: JSON.stringify(fcArgs) },
|
||||
};
|
||||
state.toolCalls.set(toolCallIndex, toolCall);
|
||||
// Keep Gemini bookkeeping separate from the shared translator state.toolCalls map.
|
||||
// The downstream OpenAI→Claude translator uses state.toolCalls for Claude block
|
||||
// metadata; pre-populating it here makes Anthropic tool deltas lose index.
|
||||
state.geminiToolCallCount = (state.geminiToolCallCount || 0) + 1;
|
||||
return buildChunk(chunkMeta(state), { tool_calls: [toolCall] }, null);
|
||||
}
|
||||
|
||||
@@ -46,6 +49,7 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
state.messageId = response.responseId || `msg_${Date.now()}`;
|
||||
state.model = response.modelVersion || "gemini";
|
||||
state.functionIndex = 0;
|
||||
state.geminiToolCallCount = 0;
|
||||
results.push(buildChunk(chunkMeta(state), { role: ROLE.ASSISTANT }, null));
|
||||
}
|
||||
|
||||
@@ -117,7 +121,7 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
// Finish reason - include usage in final chunk
|
||||
if (candidate.finishReason) {
|
||||
let finishReason = toOpenAIFinish(candidate.finishReason, "gemini");
|
||||
if (finishReason === OPENAI_FINISH.STOP && state.toolCalls.size > 0) {
|
||||
if (finishReason === OPENAI_FINISH.STOP && state.geminiToolCallCount > 0) {
|
||||
finishReason = OPENAI_FINISH.TOOL_CALLS;
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,8 @@ export function openaiToClaudeResponse(chunk, state) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
|
||||
if (tc.id) {
|
||||
// GLM/fireworks repeats id+null-name on every arg chunk; open block once per idx
|
||||
if (tc.id && !state.toolCalls.has(idx)) {
|
||||
stopThinkingBlock(state, results);
|
||||
stopTextBlock(state, results);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user