Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
42 changes: 31 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ real Copilot request are the required validation.
## Layout

```text
src/index.ts obtains and wraps Pi's effective native provider
src/index.ts wraps the built-in provider and registers startup discovery
src/models.ts fetches /models and creates Model[] entries
src/oauth.ts wraps native login only to enable discovered policies
src/families.ts maps unfamiliar model families to Pi API/compat metadata
Expand All @@ -49,19 +49,23 @@ Pi 0.83 already calls `/models`, but only extracts IDs and filters its static
`GITHUB_COPILOT_MODELS` catalog. Unknown IDs still need this extension.

```text
extension load (async factory)
├─ builtinProviders().find("github-copilot")
├─ read stored credential from auth.json (startup discovery only)
├─ GET <token-derived proxy>/models (best effort)
├─ register native provider override before enabledModels scope resolution
session_start
├─ ctx.modelRegistry.getProvider("github-copilot")
├─ preserve the provider's auth, streams, base behavior, and ID
├─ replace getModels/filterModels/refreshModels in a native Provider wrapper
└─ ctx.modelRegistry.refresh()
├─ Pi refreshes the stored OAuth credential under its lock
├─ extension receives the valid credential in RefreshModelsContext
├─ GET <token-derived proxy>/models
└─ publish discovered Model[] entries
```

The extension does not read or write `auth.json`. Pi owns credentials,
persistence, token refresh, enterprise endpoint derivation, and logout.
The extension does not write `auth.json`. Pi owns credential persistence,
token refresh, enterprise endpoint derivation, and logout. Startup discovery
reads the stored credential once during extension load so `enabledModels` scope
can resolve before `session_start`.

## Critical rules

Expand All @@ -75,8 +79,10 @@ imports. The package's first compatible release is 0.4.0.

Do not import `@earendil-works/pi-ai/providers/github-copilot` from an
extension. Pi's jiti aliasing currently treats that subpath as a suffix of its
compat entry and fails resolution. Obtain the provider from
`ctx.modelRegistry.getProvider("github-copilot")` during `session_start`.
compat entry and fails resolution. Use `builtinProviders()` from
`@earendil-works/pi-ai/providers/all` during extension load, then register the
provider override before Pi resolves `enabledModels` scope. Waiting until
`session_start` is too late for scoped startup models.

### Keep the provider ID `github-copilot`

Expand All @@ -91,11 +97,14 @@ The provider wrapper must spread the effective provider and override only:
- `auth.oauth.login` (policy enablement only)
- `getModels`
- `refreshModels`
- `stream` / `streamSimple` (only to translate Pi-only Luna context aliases
back to the canonical Copilot model ID)
- `filterModels`

Never reimplement login, refresh, credential persistence, `toAuth`, or stream
functions. `src/oauth.ts` delegates login to the native OAuth object before
performing best-effort policy POSTs.
Never reimplement login, refresh, credential persistence, `toAuth`, or the
actual stream implementation. `src/oauth.ts` delegates login to the native
OAuth object before performing best-effort policy POSTs; stream wrappers must
continue delegating directly to the native provider.

### Include static Copilot client headers on discovered models

Expand Down Expand Up @@ -124,6 +133,17 @@ Do not trust `/models` blindly. Keep only object records with a non-empty string
`id`, chat capability, model-picker availability, and tool-call support. Dedup
by ID before publishing.

### Keep context-tier aliases generic and local

Copilot's `/models` endpoint returns one canonical ID per model, not suffixed
context IDs. When billing metadata reports a default and `long_context` tier,
the extension may publish aliases such as `model@200k` and `model@1m` using the
reported context sizes. Every alias must be translated back to its canonical
base ID before calling the native stream function; never send suffixed IDs to
Copilot. Do not special-case Luna or assume every model uses a 200K threshold.
Sort the published catalog by canonical ID, base model first, then aliases by
numeric context size so refreshes do not reshuffle the selector.

### Keep family routing conservative

Prefer Copilot `supported_endpoints` when a model is responses-only
Expand Down
68 changes: 54 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,24 @@ waiting for another Pi release.
- **Preserves Pi's built-in provider.** Authentication, credential persistence,
token refresh, enterprise endpoint selection, request headers, and streaming
remain owned by Pi 0.83's native `github-copilot` provider.
- **Discovers models on session start and after login.** The extension wraps the
effective provider with a native `refreshModels` implementation.
- **Discovers models during extension load and after login.** The extension
registers its provider override before Pi resolves `enabledModels` scope, then
wraps the built-in provider with a native `refreshModels` implementation.
- **Refreshes on demand.** `/copilot-refresh` re-fetches the tenant catalog.
- **Enables tenant policies after login.** The wrapped built-in OAuth login runs
`POST /models/<id>/policy {"state":"enabled"}` for every discovered model.
- **Classifies unknown models conservatively.** Claude uses Anthropic Messages,
GPT-5/o1/o3 use OpenAI Responses, responses-only models (for example Grok 4.5)
use OpenAI Responses from `supported_endpoints`, and remaining chat models use
OpenAI Chat Completions.
- **Exposes generic context choices.** When Copilot reports tiered billing
metadata, each model gets Pi-only aliases such as `gpt-5.6-luna@200k` and
`gpt-5.6-luna@1m`. Aliases send the canonical model ID to Copilot and control
Pi's compaction/context limit rather than pretending to be Copilot API IDs.
- **Keeps discovery order deterministic.** Models are ordered by canonical ID,
with the base model first and context aliases from smallest to largest. Pi's
scoped selector still places enabled models first, so its search field is the
quickest way to isolate a family or suffix.

## Install

Expand Down Expand Up @@ -75,29 +84,59 @@ work. If needed, run `/login github-copilot`.
## How it works

```text
Pi initializes its built-in github-copilot provider
└─ session_start
├─ extension obtains the effective provider from ctx.modelRegistry
├─ wraps it without replacing auth or streaming
├─ registers a native provider with refreshModels
└─ refreshModels receives Pi's already-refreshed credential
├─ GET <credential-specific proxy>/models
├─ build Model[] from the live response
└─ publish the live catalog synchronously through getModels()
Pi loads extensions
├─ extension reads stored github-copilot credential from auth.json
├─ GET <credential-specific proxy>/models (best-effort startup discovery)
├─ registers a native provider override with the live catalog
└─ Pi resolves enabledModels scope against the discovered catalog
session_start
└─ modelRegistry.refresh() with Pi's refreshed credential
login / copilot-refresh
└─ refreshModels receives Pi's credential
├─ GET <credential-specific proxy>/models
├─ build Model[] from the live response
└─ publish the live catalog synchronously through getModels()
```

The wrapper keeps the literal provider ID `github-copilot`, so Pi's built-in
Copilot header injection and provider-specific request behavior still apply.
Each discovered model also receives the static Copilot client headers required
by the proxy.
by the proxy. Luna context aliases are translated back to the canonical wire
model ID before delegating to the native stream functions.

### Context-tier aliases

GitHub's live `/models` response includes billing metadata for models with
multiple context tiers. The extension maps those prices into Pi's dollar-per-
million-token `cost` fields (the response reports AI credits, where 1 credit is
$0.01). For each model with both a default tier and a `long_context` tier, the
extension publishes aliases based on the reported context sizes. For example,
the current Luna response becomes:

| Pi model ID | Pi context window | Copilot wire model |
| --- | ---: | --- |
| `gpt-5.6-luna@200k` | 200,000 | `gpt-5.6-luna` |
| `gpt-5.6-luna@1m` | 1,050,000 (tenant-supplied) | `gpt-5.6-luna` |

The same mechanism applies to other tiered models, using their own thresholds
(for example, a model whose default tier is 272K receives an `@272k` alias).
The aliases are intentionally local because suffixed IDs are not returned by
`/models` and are not valid Copilot API model IDs. Copilot's default versus
long-context billing tier is selected by the service based on request context;
the smaller alias prevents Pi from building a request beyond the default tier,
while the larger alias allows the long-context tier.

Pi's `/scoped-models` view intentionally displays enabled models before disabled
models. Search for a canonical family (`gpt-5.6-luna`) or suffix (`@200k`) to
find a variant immediately; use Alt+Up/Alt+Down to reorder enabled entries.

### Family → API routing

| Model family / endpoint signal | Pi API | Reasoning |
| --- | --- | --- |
| `claude-*` (3.5+, 4.x, 5.x) | `anthropic-messages` | yes |
| `claude-2.x`, `claude-3` (3.0–3.4) | `anthropic-messages` | no |
| `gpt-5*`, `o1`, `o3` | `openai-responses` | yes |
| `gpt-5*`, `o1`, `o3` (including Luna aliases) | `openai-responses` | yes |
| responses-only (`supported_endpoints: ["/responses"]`) | `openai-responses` | if advertised |
| `gpt-4*`, `gemini-*`, other chat-completions | `openai-completions` | no |

Expand All @@ -114,7 +153,8 @@ for an unfamiliar family.
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `/model` has no Copilot entries | No configured Copilot credential | Run `/login github-copilot` |
| Only Pi's bundled models appear | Live refresh failed or the extension did not load | Run `/copilot-refresh` and inspect the notification |
| Only Pi's bundled models appear at startup | Live refresh failed or the extension did not load | Run `/copilot-refresh` and inspect the notification |
| `enabledModels` warnings for discovered IDs | Provider registered after scope resolution (fixed in 0.4.2+) | Upgrade pi-copilot-discovery |
| A preview model returns 403 | Its tenant policy was not enabled | Re-run `/login github-copilot` |
| Proxy rejects `Editor-Version` or `User-Agent` | Pi changed its Copilot client headers | Update `COPILOT_HEADERS` in `src/models.ts` |
| `unsupported_api_for_model` on `/chat/completions` | Model is responses-only (`supported_endpoints: ["/responses"]`) | Ensure discovery reads `supported_endpoints`; update if needed |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@milespossing/pi-copilot-discovery",
"version": "0.4.1",
"version": "0.5.0",
"description": "Dynamic GitHub Copilot model discovery for pi — replaces pi-ai's static catalog with the live /models list from your Copilot tenant.",
"type": "module",
"license": "MIT",
Expand Down
116 changes: 98 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,78 @@
* pi-copilot-discovery — dynamic GitHub Copilot model discovery for pi.
*
* Pi 0.83 owns the built-in provider's auth, token refresh, request base URL,
* headers, and streaming. Once a session starts, this extension wraps that
* effective provider and replaces only its model catalog with live /models data.
* headers, and streaming. This extension wraps that provider and replaces only
* its model catalog with live /models data.
*/

import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";

import type {
Api,
Credential,
Model,
OAuthCredential,
Provider,
RefreshModelsContext,
} from "@earendil-works/pi-ai";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";

import { fetchCopilotModels, resolveCopilotBaseUrl, toProviderModels } from "./models.ts";
import {
fetchCopilotModels,
resolveCopilotBaseUrl,
toCopilotWireModel,
toProviderModels,
} from "./models.ts";
import { wrapCopilotOAuth } from "./oauth.ts";

const PROVIDER_NAME = "github-copilot";
type RefreshResult = { ok: true; count: number } | { ok: false; error: string };

function getBuiltinProvider(): Provider {
const builtin = builtinProviders().find((provider) => provider.id === PROVIDER_NAME);
if (!builtin) {
throw new Error("built-in github-copilot provider not found");
}
return builtin;
}

function getAuthPath(): string {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey in the past I had some strange issues with auth and stored creds. I'm not sure what you've done here is wrong, but the behavior I saw before was that the extension would start and work for maybe an hour or so, but eventually we would get token expired exceptions.

I'm not reading super closely yet, but I wanted to be sure that pi is still owning the token acquisition and we aren't exposing ourselves to auth token rot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i will admit, i vibed through the issue, i dont know much about pi, but the outcome was good, doesnt mean what i did is 'correct'

const envDir = process.env.PI_CODING_AGENT_DIR;
const base = envDir
? envDir.replace(/^~(\/|$)/, `${homedir()}$1`)
: join(homedir(), ".pi", "agent");
return join(base, "auth.json");
}

async function readStoredCredential(): Promise<OAuthCredential | null> {
try {
const raw = await readFile(getAuthPath(), "utf8");
const json = JSON.parse(raw) as Record<string, unknown>;
const entry = json[PROVIDER_NAME];
if (
entry &&
typeof entry === "object" &&
"access" in entry &&
typeof (entry as OAuthCredential).access === "string"
) {
return entry as OAuthCredential;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
console.error(
`pi-copilot-discovery: could not read auth.json (${error instanceof Error ? error.message : String(error)})`,
);
}
}
return null;
}

function credentialToken(credential: Credential | undefined): string | undefined {
if (credential?.type === "oauth") return credential.access;
return credential?.key;
Expand All @@ -39,9 +89,10 @@ function notifyRefreshResult(result: RefreshResult, ctx: ExtensionCommandContext

function createDiscoveryProvider(
builtin: Provider,
initialModels: readonly Model<Api>[] | undefined,
onRefresh: (result: RefreshResult) => void,
): Provider {
let models: readonly Model<Api>[] = builtin.getModels();
let models: readonly Model<Api>[] = initialModels ?? builtin.getModels();

const refreshModels = async (context: RefreshModelsContext): Promise<void> => {
if (!context.allowNetwork || context.signal?.aborted) return;
Expand Down Expand Up @@ -74,32 +125,61 @@ function createDiscoveryProvider(
: builtin.auth,
getModels: () => models,
refreshModels,
// Context-tier aliases are Pi-only IDs. Keep the native Copilot request
// path, but translate them back to the canonical model ID on the wire.
stream: (model, context, options) =>
builtin.stream(toCopilotWireModel(model), context, options),
streamSimple: (model, context, options) =>
builtin.streamSimple(toCopilotWireModel(model), context, options),
// The live catalog is already credential-specific. The built-in filter
// projects a static catalog and would remove models unknown to that list.
filterModels: (available) => available,
};
}

export default function (pi: ExtensionAPI): void {
async function discoverStartupModels(
builtin: Provider,
credential: OAuthCredential,
): Promise<readonly Model<Api>[] | undefined> {
const baseUrl = await resolveCopilotBaseUrl(builtin, credential, credential.access);
const raw = await fetchCopilotModels(credential.access, baseUrl);
const discovered = toProviderModels(raw, baseUrl);
return discovered.length > 0 ? discovered : undefined;
}

export default async function (pi: ExtensionAPI): Promise<void> {
const refreshState: { last: RefreshResult } = {
last: { ok: false, error: "not logged in" },
};
let registered = false;
const builtin = getBuiltinProvider();
const storedCredential = await readStoredCredential();
let initialModels: readonly Model<Api>[] | undefined;

pi.on("session_start", async (_event, ctx) => {
if (!registered) {
const builtin = ctx.modelRegistry.getProvider(PROVIDER_NAME);
if (!builtin) {
refreshState.last = { ok: false, error: "built-in provider not found" };
return;
if (storedCredential) {
try {
initialModels = await discoverStartupModels(builtin, storedCredential);
if (initialModels) {
refreshState.last = { ok: true, count: initialModels.length };
} else {
refreshState.last = { ok: false, error: "discovery returned no models" };
}
pi.registerProvider(
createDiscoveryProvider(builtin, (result) => {
refreshState.last = result;
}),
);
registered = true;
} catch (error) {
refreshState.last = {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
console.error(`pi-copilot-discovery: startup discovery failed (${refreshState.last.error})`);
}
}

// Register before pi resolves enabledModels scope. session_start is too late.
pi.registerProvider(
createDiscoveryProvider(builtin, initialModels, (result) => {
refreshState.last = result;
}),
);

pi.on("session_start", async (_event, ctx) => {
await ctx.modelRegistry.refresh();
});

Expand Down
Loading