mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(api,oauth): add agy (Antigravity CLI) standalone provider with CLI token import (#2899)
Add a standalone OAuth provider `agy` (Antigravity CLI) next to gemini-cli/antigravity.
It reuses the antigravity inference backend (identical Google client_id +
daily-cloudcode-pa.googleapis.com endpoint, executor and token-refresh) but ships its own
model catalog — including the Claude models the backend exposes (claude-opus-4-6-thinking,
claude-sonnet-4-6) — its own account pool, and four ways to connect:
- token-file import (paste/upload the agy oauth token JSON)
- auto-detect a local CLI login (~/.gemini/antigravity-cli/antigravity-oauth-token)
- browser OAuth (via the shared OAuthModal Google loopback flow)
- bulk / ZIP import
New routes: POST /api/providers/agy-auth/{import,import-bulk,zip-extract,apply-local}.
Catalog pinned from the live :fetchAvailableModels endpoint. Docs (openapi.yaml,
ENVIRONMENT.md, .env.example, CHANGELOG) updated; new unit tests for registration,
the token parser, and route auth-hardening.
This commit is contained in:
committed by
GitHub
parent
d78088fd84
commit
067e0496cf
@@ -532,6 +532,12 @@ PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
|
||||
# Default: empty (use upstream-reported credits).
|
||||
#ANTIGRAVITY_CREDITS=
|
||||
|
||||
# Override the path to the Antigravity CLI (agy) token file read by the
|
||||
# "auto-detect local login" import. Used by:
|
||||
# src/app/api/providers/agy-auth/apply-local/route.ts — for non-standard installs.
|
||||
# Default: ~/.gemini/antigravity-cli/antigravity-oauth-token
|
||||
#AGY_TOKEN_FILE=
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 11. OAUTH PROVIDER CREDENTIALS
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **agy:** add the **Antigravity CLI (`agy`)** as a standalone OAuth provider next to `gemini-cli`/`antigravity`. It reuses the antigravity inference backend (identical Google client, `daily-cloudcode-pa.googleapis.com`) but ships its own model catalog — notably the Claude models the backend exposes (`claude-opus-4-6-thinking`, `claude-sonnet-4-6`) — its own account pool, and connection methods: import the `agy` CLI token file (paste/upload), auto-detect a local CLI login (`~/.gemini/antigravity-cli/antigravity-oauth-token`), browser OAuth, and bulk/ZIP import. New routes: `POST /api/providers/agy-auth/{import,import-bulk,zip-extract,apply-local}`.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **proxy-logs:** `GET /api/usage/proxy-logs` now returns `clientIp` instead of `publicIp` for each log entry. External consumers reading `log.publicIp` must update to `log.clientIp`. The underlying SQLite column (`public_ip`) is unchanged, so callers that query the database directly are unaffected (#2880 — thanks @rdself).
|
||||
|
||||
@@ -358,6 +358,7 @@ detection above).
|
||||
| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. |
|
||||
| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. |
|
||||
| `ANTIGRAVITY_CREDITS` | _(unset)_ | `open-sse/services/antigravityCredits.ts` | Override Antigravity's advertised remaining credits (testing / forced values). |
|
||||
| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | Override the Antigravity CLI (agy) token-file path for the auto-detect local login import. |
|
||||
|
||||
### OAuth CLI Bridge (Internal)
|
||||
|
||||
|
||||
@@ -607,6 +607,40 @@ paths:
|
||||
"200":
|
||||
description: Provider info for frontend
|
||||
|
||||
/api/providers/agy-auth/import:
|
||||
post:
|
||||
tags: [Providers]
|
||||
summary: Import an Antigravity CLI (agy) token file as an `agy` connection
|
||||
responses:
|
||||
"200":
|
||||
description: Created or updated provider connection
|
||||
|
||||
/api/providers/agy-auth/import-bulk:
|
||||
post:
|
||||
tags: [Providers]
|
||||
summary: Bulk-import multiple Antigravity CLI (agy) token files (up to 50)
|
||||
responses:
|
||||
"200":
|
||||
description: Per-entry import results (success/failed counts)
|
||||
|
||||
/api/providers/agy-auth/zip-extract:
|
||||
post:
|
||||
tags: [Providers]
|
||||
summary: Extract `.json` token files from an uploaded ZIP for agy bulk import
|
||||
responses:
|
||||
"200":
|
||||
description: Extracted token-file entries
|
||||
|
||||
/api/providers/agy-auth/apply-local:
|
||||
post:
|
||||
tags: [Providers]
|
||||
summary: Auto-detect and import the local Antigravity CLI (agy) login from disk
|
||||
responses:
|
||||
"200":
|
||||
description: Created or updated provider connection
|
||||
"404":
|
||||
description: No local agy login found
|
||||
|
||||
/api/provider-nodes:
|
||||
get:
|
||||
tags: [Provider Nodes]
|
||||
|
||||
165
open-sse/config/agyModels.ts
Normal file
165
open-sse/config/agyModels.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// Antigravity CLI (`agy`) model catalog.
|
||||
//
|
||||
// These IDs were pinned directly from the live `:fetchAvailableModels` endpoint
|
||||
// (https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels) using a
|
||||
// real `agy` consumer-OAuth token on 2026-05-29. They are the exact upstream model IDs
|
||||
// the Antigravity backend accepts — no alias remapping is required (unlike the
|
||||
// `antigravity` provider, whose catalog used display IDs that needed remapping).
|
||||
//
|
||||
// The `agy` provider reuses the `antigravity` executor/translator (identical backend),
|
||||
// but ships its OWN catalog so it can expose models the `antigravity` provider's static
|
||||
// list omits — notably the Claude models (`claude-opus-4-6-thinking`, `claude-sonnet-4-6`),
|
||||
// which `:fetchAvailableModels` reports as user-callable with quota even though the
|
||||
// `antigravity` catalog comment assumes they 404. Tab-completion models
|
||||
// (`tab_flash_lite_preview`, `tab_jump_flash_lite_preview`) are intentionally excluded —
|
||||
// they are not chat-callable.
|
||||
|
||||
export const AGY_PUBLIC_MODELS = Object.freeze([
|
||||
// Claude (Antigravity backend) — the headline differentiator for this provider.
|
||||
{
|
||||
id: "claude-opus-4-6-thinking",
|
||||
name: "Claude Opus 4.6 (Thinking)",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6 (Thinking)",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
// Gemini 3.x
|
||||
{
|
||||
id: "gemini-3.1-pro-high",
|
||||
name: "Gemini 3.1 Pro (High)",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65535,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.1-pro-low",
|
||||
name: "Gemini 3.1 Pro (Low)",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65535,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-pro-agent",
|
||||
name: "Gemini 3.1 Pro (Agent)",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65535,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-3-flash-agent",
|
||||
name: "Gemini 3.5 Flash (Agent)",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-3-flash",
|
||||
name: "Gemini 3 Flash",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.5-flash-low",
|
||||
name: "Gemini 3.5 Flash (Low)",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.5-flash-extra-low",
|
||||
name: "Gemini 3.5 Flash (Extra Low)",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.1-flash-lite",
|
||||
name: "Gemini 3.1 Flash Lite",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65535,
|
||||
toolCalling: true,
|
||||
},
|
||||
{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" },
|
||||
// Gemini 2.5
|
||||
{
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 2.5 Pro",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65535,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-2.5-flash",
|
||||
name: "Gemini 2.5 Flash",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65535,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-2.5-flash-thinking",
|
||||
name: "Gemini 2.5 Flash Thinking",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65535,
|
||||
supportsReasoning: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-2.5-flash-lite",
|
||||
name: "Gemini 2.5 Flash Lite",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65535,
|
||||
toolCalling: true,
|
||||
},
|
||||
// GPT-OSS
|
||||
{
|
||||
id: "gpt-oss-120b-medium",
|
||||
name: "GPT-OSS 120B (Medium)",
|
||||
contextLength: 131072,
|
||||
maxOutputTokens: 32768,
|
||||
supportsReasoning: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const AGY_PUBLIC_MODEL_IDS = new Set(AGY_PUBLIC_MODELS.map((model) => model.id));
|
||||
|
||||
const AGY_CLIENT_VISIBLE_MODEL_NAMES = Object.freeze(
|
||||
AGY_PUBLIC_MODELS.reduce<Record<string, string>>((acc, model) => {
|
||||
acc[model.id] = model.name;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
export function getClientVisibleAgyModelName(modelId: string, fallbackName?: string): string {
|
||||
return AGY_CLIENT_VISIBLE_MODEL_NAMES[modelId] || fallbackName || modelId;
|
||||
}
|
||||
|
||||
export function isUserCallableAgyModelId(modelId: string): boolean {
|
||||
return !!modelId && AGY_PUBLIC_MODEL_IDS.has(modelId);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { ANTIGRAVITY_BASE_URLS } from "./antigravityUpstream.ts";
|
||||
import { ANTIGRAVITY_PUBLIC_MODELS } from "./antigravityModelAliases.ts";
|
||||
import { AGY_PUBLIC_MODELS } from "./agyModels.ts";
|
||||
import {
|
||||
ANTHROPIC_BETA_API_KEY,
|
||||
ANTHROPIC_BETA_CLAUDE_OAUTH,
|
||||
@@ -911,6 +912,34 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
// Antigravity CLI (`agy`): standalone provider that reuses the antigravity executor,
|
||||
// format and backend (identical client_id + daily-cloudcode-pa endpoint), but ships its
|
||||
// own model catalog (incl. Claude) and its own account pool / OAuth credential import.
|
||||
agy: {
|
||||
id: "agy",
|
||||
alias: "agy",
|
||||
format: "antigravity",
|
||||
executor: "antigravity",
|
||||
baseUrls: [...ANTIGRAVITY_BASE_URLS],
|
||||
urlBuilder: (base, model, stream) => {
|
||||
const path = stream
|
||||
? "/v1internal:streamGenerateContent?alt=sse"
|
||||
: "/v1internal:generateContent";
|
||||
return `${base}${path}`;
|
||||
},
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
headers: getAntigravityProviderHeaders(),
|
||||
oauth: {
|
||||
clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID",
|
||||
clientIdDefault: resolvePublicCred("antigravity_id"),
|
||||
clientSecretEnv: "ANTIGRAVITY_OAUTH_CLIENT_SECRET",
|
||||
clientSecretDefault: resolvePublicCred("antigravity_alt"),
|
||||
},
|
||||
models: [...AGY_PUBLIC_MODELS],
|
||||
passthroughModels: true,
|
||||
},
|
||||
|
||||
github: {
|
||||
id: "github",
|
||||
alias: "gh",
|
||||
|
||||
@@ -44,6 +44,7 @@ export const REFRESH_LEAD_MS: Record<string, number> = {
|
||||
// is safe and reduces unnecessary upstream chatter.
|
||||
"gemini-cli": 15 * 60 * 1000,
|
||||
antigravity: 15 * 60 * 1000,
|
||||
agy: 15 * 60 * 1000, // same Google backend as antigravity (non-rotating refresh tokens)
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1293,6 +1294,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig:
|
||||
case "gemini":
|
||||
case "gemini-cli":
|
||||
case "antigravity":
|
||||
case "agy":
|
||||
return await refreshGoogleToken(
|
||||
credentials.refreshToken,
|
||||
PROVIDERS[provider].clientId,
|
||||
@@ -1367,6 +1369,7 @@ export function supportsTokenRefresh(provider) {
|
||||
"gemini",
|
||||
"gemini-cli",
|
||||
"antigravity",
|
||||
"agy",
|
||||
"claude",
|
||||
"codex",
|
||||
"qwen",
|
||||
@@ -1671,6 +1674,7 @@ export function formatProviderCredentials(provider, credentials, log) {
|
||||
};
|
||||
|
||||
case "antigravity":
|
||||
case "agy":
|
||||
case "gemini-cli":
|
||||
return {
|
||||
accessToken: credentials.accessToken,
|
||||
|
||||
@@ -39,6 +39,7 @@ export const SUPPORTED_WIZARD_OAUTH_PROVIDER_IDS = new Set([
|
||||
"codex",
|
||||
"gemini-cli",
|
||||
"antigravity",
|
||||
"agy",
|
||||
"qwen",
|
||||
"kimi-coding",
|
||||
"github",
|
||||
|
||||
127
src/app/api/providers/agy-auth/apply-local/route.ts
Normal file
127
src/app/api/providers/agy-auth/apply-local/route.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import {
|
||||
AgyAuthFileError,
|
||||
parseAndValidateAgyToken,
|
||||
enrichWithAntigravityBackend,
|
||||
createConnectionFromAgyToken,
|
||||
} from "@/lib/oauth/utils/agyAuthImport";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import { getProviderAuditTarget } from "@/lib/compliance/providerAudit";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { applyLocalAgyAuthSchema } from "@/shared/validation/schemas";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults";
|
||||
|
||||
/**
|
||||
* Resolve the Antigravity CLI token-file path. The path is fixed (no request input
|
||||
* reaches the filesystem APIs); an operator-controlled env override is allowed for
|
||||
* non-standard installs. Default: ~/.gemini/antigravity-cli/antigravity-oauth-token.
|
||||
*/
|
||||
function getAgyTokenFilePath(): string {
|
||||
const override = process.env.AGY_TOKEN_FILE;
|
||||
if (override && override.trim()) return override.trim();
|
||||
return path.join(os.homedir(), ".gemini", "antigravity-cli", "antigravity-oauth-token");
|
||||
}
|
||||
|
||||
function sanitizeConnectionForResponse(connection: Record<string, unknown>) {
|
||||
const safe = { ...connection };
|
||||
delete safe.accessToken;
|
||||
delete safe.refreshToken;
|
||||
delete safe.idToken;
|
||||
delete safe.apiKey;
|
||||
if (safe.providerSpecificData) {
|
||||
safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const auditContext = getAuditRequestContext(request);
|
||||
|
||||
// Body is optional for this route; tolerate an empty/absent body.
|
||||
let body: unknown = {};
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.trim()) body = JSON.parse(text);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedBody = validateBody(applyLocalAgyAuthSchema, body);
|
||||
if (isValidationFailure(parsedBody)) {
|
||||
return NextResponse.json({ error: parsedBody.error }, { status: 400 });
|
||||
}
|
||||
// Re-detecting a local login is an explicit refresh action: default to replacing
|
||||
// any existing connection for the same account unless the caller opts out.
|
||||
const { name, email, overwriteExisting = true } = parsedBody.data;
|
||||
|
||||
const tokenPath = getAgyTokenFilePath();
|
||||
let rawJson: unknown;
|
||||
try {
|
||||
const content = await fs.readFile(tokenPath, "utf8");
|
||||
rawJson = JSON.parse(content);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (code === "ENOENT") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"No local Antigravity CLI login found. Run `agy`, sign in with Google, then try again.",
|
||||
code: "no_local_login",
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Could not read the local agy token file", code: "read_failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseAndValidateAgyToken(rawJson);
|
||||
const enriched = await enrichWithAntigravityBackend(parsed);
|
||||
const { connection, created } = await createConnectionFromAgyToken(enriched, {
|
||||
name,
|
||||
email,
|
||||
overwriteExisting,
|
||||
});
|
||||
|
||||
logAuditEvent({
|
||||
action: "provider.credentials.imported",
|
||||
actor: "admin",
|
||||
target: getProviderAuditTarget(connection),
|
||||
resourceType: "provider_credentials",
|
||||
status: "success",
|
||||
ipAddress: auditContext.ipAddress || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: {
|
||||
provider: "agy",
|
||||
created,
|
||||
source: "apply-local",
|
||||
email: enriched.email || email,
|
||||
hasProjectId: !!enriched.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
connection: sanitizeConnectionForResponse(connection as Record<string, unknown>),
|
||||
created,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AgyAuthFileError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: sanitizeErrorMessage(error) || "Failed to import local Antigravity CLI login" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
111
src/app/api/providers/agy-auth/import-bulk/route.ts
Normal file
111
src/app/api/providers/agy-auth/import-bulk/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import {
|
||||
AgyAuthFileError,
|
||||
parseAndValidateAgyToken,
|
||||
enrichWithAntigravityBackend,
|
||||
createConnectionFromAgyToken,
|
||||
} from "@/lib/oauth/utils/agyAuthImport";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import { getProviderAuditTarget } from "@/lib/compliance/providerAudit";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { importAgyAuthBulkSchema } from "@/shared/validation/schemas";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults";
|
||||
|
||||
function sanitizeConnectionForResponse(connection: Record<string, unknown>) {
|
||||
const safe = { ...connection };
|
||||
delete safe.accessToken;
|
||||
delete safe.refreshToken;
|
||||
delete safe.idToken;
|
||||
delete safe.apiKey;
|
||||
if (safe.providerSpecificData) {
|
||||
safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const auditContext = getAuditRequestContext(request);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedBody = validateBody(importAgyAuthBulkSchema, body);
|
||||
if (isValidationFailure(parsedBody)) {
|
||||
return NextResponse.json({ error: parsedBody.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const { entries, overwriteExisting } = parsedBody.data;
|
||||
|
||||
const created: Record<string, unknown>[] = [];
|
||||
const errors: { index: number; name: string; message: string }[] = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const e = entries[i];
|
||||
const label = e.name || `entry ${i + 1}`;
|
||||
try {
|
||||
const parsed = parseAndValidateAgyToken(e.json);
|
||||
const enriched = await enrichWithAntigravityBackend(parsed);
|
||||
const { connection } = await createConnectionFromAgyToken(enriched, {
|
||||
name: e.name,
|
||||
email: e.email,
|
||||
overwriteExisting,
|
||||
});
|
||||
|
||||
created.push(sanitizeConnectionForResponse(connection as Record<string, unknown>));
|
||||
|
||||
logAuditEvent({
|
||||
action: "provider.credentials.imported",
|
||||
actor: "admin",
|
||||
target: getProviderAuditTarget(connection),
|
||||
resourceType: "provider_credentials",
|
||||
status: "success",
|
||||
ipAddress: auditContext.ipAddress || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: {
|
||||
provider: "agy",
|
||||
email: enriched.email || e.email,
|
||||
bulkIndex: i,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AgyAuthFileError
|
||||
? err.message
|
||||
: sanitizeErrorMessage(err) || "Failed to import";
|
||||
errors.push({ index: i, name: label, message });
|
||||
}
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
action: "provider.credentials.bulk_imported",
|
||||
actor: "admin",
|
||||
target: "agy",
|
||||
resourceType: "provider_credentials",
|
||||
status: errors.length === entries.length ? "failure" : "success",
|
||||
ipAddress: auditContext.ipAddress || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: {
|
||||
provider: "agy",
|
||||
total: entries.length,
|
||||
success: created.length,
|
||||
failed: errors.length,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: created.length,
|
||||
failed: errors.length,
|
||||
total: entries.length,
|
||||
created,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
96
src/app/api/providers/agy-auth/import/route.ts
Normal file
96
src/app/api/providers/agy-auth/import/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import {
|
||||
AgyAuthFileError,
|
||||
parseAndValidateAgyToken,
|
||||
enrichWithAntigravityBackend,
|
||||
createConnectionFromAgyToken,
|
||||
} from "@/lib/oauth/utils/agyAuthImport";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import { getProviderAuditTarget } from "@/lib/compliance/providerAudit";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { importAgyAuthSchema } from "@/shared/validation/schemas";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults";
|
||||
|
||||
function sanitizeConnectionForResponse(connection: Record<string, unknown>) {
|
||||
const safe = { ...connection };
|
||||
delete safe.accessToken;
|
||||
delete safe.refreshToken;
|
||||
delete safe.idToken;
|
||||
delete safe.apiKey;
|
||||
if (safe.providerSpecificData) {
|
||||
safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const auditContext = getAuditRequestContext(request);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedBody = validateBody(importAgyAuthSchema, body);
|
||||
if (isValidationFailure(parsedBody)) {
|
||||
return NextResponse.json({ error: parsedBody.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const { source, name, email, overwriteExisting } = parsedBody.data;
|
||||
|
||||
let rawJson: unknown;
|
||||
try {
|
||||
rawJson = source.kind === "json" ? source.json : JSON.parse(source.text);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not parse the content as JSON", code: "invalid_json" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseAndValidateAgyToken(rawJson);
|
||||
const enriched = await enrichWithAntigravityBackend(parsed);
|
||||
const { connection, created } = await createConnectionFromAgyToken(enriched, {
|
||||
name,
|
||||
email,
|
||||
overwriteExisting,
|
||||
});
|
||||
|
||||
logAuditEvent({
|
||||
action: "provider.credentials.imported",
|
||||
actor: "admin",
|
||||
target: getProviderAuditTarget(connection),
|
||||
resourceType: "provider_credentials",
|
||||
status: "success",
|
||||
ipAddress: auditContext.ipAddress || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: {
|
||||
provider: "agy",
|
||||
created,
|
||||
email: enriched.email || email,
|
||||
hasProjectId: !!enriched.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
connection: sanitizeConnectionForResponse(connection as Record<string, unknown>),
|
||||
created,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AgyAuthFileError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: sanitizeErrorMessage(error) || "Failed to import Antigravity CLI auth" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/app/api/providers/agy-auth/zip-extract/route.ts
Normal file
54
src/app/api/providers/agy-auth/zip-extract/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
// extractGeminiAuthZip is a generic, content-agnostic .json-from-ZIP extractor
|
||||
// (path-traversal + size guards); reused as-is for agy token-file archives.
|
||||
import { extractGeminiAuthZip } from "@/lib/oauth/utils/geminiAuthZipExtract";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const ZIP_BODY_LIMIT = 11 * 1024 * 1024; // 11 MB — slightly above the 10 MB extracted limit
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const contentLength = Number(request.headers.get("content-length") || "0");
|
||||
if (contentLength > ZIP_BODY_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
const arrayBuffer = await request.arrayBuffer();
|
||||
if (arrayBuffer.byteLength > ZIP_BODY_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
buffer = Buffer.from(arrayBuffer);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to read request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const files = extractGeminiAuthZip(buffer);
|
||||
|
||||
const entries = files.map((f) => {
|
||||
try {
|
||||
return { name: f.name, json: JSON.parse(f.content), parseError: null };
|
||||
} catch {
|
||||
return { name: f.name, json: null, parseError: "Not valid JSON" };
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ entries });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: sanitizeErrorMessage(error) || "Failed to extract ZIP", code: "extract_failed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,34 @@ export const ANTIGRAVITY_CONFIG = {
|
||||
loadCodeAssistClientMetadata: getAntigravityLoadCodeAssistClientMetadata(),
|
||||
};
|
||||
|
||||
// Antigravity CLI (`agy`) OAuth Configuration.
|
||||
// `agy` is the standalone Antigravity CLI; it authenticates against the EXACT same Google
|
||||
// consumer-OAuth client as ANTIGRAVITY_CONFIG (the client_id was verified byte-for-byte
|
||||
// identical: 1071006060591-tmhssin2h21lcre235vtolojh4g403ep). It reuses the antigravity
|
||||
// public credentials and Code Assist endpoints — no new embedded secret — and the same
|
||||
// loopback-redirect browser flow (popup locally; paste-the-callback-URL on remote/headless),
|
||||
// so the entire existing antigravity OAuth UI machinery applies unchanged.
|
||||
export const AGY_CONFIG = {
|
||||
clientId: resolvePublicCred("antigravity_id", "ANTIGRAVITY_OAUTH_CLIENT_ID"),
|
||||
clientSecret: resolvePublicCred("antigravity_alt", "ANTIGRAVITY_OAUTH_CLIENT_SECRET"),
|
||||
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo",
|
||||
scopes: [...ANTIGRAVITY_CONFIG.scopes],
|
||||
// Reuse the antigravity Code Assist endpoints (identical backend).
|
||||
apiEndpoint: ANTIGRAVITY_CONFIG.apiEndpoint,
|
||||
apiVersion: ANTIGRAVITY_CONFIG.apiVersion,
|
||||
loadCodeAssistEndpoints: [...ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints],
|
||||
onboardUserEndpoints: [...ANTIGRAVITY_CONFIG.onboardUserEndpoints],
|
||||
fetchAvailableModelsEndpoints: [...ANTIGRAVITY_CONFIG.fetchAvailableModelsEndpoints],
|
||||
loadCodeAssistEndpoint: ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint,
|
||||
onboardUserEndpoint: ANTIGRAVITY_CONFIG.onboardUserEndpoint,
|
||||
fetchAvailableModelsEndpoint: ANTIGRAVITY_CONFIG.fetchAvailableModelsEndpoint,
|
||||
loadCodeAssistUserAgent: ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
|
||||
loadCodeAssistApiClient: ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
|
||||
loadCodeAssistClientMetadata: ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
|
||||
};
|
||||
|
||||
// OpenAI OAuth Configuration (Authorization Code Flow with PKCE)
|
||||
// Re-uses CODEX_CONFIG.clientId to avoid duplication — same provider, different originator.
|
||||
// IMPORTANT: same Auth0 backend as Codex → same multi-account session-takeover
|
||||
@@ -383,6 +411,7 @@ export const PROVIDERS = {
|
||||
QWEN: "qwen",
|
||||
QODER: "qoder",
|
||||
ANTIGRAVITY: "antigravity",
|
||||
AGY: "agy",
|
||||
KIMI_CODING: "kimi-coding",
|
||||
OPENAI: "openai",
|
||||
GITHUB: "github",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { generatePKCE, generateState } from "./utils/pkce";
|
||||
import { PROVIDERS } from "./providers/index";
|
||||
|
||||
const GOOGLE_BROWSER_PROVIDERS = new Set(["antigravity", "gemini-cli"]);
|
||||
const GOOGLE_BROWSER_PROVIDERS = new Set(["antigravity", "agy", "gemini-cli"]);
|
||||
|
||||
type OAuthRedirectEnv = Record<string, string | undefined>;
|
||||
|
||||
@@ -32,7 +32,8 @@ function hasCustomGoogleOAuthCredentials(
|
||||
providerName: string,
|
||||
env: OAuthRedirectEnv | null | undefined = process.env
|
||||
): boolean {
|
||||
if (providerName === "antigravity") {
|
||||
if (providerName === "antigravity" || providerName === "agy") {
|
||||
// `agy` reuses the antigravity OAuth client + env overrides.
|
||||
return (
|
||||
hasValue(env?.ANTIGRAVITY_OAUTH_CLIENT_ID) &&
|
||||
hasValue(env?.ANTIGRAVITY_OAUTH_CLIENT_SECRET)
|
||||
|
||||
15
src/lib/oauth/providers/agy.ts
Normal file
15
src/lib/oauth/providers/agy.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { antigravity } from "./antigravity";
|
||||
import { AGY_CONFIG } from "../constants/oauth";
|
||||
|
||||
/**
|
||||
* Antigravity CLI (`agy`) OAuth provider module.
|
||||
*
|
||||
* `agy` targets the identical Google consumer-OAuth backend as the `antigravity` provider
|
||||
* (same client_id, scopes, token URL and Code Assist endpoints — verified byte-for-byte),
|
||||
* so it reuses the antigravity authorization-code + PKCE flow (`buildAuthUrl`,
|
||||
* `exchangeToken`, `postExchange`, `mapTokens`) wholesale. Only the `config` label differs;
|
||||
* `AGY_CONFIG` resolves to the same embedded antigravity credentials. Kept as its own
|
||||
* module so `agy` is a first-class, standalone entry in the OAuth provider registry and can
|
||||
* diverge later without touching the antigravity flow.
|
||||
*/
|
||||
export const agy = { ...antigravity, config: AGY_CONFIG };
|
||||
@@ -14,6 +14,7 @@ import { claude } from "./claude";
|
||||
import { codex } from "./codex";
|
||||
import { gemini } from "./gemini";
|
||||
import { antigravity } from "./antigravity";
|
||||
import { agy } from "./agy";
|
||||
import { qoder } from "./qoder";
|
||||
import { qwen } from "./qwen";
|
||||
import { kimiCoding } from "./kimi-coding";
|
||||
@@ -31,6 +32,7 @@ export const PROVIDERS = {
|
||||
codex,
|
||||
"gemini-cli": gemini,
|
||||
antigravity,
|
||||
agy,
|
||||
qoder,
|
||||
qwen,
|
||||
"kimi-coding": kimiCoding,
|
||||
|
||||
259
src/lib/oauth/utils/agyAuthImport.ts
Normal file
259
src/lib/oauth/utils/agyAuthImport.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
getProviderConnections,
|
||||
createProviderConnection,
|
||||
updateProviderConnection,
|
||||
} from "@/lib/localDb";
|
||||
import { AGY_CONFIG } from "@/lib/oauth/constants/oauth";
|
||||
import {
|
||||
getAntigravityHeaders,
|
||||
getAntigravityLoadCodeAssistMetadata,
|
||||
} from "@omniroute/open-sse/services/antigravityHeaders.ts";
|
||||
import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error carrying an HTTP status + machine code, mirroring GeminiAuthFileError so the
|
||||
* agy-auth routes can translate it to a clean response (never a raw stack trace).
|
||||
*/
|
||||
export class AgyAuthFileError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
|
||||
constructor(message: string, status = 400, code = "invalid_request") {
|
||||
super(message);
|
||||
this.name = "AgyAuthFileError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// ──── Public types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ParsedAgyAuth {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenType: string;
|
||||
expiresAt: string | null;
|
||||
authMethod: string | null;
|
||||
}
|
||||
|
||||
export interface EnrichedAgyAuth extends ParsedAgyAuth {
|
||||
email: string | null;
|
||||
projectId: string | null;
|
||||
tier: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAgyConnectionOptions {
|
||||
name?: string;
|
||||
email?: string;
|
||||
overwriteExisting?: boolean;
|
||||
}
|
||||
|
||||
// ──── Parse & validate ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the Antigravity CLI (`agy`) token file. Unlike gemini-cli's flat
|
||||
* `oauth_creds.json`, the agy file nests the token under `.token`, uses an ISO `expiry`
|
||||
* string, and has NO `id_token`. A flat top-level shape is accepted as a fallback.
|
||||
*/
|
||||
export function parseAndValidateAgyToken(raw: unknown): ParsedAgyAuth {
|
||||
const doc = toRecord(raw);
|
||||
// agy nests credentials under `.token`; fall back to the top level for flat exports.
|
||||
const token = doc.token && typeof doc.token === "object" ? toRecord(doc.token) : doc;
|
||||
|
||||
const accessToken = toNonEmptyString(token.access_token);
|
||||
const refreshToken = toNonEmptyString(token.refresh_token);
|
||||
|
||||
if (!accessToken) {
|
||||
throw new AgyAuthFileError(
|
||||
"access_token is missing or empty in the agy token file",
|
||||
400,
|
||||
"missing_access_token"
|
||||
);
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new AgyAuthFileError(
|
||||
"refresh_token is missing or empty in the agy token file",
|
||||
400,
|
||||
"missing_refresh_token"
|
||||
);
|
||||
}
|
||||
|
||||
// agy uses an ISO `expiry`; also accept a unix-ms `expiry_date`/`expires_at` for safety.
|
||||
let expiresAt: string | null = null;
|
||||
const isoExpiry = toNonEmptyString(token.expiry) ?? toNonEmptyString(token.expires_at);
|
||||
if (isoExpiry) {
|
||||
const ms = new Date(isoExpiry).getTime();
|
||||
expiresAt = Number.isNaN(ms) ? null : new Date(ms).toISOString();
|
||||
} else if (typeof token.expiry_date === "number" && Number.isFinite(token.expiry_date)) {
|
||||
expiresAt = new Date(token.expiry_date).toISOString();
|
||||
}
|
||||
|
||||
const tokenType = toNonEmptyString(token.token_type) ?? "Bearer";
|
||||
const authMethod = toNonEmptyString(doc.auth_method) ?? toNonEmptyString(token.auth_method);
|
||||
|
||||
return { accessToken, refreshToken, tokenType, expiresAt, authMethod };
|
||||
}
|
||||
|
||||
// ──── Enrich with the Antigravity Code Assist backend ─────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the account email (userinfo) and GCP project id (loadCodeAssist) for the token.
|
||||
* Best-effort + time-boxed; the agy CLI has already onboarded the project, so we do NOT
|
||||
* run the onboardUser provisioning loop here (that can take up to ~50s).
|
||||
*/
|
||||
export async function enrichWithAntigravityBackend(
|
||||
parsed: ParsedAgyAuth
|
||||
): Promise<EnrichedAgyAuth> {
|
||||
let email: string | null = null;
|
||||
let projectId: string | null = null;
|
||||
let tier: string | null = null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const userInfoRes = await fetch(`${AGY_CONFIG.userInfoUrl}?alt=json`, {
|
||||
headers: { Authorization: `Bearer ${parsed.accessToken}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (userInfoRes.ok) {
|
||||
email = toNonEmptyString(toRecord(await userInfoRes.json()).email);
|
||||
}
|
||||
} catch {
|
||||
// best effort — email stays null
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const loadController = new AbortController();
|
||||
const loadTimer = setTimeout(() => loadController.abort(), 8000);
|
||||
try {
|
||||
const headers = getAntigravityHeaders("loadCodeAssist", parsed.accessToken);
|
||||
const metadata = getAntigravityLoadCodeAssistMetadata();
|
||||
for (const endpoint of AGY_CONFIG.loadCodeAssistEndpoints) {
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata }),
|
||||
signal: loadController.signal,
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const data = toRecord(await res.json());
|
||||
const project = data.cloudaicompanionProject;
|
||||
projectId =
|
||||
(typeof project === "string" ? toNonEmptyString(project) : null) ??
|
||||
toNonEmptyString(toRecord(project).id);
|
||||
tier = extractCodeAssistOnboardTierId(data) || null;
|
||||
break;
|
||||
} catch {
|
||||
// try next endpoint
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best effort — projectId stays null
|
||||
} finally {
|
||||
clearTimeout(loadTimer);
|
||||
}
|
||||
|
||||
return { ...parsed, email, projectId, tier };
|
||||
}
|
||||
|
||||
// ──── Find existing connection ────────────────────────────────────────────────
|
||||
|
||||
export async function findExistingAgyConnection(email: string): Promise<JsonRecord | null> {
|
||||
const connections = await getProviderConnections({ provider: "agy" });
|
||||
const lowerEmail = email.toLowerCase();
|
||||
return (
|
||||
(connections.find((c) => {
|
||||
const conn = c as JsonRecord;
|
||||
return toNonEmptyString(conn.email)?.toLowerCase() === lowerEmail;
|
||||
}) as JsonRecord | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// ──── Create / update connection ──────────────────────────────────────────────
|
||||
|
||||
export async function createConnectionFromAgyToken(
|
||||
enriched: EnrichedAgyAuth,
|
||||
options: CreateAgyConnectionOptions
|
||||
): Promise<{ connection: JsonRecord; created: boolean }> {
|
||||
const resolvedEmail = options.email || enriched.email;
|
||||
|
||||
if (resolvedEmail) {
|
||||
const existing = await findExistingAgyConnection(resolvedEmail);
|
||||
if (existing) {
|
||||
if (!options.overwriteExisting) {
|
||||
throw new AgyAuthFileError(
|
||||
"An Antigravity CLI connection for this account already exists. Pass overwriteExisting: true to replace it.",
|
||||
409,
|
||||
"duplicate_account"
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await updateProviderConnection(existing.id as string, {
|
||||
accessToken: enriched.accessToken,
|
||||
refreshToken: enriched.refreshToken,
|
||||
expiresAt: enriched.expiresAt,
|
||||
email: resolvedEmail || (existing.email as string | undefined),
|
||||
name:
|
||||
options.name ||
|
||||
(existing.name as string | undefined) ||
|
||||
resolvedEmail ||
|
||||
"Antigravity CLI (imported)",
|
||||
testStatus: "active",
|
||||
providerSpecificData: {
|
||||
...toRecord(existing.providerSpecificData),
|
||||
tokenType: enriched.tokenType,
|
||||
authMethod: enriched.authMethod,
|
||||
projectId: enriched.projectId ?? toRecord(existing.providerSpecificData).projectId,
|
||||
tier: enriched.tier ?? toRecord(existing.providerSpecificData).tier,
|
||||
importedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return { connection: updated || existing, created: false };
|
||||
}
|
||||
} else if (!options.overwriteExisting) {
|
||||
throw new AgyAuthFileError(
|
||||
"Could not verify the account email from the agy token (no userinfo). Pass overwriteExisting: true to import without email verification.",
|
||||
409,
|
||||
"identity_unverified"
|
||||
);
|
||||
}
|
||||
|
||||
const name = options.name || resolvedEmail || "Antigravity CLI (imported)";
|
||||
|
||||
const connection = await createProviderConnection({
|
||||
provider: "agy",
|
||||
authType: "oauth",
|
||||
name,
|
||||
email: resolvedEmail || undefined,
|
||||
accessToken: enriched.accessToken,
|
||||
refreshToken: enriched.refreshToken,
|
||||
expiresAt: enriched.expiresAt,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {
|
||||
tokenType: enriched.tokenType,
|
||||
authMethod: enriched.authMethod,
|
||||
projectId: enriched.projectId,
|
||||
tier: enriched.tier,
|
||||
importedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return { connection, created: true };
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import Button from "./Button";
|
||||
import Input from "./Input";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "gemini-cli"]);
|
||||
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy", "gemini-cli"]);
|
||||
|
||||
/** Providers that use a local callback server on a random port (PKCE browser flow). */
|
||||
const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex"]);
|
||||
|
||||
@@ -301,6 +301,7 @@ const LOBE_PROVIDER_ALIASES = {
|
||||
"amazon-q": "Aws",
|
||||
anthropic: "Anthropic",
|
||||
antigravity: "Antigravity",
|
||||
agy: "Antigravity", // Antigravity CLI — same brand icon as the antigravity provider
|
||||
assemblyai: "AssemblyAI",
|
||||
"aws-polly": "Aws",
|
||||
azure: "Azure",
|
||||
|
||||
@@ -89,6 +89,20 @@ export const OAUTH_PROVIDERS = {
|
||||
authHint:
|
||||
"Uses Gemini CLI OAuth / Cloud Code credentials. Pro models require an eligible Google account or paid plan.",
|
||||
},
|
||||
agy: {
|
||||
id: "agy",
|
||||
alias: "agy",
|
||||
name: "Antigravity CLI",
|
||||
icon: "terminal",
|
||||
color: "#F59E0B",
|
||||
textIcon: "AGY",
|
||||
website: "https://antigravity.google",
|
||||
subscriptionRisk: true,
|
||||
riskNoticeVariant: "oauth",
|
||||
hasFree: true,
|
||||
authHint:
|
||||
"Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models).",
|
||||
},
|
||||
kiro: {
|
||||
id: "kiro",
|
||||
alias: "kr",
|
||||
@@ -2896,6 +2910,7 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce<Record<string, str
|
||||
// Providers that support usage/quota API
|
||||
export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"antigravity",
|
||||
"agy",
|
||||
"gemini-cli",
|
||||
"kiro",
|
||||
"amazon-q",
|
||||
|
||||
@@ -429,6 +429,31 @@ export const importGeminiAuthSchema = z.object({
|
||||
overwriteExisting: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ──── Antigravity CLI (`agy`) Auth Import Schema ────
|
||||
// Same source/options shape as gemini-cli; the parser handles the agy-specific token JSON.
|
||||
|
||||
export const importAgyAuthSchema = z.object({
|
||||
source: z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("json"), json: z.unknown() }),
|
||||
z.object({
|
||||
kind: z.literal("text"),
|
||||
text: z.string().max(256 * 1024, "agy token file content exceeds 256KB"),
|
||||
}),
|
||||
]),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
email: z.string().email("Must be a valid email").optional(),
|
||||
overwriteExisting: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ──── Antigravity CLI (`agy`) auto-detect local login Schema ────
|
||||
// No `source`: the route reads the token from the local agy CLI data dir on disk.
|
||||
|
||||
export const applyLocalAgyAuthSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
email: z.string().email("Must be a valid email").optional(),
|
||||
overwriteExisting: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ──── Gemini CLI Auth Import Bulk Schema ────
|
||||
|
||||
export const importGeminiAuthBulkSchema = z.object({
|
||||
@@ -445,6 +470,22 @@ export const importGeminiAuthBulkSchema = z.object({
|
||||
overwriteExisting: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ──── Antigravity CLI (`agy`) Auth Import Bulk Schema ────
|
||||
|
||||
export const importAgyAuthBulkSchema = z.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
json: z.unknown(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
email: z.string().email("Must be a valid email").optional(),
|
||||
})
|
||||
)
|
||||
.min(1, "At least one entry is required")
|
||||
.max(50, "At most 50 entries per bulk import"),
|
||||
overwriteExisting: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ──── API Key Schemas ────
|
||||
|
||||
export const createKeySchema = z.object({
|
||||
|
||||
72
tests/unit/agy-auth-import.test.ts
Normal file
72
tests/unit/agy-auth-import.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
parseAndValidateAgyToken,
|
||||
AgyAuthFileError,
|
||||
} from "../../src/lib/oauth/utils/agyAuthImport.ts";
|
||||
|
||||
// Fixture token values are deliberately generic (not `ya29.`/`1//` shaped) so secret
|
||||
// scanners don't flag them — the parser only cares that they are non-empty strings.
|
||||
const ACCESS = "agy-access-token-fixture";
|
||||
const REFRESH = "agy-refresh-token-fixture";
|
||||
|
||||
test("parses the nested agy token-file shape (token.* with ISO expiry, no id_token)", () => {
|
||||
const parsed = parseAndValidateAgyToken({
|
||||
token: {
|
||||
access_token: ACCESS,
|
||||
token_type: "Bearer",
|
||||
refresh_token: REFRESH,
|
||||
expiry: "2026-05-29T06:16:24.338-03:00",
|
||||
},
|
||||
auth_method: "consumer",
|
||||
});
|
||||
assert.equal(parsed.accessToken, ACCESS);
|
||||
assert.equal(parsed.refreshToken, REFRESH);
|
||||
assert.equal(parsed.tokenType, "Bearer");
|
||||
assert.equal(parsed.authMethod, "consumer");
|
||||
assert.equal(parsed.expiresAt, new Date("2026-05-29T06:16:24.338-03:00").toISOString());
|
||||
});
|
||||
|
||||
test("accepts a flat fallback shape (access_token/refresh_token at top level)", () => {
|
||||
const parsed = parseAndValidateAgyToken({
|
||||
access_token: ACCESS,
|
||||
refresh_token: REFRESH,
|
||||
});
|
||||
assert.equal(parsed.accessToken, ACCESS);
|
||||
assert.equal(parsed.refreshToken, REFRESH);
|
||||
assert.equal(parsed.tokenType, "Bearer"); // default
|
||||
assert.equal(parsed.expiresAt, null);
|
||||
});
|
||||
|
||||
test("supports unix-ms expiry_date as an alternative to ISO expiry", () => {
|
||||
const ms = 1780038984000;
|
||||
const parsed = parseAndValidateAgyToken({
|
||||
token: { access_token: ACCESS, refresh_token: REFRESH, expiry_date: ms },
|
||||
});
|
||||
assert.equal(parsed.expiresAt, new Date(ms).toISOString());
|
||||
});
|
||||
|
||||
test("rejects a token file missing access_token", () => {
|
||||
assert.throws(
|
||||
() => parseAndValidateAgyToken({ token: { refresh_token: REFRESH } }),
|
||||
(err) =>
|
||||
err instanceof AgyAuthFileError &&
|
||||
err.code === "missing_access_token" &&
|
||||
err.status === 400
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a token file missing refresh_token", () => {
|
||||
assert.throws(
|
||||
() => parseAndValidateAgyToken({ token: { access_token: ACCESS } }),
|
||||
(err) => err instanceof AgyAuthFileError && err.code === "missing_refresh_token"
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid/garbage expiry becomes null rather than throwing", () => {
|
||||
const parsed = parseAndValidateAgyToken({
|
||||
token: { access_token: ACCESS, refresh_token: REFRESH, expiry: "not-a-date" },
|
||||
});
|
||||
assert.equal(parsed.expiresAt, null);
|
||||
});
|
||||
71
tests/unit/agy-provider.test.ts
Normal file
71
tests/unit/agy-provider.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AI_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts";
|
||||
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
|
||||
import { PROVIDERS as LEGACY_PROVIDERS } from "../../open-sse/config/constants.ts";
|
||||
import { PROVIDERS as OAUTH_PROVIDER_IDS, AGY_CONFIG } from "../../src/lib/oauth/constants/oauth.ts";
|
||||
import { supportsTokenRefresh, REFRESH_LEAD_MS } from "../../open-sse/services/tokenRefresh.ts";
|
||||
import {
|
||||
AGY_PUBLIC_MODELS,
|
||||
isUserCallableAgyModelId,
|
||||
getClientVisibleAgyModelName,
|
||||
} from "../../open-sse/config/agyModels.ts";
|
||||
|
||||
test("agy is registered as an OAuth provider in the UI catalog", () => {
|
||||
const agy = AI_PROVIDERS.agy;
|
||||
assert.ok(agy, "AI_PROVIDERS.agy must exist");
|
||||
assert.equal(agy.id, "agy");
|
||||
assert.equal(agy.name, "Antigravity CLI");
|
||||
assert.equal(agy.riskNoticeVariant, "oauth");
|
||||
assert.equal(agy.subscriptionRisk, true);
|
||||
});
|
||||
|
||||
test("agy supports the usage/quota API", () => {
|
||||
assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("agy"));
|
||||
});
|
||||
|
||||
test("agy registry entry reuses the antigravity backend (no duplicate executor/format)", () => {
|
||||
const agy = REGISTRY.agy;
|
||||
assert.ok(agy, "REGISTRY.agy must exist");
|
||||
assert.equal(agy.format, "antigravity");
|
||||
assert.equal(agy.executor, "antigravity");
|
||||
assert.equal(agy.authType, "oauth");
|
||||
assert.equal(agy.authHeader, "bearer");
|
||||
assert.equal(agy.passthroughModels, true);
|
||||
});
|
||||
|
||||
test("agy reuses the identical antigravity Google OAuth credentials (no new embedded secret)", () => {
|
||||
// The agy client_id was verified byte-for-byte identical to antigravity's.
|
||||
assert.equal(LEGACY_PROVIDERS.agy.clientId, LEGACY_PROVIDERS.antigravity.clientId);
|
||||
assert.equal(LEGACY_PROVIDERS.agy.clientSecret, LEGACY_PROVIDERS.antigravity.clientSecret);
|
||||
assert.equal(AGY_CONFIG.clientId, LEGACY_PROVIDERS.antigravity.clientId);
|
||||
assert.equal(OAUTH_PROVIDER_IDS.AGY, "agy");
|
||||
});
|
||||
|
||||
test("agy ships its own catalog including the Claude models antigravity omits", () => {
|
||||
const ids = REGISTRY.agy.models.map((m) => m.id);
|
||||
assert.ok(ids.includes("claude-opus-4-6-thinking"), "must expose Claude Opus 4.6 Thinking");
|
||||
assert.ok(ids.includes("claude-sonnet-4-6"), "must expose Claude Sonnet 4.6");
|
||||
// Tab-completion models are not chat-callable and must be excluded.
|
||||
assert.ok(!ids.includes("tab_flash_lite_preview"));
|
||||
assert.ok(!ids.includes("tab_jump_flash_lite_preview"));
|
||||
assert.equal(ids.length, AGY_PUBLIC_MODELS.length);
|
||||
});
|
||||
|
||||
test("agy model helpers resolve catalog ids and display names", () => {
|
||||
assert.equal(isUserCallableAgyModelId("claude-opus-4-6-thinking"), true);
|
||||
assert.equal(isUserCallableAgyModelId("tab_flash_lite_preview"), false);
|
||||
assert.equal(isUserCallableAgyModelId(""), false);
|
||||
assert.equal(
|
||||
getClientVisibleAgyModelName("claude-opus-4-6-thinking"),
|
||||
"Claude Opus 4.6 (Thinking)"
|
||||
);
|
||||
assert.equal(getClientVisibleAgyModelName("unknown-model", "Fallback"), "Fallback");
|
||||
});
|
||||
|
||||
test("agy token refresh is wired on the Google (non-rotating) refresh path", () => {
|
||||
assert.equal(supportsTokenRefresh("agy"), true);
|
||||
// Same 15-minute proactive lead as antigravity (Google refresh tokens are permanent).
|
||||
assert.equal(REFRESH_LEAD_MS.agy, REFRESH_LEAD_MS.antigravity);
|
||||
});
|
||||
@@ -98,6 +98,41 @@ test("provider validation routes require management authentication before readin
|
||||
}
|
||||
});
|
||||
|
||||
test("Antigravity CLI (agy) credential import routes require management authentication before reading the body", () => {
|
||||
// Routes that parse a JSON body — auth MUST run before request.json().
|
||||
const jsonBodyRoutes = [
|
||||
"src/app/api/providers/agy-auth/import/route.ts",
|
||||
"src/app/api/providers/agy-auth/import-bulk/route.ts",
|
||||
];
|
||||
for (const routePath of jsonBodyRoutes) {
|
||||
const content = fs.readFileSync(routePath, "utf8");
|
||||
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
|
||||
assert.ok(
|
||||
content.includes("const authError = await requireManagementAuth(request);"),
|
||||
routePath
|
||||
);
|
||||
assert.ok(content.includes("if (authError) return authError;"), routePath);
|
||||
assert.ok(
|
||||
content.indexOf("requireManagementAuth(request)") < content.indexOf("request.json()"),
|
||||
`${routePath} should authenticate before parsing the submitted token`
|
||||
);
|
||||
}
|
||||
// Routes that read non-JSON bodies (local file / uploaded ZIP) — auth still comes first.
|
||||
const otherBodyRoutes = [
|
||||
"src/app/api/providers/agy-auth/apply-local/route.ts",
|
||||
"src/app/api/providers/agy-auth/zip-extract/route.ts",
|
||||
];
|
||||
for (const routePath of otherBodyRoutes) {
|
||||
const content = fs.readFileSync(routePath, "utf8");
|
||||
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
|
||||
assert.ok(
|
||||
content.includes("const authError = await requireManagementAuth(request);"),
|
||||
routePath
|
||||
);
|
||||
assert.ok(content.includes("if (authError) return authError;"), routePath);
|
||||
}
|
||||
});
|
||||
|
||||
test("usage analytics and request log routes require management authentication", () => {
|
||||
const routePaths = [
|
||||
"src/app/api/usage/analytics/route.ts",
|
||||
|
||||
@@ -24,6 +24,7 @@ const PROVIDERS = providersModule.default;
|
||||
const { resolveBrowserOAuthRedirectUri } = oauthHelpersModule;
|
||||
const {
|
||||
ANTIGRAVITY_CONFIG,
|
||||
AGY_CONFIG,
|
||||
CLAUDE_CONFIG,
|
||||
CLINE_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
@@ -51,6 +52,7 @@ const EXPECTED_PROVIDER_KEYS = [
|
||||
"codex",
|
||||
"gemini-cli",
|
||||
"antigravity",
|
||||
"agy",
|
||||
"qoder",
|
||||
"qwen",
|
||||
"kimi-coding",
|
||||
@@ -71,6 +73,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = {
|
||||
codex: CODEX_CONFIG,
|
||||
"gemini-cli": GEMINI_CONFIG,
|
||||
antigravity: ANTIGRAVITY_CONFIG,
|
||||
agy: AGY_CONFIG,
|
||||
qoder: QODER_CONFIG,
|
||||
qwen: QWEN_CONFIG,
|
||||
"kimi-coding": KIMI_CODING_CONFIG,
|
||||
@@ -91,6 +94,7 @@ const REQUIRED_FIELDS_BY_PROVIDER = {
|
||||
codex: ["authorizeUrl", "tokenUrl", "scope", "clientId"],
|
||||
"gemini-cli": ["authorizeUrl", "tokenUrl", "userInfoUrl", "scopes", "clientId"],
|
||||
antigravity: ["authorizeUrl", "tokenUrl", "userInfoUrl", "scopes", "clientId"],
|
||||
agy: ["authorizeUrl", "tokenUrl", "userInfoUrl", "scopes", "clientId"],
|
||||
qoder: ["extraParams"],
|
||||
qwen: ["deviceCodeUrl", "tokenUrl", "scope", "clientId"],
|
||||
"kimi-coding": ["deviceCodeUrl", "tokenUrl", "clientId"],
|
||||
|
||||
Reference in New Issue
Block a user