Files
oh-my-pi/packages/ai/test/github-copilot-error.test.ts
T
Magicien d33f2a1658 fix(ai): retry GitHub Copilot fleet-skew model 400s
Any Copilot model in the middle of a rollout (claude-sonnet-4.6,
claude-opus-4.6, gpt-5.4, gpt-5.3-codex, ...) returned a raw HTTP 400 on
roughly half of all turns. GET /models on api.githubcopilot.com returns
two different catalogs across repeated calls: part of the fleet serves
those ids, part rejects them with

  400 {"error":{"message":"The requested model is not available for
  integrator \"copilot-language-server\". ...",
  "code":"model_not_available_for_integrator", ...}}

The absorb machinery already existed and was correct
(isCopilotTransientModelError -> isProviderRetryableError -> the
Anthropic transport's PROVIDER_MAX_RETRIES). Only the classifier missed:
it matched the older model_not_supported code and probed err.code /
err.error.code, while the real code is model_not_available_for_integrator
sitting at err.error.error.code (the SDK stores the parsed body on
.error, and Copilot's body is itself {error:{code}}). So
isProviderRetryableError fell through to "4xx => terminal".

Fix the classifier: providerErrorCode() walks the error envelope up to
depth 3 instead of hardcoding a shape, both Copilot model-availability
codes are accepted, and a wire-body text match backs it up because SDK
envelope shapes drift between provider families while the stringified
message does not. This alone restores the retry path, because
isProviderRetryableError consults the provider hook before its
4xx short-circuit.

Retry shape, since a rejection is a per-request replica reroll rather
than upstream backpressure:

- both transports wait a flat delay between model-flap attempts instead
  of the growing backoff; generic retryable failures (429/5xx/transport)
  keep their linear ramp and Retry-After handling
- the OpenAI-transport budget goes 3 -> 8 attempts, because a measured
  ~70% flap window produced a turn that needed 6 wire attempts and
  exhausting the budget escalates to the agent-level retry, which
  restarts the whole turn

Absorbed attempts cost no tokens: rejections are gateway-side, carry no
usage block, and return in ~208ms median versus ~1884ms for a served
request.

Also refresh the exhausted-retry guidance text, which cited a
nonexistent model id and described the cause as a per-client rollout gap
rather than fleet skew.
2026-08-04 21:45:44 +01:00

62 lines
2.7 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { rewriteCopilotError } from "@oh-my-pi/pi-ai/utils/http-inspector";
function errorWithStatus(status: number): Error {
const err = new Error(`${status} Unauthorized`);
(err as any).status = status;
return err;
}
describe("rewriteCopilotError", () => {
it("returns original message for non-copilot providers", () => {
const err = errorWithStatus(401);
expect(rewriteCopilotError("some error", err, "openai")).toBe("some error");
});
it("returns original message for non-401/403 errors", () => {
const err = errorWithStatus(500);
expect(rewriteCopilotError("server error", err, "github-copilot")).toBe("server error");
});
it("rewrites message for 401 with github-copilot provider", () => {
const err = errorWithStatus(401);
const result = rewriteCopilotError("401 Unauthorized: ...", err, "github-copilot");
expect(result).toContain("GitHub Copilot authentication failed (HTTP 401)");
expect(result).toContain("/login github-copilot");
});
it("rewrites 403 with access-denied message (not auth-failed, to avoid credential removal)", () => {
const err = errorWithStatus(403);
const result = rewriteCopilotError("403 Forbidden", err, "github-copilot");
expect(result).toContain("GitHub Copilot access denied (HTTP 403)");
expect(result).not.toContain("GitHub Copilot authentication failed");
expect(result).not.toContain("/login github-copilot");
});
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", () => {
const err = new Error("400 model_not_supported");
(err as unknown as { status: number; code: string }).status = 400;
(err as unknown as { status: number; code: string }).code = "model_not_supported";
expect(rewriteCopilotError("orig", err, "openai")).toBe("orig");
});
it("leaves 400 without model_not_supported code untouched", () => {
const err = new Error("400 invalid request");
(err as unknown as { status: number; code: string }).status = 400;
(err as unknown as { status: number; code: string }).code = "invalid_request_body";
expect(rewriteCopilotError("orig", err, "github-copilot")).toBe("orig");
});
});