diff --git a/changelog.d/fixes/8136-antigravity-400-lockout.md b/changelog.d/fixes/8136-antigravity-400-lockout.md new file mode 100644 index 0000000000..10de14be52 --- /dev/null +++ b/changelog.d/fixes/8136-antigravity-400-lockout.md @@ -0,0 +1 @@ +- fix(providers): classify HTTP 400 model-unavailable as MODEL_NOT_FOUND so Antigravity Pro fallback locks out the deprecated model diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 9aebe0531d..948ff21144 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -232,8 +232,18 @@ export function classifyProviderError( } if (statusCode >= 500) return PROVIDER_ERROR_TYPES.SERVER_ERROR; - if (statusCode === 400 && isContextOverflow(bodyStr)) { - return PROVIDER_ERROR_TYPES.CONTEXT_OVERFLOW; + if (statusCode === 400) { + if (isContextOverflow(bodyStr)) { + return PROVIDER_ERROR_TYPES.CONTEXT_OVERFLOW; + } + // Some providers (e.g. Antigravity's Pro-fallback chain, #8136) return a + // plain 400 for a model that is no longer available, instead of 404/401. + // Without this check the error falls through to `return null`, so + // lockModel() never fires and the same dead model gets retried on every + // request. Detect the phrasing here, same as the 401 branch above (#7268). + if (containsModelUnavailableMessage(bodyStr)) { + return PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND; + } } return null; diff --git a/tests/unit/repro-8136-antigravity-400-model-not-supported-lockout.test.ts b/tests/unit/repro-8136-antigravity-400-model-not-supported-lockout.test.ts new file mode 100644 index 0000000000..6d040f4554 --- /dev/null +++ b/tests/unit/repro-8136-antigravity-400-model-not-supported-lockout.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +const { classifyProviderError, PROVIDER_ERROR_TYPES } = await import( + "../../open-sse/services/errorClassifier.ts" +); + +test("#8136: classifyProviderError(400, Antigravity 'model is not supported' body) returns MODEL_NOT_FOUND", () => { + const body = { + error: { + code: 400, + message: "The model gemini-3.1-pro-low is not supported for this project.", + status: "INVALID_ARGUMENT", + }, + }; + const classified = classifyProviderError(400, body, "antigravity"); + assert.equal( + classified, + PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND, + `expected MODEL_NOT_FOUND, got ${JSON.stringify(classified)}` + ); +}); + +test("#8136: same phrasing at 401 still works (regression guard for #7268)", () => { + const body = { error: { message: "The model gemini-3.1-pro-low is not supported for this project." } }; + assert.equal(classifyProviderError(401, body, "antigravity"), PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND); +}); + +test("#8136: plain 400 context-overflow still classifies as CONTEXT_OVERFLOW, not MODEL_NOT_FOUND", () => { + const body = { + error: { + message: "This model's maximum context length is 128000 tokens. Please reduce the length of the messages.", + }, + }; + assert.equal( + classifyProviderError(400, body, "antigravity"), + PROVIDER_ERROR_TYPES.CONTEXT_OVERFLOW + ); +}); + +test("#8136: a generic 400 bad-request body (no model-unavailable phrasing) is not reclassified as MODEL_NOT_FOUND", () => { + const body = { error: { message: "Invalid request: missing required field 'messages'." } }; + assert.equal(classifyProviderError(400, body, "antigravity"), null); +});