Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
628 changes: 628 additions & 0 deletions cmd/capi/antigravity.go

Large diffs are not rendered by default.

155 changes: 155 additions & 0 deletions cmd/capi/antigravity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package main

import (
"encoding/json"
"testing"

"github.com/gin-gonic/gin"
)

func TestIsAntigravityAccount(t *testing.T) {
for _, source := range []string{"antigravity", "AntiGravity", "agy", "google-antigravity"} {
if !isAntigravityAccount(OpenAIAccount{Source: source}) {
t.Fatalf("expected source %q to be recognized as antigravity", source)
}
}
for _, source := range []string{"", "cpa", "codex", "sub2api"} {
if isAntigravityAccount(OpenAIAccount{Source: source}) {
t.Fatalf("source %q should not be antigravity", source)
}
}
}

func TestBuildAntigravityPayloadEnvelope(t *testing.T) {
call := GatewayCall{
Model: Model{ID: "gemini-3.7-flash-high"},
Body: ChatRequest{
Model: "gemini-3.7-flash-high",
Messages: []ChatMessage{
{Role: "system", Content: "be terse"},
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi"},
},
Payload: map[string]interface{}{"temperature": 0.5, "max_tokens": 1024},
},
}

encoded, providerErr := buildAntigravityPayload(call, "proj-123")
if providerErr != nil {
t.Fatalf("unexpected provider error: %+v", providerErr)
}

var payload map[string]interface{}
if err := json.Unmarshal(encoded, &payload); err != nil {
t.Fatalf("payload is not valid JSON: %v", err)
}

if payload["model"] != "gemini-3.7-flash-high" {
t.Fatalf("model should be forwarded verbatim, got %v", payload["model"])
}
if payload["project"] != "proj-123" {
t.Fatalf("project not set, got %v", payload["project"])
}
if payload["requestType"] != "agent" {
t.Fatalf("requestType should be agent, got %v", payload["requestType"])
}

request, ok := payload["request"].(map[string]interface{})
if !ok {
t.Fatalf("request envelope missing")
}
if _, ok := request["systemInstruction"]; !ok {
t.Fatalf("system message should become systemInstruction")
}
contents, ok := request["contents"].([]interface{})
if !ok || len(contents) != 2 {
t.Fatalf("expected 2 non-system contents, got %v", request["contents"])
}
first := contents[0].(map[string]interface{})
if first["role"] != "user" {
t.Fatalf("first content role should be user, got %v", first["role"])
}
second := contents[1].(map[string]interface{})
if second["role"] != "model" {
t.Fatalf("assistant should map to model role, got %v", second["role"])
}

// max_tokens is dropped for non-Claude models.
if gc, ok := request["generationConfig"].(map[string]interface{}); ok {
if _, present := gc["maxOutputTokens"]; present {
t.Fatalf("maxOutputTokens should be omitted for non-Claude models")
}
if gc["temperature"] != 0.5 {
t.Fatalf("temperature should carry through, got %v", gc["temperature"])
}
} else {
t.Fatalf("generationConfig missing")
}
}

func TestBuildAntigravityPayloadKeepsMaxTokensForClaude(t *testing.T) {
call := GatewayCall{
Model: Model{ID: "claude-sonnet-4-6"},
Body: ChatRequest{
Messages: []ChatMessage{{Role: "user", Content: "hi"}},
Payload: map[string]interface{}{"max_tokens": 2048},
},
}
encoded, providerErr := buildAntigravityPayload(call, "p")
if providerErr != nil {
t.Fatalf("unexpected provider error: %+v", providerErr)
}
var payload map[string]interface{}
_ = json.Unmarshal(encoded, &payload)
request := payload["request"].(map[string]interface{})
gc := request["generationConfig"].(map[string]interface{})
if gc["maxOutputTokens"] != float64(2048) {
t.Fatalf("Claude route should keep maxOutputTokens, got %v", gc["maxOutputTokens"])
}
}

func TestParseAntigravityResponseWrapped(t *testing.T) {
body := []byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"hello "},{"text":"world"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":22,"totalTokenCount":33}}}`)
text, parsed := parseAntigravityResponse(body)
if text != "hello world" {
t.Fatalf("expected concatenated text, got %q", text)
}
usage := antigravityUsage(parsed, nil)
if usage["prompt_tokens"] != 11 || usage["completion_tokens"] != 22 || usage["total_tokens"] != 33 {
t.Fatalf("usage mismatch: %+v", usage)
}
}

