Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat(aikit): add support for provider github-copilot
- Add composite provider routing Copilot's chat, responses, and anthropic messages endpoints per model
- Implement GitHub OAuth device-flow login with editor credential reuse and plan-aware endpoint discovery
- Wire github-copilot protocol through the model catalog, registry, options, thinking, and transform paths
- Generate Copilot catalog entries with endpoint methods, thinking-level maps, and compatibility flags
- Add aikit auth --github-copilot CLI with status, logout, enterprise domain, and enable-models support
- Cover headers, request metadata, OAuth storage, device polling, routing, and streaming with tests and e2e
  • Loading branch information
sanchitrk committed Sep 19, 2026
commit 1ae3ccd74c111ee0ed692380528a0977315caa72
7 changes: 6 additions & 1 deletion packages/aikit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,20 +147,25 @@ pnpm aikit modelgen [path]

### OAuth Providers

Manage OAuth credentials for providers that require it, such as OpenAI Codex.
Manage OAuth credentials for providers that require it, such as OpenAI Codex and GitHub Copilot.

```bash
# Start an OAuth login flow in your browser
pnpm aikit auth --openai-codex

# GitHub Copilot uses the device flow; add --enterprise <domain> for GitHub Enterprise
pnpm aikit auth --github-copilot

# Check the status of your stored credentials
pnpm aikit auth --openai-codex --status
pnpm aikit auth --github-copilot --status

# Refresh your current credentials
pnpm aikit auth --openai-codex --refresh

# Clear stored credentials
pnpm aikit auth --openai-codex --logout
pnpm aikit auth --github-copilot --logout
```

## Contribute
Expand Down
78 changes: 78 additions & 0 deletions packages/aikit/docs/providers/github-copilot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# GitHub Copilot

The GitHub Copilot provider lets you use models served by `api.githubcopilot.com` — Claude, GPT, Gemini, Grok, Kimi, and others — with a Copilot subscription.

## Setup

Copilot authenticates with GitHub OAuth. Sign in through the bundled CLI device flow:

```bash
pnpm aikit auth --github-copilot

# GitHub Enterprise
pnpm aikit auth --github-copilot --enterprise your.ghe.domain
```

Credentials are stored in `~/.codework/aikit/auth.json` under the `github-copilot` key. Login records the plan-specific API endpoint (individual vs. business) and the account's available model ids.

The provider also picks up existing credentials without a separate login:

```bash
export COPILOT_GITHUB_TOKEN="ghu_..." # or GITHUB_TOKEN / GH_TOKEN
```

It reads `~/.config/github-copilot/hosts.json` (fallback `apps.json`) — the file VS Code, JetBrains, and Neovim Copilot clients already persist — so an editor sign-in is enough.

GitHub OAuth tokens are used directly as the `Authorization: Bearer` credential. They do not expire; re-run `auth --github-copilot` if calls start returning persistent `401`s.

## Language Models

```ts
import { llm, stream, Message } from "@codeworksh/aikit";

// Claude Opus 5 via the Anthropic Messages route
const model = await llm("github-copilot", "claude-opus-5");

const message = await stream.complete(model, {
messages: [
Message.createUserMessage({
role: "user",
time: { created: Date.now() },
parts: [{ type: "text", text: "Explain recursion briefly." }],
}),
],
});
```

## Endpoint routing

Each catalog model carries `api.method`, which selects the wire protocol on the shared Copilot host:

| Models | `api.method` | Endpoint |
| ------------------------------------------------------ | ------------ | ------------------------ |
| `claude-(haiku\|sonnet\|opus\|fable)-[45]*` | `messages` | `POST /v1/messages` |
| `gpt-5+`, `grok-*`, `oswe*`, `mai-*` | `responses` | `POST /responses` |
| everything else (`gpt-4.1`, `gemini-*`, `kimi-*`, ...) | `chat` | `POST /chat/completions` |

The authenticated `/models` catalog's `supported_endpoints` is authoritative; the static routing above is the offline approximation.

