Files
2026-07-13 13:39:12 +08:00

658 lines
21 KiB
TypeScript

// Gemini helper functions for translator
import { safeParseJSON } from "./jsonUtil.ts";
type JsonRecord = Record<string, unknown>;
// Unsupported JSON Schema constraints that should be removed for Antigravity.
// `additionalProperties` is handled separately so `true` can be preserved.
export const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([
// Basic constraints (not supported by Gemini API)
"minLength",
"maxLength",
"exclusiveMinimum",
"exclusiveMaximum",
// `multipleOf` is not part of the Gemini/antigravity OpenAPI 3.0 schema subset;
// leaving it in function_declarations triggers a hard upstream 400
// ("Unknown name \"multipleOf\""). `minimum`/`maximum` ARE accepted and kept.
"multipleOf",
// NOTE: `pattern` is intentionally NOT in this set. Antigravity (Gemini-derived
// surface) accepts `pattern` on string constraints, and glob/grep/file-search
// tools depend on it to express their argument regex. Removing it produced
// upstream 400s and wrong-tool semantics (decolua/9router#1368).
"minItems",
"maxItems",
"format",
// Claude rejects these in VALIDATED mode
"default",
"examples",
// JSON Schema meta keywords
"$schema",
"$id",
"$anchor",
"$dynamicRef",
"$dynamicAnchor",
"$vocabulary",
"$comment",
"$defs",
"definitions",
"const",
"$ref",
"ref",
// Object validation keywords (not supported)
"propertyNames",
"patternProperties",
"unevaluatedProperties",
"unevaluatedItems",
"contains",
"minContains",
"maxContains",
// 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",
"contentSchema",
"readOnly",
"writeOnly",
// Non-standard schema fields (not recognized by Gemini API)
"deprecated",
"optional",
// VS Code / JSON Language Service extensions injected by GitHub Copilot tools (#1175)
"enumDescriptions",
"markdownDescription",
"markdownEnumDescriptions",
"enumItemLabels",
"tags",
// UI/Styling properties (from Cursor tools - NOT JSON Schema standard)
"cornerRadius",
"fillColor",
"fontFamily",
"fontSize",
"fontWeight",
"gap",
"padding",
"strokeColor",
"strokeThickness",
"textColor",
]);
export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [...GEMINI_UNSUPPORTED_SCHEMA_KEYS];
// 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" },
];
function normalizeAudioMimeType(format: unknown): string {
const normalized =
typeof format === "string" && format.trim() ? format.trim().toLowerCase() : "wav";
if (normalized === "mp3") {
return "audio/mpeg";
}
return `audio/${normalized}`;
}
// Convert OpenAI content to Gemini parts
export function convertOpenAIContentToParts(content: unknown): JsonRecord[] {
const parts: JsonRecord[] = [];
if (typeof content === "string") {
parts.push({ text: content });
} else if (Array.isArray(content)) {
for (const item of content) {
const rec = toRecord(item);
if (rec.type === "text") {
parts.push({ text: rec.text });
} else if (rec.type === "input_audio" || rec.type === "audio") {
const audio = toRecord(rec.input_audio || rec.audio);
if (typeof audio.data === "string" && audio.data) {
parts.push({
inlineData: {
mimeType: normalizeAudioMimeType(audio.format),
data: audio.data.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""),
},
});
}
} else if (rec.type === "audio_url") {
// OpenAI-style audio_url (data: URI). Mirrors the image_url data-URL
// parser below but produces an audio inlineData part (#913).
const audioUrl = toRecord(rec.audio_url);
const url = typeof audioUrl.url === "string" ? audioUrl.url : "";
if (url.startsWith("data:")) {
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] || "audio/wav";
parts.push({ inlineData: { mimeType, data } });
}
}
} else {
// 1. Handle Gemini native inline_data injected into OpenAI arrays (e.g. Cherry Studio)
const geminiInline = toRecord(rec.inline_data || rec.inlineData);
if (geminiInline?.data) {
parts.push({
inlineData: {
mimeType: String(
geminiInline.mime_type || geminiInline.mimeType || "application/pdf"
),
data: String(geminiInline.data).replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""),
},
});
continue;
}
// 2. Handle Claude-style source blocks commonly used by AI clients
const source = toRecord(rec.source);
if (source?.type === "base64" && source?.data) {
parts.push({
inlineData: {
mimeType: String(source.media_type || "application/pdf"),
data: String(source.data).replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""),
},
});
continue;
}
// 3. Handle raw data strings (e.g. {"type": "file", "data": "JVBER...", "mime_type": "..."}).
// Also accept the Responses-API shape {"type":"input_file","file_data":"JVBER...","filename":...}
// so PDFs sent as `input_file` reach Gemini instead of being silently dropped (#2515).
const file = toRecord(rec.file);
const doc = toRecord(rec.document);
const rawDataStr = rec.data || rec.file_data || file?.data || doc?.data;
const mimeTypeFallback =
rec.mime_type || rec.media_type || file?.mime_type || doc?.mime_type || "application/pdf";
if (typeof rawDataStr === "string" && !rawDataStr.startsWith("http")) {
const rawData = rawDataStr.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, "");
parts.push({
inlineData: {
mimeType: String(mimeTypeFallback),
data: rawData,
},
});
continue;
}
// 4. Standard OpenAI Data URIs
const imageUrl = toRecord(rec.image_url);
const imageObj = toRecord(rec.image);
const fileUrl = toRecord(rec.file_url);
const fileObj = toRecord(rec.file);
const docObj = toRecord(rec.document);
// `file_url` is a top-level string on the Responses-API input_file shape (#2515).
// `rec.image` (with nested {url}) is emitted by some MCP tool wrappers and
// translation layers as an alternative to `rec.image_url` (#2807).
const fileData =
(typeof rec.file_url === "string" ? rec.file_url : undefined) ||
// AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330)
(typeof rec.image === "string" ? rec.image : undefined) ||
imageUrl?.url ||
imageObj?.url ||
fileUrl?.url ||
fileObj?.url ||
docObj?.url;
if (typeof fileData === "string" && fileData.startsWith("data:")) {
const commaIndex = fileData.indexOf(",");
if (commaIndex !== -1) {
const mimePart = fileData.substring(5, commaIndex); // skip "data:"
const data = fileData.substring(commaIndex + 1);
const mimeType = mimePart.split(";")[0];
parts.push({
inlineData: { mimeType, data },
});
}
} else if (typeof fileData === "string" && /^https?:\/\//i.test(fileData)) {
// Remote URLs cannot be embedded as inlineData (which requires base64),
// but Gemini's Part schema natively accepts `fileData: { fileUri }` for
// HTTP/HTTPS sources — the model fetches the asset itself. Pass the URL
// through instead of dropping it (#2807; ported from upstream PR #344).
// The MIME type is intentionally `image/*` because we do not block on
// a HEAD request to sniff it; Gemini infers the concrete type on fetch.
parts.push({
fileData: { fileUri: fileData, mimeType: "image/*" },
});
}
}
}
}
return parts;
}
// Extract text content from OpenAI content
export function extractTextContent(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((item) => toRecord(item))
.filter((c) => c.type === "text")
.map((c) => (typeof c.text === "string" ? c.text : ""))
.join("");
}
return "";
}
// Try parse JSON safely (null fallback on parse error; re-export keeps legacy API).
export function tryParseJSON(str: unknown): unknown {
return safeParseJSON(str, null);
}
// Generate request ID
export function generateRequestId() {
return `agent-${crypto.randomUUID()}`;
}
// Generate session ID
export function generateSessionId() {
const arr = new BigUint64Array(1);
globalThis.crypto.getRandomValues(arr);
const num = arr[0] % 9000000000000000000n;
return `-${num.toString()}`;
}
function cloneSchemaValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => cloneSchemaValue(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, cloneSchemaValue(nestedValue)])
);
}
return value;
}
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function decodeJsonPointerSegment(segment: unknown): string {
return String(segment).replace(/~1/g, "/").replace(/~0/g, "~");
}
function resolveLocalReference(root: unknown, ref: unknown): unknown | null {
if (typeof ref !== "string" || !ref.startsWith("#/")) return null;
let current: unknown = root;
const segments = ref
.slice(2)
.split("/")
.filter(Boolean)
.map((segment) => decodeJsonPointerSegment(segment));
for (const segment of segments) {
const currentRecord = toRecord(current);
if (!(segment in currentRecord)) {
return null;
}
current = currentRecord[segment];
}
return current;
}
function inlineLocalSchemaRefs(
node: unknown,
root: unknown,
activeRefs: Set<string> = new Set<string>()
): unknown {
if (Array.isArray(node)) {
return node.map((item) => inlineLocalSchemaRefs(item, root, activeRefs));
}
if (!node || typeof node !== "object") {
return node;
}
const record: JsonRecord = { ...toRecord(node) };
const ref = typeof record.$ref === "string" ? record.$ref : "";
if (ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/")) {
const rest = { ...record };
delete rest.$ref;
if (activeRefs.has(ref)) {
return inlineLocalSchemaRefs(rest, root, activeRefs);
}
const resolved = resolveLocalReference(root, ref);
if (!resolved || typeof resolved !== "object") {
return inlineLocalSchemaRefs(rest, root, activeRefs);
}
activeRefs.add(ref);
const merged = {
...toRecord(inlineLocalSchemaRefs(cloneSchemaValue(resolved), root, activeRefs)),
...rest,
};
activeRefs.delete(ref);
return inlineLocalSchemaRefs(merged, root, activeRefs);
}
return Object.fromEntries(
Object.entries(record).map(([key, value]) => [
key,
inlineLocalSchemaRefs(value, root, activeRefs),
])
);
}
// Helper: Remove unsupported keywords recursively from object/array
function removeUnsupportedKeywords(obj: unknown, keywords: Set<string>): void {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
for (const item of obj) {
removeUnsupportedKeywords(item, keywords);
}
return;
}
const record = obj as JsonRecord;
// Delete unsupported *constraint* keywords at the current schema level.
for (const key of Object.keys(record)) {
if (keywords.has(key) || key.startsWith("x-")) {
delete record[key];
}
}
// Recurse into remaining values. `properties` is a map keyed by arbitrary,
// user-defined property NAMES — a tool may legitimately declare a property
// called `pattern`, `enum`, `minLength`, etc. Descend into each property's
// subschema, but never run keyword-deletion against the property names
// themselves, or glob/grep-style tools lose their `pattern` argument (#1368).
for (const [key, value] of Object.entries(record)) {
if (!value || typeof value !== "object") continue;
if (key === "properties" && !Array.isArray(value)) {
for (const subSchema of Object.values(value as JsonRecord)) {
removeUnsupportedKeywords(subSchema, keywords);
}
} else {
removeUnsupportedKeywords(value, keywords);
}
}
}
function normalizeAdditionalProperties(obj: unknown): void {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
for (const item of obj) {
normalizeAdditionalProperties(item);
}
return;
}
const record = obj as JsonRecord;
// Gemini API does not support `additionalProperties` at all in function_declarations
// schemas (returns 400 "Unknown name"). Since Gemini defaults to allowing additional
// properties anyway, stripping it unconditionally is safe and prevents errors (#1421).
if ("additionalProperties" in record) {
delete record.additionalProperties;
}
for (const value of Object.values(record)) {
if (value && typeof value === "object") {
normalizeAdditionalProperties(value);
}
}
}
// Convert const to enum
function convertConstToEnum(obj: unknown): void {
if (!obj || typeof obj !== "object") return;
const record = obj as JsonRecord;
if (record.const !== undefined && !record.enum) {
record.enum = [record.const];
delete record.const;
}
for (const value of Object.values(record)) {
if (value && typeof value === "object") {
convertConstToEnum(value);
}
}
}
// Convert enum values to strings (Gemini requires string enum values)
// For integer types, remove enum entirely as Gemini doesn't support it
function convertEnumValuesToStrings(obj: unknown): void {
if (!obj || typeof obj !== "object") return;
const record = obj as JsonRecord;
if (record.enum && Array.isArray(record.enum)) {
// Gemini only supports enum for string types, not integer
if (record.type === "integer" || record.type === "number") {
delete record.enum;
} else {
record.enum = record.enum.map((v: unknown) => String(v));
if (!record.type) {
record.type = "string";
}
}
}
for (const value of Object.values(record)) {
if (value && typeof value === "object") {
convertEnumValuesToStrings(value);
}
}
}
// Merge allOf schemas
function mergeAllOf(obj: unknown): void {
if (!obj || typeof obj !== "object") return;
const record = obj as JsonRecord;
if (record.allOf && Array.isArray(record.allOf)) {
const merged: { properties?: JsonRecord; required?: string[] } = {};
for (const item of record.allOf) {
const itemRecord = toRecord(item);
const itemProperties = toRecord(itemRecord.properties);
if (Object.keys(itemProperties).length > 0) {
if (!merged.properties) merged.properties = {};
Object.assign(merged.properties, itemProperties);
}
if (itemRecord.required && Array.isArray(itemRecord.required)) {
if (!merged.required) merged.required = [];
for (const req of itemRecord.required) {
if (typeof req === "string" && !merged.required.includes(req)) {
merged.required.push(req);
}
}
}
}
delete record.allOf;
if (merged.properties)
record.properties = { ...toRecord(record.properties), ...merged.properties };
if (merged.required) {
const required = Array.isArray(record.required)
? record.required.filter((item): item is string => typeof item === "string")
: [];
record.required = [...required, ...merged.required];
}
}
for (const value of Object.values(record)) {
if (value && typeof value === "object") {
mergeAllOf(value);
}
}
}
// Select best schema from anyOf/oneOf
function selectBest(items: unknown[]): number {
let bestIdx = 0;
let bestScore = -1;
for (let i = 0; i < items.length; i++) {
const item = toRecord(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: unknown): void {
if (!obj || typeof obj !== "object") return;
const record = obj as JsonRecord;
if (record.anyOf && Array.isArray(record.anyOf) && record.anyOf.length > 0) {
const nonNullSchemas = record.anyOf.filter((s) => s && toRecord(s).type !== "null");
if (nonNullSchemas.length > 0) {
const bestIdx = selectBest(nonNullSchemas);
const selected = nonNullSchemas[bestIdx];
delete record.anyOf;
Object.assign(record, toRecord(selected));
}
}
if (record.oneOf && Array.isArray(record.oneOf) && record.oneOf.length > 0) {
const nonNullSchemas = record.oneOf.filter((s) => s && toRecord(s).type !== "null");
if (nonNullSchemas.length > 0) {
const bestIdx = selectBest(nonNullSchemas);
const selected = nonNullSchemas[bestIdx];
delete record.oneOf;
Object.assign(record, toRecord(selected));
}
}
for (const value of Object.values(record)) {
if (value && typeof value === "object") {
flattenAnyOfOneOf(value);
}
}
}
// Flatten type arrays
function flattenTypeArrays(obj: unknown): void {
if (!obj || typeof obj !== "object") return;
const record = obj as JsonRecord;
if (record.type && Array.isArray(record.type)) {
const nonNullTypes = record.type.filter((t) => t !== "null");
record.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string";
}
for (const value of Object.values(record)) {
if (value && typeof value === "object") {
flattenTypeArrays(value);
}
}
}
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
// Reference: CLIProxyAPI/internal/util/gemini_schema.go
export function cleanJSONSchemaForAntigravity(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
const root = cloneSchemaValue(schema);
let cleaned = inlineLocalSchemaRefs(root, root);
// Phase 1: Convert and prepare
convertConstToEnum(cleaned);
convertEnumValuesToStrings(cleaned);
// Phase 2: Flatten complex structures
mergeAllOf(cleaned);
flattenAnyOfOneOf(cleaned);
flattenTypeArrays(cleaned);
// Phase 3: Preserve the only supported additionalProperties shape before keyword cleanup.
normalizeAdditionalProperties(cleaned);
// Phase 4: Remove all unsupported keywords at ALL levels (including inside arrays).
removeUnsupportedKeywords(cleaned, GEMINI_UNSUPPORTED_SCHEMA_KEYS);
// Phase 5: Cleanup required fields recursively.
function cleanupRequired(obj: unknown): void {
if (!obj || typeof obj !== "object") return;
const record = obj as JsonRecord;
if (record.required && Array.isArray(record.required) && record.properties) {
const properties = toRecord(record.properties);
const validRequired = record.required.filter(
(field) =>
typeof field === "string" && Object.prototype.hasOwnProperty.call(properties, field)
);
if (validRequired.length === 0) {
delete record.required;
} else {
record.required = validRequired;
}
}
// Recurse into nested objects
for (const value of Object.values(record)) {
if (value && typeof value === "object") {
cleanupRequired(value);
}
}
}
cleanupRequired(cleaned);
// Phase 6: Add placeholder for empty object schemas (Antigravity requirement).
function addPlaceholders(obj: unknown): void {
if (!obj || typeof obj !== "object") return;
const record = obj as JsonRecord;
if (record.type === "object") {
if (!record.properties || Object.keys(toRecord(record.properties)).length === 0) {
record.properties = {
reason: {
type: "string",
description: "Brief explanation of why you are calling this tool",
},
};
record.required = ["reason"];
}
}
// Recurse into nested objects
for (const value of Object.values(record)) {
if (value && typeof value === "object") {
addPlaceholders(value);
}
}
}
addPlaceholders(cleaned);
return cleaned;
}