@link-assistant/agent 0.27.0 → 0.27.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 (54) hide show
  1. package/README.md +47 -5
  2. package/package.json +41 -42
  3. package/src/cli/argv.ts +1 -1
  4. package/src/cli/cmd/run.ts +7 -1
  5. package/src/cli/continuous-mode.js +38 -12
  6. package/src/cli/event-handler.js +3 -1
  7. package/src/cli/model-config.js +20 -4
  8. package/src/cli/run-options.js +144 -78
  9. package/src/config/config.ts +306 -105
  10. package/src/config/defaults.ts +13 -5
  11. package/src/config/file-config.ts +16 -0
  12. package/src/file/ripgrep.ts +38 -5
  13. package/src/file/time.ts +86 -9
  14. package/src/index.js +39 -33
  15. package/src/mcp/index.ts +370 -106
  16. package/src/permission/index.ts +22 -0
  17. package/src/project/instance.ts +5 -1
  18. package/src/project/project.ts +35 -2
  19. package/src/provider/free-tier-error.ts +1 -1
  20. package/src/provider/kilo-deprecation.ts +30 -0
  21. package/src/provider/kilo.ts +25 -7
  22. package/src/provider/provider.ts +53 -22
  23. package/src/provider/retry-fetch.ts +5 -0
  24. package/src/provider/transform.ts +139 -17
  25. package/src/session/compaction.ts +18 -2
  26. package/src/session/index.ts +30 -3
  27. package/src/session/message-v2.ts +318 -32
  28. package/src/session/processor.ts +274 -140
  29. package/src/session/prompt.ts +154 -128
  30. package/src/session/retry.ts +26 -13
  31. package/src/session/summary.ts +4 -1
  32. package/src/tool/bash.ts +232 -45
  33. package/src/tool/bash.txt +1 -1
  34. package/src/tool/codesearch.ts +15 -78
  35. package/src/tool/edit.ts +111 -30
  36. package/src/tool/glob.ts +2 -1
  37. package/src/tool/grep.ts +175 -57
  38. package/src/tool/ls.ts +2 -1
  39. package/src/tool/multiedit.ts +3 -1
  40. package/src/tool/read.ts +169 -41
  41. package/src/tool/task.ts +26 -18
  42. package/src/tool/tool.ts +4 -1
  43. package/src/tool/webfetch.ts +68 -56
  44. package/src/tool/webfetch.txt +4 -4
  45. package/src/tool/websearch.ts +20 -75
  46. package/src/tool/websearch.txt +2 -0
  47. package/src/tool/write.ts +23 -6
  48. package/src/util/bom.ts +32 -0
  49. package/src/util/exa-mcp.ts +82 -0
  50. package/src/util/legacy-error-names.ts +39 -0
  51. package/src/util/read-bounded.ts +32 -0
  52. package/src/util/sse-usage-extractor.ts +67 -13
  53. package/src/util/timeout.ts +3 -4
  54. package/src/util/verbose-fetch.ts +40 -1
@@ -45,7 +45,12 @@ export namespace Project {
45
45
  }
46
46
  let worktree = path.dirname(git);
47
47
  const timer = log.time('git.rev-parse');
48
- let id = await Bun.file(path.join(git, 'opencode'))
48
+ // In a git worktree or submodule `.git` is a file, so the id cache lives in
49
+ // the common git directory instead (as upstream does). Writing to
50
+ // `.git/opencode` there used to reject with ENOTDIR; the rejection was not
51
+ // handled and the CLI exited with an UnhandledRejection (issue #335).
52
+ const cacheDir = await gitCacheDir(git, worktree);
53
+ let id = await Bun.file(path.join(cacheDir, 'opencode'))
49
54
  .text()
50
55
  .then((x) => x.trim())
51
56
  .catch(() => {});
@@ -63,7 +68,16 @@ export namespace Project {
63
68
  .toSorted()
64
69
  );
65
70
  id = roots[0];
66
- if (id) Bun.file(path.join(git, 'opencode')).write(id);
71
+ // The cache is best effort: a read-only or unusual `.git` must not fail
72
+ // project detection.
73
+ if (id)
74
+ await Bun.write(path.join(cacheDir, 'opencode'), id).catch((error) =>
75
+ log.info(() => ({
76
+ message: 'project id cache not written',
77
+ path: path.join(cacheDir, 'opencode'),
78
+ error: String(error),
79
+ }))
80
+ );
67
81
  }
