Skip to content
Merged
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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- **Non-cached input tokens now reach opencode (#146).** Cursor's
`inputTokens` already includes cache reads and writes, so it stays the V3
`inputTokens.total`; the plugin now also reports `noCache`
(`inputTokens − cacheRead − cacheWrite`, clamped at 0) and passes
`reasoningTokens` through as `outputTokens.reasoning`. opencode 2.x reads
`noCache` directly, so input showed as 0 and was never priced.
- **Model costs follow Cursor's 2026-10-27 published data.** Regenerated
with `npm run sync:model-limits` (the weekly drift check was failing):
claude-sonnet-5-5 cache read $0.2 → $0.1; gemini-3.7-flash and
gemini-3.8-flash output $3.5 → $3.75 per million tokens.

### Security

- Bumped `@cursor/sdk` to ^1.1.0, `@connectrpc/connect-node` to ^2.3.0,
`@opencode-ai/plugin`/`sdk` to ^1.19.4 and `vitest` to ^5.0.5 (vite
8.3.5, `@vitest/mocker` 5.0.5, `source-map-js` 1.2.2). New overrides:
`zod` ^4.6.6 under `@opencode-ai/plugin` (GHSA-mrhx-c7xc-m3w5) and `toml`
^5.0.1 (GHSA-4qx8-w7c3-pj3p, GHSA-9j65-hcg3-gc64, GHSA-9qmh-m7c6-f92p).
The `undici` override is gone: nothing in the tree depends on it any more.
`npm audit` on this repo reports 0 vulnerabilities. npm does not publish
overrides, so installs of this package still get `zod` 4.1.8 (pinned by
`@opencode-ai/plugin` 1.19.4) and `toml` 4.x (via its pinned `effect`
4.0.0-beta.83, which wants `^4.1.1`) until upstream bumps them.

## [0.10.0] — 2026-10-03

Adds opencode v2 support alongside v1 (#127), regenerates the model
Expand Down
421 changes: 194 additions & 227 deletions package-lock.json

Large diffs are not rendered by default.

20 changes: 11 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,29 +58,31 @@
"prepublishOnly": "npm run typecheck && npm test && npm run build"
},
"dependencies": {
"@connectrpc/connect-node": "^2.2.0",
"@cursor/sdk": "^1.0.32",
"@opencode-ai/plugin": "^1.18.33",
"@connectrpc/connect-node": "^2.3.0",
"@cursor/sdk": "^1.1.0",
"@opencode-ai/plugin": "^1.19.4",
"semver": "^7.8.4"
},
"overrides": {
"@opencode-ai/sdk": "$@opencode-ai/sdk",
"undici": "^6.28.1",
"@opencode-ai/plugin": {
"zod": "^4.6.6"
},
"tar": "^7.5.11",
"node-gyp": "^12.4.0",
"esbuild": "^0.28.1",
"toml": "^4.2.0"
"toml": "^5.0.1"
},
"peerDependencies": {
"@ai-sdk/provider": "^3.0.0"
},
"devDependencies": {
"@ai-sdk/provider": "^3.0.18",
"@opencode-ai/sdk": "^1.18.33",
"@types/node": "^26.6.3",
"@ai-sdk/provider": "^3.0.19",
"@opencode-ai/sdk": "^1.19.4",
"@types/node": "^26.6.5",
"@types/semver": "^7.8.0",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^5.0.2"
"vitest": "^5.0.5"
}
}
8 changes: 4 additions & 4 deletions src/model-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* context windows https://cursor.com/docs/account/pricing/request-based-legacy.md
* pricing https://cursor.com/docs/models-and-pricing.md
*
* Data last changed: 2026-09-29
* Data last changed: 2026-10-27
* (a sync that finds no data change leaves this date alone, so it dates the
* last change to the generated maps — NOT the last time they were verified.
* Verification runs on a schedule in CI; see the model-data-drift job.)
Expand Down Expand Up @@ -156,7 +156,7 @@ const MODEL_COST: Record<string, { input: number; output: number; cacheRead: num
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5 },
"composer-2": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"composer-2.5": { input: 0.5, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
"default": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
Expand All @@ -165,8 +165,8 @@ const MODEL_COST: Record<string, { input: number; output: number; cacheRead: num
"gemini-3.1-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
"gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 },
"gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 },
"gemini-3.7-flash": { input: 0.75, output: 3.5, cacheRead: 0.075, cacheWrite: 0 },
"gemini-3.8-flash": { input: 0.75, output: 3.5, cacheRead: 0.075, cacheWrite: 0 },
"gemini-3.7-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 },
"gemini-3.8-flash": { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 },
"gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
"gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
Expand Down
5 changes: 5 additions & 0 deletions src/provider/agent-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface CursorUsage {
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
/** Subset of outputTokens; absent when the model reported none. */
reasoningTokens?: number;
}

/**
Expand Down Expand Up @@ -67,6 +69,9 @@ export function addUsage(a?: CursorUsage, b?: CursorUsage): CursorUsage | undefi
outputTokens: a.outputTokens + b.outputTokens,
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
...(a.reasoningTokens !== undefined || b.reasoningTokens !== undefined
? { reasoningTokens: (a.reasoningTokens ?? 0) + (b.reasoningTokens ?? 0) }
: {}),
};
}

Expand Down
5 changes: 3 additions & 2 deletions src/provider/stream-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1047,18 +1047,19 @@ export const EMPTY_USAGE: LanguageModelV3Usage = {
outputTokens: { total: undefined, text: undefined, reasoning: undefined },
};

// Cursor's inputTokens already includes cache reads/writes (V3 `total` semantics).
export function mapUsage(usage: CursorUsage): LanguageModelV3Usage {
return {
inputTokens: {
total: usage.inputTokens,
noCache: undefined,
noCache: Math.max(0, usage.inputTokens - usage.cacheReadTokens - usage.cacheWriteTokens),
cacheRead: usage.cacheReadTokens,
cacheWrite: usage.cacheWriteTokens,
},
outputTokens: {
total: usage.outputTokens,
text: undefined,
reasoning: undefined,
reasoning: usage.reasoningTokens,
},
};
}
Expand Down
15 changes: 15 additions & 0 deletions test/agent-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,21 @@ describe("silent-turn usage capture", () => {
usage: { inputTokens: 110, outputTokens: 55, cacheReadTokens: 3, cacheWriteTokens: 4 },
});
});

it("streamAgentTurn sums reasoningTokens when only one side reports them", async () => {
const agent = fakeAgent({
updates: [{ type: "turn-ended", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 3 } }],
});
const events = await collect(streamAgentTurn(agent, MESSAGE, {
mode: "agent",
usageBase: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
}));
const usage = events.find((e) => e.type === "usage");
expect(usage).toEqual({
type: "usage",
usage: { inputTokens: 110, outputTokens: 55, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 3 },
});
});
});

describe("sendAgentTurnSilently", () => {
Expand Down
3 changes: 2 additions & 1 deletion test/language-model-interjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,14 @@ describe("multi-message interjection (continuation-multi)", () => {

const finish = parts.find((p) => p.type === "finish") as unknown as {
usage: {
inputTokens: { total: number; cacheRead: number; cacheWrite: number };
inputTokens: { total: number; noCache: number; cacheRead: number; cacheWrite: number };
outputTokens: { total: number };
};
};
expect(finish).toBeDefined();
// b (silent) + c (streamed): 10+10, 5+5, 1+1 cacheRead, 2+2 cacheWrite.
expect(finish.usage.inputTokens.total).toBe(20);
expect(finish.usage.inputTokens.noCache).toBe(14);
expect(finish.usage.outputTokens.total).toBe(10);
expect(finish.usage.inputTokens.cacheRead).toBe(2);
expect(finish.usage.inputTokens.cacheWrite).toBe(4);
Expand Down
16 changes: 14 additions & 2 deletions test/mcp-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Config } from "@opencode-ai/plugin";
import {
findUnshareableOAuthServers,
translateMcpServers,
} from "../src/plugin/mcp-config.js";
import { CursorPlugin as plugin } from "../src/plugin/index.js";

// Mock homedir so the config hook's skill mirror doesn't pick up real skills.
const fakeHome = mkdtempSync(join(tmpdir(), "cursor-mcp-config-home-"));
vi.mock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return { ...actual, homedir: () => fakeHome };
});

// Dynamic so the plugin loads after the homedir mock (same as plugin-skill-mirror.test.ts).
const { CursorPlugin: plugin } = await import("../src/plugin/index.js");

describe("translateMcpServers", () => {
it("maps a local (stdio) server, splitting command/args and keeping env", () => {
Expand Down
29 changes: 27 additions & 2 deletions test/stream-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1511,7 +1511,9 @@ describe("tool-input streaming (partial-tool-call)", () => {
});

describe("mapUsage", () => {
it("maps Cursor usage into the V3 nested shape", () => {
// Cursor's inputTokens already includes cache reads/writes; opencode reads
// noCache directly and shows 0 input when it is absent (#146).
it("derives noCache from Cursor's cache-inclusive inputTokens", () => {
expect(
mapUsage({
inputTokens: 100,
Expand All @@ -1522,13 +1524,36 @@ describe("mapUsage", () => {
).toEqual({
inputTokens: {
total: 100,
noCache: undefined,
noCache: 15,
cacheRead: 80,
cacheWrite: 5,
},
outputTokens: { total: 20, text: undefined, reasoning: undefined },
});
});

it("clamps noCache at zero when cache counts exceed inputTokens", () => {
expect(
mapUsage({
inputTokens: 10,
outputTokens: 1,
cacheReadTokens: 8,
cacheWriteTokens: 5,
}).inputTokens.noCache,
).toBe(0);
});

it("passes Cursor's reasoningTokens through as outputTokens.reasoning", () => {
expect(
mapUsage({
inputTokens: 10,
outputTokens: 40,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 30,
}).outputTokens,
).toEqual({ total: 40, text: undefined, reasoning: 30 });
});
});

describe("finish providerMetadata", () => {
Expand Down
Loading