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
51 changes: 35 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ What you get:
- popup quota toasts after assistant responses
- manual `/quota`, `/quota_status`, and `/tokens_*` commands

**Quota providers**: Anthropic (Claude), GitHub Copilot, OpenAI (Plus/Pro), Cursor, Qwen Code, Alibaba Coding Plan, MiniMax Coding Plan, Chutes AI, Firmware AI, Google Antigravity, Z.ai Coding Plan, NanoGPT, and OpenCode Go.
**Quota providers**: Anthropic (Claude), GitHub Copilot, OpenAI (Plus/Pro), Cursor, Qwen Code, Alibaba Coding Plan, MiniMax Coding Plan, Kimi Code, Chutes AI, Firmware AI, Google Antigravity, Z.ai Coding Plan, NanoGPT, and OpenCode Go.

**Token reports**: All models and providers in [models.dev](https://models.dev), plus deterministic local pricing for Cursor Auto/Composer and Cursor model aliases that are not on models.dev.

Expand Down Expand Up @@ -155,24 +155,26 @@ Keep the `tui.json` or `tui.jsonc` entry above and disable toasts in `opencode.j

## Provider Setup At A Glance

| Provider | Auto setup | Authentication | Quota |
| --- | --- | --- | --- |
| **Anthropic (Claude)** | Needs [quick setup](#anthropic-quick-setup) | Local CLI auth | Local CLI report |
| **GitHub Copilot** | Usually | OpenCode auth or PAT | Remote API |
| **OpenAI** | Yes | OpenCode auth | Remote API |
| **Cursor** | Needs [quick setup](#cursor-quick-setup) | Companion auth | Local runtime accounting |
| **Qwen Code** | Needs [quick setup](#qwen-code-quick-setup) | Companion auth | Local estimation |
| **Alibaba Coding Plan** | Yes | OpenCode auth/global config/env | Local estimation |
| **Firmware AI** | Usually | OpenCode auth/global config/env | Remote API |
| **Chutes AI** | Usually | OpenCode auth/global config/env | Remote API |
| **Google Antigravity** | Needs [quick setup](#google-antigravity-quick-setup) | Companion auth | Remote API |
| **Z.ai** | Yes | OpenCode auth/global config/env | Remote API |
| **NanoGPT** | Usually | OpenCode auth/global config/env | Remote API |
| **MiniMax Coding Plan** | Yes | OpenCode auth/global config/env | Remote API |
| **OpenCode Go** | Needs [quick setup](#opencode-go-quick-setup) | Env/config auth | Dashboard scraping |
| Provider | Auto setup | Authentication | Quota |
| ----------------------- | ---------------------------------------------------- | ------------------------------- | ------------------------ |
| **Anthropic (Claude)** | Needs [quick setup](#anthropic-quick-setup) | Local CLI auth | Local CLI report |
| **GitHub Copilot** | Usually | OpenCode auth or PAT | Remote API |
| **OpenAI** | Yes | OpenCode auth | Remote API |
| **Cursor** | Needs [quick setup](#cursor-quick-setup) | Companion auth | Local runtime accounting |
| **Qwen Code** | Needs [quick setup](#qwen-code-quick-setup) | Companion auth | Local estimation |
| **Alibaba Coding Plan** | Yes | OpenCode auth/global config/env | Local estimation |
| **Firmware AI** | Usually | OpenCode auth/global config/env | Remote API |
| **Chutes AI** | Usually | OpenCode auth/global config/env | Remote API |
| **Google Antigravity** | Needs [quick setup](#google-antigravity-quick-setup) | Companion auth | Remote API |
| **Z.ai** | Yes | OpenCode auth/global config/env | Remote API |
| **NanoGPT** | Usually | OpenCode auth/global config/env | Remote API |
| **MiniMax Coding Plan** | Yes | OpenCode auth/global config/env | Remote API |
| **Kimi Code** | Yes | OpenCode auth/global config/env | Remote API |
| **OpenCode Go** | Needs [quick setup](#opencode-go-quick-setup) | Env/config auth | Dashboard scraping |


<a id="anthropic-quick-setup"></a>

<details>
<summary><strong>Quick setup: Anthropic (Claude)</strong></summary>

Expand Down Expand Up @@ -462,7 +464,24 @@ MiniMax Coding Plan uses trusted env vars or trusted user/global OpenCode config

</details>

<a id="kimi-code-notes"></a>

<details>
<summary><strong>Kimi Code</strong></summary>

Kimi Code uses trusted env vars or trusted user/global OpenCode config first, then native OpenCode auth from `auth.json["kimi-for-coding"]` or `auth.json["kimi-code"]`. No additional plugin is required.

- API key sources are `KIMI_API_KEY`, `KIMI_CODE_API_KEY`, trusted user/global `provider["kimi-for-coding"].options.apiKey` or `provider["kimi-code"].options.apiKey`, then `auth.json`.
- Legacy compatibility aliases `provider.kimi.options.apiKey` and `auth.json["kimi"]` are also accepted.
- Repo-local `opencode.json` / `opencode.jsonc` is ignored for Kimi secrets.
- Allowed env templates are limited to `{env:KIMI_API_KEY}` and `{env:KIMI_CODE_API_KEY}`.
- The plugin calls `https://api.kimi.com/coding/v1/usages` (Kimi Code) and falls back to `https://api.moonshot.ai/v1/usages` (Moonshot) when needed.
- `/quota_status` shows auth detection, API-key diagnostics, live quota state, and endpoint errors.

</details>

<a id="zai-notes"></a>

<details>
<summary><strong>Z.ai</strong></summary>

Expand Down
31 changes: 11 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/lib/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,5 +654,5 @@ export function formatGoogleQuota(result: GoogleResult): string | null {
return null;
}

return result.models.map((m) => `${m.displayName} ${m.percentRemaining}%`).join(" \u2022 ");
return result.models.map((m) => `${m.displayName} ${m.percentRemaining}%`).join(" ");
}
171 changes: 171 additions & 0 deletions src/lib/kimi-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {
extractProviderOptionsApiKey,
getApiKeyCheckedPaths,
getFirstAuthEntryValue,
getGlobalOpencodeConfigCandidatePaths,
resolveApiKeyFromEnvAndConfig,
} from "./api-key-resolver.js";
import { sanitizeDisplayText } from "./display-sanitize.js";
import { getAuthPaths, readAuthFileCached } from "./opencode-auth.js";

