github-copilot: live catalog discovery via /models + add gpt-5.5

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) <noreply@anthropic.com>
This commit is contained in:
Eduardo Piva
2026-05-09 00:01:45 +00:00
committed by Peter Steinberger
parent 8111ae40c0
commit 75405f64d0
6 changed files with 427 additions and 4 deletions

View File

@@ -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.

View File

@@ -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<ReturnType<typeof fetchCopilotModelCatalog>> = [];
if (copilotApiToken) {
try {
discoveredModels = await fetchCopilotModelCatalog({
copilotApiToken,
baseUrl,
});
} catch {
discoveredModels = [];
}
}
return {
provider: {
baseUrl,
models: [],
models: discoveredModels,
},
};
},

View File

@@ -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",

View File

@@ -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<string, string>).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();
});
});

View File

@@ -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<ModelDefinitionConfig[]> {
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<string>();
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);
}
}
}

View File

@@ -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": {