Merge pull request #7651 from lederniermagicien/fix/copilot-fleet-skew-400

fix(ai): retry GitHub Copilot fleet-skew model 400s
This commit is contained in:
Can Bölük
2026-08-04 23:15:49 +02:00
committed by GitHub
10 changed files with 372 additions and 47 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed GitHub Copilot requests failing with a raw `HTTP 400 model_not_available_for_integrator` on roughly half of all turns for recently rolled-out models. Copilot's fleet is not uniform — part of it rejects models that `/models` advertises on the same host — and the transient classifier matched only the older `model_not_supported` code at a fixed envelope depth, so these rejections surfaced as terminal errors instead of entering the existing retry path. Model-availability 400s are now recognized at any envelope depth and rerolled on a flat delay with a dedicated 8-attempt budget on the OpenAI transports; every other retryable failure keeps its previous backoff and attempt count.
## [17.2.7] - 2026-08-03
### Changed
+42 -14
View File
@@ -107,10 +107,17 @@ const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|
export const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN =
/failed to parse tool call arguments as json|\[json\.exception\.parse_error\.101\]/i;
// Copilot routing flap: HTTP 400 `model_not_supported` (structural code on the
// error, also surfaced in text). Treated as transient — a retry usually lands
// on a backend that has the model.
const COPILOT_MODEL_NOT_SUPPORTED_PATTERN = /model_not_supported/i;
// Copilot fleet skew: HTTP 400 rejecting a model that `/models` advertised on
// the very same host. Two codes appear in the wild — `model_not_supported`
// (per-OAuth-client rollout gap) and `model_not_available_for_integrator`
// (replicas whose integrator allowlist predates the model). Both flap
// request-to-request, so a retry usually lands on a backend that has the model.
const COPILOT_TRANSIENT_MODEL_CODES: Record<string, true> = {
model_not_supported: true,
model_not_available_for_integrator: true,
};
const COPILOT_MODEL_UNAVAILABLE_PATTERN =
/model_not_supported|model_not_available_for_integrator|not available for integrator/i;
// Anthropic strict-tool grammar too large / schema too complex (400 invalid_request_error).
// Feature-gated deployments (Azure Foundry, Baseten, …) reject `strict: true`
// tools outright when the hosted model lacks structured outputs, e.g.
@@ -345,8 +352,8 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
kinds |= Flag.StaleResponsesItem;
}
// Copilot per-client routing flap is transient.
if (statusClean === 400 && COPILOT_MODEL_NOT_SUPPORTED_PATTERN.test(cleanMessage)) kinds |= Flag.Transient;
// Copilot fleet-skew model rejection is transient.
if (statusClean === 400 && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(cleanMessage)) kinds |= Flag.Transient;
if (matchesStrictToolsRejection(cleanMessage, statusClean)) kinds |= Flag.Grammar;
if (matchesFastModeUnsupported(cleanMessage, statusClean)) kinds |= Flag.FastModeUnsupported;
}
@@ -460,16 +467,37 @@ export function isFastModeUnsupported(error: unknown): boolean {
}
/**
* GitHub Copilot 400 `model_not_supported` routing flap — transient. Reads the
* structural `code` (and falls back to {@link Flag.Transient} text classification).
* Depth-bounded search for a provider error `code`. SDK error objects keep the
* parsed response body on `.error`, and Copilot's body is itself
* `{ error: { code } }`, so the code sits up to two envelopes below the thrown
* error depending on which SDK produced it.
*/
function providerErrorCode(error: object): string | undefined {
let node: object = error;
for (let depth = 0; depth < 3; depth++) {
if ("code" in node && typeof node.code === "string") return node.code;
if (!("error" in node)) return undefined;
const nested: unknown = node.error;
if (!nested || typeof nested !== "object") return undefined;
node = nested;
}
return undefined;
}
/**
* GitHub Copilot 400 rejecting a model its own `/models` catalog advertises —
* transient fleet skew, not a malformed request. Reads the structural `code`
* through the SDK/body envelopes, then falls back to the stringified body both
* SDK families put in `message` (shapes drift; the wire text does not).
*/
export function isCopilotTransientModelError(error: unknown): boolean {
if (status(error) === 400 && error && typeof error === "object") {
const info = error as { code?: unknown; error?: { code?: unknown } | null };
const code = typeof info.code === "string" ? info.code : info.error?.code;
if (code === "model_not_supported") return true;
}
return false;
if (!error || typeof error !== "object" || status(error) !== 400) return false;
const code = providerErrorCode(error);
// `Object.hasOwn`, not a bare index: `code` is provider-controlled, and a
// prototype key (`__proto__`, `toString`, …) would otherwise read truthy.
if (code !== undefined && Object.hasOwn(COPILOT_TRANSIENT_MODEL_CODES, code)) return true;
const message: unknown = "message" in error ? error.message : undefined;
return typeof message === "string" && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(message);
}
export function classifyMessage(message: {
+1 -1
View File
@@ -33,7 +33,7 @@ function isTransientTransportMessage(message: string): boolean {
export interface ProviderRetryableHooks {
/** Provider id of the failing request, used to gate provider-specific checks. */
provider?: string;
/** Provider-specific transient predicate (e.g. Copilot `model_not_supported`). */
/** Provider-specific transient predicate (e.g. Copilot model-availability 400s). */
isProviderTransient?: (error: Error) => boolean;
}
+15 -3
View File
@@ -1531,6 +1531,13 @@ async function* observeDecodedAnthropicSdkEvents(
const PROVIDER_MAX_RETRIES = 10;
/**
* Flat delay between attempts when Copilot 400s a model its own `/models`
* catalog advertises. Part of the fleet carries the model and part doesn't, so
* the retry is a reroll rather than a wait for capacity to free up.
*/
const COPILOT_MODEL_FLAP_RETRY_DELAY_MS = 400;
/**
* How long `ping` keepalives may keep extending the idle deadline without any
* semantic stream progress, as a multiple of the idle timeout. Anthropic pings
@@ -1561,8 +1568,8 @@ function shouldIgnoreAnthropicPreambleEvent(eventType: unknown): boolean {
/**
* Whether an Anthropic (or Copilot-over-Anthropic) stream error should be
* retried. The classification lives in {@link AIError.isProviderRetryableError};
* this wrapper injects the Copilot-specific `model_not_supported` transient
* check, which the error module must not import directly.
* this wrapper injects the Copilot-specific model-availability transient check,
* which the error module must not import directly.
*/
export function isProviderRetryableError(error: unknown, provider?: string): boolean {
return AIError.isProviderRetryableError(error, {
@@ -2709,7 +2716,12 @@ const streamAnthropicOnce = (
throw streamFailure;
}
providerRetryAttempt++;
const backoffDelayMs = calculateAnthropicRetryDelayMs(providerRetryAttempt - 1);
// Copilot's model-availability 400 is a per-request replica reroll, not
// upstream backpressure — the exponential curve would just add dead
// time to a coin flip that the next attempt is as likely to win.
const backoffDelayMs = AIError.isCopilotTransientModelError(streamFailure)
? COPILOT_MODEL_FLAP_RETRY_DELAY_MS
: calculateAnthropicRetryDelayMs(providerRetryAttempt - 1);
// Honor the server's retry hint (`retry-after-ms`/`retry-after`) on
// 429/529-style failures: retrying sooner than the server asked is a
// guaranteed failure that just burns the retry budget.
+6 -5
View File
@@ -95,10 +95,11 @@ export async function finalizeErrorMessage(
* Rewrite error message for GitHub Copilot request failures.
* Must run AFTER finalizeErrorMessage since it replaces the message entirely.
*
* 400 `model_not_supported` = Copilot routing rollout gap for our OAuth client.
* A preview model (gpt-5.3-codex, gpt-5.4*, ...) flaps between 200 and
* 400 because only some of Copilot's backends have the model. After the
* in-request retry exhausts, surface guidance rather than the raw error.
* 400 model-unavailable = Copilot fleet skew. A model that `/models` advertises
* (claude-sonnet-4.6, claude-opus-4.6, gpt-5.4, gpt-5.3-codex, ...)
* flaps between 200 and 400 because only part of Copilot's fleet has it
* in the integrator allowlist. After the in-request retry exhausts,
* surface guidance rather than the raw error.
* 401 = token invalid/expired → credential removal is safe, prompt re-login.
* 403 = token valid but access denied (plan, model policy, org restriction) →
* do NOT reuse the auth-failed string (which triggers credential removal).
@@ -113,7 +114,7 @@ export function rewriteCopilotError(errorMessage: string, error: unknown, provid
return `GitHub Copilot access denied (HTTP 403). Your account may not have access to this model or feature. Check your Copilot plan or model policy settings.`;
}
if (isCopilotTransientModelError(error)) {
return `GitHub Copilot rejected this model (HTTP 400 model_not_supported) after retries. This is a known intermittent rollout gap for preview models on OAuth clients other than VS Code. Try again in a few seconds, switch to a GA model (gpt-5-mini, gpt-5.2), or run this model from VS Code.`;
return `GitHub Copilot rejected this model (HTTP 400) after retries: only part of its fleet currently serves this model id, even though /models advertises it. Try again in a few seconds or switch to a model Copilot serves fleet-wide (claude-opus-4.7, claude-sonnet-4.5, gpt-4.1).`;
}
return errorMessage;
}
+24 -5
View File
@@ -7,14 +7,25 @@ import { getHeadersFromError, getRetryAfterMsFromHeaders } from "./retry-after";
// home). Re-exported here so existing `../utils/retry` importers keep working.
export { isCopilotTransientModelError };
const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 3;
// Copilot's model-availability flap is a per-request coin flip across fleet
// replicas, not backpressure. Measured per-attempt rejection rates for models
// mid-rollout reach ~70% (gpt-5.4, 2026-08-04), and a live 10-turn run needed 6
// attempts on one turn — so a small budget just pushes the failure up to the
// agent-level retry, which restarts the whole turn. Eight attempts on a flat
// delay keep the residual near 5% at p=0.7 and under 1% at p=0.5, bounded at
// ~2.8s of dead time in the pathological case.
const COPILOT_MODEL_RETRY_MAX_ATTEMPTS = 8;
// Transport blips and status-bearing failures keep the pre-flap budget: they are
// not coin flips, so a longer ramp only delays surfacing a persistent fault.
const COPILOT_GENERIC_RETRY_MAX_ATTEMPTS = 3;
const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400;
/** Longest server-requested backoff we are willing to sit out before giving up. */
const COPILOT_RETRY_AFTER_MAX_WAIT_MS = 30_000;
/**
* Wrap an initial Copilot request so transient `model_not_supported` 400s are
* retried a small number of times. No-op for non-Copilot providers.
* Wrap an initial Copilot request so transient model-availability 400s
* (`model_not_supported`, `model_not_available_for_integrator`) are retried a
* small number of times. No-op for non-Copilot providers.
*
* The callback **MUST** create a fresh in-flight request each invocation — a
* once-consumed AsyncIterable cannot be re-iterated.
@@ -38,8 +49,16 @@ export async function callWithCopilotModelRetry<T>(
if (options.signal?.aborted) throw error;
const transientModelError = isCopilotTransientModelError(error);
if (!transientModelError && !isRetryableError(error)) throw error;
if (attempt === COPILOT_MODEL_RETRY_MAX_ATTEMPTS - 1) break;
let delayMs = retryBaseDelayMs * (attempt + 1);
// Budget is per failure kind, counted over attempts already spent: the
// eight-attempt allowance only covers the cheap model-availability reroll.
const maxAttempts = transientModelError
? COPILOT_MODEL_RETRY_MAX_ATTEMPTS
: COPILOT_GENERIC_RETRY_MAX_ATTEMPTS;
if (attempt >= maxAttempts - 1) break;
// Reroll the model flap on a flat delay: a ramp only adds dead time to a
// coin flip the next attempt is equally likely to win. Generic retryable
// failures (429/5xx/transport) keep the linear backoff below.
let delayMs = transientModelError ? retryBaseDelayMs : retryBaseDelayMs * (attempt + 1);
if (!transientModelError) {
const errorStatus = status(error);
if (errorStatus !== undefined) {
+18
View File
@@ -105,4 +105,22 @@ describe("isProviderRetryableError", () => {
expect(isProviderRetryableError(err, "anthropic")).toBe(false);
expect(isProviderRetryableError(err)).toBe(false);
});
it("retries Copilot's model_not_available_for_integrator 400 from the Anthropic messages proxy", () => {
// Shape thrown by @anthropic-ai/sdk against api.githubcopilot.com/v1/messages:
// the parsed body lands on `.error` and is itself `{ error: { code } }`.
const body = {
error: {
message:
'The requested model is not available for integrator "copilot-language-server". Available models: [gpt-4.1 claude-opus-4.7]. Verify the correct Copilot-Integration-Id header is being sent.',
code: "model_not_available_for_integrator",
param: "model",
type: "invalid_request_error",
},
};
const err = new Error(`400 ${JSON.stringify(body)}`);
Object.assign(err, { status: 400, error: body });
expect(isProviderRetryableError(err, "github-copilot")).toBe(true);
expect(isProviderRetryableError(err, "anthropic")).toBe(false);
});
});
+127 -11
View File
@@ -1,14 +1,29 @@
import { describe, expect, it } from "bun:test";
import { afterEach, describe, expect, it, vi } from "bun:test";
import { scheduler } from "node:timers/promises";
import { callWithCopilotModelRetry, isCopilotTransientModelError } from "@oh-my-pi/pi-ai/utils/retry";
import { isRetryableError } from "@oh-my-pi/pi-utils";
type ErrorShape = { status: number; code?: string; error?: { code?: string; message?: string }; message: string };
afterEach(() => {
vi.restoreAllMocks();
});
function copilotError({ status, code, error, message }: ErrorShape): Error {
type ErrorShape = {
status: number;
code?: string;
error?: { code?: string; message?: string } | { error: { code?: string; message?: string } };
message: string;
headers?: Record<string, string>;
};
function copilotError({ status, code, error, message, headers }: ErrorShape): Error {
const err = new Error(message);
(err as unknown as ErrorShape).status = status;
if (code !== undefined) (err as unknown as ErrorShape).code = code;
if (error !== undefined) (err as unknown as ErrorShape).error = error;
// Single sanctioned assertion point: `Error` carries no provider fields, and
// every test reads them back through the real classifier.
const shaped = err as unknown as ErrorShape;
shaped.status = status;
if (code !== undefined) shaped.code = code;
if (error !== undefined) shaped.error = error;
if (headers !== undefined) shaped.headers = headers;
return err;
}
@@ -31,6 +46,31 @@ describe("isCopilotTransientModelError", () => {
expect(isCopilotTransientModelError(err)).toBe(true);
});
it("matches 400 model_not_available_for_integrator nested two envelopes deep (Anthropic SDK shape)", () => {
// api.githubcopilot.com/v1/messages: the SDK stores the parsed body on
// `.error`, and that body is itself `{ error: { code } }`.
const err = copilotError({
status: 400,
error: {
error: {
code: "model_not_available_for_integrator",
message: 'The requested model is not available for integrator "copilot-language-server".',
},
},
message:
'400 {"error":{"message":"The requested model is not available for integrator \\"copilot-language-server\\". Available models: [gpt-4.1 claude-opus-4.7]","code":"model_not_available_for_integrator","param":"model","type":"invalid_request_error"}}',
});
expect(isCopilotTransientModelError(err)).toBe(true);
});
it("falls back to the stringified body when no envelope exposes a code", () => {
const err = copilotError({
status: 400,
message: '400 {"error":{"message":"The requested model is not available for integrator \\"x\\"."}}',
});
expect(isCopilotTransientModelError(err)).toBe(true);
});
it("does not match other 400 codes", () => {
const err = copilotError({
status: 400,
@@ -40,6 +80,13 @@ describe("isCopilotTransientModelError", () => {
expect(isCopilotTransientModelError(err)).toBe(false);
});
it("does not match 400 codes that collide with Object.prototype keys", () => {
for (const code of ["__proto__", "constructor", "toString", "hasOwnProperty"]) {
const err = copilotError({ status: 400, code, message: "bad request" });
expect(isCopilotTransientModelError(err)).toBe(false);
}
});
it("does not match 401/403/500 regardless of code", () => {
for (const status of [401, 403, 500]) {
const err = copilotError({
@@ -74,7 +121,7 @@ describe("callWithCopilotModelRetry", () => {
expect(calls).toBe(1);
});
it("retries up to 3 attempts for Copilot transient errors and eventually throws the last error", async () => {
it("retries up to 8 attempts for Copilot transient errors and eventually throws the last error", async () => {
let calls = 0;
const err = copilotError({ status: 400, code: "model_not_supported", message: "transient" });
await expect(
@@ -86,7 +133,7 @@ describe("callWithCopilotModelRetry", () => {
{ provider: "github-copilot", retryBaseDelayMs: 0 },
),
).rejects.toBe(err);
expect(calls).toBe(3);
expect(calls).toBe(8);
});
it("succeeds on the second attempt when the first is transient", async () => {
@@ -141,9 +188,7 @@ describe("callWithCopilotModelRetry", () => {
async () => {
calls += 1;
if (calls === 1) {
const err = copilotError({ status: 429, message: "rate limited" });
(err as unknown as { headers: Record<string, string> }).headers = { "retry-after": "0.01" };
throw err;
throw copilotError({ status: 429, message: "rate limited", headers: { "retry-after": "0.01" } });
}
return "ok" as const;
},
@@ -153,6 +198,21 @@ describe("callWithCopilotModelRetry", () => {
expect(calls).toBe(2);
});
it("does not stretch a persistent Retry-After 429 across the flap budget", async () => {
let calls = 0;
const err = copilotError({ status: 429, message: "rate limited", headers: { "retry-after": "0.01" } });
await expect(
callWithCopilotModelRetry(
async () => {
calls += 1;
throw err;
},
{ provider: "github-copilot", retryBaseDelayMs: 0 },
),
).rejects.toBe(err);
expect(calls).toBe(3);
});
it("still retries status-less transport blips with the linear backoff", async () => {
let calls = 0;
const result = await callWithCopilotModelRetry(
@@ -171,6 +231,62 @@ describe("callWithCopilotModelRetry", () => {
expect(calls).toBe(2);
});
it("caps persistent generic retryable failures at the pre-flap budget", async () => {
let calls = 0;
const err = new Error(
'HTTP2StreamReset fetching "https://api.example.com/x". For more information, pass `verbose: true` in the second argument to fetch()',
);
await expect(
callWithCopilotModelRetry(
async () => {
calls += 1;
throw err;
},
{ provider: "github-copilot", retryBaseDelayMs: 0 },
),
).rejects.toBe(err);
expect(calls).toBe(3);
});
it("keeps the flat delay and the larger budget scoped to model flaps", async () => {
const flatWaits: number[] = [];
const rampWaits: number[] = [];
const record = (into: number[]) => {
const spy = vi.spyOn(scheduler, "wait");
spy.mockImplementation(async (delay?: number) => {
into.push(delay ?? 0);
});
return spy;
};
record(flatWaits);
await expect(
callWithCopilotModelRetry(
async () => {
throw copilotError({ status: 400, code: "model_not_available_for_integrator", message: "flap" });
},
{ provider: "github-copilot", retryBaseDelayMs: 100 },
),
).rejects.toBeInstanceOf(Error);
vi.restoreAllMocks();
record(rampWaits);
await expect(
callWithCopilotModelRetry(
async () => {
throw new Error(
'HTTP2StreamReset fetching "https://api.example.com/x". For more information, pass `verbose: true` in the second argument to fetch()',
);
},
{ provider: "github-copilot", retryBaseDelayMs: 100 },
),
).rejects.toBeInstanceOf(Error);
vi.restoreAllMocks();
expect(flatWaits).toEqual([100, 100, 100, 100, 100, 100, 100]);
expect(rampWaits).toEqual([100, 200]);
});
it("stops retrying when the caller aborts during backoff", async () => {
const controller = new AbortController();
controller.abort();
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic";
import type { Context, Model } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { OPENCODE_HEADERS } from "@oh-my-pi/pi-catalog/wire/github-copilot";
afterEach(() => {
vi.restoreAllMocks();
});
function makeCopilotClaudeModel(): Model<"anthropic-messages"> {
return buildModel({
id: "claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
api: "anthropic-messages",
provider: "github-copilot",
baseUrl: "https://api.githubcopilot.com",
headers: { ...OPENCODE_HEADERS },
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_000,
});
}
const testContext: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
/**
* Verbatim body served by `api.githubcopilot.com/v1/messages` when the request
* lands on a fleet replica whose integrator allowlist predates the model, even
* though `/models` on the same host advertises it.
*/
const FLEET_SKEW_BODY = {
error: {
message:
'The requested model is not available for integrator "copilot-language-server". Available models: [gpt-4.1 claude-opus-4.7 claude-sonnet-4.5]. Verify the correct Copilot-Integration-Id header is being sent.',
code: "model_not_available_for_integrator",
param: "model",
type: "invalid_request_error",
},
};
const SSE_EVENTS = [
{
type: "message_start",
message: {
id: "msg_fleet",
type: "message",
role: "assistant",
model: "claude-sonnet-4.6",
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 7, output_tokens: 0 },
},
},
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "second try" } },
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: null },
usage: { output_tokens: 3 },
},
{ type: "message_stop" },
];
function sseResponse(): Response {
const body = `${SSE_EVENTS.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n`).join("\n")}\n`;
return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } });
}
describe("GitHub Copilot Anthropic fleet skew", () => {
it("retries the model-availability 400 and completes on the next replica", async () => {
let attempts = 0;
const fetchMock = vi.fn(async () => {
attempts += 1;
if (attempts === 1) {
return new Response(JSON.stringify(FLEET_SKEW_BODY), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
return sseResponse();
});
const result = await streamAnthropic(makeCopilotClaudeModel(), testContext, {
apiKey: "ghu_test_copilot_token",
fetch: fetchMock as unknown as typeof fetch,
providerRetryWait: async () => {},
}).result();
expect(attempts).toBe(2);
expect(result.stopReason).toBe("stop");
expect(result.errorMessage).toBeUndefined();
expect(result.content).toMatchObject([{ type: "text", text: "second try" }]);
// Billing is per user prompt, not per wire attempt: a turn that burned an
// extra gateway-rejected attempt must still report one premium request.
expect(result.usage.premiumRequests).toBe(1);
});
it("surfaces fleet-skew guidance once every retry lands on a stale replica", async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify(FLEET_SKEW_BODY), {
status: 400,
headers: { "Content-Type": "application/json" },
}),
);
const result = await streamAnthropic(makeCopilotClaudeModel(), testContext, {
apiKey: "ghu_test_copilot_token",
fetch: fetchMock as unknown as typeof fetch,
providerRetryWait: async () => {},
}).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("only part of its fleet");
// Every attempt is a fresh wire request, not a replayed promise.
expect(fetchMock.mock.calls.length).toBeGreaterThan(1);
});
});
+10 -8
View File
@@ -33,14 +33,16 @@ describe("rewriteCopilotError", () => {
expect(result).not.toContain("/login github-copilot");
});
it("rewrites 400 model_not_supported with rollout-gap guidance", () => {
const err = new Error("400 The requested model is not supported.");
(err as unknown as { status: number; code: string }).status = 400;
(err as unknown as { status: number; code: string }).code = "model_not_supported";
const result = rewriteCopilotError("original", err, "github-copilot");
expect(result).toContain("HTTP 400 model_not_supported");
expect(result).toContain("rollout gap");
expect(result).not.toContain("authentication failed");
it("rewrites 400 model-unavailable codes with fleet-skew guidance", () => {
for (const code of ["model_not_supported", "model_not_available_for_integrator"]) {
const err = new Error("400 The requested model is not available.");
(err as unknown as { status: number; code: string }).status = 400;
(err as unknown as { status: number; code: string }).code = code;
const result = rewriteCopilotError("original", err, "github-copilot");
expect(result).toContain("HTTP 400");
expect(result).toContain("only part of its fleet");
expect(result).not.toContain("authentication failed");
}
});
it("leaves non-copilot 400 model_not_supported untouched", () => {