mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 18:54:48 +00:00
* refactor: remove stale file-backed shims * fix: harden sqlite state ci boundaries * refactor: store matrix idb snapshots in sqlite * fix: satisfy rebased CI guardrails * refactor: store current conversation bindings in sqlite table * refactor: store tui last sessions in sqlite table * refactor: reset sqlite schema history * refactor: drop unshipped sqlite table migration * refactor: remove plugin index file rollback * refactor: drop unshipped sqlite sidecar migrations * refactor: remove runtime commitments kv migration * refactor: preserve kysely sync result types * refactor: drop unshipped sqlite schema migration table * test: keep session usage coverage sqlite-backed * refactor: keep sqlite migration doctor-only * refactor: isolate device legacy imports * refactor: isolate push voicewake legacy imports * refactor: isolate remaining runtime legacy imports * refactor: tighten sqlite migration guardrails * test: cover sqlite persisted enum parsing * refactor: isolate legacy update and tui imports * refactor: tighten sqlite state ownership * refactor: move legacy imports behind doctor * refactor: remove legacy session row lookup * refactor: canonicalize memory transcript locators * refactor: drop transcript path scope fallbacks * refactor: drop runtime legacy session delivery pruning * refactor: store tts prefs only in sqlite * refactor: remove cron store path runtime * refactor: use cron sqlite store keys * refactor: rename telegram message cache scope * refactor: read memory dreaming status from sqlite * refactor: rename cron status store key * refactor: stop remembering transcript file paths * test: use sqlite locators in agent fixtures * refactor: remove file-shaped commitments and cron store surfaces * refactor: keep compaction transcript handles out of session rows * refactor: derive transcript handles from session identity * refactor: derive runtime transcript handles * refactor: remove gateway session locator reads * refactor: remove transcript locator from session rows * refactor: store raw stream diagnostics in sqlite * refactor: remove file-shaped transcript rotation * refactor: hide legacy trajectory paths from runtime * refactor: remove runtime transcript file bridges * refactor: repair database-first rebase fallout * refactor: align tests with database-first state * refactor: remove transcript file handoffs * refactor: sync post-compaction memory by transcript scope * refactor: run codex app-server sessions by id * refactor: bind codex runtime state by session id * refactor: pass memory transcripts by sqlite scope * refactor: remove transcript locator cleanup leftovers * test: remove stale transcript file fixtures * refactor: remove transcript locator test helper * test: make cron sqlite keys explicit * test: remove cron runtime store paths * test: remove stale session file fixtures * test: use sqlite cron keys in diagnostics * refactor: remove runtime delivery queue backfill * test: drop fake export session file mocks * refactor: rename acp session read failure flag * refactor: rename acp row session key * refactor: remove session store test seams * refactor: move legacy session parser tests to doctor * refactor: reindex managed memory in place * refactor: drop stale session store wording * refactor: rename session row helpers * refactor: rename sqlite session entry modules * refactor: remove transcript locator leftovers * refactor: trim file-era audit wording * refactor: clean managed media through sqlite * fix: prefer explicit agent for exports * fix: use prepared agent for session resets * fix: canonicalize legacy codex binding import * test: rename state cleanup helper * docs: align backup docs with sqlite state * refactor: drop legacy Pi usage auth fallback * refactor: move legacy auth profile imports to doctor * refactor: keep Pi model discovery auth in memory * refactor: remove MSTeams legacy learning key fallback * refactor: store model catalog config in sqlite * refactor: use sqlite model catalog at runtime * refactor: remove model json compatibility aliases * refactor: store auth profiles in sqlite * refactor: seed copied auth profiles in sqlite * refactor: make auth profile runtime sqlite-addressed * refactor: migrate hermes secrets into sqlite auth store * refactor: move plugin install config migration to doctor * refactor: rename plugin index audit checks * test: drop auth file assumptions * test: remove legacy transcript file assertions * refactor: drop legacy cli session aliases * refactor: store skill uploads in sqlite * refactor: keep subagent attachments in sqlite vfs * refactor: drop subagent attachment cleanup state * refactor: move legacy session aliases to doctor * refactor: require node 24 for sqlite state runtime * refactor: move provider caches into sqlite state * fix: harden virtual agent filesystem * refactor: enforce database-first runtime state * refactor: rename compaction transcript rotation setting * test: clean sqlite refactor test types * refactor: consolidate sqlite runtime state * refactor: model session conversations in sqlite * refactor: stop deriving cron delivery from session keys * refactor: stop classifying sessions from key shape * refactor: hydrate announce targets from typed delivery * refactor: route heartbeat delivery from typed sqlite context * refactor: tighten typed sqlite session routing * refactor: remove session origin routing shadow * refactor: drop session origin shadow fixtures * perf: query sqlite vfs paths by prefix * refactor: use typed conversation metadata for sessions * refactor: prefer typed session routing metadata * refactor: require typed session routing metadata * refactor: resolve group tool policy from typed sessions * refactor: delete dead session thread info bridge * Show Codex subscription reset times in channel errors (#80456) * feat(plugin-sdk): consolidate session workflow APIs * fix(agents): allow read-only agent mount reads * [codex] refresh plugin regression fixtures * fix(agents): restore compaction gateway logs * test: tighten gateway startup assertions * Redact persisted secret-shaped payloads [AI] (#79006) * test: tighten device pair notify assertions * test: tighten hermes secret assertions * test: assert matrix client error shapes * test: assert config compat warnings * fix(heartbeat): remap cron-run exec events to session keys (#80214) * fix(codex): route btw through native side threads * fix(auth): accept friendly OpenAI order for Codex profiles * fix(codex): rotate auth profiles inside harness * fix: keep browser status page probe within timeout * test: assert agents add outputs * test: pin cron read status * fix(agents): avoid Pi resource discovery stalls Co-authored-by: dataCenter430 <titan032000@gmail.com> * fix: retire timed-out codex app-server clients * test: tighten qa lab runtime assertions * test: check security fix outputs * test: verify extension runtime messages * feat(wake): expose typed sessionKey on wake protocol + system event CLI * fix(gateway): await session_end during shutdown drain and track channel + compaction lifecycle paths (#57790) * test: guard talk consult call helper * fix(codex): scale context engine projection (#80761) * fix(codex): scale context engine projection * fix: document Codex context projection scaling * fix: document Codex context projection scaling * fix: document Codex context projection scaling * fix: document Codex context projection scaling * chore: align Codex projection changelog * chore: realign Codex projection changelog * fix: isolate Codex projection patch --------- Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org> Co-authored-by: Josh Lehman <josh@martian.engineering> * refactor: move agent runtime state toward piless * refactor: remove cron session reaper * refactor: move session management to sqlite * refactor: finish database-first state migration * chore: refresh generated sqlite db types * refactor: remove stale file-backed shims * test: harden kysely type coverage # Conflicts: # .agents/skills/kysely-database-access/SKILL.md # src/infra/kysely-sync.types.test.ts # src/proxy-capture/store.sqlite.test.ts # src/state/openclaw-agent-db.test.ts # src/state/openclaw-state-db.test.ts * refactor: remove cron store path runtime * refactor: keep compaction transcript handles out of session rows * refactor: derive embedded transcripts from sqlite identity * refactor: remove embedded transcript locator handoff * refactor: remove runtime transcript file bridges * refactor: remove transcript file handoffs * refactor: remove MSTeams legacy learning key fallback * refactor: store model catalog config in sqlite * refactor: use sqlite model catalog at runtime # Conflicts: # docs/cli/secrets.md # docs/gateway/authentication.md # docs/gateway/secrets.md * fix: keep oauth sibling sync sqlite-local # Conflicts: # src/commands/onboard-auth.test.ts * refactor: remove task session store maintenance # Conflicts: # src/commands/tasks.ts * refactor: keep diagnostics in state sqlite * refactor: enforce database-first runtime state * refactor: consolidate sqlite runtime state * Show Codex subscription reset times in channel errors (#80456) * fix(codex): refresh subscription limit resets * fix(codex): format reset times for channels * Update CHANGELOG with latest changes and fixes Updated CHANGELOG with recent fixes and improvements. * fix(codex): keep command load failures on codex surface * fix(codex): format account rate limits as rows * fix(codex): summarize account limits as usage status * fix(codex): simplify account limit status * test: tighten subagent announce queue assertion * test: tighten session delete lifecycle assertions * test: tighten cron ops assertions * fix: track cron execution milestones * test: tighten hermes secret assertions * test: assert matrix sync store payloads * test: assert config compat warnings * fix(codex): align btw side thread semantics * fix(codex): honor codex fallback blocking * fix(agents): avoid Pi resource discovery stalls * test: tighten codex event assertions * test: tighten cron assertions * Fix Codex app-server OAuth harness auth * refactor: move agent runtime state toward piless * refactor: move device and push state to sqlite * refactor: move runtime json state imports to doctor * refactor: finish database-first state migration * chore: refresh generated sqlite db types * refactor: clarify cron sqlite store keys * refactor: remove stale file-backed shims * refactor: bind codex runtime state by session id * test: expect sqlite trajectory branch export * refactor: rename session row helpers * fix: keep legacy device identity import in doctor * refactor: enforce database-first runtime state * refactor: consolidate sqlite runtime state * build: align pi contract wrappers * chore: repair database-first rebase * refactor: remove session file test contracts * test: update gateway session expectations * refactor: stop routing from session compatibility shadows * refactor: stop persisting session route shadows * refactor: use typed delivery context in clients * refactor: stop echoing session route shadows * refactor: repair embedded runner rebase imports # Conflicts: # src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts * refactor: align pi contract imports * refactor: satisfy kysely sync helper guard * refactor: remove file transcript bridge remnants * refactor: remove session locator compatibility * refactor: remove session file test contracts * refactor: keep rebase database-first clean * refactor: remove session file assumptions from e2e * docs: clarify database-first goal state * test: remove legacy store markers from sqlite runtime tests * refactor: remove legacy store assumptions from runtime seams * refactor: align sqlite runtime helper seams * test: update memory recall sqlite audit mock * refactor: align database-first runtime type seams * test: clarify doctor cron legacy store names * fix: preserve sqlite session route projections * test: fix copilot token cache test syntax * docs: update database-first proof status * test: align database-first test fixtures * docs: update database-first proof status * refactor: clean extension database-first drift * test: align agent session route proof * test: clarify doctor legacy path fixtures * chore: clean database-first changed checks * chore: repair database-first rebase markers * build: allow baileys git subdependency * chore: repair exp-vfs rebase drift * chore: finish exp-vfs rebase cleanup * chore: satisfy rebase lint drift * chore: fix qqbot rebase type seam * chore: fix rebase drift leftovers * fix: keep auth profile oauth secrets out of sqlite * fix: repair rebase drift tests * test: stabilize pairing request ordering * test: use source manifests in plugin contract checks * fix: restore gateway session metadata after rebase * fix: repair database-first rebase drift * fix: clean up database-first rebase fallout * test: stabilize line quick reply receipt time * fix: repair extension rebase drift * test: keep transcript redaction tests sqlite-backed * fix: carry injected transcript redaction through sqlite * chore: clean database branch rebase residue * fix: repair database branch CI drift * fix: repair database branch CI guard drift * fix: stabilize oauth tls preflight test * test: align database branch fast guards * test: repair build artifact boundary guards * chore: clean changelog rebase markers --------- Co-authored-by: pashpashpash <nik@vault77.ai> Co-authored-by: Eva <eva@100yen.org> Co-authored-by: stainlu <stainlu@newtype-ai.org> Co-authored-by: Jason Zhou <jason.zhou.design@gmail.com> Co-authored-by: Ruben Cuevas <hi@rubencu.com> Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> Co-authored-by: Shakker <shakkerdroid@gmail.com> Co-authored-by: Kaspre <36520309+Kaspre@users.noreply.github.com> Co-authored-by: dataCenter430 <titan032000@gmail.com> Co-authored-by: Kaspre <kaspre@gmail.com> Co-authored-by: pandadev66 <nova.full.stack@outlook.com> Co-authored-by: Eva <admin@100yen.org> Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org> Co-authored-by: Josh Lehman <josh@martian.engineering> Co-authored-by: jeffjhunter <support@aipersonamethod.com>
615 lines
19 KiB
TypeScript
615 lines
19 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { createProviderUsageFetch, makeResponse } from "openclaw/plugin-sdk/test-env";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { buildCopilotModelDefinition, getDefaultCopilotModelIds } from "./models-defaults.js";
|
|
import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } from "./token.js";
|
|
import { fetchCopilotUsage } from "./usage.js";
|
|
|
|
vi.mock("@earendil-works/pi-ai/oauth", async () => {
|
|
const actual = await vi.importActual<typeof import("@earendil-works/pi-ai/oauth")>(
|
|
"@earendil-works/pi-ai/oauth",
|
|
);
|
|
return {
|
|
...actual,
|
|
getOAuthApiKey: vi.fn(),
|
|
getOAuthProviders: vi.fn(() => []),
|
|
};
|
|
});
|
|
|
|
vi.mock("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 } 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("github-copilot model defaults", () => {
|
|
describe("getDefaultCopilotModelIds", () => {
|
|
it("includes claude-opus-4.7", () => {
|
|
expect(getDefaultCopilotModelIds()).toContain("claude-opus-4.7");
|
|
expect(getDefaultCopilotModelIds()).toContain("claude-opus-4.6");
|
|
});
|
|
|
|
it("includes claude-sonnet-4.6", () => {
|
|
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.6");
|
|
});
|
|
|
|
it("includes claude-sonnet-4.5", () => {
|
|
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.5");
|
|
});
|
|
|
|
it("returns a mutable copy", () => {
|
|
const a = getDefaultCopilotModelIds();
|
|
const b = getDefaultCopilotModelIds();
|
|
expect(a).not.toBe(b);
|
|
expect(a).toEqual(b);
|
|
});
|
|
});
|
|
|
|
describe("buildCopilotModelDefinition", () => {
|
|
it("builds a valid definition for claude-sonnet-4.6", () => {
|
|
const def = buildCopilotModelDefinition("claude-sonnet-4.6");
|
|
expect(def.id).toBe("claude-sonnet-4.6");
|
|
expect(def.api).toBe("anthropic-messages");
|
|
});
|
|
|
|
it("uses static metadata overrides for gpt-5.5 fallback rows", () => {
|
|
const def = buildCopilotModelDefinition("gpt-5.5");
|
|
expect(def).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: 400_000,
|
|
maxTokens: 128_000,
|
|
});
|
|
});
|
|
|
|
it("trims whitespace from model id", () => {
|
|
const def = buildCopilotModelDefinition(" gpt-4o ");
|
|
expect(def.id).toBe("gpt-4o");
|
|
expect(def.api).toBe("openai-responses");
|
|
});
|
|
|
|
it("throws on empty model id", () => {
|
|
expect(() => buildCopilotModelDefinition("")).toThrow("Model id required");
|
|
expect(() => buildCopilotModelDefinition(" ")).toThrow("Model id required");
|
|
});
|
|
});
|
|
});
|
|
|
|
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("clones gpt-5.2-codex template for gpt-5.4", () => {
|
|
const template = {
|
|
id: "gpt-5.2-codex",
|
|
name: "gpt-5.2-codex",
|
|
provider: "github-copilot",
|
|
api: "openai-responses",
|
|
reasoning: true,
|
|
contextWindow: 200_000,
|
|
};
|
|
const ctx = createMockCtx("gpt-5.4", {
|
|
"github-copilot/gpt-5.2-codex": template,
|
|
});
|
|
const result = requireResolvedModel(ctx);
|
|
expect(result.id).toBe("gpt-5.4");
|
|
expect(result.name).toBe("gpt-5.4");
|
|
expect((result as unknown as Record<string, unknown>).reasoning).toBe(true);
|
|
});
|
|
|
|
it("clones gpt-5.3-codex template for gpt-5.3-codex when not in registry", () => {
|
|
const template = {
|
|
id: "gpt-5.2-codex",
|
|
name: "gpt-5.2-codex",
|
|
provider: "github-copilot",
|
|
api: "openai-responses",
|
|
reasoning: true,
|
|
contextWindow: 200_000,
|
|
};
|
|
const ctx = createMockCtx("gpt-5.3-codex", {
|
|
"github-copilot/gpt-5.2-codex": template,
|
|
});
|
|
const result = requireResolvedModel(ctx);
|
|
expect(result.id).toBe("gpt-5.3-codex");
|
|
expect(result.name).toBe("gpt-5.3-codex");
|
|
expect((result as unknown as Record<string, unknown>).reasoning).toBe(true);
|
|
});
|
|
|
|
it("prefers gpt-5.3-codex as template source over gpt-5.2-codex for gpt-5.4", () => {
|
|
const template53 = {
|
|
id: "gpt-5.3-codex",
|
|
name: "gpt-5.3-codex",
|
|
provider: "github-copilot",
|
|
api: "openai-responses",
|
|
reasoning: true,
|
|
contextWindow: 300_000,
|
|
};
|
|
const template52 = {
|
|
id: "gpt-5.2-codex",
|
|
name: "gpt-5.2-codex",
|
|
provider: "github-copilot",
|
|
api: "openai-responses",
|
|
reasoning: true,
|
|
contextWindow: 200_000,
|
|
};
|
|
const ctx = createMockCtx("gpt-5.4", {
|
|
"github-copilot/gpt-5.3-codex": template53,
|
|
"github-copilot/gpt-5.2-codex": template52,
|
|
});
|
|
const result = requireResolvedModel(ctx);
|
|
expect(result.id).toBe("gpt-5.4");
|
|
expect((result as unknown as Record<string, unknown>).contextWindow).toBe(300_000);
|
|
});
|
|
|
|
it("falls through to synthetic catch-all when codex template is missing", () => {
|
|
const ctx = createMockCtx("gpt-5.4");
|
|
const result = requireResolvedModel(ctx);
|
|
expect(result.id).toBe("gpt-5.4");
|
|
});
|
|
|
|
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: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400_000,
|
|
maxTokens: 128_000,
|
|
});
|
|
});
|
|
|
|
it("creates synthetic model for arbitrary unknown model ID", () => {
|
|
const ctx = createMockCtx("gpt-5.4-mini");
|
|
const result = requireResolvedModel(ctx);
|
|
expect(result.id).toBe("gpt-5.4-mini");
|
|
expect(result.name).toBe("gpt-5.4-mini");
|
|
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("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-5.4-mini",
|
|
"claude-sonnet-4.6",
|
|
"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);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("fetchCopilotUsage", () => {
|
|
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("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",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("github-copilot token", () => {
|
|
function makeCopilotEnv(): NodeJS.ProcessEnv {
|
|
return {
|
|
...process.env,
|
|
OPENCLAW_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-token-")),
|
|
};
|
|
}
|
|
|
|
it("derives baseUrl from token", () => {
|
|
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe(
|
|
"https://api.example.com",
|
|
);
|
|
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBe(
|
|
"https://api.foo.bar",
|
|
);
|
|
});
|
|
|
|
it("uses cache when token is still valid", async () => {
|
|
const env = makeCopilotEnv();
|
|
const fetchImpl = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
token: "cached;proxy-ep=proxy.example.com;",
|
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
}),
|
|
});
|
|
const first = await resolveCopilotApiToken({
|
|
githubToken: "gh",
|
|
env,
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
});
|
|
const second = await resolveCopilotApiToken({
|
|
githubToken: "gh",
|
|
env,
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
});
|
|
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
expect(first.source).toContain("fetched:");
|
|
expect(second.token).toBe("cached;proxy-ep=proxy.example.com;");
|
|
expect(second.baseUrl).toBe("https://api.example.com");
|
|
expect(second.source).toContain("cache:sqlite:");
|
|
});
|
|
|
|
it("fetches and stores token when cache is missing", async () => {
|
|
const env = makeCopilotEnv();
|
|
const fetchImpl = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
token: "fresh;proxy-ep=https://proxy.contoso.test;",
|
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
}),
|
|
});
|
|
|
|
const res = await resolveCopilotApiToken({
|
|
githubToken: "gh",
|
|
env,
|
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
});
|
|
|
|
expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;");
|
|
expect(res.baseUrl).toBe("https://api.contoso.test");
|
|
expect(fetchImpl).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).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,
|
|
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();
|
|
});
|
|
});
|