Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions src/providers/github-copilot-auto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,32 @@ export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedM
let models: CopilotModel[];
try { models = await fetchCopilotAutoModels(transport, signal, beforeSend, 60_000, concurrency); }
catch (error) {
if (signal?.aborted || error instanceof ProviderOutboundSendCancelledError) throw error;
if (signal?.aborted) throw error;
// A manual account switch makes `beforeSend` return false, which cancels this
// control-plane send. That is not a client abort: fall back to the requested model
// so the dispatch path revalidates the selection and rebuilds on the new account.
if (error instanceof ProviderOutboundSendCancelledError) {
return { provider: transport, modelId: requestedModel, auto: false };
}
// Permission discovery is additive for legacy paid configurations. A temporary
// discovery outage must not turn a previously usable named route into a refusal.
return { provider: transport, modelId: requestedModel, auto: false };
}
if (!copilotRequiresAuto(provider, models))
return { provider: transport, modelId: requestedModel, auto: false };
const session = await requestJson(transport, "/models/session", signal,
{ auto_mode: { model_hints: ["auto"] } }, undefined, beforeSend, concurrency);
// The same authority fence guards the later control-plane sends: a manual switch that
// lands during the session or intent step falls back too, so dispatch rebuilds on the
// new account instead of failing the whole negotiation. A client abort still rejects.
const fallBackOnSwitch = (error: unknown) => {
if (!signal?.aborted && error instanceof ProviderOutboundSendCancelledError)
return { provider: transport, modelId: requestedModel, auto: false } as const;
throw error;
};
let session: Awaited<ReturnType<typeof requestJson>>;
try {
session = await requestJson(transport, "/models/session", signal,
{ auto_mode: { model_hints: ["auto"] } }, undefined, beforeSend, concurrency);
} catch (error) { return fallBackOnSwitch(error); }
const pool = strings(session.available_models);
if (typeof session.session_token !== "string" || !session.session_token || session.session_token.length > 32_768 || !/^[\x21-\x7e]+$/.test(session.session_token) || !pool.length)
throw new Error("GitHub Copilot Auto session omitted its routing credentials");
Expand All @@ -153,8 +170,11 @@ export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedM
? typeof lastUser.content === "string" ? lastUser.content : lastUser.content.flatMap(/** Extract text parts for intent negotiation without forwarding image or other multimodal payloads. */
part => part.type === "text" ? [part.text] : []).join("\n")
: "";
const intent = await requestJson(transport, "/models/session/intent", signal,
{ prompt: prompt.slice(0, 32_768), available_models: pool }, session.session_token, beforeSend, concurrency);
let intent: Awaited<ReturnType<typeof requestJson>>;
try {
intent = await requestJson(transport, "/models/session/intent", signal,
{ prompt: prompt.slice(0, 32_768), available_models: pool }, session.session_token, beforeSend, concurrency);
} catch (error) { return fallBackOnSwitch(error); }
const modelId = strings(intent.candidate_models).find(/** Choose the first server-ranked candidate that belongs to this credential-bound session pool. */
candidate => pool.includes(candidate));
if (!modelId) throw new Error("GitHub Copilot Auto intent returned no eligible model");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { getAccountSet, saveCredential as persistCredential, setActiveAccount as
import { handleResponses as dispatchResponses } from "../../../src/server/responses";
import { saveConfig } from "../../../src/config";
import { setActiveProviderApiKey } from "../../../src/providers/api-keys";
import { clearCopilotAutoModelsForTests } from "../../../src/providers/github-copilot-auto";
import type { OcxConfig } from "../../../src/types";
import { removeTreeWithRetry } from "../../helpers/remove-tree";
import { acquireOwnedSpendHome } from "../../helpers/owned-spend-home";
import { stubbedProviderFetch } from "../../helpers/catalog-provider-fetch";

const ACCOUNT_A_ORIGIN = "https://a.githubcopilot.com";
const ACCOUNT_B_ORIGIN = "https://b.githubcopilot.com";
Expand Down Expand Up @@ -136,6 +138,9 @@ function config(wire: Wire): OcxConfig {
adapter: "openai-chat",
authMode: "oauth",
baseUrl: "https://api.githubcopilot.com",
// Copilot Auto negotiates /models on the pinned outbound transport. Give it the
// fixture executor so the negotiation never resolves a real peer address.
fetch: stubbedProviderFetch,
models: [model],
...(wire === "responses" ? { modelAdapters: { [model]: "openai-responses" } } : {}),
},
Expand Down Expand Up @@ -209,8 +214,9 @@ function installFetch(options: {
switchToAccountId?: string;
switchOn: "refresh" | "first-dispatch" | "never";
emptyFirst?: boolean;
}): { dispatches: { origin: string; authorization: string }[] } {
}): { dispatches: { origin: string; authorization: string }[]; modelsRequests: { origin: string }[] } {
const dispatches: { origin: string; authorization: string }[] = [];
const modelsRequests: { origin: string }[] = [];
let refreshSwitched = false;
globalThis.fetch = (async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
Expand All @@ -228,6 +234,13 @@ function installFetch(options: {
if (url === GITHUB_USER_URL) return Response.json({ id: 101 });

const parsed = new URL(url);
if (parsed.pathname === "/models") {
modelsRequests.push({ origin: parsed.origin });
return Response.json({ data: [
{ id: "gpt-4o", model_picker_enabled: true },
{ id: "gpt-5.4", model_picker_enabled: true },
] });
}
if (parsed.hostname.endsWith(".githubcopilot.com") || parsed.hostname === "api.githubcopilot.com") {
dispatches.push({
origin: parsed.origin,
Expand Down Expand Up @@ -255,7 +268,7 @@ function installFetch(options: {
}
return originalFetch(input, init);
}) as typeof fetch;
return { dispatches };
return { dispatches, modelsRequests };
}

beforeEach(() => {
Expand All @@ -275,6 +288,7 @@ beforeEach(() => {
));
process.env.OPENCODEX_HOME = home;
clearGenericFailoverHealth();
clearCopilotAutoModelsForTests();
// Dispatches without starting a server, so it takes the spend-journal lease itself. Taken
// last because the lease binds the home in effect at the moment it is taken.
releaseSpendHome = acquireOwnedSpendHome();
Expand Down Expand Up @@ -450,6 +464,12 @@ describe("GitHub Copilot bearer/origin snapshot atomicity", () => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.hostname === "api.exa.ai") return Response.json({ results: [] });
if (!url.hostname.endsWith(".githubcopilot.com")) throw new Error("Unexpected fixture request");
if (url.pathname === "/models") {
return Response.json({ data: [
{ id: "gpt-4o", model_picker_enabled: true },
{ id: "gpt-5.4", model_picker_enabled: true },
] });
}
sent.push(new Headers(init?.headers).get("authorization") ?? "");
bodies.push(JSON.parse(String(init?.body)));
const delta = sent.length === 1
Expand Down Expand Up @@ -480,6 +500,18 @@ describe("GitHub Copilot bearer/origin snapshot atomicity", () => {
expect(sends).toBe(0);
});

test("Copilot Auto model negotiation is served by the fixture fetch, never real DNS", async () => {
await seedAccounts();
const observed = installFetch({ wire: "chat", statuses: [200], switchOn: "never" });
const response = await handleResponses(request("chat"), config("chat"), { model: "", provider: "" });
await responseText(response);
expect(response.status).toBe(200);
// The negotiation runs on providerOutboundGet, which resolves and pins a real peer
// address unless the provider hands it an executor. This fixture must see the request.
expect(observed.modelsRequests).toEqual([{ origin: ACCOUNT_A_ORIGIN }]);
expect(observed.dispatches).toEqual([{ origin: ACCOUNT_A_ORIGIN, authorization: bearer("copilot-access-a") }]);
});

for (const wire of ["chat", "responses"] as const) {
test(`${wire} revalidates selection after pacing and before physical dispatch`, async () => {
const accounts = await seedAccounts();
Expand Down
28 changes: 26 additions & 2 deletions tests/providers/github-copilot/github-copilot-auto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,37 @@ describe("Copilot Auto permissions and negotiation", () => {
expect((await resolveCopilotAuto(provider, "named", parsed)).modelId).toBe("named");
expect(calls).toBe(1);
});
test("cancellation and authority guard prevent negotiation sends", async () => {
for (const cancelAt of ["/models/session", "/models/session/intent"] as const) {
test(`an authority change during ${cancelAt} also falls back instead of failing negotiation`, async () => {
const seen: string[] = [];
let allowed = true;
const provider = { authMode: "oauth", baseUrl: "https://api.githubcopilot.com", fetch: (async (url: string | URL | Request) => {
const path = new URL(String(url instanceof Request ? url.url : url)).pathname;
seen.push(path);
if (path.endsWith("/models")) return Response.json({ data: [{ id: "eligible", model_picker_enabled: false, supported_endpoints: ["/responses"] }] });
if (path.endsWith("/models/session")) return Response.json({ session_token: "private-session", available_models: ["eligible"], expires_at: Math.floor(Date.now() / 1000) + 3600 });
return Response.json({ candidate_models: ["eligible"] });
}) as typeof fetch } as OcxProviderConfig;
// The switch lands just before the named control-plane send.
const beforeSend = () => {
if (seen.length === (cancelAt === "/models/session" ? 1 : 2)) allowed = false;
return allowed;
};
await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, beforeSend))
.resolves.toMatchObject({ modelId: "auto", auto: false });
expect(seen.some(path => path.endsWith(cancelAt))).toBe(false);
});
}
test("cancellation aborts, while an authority change falls back without a negotiation send", async () => {
let sends = 0;
const provider = { authMode: "oauth", baseUrl: "https://api.githubcopilot.com", fetch: (async () => {
sends++; return Response.json({});
}) as typeof fetch } as OcxProviderConfig;
await expect(resolveCopilotAuto(provider, "auto", parsed, AbortSignal.abort())).rejects.toThrow();
await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, () => false)).rejects.toThrow();
// A manual account switch cancels the queued control-plane send. That is not a client
// abort: the caller revalidates the selection and rebuilds before dispatch.
await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, () => false))
.resolves.toMatchObject({ modelId: "auto", auto: false });
expect(sends).toBe(0);
});
});
Loading