feat(providers): expose antigravity preview aliases and gemini cli onboarding

Centralize Antigravity public model definitions and use the
client-visible preview aliases in provider discovery, model catalog
responses, and default alias seeding.

Add Gemini CLI managed-project onboarding with retries when
loadCodeAssist does not return a project, and update Gemini CLI header
fingerprints to match newer native clients.

Improve non-stream handling by converting NDJSON event payloads into
SSE-compatible parsing for stream=false requests, add PUT support for
the settings API, expand Gemini schema cleanup for local refs and
unsupported keys, and include Anthropic beta headers for API-key
requests.
This commit is contained in:
diegosouzapw
2026-04-16 23:25:58 -03:00
parent 7b51ccd9e4
commit 14d18d27b1
19 changed files with 624 additions and 97 deletions

View File

@@ -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<Record<string, string>>((acc, model) => {
acc[model.id] = model.name;
return acc;
}, {})
);
export function resolveAntigravityModelId(modelId: string): string {
if (!modelId) return modelId;

View File

@@ -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<string, RegistryEntry> = {
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<string, RegistryEntry> = {
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" },

View File

@@ -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<string, { projectId: string; expiresAt: number }>();
// In-flight deduplication: prevents thundering herd on cache miss
const inflightRefresh = new Map<string, Promise<string | null>>();
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<void> {
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<string | null> {
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) {

View File

@@ -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<string, unknown> | 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({

View File

@@ -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()})`;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<string, () => 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" },

View File

@@ -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);
}

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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",

View File

@@ -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;

View File

@@ -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, {

View File

@@ -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",

View File

@@ -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));

View File

@@ -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");
});
});
});

View File

@@ -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");

View File

@@ -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",