@link-assistant/agent 0.25.8 → 0.26.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/README.md CHANGED
@@ -376,6 +376,30 @@ echo "hi" | agent --json-standard claude
376
376
  - **Event Types**: `init`, `message`, `tool_use`, `tool_result`, `result`
377
377
  - **Timestamps**: ISO 8601 strings
378
378
  - **Session ID**: `session_id` (snake_case)
379
+ - **Model**: the `init` event reports the resolved model as `"model": "<provider>/<model>"`
380
+
381
+ #### Model Routing Attestation
382
+
383
+ Both standards emit exactly one `model_resolved` event per run, immediately after
384
+ the model is resolved and before the first request reaches a provider:
385
+
386
+ ```json
387
+ {
388
+ "type": "model_resolved",
389
+ "timestamp": "2026-08-11T00:12:31.320Z",
390
+ "requested": "formalai/formal-ai",
391
+ "selector": "formalai/formal-ai",
392
+ "providerID": "formalai",
393
+ "modelID": "formal-ai",
394
+ "source": "cli",
395
+ "matchesRequest": true
396
+ }
397
+ ```
398
+
399
+ Switch on `type` rather than matching the human-readable
400
+ `using explicit provider/model` log message — the log message is prose and may be
401
+ reworded, the event type is a contract. See
402
+ [docs/model-resolved-event.md](../docs/model-resolved-event.md).
379
403
 
380
404
  ### Input Formats
381
405
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.25.8",
3
+ "version": "0.26.0",
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",
@@ -303,8 +303,11 @@ export async function runContinuousServerMode(
303
303
  }
304
304
  }
305
305
 
306
- // Create event handler for the selected JSON standard
307
- const eventHandler = createEventHandler(jsonStandard, sessionID);
306
+ // Create event handler for the selected JSON standard.
307
+ // The resolved model rides along so the Claude `init` event reports it (#295).
308
+ const eventHandler = createEventHandler(jsonStandard, sessionID, {
309
+ model: `${providerID}/${modelID}`,
310
+ });
308
311
 
309
312
  // Track if we're currently processing a message
310
313
  let isProcessing = false;
@@ -526,8 +529,11 @@ export async function runContinuousDirectMode(
526
529
  sessionID = session.id;
527
530
  }
528
531
 
529
- // Create event handler for the selected JSON standard
530
- const eventHandler = createEventHandler(jsonStandard, sessionID);
532
+ // Create event handler for the selected JSON standard.
533
+ // The resolved model rides along so the Claude `init` event reports it (#295).
534
+ const eventHandler = createEventHandler(jsonStandard, sessionID, {
535
+ model: `${providerID}/${modelID}`,
536
+ });
531
537
 
532
538
  // Track if we're currently processing a message
533
539
  let isProcessing = false;
@@ -10,10 +10,12 @@ import {
10
10
  DEFAULT_MODEL_ID,
11
11
  getDefaultModel,
12
12
  getDefaultModelParts,
13
+ getDefaultModelSource,
13
14
  getDefaultCompactionModel,
14
15
  getDefaultCompactionModels,
15
16
  getDefaultCompactionSafetyMarginPercent,
16
17
  } from './defaults.ts';
18
+ import { outputModelResolved } from './model-resolution.ts';
17
19
 
18
20
  export class ModelResolutionError extends Error {
19
21
  constructor({ modelArgv, defaultModel }) {
@@ -96,6 +98,19 @@ export async function parseModelConfig(
96
98
  }
97
99
  }
98
100
 
101
+ // Attestation inputs (#295): record what was requested, and where it came
102
+ // from, before resolution can rewrite the effective model. `argv.model` is
103
+ // only treated as a request when it differs from the default, because yargs
104
+ // fills it with the default value even when no --model flag was passed.
105
+ const argvModelArg =
106
+ typeof argv.model === 'string' && argv.model !== defaultModel
107
+ ? argv.model
108
+ : null;
109
+ const requestedModel = cliModelArg ?? argvModelArg;
110
+ const modelSource = requestedModel
111
+ ? 'cli'
112
+ : getDefaultModelSource(defaultOptions);
113
+
99
114
  let providerID;
100
115
  let modelID;
101
116
 
@@ -257,6 +272,28 @@ export async function parseModelConfig(
257
272
  }
258
273
  }
259
274
 
275
+ // Machine-readable routing attestation (#295).
276
+ // Emitted last inside this function, which is still the earliest point where
277
+ // the effective model is final: --use-existing-claude-oauth above is the one
278
+ // path that can replace an already selected model, and the attestation must
279
+ // describe what actually runs. It is emitted before session creation and
280
+ // before any completion request, so a consumer can terminate the run before a
281
+ // request reaches a provider it did not ask for. Claude NDJSON streams must
282
+ // stay one line, so that standard forces compact output regardless of
283
+ // --compact-json.
284
+ const usesClaudeStandard =
285
+ argv['json-standard'] === 'claude' || argv.jsonStandard === 'claude';
286
+ outputModelResolved(
287
+ {
288
+ requested: requestedModel,
289
+ selector: modelArg,
290
+ providerID,
291
+ modelID,
292
+ source: modelSource,
293
+ },
294
+ usesClaudeStandard || argv['compact-json'] === true ? true : undefined
295
+ );
296
+
260
297
  return { providerID, modelID, compactionModel: compactionModelResult };
