mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
d62f0b8993
* fix: release stalled captured provider responses promptly * fix: preserve caller cancellation through guarded request timeouts * test: bound guarded response regression reads * test: retain owned sockets in portal collision fixtures
1086 lines
36 KiB
TypeScript
1086 lines
36 KiB
TypeScript
import { streamSimpleOpenAIResponses } from "@openclaw/ai/internal/openai";
|
|
// Github Copilot tests cover models plugin behavior.
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { createProviderUsageFetch, makeResponse } from "openclaw/plugin-sdk/test-env";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { resolveThinkingProfile } from "./provider-policy-api.js";
|
|
import { CopilotRuntimeAuthError } from "./runtime-auth-error.js";
|
|
import { resolveCopilotRuntimeAuth } from "./runtime-auth.js";
|
|
import { resolveCopilotStarterModel } from "./starter-model.js";
|
|
import { fetchCopilotUsage } from "./usage.js";
|
|
|
|
vi.mock("openclaw/plugin-sdk/provider-model-shared", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("openclaw/plugin-sdk/provider-model-shared")>()),
|
|
normalizeModelCompat: (model: Record<string, unknown>) => model,
|
|
resolveProviderEndpoint: (baseUrl: string) => ({
|
|
baseUrl,
|
|
endpointClass: "custom",
|
|
warnings: [],
|
|
}),
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/state-paths", () => ({
|
|
resolveStateDir: () => "/tmp/openclaw-state",
|
|
}));
|
|
|
|
import type { ProviderResolveDynamicModelContext } from "openclaw/plugin-sdk/core";
|
|
import {
|
|
fetchCopilotModelCatalog,
|
|
resolveCopilotForwardCompatModel,
|
|
selectCopilotStarterModel,
|
|
} from "./models.js";
|
|
|
|
function createMockCtx(
|
|
modelId: string,
|
|
registryModels: Record<string, Record<string, unknown>> = {},
|
|
): ProviderResolveDynamicModelContext {
|
|
return {
|
|
modelId,
|
|
provider: "github-copilot",
|
|
config: {},
|
|
modelRegistry: {
|
|
find: (provider: string, id: string) => registryModels[`${provider}/${id}`] ?? null,
|
|
},
|
|
} as unknown as ProviderResolveDynamicModelContext;
|
|
}
|
|
|
|
function requireResolvedModel(ctx: ProviderResolveDynamicModelContext) {
|
|
const result = resolveCopilotForwardCompatModel(ctx);
|
|
if (!result) {
|
|
throw new Error(`expected model ${ctx.modelId} to resolve`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
describe("resolveCopilotForwardCompatModel", () => {
|
|
it("returns undefined for empty modelId", () => {
|
|
expect(resolveCopilotForwardCompatModel(createMockCtx(""))).toBeUndefined();
|
|
expect(resolveCopilotForwardCompatModel(createMockCtx(" "))).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined when model is already in registry", () => {
|
|
const ctx = createMockCtx("gpt-4o", {
|
|
"github-copilot/gpt-4o": { id: "gpt-4o", name: "gpt-4o" },
|
|
});
|
|
expect(resolveCopilotForwardCompatModel(ctx)).toBeUndefined();
|
|
});
|
|
|
|
it("uses static metadata for gpt-5.3-codex when not in registry", () => {
|
|
const result = requireResolvedModel(createMockCtx("gpt-5.3-codex"));
|
|
expect(result).toEqual({
|
|
id: "gpt-5.3-codex",
|
|
name: "GPT-5.3-Codex",
|
|
provider: "github-copilot",
|
|
api: "openai-responses",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
|
|
contextWindow: 400_000,
|
|
contextTokens: 272_000,
|
|
maxTokens: 128_000,
|
|
thinkingLevelMap: { minimal: "low", xhigh: "xhigh", max: null },
|
|
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
|
|
});
|
|
});
|
|
|
|
it("uses curated static metadata for gpt-5.4 when not in registry", () => {
|
|
const result = requireResolvedModel(createMockCtx("gpt-5.4"));
|
|
expect(result).toEqual({
|
|
id: "gpt-5.4",
|
|
name: "GPT-5.4",
|
|
provider: "github-copilot",
|
|
api: "openai-responses",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
|
contextWindow: 1_050_000,
|
|
maxTokens: 128_000,
|
|
thinkingLevelMap: { minimal: "low", xhigh: "xhigh", max: null },
|
|
compat: { supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh"] },
|
|
});
|
|
});
|
|
|
|
it("uses static metadata for gpt-5.5 when live discovery rows are unavailable", () => {
|
|
const result = requireResolvedModel(createMockCtx("gpt-5.5"));
|
|
expect(result).toEqual({
|
|
id: "gpt-5.5",
|
|
name: "GPT-5.5",
|
|
provider: "github-copilot",
|
|
api: "openai-responses",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
|
|
contextWindow: 1_050_000,
|
|
contextTokens: 272_000,
|
|
maxTokens: 128_000,
|
|
thinkingLevelMap: { minimal: "low", xhigh: "xhigh", max: null },
|
|
compat: {
|
|
codeMode: "capable",
|
|
supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh"],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves static Anthropic thinking maps for legacy Claude Opus configured ids", () => {
|
|
const opus46 = requireResolvedModel(createMockCtx("claude-opus-4.6-1m"));
|
|
expect(opus46.thinkingLevelMap).toEqual({ xhigh: null, max: null });
|
|
|
|
const result = requireResolvedModel(createMockCtx("claude-opus-4.7-1m-internal"));
|
|
expect(result.thinkingLevelMap).toEqual({ xhigh: "xhigh", max: null });
|
|
expect(result.compat).toEqual({
|
|
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
});
|
|
});
|
|
|
|
it("creates synthetic model for arbitrary unknown model ID", () => {
|
|
const ctx = createMockCtx("future-model");
|
|
const result = requireResolvedModel(ctx);
|
|
expect(result.id).toBe("future-model");
|
|
expect(result.name).toBe("future-model");
|
|
expect((result as unknown as Record<string, unknown>).api).toBe("openai-responses");
|
|
expect((result as unknown as Record<string, unknown>).input).toEqual(["text", "image"]);
|
|
});
|
|
|
|
it("disables eager tool streaming for synthetic Copilot Claude 4.5 models", () => {
|
|
const result = requireResolvedModel(createMockCtx("claude-haiku-4.5"));
|
|
expect(result.api).toBe("anthropic-messages");
|
|
expect(result.compat).toEqual({ supportsEagerToolInputStreaming: false });
|
|
});
|
|
|
|
it("creates synthetic Gemini models with Chat Completions compatibility", () => {
|
|
const result = requireResolvedModel(createMockCtx("gemini-3.1-pro-preview"));
|
|
expect((result as unknown as Record<string, unknown>).api).toBe("openai-completions");
|
|
expect((result as unknown as Record<string, unknown>).compat).toEqual({
|
|
supportsStore: false,
|
|
supportsDeveloperRole: false,
|
|
supportsUsageInStreaming: false,
|
|
maxTokensField: "max_tokens",
|
|
});
|
|
});
|
|
|
|
it("infers reasoning=true for o1/o3 model IDs", () => {
|
|
for (const id of ["o1", "o3", "o3-mini", "o1-preview"]) {
|
|
const ctx = createMockCtx(id);
|
|
const result = requireResolvedModel(ctx);
|
|
expect((result as unknown as Record<string, unknown>).reasoning).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("infers reasoning=true for Codex model IDs", () => {
|
|
for (const id of ["gpt-5.4-codex", "gpt-5.5-codex", "gpt-5.4-codex-mini", "gpt-5.3-codex"]) {
|
|
const ctx = createMockCtx(id);
|
|
const result = requireResolvedModel(ctx);
|
|
expect((result as unknown as Record<string, unknown>).reasoning).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("sets reasoning=false for non-reasoning model IDs including mid-string o1/o3", () => {
|
|
for (const id of ["gpt-4o", "mycodexmodel", "audio-o1-hd", "turbo-o3-voice"]) {
|
|
const ctx = createMockCtx(id);
|
|
const result = requireResolvedModel(ctx);
|
|
expect((result as unknown as Record<string, unknown>).reasoning).toBe(false);
|
|
}
|
|
});
|
|
|
|
it.each(["gpt-5.4-mini", "claude-sonnet-5"])(
|
|
"uses manifest reasoning metadata for %s instead of synthesizing an unknown model",
|
|
(modelId) => {
|
|
expect(requireResolvedModel(createMockCtx(modelId)).reasoning).toBe(true);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("fetchCopilotUsage", () => {
|
|
it("targets the public github.com usage endpoint by default", async () => {
|
|
let calledUrl: string | undefined;
|
|
const mockFetch = createProviderUsageFetch(async (url) => {
|
|
calledUrl = url;
|
|
return makeResponse(200, { copilot_plan: "pro" });
|
|
});
|
|
|
|
await fetchCopilotUsage("token", 5000, mockFetch);
|
|
|
|
expect(calledUrl).toBe("https://api.github.com/copilot_internal/user");
|
|
});
|
|
|
|
it("routes usage through the tenant host for *.ghe.com domains", async () => {
|
|
let calledUrl: string | undefined;
|
|
const mockFetch = createProviderUsageFetch(async (url) => {
|
|
calledUrl = url;
|
|
return makeResponse(200, { copilot_plan: "business" });
|
|
});
|
|
|
|
await fetchCopilotUsage("token", 5000, mockFetch, "acme.ghe.com");
|
|
|
|
expect(calledUrl).toBe("https://api.acme.ghe.com/copilot_internal/user");
|
|
});
|
|
|
|
it("returns HTTP errors for failed requests", async () => {
|
|
const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom"));
|
|
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
|
|
|
expect(result.error).toBe("HTTP 500");
|
|
expect(result.windows).toHaveLength(0);
|
|
});
|
|
|
|
it("cancels failed response bodies", async () => {
|
|
let canceled = false;
|
|
const body = new ReadableStream({
|
|
cancel() {
|
|
canceled = true;
|
|
throw new Error("stream already closed");
|
|
},
|
|
});
|
|
const mockFetch = createProviderUsageFetch(async () => new Response(body, { status: 500 }));
|
|
|
|
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
|
|
|
expect(result.error).toBe("HTTP 500");
|
|
expect(canceled).toBe(true);
|
|
});
|
|
|
|
it("parses premium/chat usage from remaining percentages", async () => {
|
|
const mockFetch = createProviderUsageFetch(async (_url, init) => {
|
|
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
|
|
expect(headers.Authorization).toBe("token token");
|
|
expect(headers["X-Github-Api-Version"]).toBe("2025-04-01");
|
|
|
|
return makeResponse(200, {
|
|
quota_snapshots: {
|
|
premium_interactions: { percent_remaining: 20 },
|
|
chat: { percent_remaining: 75 },
|
|
},
|
|
copilot_plan: "pro",
|
|
});
|
|
});
|
|
|
|
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
|
|
|
expect(result.plan).toBe("pro");
|
|
expect(result.windows).toEqual([
|
|
{ label: "Premium", usedPercent: 80 },
|
|
{ label: "Chat", usedPercent: 25 },
|
|
]);
|
|
});
|
|
|
|
it("defaults missing snapshot values and clamps invalid remaining percentages", async () => {
|
|
const mockFetch = createProviderUsageFetch(async () =>
|
|
makeResponse(200, {
|
|
quota_snapshots: {
|
|
premium_interactions: { percent_remaining: null },
|
|
chat: { percent_remaining: 140 },
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
|
|
|
expect(result.windows).toEqual([
|
|
{ label: "Premium", usedPercent: 100 },
|
|
{ label: "Chat", usedPercent: 0 },
|
|
]);
|
|
expect(result.plan).toBeUndefined();
|
|
});
|
|
|
|
it("returns an empty window list when quota snapshots are missing", async () => {
|
|
const mockFetch = createProviderUsageFetch(async () =>
|
|
makeResponse(200, {
|
|
copilot_plan: "free",
|
|
}),
|
|
);
|
|
|
|
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
|
|
|
expect(result).toEqual({
|
|
provider: "github-copilot",
|
|
displayName: "Copilot",
|
|
windows: [],
|
|
plan: "free",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["null", null],
|
|
["an array", []],
|
|
])("returns an empty window list for a non-object %s payload", async (_label, payload) => {
|
|
const mockFetch = createProviderUsageFetch(async () => makeResponse(200, payload));
|
|
|
|
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
|
|
|
expect(result).toEqual({
|
|
provider: "github-copilot",
|
|
displayName: "Copilot",
|
|
windows: [],
|
|
plan: undefined,
|
|
});
|
|
});
|
|
|
|
it("bounds the usage read and cancels the stream when the body exceeds the JSON byte cap", async () => {
|
|
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels the
|
|
// stream mid-flight; if the cap were removed the unbounded res.json() would buffer the whole body.
|
|
const ONE_MIB = 1024 * 1024;
|
|
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
|
|
const chunk = new Uint8Array(ONE_MIB);
|
|
|
|
let bytesPulled = 0;
|
|
let canceled = false;
|
|
const makeOversizedJsonResponse = (): Response => {
|
|
let pulled = 0;
|
|
const body = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (pulled >= TOTAL_CHUNKS) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
pulled += 1;
|
|
bytesPulled += chunk.length;
|
|
controller.enqueue(chunk);
|
|
},
|
|
cancel() {
|
|
canceled = true;
|
|
},
|
|
});
|
|
return new Response(body, {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const mockFetch = createProviderUsageFetch(async () => makeOversizedJsonResponse());
|
|
|
|
await expect(fetchCopilotUsage("token", 5000, mockFetch)).rejects.toThrow(
|
|
/github-copilot-usage: JSON response exceeds/,
|
|
);
|
|
// The bounded reader cancels the body and never pulls the full advertised 32 MiB stream.
|
|
expect(canceled).toBe(true);
|
|
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
|
});
|
|
});
|
|
|
|
describe("github-copilot runtime auth", () => {
|
|
it("validates and preserves the source token while resolving the account API endpoint", async () => {
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({ endpoints: { api: "https://api.individual.githubcopilot.com/" } }),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
),
|
|
);
|
|
|
|
const auth = await resolveCopilotRuntimeAuth({
|
|
githubToken: "github-source-token",
|
|
fetchImpl: fetchImpl as typeof fetch,
|
|
});
|
|
|
|
expect(auth).toEqual({
|
|
apiKey: "github-source-token",
|
|
baseUrl: "https://api.individual.githubcopilot.com",
|
|
source: "validated:https://api.github.com/copilot_internal/user",
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
"https://api.github.com/copilot_internal/user",
|
|
expect.objectContaining({
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: "Bearer github-source-token",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("accepts an account endpoint under the configured data-residency tenant", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ endpoints: { api: "https://copilot-api.acme.ghe.com" } }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
const auth = await resolveCopilotRuntimeAuth({
|
|
githubToken: "tenant-source-token",
|
|
githubDomain: "acme.ghe.com",
|
|
fetchImpl: fetchImpl as typeof fetch,
|
|
});
|
|
|
|
expect(auth.baseUrl).toBe("https://copilot-api.acme.ghe.com");
|
|
expect(fetchImpl.mock.calls[0]?.[0]).toBe("https://api.acme.ghe.com/copilot_internal/user");
|
|
});
|
|
|
|
it("uses a domain-safe fallback when account metadata omits the API endpoint", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ copilot_plan: "individual" }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
resolveCopilotRuntimeAuth({
|
|
githubToken: "github-source-token",
|
|
fetchImpl: fetchImpl as typeof fetch,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
apiKey: "github-source-token",
|
|
baseUrl: "https://api.individual.githubcopilot.com",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
"http://api.individual.githubcopilot.com",
|
|
"https://api.individual.githubcopilot.com.attacker.test",
|
|
"https://user@api.individual.githubcopilot.com",
|
|
])("rejects an untrusted account endpoint: %s", async (api) => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ endpoints: { api } }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
resolveCopilotRuntimeAuth({
|
|
githubToken: "github-source-token",
|
|
fetchImpl: fetchImpl as typeof fetch,
|
|
}),
|
|
).rejects.toThrow("untrusted endpoints.api URL");
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"explains forbidden auth without waiting for capture (clone: %s)",
|
|
async (cloned) => {
|
|
const response = new Response(new ReadableStream<Uint8Array>(), { status: 403 });
|
|
const capture = cloned ? response.clone() : undefined;
|
|
const fetchImpl = vi.fn().mockResolvedValue(response);
|
|
const rejection = resolveCopilotRuntimeAuth({
|
|
githubToken: "github-source-token",
|
|
fetchImpl: fetchImpl as typeof fetch,
|
|
}).catch((error: unknown) => error);
|
|
|
|
try {
|
|
const error = await Promise.race([
|
|
rejection,
|
|
new Promise<undefined>((resolve) => {
|
|
setImmediate(() => resolve(undefined));
|
|
}),
|
|
]);
|
|
expect(error).toBeInstanceOf(CopilotRuntimeAuthError);
|
|
expect(error).toMatchObject({
|
|
code: "github_copilot_auth_failed",
|
|
reason: "http_error",
|
|
status: 403,
|
|
message: expect.stringContaining("login-github-copilot"),
|
|
});
|
|
expect(response.bodyUsed).toBe(true);
|
|
} finally {
|
|
await capture?.body?.cancel();
|
|
await rejection;
|
|
}
|
|
},
|
|
);
|
|
|
|
it("maps a stalled response body to a runtime-auth timeout", async () => {
|
|
const controller = new AbortController();
|
|
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockImplementation(() => controller.signal);
|
|
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
const signal = init?.signal;
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(streamController) {
|
|
signal?.addEventListener("abort", () => streamController.error(signal.reason), {
|
|
once: true,
|
|
});
|
|
queueMicrotask(() => controller.abort(new DOMException("timed out", "TimeoutError")));
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
resolveCopilotRuntimeAuth({
|
|
githubToken: "github-source-token",
|
|
fetchImpl: fetchImpl as typeof fetch,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: "CopilotRuntimeAuthError",
|
|
reason: "timeout",
|
|
timeoutMs: 30_000,
|
|
});
|
|
} finally {
|
|
timeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
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: "gemini-3.1-pro-preview",
|
|
name: "Gemini 3.1 Pro Preview",
|
|
object: "model",
|
|
vendor: "Google",
|
|
capabilities: {
|
|
type: "chat",
|
|
limits: {
|
|
max_context_window_tokens: 1_000_000,
|
|
max_output_tokens: 65_536,
|
|
},
|
|
supports: {
|
|
vision: true,
|
|
tool_calls: true,
|
|
streaming: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
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,
|
|
reasoning_effort: ["low", "medium", "high", "xhigh"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "claude-opus-4-5",
|
|
name: "Claude Opus 4.5",
|
|
object: "model",
|
|
vendor: "Anthropic",
|
|
capabilities: {
|
|
type: "chat",
|
|
limits: {
|
|
max_context_window_tokens: 200000,
|
|
max_output_tokens: 64000,
|
|
},
|
|
supports: {
|
|
vision: true,
|
|
tool_calls: true,
|
|
reasoning_effort: ["low", "medium", "high", "max"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// 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" },
|
|
},
|
|
],
|
|
};
|
|
|
|
function selectableModelEntry(params: {
|
|
id: string;
|
|
category?: string;
|
|
contextWindow?: number;
|
|
maxTokens?: number;
|
|
pickerEnabled?: boolean;
|
|
policyState?: string;
|
|
preview?: boolean;
|
|
streaming?: boolean | "omit";
|
|
toolCalls?: boolean;
|
|
}) {
|
|
return {
|
|
id: params.id,
|
|
name: params.id,
|
|
object: "model",
|
|
model_picker_enabled: params.pickerEnabled ?? true,
|
|
model_picker_category: params.category ?? "versatile",
|
|
policy: { state: params.policyState ?? "enabled" },
|
|
preview: params.preview ?? false,
|
|
capabilities: {
|
|
type: "chat",
|
|
limits: {
|
|
max_context_window_tokens: params.contextWindow ?? 200_000,
|
|
max_output_tokens: params.maxTokens ?? 64_000,
|
|
},
|
|
supports: {
|
|
...(params.streaming === "omit" ? {} : { streaming: params.streaming ?? true }),
|
|
tool_calls: params.toolCalls ?? true,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function fetchSelectionFixture(data: unknown[]) {
|
|
return await fetchCopilotModelCatalog({
|
|
copilotApiToken: "tid=test",
|
|
baseUrl: "https://api.githubcopilot.com",
|
|
fetchImpl: vi.fn().mockResolvedValue(makeResponse(200, { data })) as unknown as typeof fetch,
|
|
});
|
|
}
|
|
|
|
it.each([
|
|
{
|
|
source: "live",
|
|
reasoning: "minimal",
|
|
efforts: ["none", "low", "medium", "high", "xhigh", "max"],
|
|
expected: "low",
|
|
},
|
|
{ source: "static", reasoning: "minimal", efforts: [], expected: "low" },
|
|
{
|
|
source: "native minimal live",
|
|
reasoning: "minimal",
|
|
efforts: ["minimal", "low", "high"],
|
|
expected: "minimal",
|
|
},
|
|
{
|
|
source: "live",
|
|
reasoning: "xhigh",
|
|
efforts: ["low", "high", "xhigh", "max"],
|
|
expected: "xhigh",
|
|
},
|
|
{ source: "live", reasoning: "max", efforts: ["low", "high", "xhigh", "max"], expected: "max" },
|
|
{ source: "static", reasoning: "xhigh", efforts: [], expected: "xhigh" },
|
|
{ source: "static", reasoning: "max", efforts: [], expected: "max" },
|
|
{
|
|
source: "limited live",
|
|
reasoning: "xhigh",
|
|
efforts: ["low", "medium", "high"],
|
|
expected: "high",
|
|
},
|
|
{
|
|
source: "limited live",
|
|
reasoning: "max",
|
|
efforts: ["low", "medium", "high"],
|
|
expected: "high",
|
|
},
|
|
{ source: "empty live", reasoning: "max", efforts: [], expected: undefined },
|
|
] as const)(
|
|
"keeps $source catalog policy and $reasoning Responses effort aligned",
|
|
async ({ source, reasoning, efforts, expected }) => {
|
|
const [entry] = await fetchSelectionFixture([
|
|
{
|
|
id: "gpt-5.6-luna",
|
|
vendor: "OpenAI",
|
|
capabilities: {
|
|
type: "chat",
|
|
supports: { reasoning_effort: efforts },
|
|
},
|
|
},
|
|
]);
|
|
const model = {
|
|
...(source === "static"
|
|
? requireResolvedModel(createMockCtx("gpt-5.6-luna"))
|
|
: expectDefined(entry, "discovered Copilot model")),
|
|
provider: "github-copilot",
|
|
api: "openai-responses" as const,
|
|
baseUrl: "https://api.githubcopilot.com",
|
|
};
|
|
const profile = resolveThinkingProfile({
|
|
provider: model.provider,
|
|
modelId: model.id,
|
|
api: model.api,
|
|
compat: model.compat,
|
|
});
|
|
expect(profile?.levels.some(({ id }) => id === reasoning)).toBe(
|
|
reasoning === "minimal" || expected === reasoning,
|
|
);
|
|
const onPayload = vi.fn(() => {
|
|
throw new Error("captured before sending");
|
|
});
|
|
|
|
const result = await streamSimpleOpenAIResponses(
|
|
model,
|
|
{ messages: [{ role: "user", content: "Reply OK", timestamp: 1 }] },
|
|
{ apiKey: "test-token", reasoning, onPayload },
|
|
).result();
|
|
|
|
expect(result.errorMessage).toBe("captured before sending");
|
|
expect(onPayload).toHaveBeenCalledWith(
|
|
expected
|
|
? expect.objectContaining({ reasoning: { effort: expected, summary: "auto" } })
|
|
: expect.not.objectContaining({ reasoning: expect.anything() }),
|
|
model,
|
|
);
|
|
},
|
|
);
|
|
it("selects onboarding's starter model using the configured integration identity", async () => {
|
|
const fetchMock = vi.fn<typeof fetch>(async (url, init) => {
|
|
const requestUrl = typeof url === "string" ? url : url instanceof URL ? url.href : url.url;
|
|
if (requestUrl.endsWith("/copilot_internal/user")) {
|
|
return Response.json({ endpoints: { api: "https://copilot-api.acme.ghe.com" } });
|
|
}
|
|
const headers = new Headers(init?.headers);
|
|
expect(headers.get("copilot-integration-id")).toBe("vscode-chat");
|
|
expect(headers.get("authorization")).toBe("Bearer setup-source-token");
|
|
expect(headers.has("x-private-header")).toBe(false);
|
|
return Response.json({ data: [selectableModelEntry({ id: "tenant-model" })] });
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
try {
|
|
await expect(
|
|
resolveCopilotStarterModel({
|
|
githubToken: "setup-source-token",
|
|
githubDomain: "acme.ghe.com",
|
|
config: {
|
|
models: {
|
|
providers: {
|
|
"github-copilot": {
|
|
baseUrl: "https://copilot-api.acme.ghe.com",
|
|
models: [],
|
|
headers: {
|
|
"copilot-integration-id": "vscode-chat",
|
|
"X-Private-Header": "not-for-catalog",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
).resolves.toBe("github-copilot/tenant-model");
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("selects the preferred model only when the authenticated catalog marks it eligible", async () => {
|
|
const models = await fetchSelectionFixture([
|
|
selectableModelEntry({ id: "fallback", contextWindow: 1_000_000 }),
|
|
selectableModelEntry({ id: "preferred", category: "powerful" }),
|
|
]);
|
|
|
|
expect(selectCopilotStarterModel(models, "preferred")?.id).toBe("preferred");
|
|
});
|
|
|
|
it("uses a deterministic eligible fallback when the preferred model is policy-disabled", async () => {
|
|
const entries = [
|
|
selectableModelEntry({ id: "preferred", policyState: "disabled" }),
|
|
selectableModelEntry({ id: "preview", preview: true, contextWindow: 1_000_000 }),
|
|
selectableModelEntry({ id: "powerful", category: "powerful", contextWindow: 1_000_000 }),
|
|
selectableModelEntry({ id: "versatile-b", contextWindow: 400_000 }),
|
|
selectableModelEntry({ id: "versatile-a", contextWindow: 400_000 }),
|
|
];
|
|
const forward = await fetchSelectionFixture(entries);
|
|
const reversed = await fetchSelectionFixture(entries.toReversed());
|
|
|
|
expect(selectCopilotStarterModel(forward, "preferred")?.id).toBe("versatile-a");
|
|
expect(selectCopilotStarterModel(reversed, "preferred")?.id).toBe("versatile-a");
|
|
});
|
|
|
|
it("rejects hidden, unconfigured, non-streaming, and tool-less setup candidates", async () => {
|
|
const models = await fetchSelectionFixture([
|
|
selectableModelEntry({ id: "hidden", pickerEnabled: false }),
|
|
selectableModelEntry({ id: "unconfigured", policyState: "unconfigured" }),
|
|
selectableModelEntry({ id: "no-stream", streaming: false }),
|
|
selectableModelEntry({ id: "no-tools", toolCalls: false }),
|
|
]);
|
|
|
|
expect(selectCopilotStarterModel(models, "hidden")).toBeUndefined();
|
|
});
|
|
|
|
it("does not treat omitted streaming metadata as an explicit lack of support", async () => {
|
|
const models = await fetchSelectionFixture([
|
|
selectableModelEntry({ id: "omitted-streaming", streaming: "omit" }),
|
|
]);
|
|
|
|
expect(selectCopilotStarterModel(models, "omitted-streaming")?.id).toBe("omitted-streaming");
|
|
});
|
|
|
|
it("maps Copilot /models entries to ModelDefinitionConfig with real context windows", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, 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(((calledInit as RequestInit).headers as Record<string, string>)["Accept-Encoding"]).toBe(
|
|
"identity",
|
|
);
|
|
|
|
expect(out.map((m) => m.id)).toEqual([
|
|
"gpt-5.5",
|
|
"gpt-5.3-codex",
|
|
"gemini-3.1-pro-preview",
|
|
"claude-opus-4.7-1m-internal",
|
|
"claude-opus-4-5",
|
|
]);
|
|
|
|
const gpt55 = out.find((m) => m.id === "gpt-5.5");
|
|
expect(gpt55).toEqual({
|
|
id: "gpt-5.5",
|
|
name: "GPT-5.5",
|
|
api: "openai-responses",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
contextTokens: 272000,
|
|
maxTokens: 128000,
|
|
compat: { supportedReasoningEfforts: ["low", "medium", "high"] },
|
|
thinkingLevelMap: { minimal: "low", xhigh: null, max: null },
|
|
});
|
|
|
|
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);
|
|
expect(codex?.contextTokens).toBeUndefined();
|
|
|
|
const gemini = out.find((m) => m.id === "gemini-3.1-pro-preview");
|
|
expect(gemini?.api).toBe("openai-completions");
|
|
expect(gemini?.compat).toEqual({
|
|
supportsStore: false,
|
|
supportsDeveloperRole: false,
|
|
supportsUsageInStreaming: false,
|
|
maxTokensField: "max_tokens",
|
|
});
|
|
|
|
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);
|
|
expect(opus1m?.thinkingLevelMap).toEqual({ xhigh: "xhigh", max: null });
|
|
expect(opus1m?.compat).toEqual({
|
|
supportedReasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
});
|
|
|
|
const opus45 = out.find((m) => m.id === "claude-opus-4-5");
|
|
expect(opus45?.thinkingLevelMap).toEqual({ xhigh: null, max: null });
|
|
expect(opus45?.compat).toEqual({
|
|
supportsEagerToolInputStreaming: false,
|
|
supportedReasoningEfforts: ["low", "medium", "high", "max"],
|
|
});
|
|
});
|
|
|
|
it("strips trailing slash from baseUrl when building the /models URL", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, { 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(
|
|
makeResponse(200, {
|
|
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(expectDefined(out[0], "GitHub Copilot model").name).toBe("GPT-5.5");
|
|
});
|
|
|
|
it("falls back from malformed live token limits", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(
|
|
makeResponse(200, {
|
|
data: [
|
|
{
|
|
id: "gpt-bad-window",
|
|
name: "GPT Bad Window",
|
|
object: "model",
|
|
capabilities: {
|
|
type: "chat",
|
|
limits: {
|
|
max_context_window_tokens: -1,
|
|
max_prompt_tokens: -1,
|
|
max_output_tokens: 128000.5,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "gpt-bad-output",
|
|
name: "GPT Bad Output",
|
|
object: "model",
|
|
capabilities: {
|
|
type: "chat",
|
|
limits: {
|
|
max_context_window_tokens: Number.POSITIVE_INFINITY,
|
|
max_prompt_tokens: Number.POSITIVE_INFINITY,
|
|
max_output_tokens: 0,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const out = await fetchCopilotModelCatalog({
|
|
copilotApiToken: "tid=test",
|
|
baseUrl: "https://api.githubcopilot.com",
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
});
|
|
|
|
expect(out).toHaveLength(2);
|
|
expect(out[0]).toMatchObject({
|
|
id: "gpt-bad-window",
|
|
contextWindow: 128000,
|
|
maxTokens: 8192,
|
|
});
|
|
expect(out[0]).not.toHaveProperty("contextTokens");
|
|
expect(out[1]).toMatchObject({
|
|
id: "gpt-bad-output",
|
|
contextWindow: 128000,
|
|
maxTokens: 8192,
|
|
});
|
|
expect(out[1]).not.toHaveProperty("contextTokens");
|
|
});
|
|
|
|
it("cancels stalled non-2xx response bodies before the caller falls back", async () => {
|
|
let canceled = false;
|
|
const response = new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
cancel() {
|
|
canceled = true;
|
|
},
|
|
}),
|
|
{ status: 401 },
|
|
);
|
|
const fetchImpl = vi.fn().mockResolvedValue(response);
|
|
|
|
await expect(
|
|
fetchCopilotModelCatalog({
|
|
copilotApiToken: "tid=bad",
|
|
baseUrl: "https://api.githubcopilot.com",
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
}),
|
|
).rejects.toThrow(/HTTP 401/);
|
|
|
|
expect(canceled).toBe(true);
|
|
});
|
|
|
|
it("throws provider-owned errors for malformed successful /models payloads", async () => {
|
|
for (const payload of [[], { data: {} }, { data: [null] }]) {
|
|
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, payload));
|
|
|
|
await expect(
|
|
fetchCopilotModelCatalog({
|
|
copilotApiToken: "tid=test",
|
|
baseUrl: "https://api.githubcopilot.com",
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
}),
|
|
).rejects.toThrow("Copilot /models: malformed JSON response");
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|