From 0f8f14dbd4f041f2b27f98b41ea6bb737ee6e75b Mon Sep 17 00:00:00 2001 From: lemon07r Date: Fri, 17 Apr 2026 01:33:39 -0400 Subject: [PATCH] feat: add GitHub Actions release workflow and rename provider to kimi-for-coding-oauth --- .github/workflows/release.yml | 96 ++++++++++++++++++++++++++++++++ AGENTS.md | 12 ++-- README.md | 102 ++++++++++++++++++++++------------ src/index.ts | 13 +++-- 4 files changed, 177 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f3e8c78 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + id-token: write + +jobs: + release: + name: Publish release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Verify tag matches package.json version + run: | + tag="${GITHUB_REF_NAME#v}" + pkg=$(node -p "require('./package.json').version") + if [ "$tag" != "$pkg" ]; then + echo "Tag v$tag does not match package.json version $pkg" >&2 + exit 1 + fi + + - name: Install dev deps + run: bun install --frozen-lockfile || bun install + + - name: Type-check + run: bunx tsc --noEmit + + - name: Syntax check (no bundle) + run: bun build --target=node --no-bundle src/index.ts > /dev/null + + - name: Pack tarball + id: pack + run: | + file=$(npm pack --silent) + echo "file=$file" >> "$GITHUB_OUTPUT" + echo "Packed $file" + tar -tzf "$file" + + - name: Generate changelog + id: changelog + run: | + tag="$GITHUB_REF_NAME" + prev=$(git tag --sort=-creatordate | grep -v "^${tag}$" | head -n1 || true) + { + echo "## ${tag}" + echo + if [ -n "$prev" ]; then + echo "Changes since \`${prev}\`:" + echo + git log --no-merges --pretty=format:'- %s (%h)' "${prev}..${tag}" + echo + echo + echo "**Full diff:** https://github.com/${GITHUB_REPOSITORY}/compare/${prev}...${tag}" + else + echo "Initial release." + echo + git log --no-merges --pretty=format:'- %s (%h)' "${tag}" + fi + } > CHANGELOG_RELEASE.md + cat CHANGELOG_RELEASE.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + body_path: CHANGELOG_RELEASE.md + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + files: ${{ steps.pack.outputs.file }} + + - name: Publish to npm + if: ${{ env.NPM_TOKEN != '' }} + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --provenance --access public diff --git a/AGENTS.md b/AGENTS.md index ac18228..a8f7987 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,20 +66,22 @@ These are the invariants that, if broken, silently degrade K2.6 → K2.5 or prod 5. **`prompt_cache_key` only for `kimi-for-coding`.** Never attach it to unrelated models. The check is `input.model.id === MODEL_ID` in `chat.params`. 6. **Model id goes over the wire verbatim.** Don't strip the `kimi-` prefix — the backend expects exactly `kimi-for-coding`. -7. **Auth store is opencode's, not kimi-cli's.** We use `client.auth.get/set` against the `kimi-for-coding` 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. +7. **Auth store is opencode's, not kimi-cli's.** We use `client.auth.get/set` against 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. +8. **Provider id must not collide with any id in the [models.dev](https://models.dev) catalog.** models.dev publishes `kimi-for-coding` (static `KIMI_API_KEY` → `@ai-sdk/anthropic` → K2.5). If we registered under that same id, `opencode auth login kimi-for-coding` would surface two methods under one entry and users picking the API-key one would silently land on K2.5. We deliberately use `kimi-for-coding-oauth` instead; `MODEL_ID` on the wire stays `kimi-for-coding` (rule 6). ### Working on this repo - **Code style:** see `tsconfig.json` (strict, `noUncheckedIndexedAccess`, ES2022). Prefer small pure functions, avoid `try`/`catch` except where we genuinely convert one error shape to another. - **Comments:** match the existing density — only explain non-obvious upstream-parity reasoning. Do not narrate the obvious ("// refresh the token"); instead reference upstream files when the reasoning is "because kimi-cli does it that way". - **Dependencies:** runtime deps stay at **zero**. The only dev/peer dep is `@opencode-ai/plugin` for types. -- **Git commits:** small, logical, imperative subject ("Add oauth device flow"). When committing, pass `--trailer "Co-authored-by: Junie "` if you are an AI agent. +- **Git commits:** small, logical, imperative subject ("Add oauth device flow"). Do not add a `Co-authored-by` trailer. - **Upstream research:** the `research/` directory is a read-only git-ignored pair of shallow clones (opencode + kimi-cli) for grep. Never edit files there; re-clone if you suspect drift. When citing upstream in a comment, use the `research/…` path so the reference is resolvable. -- **Version bumps:** when kimi-cli bumps, (1) pull a fresh `research/kimi-cli`, (2) update `KIMI_CLI_VERSION` in `src/constants.ts`, (3) re-diff `_kimi_default_headers()` / `oauth.py` against `src/headers.ts` and `src/oauth.ts`, (4) smoke-test with `opencode auth login kimi-for-coding` and a one-turn chat, (5) tag release. +- **Version bumps:** when kimi-cli bumps, (1) pull a fresh `research/kimi-cli`, (2) update `KIMI_CLI_VERSION` in `src/constants.ts`, (3) re-diff `_kimi_default_headers()` / `oauth.py` against `src/headers.ts` and `src/oauth.ts`, (4) smoke-test with `opencode auth login kimi-for-coding-oauth` and a one-turn chat, (5) tag release. ### What not to do - ❌ Don't add heuristics that look at the model id outside of `chat.params`. The `auth.loader` fetch is already scoped to this provider; the only place that needs to match on `kimi-for-coding` is the params hook. +- ❌ Don't rename the provider id back to `kimi-for-coding` or to anything else listed in models.dev. See rule 8. - ❌ Don't add new header values that kimi-cli doesn't send. The fingerprint matters. - ❌ 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. @@ -97,8 +99,8 @@ Online (requires a real Kimi-for-coding account): 1. `cd ~/.opencode && bun add /path/to/this/repo` 2. Paste the provider block from `README.md` into your opencode config. -3. `opencode auth login kimi-for-coding` — confirm a token lands in opencode's `auth.json` with `type: "oauth"`, a JWT `access`, and `expires` ~15 min in the future. -4. Start opencode, select `kimi-for-coding/kimi-for-coding`, and ask the model to self-identify. It should claim to be K2.6 / `kimi-for-coding`. +3. `opencode auth login kimi-for-coding-oauth` — confirm a token lands in opencode's `auth.json` with `type: "oauth"`, a JWT `access`, and `expires` ~15 min in the future. +4. Start opencode, select `kimi-for-coding-oauth/kimi-for-coding`, and ask the model to self-identify. It should claim to be K2.6 / `kimi-for-coding`. 5. Confirm `reasoning_content` deltas render as thinking content (not assistant text). 6. In a second turn of the same session, confirm the response comes back faster (cache hit via `prompt_cache_key`). diff --git a/README.md b/README.md index 7825415..91282bf 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,17 @@ ## opencode-kimi-full -An [opencode](https://opencode.ai) plugin for the **Kimi For Coding** plan. It authenticates the same way the official [`kimi` CLI](https://github.com/MoonshotAI/kimi-cli) does and mirrors its wire shape, so opencode requests to Moonshot's `/coding` endpoint match `kimi` CLI's byte-for-byte. +An [opencode](https://opencode.ai) plugin for the **Kimi For Coding** plan. + +It authenticates the same way the official [kimi-cli](https://github.com/MoonshotAI/kimi-cli) does and mirrors its wire shape, so opencode's requests to Moonshot's `/coding` endpoint match kimi-cli byte-for-byte, including OAuth, fingerprint headers, session-scoped prompt caching, and paired thinking / reasoning-effort fields. Contributor and agent documentation lives in [`AGENTS.md`](./AGENTS.md). --- -### Why this plugin exists - -There are two ways to talk to Moonshot's Kimi For Coding plan today: the way `kimi` CLI does it, and the way opencode does it. They target different endpoints and use different authentication. This plugin brings the `kimi` CLI parity into opencode. - -**How `kimi` CLI does it.** OAuth device-code flow against `auth.moonshot.cn` with `scope: kimi-code`, producing a short-lived JWT. Requests go to `https://api.kimi.com/coding/v1` (OpenAI-compatible) with the JWT as the bearer token, seven `X-Msh-*` fingerprint headers, a stable `~/.kimi/device_id`, and per-request extras: `prompt_cache_key` (an opt-in, session-scoped cache key) and paired `thinking.type` + `reasoning_effort` (sent together, matching `kimi` CLI). The backend routes this token to K2.6. - -**How opencode does it.** `opencode auth login` selects a Kimi For Coding provider from the catalog and prompts for a `KIMI_API_KEY` (a static `sk-kimi-...` key). The catalog entry uses `@ai-sdk/anthropic` against `api.kimi.com/coding`, which is valid since the endpoint exposes both OpenAI-compatible and Anthropic-compatible routes for third-party agents. Authentication is the static key; no Kimi-specific request extras are sent (opencode's generic plumbing has no code path for `prompt_cache_key`, the paired `thinking` + `reasoning_effort` shape, or the `X-Msh-*` headers). The backend currently routes a static `sk-kimi-...` key to K2.5. - -**What this plugin gives you.** Everything `kimi` CLI does, inside opencode. OAuth device flow with `scope: kimi-code` (so you land on K2.6, if you have access to it), `prompt_cache_key` set to the opencode session id, paired `thinking` + `reasoning_effort`, the seven `X-Msh-*` headers and `kimi`-CLI-shaped UA, and a `~/.kimi/device_id` shared with a locally-installed `kimi` CLI. Tokens are stored in opencode's `auth.json` under a dedicated `kimi-for-coding` provider id, so the plugin and `kimi` CLI keep independent refresh-token chains and do not invalidate each other. Streaming, `reasoning_content` deltas, and tool-call schemas are handled upstream by `@ai-sdk/openai-compatible` and are not reimplemented. - -Two upstream changes would narrow the gap between the two paths. Even after both, the plugin remains a higher-fidelity alternative to opencode's built-in Kimi For Coding path: - -- If Moonshot starts routing `sk-kimi-...` keys to K2.6, opencode's built-in path reaches K2.6 too, but still without `prompt_cache_key` or the paired reasoning fields. Explicit session-scoped cache reuse via `prompt_cache_key` stays unavailable on that path (any automatic prefix caching Moonshot may do is orthogonal and would apply to both paths), and reasoning is controlled on the Anthropic route via `thinking.budget_tokens` (a token budget); the paired `reasoning_effort: low|medium|high` knob that `kimi` CLI exposes has no equivalent there. -- If opencode ships a native Kimi For Coding OAuth, the auth story converges, but the request-field gap stays until opencode's provider code emits these exact fields for `/coding`. `kimi` CLI is Moonshot's first-party client and targets the OpenAI-compatible route, so mirroring its wire shape is the lowest-risk way to stay aligned with upstream. Fingerprint parity with `kimi` CLI (same `X-Msh-Device-Id` and headers as `kimi` CLI, `kimi`-CLI-shaped UA) and independent refresh-token chains are unlikely to be replicated by a first-party integration. - ---- - ### Requirements - `opencode` ≥ 1.4.6 -- A Kimi account with an active **Kimi For Coding** subscription (the same plan that works with `kimi` CLI) +- A Kimi account with an active **Kimi For Coding** subscription (the same plan that works with kimi-cli) ### Install @@ -35,13 +20,16 @@ cd ~/.opencode bun add opencode-kimi-full ``` -From a local checkout: +
+From a local checkout ```sh cd ~/.opencode bun add /path/to/opencode-kimi-full ``` +
+ ### Configure Add the plugin and a provider entry to `opencode.json` (or `~/.config/opencode/opencode.json`): @@ -51,7 +39,7 @@ Add the plugin and a provider entry to `opencode.json` (or `~/.config/opencode/o "$schema": "https://opencode.ai/config.json", "plugin": ["opencode-kimi-full"], "provider": { - "kimi-for-coding": { + "kimi-for-coding-oauth": { "name": "Kimi K2.6 (for coding)", "npm": "@ai-sdk/openai-compatible", "options": { @@ -70,53 +58,91 @@ Add the plugin and a provider entry to `opencode.json` (or `~/.config/opencode/o } ``` -Two identifiers are load-bearing and must not be renamed: +Two identifiers are load-bearing: -- the **provider id** `kimi-for-coding`. The plugin's `auth` and `chat.params` hooks match on it. -- the **model id** `kimi-for-coding`. Sent to Moonshot verbatim; do not strip the `kimi-` prefix. +- **provider id** `kimi-for-coding-oauth` — the plugin's `auth` and `chat.params` hooks match on it. +- **model id** `kimi-for-coding` — sent to Moonshot verbatim; do not strip the `kimi-` prefix. + +> **Note.** The provider id is intentionally not `kimi-for-coding`. That id is already published by [models.dev](https://models.dev) and points at a static-API-key flow that routes to K2.5. Using a distinct id keeps the two paths from colliding under a single `opencode auth login` entry. ### Log in ```sh -opencode auth login kimi-for-coding +opencode auth login kimi-for-coding-oauth ``` The plugin returns a verification URL and user code. After browser approval it polls the device-auth endpoint and persists tokens through opencode's `auth.json`. Access tokens have a ~15 minute TTL and refresh automatically; refresh tokens last ~30 days. ### Use -Select `kimi-for-coding/kimi-for-coding` in opencode. +Select `kimi-for-coding-oauth/kimi-for-coding` in opencode. --- -### Request fields in detail +
+Why this plugin exists + +There are two ways to talk to Moonshot's Kimi For Coding plan today: the way kimi-cli does it, and the way opencode does it. They target different endpoints and use different authentication. This plugin brings kimi-cli parity into opencode. + +**How kimi-cli does it.** OAuth device-code flow against `auth.moonshot.cn` with `scope: kimi-code`, producing a short-lived JWT. Requests go to `https://api.kimi.com/coding/v1` (OpenAI-compatible) with the JWT as the bearer token, seven `X-Msh-*` fingerprint headers, a stable `~/.kimi/device_id`, and per-request extras: `prompt_cache_key` (an opt-in, session-scoped cache key) and paired `thinking.type` + `reasoning_effort`. The backend routes this token to K2.6. + +**How opencode does it out of the box.** `opencode auth login` selects a Kimi For Coding provider from the catalog and prompts for a `KIMI_API_KEY` (a static `sk-kimi-...` key). The catalog entry uses `@ai-sdk/anthropic` against `api.kimi.com/coding`, which is valid since the endpoint exposes both OpenAI-compatible and Anthropic-compatible routes. No Kimi-specific request extras are sent. The backend currently routes a static `sk-kimi-...` key to K2.5. + +**What this plugin gives you.** Everything kimi-cli does, inside opencode: + +- OAuth device flow with `scope: kimi-code`, so you land on K2.6 (if you have access). +- `prompt_cache_key` set to opencode's session id, for session-scoped cache reuse. +- Paired `thinking` + `reasoning_effort` fields. +- The seven `X-Msh-*` headers and a kimi-cli-shaped `User-Agent`. +- `~/.kimi/device_id` shared with a locally-installed kimi-cli. +- Tokens stored in opencode's `auth.json` under a dedicated provider id, so the plugin and kimi-cli keep independent refresh-token chains and do not invalidate each other. +- Streaming, `reasoning_content` deltas, and tool-call schemas are handled upstream by `@ai-sdk/openai-compatible` — not reimplemented here. + +
+ +
+Relationship to potential upstream fixes + +Two upstream changes would narrow the gap, but neither would make this plugin redundant: + +- **If Moonshot routes `sk-kimi-...` keys to K2.6**, opencode's built-in path reaches K2.6 too, but still without `prompt_cache_key` or the paired reasoning fields. Explicit session-scoped cache reuse stays unavailable on that path (any automatic prefix caching Moonshot may do is orthogonal and would apply to both paths), and reasoning is controlled on the Anthropic route via `thinking.budget_tokens` — the paired `reasoning_effort: low|medium|high` knob that kimi-cli exposes has no equivalent there. +- **If opencode ships a native Kimi For Coding OAuth**, the auth story converges, but the request-field gap stays until opencode's provider code emits these exact fields for `/coding`. kimi-cli is Moonshot's first-party client and targets the OpenAI-compatible route, so mirroring its wire shape is the lowest-risk way to stay aligned with upstream. Fingerprint parity (same `X-Msh-Device-Id` and headers, kimi-cli-shaped UA) and independent refresh-token chains are unlikely to be replicated by a first-party integration. + +
+ +
+Request fields in detail | Field | Wire shape | Purpose | |---|---|---| -| `prompt_cache_key` | top-level body, snake_case, set to opencode's `sessionID` | Opt-in, session-scoped cache key, mirroring `kimi` CLI. | -| `thinking` + `reasoning_effort` | `thinking: { type: "enabled" \| "disabled" }` with sibling `reasoning_effort: "low" \| "medium" \| "high"` | Sent together, matching `kimi` CLI. | -| Seven `X-Msh-*` headers + UA | `User-Agent`, `X-Msh-Platform`, `X-Msh-Version`, `X-Msh-Device-Name`, `X-Msh-Device-Model`, `X-Msh-Device-Id`, `X-Msh-OS-Version` | Matches `kimi` CLI's `_kimi_default_headers()` at the pinned `KIMI_CLI_VERSION`. | -| `~/.kimi/device_id` | UUID persisted on disk, embedded in `X-Msh-Device-Id` | Sends the same `X-Msh-Device-Id` as a locally-installed `kimi` CLI. | +| `prompt_cache_key` | top-level body, snake_case, set to opencode's `sessionID` | Opt-in, session-scoped cache key, mirroring kimi-cli. | +| `thinking` + `reasoning_effort` | `thinking: { type: "enabled" \| "disabled" }` with sibling `reasoning_effort: "low" \| "medium" \| "high"` | Sent together, matching kimi-cli. | +| Seven `X-Msh-*` headers + UA | `User-Agent`, `X-Msh-Platform`, `X-Msh-Version`, `X-Msh-Device-Name`, `X-Msh-Device-Model`, `X-Msh-Device-Id`, `X-Msh-OS-Version` | Matches kimi-cli's `_kimi_default_headers()` at the pinned `KIMI_CLI_VERSION`. | +| `~/.kimi/device_id` | UUID persisted on disk, embedded in `X-Msh-Device-Id` | Sends the same `X-Msh-Device-Id` as a locally-installed kimi-cli. | -Effort-to-field mapping, taken verbatim from `kimi` CLI: +Effort-to-field mapping, taken verbatim from kimi-cli: | user effort | `reasoning_effort` | `thinking.type` | |---|---|---| | `off` | *(omitted)* | `"disabled"` | | `low` / `medium` / `high` | same string | `"enabled"` | ---- +
-### Files the plugin touches +
+Files the plugin touches | Path | Purpose | |---|---| -| `~/.kimi/device_id` | Stable UUID used in `X-Msh-Device-Id`. Shared with `kimi` CLI. | +| `~/.kimi/device_id` | Stable UUID used in `X-Msh-Device-Id`. Shared with kimi-cli. | | `/auth.json` | Token storage, managed by opencode through `client.auth.*`. | -No other state is persisted. Credentials are never written to `~/.kimi/credentials/`; that path belongs to `kimi` CLI, and sharing it would cause refresh-token races between the two clients. +No other state is persisted. Credentials are never written to `~/.kimi/credentials/`; that path belongs to kimi-cli, and sharing it would cause refresh-token races between the two clients. -### Architecture +
+ +
+Architecture at a glance ``` ┌────────────── opencode core ─────────────┐ @@ -135,6 +161,8 @@ No other state is persisted. Credentials are never written to `~/.kimi/credentia A full description of the invariants that keep this working is in [`AGENTS.md`](./AGENTS.md), under "Architecture" and "Contracts to keep intact". +
+ ### License MIT. diff --git a/src/index.ts b/src/index.ts index 75102c8..d10bf8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,12 @@ import { pollDeviceToken, refreshToken, startDeviceAuth } from "./oauth.ts" // Provider id the user must use in their opencode config. Keep it in sync with // README.md → "Configure opencode". -export const PROVIDER_ID = "kimi-for-coding" +// +// Note: intentionally NOT "kimi-for-coding" — models.dev publishes an entry +// under that id (static KIMI_API_KEY → K2.5 via @ai-sdk/anthropic), and sharing +// the id would surface two auth methods under one `opencode auth login` entry +// and silently route API-key users to the wrong backend. See AGENTS.md rule 7. +export const PROVIDER_ID = "kimi-for-coding-oauth" type OAuthAuth = { type: "oauth" @@ -18,8 +23,8 @@ type OAuthAuth = { * Plugin entry point. * * Responsibilities, in order of execution: - * 1. `auth` — register device-flow OAuth login under the `kimi-for-coding` - * provider id. opencode persists the returned tokens in its + * 1. `auth` — register device-flow OAuth login under the + * `kimi-for-coding-oauth` provider id. opencode persists the returned tokens in its * own auth.json; we never touch disk for credentials. * 2. `loader` — runs every time opencode instantiates the provider. Returns * a custom `fetch` that (a) refreshes the access token when @@ -49,7 +54,7 @@ const plugin: Plugin = async ({ client }) => { const ensureFresh = async (force = false): Promise => { const current = await readAuth() - if (!current) throw new Error("kimi-for-coding: not logged in — run `opencode auth login kimi-for-coding`") + if (!current) throw new Error("kimi-for-coding-oauth: not logged in — run `opencode auth login kimi-for-coding-oauth`") if (!force && !isExpiring(current)) return current const tokens = await refreshToken(current.refresh) const next: OAuthAuth = {