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
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)