feat: initial impl

- add GitHub Copilot model discovery (env token fallback, headers,
compat) plus fallback list and quoted provider keys in generated map
- surface Copilot provider end-to-end (KnownProvider/default, env+OAuth
token refresh/save, enterprise base URL swap, available only when
creds/env exist)
- tweak interactive OAuth UI to render instruction text and prompt
placeholders

gpt-5.2-high took about 35 minutes. It had a lot of trouble with `npm
check`  and went off on a "let's adjust every tsconfig" side quest.
Device code flow works, but the ai/scripts/generate-models.ts impl is
wrong as models from months ago are missing and only those deprecated
are accessible in the /models picker.
This commit is contained in:
cau1k 2025-12-14 17:18:13 -05:00
parent 0a7d1fa51e
commit ccae7a4e0e
No known key found for this signature in database
10 changed files with 727 additions and 101 deletions

View file

@ -3,8 +3,9 @@ import { type Static, Type } from "@sinclair/typebox";
import AjvModule from "ajv";
import { existsSync, readFileSync } from "fs";
import { getModelsPath } from "../config.js";
import { getGitHubCopilotBaseUrl, normalizeDomain, refreshGitHubCopilotToken } from "./oauth/github-copilot.js";
import { getOAuthToken, type SupportedOAuthProvider } from "./oauth/index.js";
import { loadOAuthCredentials } from "./oauth/storage.js";
import { loadOAuthCredentials, saveOAuthCredentials } from "./oauth/storage.js";
// Handle both default and named exports
const Ajv = (AjvModule as any).default || AjvModule;
@ -239,8 +240,23 @@ export function loadAndMergeModels(): { models: Model<Api>[]; error: string | nu
return { models: [], error };
}
// Merge: custom models come after built-in
return { models: [...builtInModels, ...customModels], error: null };
const combined = [...builtInModels, ...customModels];
const copilotCreds = loadOAuthCredentials("github-copilot");
if (copilotCreds?.enterpriseUrl) {
const domain = normalizeDomain(copilotCreds.enterpriseUrl);
if (domain) {
const baseUrl = getGitHubCopilotBaseUrl(domain);
return {
models: combined.map((m) =>
m.provider === "github-copilot" && m.baseUrl === "https://api.githubcopilot.com" ? { ...m, baseUrl } : m,
),
error: null,
};
}
}
return { models: combined, error: null };
}
/**
@ -271,6 +287,26 @@ export async function getApiKeyForModel(model: Model<Api>): Promise<string | und
// 3. Fall back to ANTHROPIC_API_KEY env var
}
if (model.provider === "github-copilot") {
const oauthToken = await getOAuthToken("github-copilot");
if (oauthToken) {
return oauthToken;
}
const githubToken = process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (!githubToken) {
return undefined;
}
const enterpriseDomain = process.env.COPILOT_ENTERPRISE_URL
? normalizeDomain(process.env.COPILOT_ENTERPRISE_URL)
: undefined;
const creds = await refreshGitHubCopilotToken(githubToken, enterpriseDomain || undefined);
saveOAuthCredentials("github-copilot", creds);
return creds.access;
}
// For built-in providers, use getApiKey from @mariozechner/pi-ai
return getApiKey(model.provider as KnownProvider);
}
@ -287,7 +323,18 @@ export async function getAvailableModels(): Promise<{ models: Model<Api>[]; erro
}
const availableModels: Model<Api>[] = [];
const copilotCreds = loadOAuthCredentials("github-copilot");
const hasCopilotEnv = !!(process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN);
const hasCopilot = !!copilotCreds || hasCopilotEnv;
for (const model of allModels) {
if (model.provider === "github-copilot") {
if (hasCopilot) {
availableModels.push(model);
}
continue;
}
const apiKey = await getApiKeyForModel(model);
if (apiKey) {
availableModels.push(model);
@ -318,7 +365,7 @@ export function findModel(provider: string, modelId: string): { model: Model<Api
*/
const providerToOAuthProvider: Record<string, SupportedOAuthProvider> = {
anthropic: "anthropic",
// Add more mappings as OAuth support is added for other providers
"github-copilot": "github-copilot",
};
// Cache for OAuth status per provider (avoids file reads on every render)

View file

