mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
fix(providers): poll onboardUser LRO before classifying BYOP (#11519)
Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição.
This commit is contained in:
@@ -30,6 +30,16 @@ const BOOTSTRAP_TIMEOUT_MS = 8_000;
|
||||
const ONBOARD_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_TIER_ID = "legacy-tier";
|
||||
|
||||
// onboardUser is a Long-Running Operation: Google frequently answers the
|
||||
// first call with {"done": false} (no cloudaicompanionProject field yet) and
|
||||
// expects the SAME request re-sent every couple of seconds until the
|
||||
// operation settles with {"done": true, response: {...}}. Treating the
|
||||
// first "done:false" response as "no project" (BYOP) misclassifies a normal
|
||||
// in-progress onboarding as "bring your own project" and permanently caches
|
||||
// that wrong verdict. Poll bounded, matching 9router's onboardUser().
|
||||
const ONBOARD_POLL_MAX_ATTEMPTS = 5;
|
||||
const ONBOARD_POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
/** Ordered list of loadCodeAssist endpoint URLs. */
|
||||
export function getAntigravityLoadCodeAssistUrls(): string[] {
|
||||
return ANTIGRAVITY_BOOTSTRAP_BASE_URLS.map((base) => `${base}${LOAD_CODE_ASSIST_PATH}`);
|
||||
@@ -162,10 +172,31 @@ async function tryLoadCodeAssist(
|
||||
return { projectId: null, tierId: DEFAULT_TIER_ID };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the project id from a settled ({done:true}) onboardUser response body.
|
||||
* The documented LRO shape nests it under `response.cloudaicompanionProject`
|
||||
* (matches 9router's onboardUser and Google's Operation envelope), but some
|
||||
* observed responses put it at the top level — check both.
|
||||
*/
|
||||
function extractProjectIdFromOnboardResponse(data: Record<string, unknown> | null): string | null {
|
||||
const nested = (data?.response as Record<string, unknown> | undefined)?.cloudaicompanionProject;
|
||||
const project = nested ?? data?.cloudaicompanionProject;
|
||||
if (typeof project === "string") {
|
||||
const id = project.trim();
|
||||
return id || null;
|
||||
}
|
||||
if (project && typeof project === "object") {
|
||||
const id = (project as Record<string, unknown>).id;
|
||||
if (typeof id === "string" && id.trim()) return id.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt onboardUser to create a Cloud Code project for the account.
|
||||
* Called when loadCodeAssist returns no project — the account has never
|
||||
* been onboarded. Returns true if any endpoint reports success.
|
||||
* been onboarded. Polls the same endpoint on {done:false} responses (an
|
||||
* in-progress LRO) before concluding anything about the account.
|
||||
*/
|
||||
async function tryOnboardUser(
|
||||
accessToken: string,
|
||||
@@ -176,47 +207,74 @@ async function tryOnboardUser(
|
||||
): Promise<AntigravityOnboardStatus> {
|
||||
const urls = getAntigravityOnboardUrls();
|
||||
const headers = getAntigravityContentHeaders(clientProfile, accessToken);
|
||||
const body = JSON.stringify({
|
||||
tier_id: tierId,
|
||||
metadata: getAntigravityLoadCodeAssistMetadata(),
|
||||
});
|
||||
|
||||
for (const url of urls) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
try {
|
||||
const timeoutSignal = AbortSignal.timeout(ONBOARD_TIMEOUT_MS);
|
||||
const response = await fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
tier_id: tierId,
|
||||
metadata: getAntigravityLoadCodeAssistMetadata(),
|
||||
}),
|
||||
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
||||
});
|
||||
for (let attempt = 1; attempt <= ONBOARD_POLL_MAX_ATTEMPTS; attempt++) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
try {
|
||||
const timeoutSignal = AbortSignal.timeout(ONBOARD_TIMEOUT_MS);
|
||||
const response = await fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Accounts Google expects to Bring Their Own Project: onboardUser
|
||||
// returns 200 without a `cloudaicompanionProject` in the body — no
|
||||
// automatic project creation for standard-tier/personal accounts
|
||||
// (tracked in #8491). Detect that so we can fail fast with a clear
|
||||
// instruction instead of retrying forever or fabricating an id that
|
||||
// Google later rejects with a delayed 429 RESOURCE_EXHAUSTED.
|
||||
const body = await response.text().catch(() => "");
|
||||
if (body && !/cloudaicompanionProject/.test(body)) {
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[models] antigravity onboardUser done but no project in response at ${url} — Google BYOP (user-defined GCP project) required`
|
||||
`[models] antigravity onboardUser failed at ${url} (${response.status}) — trying next`
|
||||
);
|
||||
return "requires_manual_project";
|
||||
break;
|
||||
}
|
||||
return "onboarded";
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[models] antigravity onboardUser failed at ${url} (${response.status}) — trying next`
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) {
|
||||
throw signal?.reason ?? error;
|
||||
const data = (await response.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
|
||||
// Only an EXPLICIT `done: false` means "in-progress LRO, poll again".
|
||||
// A proper Google Operation always carries `done` when it is one; a
|
||||
// response with `done` absent entirely (e.g. `{}`) is not an LRO in
|
||||
// progress — it's Google's immediate, settled "no project" answer for
|
||||
// BYOP accounts (#8491) and must fall through to that classification
|
||||
// on the first attempt, same as before this polling was added.
|
||||
if (data?.done === false) {
|
||||
// In-progress LRO — Google hasn't decided (project created, or
|
||||
// BYOP required) yet. Re-send the same request after a short wait.
|
||||
if (attempt < ONBOARD_POLL_MAX_ATTEMPTS) {
|
||||
console.warn(
|
||||
`[models] antigravity onboardUser at ${url} not done yet (attempt ${attempt}/${ONBOARD_POLL_MAX_ATTEMPTS}) — waiting`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, ONBOARD_POLL_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
console.warn(
|
||||
`[models] antigravity onboardUser at ${url} still not done after ${ONBOARD_POLL_MAX_ATTEMPTS} attempts — treating as failed`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// done:true — Google has settled the operation. Accounts Google
|
||||
// expects to Bring Their Own Project answer with done:true and no
|
||||
// cloudaicompanionProject — no automatic project creation for
|
||||
// standard-tier/personal accounts (tracked in #8491). Only now is it
|
||||
// safe to draw that conclusion.
|
||||
if (extractProjectIdFromOnboardResponse(data)) {
|
||||
return "onboarded";
|
||||
}
|
||||
console.warn(
|
||||
`[models] antigravity onboardUser done but no project in response at ${url} — Google BYOP (user-defined GCP project) required`
|
||||
);
|
||||
return "requires_manual_project";
|
||||
} catch (error) {
|
||||
if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) {
|
||||
throw signal?.reason ?? error;
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[models] antigravity onboardUser threw for ${url}: ${msg} — trying next`);
|
||||
break;
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[models] antigravity onboardUser threw for ${url}: ${msg} — trying next`);
|
||||
}
|
||||
}
|
||||
return "failed";
|
||||
|
||||
132
tests/unit/antigravity-onboard-lro-polling.test.ts
Normal file
132
tests/unit/antigravity-onboard-lro-polling.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tests: onboardUser Long-Running-Operation polling in
|
||||
* ensureAntigravityProjectAssigned.
|
||||
*
|
||||
* onboardUser is a Google LRO: the first call frequently answers with
|
||||
* {"done": false} (no cloudaicompanionProject field yet) and expects the
|
||||
* SAME request re-sent every couple of seconds until the operation settles
|
||||
* with {"done": true, response: {...}}. Treating that first "done:false"
|
||||
* response as "no project" (BYOP) misclassifies a normal in-progress
|
||||
* onboarding as "bring your own project" and permanently caches the wrong
|
||||
* verdict for the process lifetime — every subsequent request for that
|
||||
* account 422s with "Missing Google projectId" even though onboarding would
|
||||
* have succeeded on the next poll. Reproduces the exact symptom reported in
|
||||
* https://github.com/diegosouzapw/OmniRoute/issues/11379 (repeated
|
||||
* "loadCodeAssist ... returned no project id" across reconnect/restart).
|
||||
*
|
||||
* These tests verify:
|
||||
* 1. A {"done": false} response is polled (same endpoint, re-sent) rather
|
||||
* than immediately classified as BYOP.
|
||||
* 2. A subsequent {"done": true, response: {cloudaicompanionProject}}
|
||||
* resolves the project id.
|
||||
* 3. A genuinely empty {} response (no `done` field at all — not an LRO in
|
||||
* progress, Google's immediate "no project" answer) is still classified
|
||||
* as BYOP on the FIRST attempt, unchanged from before this fix — this is
|
||||
* the case #8491 introduced, and must not regress.
|
||||
* 4. Polling is bounded (does not loop forever on a stuck operation).
|
||||
*/
|
||||
|
||||
import { test, describe, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ensureAntigravityProjectAssigned,
|
||||
clearAntigravityProjectCache,
|
||||
ANTIGRAVITY_REQUIRES_MANUAL_PROJECT,
|
||||
} from "../../open-sse/services/antigravityProjectBootstrap.ts";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAntigravityProjectCache();
|
||||
});
|
||||
|
||||
describe("ensureAntigravityProjectAssigned — onboardUser LRO polling", () => {
|
||||
test("polls a {done:false} response instead of treating it as BYOP", async () => {
|
||||
let onboardCalls = 0;
|
||||
let onboarded = false;
|
||||
|
||||
const mockFetch = async (url: string): Promise<Response> => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
// Post-onboard retry (ensureAntigravityProjectAssigned re-calls
|
||||
// loadCodeAssist once onboardUser reports success) now finds the
|
||||
// freshly-created project.
|
||||
return onboarded
|
||||
? new Response(JSON.stringify({ cloudaicompanionProject: "proj-after-poll" }), {
|
||||
status: 200,
|
||||
})
|
||||
: new Response(JSON.stringify({}), { status: 200 });
|
||||
}
|
||||
if (url.includes("onboardUser")) {
|
||||
onboardCalls++;
|
||||
if (onboardCalls === 1) {
|
||||
return new Response(JSON.stringify({ done: false }), { status: 200 });
|
||||
}
|
||||
onboarded = true;
|
||||
return new Response(
|
||||
JSON.stringify({ done: true, response: { cloudaicompanionProject: "proj-after-poll" } }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
const projectId = await ensureAntigravityProjectAssigned(
|
||||
"test-token",
|
||||
mockFetch as typeof fetch,
|
||||
"ide"
|
||||
);
|
||||
|
||||
assert.equal(onboardCalls, 2, "should have polled onboardUser a second time");
|
||||
assert.equal(projectId, "proj-after-poll");
|
||||
});
|
||||
|
||||
test("still classifies a genuinely empty {} response as BYOP on the first attempt (#8491, no regression)", async () => {
|
||||
let onboardCalls = 0;
|
||||
|
||||
const mockFetch = async (url: string): Promise<Response> => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return new Response(JSON.stringify({}), { status: 200 });
|
||||
}
|
||||
if (url.includes("onboardUser")) {
|
||||
onboardCalls++;
|
||||
return new Response(JSON.stringify({}), { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
const result = await ensureAntigravityProjectAssigned(
|
||||
"test-token",
|
||||
mockFetch as typeof fetch,
|
||||
"ide"
|
||||
);
|
||||
|
||||
assert.equal(onboardCalls, 1, "an absent `done` field must not trigger polling");
|
||||
assert.equal(result, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT);
|
||||
});
|
||||
|
||||
test("bounds polling instead of looping forever on a stuck operation", async () => {
|
||||
let onboardCalls = 0;
|
||||
|
||||
const mockFetch = async (url: string): Promise<Response> => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return new Response(JSON.stringify({}), { status: 200 });
|
||||
}
|
||||
if (url.includes("onboardUser")) {
|
||||
onboardCalls++;
|
||||
return new Response(JSON.stringify({ done: false }), { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
const result = await ensureAntigravityProjectAssigned(
|
||||
"test-token",
|
||||
mockFetch as typeof fetch,
|
||||
"ide"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
onboardCalls > 1 && onboardCalls <= 5,
|
||||
`expected bounded polling, got ${onboardCalls} calls`
|
||||
);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user