diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3f4da9b40..15c9f504e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index 09e54f17a..4e348493b 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -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 = { + 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: { diff --git a/packages/ai/src/error/retryable.ts b/packages/ai/src/error/retryable.ts index 90a065ee3..af2f3ae87 100644 --- a/packages/ai/src/error/retryable.ts +++ b/packages/ai/src/error/retryable.ts @@ -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; } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 880a6b42e..9232364b4 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -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. diff --git a/packages/ai/src/utils/http-inspector.ts b/packages/ai/src/utils/http-inspector.ts index bf4f4e047..b62e0f5fd 100644 --- a/packages/ai/src/utils/http-inspector.ts +++ b/packages/ai/src/utils/http-inspector.ts @@ -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; } diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index d89ee81b1..fce896bce 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -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( 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) { diff --git a/packages/ai/test/anthropic-retry.test.ts b/packages/ai/test/anthropic-retry.test.ts index 9f20af608..8ab9a26e0 100644 --- a/packages/ai/test/anthropic-retry.test.ts +++ b/packages/ai/test/anthropic-retry.test.ts @@ -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); + }); }); diff --git a/packages/ai/test/copilot-retry.test.ts b/packages/ai/test/copilot-retry.test.ts index 79b79d85f..850d26506 100644 --- a/packages/ai/test/copilot-retry.test.ts +++ b/packages/ai/test/copilot-retry.test.ts @@ -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; +}; + +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 }).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(); diff --git a/packages/ai/test/github-copilot-anthropic-fleet-skew.test.ts b/packages/ai/test/github-copilot-anthropic-fleet-skew.test.ts new file mode 100644 index 000000000..3146cfac5 --- /dev/null +++ b/packages/ai/test/github-copilot-anthropic-fleet-skew.test.ts @@ -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); + }); +}); diff --git a/packages/ai/test/github-copilot-error.test.ts b/packages/ai/test/github-copilot-error.test.ts index e7fce6edb..badf9be60 100644 --- a/packages/ai/test/github-copilot-error.test.ts +++ b/packages/ai/test/github-copilot-error.test.ts @@ -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", () => {