@link-assistant/agent 0.26.5 → 0.26.7

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/README.md CHANGED
@@ -347,6 +347,8 @@ Commands:
347
347
 
348
348
  See [docs/stdin-mode.md](../docs/stdin-mode.md) for comprehensive stdin mode documentation.
349
349
 
350
+ Hosts that embed the CLI supply configuration through `LINK_ASSISTANT_AGENT_CONFIG_DIR`, `LINK_ASSISTANT_AGENT_CONFIG` or `LINK_ASSISTANT_AGENT_CONFIG_CONTENT` — never by relocating `XDG_CONFIG_HOME`, which would also hide `gh`/`git` credentials from the agent's tool calls. See [docs/host-integration.md](../docs/host-integration.md).
351
+
350
352
  ### JSON Output Standards
351
353
 
352
354
  The agent supports two JSON output format standards via the `--json-standard` option:
@@ -773,6 +775,7 @@ For full documentation, see the [main README](../README.md) in the repository ro
773
775
  - [Tools Reference](../TOOLS.md)
774
776
  - [Usage Examples](../EXAMPLES.md)
775
777
  - [Testing Guide](../TESTING.md)
778
+ - [Host Integration](../docs/host-integration.md) — supplying config via `LINK_ASSISTANT_AGENT_CONFIG_DIR`, `LINK_ASSISTANT_AGENT_CONFIG` and `LINK_ASSISTANT_AGENT_CONFIG_CONTENT`
776
779
 
777
780
  ## License
778
781
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.26.5",
3
+ "version": "0.26.7",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -50,76 +50,77 @@
50
50
  "LICENSE"
51
51
  ],
52
52
  "dependencies": {
53
- "@agentclientprotocol/sdk": "^0.5.1",
54
- "@ai-sdk/amazon-bedrock": "^3.0.74",
55
- "@ai-sdk/anthropic": "^2.0.58",
56
- "@ai-sdk/azure": "^2.0.91",
57
- "@ai-sdk/google": "^2.0.52",
58
- "@ai-sdk/google-vertex": "^3.0.98",
59
- "@ai-sdk/groq": "^2.0.34",
60
- "@ai-sdk/mcp": "^0.0.8",
61
- "@ai-sdk/mistral": "^2.0.27",
62
- "@ai-sdk/openai": "^2.0.89",
63
- "@ai-sdk/openai-compatible": "^2.0.62",
64
- "@ai-sdk/xai": "^2.0.33",
65
- "@clack/prompts": "^0.11.0",
66
- "@hono/standard-validator": "^0.2.0",
67
- "@hono/zod-validator": "^0.7.5",
68
- "@modelcontextprotocol/sdk": "^1.22.0",
69
- "@octokit/graphql": "^9.0.3",
53
+ "@agentclientprotocol/sdk": "^1.5.1",
54
+ "@ai-sdk/amazon-bedrock": "^5.0.101",
55
+ "@ai-sdk/anthropic": "^4.0.68",
56
+ "@ai-sdk/azure": "^4.0.85",
57
+ "@ai-sdk/google": "^4.0.85",
58
+ "@ai-sdk/google-vertex": "^5.0.98",
59
+ "@ai-sdk/groq": "^4.0.52",
60
+ "@ai-sdk/mcp": "^2.0.62",
61
+ "@ai-sdk/mistral": "^4.0.54",
62
+ "@ai-sdk/openai": "^4.0.81",
63
+ "@ai-sdk/openai-compatible": "^3.0.59",
64
+ "@ai-sdk/provider": "^4.0.19",
65
+ "@ai-sdk/xai": "^5.0.12",
66
+ "@clack/prompts": "^1.8.1",
67
+ "@hono/standard-validator": "^0.4.0",
68
+ "@hono/zod-validator": "^0.9.1",
69
+ "@modelcontextprotocol/sdk": "^1.31.0",
70
+ "@octokit/graphql": "^9.0.5",
70
71
  "@octokit/rest": "^22.0.1",
71
72
  "@openauthjs/openauth": "^0.4.3",
72
- "@opencode-ai/sdk": "^1.0.78",
73
- "@openrouter/ai-sdk-provider": "^2.2.3",
74
- "@opentui/core": "0.1.107",
75
- "@opentui/solid": "0.1.107",
76
- "@parcel/watcher": "^2.5.1",
77
- "@solid-primitives/event-bus": "^1.1.2",
78
- "@standard-community/standard-json": "^0.3.5",
79
- "@standard-community/standard-openapi": "^0.2.9",
80
- "@zip.js/zip.js": "^2.8.10",
81
- "ai": "^6.0.235",
82
- "chokidar": "^4.0.3",
83
- "clipboardy": "^5.0.0",
84
- "command-stream": "^0.7.1",
73
+ "@opencode-ai/sdk": "^1.18.33",
74
+ "@openrouter/ai-sdk-provider": "^3.1.0",
75
+ "@opentui/core": "0.5.12",
76
+ "@opentui/solid": "0.5.12",
77
+ "@parcel/watcher": "^2.6.0",
78
+ "@solid-primitives/event-bus": "^1.1.4",
79
+ "@standard-community/standard-json": "^0.3.6",
80
+ "@standard-community/standard-openapi": "^0.2.10",
81
+ "@zip.js/zip.js": "^2.18.2",
82
+ "ai": "^7.0.122",
83
+ "chokidar": "^5.0.0",
84
+ "clipboardy": "^5.3.2",
85
+ "command-stream": "^1.3.0",
85
86
  "decimal.js": "^10.6.0",
86
- "diff": "^8.0.2",
87
- "fuzzysort": "^3.1.0",
88
- "glob": "^10.0.0",
89
- "gpt-tokenizer": "^3.4.0",
87
+ "diff": "^9.0.0",
88
+ "fuzzysort": "^4.0.2",
89
+ "glob": "^13.0.6",
90
+ "gpt-tokenizer": "^4.0.0",
90
91
  "gray-matter": "^4.0.3",
91
- "hono": "^4.10.6",
92
- "hono-openapi": "^1.1.1",
93
- "ignore": "^7.0.5",
92
+ "hono": "^4.13.11",
93
+ "hono-openapi": "^1.3.3",
94
+ "ignore": "^7.0.10",
94
95
  "jsonc-parser": "^3.3.1",
95
- "lino-arguments": "^0.2.5",
96
- "lino-objects-codec": "^0.1.1",
96
+ "lino-arguments": "^0.3.0",
97
+ "lino-objects-codec": "^0.8.0",
97
98
  "log-lazy": "^1.0.4",
98
- "minimatch": "^10.1.1",
99
- "remeda": "^2.32.0",
99
+ "minimatch": "^10.2.6",
100
+ "remeda": "^2.50.0",
100
101
  "solid-js": "1.9.12",
101
- "strip-ansi": "^7.1.2",
102
- "test-anywhere": "^0.7.0",
103
- "tree-sitter-bash": "^0.25.0",
104
- "turndown": "^7.2.2",
105
- "ulid": "^3.0.1",
106
- "vscode-jsonrpc": "^8.2.1",
107
- "vscode-languageserver-types": "^3.17.5",
108
- "web-tree-sitter": "^0.25.10",
102
+ "strip-ansi": "^7.2.0",
103
+ "test-anywhere": "^0.9.1",
104
+ "tree-sitter-bash": "^0.25.1",
105
+ "turndown": "^7.2.4",
106
+ "ulid": "^3.0.2",
107
+ "vscode-jsonrpc": "^9.0.3",
108
+ "vscode-languageserver-types": "^3.18.4",
109
+ "web-tree-sitter": "0.25.10",
109
110
  "xdg-basedir": "^5.1.0",
110
- "yargs": "^18.0.0",
111
- "zod": "^4.1.12"
111
+ "yargs": "^18.2.0",
112
+ "zod": "^4.6.5"
112
113
  },