261
298
  }
262
299
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Machine-readable model routing attestation.
3
+ *
4
+ * The CLI already logs which provider/model it settled on, but that record is
5
+ * discriminated by a free-text `message` ("using explicit provider/model").
6
+ * A consumer that must *verify* routing — for example a supervisor that kills
7
+ * the run when the agent resolves anything other than the requested model —
8
+ * would have to match English prose, so an ordinary reword upstream silently
9
+ * disables a downstream safety check.
10
+ *
11
+ * `model_resolved` is the contract instead: one event per run, emitted on
12
+ * every resolution path (explicit `provider/model`, bare model id and default),
13
+ * before the first `step_start` and before any completion request.
14
+ *
15
+ * @see https://github.com/link-assistant/agent/issues/295
16
+ */
17
+
18
+ import { output } from './output';
19
+
20
+ /**
21
+ * Stable `type` of the routing attestation event.
22
+ *
23
+ * This string is a machine contract: consumers switch on it. Never reword it —
24
+ * a change here is a breaking change, unlike a change to any log message.
25
+ */
26
+ export const MODEL_RESOLVED_EVENT_TYPE = 'model_resolved';
27
+
28
+ /**
29
+ * Where the applied model selector came from.
30
+ * - `cli`: the `--model`/`-m` command line argument.
31
+ * - `config`: no CLI flag; an operator-configured default is in effect.
32
+ * - `default`: no CLI flag and no override; the built-in default model.
33
+ */
34
+ export type ModelSource = 'cli' | 'config' | 'default';
35
+
36
+ export interface ModelResolvedDetails {
37
+ /** Raw `--model` value, or `null` when no model was requested. */
38
+ requested: string | null;
39
+ /** Selector actually applied: `requested`, or the effective default. */
40
+ selector: string;
41
+ providerID: string;
42
+ modelID: string;
43
+ source: ModelSource;
44
+ /** ISO timestamp; injectable so tests stay deterministic. */
45
+ timestamp?: string;
46
+ }
47
+
48
+ export interface ModelResolvedEvent extends ModelResolvedDetails {
49
+ type: typeof MODEL_RESOLVED_EVENT_TYPE;
50
+ timestamp: string;
51
+ /**
52
+ * Whether the resolved provider/model satisfies `selector`. `false` means the
53
+ * run is about to talk to a model other than the one that was selected, even
54
+ * when the selector came from a default rather than from `--model`.
55
+ */
56
+ matchesRequest: boolean;
57
+ [key: string]: unknown;
58
+ }
59
+
60
+ /**
61
+ * Does `providerID`/`modelID` satisfy the selector the caller supplied?
62
+ *
63
+ * A selector with a `/` names both provider and model and must match both.
64
+ * A bare selector names only the model, so the resolved provider is free.
65
+ */
66
+ export function selectorMatchesModel(
67
+ selector: string,
68
+ providerID: string,
69
+ modelID: string
70
+ ): boolean {
71
+ if (selector.includes('/')) {
72
+ return selector === `${providerID}/${modelID}`;
73
+ }
74
+ return selector === modelID;
75
+ }
76
+
77
+ /** Build the attestation event without emitting it (pure; used by tests). */
78
+ export function buildModelResolvedEvent(
79
+ details: ModelResolvedDetails
80
+ ): ModelResolvedEvent {
81
+ const { requested, selector, providerID, modelID, source, timestamp } =
82
+ details;
83
+ return {
84
+ type: MODEL_RESOLVED_EVENT_TYPE,
85
+ timestamp: timestamp ?? new Date().toISOString(),
86
+ requested: requested ?? null,
87
+ selector,
88
+ providerID,
89
+ modelID,
90
+ source,
91
+ matchesRequest: selectorMatchesModel(selector, providerID, modelID),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Emit the attestation on stdout and return the emitted event.
97
+ *
98
+ * @param compact - Force single-line JSON (required for NDJSON streams such as
99
+ * `--output-format stream-json`); defaults to the global compact setting.
100
+ */
101
+ export function outputModelResolved(
102
+ details: ModelResolvedDetails,
103
+ compact?: boolean
104
+ ): ModelResolvedEvent {
105
+ const event = buildModelResolvedEvent(details);
106
+ output(event, compact);
107
+ return event;
108
+ }
package/src/cli/output.ts CHANGED
@@ -28,7 +28,9 @@ export type OutputType =
28
28
  | 'init'
29
29
  | 'message'
30
30
  | 'tool_result'
31
- | 'input';
31
+ | 'input'
32
+ // Routing attestation emitted once per run, see cli/model-resolution.ts (#295)
33
+ | 'model_resolved';
32
34
 
33
35
  /**
34
36
  * Base interface for all output messages
@@ -112,6 +112,25 @@ export function getDefaultModel(options: DefaultConfigOptions = {}): string {
112
112
  );
113
113
  }
114
114
 
115
+ /**
116
+ * Report where the effective default model came from.
117
+ *
118
+ * `'config'` means an operator override is in effect (`defaultOptions.defaultModel`
119
+ * or `LINK_ASSISTANT_AGENT_DEFAULT_MODEL`); `'default'` means the built-in
120
+ * `DEFAULT_MODEL`. Used by the `model_resolved` attestation so a consumer can
121
+ * tell a configured fallback from the shipped one.
122
+ *
123
+ * @see https://github.com/link-assistant/agent/issues/295
124
+ */
125
+ export function getDefaultModelSource(
126
+ options: DefaultConfigOptions = {}
127
+ ): 'config' | 'default' {
128
+ const env = options.env ?? process.env;
129
+ if (optionString(options.defaultModel)) return 'config';
130
+ if (envString(env, DEFAULT_MODEL_ENV)) return 'config';
131
+ return 'default';
132
+ }
133
+
115
134
  export function getDefaultCompactionModel(
116
135
  options: DefaultConfigOptions = {}
117
136
  ): string {
package/src/index.js CHANGED
@@ -486,8 +486,11 @@ async function runServerMode(
486
486
  }
487
487
  }
488
488
 
489
- // Create event handler for the selected JSON standard
490
- const eventHandler = createEventHandler(jsonStandard, sessionID);
489
+ // Create event handler for the selected JSON standard.
490
+ // The resolved model rides along so the Claude `init` event reports it (#295).
491
+ const eventHandler = createEventHandler(jsonStandard, sessionID, {
492
+ model: `${providerID}/${modelID}`,
493
+ });
491
494
 
492
495
  // Subscribe to all bus events and output in selected format
493
496
  const { unsub: eventUnsub, idlePromise: eventPromise } =
@@ -580,8 +583,11 @@ async function runDirectMode(
580
583
  sessionID = session.id;
581
584
  }
582
585
 
583
- // Create event handler for the selected JSON standard
584
- const eventHandler = createEventHandler(jsonStandard, sessionID);
586
+ // Create event handler for the selected JSON standard.
587
+ // The resolved model rides along so the Claude `init` event reports it (#295).
588
+ const eventHandler = createEventHandler(jsonStandard, sessionID, {
589
+ model: `${providerID}/${modelID}`,
590
+ });
585
591
 
586
592
  // Subscribe to all bus events and output in selected format
587
593
  const { unsub: eventUnsub, idlePromise: eventPromise } =
@@ -75,10 +75,14 @@ export function serializeOutput(
75
75
 
76
76
  /**
77
77
  * Convert OpenCode event to Claude event format
78
+ *
79
+ * @param model - Resolved `providerID/modelID`, reported on the `init` event so
80
+ * Claude-standard consumers see which model the run settled on (#295)
78
81
  */
79
82
  export function convertOpenCodeToClaude(
80
83
  event: OpenCodeEvent,
81
- startTime: number
84
+ startTime: number,
85
+ model?: string
82
86
  ): ClaudeEvent | null {
83
87
  const timestamp = new Date(event.timestamp).toISOString();
84
88
  const session_id = event.sessionID;
@@ -89,6 +93,7 @@ export function convertOpenCodeToClaude(
89
93
  type: 'init',
90
94
  timestamp,
91
95
  session_id,
96
+ ...(model ? { model } : {}),
92
97
  };
93
98
 
94
99
  case 'text':
@@ -157,9 +162,17 @@ export function convertOpenCodeToClaude(
157
162
 
158
163
  /**
159
164
  * Create an event output handler based on the selected standard
165
+ *
166
+ * @param options.model - Resolved `providerID/modelID` for the run; carried on
167
+ * the Claude `init` event (#295)
160
168
  */
161
- export function createEventHandler(standard: JsonStandard, sessionID: string) {
169
+ export function createEventHandler(
170
+ standard: JsonStandard,
171
+ sessionID: string,
172
+ options: { model?: string } = {}
173
+ ) {
162
174
  const startTime = Date.now();
175
+ const { model } = options;
163
176
 
164
177
  return {
165
178
  /**
@@ -169,7 +182,7 @@ export function createEventHandler(standard: JsonStandard, sessionID: string) {
169
182
  const outputStream =
170
183
  event.type === 'error' ? process.stderr : process.stdout;
171
184
  if (standard === 'claude') {
172
- const claudeEvent = convertOpenCodeToClaude(event, startTime);
185
+ const claudeEvent = convertOpenCodeToClaude(event, startTime, model);
173
186
  if (claudeEvent) {
174
187
  outputStream.write(serializeOutput(claudeEvent, standard));
175
188
  }