Every request sends the Copilot identity headers (`User-Agent: GitHubCopilotChat/…`, `Editor-Version`, `Editor-Plugin-Version`, `Copilot-Integration-Id`, `X-GitHub-Api-Version`) plus `x-initiator` (`user` vs `agent`, inferred from the last message role) and `Copilot-Vision-Request` when the payload contains images. `options.sessionId` maps to `X-Interaction-Id` for session grouping.

## Thinking / reasoning

Copilot models carry `thinkingLevelMap` derived from the live catalog's `reasoning_options`, with manual overrides for verified behavior (e.g. Opus 4.7/4.8/5 clamp `minimal` to `low`). Adaptive Claude models send `effort` instead of a token budget; Responses models send `reasoningEffort` with `store: false` + encrypted reasoning content; the chat route has no reasoning control.

```ts
await stream.complete(model, context, { reasoning: "high" });
```

## CLI

```bash
pnpm aikit auth --github-copilot # device-flow login
pnpm aikit auth --github-copilot --status # stored credential status (no tokens)
pnpm aikit auth --github-copilot --logout # clear credentials
pnpm aikit auth --github-copilot --enable-models # also enable `unconfigured` catalog models
```

Note: Copilot Free accounts (`free_limited_copilot` SKU) can only reach a small subset of models — typically just `gpt-4.1` — regardless of what the catalog lists. Plan-restricted models fail with `model_not_supported`.
6 changes: 6 additions & 0 deletions packages/aikit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
"import": "./dist/pack/oauth/openai/codex.mjs",
"default": "./dist/pack/oauth/openai/codex.mjs"
},
"./oauth/github/copilot": {
"development": "./src/oauth/github/copilot.ts",
"types": "./dist/pack/oauth/github/copilot.d.mts",
"import": "./dist/pack/oauth/github/copilot.mjs",
"default": "./dist/pack/oauth/github/copilot.mjs"
},
"./modelgen": {
"development": "./src/modelgen.ts",
"types": "./dist/pack/modelgen.d.mts",
Expand Down
222 changes: 194 additions & 28 deletions packages/aikit/src/cli/modelgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CommandModule } from "yargs";
import { DEFAULT_AI_SDK_FALLBACK, isAISDKPackage, protocolForPackage } from "../llm/registry.ts";
import * as ModelCatalog from "../model/catalog.ts";
import * as Model from "../model/model.ts";
import { GITHUB_COPILOT_STATIC_HEADERS } from "../providers/github-copilot/copilot-headers.ts";
import * as Filesystem from "../utils/filesystem.ts";
import { lazy } from "../utils/lazy.ts";

Expand Down Expand Up @@ -37,7 +38,21 @@ interface ModelsDevModel {
output?: number;
cache_read?: number;
cache_write?: number;
tiers?: Array<{
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
tier?: { type?: string; size?: number };
}>;
};
reasoning_options?: Array<{
type?: string;
values?: string[];
min?: number;
max?: number;
}>;
status?: string;
limit?: {
context?: number;
output?: number;
Expand Down Expand Up @@ -180,32 +195,6 @@ function mergeThinkingLevelMap(model: Model.Info, map: ThinkingLevelMap): void {
model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map };
}

/**
* Claude models that decide their own thinking depth.
*
* These take an `effort` level and let the model size each turn's reasoning; the
* older models take a fixed `budget_tokens` instead. Sending a budget to an
* adaptive model pins it to one depth for every turn, which is the thing adaptive
* thinking exists to avoid.
*/
function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
return [
"opus-4-6",
"opus-4.6",
"opus-4-7",
"opus-4.7",
"opus-4-8",
"opus-4.8",
"opus-5",
"opus.5",
"sonnet-4-6",
"sonnet-4.6",
"sonnet-5",
"sonnet.5",
"fable-5",
].some((needle) => modelId.includes(needle));
}

