diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 339b2de12..d23f366fb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed GitHub Copilot OpenAI-compatible requests being rejected when the session's native OpenAI service tier was set to `priority`. + ## [16.4.3] - 2026-07-11 ### Fixed diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 5b568de29..0f7830633 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -141,13 +141,17 @@ function isOpenAIServiceTierApi(api: Api | undefined): boolean { return api === "openai-completions" || api === "openai-responses" || api === "openai-codex-responses"; } -function hasDedicatedServiceTierControl(provider: Provider | undefined): boolean { - return provider === "fireworks"; +function excludesInferredOpenAIServiceTier(provider: Provider | undefined): boolean { + // Fireworks has its own priority-only control. GitHub Copilot proxies OpenAI + // models but rejects OpenAI's `service_tier` request field. + return provider === "fireworks" || provider === "github-copilot"; } function isOpenAIServiceTierModel(model: ServiceTierModel): boolean { return ( - !hasDedicatedServiceTierControl(model.provider) && isOpenAIServiceTierApi(model.api) && isOpenAIModelId(model.id) + !excludesInferredOpenAIServiceTier(model.provider) && + isOpenAIServiceTierApi(model.api) && + isOpenAIModelId(model.id) ); } @@ -159,7 +163,8 @@ function isOpenAIServiceTierModel(model: ServiceTierModel): boolean { * `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the * anthropic family even though its provider is `amazon-bedrock`/`google-vertex`. * Custom OpenAI-compatible relays that serve OpenAI model ids are OpenAI family - * too unless that provider owns a separate tier control such as Fireworks. + * too unless the provider owns a separate tier control (Fireworks) or rejects + * OpenAI's service-tier field (GitHub Copilot). */ export function serviceTierFamily(model: ServiceTierModel): ServiceTierFamily | undefined { const provider = model.provider; diff --git a/packages/ai/test/github-copilot-openai-base-url.test.ts b/packages/ai/test/github-copilot-openai-base-url.test.ts index 0b65672df..76cffdaae 100644 --- a/packages/ai/test/github-copilot-openai-base-url.test.ts +++ b/packages/ai/test/github-copilot-openai-base-url.test.ts @@ -30,6 +30,13 @@ function getRequestHeader( return new Headers(init?.headers).get(headerName); } +async function getRequestBody(input: string | URL | Request, init?: RequestInit): Promise> { + if (input instanceof Request) { + return (await input.clone().json()) as Record; + } + return JSON.parse(String(init?.body)) as Record; +} + function createUnauthorizedResponse(): Response { return new Response(JSON.stringify({ error: { message: "Unauthorized" } }), { status: 401, @@ -79,6 +86,29 @@ describe("GitHub Copilot OpenAI transport base URL", () => { expect(requestedUrls[0]).toBe("https://api.githubcopilot.com/responses"); }); + it("omits OpenAI priority service tier while native OpenAI keeps it", async () => { + const requestedBodies: Record[] = []; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + requestedBodies.push(await getRequestBody(input, init)); + return createUnauthorizedResponse(); + }); + const requestOptions = { + apiKey: testToken, + fetch: fetchMock as unknown as typeof fetch, + serviceTier: "priority" as const, + }; + + const copilotModel = getBundledModel("github-copilot", "gpt-5.4") as Model<"openai-responses">; + await streamOpenAIResponses(copilotModel, testContext, requestOptions).result(); + + const openAIModel = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; + await streamOpenAIResponses(openAIModel, testContext, requestOptions).result(); + + expect(requestedBodies).toHaveLength(2); + expect(requestedBodies[0]?.service_tier).toBeUndefined(); + expect(requestedBodies[1]?.service_tier).toBe("priority"); + }); + it("routes structured enterprise credentials to the enterprise chat completions host", async () => { const requestedUrls: string[] = []; const requestedAuthHeaders: Array = [];