Use v1 PluginModule format to fix Windows plugin loading

opencode's getLegacyPlugins iterates every module export and throws on
non-function values. On Windows, Bun's standalone-binary dynamic imports
can produce module namespace objects with extra non-function metadata,
silently preventing the plugin from loading (the provider never appears
in `opencode auth login`).

Switch the default export from a bare Plugin function to the v1
PluginModule object ({ id, server }), which opencode's readV1Plugin
detects and handles before getLegacyPlugins ever runs. Also add
exports["./server"] for explicit entry point resolution.
This commit is contained in:
lemon07r
2026-04-22 05:31:05 -04:00
parent 8018ca9f4d
commit e5fd91feba
5 changed files with 41 additions and 23 deletions
+3 -3
View File
@@ -33,7 +33,7 @@ Three files, 1 job each. Do not add a fourth unless the existing three genuinely
| `src/constants.ts` | Pinned strings that must mirror upstream kimi-cli (version, endpoints, client id, scope). |
| `src/headers.ts` | The seven `X-Msh-*` / UA headers + the persistent `~/.kimi/device_id` file. |
| `src/oauth.ts` | Device-code start, device-code poll, refresh-token exchange, and `GET /coding/v1/models` discovery. |
| `src/index.ts` | Plugin entry. Wires `auth` (login + loader) plus the Kimi chat hooks/body rewrite. |
| `src/index.ts` | Plugin entry (v1 `PluginModule` format). Wires `auth` (login + loader) plus the Kimi chat hooks/body rewrite. |
Data flow on a chat request:
@@ -66,7 +66,7 @@ These are the invariants that, if broken, silently route requests onto the wrong
6. **Wire model id comes from `/coding/v1/models`, not from user config.** The opencode-side model id is a stable alias (`MODEL_ID = "kimi-for-coding"`); the plugin calls `GET /coding/v1/models` at login and on every token refresh (mirroring kimi-cli's `refresh_managed_models` in `research/kimi-cli/src/kimi_cli/auth/platforms.py`), caches the first returned `{id, context_length, display_name}` in loader memory, rewrites the JSON body `model` field inside `loader.fetch` whenever the discovered id differs from `MODEL_ID`, and backfills runtime model metadata from the same discovery response. A new loader instance re-discovers on first use if needed. Do not strip the `kimi-` prefix; send whatever the server returned. Discovery failures are non-fatal (warm cached id still works; 401 retry flushes broken tokens).
7. **Auth store is opencode's, not kimi-cli's.** We use opencode's auth store for tokens under the `kimi-for-coding-oauth` provider id. Do not read/write `~/.kimi/credentials/kimi-code.json`; that's kimi-cli's file and sharing it across independent apps causes token-race bugs. The plugin may live-read opencode's `auth.json` entry for this provider to bypass stale `OPENCODE_AUTH_CONTENT` workspace snapshots, but writes still go through opencode's auth store (`client.auth.set`). Also note that opencode's SDK auth schema only persists the standard oauth fields, so model discovery metadata cannot be stored there durably.
8. **Provider id must not collide with any id in the [models.dev](https://models.dev) catalog.** models.dev publishes `kimi-for-coding` as a separate API-key-driven integration. If we registered under that same id, `opencode auth login kimi-for-coding` would surface two methods under one entry and users could silently land on the wrong integration path. We deliberately use `kimi-for-coding-oauth` instead; `MODEL_ID` on the wire stays `kimi-for-coding` (rule 6).
9. **`src/index.ts` must have exactly one export — the default plugin function.** opencode's plugin loader (`research/opencode/packages/opencode/src/plugin/index.ts``getLegacyPlugins`) iterates every export of the plugin module and throws `Plugin export is not a function` if any named export is not callable. The failure mode is silent in the CLI (the provider just doesn't appear in `opencode auth login`); the error only surfaces in `~/.local/share/opencode/log/*.log`. Keep constants in `src/constants.ts` and import them in `src/index.ts` rather than re-exporting. `test/exports.test.ts` guards this.
9. **`src/index.ts` must have exactly one export — the default `PluginModule` object `{ id, server }`.** opencode's plugin loader (`research/opencode/packages/opencode/src/plugin/index.ts`) first tries `readV1Plugin` (detect mode) on the default export. If it finds an object with `server` (and optional `id`), it uses the v1 path directly. The older legacy path (`getLegacyPlugins`) iterates every export and throws `Plugin export is not a function` on any non-callable value — a problem that surfaced on Windows where Bun's standalone-binary dynamic imports can produce module namespace objects with unexpected non-function metadata. The v1 format bypasses `getLegacyPlugins` entirely. Keep constants in `src/constants.ts` and import them in `src/index.ts` rather than re-exporting. `test/exports.test.ts` guards this. The failure mode of a broken export is silent in the CLI (the provider just doesn't appear in `opencode auth login`); the error only surfaces in `~/.local/share/opencode/log/*.log`.
10. **The post-login config hint must not emit a partial `limit` object.** opencode's live config schema at `https://opencode.ai/config.json` requires both `limit.context` and `limit.output` whenever `limit` is present, while Kimi's `GET /coding/v1/models` only gives us `context_length`. Therefore `buildConfigBlock()` omits `limit` entirely and leaves `provider.models` to backfill `limit.context` at runtime. Do not invent `output` or set `input` heuristically; opencode's overflow logic treats `limit.input` as authoritative (`research/opencode/packages/opencode/src/session/overflow.ts`).
11. **Concurrent refreshes must collapse to one in-flight OAuth exchange, even across plugin instances.** `provider.models` and `auth.loader` can both notice an expiring token at about the same time, and separate opencode workspace/plugin instances can inherit stale auth snapshots. `refreshAuth()` in `src/index.ts` therefore shares one promise across overlapping callers, takes a provider-scoped auth-store lock before refreshing, re-reads opencode's live auth-store entry under that lock, and treats a changed on-disk token chain as authoritative. `test/plugin.test.ts` covers loader-vs-loader, provider.models-vs-loader, cross-instance lock reuse, and the `invalid_grant` self-heal path where another process already rotated the refresh token.
@@ -88,7 +88,7 @@ These are the invariants that, if broken, silently route requests onto the wrong
- ❌ Don't call out to other files to "share" the kimi-cli credentials. Different OAuth consumers must have independent refresh-token chains or one will invalidate the other.
- ❌ Don't introduce a build step. The plugin ships as `.ts` and opencode's bun-based loader handles it.
- ❌ Don't add tests that require real Kimi credentials and check them in. If you add offline unit tests, put them under `test/` and mock `fetch`.
- ❌ Don't add named exports to `src/index.ts`. See rule 9.
- ❌ Don't add named exports to `src/index.ts` or change the default export away from the `{ id, server }` PluginModule shape. See rule 9.
### How to verify a change
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-kimi-full",
"version": "1.2.7",
"version": "1.2.8",
"description": "OpenCode plugin that brings the official Kimi OAuth device flow and Kimi-specific coding request fields to opencode, matching upstream kimi-cli.",
"license": "MIT",
"repository": {
@@ -18,7 +18,8 @@
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./server": "./src/index.ts"
},
"files": [
"src",
+16 -7
View File
@@ -1,16 +1,19 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import type { Plugin } from "@opencode-ai/plugin"
import type { Plugin, PluginModule } from "@opencode-ai/plugin"
import { API_BASE_URL, MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts"
import { kimiHeaders } from "./headers.ts"
import { type KimiModelInfo, listModels, pollDeviceToken, refreshToken, startDeviceAuth } from "./oauth.ts"
// IMPORTANT: this module must have exactly ONE export — the default plugin
// function. opencode's plugin loader (packages/opencode/src/plugin/index.ts →
// getLegacyPlugins) iterates every export and throws "Plugin export is not a
// function" if any named export is not a function. Keep constants in
// constants.ts and import them here.
// IMPORTANT: this module must have exactly ONE export — the default
// PluginModule object. opencode's plugin loader detects the v1 format
// ({ id, server }) via readV1Plugin *before* falling back to
// getLegacyPlugins — which iterates every export and throws "Plugin export
// is not a function" on any non-callable value. The v1 path is more
// reliable on Windows where Bun standalone dynamic imports can produce
// module namespace objects with unexpected non-function metadata.
// Keep constants in constants.ts and import them here.
type OAuthAuth = {
type: "oauth"
@@ -688,4 +691,10 @@ const plugin: Plugin = async ({ client }) => {
}
}
export default plugin
// v1 PluginModule format — bypasses getLegacyPlugins entirely.
// For npm-sourced plugins, id is optional (falls back to package.json name),
// but we set it explicitly for clarity.
export default {
id: "opencode-kimi-full",
server: plugin,
} satisfies PluginModule
+16 -10
View File
@@ -1,17 +1,23 @@
import { test, expect } from "bun:test"
import * as mod from "../src/index.ts"
// Regression guard for the 1.0.0 bug:
// opencode's plugin loader (packages/opencode/src/plugin/index.ts →
// getLegacyPlugins) iterates every export of the plugin module and throws
// "Plugin export is not a function" if any export is not callable. The
// published v1.0.0 re-exported PROVIDER_ID (a string), which broke loading
// silently — `opencode auth login` just did not list the provider.
// Regression guard for the 1.0.0 bug + the Windows loading fix:
// opencode's plugin loader first tries readV1Plugin (detect mode) on the
// default export. If it finds { id?, server } it uses the v1 path and
// never touches getLegacyPlugins. The legacy path iterates every export and
// throws "Plugin export is not a function" on any non-callable value — a
// problem that surfaced on Windows where Bun standalone dynamic imports can
// produce module namespaces with extra non-function metadata.
//
// Keep this file cheap and dependency-free; do not import anything that
// makes network calls.
test("src/index.ts exports exactly one default export which is a function", () => {
// This test ensures the module exports exactly one default PluginModule
// object with a callable `server` and no named exports.
test("src/index.ts exports exactly one default PluginModule object", () => {
const keys = Object.keys(mod)
expect(keys).toEqual(["default"])
expect(typeof (mod as { default: unknown }).default).toBe("function")
const plugin = (mod as { default: unknown }).default
expect(typeof plugin).toBe("object")
expect(plugin).not.toBeNull()
const obj = plugin as Record<string, unknown>
expect(typeof obj.server).toBe("function")
expect("id" in obj).toBe(true)
})
+3 -1
View File
@@ -2,7 +2,9 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { test, expect, afterEach } from "bun:test"
import plugin from "../src/index.ts"
import pluginModule from "../src/index.ts"
const plugin = pluginModule.server
import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "../src/constants.ts"
import { installFetchMock } from "./_util/fetchMock.ts"