






























@@ -38,7 +38,7 @@ vi.mock("openclaw/plugin-sdk/state-paths", () => ({
3838}));
39394040import type { ProviderResolveDynamicModelContext } from "openclaw/plugin-sdk/core";
41-import { resolveCopilotForwardCompatModel } from "./models.js";
41+import { fetchCopilotModelCatalog, resolveCopilotForwardCompatModel } from "./models.js";
42424343afterAll(() => {
4444vi.doUnmock("@mariozechner/pi-ai/oauth");
@@ -375,3 +375,227 @@ describe("github-copilot token", () => {
375375expect(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+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。