pi-provider-kimi-code 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -11
  2. package/index.ts +208 -89
  3. package/package.json +1 -3
package/README.md CHANGED
@@ -4,13 +4,13 @@
4
4
 
5
5
  **Reuse your [Kimi Code Plan](https://www.kimi.com/code/docs/en/) inside [pi-coding-agent](https://pi.dev/)** — no separate API credits, no second billing dashboard. Every request draws from your Kimi Code membership quota (the 5-hour token bucket) instead of billing per-token on the Moonshot Open Platform.
6
6
 
7
- It's **"Claude Code for Kimi"** — log in with your Kimi account, with `KIMI_API_KEY` still supported as a fallback for CI. You get Kimi for Coding, **Kimi K2.6 Code Preview**, Kimi K2.5, Kimi K2 Thinking Turbo, and the umbrella `kimi-code` model with a 262K-token context window, automatic prompt caching, automatic large-image and video upload, and compatibility with both Anthropic-style and OpenAI-style clients.
7
+ It's **"Claude Code for Kimi"** — log in with your Kimi account, with `KIMI_API_KEY` still supported as a fallback for CI. You get `kimi-for-coding` (the Kimi-k2.6 latest alias, backed by the same Kimi Code Plan that covers K2.6 and K2.5) with a 262K-token context window, automatic prompt caching, automatic large-image and video upload, and compatibility with both Anthropic-style and OpenAI-style clients.
8
8
 
9
9
  ## Who is this for?
10
10
 
11
11
  - You already pay for a **[Kimi Code Plan](https://www.kimi.com/code/docs/en/)** and want to use it inside `pi-coding-agent` instead of the official `kimi-cli` — see [MoonshotAI/kimi-cli#757](https://github.com/MoonshotAI/kimi-cli/issues/757) for the canonical feature request this extension answers.
12
12
  - You want **"Claude Code for Kimi"**: log in with your Kimi account instead of buying separate API credits. (`KIMI_API_KEY` is also supported as a fallback for CI.)
13
- - You're in the [pi / pi-mono](https://github.com/badlogic/pi-mono) ecosystem and want **Kimi K2.6 Code Preview**, **Kimi K2.5**, or **Kimi K2 Thinking Turbo** as a pi provider.
13
+ - You're in the [pi / pi-mono](https://github.com/badlogic/pi-mono) ecosystem and want **Kimi K2.6** (and the K2.5 coverage that comes with the Code Plan) as a pi provider.
14
14
 
15
15
  Pay-per-token via `KIMI_API_KEY` also works if you just want to try Kimi in CI or without a subscription.
16
16
 
@@ -72,18 +72,16 @@ KIMI_API_KEY=sk-... pi
72
72
 
73
73
  ## Models
74
74
 
75
- | ID | Name | Reasoning | Input | Context | Max Output |
76
- | -------------------------- | ---------------------- | --------- | ------------------ | ------- | ---------- |
77
- | `kimi-for-coding` | Kimi for Coding | yes | text, image, video | 262 144 | 32 000 |
78
- | `kimi-k2.6-code-preview` | Kimi K2.6 Code Preview | yes | text, image, video | 262 144 | 32 000 |
79
- | `kimi-code` | Kimi Code (kimi-k2.5) | yes | text, image | 262 144 | 32 000 |
80
- | `kimi-k2.5` | Kimi K2.5 | yes | text, image | 262 144 | 32 000 |
81
- | `kimi-k2-thinking-turbo` | Kimi K2 Thinking Turbo | yes | text | 262 144 | 32 000 |
75
+ | ID | Name | Reasoning | Input | Context | Max Output |
76
+ | ----------------- | --------------- | --------- | ------------------ | ------- | ---------- |
77
+ | `kimi-for-coding` | Kimi for Coding | yes | text, image, video | 262 144 | 32 000 |
82
78
 
83
- Select a model inside `pi`:
79
+ `kimi-for-coding` is the latest alias on the Kimi Code Plan — today it points at Kimi-k2.6. The Plan itself still covers K2.5; the upstream routes older model IDs to the current alias, but this provider only publishes the canonical `kimi-for-coding` entry.
80
+
81
+ Select it inside `pi`:
84
82
 
85
83
  ```
86
- /model kimi-coding/kimi-code
84
+ /model kimi-coding/kimi-for-coding
87
85
  ```
88
86
 
89
87
  ## Environment variables
package/index.ts CHANGED
@@ -29,7 +29,8 @@ import {
29
29
  streamSimpleOpenAICompletions,
30
30
  AssistantMessageEventStream,
31
31
  } from "@mariozechner/pi-ai";
32
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
32
+ import type { ExtensionAPI, OAuthCredential } from "@mariozechner/pi-coding-agent";
33
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
33
34
 
34
35
  // =============================================================================
35
36
  // Constants
@@ -39,6 +40,9 @@ const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
39
40
  const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
40
41
  const PROTOCOL =
41
42
  process.env.KIMI_CODE_PROTOCOL === "openai" ? "openai-completions" : "anthropic-messages";
43
+ // Use a custom api identifier so this provider never conflicts with the
44
+ // built-in "anthropic-messages" or "openai-completions" stream handlers.
45
+ const KIMI_API_TYPE = PROTOCOL === "openai-completions" ? "kimi-openai-completions" : "kimi-anthropic-messages";
42
46
  const DEFAULT_BASE_URL =
43
47
  PROTOCOL === "openai-completions"
44
48
  ? "https://api.kimi.com/coding/v1"
@@ -111,11 +115,32 @@ function getDeviceModel(): string {
111
115
  return version && arch ? `macOS ${version} ${arch}` : `macOS ${arch}`;
112
116
  }
113
117
  if (platform === "win32") {
114
- const release = os.release();
118
+ // Only show the major release (e.g. "Windows 10", "Windows 11") to match
119
+ // the upstream kimi-cli behavior in `def _device_model() -> str:`.
120
+ // See: https://github.com/MoonshotAI/kimi-cli/blob/main/src/kimi_cli/auth/oauth.py
121
+ const parts = os.release().split(".");
122
+ let release = parts[0];
123
+ if (release === "10" && parts.length >= 3) {
124
+ const build = parseInt(parts[2], 10);
125
+ if (!isNaN(build) && build >= 22000) {
126
+ release = "11";
127
+ }
128
+ }
115
129
  return release && arch ? `Windows ${release} ${arch}` : `Windows ${arch}`;
116
130
  }
131
+ // Normalize platform name to match Python `platform.system()` casing used by
132
+ // upstream kimi-cli (e.g. "Linux", "FreeBSD", "SunOS"), since Node's
133
+ // `process.platform` is always lower-case.
134
+ const SYSTEM_NAME: Record<string, string> = {
135
+ aix: "AIX",
136
+ freebsd: "FreeBSD",
137
+ linux: "Linux",
138
+ openbsd: "OpenBSD",
139
+ sunos: "SunOS",
140
+ };
141
+ const system = SYSTEM_NAME[platform] ?? platform;
117
142
  const release = os.release();
118
- return release && arch ? `${platform} ${release} ${arch}` : `${platform} ${arch}`;
143
+ return release && arch ? `${system} ${release} ${arch}` : `${system} ${arch}`;
119
144
  }
120
145
 
121
146
  function asciiHeaderValue(value: string, fallback = "unknown"): string {
@@ -709,6 +734,54 @@ async function* filterEmptyResponseStream(
709
734
  }
710
735
  }
711
736
 
737
+ // =============================================================================
738
+ // Auth refresh: recover from server-side token invalidation
739
+ // =============================================================================
740
+ // pi-coding-agent only refreshes an OAuth token when the locally cached
741
+ // `expires` is in the past. If the server rotates/revokes the access token
742
+ // before that (common with short-lived session tokens), every request keeps
743
+ // returning 401. We detect that situation by inspecting the first event of
744
+ // the upstream stream, force a refresh through AuthStorage (which persists
745
+ // the new credentials under a file lock), and retry once.
746
+
747
+ const PROVIDER_ID = "kimi-coding";
748
+
749
+ async function refreshKimiAuthToken(currentKey: string): Promise<string | null> {
750
+ try {
751
+ const storage = AuthStorage.create();
752
+ const cred = storage.get(PROVIDER_ID);
753
+ if (!cred || cred.type !== "oauth") {
754
+ console.error(
755
+ `[kimi-coding] auth refresh skipped: no OAuth credentials for ${PROVIDER_ID} on disk`,
756
+ );
757
+ return null;
758
+ }
759
+
760
+ // If disk already has a different valid token (e.g., another process or a
761
+ // previous retry refreshed it while the caller's in-memory cache went
762
+ // stale), reuse it without hitting the OAuth endpoint.
763
+ if (cred.access !== currentKey && Date.now() < cred.expires) {
764
+ console.error("[kimi-coding] auth refresh: reusing newer on-disk token");
765
+ return cred.access;
766
+ }
767
+
768
+ console.error("[kimi-coding] auth refresh: requesting new access token");
769
+ const refreshed = await refreshAccessToken(cred.refresh);
770
+ const newCred: OAuthCredential = {
771
+ type: "oauth",
772
+ access: refreshed.access_token,
773
+ refresh: refreshed.refresh_token,
774
+ expires: Date.now() + refreshed.expires_in * 1000,
775
+ };
776
+ storage.set(PROVIDER_ID, newCred);
777
+ console.error("[kimi-coding] auth refresh: new token persisted");
778
+ return newCred.access;
779
+ } catch (err) {
780
+ console.error("[kimi-coding] auth refresh failed:", err);
781
+ return null;
782
+ }
783
+ }
784
+
712
785
  // =============================================================================
713
786
  // Stream wrapper: orchestrates payload mutation + event filter
714
787
  // =============================================================================
@@ -723,7 +796,7 @@ function streamSimpleKimi(
723
796
  options?: SimpleStreamOptions,
724
797
  ): AssistantMessageEventStream {
725
798
  const filtered = new AssistantMessageEventStream();
726
- const apiKey = options?.apiKey || process.env.KIMI_API_KEY || "";
799
+ const initialKey = options?.apiKey || process.env.KIMI_API_KEY || "";
727
800
 
728
801
  const cacheKeyOverride = (
729
802
  options as (SimpleStreamOptions & { prompt_cache_key?: unknown }) | undefined
@@ -731,61 +804,143 @@ function streamSimpleKimi(
731
804
  const cacheKey = (typeof cacheKeyOverride === "string" && cacheKeyOverride) || options?.sessionId;
732
805
  const cacheRetention = resolveCacheRetention(options?.cacheRetention);
733
806
  const envOverrides = readEnvOverrides();
734
- const upload: Uploader | undefined = apiKey
735
- ? (mimeType, data) => uploadKimiFile(apiKey, mimeType, data)
736
- : undefined;
737
-
738
807
  const originalOnPayload = options?.onPayload;
739
- const patchedOptions: SimpleStreamOptions = {
740
- ...options,
741
- onPayload: async (payload, modelData) => {
742
- let nextPayload: unknown = payload;
743
-
744
- if (isRecord(nextPayload)) {
745
- await applyKimiPayloadMutations(nextPayload, {
746
- api: model.api,
747
- upload,
748
- cacheKey,
749
- cacheRetention,
750
- reasoning: options?.reasoning,
751
- envOverrides,
752
- });
753
- }
754
808
 
755
- if (originalOnPayload) {
756
- const res = await originalOnPayload(nextPayload, modelData);
757
- if (res !== undefined) nextPayload = res;
758
- }
809
+ const buildPatchedOptions = (apiKey: string): SimpleStreamOptions => {
810
+ const upload: Uploader | undefined = apiKey
811
+ ? (mimeType, data) => uploadKimiFile(apiKey, mimeType, data)
812
+ : undefined;
813
+ // Only forward apiKey if we actually have one — never override the
814
+ // caller's credential (e.g. Claude Code OAuth token) with an empty string.
815
+ const apiKeyOverride = apiKey ? { apiKey } : {};
816
+ return {
817
+ ...options,
818
+ ...apiKeyOverride,
819
+ onPayload: async (payload, modelData) => {
820
+ let nextPayload: unknown = payload;
821
+
822
+ if (isRecord(nextPayload)) {
823
+ await applyKimiPayloadMutations(nextPayload, {
824
+ api: model.api,
825
+ upload,
826
+ cacheKey,
827
+ cacheRetention,
828
+ reasoning: options?.reasoning,
829
+ envOverrides,
830
+ });
831
+ }
759
832
 
760
- return nextPayload;
761
- },
833
+ if (originalOnPayload) {
834
+ const res = await originalOnPayload(nextPayload, modelData);
835
+ if (res !== undefined) nextPayload = res;
836
+ }
837
+
838
+ return nextPayload;
839
+ },
840
+ };
762
841
  };
763
842
 
764
843
  void (async () => {
765
- const upstream =
766
- model.api === "openai-completions"
767
- ? streamSimpleOpenAICompletions(
768
- model as Model<"openai-completions">,
769
- context,
770
- patchedOptions,
771
- )
772
- : streamSimpleAnthropic(model as Model<"anthropic-messages">, context, patchedOptions);
773
-
774
- try {
775
- for await (const event of filterEmptyResponseStream(upstream)) {
776
- filtered.push(event);
844
+ let attempt = 0;
845
+ let currentKey = initialKey;
846
+
847
+ while (true) {
848
+ const patchedOptions = buildPatchedOptions(currentKey);
849
+ // Route by the module-level PROTOCOL, not model.api, since we register
850
+ // with a custom api type (kimi-openai-completions / kimi-anthropic-messages)
851
+ // to avoid overriding the built-in Anthropic/OpenAI stream handlers.
852
+ const upstream =
853
+ PROTOCOL === "openai-completions"
854
+ ? streamSimpleOpenAICompletions(
855
+ model as Model<"openai-completions">,
856
+ context,
857
+ patchedOptions,
858
+ )
859
+ : streamSimpleAnthropic(model as Model<"anthropic-messages">, context, patchedOptions);
860
+
861
+ let shouldRetry = false;
862
+ let prefixBuffer: AssistantMessageEvent[] = [];
863
+
864
+ try {
865
+ for await (const event of filterEmptyResponseStream(upstream)) {
866
+ // streamAnthropic emits a synthetic "start" event synchronously,
867
+ // before the for-await loop begins iterating and therefore before
868
+ // the HTTP request is actually made. If the request 401s, the loop
869
+ // throws and the catch block emits "error". Without buffering, the
870
+ // "start" event (which carries an empty AssistantMessage) leaks into
871
+ // the session history and the TUI, leaving a phantom empty assistant
872
+ // bubble. We buffer "start" events and only flush them once we see
873
+ // a non-error event that proves the stream is alive.
874
+ if (event.type === "start") {
875
+ prefixBuffer.push(event);
876
+ continue;
877
+ }
878
+
879
+ // Speculative OAuth refresh on the first error. We retry once so
880
+ // that short-lived Kimi tokens invalidated by the server before the
881
+ // local expires timestamp lapses don't surface as raw 401s to the
882
+ // user. Non-auth errors still trigger one wasted refresh, but the
883
+ // retried request fails the same way and we forward it.
884
+ if (attempt === 0 && event.type === "error") {
885
+ console.error(
886
+ `[kimi-coding] upstream error on first event, attempting refresh: ${event.error?.errorMessage?.slice(0, 200)}`,
887
+ );
888
+ const refreshed = await refreshKimiAuthToken(currentKey);
889
+ if (refreshed && refreshed !== currentKey) {
890
+ console.error("[kimi-coding] retrying stream with refreshed token");
891
+ currentKey = refreshed;
892
+ shouldRetry = true;
893
+ break; // discard prefixBuffer — don't leak the stale start
894
+ }
895
+ console.error(
896
+ "[kimi-coding] refresh did not yield a new token, forwarding original error",
897
+ );
898
+ }
899
+
900
+ // First non-start, non-retry event: flush buffered prefix, then
901
+ // stream normally.
902
+ for (const e of prefixBuffer) filtered.push(e);
903
+ prefixBuffer = [];
904
+ filtered.push(event);
905
+ }
906
+
907
+ // Stream ended normally: flush any remaining buffered starts.
908
+ for (const e of prefixBuffer) filtered.push(e);
909
+ } catch (err) {
910
+ // Upstream threw rather than emitting a stream `error` event. This can
911
+ // be the same stale-token 401 surfaced as an exception (depending on
912
+ // the SDK path / network layer), so mirror the in-stream refresh
913
+ // branch: on attempt 0, try one OAuth refresh + retry. Either way,
914
+ // discard `prefixBuffer` — we never confirmed the stream actually
915
+ // started, and flushing the buffered `start` would resurrect the
916
+ // phantom empty assistant message this PR set out to fix.
917
+ console.error("[kimi-coding] stream error:", err);
918
+ if (attempt === 0) {
919
+ const refreshed = await refreshKimiAuthToken(currentKey);
920
+ if (refreshed && refreshed !== currentKey) {
921
+ console.error("[kimi-coding] retrying stream after thrown error with refreshed token");
922
+ currentKey = refreshed;
923
+ shouldRetry = true;
924
+ }
925
+ }
926
+ if (!shouldRetry) {
927
+ filtered.push({
928
+ type: "error",
929
+ reason: "error",
930
+ error: {
931
+ content: [],
932
+ stopReason: "error",
933
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
934
+ },
935
+ } as AssistantMessageEvent & { type: "error" });
936
+ }
777
937
  }
778
- } catch (err) {
779
- console.error("[kimi-coding] stream error:", err);
780
- filtered.push({
781
- type: "error",
782
- reason: "error",
783
- error: {
784
- content: [],
785
- stopReason: "error",
786
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
787
- },
788
- } as AssistantMessageEvent & { type: "error" });
938
+
939
+ if (shouldRetry) {
940
+ attempt++;
941
+ continue;
942
+ }
943
+ break;
789
944
  }
790
945
  })();
791
946
 
@@ -800,7 +955,7 @@ export default function (pi: ExtensionAPI) {
800
955
  pi.registerProvider("kimi-coding", {
801
956
  baseUrl: getBaseUrl(),
802
957
  apiKey: "KIMI_API_KEY",
803
- api: PROTOCOL,
958
+ api: KIMI_API_TYPE,
804
959
  streamSimple: streamSimpleKimi,
805
960
 
806
961
  headers: getCommonHeaders(),
@@ -815,42 +970,6 @@ export default function (pi: ExtensionAPI) {
815
970
  contextWindow: 262144,
816
971
  maxTokens: 32000,
817
972
  },
818
- {
819
- id: "kimi-k2.6-code-preview",
820
- name: "Kimi K2.6 Code Preview",
821
- reasoning: true,
822
- input: ["text", "image", "video"],
823
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
824
- contextWindow: 262144,
825
- maxTokens: 32000,
826
- },
827
- {
828
- id: "kimi-code",
829
- name: "Kimi Code (kimi-k2.5)",
830
- reasoning: true,
831
- input: ["text", "image"],
832
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
833
- contextWindow: 262144,
834
- maxTokens: 32000,
835
- },
836
- {
837
- id: "kimi-k2.5",
838
- name: "Kimi K2.5",
839
- reasoning: true,
840
- input: ["text", "image"],
841
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
842
- contextWindow: 262144,
843
- maxTokens: 32000,
844
- },
845
- {
846
- id: "kimi-k2-thinking-turbo",
847
- name: "Kimi K2 Thinking Turbo",
848
- reasoning: true,
849
- input: ["text"],
850
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
851
- contextWindow: 262144,
852
- maxTokens: 32000,
853
- },
854
973
  ],
855
974
 
856
975
  oauth: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Pi extension for Kimi Code — reuse your Kimi Code Plan in pi-coding-agent (K2.6, K2.5)",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -14,13 +14,11 @@
14
14
  "llm",
15
15
  "llm-provider",
16
16
  "kimi",
17
- "kimi-code",
18
17
  "kimi-for-coding",
19
18
  "kimi-coder",
20
19
  "kimi-k2",
21
20
  "kimi-k2.6",
22
21
  "kimi-k2.5",
23
- "kimi-k2-thinking",
24
22
  "moonshot",
25
23
  "moonshotai",
26
24
  "oauth",