mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(gemini-cli): align Cloud Code transport (#1869)
Integrated into release/v3.7.9
This commit is contained in:
@@ -120,6 +120,8 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0) {
|
||||
void model;
|
||||
void urlIndex;
|
||||
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
|
||||
return `${this.config.baseUrl}:${action}`;
|
||||
}
|
||||
|
||||
@@ -281,8 +281,12 @@ export function translateNonStreamingResponse(
|
||||
const fn = toRecord(partObj.functionCall);
|
||||
const rawName = toString(fn.name);
|
||||
const restoredName = toolNameMap?.get(rawName) ?? rawName;
|
||||
const nativeId = toString(fn.id);
|
||||
toolCalls.push({
|
||||
id: `call_${toString(restoredName, "unknown")}_${Date.now()}_${toolCalls.length}`,
|
||||
id:
|
||||
nativeId.length > 0
|
||||
? nativeId
|
||||
: `call_${toString(restoredName, "unknown")}_${Date.now()}_${toolCalls.length}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: restoredName,
|
||||
|
||||
@@ -7,6 +7,16 @@ import {
|
||||
export const GEMINI_CLI_VERSION = "0.40.1";
|
||||
export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "9.15.1";
|
||||
|
||||
const GEMINI_CLI_LOAD_CODE_ASSIST_METADATA = Object.freeze({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
});
|
||||
|
||||
export function getGeminiCliLoadCodeAssistMetadata(): Record<string, string> {
|
||||
return { ...GEMINI_CLI_LOAD_CODE_ASSIST_METADATA };
|
||||
}
|
||||
|
||||
export function geminiCliUserAgent(model: string): string {
|
||||
const normalizedModel = model || "unknown";
|
||||
return `GeminiCLI/${GEMINI_CLI_VERSION}/${normalizedModel} (${normalizeCloudCodePlatform()}; ${normalizeCloudCodeArch()}; terminal) google-api-nodejs-client/${GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION}`;
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
convertOpenAIContentToParts,
|
||||
extractTextContent,
|
||||
tryParseJSON,
|
||||
generateRequestId,
|
||||
generateSessionId,
|
||||
cleanJSONSchemaForAntigravity,
|
||||
} from "../helpers/geminiHelper.ts";
|
||||
@@ -97,6 +96,7 @@ type CloudCodeEnvelope = {
|
||||
|
||||
type GeminiToolNameOptions = {
|
||||
stripNamespace?: boolean;
|
||||
functionResponseShape?: "result" | "output";
|
||||
};
|
||||
|
||||
function buildChangedToolNameMap(toolNameMap: Map<string, string>): Map<string, string> | null {
|
||||
@@ -296,7 +296,10 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName
|
||||
functionResponse: {
|
||||
id: fid,
|
||||
name: name,
|
||||
response: { result: parsedResp },
|
||||
response:
|
||||
toolNameOptions.functionResponseShape === "output"
|
||||
? { output: typeof resp === "string" ? resp : JSON.stringify(resp) }
|
||||
: { result: parsedResp },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -350,8 +353,16 @@ export function openaiToGeminiRequest(model, body, stream) {
|
||||
}
|
||||
|
||||
// OpenAI -> Gemini CLI (Cloud Code Assist)
|
||||
export function openaiToGeminiCLIRequest(model, body, stream) {
|
||||
const gemini = openaiToGeminiBase(model, body, stream, { stripNamespace: true });
|
||||
export function openaiToGeminiCLIRequest(
|
||||
model,
|
||||
body,
|
||||
stream,
|
||||
options: { functionResponseShape?: "result" | "output" } = {}
|
||||
) {
|
||||
const gemini = openaiToGeminiBase(model, body, stream, {
|
||||
stripNamespace: true,
|
||||
functionResponseShape: options.functionResponseShape,
|
||||
});
|
||||
|
||||
// Add thinking config for CLI
|
||||
if (body.reasoning_effort) {
|
||||
@@ -413,7 +424,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
|
||||
: {
|
||||
model: cleanModel,
|
||||
project: projectId,
|
||||
user_prompt_id: generateRequestId(),
|
||||
user_prompt_id: generateUUID(),
|
||||
request: {
|
||||
contents: geminiCLI.contents,
|
||||
systemInstruction: geminiCLI.systemInstruction,
|
||||
@@ -443,7 +454,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
|
||||
}
|
||||
} else {
|
||||
// Gemini CLI's native Cloud Code envelope uses snake_case identifiers.
|
||||
envelope.request.session_id = generateSessionId();
|
||||
envelope.request.session_id = envelope.user_prompt_id;
|
||||
envelope.request.safetySettings = geminiCLI.safetySettings;
|
||||
}
|
||||
|
||||
@@ -619,7 +630,11 @@ register(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.GEMINI_CLI,
|
||||
(model, body, stream, credentials) =>
|
||||
wrapInCloudCodeEnvelope(model, openaiToGeminiCLIRequest(model, body, stream), credentials),
|
||||
wrapInCloudCodeEnvelope(
|
||||
model,
|
||||
openaiToGeminiCLIRequest(model, body, stream, { functionResponseShape: "output" }),
|
||||
credentials
|
||||
),
|
||||
null
|
||||
);
|
||||
register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, openaiToAntigravityRequest, null);
|
||||
|
||||
@@ -2,6 +2,12 @@ import { register } from "../registry.ts";
|
||||
import { FORMATS } from "../formats.ts";
|
||||
import { storeGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts";
|
||||
|
||||
function buildToolCallId(functionCall, toolName, toolCallIndex) {
|
||||
return typeof functionCall?.id === "string" && functionCall.id.length > 0
|
||||
? functionCall.id
|
||||
: `${toolName}-${Date.now()}-${toolCallIndex}`;
|
||||
}
|
||||
|
||||
// Convert Gemini response chunk to OpenAI format
|
||||
export function geminiToOpenAIResponse(chunk, state) {
|
||||
if (!chunk) return null;
|
||||
@@ -111,7 +117,7 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
const toolCallIndex = state.functionIndex++;
|
||||
|
||||
const toolCall = {
|
||||
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
|
||||
id: buildToolCallId(part.functionCall, fcName, toolCallIndex),
|
||||
index: toolCallIndex,
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -169,7 +175,7 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
const toolCallIndex = state.functionIndex++;
|
||||
|
||||
const toolCall = {
|
||||
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
|
||||
id: buildToolCallId(part.functionCall, fcName, toolCallIndex),
|
||||
index: toolCallIndex,
|
||||
type: "function",
|
||||
function: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GEMINI_CONFIG } from "../constants/oauth";
|
||||
import { getGeminiCliLoadCodeAssistMetadata } from "@omniroute/open-sse/services/geminiCliHeaders.ts";
|
||||
|
||||
export const gemini = {
|
||||
config: GEMINI_CONFIG,
|
||||
@@ -72,11 +73,7 @@ export const gemini = {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
metadata: getGeminiCliLoadCodeAssistMetadata(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
import open from "open";
|
||||
import {
|
||||
ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT,
|
||||
ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT,
|
||||
} from "@omniroute/open-sse/services/antigravityHeaders.ts";
|
||||
import { getGeminiCliLoadCodeAssistMetadata } from "@omniroute/open-sse/services/geminiCliHeaders.ts";
|
||||
import { GEMINI_CONFIG } from "../constants/oauth";
|
||||
import { getServerCredentials } from "../config/index";
|
||||
import { startLocalServer } from "../utils/server";
|
||||
@@ -73,20 +70,9 @@ export class GeminiCLIService {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT,
|
||||
"X-Goog-Api-Client": ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT,
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
metadata: getGeminiCliLoadCodeAssistMetadata(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -19,9 +19,8 @@ export const FREE_PROVIDERS = {
|
||||
name: "Gemini CLI",
|
||||
icon: "terminal",
|
||||
color: "#4285F4",
|
||||
deprecated: true,
|
||||
deprecationReason:
|
||||
"Google restricts third-party OAuth usage for Gemini CLI (Mar 2026). Pro models require paid plans. Use 'gemini' (API key) provider instead.",
|
||||
authHint:
|
||||
"Uses Gemini CLI OAuth / Cloud Code credentials. Pro models require an eligible Google account or paid plan.",
|
||||
},
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
|
||||
"amazon-q": {
|
||||
|
||||
@@ -22,6 +22,7 @@ const { skillExecutor } = await import("../../src/lib/skills/executor.ts");
|
||||
const { handleChat } = await import("../../src/sse/handlers/chat.ts");
|
||||
const { initTranslators } = await import("../../open-sse/translator/index.ts");
|
||||
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
|
||||
const { setCliCompatProviders } = await import("../../open-sse/config/cliFingerprints.ts");
|
||||
const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
|
||||
const { getCircuitBreaker, resetAllCircuitBreakers } =
|
||||
await import("../../src/shared/utils/circuitBreaker.ts");
|
||||
@@ -31,9 +32,40 @@ const { setCliCompatProviders } = await import("../../open-sse/config/cliFingerp
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalRetryDelayMs = BaseExecutor.RETRY_CONFIG.delayMs;
|
||||
|
||||
function toPlainHeaders(headers: Headers | Record<string, unknown> | undefined | null) {
|
||||
type SeedConnectionOverrides = {
|
||||
name?: string;
|
||||
authType?: string;
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
tokenType?: string;
|
||||
expiresAt?: string;
|
||||
tokenExpiresAt?: string;
|
||||
isActive?: boolean;
|
||||
testStatus?: string;
|
||||
priority?: number;
|
||||
rateLimitedUntil?: string | number | null;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FetchCall = {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, any> | null;
|
||||
};
|
||||
|
||||
type SeedApiKeyOptions = {
|
||||
name?: string;
|
||||
noLog?: boolean;
|
||||
allowedConnections?: string[];
|
||||
allowedModels?: string[];
|
||||
};
|
||||
|
||||
function toPlainHeaders(headers: HeadersInit | undefined | null) {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
if (Array.isArray(headers)) return Object.fromEntries(headers);
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
|
||||
);
|
||||
@@ -344,7 +376,7 @@ async function resetStorage() {
|
||||
initTranslators();
|
||||
}
|
||||
|
||||
async function seedConnection(provider, overrides = {}) {
|
||||
async function seedConnection(provider, overrides: SeedConnectionOverrides = {}) {
|
||||
return providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: overrides.authType || "apikey",
|
||||
@@ -369,9 +401,9 @@ async function seedApiKey({
|
||||
noLog = false,
|
||||
allowedConnections,
|
||||
allowedModels,
|
||||
} = {}) {
|
||||
}: SeedApiKeyOptions = {}) {
|
||||
const key = await apiKeysDb.createApiKey(name, "machine-test");
|
||||
const updates = {};
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (noLog) updates.noLog = true;
|
||||
if (allowedConnections) updates.allowedConnections = allowedConnections;
|
||||
if (allowedModels) updates.allowedModels = allowedModels;
|
||||
@@ -494,9 +526,9 @@ test.after(async () => {
|
||||
test("chat pipeline handles OpenAI passthrough with valid API key auth", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-primary" });
|
||||
const apiKey = await seedApiKey();
|
||||
const fetchCalls = [];
|
||||
const fetchCalls: FetchCall[] = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
method: init.method || "GET",
|
||||
@@ -530,7 +562,7 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call
|
||||
await seedConnection("codex", { apiKey: "sk-codex-primary" });
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
@@ -657,7 +689,7 @@ test("chat pipeline treats Codex /responses/compact as non-streaming JSON", asyn
|
||||
await seedConnection("codex", { apiKey: "sk-codex-compact" });
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
@@ -699,7 +731,7 @@ test("chat pipeline serves repeated /v1/responses requests as MISS then HIT and
|
||||
await seedConnection("codex", { apiKey: "sk-codex-cache-seq" });
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
@@ -789,7 +821,7 @@ test("chat pipeline translates OpenAI requests to Claude and returns OpenAI-shap
|
||||
await seedConnection("claude", { apiKey: "sk-claude-primary" });
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
@@ -823,7 +855,7 @@ test("chat pipeline translates OpenAI requests to Gemini and returns OpenAI-shap
|
||||
await seedConnection("gemini", { apiKey: "sk-gemini-primary" });
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
@@ -853,11 +885,84 @@ test("chat pipeline translates OpenAI requests to Gemini and returns OpenAI-shap
|
||||
assert.equal(json.choices[0].message.content, "Gemini translated reply");
|
||||
});
|
||||
|
||||
test("chat pipeline sends Gemini CLI OAuth requests with native Cloud Code transport", async () => {
|
||||
setCliCompatProviders(["gemini-cli"]);
|
||||
await seedConnection("gemini-cli", {
|
||||
authType: "oauth",
|
||||
apiKey: "unused-for-oauth",
|
||||
accessToken: "gemini-cli-oauth-token",
|
||||
providerSpecificData: { projectId: "stored-project" },
|
||||
});
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
|
||||
if (String(url).endsWith("loadCodeAssist")) {
|
||||
return new Response(JSON.stringify({ cloudaicompanionProject: "fresh-project" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return buildGeminiResponse("Gemini CLI translated reply", "gemini-3-flash-preview");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "gemini-cli/gemini-3-flash-preview",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Hello Gemini CLI" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = (await response.json()) as any;
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 2);
|
||||
|
||||
const loadCodeAssistCall = fetchCalls[0];
|
||||
assert.match(loadCodeAssistCall.url, /loadCodeAssist$/);
|
||||
assert.equal(loadCodeAssistCall.headers.Authorization, "Bearer gemini-cli-oauth-token");
|
||||
assert.equal(loadCodeAssistCall.body.metadata.ideType, "IDE_UNSPECIFIED");
|
||||
|
||||
const generateCall = fetchCalls[1];
|
||||
assert.match(generateCall.url, /generateContent$/);
|
||||
assert.equal(generateCall.headers.Authorization, "Bearer gemini-cli-oauth-token");
|
||||
assert.equal(generateCall.headers.Accept, "application/json");
|
||||
assert.match(
|
||||
generateCall.headers["User-Agent"],
|
||||
/^GeminiCLI\/0\.40\.1\/gemini-3-flash-preview .* google-api-nodejs-client\/9\.15\.1$/
|
||||
);
|
||||
assert.match(generateCall.headers["X-Goog-Api-Client"], /^gl-node\/\d+\.\d+\.\d+$/);
|
||||
assert.equal(generateCall.body.project, "fresh-project");
|
||||
assert.equal(generateCall.body.model, "gemini-3-flash-preview");
|
||||
assert.equal(generateCall.body.userAgent, undefined);
|
||||
assert.equal(generateCall.body.requestId, undefined);
|
||||
assert.equal(generateCall.body.user_prompt_id, generateCall.body.request.session_id);
|
||||
assert.deepEqual(Object.keys(generateCall.body).slice(0, 4), [
|
||||
"model",
|
||||
"project",
|
||||
"user_prompt_id",
|
||||
"request",
|
||||
]);
|
||||
assert.equal(generateCall.body.request.sessionId, undefined);
|
||||
assert.match(generateCall.body.request.session_id, /^[0-9a-f-]{36}$/i);
|
||||
assert.equal(generateCall.body.request.contents.at(-1).parts[0].text, "Hello Gemini CLI");
|
||||
assert.equal(json.object, "chat.completion");
|
||||
assert.equal(json.choices[0].message.content, "Gemini CLI translated reply");
|
||||
});
|
||||
|
||||
test("chat pipeline translates Claude-format requests into OpenAI upstream and back to Claude", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-claude-route" });
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
@@ -1010,7 +1115,7 @@ test("chat pipeline supports local mode without Authorization on explicit combos
|
||||
});
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
@@ -1220,7 +1325,7 @@ test("chat pipeline injects memory context before sending the upstream request",
|
||||
insertLegacyMemory(apiKey.id, "User prefers concise answers.");
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
@@ -1281,7 +1386,7 @@ test("chat pipeline injects skills into tools and intercepts tool calls with ski
|
||||
});
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
@@ -1325,7 +1430,7 @@ test("chat pipeline falls back to the next account after a provider failure", as
|
||||
});
|
||||
const seenAuthHeaders = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
const headers = toPlainHeaders(init.headers);
|
||||
seenAuthHeaders.push(headers.Authorization);
|
||||
if (seenAuthHeaders.length === 1) {
|
||||
@@ -1370,7 +1475,7 @@ test("chat pipeline falls back across combo models when the first provider fails
|
||||
});
|
||||
const attempts = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
const call = {
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
|
||||
@@ -5,7 +5,14 @@ import { GeminiCLIExecutor } from "../../open-sse/executors/gemini-cli.ts";
|
||||
import { setCliCompatProviders } from "../../open-sse/config/cliFingerprints.ts";
|
||||
import { GEMINI_CLI_VERSION } from "../../open-sse/services/geminiCliHeaders.ts";
|
||||
|
||||
type CapturedFetchCall = { url: string; body: Record<string, unknown> };
|
||||
type CapturedFetchCall = {
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function parseInitBody(init: RequestInit): Record<string, unknown> {
|
||||
return init.body ? JSON.parse(String(init.body)) : {};
|
||||
}
|
||||
|
||||
function getMetadata(body: Record<string, unknown>): Record<string, unknown> {
|
||||
return body.metadata && typeof body.metadata === "object"
|
||||
@@ -54,6 +61,27 @@ test("GeminiCLIExecutor.buildHeaders uses JSON accept for non-streaming requests
|
||||
assert.equal(headers.Accept, "application/json");
|
||||
});
|
||||
|
||||
test("GeminiCLIExecutor.buildHeaders derives the User-Agent from the request model", () => {
|
||||
const executor = new GeminiCLIExecutor();
|
||||
|
||||
const flashHeaders = executor.buildHeaders(
|
||||
{ accessToken: "gcli-token" },
|
||||
true,
|
||||
undefined,
|
||||
"models/gemini-3-flash-preview"
|
||||
);
|
||||
const proHeaders = executor.buildHeaders(
|
||||
{ accessToken: "gcli-token" },
|
||||
true,
|
||||
undefined,
|
||||
"models/gemini-3.1-pro-preview"
|
||||
);
|
||||
|
||||
assert.match(flashHeaders["User-Agent"], /\/gemini-3-flash-preview /);
|
||||
assert.match(proHeaders["User-Agent"], /\/gemini-3\.1-pro-preview /);
|
||||
assert.notEqual(flashHeaders["User-Agent"], proHeaders["User-Agent"]);
|
||||
});
|
||||
|
||||
test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest updates body.project", async () => {
|
||||
const executor = new GeminiCLIExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -177,7 +205,7 @@ test("GeminiCLIExecutor.refreshProject onboards a managed project when loadCodeA
|
||||
const calls: CapturedFetchCall[] = [];
|
||||
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
const body = init.body ? JSON.parse(String(init.body)) : {};
|
||||
const body = parseInitBody(init);
|
||||
calls.push({ url: String(url), body });
|
||||
|
||||
if (String(url).endsWith("loadCodeAssist")) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
||||
|
||||
const { openaiToAntigravityRequest, openaiToGeminiCLIRequest, openaiToGeminiRequest } =
|
||||
await import("../../open-sse/translator/request/openai-to-gemini.ts");
|
||||
const { getRequestTranslator } = await import("../../open-sse/translator/registry.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const {
|
||||
DEFAULT_SAFETY_SETTINGS,
|
||||
cleanJSONSchemaForAntigravity,
|
||||
@@ -509,6 +511,69 @@ test("OpenAI -> Gemini helper IDs and JSON parsing stay in the expected format",
|
||||
assert.equal(tryParseJSON("not-json"), null as any);
|
||||
});
|
||||
|
||||
test("OpenAI -> Gemini CLI wraps requests like native Cloud Code", () => {
|
||||
const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI_CLI);
|
||||
assert.ok(translate, "expected Gemini CLI translator registration");
|
||||
|
||||
const envelope = translate(
|
||||
"gemini-3-flash-preview",
|
||||
{
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
true,
|
||||
{ projectId: "project-1" }
|
||||
) as any;
|
||||
|
||||
assert.equal(envelope.model, "gemini-3-flash-preview");
|
||||
assert.equal(envelope.userAgent, undefined);
|
||||
assert.equal(envelope.requestId, undefined);
|
||||
assert.equal(envelope.request.sessionId, undefined);
|
||||
assert.match(envelope.request.session_id, /^[0-9a-f-]{36}$/i);
|
||||
assert.equal(envelope.user_prompt_id, envelope.request.session_id);
|
||||
});
|
||||
|
||||
test("OpenAI -> Gemini CLI emits native Cloud Code functionResponse output", () => {
|
||||
const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI_CLI);
|
||||
assert.ok(translate, "expected Gemini CLI translator registration");
|
||||
|
||||
const envelope = translate(
|
||||
"gemini-3-flash-preview",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "Read fixture" },
|
||||
{
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "read_file_123_0",
|
||||
type: "function",
|
||||
function: { name: "read_file", arguments: '{"file_path":"fixture.txt"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "read_file_123_0",
|
||||
content: "The answer is capybara-4729.",
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
{ projectId: "project-1" }
|
||||
) as any;
|
||||
|
||||
const toolTurn = envelope.request.contents.find(
|
||||
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
|
||||
);
|
||||
assert.ok(toolTurn, "expected Gemini CLI tool response turn");
|
||||
assert.deepEqual(getFunctionResponse(toolTurn.parts[0]), {
|
||||
id: "read_file_123_0",
|
||||
name: "read_file",
|
||||
response: { output: "The answer is capybara-4729." },
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () => {
|
||||
const result = openaiToAntigravityRequest(
|
||||
"gemini-2.5-pro",
|
||||
@@ -678,6 +743,7 @@ test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves res
|
||||
);
|
||||
assert.ok(toolTurn, "expected a tool response turn");
|
||||
assert.equal(getFunctionResponse(toolTurn.parts[0]).name, sanitizedToolName);
|
||||
assert.deepEqual(getFunctionResponse(toolTurn.parts[0]).response, { result: { ok: true } });
|
||||
});
|
||||
|
||||
test("OpenAI -> Antigravity Claude bridge applies Antigravity output cap without forwarding thinking", () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ test("Gemini non-stream: multiple candidates keep multimodal content, reasoning
|
||||
{ thought: true, text: "Plan first." },
|
||||
{ text: "Answer:" },
|
||||
{ inlineData: { mimeType: "image/png", data: "abc123" } },
|
||||
{ functionCall: { name: "read_file", args: { path: "/tmp/a" } } },
|
||||
{ functionCall: { id: "native-read-1", name: "read_file", args: { path: "/tmp/a" } } },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
@@ -101,6 +101,7 @@ test("Gemini non-stream: multiple candidates keep multimodal content, reasoning
|
||||
((result as any).choices[0].message as any).tool_calls[0].function.arguments,
|
||||
JSON.stringify({ path: "/tmp/a" })
|
||||
);
|
||||
assert.equal((result as any).choices[0].message.tool_calls[0].id, "native-read-1");
|
||||
assert.equal(((result as any).choices[1].message as any).content, "Second option");
|
||||
(assert as any).equal((result as any).choices[1].finish_reason, "length");
|
||||
assert.equal((result as any).usage.prompt_tokens, 4);
|
||||
@@ -259,6 +260,7 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve
|
||||
{ thought: true, thoughtSignature: "sig-1", text: "Need a plan." },
|
||||
{
|
||||
functionCall: {
|
||||
id: "native-call-1",
|
||||
name: "weather_lookup_bundle_ab12cd34",
|
||||
args: { city: "Sao Paulo" },
|
||||
},
|
||||
@@ -281,6 +283,10 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve
|
||||
);
|
||||
|
||||
assert.equal(result[1].choices[0].delta.reasoning_content, "Need a plan.");
|
||||
assert.equal(
|
||||
result[2].choices[0].delta.tool_calls[0].id,
|
||||
"native-call-1"
|
||||
);
|
||||
assert.equal(
|
||||
result[2].choices[0].delta.tool_calls[0].function.name,
|
||||
"mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle_v2"
|
||||
@@ -297,6 +303,37 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve
|
||||
assert.equal(result[4].usage.completion_tokens_details.reasoning_tokens, 2);
|
||||
});
|
||||
|
||||
test("Gemini stream: tool calls without native IDs keep deterministic fallback shape", () => {
|
||||
const state = createStreamingState();
|
||||
const result = geminiToOpenAIResponse(
|
||||
{
|
||||
responseId: "resp-tool-no-id",
|
||||
modelVersion: "gemini-3-flash-preview",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
thoughtSignature: "sig-2",
|
||||
functionCall: {
|
||||
name: "read_file",
|
||||
args: { file_path: "fixture.txt" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
const toolCall = result[1].choices[0].delta.tool_calls[0];
|
||||
assert.match(toolCall.id, /^read_file-\d+-0$/);
|
||||
assert.equal(toolCall.function.name, "read_file");
|
||||
assert.equal(toolCall.function.arguments, JSON.stringify({ file_path: "fixture.txt" }));
|
||||
});
|
||||
|
||||
test("Gemini stream: safety block without candidates emits role chunk then content_filter finish", () => {
|
||||
const state = createStreamingState();
|
||||
const result = geminiToOpenAIResponse(
|
||||
|
||||
Reference in New Issue
Block a user