import type { AuthData, KimiAuthData } from "./types.js";

export const DEFAULT_KIMI_AUTH_CACHE_MAX_AGE_MS = 5_000;
const KIMI_AUTH_KEYS = ["kimi-for-coding", "kimi-code", "kimi"] as const;
const KIMI_PROVIDER_KEYS = ["kimi-for-coding", "kimi-code", "kimi"] as const;
const ALLOWED_KIMI_ENV_VARS = ["KIMI_API_KEY", "KIMI_CODE_API_KEY"] as const;

export type KimiKeySource =
| "env:KIMI_API_KEY"
| "env:KIMI_CODE_API_KEY"
| "opencode.json"
| "opencode.jsonc"
| "auth.json";

export type ResolvedKimiAuth =
| { state: "none" }
| { state: "configured"; apiKey: string }
| { state: "invalid"; error: string };

export type KimiAuthDiagnostics =
| {
state: "none";
source: null;
checkedPaths: string[];
authPaths: string[];
}
| {
state: "configured";
source: KimiKeySource;
checkedPaths: string[];
authPaths: string[];
}
| {
state: "invalid";
source: "auth.json";
checkedPaths: string[];
authPaths: string[];
error: string;
};

export { getGlobalOpencodeConfigCandidatePaths as getOpencodeConfigCandidatePaths } from "./api-key-resolver.js";

