Skip to content

Commit e24ee5f

Browse files
committed
Merge branch 'codework-aikit-github-copilot-v1' into harness-aikit-oauth
2 parents 05caed4 + 9edf183 commit e24ee5f

30 files changed

Lines changed: 2621 additions & 138 deletions

‎packages/aikit/README.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,20 +147,25 @@ pnpm aikit modelgen [path]
147147

148148
### OAuth Providers
149149

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

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

156+
# GitHub Copilot uses the device flow; add --enterprise <domain> for GitHub Enterprise
157+
pnpm aikit auth --github-copilot
158+
156159
# Check the status of your stored credentials
157160
pnpm aikit auth --openai-codex --status
161+
pnpm aikit auth --github-copilot --status
158162

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

162166
# Clear stored credentials
163167
pnpm aikit auth --openai-codex --logout
168+
pnpm aikit auth --github-copilot --logout
164169
```
165170

166171
## Contribute
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# GitHub Copilot
2+
3+
The GitHub Copilot provider lets you use models served by `api.githubcopilot.com` — Claude, GPT, Gemini, Grok, Kimi, and others — with a Copilot subscription.
4+
5+
## Setup
6+
7+
Copilot authenticates with GitHub OAuth. Sign in through the bundled CLI device flow:
8+
9+
```bash
10+
pnpm aikit auth --github-copilot
11+
12+
# GitHub Enterprise
13+
pnpm aikit auth --github-copilot --enterprise your.ghe.domain
14+
```
15+
16+
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.
17+
18+
The provider also picks up existing credentials without a separate login:
19+
20+
```bash
21+
export COPILOT_GITHUB_TOKEN="ghu_..." # or GITHUB_TOKEN / GH_TOKEN
22+
```
23+
24+
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.
25+
26+
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.
27+
28+
## Language Models
29+
30+
```ts
31+
import { llm, stream, Message } from "@codeworksh/aikit";
32+
33+
// Claude Opus 5 via the Anthropic Messages route
34+
const model = await llm("github-copilot", "claude-opus-5");
35+
36+
const message = await stream.complete(model, {
37+
messages: [
38+
Message.createUserMessage({
39+
role: "user",
40+
time: { created: Date.now() },
41+
parts: [{ type: "text", text: "Explain recursion briefly." }],
42+
}),
43+
],
44+
});
45+
```
46+
47+
## Endpoint routing
48+
49+
Each catalog model carries `api.method`, which selects the wire protocol on the shared Copilot host:
50+
51+
| Models | `api.method` | Endpoint |
52+
| ------------------------------------------------------ | ------------ | ------------------------ |
53+
| `claude-(haiku\|sonnet\|opus\|fable)-[45]*` | `messages` | `POST /v1/messages` |
54+
| `gpt-5+`, `grok-*`, `oswe*`, `mai-*` | `responses` | `POST /responses` |
55+
| everything else (`gpt-4.1`, `gemini-*`, `kimi-*`, ...) | `chat` | `POST /chat/completions` |
56+
57+
The authenticated `/models` catalog's `supported_endpoints` is authoritative; the static routing above is the offline approximation.
58+
59+
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.
60+
61+
## Thinking / reasoning
62+
63+
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.
64+
65+
```ts
66+
await stream.complete(model, context, { reasoning: "high" });
67+
```
68+
69+
## CLI
70+
71+
```bash
72+
pnpm aikit auth --github-copilot # device-flow login
73+
pnpm aikit auth --github-copilot --status # stored credential status (no tokens)
74+
pnpm aikit auth --github-copilot --logout # clear credentials
75+
pnpm aikit auth --github-copilot --enable-models # also enable `unconfigured` catalog models
76+
```
77+
78+
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`.