function mergeCompat(model: Model.Info, compat: Model.Compatibility): void {
model.compat = { ...model.compat, ...compat };
}
Expand Down Expand Up @@ -251,7 +240,7 @@ function applyModelMetadata(model: Model.Info): void {
if (
(model.protocol === Model.KnownProviderEnum.anthropic ||
model.protocol === Model.KnownProviderEnum.googleVertexAnthropic) &&
isAnthropicAdaptiveThinkingModel(model.id)
Model.isAnthropicAdaptiveThinkingModel(model.id)
) {
mergeCompat(model, { forceAdaptiveThinking: true });
}
Expand Down Expand Up @@ -420,12 +409,189 @@ export function openAICodexBuiltInModels(): Record<string, Model.Info> {
return models;
}

//
// GitHub Copilot models.
// Sourced from models.dev, then rewritten onto the bundled provider: one
// `github-copilot` protocol whose `api.method` selects between Chat
// Completions, Responses, and Anthropic Messages.
const GITHUB_COPILOT_PROVIDER_ID = "github-copilot";
const GITHUB_COPILOT_NPM = "@codeworksh/ai-sdk-github-copilot";
const GITHUB_COPILOT_BASE_URL = "https://api.githubcopilot.com";
const GITHUB_COPILOT_EXTENDED_CONTEXT = 1_000_000;

// GitHub's "Models with extended capabilities" table lists these Copilot
// models as supporting the extended 1 million token context window (pi).
const GITHUB_COPILOT_EXTENDED_CONTEXT_MODELS = new Set([
"claude-fable-5",
"claude-opus-4.6",
"claude-opus-4.7",
"claude-opus-4.8",
"claude-opus-5",
"claude-sonnet-4.6",
"claude-sonnet-5",
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.5",
]);

// Checked manually against the authenticated GitHub Copilot /models endpoint
// (pi). Narrow corrections over models.dev metadata, not a catalog snapshot.
const GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES: Record<string, ThinkingLevelMap> = {
"claude-opus-4.7": { minimal: "low" },
"claude-opus-4.8": { minimal: "low" },
"claude-opus-5": { minimal: "low" },
"claude-sonnet-4.6": { minimal: "low", max: "max" },
};

/**
* The offline routing approximation (pi). The live `/models` response's
* `supported_endpoints` is authoritative at runtime.
*
* - `claude-(haiku|sonnet|opus|fable)-[45]` → Anthropic Messages
* - `gpt-*`, `grok-*`, `oswe*`, `mai-*` → OpenAI Responses
* - everything else → OpenAI Chat Completions
*/
export function githubCopilotApiMethod(modelId: string): Model.APIMethodEnum {
if (/^claude-(haiku|sonnet|opus|fable)-[45]([.-]|$)/.test(modelId)) {
return Model.APIMethodEnum.messages;
}
const gpt = /^gpt-(\d+)/.exec(modelId);
if (
(gpt !== null && Number(gpt[1]) >= 5) ||
modelId.startsWith("grok-") ||
modelId.startsWith("oswe") ||
modelId.startsWith("mai-")
) {
return Model.APIMethodEnum.responses;
}
return Model.APIMethodEnum.chat;
}

/** Effort values models.dev advertises for a model, when it lists any. */
function githubCopilotEffortValues(model: ModelsDevModel): Set<string> | undefined {
for (const option of model.reasoning_options ?? []) {
if (option.type === "effort" && Array.isArray(option.values)) return new Set(option.values);
}
return undefined;
}

/**
* Derive the level map from models.dev `reasoning_options`: a level maps to
* itself only when the endpoint actually advertises it. `minimal` degrades to
* `low` — the lowest effort every reasoning model accepts.
*/
function githubCopilotThinkingLevels(model: ModelsDevModel, method: Model.APIMethodEnum): ThinkingLevelMap | undefined {
const efforts = githubCopilotEffortValues(model);
const map: ThinkingLevelMap = {};
// Copilot rejects `reasoningEffort: "none"` (pi), and the chat route has no
// reasoning control, so only Messages models can disable thinking.
if (method !== Model.APIMethodEnum.messages) map.off = null;
if (!efforts) return Object.keys(map).length > 0 ? map : undefined;

if (efforts.has("minimal")) map.minimal = "minimal";
else if (efforts.has("low")) map.minimal = "low";
for (const level of ["low", "medium", "high", "xhigh", "max"] as const) {
map[level] = efforts.has(level) ? level : null;
}
return map;
}

function githubCopilotCostTiers(model: ModelsDevModel): Model.Info["cost"]["tiers"] | undefined {
const tiers = model.cost?.tiers?.flatMap((tier) => {
const context = tier.tier;
if (context?.type !== "context" || context.size === undefined) return [];
return [
{
inputTokensAbove: context.size,
input: tier.input ?? 0,
output: tier.output ?? 0,
cacheRead: tier.cache_read ?? 0,
cacheWrite: tier.cache_write ?? 0,
},
];
});
return tiers && tiers.length > 0 ? tiers : undefined;
}

export function githubCopilotBuiltInModels(provider: ModelsDevProvider | undefined): Record<string, Model.Info> {
const models: Record<string, Model.Info> = {};
if (!provider) return models;
for (const [id, source] of Object.entries(provider.models)) {
if (source.tool_call !== true) continue;
if (source.status === "deprecated") continue;
// The chat-latest alias races the Responses route for gpt-5 models.
if (id === "gpt-5-chat-latest") continue;

const base = applyModification(GITHUB_COPILOT_PROVIDER_ID, provider, source);
if (!base) continue;

const method = githubCopilotApiMethod(id);
const tiers = githubCopilotCostTiers(source);
const info: Model.Info = {
...base,
provider: {
id: GITHUB_COPILOT_PROVIDER_ID,
name: "GitHub Copilot",
source: "custom",
env: ["COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN"],
},
baseUrl: GITHUB_COPILOT_BASE_URL,
headers: { ...GITHUB_COPILOT_STATIC_HEADERS },
cost: { ...base.cost, ...(tiers ? { tiers } : {}) },
npm: GITHUB_COPILOT_NPM,
api: { id, url: GITHUB_COPILOT_BASE_URL, method },
providerOptionsKey: GITHUB_COPILOT_PROVIDER_ID,
protocol: Model.KnownProviderEnum.githubCopilot,
// Copilot's Responses route requires store=false + encrypted reasoning
// content for thinking to round-trip.
...(method === Model.APIMethodEnum.responses
? {
providerOptions: {
...base.providerOptions,
[GITHUB_COPILOT_PROVIDER_ID]: {
store: false,
include: ["reasoning.encrypted_content"],
},
},
}
: {}),
};

if (GITHUB_COPILOT_EXTENDED_CONTEXT_MODELS.has(id)) info.contextWindow = GITHUB_COPILOT_EXTENDED_CONTEXT;
if (method === Model.APIMethodEnum.messages && Model.isAnthropicAdaptiveThinkingModel(id)) {
mergeCompat(info, { forceAdaptiveThinking: true });
}
// Copilot's Responses endpoint passes OpenAI custom grammar tools through
// for GPT-5+ (verified by pi).
const gpt = /^gpt-(\d+)/.exec(id);
if (method === Model.APIMethodEnum.responses && gpt !== null && Number(gpt[1]) >= 5) {
mergeCompat(info, { supportsOpenAIGrammarTools: true });
}
if (id.startsWith("gpt-5")) mergeThinkingLevelMap(info, { off: null, minimal: "low" });
if (id.includes("fable-5")) mergeThinkingLevelMap(info, { off: null, xhigh: "xhigh", max: "max" });

const levels = githubCopilotThinkingLevels(source, method);
if (levels) mergeThinkingLevelMap(info, levels);
const override = GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES[id];
if (override) mergeThinkingLevelMap(info, override);

models[id] = info;
}
return models;
}

export async function generateModels(args: { path?: string | undefined } = {}): Promise<string> {
const path = resolve(args.path ?? ModelCatalog.path());
const catalog = await pullModelsDevData();
const modelsDev = await loadBuiltInFromModelsDev();
const customCodexModels = openAICodexBuiltInModels();
const copilotProvider = catalog[GITHUB_COPILOT_PROVIDER_ID];

const allModels = { ...modelsDev, [OPENAI_CODEX_PROVIDER_ID]: customCodexModels };
const allModels = {
...modelsDev,
[OPENAI_CODEX_PROVIDER_ID]: customCodexModels,
...(copilotProvider ? { [GITHUB_COPILOT_PROVIDER_ID]: githubCopilotBuiltInModels(copilotProvider) } : {}),
};
await Filesystem.writeJson(path, allModels);
return path;
}
Expand Down
Loading