diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 4d6f748766..e77a5ffd28 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -1,3 +1,27 @@ +export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ + { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, + { id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" }, + { id: "gemini-3-pro-image-preview", name: "Gemini 3 Pro Image Preview" }, + { + id: "gemini-2.5-computer-use-preview-10-2025", + name: "Gemini 2.5 Computer Use Preview (10/2025)", + }, + { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, + { id: "gemini-claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Gemini Route)" }, + { + id: "gemini-claude-sonnet-4-5-thinking", + name: "Claude Sonnet 4.5 Thinking (Gemini Route)", + }, + { + id: "gemini-claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking (Gemini Route)", + }, + { id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" }, +]); + export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ "gemini-3-pro-preview": "gemini-3.1-pro-high", "gemini-3-flash-preview": "gemini-3-flash", @@ -20,15 +44,12 @@ export const ANTIGRAVITY_REVERSE_MODEL_ALIASES = Object.freeze( ) ); -const CLIENT_VISIBLE_MODEL_NAMES = Object.freeze({ - "gemini-3-pro-preview": "Gemini 3 Pro Preview", - "gemini-3-flash-preview": "Gemini 3 Flash Preview", - "gemini-3-pro-image-preview": "Gemini 3 Pro Image Preview", - "gemini-2.5-computer-use-preview-10-2025": "Gemini 2.5 Computer Use Preview (10/2025)", - "gemini-claude-sonnet-4-5": "Claude Sonnet 4.5 (Gemini Route)", - "gemini-claude-sonnet-4-5-thinking": "Claude Sonnet 4.5 Thinking (Gemini Route)", - "gemini-claude-opus-4-5-thinking": "Claude Opus 4.5 Thinking (Gemini Route)", -}); +const CLIENT_VISIBLE_MODEL_NAMES = Object.freeze( + ANTIGRAVITY_PUBLIC_MODELS.reduce>((acc, model) => { + acc[model.id] = model.name; + return acc; + }, {}) +); export function resolveAntigravityModelId(modelId: string): string { if (!modelId) return modelId; diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 162d907749..93edb4e522 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -8,6 +8,7 @@ import { platform, arch } from "os"; import { ANTIGRAVITY_BASE_URLS } from "./antigravityUpstream.ts"; +import { ANTIGRAVITY_PUBLIC_MODELS } from "./antigravityModelAliases.ts"; import { ANTHROPIC_BETA_API_KEY, ANTHROPIC_BETA_CLAUDE_OAUTH, @@ -481,15 +482,7 @@ export const REGISTRY: Record = { clientSecretEnv: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", clientSecretDefault: "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", }, - models: [ - { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - { id: "gemini-3-flash", name: "Gemini 3 Flash" }, - { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, - { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)" }, - { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, - { id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" }, - ], + models: [...ANTIGRAVITY_PUBLIC_MODELS], passthroughModels: true, }, @@ -638,7 +631,8 @@ export const REGISTRY: Record = { authHeader: "x-api-key", defaultContextLength: 200000, headers: { - "Anthropic-Version": "2023-06-01", + "Anthropic-Version": ANTHROPIC_VERSION_HEADER, + "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index 228b00b38c..7031dc9d12 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -5,21 +5,103 @@ import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderSc import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; const LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; +const ONBOARD_USER_URL = "https://cloudcode-pa.googleapis.com/v1internal:onboardUser"; const PROJECT_TTL_MS = 30_000; // 30 seconds — matches native Gemini CLI const MAX_CACHE_SIZE = 100; const LOAD_CODE_ASSIST_TIMEOUT_MS = 10_000; // 10 seconds timeout +const ONBOARD_TIMEOUT_MS = 30_000; +const ONBOARD_MAX_ATTEMPTS = 10; +const ONBOARD_DELAY_MS = 5_000; +const DEFAULT_PROJECT_ID = "default-project"; +const DEFAULT_ONBOARD_TIER = "free-tier"; +const LOAD_CODE_ASSIST_METADATA = Object.freeze({ + ideType: "ANTIGRAVITY", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + duetProject: DEFAULT_PROJECT_ID, +}); +const ONBOARD_METADATA = Object.freeze({ + ideType: "ANTIGRAVITY", + pluginType: "GEMINI", + duetProject: DEFAULT_PROJECT_ID, +}); // Per-account cache: accessToken -> { projectId, expiresAt } const projectCache = new Map(); // In-flight deduplication: prevents thundering herd on cache miss const inflightRefresh = new Map>(); +type LoadCodeAssistResponse = { + cloudaicompanionProject?: string | { id?: string | null } | null; + allowedTiers?: Array<{ id?: string | null; isDefault?: boolean | null }> | null; +}; + +type OnboardOptions = { + attempts?: number; + delayMs?: number; +}; + +function normalizeGeminiModel(model: string): string { + return typeof model === "string" && model.trim().length > 0 + ? model.replace(/^models\//, "").trim() + : "unknown"; +} + +function extractProjectId(payload: unknown): string { + if (!payload || typeof payload !== "object") return ""; + const data = payload as LoadCodeAssistResponse; + if (typeof data.cloudaicompanionProject === "string") { + return data.cloudaicompanionProject.trim(); + } + if (typeof data.cloudaicompanionProject?.id === "string") { + return data.cloudaicompanionProject.id.trim(); + } + return ""; +} + +function extractDefaultTierId(payload: unknown): string { + if (!payload || typeof payload !== "object") return DEFAULT_ONBOARD_TIER; + const tiers = Array.isArray((payload as LoadCodeAssistResponse).allowedTiers) + ? (payload as LoadCodeAssistResponse).allowedTiers + : []; + for (const tier of tiers) { + if (tier?.isDefault && typeof tier.id === "string" && tier.id.trim()) { + return tier.id.trim(); + } + } + return DEFAULT_ONBOARD_TIER; +} + +function cacheProject(accessToken: string, projectId: string): void { + if (projectCache.size >= MAX_CACHE_SIZE) { + const now = Date.now(); + for (const [key, val] of projectCache) { + if (val.expiresAt <= now) projectCache.delete(key); + } + if (projectCache.size >= MAX_CACHE_SIZE) { + const firstKey = projectCache.keys().next().value; + if (firstKey !== undefined) projectCache.delete(firstKey); + } + } + + projectCache.set(accessToken, { + projectId, + expiresAt: Date.now() + PROJECT_TTL_MS, + }); +} + +function sleep(ms: number): Promise { + if (!ms || ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export class GeminiCLIExecutor extends BaseExecutor { constructor() { super("gemini-cli", PROVIDERS["gemini-cli"]); } buildUrl(model, stream, urlIndex = 0) { + this._currentModel = normalizeGeminiModel(model); const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; return `${this.config.baseUrl}:${action}`; } @@ -36,9 +118,80 @@ export class GeminiCLIExecutor extends BaseExecutor { return scrubProxyAndFingerprintHeaders(raw); } - // Track current model for dynamic UA (set by transformRequest) + // Track current model for dynamic UA. BaseExecutor calls buildUrl before buildHeaders. private _currentModel = "unknown"; + async onboardManagedProject( + accessToken: string, + tierId = DEFAULT_ONBOARD_TIER, + options: OnboardOptions = {} + ): Promise { + const attempts = + Number.isInteger(options.attempts) && options.attempts! > 0 + ? Number(options.attempts) + : ONBOARD_MAX_ATTEMPTS; + const delayMs = + typeof options.delayMs === "number" && + Number.isFinite(options.delayMs) && + options.delayMs >= 0 + ? options.delayMs + : ONBOARD_DELAY_MS; + + const requestBody = { + tierId: tierId || DEFAULT_ONBOARD_TIER, + metadata: { ...ONBOARD_METADATA }, + }; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), ONBOARD_TIMEOUT_MS); + + let response; + try { + response = await fetch(ONBOARD_USER_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + "User-Agent": "GeminiCLI/1.0.0", + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (response.ok) { + const payload = await response.json(); + const managedProjectId = extractProjectId(payload?.response); + + if (payload?.done === true && managedProjectId) { + return managedProjectId; + } + + if (payload?.done === true) { + return DEFAULT_PROJECT_ID; + } + } else { + console.warn( + `[OmniRoute] onboardUser returned ${response.status} on attempt ${attempt + 1}` + ); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn(`[OmniRoute] onboardUser attempt ${attempt + 1} failed (${msg})`); + } + + if (attempt < attempts - 1) { + await sleep(delayMs); + } + } + + return null; + } + /** * Fetch the current cloudaicompanionProject via loadCodeAssist API. * Native Gemini CLI refreshes this every 30 seconds — OmniRoute stores it once @@ -78,11 +231,8 @@ export class GeminiCLIExecutor extends BaseExecutor { "Content-Type": "application/json", }, body: JSON.stringify({ - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, + cloudaicompanionProject: DEFAULT_PROJECT_ID, + metadata: { ...LOAD_CODE_ASSIST_METADATA }, }), signal: controller.signal, }); @@ -97,37 +247,24 @@ export class GeminiCLIExecutor extends BaseExecutor { return null; } - const data = await response.json(); - let projectId = ""; - if (typeof data.cloudaicompanionProject === "string") { - projectId = data.cloudaicompanionProject.trim(); - } else if (typeof data.cloudaicompanionProject?.id === "string") { - projectId = data.cloudaicompanionProject.id.trim(); + const data = (await response.json()) as LoadCodeAssistResponse; + let projectId = extractProjectId(data); + + if (!projectId) { + console.warn( + "[OmniRoute] loadCodeAssist returned no project — attempting managed project onboarding" + ); + projectId = await this.onboardManagedProject(accessToken, extractDefaultTierId(data)); } if (!projectId) { console.warn( - "[OmniRoute] loadCodeAssist returned no project — falling back to stored projectId" + "[OmniRoute] managed project onboarding failed — falling back to stored projectId" ); return null; } - // Cache for 30 seconds (evict stale entries if cache is full) - if (projectCache.size >= MAX_CACHE_SIZE) { - const now = Date.now(); - for (const [key, val] of projectCache) { - if (val.expiresAt <= now) projectCache.delete(key); - } - // If still full, evict the oldest entry (Map maintains insertion order) - if (projectCache.size >= MAX_CACHE_SIZE) { - const firstKey = projectCache.keys().next().value; - if (firstKey !== undefined) projectCache.delete(firstKey); - } - } - projectCache.set(accessToken, { - projectId, - expiresAt: Date.now() + PROJECT_TTL_MS, - }); + cacheProject(accessToken, projectId); return projectId; } catch (error) { @@ -138,8 +275,7 @@ export class GeminiCLIExecutor extends BaseExecutor { } async transformRequest(model, body, stream, credentials) { - // Track model for dynamic User-Agent - this._currentModel = model || "unknown"; + this._currentModel = normalizeGeminiModel(model); // Refresh the project ID via loadCodeAssist (cached for 30s). if (body && typeof body === "object" && body.request && credentials.accessToken) { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d8cd96ce64..81b3cb6e08 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -307,6 +307,24 @@ function parseNonStreamingSSEPayload( return null; } +function convertNDJSONToSSE(rawBody: string): string { + const chunks = String(rawBody || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (chunks.length === 0) return rawBody; + + return `${chunks.map((chunk) => `data: ${chunk}\n`).join("\n")}\n`; +} + +function normalizeNonStreamingEventPayload(rawBody: string, contentType: string): string { + if (contentType.includes("application/x-ndjson")) { + return convertNDJSONToSSE(rawBody); + } + return rawBody; +} + function getHeaderValueCaseInsensitive( headers: Record | null | undefined, targetName: string @@ -2180,11 +2198,19 @@ export async function handleChatCore({ const rawBody = await providerResponse.text(); const normalizedProviderPayload = normalizePayloadForLog(rawBody); const looksLikeSSE = - contentType.includes("text/event-stream") || /(^|\n)\s*(event|data):/m.test(rawBody); + contentType.includes("text/event-stream") || + contentType.includes("application/x-ndjson") || + /(^|\n)\s*(event|data):/m.test(rawBody); if (looksLikeSSE) { + const streamPayload = normalizeNonStreamingEventPayload(rawBody, contentType); + const streamKind = contentType.includes("application/x-ndjson") ? "NDJSON" : "SSE"; + log?.warn?.( + "STREAM", + `Unexpected ${streamKind} response for non-streaming request — buffering` + ); // Upstream returned SSE even though stream=false; convert best-effort to JSON. - const parsedFromSSE = parseNonStreamingSSEPayload(rawBody, targetFormat, model); + const parsedFromSSE = parseNonStreamingSSEPayload(streamPayload, targetFormat, model); if (!parsedFromSSE) { appendRequestLog({ diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index c7c0f0c6c9..39d61f4e39 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -17,7 +17,7 @@ import { type AntigravityHeaderProfile = "loadCodeAssist" | "fetchAvailableModels" | "models"; const ANTIGRAVITY_VERSION = ANTIGRAVITY_FALLBACK_VERSION; -const GEMINI_CLI_VERSION = "0.31.0"; +const GEMINI_CLI_VERSION = "1.0.0"; const GEMINI_SDK_VERSION = "1.41.0"; const NODE_VERSION = "v22.19.0"; const LOAD_CODE_ASSIST_USER_AGENT = "google-api-nodejs-client/9.15.1"; @@ -42,9 +42,9 @@ function getPlatform(): string { const p = os.platform(); switch (p) { case "win32": - return "win32"; + return "windows"; case "darwin": - return "darwin"; + return "macos"; default: return p; // "linux", etc. } @@ -120,7 +120,7 @@ export function getAntigravityHeaders( /** * Gemini CLI User-Agent: "GeminiCLI/VERSION/MODEL (OS; ARCH)" - * Example: "GeminiCLI/0.31.0/gemini-3-flash (darwin; arm64)" + * Example: "GeminiCLI/1.0.0/gemini-3-flash (macos; arm64)" */ export function geminiCLIUserAgent(model: string): string { return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${getPlatform()}; ${getArch()})`; diff --git a/open-sse/services/antigravityVersion.ts b/open-sse/services/antigravityVersion.ts index e299074941..307ebdd421 100644 --- a/open-sse/services/antigravityVersion.ts +++ b/open-sse/services/antigravityVersion.ts @@ -5,7 +5,7 @@ const ANTIGRAVITY_GITHUB_RELEASE_URL = export const ANTIGRAVITY_VERSION_CACHE_TTL_MS = 6 * 60 * 60 * 1000; export const ANTIGRAVITY_VERSION_FETCH_TIMEOUT_MS = 5_000; -export const ANTIGRAVITY_FALLBACK_VERSION = "1.23.2"; +export const ANTIGRAVITY_FALLBACK_VERSION = "1.22.2"; type VersionCache = { fetchedAt: number; diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 23908e9a80..1e7e74241b 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -1,8 +1,8 @@ // Gemini helper functions for translator -// Unsupported JSON Schema constraints that should be removed for Antigravity -// Reference: CLIProxyAPI/internal/util/gemini_schema.go (removeUnsupportedKeywords) -export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ +// 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", @@ -17,14 +17,24 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ "examples", // JSON Schema meta keywords "$schema", + "$id", + "$anchor", + "$dynamicRef", + "$dynamicAnchor", + "$vocabulary", + "$comment", "$defs", "definitions", "const", "$ref", // Object validation keywords (not supported) - "additionalProperties", "propertyNames", "patternProperties", + "unevaluatedProperties", + "unevaluatedItems", + "contains", + "minContains", + "maxContains", // Complex schema keywords (handled by flattenAnyOfOneOf/mergeAllOf) "anyOf", "oneOf", @@ -41,6 +51,9 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ "else", "contentMediaType", "contentEncoding", + "contentSchema", + "readOnly", + "writeOnly", // Non-standard schema fields (not recognized by Gemini API) "deprecated", "optional", @@ -61,7 +74,9 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ "strokeColor", "strokeThickness", "textColor", -]; +]); + +export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [...GEMINI_UNSUPPORTED_SCHEMA_KEYS]; // Default safety settings export const DEFAULT_SAFETY_SETTINGS = [ @@ -182,6 +197,87 @@ export function generateSessionId() { return `-${num.toString()}`; } +function cloneSchemaValue(value) { + 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) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function decodeJsonPointerSegment(segment) { + return String(segment).replace(/~1/g, "/").replace(/~0/g, "~"); +} + +function resolveLocalReference(root, ref) { + if (typeof ref !== "string" || !ref.startsWith("#/")) return null; + + let current = root; + const segments = ref + .slice(2) + .split("/") + .filter(Boolean) + .map((segment) => decodeJsonPointerSegment(segment)); + + for (const segment of segments) { + if (!current || typeof current !== "object" || !(segment in current)) { + return null; + } + current = current[segment]; + } + + return current; +} + +function inlineLocalSchemaRefs(node, root, activeRefs = new Set()) { + if (Array.isArray(node)) { + return node.map((item) => inlineLocalSchemaRefs(item, root, activeRefs)); + } + + if (!node || typeof node !== "object") { + return node; + } + + const record = { ...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, keywords) { if (!obj || typeof obj !== "object") return; @@ -193,7 +289,7 @@ function removeUnsupportedKeywords(obj, keywords) { } else { // Delete unsupported keys at current level for (const key of Object.keys(obj)) { - if (keywords.includes(key) || key.startsWith("x-")) { + if (keywords.has(key) || key.startsWith("x-")) { delete obj[key]; } } @@ -206,6 +302,27 @@ function removeUnsupportedKeywords(obj, keywords) { } } +function normalizeAdditionalProperties(obj) { + if (!obj || typeof obj !== "object") return; + + if (Array.isArray(obj)) { + for (const item of obj) { + normalizeAdditionalProperties(item); + } + return; + } + + if ("additionalProperties" in obj && obj.additionalProperties !== true) { + delete obj.additionalProperties; + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + normalizeAdditionalProperties(value); + } + } +} + // Convert const to enum function convertConstToEnum(obj) { if (!obj || typeof obj !== "object") return; @@ -359,8 +476,8 @@ function flattenTypeArrays(obj) { export function cleanJSONSchemaForAntigravity(schema) { if (!schema || typeof schema !== "object") return schema; - // Mutate directly (schema is only used once per request) - let cleaned = schema; + const root = cloneSchemaValue(schema); + let cleaned = inlineLocalSchemaRefs(root, root); // Phase 1: Convert and prepare convertConstToEnum(cleaned); @@ -371,10 +488,13 @@ export function cleanJSONSchemaForAntigravity(schema) { flattenAnyOfOneOf(cleaned); flattenTypeArrays(cleaned); - // Phase 3: Remove all unsupported keywords at ALL levels (including inside arrays) - removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS); + // Phase 3: Preserve the only supported additionalProperties shape before keyword cleanup. + normalizeAdditionalProperties(cleaned); - // Phase 4: Cleanup required fields recursively + // 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) { if (!obj || typeof obj !== "object") return; @@ -399,7 +519,7 @@ export function cleanJSONSchemaForAntigravity(schema) { cleanupRequired(cleaned); - // Phase 5: Add placeholder for empty object schemas (Antigravity requirement) + // Phase 6: Add placeholder for empty object schemas (Antigravity requirement). function addPlaceholders(obj) { if (!obj || typeof obj !== "object") return; diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 83b90b9f1e..dd91c75c5c 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -20,6 +20,7 @@ import { getGlmModelsUrl } from "@omniroute/open-sse/config/glmProvider.ts"; import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts"; import { resolveAntigravityVersion } from "@omniroute/open-sse/services/antigravityVersion.ts"; import { + ANTIGRAVITY_PUBLIC_MODELS, getClientVisibleAntigravityModelName, toClientAntigravityModelId, } from "@omniroute/open-sse/config/antigravityModelAliases.ts"; @@ -131,15 +132,7 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str { id: "nanobanana-flash", name: "NanoBanana Flash (Gemini 2.5 Flash)" }, { id: "nanobanana-pro", name: "NanoBanana Pro (Gemini 3 Pro)" }, ], - antigravity: () => [ - { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, - { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, - { id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" }, - { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, - { id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" }, - ], + antigravity: () => ANTIGRAVITY_PUBLIC_MODELS.map((model) => ({ ...model })), claude: () => [ { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 199a40b54e..6a4dbbb80b 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -187,3 +187,7 @@ export async function PATCH(request) { return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }); } } + +export async function PUT(request: Request) { + return PATCH(request); +} diff --git a/src/lib/modelAliasSeed.ts b/src/lib/modelAliasSeed.ts index 2ff80b57e7..c3848d1ed8 100644 --- a/src/lib/modelAliasSeed.ts +++ b/src/lib/modelAliasSeed.ts @@ -1,12 +1,12 @@ import { getModelAliases, setModelAlias } from "@/lib/db/models"; export const DEFAULT_MODEL_ALIAS_SEED = Object.freeze({ - "gemini-3-pro-high": "antigravity/gemini-3.1-pro-high", + "gemini-3-pro-high": "antigravity/gemini-3-pro-preview", "gemini-3-pro-low": "antigravity/gemini-3.1-pro-low", - "gemini-3-pro-preview": "antigravity/gemini-3.1-pro-high", - "gemini-3.1-pro-preview": "antigravity/gemini-3.1-pro-high", - "gemini-3.1-pro-preview-customtools": "antigravity/gemini-3.1-pro-high", - "gemini-3-flash-preview": "antigravity/gemini-3-flash", + "gemini-3-pro-preview": "antigravity/gemini-3-pro-preview", + "gemini-3.1-pro-preview": "antigravity/gemini-3-pro-preview", + "gemini-3.1-pro-preview-customtools": "antigravity/gemini-3-pro-preview", + "gemini-3-flash-preview": "antigravity/gemini-3-flash-preview", }); type SeedLogger = { diff --git a/tests/unit/antigravity-version.test.ts b/tests/unit/antigravity-version.test.ts index 466712a4f9..a2566ed60e 100644 --- a/tests/unit/antigravity-version.test.ts +++ b/tests/unit/antigravity-version.test.ts @@ -21,7 +21,7 @@ test("resolveAntigravityVersion uses the official release feed and caches the re let calls = 0; const fetchMock = async () => { calls += 1; - return new Response(JSON.stringify([{ version: "1.23.2", execution_id: "4781536860569600" }]), { + return new Response(JSON.stringify([{ version: "1.22.2", execution_id: "4781536860569600" }]), { status: 200, headers: { "Content-Type": "application/json" }, }); @@ -30,10 +30,10 @@ test("resolveAntigravityVersion uses the official release feed and caches the re const first = await resolveAntigravityVersion(fetchMock as typeof fetch); const second = await resolveAntigravityVersion(fetchMock as typeof fetch); - assert.equal(first, "1.23.2"); - assert.equal(second, "1.23.2"); + assert.equal(first, "1.22.2"); + assert.equal(second, "1.22.2"); assert.equal(calls, 1); - assert.equal(getCachedAntigravityVersion(), "1.23.2"); + assert.equal(getCachedAntigravityVersion(), "1.22.2"); }); test("resolveAntigravityVersion refreshes the cache after the TTL elapses", async () => { @@ -41,7 +41,7 @@ test("resolveAntigravityVersion refreshes the cache after the TTL elapses", asyn Date.now = () => now; const firstFetch = async () => - new Response(JSON.stringify([{ version: "1.23.2" }]), { + new Response(JSON.stringify([{ version: "1.22.2" }]), { status: 200, headers: { "Content-Type": "application/json" }, }); @@ -51,7 +51,7 @@ test("resolveAntigravityVersion refreshes the cache after the TTL elapses", asyn headers: { "Content-Type": "application/json" }, }); - assert.equal(await resolveAntigravityVersion(firstFetch as typeof fetch), "1.23.2"); + assert.equal(await resolveAntigravityVersion(firstFetch as typeof fetch), "1.22.2"); now += ANTIGRAVITY_VERSION_CACHE_TTL_MS + 1; diff --git a/tests/unit/codex-stream-false.test.ts b/tests/unit/codex-stream-false.test.ts index 714b082112..fd8f78f140 100644 --- a/tests/unit/codex-stream-false.test.ts +++ b/tests/unit/codex-stream-false.test.ts @@ -82,6 +82,48 @@ function buildResponsesSse(text = "Brasilia") { ); } +function buildResponsesNdjson(text = "Brasilia") { + return new Response( + [ + JSON.stringify({ + type: "response.created", + response: { + id: "resp_1", + model: "gpt-5.3-codex", + status: "in_progress", + output: [], + }, + }), + JSON.stringify({ + type: "response.output_text.delta", + output_index: 0, + delta: text, + }), + JSON.stringify({ + type: "response.completed", + response: { + id: "resp_1", + object: "response", + model: "gpt-5.3-codex", + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text }], + }, + ], + usage: { input_tokens: 6, output_tokens: 1 }, + }, + }), + ].join("\n"), + { + status: 200, + headers: { "Content-Type": "application/x-ndjson" }, + } + ); +} + async function resetStorage() { core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); @@ -196,6 +238,27 @@ test("chatCore converts Responses-style SSE fallback into JSON when stream=false assert.ok(payload.usage.completion_tokens > 0); }); +test("chatCore converts Responses-style NDJSON fallback into JSON when stream=false", async () => { + const { result, call } = await invokeChatCore({ + body: { + model: "gpt-4o-mini", + stream: false, + messages: [{ role: "user", content: "Qual a capital do Brasil?" }], + }, + provider: "openai", + model: "gpt-4o-mini", + responseFactory: () => buildResponsesNdjson("Brasilia"), + }); + + const payload = await result.response.json(); + + assert.equal(result.success, true); + assert.equal(call.headers.Accept || call.headers.accept, "application/json"); + assert.equal(payload.object, "chat.completion"); + assert.equal(payload.choices[0].message.content, "Brasilia"); + assert.ok(payload.usage.total_tokens >= 7); +}); + test("handleComboChat validates non-stream quality using the original client stream intent", async () => { const combo = { name: "codex-stream-false-quality", diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index 41abab0022..ba0d7253a3 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -7,18 +7,21 @@ test("GeminiCLIExecutor.buildUrl and buildHeaders match the native Gemini CLI fi const executor = new GeminiCLIExecutor(); assert.equal( - executor.buildUrl("gemini-2.5-flash", true), + executor.buildUrl("models/gemini-2.5-flash", true), "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse" ); assert.equal( - executor.buildUrl("gemini-2.5-flash", false), + executor.buildUrl("models/gemini-2.5-flash", false), "https://cloudcode-pa.googleapis.com/v1internal:generateContent" ); const headers = executor.buildHeaders({ accessToken: "gcli-token" }, true); assert.equal(headers.Authorization, "Bearer gcli-token"); assert.equal(headers.Accept, "text/event-stream"); - assert.match(headers["User-Agent"], /GeminiCLI/); + assert.match( + headers["User-Agent"], + /^GeminiCLI\/1\.0\.0\/gemini-2\.5-flash \((linux|macos|windows); (x64|arm64|x86)\)$/ + ); assert.match(headers["X-Goog-Api-Client"], /google-genai-sdk/); }); @@ -65,6 +68,93 @@ test("GeminiCLIExecutor.refreshProject returns null on failed loadCodeAssist res } }); +test("GeminiCLIExecutor.refreshProject onboards a managed project when loadCodeAssist has no project", async () => { + const executor = new GeminiCLIExecutor(); + const originalFetch = globalThis.fetch; + const calls = []; + + globalThis.fetch = async (url, init = {}) => { + const body = init.body ? JSON.parse(String(init.body)) : null; + calls.push({ url: String(url), body }); + + if (String(url).endsWith("loadCodeAssist")) { + return new Response( + JSON.stringify({ + allowedTiers: [{ id: "free-tier", isDefault: true }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + if (String(url).endsWith("onboardUser")) { + return new Response( + JSON.stringify({ + done: true, + response: { + cloudaicompanionProject: { + id: "managed-project-id", + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + throw new Error(`Unexpected URL: ${url}`); + }; + + try { + assert.equal(await executor.refreshProject("access-token-3"), "managed-project-id"); + assert.deepEqual( + calls.map((call) => call.url.split(":").at(-1)), + ["loadCodeAssist", "onboardUser"] + ); + assert.equal(calls[0].body.cloudaicompanionProject, "default-project"); + assert.equal(calls[0].body.metadata.ideType, "ANTIGRAVITY"); + assert.equal(calls[0].body.metadata.duetProject, "default-project"); + assert.equal(calls[1].body.tierId, "free-tier"); + assert.equal(calls[1].body.metadata.ideType, "ANTIGRAVITY"); + assert.equal(calls[1].body.metadata.duetProject, "default-project"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GeminiCLIExecutor.onboardManagedProject retries until completion", async () => { + const executor = new GeminiCLIExecutor(); + const originalFetch = globalThis.fetch; + let attempts = 0; + + globalThis.fetch = async (url) => { + attempts += 1; + assert.match(String(url), /onboardUser$/); + return new Response( + JSON.stringify( + attempts === 1 + ? { done: false } + : { + done: true, + response: { cloudaicompanionProject: { id: "managed-project-retry" } }, + } + ), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }; + + try { + assert.equal( + await executor.onboardManagedProject("access-token-4", "free-tier", { + attempts: 2, + delayMs: 0, + }), + "managed-project-retry" + ); + assert.equal(attempts, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("GeminiCLIExecutor.refreshCredentials exchanges refresh tokens via Google OAuth", async () => { const executor = new GeminiCLIExecutor(); const originalFetch = globalThis.fetch; diff --git a/tests/unit/model-alias-seed.test.ts b/tests/unit/model-alias-seed.test.ts index 5d6accb16c..524df32912 100644 --- a/tests/unit/model-alias-seed.test.ts +++ b/tests/unit/model-alias-seed.test.ts @@ -34,11 +34,11 @@ test("default model alias seed writes missing aliases and is idempotent", async assert.deepEqual(first.failed, []); assert.equal(first.applied.length, Object.keys(DEFAULT_MODEL_ALIAS_SEED).length); - assert.equal(aliases["gemini-3-pro-high"], "antigravity/gemini-3.1-pro-high"); + assert.equal(aliases["gemini-3-pro-high"], "antigravity/gemini-3-pro-preview"); assert.equal(aliases["gemini-3-pro-low"], "antigravity/gemini-3.1-pro-low"); - assert.equal(aliases["gemini-3-pro-preview"], "antigravity/gemini-3.1-pro-high"); - assert.equal(aliases["gemini-3.1-pro-preview"], "antigravity/gemini-3.1-pro-high"); - assert.equal(aliases["gemini-3-flash-preview"], "antigravity/gemini-3-flash"); + assert.equal(aliases["gemini-3-pro-preview"], "antigravity/gemini-3-pro-preview"); + assert.equal(aliases["gemini-3.1-pro-preview"], "antigravity/gemini-3-pro-preview"); + assert.equal(aliases["gemini-3-flash-preview"], "antigravity/gemini-3-flash-preview"); const routed = await sseModelService.getModelInfo("gemini-3-pro-high"); assert.deepEqual(routed, { diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 6123e1a801..47583fa035 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -206,6 +206,26 @@ test("v1 models catalog exposes claude alias and provider-prefixed built-in mode assert.deepEqual(aliasModel.output_modalities, ["text"]); }); +test("v1 models catalog exposes Antigravity client-visible preview aliases instead of upstream internal IDs", async () => { + await seedConnection("antigravity", { + authType: "oauth", + name: "antigravity-preview", + apiKey: null, + accessToken: "antigravity-access", + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = await response.json(); + const ids = new Set(body.data.map((item) => item.id)); + + assert.equal(response.status, 200); + assert.ok(ids.has("antigravity/gemini-3-pro-preview")); + assert.ok(ids.has("antigravity/gemini-3-flash-preview")); + assert.equal(ids.has("antigravity/gemini-3.1-pro-high"), false); +}); + test("v1 models catalog uses provider-node prefixes for compatible provider custom models", async () => { await providersDb.createProviderNode({ id: "anthropic-compatible-demo", diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 628270689c..224c1524a6 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -294,7 +294,7 @@ test("provider models route retries Antigravity discovery endpoints before retur apiKey: null, }); const seenUrls = []; - antigravityVersion.seedAntigravityVersionCache("1.23.2"); + antigravityVersion.seedAntigravityVersionCache("1.22.2"); globalThis.fetch = async (url, init = {}) => { seenUrls.push(String(url)); diff --git a/tests/unit/settings-api.test.ts b/tests/unit/settings-api.test.ts index 2f0c166a63..f636e97e40 100644 --- a/tests/unit/settings-api.test.ts +++ b/tests/unit/settings-api.test.ts @@ -1,6 +1,7 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { getSettings, updateSettings } from "../../src/lib/db/settings.ts"; +const settingsRoute = await import("../../src/app/api/settings/route.ts"); describe("Settings API - debugMode and hiddenSidebarItems", () => { describe("debugMode", () => { @@ -77,5 +78,19 @@ describe("Settings API - debugMode and hiddenSidebarItems", () => { "antigravitySignatureCacheMode should be updated" ); }); + + test("PUT /api/settings reuses the PATCH update flow", async () => { + const response = await settingsRoute.PUT( + new Request("http://localhost/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ antigravitySignatureCacheMode: "bypass" }), + }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.antigravitySignatureCacheMode, "bypass"); + }); }); }); diff --git a/tests/unit/t20-t22-provider-headers.test.ts b/tests/unit/t20-t22-provider-headers.test.ts index 604c8a827d..56d333c6ad 100644 --- a/tests/unit/t20-t22-provider-headers.test.ts +++ b/tests/unit/t20-t22-provider-headers.test.ts @@ -13,6 +13,14 @@ test("T20: antigravity config has updated User-Agent and sandbox fallback URL", assert.equal(antigravity.headers["User-Agent"], antigravityUserAgent()); }); +test("T25: anthropic API-key config includes the full Anthropic beta header set", () => { + const anthropic = REGISTRY.anthropic; + assert.equal(anthropic.headers["Anthropic-Version"], "2023-06-01"); + assert.ok(anthropic.headers["Anthropic-Beta"]?.includes("advanced-tool-use-2025-11-20")); + assert.ok(anthropic.headers["Anthropic-Beta"]?.includes("structured-outputs-2025-12-15")); + assert.ok(anthropic.headers["Anthropic-Beta"]?.includes("token-efficient-tools-2026-03-28")); +}); + test("T22: github headers include updated editor/plugin versions and required fields", () => { const github = REGISTRY.github; assert.equal(github.headers["editor-version"], "vscode/1.110.0"); diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index e3dbf4c862..88e419ee7c 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -88,6 +88,43 @@ test("OpenAI -> Gemini helper cleans complex JSON Schema structures for Gemini c assert.equal(cleaned.properties.emptyObject.properties.reason.type, "string"); }); +test("OpenAI -> Gemini helper inlines local refs and preserves only additionalProperties=true", () => { + const cleaned = cleanJSONSchemaForAntigravity({ + type: "object", + $defs: { + Address: { + type: "object", + properties: { + street: { type: "string", minLength: 1 }, + }, + required: ["street"], + additionalProperties: false, + }, + }, + properties: { + shipping: { $ref: "#/$defs/Address" }, + metadata: { + type: "object", + additionalProperties: true, + }, + options: { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + required: ["shipping"], + }); + + assert.equal(cleaned.$defs, undefined); + assert.equal(cleaned.properties.shipping.$ref, undefined); + assert.equal(cleaned.properties.shipping.properties.street.type, "string"); + assert.equal(cleaned.properties.shipping.properties.street.minLength, undefined); + assert.deepEqual(cleaned.properties.shipping.required, ["street"]); + assert.equal(cleaned.properties.shipping.additionalProperties, undefined); + assert.equal(cleaned.properties.metadata.additionalProperties, true); + assert.equal(cleaned.properties.options.additionalProperties, undefined); +}); + test("OpenAI -> Gemini request maps messages, merged system instructions, tools and response schema", () => { const result = openaiToGeminiRequest( "gemini-2.5-pro",