mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 13:05:03 +00:00
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:
parent
0a7d1fa51e
commit
ccae7a4e0e
10 changed files with 727 additions and 101 deletions
|
|
@ -29,6 +29,162 @@ interface ModelsDevModel {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COPILOT_STATIC_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;
|
||||||
|
|
||||||
|
function getGitHubTokenFromEnv(): string | null {
|
||||||
|
return process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeprecatedCopilotModel(model: unknown): boolean {
|
||||||
|
if (!model || typeof model !== "object") return false;
|
||||||
|
const m = model as Record<string, unknown>;
|
||||||
|
if (m.deprecated === true) return true;
|
||||||
|
if (m.is_deprecated === true) return true;
|
||||||
|
if (m.status === "deprecated") return true;
|
||||||
|
if (m.lifecycle === "deprecated") return true;
|
||||||
|
const id = typeof m.id === "string" ? m.id : "";
|
||||||
|
return id.includes("deprecated");
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsToolsCopilotModel(model: unknown): boolean {
|
||||||
|
if (!model || typeof model !== "object") return true;
|
||||||
|
const m = model as Record<string, unknown>;
|
||||||
|
const caps = m.capabilities;
|
||||||
|
if (!caps || typeof caps !== "object") return true;
|
||||||
|
const tools = (caps as Record<string, unknown>).tools;
|
||||||
|
if (tools === undefined) return true;
|
||||||
|
return tools !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsVisionCopilotModel(model: unknown): boolean {
|
||||||
|
if (!model || typeof model !== "object") return false;
|
||||||
|
const m = model as Record<string, unknown>;
|
||||||
|
const caps = m.capabilities;
|
||||||
|
if (!caps || typeof caps !== "object") return false;
|
||||||
|
const vision = (caps as Record<string, unknown>).vision;
|
||||||
|
if (vision === true) return true;
|
||||||
|
const modalities = (caps as Record<string, unknown>).modalities;
|
||||||
|
if (Array.isArray(modalities)) return modalities.includes("vision") || modalities.includes("image");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumberField(model: unknown, keys: string[], fallback: number): number {
|
||||||
|
if (!model || typeof model !== "object") return fallback;
|
||||||
|
const m = model as Record<string, unknown>;
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = m[key];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCopilotModels(githubToken: string): Promise<Model<any>[]> {
|
||||||
|
try {
|
||||||
|
console.log("Fetching models from GitHub Copilot API...");
|
||||||
|
const response = await fetch("https://api.githubcopilot.com/models", {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${githubToken}`,
|
||||||
|
...COPILOT_STATIC_HEADERS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.warn(`Failed to fetch GitHub Copilot models: ${response.status} ${text}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as unknown;
|
||||||
|
const list =
|
||||||
|
Array.isArray(data) ? data : Array.isArray((data as any)?.models) ? (data as any).models : (data as any)?.data;
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
console.warn("Failed to parse GitHub Copilot models response");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const models: Model<any>[] = [];
|
||||||
|
|
||||||
|
for (const item of list) {
|
||||||
|
if (isDeprecatedCopilotModel(item)) continue;
|
||||||
|
if (!supportsToolsCopilotModel(item)) continue;
|
||||||
|
|
||||||
|
const id = typeof (item as any)?.id === "string" ? (item as any).id : typeof item === "string" ? item : null;
|
||||||
|
if (!id) continue;
|
||||||
|
|
||||||
|
const name = typeof (item as any)?.name === "string" ? (item as any).name : id;
|
||||||
|
const contextWindow = getNumberField(item, ["context_window", "contextWindow", "max_context_tokens"], 128000);
|
||||||
|
const maxTokens = getNumberField(item, ["max_output_tokens", "maxTokens", "max_tokens"], 8192);
|
||||||
|
const input: ("text" | "image")[] = supportsVisionCopilotModel(item) ? ["text", "image"] : ["text"];
|
||||||
|
|
||||||
|
models.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "github-copilot",
|
||||||
|
baseUrl: "https://api.githubcopilot.com",
|
||||||
|
reasoning: false,
|
||||||
|
input,
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow,
|
||||||
|
maxTokens,
|
||||||
|
headers: { ...COPILOT_STATIC_HEADERS },
|
||||||
|
compat: {
|
||||||
|
supportsStore: false,
|
||||||
|
supportsDeveloperRole: false,
|
||||||
|
supportsReasoningEffort: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Fetched ${models.length} models from GitHub Copilot`);
|
||||||
|
return models;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to fetch GitHub Copilot models:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackCopilotModels(): Model<any>[] {
|
||||||
|
const fallbackModelIds = ["gpt-4o", "gpt-4o-mini", "claude-3.5-sonnet", "o1", "o1-mini"];
|
||||||
|
|
||||||
|
return fallbackModelIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "github-copilot",
|
||||||
|
baseUrl: "https://api.githubcopilot.com",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
headers: { ...COPILOT_STATIC_HEADERS },
|
||||||
|
compat: {
|
||||||
|
supportsStore: false,
|
||||||
|
supportsDeveloperRole: false,
|
||||||
|
supportsReasoningEffort: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
||||||
try {
|
try {
|
||||||
console.log("Fetching models from OpenRouter API...");
|
console.log("Fetching models from OpenRouter API...");
|
||||||
|
|
@ -321,6 +477,20 @@ async function generateModels() {
|
||||||
// Combine models (models.dev has priority)
|
// Combine models (models.dev has priority)
|
||||||
const allModels = [...modelsDevModels, ...openRouterModels];
|
const allModels = [...modelsDevModels, ...openRouterModels];
|
||||||
|
|
||||||
|
const githubToken = getGitHubTokenFromEnv();
|
||||||
|
let copilotModels: Model<any>[] = [];
|
||||||
|
if (!githubToken) {
|
||||||
|
console.warn("No GitHub token found for GitHub Copilot model discovery (set COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN). Using fallback list.");
|
||||||
|
copilotModels = getFallbackCopilotModels();
|
||||||
|
} else {
|
||||||
|
copilotModels = await fetchCopilotModels(githubToken);
|
||||||
|
if (copilotModels.length === 0) {
|
||||||
|
console.warn("No GitHub Copilot models fetched. Using fallback list.");
|
||||||
|
copilotModels = getFallbackCopilotModels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allModels.push(...copilotModels);
|
||||||
|
|
||||||
// Fix incorrect cache pricing for Claude Opus 4.5 from models.dev
|
// Fix incorrect cache pricing for Claude Opus 4.5 from models.dev
|
||||||
// models.dev has 3x the correct pricing (1.5/18.75 instead of 0.5/6.25)
|
// models.dev has 3x the correct pricing (1.5/18.75 instead of 0.5/6.25)
|
||||||
const opus45 = allModels.find(m => m.provider === "anthropic" && m.id === "claude-opus-4-5");
|
const opus45 = allModels.find(m => m.provider === "anthropic" && m.id === "claude-opus-4-5");
|
||||||
|
|
@ -458,7 +628,7 @@ export const MODELS = {
|
||||||
|
|
||||||
// Generate provider sections
|
// Generate provider sections
|
||||||
for (const [providerId, models] of Object.entries(providers)) {
|
for (const [providerId, models] of Object.entries(providers)) {
|
||||||
output += `\t${providerId}: {\n`;
|
output += `\t${JSON.stringify(providerId)}: {\n`;
|
||||||
|
|
||||||
for (const model of Object.values(models)) {
|
for (const model of Object.values(models)) {
|
||||||
output += `\t\t"${model.id}": {\n`;
|
output += `\t\t"${model.id}": {\n`;
|
||||||
|
|
@ -469,6 +639,12 @@ export const MODELS = {
|
||||||
if (model.baseUrl) {
|
if (model.baseUrl) {
|
||||||
output += `\t\t\tbaseUrl: "${model.baseUrl}",\n`;
|
output += `\t\t\tbaseUrl: "${model.baseUrl}",\n`;
|
||||||
}
|
}
|
||||||
|
if (model.headers) {
|
||||||
|
output += `\t\t\theaders: ${JSON.stringify(model.headers)},\n`;
|
||||||
|
}
|
||||||
|
if ((model as any).compat) {
|
||||||
|
output += `\t\t\tcompat: ${JSON.stringify((model as any).compat)},\n`;
|
||||||
|
}
|
||||||
output += `\t\t\treasoning: ${model.reasoning},\n`;
|
output += `\t\t\treasoning: ${model.reasoning},\n`;
|
||||||
output += `\t\t\tinput: [${model.input.map(i => `"${i}"`).join(", ")}],\n`;
|
output += `\t\t\tinput: [${model.input.map(i => `"${i}"`).join(", ")}],\n`;
|
||||||
output += `\t\t\tcost: {\n`;
|
output += `\t\t\tcost: {\n`;
|
||||||
|
|
|
||||||
|
|
@ -5733,23 +5733,6 @@ export const MODELS = {
|
||||||
contextWindow: 32768,
|
contextWindow: 32768,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"cohere/command-r-08-2024": {
|
|
||||||
id: "cohere/command-r-08-2024",
|
|
||||||
name: "Cohere: Command R (08-2024)",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 0.15,
|
|
||||||
output: 0.6,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 128000,
|
|
||||||
maxTokens: 4000,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"cohere/command-r-plus-08-2024": {
|
"cohere/command-r-plus-08-2024": {
|
||||||
id: "cohere/command-r-plus-08-2024",
|
id: "cohere/command-r-plus-08-2024",
|
||||||
name: "Cohere: Command R+ (08-2024)",
|
name: "Cohere: Command R+ (08-2024)",
|
||||||
|
|
@ -5767,6 +5750,23 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4000,
|
maxTokens: 4000,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"cohere/command-r-08-2024": {
|
||||||
|
id: "cohere/command-r-08-2024",
|
||||||
|
name: "Cohere: Command R (08-2024)",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0.15,
|
||||||
|
output: 0.6,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 4000,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"sao10k/l3.1-euryale-70b": {
|
"sao10k/l3.1-euryale-70b": {
|
||||||
id: "sao10k/l3.1-euryale-70b",
|
id: "sao10k/l3.1-euryale-70b",
|
||||||
name: "Sao10K: Llama 3.1 Euryale 70B v2.2",
|
name: "Sao10K: Llama 3.1 Euryale 70B v2.2",
|
||||||
|
|
@ -5835,23 +5835,6 @@ export const MODELS = {
|
||||||
contextWindow: 131072,
|
contextWindow: 131072,
|
||||||
maxTokens: 16384,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"meta-llama/llama-3.1-405b-instruct": {
|
|
||||||
id: "meta-llama/llama-3.1-405b-instruct",
|
|
||||||
name: "Meta: Llama 3.1 405B Instruct",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 3.5,
|
|
||||||
output: 3.5,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 130815,
|
|
||||||
maxTokens: 4096,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"meta-llama/llama-3.1-70b-instruct": {
|
"meta-llama/llama-3.1-70b-instruct": {
|
||||||
id: "meta-llama/llama-3.1-70b-instruct",
|
id: "meta-llama/llama-3.1-70b-instruct",
|
||||||
name: "Meta: Llama 3.1 70B Instruct",
|
name: "Meta: Llama 3.1 70B Instruct",
|
||||||
|
|
@ -5869,6 +5852,23 @@ export const MODELS = {
|
||||||
contextWindow: 131072,
|
contextWindow: 131072,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"meta-llama/llama-3.1-405b-instruct": {
|
||||||
|
id: "meta-llama/llama-3.1-405b-instruct",
|
||||||
|
name: "Meta: Llama 3.1 405B Instruct",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 3.5,
|
||||||
|
output: 3.5,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 130815,
|
||||||
|
maxTokens: 4096,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"mistralai/mistral-nemo": {
|
"mistralai/mistral-nemo": {
|
||||||
id: "mistralai/mistral-nemo",
|
id: "mistralai/mistral-nemo",
|
||||||
name: "Mistral: Mistral Nemo",
|
name: "Mistral: Mistral Nemo",
|
||||||
|
|
@ -5886,9 +5886,9 @@ export const MODELS = {
|
||||||
contextWindow: 131072,
|
contextWindow: 131072,
|
||||||
maxTokens: 16384,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-4o-mini-2024-07-18": {
|
"openai/gpt-4o-mini": {
|
||||||
id: "openai/gpt-4o-mini-2024-07-18",
|
id: "openai/gpt-4o-mini",
|
||||||
name: "OpenAI: GPT-4o-mini (2024-07-18)",
|
name: "OpenAI: GPT-4o-mini",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
|
@ -5903,9 +5903,9 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 16384,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-4o-mini": {
|
"openai/gpt-4o-mini-2024-07-18": {
|
||||||
id: "openai/gpt-4o-mini",
|
id: "openai/gpt-4o-mini-2024-07-18",
|
||||||
name: "OpenAI: GPT-4o-mini",
|
name: "OpenAI: GPT-4o-mini (2024-07-18)",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
|
@ -6056,23 +6056,6 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 64000,
|
maxTokens: 64000,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"meta-llama/llama-3-70b-instruct": {
|
|
||||||
id: "meta-llama/llama-3-70b-instruct",
|
|
||||||
name: "Meta: Llama 3 70B Instruct",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 0.3,
|
|
||||||
output: 0.39999999999999997,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 8192,
|
|
||||||
maxTokens: 16384,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"meta-llama/llama-3-8b-instruct": {
|
"meta-llama/llama-3-8b-instruct": {
|
||||||
id: "meta-llama/llama-3-8b-instruct",
|
id: "meta-llama/llama-3-8b-instruct",
|
||||||
name: "Meta: Llama 3 8B Instruct",
|
name: "Meta: Llama 3 8B Instruct",
|
||||||
|
|
@ -6090,6 +6073,23 @@ export const MODELS = {
|
||||||
contextWindow: 8192,
|
contextWindow: 8192,
|
||||||
maxTokens: 16384,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"meta-llama/llama-3-70b-instruct": {
|
||||||
|
id: "meta-llama/llama-3-70b-instruct",
|
||||||
|
name: "Meta: Llama 3 70B Instruct",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0.3,
|
||||||
|
output: 0.39999999999999997,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 16384,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"mistralai/mixtral-8x22b-instruct": {
|
"mistralai/mixtral-8x22b-instruct": {
|
||||||
id: "mistralai/mixtral-8x22b-instruct",
|
id: "mistralai/mixtral-8x22b-instruct",
|
||||||
name: "Mistral: Mixtral 8x22B Instruct",
|
name: "Mistral: Mixtral 8x22B Instruct",
|
||||||
|
|
@ -6175,23 +6175,6 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-3.5-turbo-0613": {
|
|
||||||
id: "openai/gpt-3.5-turbo-0613",
|
|
||||||
name: "OpenAI: GPT-3.5 Turbo (older v0613)",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 1,
|
|
||||||
output: 2,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 4095,
|
|
||||||
maxTokens: 4096,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"openai/gpt-4-turbo-preview": {
|
"openai/gpt-4-turbo-preview": {
|
||||||
id: "openai/gpt-4-turbo-preview",
|
id: "openai/gpt-4-turbo-preview",
|
||||||
name: "OpenAI: GPT-4 Turbo Preview",
|
name: "OpenAI: GPT-4 Turbo Preview",
|
||||||
|
|
@ -6209,6 +6192,23 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"openai/gpt-3.5-turbo-0613": {
|
||||||
|
id: "openai/gpt-3.5-turbo-0613",
|
||||||
|
name: "OpenAI: GPT-3.5 Turbo (older v0613)",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 4095,
|
||||||
|
maxTokens: 4096,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"mistralai/mistral-tiny": {
|
"mistralai/mistral-tiny": {
|
||||||
id: "mistralai/mistral-tiny",
|
id: "mistralai/mistral-tiny",
|
||||||
name: "Mistral Tiny",
|
name: "Mistral Tiny",
|
||||||
|
|
@ -6277,9 +6277,9 @@ export const MODELS = {
|
||||||
contextWindow: 16385,
|
contextWindow: 16385,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-4-0314": {
|
"openai/gpt-4": {
|
||||||
id: "openai/gpt-4-0314",
|
id: "openai/gpt-4",
|
||||||
name: "OpenAI: GPT-4 (older v0314)",
|
name: "OpenAI: GPT-4",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
|
@ -6294,9 +6294,9 @@ export const MODELS = {
|
||||||
contextWindow: 8191,
|
contextWindow: 8191,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-4": {
|
"openai/gpt-4-0314": {
|
||||||
id: "openai/gpt-4",
|
id: "openai/gpt-4-0314",
|
||||||
name: "OpenAI: GPT-4",
|
name: "OpenAI: GPT-4 (older v0314)",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
|
@ -6346,4 +6346,136 @@ export const MODELS = {
|
||||||
maxTokens: 30000,
|
maxTokens: 30000,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
},
|
},
|
||||||
|
"github-copilot": {
|
||||||
|
"gpt-4o": {
|
||||||
|
id: "gpt-4o",
|
||||||
|
name: "gpt-4o",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "github-copilot",
|
||||||
|
baseUrl: "https://api.githubcopilot.com",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false },
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
|
"gpt-4o-mini": {
|
||||||
|
id: "gpt-4o-mini",
|
||||||
|
name: "gpt-4o-mini",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "github-copilot",
|
||||||
|
baseUrl: "https://api.githubcopilot.com",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false },
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
|
"claude-3.5-sonnet": {
|
||||||
|
id: "claude-3.5-sonnet",
|
||||||
|
name: "claude-3.5-sonnet",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "github-copilot",
|
||||||
|
baseUrl: "https://api.githubcopilot.com",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false },
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
|
o1: {
|
||||||
|
id: "o1",
|
||||||
|
name: "o1",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "github-copilot",
|
||||||
|
baseUrl: "https://api.githubcopilot.com",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false },
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
|
"o1-mini": {
|
||||||
|
id: "o1-mini",
|
||||||
|
name: "o1-mini",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "github-copilot",
|
||||||
|
baseUrl: "https://api.githubcopilot.com",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false },
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ export function getApiKey(provider: any): string | undefined {
|
||||||
if (key) return key;
|
if (key) return key;
|
||||||
|
|
||||||
// Fall back to environment variables
|
// Fall back to environment variables
|
||||||
|
if (provider === "github-copilot") {
|
||||||
|
return process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
const envMap: Record<string, string> = {
|
const envMap: Record<string, string> = {
|
||||||
openai: "OPENAI_API_KEY",
|
openai: "OPENAI_API_KEY",
|
||||||
anthropic: "ANTHROPIC_API_KEY",
|
anthropic: "ANTHROPIC_API_KEY",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export type KnownProvider =
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "google"
|
| "google"
|
||||||
| "openai"
|
| "openai"
|
||||||
|
| "github-copilot"
|
||||||
| "xai"
|
| "xai"
|
||||||
| "groq"
|
| "groq"
|
||||||
| "cerebras"
|
| "cerebras"
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { type Static, Type } from "@sinclair/typebox";
|
||||||
import AjvModule from "ajv";
|
import AjvModule from "ajv";
|
||||||
import { existsSync, readFileSync } from "fs";
|
import { existsSync, readFileSync } from "fs";
|
||||||
import { getModelsPath } from "../config.js";
|
import { getModelsPath } from "../config.js";
|
||||||
|
import { getGitHubCopilotBaseUrl, normalizeDomain, refreshGitHubCopilotToken } from "./oauth/github-copilot.js";
|
||||||
import { getOAuthToken, type SupportedOAuthProvider } from "./oauth/index.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
|
// Handle both default and named exports
|
||||||
const Ajv = (AjvModule as any).default || AjvModule;
|
const Ajv = (AjvModule as any).default || AjvModule;
|
||||||
|
|
@ -239,8 +240,23 @@ export function loadAndMergeModels(): { models: Model<Api>[]; error: string | nu
|
||||||
return { models: [], error };
|
return { models: [], error };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge: custom models come after built-in
|
const combined = [...builtInModels, ...customModels];
|
||||||
return { models: [...builtInModels, ...customModels], error: null };
|
|
||||||
|
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
|
// 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
|
// For built-in providers, use getApiKey from @mariozechner/pi-ai
|
||||||
return getApiKey(model.provider as KnownProvider);
|
return getApiKey(model.provider as KnownProvider);
|
||||||
}
|
}
|
||||||
|
|
@ -287,7 +323,18 @@ export async function getAvailableModels(): Promise<{ models: Model<Api>[]; erro
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableModels: Model<Api>[] = [];
|
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) {
|
for (const model of allModels) {
|
||||||
|
if (model.provider === "github-copilot") {
|
||||||
|
if (hasCopilot) {
|
||||||
|
availableModels.push(model);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = await getApiKeyForModel(model);
|
const apiKey = await getApiKeyForModel(model);
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
availableModels.push(model);
|
availableModels.push(model);
|
||||||
|
|
@ -318,7 +365,7 @@ export function findModel(provider: string, modelId: string): { model: Model<Api
|
||||||
*/
|
*/
|
||||||
const providerToOAuthProvider: Record<string, SupportedOAuthProvider> = {
|
const providerToOAuthProvider: Record<string, SupportedOAuthProvider> = {
|
||||||
anthropic: "anthropic",
|
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)
|
// Cache for OAuth status per provider (avoids file reads on every render)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
||||||
anthropic: "claude-sonnet-4-5",
|
anthropic: "claude-sonnet-4-5",
|
||||||
openai: "gpt-5.1-codex",
|
openai: "gpt-5.1-codex",
|
||||||
google: "gemini-2.5-pro",
|
google: "gemini-2.5-pro",
|
||||||
|
"github-copilot": "gpt-4o",
|
||||||
openrouter: "openai/gpt-5.1-codex",
|
openrouter: "openai/gpt-5.1-codex",
|
||||||
xai: "grok-4-fast-non-reasoning",
|
xai: "grok-4-fast-non-reasoning",
|
||||||
groq: "openai/gpt-oss-120b",
|
groq: "openai/gpt-oss-120b",
|
||||||
|
|
|
||||||
235
packages/coding-agent/src/core/oauth/github-copilot.ts
Normal file
235
packages/coding-agent/src/core/oauth/github-copilot.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { loginAnthropic, refreshAnthropicToken } from "./anthropic.js";
|
import { loginAnthropic, refreshAnthropicToken } from "./anthropic.js";
|
||||||
|
import { loginGitHubCopilot, refreshGitHubCopilotToken } from "./github-copilot.js";
|
||||||
import {
|
import {
|
||||||
listOAuthProviders as listOAuthProvidersFromStorage,
|
listOAuthProviders as listOAuthProvidersFromStorage,
|
||||||
loadOAuthCredentials,
|
loadOAuthCredentials,
|
||||||
|
|
@ -18,6 +19,17 @@ export interface OAuthProviderInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OAuthPrompt = {
|
||||||
|
message: string;
|
||||||
|
placeholder?: string;
|
||||||
|
allowEmpty?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OAuthAuthInfo = {
|
||||||
|
url: string;
|
||||||
|
instructions?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of OAuth providers
|
* Get list of OAuth providers
|
||||||
*/
|
*/
|
||||||
|
|
@ -30,8 +42,8 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "github-copilot",
|
id: "github-copilot",
|
||||||
name: "GitHub Copilot (coming soon)",
|
name: "GitHub Copilot",
|
||||||
available: false,
|
available: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -41,15 +53,24 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
|
||||||
*/
|
*/
|
||||||
export async function login(
|
export async function login(
|
||||||
provider: SupportedOAuthProvider,
|
provider: SupportedOAuthProvider,
|
||||||
onAuthUrl: (url: string) => void,
|
onAuth: (info: OAuthAuthInfo) => void,
|
||||||
onPromptCode: () => Promise<string>,
|
onPrompt: (prompt: OAuthPrompt) => Promise<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
await loginAnthropic(onAuthUrl, onPromptCode);
|
await loginAnthropic(
|
||||||
|
(url) => onAuth({ url }),
|
||||||
|
async () => onPrompt({ message: "Paste the authorization code below:" }),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "github-copilot":
|
case "github-copilot": {
|
||||||
throw new Error("GitHub Copilot OAuth is not yet implemented");
|
const creds = await loginGitHubCopilot({
|
||||||
|
onAuth: (url, instructions) => onAuth({ url, instructions }),
|
||||||
|
onPrompt,
|
||||||
|
});
|
||||||
|
saveOAuthCredentials("github-copilot", creds);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +99,8 @@ export async function refreshToken(provider: SupportedOAuthProvider): Promise<st
|
||||||
newCredentials = await refreshAnthropicToken(credentials.refresh);
|
newCredentials = await refreshAnthropicToken(credentials.refresh);
|
||||||
break;
|
break;
|
||||||
case "github-copilot":
|
case "github-copilot":
|
||||||
throw new Error("GitHub Copilot OAuth is not yet implemented");
|
newCredentials = await refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown OAuth provider: ${provider}`);
|
throw new Error(`Unknown OAuth provider: ${provider}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export interface OAuthCredentials {
|
||||||
refresh: string;
|
refresh: string;
|
||||||
access: string;
|
access: string;
|
||||||
expires: number;
|
expires: number;
|
||||||
|
enterpriseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OAuthStorageFormat {
|
interface OAuthStorageFormat {
|
||||||
|
|
|
||||||
|
|
@ -1378,14 +1378,14 @@ export class InteractiveMode {
|
||||||
try {
|
try {
|
||||||
await login(
|
await login(
|
||||||
providerId as SupportedOAuthProvider,
|
providerId as SupportedOAuthProvider,
|
||||||
(url: string) => {
|
(info) => {
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
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", "Opening browser to:"), 1, 0));
|
||||||
this.chatContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
|
this.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
if (info.instructions) {
|
||||||
this.chatContainer.addChild(
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
new Text(theme.fg("warning", "Paste the authorization code below:"), 1, 0),
|
this.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
|
||||||
);
|
}
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
|
|
||||||
const openCmd =
|
const openCmd =
|
||||||
|
|
@ -1394,9 +1394,16 @@ export class InteractiveMode {
|
||||||
: process.platform === "win32"
|
: process.platform === "win32"
|
||||||
? "start"
|
? "start"
|
||||||
: "xdg-open";
|
: "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) => {
|
return new Promise<string>((resolve) => {
|
||||||
const codeInput = new Input();
|
const codeInput = new Input();
|
||||||
codeInput.onSubmit = () => {
|
codeInput.onSubmit = () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue