Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 84 additions & 15 deletions src/models/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
* Non-blocking model auto-refresh for plugin startup.
*
* Discovers currently available models from cursor-agent and merges them
* into the opencode.json config. Only adds new models — never removes
* user-configured ones. Safe to call fire-and-forget; all errors are
* caught and logged silently.
* into the opencode.json config. Direct mode only adds new models;
* compact mode folds raw variants into OpenCode variants. Safe to call
* fire-and-forget; all errors are caught and logged silently.
*/
import {
existsSync as nodeExistsSync,
Expand All @@ -14,10 +14,12 @@ import {
import { discoverModelsFromCursorAgent, type DiscoveredModel } from "../cli/model-discovery.js";
import { resolveOpenCodeConfigPath } from "../plugin-toggle.js";
import { createLogger, type Logger } from "../utils/logger.js";
import { mergeCursorModelEntries } from "./variants.js";

const log = createLogger("model-sync");
const PROVIDER_ID = "cursor-acp";

type AutoRefreshMode = "disabled" | "direct" | "compact";
type ModelConfigEntry = { name: string };
type ProviderConfig = { models?: Record<string, unknown> } & Record<string, unknown>;
type OpenCodeConfig = {
Expand Down Expand Up @@ -69,17 +71,50 @@ function getExistingModels(provider: ProviderConfig): Record<string, unknown> {
return isRecord(provider.models) ? { ...provider.models } : {};
}

function readCursorModel(value: unknown): string | undefined {
if (!isRecord(value)) return undefined;
const cursorModel = value.cursorModel;
return typeof cursorModel === "string" && cursorModel.trim().length > 0
? cursorModel.trim()
: undefined;
}

function collectRepresentedModelIds(models: Record<string, unknown>): Set<string> {
const represented = new Set<string>(Object.keys(models));

for (const entry of Object.values(models)) {
if (!isRecord(entry)) continue;
const optionModel = readCursorModel(entry.options);
if (optionModel) represented.add(optionModel);

if (!isRecord(entry.variants)) continue;
for (const variantEntry of Object.values(entry.variants)) {
const variantModel = readCursorModel(variantEntry);
if (variantModel) represented.add(variantModel);
}
}

return represented;
}

function yieldForFireAndForget(): Promise<void> {
return Promise.resolve();
}

function getAutoRefreshMode(env: NodeJS.ProcessEnv): AutoRefreshMode {
const raw = env.CURSOR_ACP_MODEL_AUTO_REFRESH?.trim().toLowerCase();
if (raw === "false") return "disabled";
if (raw === "direct") return "direct";
return "compact";
}

/**
* Auto-refresh models at plugin startup.
*
* - Reads the current opencode.json config
* - Queries cursor-agent for available models
* - Merges discovered models into the provider config (additive only)
* - Writes back if any new models were added
* - Merges discovered models into the provider config
* - Writes back if new models were added or compacted
*
* This function never throws. All failures are logged at debug level
* and silently ignored so plugin startup is never blocked.
Expand All @@ -96,6 +131,12 @@ export async function autoRefreshModels(
await resolvedDeps.defer();

try {
const refreshMode = getAutoRefreshMode(resolvedDeps.env);
if (refreshMode === "disabled") {
resolvedDeps.log.debug("Model auto-refresh disabled by CURSOR_ACP_MODEL_AUTO_REFRESH");
return;
}

const configPath = resolveOpenCodeConfigPath(resolvedDeps.env);
if (!resolvedDeps.existsSync(configPath)) {
resolvedDeps.log.debug("Config file not found, skipping model auto-refresh", { configPath });
Expand Down Expand Up @@ -126,26 +167,54 @@ export async function autoRefreshModels(
return;
}

let addedCount = 0;
for (const model of discovered) {
if (Object.prototype.hasOwnProperty.call(existingModels, model.id)) continue;
existingModels[model.id] = { name: model.name } satisfies ModelConfigEntry;
addedCount++;
if (refreshMode === "direct") {
const existingModelIds = new Set(Object.keys(existingModels));
const missingModels = discovered.filter(model => !existingModelIds.has(model.id));
if (missingModels.length === 0) {
resolvedDeps.log.debug("Model auto-refresh: no new models found", {
existing: Object.keys(existingModels).length,
discovered: discovered.length,
});
return;
}

const models = { ...existingModels };
for (const model of missingModels) {
models[model.id] = { name: model.name } satisfies ModelConfigEntry;
}

provider.models = models;
resolvedDeps.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
resolvedDeps.log.info("Model auto-refresh: added new models", {
added: missingModels.length,
total: Object.keys(models).length,
});
return;
}

if (addedCount === 0) {
const representedModelIds = collectRepresentedModelIds(existingModels);
const missingModels = discovered.filter(model => !representedModelIds.has(model.id));
const result = mergeCursorModelEntries(existingModels, discovered, {
variants: true,
compact: true,
});

if (missingModels.length === 0 && result.removedCount === 0) {
resolvedDeps.log.debug("Model auto-refresh: no new models found", {
existing: Object.keys(existingModels).length,
discovered: discovered.length,
});
return;
}

provider.models = existingModels;
provider.models = result.models;
resolvedDeps.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
resolvedDeps.log.info("Model auto-refresh: added new models", {
added: addedCount,
total: Object.keys(existingModels).length,
resolvedDeps.log.info("Model auto-refresh: synced models", {
mode: refreshMode,
synced: result.syncedCount,
grouped: result.groupedCount,
removed: result.removedCount,
total: Object.keys(result.models).length,
});
} catch (err) {
resolvedDeps.log.debug("Model auto-refresh failed", { error: String(err) });
Expand Down
148 changes: 147 additions & 1 deletion tests/unit/models/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ describe("models/sync", () => {
vi.clearAllMocks();
});

it("adds newly discovered models without removing existing entries", async () => {
it("uses direct sync when explicitly requested", async () => {
const { deps, writeFileSync } = createDeps({
env: {
...process.env,
OPENCODE_CONFIG: "/tmp/opencode.json",
CURSOR_ACP_MODEL_AUTO_REFRESH: "direct",
},
readFileSync: vi.fn(() =>
JSON.stringify({
provider: {
Expand Down Expand Up @@ -77,6 +82,147 @@ describe("models/sync", () => {
});
});

it("uses compact variant sync by default", async () => {
const { deps, writeFileSync } = createDeps({
readFileSync: vi.fn(() =>
JSON.stringify({
provider: {
"cursor-acp": {
models: {
auto: { name: "Auto" },
"custom-model": { name: "Custom" },
"gpt-5.4-low": { name: "Old Low" },
"gpt-5.4-high": { name: "Old High" },
},
},
},
}),
),
discoverModels: vi.fn(() => [
{ id: "auto", name: "Auto" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.4-low", name: "GPT-5.4 Low" },
{ id: "gpt-5.4-high", name: "GPT-5.4 High" },
]),
});

await autoRefreshModels(deps);

expect(writeFileSync).toHaveBeenCalledTimes(1);
const [, writtenConfig] = writeFileSync.mock.calls[0];
const parsed = JSON.parse(writtenConfig as string);
expect(parsed.provider["cursor-acp"].models).toMatchObject({
auto: { name: "Auto" },
"custom-model": { name: "Custom" },
"gpt-5.4": {
name: "GPT-5.4",
options: { cursorModel: "gpt-5.4" },
variants: {
low: { cursorModel: "gpt-5.4-low" },
high: { cursorModel: "gpt-5.4-high" },
},
},
});
expect(parsed.provider["cursor-acp"].models["gpt-5.4-low"]).toBeUndefined();
expect(parsed.provider["cursor-acp"].models["gpt-5.4-high"]).toBeUndefined();
});

it("compacts existing raw variant entries when no discovered ids are missing", async () => {
const { deps, writeFileSync } = createDeps({
readFileSync: vi.fn(() =>
JSON.stringify({
provider: {
"cursor-acp": {
models: {
"gpt-5.4": { name: "GPT-5.4" },
"gpt-5.4-low": { name: "GPT-5.4 Low" },
"gpt-5.4-high": { name: "GPT-5.4 High" },
},
},
},
}),
),
discoverModels: vi.fn(() => [
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.4-low", name: "GPT-5.4 Low" },
{ id: "gpt-5.4-high", name: "GPT-5.4 High" },
]),
});

await autoRefreshModels(deps);

expect(writeFileSync).toHaveBeenCalledTimes(1);
const [, writtenConfig] = writeFileSync.mock.calls[0];
const parsed = JSON.parse(writtenConfig as string);
expect(parsed.provider["cursor-acp"].models["gpt-5.4"]).toMatchObject({
options: { cursorModel: "gpt-5.4" },
variants: {
low: { cursorModel: "gpt-5.4-low" },
high: { cursorModel: "gpt-5.4-high" },
},
});
expect(parsed.provider["cursor-acp"].models["gpt-5.4-low"]).toBeUndefined();
expect(parsed.provider["cursor-acp"].models["gpt-5.4-high"]).toBeUndefined();
});

it("uses compact variant sync when explicitly requested", async () => {
const { deps, writeFileSync } = createDeps({
env: {
...process.env,
OPENCODE_CONFIG: "/tmp/opencode.json",
CURSOR_ACP_MODEL_AUTO_REFRESH: "compact",
},
readFileSync: vi.fn(() =>
JSON.stringify({
provider: {
"cursor-acp": {
models: {
"gpt-5.4-low": { name: "Old Low" },
"gpt-5.4-high": { name: "Old High" },
},
},
},
}),
),
discoverModels: vi.fn(() => [
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.4-low", name: "GPT-5.4 Low" },
{ id: "gpt-5.4-high", name: "GPT-5.4 High" },
]),
});

await autoRefreshModels(deps);

expect(writeFileSync).toHaveBeenCalledTimes(1);
const [, writtenConfig] = writeFileSync.mock.calls[0];
const parsed = JSON.parse(writtenConfig as string);
expect(parsed.provider["cursor-acp"].models["gpt-5.4"]).toMatchObject({
options: { cursorModel: "gpt-5.4" },
variants: {
low: { cursorModel: "gpt-5.4-low" },
high: { cursorModel: "gpt-5.4-high" },
},
});
expect(parsed.provider["cursor-acp"].models["gpt-5.4-low"]).toBeUndefined();
expect(parsed.provider["cursor-acp"].models["gpt-5.4-high"]).toBeUndefined();
});

it("can disable startup model refresh", async () => {
const { deps, readFileSync, writeFileSync, discoverModels } = createDeps({
env: {
...process.env,
OPENCODE_CONFIG: "/tmp/opencode.json",
CURSOR_ACP_MODEL_AUTO_REFRESH: "false",
},
});

await autoRefreshModels(deps);

expect(readFileSync).not.toHaveBeenCalled();
expect(discoverModels).not.toHaveBeenCalled();
expect(writeFileSync).not.toHaveBeenCalled();
});

it("returns silently when the config file is missing", async () => {
const { deps, readFileSync, writeFileSync, discoverModels } = createDeps({
existsSync: vi.fn(() => false),
Expand Down