diff --git a/AGENTS.md b/AGENTS.md index 303f744..9ad53e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -49,10 +49,12 @@ 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 /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 @@ -60,8 +62,10 @@ session_start └─ 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 @@ -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` @@ -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 @@ -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 diff --git a/README.md b/README.md index ce2afc0..93f18f3 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ 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//policy {"state":"enabled"}` for every discovered model. @@ -32,6 +33,14 @@ waiting for another Pi release. 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 @@ -75,21 +84,51 @@ 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 /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 /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 /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 @@ -97,7 +136,7 @@ by the proxy. | --- | --- | --- | | `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 | @@ -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 | diff --git a/package-lock.json b/package-lock.json index de77d44..9d5f26e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@milespossing/pi-copilot-discovery", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@milespossing/pi-copilot-discovery", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "devDependencies": { "@earendil-works/pi-ai": "^0.83.0", diff --git a/package.json b/package.json index d9e19bc..f90dea5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 8cd5c94..8f647b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { + 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 { + try { + const raw = await readFile(getAuthPath(), "utf8"); + const json = JSON.parse(raw) as Record; + 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; @@ -39,9 +89,10 @@ function notifyRefreshResult(result: RefreshResult, ctx: ExtensionCommandContext function createDiscoveryProvider( builtin: Provider, + initialModels: readonly Model[] | undefined, onRefresh: (result: RefreshResult) => void, ): Provider { - let models: readonly Model[] = builtin.getModels(); + let models: readonly Model[] = initialModels ?? builtin.getModels(); const refreshModels = async (context: RefreshModelsContext): Promise => { if (!context.allowNetwork || context.signal?.aborted) return; @@ -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[] | 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 { 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[] | 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(); }); diff --git a/src/models.ts b/src/models.ts index 8e9e2d6..7b39d19 100644 --- a/src/models.ts +++ b/src/models.ts @@ -5,6 +5,7 @@ import { classify } from "./families.ts"; const PROVIDER_NAME = "github-copilot"; const DEFAULT_BASE_URL = "https://api.individual.githubcopilot.com"; +const CONTEXT_ALIAS_PATTERN = /@(\d+(?:\.\d+)?)([km])$/i; /** Static client headers mirrored from pi-ai's GitHub Copilot OAuth flow. */ export const COPILOT_HEADERS = { @@ -15,6 +16,17 @@ export const COPILOT_HEADERS = { "X-GitHub-Api-Version": "2026-06-01", } as const; +type CopilotBillingTier = { + context_max?: number; + max_prompt_tokens?: number; + input_price?: number; + output_price?: number; + cache_price?: number; + cache_read_price?: number; + cache_write_price?: number; + batch_size?: number; +}; + export type CopilotModel = { id: string; name?: string; @@ -37,6 +49,12 @@ export type CopilotModel = { supported_endpoints?: string[]; model_picker_enabled?: boolean; policy?: { state?: "enabled" | "disabled" | "unconfigured" }; + billing?: { + token_prices?: { + default?: CopilotBillingTier; + long_context?: CopilotBillingTier; + }; + }; }; function baseUrlFromToken(token: string): string | undefined { @@ -93,6 +111,91 @@ export async function fetchCopilotModels( ); } +/** + * Return the provider-wire ID for a Pi-only context-window alias. + * + * Copilot's `/models` response exposes one model ID per model. A suffix such + * as `@200k` or `@1m` is a local selector, not an ID accepted by Copilot. + */ +export function toCopilotWireModelId(id: string): string { + return id.replace(CONTEXT_ALIAS_PATTERN, ""); +} + +/** Make a model alias usable with pi-ai's native provider stream functions. */ +export function toCopilotWireModel>(model: T): T { + const wireId = toCopilotWireModelId(model.id); + return wireId === model.id ? model : ({ ...model, id: wireId } as T); +} + +function positiveNumber(value: number | undefined): number | undefined { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : undefined; +} + +type ModelCost = Model["cost"]; + +function billingPrice(value: number | undefined): number { + // Copilot reports AI credits per million tokens (1 credit = $0.01), + // while pi-ai model costs are dollar amounts per million tokens. + return value !== undefined && Number.isFinite(value) && value >= 0 ? value / 100 : 0; +} + +function billingCost(tier: CopilotBillingTier | undefined): ModelCost { + return { + input: billingPrice(tier?.input_price), + output: billingPrice(tier?.output_price), + // Copilot calls cache-read pricing `cache_price` in the live catalog. + // Accept the more explicit name too for compatibility with variants of + // the endpoint response. + cacheRead: billingPrice(tier?.cache_read_price ?? tier?.cache_price), + cacheWrite: billingPrice(tier?.cache_write_price), + }; +} + +function hasBillingPrices(tier: CopilotBillingTier | undefined): boolean { + return [ + tier?.input_price, + tier?.output_price, + tier?.cache_read_price, + tier?.cache_price, + tier?.cache_write_price, + ].some((value) => value !== undefined && Number.isFinite(value) && value >= 0); +} + +function contextSuffix(tokens: number): string { + // Copilot's UI and docs describe a roughly-million-token tier as "1M" + // even when the capability limit is slightly above one million (for + // example 1.05M). Preserve smaller limits such as 922K precisely. + if (tokens >= 1_000_000 && tokens < 1_100_000) return "1m"; + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; + return `${Math.round(tokens)}`; +} + +function contextLabel(tokens: number): string { + return contextSuffix(tokens).toUpperCase(); +} + +function contextAliasTokens(id: string): number | undefined { + const match = id.match(CONTEXT_ALIAS_PATTERN); + if (!match) return undefined; + const value = Number(match[1]); + if (!Number.isFinite(value)) return undefined; + return value * (match[2].toLowerCase() === "m" ? 1_000_000 : 1_000); +} + +function compareProviderModels(left: Model, right: Model): number { + const leftBase = toCopilotWireModelId(left.id); + const rightBase = toCopilotWireModelId(right.id); + const baseOrder = leftBase.localeCompare(rightBase); + if (baseOrder !== 0) return baseOrder; + + const leftContext = contextAliasTokens(left.id); + const rightContext = contextAliasTokens(right.id); + // Keep the canonical model before its context aliases. + if (leftContext === undefined) return rightContext === undefined ? 0 : -1; + if (rightContext === undefined) return 1; + return leftContext - rightContext || left.id.localeCompare(right.id); +} + export function toProviderModels(models: CopilotModel[], baseUrl: string): Model[] { const seen = new Set(); const output: Model[] = []; @@ -107,8 +210,37 @@ export function toProviderModels(models: CopilotModel[], baseUrl: string): Model }); const vision = model.capabilities?.supports?.vision === true; const limits = model.capabilities?.limits ?? {}; + const fullContextWindow = + limits.max_context_window_tokens ?? limits.max_prompt_tokens ?? 128000; + const defaultTier = model.billing?.token_prices?.default; + const longContextTier = model.billing?.token_prices?.long_context; + const defaultCost = billingCost(defaultTier); + + // A long-context request is billed using a different rate set once its + // total input usage exceeds the default context threshold. Pi calculates + // the matching tier from the model cost metadata, so retain both rates on + // the canonical model and on the long-context alias. + const defaultWindow = positiveNumber( + defaultTier?.context_max ?? defaultTier?.max_prompt_tokens, + ); + let modelCost: ModelCost = defaultCost; + if ( + defaultWindow !== undefined && + fullContextWindow > defaultWindow && + hasBillingPrices(longContextTier) + ) { + modelCost = { + ...defaultCost, + tiers: [ + { + inputTokensAbove: defaultWindow, + ...billingCost(longContextTier), + }, + ], + }; + } - output.push({ + const providerModel: Model = { id: model.id, name: model.name ?? model.id, api, @@ -118,12 +250,40 @@ export function toProviderModels(models: CopilotModel[], baseUrl: string): Model thinkingLevelMap, compat, input: vision ? ["text", "image"] : ["text"], - contextWindow: - limits.max_context_window_tokens ?? limits.max_prompt_tokens ?? 128000, + contextWindow: fullContextWindow, maxTokens: limits.max_output_tokens ?? 16384, headers: { ...COPILOT_HEADERS }, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }); + cost: modelCost, + }; + output.push(providerModel); + + // Copilot's billing metadata is the source of truth for whether a model + // has multiple context/pricing tiers. Publish one Pi alias per tier while + // keeping the actual request model ID canonical. + const tierWindows: Array<[string, number]> = []; + if ( + defaultWindow !== undefined && + longContextTier !== undefined && + fullContextWindow > defaultWindow + ) { + tierWindows.push([contextSuffix(defaultWindow), defaultWindow]); + tierWindows.push([contextSuffix(fullContextWindow), fullContextWindow]); + } + + for (const [suffix, contextWindow] of tierWindows) { + const id = `${model.id}@${suffix}`; + if (seen.has(id)) continue; + seen.add(id); + output.push({ + ...providerModel, + id, + name: `${model.name ?? model.id} (${contextLabel(contextWindow)} context)`, + contextWindow, + // The smaller alias is bounded to the default tier. The larger + // alias retains the automatic long-context rate selection above. + cost: contextWindow === defaultWindow ? defaultCost : modelCost, + }); + } } - return output; + return output.sort(compareProviderModels); }