Skip to content
Closed
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

### Fixed: loading the plugin from a local directory

Added `server.js` at the repository root so OpenCode can load the built V2 adapter from a local directory.

### Fixed: recommended aliases were hidden in OpenCode Desktop 2

The exact aliases `coding`, `thinking` and `fast` receive the current timestamp and separate families to appear by default in Desktop 2.0.29. This is a visibility workaround, not their actual release date. Explicit user visibility choices, other models and V1 are unchanged.

### Fixed: setup could not store the API key for OpenCode 2 on Windows

With OpenCode 2 installed through npm, `opencode` on Windows is a `.cmd` shim. Node refuses to pass arguments containing JSON quotes to a batch file (CVE-2024-27980 hardening), so `credential.create` failed with "contains a cmd.exe special character", and the error message echoed the request body including the API key. Setup now resolves the real `opencode.exe` from PATH, looking through the npm shim, and synchronous spawn errors no longer leak argv. Replacing an existing credential also failed: `credential.remove` takes `credentialID` as a path parameter (`--param`), not as a request body.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ The layer depends on the `config` hook running before OpenCode resolves provider

## Behavior

- On OpenCode Desktop 2, `coding`, `thinking` and `fast` receive the current timestamp and separate families to appear by default. This is a visibility workaround, not their actual release date. Explicit user visibility choices still take precedence; other models may need enabling under **Manage models**.
- Discovery prefers `GET /v1/model_group/info`, which reports one entry per alias with mode, context limits, costs and capability flags. It falls back to `GET /v1/models` for older LiteLLM versions and restricted keys.
- Costs are converted from LiteLLM's per-token prices to the per-million unit OpenCode expects. Cache-read and cache-write prices come from `GET /v1/model/info` (`cache_read_input_token_cost`, `cache_creation_input_token_cost`), since `/model_group/info` reports only input and output. OpenCode prices cached tokens at zero unless told otherwise, and in an agentic session those are nearly all the prompt tokens, so without them its cost display sits 10–50× below what the proxy bills. If that lookup fails, cached tokens are priced like input: too high beats invisible.
- Wildcard entries are LiteLLM access rules rather than callable models and are dropped, as is anything whose mode is not `chat` or `responses`. An entry without a mode is kept.
Expand All @@ -256,6 +257,10 @@ npm install
npm run check
```

### Testing locally in OpenCode Desktop 2

Run `npm run build`, then set the Neuron entry's `package` to the absolute repository directory, for example `"C:/workspaces/opencode-plugin-neuron"`. Use the same source globally and in projects to avoid duplicate plugin IDs; keep profile options unchanged. Rebuild after source changes and restore the npm package source when finished.

To inspect the package that will be distributed:

```sh
Expand Down
1 change: 1 addition & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./dist/v2.js"
1 change: 1 addition & 0 deletions src/v2/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface V2ModelInfo {
modelID: string
providerID: string
name: string
family?: string
/** Overrides the provider's adapter for this one model. */
package?: string
capabilities: { tools: boolean; input: string[]; output: string[] }
Expand Down
5 changes: 4 additions & 1 deletion src/v2/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,19 @@ export function toModelInfo(model: LiteLLMModel, providerID: string): V2ModelInf
: []

const isResponsesAPI = model.mode?.toLowerCase() === "responses"
// Recent dates and separate families keep all three aliases visible in Desktop 2.
const isDefaultAlias = model.id === "coding" || model.id === "thinking" || model.id === "fast"

return {
id: model.id,
modelID: model.id,
providerID,
name: model.id,
...(isDefaultAlias ? { family: model.id } : {}),
...(isResponsesAPI ? { package: RESPONSES_API_PACKAGE_V2 } : {}),
capabilities: { tools: model.supports_function_calling ?? true, input, output: ["text"] },
variants: [],
time: { released: 0 },
time: { released: isDefaultAlias ? Date.now() : 0 },
cost,
status: "active",
enabled: true,
Expand Down
8 changes: 8 additions & 0 deletions test/v2/entry.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { describe, expect, it } from "vitest"
import { Host } from "@opencode/plugin/host"
import { fileURLToPath } from "node:url"
import { NeuronPlugin } from "../../src/plugin.js"
import entry from "../../src/v2.js"

describe("the ./server entry", () => {
it("resolves a local plugin directory to the server entry", () => {
const directory = fileURLToPath(new URL("../../", import.meta.url))
const resolved = Host.resolve({ directory })
expect(resolved.server).toBe(new URL("../../server.js", import.meta.url).href)
})

it("serves OpenCode 2 through setup and OpenCode 1 through server", () => {
// OpenCode 1.16 to 1.18 resolve exports["./server"] for npm packages too
// and insist on a server() function; 0.5.0 and 0.5.1 lacked it.
Expand Down
21 changes: 20 additions & 1 deletion test/v2/model.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
import { describe, expect, it } from "vitest"
import { describe, expect, it, vi } from "vitest"
import { DEFAULT_CONTEXT_LIMIT, DEFAULT_OUTPUT_LIMIT, RESPONSES_API_PACKAGE_V2 } from "../../src/constants.js"
import { toModelInfo } from "../../src/v2/model.js"

describe("toModelInfo", () => {
it.each(["coding", "thinking", "fast"])("makes the %s alias recent and independently selectable in Desktop", (id) => {
const now = Date.UTC(2026, 9, 14)
const clock = vi.spyOn(Date, "now").mockReturnValue(now)
try {
const info = toModelInfo({ id }, "neuron-2")
expect(info.time.released).toBe(now)
expect(info).toHaveProperty("family", id)
expect(info.enabled).toBe(true)
} finally {
clock.mockRestore()
}
})

it.each(["Coding", "fast-model", "provider/thinking", "claude-sonnet"])("does not change release metadata for %s", (id) => {
const info = toModelInfo({ id }, "neuron-2")
expect(info.time.released).toBe(0)
expect(info).not.toHaveProperty("family")
})

it("maps ids, limits, capabilities and prices", () => {
const info = toModelInfo(
{
Expand Down
Loading