113
114
  "devDependencies": {
114
- "@changesets/cli": "^2.29.7",
115
- "@eslint/js": "^9.18.0",
116
- "eslint": "^9.38.0",
115
+ "@changesets/cli": "^3.0.3",
116
+ "@eslint/js": "^10.0.1",
117
+ "eslint": "^10.11.0",
117
118
  "eslint-config-prettier": "^10.1.8",
118
- "eslint-plugin-prettier": "^5.5.4",
119
- "eslint-plugin-promise": "^7.2.1",
119
+ "eslint-plugin-prettier": "^5.5.6",
120
+ "eslint-plugin-promise": "^7.3.0",
120
121
  "husky": "^9.1.7",
121
- "lint-staged": "^16.2.6",
122
- "prettier": "^3.6.2"
122
+ "lint-staged": "^17.6.0",
123
+ "prettier": "^3.9.9"
123
124
  },
124
125
  "lint-staged": {
125
126
  "*.{js,mjs,cjs}": [
@@ -134,10 +135,15 @@
134
135
  },
135
136
  "license": "Unlicense",
136
137
  "allowScripts": {
137
- "@parcel/watcher@2.5.1": true,
138
- "tree-sitter-bash@0.25.1": true
138
+ "@parcel/watcher@2.6.0": true,
139
+ "tree-sitter-bash@0.25.1": true,
140
+ "node-pty@1.2.0-beta.15": true
139
141
  },
140
142
  "overrides": {
141
143
  "undici": "^7.16.0"
144
+ },
145
+ "dependencyPins": {
146
+ "solid-js": "https://github.com/anomalyco/opentui/issues/689",
147
+ "web-tree-sitter": "https://github.com/link-assistant/agent/issues/322"
142
148
  }
143
149
  }
