pi-provider-kimi-code 0.3.0 → 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 (2) hide show
  1. package/index.ts +90 -28
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -40,6 +40,9 @@ const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
40
40
  const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
41
41
  const PROTOCOL =
42
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";
43
46
  const DEFAULT_BASE_URL =
44
47
  PROTOCOL === "openai-completions"
45
48
  ? "https://api.kimi.com/coding/v1"
@@ -112,11 +115,32 @@ function getDeviceModel(): string {
112
115
  return version && arch ? `macOS ${version} ${arch}` : `macOS ${arch}`;
113
116
  }
114
117
  if (platform === "win32") {
115
- 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
+ }
116
129
  return release && arch ? `Windows ${release} ${arch}` : `Windows ${arch}`;
117
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;
118
142
  const release = os.release();
119
- return release && arch ? `${platform} ${release} ${arch}` : `${platform} ${arch}`;
143
+ return release && arch ? `${system} ${release} ${arch}` : `${system} ${arch}`;
120
144
  }
121
145
 
122
146
  function asciiHeaderValue(value: string, fallback = "unknown"): string {
@@ -786,9 +810,12 @@ function streamSimpleKimi(
786
810
  const upload: Uploader | undefined = apiKey
787
811
  ? (mimeType, data) => uploadKimiFile(apiKey, mimeType, data)
788
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 } : {};
789
816
  return {
790
817
  ...options,
791
- apiKey,
818
+ ...apiKeyOverride,
792
819
  onPayload: async (payload, modelData) => {
793
820
  let nextPayload: unknown = payload;
794
821
 
@@ -819,8 +846,11 @@ function streamSimpleKimi(
819
846
 
820
847
  while (true) {
821
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.
822
852
  const upstream =
823
- model.api === "openai-completions"
853
+ PROTOCOL === "openai-completions"
824
854
  ? streamSimpleOpenAICompletions(
825
855
  model as Model<"openai-completions">,
826
856
  context,
@@ -828,22 +858,30 @@ function streamSimpleKimi(
828
858
  )
829
859
  : streamSimpleAnthropic(model as Model<"anthropic-messages">, context, patchedOptions);
830
860
 
831
- let pushedAny = false;
832
861
  let shouldRetry = false;
862
+ let prefixBuffer: AssistantMessageEvent[] = [];
833
863
 
834
864
  try {
835
865
  for await (const event of filterEmptyResponseStream(upstream)) {
836
- // If the upstream terminates with an error before we've emitted
837
- // anything downstream, speculatively try an OAuth refresh and
838
- // retry once. This handles the common case of a server-side token
839
- // invalidation before the local `expires` lapses, without having
840
- // to pattern-match every possible error-message format.
841
- //
842
- // Non-auth errors (overflow, network, rate-limit, etc.) still
843
- // trigger one wasted refresh, but the retried request fails the
844
- // same way and we forward it — pi-coding-agent's own recovery
845
- // paths (compaction, retry) take over from there.
846
- if (!pushedAny && attempt === 0 && event.type === "error") {
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") {
847
885
  console.error(
848
886
  `[kimi-coding] upstream error on first event, attempting refresh: ${event.error?.errorMessage?.slice(0, 200)}`,
849
887
  );
@@ -852,26 +890,50 @@ function streamSimpleKimi(
852
890
  console.error("[kimi-coding] retrying stream with refreshed token");
853
891
  currentKey = refreshed;
854
892
  shouldRetry = true;
855
- break;
893
+ break; // discard prefixBuffer — don't leak the stale start
856
894
  }
857
895
  console.error(
858
896
  "[kimi-coding] refresh did not yield a new token, forwarding original error",
859
897
  );
860
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 = [];
861
904
  filtered.push(event);
862
- pushedAny = true;
863
905
  }
906
+
907
+ // Stream ended normally: flush any remaining buffered starts.
908
+ for (const e of prefixBuffer) filtered.push(e);
864
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.
865
917
  console.error("[kimi-coding] stream error:", err);
866
- filtered.push({
867
- type: "error",
868
- reason: "error",
869
- error: {
870
- content: [],
871
- stopReason: "error",
872
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
873
- },
874
- } as AssistantMessageEvent & { type: "error" });
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
+ }
875
937
  }
876
938
 
877
939
  if (shouldRetry) {
@@ -893,7 +955,7 @@ export default function (pi: ExtensionAPI) {
893
955
  pi.registerProvider("kimi-coding", {
894
956
  baseUrl: getBaseUrl(),
895
957
  apiKey: "KIMI_API_KEY",
896
- api: PROTOCOL,
958
+ api: KIMI_API_TYPE,
897
959
  streamSimple: streamSimpleKimi,
898
960
 
899
961
  headers: getCommonHeaders(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.3.0",
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",