From 75405f64d0a79fd332389ae46afd349ebdf60425 Mon Sep 17 00:00:00 2001 From: Eduardo Piva Date: Sat, 9 May 2026 00:01:45 +0000 Subject: [PATCH] github-copilot: live catalog discovery via /models + add gpt-5.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin's `catalog.run` hook already exchanged a GitHub OAuth token for a short-lived Copilot API token and resolved the per-account baseUrl, but it returned `models: []` and the bundled openclaw runtime relied entirely on the static manifest catalog. That meant: - Static `contextWindow` values were a conservative 128k for every model, far below reality (gpt-5.4/5.5 are 400k, claude-opus-4.6/4.7 internal variants are 1M, claude-sonnet-4 is 200k, etc.). - Newly published Copilot models (gpt-5.5, gpt-5.1*, gemini-3-pro-preview, the claude-opus-*-1m internal variants, etc.) didn't appear at all until the manifest was patched. - Per-account entitlement was invisible — every user saw the same hardcoded 22-model list regardless of plan. Wire it up: - Add `fetchCopilotModelCatalog` in `extensions/github-copilot/models.ts`. Calls `${baseUrl}/models` with the resolved Copilot API token and the same Editor-Version / Copilot-Integration-Id headers used elsewhere in the plugin. Maps each entry to a `ModelDefinitionConfig`: - `contextWindow` ← `capabilities.limits.max_context_window_tokens` - `maxTokens` ← `capabilities.limits.max_output_tokens` - `input` ← `["text", "image"]` if `supports.vision`, else `["text"]` - `reasoning` ← `Array.isArray(supports.reasoning_effort) && supports.reasoning_effort.length > 0` - `api` ← `anthropic-messages` for Anthropic vendor or claude* ids; otherwise `openai-responses` Filters out non-chat objects (embeddings) and internal routers (`accounts/...` ids). Dedupes by id. 10s default timeout. - Update the `catalog.run` hook in `extensions/github-copilot/index.ts` to call the new function after token-exchange and return the live results. On any HTTP/parse failure it falls back to `models: []`, which preserves the static manifest catalog as the visible fallback — no behavior regression for users with `discovery.enabled: false` or in offline scenarios. - Bump `modelCatalog.discovery."github-copilot"` from `"static"` to `"refreshable"` in `openclaw.plugin.json` so the catalog hook is actually invoked at runtime. Without this the discovery infrastructure treats the provider as static-only and never calls `catalog.run`. - Add `gpt-5.5` to the static manifest catalog and `DEFAULT_MODEL_IDS` with the correct values from the API (`contextWindow: 400000`, `maxTokens: 128000`, `reasoning: true`, multimodal). This means users on `discovery.enabled: false` still get gpt-5.5 visible without needing to override `models.providers.github-copilot.models` in their config. Tests added (5, all passing alongside the existing 24): - `fetchCopilotModelCatalog` maps a representative `/models` response (chat models incl. an internal 1M-context Anthropic variant, a router, an embedding) to the right `ModelDefinitionConfig` shape with real context windows. - baseUrl trailing slash is normalized. - Duplicate ids in the API response are deduped (first wins). - Non-2xx HTTP raises so the caller can fall back to the static catalog. - Empty token / baseUrl reject synchronously without calling fetch. Targeted run: `pnpm test extensions/github-copilot/models.test.ts` → 29/29 pass. `pnpm exec oxfmt --check extensions/github-copilot/` clean. `pnpm tsgo:core` clean. Real-world proof: Built locally and dropped the resulting tarball into a downstream container with `gh auth login --hostname github.com` (Copilot subscription on the linked account). Before this change, `openclaw models list --provider github-copilot` returned the 22-entry static catalog with every entry showing 128k context. After this change, the same command (with `--refresh`) returns 30 entries with API-accurate context windows including the new gpt-5.1 family, the claude-opus-*-1m variants, and the corrected `gemini-3*-preview` ids. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 + extensions/github-copilot/index.ts | 27 ++- extensions/github-copilot/models-defaults.ts | 1 + extensions/github-copilot/models.test.ts | 226 +++++++++++++++++- extensions/github-copilot/models.ts | 164 +++++++++++++ .../github-copilot/openclaw.plugin.json | 11 +- 6 files changed, 427 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46038c8e2c..214a8c2838b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Docs: https://docs.openclaw.ai ### Changes - CLI: make parser, startup, config, guardrail, channel, agent, task, session, and MCP failures explain what happened and point to the next recovery command. +- GitHub Copilot: implement live model catalog discovery from the Copilot API. The plugin already exchanged a GitHub OAuth token for a short-lived Copilot API token in its catalog hook, but returned `models: []` and relied entirely on the static manifest catalog. The hook now also calls `${baseUrl}/models` and projects each chat-capable entry into a `ModelDefinitionConfig` with the real `max_context_window_tokens` (e.g., 400k for the gpt-5 series, 1M for the internal claude-opus-\*-1m variants, 200k for the standard Claude/Gemini 3.1 entries) and `max_output_tokens`. Internal routers (`accounts/...`) and non-chat objects (embeddings) are filtered out. On any HTTP/parse failure the hook returns an empty array, preserving the static manifest catalog as the visible fallback. Users on `discovery.enabled: false` continue to see only the manifest catalog. The `modelCatalog.discovery` flag in `openclaw.plugin.json` is bumped from `"static"` to `"refreshable"` so the catalog hook is actually invoked at runtime. (`extensions/github-copilot/index.ts` + `extensions/github-copilot/models.ts` `fetchCopilotModelCatalog`). +- GitHub Copilot: add `gpt-5.5` to the static manifest catalog and `DEFAULT_MODEL_IDS` with the correct `contextWindow: 400000` / `maxTokens: 128000` from the Copilot `/models` API. Users with discovery enabled also pick this up automatically; the static entry is the fallback for `discovery.enabled: false` configs. - Active Memory: support concrete `plugins.entries.active-memory.config.toolsAllow` recall tool names for custom memory plugins while keeping the built-in memory-core default on `memory_search`/`memory_get` and preserving `memory_recall` automatically for `plugins.slots.memory: "memory-lancedb"`. - Telegram/Feishu: honor configured per-agent and global `reasoningDefault` values when deciding whether channel reasoning previews should stream or stay hidden, addressing the preview-default part of #73182. Thanks @anagnorisis2peripeteia. - Docker: run the runtime image under `tini` so long-lived containers reap orphaned child processes and forward signals correctly. (#77885) Thanks @VintageAyu. diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index dc3431a9ea0..76403ad30a1 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -18,7 +18,11 @@ import { import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import { resolveFirstGithubToken } from "./auth.js"; import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js"; -import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js"; +import { + PROVIDER_ID, + fetchCopilotModelCatalog, + resolveCopilotForwardCompatModel, +} from "./models.js"; import { buildGithubCopilotReplayPolicy } from "./replay-policy.js"; import { wrapCopilotProviderStream } from "./stream.js"; @@ -373,6 +377,7 @@ export default definePluginEntry({ return null; } let baseUrl = DEFAULT_COPILOT_API_BASE_URL; + let copilotApiToken: string | undefined; if (githubToken) { try { const token = await resolveCopilotApiToken({ @@ -380,14 +385,32 @@ export default definePluginEntry({ env: ctx.env, }); baseUrl = token.baseUrl; + copilotApiToken = token.token; } catch { baseUrl = DEFAULT_COPILOT_API_BASE_URL; } } + // Try to fetch the live model catalog from Copilot's /models + // endpoint so the runtime tracks per-account entitlements and + // accurate context windows (max_context_window_tokens) without + // manifest churn. On any failure we return an empty model list, + // which lets the static manifest catalog continue to be the + // visible fallback for users. + let discoveredModels: Awaited> = []; + if (copilotApiToken) { + try { + discoveredModels = await fetchCopilotModelCatalog({ + copilotApiToken, + baseUrl, + }); + } catch { + discoveredModels = []; + } + } return { provider: { baseUrl, - models: [], + models: discoveredModels, }, }; }, diff --git a/extensions/github-copilot/models-defaults.ts b/extensions/github-copilot/models-defaults.ts index 1b5f9ef6905..8d642729b60 100644 --- a/extensions/github-copilot/models-defaults.ts +++ b/extensions/github-copilot/models-defaults.ts @@ -26,6 +26,7 @@ const DEFAULT_MODEL_IDS = [ "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", + "gpt-5.5", "grok-code-fast-1", "raptor-mini", "goldeneye", diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 6872e578e16..19ec7f5439d 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -38,7 +38,7 @@ vi.mock("openclaw/plugin-sdk/state-paths", () => ({ })); import type { ProviderResolveDynamicModelContext } from "openclaw/plugin-sdk/core"; -import { resolveCopilotForwardCompatModel } from "./models.js"; +import { fetchCopilotModelCatalog, resolveCopilotForwardCompatModel } from "./models.js"; afterAll(() => { vi.doUnmock("@mariozechner/pi-ai/oauth"); @@ -375,3 +375,227 @@ describe("github-copilot token", () => { expect(jsonStoreMocks.saveJsonFile).toHaveBeenCalledTimes(1); }); }); + +describe("fetchCopilotModelCatalog", () => { + // Trimmed sample of the real Copilot /models response shape captured against + // api.githubcopilot.com against an Individual Copilot subscription. Includes + // a chat model, a router (must be filtered), an embedding (must be filtered), + // an internal 1M-context Claude variant (must be kept), and a vision-disabled + // codex model. + const sampleApiResponse = { + data: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + object: "model", + vendor: "OpenAI", + capabilities: { + type: "chat", + family: "gpt-5.5", + limits: { + max_context_window_tokens: 400000, + max_output_tokens: 128000, + max_prompt_tokens: 272000, + }, + supports: { + vision: true, + tool_calls: true, + streaming: true, + structured_outputs: true, + reasoning_effort: ["low", "medium", "high"], + }, + }, + }, + { + id: "gpt-5.3-codex", + name: "GPT-5.3-Codex", + object: "model", + vendor: "OpenAI", + capabilities: { + type: "chat", + family: "gpt-5.3-codex", + limits: { + max_context_window_tokens: 400000, + max_output_tokens: 128000, + }, + supports: { + vision: false, + tool_calls: true, + reasoning_effort: ["low", "medium", "high"], + }, + }, + }, + { + id: "claude-opus-4.7-1m-internal", + name: "Claude Opus 4.7 (1M context)(Internal only)", + object: "model", + vendor: "Anthropic", + capabilities: { + type: "chat", + limits: { + max_context_window_tokens: 1000000, + max_output_tokens: 64000, + }, + supports: { vision: true, tool_calls: true }, + }, + }, + { + // Internal router — must be filtered out (id starts with "accounts/"). + id: "accounts/msft/routers/abc123", + name: "Search Agent A", + object: "model", + capabilities: { + type: "chat", + limits: { max_context_window_tokens: 256000, max_output_tokens: 1024 }, + }, + }, + { + // Embedding — must be filtered out by capabilities.type !== "chat". + id: "text-embedding-3-small", + name: "Embedding V3 small", + object: "model", + capabilities: { type: "embedding" }, + }, + ], + }; + + it("maps Copilot /models entries to ModelDefinitionConfig with real context windows", async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => sampleApiResponse, + }); + + const out = await fetchCopilotModelCatalog({ + copilotApiToken: "tid=test", + baseUrl: "https://api.githubcopilot.com", + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [calledUrl, calledInit] = fetchImpl.mock.calls[0]; + expect(calledUrl).toBe("https://api.githubcopilot.com/models"); + expect((calledInit as RequestInit).method).toBe("GET"); + expect(((calledInit as RequestInit).headers as Record).Authorization).toBe( + "Bearer tid=test", + ); + + expect(out.map((m) => m.id)).toEqual([ + "gpt-5.5", + "gpt-5.3-codex", + "claude-opus-4.7-1m-internal", + ]); + + const gpt55 = out.find((m) => m.id === "gpt-5.5"); + expect(gpt55).toMatchObject({ + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-responses", + reasoning: true, + input: ["text", "image"], + contextWindow: 400000, + maxTokens: 128000, + }); + + const codex = out.find((m) => m.id === "gpt-5.3-codex"); + expect(codex?.input).toEqual(["text"]); + expect(codex?.reasoning).toBe(true); + expect(codex?.contextWindow).toBe(400000); + + const opus1m = out.find((m) => m.id === "claude-opus-4.7-1m-internal"); + expect(opus1m?.api).toBe("anthropic-messages"); + expect(opus1m?.contextWindow).toBe(1_000_000); + }); + + it("strips trailing slash from baseUrl when building the /models URL", async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: [] }), + }); + + await fetchCopilotModelCatalog({ + copilotApiToken: "tid=test", + baseUrl: "https://api.githubcopilot.com/", + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(fetchImpl.mock.calls[0][0]).toBe("https://api.githubcopilot.com/models"); + }); + + it("dedupes by id when API returns duplicates", async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + object: "model", + capabilities: { + type: "chat", + limits: { max_context_window_tokens: 400000, max_output_tokens: 128000 }, + }, + }, + { + id: "gpt-5.5", + name: "GPT-5.5 (dup)", + object: "model", + capabilities: { + type: "chat", + limits: { max_context_window_tokens: 100000, max_output_tokens: 1000 }, + }, + }, + ], + }), + }); + + const out = await fetchCopilotModelCatalog({ + copilotApiToken: "tid=test", + baseUrl: "https://api.githubcopilot.com", + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(out).toHaveLength(1); + expect(out[0].name).toBe("GPT-5.5"); + }); + + it("throws on non-2xx HTTP responses so the caller can fall back to the static catalog", async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({}), + }); + + await expect( + fetchCopilotModelCatalog({ + copilotApiToken: "tid=bad", + baseUrl: "https://api.githubcopilot.com", + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toThrow(/HTTP 401/); + }); + + it("rejects empty token / baseUrl synchronously before fetching", async () => { + const fetchImpl = vi.fn(); + + await expect( + fetchCopilotModelCatalog({ + copilotApiToken: "", + baseUrl: "https://api.githubcopilot.com", + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toThrow(/copilotApiToken required/); + + await expect( + fetchCopilotModelCatalog({ + copilotApiToken: "tid=test", + baseUrl: "", + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toThrow(/baseUrl required/); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/github-copilot/models.ts b/extensions/github-copilot/models.ts index 5864aa44347..b2294447e2e 100644 --- a/extensions/github-copilot/models.ts +++ b/extensions/github-copilot/models.ts @@ -2,6 +2,7 @@ import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; +import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; @@ -81,3 +82,166 @@ export function resolveCopilotForwardCompatModel( maxTokens: DEFAULT_MAX_TOKENS, } as ProviderRuntimeModel); } + +// Subset of the Copilot /models response shape that we depend on. We only read +// fields we need; everything else is preserved as `unknown` so upstream changes +// don't break parsing. +type CopilotApiModelEntry = { + id?: string; + name?: string; + object?: string; + vendor?: string; + preview?: boolean; + model_picker_enabled?: boolean; + capabilities?: { + type?: string; + family?: string; + limits?: { + max_context_window_tokens?: number; + max_output_tokens?: number; + max_prompt_tokens?: number; + }; + supports?: { + vision?: boolean; + tool_calls?: boolean; + streaming?: boolean; + structured_outputs?: boolean; + reasoning_effort?: string[] | null; + }; + }; +}; + +const COPILOT_MODELS_LIST_DEFAULT_TIMEOUT_MS = 10_000; +const COPILOT_ROUTER_ID_PREFIX = "accounts/"; + +function resolveCopilotApiForVendor( + vendor: string | undefined, + modelId: string, +): "anthropic-messages" | "openai-responses" { + if (vendor && vendor.toLowerCase() === "anthropic") { + return "anthropic-messages"; + } + return resolveCopilotTransportApi(modelId); +} + +function mapCopilotApiModelToDefinition( + entry: CopilotApiModelEntry, +): ModelDefinitionConfig | undefined { + const id = entry.id?.trim(); + if (!id) { + return undefined; + } + // Skip non-chat objects (embeddings, routers, etc.) and internal router ids. + if (entry.object && entry.object !== "model") { + return undefined; + } + if (entry.capabilities?.type && entry.capabilities.type !== "chat") { + return undefined; + } + if (id.startsWith(COPILOT_ROUTER_ID_PREFIX)) { + return undefined; + } + + const limits = entry.capabilities?.limits; + const supports = entry.capabilities?.supports; + const reasoning = Array.isArray(supports?.reasoning_effort) + ? supports.reasoning_effort.length > 0 + : false; + const supportsVision = supports?.vision === true; + const input: ModelDefinitionConfig["input"] = supportsVision ? ["text", "image"] : ["text"]; + + const contextWindow = + typeof limits?.max_context_window_tokens === "number" && limits.max_context_window_tokens > 0 + ? limits.max_context_window_tokens + : DEFAULT_CONTEXT_WINDOW; + const maxTokens = + typeof limits?.max_output_tokens === "number" && limits.max_output_tokens > 0 + ? limits.max_output_tokens + : DEFAULT_MAX_TOKENS; + + const definition: ModelDefinitionConfig = { + id, + name: entry.name?.trim() || id, + api: resolveCopilotApiForVendor(entry.vendor, id), + reasoning, + input, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens, + }; + return definition; +} + +export type FetchCopilotModelCatalogParams = { + /** Short-lived Copilot API token (from `resolveCopilotApiToken`). */ + copilotApiToken: string; + /** Resolved baseUrl from the same token-exchange response. */ + baseUrl: string; + /** Optional fetch override for testing. */ + fetchImpl?: typeof fetch; + /** Optional AbortSignal; defaults to a 10s timeout. */ + signal?: AbortSignal; +}; + +/** + * Fetch the live Copilot model catalog from `${baseUrl}/models` and project it + * into `ModelDefinitionConfig[]`. Used by the plugin's discovery hook so the + * runtime catalog tracks per-account entitlements + accurate context windows + * without manifest churn. + * + * Filters out non-chat objects (embeddings, routers) and internal router ids. + * On any HTTP/parse failure the caller should fall back to the static manifest + * catalog; this function throws so the caller decides the recovery shape. + */ +export async function fetchCopilotModelCatalog( + params: FetchCopilotModelCatalogParams, +): Promise { + const fetchImpl = params.fetchImpl ?? fetch; + const trimmedBase = params.baseUrl.replace(/\/+$/, ""); + if (!trimmedBase) { + throw new Error("fetchCopilotModelCatalog: baseUrl required"); + } + if (!params.copilotApiToken.trim()) { + throw new Error("fetchCopilotModelCatalog: copilotApiToken required"); + } + const url = `${trimmedBase}/models`; + const controller = params.signal ? undefined : new AbortController(); + const timeoutId = controller + ? setTimeout(() => controller.abort(), COPILOT_MODELS_LIST_DEFAULT_TIMEOUT_MS) + : undefined; + try { + const res = await fetchImpl(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${params.copilotApiToken}`, + "Editor-Version": "vscode/1.96.2", + "Copilot-Integration-Id": "vscode-chat", + }, + signal: params.signal ?? controller?.signal, + }); + if (!res.ok) { + throw new Error(`Copilot /models fetch failed: HTTP ${res.status}`); + } + const json = (await res.json()) as { data?: CopilotApiModelEntry[] }; + const data = Array.isArray(json?.data) ? json.data : []; + const seen = new Set(); + const out: ModelDefinitionConfig[] = []; + for (const entry of data) { + const def = mapCopilotApiModelToDefinition(entry); + if (!def) { + continue; + } + if (seen.has(def.id)) { + continue; + } + seen.add(def.id); + out.push(def); + } + return out; + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } +} diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index 6b836c3552a..eddf819f509 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -164,6 +164,15 @@ "maxTokens": 8192, "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }, + { + "id": "gpt-5.5", + "name": "GPT-5.5", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 400000, + "maxTokens": 128000, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } + }, { "id": "gpt-5.4-mini", "name": "GPT-5.4 mini", @@ -210,7 +219,7 @@ } }, "discovery": { - "github-copilot": "static" + "github-copilot": "refreshable" } }, "contracts": {