@ -14,6 +14,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
anthropic: "claude-sonnet-4-5",
openai: "gpt-5.1-codex",
google: "gemini-2.5-pro",
"github-copilot": "gpt-4o",
openrouter: "openai/gpt-5.1-codex",
xai: "grok-4-fast-non-reasoning",
groq: "openai/gpt-oss-120b",

View file

@ -0,0 +1,235 @@
import type { OAuthCredentials } from "./storage.js";
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const COPILOT_HEADERS = {
"User-Agent": "GitHubCopilotChat/0.35.0",
"Editor-Version": "vscode/1.105.1",
"Editor-Plugin-Version": "copilot-chat/0.35.0",
"Copilot-Integration-Id": "copilot-developer-cli",
"Openai-Intent": "conversation-edits",
"X-Initiator": "agent",
} as const;
type DeviceCodeResponse = {
device_code: string;
user_code: string;
verification_uri: string;
interval: number;
expires_in: number;
};
type DeviceTokenSuccessResponse = {
access_token: string;
token_type?: string;
scope?: string;
};
type DeviceTokenErrorResponse = {
error: string;
error_description?: string;
interval?: number;
};
export function normalizeDomain(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
try {
const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
return url.hostname;
} catch {
return null;
}
}
function getUrls(domain: string): {
deviceCodeUrl: string;
accessTokenUrl: string;
copilotTokenUrl: string;
} {
return {
deviceCodeUrl: `https://${domain}/login/device/code`,
accessTokenUrl: `https://${domain}/login/oauth/access_token`,
copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`,
};
}
export function getGitHubCopilotBaseUrl(enterpriseDomain?: string): string {
if (!enterpriseDomain) return "https://api.githubcopilot.com";
return `https://copilot-api.${enterpriseDomain}`;
}
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
const response = await fetch(url, init);
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status} ${response.statusText}: ${text}`);
}
return response.json();
}
async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
const urls = getUrls(domain);
const data = await fetchJson(urls.deviceCodeUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "read:user",
}),
});
if (!data || typeof data !== "object") {
throw new Error("Invalid device code response");
}
const deviceCode = (data as Record<string, unknown>).device_code;
const userCode = (data as Record<string, unknown>).user_code;
const verificationUri = (data as Record<string, unknown>).verification_uri;
const interval = (data as Record<string, unknown>).interval;
const expiresIn = (data as Record<string, unknown>).expires_in;
if (
typeof deviceCode !== "string" ||
typeof userCode !== "string" ||
typeof verificationUri !== "string" ||
typeof interval !== "number" ||
typeof expiresIn !== "number"
) {
throw new Error("Invalid device code response fields");
}
return {
device_code: deviceCode,
user_code: userCode,
verification_uri: verificationUri,
interval,
expires_in: expiresIn,
};
}
async function pollForGitHubAccessToken(
domain: string,
deviceCode: string,
intervalSeconds: number,
expiresIn: number,
) {
const urls = getUrls(domain);
const deadline = Date.now() + expiresIn * 1000;
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
while (Date.now() < deadline) {
const raw = await fetchJson(urls.accessTokenUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
return (raw as DeviceTokenSuccessResponse).access_token;
}
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
const err = (raw as DeviceTokenErrorResponse).error;
if (err === "authorization_pending") {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
if (err === "slow_down") {
intervalMs += 5000;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
throw new Error(`Device flow failed: ${err}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error("Device flow timed out");
}
export async function refreshGitHubCopilotToken(
refreshToken: string,
enterpriseDomain?: string,
): Promise<OAuthCredentials> {
const domain = enterpriseDomain || "github.com";
const urls = getUrls(domain);
const raw = await fetchJson(urls.copilotTokenUrl, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${refreshToken}`,
...COPILOT_HEADERS,
},
});
if (!raw || typeof raw !== "object") {
throw new Error("Invalid Copilot token response");
}
const token = (raw as Record<string, unknown>).token;
const expiresAt = (raw as Record<string, unknown>).expires_at;
if (typeof token !== "string" || typeof expiresAt !== "number") {
throw new Error("Invalid Copilot token response fields");
}
const expires = expiresAt * 1000 - 5 * 60 * 1000;
return {
type: "oauth",
refresh: refreshToken,
access: token,
expires,
enterpriseUrl: enterpriseDomain,
} satisfies OAuthCredentials;
}
export async function loginGitHubCopilot(options: {
onAuth: (url: string, instructions?: string) => void;
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
}): Promise<OAuthCredentials> {
const input = await options.onPrompt({
message: "GitHub Enterprise URL/domain (blank for github.com)",
placeholder: "company.ghe.com",
allowEmpty: true,
});
const trimmed = input.trim();
const enterpriseDomain = normalizeDomain(input);
if (trimmed && !enterpriseDomain) {
throw new Error("Invalid GitHub Enterprise URL/domain");
}
const domain = enterpriseDomain || "github.com";
const device = await startDeviceFlow(domain);
options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`);
const githubAccessToken = await pollForGitHubAccessToken(
domain,
device.device_code,
device.interval,
device.expires_in,
);
return await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined);
}
export async function exchangeGitHubTokenForCopilotCredentials(options: {
githubToken: string;
enterpriseDomain?: string;
}): Promise<OAuthCredentials> {
return refreshGitHubCopilotToken(options.githubToken, options.enterpriseDomain);
}