function getKimiAuthEntry(auth: AuthData | null | undefined): unknown {
return getFirstAuthEntryValue(auth, KIMI_AUTH_KEYS);
}

function isKimiAuthData(value: unknown): value is KimiAuthData {
return value !== null && typeof value === "object";
}

function sanitizeKimiAuthValue(value: string): string {
const sanitized = sanitizeDisplayText(value).replace(/\s+/g, " ").trim();
return (sanitized || "unknown").slice(0, 120);
}

export function resolveKimiAuth(auth: AuthData | null | undefined): ResolvedKimiAuth {
const kimi = getKimiAuthEntry(auth);
if (kimi === null || kimi === undefined) {
return { state: "none" };
}

if (!isKimiAuthData(kimi)) {
return { state: "invalid", error: "Kimi auth entry has invalid shape" };
}

if (typeof kimi.type !== "string") {
return { state: "invalid", error: "Kimi auth entry present but type is missing or invalid" };
}

if (kimi.type !== "api") {
return {
state: "invalid",
error: `Unsupported Kimi auth type: "${sanitizeKimiAuthValue(kimi.type)}"`,
};
}

const key = typeof kimi.key === "string" ? kimi.key.trim() : "";
if (!key) {
return { state: "invalid", error: "Kimi auth entry present but key is empty" };
}

return { state: "configured", apiKey: key };
}

async function resolveKimiAuthWithSource(params?: {
maxAgeMs?: number;
}): Promise<{ auth: ResolvedKimiAuth; source: KimiKeySource | null }> {
const resolvedFromEnvOrConfig = await resolveApiKeyFromEnvAndConfig<KimiKeySource>({
envVars: [
{ name: "KIMI_API_KEY", source: "env:KIMI_API_KEY" },
{ name: "KIMI_CODE_API_KEY", source: "env:KIMI_CODE_API_KEY" },
],
extractFromConfig: (config) =>
extractProviderOptionsApiKey(config, {
providerKeys: KIMI_PROVIDER_KEYS,
allowedEnvVars: ALLOWED_KIMI_ENV_VARS,
}),
configJsonSource: "opencode.json",
configJsoncSource: "opencode.jsonc",
getConfigCandidates: getGlobalOpencodeConfigCandidatePaths,
});

if (resolvedFromEnvOrConfig) {
return {
auth: { state: "configured", apiKey: resolvedFromEnvOrConfig.key },
source: resolvedFromEnvOrConfig.source,
};
}

const maxAgeMs = Math.max(0, params?.maxAgeMs ?? DEFAULT_KIMI_AUTH_CACHE_MAX_AGE_MS);
const authData = await readAuthFileCached({ maxAgeMs });
const auth = resolveKimiAuth(authData);

return {
auth,
source: auth.state === "none" ? null : "auth.json",
};
}

export async function resolveKimiAuthCached(params?: {
maxAgeMs?: number;
}): Promise<ResolvedKimiAuth> {
return (await resolveKimiAuthWithSource(params)).auth;
}

export async function getKimiAuthDiagnostics(params?: {
maxAgeMs?: number;
}): Promise<KimiAuthDiagnostics> {
const { auth, source } = await resolveKimiAuthWithSource(params);
const checkedPaths = getApiKeyCheckedPaths({
envVarNames: [...ALLOWED_KIMI_ENV_VARS],
getConfigCandidates: getGlobalOpencodeConfigCandidatePaths,
});
const authPaths = getAuthPaths();

if (auth.state === "none") {
return {
state: "none",
source: null,
checkedPaths,
authPaths,
};
}

if (auth.state === "invalid") {
return {
state: "invalid",
source: "auth.json",
checkedPaths,
authPaths,
error: auth.error,
};
}

return {
state: "configured",
source: source ?? "auth.json",
checkedPaths,
authPaths,
};
}
Loading