@@ -138,10 +138,10 @@ export namespace Agent {
138
138
  const existing = await list();
139
139
  const result = await generateObject({
140
140
  temperature: 0.3,
141
- // The system prompt belongs in `system`, not in `prompt`: the AI SDK
141
+ // The system prompt belongs in `instructions`, not in `prompt`: the AI SDK
142
142
  // warns that a system message inside the prompt enables prompt
143
143
  // injection, and providers are free to treat it as ordinary content.
144
- system: system.map((content) => ({
144
+ instructions: system.map((content) => ({
145
145
  role: 'system' as const,
146
146
  content,
147
147
  })),
@@ -275,7 +275,8 @@ export function parseStreamJsonInput(input) {
275
275
  throw new Error(
276
276
  `Invalid stream-json input frame: ${
277
277
  error instanceof Error ? error.message : String(error)
278
- }`
278
+ }`,
279
+ { cause: error }
279
280
  );
280
281
  }
281
282
 
@@ -100,6 +100,10 @@ export async function parseModelConfig(
100
100
  }
101
101
  }
102
102
 
103
+ if (argv['dry-run'] && !cliModelArg && modelArg === defaultModel) {
104
+ modelArg = 'link-assistant/echo';
105
+ }
106
+
103
107
  // Attestation inputs (#295): record what was requested, and where it came
104
108
  // from, before resolution can rewrite the effective model. `argv.model` is
105
109
  // only treated as a request when it differs from the default, because yargs
@@ -358,6 +362,52 @@ async function resolveCompactionModelEntry(
358
362
  };
359
363
  }
360
364
 
365
+ /**
366
+ * Resolve a built-in cascade entry against the run's own provider only.
367
+ *
368
+ * The built-in cascade is narrowed to the configured provider anyway (#307),
369
+ * so resolving its short names across the whole provider registry only to
370
+ * drop the result afterwards was wasted work — and it logged records such as
371
+ * `{"providerID":"opencode","message":"resolved short model name"}` that a
372
+ * host reads as the run using OpenCode (#313). Only the base provider's own
373
+ * catalog is consulted here; any other provider is never looked up.
374
+ *
375
+ * @returns {Promise<{providerID: string, modelID: string, useSameModel: boolean} | null>}
376
+ * the entry, or `null` when the base provider does not serve it
377
+ * @see https://github.com/link-assistant/agent/issues/313
378
+ */
379
+ async function resolveEntryWithinProvider(
380
+ modelArg,
381
+ baseProviderID,
382
+ baseModelID
383
+ ) {
384
+ if (modelArg.toLowerCase() === 'same') {
385
+ return {
386
+ providerID: baseProviderID,
387
+ modelID: baseModelID,
388
+ useSameModel: true,
389
+ };
390
+ }
391
+
392
+ if (modelArg.includes('/')) {
393
+ const parts = modelArg.split('/');
394
+ if (parts[0] !== baseProviderID) {
395
+ return null;
396
+ }
397
+ return {
398
+ providerID: parts[0],
399
+ modelID: parts.slice(1).join('/'),
400
+ useSameModel: false,
401
+ };
402
+ }
403
+
404
+ const { Provider } = await import('../provider/provider.ts');
405
+ if (!(await Provider.hasModel(baseProviderID, modelArg))) {
406
+ return null;
407
+ }
408
+ return { providerID: baseProviderID, modelID: modelArg, useSameModel: false };
409
+ }
410
+
361
411
  /**
362
412
  * Narrow a built-in compaction default to the provider the run was pointed at.
363
413
  *
@@ -376,19 +426,17 @@ async function resolveCompactionModelEntry(
376
426
  * provider keeps the shipped cascade, whose extra entries are the rate-limit
377
427
  * fallbacks it was written for.
378
428
  *
379
- * @param {{providerID: string, modelID: string, useSameModel: boolean}[]} entries
429
+ * @param {{providerID: string, modelID: string, useSameModel: boolean}[]} kept
430
+ * Entries served by the base provider
431
+ * @param {string[]} dropped Default entries, as written, that were left out
380
432
  * @param {string} baseProviderID Provider of the model that runs the turns
381
433
  * @param {string} baseModelID Model that runs the turns
382
434
  * @returns {{providerID: string, modelID: string, useSameModel: boolean}[]}
383
435
  * @see https://github.com/link-assistant/agent/issues/307
384
436
  */
385
- function inheritBaseProvider(entries, baseProviderID, baseModelID) {
386
- const kept = entries.filter(
387
- (entry) => entry.useSameModel || entry.providerID === baseProviderID
388
- );
389
- const dropped = entries.filter((entry) => !kept.includes(entry));
437
+ function inheritBaseProvider(kept, dropped, baseProviderID, baseModelID) {
390
438
  if (dropped.length === 0) {
391
- return entries;
439
+ return kept;
392
440
  }
393
441
 
394
442
  const inherited =
@@ -407,7 +455,9 @@ function inheritBaseProvider(entries, baseProviderID, baseModelID) {
407
455
  hint: 'Secondary calls (compaction, session summary) inherit --model unless a compaction model is named explicitly',
408
456
  baseProviderID,
409
457
  baseModelID,
410
- dropped: dropped.map((entry) => `${entry.providerID}/${entry.modelID}`),
458
+ // The names as written in the default: they were never resolved, so they
459
+ // say nothing about which provider would have served them (#313).
460
+ dropped,
411
461
  kept: inherited.map((entry) =>
412
462
  entry.useSameModel ? 'same' : `${entry.providerID}/${entry.modelID}`
413
463
  ),
@@ -459,9 +509,30 @@ async function parseCompactionModelConfig(
459
509
  const modelNames = parseLinksNotationSequence(compactionModelsArg);
460
510
 
461
511
  if (modelNames.length > 0) {
462
- // Resolve each model in the cascade
512
+ // A cascade nobody asked for must not reach across providers (#307), and
513
+ // is not even resolved against other providers (#313).
514
+ const builtInCascade =
515
+ compactionModelsSource === 'default' &&
516
+ getDefaultCompactionModelsSource(defaultOptions) === 'default';
517
+ const usingBuiltInCascade =
518
+ builtInCascade && baseProviderID !== DEFAULT_PROVIDER_ID;
519
+
463
520
  const compactionModels = [];
521
+ const unavailable = [];
464
522
  for (const name of modelNames) {
523
+ if (usingBuiltInCascade) {
524
+ const entry = await resolveEntryWithinProvider(
525
+ name,
526
+ baseProviderID,
527
+ baseModelID
528
+ );
529
+ if (entry) {
530
+ compactionModels.push(entry);
531
+ } else {
532
+ unavailable.push(name);
533
+ }
534
+ continue;
535
+ }
465
536
  try {
466
537
  const resolved = await resolveCompactionModelEntry(
467
538
  name,
@@ -474,6 +545,12 @@ async function parseCompactionModelConfig(
474
545
  useSameModel: resolved.useSameModel,
475
546
  });
476
547
  } catch (err) {
548
+ // A model nobody asked for is not an error worth a record of its
549
+ // own (#313); the summary below lists it.
550
+ if (builtInCascade) {
551
+ unavailable.push(name);
552
+ continue;
553
+ }
477
554
  const logSkip =
478
555
  compactionModelsSource === 'default'
479
556
  ? Log.Default.debug
@@ -486,13 +563,13 @@ async function parseCompactionModelConfig(
486
563
  }
487
564
  }
488
565
 
489
- // A cascade nobody asked for must not reach across providers (#307).
490
- const usingBuiltInCascade =
491
- compactionModelsSource === 'default' &&
492
- getDefaultCompactionModelsSource(defaultOptions) === 'default' &&
493
- baseProviderID !== DEFAULT_PROVIDER_ID;
494
566
  const cascade = usingBuiltInCascade
495
- ? inheritBaseProvider(compactionModels, baseProviderID, baseModelID)
567
+ ? inheritBaseProvider(
568
+ compactionModels,
569
+ unavailable,
570
+ baseProviderID,
571
+ baseModelID
572
+ )
496
573
  : compactionModels;
497
574
 
498
575
  Log.Default.info(() => ({
@@ -501,6 +578,9 @@ async function parseCompactionModelConfig(
501
578
  m.useSameModel ? 'same' : `${m.providerID}/${m.modelID}`
502
579
  ),
503
580
  source: compactionModelsSource,
581
+ ...(unavailable.length > 0 && !usingBuiltInCascade
582
+ ? { unavailable }
583
+ : {}),
504
584
  }));
505
585
 
506
586
  // Use the first model as the primary compaction model (for backward compatibility)
@@ -543,9 +623,17 @@ async function parseCompactionModelConfig(
543
623
  compactionModelSource === 'default' &&
544
624
  getDefaultCompactionModelSource(defaultOptions) === 'default' &&
545
625
  baseProviderID !== DEFAULT_PROVIDER_ID;
546
- const [entry] = usingBuiltInCompactionModel
547
- ? inheritBaseProvider([resolved], baseProviderID, baseModelID)
548
- : [resolved];
626
+ const [entry] =
627
+ usingBuiltInCompactionModel &&
628
+ !resolved.useSameModel &&
629
+ resolved.providerID !== baseProviderID
630
+ ? inheritBaseProvider(
631
+ [],
632
+ [compactionModelArg],
633
+ baseProviderID,
634
+ baseModelID
635
+ )
636
+ : [resolved];
549
637
 
550
638
  Log.Default.info(() => ({
551
639
  message: 'using single compaction model',
@@ -0,0 +1,62 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Host integration contract: how an embedding host hands the CLI its config.
6
+ *
7
+ * A host supplies configuration through the CLI's own variables:
8
+ *
9
+ * LINK_ASSISTANT_AGENT_CONFIG_DIR a directory holding opencode.json(c)
10
+ * LINK_ASSISTANT_AGENT_CONFIG a single config file
11
+ * LINK_ASSISTANT_AGENT_CONFIG_CONTENT the config itself, as JSON
12
+ *
13
+ * Relocating `XDG_CONFIG_HOME` to feed the CLI a generated config also moves
14
+ * the config home of every tool the agent runs — `gh` then finds no
15
+ * `hosts.yml` and the session loses GitHub authentication (#314). This module
16
+ * spots that setup so the CLI can point the host at the variables above.
17
+ *
18
+ * @see https://github.com/link-assistant/agent/issues/314
19
+ * @see ../../../docs/host-integration.md
20
+ */
21
+ export namespace HostConfigHint {
22
+ export const CONFIG_ENV_VARS = [
23
+ 'LINK_ASSISTANT_AGENT_CONFIG_DIR',
24
+ 'LINK_ASSISTANT_AGENT_CONFIG',
25
+ 'LINK_ASSISTANT_AGENT_CONFIG_CONTENT',
26
+ ] as const;
27
+
28
+ export interface Hint {
29
+ message: string;
30
+ xdgConfigHome: string;
31
+ realConfigHome: string;
32
+ hint: string;
33
+ }
34
+
35
+ /**
36
+ * Return a hint when `XDG_CONFIG_HOME` points away from the user's real
37
+ * config home and none of the CLI's own config variables is set — the
38
+ * signature of a host relocating XDG to deliver config. Pure, so it can be
39
+ * tested with any environment.
40
+ */
41
+ export function detect(
42
+ env: Record<string, string | undefined> = process.env,
43
+ homedir: string = os.homedir()
44
+ ): Hint | undefined {
45
+ const xdgConfigHome = env.XDG_CONFIG_HOME?.trim();
46
+ if (!xdgConfigHome) return undefined;
47
+
48
+ const realConfigHome = path.join(homedir, '.config');
49
+ if (path.resolve(xdgConfigHome) === path.resolve(realConfigHome)) {
50
+ return undefined;
51
+ }
52
+
53
+ if (CONFIG_ENV_VARS.some((name) => env[name]?.trim())) return undefined;
54
+
55
+ return {
56
+ message: 'XDG_CONFIG_HOME is relocated',
57
+ xdgConfigHome,
58
+ realConfigHome,
59
+ hint: `To supply agent config, set ${CONFIG_ENV_VARS.join(', ')} instead of XDG_CONFIG_HOME: tools the agent runs (gh, git) read their config and credentials from XDG_CONFIG_HOME too`,
60
+ };
61
+ }
62
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  getConfigSnapshot,
8
8
  } from './config/config.ts';
9
9
  import { setProcessName } from './cli/process-name.ts';
10
+ import { HostConfigHint } from './config/host-config-hint.ts';
10
11
  setProcessName('agent');
11
12
  import { Server } from './server/server.ts';
12
13
  import { Instance } from './project/instance.ts';
@@ -909,6 +910,13 @@ async function main() {
909
910
  config: getConfigSnapshot(),
910
911
  }));
911
912
 
913
+ // A host that relocates XDG_CONFIG_HOME to deliver agent config also
914
+ // hides gh/git credentials from the agent's tool calls (#314).
915
+ const hostConfigHint = HostConfigHint.detect();
916
+ if (hostConfigHint) {
917
+ Log.Default.warn(() => hostConfigHint);
918
+ }
919
+
912
920
  // Global fetch monkey-patch for verbose HTTP logging (#221).
913
921
  if (!globalThis.__agentVerboseFetchInstalled) {
914
922
  globalThis.fetch = createVerboseFetch(globalThis.fetch, {
@@ -15,7 +15,11 @@
15
15
  * @see https://github.com/link-foundation/lino-objects-codec
16
16
  */
17
17
 
18
- import type { LanguageModelV2, LanguageModelV2CallOptions } from 'ai';
18
+ import type {
19
+ LanguageModelV4,
20
+ LanguageModelV4CallOptions,
21
+ LanguageModelV4GenerateResult,
22
+ } from '@ai-sdk/provider';
19
23
  import { Log } from '../util/log';
20
24
  import { createEchoModel } from './echo';
21
25
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
@@ -33,7 +37,7 @@ const CACHE_ROOT = join(__dirname, '../../data/api-cache');
33
37
  * Generate a cache key from the prompt
34
38
  */
35
39
  function generateCacheKey(
36
- prompt: LanguageModelV2CallOptions['prompt']
40
+ prompt: LanguageModelV4CallOptions['prompt']
37
41
  ): string {
38
42
  // Simple hash of the prompt content
39
43
  const content = JSON.stringify(prompt);
@@ -64,14 +68,43 @@ function generatePartId(): string {
64
68
  /**
65
69
  * Load cached response from file using Links Notation format
66
70
  */
67
- function loadCachedResponse(filePath: string): any | null {
71
+ function loadCachedResponse(
72
+ filePath: string
73
+ ): LanguageModelV4GenerateResult | null {
68
74
  try {
69
75
  if (!existsSync(filePath)) {
70
76
  return null;
71
77
  }
72
78
  const content = readFileSync(filePath, 'utf8');
73
79
  // Decode from Links Notation format
74
- return decode({ notation: content });
80
+ const cached = decode({ notation: content });
81
+ // Existing .lino files may contain AI SDK 6 responses. Normalize their
82
+ // finish reason and usage so they remain usable by AI SDK 7.
83
+ if (typeof cached?.finishReason === 'string') {
84
+ const promptTokens = cached.usage?.promptTokens ?? 0;
85
+ const completionTokens = cached.usage?.completionTokens ?? 0;
86
+ return {
87
+ ...cached,
88
+ finishReason: {
89
+ unified: cached.finishReason,
90
+ raw: cached.finishReason,
91
+ },
92
+ usage: {
93
+ inputTokens: {
94
+ total: promptTokens,
95
+ noCache: promptTokens,
96
+ cacheRead: 0,
97
+ cacheWrite: 0,
98
+ },
99
+ outputTokens: {
100
+ total: completionTokens,
101
+ text: completionTokens,
102
+ reasoning: 0,
103
+ },
104
+ },
105
+ };
106
+ }
107
+ return cached;
75
108
  } catch (error: any) {
76
109
  log.warn('Failed to load cached response', {
77
110
  filePath,
@@ -104,21 +137,21 @@ function saveCachedResponse(filePath: string, response: any): void {
104
137
 
105
138
  /**
106
139
  * Creates a cache language model that stores/retrieves responses
107
- * Implements LanguageModelV2 interface for AI SDK 6.x compatibility
140
+ * Implements the AI SDK 7 language model interface.
108
141
  */
109
142
  export function createCacheModel(
110
143
  providerId: string,
111
144
  modelId: string
112
- ): LanguageModelV2 {
113
- const model: LanguageModelV2 = {
114
- specificationVersion: 'v2',
145
+ ): LanguageModelV4 {
146
+ const model: LanguageModelV4 = {
147
+ specificationVersion: 'v4',
115
148
  provider: 'link-assistant',
116
149
  modelId: `${providerId}/${modelId}`,
117
150
 
118
151
  // No external URLs are supported by this synthetic provider
119
152
  supportedUrls: {},
120
153
 
121
- async doGenerate(options: LanguageModelV2CallOptions) {
154
+ async doGenerate(options: LanguageModelV4CallOptions) {
122
155
  const cacheKey = generateCacheKey(options.prompt);
123
156
  const cachePath = getCachePath(providerId, modelId, cacheKey);
124
157
 
@@ -144,7 +177,7 @@ export function createCacheModel(
144
177
  return response;
145
178
  },
146
179
 
147
- async doStream(options: LanguageModelV2CallOptions) {
180
+ async doStream(options: LanguageModelV4CallOptions) {
148
181
  const cacheKey = generateCacheKey(options.prompt);
149
182
  const cachePath = getCachePath(providerId, modelId, cacheKey);
150
183
 
@@ -163,7 +196,7 @@ export function createCacheModel(
163
196
  cached.content?.[0]?.text || cached.text || 'Cached response';
164
197
  const textPartId = generatePartId();
165
198
 
166
- // Create a ReadableStream with LanguageModelV2StreamPart format
199
+ // Create a ReadableStream with LanguageModelV4StreamPart format.
167
200
  const stream = new ReadableStream({
168
201
  async start(controller) {
169
202
  // Emit text-start
@@ -195,10 +228,19 @@ export function createCacheModel(
195
228
  // Emit finish event
196
229
  controller.enqueue({
197
230
  type: 'finish',
198
- finishReason: 'stop',
231
+ finishReason: { unified: 'stop', raw: 'stop' },
199
232
  usage: cached.usage || {
200
- promptTokens: Math.ceil(echoText.length / 4),
201
- completionTokens: Math.ceil(echoText.length / 4),
233
+ inputTokens: {
234
+ total: Math.ceil(echoText.length / 4),
235
+ noCache: Math.ceil(echoText.length / 4),
236
+ cacheRead: 0,
237
+ cacheWrite: 0,
238
+ },
239
+ outputTokens: {
240
+ total: Math.ceil(echoText.length / 4),
241
+ text: Math.ceil(echoText.length / 4),
242
+ reasoning: 0,
243
+ },
202
244
  },
203
245
  providerMetadata: undefined,
204
246
  });
@@ -211,7 +253,6 @@ export function createCacheModel(
211
253
  stream,
212
254
  request: undefined,
213
255
  response: undefined,
214
- warnings: [],
215
256
  };
216
257
  }
217
258
 
@@ -237,7 +278,7 @@ export function createCacheModel(
237
278
  */
238
279
  export function createCacheProvider(options?: { name?: string }) {
239
280
  return {
240
- languageModel(modelId: string): LanguageModelV2 {
281
+ languageModel(modelId: string): LanguageModelV4 {
241
282
  // Parse provider/model from modelId like "opencode/grok-code"
242
283
  const parts = modelId.split('/');
243
284
  if (parts.length < 2) {
@@ -14,7 +14,10 @@
14
14
  * Input: "How are you?" -> Output: "How are you?"
15
15
  */
16
16
 
17
- import type { LanguageModelV2, LanguageModelV2CallOptions } from 'ai';
17
+ import type {
18
+ LanguageModelV4,
19
+ LanguageModelV4CallOptions,
20
+ } from '@ai-sdk/provider';
18
21
  import { Log } from '../util/log';
19
22
 
20
23
  const log = Log.create({ service: 'provider.echo' });
@@ -23,7 +26,7 @@ const log = Log.create({ service: 'provider.echo' });
23
26
  * Extract text content from the prompt messages
24
27
  */
25
28
  function extractTextFromPrompt(
26
- prompt: LanguageModelV2CallOptions['prompt']
29
+ prompt: LanguageModelV4CallOptions['prompt']
27
30
  ): string {
28
31
  const textParts: string[] = [];
29
32
 
@@ -52,18 +55,18 @@ function generatePartId(): string {
52
55
 
53
56
  /**
54
57
  * Creates an echo language model that echoes back the user's input
55
- * Implements LanguageModelV2 interface for AI SDK 6.x compatibility
58
+ * Implements the AI SDK 7 language model interface.
56
59
  */
57
- export function createEchoModel(modelId: string = 'echo'): LanguageModelV2 {
58
- const model: LanguageModelV2 = {
59
- specificationVersion: 'v2',
60
+ export function createEchoModel(modelId: string = 'echo'): LanguageModelV4 {
61
+ const model: LanguageModelV4 = {
62
+ specificationVersion: 'v4',
60
63
  provider: 'link-assistant',
61
64
  modelId,
62
65
 
63
66
  // No external URLs are supported by this synthetic provider
64
67
  supportedUrls: {},
65
68
 
66
- async doGenerate(options: LanguageModelV2CallOptions) {
69
+ async doGenerate(options: LanguageModelV4CallOptions) {
67
70
  const echoText = extractTextFromPrompt(options.prompt);
68
71
  log.info('echo generate', { modelId, echoText });
69
72
 
@@ -78,10 +81,19 @@ export function createEchoModel(modelId: string = 'echo'): LanguageModelV2 {
78
81
  text: echoText,
79
82
  },
80
83
  ],
81
- finishReason: 'stop' as const,
84
+ finishReason: { unified: 'stop' as const, raw: 'stop' },
82
85
  usage: {
83
- promptTokens,
84
- completionTokens,
86
+ inputTokens: {
87
+ total: promptTokens,
88
+ noCache: promptTokens,
89
+ cacheRead: 0,
90
+ cacheWrite: 0,
91
+ },
92
+ outputTokens: {
93
+ total: completionTokens,
94
+ text: completionTokens,
95
+ reasoning: 0,
96
+ },
85
97
  },
86
98
  warnings: [],
87
99
  providerMetadata: undefined,
@@ -90,7 +102,7 @@ export function createEchoModel(modelId: string = 'echo'): LanguageModelV2 {
90
102
  };
91
103
  },
92
104
 
93
- async doStream(options: LanguageModelV2CallOptions) {
105
+ async doStream(options: LanguageModelV4CallOptions) {
94
106
  const echoText = extractTextFromPrompt(options.prompt);
95
107
  log.info('echo stream', { modelId, echoText });
96
108
 
@@ -100,8 +112,7 @@ export function createEchoModel(modelId: string = 'echo'): LanguageModelV2 {
100
112
 
101
113
  const textPartId = generatePartId();
102
114
 
103
- // Create a ReadableStream with LanguageModelV2StreamPart format
104
- // V2 format uses: text-start -> text-delta (with delta) -> text-end -> finish
115
+ // Stream text-start -> text-delta -> text-end -> finish.
105
116
  const stream = new ReadableStream({
106
117
  async start(controller) {
107
118
  // Emit text-start
@@ -133,10 +144,19 @@ export function createEchoModel(modelId: string = 'echo'): LanguageModelV2 {
133
144
  // Emit finish event with usage information
134
145
  controller.enqueue({
135
146
  type: 'finish',
136
- finishReason: 'stop',
147
+ finishReason: { unified: 'stop', raw: 'stop' },
137
148
  usage: {
138
- promptTokens,
139
- completionTokens,
149
+ inputTokens: {
150
+ total: promptTokens,
151
+ noCache: promptTokens,
152
+ cacheRead: 0,
153
+ cacheWrite: 0,
154
+ },
155
+ outputTokens: {
156
+ total: completionTokens,
157
+ text: completionTokens,
158
+ reasoning: 0,
159
+ },
140
160
  },
141
161
  providerMetadata: undefined,
142
162
  });
@@ -149,7 +169,6 @@ export function createEchoModel(modelId: string = 'echo'): LanguageModelV2 {
149
169
  stream,
150
170
  request: undefined,
151
171
  response: undefined,
152
- warnings: [],
153
172
  };
154
173
  },
155
174
  };
@@ -162,7 +181,7 @@ export function createEchoModel(modelId: string = 'echo'): LanguageModelV2 {
162
181
  */
163
182
  export function createEchoProvider(options?: { name?: string }) {
164
183
  return {
165
- languageModel(modelId: string): LanguageModelV2 {
184
+ languageModel(modelId: string): LanguageModelV4 {
166
185
  return createEchoModel(modelId);
167
186
  },
168
187
  textEmbeddingModel() {
@@ -90,8 +90,7 @@ export namespace OpenCodeZen {
90
90
  }
91
91
 
92
92
  const body = (await response.json().catch(() => undefined)) as
93
- | { data?: Array<{ id?: unknown }> }
94
- | undefined;
93
+ { data?: Array<{ id?: unknown }> } | undefined;
95
94
  const rows = Array.isArray(body?.data) ? body.data : [];
96
95
  return new Set(
97
96
  rows
@@ -38,6 +38,14 @@ import { createGroq } from '@ai-sdk/groq';
38
38
 
39
39
  export namespace Provider {
40
40
  const log = Log.create({ service: 'provider' });
41
+ /**
42
+ * Catalog lookups — which providers are installed, which provider a short
43
+ * model name would map to — describe candidates, not the model a run uses.
44
+ * They are logged under their own service and never carry `providerID` /
45
+ * `modelID` fields, which stream consumers read as the run's model identity
46
+ * (#313). The active model is attested by the `model_resolved` event.
47
+ */
48
+ const registryLog = Log.create({ service: 'provider-registry' });
41
49
 
42
50
  /**
43
51
  * Bundled providers - these are pre-installed and imported statically
@@ -722,9 +730,9 @@ export namespace Provider {
722
730
  'link-assistant': async () => {
723
731
  // Echo provider is always available - no external dependencies needed
724
732
  return {
725
- autoload: config.dryRun, // Auto-load only in dry-run mode
733
+ autoload: true, // Also available through --model link-assistant/echo
726
734
  async getModel(_sdk: any, modelID: string) {
727
- // Return our custom echo model that implements LanguageModelV1
735
+ // Return our custom AI SDK language model.
728
736
  return createEchoModel(modelID);
729
737
  },
730
738
  options: {},
@@ -754,7 +762,7 @@ export namespace Provider {
754
762
  const [providerId, ...modelParts] = parts;
755
763
  const actualModelId = modelParts.join('/');
756
764
 
757
- // Return our custom cache model that implements LanguageModelV1
765
+ // Return our custom AI SDK language model.
758
766
  return createCacheModel(providerId, actualModelId);
759
767
  },
760
768
  options: {},
@@ -1235,8 +1243,11 @@ export namespace Provider {
1235
1243
  delete providers[providerID];
1236
1244
  continue;
1237
1245
  }
1238
- log.info(() => ({ message: 'found', providerID }));
1239
1246
  }
1247
+ registryLog.info(() => ({
1248
+ message: 'providers available',
1249
+ available: Object.keys(providers),
1250
+ }));
1240
1251
 
1241
1252
  return {
1242
1253
  models,
@@ -1696,6 +1707,15 @@ export namespace Provider {
1696
1707
  return state().then((s) => s.providers[providerID]);
1697
1708
  }
1698
1709
 
1710
+ /**
1711
+ * Whether a provider's catalog lists a model. Looks at that one provider
1712
+ * only and logs nothing (#313).
1713
+ */
1714
+ export async function hasModel(providerID: string, modelID: string) {
1715
+ const provider = await getProvider(providerID);
1716
+ return Boolean(provider?.info.models[modelID]);
1717
+ }
1718
+
1699
1719
  async function getLiveModelInfo(providerID: string, modelID: string) {
1700
1720
  if (providerID !== 'opencode') return undefined;
1701
1721
  return OpenCodeZen.getLiveFreeModelInfo(modelID);
@@ -1752,8 +1772,16 @@ export namespace Provider {
1752
1772
  const isSyntheticProvider =
1753
1773
  providerID === 'link-assistant' || providerID === 'link-assistant/cache';
1754
1774
 
1755
- // For synthetic providers, we don't need model info from the database
1756
- let info = isSyntheticProvider ? null : provider.info.models[modelID];
1775
+ // Synthetic models still need metadata for context limits and accounting.
1776
+ let info =
1777
+ provider.info.models[modelID] ??
1778
+ (isSyntheticProvider
1779
+ ? {
1780
+ ...s.providers['link-assistant'].info.models.echo,
1781
+ id: modelID,
1782
+ name: modelID,
1783
+ }
1784
+ : undefined);
1757
1785
  if (!isSyntheticProvider && !info) {
1758
1786
  // Model not in provider's known list - try refreshing the cache first (#200)
1759
1787
  // This handles stale bundled data or expired cache (1-hour TTL)
@@ -2084,9 +2112,10 @@ export namespace Provider {
2084
2112
  if (kiloUniqueModels.includes(modelID)) {
2085
2113
  const kiloProvider = s.providers['kilo'];
2086
2114
  if (kiloProvider && kiloProvider.info.models[modelID]) {
2087
- log.info(() => ({
2115
+ registryLog.info(() => ({
2088
2116
  message: 'resolved short model name to kilo (unique)',
2089
- modelID,
2117
+ name: modelID,
2118
+ candidate: `kilo/${modelID}`,
2090
2119
  }));
2091
2120
  return { providerID: 'kilo', modelID };
2092
2121
  }
@@ -2095,9 +2124,10 @@ export namespace Provider {
2095
2124
  if (modelID === 'formal-ai') {
2096
2125
  const formalAiProvider = s.providers['formal-ai'];
2097
2126
  if (formalAiProvider && formalAiProvider.info.models[modelID]) {
2098
- log.info(() => ({
2127
+ registryLog.info(() => ({
2099
2128
  message: 'resolved short model name to formal-ai (canonical)',
2100
- modelID,
2129
+ name: modelID,
2130
+ candidate: `formal-ai/${modelID}`,
2101
2131
  }));
2102
2132
  return { providerID: 'formal-ai', modelID };
2103
2133
  }
@@ -2117,10 +2147,10 @@ export namespace Provider {
2117
2147
 
2118
2148
  if (matchingProviders.length === 1) {
2119
2149
  const providerID = matchingProviders[0];
2120
- log.info(() => ({
2150
+ registryLog.info(() => ({
2121
2151
  message: 'resolved short model name (single match)',
2122
- modelID,
2123
- providerID,
2152
+ name: modelID,
2153
+ candidate: `${providerID}/${modelID}`,
2124
2154
  }));
2125
2155
  return { providerID, modelID };
2126
2156
  }
@@ -2128,9 +2158,10 @@ export namespace Provider {
2128
2158
  // Multiple providers have this model - prefer OpenCode for shared free models
2129
2159
  // This follows the convention that opencode is the primary free provider
2130
2160
  if (matchingProviders.includes('opencode')) {
2131
- log.info(() => ({
2161
+ registryLog.info(() => ({
2132
2162
  message: 'resolved short model name to opencode (multiple providers)',
2133
- modelID,
2163
+ name: modelID,
2164
+ candidate: `opencode/${modelID}`,
2134
2165
  availableProviders: matchingProviders,
2135
2166
  }));
2136
2167
  return { providerID: 'opencode', modelID };
@@ -2138,10 +2169,10 @@ export namespace Provider {
2138
2169
 
2139
2170
  // Fallback to first matching provider
2140
2171
  const providerID = matchingProviders[0];
2141
- log.info(() => ({
2172
+ registryLog.info(() => ({
2142
2173
  message: 'resolved short model name (fallback)',
2143
- modelID,
2144
- providerID,
2174
+ name: modelID,
2175
+ candidate: `${providerID}/${modelID}`,
2145
2176
  availableProviders: matchingProviders,
2146
2177
  }));
2147
2178
  return { providerID, modelID };
@@ -387,7 +387,7 @@ export namespace SessionCompaction {
387
387
  headers: model.info.headers,
388
388
  abortSignal: input.abort,
389
389
  tools: model.info.tool_call ? {} : undefined,
390
- system: system.map((content) => ({ role: 'system', content })),
390
+ instructions: system.map((content) => ({ role: 'system', content })),
391
391
  messages: [
392
392
  ...safeModelMessages,
393
393
  {
@@ -116,7 +116,7 @@ export namespace SessionProcessor {
116
116
  let reasoningMap: Record<string, MessageV2.ReasoningPart> = {};
117
117
  const stream = fn();
118
118
 
119
- for await (const value of stream.fullStream) {
119
+ for await (const value of stream.stream) {
120
120
  input.abort.throwIfAborted();
121
121
  switch (value.type) {
122
122
  case 'start':
@@ -15,7 +15,7 @@ import {
15
15
  type Tool as AITool,
16
16
  tool,
17
17
  wrapLanguageModel,
18
- stepCountIs,
18
+ isStepCount,
19
19
  jsonSchema,
20
20
  } from 'ai';
21
21
  import { SessionCompaction } from './compaction';
@@ -1011,10 +1011,10 @@ export namespace SessionPrompt {
1011
1011
  model.providerID,
1012
1012
  params.options
1013
1013
  ),
1014
- stopWhen: stepCountIs(1),
1014
+ stopWhen: isStepCount(1),
1015
1015
  temperature: params.temperature,
1016
1016
  topP: params.topP,
1017
- system: system.map((content) => ({ role: 'system', content })),
1017
+ instructions: system.map((content) => ({ role: 'system', content })),
1018
1018
  messages: safeModelMessages,
1019
1019
  tools: model.info?.tool_call === false ? undefined : tools,
1020
1020
  model: wrapLanguageModel({
@@ -1059,6 +1059,9 @@ export namespace SessionPrompt {
1059
1059
  if (input.model) {
1060
1060
  return input.model;
1061
1061
  }
1062
+ if (config.dryRun) {
1063
+ return Provider.defaultModel();
1064
+ }
1062
1065
  if (input.agent.model) {
1063
1066
  return input.agent.model;
1064
1067
  }
@@ -1942,7 +1945,7 @@ export namespace SessionPrompt {
1942
1945
  small.providerID,
1943
1946
  options
1944
1947
  ),
1945
- system: safeTitleSystemMessages.map((content) => ({
1948
+ instructions: safeTitleSystemMessages.map((content) => ({
1946
1949
  role: 'system',
1947
1950
  content,
1948
1951
  })),
@@ -222,10 +222,10 @@ export namespace SessionSummary {
222
222
  model.providerID,
223
223
  {}
224
224
  ),
225
- // The system prompt belongs in `system`, not in `messages`: the AI SDK
225
+ // The system prompt belongs in `instructions`, not in `messages`: the AI SDK
226
226
  // warns that a system message inside `messages` enables prompt
227
227
  // injection, and providers are free to treat it as ordinary content.
228
- system: systemPrompts.map((content) => ({
228
+ instructions: systemPrompts.map((content) => ({
229
229
  role: 'system' as const,
230
230
  content,
231
231
  })),