68
82
  timer.stop();
69
83
  if (!id) {
@@ -95,6 +109,25 @@ export namespace Project {
95
109
  return project;
96
110
  }
97
111
 
112
+ /**
113
+ * Directory that holds the project id cache: `.git` itself when it is a
114
+ * directory, otherwise the common git directory reported by git (worktrees
115
+ * and submodules have a `.git` file pointing elsewhere).
116
+ */
117
+ async function gitCacheDir(git: string, worktree: string): Promise<string> {
118
+ const stat = await fs.stat(git).catch(() => undefined);
119
+ if (stat?.isDirectory()) return git;
120
+ const common =
121
+ await $`git rev-parse --path-format=absolute --git-common-dir`
122
+ .quiet()
123
+ .nothrow()
124
+ .cwd(worktree)
125
+ .text()
126
+ .then((x) => x.trim())
127
+ .catch(() => '');
128
+ return common || git;
129
+ }
130
+
98
131
  export async function setInitialized(projectID: string) {
99
132
  await Storage.update<Info>(['project', projectID], (draft) => {
100
133
  draft.time.initialized = Date.now();
@@ -13,4 +13,4 @@ export function isFreeTierError(
13
13
  }
14
14
 
15
15
  export const FREE_TIER_ERROR_MESSAGE =
16
- "OpenCode Zen's free tier is limited to OpenCode clients. Use kilo/nemotron-3-super-free (authenticate with agent auth login or KILO_API_KEY), or select a paid model.";
16
+ "OpenCode Zen's free tier is limited to OpenCode clients. Use kilo/nemotron-3-ultra-free (authenticate with agent auth login or KILO_API_KEY), or select a paid model.";
@@ -0,0 +1,30 @@
1
+ import { Log } from '../util/log';
2
+ import { Kilo } from './kilo';
3
+
4
+ const log = Log.create({ service: 'kilo' });
5
+
6
+ // Aliases already warned about. A stored session or config that names an
7
+ // alias reaches getModel()/parseModel() many times in one process, so warn
8
+ // once per alias; the set is bounded by `Kilo.DEPRECATED_ALIASES`.
9
+ const warned = new Set<string>();
10
+
11
+ /**
12
+ * Map a deprecated Kilo alias (`Kilo.DEPRECATED_ALIASES`) to its replacement
13
+ * and warn about it (once per alias and process); any other model ID is
14
+ * returned unchanged (#335).
15
+ *
16
+ * Kept out of `kilo.ts` so that module has no runtime dependencies.
17
+ */
18
+ export function resolveKiloDeprecatedAlias(modelID: string): string {
19
+ const replacement = Kilo.deprecatedAliasTarget(modelID);
20
+ if (!replacement) return modelID;
21
+ if (warned.has(modelID)) return replacement;
22
+ warned.add(modelID);
23
+ log.warn(() => ({
24
+ message: `kilo/${modelID} is deprecated and no longer served by Kilo; using kilo/${replacement} instead`,
25
+ deprecatedModel: `kilo/${modelID}`,
26
+ replacement: `kilo/${replacement}`,
27
+ hint: `Pass --model kilo/${replacement} to silence this warning`,
28
+ }));
29
+ return replacement;
30
+ }
@@ -25,14 +25,7 @@ export namespace Kilo {
25
25
  };
26
26
  }
27
27
 
28
- // Super's output cap is deliberately conservative for existing integrations.
29
28
  export const FREE_MODELS: Record<string, ModelsDev.Model> = {
30
- 'nemotron-3-super-free': freeModel(
31
- 'nvidia/nemotron-3-super-120b-a12b:free',
32
- 'Nemotron 3 Super (Free)',
33
- 262144,
34
- 32768
35
- ),
36
29
  'nemotron-3-ultra-free': freeModel(
37
30
  'nvidia/nemotron-3-ultra-550b-a55b:free',
38
31
  'Nemotron 3 Ultra (Free)',
@@ -47,6 +40,31 @@ export namespace Kilo {
47
40
  ),
48
41
  };
49
42
 
43
+ /**
44
+ * Withdrawn built-in aliases that keep working temporarily. Each one maps to
45
+ * the served alias in `FREE_MODELS` that replaces it, and every resolution
46
+ * logs a warning naming the replacement. These are not catalog entries, so
47
+ * the drift check (`scripts/check-model-drift.mjs`) does not look them up.
48
+ *
49
+ * Kilo stopped serving `nvidia/nemotron-3-super-120b-a12b:free`; only the
50
+ * paid Super model remains on the gateway (#335).
51
+ */
52
+ export const DEPRECATED_ALIASES: Readonly<Record<string, string>> = {
53
+ 'nemotron-3-super-free': 'nemotron-3-ultra-free',
54
+ };
55
+
56
+ /**
57
+ * Replacement for a deprecated alias, or `undefined`. Logs nothing; use
58
+ * `resolveKiloDeprecatedAlias` from `./kilo-deprecation` to also warn.
59
+ * This module stays free of runtime dependencies because
60
+ * `scripts/check-default-models.mjs` imports it without installed packages.
61
+ */
62
+ export function deprecatedAliasTarget(modelID: string): string | undefined {
63
+ return Object.hasOwn(DEPRECATED_ALIASES, modelID)
64
+ ? DEPRECATED_ALIASES[modelID]
65
+ : undefined;
66
+ }
67
+
50
68
  /**
51
69
  * Text models models.dev lists for Kilo. Their IDs are the same on the
52
70
  * OpenRouter-compatible route this provider uses, so the curated aliases
@@ -20,8 +20,10 @@ import { createEchoModel } from './echo';
20
20
  import { createCacheModel } from './cache';
21
21
  import { RetryFetch } from './retry-fetch';
22
22
  import { SSEUsageExtractor } from '../util/sse-usage-extractor';
23
+ import { sanitizeUrl } from '../util/verbose-fetch';
23
24
  import { OpenCodeZen } from './opencode-zen';
24
25
  import { Kilo } from './kilo';
26
+ import { resolveKiloDeprecatedAlias } from './kilo-deprecation';
25
27
  import { EnvTemplate } from './env-template';
26
28
  import { ExtraProviders } from './extra-providers';
27
29
  import { ModelDiscovery } from './model-discovery';
@@ -1199,19 +1201,23 @@ export namespace Provider {
1199
1201
  const [sdkStream, usageStream] = response.body.tee();
1200
1202
  const sseReqId = SSEUsageExtractor.nextRequestId();
1201
1203
  (async () => {
1204
+ const reader = usageStream.getReader();
1202
1205
  try {
1203
- const reader = usageStream.getReader();
1206
+ // Read the whole branch: a tee branch that stops being read
1207
+ // keeps buffering the rest of the response (issue #335).
1204
1208
  const decoder = new TextDecoder();
1205
- let body = '';
1209
+ const tracker = SSEUsageExtractor.createTracker();
1206
1210
  while (true) {
1207
1211
  const { done, value } = await reader.read();
1208
1212
  if (done) break;
1209
- body += decoder.decode(value, { stream: true });
1210
- if (body.length > 50000) break;
1213
+ tracker.push(decoder.decode(value, { stream: true }));
1211
1214
  }
1212
- SSEUsageExtractor.processStreamForUsage(sseReqId, body);
1215
+ tracker.push(decoder.decode());
1216
+ const usage = tracker.finish();
1217
+ if (usage) SSEUsageExtractor.storeUsage(sseReqId, usage);
1213
1218
  } catch {
1214
- // Never break the SDK stream
1219
+ // Never break the SDK stream; release the branch instead.
1220
+ reader.cancel().catch(() => {});
1215
1221
  }
1216
1222
  })();
1217
1223
  return new Response(sdkStream, {
@@ -1237,12 +1243,14 @@ export namespace Provider {
1237
1243
  });
1238
1244
  }
1239
1245
 
1240
- const url =
1246
+ // Logged only; query-string credentials are masked (issue #335).
1247
+ const url = sanitizeUrl(
1241
1248
  typeof input === 'string'
1242
1249
  ? input
1243
1250
  : input instanceof URL
1244
1251
  ? input.toString()
1245
- : input.url;
1252
+ : input.url
1253
+ );
1246
1254
  const method = init?.method ?? 'GET';
1247
1255
 
1248
1256
  // Wrap all verbose logging in try/catch so it never breaks the actual HTTP request.
@@ -1369,13 +1377,15 @@ export namespace Provider {
1369
1377
  try {
1370
1378
  const reader = logStream.getReader();
1371
1379
  const decoder = new TextDecoder();
1380
+ const tracker = SSEUsageExtractor.createTracker();
1372
1381
  let bodyPreview = '';
1373
1382
  let truncated = false;
1374
1383
  while (true) {
1375
1384
  const { done, value } = await reader.read();
1376
1385
  if (done) break;
1386
+ const chunk = decoder.decode(value, { stream: true });
1387
+ tracker.push(chunk);
1377
1388
  if (!truncated) {
1378
- const chunk = decoder.decode(value, { stream: true });
1379
1389
  bodyPreview += chunk;
1380
1390
  if (bodyPreview.length > responseBodyMaxChars) {
1381
1391
  bodyPreview = bodyPreview.slice(
@@ -1386,11 +1396,12 @@ export namespace Provider {
1386
1396
  }
1387
1397
  }
1388
1398
  }
1389
- // Extract usage from raw SSE stream as AI SDK fallback
1390
- SSEUsageExtractor.processStreamForUsage(
1391
- sseRequestId,
1392
- bodyPreview
1393
- );
1399
+ // Extract usage from the whole raw SSE stream (not just
1400
+ // the logged preview) as AI SDK fallback.
1401
+ tracker.push(decoder.decode());
1402
+ const usage = tracker.finish();
1403
+ if (usage)
1404
+ SSEUsageExtractor.storeUsage(sseRequestId, usage);
1394
1405
  // Use direct (non-lazy) logging for stream body
1395
1406
  // See: https://github.com/link-assistant/agent/issues/211
1396
1407
  log.info('HTTP response body (stream)', {
@@ -1554,6 +1565,12 @@ export namespace Provider {
1554
1565
  const provider = s.providers[ExtraProviders.canonicalID(providerID)];
1555
1566
  if (!provider) return false;
1556
1567
  if (provider.info.models[modelID]) return true;
1568
+ // Deprecated Kilo aliases still resolve to their replacement (#335).
1569
+ const replacement =
1570
+ provider.info.id === 'kilo'
1571
+ ? Kilo.deprecatedAliasTarget(modelID)
1572
+ : undefined;
1573
+ if (replacement && provider.info.models[replacement]) return true;
1557
1574
  return ExtraProviders.allowsUnknownModels(provider.info.id);
1558
1575
  }
1559
1576
 
@@ -1642,6 +1659,8 @@ export namespace Provider {
1642
1659
 
1643
1660
  export async function getModel(providerID: string, modelID: string) {
1644
1661
  providerID = ExtraProviders.canonicalID(providerID);
1662
+ // Sessions and configs may still name a withdrawn Kilo alias (#335).
1663
+ if (providerID === 'kilo') modelID = resolveKiloDeprecatedAlias(modelID);
1645
1664
  const key = `${providerID}/${modelID}`;
1646
1665
  const s = await state();
1647
1666
  if (s.models.has(key)) return s.models.get(key)!;
@@ -1910,7 +1929,6 @@ export namespace Provider {
1910
1929
  'gpt-5-nano',
1911
1930
  'nemotron-3.5-lightning-free',
1912
1931
  'nemotron-3-ultra-free',
1913
- 'nemotron-3-super-free',
1914
1932
  'gpt-5',
1915
1933
  'claude-sonnet-4',
1916
1934
  'gemini-3-pro',
@@ -1984,10 +2002,14 @@ export namespace Provider {
1984
2002
  }
1985
2003
 
1986
2004
  export function parseModel(model: string) {
1987
- const [providerID, ...rest] = model.split('/');
2005
+ const [rawProviderID, ...rest] = model.split('/');
2006
+ const providerID = ExtraProviders.canonicalID(rawProviderID);
2007
+ const modelID = rest.join('/');
1988
2008
  return {
1989
- providerID: ExtraProviders.canonicalID(providerID),
1990
- modelID: rest.join('/'),
2009
+ providerID,
2010
+ // Withdrawn Kilo aliases keep working with a warning (#335).
2011
+ modelID:
2012
+ providerID === 'kilo' ? resolveKiloDeprecatedAlias(modelID) : modelID,
1991
2013
  };
1992
2014
  }
1993
2015
 
@@ -2007,6 +2029,14 @@ export namespace Provider {
2007
2029
  ): Promise<{ providerID: string; modelID: string } | undefined> {
2008
2030
  const s = await state();
2009
2031
 
2032
+ // A withdrawn built-in alias resolves to its Kilo replacement (#335).
2033
+ if (Kilo.deprecatedAliasTarget(modelID)) {
2034
+ const replacement = resolveKiloDeprecatedAlias(modelID);
2035
+ if (s.providers.kilo?.info.models[replacement]) {
2036
+ return { providerID: 'kilo', modelID: replacement };
2037
+ }
2038
+ }
2039
+
2010
2040
  if (Object.hasOwn(Kilo.FREE_MODELS, modelID)) {
2011
2041
  const kiloProvider = s.providers.kilo;
2012
2042
  if (kiloProvider?.info.models[modelID]) {
@@ -2080,8 +2110,9 @@ export namespace Provider {
2080
2110
  * or a clear error explaining why it's not available.
2081
2111
  *
2082
2112
  * Examples:
2083
- * - "kilo/nemotron-3-super-free" -> { providerID: "kilo", modelID: "nemotron-3-super-free" }
2084
- * - "nemotron-3-super-free" -> { providerID: "kilo", modelID: "nemotron-3-super-free" } (resolved)
2113
+ * - "kilo/nemotron-3-ultra-free" -> { providerID: "kilo", modelID: "nemotron-3-ultra-free" }
2114
+ * - "nemotron-3-ultra-free" -> { providerID: "kilo", modelID: "nemotron-3-ultra-free" } (resolved)
2115
+ * - "nemotron-3-super-free" -> { providerID: "kilo", modelID: "nemotron-3-ultra-free" } (deprecated alias, warns)
2085
2116
  * - "big-pickle" -> { providerID: "opencode", modelID: "big-pickle" } (resolved)
2086
2117
  * - "nonexistent-model" -> throws ModelNotFoundError
2087
2118
  *
@@ -2142,7 +2173,7 @@ export namespace Provider {
2142
2173
  * When one provider hits rate limits, the system can try an alternative.
2143
2174
  *
2144
2175
  * Note: This is only used for models without explicit provider specification.
2145
- * If user specifies "kilo/nemotron-3-super-free", no fallback will occur.
2176
+ * If user specifies "kilo/nemotron-3-ultra-free", no fallback will occur.
2146
2177
  */
2147
2178
  const SHARED_FREE_MODELS: Record<string, string[]> = {
2148
2179
  'nemotron-3.5-lightning-free': ['kilo', 'opencode'],
@@ -2153,7 +2184,7 @@ export namespace Provider {
2153
2184
  * This function returns a list of alternative providers that offer the same model.
2154
2185
  *
2155
2186
  * Note: This only returns alternatives for models without explicit provider specification.
2156
- * If the original request had an explicit provider (like "kilo/nemotron-3-super-free"), this returns empty array.
2187
+ * If the original request had an explicit provider (like "kilo/nemotron-3-ultra-free"), this returns empty array.
2157
2188
  *
2158
2189
  * @param modelID - The model ID to find alternatives for
2159
2190
  * @param failedProviderID - The provider that failed
@@ -461,6 +461,9 @@ export namespace RetryFetch {
461
461
  }));
462
462
 
463
463
  await sleep(delay, init?.signal ?? undefined);
464
+ // The failed response is dropped: release its body and connection
465
+ // now instead of waiting for GC (issue #335). Not awaited.
466
+ response.body?.cancel().catch(() => {});
464
467
  continue;
465
468
  }
466
469
 
@@ -554,6 +557,8 @@ export namespace RetryFetch {
554
557
 
555
558
  try {
556
559
  await sleep(delay, isolatedSignal);
560
+ // Retrying: release the dropped 429 body (issue #335).
561
+ response.body?.cancel().catch(() => {});
557
562
  } catch (sleepError) {
558
563
  // Check if the original request was aborted (user cancellation)
559
564
  // In that case, we should stop retrying
@@ -1,8 +1,97 @@
1
- import type { ModelMessage } from 'ai';
2
- import { unique } from 'remeda';
1
+ import type { ModelMessage, ToolResultPart } from 'ai';
2
+ import { mergeDeep, unique } from 'remeda';
3
3
  import type { JSONSchema } from 'zod/v4/core';
4
4
 
5
5
  export namespace ProviderTransform {
6
+ /**
7
+ * Replace lone UTF-16 surrogates, which strict JSON parsers reject with a
8
+ * 400 on every later turn (upstream #25934).
9
+ */
10
+ export function sanitizeSurrogates(content: string) {
11
+ return content.replace(
12
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
13
+ '�'
14
+ );
15
+ }
16
+
17
+ function sanitizeToolResult(part: ToolResultPart) {
18
+ const output = part.output;
19
+ if (output.type === 'text' || output.type === 'error-text') {
20
+ return {
21
+ ...part,
22
+ output: { ...output, value: sanitizeSurrogates(output.value) },
23
+ };
24
+ }
25
+ if (output.type === 'content') {
26
+ return {
27
+ ...part,
28
+ output: {
29
+ ...output,
30
+ value: output.value.map((item) =>
31
+ item.type === 'text'
32
+ ? { ...item, text: sanitizeSurrogates(item.text) }
33
+ : item
34
+ ),
35
+ },
36
+ };
37
+ }
38
+ return part;
39
+ }
40
+
41
+ function sanitizeMessages(msgs: ModelMessage[]): ModelMessage[] {
42
+ return msgs.map((msg) => {
43
+ if (typeof msg.content === 'string') {
44
+ return {
45
+ ...msg,
46
+ content: sanitizeSurrogates(msg.content),
47
+ } as typeof msg;
48
+ }
49
+ if (!Array.isArray(msg.content)) return msg;
50
+ return {
51
+ ...msg,
52
+ content: msg.content.map((part: any) => {
53
+ if (part.type === 'text' || part.type === 'reasoning') {
54
+ return { ...part, text: sanitizeSurrogates(part.text) };
55
+ }
56
+ if (part.type === 'tool-result') return sanitizeToolResult(part);
57
+ return part;
58
+ }),
59
+ } as typeof msg;
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Anthropic and Bedrock reject empty text blocks; keep reasoning that is
65
+ * signed or redacted (upstream #14586, #26276).
66
+ */
67
+ function dropEmptyParts(msgs: ModelMessage[], npm?: string) {
68
+ if (npm !== '@ai-sdk/anthropic' && npm !== '@ai-sdk/amazon-bedrock') {
69
+ return msgs;
70
+ }
71
+ const ns = npm === '@ai-sdk/anthropic' ? 'anthropic' : 'bedrock';
72
+ return msgs
73
+ .map((msg) => {
74
+ if (typeof msg.content === 'string') {
75
+ return msg.content === '' ? undefined : msg;
76
+ }
77
+ if (!Array.isArray(msg.content)) return msg;
78
+ const filtered = (msg.content as any[]).filter((part) => {
79
+ if (part.type === 'text') return part.text !== '';
80
+ if (part.type === 'reasoning') {
81
+ return (
82
+ part.text.trim().length > 0 ||
83
+ part.providerOptions?.[ns]?.signature != null ||
84
+ part.providerOptions?.[ns]?.redactedData != null
85
+ );
86
+ }
87
+ return true;
88
+ });
89
+ if (filtered.length === 0) return undefined;
90
+ return { ...msg, content: filtered } as typeof msg;
91
+ })
92
+ .filter((msg): msg is ModelMessage => msg !== undefined);
93
+ }
94
+
6
95
  function normalizeMessages(
7
96
  msgs: ModelMessage[],
8
97
  providerID: string,
@@ -84,7 +173,8 @@ export namespace ProviderTransform {
84
173
 
85
174
  function applyCaching(
86
175
  msgs: ModelMessage[],
87
- providerID: string
176
+ providerID: string,
177
+ npm?: string
88
178
  ): ModelMessage[] {
89
179
  const system = msgs.filter((msg) => msg.role === 'system').slice(0, 2);
90
180
  const final = msgs.filter((msg) => msg.role !== 'system').slice(-2);
@@ -96,35 +186,43 @@ export namespace ProviderTransform {
96
186
  openrouter: {
97
187
  cache_control: { type: 'ephemeral' },
98
188
  },
189
+ // Bedrock only accepts the 'default' cache point (upstream #11664).
99
190
  bedrock: {
100
- cachePoint: { type: 'ephemeral' },
191
+ cachePoint: { type: 'default' },
101
192
  },
102
193
  openaiCompatible: {
103
194
  cache_control: { type: 'ephemeral' },
104
195
  },
105
196
  };
106
197
 
198
+ // Bedrock reads the cache point from the message (upstream #18959).
199
+ const useMessageLevelOptions =
200
+ providerID === 'anthropic' ||
201
+ providerID.includes('bedrock') ||
202
+ npm === '@ai-sdk/amazon-bedrock';
203
+
107
204
  for (const msg of unique([...system, ...final])) {
108
205
  const shouldUseContentOptions =
109
- providerID !== 'anthropic' &&
206
+ !useMessageLevelOptions &&
110
207
  Array.isArray(msg.content) &&
111
208
  msg.content.length > 0;
112
209
 
210
+ // Deep-merge so signatures already in providerOptions survive (upstream #10323).
113
211
  if (shouldUseContentOptions) {
114
212
  const lastContent = msg.content[msg.content.length - 1];
115
213
  if (lastContent && typeof lastContent === 'object') {
116
- lastContent.providerOptions = {
117
- ...lastContent.providerOptions,
118
- ...providerOptions,
119
- };
214
+ lastContent.providerOptions = mergeDeep(
215
+ lastContent.providerOptions ?? {},
216
+ providerOptions
217
+ ) as typeof lastContent.providerOptions;
120
218
  continue;
121
219
  }
122
220
  }
123
221
 
124
- msg.providerOptions = {
125
- ...msg.providerOptions,
126
- ...providerOptions,
127
- };
222
+ msg.providerOptions = mergeDeep(
223
+ msg.providerOptions ?? {},
224
+ providerOptions
225
+ ) as typeof msg.providerOptions;
128
226
  }
129
227
 
130
228
  return msgs;
@@ -133,15 +231,18 @@ export namespace ProviderTransform {
133
231
  export function message(
134
232
  msgs: ModelMessage[],
135
233
  providerID: string,
136
- modelID: string
234
+ modelID: string,
235
+ npm?: string
137
236
  ) {
237
+ msgs = sanitizeMessages(msgs);
238
+ msgs = dropEmptyParts(msgs, npm);
138
239
  msgs = normalizeMessages(msgs, providerID, modelID);
139
240
  if (
140
241
  providerID === 'anthropic' ||
141
242
  modelID.includes('anthropic') ||
142
243
  modelID.includes('claude')
143
244
  ) {
144
- msgs = applyCaching(msgs, providerID);
245
+ msgs = applyCaching(msgs, providerID, npm);
145
246
  }
146
247
 
147
248
  return msgs;
@@ -280,11 +381,32 @@ export namespace ProviderTransform {
280
381
  return standardLimit;
281
382
  }
282
383
 
384
+ /**
385
+ * Moonshot expands $ref before validating and rejects sibling keywords, and
386
+ * it does not accept tuple-style `items` arrays (upstream #24730).
387
+ */
388
+ function sanitizeMoonshot(node: unknown): unknown {
389
+ if (node === null || typeof node !== 'object') return node;
390
+ if (Array.isArray(node)) return node.map(sanitizeMoonshot);
391
+ if ('$ref' in node && typeof node.$ref === 'string') {
392
+ return { $ref: node.$ref };
393
+ }
394
+ const result: Record<string, unknown> = Object.fromEntries(
395
+ Object.entries(node).map(([key, value]) => [key, sanitizeMoonshot(value)])
396
+ );
397
+ if (Array.isArray(result.items)) result.items = result.items[0] ?? {};
398
+ return result;
399
+ }
400
+
283
401
  export function schema(
284
- _providerID: string,
285
- _modelID: string,
402
+ providerID: string,
403
+ modelID: string,
286
404
  schema: JSONSchema.BaseSchema
287
405
  ) {
406
+ if (providerID === 'moonshotai' || modelID.toLowerCase().includes('kimi')) {
407
+ schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema;
408
+ }
409
+
288
410
  /*
289
411
  if (["openai", "azure"].includes(providerID)) {
290
412
  if (schema.type === "object" && schema.properties) {
@@ -347,7 +347,8 @@ export namespace SessionCompaction {
347
347
  }
348
348
 
349
349
  return false;
350
- })
350
+ }),
351
+ { providerID: model.providerID, modelID: model.modelID }
351
352
  );
352
353
  // Defensive check: ensure modelMessages is iterable (AI SDK 6.0.1 compatibility fix)
353
354
  const safeModelMessages = Array.isArray(modelMessages) ? modelMessages : [];
@@ -410,7 +411,8 @@ export namespace SessionCompaction {
410
411
  args.params.prompt = ProviderTransform.message(
411
412
  args.params.prompt,
412
413
  model.providerID,
413
- model.modelID
414
+ model.modelID,
415
+ model.npm
414
416
  );
415
417
  }
416
418
  return args.params;
@@ -420,6 +422,20 @@ export namespace SessionCompaction {
420
422
  }),
421
423
  })
422
424
  );
425
+ // The summary request itself overflowed: compacting again would send the
426
+ // same history, so stop with a clear error (upstream #14707).
427
+ if (result === 'compact') {
428
+ processor.message.error = new MessageV2.ContextOverflowError({
429
+ message:
430
+ 'Conversation history too large to compact - exceeds model context limit',
431
+ }).toObject();
432
+ await Session.updateMessage(processor.message);
433
+ Bus.publish(Session.Event.Error, {
434
+ sessionID: input.sessionID,
435
+ error: processor.message.error,
436
+ });
437
+ return 'stop';
438
+ }
423
439
  if (result === 'continue') {
424
440
  const continueMsg = await Session.updateMessage({
425
441
  id: Identifier.ascending('message'),
@@ -1,9 +1,10 @@
1
1
  import { Decimal } from 'decimal.js';
2
+ import { FileTime } from '../file/time';
2
3
  import z from 'zod';
3
4
  import { type LanguageModelUsage, type ProviderMetadata } from 'ai';
4
5
  import { Bus } from '../bus';
5
6
  import { Config } from '../config/file-config';
6
- import { isVerbose } from '../config/config';
7
+ import { config, isVerbose } from '../config/config';
7
8
  import { Identifier } from '../id/id';
8
9
  import type { ModelsDev } from '../provider/models';
9
10
  import { Storage } from '../storage/storage';
@@ -328,6 +329,7 @@ export namespace Session {
328
329
  await Storage.remove(msg);
329
330
  }
330
331
  await Storage.remove(['session', project.id, sessionID]);
332
+ FileTime.clear(sessionID);
331
333
  Bus.publish(Event.Deleted, {
332
334
  info: session,
333
335
  });
@@ -757,11 +759,36 @@ export namespace Session {
757
759
  )
758
760
  )
759
761
  : 0;
760
- const reasoningTokens = topLevelReasoningTokens || nestedReasoning;
762
+ // AI SDK 7 reports reasoning under outputTokenDetails.
763
+ const detailReasoning = safeNum(
764
+ toNumber(
765
+ (
766
+ effectiveUsage as {
767
+ outputTokenDetails?: { reasoningTokens?: unknown };
768
+ }
769
+ ).outputTokenDetails?.reasoningTokens,
770
+ 'outputTokenDetails.reasoningTokens'
771
+ )
772
+ );
773
+ const reasoningTokens =
774
+ topLevelReasoningTokens || nestedReasoning || detailReasoning;
775
+
776
+ // outputTokens already includes reasoning, which is billed separately
777
+ // below, so take it out to avoid charging it twice (upstream #21047).
778
+ // Providers that report more reasoning than output count it apart.
779
+ const rawOutputTokens = safeNum(
780
+ toNumber(effectiveUsage.outputTokens, 'outputTokens')
781
+ );
782
+ // --legacy-token-usage / LINK_ASSISTANT_AGENT_LEGACY_TOKEN_USAGE keeps
783
+ // the raw figure (reasoning counted in output, as before).
784
+ const outputTokens =
785
+ !config.legacyTokenUsage && reasoningTokens <= rawOutputTokens
786
+ ? rawOutputTokens - reasoningTokens
787
+ : rawOutputTokens;
761
788
 
762
789
  const tokens = {
763
790
  input: Math.max(0, adjustedInputTokens), // Ensure non-negative
764
- output: safeNum(toNumber(effectiveUsage.outputTokens, 'outputTokens')),
791
+ output: outputTokens,
765
792
  reasoning: reasoningTokens,
766
793
  cache: {
767
794
  write: cacheWriteTokens,