diff --git a/packages/ai/README.md b/packages/ai/README.md index 7af343eab..73f831905 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -891,7 +891,7 @@ console.log(model.output); // ['image'] or ['image', 'text'] ## Classification -Classifier models consume structured JSON state, and optionally images, and answer one or more typed questions. They do not use chat or image-generation APIs. TypeSafe's Jev model, Cloudflare's Clef models, and OpenAI's GPT-6 Luna are available from these built-in providers: +Classifier models consume structured JSON state, and optionally images, and answer one or more typed questions. They do not use chat or image-generation APIs. TypeSafe's Jev model, Cloudflare's Clef models, OpenAI's GPT-6 Luna, and Microsoft-Decision-1 are available from these built-in providers: | Provider | Model IDs | Auth | | --- | --- | --- | @@ -901,6 +901,7 @@ Classifier models consume structured JSON state, and optionally images, and answ | `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` | | `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` | | `openai` | `gpt-6-luna` | `OPENAI_API_KEY` | +| `github-copilot` | `microsoft-decision-1` | `COPILOT_GITHUB_TOKEN` or GitHub Copilot OAuth | ```typescript import { builtinModels } from '@earendil-works/pi-ai/providers/all'; diff --git a/packages/ai/catalog/providers/github-copilot.ts b/packages/ai/catalog/providers/github-copilot.ts index 2bea3586b..b91a03213 100644 --- a/packages/ai/catalog/providers/github-copilot.ts +++ b/packages/ai/catalog/providers/github-copilot.ts @@ -1,4 +1,4 @@ -import type { Api, Model, ThinkingLevelMap } from "../../src/types.ts"; +import type { Api, ClassifierModel, Model, ThinkingLevelMap } from "../../src/types.ts"; import { addMissing, type ChatModel, defineProvider, mergeCompat, mergeThinking, modelsDevModels } from "../define.ts"; import { claude55ThinkingLevels, supportsClaudeMidConvoSystemMessages } from "../families/anthropic.ts"; import { @@ -67,6 +67,22 @@ const GPT_6_LUNA: Model<"openai-responses"> = { headers: { ...STATIC_HEADERS }, }; +// The authenticated Copilot catalog lists this decision model on /v1/systemone (checked 2026-11-02) without +// a context window or price, and GitHub's Copilot pricing page does not list it. These are Azure's values, +// which OpenRouter and Vercel AI Gateway also list. +const MICROSOFT_DECISION_1: ClassifierModel<"typesafe-system-one"> = { + type: "classifier", + id: "microsoft-decision-1", + name: "Microsoft-Decision-1", + api: "typesafe-system-one", + provider: "github-copilot", + baseUrl: BASE_URL, + input: ["text"], + cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32768, + headers: { ...STATIC_HEADERS }, +}; + export default defineProvider({ id: "github-copilot", models: (ctx) => @@ -94,6 +110,7 @@ export default defineProvider({ }), [GPT_6_LUNA], ).map((model) => (EXTENDED_CONTEXT_MODEL_IDS.has(model.id) ? { ...model, contextWindow: 1000000 } : model)), + classifiers: () => [MICROSOFT_DECISION_1], compat: { "openai-completions": { supportsStrictMode: true } }, finalize: (model) => { if (model.id.startsWith("gpt-5")) mergeThinking(model, { minimal: "low" }); diff --git a/packages/ai/src/providers/github-copilot.ts b/packages/ai/src/providers/github-copilot.ts index eab784d88..282bc1da1 100644 --- a/packages/ai/src/providers/github-copilot.ts +++ b/packages/ai/src/providers/github-copilot.ts @@ -1,13 +1,28 @@ import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; +import { typesafeSystemOneApi } from "../api/typesafe-system-one.lazy.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { loadGitHubCopilotOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; -import { GITHUB_COPILOT_MODELS } from "./github-copilot.models.ts"; +import type { ProviderClassifier } from "../types.ts"; +import { GITHUB_COPILOT_CLASSIFIER_MODELS, GITHUB_COPILOT_MODELS } from "./github-copilot.models.ts"; -export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai-completions" | "openai-responses"> { - return createProvider({ +type GitHubCopilotApi = "anthropic-messages" | "openai-completions" | "openai-responses"; + +/** + * Copilot serves System One at `/v1/systemone`, while its chat APIs have no version prefix. + * The login replaces the catalog base URL with the account's endpoint, so the prefix is added per request. + */ +function copilotSystemOne(classifier: ProviderClassifier): ProviderClassifier { + return { + classify: (model, context, options) => + classifier.classify({ ...model, baseUrl: `${model.baseUrl.replace(/\/+$/u, "")}/v1` }, context, options), + }; +} + +export function githubCopilotProvider(): Provider { + return createProvider({ id: "github-copilot", name: "GitHub Copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -15,7 +30,7 @@ export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai apiKey: envApiKeyAuth("GitHub Copilot token", ["COPILOT_GITHUB_TOKEN"]), oauth: lazyOAuth({ name: "GitHub Copilot", isSubscription: true, load: loadGitHubCopilotOAuth }), }, - models: Object.values(GITHUB_COPILOT_MODELS), + models: [...Object.values(GITHUB_COPILOT_MODELS), ...Object.values(GITHUB_COPILOT_CLASSIFIER_MODELS)], filterModels: (models, credential) => { if (credential?.type !== "oauth") return models; const availableModelIds = credential.availableModelIds; @@ -30,5 +45,6 @@ export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai "openai-completions": openAICompletionsApi(), "openai-responses": openAIResponsesApi(), }, + classifiers: { "typesafe-system-one": copilotSystemOne(typesafeSystemOneApi()) }, }); } diff --git a/packages/ai/test/classifier-models.test.ts b/packages/ai/test/classifier-models.test.ts index cae1df4a5..9f26b199c 100644 --- a/packages/ai/test/classifier-models.test.ts +++ b/packages/ai/test/classifier-models.test.ts @@ -245,6 +245,62 @@ describe("Models with classifier models", () => { expect((await withOAuth.getAvailable("openai")).some((model) => model.id === "gpt-6-luna")).toBe(true); }); + it("routes GitHub Copilot Microsoft-Decision-1 to the account's /v1/systemone endpoint", async () => { + const credentials = new InMemoryCredentialStore(); + await credentials.modify("github-copilot", async () => ({ + type: "oauth", + access: "tid=test;exp=9999999999;proxy-ep=proxy.enterprise.githubcopilot.com;", + refresh: "refresh", + expires: Date.now() + 3_600_000, + // The login records only picker models; classifiers stay listed. + availableModelIds: ["gpt-6-luna"], + })); + const models = builtinModels({ credentials }); + const decision = models.getModelOfType("classifier", "github-copilot", "microsoft-decision-1"); + if (!decision) throw new Error("missing GitHub Copilot decision model"); + expect(decision).toMatchObject({ + api: "typesafe-system-one", + baseUrl: "https://api.individual.githubcopilot.com", + input: ["text"], + contextWindow: 32768, + }); + expect(models.getModel("github-copilot", "microsoft-decision-1")).toBeUndefined(); + expect(await models.getAvailableOfType("classifier", "github-copilot")).toEqual([decision]); + + const requests: Array<{ url: string; headers: Headers }> = []; + const result = await models.classify(decision, context, { + fetch: async (input, init) => { + requests.push({ url: String(input), headers: new Headers(init?.headers) }); + return Response.json({ answers: { approved: { type: "noul", noul: 0.8 } } }); + }, + }); + + expect(requests.map((request) => request.url)).toEqual(["https://api.enterprise.githubcopilot.com/v1/systemone"]); + expect(requests[0].headers.get("authorization")).toBe( + "Bearer tid=test;exp=9999999999;proxy-ep=proxy.enterprise.githubcopilot.com;", + ); + // Copilot rejects requests without an editor version. + expect(requests[0].headers.get("editor-version")).toBeTruthy(); + expect(requests[0].headers.get("copilot-integration-id")).toBe("vscode-chat"); + expect(result.stopReason).toBe("stop"); + expect(result.answers.approved).toEqual({ type: "bool", probability: 0.8 }); + }); + + it("adds the System One version prefix to the GitHub Copilot catalog base URL", async () => { + const models = builtinModels(); + const decision = models.getModelOfType("classifier", "github-copilot", "microsoft-decision-1"); + if (!decision) throw new Error("missing GitHub Copilot decision model"); + const urls: string[] = []; + await models.classify(decision, context, { + apiKey: "secret", + fetch: async (input) => { + urls.push(String(input)); + return Response.json({ answers: { approved: { type: "noul", noul: 0.8 } } }); + }, + }); + expect(urls).toEqual(["https://api.individual.githubcopilot.com/v1/systemone"]); + }); + it("routes OpenRouter classifier models through the System One API", () => { const models = builtinModels(); for (const model of getBuiltinClassifierModels("openrouter")) { diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index e076ac139..803458a8b 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -141,7 +141,7 @@ Compatibility settings should describe verified differences in the endpoint's re ## Use classifier models -Classifier models do not chat. They answer typed questions about JSON state: pick one of several choices, answer yes or no, or give a score, each with probabilities. Pi includes TypeSafe's Jev model from these providers, Cloudflare's Clef, Clef Flash, and Clef Omni models from Workers AI and OpenRouter, and OpenAI's GPT-6 Luna through the [Decisions API](https://developers.openai.com/api/docs/guides/decisions): +Classifier models do not chat. They answer typed questions about JSON state: pick one of several choices, answer yes or no, or give a score, each with probabilities. Pi includes TypeSafe's Jev model from these providers, Cloudflare's Clef, Clef Flash, and Clef Omni models from Workers AI and OpenRouter, OpenAI's GPT-6 Luna through the [Decisions API](https://developers.openai.com/api/docs/guides/decisions), and Microsoft-Decision-1 through GitHub Copilot: | Provider | Model IDs | Authentication | |---|---|---| @@ -151,6 +151,7 @@ Classifier models do not chat. They answer typed questions about JSON state: pic | `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` | | `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` | | `openai` | `gpt-6-luna` | `OPENAI_API_KEY` | +| `github-copilot` | `microsoft-decision-1` | `COPILOT_GITHUB_TOKEN` or `/login` | Chat models on a [llama.cpp router](llama-cpp.md#classification) are also listed as classifier models.