mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-06 05:12:03 +08:00
Merge main and host authorization-code OAuth in core
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
/** Official Google Antigravity OAuth client configuration. */
|
||||
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 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",
|
||||
];
|
||||
|
||||
export const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
@@ -1,12 +1,7 @@
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { antigravityDeviceOAuth } from "./oauth.ts";
|
||||
import { antigravityAuthorizationCodeOAuth } from "./oauth.ts";
|
||||
import { antigravityProvider } from "./provider.ts";
|
||||
import {
|
||||
credentialImport,
|
||||
presentAccount,
|
||||
refreshAccount,
|
||||
RESOURCE_TYPE,
|
||||
} from "./resources.ts";
|
||||
import { credentialImport, presentAccount, refreshAccount, RESOURCE_TYPE } from "./resources.ts";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [antigravityProvider],
|
||||
@@ -16,7 +11,7 @@ export default defineProviderPlugin({
|
||||
"en-US": "Google accounts & API keys",
|
||||
"zh-CN": "Google 账号与 API 密钥",
|
||||
},
|
||||
add: [antigravityDeviceOAuth],
|
||||
add: [antigravityAuthorizationCodeOAuth],
|
||||
import: credentialImport,
|
||||
present: presentAccount,
|
||||
refresh: refreshAccount,
|
||||
|
||||
@@ -277,7 +277,9 @@ export function parseAntigravityModels(payload: unknown): ModelDefinition[] {
|
||||
models.push({
|
||||
id,
|
||||
displayName,
|
||||
capabilities: { images: model.supportsImages === true || id.includes("gemini") || id.includes("claude") },
|
||||
capabilities: {
|
||||
images: model.supportsImages === true || id.includes("gemini") || id.includes("claude"),
|
||||
},
|
||||
maxOutputTokens,
|
||||
privateData: { reasoningEfforts },
|
||||
});
|
||||
@@ -318,16 +320,19 @@ export const antigravityModels: ModelSupport = {
|
||||
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,
|
||||
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,
|
||||
},
|
||||
body: bodyPayload,
|
||||
});
|
||||
);
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const body = JSON.parse(response.body);
|
||||
const models = parseAntigravityModels(body);
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
|
||||
import type {
|
||||
OAuth2AuthorizationCodeAddMethod,
|
||||
OAuth2AuthorizationCodeBegin,
|
||||
ResourceDraft,
|
||||
} 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("-");
|
||||
import {
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
GOOGLE_AUTHORIZATION_URL,
|
||||
GOOGLE_TOKEN_URL,
|
||||
SCOPES,
|
||||
} from "./google_oauth.ts";
|
||||
|
||||
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 AUTHORIZATION_LIFETIME_MS = 5 * 60 * 1000;
|
||||
|
||||
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;
|
||||
};
|
||||
type Session = { createdAtMs: number };
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
@@ -54,146 +40,118 @@ function parseBody(body: string): Record<string, unknown> {
|
||||
|
||||
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 };
|
||||
const createdAtMs = session?.createdAtMs;
|
||||
if (typeof createdAtMs !== "number") throw new Error("Antigravity OAuth session is invalid");
|
||||
if (Date.now() - createdAtMs > AUTHORIZATION_LIFETIME_MS) {
|
||||
throw new Error("Google authorization expired. Please try again.");
|
||||
}
|
||||
return { createdAtMs };
|
||||
}
|
||||
|
||||
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();
|
||||
async function begin(
|
||||
input: { redirectUri: string; state: string; codeChallenge: string },
|
||||
_context: PluginContext,
|
||||
): Promise<OAuth2AuthorizationCodeBegin> {
|
||||
const authParams = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
redirect_uri: input.redirectUri,
|
||||
scope: SCOPES.join(" "),
|
||||
state,
|
||||
state: input.state,
|
||||
code_challenge: input.codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
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,
|
||||
session: { createdAtMs: Date.now() },
|
||||
authorizationUrl: `${GOOGLE_AUTHORIZATION_URL}?${authParams.toString()}`,
|
||||
expiresAtMs: Date.now() + AUTHORIZATION_LIFETIME_MS,
|
||||
};
|
||||
}
|
||||
|
||||
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." };
|
||||
async function complete(
|
||||
sessionValue: JsonValue,
|
||||
input: { code: string; redirectUri: string; codeVerifier: string },
|
||||
context: PluginContext,
|
||||
): Promise<ResourceDraft[]> {
|
||||
parseSession(sessionValue);
|
||||
const tokenResponse = await context.network.fetch(GOOGLE_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: input.code,
|
||||
code_verifier: input.codeVerifier,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: input.redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
const tokenBody = parseBody(tokenResponse.body);
|
||||
if (tokenResponse.status < 200 || tokenResponse.status >= 300) {
|
||||
const detail = text(tokenBody.error_description) ?? text(tokenBody.error) ??
|
||||
`HTTP ${tokenResponse.status}`;
|
||||
throw new Error(`Google token exchange failed: ${detail}`);
|
||||
}
|
||||
|
||||
// Attempt to check if local callback server on 51121 received the auth code
|
||||
const accessToken = text(tokenBody.access_token);
|
||||
if (!accessToken) throw new Error("Google token response did not include an access token");
|
||||
|
||||
let displayName = text(tokenBody.email);
|
||||
try {
|
||||
const callbackCheck = await context.network.fetch(
|
||||
`http://127.0.0.1:${CALLBACK_PORT}/auth-status?state=${session.state}`,
|
||||
{ method: "GET" },
|
||||
const userInfoResponse = await context.network.fetch(
|
||||
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
|
||||
},
|
||||
);
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
if (userInfoResponse.status >= 200 && userInfoResponse.status < 300) {
|
||||
displayName = text(parseBody(userInfoResponse.body).email) ?? displayName;
|
||||
}
|
||||
} catch {
|
||||
// Network retry on pending callback
|
||||
// Account identity has a token fingerprint fallback.
|
||||
}
|
||||
|
||||
return { status: "pending" };
|
||||
let projectId = "bamboo-precept-lgxtn";
|
||||
let quota = null;
|
||||
try {
|
||||
const result = await queryAccountQuota(accessToken, context.network);
|
||||
projectId = result.projectId;
|
||||
quota = result.quota;
|
||||
} catch {
|
||||
// Quota can be refreshed after the account has been persisted.
|
||||
}
|
||||
|
||||
const expiresIn = typeof tokenBody.expires_in === "number" ? tokenBody.expires_in : null;
|
||||
return [
|
||||
await credentialDraft({
|
||||
accessToken,
|
||||
refreshToken: text(tokenBody.refresh_token),
|
||||
displayName: displayName ?? "Google Antigravity",
|
||||
projectId,
|
||||
expiresAtMs: expiresIn === null ? null : Date.now() + expiresIn * 1000,
|
||||
quota,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export const antigravityDeviceOAuth: OAuth2AddMethod = {
|
||||
type: "oauth2.0",
|
||||
export const antigravityAuthorizationCodeOAuth: OAuth2AuthorizationCodeAddMethod = {
|
||||
type: "oauth2.authorization-code",
|
||||
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 模型。",
|
||||
"en-US": "Authorize Antigravity with your Google Account for Gemini and Claude models.",
|
||||
"zh-CN": "使用 Google 账号完成 Antigravity 授权,以使用 Gemini 与 Claude 模型。",
|
||||
},
|
||||
callback: { port: CALLBACK_PORT, path: CALLBACK_PATH },
|
||||
begin,
|
||||
poll,
|
||||
complete,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PluginContext } from "cursor-byok:plugin";
|
||||
import { antigravityAuthorizationCodeOAuth } from "./oauth.ts";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function context(requests: Array<{ url: string; body?: string }>): PluginContext {
|
||||
return {
|
||||
network: {
|
||||
fetch: async (url, init = {}) => {
|
||||
requests.push({ url, body: init.body });
|
||||
if (url === "https://oauth2.googleapis.com/token") {
|
||||
return {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify({
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.startsWith("https://www.googleapis.com/oauth2/v1/userinfo")) {
|
||||
return { status: 200, headers: {}, body: JSON.stringify({ email: "user@example.com" }) };
|
||||
}
|
||||
return { status: 500, headers: {}, body: "{}" };
|
||||
},
|
||||
stream: () => Promise.reject(new Error("stream is not expected")),
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("authorization URL uses Core-owned state, callback, and PKCE challenge", async () => {
|
||||
const result = await antigravityAuthorizationCodeOAuth.begin(
|
||||
{
|
||||
redirectUri: "http://127.0.0.1:51121/oauth-callback",
|
||||
state: "core-state",
|
||||
codeChallenge: "core-challenge",
|
||||
},
|
||||
context([]),
|
||||
);
|
||||
const url = new URL(result.authorizationUrl);
|
||||
assert(url.searchParams.get("state") === "core-state", "state must come from Core");
|
||||
assert(
|
||||
url.searchParams.get("redirect_uri")?.endsWith("/oauth-callback"),
|
||||
"callback must be forwarded",
|
||||
);
|
||||
assert(
|
||||
url.searchParams.get("code_challenge") === "core-challenge",
|
||||
"PKCE challenge must be forwarded",
|
||||
);
|
||||
assert(url.searchParams.get("code_challenge_method") === "S256", "PKCE must use S256");
|
||||
});
|
||||
|
||||
Deno.test("authorization completion exchanges the code with the Core PKCE verifier", async () => {
|
||||
const requests: Array<{ url: string; body?: string }> = [];
|
||||
const resources = await antigravityAuthorizationCodeOAuth.complete(
|
||||
{ createdAtMs: Date.now() },
|
||||
{
|
||||
code: "authorization-code",
|
||||
redirectUri: "http://127.0.0.1:51121/oauth-callback",
|
||||
codeVerifier: "core-verifier",
|
||||
},
|
||||
context(requests),
|
||||
);
|
||||
const tokenRequest = requests.find((request) =>
|
||||
request.url === "https://oauth2.googleapis.com/token"
|
||||
);
|
||||
const body = new URLSearchParams(tokenRequest?.body);
|
||||
assert(body.get("code") === "authorization-code", "authorization code must be exchanged");
|
||||
assert(body.get("code_verifier") === "core-verifier", "PKCE verifier must come from Core");
|
||||
assert(resources.length === 1, "one Google account resource must be returned");
|
||||
assert(
|
||||
resources[0].key === "antigravity:user@example.com",
|
||||
"the account email must be the resource key",
|
||||
);
|
||||
});
|
||||
@@ -2,23 +2,18 @@
|
||||
"apiVersion": 1,
|
||||
"id": "dev.cursorbyok.plugins.antigravity-auth",
|
||||
"name": "Antigravity",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.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"
|
||||
"www.googleapis.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,16 +88,23 @@ function resolveAntigravityModel(modelId: string): string {
|
||||
}
|
||||
|
||||
// 2. Canonical Antigravity-Manager mapping for aliases
|
||||
if (lower === "claude-3-7-sonnet" || lower === "claude-3-5-sonnet" || lower === "claude-sonnet-4-5") {
|
||||
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") {
|
||||
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") {
|
||||
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") {
|
||||
@@ -195,7 +202,7 @@ function convertToCloudCodeContents(
|
||||
messages: LlmMessage[],
|
||||
): {
|
||||
contents: Array<{ role: string; parts: Array<Record<string, unknown>> }>;
|
||||
systemInstruction?: { parts: Array<{ text: string }> };
|
||||
systemInstruction?: { role: string; parts: Array<{ text: string }> };
|
||||
} {
|
||||
const rawContents: Array<{ role: string; parts: Array<Record<string, unknown>> }> = [];
|
||||
let systemText = instructions || "";
|
||||
@@ -221,13 +228,17 @@ function convertToCloudCodeContents(
|
||||
const replayVal = msg.replayState?.providerKind === "antigravity"
|
||||
? (msg.replayState.value as Record<string, unknown> | null)
|
||||
: null;
|
||||
const sig = typeof replayVal?.thoughtSignature === "string" ? replayVal.thoughtSignature : 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 : {},
|
||||
args: typeof call.arguments === "object" && call.arguments !== null
|
||||
? call.arguments
|
||||
: {},
|
||||
},
|
||||
thoughtSignature: sig || "skip_thought_signature_validator",
|
||||
});
|
||||
@@ -287,7 +298,9 @@ function convertToCloudCodeContents(
|
||||
|
||||
return {
|
||||
contents,
|
||||
...(systemText.trim() ? { systemInstruction: { role: "system", parts: [{ text: systemText.trim() }] } } : {}),
|
||||
...(systemText.trim()
|
||||
? { systemInstruction: { role: "system", parts: [{ text: systemText.trim() }] } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,20 +320,20 @@ async function streamCloudCode(
|
||||
|
||||
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),
|
||||
})),
|
||||
},
|
||||
]
|
||||
{
|
||||
functionDeclarations: input.request.tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description || "",
|
||||
parameters: sanitizeSchema(t.parameters),
|
||||
})),
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
|
||||
const toolConfig = tools
|
||||
? {
|
||||
functionCallingConfig: { mode: "AUTO" },
|
||||
}
|
||||
functionCallingConfig: { mode: "AUTO" },
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const payload = {
|
||||
@@ -348,7 +361,8 @@ async function streamCloudCode(
|
||||
...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";
|
||||
headers["anthropic-beta"] =
|
||||
"claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14";
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
@@ -370,7 +384,10 @@ async function streamCloudCode(
|
||||
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) {
|
||||
if (
|
||||
response.status === 503 || response.status === 502 || response.status === 504 ||
|
||||
response.status === 404
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw lastError;
|
||||
@@ -382,7 +399,11 @@ async function streamCloudCode(
|
||||
let hasTools = false;
|
||||
let toolIndex = 0;
|
||||
let lastThoughtSignature: string | null = null;
|
||||
let finalUsage: { inputTokens: number | null; outputTokens: number | null; totalTokens: number | null } | 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;
|
||||
@@ -403,7 +424,9 @@ async function streamCloudCode(
|
||||
if (usage) {
|
||||
finalUsage = {
|
||||
inputTokens: typeof usage.promptTokenCount === "number" ? usage.promptTokenCount : null,
|
||||
outputTokens: typeof usage.candidatesTokenCount === "number" ? usage.candidatesTokenCount : null,
|
||||
outputTokens: typeof usage.candidatesTokenCount === "number"
|
||||
? usage.candidatesTokenCount
|
||||
: null,
|
||||
totalTokens: typeof usage.totalTokenCount === "number" ? usage.totalTokenCount : null,
|
||||
};
|
||||
}
|
||||
@@ -485,7 +508,9 @@ async function streamCloudCode(
|
||||
}
|
||||
}
|
||||
|
||||
const finishReason = typeof candidate?.finishReason === "string" ? candidate.finishReason : null;
|
||||
const finishReason = typeof candidate?.finishReason === "string"
|
||||
? candidate.finishReason
|
||||
: null;
|
||||
if (finishReason) {
|
||||
if (thinkingStarted) {
|
||||
thinkingStarted = false;
|
||||
@@ -517,7 +542,8 @@ async function streamCloudCode(
|
||||
});
|
||||
finalUsage = null;
|
||||
}
|
||||
const isTool = finishReason === "STOP" && (hasTools || parts?.some((p) => p.functionCall));
|
||||
const isTool = finishReason === "STOP" &&
|
||||
(hasTools || parts?.some((p) => p.functionCall));
|
||||
output.emit({
|
||||
type: "done",
|
||||
reason: isTool ? "tool-use" : "stop",
|
||||
@@ -610,17 +636,30 @@ async function invoke(
|
||||
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",
|
||||
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)) {
|
||||
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);
|
||||
await streamCloudCode(
|
||||
freshData.accessToken,
|
||||
freshProj,
|
||||
input.model.id,
|
||||
input,
|
||||
output,
|
||||
context,
|
||||
);
|
||||
return {
|
||||
status: "completed",
|
||||
patch: { privateData: freshData as unknown as JsonValue, state: { status: "ready" } },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||
import type { JsonValue, NetworkRequestInit, PluginContext } from "cursor-byok:plugin";
|
||||
import type {
|
||||
ResourceDraft,
|
||||
ResourceImportFile,
|
||||
@@ -15,11 +15,25 @@ import {
|
||||
ANTIGRAVITY_ENDPOINTS,
|
||||
ANTIGRAVITY_USER_AGENT,
|
||||
} from "./models.ts";
|
||||
import { CLIENT_ID, CLIENT_SECRET } from "./google_oauth.ts";
|
||||
|
||||
export const RESOURCE_TYPE = "antigravity-account";
|
||||
|
||||
const REFRESH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
|
||||
async function fetchText(
|
||||
network: PluginContext["network"] | undefined,
|
||||
url: string,
|
||||
init: NetworkRequestInit,
|
||||
): Promise<{ status: number; body: string }> {
|
||||
if (network) {
|
||||
const response = await network.fetch(url, init);
|
||||
return { status: response.status, body: response.body };
|
||||
}
|
||||
const response = await fetch(url, init);
|
||||
return { status: response.status, body: await response.text() };
|
||||
}
|
||||
|
||||
export type QuotaMetric = {
|
||||
remainingPercent: number;
|
||||
resetAtMs: number | null;
|
||||
@@ -73,12 +87,15 @@ export async function fetchAccountProjectAndTier(
|
||||
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);
|
||||
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";
|
||||
else if (
|
||||
lower.includes("pro") || lower.includes("premium") || lower.includes("advanced")
|
||||
) planLabel = "PRO";
|
||||
}
|
||||
return { projectId: project ?? "bamboo-precept-lgxtn", planLabel };
|
||||
}
|
||||
@@ -121,7 +138,9 @@ export async function queryAccountQuota(
|
||||
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 fraction = typeof quota?.remainingFraction === "number"
|
||||
? quota.remainingFraction
|
||||
: null;
|
||||
const resetTime = text(quota?.resetTime);
|
||||
const resetAtMs = resetTime ? Date.parse(resetTime) : null;
|
||||
if (fraction === null) continue;
|
||||
@@ -147,8 +166,12 @@ export async function queryAccountQuota(
|
||||
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,
|
||||
claude: claudeFraction !== null
|
||||
? { remainingPercent: Math.round(claudeFraction * 100), resetAtMs: claudeResetAtMs }
|
||||
: null,
|
||||
gemini: geminiFraction !== null
|
||||
? { remainingPercent: Math.round(geminiFraction * 100), resetAtMs: geminiResetAtMs }
|
||||
: null,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
@@ -235,7 +258,8 @@ export async function accountIdentity(
|
||||
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);
|
||||
const displayName = providedDisplayName ?? email ?? name ??
|
||||
(token.startsWith("AIza") ? `API Key (${fingerprint.slice(0, 6)})` : identity);
|
||||
return { key: `antigravity:${identity}`, displayName };
|
||||
}
|
||||
|
||||
@@ -246,7 +270,8 @@ export async function credentialDraft(credential: CredentialCandidate): Promise<
|
||||
refreshToken: credential.refreshToken,
|
||||
displayName: credential.displayName ?? identity.displayName,
|
||||
projectId: credential.projectId ?? "bamboo-precept-lgxtn",
|
||||
expiresAtMs: credential.expiresAtMs ?? (credential.refreshToken ? Date.now() + 3500 * 1000 : null),
|
||||
expiresAtMs: credential.expiresAtMs ??
|
||||
(credential.refreshToken ? Date.now() + 3500 * 1000 : null),
|
||||
quota: credential.quota ?? null,
|
||||
};
|
||||
return { key: identity.key, privateData: data as unknown as JsonValue };
|
||||
@@ -346,8 +371,6 @@ export function presentAccount(resource: ResourceSnapshot): ResourceView {
|
||||
};
|
||||
}
|
||||
|
||||
import { CLIENT_ID, CLIENT_SECRET } from "./oauth.ts";
|
||||
|
||||
export async function refreshAccount(
|
||||
resource: ResourceSnapshot,
|
||||
context: PluginContext,
|
||||
@@ -378,7 +401,10 @@ export async function refreshAccount(
|
||||
// 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" },
|
||||
state: {
|
||||
status: "invalid",
|
||||
message: "Google authorization expired or revoked; please sign in again",
|
||||
},
|
||||
};
|
||||
}
|
||||
// On network glitches or temporary Google server errors, keep ready
|
||||
@@ -461,18 +487,18 @@ function collectCredentials(value: unknown, output: CredentialCandidate[]): void
|
||||
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"]);
|
||||
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;
|
||||
}
|
||||
if (!accessToken) return;
|
||||
output.push({ accessToken, refreshToken, displayName, projectId });
|
||||
}
|
||||
|
||||
import { REDIRECT_URI } from "./oauth.ts";
|
||||
|
||||
export async function parseCredentialFiles(
|
||||
files: ResourceImportFile[],
|
||||
network?: PluginContext["network"],
|
||||
@@ -486,41 +512,6 @@ export async function parseCredentialFiles(
|
||||
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 });
|
||||
@@ -532,9 +523,15 @@ export async function parseCredentialFiles(
|
||||
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);
|
||||
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 });
|
||||
credentials.push({
|
||||
accessToken: envMatch[1].trim(),
|
||||
refreshToken: null,
|
||||
displayName: file.name,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const keyMatch = raw.match(/AIza[0-9A-Za-z-_]{35}/);
|
||||
@@ -560,11 +557,7 @@ export async function parseCredentialFiles(
|
||||
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, {
|
||||
const response = await fetchText(network, REFRESH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
@@ -581,7 +574,8 @@ export async function parseCredentialFiles(
|
||||
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);
|
||||
candidate.expiresAtMs = Date.now() +
|
||||
((typeof body?.expires_in === "number" ? body.expires_in : 3600) * 1000);
|
||||
}
|
||||
} catch {
|
||||
// Keep placeholder
|
||||
@@ -599,15 +593,22 @@ export const credentialImport: ResourceImportSupport = {
|
||||
"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 文件。",
|
||||
"en-US":
|
||||
"Import a JSON, TXT, or environment file containing Antigravity tokens or Google API keys.",
|
||||
"zh-CN": "导入包含 Antigravity Token 或 Google API Key 的 JSON、TXT 或环境变量文件。",
|
||||
},
|
||||
accept: [".json", ".txt", ".key", ".env"],
|
||||
multiple: true,
|
||||
parse: async (files: ResourceImportFile[], context: PluginContext): Promise<ResourceImportResult> => {
|
||||
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");
|
||||
throw new Error(
|
||||
warnings.join("; ") ||
|
||||
"credential file does not contain a valid token or API key",
|
||||
);
|
||||
}
|
||||
const drafts = await Promise.all(
|
||||
credentials.map(async (c) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { LlmRequest, ModelEvent } from "cursor-byok:provider";
|
||||
import type { ResourceSnapshot } from "cursor-byok:resource";
|
||||
import { codexDeviceOAuth } from "./oauth.ts";
|
||||
import { parseOfficialModels } from "./models.ts";
|
||||
import { buildResponsesBody } from "cursor-byok:protocol/openai-responses";
|
||||
import { codexProvider, isQuotaError } from "./provider.ts";
|
||||
import {
|
||||
accountIdentity,
|
||||
@@ -305,6 +306,43 @@ Deno.test("invoke streams normalized events from the Codex Responses API", async
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("reasoning replay projects response items to valid input items", () => {
|
||||
const replayRequest = request();
|
||||
replayRequest.messages = [{
|
||||
role: "assistant",
|
||||
text: "",
|
||||
thinking: "",
|
||||
replayState: {
|
||||
providerKind: "openai_responses",
|
||||
value: {
|
||||
items: [{
|
||||
type: "reasoning",
|
||||
id: "item-1",
|
||||
status: "completed",
|
||||
summary: [{ type: "summary_text", text: "why" }],
|
||||
content: [],
|
||||
encrypted_content: "opaque",
|
||||
output_only: true,
|
||||
}],
|
||||
},
|
||||
},
|
||||
toolCalls: [],
|
||||
}];
|
||||
|
||||
const body = buildResponsesBody({
|
||||
url: "https://example.com/responses",
|
||||
model: "gpt-test",
|
||||
request: replayRequest,
|
||||
});
|
||||
assertEquals(body.input, [{
|
||||
type: "reasoning",
|
||||
id: "item-1",
|
||||
summary: [{ type: "summary_text", text: "why" }],
|
||||
content: [],
|
||||
encrypted_content: "opaque",
|
||||
}]);
|
||||
});
|
||||
|
||||
Deno.test("invoke streams incremental tool calls and replays reasoning items", async () => {
|
||||
const token = jwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } });
|
||||
const draft = await credentialDraft({
|
||||
|
||||
@@ -277,7 +277,9 @@ 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>;
|
||||
assert(!("prompt_cache_key" in body), "standard OpenAI chat completion does not include prompt_cache_key");
|
||||
assertEquals(body.model, "grok-4");
|
||||
assertEquals(body.stream, true);
|
||||
assertEquals(body.prompt_cache_key, "conversation-1");
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user