‎packages/aikit/package.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
"import": "./dist/pack/oauth/openai/codex.mjs",
4747
"default": "./dist/pack/oauth/openai/codex.mjs"
4848
},
49+
"./oauth/github/copilot": {
50+
"development": "./src/oauth/github/copilot.ts",
51+
"types": "./dist/pack/oauth/github/copilot.d.mts",
52+
"import": "./dist/pack/oauth/github/copilot.mjs",
53+
"default": "./dist/pack/oauth/github/copilot.mjs"
54+
},
4955
"./modelgen": {
5056
"development": "./src/modelgen.ts",
5157
"types": "./dist/pack/modelgen.d.mts",

‎packages/aikit/src/cli/modelgen.ts‎

Lines changed: 194 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { CommandModule } from "yargs";
44
import { DEFAULT_AI_SDK_FALLBACK, isAISDKPackage, protocolForPackage } from "../llm/registry.ts";
55
import * as ModelCatalog from "../model/catalog.ts";
66
import * as Model from "../model/model.ts";
7+
import { GITHUB_COPILOT_STATIC_HEADERS } from "../providers/github-copilot/copilot-headers.ts";
78
import * as Filesystem from "../utils/filesystem.ts";
89
import { lazy } from "../utils/lazy.ts";
910

@@ -37,7 +38,21 @@ interface ModelsDevModel {
3738
output?: number;
3839
cache_read?: number;
3940
cache_write?: number;
41+
tiers?: Array<{
42+
input?: number;
43+
output?: number;
44+
cache_read?: number;
45+
cache_write?: number;
46+
tier?: { type?: string; size?: number };
47+
}>;
4048
};
49+
reasoning_options?: Array<{
50+
type?: string;
51+
values?: string[];
52+
min?: number;
53+
max?: number;
54+
}>;
55+
status?: string;
4156
limit?: {
4257
context?: number;
4358
output?: number;
@@ -180,32 +195,6 @@ function mergeThinkingLevelMap(model: Model.Info, map: ThinkingLevelMap): void {
180195
model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map };
181196
}
182197

183-
/**
184-
* Claude models that decide their own thinking depth.
185-
*
186-
* These take an `effort` level and let the model size each turn's reasoning; the
187-
* older models take a fixed `budget_tokens` instead. Sending a budget to an
188-
* adaptive model pins it to one depth for every turn, which is the thing adaptive
189-
* thinking exists to avoid.
190-
*/
191-
function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
192-
return [
193-
"opus-4-6",
194-
"opus-4.6",
195-
"opus-4-7",
196-
"opus-4.7",
197-
"opus-4-8",
198-
"opus-4.8",
199-
"opus-5",
200-
"opus.5",
201-
"sonnet-4-6",
202-
"sonnet-4.6",
203-
"sonnet-5",
204-
"sonnet.5",
205-
"fable-5",
206-
].some((needle) => modelId.includes(needle));
207-
}
208-
209198
function mergeCompat(model: Model.Info, compat: Model.Compatibility): void {
210199
model.compat = { ...model.compat, ...compat };
211200
}
@@ -251,7 +240,7 @@ function applyModelMetadata(model: Model.Info): void {
251240
if (
252241
(model.protocol === Model.KnownProviderEnum.anthropic ||
253242
model.protocol === Model.KnownProviderEnum.googleVertexAnthropic) &&
254-
isAnthropicAdaptiveThinkingModel(model.id)
243+
Model.isAnthropicAdaptiveThinkingModel(model.id)
255244
) {
256245
mergeCompat(model, { forceAdaptiveThinking: true });
257246
}
@@ -420,12 +409,189 @@ export function openAICodexBuiltInModels(): Record<string, Model.Info> {
420409
return models;
421410
}
422411

412+
//
413+
// GitHub Copilot models.
414+
// Sourced from models.dev, then rewritten onto the bundled provider: one
415+
// `github-copilot` protocol whose `api.method` selects between Chat
416+
// Completions, Responses, and Anthropic Messages.
417+
const GITHUB_COPILOT_PROVIDER_ID = "github-copilot";
418+
const GITHUB_COPILOT_NPM = "@codeworksh/ai-sdk-github-copilot";
419+
const GITHUB_COPILOT_BASE_URL = "https://api.githubcopilot.com";
420+
const GITHUB_COPILOT_EXTENDED_CONTEXT = 1_000_000;
421+
422+
// GitHub's "Models with extended capabilities" table lists these Copilot
423+
// models as supporting the extended 1 million token context window (pi).
424+
const GITHUB_COPILOT_EXTENDED_CONTEXT_MODELS = new Set([
425+
"claude-fable-5",
426+
"claude-opus-4.6",
427+
"claude-opus-4.7",
428+
"claude-opus-4.8",
429+
"claude-opus-5",
430+
"claude-sonnet-4.6",
431+
"claude-sonnet-5",
432+
"gpt-5.3-codex",
433+
"gpt-5.4",
434+
"gpt-5.5",
435+
]);
436+
437+
// Checked manually against the authenticated GitHub Copilot /models endpoint
438+
// (pi). Narrow corrections over models.dev metadata, not a catalog snapshot.
439+
const GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES: Record<string, ThinkingLevelMap> = {
440+
"claude-opus-4.7": { minimal: "low" },
441+
"claude-opus-4.8": { minimal: "low" },
442+
"claude-opus-5": { minimal: "low" },
443+
"claude-sonnet-4.6": { minimal: "low", max: "max" },
444+
};
445+
446+
/**
447+
* The offline routing approximation (pi). The live `/models` response's
448+
* `supported_endpoints` is authoritative at runtime.
449+
*
450+
* - `claude-(haiku|sonnet|opus|fable)-[45]` → Anthropic Messages
451+
* - `gpt-*`, `grok-*`, `oswe*`, `mai-*` → OpenAI Responses
452+
* - everything else → OpenAI Chat Completions
453+
*/
454+
export function githubCopilotApiMethod(modelId: string): Model.APIMethodEnum {
455+
if (/^claude-(haiku|sonnet|opus|fable)-[45]([.-]|$)/.test(modelId)) {
456+
return Model.APIMethodEnum.messages;
457+
}
458+
const gpt = /^gpt-(\d+)/.exec(modelId);
459+
if (
460+
(gpt !== null && Number(gpt[1]) >= 5) ||
461+
modelId.startsWith("grok-") ||
462+
modelId.startsWith("oswe") ||
463+
modelId.startsWith("mai-")
464+
) {
465+
return Model.APIMethodEnum.responses;
466+
}
467+
return Model.APIMethodEnum.chat;
468+
}
469+
470+
/** Effort values models.dev advertises for a model, when it lists any. */
471+
function githubCopilotEffortValues(model: ModelsDevModel): Set<string> | undefined {
472+
for (const option of model.reasoning_options ?? []) {
473+
if (option.type === "effort" && Array.isArray(option.values)) return new Set(option.values);
474+
}
475+
return undefined;
476+
}
477+
478+
/**
479+
* Derive the level map from models.dev `reasoning_options`: a level maps to
480+
* itself only when the endpoint actually advertises it. `minimal` degrades to
481+
* `low` — the lowest effort every reasoning model accepts.
482+
*/
483+
function githubCopilotThinkingLevels(model: ModelsDevModel, method: Model.APIMethodEnum): ThinkingLevelMap | undefined {
484+
const efforts = githubCopilotEffortValues(model);
485+
const map: ThinkingLevelMap = {};
486+
// Copilot rejects `reasoningEffort: "none"` (pi), and the chat route has no
487+
// reasoning control, so only Messages models can disable thinking.
488+
if (method !== Model.APIMethodEnum.messages) map.off = null;
489+
if (!efforts) return Object.keys(map).length > 0 ? map : undefined;
490+
491+
if (efforts.has("minimal")) map.minimal = "minimal";
492+
else if (efforts.has("low")) map.minimal = "low";
493+
for (const level of ["low", "medium", "high", "xhigh", "max"] as const) {
494+
map[level] = efforts.has(level) ? level : null;
495+
}
496+
return map;
497+
}
498+
499+
function githubCopilotCostTiers(model: ModelsDevModel): Model.Info["cost"]["tiers"] | undefined {
500+
const tiers = model.cost?.tiers?.flatMap((tier) => {
501+
const context = tier.tier;
502+
if (context?.type !== "context" || context.size === undefined) return [];
503+
return [
504+
{
505+
inputTokensAbove: context.size,
506+
input: tier.input ?? 0,
507+
output: tier.output ?? 0,
508+
cacheRead: tier.cache_read ?? 0,
509+
cacheWrite: tier.cache_write ?? 0,
510+
},
511+
];
512+
});
513+
return tiers && tiers.length > 0 ? tiers : undefined;
514+
}
515+
516+
export function githubCopilotBuiltInModels(provider: ModelsDevProvider | undefined): Record<string, Model.Info> {
517+
const models: Record<string, Model.Info> = {};
518+
if (!provider) return models;
519+
for (const [id, source] of Object.entries(provider.models)) {
520+
if (source.tool_call !== true) continue;
521+
if (source.status === "deprecated") continue;
522+
// The chat-latest alias races the Responses route for gpt-5 models.
523+
if (id === "gpt-5-chat-latest") continue;
524+
525+
const base = applyModification(GITHUB_COPILOT_PROVIDER_ID, provider, source);
526+
if (!base) continue;
527+
528+
const method = githubCopilotApiMethod(id);
529+
const tiers = githubCopilotCostTiers(source);
530+
const info: Model.Info = {
531+
...base,
532+
provider: {
533+
id: GITHUB_COPILOT_PROVIDER_ID,
534+
name: "GitHub Copilot",
535+
source: "custom",
536+
env: ["COPILOT_GITHUB_TOKEN", "GITHUB_TOKEN"],
537+
},
538+
baseUrl: GITHUB_COPILOT_BASE_URL,
539+
headers: { ...GITHUB_COPILOT_STATIC_HEADERS },
540+
cost: { ...base.cost, ...(tiers ? { tiers } : {}) },
541+
npm: GITHUB_COPILOT_NPM,
542+
api: { id, url: GITHUB_COPILOT_BASE_URL, method },
543+
providerOptionsKey: GITHUB_COPILOT_PROVIDER_ID,
544+
protocol: Model.KnownProviderEnum.githubCopilot,
545+
// Copilot's Responses route requires store=false + encrypted reasoning
546+
// content for thinking to round-trip.
547+
...(method === Model.APIMethodEnum.responses
548+
? {
549+
providerOptions: {
550+
...base.providerOptions,
551+
[GITHUB_COPILOT_PROVIDER_ID]: {
552+
store: false,
553+
include: ["reasoning.encrypted_content"],
554+
},
555+
},
556+
}
557+
: {}),
558+
};
559+
560+
if (GITHUB_COPILOT_EXTENDED_CONTEXT_MODELS.has(id)) info.contextWindow = GITHUB_COPILOT_EXTENDED_CONTEXT;
561+
if (method === Model.APIMethodEnum.messages && Model.isAnthropicAdaptiveThinkingModel(id)) {
562+
mergeCompat(info, { forceAdaptiveThinking: true });
563+
}
564+
// Copilot's Responses endpoint passes OpenAI custom grammar tools through
565+
// for GPT-5+ (verified by pi).
566+
const gpt = /^gpt-(\d+)/.exec(id);
567+
if (method === Model.APIMethodEnum.responses && gpt !== null && Number(gpt[1]) >= 5) {
568+
mergeCompat(info, { supportsOpenAIGrammarTools: true });
569+
}
570+
if (id.startsWith("gpt-5")) mergeThinkingLevelMap(info, { off: null, minimal: "low" });
571+
if (id.includes("fable-5")) mergeThinkingLevelMap(info, { off: null, xhigh: "xhigh", max: "max" });
572+
573+
const levels = githubCopilotThinkingLevels(source, method);
574+
if (levels) mergeThinkingLevelMap(info, levels);
575+
const override = GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES[id];
576+
if (override) mergeThinkingLevelMap(info, override);
577+
578+
models[id] = info;
579+
}
580+
return models;
581+
}
582+
423583
export async function generateModels(args: { path?: string | undefined } = {}): Promise<string> {
424584
const path = resolve(args.path ?? ModelCatalog.path());
585+
const catalog = await pullModelsDevData();
425586
const modelsDev = await loadBuiltInFromModelsDev();
426587
const customCodexModels = openAICodexBuiltInModels();
588+
const copilotProvider = catalog[GITHUB_COPILOT_PROVIDER_ID];
427589

428-
const allModels = { ...modelsDev, [OPENAI_CODEX_PROVIDER_ID]: customCodexModels };
590+
const allModels = {
591+
...modelsDev,
592+
[OPENAI_CODEX_PROVIDER_ID]: customCodexModels,
593+
...(copilotProvider ? { [GITHUB_COPILOT_PROVIDER_ID]: githubCopilotBuiltInModels(copilotProvider) } : {}),
594+
};
429595
await Filesystem.writeJson(path, allModels);
430596
return path;
431597
}

0 commit comments

Comments
 (0)