Skip to content

Commit 75405f6

Browse files
efpivaclaude
authored andcommitted
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>
1 parent 8111ae4 commit 75405f6

6 files changed

Lines changed: 427 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Docs: https://docs.openclaw.ai
77
### Changes
88

99
- CLI: make parser, startup, config, guardrail, channel, agent, task, session, and MCP failures explain what happened and point to the next recovery command.
10+
- 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`).
11+
- 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.
1012
- 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"`.
1113
- 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.
1214
- Docker: run the runtime image under `tini` so long-lived containers reap orphaned child processes and forward signals correctly. (#77885) Thanks @VintageAyu.

extensions/github-copilot/index.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import {
1818
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
1919
import { resolveFirstGithubToken } from "./auth.js";
2020
import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js";
21-
import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js";
21+
import {
22+
PROVIDER_ID,
23+
fetchCopilotModelCatalog,
24+
resolveCopilotForwardCompatModel,
25+
} from "./models.js";
2226
import { buildGithubCopilotReplayPolicy } from "./replay-policy.js";
2327
import { wrapCopilotProviderStream } from "./stream.js";
2428

@@ -373,21 +377,40 @@ export default definePluginEntry({
373377
return null;
374378
}
375379
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
380+
let copilotApiToken: string | undefined;
376381
if (githubToken) {
377382
try {
378383
const token = await resolveCopilotApiToken({
379384
githubToken,
380385
env: ctx.env,
381386
});
382387
baseUrl = token.baseUrl;
388+
copilotApiToken = token.token;
383389
} catch {
384390
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
385391
}
386392
}
393+
// Try to fetch the live model catalog from Copilot's /models
394+
// endpoint so the runtime tracks per-account entitlements and
395+
// accurate context windows (max_context_window_tokens) without
396+
// manifest churn. On any failure we return an empty model list,
397+
// which lets the static manifest catalog continue to be the
398+
// visible fallback for users.
399+
let discoveredModels: Awaited<ReturnType<typeof fetchCopilotModelCatalog>> = [];
400+
if (copilotApiToken) {
401+
try {
402+
discoveredModels = await fetchCopilotModelCatalog({
403+
copilotApiToken,
404+
baseUrl,
405+
});
406+
} catch {
407+
discoveredModels = [];
408+
}
409+
}
387410
return {
388411
provider: {
389412
baseUrl,
390-
models: [],
413+
models: discoveredModels,
391414
},
392415
};
393416
},

extensions/github-copilot/models-defaults.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const DEFAULT_MODEL_IDS = [
2626
"gpt-5.4",
2727
"gpt-5.4-mini",
2828
"gpt-5.4-nano",
29+
"gpt-5.5",
2930
"grok-code-fast-1",
3031
"raptor-mini",
3132
"goldeneye",

extensions/github-copilot/models.test.ts

Lines changed: 225 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ vi.mock("openclaw/plugin-sdk/state-paths", () => ({
3838
}));
3939

4040
import type { ProviderResolveDynamicModelContext } from "openclaw/plugin-sdk/core";
41-
import { resolveCopilotForwardCompatModel } from "./models.js";
41+
import { fetchCopilotModelCatalog, resolveCopilotForwardCompatModel } from "./models.js";
4242

4343
afterAll(() => {
4444
vi.doUnmock("@mariozechner/pi-ai/oauth");
@@ -375,3 +375,227 @@ describe("github-copilot token", () => {
375375
expect(jsonStoreMocks.saveJsonFile).toHaveBeenCalledTimes(1);
376376
});
377377
});
378+
379+
describe("fetchCopilotModelCatalog", () => {
380+
// Trimmed sample of the real Copilot /models response shape captured against
381+
// api.githubcopilot.com against an Individual Copilot subscription. Includes
382+
// a chat model, a router (must be filtered), an embedding (must be filtered),
383+
// an internal 1M-context Claude variant (must be kept), and a vision-disabled
384+
// codex model.
385+
const sampleApiResponse = {
386+
data: [
387+
{
388+
id: "gpt-5.5",
389+
name: "GPT-5.5",
390+
object: "model",
391+
vendor: "OpenAI",
392+
capabilities: {
393+
type: "chat",
394+
family: "gpt-5.5",
395+
limits: {
396+
max_context_window_tokens: 400000,
397+
max_output_tokens: 128000,
398+
max_prompt_tokens: 272000,
399+
},
400+
supports: {
401+
vision: true,
402+
tool_calls: true,
403+
streaming: true,
404+
structured_outputs: true,
405+
reasoning_effort: ["low", "medium", "high"],
406+
},
407+
},
408+
},
409+
{
410+
id: "gpt-5.3-codex",
411+
name: "GPT-5.3-Codex",
412+
object: "model",
413+
vendor: "OpenAI",
414+
capabilities: {
415+
type: "chat",
416+
family: "gpt-5.3-codex",
417+
limits: {
418+
max_context_window_tokens: 400000,
419+
max_output_tokens: 128000,
420+
},
421+
supports: {
422+
vision: false,
423+
tool_calls: true,
424+
reasoning_effort: ["low", "medium", "high"],
425+
},
426+
},
427+
},
428+
{
429+
id: "claude-opus-4.7-1m-internal",
430+
name: "Claude Opus 4.7 (1M context)(Internal only)",
431+
object: "model",
432+
vendor: "Anthropic",
433+
capabilities: {
434+
type: "chat",
435+
limits: {
436+
max_context_window_tokens: 1000000,
437+
max_output_tokens: 64000,
438+
},
439+
supports: { vision: true, tool_calls: true },
440+
},
441+
},
442+
{
443+
// Internal router — must be filtered out (id starts with "accounts/").
444+
id: "accounts/msft/routers/abc123",
445+
name: "Search Agent A",
446+
object: "model",
447+
capabilities: {
448+
type: "chat",
449+
limits: { max_context_window_tokens: 256000, max_output_tokens: 1024 },
450+
},
451+
},
452+
{
453+
// Embedding — must be filtered out by capabilities.type !== "chat".
454+
id: "text-embedding-3-small",
455+
name: "Embedding V3 small",
456+
object: "model",
457+
capabilities: { type: "embedding" },
458+
},
459+
],
460+
};
461+
462+
it("maps Copilot /models entries to ModelDefinitionConfig with real context windows", async () => {
463+
const fetchImpl = vi.fn().mockResolvedValue({
464+
ok: true,
465+
status: 200,
466+
json: async () => sampleApiResponse,
467+
});
468+
469+
const out = await fetchCopilotModelCatalog({
470+
copilotApiToken: "tid=test",
471+
baseUrl: "https://api.githubcopilot.com",
472+
fetchImpl: fetchImpl as unknown as typeof fetch,
473+
});
474+
475+
expect(fetchImpl).toHaveBeenCalledTimes(1);
476+
const [calledUrl, calledInit] = fetchImpl.mock.calls[0];
477+
expect(calledUrl).toBe("https://api.githubcopilot.com/models");
478+
expect((calledInit as RequestInit).method).toBe("GET");
479+
expect(((calledInit as RequestInit).headers as Record<string, string>).Authorization).toBe(
480+
"Bearer tid=test",
481+
);
482+
483+
expect(out.map((m) => m.id)).toEqual([
484+
"gpt-5.5",
485+
"gpt-5.3-codex",
486+
"claude-opus-4.7-1m-internal",
487+
]);
488+
489+
const gpt55 = out.find((m) => m.id === "gpt-5.5");
490+
expect(gpt55).toMatchObject({
491+
id: "gpt-5.5",
492+
name: "GPT-5.5",
493+
api: "openai-responses",
494+
reasoning: true,
495+
input: ["text", "image"],
496+
contextWindow: 400000,
497+
maxTokens: 128000,
498+
});
499+
500+
const codex = out.find((m) => m.id === "gpt-5.3-codex");
501+
expect(codex?.input).toEqual(["text"]);
502+
expect(codex?.reasoning).toBe(true);
503+
expect(codex?.contextWindow).toBe(400000);
504+
505+
const opus1m = out.find((m) => m.id === "claude-opus-4.7-1m-internal");
506+
expect(opus1m?.api).toBe("anthropic-messages");
507+
expect(opus1m?.contextWindow).toBe(1_000_000);
508+
});
509+
510+
it("strips trailing slash from baseUrl when building the /models URL", async () => {
511+
const fetchImpl = vi.fn().mockResolvedValue({
512+
ok: true,
513+
status: 200,
514+
json: async () => ({ data: [] }),
515+
});
516+
517+
await fetchCopilotModelCatalog({
518+
copilotApiToken: "tid=test",
519+
baseUrl: "https://api.githubcopilot.com/",
520+
fetchImpl: fetchImpl as unknown as typeof fetch,
521+
});
522+
523+
expect(fetchImpl.mock.calls[0][0]).toBe("https://api.githubcopilot.com/models");
524+
});
525+
526+
it("dedupes by id when API returns duplicates", async () => {
527+
const fetchImpl = vi.fn().mockResolvedValue({
528+
ok: true,
529+
status: 200,
530+
json: async () => ({
531+
data: [
532+
{
533+
id: "gpt-5.5",
534+
name: "GPT-5.5",
535+
object: "model",
536+
capabilities: {
537+
type: "chat",
538+
limits: { max_context_window_tokens: 400000, max_output_tokens: 128000 },
539+
},
540+
},
541+
{
542+
id: "gpt-5.5",
543+
name: "GPT-5.5 (dup)",
544+
object: "model",
545+
capabilities: {
546+
type: "chat",
547+
limits: { max_context_window_tokens: 100000, max_output_tokens: 1000 },
548+
},
549+
},
550+
],
551+
}),
552+
});
553+
554+
const out = await fetchCopilotModelCatalog({
555+
copilotApiToken: "tid=test",
556+
baseUrl: "https://api.githubcopilot.com",
557+
fetchImpl: fetchImpl as unknown as typeof fetch,
558+
});
559+
560+
expect(out).toHaveLength(1);
561+
expect(out[0].name).toBe("GPT-5.5");
562+
});
563+
564+
it("throws on non-2xx HTTP responses so the caller can fall back to the static catalog", async () => {
565+
const fetchImpl = vi.fn().mockResolvedValue({
566+
ok: false,
567+
status: 401,
568+
json: async () => ({}),
569+
});
570+
571+
await expect(
572+
fetchCopilotModelCatalog({
573+
copilotApiToken: "tid=bad",
574+
baseUrl: "https://api.githubcopilot.com",
575+
fetchImpl: fetchImpl as unknown as typeof fetch,
576+
}),
577+
).rejects.toThrow(/HTTP 401/);
578+
});
579+
580+
it("rejects empty token / baseUrl synchronously before fetching", async () => {
581+
const fetchImpl = vi.fn();
582+
583+
await expect(
584+
fetchCopilotModelCatalog({
585+
copilotApiToken: "",
586+
baseUrl: "https://api.githubcopilot.com",
587+
fetchImpl: fetchImpl as unknown as typeof fetch,
588+
}),
589+
).rejects.toThrow(/copilotApiToken required/);
590+
591+
await expect(
592+
fetchCopilotModelCatalog({
593+
copilotApiToken: "tid=test",
594+
baseUrl: "",
595+
fetchImpl: fetchImpl as unknown as typeof fetch,
596+
}),
597+
).rejects.toThrow(/baseUrl required/);
598+
599+
expect(fetchImpl).not.toHaveBeenCalled();
600+
});
601+
});

0 commit comments

Comments
 (0)