func TestAntigravityPartsMultimodal(t *testing.T) {
content := []interface{}{
map[string]interface{}{"type": "text", "text": "look"},
map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": "data:image/png;base64,AAAA"}},
}
parts := antigravityParts(content)
if len(parts) != 2 {
t.Fatalf("expected 2 parts, got %d", len(parts))
}
if parts[0]["text"] != "look" {
t.Fatalf("first part should be text, got %+v", parts[0])
}
inline, ok := parts[1]["inlineData"].(gin.H)
if !ok {
t.Fatalf("second part should carry inlineData, got %+v", parts[1])
}
if inline["mimeType"] != "image/png" || inline["data"] != "AAAA" {
t.Fatalf("inlineData mismatch: %+v", inline)
}
}

func TestParseDataURL(t *testing.T) {
mime, data, ok := parseDataURL("data:image/jpeg;base64,Zm9v")
if !ok || mime != "image/jpeg" || data != "Zm9v" {
t.Fatalf("unexpected parse: mime=%q data=%q ok=%v", mime, data, ok)
}
if _, _, ok := parseDataURL("https://example.com/x.png"); ok {
t.Fatalf("non data URL should not parse")
}
if _, _, ok := parseDataURL("data:text/plain,notbase64"); ok {
t.Fatalf("non-base64 data URL should not parse")
}
}
15 changes: 13 additions & 2 deletions cmd/capi/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,25 @@ func (s *Server) resolveOpenAIAccountAccessToken(account OpenAIAccount) (string,
}
return "", fmt.Errorf("网页会话 Cookie 已失效或不是有效的 __Secure-next-auth.session-token: %w", err)
}
if !openAIAccountAccessTokenExpiring(accessToken, account.ExpiresAt, 24*time.Hour) || !hasRefreshToken {
refreshLeeway := 24 * time.Hour
if isAntigravityAccount(account) {
// Google access tokens live ~1h; only refresh near expiry so a valid
// token is reused instead of exchanged on every request.
refreshLeeway = antigravityTokenSafetyWindow
}
if !openAIAccountAccessTokenExpiring(accessToken, account.ExpiresAt, refreshLeeway) || !hasRefreshToken {
return accessToken, nil
}
refreshToken, err := s.revealSecret(account.RefreshToken)
if err != nil {
return "", err
}
refreshed, err := s.refreshOpenAIAccount(refreshToken)
var refreshed OpenAIRefreshResult
if isAntigravityAccount(account) {
refreshed, err = s.refreshAntigravityAccount(refreshToken)
} else {
refreshed, err = s.refreshOpenAIAccount(refreshToken)
}
if err != nil {
return "", err
}
Expand Down
22 changes: 22 additions & 0 deletions cmd/capi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3203,6 +3203,19 @@ func (s *Server) importOpenAIAccounts(c *gin.Context) {
if strings.TrimSpace(account.AccessToken) == "" && strings.TrimSpace(account.SessionToken) != "" {
account.AccessToken, account.ExpiresAt, _ = fetchChatGPTAccessTokenViaSessionCookie(account.SessionToken)
}
// Antigravity exports may carry only a Google refresh token; mint an
// access token now so the account joins the active pool immediately.
if strings.TrimSpace(account.AccessToken) == "" && isAntigravitySource(account.Source) && strings.TrimSpace(account.RefreshToken) != "" {
if refreshed, err := s.refreshAntigravityAccount(account.RefreshToken); err == nil && strings.TrimSpace(refreshed.AccessToken) != "" {
account.AccessToken = refreshed.AccessToken
if strings.TrimSpace(refreshed.RefreshToken) != "" {
account.RefreshToken = refreshed.RefreshToken
}
if strings.TrimSpace(account.ExpiresAt) == "" {
account.ExpiresAt = refreshed.ExpiresAt
}
}
}
if strings.TrimSpace(account.AccessToken) == "" {
invalid++
continue
Expand Down Expand Up @@ -5754,6 +5767,9 @@ func (s *Server) callChatGPTCodex(call GatewayCall) (gin.H, *ProviderError) {
}

func (s *Server) callChatGPTCodexWithAccount(call GatewayCall, account OpenAIAccount, accessToken string) (gin.H, *ProviderError) {
if isAntigravityAccount(account) {
return s.callAntigravityWithAccount(call, account, accessToken)
}
if call.Channel.WebEndpoint {
return s.callChatGPTWebConversation(call, account, accessToken)
}
Expand Down Expand Up @@ -5835,6 +5851,9 @@ func (s *Server) streamChatGPTCodex(c *gin.Context, call GatewayCall) *ProviderE
}

func (s *Server) streamChatGPTCodexWithAccount(c *gin.Context, call GatewayCall, account OpenAIAccount, accessToken string) *ProviderError {
if isAntigravityAccount(account) {
return s.streamAntigravityWithAccount(c, call, account, accessToken)
}
if call.Channel.WebEndpoint {
return s.streamChatGPTWebConversation(c, call, account, accessToken)
}
Expand Down Expand Up @@ -7006,6 +7025,9 @@ func newUpstreamModelsRequest(endpoint, key string, anthropicAuth bool) (*http.R
}

func (s *Server) checkOpenAIAccount(account OpenAIAccount, channel Channel, allowProbe bool) OpenAIAccountCheckResult {
if isAntigravityAccount(account) {
return s.checkAntigravityAccount(account)
}
result := OpenAIAccountCheckResult{Status: "unchecked"}
accessToken, err := s.revealSecret(account.AccessToken)
if err != nil {
Expand Down
43 changes: 43 additions & 0 deletions dist/assets/index-B2BgeCFL.js

Large diffs are not rendered by default.

43 changes: 0 additions & 43 deletions dist/assets/index-Cv9ZCWeR.js

This file was deleted.

2 changes: 1 addition & 1 deletion dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CAPI</title>
<script type="module" crossorigin src="/assets/index-Cv9ZCWeR.js"></script>
<script type="module" crossorigin src="/assets/index-B2BgeCFL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-699x73P2.css">
</head>
<body>
Expand Down
77 changes: 76 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3062,6 +3062,7 @@ function DrawingView({
const [accountSorts, setAccountSorts] = useState<Record<string, "pool" | "oldest" | "recent" | "expiry">>({});
const [oauthChannelId, setOAuthChannelId] = useState("");
const [authSessionChannelId, setAuthSessionChannelId] = useState("");
const [antigravityChannelId, setAntigravityChannelId] = useState("");
const [addAccountChannelId, setAddAccountChannelId] = useState("");
const defaultVisibleAccounts = 24;
const accountBatchSize = 48;
Expand Down Expand Up @@ -3253,7 +3254,7 @@ function DrawingView({
{account.email || account.name || account.accountId || account.id}
</strong>
<span className={`source-tag source-tag-${account.source === "web-login" ? "web" : "manual"}`}>
{account.source === "web-login" ? "网页登录" : account.source === "web-oauth" ? "网页 OAuth" : account.source === "oauth" ? "Codex OAuth" : "导入"}
{account.source === "web-login" ? "网页登录" : account.source === "web-oauth" ? "网页 OAuth" : account.source === "oauth" ? "Codex OAuth" : (account.source === "antigravity" || account.source === "agy" || account.source === "google-antigravity") ? "Antigravity" : "导入"}
</span>
</div>
<span>{account.lastError ? `${accountErrorLabel(account.lastErrorCode)}${accountErrorLabel(account.lastErrorCode) ? " · " : ""}${account.lastError}` : (account.lastCheckedAt ? `上次检测 ${formatDate(account.lastCheckedAt)}` : "未检测")}</span>
Expand Down Expand Up @@ -3348,6 +3349,10 @@ function DrawingView({
setAuthSessionChannelId(addAccountChannelId);
setAddAccountChannelId("");
}}
onAntigravity={() => {
setAntigravityChannelId(addAccountChannelId);
setAddAccountChannelId("");
}}
onImport={async (file) => {
await importFile(addAccountChannelId, file);
setAddAccountChannelId("");
Expand All @@ -3363,18 +3368,28 @@ function DrawingView({
onClose={() => setAuthSessionChannelId("")}
/>
)}
{antigravityChannelId && (
<AntigravityModal
onImport={async (token) => {
await importFile(antigravityChannelId, new File([JSON.stringify(antigravityImportPayload(token))], "antigravity.json", { type: "application/json" }));
}}
onClose={() => setAntigravityChannelId("")}
/>
)}
</Panel>
);
}

function AccountAddModal({
busy,
onAuthSession,
onAntigravity,
onImport,
onClose
}: {
busy: boolean;
onAuthSession: () => void;
onAntigravity: () => void;
onImport: (file: File) => Promise<void>;
onClose: () => void;
}) {
Expand All @@ -3394,6 +3409,11 @@ function AccountAddModal({
<strong>导入网页会话</strong>
<small>粘贴完整 authsession JSON,保留 sessionToken</small>
</button>
<button type="button" className="account-add-option" onClick={onAntigravity} disabled={busy}>
<span className="account-add-icon">G</span>
<strong>导入 Antigravity</strong>
<small>粘贴 Google OAuth JSON 或 refresh token</small>
</button>
<label className={`account-add-option${busy ? " disabled" : ""}`}>
<span className="account-add-icon">J</span>
<strong>{busy ? "导入中" : "导入 JSON / ZIP / TXT"}</strong>
Expand Down Expand Up @@ -3473,6 +3493,61 @@ function AuthSessionModal({ onImport, onClose }: { onImport: (token: string) =>
</div></div>;
}

function antigravityImportPayload(value: string) {
const raw = value.trim();
try {
const json = JSON.parse(raw) as Record<string, unknown>;
const tokens = json.tokens && typeof json.tokens === "object" ? (json.tokens as Record<string, unknown>) : {};
const pick = (...keys: string[]) => {
for (const key of keys) {
if (typeof json[key] === "string" && json[key]) return json[key] as string;
if (typeof tokens[key] === "string" && tokens[key]) return tokens[key] as string;
}
return "";
};
const accessToken = pick("access_token", "accessToken");
const refreshToken = pick("refresh_token", "refreshToken");
const email = pick("email");
const expired = pick("expired", "expires_at", "expiresAt", "expiry");
if (accessToken || refreshToken) {
return {
accessToken: accessToken || undefined,
refreshToken: refreshToken || undefined,
email: email || undefined,
expired: expired || undefined,
source: "antigravity"
};
}
} catch {
// A bare refresh token is valid input and need not be JSON.
}
return { refreshToken: raw, source: "antigravity" };
}

function AntigravityModal({ onImport, onClose }: { onImport: (token: string) => Promise<void>; onClose: () => void }) {
const [token, setToken] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function submit() {
if (!token.trim()) { setError("请粘贴 Antigravity OAuth JSON 或 refresh token"); return; }
setBusy(true); setError(""); setSuccess("");
try {
await onImport(token.trim());
setToken("");
setSuccess("已导入,继续粘贴下一条即可");
} catch (err) { setError(err instanceof Error ? err.message : "导入失败"); } finally { setBusy(false); }
}
return <div className="modal-backdrop" onClick={onClose}><div className="modal-card" onClick={(event) => event.stopPropagation()}>
<div className="modal-head"><strong>导入 Antigravity 账号</strong><button type="button" className="icon-button" onClick={onClose}>×</button></div>
<p className="muted-inline">粘贴 Antigravity 的 Google OAuth 凭证 JSON(含 <code>access_token</code> / <code>refresh_token</code>),或直接粘贴 refresh token。导入后会自动刷新并加入账号池。</p>
<label className="authsession-field"><span>OAuth JSON / refresh token</span><textarea autoFocus value={token} onChange={(event) => setToken(event.target.value)} placeholder='{"access_token":"ya29...","refresh_token":"1//..."}' /></label>
{error && <div className="form-error">{error}</div>}
{success && <div className="form-success">{success}</div>}
<div className="modal-actions"><button type="button" className="secondary-button" onClick={onClose}>取消</button><button type="button" className="primary-button" disabled={busy} onClick={submit}>{busy ? "导入中" : "导入账号"}</button></div>
</div></div>;
}

function OpenAIOAuthModal({
channelId,
onStart,
Expand Down
Loading