pi-provider-kimi-code 0.5.0 → 0.6.0

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.
package/src/config.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
+ export const KIMI_TOOL_NAMES = ["moonshot_search", "moonshot_fetch", "kimi_datasource"] as const;
5
+
6
+ export type KimiToolName = (typeof KIMI_TOOL_NAMES)[number];
7
+
8
+ export type KimiConfigSource = "project" | "home" | "default";
9
+
4
10
  export interface KimiCodeConfig {
5
- tools: {
6
- moonshot_search: { enabled: boolean; default_collapsed: boolean };
7
- moonshot_fetch: { enabled: boolean; default_collapsed: boolean };
8
- };
11
+ tools: Record<KimiToolName, { enabled: boolean; default_collapsed: boolean }>;
12
+ }
13
+
14
+ export interface KimiCodeConfigSources {
15
+ tools: Record<KimiToolName, { enabled: KimiConfigSource; default_collapsed: KimiConfigSource }>;
9
16
  }
10
17
 
11
18
  export interface LoadKimiCodeConfigOptions {
@@ -13,11 +20,16 @@ export interface LoadKimiCodeConfigOptions {
13
20
  home: string;
14
21
  }
15
22
 
23
+ function makeDefaultTools(): KimiCodeConfig["tools"] {
24
+ const tools = {} as KimiCodeConfig["tools"];
25
+ for (const name of KIMI_TOOL_NAMES) {
26
+ tools[name] = { enabled: false, default_collapsed: true };
27
+ }
28
+ return tools;
29
+ }
30
+
16
31
  export const DEFAULT_KIMI_CODE_CONFIG: KimiCodeConfig = {
17
- tools: {
18
- moonshot_search: { enabled: false, default_collapsed: true },
19
- moonshot_fetch: { enabled: false, default_collapsed: true },
20
- },
32
+ tools: makeDefaultTools(),
21
33
  };
22
34
 
23
35
  export function getGlobalKimiCodeConfigPath(home: string): string {
@@ -85,23 +97,61 @@ function readDefaultCollapsed(config: Record<string, unknown>, toolName: string)
85
97
  return tool.default_collapsed !== false;
86
98
  }
87
99
 
100
+ function hasToolField(
101
+ config: Record<string, unknown>,
102
+ toolName: KimiToolName,
103
+ field: "enabled" | "default_collapsed",
104
+ ): boolean {
105
+ const tools = config.tools;
106
+ if (!isRecord(tools)) return false;
107
+ const tool = tools[toolName];
108
+ if (!isRecord(tool)) return false;
109
+ return Object.hasOwn(tool, field);
110
+ }
111
+
112
+ function readToolConfig(
113
+ config: Record<string, unknown>,
114
+ toolName: KimiToolName,
115
+ ): { enabled: boolean; default_collapsed: boolean } {
116
+ return {
117
+ enabled: readEnabled(config, toolName),
118
+ default_collapsed: readDefaultCollapsed(config, toolName),
119
+ };
120
+ }
121
+
88
122
  export function loadKimiCodeConfig(options: LoadKimiCodeConfigOptions): KimiCodeConfig {
89
123
  const globalConfig = readConfigFile(getGlobalKimiCodeConfigPath(options.home));
90
124
  const projectConfig = readConfigFile(getProjectKimiCodeConfigPath(options.cwd));
91
125
  const merged = mergeConfig(globalConfig, projectConfig);
92
126
 
93
- return {
94
- tools: {
95
- moonshot_search: {
96
- enabled: readEnabled(merged, "moonshot_search"),
97
- default_collapsed: readDefaultCollapsed(merged, "moonshot_search"),
98
- },
99
- moonshot_fetch: {
100
- enabled: readEnabled(merged, "moonshot_fetch"),
101
- default_collapsed: readDefaultCollapsed(merged, "moonshot_fetch"),
102
- },
103
- },
104
- };
127
+ const tools = {} as KimiCodeConfig["tools"];
128
+ for (const name of KIMI_TOOL_NAMES) {
129
+ tools[name] = readToolConfig(merged, name);
130
+ }
131
+ return { tools };
132
+ }
133
+
134
+ export function loadKimiCodeConfigSources(
135
+ options: LoadKimiCodeConfigOptions,
136
+ ): KimiCodeConfigSources {
137
+ const homeConfig = readConfigFileQuiet(getGlobalKimiCodeConfigPath(options.home));
138
+ const projectConfig = readConfigFileQuiet(getProjectKimiCodeConfigPath(options.cwd));
139
+ const tools = {} as KimiCodeConfigSources["tools"];
140
+ for (const name of KIMI_TOOL_NAMES) {
141
+ tools[name] = {
142
+ enabled: hasToolField(projectConfig, name, "enabled")
143
+ ? "project"
144
+ : hasToolField(homeConfig, name, "enabled")
145
+ ? "home"
146
+ : "default",
147
+ default_collapsed: hasToolField(projectConfig, name, "default_collapsed")
148
+ ? "project"
149
+ : hasToolField(homeConfig, name, "default_collapsed")
150
+ ? "home"
151
+ : "default",
152
+ };
153
+ }
154
+ return { tools };
105
155
  }
106
156
 
107
157
  export function loadProjectKimiCodeConfig(cwd: string): KimiCodeConfig {
@@ -114,18 +164,11 @@ export function loadHomeKimiCodeConfig(home: string): KimiCodeConfig {
114
164
 
115
165
  function loadKimiCodeConfigFile(path: string): KimiCodeConfig {
116
166
  const projectConfig = readConfigFileQuiet(path);
117
- return {
118
- tools: {
119
- moonshot_search: {
120
- enabled: readEnabled(projectConfig, "moonshot_search"),
121
- default_collapsed: readDefaultCollapsed(projectConfig, "moonshot_search"),
122
- },
123
- moonshot_fetch: {
124
- enabled: readEnabled(projectConfig, "moonshot_fetch"),
125
- default_collapsed: readDefaultCollapsed(projectConfig, "moonshot_fetch"),
126
- },
127
- },
128
- };
167
+ const tools = {} as KimiCodeConfig["tools"];
168
+ for (const name of KIMI_TOOL_NAMES) {
169
+ tools[name] = readToolConfig(projectConfig, name);
170
+ }
171
+ return { tools };
129
172
  }
130
173
 
131
174
  export function saveProjectKimiCodeConfig(cwd: string, config: KimiCodeConfig): void {
@@ -138,13 +181,15 @@ export function saveHomeKimiCodeConfig(home: string, config: KimiCodeConfig): vo
138
181
 
139
182
  function saveKimiCodeConfigFile(path: string, config: KimiCodeConfig): void {
140
183
  const raw = readConfigFileQuiet(path);
184
+ const tools: Record<string, unknown> = {
185
+ ...(isRecord(raw.tools) ? raw.tools : {}),
186
+ };
187
+ for (const name of KIMI_TOOL_NAMES) {
188
+ tools[name] = { ...config.tools[name] };
189
+ }
141
190
  const next = {
142
191
  ...raw,
143
- tools: {
144
- ...(isRecord(raw.tools) ? raw.tools : {}),
145
- moonshot_search: { ...config.tools.moonshot_search },
146
- moonshot_fetch: { ...config.tools.moonshot_fetch },
147
- },
192
+ tools,
148
193
  };
149
194
  mkdirSync(dirname(path), { recursive: true });
150
195
  writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
package/src/constants.ts CHANGED
@@ -24,10 +24,9 @@ export const DEFAULT_BASE_URL = IS_OPENAI_PROTOCOL
24
24
  ? "https://api.kimi.com/coding/v1"
25
25
  : "https://api.kimi.com/coding";
26
26
 
27
- export const KIMI_CODE_VERSION = "0.1.1";
28
- export const KIMI_CODE_USER_AGENT_PRODUCT = "kimi-code-cli";
29
- export const KIMI_CODE_USER_AGENT = `${KIMI_CODE_USER_AGENT_PRODUCT}/${KIMI_CODE_VERSION}`;
30
- export const KIMI_PLATFORM = "kimi-code-cli";
27
+ export const KIMI_CODE_VERSION = "0.6.0";
28
+ export const KIMI_CODE_USER_AGENT = `kimi-code-cli/${KIMI_CODE_VERSION}`;
29
+ export const KIMI_PLATFORM = "kimi_code_cli";
31
30
 
32
31
  export function getKimiCodeHome(): string {
33
32
  const value = process.env.KIMI_CODE_HOME?.trim();
package/src/models.ts CHANGED
@@ -13,6 +13,7 @@ export interface KimiOAuthExtras {
13
13
  contextLength?: number;
14
14
  supportsReasoning?: boolean;
15
15
  supportsImageIn?: boolean;
16
+ supportsThinkingType?: "only" | "no" | "both";
16
17
  }
17
18
 
18
19
  export type KimiOAuthCredentials = OAuthCredentials & KimiOAuthExtras;
@@ -23,6 +24,12 @@ interface KimiServerModel {
23
24
  context_length?: unknown;
24
25
  supports_reasoning?: unknown;
25
26
  supports_image_in?: unknown;
27
+ supports_thinking_type?: unknown;
28
+ }
29
+
30
+ function parseSupportsThinkingType(value: unknown): "only" | "no" | "both" | undefined {
31
+ if (value === "only" || value === "no" || value === "both") return value;
32
+ return undefined;
26
33
  }
27
34
 
28
35
  export function buildModelsUrl(baseUrl: string): string {
@@ -55,7 +62,10 @@ export async function discoverKimiModelMetadata(accessToken: string): Promise<Ki
55
62
  if (typeof preferred.context_length === "number" && preferred.context_length > 0) {
56
63
  extras.contextLength = preferred.context_length;
57
64
  }
58
- if (typeof preferred.supports_reasoning === "boolean") {
65
+ const thinkingType = parseSupportsThinkingType(preferred.supports_thinking_type);
66
+ if (thinkingType) {
67
+ extras.supportsThinkingType = thinkingType;
68
+ } else if (typeof preferred.supports_reasoning === "boolean") {
59
69
  extras.supportsReasoning = preferred.supports_reasoning;
60
70
  }
61
71
  if (typeof preferred.supports_image_in === "boolean") {
@@ -71,7 +81,8 @@ export function applyKimiOAuthExtrasToModel(
71
81
  model: Model<Api>,
72
82
  extras: KimiOAuthExtras,
73
83
  ): Model<Api> {
74
- const next: Model<Api> & { wireModelId?: string } = { ...model };
84
+ const next: Model<Api> & { wireModelId?: string; supportsThinkingType?: "only" | "no" | "both" } =
85
+ { ...model };
75
86
  if (typeof extras.modelDisplay === "string" && extras.modelDisplay) {
76
87
  next.name = extras.modelDisplay;
77
88
  }
@@ -81,8 +92,12 @@ export function applyKimiOAuthExtrasToModel(
81
92
  if (typeof extras.wireModelId === "string" && extras.wireModelId) {
82
93
  next.wireModelId = extras.wireModelId;
83
94
  }
84
- if (typeof extras.supportsReasoning === "boolean") {
95
+ if (typeof extras.supportsThinkingType === "string") {
96
+ next.reasoning = extras.supportsThinkingType !== "no";
97
+ next.supportsThinkingType = extras.supportsThinkingType;
98
+ } else if (typeof extras.supportsReasoning === "boolean") {
85
99
  next.reasoning = extras.supportsReasoning;
100
+ next.supportsThinkingType = undefined;
86
101
  }
87
102
  if (typeof extras.supportsImageIn === "boolean") {
88
103
  const input = ["text"];
@@ -101,7 +116,11 @@ function parseKimiModelCapabilities(value: string | undefined): KimiOAuthExtras
101
116
  .filter(Boolean),
102
117
  );
103
118
  return {
104
- supportsReasoning: caps.has("thinking") || caps.has("always_thinking"),
119
+ supportsThinkingType: caps.has("always_thinking")
120
+ ? "only"
121
+ : caps.has("thinking")
122
+ ? "both"
123
+ : "no",
105
124
  supportsImageIn: caps.has("image_in"),
106
125
  };
107
126
  }
package/src/oauth.ts CHANGED
@@ -388,6 +388,7 @@ export async function refreshKimiAuthToken(currentKey: string): Promise<string |
388
388
  console.error("[kimi-coding] auth refresh: requesting new access token");
389
389
  const refreshed = await refreshAccessToken(cred.refresh);
390
390
  const newCred: OAuthCredential = {
391
+ ...cred,
391
392
  type: "oauth",
392
393
  access: refreshed.access_token,
393
394
  refresh: refreshed.refresh_token,
package/src/payload.ts CHANGED
@@ -39,6 +39,7 @@ export interface KimiPayloadContext {
39
39
  reasoning?: ThinkingLevel;
40
40
  thinkingKeep?: string;
41
41
  envOverrides: KimiEnvOverrides;
42
+ supportsThinkingType?: "only" | "no" | "both";
42
43
  }
43
44
 
44
45
  export function isRecord(value: unknown): value is JsonRecord {
@@ -54,6 +55,16 @@ function mapThinkingLevel(level?: string): { effort: string | null; enabled: boo
54
55
  return undefined;
55
56
  }
56
57
 
58
+ function resolveThinkingLevel(ctx: KimiPayloadContext): ThinkingLevel | undefined {
59
+ if (ctx.supportsThinkingType === "no") return undefined;
60
+ if (ctx.supportsThinkingType === "only") {
61
+ if (!ctx.reasoning) return "low";
62
+ const mapped = mapThinkingLevel(ctx.reasoning);
63
+ if (mapped && !mapped.enabled) return "low";
64
+ }
65
+ return ctx.reasoning;
66
+ }
67
+
57
68
  function parseInlineUploadThreshold(raw: string | undefined): number {
58
69
  const parsed = Number.parseInt(raw ?? "", 10);
59
70
  return Number.isFinite(parsed) && parsed >= 0
@@ -87,8 +98,7 @@ export function readEnvOverrides(): KimiEnvOverrides {
87
98
  if (temp) out.temperature = parseFloat(temp);
88
99
  const topP = process.env.KIMI_MODEL_TOP_P;
89
100
  if (topP) out.topP = parseFloat(topP);
90
- const maxCompletionTokens =
91
- process.env.KIMI_MODEL_MAX_COMPLETION_TOKENS ?? process.env.KIMI_MODEL_MAX_TOKENS;
101
+ const maxCompletionTokens = process.env.KIMI_MODEL_MAX_COMPLETION_TOKENS;
92
102
  if (maxCompletionTokens) out.maxCompletionTokens = parseInt(maxCompletionTokens, 10);
93
103
  return out;
94
104
  }
@@ -421,18 +431,24 @@ export async function applyKimiPayloadMutations(
421
431
  if (resolved) payload.prompt_cache_key = resolved;
422
432
  }
423
433
 
424
- // 4. Env-level hyperparameter overrides (pre-parsed into numbers by caller).
434
+ // 4. Normalize deprecated max_tokens and apply env-level hyperparameter
435
+ // overrides (pre-parsed into numbers by caller).
436
+ if (payload.max_completion_tokens === undefined && typeof payload.max_tokens === "number") {
437
+ payload.max_completion_tokens = payload.max_tokens;
438
+ }
439
+ delete payload.max_tokens;
440
+
425
441
  const { temperature, topP, maxCompletionTokens } = ctx.envOverrides;
426
442
  if (temperature !== undefined) payload.temperature = temperature;
427
443
  if (topP !== undefined) payload.top_p = topP;
428
444
  if (maxCompletionTokens !== undefined) {
429
445
  payload.max_completion_tokens = maxCompletionTokens;
430
- delete payload.max_tokens;
431
446
  }
432
447
 
433
448
  // 5. Reasoning effort mapping.
434
- if (ctx.reasoning) {
435
- const mapped = mapThinkingLevel(ctx.reasoning);
449
+ const resolvedReasoning = resolveThinkingLevel(ctx);
450
+ if (resolvedReasoning) {
451
+ const mapped = mapThinkingLevel(resolvedReasoning);
436
452
  if (mapped) {
437
453
  payload.reasoning_effort = mapped.effort;
438
454
  const extraBody = isRecord(payload.extra_body) ? payload.extra_body : {};
package/src/stream.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "@earendil-works/pi-ai";
20
20
 
21
21
  import { IS_OPENAI_PROTOCOL, PROTOCOL } from "./constants.ts";
22
+ import { getCommonHeaders } from "./device.ts";
22
23
  import { isKimiAuthErrorMessage, refreshKimiAuthToken } from "./oauth.ts";
23
24
  import {
24
25
  type Uploader,
@@ -46,24 +47,50 @@ export async function* filterEmptyResponseStream(
46
47
  ): AsyncIterable<AssistantMessageEvent> {
47
48
  const suppressedIndices = new Set<number>();
48
49
  let textBuffer: AssistantMessageEvent[] = [];
50
+ let bufferedText = "";
49
51
  let bufferingIndex: number | null = null;
50
52
 
53
+ const suppressBufferedTextBlock = (): void => {
54
+ if (bufferingIndex !== null) suppressedIndices.add(bufferingIndex);
55
+ textBuffer = [];
56
+ bufferedText = "";
57
+ bufferingIndex = null;
58
+ };
59
+
60
+ const flushBufferedTextBlock = async function* (): AsyncIterable<AssistantMessageEvent> {
61
+ for (const buffered of textBuffer) yield buffered;
62
+ textBuffer = [];
63
+ bufferedText = "";
64
+ bufferingIndex = null;
65
+ };
66
+
51
67
  for await (const event of upstream) {
52
- // Start buffering when a new text block begins.
68
+ // Start buffering only long enough to decide whether this text block is
69
+ // Kimi's synthetic "(Empty response: ...)" wrapper. Normal text is flushed
70
+ // as soon as it diverges from the marker so output still streams.
53
71
  if (event.type === "text_start") {
54
72
  bufferingIndex = event.contentIndex;
55
73
  textBuffer = [event];
74
+ bufferedText = "";
56
75
  continue;
57
76
  }
58
77
 
59
- // Accumulate text deltas + detect the empty-response marker on text_end.
60
78
  if (
61
79
  bufferingIndex !== null &&
62
80
  "contentIndex" in event &&
63
81
  event.contentIndex === bufferingIndex
64
82
  ) {
65
83
  if (event.type === "text_delta") {
84
+ bufferedText += event.delta;
66
85
  textBuffer.push(event);
86
+ if (bufferedText.startsWith(EMPTY_RESPONSE_PREFIX)) {
87
+ suppressBufferedTextBlock();
88
+ continue;
89
+ }
90
+ if (EMPTY_RESPONSE_PREFIX.startsWith(bufferedText)) {
91
+ continue;
92
+ }
93
+ yield* flushBufferedTextBlock();
67
94
  continue;
68
95
  }
69
96
  if (event.type === "text_end") {
@@ -72,14 +99,11 @@ export async function* filterEmptyResponseStream(
72
99
  // array: it is a shared reference into session state, and mutating
73
100
  // it would shift subsequent contentIndex values, corrupting the
74
101
  // stream.
75
- suppressedIndices.add(bufferingIndex);
102
+ suppressBufferedTextBlock();
76
103
  } else {
77
- // Legitimate text block — flush buffered events + end event.
78
- for (const buffered of textBuffer) yield buffered;
104
+ yield* flushBufferedTextBlock();
79
105
  yield event;
80
106
  }
81
- textBuffer = [];
82
- bufferingIndex = null;
83
107
  continue;
84
108
  }
85
109
  }
@@ -103,19 +127,36 @@ export async function* filterEmptyResponseStream(
103
127
 
104
128
  yield event;
105
129
  }
130
+
131
+ if (bufferingIndex !== null) {
132
+ yield* flushBufferedTextBlock();
133
+ }
106
134
  }
107
135
 
108
136
  // =============================================================================
109
137
  // Stream wrapper: orchestrates payload mutation + event filter
110
138
  // =============================================================================
111
139
 
140
+ const KIMI_API_KEY_ENV_REFERENCES = new Set(["$KIMI_API_KEY", "${KIMI_API_KEY}"]);
141
+
142
+ export function resolveKimiApiKey(apiKey: string | undefined): string {
143
+ if (apiKey !== undefined && KIMI_API_KEY_ENV_REFERENCES.has(apiKey)) {
144
+ return process.env.KIMI_API_KEY?.trim() || "";
145
+ }
146
+ return apiKey || process.env.KIMI_API_KEY || "";
147
+ }
148
+
149
+ export function mergeKimiRequestHeaders(headers?: Record<string, string>): Record<string, string> {
150
+ return { ...getCommonHeaders(), ...headers };
151
+ }
152
+
112
153
  export function streamSimpleKimi(
113
154
  model: Model<Api>,
114
155
  context: Context,
115
156
  options?: SimpleStreamOptions,
116
157
  ): AssistantMessageEventStream {
117
158
  const filtered = createAssistantMessageEventStream();
118
- const initialKey = options?.apiKey || process.env.KIMI_API_KEY || "";
159
+ const initialKey = resolveKimiApiKey(options?.apiKey);
119
160
 
120
161
  const cacheKeyOverride = (
121
162
  options as (SimpleStreamOptions & { prompt_cache_key?: unknown }) | undefined
@@ -131,6 +172,9 @@ export function streamSimpleKimi(
131
172
  // the model object via modifyModels and rewritten into the request payload
132
173
  // here so /v1/chat/completions and /v1/messages see the real wire id.
133
174
  const wireModelId = (model as Model<Api> & { wireModelId?: unknown }).wireModelId;
175
+ const supportsThinkingType = (
176
+ model as Model<Api> & { supportsThinkingType?: "only" | "no" | "both" }
177
+ ).supportsThinkingType;
134
178
 
135
179
  const buildPatchedOptions = (apiKey: string): SimpleStreamOptions => {
136
180
  const upload: Uploader | undefined = apiKey
@@ -142,6 +186,7 @@ export function streamSimpleKimi(
142
186
  return {
143
187
  ...options,
144
188
  ...apiKeyOverride,
189
+ headers: mergeKimiRequestHeaders(options?.headers),
145
190
  onPayload: async (payload, modelData) => {
146
191
  let nextPayload: unknown = payload;
147
192
 
@@ -154,6 +199,7 @@ export function streamSimpleKimi(
154
199
  reasoning: options?.reasoning,
155
200
  thinkingKeep,
156
201
  envOverrides,
202
+ supportsThinkingType,
157
203
  });
158
204
  if (
159
205
  typeof wireModelId === "string" &&
@@ -0,0 +1,115 @@
1
+ import { AuthStorage, type AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ import { PROVIDER_ID, getBaseUrl } from "../constants.ts";
5
+ import { getCommonHeaders } from "../device.ts";
6
+ import { refreshKimiAuthToken } from "../oauth.ts";
7
+
8
+ export const KIMI_TOOL_TIMEOUT_MS = 180_000;
9
+
10
+ export interface KimiToolDeps {
11
+ fetch: typeof fetch;
12
+ getAccessToken: () => string | null;
13
+ refreshAccessToken: (currentToken: string) => Promise<string | null>;
14
+ }
15
+
16
+ export interface BuildKimiToolOptions {
17
+ deps?: Partial<KimiToolDeps>;
18
+ defaultCollapsed?: boolean;
19
+ }
20
+
21
+ function defaultGetAccessToken(): string | null {
22
+ const credential = AuthStorage.create().get(PROVIDER_ID);
23
+ if (!credential || credential.type !== "oauth") return null;
24
+ return credential.access;
25
+ }
26
+
27
+ export function buildKimiToolDeps(options: BuildKimiToolOptions = {}): KimiToolDeps {
28
+ return {
29
+ fetch: options.deps?.fetch ?? fetch,
30
+ getAccessToken: options.deps?.getAccessToken ?? defaultGetAccessToken,
31
+ refreshAccessToken: options.deps?.refreshAccessToken ?? refreshKimiAuthToken,
32
+ };
33
+ }
34
+
35
+ export function buildTimeoutSignal(signal: AbortSignal | undefined): {
36
+ signal: AbortSignal;
37
+ cleanup: () => void;
38
+ } {
39
+ const controller = new AbortController();
40
+ const timeout = setTimeout(() => controller.abort(), KIMI_TOOL_TIMEOUT_MS);
41
+ const abort = () => controller.abort();
42
+ if (signal?.aborted) {
43
+ controller.abort();
44
+ } else {
45
+ signal?.addEventListener("abort", abort, { once: true });
46
+ }
47
+
48
+ return {
49
+ signal: controller.signal,
50
+ cleanup: () => {
51
+ clearTimeout(timeout);
52
+ signal?.removeEventListener("abort", abort);
53
+ },
54
+ };
55
+ }
56
+
57
+ export function getKimiBaseV1(): string {
58
+ const base = getBaseUrl().replace(/\/+$/, "");
59
+ return base.endsWith("/v1") ? base : `${base}/v1`;
60
+ }
61
+
62
+ export function getKimiDatasourceUrl(): string {
63
+ const explicit = process.env.KIMI_DATASOURCE_API_URL?.trim();
64
+ if (explicit) return explicit;
65
+ return `${getKimiBaseV1()}/tools`;
66
+ }
67
+
68
+ export function buildHeaders(accessToken: string, toolCallId: string): Record<string, string> {
69
+ return {
70
+ ...getCommonHeaders(),
71
+ Authorization: `Bearer ${accessToken}`,
72
+ "Content-Type": "application/json",
73
+ "X-Msh-Tool-Call-Id": toolCallId,
74
+ };
75
+ }
76
+
77
+ export function errorResult<T>(message: string): AgentToolResult<T> {
78
+ return {
79
+ content: [{ type: "text", text: message }],
80
+ details: undefined as T,
81
+ };
82
+ }
83
+
84
+ export async function readErrorBody(response: Response): Promise<string> {
85
+ return response.text().catch(() => "");
86
+ }
87
+
88
+ export async function fetchWithAuthRetry(
89
+ deps: KimiToolDeps,
90
+ accessToken: string,
91
+ request: (token: string) => Promise<Response>,
92
+ ): Promise<Response> {
93
+ const response = await request(accessToken);
94
+ if (response.status !== 401) return response;
95
+
96
+ const refreshed = await deps.refreshAccessToken(accessToken);
97
+ if (!refreshed || refreshed === accessToken) return response;
98
+ return request(refreshed);
99
+ }
100
+
101
+ export function textComponent(text: string) {
102
+ return {
103
+ render: (width: number) => text.split("\n").map((line) => truncateToWidth(line, width, "…")),
104
+ invalidate: () => {},
105
+ };
106
+ }
107
+
108
+ export function firstText(result: AgentToolResult<unknown>): string {
109
+ const first = result.content[0];
110
+ return first?.type === "text" ? first.text : "";
111
+ }
112
+
113
+ export function shouldCollapse(defaultCollapsed: boolean, expanded: boolean | undefined): boolean {
114
+ return defaultCollapsed && expanded !== true;
115
+ }