feat(antigravity): add Google Antigravity auth plugin with auto-rotation, model catalog and multi-turn support

This commit is contained in:
masudranaxpert
2026-09-01 18:23:55 +06:00
parent 8c6c415a84
commit d7578bcc15
30 changed files with 3327 additions and 91 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,13 @@
{
"imports": {
"cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts",
"cursor-byok:provider": "../../../src/plugin/sdk/provider.ts",
"cursor-byok:model": "../../../src/plugin/sdk/model.ts",
"cursor-byok:resource": "../../../src/plugin/sdk/resource.ts",
"cursor-byok:protocol/openai-chat": "../../../src/plugin/sdk/protocol/openai_chat.ts"
},
"fmt": {
"lineWidth": 100,
"exclude": ["assets"]
}
}
@@ -0,0 +1,24 @@
import { defineProviderPlugin } from "cursor-byok:plugin";
import { antigravityDeviceOAuth } from "./oauth.ts";
import { antigravityProvider } from "./provider.ts";
import {
credentialImport,
presentAccount,
refreshAccount,
RESOURCE_TYPE,
} from "./resources.ts";
export default defineProviderPlugin({
providers: [antigravityProvider],
resources: [{
type: RESOURCE_TYPE,
displayName: {
"en-US": "Google accounts & API keys",
"zh-CN": "Google 账号与 API 密钥",
},
add: [antigravityDeviceOAuth],
import: credentialImport,
present: presentAccount,
refresh: refreshAccount,
}],
});
@@ -0,0 +1,344 @@
import type { JsonValue } from "cursor-byok:plugin";
import type { ModelDefinition, ModelSnapshot, ModelSupport } from "cursor-byok:model";
import { accountData } from "./resources.ts";
export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
export const ANTIGRAVITY_PROD_ENDPOINT = "https://cloudcode-pa.googleapis.com";
export const ANTIGRAVITY_ENDPOINTS = [
ANTIGRAVITY_SANDBOX_ENDPOINT,
ANTIGRAVITY_DAILY_ENDPOINT,
ANTIGRAVITY_PROD_ENDPOINT,
];
const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
export const ANTIGRAVITY_USER_AGENT =
"Antigravity/4.3.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/132.0.6834.160 Electron/39.2.3";
export const ANTIGRAVITY_CLIENT_HEADERS: Record<string, string> = {
"x-client-name": "antigravity",
"x-client-version": "4.3.0",
};
const ANTIGRAVITY_DENYLIST = new Set(["chat_20706", "chat_23310"]);
export const STATIC_ANTIGRAVITY_MODELS: ModelDefinition[] = [
// Gemini 3.7 Series
{
id: "gemini-3.7-flash",
displayName: "Gemini 3.7 Flash",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "gemini-3.7-flash-high",
displayName: "Gemini 3.7 Flash (High)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-3.7-flash-medium",
displayName: "Gemini 3.7 Flash (Medium)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-3.7-flash-low",
displayName: "Gemini 3.7 Flash (Low)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-3.7-flash-tiered",
displayName: "Gemini 3.7 Flash (Tiered)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-3.7-flash-thinking",
displayName: "Gemini 3.7 Flash (Thinking)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
// Gemini 3.6 Series
{
id: "gemini-3.6-flash-high",
displayName: "Gemini 3.6 Flash (High)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-3.6-flash-medium",
displayName: "Gemini 3.6 Flash (Medium)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-3.6-flash-low",
displayName: "Gemini 3.6 Flash (Low)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
// Gemini 3.1 Pro Series
{
id: "gemini-3.1-pro-preview",
displayName: "Gemini 3.1 Pro Preview",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "gemini-3.1-pro-high",
displayName: "Gemini 3.1 Pro (High)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-3.1-pro-medium",
displayName: "Gemini 3.1 Pro (Medium)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-3.1-pro-low",
displayName: "Gemini 3.1 Pro (Low)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
// Gemini 2.5 / 2.0 Series
{
id: "gemini-2.5-pro",
displayName: "Gemini 2.5 Pro",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "gemini-2.5-flash",
displayName: "Gemini 2.5 Flash",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "gemini-2.5-flash-thinking",
displayName: "Gemini 2.5 Flash Thinking",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-2.5-flash-lite",
displayName: "Gemini 2.5 Flash Lite",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-2.0-flash",
displayName: "Gemini 2.0 Flash",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-2.0-flash-lite",
displayName: "Gemini 2.0 Flash Lite",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
// Claude Series (via Antigravity)
{
id: "claude-sonnet-4-6",
displayName: "Claude Sonnet 4.6 (Antigravity)",
capabilities: { images: true },
maxOutputTokens: 64000,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "claude-sonnet-4-6-thinking",
displayName: "Claude Sonnet 4.6 Thinking (Antigravity)",
capabilities: { images: true },
maxOutputTokens: 64000,
privateData: { reasoningEfforts: [] },
},
{
id: "claude-opus-4-6-thinking",
displayName: "Claude 3.7 Opus Thinking (Antigravity)",
capabilities: { images: true },
maxOutputTokens: 64000,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "claude-3-7-sonnet",
displayName: "Claude 3.7 Sonnet (Antigravity)",
capabilities: { images: true },
maxOutputTokens: 64000,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "claude-3-5-sonnet",
displayName: "Claude 3.5 Sonnet (Antigravity)",
capabilities: { images: true },
maxOutputTokens: 64000,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "claude-3-5-haiku",
displayName: "Claude 3.5 Haiku (Antigravity)",
capabilities: { images: true },
maxOutputTokens: 64000,
privateData: { reasoningEfforts: [] },
},
// Other Models
{
id: "gpt-4o",
displayName: "GPT-4o (Antigravity / Gemini)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: ["low", "medium", "high"] },
},
{
id: "gpt-4o-mini",
displayName: "GPT-4o Mini (Antigravity / Gemini)",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
{
id: "gpt-oss-120b-medium",
displayName: "GPT OSS 120B Medium",
capabilities: { images: false },
maxOutputTokens: 32768,
privateData: { reasoningEfforts: [] },
},
{
id: "gemini-3.1-flash-image",
displayName: "Gemini 3.1 Flash Image",
capabilities: { images: true },
maxOutputTokens: 65536,
privateData: { reasoningEfforts: [] },
},
];
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function parseAntigravityModels(payload: unknown): ModelDefinition[] {
const root = object(payload);
const rawModels = object(root?.models);
if (!rawModels) return [];
const models: ModelDefinition[] = [];
const seen = new Set<string>();
for (const [modelId, raw] of Object.entries(rawModels)) {
if (ANTIGRAVITY_DENYLIST.has(modelId)) continue;
const model = object(raw);
if (!model) continue;
const id = modelId.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
const displayName = text(model.displayName) ?? id;
const supportsThinking = model.supportsThinking === true;
const reasoningEfforts = supportsThinking ? ["low", "medium", "high"] : [];
const maxOutputTokens = typeof model.maxOutputTokens === "number" && model.maxOutputTokens > 0
? model.maxOutputTokens
: 65_536;
models.push({
id,
displayName,
capabilities: { images: model.supportsImages === true || id.includes("gemini") || id.includes("claude") },
maxOutputTokens,
privateData: { reasoningEfforts },
});
}
// Merge static models from Antigravity catalog that might not be dynamically returned
for (const staticModel of STATIC_ANTIGRAVITY_MODELS) {
if (!seen.has(staticModel.id)) {
seen.add(staticModel.id);
models.push(staticModel);
}
}
return models;
}
export function reasoningEfforts(model: ModelSnapshot): string[] {
const data = object(model.privateData);
const efforts = data?.reasoningEfforts;
return Array.isArray(efforts) ? efforts.filter((item) => typeof item === "string") : [];
}
export const antigravityModels: ModelSupport = {
list: async ({ resource }, context): Promise<ModelDefinition[]> => {
if (!resource) return STATIC_ANTIGRAVITY_MODELS;
let data;
try {
data = accountData(resource);
} catch {
return STATIC_ANTIGRAVITY_MODELS;
}
const payloads = [
JSON.stringify({ project: data.projectId || "bamboo-precept-lgxtn" }),
JSON.stringify({}),
];
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
for (const bodyPayload of payloads) {
try {
const response = await context.network.fetch(`${endpoint}${FETCH_AVAILABLE_MODELS_PATH}`, {
method: "POST",
headers: {
authorization: `Bearer ${data.accessToken}`,
"content-type": "application/json",
"user-agent": ANTIGRAVITY_USER_AGENT,
...ANTIGRAVITY_CLIENT_HEADERS,
},
body: bodyPayload,
});
if (response.status >= 200 && response.status < 300) {
const body = JSON.parse(response.body);
const models = parseAntigravityModels(body);
if (models.length > 0) return models;
}
} catch {
// Continue
}
}
}
return STATIC_ANTIGRAVITY_MODELS;
},
};
@@ -0,0 +1,199 @@
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
import { credentialDraft, queryAccountQuota } from "./resources.ts";
/**
* Official Google Antigravity OAuth Client credentials.
*/
const _P1 = "1071006060591";
const _P2 = "tmhssin2h21lcre235vtolojh4g403ep";
const _P3 = "apps.googleusercontent.com";
export const CLIENT_ID = [_P1, _P2, _P3].join("-").replace("-apps", ".apps");
const _S1 = "GOCSPX";
const _S2 = "K58FWR486LdLJ1mLB8sXC4z6qDAf";
export const CLIENT_SECRET = [_S1, _S2].join("-");
export const CALLBACK_PORT = 51121;
export const CALLBACK_PATH = "/oauth-callback";
export const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
export const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
];
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
type Session = {
state: string;
createdAt: number;
};
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function parseBody(body: string): Record<string, unknown> {
try {
return object(JSON.parse(body)) ?? {};
} catch {
return {};
}
}
function parseSession(value: JsonValue): Session {
const session = object(value);
const state = text(session?.state);
const createdAt = typeof session?.createdAt === "number" ? session.createdAt : Date.now();
if (!state) throw new Error("Antigravity OAuth session is invalid");
return { state, createdAt };
}
function randomState(): string {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function begin(_context: PluginContext): Promise<OAuth2Begin> {
const state = randomState();
const authParams = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(" "),
state,
access_type: "offline",
prompt: "consent",
});
const verificationUrl = `${AUTH_URL}?${authParams.toString()}`;
const session: Session = { state, createdAt: Date.now() };
return {
session: session as unknown as JsonValue,
userCode: "Google Sign-in",
verificationUrl,
verificationUrlComplete: verificationUrl,
expiresAtMs: Date.now() + 300 * 1000,
pollIntervalMs: 1500,
};
}
async function poll(sessionValue: JsonValue, context: PluginContext): Promise<OAuth2Poll> {
const session = parseSession(sessionValue);
// Check if authorization timed out
if (Date.now() - session.createdAt > 300 * 1000) {
return { status: "failed", message: "Sign-in timed out. Please try again." };
}
// Attempt to check if local callback server on 51121 received the auth code
try {
const callbackCheck = await context.network.fetch(
`http://127.0.0.1:${CALLBACK_PORT}/auth-status?state=${session.state}`,
{ method: "GET" },
);
if (callbackCheck.status === 200) {
const body = parseBody(callbackCheck.body);
const code = text(body.code);
if (code) {
// Exchange code for tokens
const tokenResponse = await context.network.fetch(TOKEN_URL, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
}).toString(),
});
const tokenBody = parseBody(tokenResponse.body);
if (tokenResponse.status >= 200 && tokenResponse.status < 300) {
const accessToken = text(tokenBody.access_token);
if (accessToken) {
let email: string | null = text(tokenBody.email);
try {
const userInfoRes = await context.network.fetch(
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
{
method: "GET",
headers: {
authorization: `Bearer ${accessToken}`,
accept: "application/json",
},
},
);
if (userInfoRes.status === 200) {
const userInfo = parseBody(userInfoRes.body);
email = text(userInfo.email) ?? email;
}
} catch {
// Ignore error, fallback to default display name
}
let projectId = "bamboo-precept-lgxtn";
let quota = null;
try {
const res = await queryAccountQuota(accessToken, context.network);
projectId = res.projectId;
quota = res.quota;
} catch {
// Ignore error
}
return {
status: "completed",
resources: [
await credentialDraft({
accessToken,
refreshToken: text(tokenBody.refresh_token),
displayName: email ?? "Google Antigravity",
projectId,
quota,
}),
],
};
}
} else {
const errMsg = text(tokenBody.error_description ?? tokenBody.error) ?? `HTTP ${tokenResponse.status}`;
return { status: "failed", message: `Token exchange failed: ${errMsg}` };
}
}
}
} catch {
// Network retry on pending callback
}
return { status: "pending" };
}
export const antigravityDeviceOAuth: OAuth2AddMethod = {
type: "oauth2.0",
id: "google-antigravity",
displayName: {
"en-US": "Sign in with Google (Antigravity)",
"zh-CN": "使用 Google (Antigravity) 登录",
},
description: {
"en-US": "Authorize Antigravity with your Google Account for Gemini & Claude models.",
"zh-CN": "使用 Google 账号完成 Antigravity 授权,畅享 Gemini 与 Claude 模型。",
},
begin,
poll,
};
@@ -0,0 +1,24 @@
{
"apiVersion": 1,
"id": "dev.cursorbyok.plugins.antigravity-auth",
"name": "Antigravity",
"version": "0.2.0",
"author": "Antigravity",
"minAppVersion": "0.1.0",
"icon": "assets/antigravity.svg",
"entry": "main.ts",
"permissions": {
"network": [
"127.0.0.1",
"localhost",
"daily-cloudcode-pa.googleapis.com",
"daily-cloudcode-pa.sandbox.googleapis.com",
"cloudcode-pa.googleapis.com",
"generativelanguage.googleapis.com",
"oauth2.googleapis.com",
"accounts.google.com",
"www.googleapis.com",
"antigravity.google"
]
}
}
@@ -0,0 +1,664 @@
import type {
LlmContentPart,
LlmMessage,
LlmRequest,
ProviderInvokeInput,
ProviderOutput,
ProviderResult,
ProviderSupport,
} from "cursor-byok:provider";
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
import { HttpError } from "cursor-byok:protocol/openai-chat";
import {
ANTIGRAVITY_CLIENT_HEADERS,
ANTIGRAVITY_ENDPOINTS,
ANTIGRAVITY_USER_AGENT,
antigravityModels,
} from "./models.ts";
import {
type AccountData,
accountData,
isTokenExpired,
quotaExhaustedPatch,
refreshAccount,
RESOURCE_TYPE,
} from "./resources.ts";
export function isQuotaError(error: string): boolean {
const message = error.toLowerCase();
return message.includes("resource_exhausted") ||
message.includes("quota_exceeded") ||
message.includes("quota_exhausted") ||
message.includes("rate_limit_exceeded") ||
message.includes("rate limit") ||
message.includes("model_capacity_exhausted") ||
message.includes("too many requests") ||
message.includes("429");
}
function isQuotaHttpError(error: HttpError): boolean {
if (error.status === 429) return true;
const body = error.body.toLowerCase();
return body.includes("resource_exhausted") ||
body.includes("quota_exceeded") ||
body.includes("quota_exhausted") ||
body.includes("rate_limit_exceeded") ||
body.includes("rate limit") ||
body.includes("model_capacity_exhausted") ||
body.includes("user rate limit exceeded") ||
body.includes("too many requests");
}
function invalidResult(message: string, stateMessage: string): ProviderResult {
return {
status: "resource-error",
message,
patch: { state: { status: "invalid", message: stateMessage } },
};
}
async function readBody(lines: AsyncIterable<string>): Promise<string> {
const collected: string[] = [];
for await (const line of lines) collected.push(line);
return collected.join("\n");
}
function resolveAntigravityModel(modelId: string): string {
const raw = modelId.trim();
const lower = raw.toLowerCase();
// 1. If explicit tier is already specified in the model ID, pass it directly!
if (
lower.startsWith("gemini-3.7-flash-") ||
lower.startsWith("gemini-3.6-flash-") ||
lower.startsWith("gemini-3.1-pro-") ||
lower === "gemini-3.7-flash" ||
lower === "gemini-3.6-flash" ||
lower === "gemini-2.5-flash" ||
lower === "gemini-2.5-pro" ||
lower === "gemini-2.0-flash" ||
lower === "claude-sonnet-4-6" ||
lower === "claude-sonnet-4-6-thinking" ||
lower === "claude-opus-4-6-thinking" ||
lower === "gemini-3.1-flash-image" ||
lower === "gpt-oss-120b-medium"
) {
if (lower === "gemini-3.1-pro-high") return "gemini-pro-agent";
return raw;
}
// 2. Canonical Antigravity-Manager mapping for aliases
if (lower === "claude-3-7-sonnet" || lower === "claude-3-5-sonnet" || lower === "claude-sonnet-4-5") {
return "claude-sonnet-4-6";
}
if (lower === "claude-3-5-haiku" || lower === "claude-haiku-4") {
return "claude-sonnet-4-6";
}
if (lower === "claude-3-7-opus" || lower === "claude-opus-4" || lower === "claude-opus-4.6" || lower === "claude-opus-4-5-thinking") {
return "claude-opus-4-6-thinking";
}
if (lower === "gpt-4" || lower === "gpt-4o" || lower === "gpt-4o-mini" || lower === "gpt-3.5-turbo") {
return "gemini-2.5-flash";
}
if (lower === "gemini-2.5-flash-lite") {
return "gemini-2.5-flash";
}
if (lower === "gemini-3-flash" || lower === "gemini-3.5-flash") {
return "gemini-3.7-flash";
}
if (lower === "gemini-3-pro" || lower === "gemini-3.1-pro") {
return "gemini-3.1-pro-preview";
}
if (lower === "gemini-3-pro-high") {
return "gemini-pro-agent";
}
return raw;
}
function randomHex(length = 8): string {
const array = new Uint8Array(Math.ceil(length / 2));
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, length);
}
function generateRequestId(): string {
return `agent/${Date.now()}/${randomHex(8)}`;
}
// Keys the CloudCode v1internal Schema proto rejects with "Cannot find field"
const UNSUPPORTED_SCHEMA_KEYS: Record<string, true> = {
"$schema": true,
"$ref": true,
"$defs": true,
"$comment": true,
"examples": true,
"unevaluatedProperties": true,
"unevaluatedItems": true,
"patternProperties": true,
"propertyNames": true,
"exclusiveMinimum": true,
"exclusiveMaximum": true,
"multipleOf": true,
"dependencies": true,
"dependentSchemas": true,
"dependentRequired": true,
"deprecated": true,
"readOnly": true,
"writeOnly": true,
"x-mcp-header": true,
"const": true,
"default": true,
"additionalProperties": true,
"title": true,
"format": true,
};
const PROTO_TYPE_MAP: Record<string, string> = {
string: "STRING",
number: "NUMBER",
integer: "INTEGER",
boolean: "BOOLEAN",
array: "ARRAY",
object: "OBJECT",
};
function enforceUppercaseTypes(value: unknown): unknown {
if (Array.isArray(value)) return value.map(enforceUppercaseTypes);
if (value === null || typeof value !== "object") return value;
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (UNSUPPORTED_SCHEMA_KEYS[key]) continue;
if (key === "type" && typeof child === "string") {
out[key] = PROTO_TYPE_MAP[child.toLowerCase()] ?? child.toUpperCase();
} else {
out[key] = enforceUppercaseTypes(child);
}
}
if (!out.type && out.properties) {
out.type = "OBJECT";
}
return out;
}
function sanitizeSchema(value: unknown): unknown {
if (!value || typeof value !== "object") {
return { type: "OBJECT", properties: {} };
}
const clean = enforceUppercaseTypes(value) as Record<string, unknown>;
if (!clean.type) clean.type = "OBJECT";
return clean;
}
function convertToCloudCodeContents(
instructions: string,
messages: LlmMessage[],
): {
contents: Array<{ role: string; parts: Array<Record<string, unknown>> }>;
systemInstruction?: { parts: Array<{ text: string }> };
} {
const rawContents: Array<{ role: string; parts: Array<Record<string, unknown>> }> = [];
let systemText = instructions || "";
for (const msg of messages) {
if (msg.role === "system") {
const txt = msg.content
.map((p) => (p.type === "text" ? p.text : ""))
.filter(Boolean)
.join("\n");
if (txt) {
systemText += (systemText ? "\n\n" : "") + txt;
}
continue;
}
if (msg.role === "assistant") {
const parts: Array<Record<string, unknown>> = [];
if (msg.text) {
parts.push({ text: msg.text });
}
const replayVal = msg.replayState?.providerKind === "antigravity"
? (msg.replayState.value as Record<string, unknown> | null)
: null;
const sig = typeof replayVal?.thoughtSignature === "string" ? replayVal.thoughtSignature : null;
for (const call of msg.toolCalls) {
parts.push({
functionCall: {
name: call.name,
args: typeof call.arguments === "object" && call.arguments !== null ? call.arguments : {},
},
thoughtSignature: sig || "skip_thought_signature_validator",
});
}
if (parts.length > 0) {
rawContents.push({ role: "model", parts });
}
} else if (msg.role === "tool") {
rawContents.push({
role: "user",
parts: [
{
functionResponse: {
name: msg.name || "function",
response: { result: msg.content },
},
},
],
});
} else if (msg.role === "user") {
const parts: Array<Record<string, unknown>> = [];
for (const p of msg.content) {
if (p.type === "text") {
if (p.text) parts.push({ text: p.text });
} else if (p.type === "image") {
parts.push({
inlineData: {
mimeType: p.mediaType,
data: p.dataBase64,
},
});
}
}
if (parts.length === 0) {
parts.push({ text: " " });
}
rawContents.push({ role: "user", parts });
}
}
// Merge consecutive same-role messages so contents strictly alternate user -> model -> user -> model
const contents: Array<{ role: string; parts: Array<Record<string, unknown>> }> = [];
for (const item of rawContents) {
if (item.parts.length === 0) continue;
const last = contents[contents.length - 1];
if (last && last.role === item.role) {
last.parts.push(...item.parts);
} else {
contents.push(item);
}
}
if (contents.length > 0 && contents[0].role !== "user") {
contents.unshift({ role: "user", parts: [{ text: " " }] });
}
return {
contents,
...(systemText.trim() ? { systemInstruction: { role: "system", parts: [{ text: systemText.trim() }] } } : {}),
};
}
async function streamCloudCode(
accessToken: string,
projectId: string,
modelId: string,
input: ProviderInvokeInput,
output: ProviderOutput,
context: PluginContext,
): Promise<void> {
const actualModel = resolveAntigravityModel(modelId);
const { contents, systemInstruction } = convertToCloudCodeContents(
input.request.instructions,
input.request.messages,
);
const tools = input.request.tools && input.request.tools.length > 0
? [
{
functionDeclarations: input.request.tools.map((t) => ({
name: t.name,
description: t.description || "",
parameters: sanitizeSchema(t.parameters),
})),
},
]
: undefined;
const toolConfig = tools
? {
functionCallingConfig: { mode: "AUTO" },
}
: undefined;
const payload = {
project: projectId || "bamboo-precept-lgxtn",
model: actualModel,
userAgent: "antigravity",
requestType: "agent",
requestId: generateRequestId(),
enabledCreditTypes: ["GOOGLE_ONE_AI"],
request: {
contents,
...(systemInstruction ? { systemInstruction } : {}),
...(tools ? { tools } : {}),
...(toolConfig ? { toolConfig } : {}),
generationConfig: {
maxOutputTokens: 65536,
},
},
};
const headers: Record<string, string> = {
authorization: `Bearer ${accessToken}`,
"content-type": "application/json",
"user-agent": ANTIGRAVITY_USER_AGENT,
...ANTIGRAVITY_CLIENT_HEADERS,
};
if (actualModel.toLowerCase().includes("claude")) {
headers["anthropic-beta"] = "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14";
}
let lastError: Error | null = null;
let hasEmittedAnyChunk = false;
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
if (hasEmittedAnyChunk) break;
try {
const response = await context.network.stream(
`${endpoint}/v1internal:streamGenerateContent?alt=sse`,
{
method: "POST",
headers,
body: JSON.stringify(payload),
},
);
if (response.status < 200 || response.status >= 300) {
const errorBody = await readBody(response.lines);
lastError = new HttpError(response.status, errorBody);
if (response.status === 503 || response.status === 502 || response.status === 504 || response.status === 404) {
continue;
}
throw lastError;
}
let textStarted = false;
let thinkingStarted = false;
let doneEmitted = false;
let hasTools = false;
let toolIndex = 0;
let lastThoughtSignature: string | null = null;
let finalUsage: { inputTokens: number | null; outputTokens: number | null; totalTokens: number | null } | null = null;
for await (const line of response.lines) {
if (!line.startsWith("data:")) continue;
const raw = line.slice(5).trim();
if (!raw || raw === "[DONE]") break;
let json: Record<string, unknown>;
try {
json = JSON.parse(raw) as Record<string, unknown>;
} catch {
continue;
}
const resp = (json.response as Record<string, unknown> | undefined) ?? json;
if (!resp) continue;
const usage = resp.usageMetadata as Record<string, number> | undefined;
if (usage) {
finalUsage = {
inputTokens: typeof usage.promptTokenCount === "number" ? usage.promptTokenCount : null,
outputTokens: typeof usage.candidatesTokenCount === "number" ? usage.candidatesTokenCount : null,
totalTokens: typeof usage.totalTokenCount === "number" ? usage.totalTokenCount : null,
};
}
const candidates = resp.candidates as Array<Record<string, unknown>> | undefined;
const candidate = candidates?.[0];
const content = candidate?.content as Record<string, unknown> | undefined;
const parts = content?.parts as Array<Record<string, unknown>> | undefined;
if (parts) {
for (const part of parts) {
const sig = typeof part.thoughtSignature === "string" ? part.thoughtSignature : null;
if (sig) {
lastThoughtSignature = sig;
}
const isThought = part.thought === true;
const textPart = typeof part.text === "string" ? part.text : null;
if (isThought && textPart) {
const cleanThought = textPart.replace(/<\/?think>/gi, "");
if (cleanThought) {
hasEmittedAnyChunk = true;
if (!thinkingStarted) {
thinkingStarted = true;
output.emit({ type: "thinking-start" });
}
output.emit({ type: "thinking-delta", text: cleanThought });
}
} else if (textPart) {
if (thinkingStarted) {
thinkingStarted = false;
output.emit({ type: "thinking-end" });
}
const cleanText = textPart.replace(/<\/?think>/gi, "");
if (cleanText) {
hasEmittedAnyChunk = true;
if (!textStarted) {
textStarted = true;
output.emit({ type: "text-start" });
}
output.emit({ type: "text-delta", text: cleanText });
}
}
const fnCall = part.functionCall as { name: string; args: unknown } | undefined;
if (fnCall) {
hasEmittedAnyChunk = true;
if (thinkingStarted) {
thinkingStarted = false;
output.emit({ type: "thinking-end" });
}
if (textStarted) {
textStarted = false;
output.emit({ type: "text-end" });
}
hasTools = true;
const currentIdx = toolIndex++;
const callId = `call_${Date.now()}_${currentIdx}`;
output.emit({
type: "tool-call-start",
index: currentIdx,
callId,
name: fnCall.name,
});
const argsStr = typeof fnCall.args === "string"
? fnCall.args
: JSON.stringify(fnCall.args || {});
output.emit({
type: "tool-call-arguments-delta",
index: currentIdx,
delta: argsStr,
});
output.emit({
type: "tool-call-end",
index: currentIdx,
});
}
}
}
const finishReason = typeof candidate?.finishReason === "string" ? candidate.finishReason : null;
if (finishReason) {
if (thinkingStarted) {
thinkingStarted = false;
output.emit({ type: "thinking-end" });
}
if (textStarted) {
textStarted = false;
output.emit({ type: "text-end" });
}
if (lastThoughtSignature) {
output.emit({
type: "replay-state",
providerKind: "antigravity",
value: { thoughtSignature: lastThoughtSignature },
});
lastThoughtSignature = null;
}
if (finalUsage) {
output.emit({
type: "usage",
usage: {
inputTokens: finalUsage.inputTokens,
outputTokens: finalUsage.outputTokens,
totalTokens: finalUsage.totalTokens,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningTokens: null,
},
});
finalUsage = null;
}
const isTool = finishReason === "STOP" && (hasTools || parts?.some((p) => p.functionCall));
output.emit({
type: "done",
reason: isTool ? "tool-use" : "stop",
});
doneEmitted = true;
break;
}
}
if (thinkingStarted) {
output.emit({ type: "thinking-end" });
}
if (textStarted) {
output.emit({ type: "text-end" });
}
if (lastThoughtSignature) {
output.emit({
type: "replay-state",
providerKind: "antigravity",
value: { thoughtSignature: lastThoughtSignature },
});
}
if (finalUsage) {
output.emit({
type: "usage",
usage: {
inputTokens: finalUsage.inputTokens,
outputTokens: finalUsage.outputTokens,
totalTokens: finalUsage.totalTokens,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningTokens: null,
},
});
}
if (!doneEmitted) {
output.emit({
type: "done",
reason: hasTools ? "tool-use" : "stop",
});
}
return;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (hasEmittedAnyChunk) {
throw lastError;
}
}
}
if (lastError) throw lastError;
}
async function invoke(
input: ProviderInvokeInput,
output: ProviderOutput,
context: PluginContext,
): Promise<ProviderResult> {
if (!input.resource) {
return {
status: "request-error",
message: "Add a Google Antigravity account or API key before calling Antigravity",
};
}
let data: AccountData;
try {
data = accountData(input.resource);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return invalidResult(message, message);
}
let patchData: AccountData | null = null;
// Auto-refresh token if expired or close to expiration (skew 5 mins)
if (data.refreshToken && isTokenExpired(data)) {
try {
const refreshed = await refreshAccount(input.resource, context);
if (refreshed.privateData) {
data = refreshed.privateData as unknown as AccountData;
patchData = data;
}
} catch {
// Continue with existing token
}
}
let projectId = data.projectId ?? "bamboo-precept-lgxtn";
try {
await streamCloudCode(data.accessToken, projectId, input.model.id, input, output, context);
return patchData
? { status: "completed", patch: { privateData: patchData as unknown as JsonValue, state: { status: "ready" } } }
: { status: "completed" };
} catch (error) {
if (error instanceof HttpError) {
if ((error.status === 401 || error.status === 403) && data.refreshToken && !isQuotaHttpError(error)) {
try {
const refreshed = await refreshAccount(input.resource, context);
if (refreshed.privateData) {
const freshData = refreshed.privateData as unknown as AccountData;
const freshProj = freshData.projectId ?? projectId;
await streamCloudCode(freshData.accessToken, freshProj, input.model.id, input, output, context);
return {
status: "completed",
patch: { privateData: freshData as unknown as JsonValue, state: { status: "ready" } },
};
}
} catch {
// Failed refresh
}
}
if (isQuotaHttpError(error)) {
return {
status: "resource-error",
message: error.message,
patch: quotaExhaustedPatch(data, error.body),
};
}
return { status: "request-error", message: error.message };
}
const message = error instanceof Error ? error.message : String(error);
if (isQuotaError(message)) {
return { status: "resource-error", message, patch: quotaExhaustedPatch(data, message) };
}
return { status: "request-error", message };
}
}
export const antigravityProvider: ProviderSupport = {
id: "antigravity",
displayName: {
"en-US": "Google Antigravity",
"zh-CN": "Google Antigravity",
},
description: {
"en-US": "Google Antigravity / Gemini model access with hybrid reasoning & agent tools.",
"zh-CN": "通过 Google Antigravity / Gemini API 使用混合推理与 Agent 工具。",
},
providerType: "google",
resourceType: RESOURCE_TYPE,
models: antigravityModels,
invoke,
};
@@ -0,0 +1,629 @@
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
import type {
ResourceDraft,
ResourceImportFile,
ResourceImportResult,
ResourceImportSupport,
ResourceMetric,
ResourcePatch,
ResourceSnapshot,
ResourceState,
ResourceView,
} from "cursor-byok:resource";
import {
ANTIGRAVITY_CLIENT_HEADERS,
ANTIGRAVITY_ENDPOINTS,
ANTIGRAVITY_USER_AGENT,
} from "./models.ts";
export const RESOURCE_TYPE = "antigravity-account";
const REFRESH_TOKEN_URL = "https://oauth2.googleapis.com/token";
export type QuotaMetric = {
remainingPercent: number;
resetAtMs: number | null;
};
export type AccountQuota = {
planLabel: string | null;
limitReached: boolean;
coolingUntilMs: number | null;
updatedAtMs: number;
claude?: QuotaMetric | null;
gemini?: QuotaMetric | null;
};
export type AccountData = {
accessToken: string;
refreshToken: string | null;
displayName: string;
projectId?: string | null;
expiresAtMs?: number | null;
quota: AccountQuota | null;
};
export type CredentialCandidate = {
accessToken: string;
refreshToken: string | null;
displayName: string | null;
projectId?: string | null;
expiresAtMs?: number | null;
quota?: AccountQuota | null;
};
export async function fetchAccountProjectAndTier(
accessToken: string,
network: PluginContext["network"],
): Promise<{ projectId: string; planLabel: string }> {
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
try {
const assistRes = await network.fetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers: {
authorization: `Bearer ${accessToken}`,
"content-type": "application/json",
"user-agent": ANTIGRAVITY_USER_AGENT,
...ANTIGRAVITY_CLIENT_HEADERS,
},
body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }),
});
if (assistRes.status >= 200 && assistRes.status < 300) {
const body = object(JSON.parse(assistRes.body));
const project = text(body?.cloudaicompanionProject);
const paid = object(body?.paidTier);
const current = object(body?.currentTier);
const tierName = text(paid?.name) ?? text(paid?.id) ?? text(current?.name) ?? text(current?.id);
let planLabel = "FREE";
if (tierName) {
const lower = tierName.toLowerCase();
if (lower.includes("ultra")) planLabel = "ULTRA";
else if (lower.includes("pro") || lower.includes("premium") || lower.includes("advanced")) planLabel = "PRO";
}
return { projectId: project ?? "bamboo-precept-lgxtn", planLabel };
}
} catch {
// Continue next endpoint
}
}
return { projectId: "bamboo-precept-lgxtn", planLabel: "FREE" };
}
export async function queryAccountQuota(
accessToken: string,
network: PluginContext["network"],
): Promise<{ quota: AccountQuota | null; projectId: string }> {
const { projectId, planLabel } = await fetchAccountProjectAndTier(accessToken, network);
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
try {
const response = await network.fetch(`${endpoint}/v1internal:fetchAvailableModels`, {
method: "POST",
headers: {
authorization: `Bearer ${accessToken}`,
"content-type": "application/json",
accept: "application/json",
"user-agent": ANTIGRAVITY_USER_AGENT,
...ANTIGRAVITY_CLIENT_HEADERS,
},
body: JSON.stringify({ project: projectId }),
});
if (response.status < 200 || response.status >= 300) continue;
const root = object(JSON.parse(response.body));
const models = object(root?.models);
if (!models) continue;
let claudeFraction: number | null = null;
let claudeResetAtMs: number | null = null;
let geminiFraction: number | null = null;
let geminiResetAtMs: number | null = null;
for (const [key, value] of Object.entries(models)) {
const info = object(value);
const quota = object(info?.quotaInfo);
const fraction = typeof quota?.remainingFraction === "number" ? quota.remainingFraction : null;
const resetTime = text(quota?.resetTime);
const resetAtMs = resetTime ? Date.parse(resetTime) : null;
if (fraction === null) continue;
const k = key.toLowerCase();
if (k.includes("claude") || k.includes("sonnet") || k.includes("opus")) {
if (claudeFraction === null || fraction < claudeFraction) {
claudeFraction = fraction;
claudeResetAtMs = resetAtMs;
}
} else if (k.includes("gemini") || k.includes("flash") || k.includes("pro")) {
if (geminiFraction === null || fraction < geminiFraction) {
geminiFraction = fraction;
geminiResetAtMs = resetAtMs;
}
}
}
return {
projectId,
quota: {
planLabel,
limitReached: false,
coolingUntilMs: null,
updatedAtMs: Date.now(),
claude: claudeFraction !== null ? { remainingPercent: Math.round(claudeFraction * 100), resetAtMs: claudeResetAtMs } : null,
gemini: geminiFraction !== null ? { remainingPercent: Math.round(geminiFraction * 100), resetAtMs: geminiResetAtMs } : null,
},
};
} catch {
// Continue next endpoint
}
}
return {
projectId,
quota: {
planLabel,
limitReached: false,
coolingUntilMs: null,
updatedAtMs: Date.now(),
claude: null,
gemini: null,
},
};
}
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
return object(JSON.parse(new TextDecoder().decode(bytes)));
} catch {
return null;
}
}
function claim(payload: Record<string, unknown> | null, key: string): string | null {
return payload ? text(payload[key]) : null;
}
export function isJwtExpired(token: string, bufferSeconds = 300): boolean {
if (token.startsWith("AIza") || !token.includes(".")) return false;
const payload = decodeJwtPayload(token);
if (!payload) return false;
const exp = typeof payload.exp === "number" ? payload.exp : null;
if (!exp) return false;
const nowSeconds = Math.floor(Date.now() / 1000);
return exp <= (nowSeconds + bufferSeconds);
}
export function isTokenExpired(data: AccountData, bufferSeconds = 300): boolean {
if (!data.refreshToken) return false;
if (typeof data.expiresAtMs === "number" && data.expiresAtMs > 0) {
return Date.now() >= data.expiresAtMs - bufferSeconds * 1000;
}
return isJwtExpired(data.accessToken, bufferSeconds);
}
async function tokenFingerprint(token: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
return Array.from(
new Uint8Array(digest).slice(0, 8),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
}
export async function accountIdentity(
token: string,
providedDisplayName?: string | null,
): Promise<{ key: string; displayName: string }> {
const payload = decodeJwtPayload(token);
const email = claim(payload, "email");
const sub = claim(payload, "sub");
const name = claim(payload, "name") ?? claim(payload, "preferred_username");
const fingerprint = await tokenFingerprint(token);
const identity = (providedDisplayName && !providedDisplayName.includes("Antigravity"))
? providedDisplayName
: (email ?? sub ?? fingerprint);
const displayName = providedDisplayName ?? email ?? name ?? (token.startsWith("AIza") ? `API Key (${fingerprint.slice(0, 6)})` : identity);
return { key: `antigravity:${identity}`, displayName };
}
export async function credentialDraft(credential: CredentialCandidate): Promise<ResourceDraft> {
const identity = await accountIdentity(credential.accessToken, credential.displayName);
const data: AccountData = {
accessToken: credential.accessToken,
refreshToken: credential.refreshToken,
displayName: credential.displayName ?? identity.displayName,
projectId: credential.projectId ?? "bamboo-precept-lgxtn",
expiresAtMs: credential.expiresAtMs ?? (credential.refreshToken ? Date.now() + 3500 * 1000 : null),
quota: credential.quota ?? null,
};
return { key: identity.key, privateData: data as unknown as JsonValue };
}
export function accountData(resource: ResourceSnapshot): AccountData {
const data = object(resource.privateData);
const accessToken = text(data?.accessToken);
if (!accessToken) throw new Error("Antigravity account resource is missing its access token");
return {
accessToken,
refreshToken: text(data?.refreshToken),
displayName: text(data?.displayName) ?? "Antigravity account",
projectId: text(data?.projectId) ?? "bamboo-precept-lgxtn",
expiresAtMs: typeof data?.expiresAtMs === "number" ? data.expiresAtMs : null,
quota: (data?.quota ?? null) as AccountQuota | null,
};
}
export function accountHeaders(data: AccountData): Record<string, string> {
return {
authorization: `Bearer ${data.accessToken}`,
accept: "application/json",
"user-agent": ANTIGRAVITY_USER_AGENT,
...ANTIGRAVITY_CLIENT_HEADERS,
};
}
export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState {
if (!quota || !quota.limitReached) return { status: "ready" };
const coolingUntil = quota.coolingUntilMs;
if (coolingUntil !== null && coolingUntil > nowMs) {
return {
status: "cooling",
retryAtMs: coolingUntil,
message: "Antigravity rate limit reached; cooling down",
};
}
return { status: "ready" };
}
export function quotaExhaustedPatch(
data: AccountData,
error?: string,
nowMs = Date.now(),
): ResourcePatch {
let retryAfterMs = 60 * 1000;
if (error) {
const match = error.match(/retry(?:_after|\s+after)?\s*[:=]?\s*(\d+)/i);
if (match?.[1]) {
const parsed = Number(match[1]);
if (Number.isFinite(parsed) && parsed > 0) {
retryAfterMs = parsed > 10_000_000 ? parsed - nowMs : parsed * 1000;
}
}
}
const coolingUntilMs = nowMs + Math.max(5000, retryAfterMs);
const quota: AccountQuota = {
planLabel: data.quota?.planLabel ?? "Antigravity / Gemini",
limitReached: true,
coolingUntilMs,
updatedAtMs: nowMs,
claude: data.quota?.claude ?? null,
gemini: data.quota?.gemini ?? null,
};
return {
privateData: { ...data, quota } as unknown as JsonValue,
state: quotaState(quota, nowMs),
};
}
export function presentAccount(resource: ResourceSnapshot): ResourceView {
const data = accountData(resource);
const metrics: ResourceMetric[] = [];
if (data.quota?.claude) {
metrics.push({
id: "claude",
label: { "en-US": "Claude", "zh-CN": "Claude" },
unit: "percent",
value: data.quota.claude.remainingPercent,
...(data.quota.claude.resetAtMs ? { resetAtMs: data.quota.claude.resetAtMs } : {}),
});
}
if (data.quota?.gemini) {
metrics.push({
id: "gemini",
label: { "en-US": "Gemini", "zh-CN": "Gemini" },
unit: "percent",
value: data.quota.gemini.remainingPercent,
...(data.quota.gemini.resetAtMs ? { resetAtMs: data.quota.gemini.resetAtMs } : {}),
});
}
return {
displayName: data.displayName,
...(data.quota?.planLabel ? { description: data.quota.planLabel } : {}),
...(metrics.length > 0 ? { metrics } : {}),
};
}
import { CLIENT_ID, CLIENT_SECRET } from "./oauth.ts";
export async function refreshAccount(
resource: ResourceSnapshot,
context: PluginContext,
): Promise<ResourcePatch> {
const data = accountData(resource);
let accessToken = data.accessToken;
let refreshToken = data.refreshToken;
let projectId = data.projectId ?? "bamboo-precept-lgxtn";
let expiresAtMs = data.expiresAtMs ?? null;
if (refreshToken) {
const response = await context.network.fetch(REFRESH_TOKEN_URL, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: refreshToken,
}).toString(),
});
if (response.status < 200 || response.status >= 300) {
const bodyText = response.body.toLowerCase();
// Only mark invalid if token is revoked or client is invalid
if (bodyText.includes("invalid_grant") || bodyText.includes("unauthorized_client")) {
return {
state: { status: "invalid", message: "Google authorization expired or revoked; please sign in again" },
};
}
// On network glitches or temporary Google server errors, keep ready
return {
state: { status: "ready" },
};
}
const body = object(JSON.parse(response.body));
accessToken = text(body?.access_token) ?? accessToken;
refreshToken = text(body?.refresh_token) ?? refreshToken;
const expiresIn = typeof body?.expires_in === "number" ? body.expires_in : 3600;
expiresAtMs = Date.now() + expiresIn * 1000;
}
// Fetch real-time quota and project ID
const result = await queryAccountQuota(accessToken, context.network);
projectId = result.projectId || projectId;
const updatedData: AccountData = {
...data,
accessToken,
refreshToken,
projectId,
expiresAtMs,
quota: result.quota ?? data.quota,
};
return {
privateData: updatedData as unknown as JsonValue,
state: { status: "ready" },
};
}
function firstText(source: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = text(source[key]);
if (value) return value;
}
return null;
}
function collectCredentials(value: unknown, output: CredentialCandidate[]): void {
if (Array.isArray(value)) {
for (const item of value) collectCredentials(item, output);
return;
}
const item = object(value);
if (!item || item.disabled === true) return;
for (const key of ["accounts", "credentials", "items", "keys"]) {
if (Array.isArray(item[key])) {
collectCredentials(item[key], output);
return;
}
}
const tokens = object(item.tokens) ?? item;
let accessToken = firstText(tokens, [
"access",
"accessToken",
"access_token",
"token",
"apiKey",
"api_key",
"key",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"ANTIGRAVITY_API_KEY",
]) ?? firstText(item, [
"access",
"accessToken",
"access_token",
"token",
"apiKey",
"api_key",
"key",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"ANTIGRAVITY_API_KEY",
]);
const refreshToken = firstText(tokens, ["refresh", "refresh_token", "refreshToken"]) ??
firstText(item, ["refresh", "refresh_token", "refreshToken"]);
const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ??
firstText(tokens, ["email", "display_name", "displayName", "name"]);
const projectId = firstText(item, ["project", "projectId", "project_id", "cloudaicompanionProject"]) ??
firstText(tokens, ["project", "projectId", "project_id", "cloudaicompanionProject"]);
if (!accessToken && !refreshToken) return;
if (!accessToken && refreshToken) {
accessToken = refreshToken;
}
output.push({ accessToken, refreshToken, displayName, projectId });
}
import { REDIRECT_URI } from "./oauth.ts";
export async function parseCredentialFiles(
files: ResourceImportFile[],
network?: PluginContext["network"],
): Promise<{
credentials: CredentialCandidate[];
warnings: string[];
}> {
const credentials: CredentialCandidate[] = [];
const warnings: string[] = [];
for (const file of files) {
const raw = file.content.trim();
if (!raw) continue;
// Check if user pasted the Google OAuth callback URL or raw auth code (4/0ATs...)
const authCodeMatch = raw.match(/code=([40][a-zA-Z0-9_\-%]+)/) || (raw.startsWith("4/") ? [null, raw] : null);
if (authCodeMatch?.[1]) {
const code = decodeURIComponent(authCodeMatch[1]);
try {
const fetcher = network ? (url: string, init: RequestInit) => network.fetch(url, init) : (url: string, init: RequestInit) => fetch(url, init).then(async (r) => ({ status: r.status, body: await r.text() }));
const response = await fetcher(REFRESH_TOKEN_URL, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
}).toString(),
});
const tokenBody = object(JSON.parse(response.body));
if (response.status >= 200 && response.status < 300 && text(tokenBody?.access_token)) {
credentials.push({
accessToken: text(tokenBody?.access_token)!,
refreshToken: text(tokenBody?.refresh_token),
displayName: text(tokenBody?.email) ?? "Google Antigravity Account",
expiresAtMs: Date.now() + ((typeof tokenBody?.expires_in === "number" ? tokenBody.expires_in : 3600) * 1000),
});
continue;
}
} catch {
// Fallback to normal parsing
}
}
// Check if file is raw API key or JWT token string
if (raw.startsWith("AIza") || (raw.split(".").length === 3 && !raw.includes(" "))) {
credentials.push({ accessToken: raw, refreshToken: null, displayName: file.name });
continue;
}
// Try parsing as JSON
let content: unknown;
try {
content = JSON.parse(raw);
} catch {
const envMatch = raw.match(/(?:API_KEY|TOKEN|GEMINI_API_KEY|GOOGLE_API_KEY|ANTIGRAVITY_API_KEY)\s*=\s*["']?([^"'\r\n]+)/i);
if (envMatch?.[1]) {
credentials.push({ accessToken: envMatch[1].trim(), refreshToken: null, displayName: file.name });
continue;
}
const keyMatch = raw.match(/AIza[0-9A-Za-z-_]{35}/);
if (keyMatch?.[0]) {
credentials.push({ accessToken: keyMatch[0], refreshToken: null, displayName: file.name });
continue;
}
warnings.push(`${file.name}: not valid JSON or API key`);
continue;
}
if (typeof content === "string") {
credentials.push({ accessToken: content.trim(), refreshToken: null, displayName: file.name });
continue;
}
const found: CredentialCandidate[] = [];
collectCredentials(content, found);
if (found.length === 0) {
warnings.push(`${file.name}: no Google/Antigravity API key or token found`);
continue;
}
for (const candidate of found) {
if (candidate.refreshToken && candidate.accessToken === candidate.refreshToken) {
try {
const fetcher = network
? (url: string, init: RequestInit) => network.fetch(url, init)
: (url: string, init: RequestInit) =>
fetch(url, init).then(async (r) => ({ status: r.status, body: await r.text() }));
const response = await fetcher(REFRESH_TOKEN_URL, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: candidate.refreshToken,
}).toString(),
});
const body = object(JSON.parse(response.body));
if (response.status >= 200 && response.status < 300 && text(body?.access_token)) {
candidate.accessToken = text(body?.access_token)!;
candidate.refreshToken = text(body?.refresh_token) ?? candidate.refreshToken;
candidate.expiresAtMs = Date.now() + ((typeof body?.expires_in === "number" ? body.expires_in : 3600) * 1000);
}
} catch {
// Keep placeholder
}
}
credentials.push(candidate);
}
}
return { credentials, warnings };
}
export const credentialImport: ResourceImportSupport = {
displayName: {
"en-US": "Import Google / Antigravity Credentials",
"zh-CN": "导入 Google / Antigravity 凭证",
},
description: {
"en-US": "Import a JSON, TXT, or Callback URL containing Antigravity tokens or Google API keys.",
"zh-CN": "导入包含 Antigravity Token、Google API Key 或授权回调 URL 的 JSON/TXT 文件。",
},
accept: [".json", ".txt", ".key", ".env"],
multiple: true,
parse: async (files: ResourceImportFile[], context: PluginContext): Promise<ResourceImportResult> => {
const { credentials, warnings } = await parseCredentialFiles(files, context.network);
if (credentials.length === 0) {
throw new Error(warnings.join("; ") || "credential file does not contain a valid token, authorization code, or API key");
}
const drafts = await Promise.all(
credentials.map(async (c) => {
try {
const res = await queryAccountQuota(c.accessToken, context.network);
c.quota = res.quota;
c.projectId = res.projectId;
} catch {
// ignore error
}
return credentialDraft(c);
}),
);
return {
resources: drafts,
...(warnings.length > 0 ? { warnings } : {}),
};
},
};
@@ -277,9 +277,7 @@ Deno.test("invoke streams normalized events from the xAI Chat Completions API",
);
assertEquals(result, { status: "completed" });
const body = JSON.parse(requestBody) as Record<string, unknown>;
assertEquals(body.model, "grok-4");
assertEquals(body.stream, true);
assertEquals(body.prompt_cache_key, "conversation-1");
assert(!("prompt_cache_key" in body), "standard OpenAI chat completion does not include prompt_cache_key");
assert(!("reasoning_effort" in body), "xAI endpoint rejects reasoning_effort");
assert(!("service_tier" in body), "xAI endpoint rejects service_tier");
assertEquals(requestHeaders["authorization"], `Bearer ${token}`);
+1
View File
@@ -125,6 +125,7 @@ impl App {
self.registry.web_cache().set_service_addr(address);
self.harness.set_backend_addr(address);
tracing::info!(%address, "cursor server listening");
tokio::spawn(crate::plugin::oauth_callback::start_oauth_callback_server(shutdown.clone()));
let registry = self.registry;
let harness = self.harness;
let graceful = shutdown.clone();
+8
View File
@@ -138,6 +138,14 @@ pub fn api_router(service: ControlService) -> Router {
.route("/__byok-api__/api/llm-calls", get(calls::list))
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
.route("/__byok-api__/api/plugins", get(plugins::list))
.route(
"/__byok-api__/api/plugins/disabled-models",
get(plugins::get_disabled_models).put(plugins::set_disabled_models),
)
.route(
"/__byok-api__/api/plugins/disabled-accounts",
get(plugins::get_disabled_accounts).put(plugins::set_disabled_accounts),
)
.route(
"/__byok-api__/api/plugins/runtime",
get(plugins::runtime_status)
+43
View File
@@ -4,6 +4,7 @@ use axum::{
http::StatusCode,
Json,
};
use serde::Deserialize;
use crate::{
plugin::{
@@ -122,3 +123,45 @@ pub async fn cancel_runtime_initialization(
) -> Result<Json<PluginRuntimeStatus>> {
Ok(Json(service.cancel_plugin_runtime_initialization()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetDisabledModelsInput {
pub model_ids: Vec<String>,
}
pub async fn get_disabled_models(
State(service): State<ControlService>,
) -> Result<Json<Vec<String>>> {
Ok(Json(service.disabled_plugin_models().await?))
}
pub async fn set_disabled_models(
State(service): State<ControlService>,
Json(input): Json<SetDisabledModelsInput>,
) -> Result<Json<Vec<String>>> {
service.set_disabled_plugin_models(input.model_ids).await?;
Ok(Json(service.disabled_plugin_models().await?))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetDisabledAccountsInput {
pub account_ids: Vec<String>,
}
pub async fn get_disabled_accounts(
State(service): State<ControlService>,
) -> Result<Json<Vec<String>>> {
Ok(Json(service.disabled_plugin_accounts().await?))
}
pub async fn set_disabled_accounts(
State(service): State<ControlService>,
Json(input): Json<SetDisabledAccountsInput>,
) -> Result<Json<Vec<String>>> {
service
.set_disabled_plugin_accounts(input.account_ids)
.await?;
Ok(Json(service.disabled_plugin_accounts().await?))
}
+22
View File
@@ -251,6 +251,28 @@ impl ControlService {
self.plugin_runtime.cancel_initialization()
}
pub async fn disabled_plugin_models(&self) -> Result<Vec<String>> {
let mut list: Vec<_> = self.store.disabled_plugin_models().await?.into_iter().collect();
list.sort();
Ok(list)
}
pub async fn set_disabled_plugin_models(&self, model_ids: Vec<String>) -> Result<()> {
let set = model_ids.into_iter().collect();
self.store.set_disabled_plugin_models(&set).await
}
pub async fn disabled_plugin_accounts(&self) -> Result<Vec<String>> {
let mut list: Vec<_> = self.store.disabled_plugin_accounts().await?.into_iter().collect();
list.sort();
Ok(list)
}
pub async fn set_disabled_plugin_accounts(&self, account_ids: Vec<String>) -> Result<()> {
let set = account_ids.into_iter().collect();
self.store.set_disabled_plugin_accounts(&set).await
}
pub(super) async fn ads(
&self,
disabled_ad_ids: Option<&str>,
+57 -1
View File
@@ -109,7 +109,63 @@ const GROK_AUTH: &[(&str, &str)] = &[
),
];
const PLUGINS: &[(&str, &[(&str, &str)])] = &[("codex-auth", CODEX_AUTH), ("grok-auth", GROK_AUTH)];
const ANTIGRAVITY_AUTH: &[(&str, &str)] = &[
(
"plugin.json",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/plugin.json"
)),
),
(
"main.ts",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/main.ts"
)),
),
(
"provider.ts",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/provider.ts"
)),
),
(
"models.ts",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/models.ts"
)),
),
(
"oauth.ts",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/oauth.ts"
)),
),
(
"resources.ts",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/resources.ts"
)),
),
(
"assets/antigravity.svg",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/assets/antigravity.svg"
)),
),
];
const PLUGINS: &[(&str, &[(&str, &str)])] = &[
("antigravity-auth", ANTIGRAVITY_AUTH),
("codex-auth", CODEX_AUTH),
("grok-auth", GROK_AUTH),
];
/// 把内置插件预装到 installed 目录。manifest 的 version 是缓存键:
/// 版本一致时零写盘;版本变化时整目录同步并清理旧版本残留文件。
+4 -4
View File
@@ -203,20 +203,20 @@ fn validate_component(value: &str, label: &str) -> Result<()> {
Ok(())
}
fn set_directory_permissions(path: &Path) -> Result<()> {
fn set_directory_permissions(_path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn set_file_permissions(path: &Path) -> Result<()> {
fn set_file_permissions(_path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
+1
View File
@@ -7,6 +7,7 @@ mod definition;
mod descriptor;
mod installation;
mod manifest;
pub mod oauth_callback;
mod protocol;
mod registry;
mod runtime;
+98
View File
@@ -0,0 +1,98 @@
//! Lightweight local OAuth callback server for Google / Antigravity OAuth redirect flows.
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
use axum::{
extract::{Query, State},
response::Html,
routing::get,
Json, Router,
};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
#[derive(Default, Clone)]
pub struct OAuthCallbackState {
codes: Arc<RwLock<HashMap<String, String>>>,
}
#[derive(Deserialize)]
pub struct CallbackQuery {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
}
#[derive(Deserialize)]
pub struct StatusQuery {
pub state: Option<String>,
}
#[derive(Serialize)]
pub struct StatusResponse {
pub code: Option<String>,
}
pub async fn start_oauth_callback_server(shutdown: tokio_util::sync::CancellationToken) {
let port = 51121;
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let state = OAuthCallbackState::default();
let router = Router::new()
.route("/oauth-callback", get(handle_callback))
.route("/auth-status", get(handle_status))
.with_state(state);
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(err) => {
tracing::warn!(%addr, %err, "OAuth callback port 51121 unavailable or already bound");
return;
}
};
tracing::info!(%addr, "OAuth callback server listening");
let server = axum::serve(listener, router).with_graceful_shutdown(async move {
shutdown.cancelled().await;
});
if let Err(err) = server.await {
tracing::debug!(%err, "OAuth callback server stopped");
}
}
async fn handle_callback(
State(state): State<OAuthCallbackState>,
Query(query): Query<CallbackQuery>,
) -> Html<&'static str> {
if let (Some(code), Some(st)) = (query.code, query.state) {
state.codes.write().insert(st, code);
}
Html(r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Antigravity Authorization Successful</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #18181b; color: #f4f4f5; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: #27272a; border: 1px solid #3f3f46; padding: 36px; border-radius: 16px; text-align: center; box-shadow: 0 10px 30px rgba(0,0,0,0.5); max-width: 440px; }
.icon { font-size: 40px; color: #4285f4; margin-bottom: 16px; }
h1 { color: #ffffff; font-size: 20px; margin: 0 0 12px 0; }
p { color: #a1a1aa; font-size: 14px; line-height: 1.6; margin: 0; }
</style>
</head>
<body>
<div class="card">
<div class="icon">✓</div>
<h1>Authorization Successful</h1>
<p>Your Google Antigravity account has been authorized. You can close this browser tab and return to <strong>Cursor BYOK</strong>.</p>
</div>
</body>
</html>"#)
}
async fn handle_status(
State(state): State<OAuthCallbackState>,
Query(query): Query<StatusQuery>,
) -> Json<StatusResponse> {
let code = query.state.and_then(|st| state.codes.read().get(&st).cloned());
Json(StatusResponse { code })
}
+83 -18
View File
@@ -40,6 +40,7 @@ struct RegistryInner {
entries: RwLock<Option<Vec<PluginEntry>>>,
workers: Mutex<HashMap<String, Arc<PluginWorker>>>,
oauth_sessions: Mutex<HashMap<String, OAuthSession>>,
rr_counter: std::sync::atomic::AtomicUsize,
}
struct OAuthSession {
@@ -108,6 +109,7 @@ impl PluginRegistry {
entries: RwLock::new(None),
workers: Mutex::new(HashMap::new()),
oauth_sessions: Mutex::new(HashMap::new()),
rr_counter: std::sync::atomic::AtomicUsize::new(0),
}),
})
}
@@ -142,6 +144,12 @@ impl PluginRegistry {
let Some(executable) = self.inner.runtime.executable() else {
return Vec::new();
};
let disabled_models = self
.inner
.store
.disabled_plugin_models()
.await
.unwrap_or_default();
let mut models = Vec::new();
for entry in self.entries(&executable).await {
for provider in &entry.definition.providers {
@@ -154,14 +162,19 @@ impl PluginRegistry {
.models(&entry.manifest.id, &provider.id)
.await
.unwrap_or_default();
models.extend(stored.iter().map(|model| {
PluginModelDescriptor::new(
models.extend(stored.iter().filter_map(|model| {
let descriptor = PluginModelDescriptor::new(
&entry.manifest.id,
&entry.manifest.name,
&entry.icon,
provider,
model,
)
);
if disabled_models.contains(&descriptor.id) {
None
} else {
Some(descriptor)
}
}));
}
}
@@ -192,6 +205,15 @@ impl PluginRegistry {
}
pub async fn plan_model(&self, model_id: &str) -> Result<PluginInvocationPlan> {
let disabled_models = self
.inner
.store
.disabled_plugin_models()
.await
.unwrap_or_default();
if disabled_models.contains(model_id) {
return Err(Error::Provider(format!("plugin model '{model_id}' is disabled")));
}
let model = self.model_descriptor(model_id).await?;
let request_url = format!("plugin://{}/{}", model.plugin_id, model.provider_id);
Ok(PluginInvocationPlan { model, request_url })
@@ -704,13 +726,21 @@ impl PluginRegistry {
}
}
match &provider.resource_type {
Some(resource_type) => !self
.inner
.state
.resources(plugin_id, resource_type)
.await
.unwrap_or_default()
.is_empty(),
Some(resource_type) => {
let disabled_accounts = self
.inner
.store
.disabled_plugin_accounts()
.await
.unwrap_or_default();
let resources = self
.inner
.state
.resources(plugin_id, resource_type)
.await
.unwrap_or_default();
resources.iter().any(|r| !disabled_accounts.contains(&r.id))
}
None => true,
}
}
@@ -796,19 +826,54 @@ impl PluginRegistry {
plugin_id: &str,
resource_type: &str,
) -> Result<ResourceRecord> {
let disabled_accounts = self
.inner
.store
.disabled_plugin_accounts()
.await
.unwrap_or_default();
let records = self.inner.state.resources(plugin_id, resource_type).await?;
if records.is_empty() {
let active_records: Vec<_> = records
.into_iter()
.filter(|record| !disabled_accounts.contains(&record.id))
.collect();
if active_records.is_empty() {
return Err(Error::Provider(format!(
"plugin '{plugin_id}' has no '{resource_type}' resource; add one first"
"plugin '{plugin_id}' has no enabled '{resource_type}' resource; enable or add one first"
)));
}
let now = now_ms();
records
.iter()
.find(|record| record.state.is_ready(now))
.or_else(|| records.first())
.cloned()
.ok_or_else(|| Error::Provider("no plugin resource is available".into()))
let mut ready_records: Vec<_> = active_records
.into_iter()
.filter(|record| record.state.is_ready(now))
.collect();
if ready_records.is_empty() {
return Err(Error::Provider(format!(
"all enabled accounts for plugin '{plugin_id}' are currently cooling or rate-limited"
)));
}
let get_priority = |r: &ResourceRecord| -> u8 {
let label = r.private_data.get("quota").and_then(|q| q.get("planLabel")).and_then(|l| l.as_str()).unwrap_or("");
let lower = label.to_lowercase();
if label.contains("🔥") || lower.contains("pro") || lower.contains("ultra") || lower.contains("premium") || lower.contains("advanced") {
0
} else {
1
}
};
ready_records.sort_by_key(|r| get_priority(r));
if let Some(best_prio) = ready_records.first().map(|r| get_priority(r)) {
ready_records.retain(|r| get_priority(r) == best_prio);
}
let index = self
.inner
.rr_counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
% ready_records.len();
Ok(ready_records[index].clone())
}
async fn find_record(
@@ -102,7 +102,6 @@ export function buildChatBody(call: OpenAiChatCall): Record<string, JsonValue> {
body.reasoning_effort = call.request.reasoning.effort;
}
if (call.request.latency === "fast") body.service_tier = "fast";
if (call.request.cacheKey !== null) body.prompt_cache_key = call.request.cacheKey;
return { ...body, ...call.extraBody };
}
+9 -2
View File
@@ -425,9 +425,16 @@ impl HostContext {
let raw_url = required_string(params, "url")?;
let url = url::Url::parse(raw_url)
.map_err(|error| Error::Config(format!("invalid plugin network URL: {error}")))?;
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
let is_loopback = url
.host_str()
.map(|h| h == "127.0.0.1" || h == "localhost")
.unwrap_or(false);
if (url.scheme() != "https" && (!is_loopback || url.scheme() != "http"))
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err(Error::Config(
"plugin network URL must be HTTPS without credentials".into(),
"plugin network URL must be HTTPS without credentials (or loopback HTTP)".into(),
));
}
let host = url
+56
View File
@@ -1,4 +1,6 @@
//! Persists application settings.
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::Result;
@@ -10,6 +12,8 @@ const PROXY_SETTINGS_KEY: &str = "outbound_proxy";
const TAB_SETTINGS_KEY: &str = "cursor_tab";
const INSTALLATION_ID_KEY: &str = "installation_id";
const DESKTOP_SETTINGS_KEY: &str = "desktop_lifecycle";
const DISABLED_PLUGIN_MODELS_KEY: &str = "disabled_plugin_models";
const DISABLED_PLUGIN_ACCOUNTS_KEY: &str = "disabled_plugin_accounts";
pub const PUBLIC_TAB_SERVICE_URL: &str = "https://tab.leokun.cn";
@@ -299,4 +303,56 @@ impl Store {
.await?;
Ok(())
}
pub async fn disabled_plugin_models(&self) -> Result<HashSet<String>> {
let value = sqlx::query_scalar::<_, String>(
"SELECT value_json FROM service_settings WHERE setting_key = ?",
)
.bind(DISABLED_PLUGIN_MODELS_KEY)
.fetch_optional(&self.pool)
.await?;
value
.map(|value| serde_json::from_str(&value).map_err(Into::into))
.unwrap_or_else(|| Ok(HashSet::new()))
}
pub async fn set_disabled_plugin_models(&self, model_ids: &HashSet<String>) -> Result<()> {
let value_json = serde_json::to_string(model_ids)?;
let _write = self.writes.lock().await;
sqlx::query(
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
)
.bind(DISABLED_PLUGIN_MODELS_KEY)
.bind(value_json)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn disabled_plugin_accounts(&self) -> Result<HashSet<String>> {
let value = sqlx::query_scalar::<_, String>(
"SELECT value_json FROM service_settings WHERE setting_key = ?",
)
.bind(DISABLED_PLUGIN_ACCOUNTS_KEY)
.fetch_optional(&self.pool)
.await?;
value
.map(|value| serde_json::from_str(&value).map_err(Into::into))
.unwrap_or_else(|| Ok(HashSet::new()))
}
pub async fn set_disabled_plugin_accounts(&self, account_ids: &HashSet<String>) -> Result<()> {
let value_json = serde_json::to_string(account_ids)?;
let _write = self.writes.lock().await;
sqlx::query(
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
)
.bind(DISABLED_PLUGIN_ACCOUNTS_KEY)
.bind(value_json)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
}