View file

@ -1,4 +1,5 @@
import { loginAnthropic, refreshAnthropicToken } from "./anthropic.js";
import { loginGitHubCopilot, refreshGitHubCopilotToken } from "./github-copilot.js";
import {
listOAuthProviders as listOAuthProvidersFromStorage,
loadOAuthCredentials,
@ -18,6 +19,17 @@ export interface OAuthProviderInfo {
available: boolean;
}
export type OAuthPrompt = {
message: string;
placeholder?: string;
allowEmpty?: boolean;
};
export type OAuthAuthInfo = {
url: string;
instructions?: string;
};
/**
* Get list of OAuth providers
*/
@ -30,8 +42,8 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
},
{
id: "github-copilot",
name: "GitHub Copilot (coming soon)",
available: false,
name: "GitHub Copilot",
available: true,
},
];
}
@ -41,15 +53,24 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
*/
export async function login(
provider: SupportedOAuthProvider,
onAuthUrl: (url: string) => void,
onPromptCode: () => Promise<string>,
onAuth: (info: OAuthAuthInfo) => void,
onPrompt: (prompt: OAuthPrompt) => Promise<string>,
): Promise<void> {
switch (provider) {
case "anthropic":
await loginAnthropic(onAuthUrl, onPromptCode);
await loginAnthropic(
(url) => onAuth({ url }),
async () => onPrompt({ message: "Paste the authorization code below:" }),
);
break;
case "github-copilot":
throw new Error("GitHub Copilot OAuth is not yet implemented");
case "github-copilot": {
const creds = await loginGitHubCopilot({
onAuth: (url, instructions) => onAuth({ url, instructions }),
onPrompt,
});
saveOAuthCredentials("github-copilot", creds);
break;
}
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
@ -78,7 +99,8 @@ export async function refreshToken(provider: SupportedOAuthProvider): Promise<st
newCredentials = await refreshAnthropicToken(credentials.refresh);
break;
case "github-copilot":
throw new Error("GitHub Copilot OAuth is not yet implemented");
newCredentials = await refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl);
break;
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}

View file

@ -6,6 +6,7 @@ export interface OAuthCredentials {
refresh: string;
access: string;
expires: number;
enterpriseUrl?: string;
}
interface OAuthStorageFormat {

View file

@ -1378,14 +1378,14 @@ export class InteractiveMode {
try {
await login(
providerId as SupportedOAuthProvider,
(url: string) => {
(info) => {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("accent", "Opening browser to:"), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(theme.fg("warning", "Paste the authorization code below:"), 1, 0),
);
this.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
if (info.instructions) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
}
this.ui.requestRender();
const openCmd =
@ -1394,9 +1394,16 @@ export class InteractiveMode {
: process.platform === "win32"
? "start"
: "xdg-open";
exec(`${openCmd} "${url}"`);
exec(`${openCmd} "${info.url}"`);
},
async () => {
async (prompt) => {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
if (prompt.placeholder) {
this.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
}
this.ui.requestRender();
return new Promise<string>((resolve) => {
const codeInput = new Input();
codeInput.onSubmit = () => {