feat(gemini): persist and replay thoughtSignature with session namespace
- Add open-sse/services/thoughtSignatureStore.js managing LRU Map (2k) + SQLite kv table - Store thoughtSignature with sessionId namespace and toolCallId fallback - Replay cached signature by sessionId:tool_call_id to prevent multi-process collisions - Normalize Antigravity sessionId to numeric int64 format Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,11 @@ import { BaseExecutor } from "./base.js";
|
|||||||
import { PROVIDERS } from "../config/providers.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX, ANTIGRAVITY_PROMPT_REWRITES } from "../config/appConstants.js";
|
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX, ANTIGRAVITY_PROMPT_REWRITES } from "../config/appConstants.js";
|
||||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
import { resolveSessionId, toNumericSessionId } from "../utils/sessionManager.js";
|
||||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js";
|
import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js";
|
||||||
import { DEFAULT_THINKING_AG_SIGNATURE } from "../config/defaultThinkingSignature.js";
|
import { DEFAULT_THINKING_AG_SIGNATURE } from "../config/defaultThinkingSignature.js";
|
||||||
|
import { getGeminiThoughtSignatureSync } from "../services/thoughtSignatureStore.js";
|
||||||
|
|
||||||
// Sanitize function name: Gemini requires [a-zA-Z_][a-zA-Z0-9_.:\-]{0,63}
|
// Sanitize function name: Gemini requires [a-zA-Z_][a-zA-Z0-9_.:\-]{0,63}
|
||||||
function sanitizeFunctionName(name) {
|
function sanitizeFunctionName(name) {
|
||||||
@@ -187,6 +188,9 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawSessionId = body.request?.sessionId || resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.email || credentials?.connectionId, scope: "antigravity" });
|
||||||
|
const sessionId = toNumericSessionId(rawSessionId) || rawSessionId;
|
||||||
|
|
||||||
// ─── Standard (non-image) request ───
|
// ─── Standard (non-image) request ───
|
||||||
// Fix contents for Claude models via Antigravity
|
// Fix contents for Claude models via Antigravity
|
||||||
const contents = body.request?.contents?.map(c => {
|
const contents = body.request?.contents?.map(c => {
|
||||||
@@ -202,17 +206,31 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
// Gemini 3+ rejects functionCall parts without thoughtSignature. Clients (Claude Code, IDE)
|
// Gemini 3+ rejects functionCall parts without thoughtSignature. Clients (Claude Code, IDE)
|
||||||
// don't persist thoughtSignature in their history, so backfill the default signature on any
|
// don't persist thoughtSignature in their history, so backfill from cache or default signature.
|
||||||
// functionCall part that arrives without one.
|
// In parallel function calls, only the first call needs a signature; siblings stay unsigned.
|
||||||
const needsBackfill = parts?.some(p => p.functionCall && !p.thoughtSignature) ?? false;
|
let firstFunctionCallSeen = false;
|
||||||
if (role !== c.role || parts?.length !== c.parts?.length || needsBackfill) {
|
const modifiedParts = parts?.map(p => {
|
||||||
|
if (!p.functionCall) return p;
|
||||||
|
const callId = p.functionCall.id;
|
||||||
|
const cachedSig = callId ? getGeminiThoughtSignatureSync(callId, sessionId) : null;
|
||||||
|
const callSig = p.thoughtSignature || cachedSig || (!firstFunctionCallSeen ? DEFAULT_THINKING_AG_SIGNATURE : undefined);
|
||||||
|
firstFunctionCallSeen = true;
|
||||||
|
if (callSig) {
|
||||||
|
return { ...p, thoughtSignature: callSig };
|
||||||
|
}
|
||||||
|
if (p.thoughtSignature && !cachedSig) {
|
||||||
|
// Unsigned sibling call
|
||||||
|
const { thoughtSignature: _, ...rest } = p;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
const partsChanged = parts?.length !== c.parts?.length || modifiedParts?.some((p, idx) => p !== c.parts[idx]);
|
||||||
|
if (role !== c.role || partsChanged) {
|
||||||
return {
|
return {
|
||||||
...c, role,
|
...c, role,
|
||||||
parts: needsBackfill
|
parts: modifiedParts || parts,
|
||||||
? parts.map(p => (p.functionCall && !p.thoughtSignature)
|
|
||||||
? { ...p, thoughtSignature: DEFAULT_THINKING_AG_SIGNATURE }
|
|
||||||
: p)
|
|
||||||
: parts,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
@@ -267,7 +285,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
generationConfig,
|
generationConfig,
|
||||||
...(contents && { contents }),
|
...(contents && { contents }),
|
||||||
...(tools && { tools }),
|
...(tools && { tools }),
|
||||||
sessionId: body.request?.sessionId || resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.email || credentials?.connectionId, scope: "antigravity" }),
|
sessionId,
|
||||||
safetySettings: undefined,
|
safetySettings: undefined,
|
||||||
...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } })
|
...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } })
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
|
|
||||||
// Streaming response
|
// Streaming response
|
||||||
const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx });
|
const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx });
|
||||||
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId });
|
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId, credentials });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const CODEX_SOURCE_TO_TARGET = {
|
|||||||
/**
|
/**
|
||||||
* Determine which SSE transform stream to use based on provider/format.
|
* Determine which SSE transform stream to use based on provider/format.
|
||||||
*/
|
*/
|
||||||
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey }) {
|
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, credentials }) {
|
||||||
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
|
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
|
||||||
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
|
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
|
||||||
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
|
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
|
||||||
@@ -30,11 +30,11 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
|
|||||||
|
|
||||||
if (needsCodexTranslation) {
|
if (needsCodexTranslation) {
|
||||||
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
|
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
|
||||||
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
|
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames, credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsTranslation(targetFormat, sourceFormat)) {
|
if (needsTranslation(targetFormat, sourceFormat)) {
|
||||||
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
|
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames, credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
|
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
|
||||||
@@ -43,7 +43,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
|
|||||||
/**
|
/**
|
||||||
* Handle streaming response — pipe provider SSE through transform stream to client.
|
* Handle streaming response — pipe provider SSE through transform stream to client.
|
||||||
*/
|
*/
|
||||||
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log }) {
|
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log, credentials }) {
|
||||||
if (onRequestSuccess) {
|
if (onRequestSuccess) {
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(onRequestSuccess)
|
.then(onRequestSuccess)
|
||||||
@@ -79,7 +79,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey });
|
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, credentials });
|
||||||
|
|
||||||
// Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event
|
// Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event
|
||||||
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
|
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
|
||||||
|
|||||||
170
open-sse/services/thoughtSignatureStore.js
Normal file
170
open-sse/services/thoughtSignatureStore.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { makeKv } from "../../src/lib/db/helpers/kvStore.js";
|
||||||
|
|
||||||
|
const MAX_SIGNATURES = 2000;
|
||||||
|
const MAX_PERSISTED_SIGNATURES = 10_000;
|
||||||
|
const MEMORY_TTL_MS = 1000 * 60 * 60; // 1 hour
|
||||||
|
const PERSISTED_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
|
||||||
|
const SCOPE = "gemini_thought_signatures";
|
||||||
|
|
||||||
|
const signatureKv = makeKv(SCOPE);
|
||||||
|
const memorySignatures = new Map();
|
||||||
|
let pruneCounter = 0;
|
||||||
|
|
||||||
|
function pruneMemoryExpired() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, value] of memorySignatures.entries()) {
|
||||||
|
if (value.expiresAt <= now) {
|
||||||
|
memorySignatures.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (memorySignatures.size > MAX_SIGNATURES) {
|
||||||
|
const oldestKey = memorySignatures.keys().next().value;
|
||||||
|
if (!oldestKey) break;
|
||||||
|
memorySignatures.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybePrunePersisted() {
|
||||||
|
pruneCounter++;
|
||||||
|
if (pruneCounter % 100 !== 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const all = await signatureKv.getAll();
|
||||||
|
const keys = Object.keys(all);
|
||||||
|
const now = Date.now();
|
||||||
|
const expiredKeys = [];
|
||||||
|
const valid = [];
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
const entry = all[k];
|
||||||
|
if (!entry || typeof entry.signature !== "string" || (entry.expiresAt && entry.expiresAt <= now)) {
|
||||||
|
expiredKeys.push(k);
|
||||||
|
} else {
|
||||||
|
valid.push({ key: k, createdAt: entry.createdAt || 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of expiredKeys) {
|
||||||
|
await signatureKv.remove(k).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valid.length > MAX_PERSISTED_SIGNATURES) {
|
||||||
|
valid.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
const toRemove = valid.slice(MAX_PERSISTED_SIGNATURES);
|
||||||
|
for (const item of toRemove) {
|
||||||
|
await signatureKv.remove(item.key).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fail-open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a thought signature for a tool_call_id with optional sessionId namespace (RAM + SQLite async)
|
||||||
|
*/
|
||||||
|
export function storeGeminiThoughtSignature(toolCallId, signature, sessionId = null) {
|
||||||
|
if (typeof toolCallId !== "string" || !toolCallId) return;
|
||||||
|
if (typeof signature !== "string" || !signature) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
pruneMemoryExpired();
|
||||||
|
|
||||||
|
const keys = [];
|
||||||
|
if (sessionId && typeof sessionId === "string") {
|
||||||
|
keys.push(`${sessionId}:${toolCallId}`);
|
||||||
|
}
|
||||||
|
keys.push(toolCallId);
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
memorySignatures.set(k, {
|
||||||
|
signature,
|
||||||
|
expiresAt: now + MEMORY_TTL_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Async persist to SQLite kv table without blocking
|
||||||
|
signatureKv.set(k, {
|
||||||
|
signature,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + PERSISTED_TTL_MS,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
maybePrunePersisted().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a thought signature by tool_call_id (RAM first, then SQLite fallback)
|
||||||
|
*/
|
||||||
|
export async function getGeminiThoughtSignature(toolCallId, sessionId = null) {
|
||||||
|
if (typeof toolCallId !== "string" || !toolCallId) return null;
|
||||||
|
|
||||||
|
pruneMemoryExpired();
|
||||||
|
|
||||||
|
if (sessionId && typeof sessionId === "string") {
|
||||||
|
const sessionKey = `${sessionId}:${toolCallId}`;
|
||||||
|
const sessionEntry = memorySignatures.get(sessionKey);
|
||||||
|
if (sessionEntry && sessionEntry.expiresAt > Date.now()) {
|
||||||
|
return sessionEntry.signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = memorySignatures.get(toolCallId);
|
||||||
|
if (entry && entry.expiresAt > Date.now()) {
|
||||||
|
return entry.signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sessionId && typeof sessionId === "string") {
|
||||||
|
const sessionKey = `${sessionId}:${toolCallId}`;
|
||||||
|
const sessionRow = await signatureKv.get(sessionKey);
|
||||||
|
if (sessionRow && typeof sessionRow.signature === "string" && (!sessionRow.expiresAt || sessionRow.expiresAt > Date.now())) {
|
||||||
|
memorySignatures.set(sessionKey, {
|
||||||
|
signature: sessionRow.signature,
|
||||||
|
expiresAt: Date.now() + MEMORY_TTL_MS,
|
||||||
|
});
|
||||||
|
return sessionRow.signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await signatureKv.get(toolCallId);
|
||||||
|
if (row && typeof row.signature === "string") {
|
||||||
|
if (row.expiresAt && row.expiresAt <= Date.now()) {
|
||||||
|
signatureKv.remove(toolCallId).catch(() => {});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
memorySignatures.set(toolCallId, {
|
||||||
|
signature: row.signature,
|
||||||
|
expiresAt: Date.now() + MEMORY_TTL_MS,
|
||||||
|
});
|
||||||
|
return row.signature;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fail-open
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous get from RAM cache only (for sync translators)
|
||||||
|
*/
|
||||||
|
export function getGeminiThoughtSignatureSync(toolCallId, sessionId = null) {
|
||||||
|
if (typeof toolCallId !== "string" || !toolCallId) return null;
|
||||||
|
pruneMemoryExpired();
|
||||||
|
|
||||||
|
if (sessionId && typeof sessionId === "string") {
|
||||||
|
const sessionKey = `${sessionId}:${toolCallId}`;
|
||||||
|
const sessionEntry = memorySignatures.get(sessionKey);
|
||||||
|
if (sessionEntry && sessionEntry.expiresAt > Date.now()) {
|
||||||
|
return sessionEntry.signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = memorySignatures.get(toolCallId);
|
||||||
|
if (entry && entry.expiresAt > Date.now()) {
|
||||||
|
return entry.signature;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { register } from "../index.js";
|
|||||||
import { FORMATS } from "../formats.js";
|
import { FORMATS } from "../formats.js";
|
||||||
import { DEFAULT_THINKING_AG_SIGNATURE, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
import { DEFAULT_THINKING_AG_SIGNATURE, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
||||||
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
|
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
|
||||||
|
import { getGeminiThoughtSignatureSync } from "../../services/thoughtSignatureStore.js";
|
||||||
function generateUUID() {
|
function generateUUID() {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
@@ -46,7 +47,7 @@ function normalizeGeminiContents(contents) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Core: Convert OpenAI request to Gemini format (base for all variants)
|
// Core: Convert OpenAI request to Gemini format (base for all variants)
|
||||||
function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG_SIGNATURE) {
|
function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG_SIGNATURE, sessionId = null) {
|
||||||
const result = {
|
const result = {
|
||||||
model: model,
|
model: model,
|
||||||
contents: [],
|
contents: [],
|
||||||
@@ -133,18 +134,27 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG
|
|||||||
|
|
||||||
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
||||||
const toolCallIds = [];
|
const toolCallIds = [];
|
||||||
|
let firstFunctionCallSeen = false;
|
||||||
for (const tc of msg.tool_calls) {
|
for (const tc of msg.tool_calls) {
|
||||||
if (tc.type !== OPENAI_BLOCK.FUNCTION) continue;
|
if (tc.type !== OPENAI_BLOCK.FUNCTION) continue;
|
||||||
|
|
||||||
const args = tryParseJSON(tc.function?.arguments || "{}");
|
const args = tryParseJSON(tc.function?.arguments || "{}");
|
||||||
parts.push({
|
const cachedSig = tc.id ? getGeminiThoughtSignatureSync(tc.id, sessionId) : null;
|
||||||
thoughtSignature: signature,
|
// First call gets cached signature or fallback; sibling calls remain unsigned if no cached sig
|
||||||
|
const callSig = cachedSig || (!firstFunctionCallSeen ? signature : undefined);
|
||||||
|
firstFunctionCallSeen = true;
|
||||||
|
|
||||||
|
const part = {
|
||||||
functionCall: {
|
functionCall: {
|
||||||
id: tc.id,
|
id: tc.id,
|
||||||
name: sanitizeGeminiFunctionName(tc.function.name),
|
name: sanitizeGeminiFunctionName(tc.function.name),
|
||||||
args: args
|
args: args
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
if (callSig) {
|
||||||
|
part.thoughtSignature = callSig;
|
||||||
|
}
|
||||||
|
parts.push(part);
|
||||||
toolCallIds.push(tc.id);
|
toolCallIds.push(tc.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,13 +242,13 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI -> Gemini (standard API)
|
// OpenAI -> Gemini (standard API)
|
||||||
export function openaiToGeminiRequest(model, body, stream) {
|
export function openaiToGeminiRequest(model, body, stream, credentials = null) {
|
||||||
return openaiToGeminiBase(model, body, stream);
|
return openaiToGeminiBase(model, body, stream, DEFAULT_THINKING_AG_SIGNATURE, credentials?._clientSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI -> Gemini CLI (Cloud Code Assist)
|
// OpenAI -> Gemini CLI (Cloud Code Assist)
|
||||||
export function openaiToGeminiCLIRequest(model, body, stream) {
|
export function openaiToGeminiCLIRequest(model, body, stream, credentials = null) {
|
||||||
const gemini = openaiToGeminiBase(model, body, stream, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE);
|
const gemini = openaiToGeminiBase(model, body, stream, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE, credentials?._clientSessionId);
|
||||||
// Thinking is normalized centrally by applyThinking (thinkingUnified.js) after translation.
|
// Thinking is normalized centrally by applyThinking (thinkingUnified.js) after translation.
|
||||||
|
|
||||||
// Clean schema for tools
|
// Clean schema for tools
|
||||||
@@ -335,18 +345,26 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
if (Array.isArray(msg.content)) {
|
if (Array.isArray(msg.content)) {
|
||||||
|
let firstToolUseSeen = false;
|
||||||
for (const block of msg.content) {
|
for (const block of msg.content) {
|
||||||
if (block.type === CLAUDE_BLOCK.TEXT) {
|
if (block.type === CLAUDE_BLOCK.TEXT) {
|
||||||
parts.push({ text: block.text });
|
parts.push({ text: block.text });
|
||||||
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
|
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
|
||||||
parts.push({
|
const cachedSig = block.id ? getGeminiThoughtSignatureSync(block.id, credentials?._clientSessionId) : null;
|
||||||
thoughtSignature: signature,
|
const callSig = cachedSig || (!firstToolUseSeen ? signature : undefined);
|
||||||
|
firstToolUseSeen = true;
|
||||||
|
|
||||||
|
const part = {
|
||||||
functionCall: {
|
functionCall: {
|
||||||
id: block.id,
|
id: block.id,
|
||||||
name: sanitizeGeminiFunctionName(block.name),
|
name: sanitizeGeminiFunctionName(block.name),
|
||||||
args: block.input || {}
|
args: block.input || {}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
if (callSig) {
|
||||||
|
part.thoughtSignature = callSig;
|
||||||
|
}
|
||||||
|
parts.push(part);
|
||||||
} else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) {
|
} else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) {
|
||||||
let content = block.content;
|
let content = block.content;
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toOpenAIUsage } from "../concerns/usage.js";
|
|||||||
import { reasoningDelta } from "../concerns/reasoning.js";
|
import { reasoningDelta } from "../concerns/reasoning.js";
|
||||||
import { encodeDataUri } from "../concerns/image.js";
|
import { encodeDataUri } from "../concerns/image.js";
|
||||||
import { toOpenAIFinish } from "../concerns/finishReason.js";
|
import { toOpenAIFinish } from "../concerns/finishReason.js";
|
||||||
|
import { storeGeminiThoughtSignature } from "../../services/thoughtSignatureStore.js";
|
||||||
|
|
||||||
// Build chunk meta for current gemini state
|
// Build chunk meta for current gemini state
|
||||||
function chunkMeta(state) {
|
function chunkMeta(state) {
|
||||||
@@ -13,14 +14,18 @@ function chunkMeta(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build a tool_call chunk from a gemini functionCall part (shared by sig/non-sig branches)
|
// Build a tool_call chunk from a gemini functionCall part (shared by sig/non-sig branches)
|
||||||
function emitFunctionCall(functionCall, state) {
|
function emitFunctionCall(functionCall, state, signature = null) {
|
||||||
const rawName = functionCall.name;
|
const rawName = functionCall.name;
|
||||||
// Restore original tool name from mapping (AG cloaking)
|
// Restore original tool name from mapping (AG cloaking)
|
||||||
const fcName = state.toolNameMap?.get(rawName) || rawName;
|
const fcName = state.toolNameMap?.get(rawName) || rawName;
|
||||||
const fcArgs = functionCall.args || {};
|
const fcArgs = functionCall.args || {};
|
||||||
const toolCallIndex = state.functionIndex++;
|
const toolCallIndex = state.functionIndex++;
|
||||||
|
const callId = functionCall.id || `${fcName}-${Date.now()}-${toolCallIndex}`;
|
||||||
|
if (signature) {
|
||||||
|
storeGeminiThoughtSignature(callId, signature, state.sessionId);
|
||||||
|
}
|
||||||
const toolCall = {
|
const toolCall = {
|
||||||
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
|
id: callId,
|
||||||
index: toolCallIndex,
|
index: toolCallIndex,
|
||||||
type: OPENAI_BLOCK.FUNCTION,
|
type: OPENAI_BLOCK.FUNCTION,
|
||||||
function: { name: fcName, arguments: JSON.stringify(fcArgs) },
|
function: { name: fcName, arguments: JSON.stringify(fcArgs) },
|
||||||
@@ -57,13 +62,21 @@ export function geminiToOpenAIResponse(chunk, state) {
|
|||||||
if (content?.parts) {
|
if (content?.parts) {
|
||||||
for (const part of content.parts) {
|
for (const part of content.parts) {
|
||||||
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
|
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
|
||||||
|
if (hasThoughtSig && typeof hasThoughtSig === "string") {
|
||||||
|
state.pendingThoughtSignature = hasThoughtSig;
|
||||||
|
}
|
||||||
const isThought = part.thought === true;
|
const isThought = part.thought === true;
|
||||||
|
|
||||||
// Handle thought signature (thinking mode)
|
// Handle thought signature (thinking mode)
|
||||||
if (hasThoughtSig) {
|
if (hasThoughtSig) {
|
||||||
const hasTextContent = part.text !== undefined && part.text !== "";
|
const hasTextContent = part.text !== undefined && part.text !== "";
|
||||||
const hasFunctionCall = !!part.functionCall;
|
const hasFunctionCall = !!part.functionCall;
|
||||||
|
|
||||||
|
// Standalone thoughtSignature part (no text, no functionCall): keep pending for next functionCall
|
||||||
|
if (!hasTextContent && !hasFunctionCall) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasTextContent) {
|
if (hasTextContent) {
|
||||||
results.push(buildChunk(
|
results.push(buildChunk(
|
||||||
chunkMeta(state),
|
chunkMeta(state),
|
||||||
@@ -71,9 +84,10 @@ export function geminiToOpenAIResponse(chunk, state) {
|
|||||||
null
|
null
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFunctionCall) {
|
if (hasFunctionCall) {
|
||||||
results.push(emitFunctionCall(part.functionCall, state));
|
results.push(emitFunctionCall(part.functionCall, state, hasThoughtSig));
|
||||||
|
state.pendingThoughtSignature = null;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -92,7 +106,9 @@ export function geminiToOpenAIResponse(chunk, state) {
|
|||||||
|
|
||||||
// Function call
|
// Function call
|
||||||
if (part.functionCall) {
|
if (part.functionCall) {
|
||||||
results.push(emitFunctionCall(part.functionCall, state));
|
const sig = state.pendingThoughtSignature || null;
|
||||||
|
results.push(emitFunctionCall(part.functionCall, state, sig));
|
||||||
|
state.pendingThoughtSignature = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline data (images)
|
// Inline data (images)
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export function createSSEStream(options = {}) {
|
|||||||
connectionId = null,
|
connectionId = null,
|
||||||
body = null,
|
body = null,
|
||||||
onStreamComplete = null,
|
onStreamComplete = null,
|
||||||
apiKey = null
|
apiKey = null,
|
||||||
|
credentials = null
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
@@ -59,7 +60,7 @@ export function createSSEStream(options = {}) {
|
|||||||
const decoder = new TextDecoder("utf-8", { fatal: false });
|
const decoder = new TextDecoder("utf-8", { fatal: false });
|
||||||
|
|
||||||
const state = mode === STREAM_MODE.TRANSLATE
|
const state = mode === STREAM_MODE.TRANSLATE
|
||||||
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model }
|
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model, sessionId: credentials?._clientSessionId || null }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let totalContentLength = 0;
|
let totalContentLength = 0;
|
||||||
@@ -487,7 +488,7 @@ export function createSSEStream(options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null) {
|
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null, credentials = null) {
|
||||||
return createSSEStream({
|
return createSSEStream({
|
||||||
mode: STREAM_MODE.TRANSLATE,
|
mode: STREAM_MODE.TRANSLATE,
|
||||||
targetFormat,
|
targetFormat,
|
||||||
@@ -500,7 +501,8 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
|
|||||||
connectionId,
|
connectionId,
|
||||||
body,
|
body,
|
||||||
onStreamComplete,
|
onStreamComplete,
|
||||||
apiKey
|
apiKey,
|
||||||
|
credentials
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user