@link-assistant/agent 0.22.9 → 0.22.10

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
@@ -195,6 +195,9 @@ echo "hi" | agent
195
195
 
196
196
  # Other free models (in order of recommendation)
197
197
  echo "hi" | agent --model opencode/minimax-m2.5-free
198
+ echo "hi" | agent --model opencode/ling-2.6-flash-free
199
+ echo "hi" | agent --model opencode/hy3-preview-free
200
+ echo "hi" | agent --model opencode/nemotron-3-super-free
198
201
  echo "hi" | agent --model opencode/gpt-5-nano
199
202
  echo "hi" | agent --model opencode/big-pickle
200
203
 
@@ -602,13 +605,13 @@ bun run src/index.js
602
605
 
603
606
  ```bash
604
607
  # Run all tests
605
- bun test
608
+ npm test
606
609
 
607
610
  # Run specific test file
608
- bun test tests/mcp.test.js
609
- bun test tests/websearch.tools.test.js
610
- bun test tests/batch.tools.test.js
611
- bun test tests/plaintext.input.test.js
611
+ bun test ./tests/integration/mcp.js
612
+ bun test ./tests/integration/websearch.tools.js
613
+ bun test ./tests/integration/batch.tools.js
614
+ bun test ./tests/integration/plaintext.input.js
612
615
  ```
613
616
 
614
617
  For detailed testing information including how to run tests manually and trigger CI tests, see [TESTING.md](../TESTING.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.22.9",
3
+ "version": "0.22.10",
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",
@@ -9,8 +9,8 @@
9
9
  },
10
10
  "scripts": {
11
11
  "dev": "bun run src/index.js",
12
- "test": "bun test tests/*.test.js tests/*.test.ts",
13
- "test:integration": "bun test tests/integration/basic.test.js",
12
+ "test": "bun test ./tests/*.js ./tests/*.ts",
13
+ "test:integration": "bun test ./tests/integration/basic.js",
14
14
  "lint": "eslint .",
15
15
  "lint:fix": "eslint . --fix",
16
16
  "format": "prettier --write .",
package/src/cli/argv.ts CHANGED
@@ -105,7 +105,7 @@ export function getCompactionSafetyMarginFromProcessArgv(): string | null {
105
105
  /**
106
106
  * Extract --compaction-models argument directly from process.argv
107
107
  * The value is a links notation references sequence, e.g.:
108
- * "(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)"
108
+ * "(big-pickle minimax-m2.5-free nemotron-3-super-free hy3-preview-free ling-2.6-flash-free gpt-5-nano same)"
109
109
  * @returns The compaction models argument from CLI or null if not found
110
110
  * @see https://github.com/link-assistant/agent/issues/232
111
111
  */
@@ -51,7 +51,8 @@ const log = Log.create({ service: 'resume' });
51
51
  export async function resolveResumeSession(argv, compactJson) {
52
52
  const resumeSessionID = argv.resume;
53
53
  const shouldContinue = argv.continue === true;
54
- const noFork = argv['no-fork'] === true;
54
+ const noFork =
55
+ argv['no-fork'] === true || argv.noFork === true || argv.fork === false;
55
56
 
56
57
  // If neither --resume nor --continue is specified, return null to create new session
57
58
  if (!resumeSessionID && !shouldContinue) {
@@ -1,62 +1 @@
1
- /**
2
- * Default CLI configuration values.
3
- *
4
- * Centralizing defaults here ensures all code references the same value (#208).
5
- * When the default model changes, update this file only.
6
- */
7
-
8
- /** Default model used when no `--model` CLI argument is provided. */
9
- export const DEFAULT_MODEL = 'opencode/nemotron-3-super-free';
10
-
11
- /** Default provider ID extracted from DEFAULT_MODEL. */
12
- export const DEFAULT_PROVIDER_ID = DEFAULT_MODEL.split('/')[0];
13
-
14
- /** Default model ID extracted from DEFAULT_MODEL. */
15
- export const DEFAULT_MODEL_ID = DEFAULT_MODEL.split('/').slice(1).join('/');
16
-
17
- /**
18
- * Default compaction model used when no `--compaction-model` CLI argument is provided.
19
- * gpt-5-nano has a 400K context window, larger than most free base models (~200K),
20
- * which allows compacting 100% of the base model's context without a safety margin.
21
- * The special value "same" means use the same model as `--model`.
22
- * @see https://github.com/link-assistant/agent/issues/219
23
- */
24
- export const DEFAULT_COMPACTION_MODEL = 'opencode/gpt-5-nano';
25
-
26
- /**
27
- * Default compaction models cascade, ordered from smallest/cheapest context to largest.
28
- * During compaction, the system tries each model in order. If the used context exceeds
29
- * a model's context limit, it skips to the next larger model. If a model's rate limit
30
- * is reached, it also skips to the next model.
31
- * The special value "same" means use the same model as `--model`.
32
- *
33
- * Parsed as links notation references sequence (single anonymous link):
34
- * "(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)"
35
- *
36
- * Context limits (approximate):
37
- * big-pickle: ~200K
38
- * minimax-m2.5-free: ~200K
39
- * nemotron-3-super-free: ~262K (default model)
40
- * gpt-5-nano: ~400K
41
- * same: (base model's context)
42
- *
43
- * Note: qwen3.6-plus-free was removed — free promotion ended April 2026.
44
- * @see https://github.com/link-assistant/agent/issues/242
45
- * @see https://github.com/link-assistant/agent/issues/232
46
- */
47
- export const DEFAULT_COMPACTION_MODELS =
48
- '(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)';
49
-
50
- /**
51
- * Default compaction safety margin as a percentage of usable context window.
52
- * Applied only when the compaction model has a context window equal to or smaller
53
- * than the base model. When the compaction model has a larger context, the margin
54
- * is automatically set to 0 (allowing 100% context usage).
55
- *
56
- * Increased from 15% to 25% to reduce probability of context overflow errors,
57
- * especially when providers return inaccurate or zero token counts.
58
- * Matches OpenCode upstream's 75% threshold (25% margin).
59
- * @see https://github.com/link-assistant/agent/issues/219
60
- * @see https://github.com/link-assistant/agent/issues/249
61
- */
62
- export const DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT = 25;
1
+ export * from '../config/defaults.ts';
@@ -8,9 +8,11 @@ import { Log } from '../util/log.ts';
8
8
  import {
9
9
  DEFAULT_PROVIDER_ID,
10
10
  DEFAULT_MODEL_ID,
11
- DEFAULT_COMPACTION_MODEL,
12
- DEFAULT_COMPACTION_MODELS,
13
- DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
11
+ getDefaultModel,
12
+ getDefaultModelParts,
13
+ getDefaultCompactionModel,
14
+ getDefaultCompactionModels,
15
+ getDefaultCompactionSafetyMarginPercent,
14
16
  } from './defaults.ts';
15
17
 
16
18
  /**
@@ -20,12 +22,21 @@ import {
20
22
  * @param {function} outputStatus - Function to output status messages
21
23
  * @returns {Promise<{providerID: string, modelID: string}>}
22
24
  */
23
- export async function parseModelConfig(argv, outputError, outputStatus) {
25
+ export async function parseModelConfig(
26
+ argv,
27
+ outputError,
28
+ outputStatus,
29
+ defaultOptions = {}
30
+ ) {
31
+ const defaultModel = getDefaultModel(defaultOptions);
32
+ const { providerID: defaultProviderID, modelID: defaultModelID } =
33
+ getDefaultModelParts(defaultOptions);
34
+
24
35
  // Safeguard: validate argv.model against process.argv to detect yargs/cache mismatch (#192, #196, #239)
25
36
  // This is critical because yargs under Bun may fail to parse --model correctly,
26
37
  // returning the default value instead of the user's CLI argument.
27
38
  const cliModelArg = getModelFromProcessArgv();
28
- let modelArg = argv.model;
39
+ let modelArg = argv.model ?? defaultModel;
29
40
 
30
41
  // Diagnostic logging: always log raw argv sources when debugging model resolution (#239)
31
42
  // Bun global installs may have different process.argv structure (oven-sh/bun#22157)
@@ -54,7 +65,7 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
54
65
  // Always use CLI value when available, even if it matches yargs
55
66
  // This ensures we use the actual CLI argument, not a cached/default yargs value
56
67
  modelArg = cliModelArg;
57
- } else if (modelArg === `${DEFAULT_PROVIDER_ID}/${DEFAULT_MODEL_ID}`) {
68
+ } else if (modelArg === defaultModel) {
58
69
  // cliModelArg is null AND yargs returned the default — check if process.argv
59
70
  // actually contains --model to detect silent yargs/Bun mismatch (#239)
60
71
  const rawArgvStr = process.argv.join(' ');
@@ -74,7 +85,7 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
74
85
  typeof globalThis.Bun !== 'undefined' && globalThis.Bun.argv
75
86
  ? globalThis.Bun.argv
76
87
  : '(not available)',
77
- defaultModel: `${DEFAULT_PROVIDER_ID}/${DEFAULT_MODEL_ID}`,
88
+ defaultModel,
78
89
  }));
79
90
  }
80
91
  }
@@ -111,38 +122,54 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
111
122
  // fail immediately instead of silently falling back to a different model.
112
123
  // However, if the model is the default (no --model CLI flag), warn but proceed (#239).
113
124
  // The models.dev API may lag behind the provider's actual model availability.
114
- const isDefaultModel = !cliModelArg;
125
+ const isDefaultModel = !cliModelArg && modelArg === defaultModel;
115
126
  try {
116
127
  const { Provider } = await import('../provider/provider.ts');
117
128
  const s = await Provider.state();
118
129
  const provider = s.providers[providerID];
119
130
  if (provider && !provider.info.models[modelID]) {
120
- const availableModels = Object.keys(provider.info.models).slice(0, 10);
121
- if (isDefaultModel) {
122
- // Default model not in models.dev catalog — warn but proceed (#239)
123
- // The provider may still accept it; models.dev can lag behind actual availability.
124
- Log.Default.warn(() => ({
131
+ const liveInfo = await Provider.refreshLiveModelInfo(
132
+ providerID,
133
+ modelID
134
+ );
135
+ if (liveInfo) {
136
+ Log.Default.info(() => ({
125
137
  message:
126
- 'default model not found in models.dev catalog — proceeding anyway',
138
+ 'model not found in models.dev catalog but found in provider live endpoint',
127
139
  providerID,
128
140
  modelID,
129
- availableModels,
130
141
  }));
131
142
  } else {
132
- // User explicitly specified provider/model — fail with a clear error (#231)
133
- // Silent fallback caused kimi-k2.5-free to be routed to minimax-m2.5-free
134
- Log.Default.error(() => ({
135
- message:
136
- 'model not found in provider — refusing to proceed with explicit provider/model',
137
- providerID,
138
- modelID,
139
- availableModels,
140
- }));
141
- throw new Error(
142
- `Model "${modelID}" not found in provider "${providerID}". ` +
143
- `Available models include: ${availableModels.join(', ')}. ` +
144
- `Use --model ${providerID}/<model-id> with a valid model, or omit the provider prefix for auto-resolution.`
143
+ const availableModels = Object.keys(provider.info.models).slice(
144
+ 0,
145
+ 10
145
146
  );
147
+ if (isDefaultModel) {
148
+ // Default model not in models.dev catalog — warn but proceed (#239)
149
+ // The provider may still accept it; models.dev can lag behind actual availability.
150
+ Log.Default.warn(() => ({
151
+ message:
152
+ 'default model not found in models.dev catalog — proceeding anyway',
153
+ providerID,
154
+ modelID,
155
+ availableModels,
156
+ }));
157
+ } else {
158
+ // User explicitly specified provider/model — fail with a clear error (#231)
159
+ // Silent fallback caused kimi-k2.5-free to be routed to minimax-m2.5-free
160
+ Log.Default.error(() => ({
161
+ message:
162
+ 'model not found in provider — refusing to proceed with explicit provider/model',
163
+ providerID,
164
+ modelID,
165
+ availableModels,
166
+ }));
167
+ throw new Error(
168
+ `Model "${modelID}" not found in provider "${providerID}". ` +
169
+ `Available models include: ${availableModels.join(', ')}. ` +
170
+ `Use --model ${providerID}/<model-id> with a valid model, or omit the provider prefix for auto-resolution.`
171
+ );
172
+ }
146
173
  }
147
174
  }
148
175
  } catch (validationError) {
@@ -177,7 +204,8 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
177
204
  const compactionModelResult = await parseCompactionModelConfig(
178
205
  argv,
179
206
  providerID,
180
- modelID
207
+ modelID,
208
+ defaultOptions
181
209
  );
182
210
 
183
211
  // Handle --use-existing-claude-oauth option
@@ -206,7 +234,7 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
206
234
 
207
235
  // If user specified the default model (DEFAULT_MODEL), switch to claude-oauth
208
236
  // If user explicitly specified kilo or another provider, warn but respect their choice
209
- if (providerID === DEFAULT_PROVIDER_ID && modelID === DEFAULT_MODEL_ID) {
237
+ if (providerID === defaultProviderID && modelID === defaultModelID) {
210
238
  providerID = 'claude-oauth';
211
239
  modelID = 'claude-sonnet-4-5';
212
240
  } else if (!['claude-oauth', 'anthropic'].includes(providerID)) {
@@ -290,20 +318,28 @@ async function resolveCompactionModelEntry(
290
318
  * @see https://github.com/link-assistant/agent/issues/219
291
319
  * @see https://github.com/link-assistant/agent/issues/232
292
320
  */
293
- async function parseCompactionModelConfig(argv, baseProviderID, baseModelID) {
321
+ async function parseCompactionModelConfig(
322
+ argv,
323
+ baseProviderID,
324
+ baseModelID,
325
+ defaultOptions = {}
326
+ ) {
327
+ const defaultCompactionSafetyMarginPercent =
328
+ getDefaultCompactionSafetyMarginPercent(defaultOptions);
329
+
294
330
  // Get safety margin from CLI
295
331
  const cliSafetyMarginArg = getCompactionSafetyMarginFromProcessArgv();
296
332
  const compactionSafetyMarginPercent = cliSafetyMarginArg
297
333
  ? parseInt(cliSafetyMarginArg, 10)
298
334
  : (argv['compaction-safety-margin'] ??
299
- DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT);
335
+ defaultCompactionSafetyMarginPercent);
300
336
 
301
337
  // Check for --compaction-models (cascade) first — it overrides --compaction-model
302
338
  const cliCompactionModelsArg = getCompactionModelsFromProcessArgv();
303
339
  const compactionModelsArg =
304
340
  cliCompactionModelsArg ??
305
341
  argv['compaction-models'] ??
306
- DEFAULT_COMPACTION_MODELS;
342
+ getDefaultCompactionModels(defaultOptions);
307
343
 
308
344
  // Parse the links notation sequence into an array of model names
309
345
  const modelNames = parseLinksNotationSequence(compactionModelsArg);
@@ -363,7 +399,7 @@ async function parseCompactionModelConfig(argv, baseProviderID, baseModelID) {
363
399
  const compactionModelArg =
364
400
  cliCompactionModelArg ??
365
401
  argv['compaction-model'] ??
366
- DEFAULT_COMPACTION_MODEL;
402
+ getDefaultCompactionModel(defaultOptions);
367
403
 
368
404
  const resolved = await resolveCompactionModelEntry(
369
405
  compactionModelArg,
@@ -1,20 +1,26 @@
1
1
  import {
2
- DEFAULT_MODEL,
3
- DEFAULT_COMPACTION_MODEL,
4
- DEFAULT_COMPACTION_MODELS,
5
- DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
2
+ getDefaultModel,
3
+ getDefaultCompactionModel,
4
+ getDefaultCompactionModels,
5
+ getDefaultCompactionSafetyMarginPercent,
6
6
  } from './defaults.ts';
7
7
 
8
8
  /**
9
9
  * Yargs builder for the default `run` command options.
10
10
  * Extracted from index.js to keep file size under 1000 lines.
11
11
  */
12
- export function buildRunOptions(yargs) {
13
- return yargs
12
+ export function buildRunOptions(yargs, defaultOptions = {}) {
13
+ const defaultModel = getDefaultModel(defaultOptions);
14
+ const defaultCompactionModel = getDefaultCompactionModel(defaultOptions);
15
+ const defaultCompactionModels = getDefaultCompactionModels(defaultOptions);
16
+ const defaultCompactionSafetyMarginPercent =
17
+ getDefaultCompactionSafetyMarginPercent(defaultOptions);
18
+
19
+ const parser = yargs
14
20
  .option('model', {
15
21
  type: 'string',
16
22
  description: 'Model to use in format providerID/modelID',
17
- default: DEFAULT_MODEL,
23
+ default: defaultModel,
18
24
  })
19
25
  .option('json-standard', {
20
26
  type: 'string',
@@ -120,7 +126,19 @@ export function buildRunOptions(yargs) {
120
126
  description:
121
127
  'When used with --resume or --continue, continue in the same session without forking to a new UUID.',
122
128
  default: false,
123
- })
129
+ });
130
+
131
+ const normalizedParser =
132
+ typeof parser.middleware === 'function'
133
+ ? parser.middleware((argv) => {
134
+ if (argv.fork === false) {
135
+ argv['no-fork'] = true;
136
+ argv.noFork = true;
137
+ }
138
+ }, true)
139
+ : parser;
140
+
141
+ return normalizedParser
124
142
  .option('generate-title', {
125
143
  type: 'boolean',
126
144
  description:
@@ -153,7 +171,7 @@ export function buildRunOptions(yargs) {
153
171
  type: 'string',
154
172
  description:
155
173
  'Model to use for context compaction in format providerID/modelID. Use "same" to use the base model. Default: opencode/gpt-5-nano (free, 400K context). Overridden by --compaction-models if both are specified.',
156
- default: DEFAULT_COMPACTION_MODEL,
174
+ default: defaultCompactionModel,
157
175
  })
158
176
  .option('compaction-models', {
159
177
  type: 'string',
@@ -161,13 +179,13 @@ export function buildRunOptions(yargs) {
161
179
  'Ordered cascade of compaction models in links notation sequence format: "(model1 model2 ... same)". ' +
162
180
  "Models are tried from smallest/cheapest context to largest. If used context exceeds a model's limit or its rate limit is reached, the next model is tried. " +
163
181
  'The special value "same" uses the base model. Overrides --compaction-model when specified.',
164
- default: DEFAULT_COMPACTION_MODELS,
182
+ default: defaultCompactionModels,
165
183
  })
166
184
  .option('compaction-safety-margin', {
167
185
  type: 'number',
168
186
  description:
169
- 'Safety margin (%) of usable context window before triggering compaction. Only applies when the compaction model has equal or smaller context than the base model. Default: 15.',
170
- default: DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
187
+ 'Safety margin (%) of usable context window before triggering compaction. Only applies when the compaction model has equal or smaller context than the base model. Default: 25.',
188
+ default: defaultCompactionSafetyMarginPercent,
171
189
  })
172
190
  .option('temperature', {
173
191
  type: 'number',
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Global default model configuration shared by runtime code and tests.
3
+ *
4
+ * Keep the hard-coded defaults here. Runtime helpers allow test runs and
5
+ * local automation to override those defaults without editing source files.
6
+ */
7
+
8
+ type EnvLike = Record<string, string | undefined>;
9
+
10
+ export interface DefaultConfigOptions {
11
+ env?: EnvLike;
12
+ defaultModel?: string | null;
13
+ defaultCompactionModel?: string | null;
14
+ defaultCompactionModels?: string | null;
15
+ defaultCompactionSafetyMarginPercent?: number | string | null;
16
+ }
17
+
18
+ /** Default model used when no `--model` CLI argument is provided. */
19
+ export const DEFAULT_MODEL = 'opencode/minimax-m2.5-free';
20
+
21
+ /** Env var for overriding the default model in test runs and automation. */
22
+ export const DEFAULT_MODEL_ENV = 'LINK_ASSISTANT_AGENT_DEFAULT_MODEL';
23
+
24
+ /** Default compaction model used when no `--compaction-model` CLI argument is provided. */
25
+ export const DEFAULT_COMPACTION_MODEL = 'opencode/gpt-5-nano';
26
+
27
+ /** Env var for overriding the default compaction model in test runs and automation. */
28
+ export const DEFAULT_COMPACTION_MODEL_ENV =
29
+ 'LINK_ASSISTANT_AGENT_DEFAULT_COMPACTION_MODEL';
30
+
31
+ /**
32
+ * Default compaction models cascade, ordered from smallest/cheapest context to largest.
33
+ * During compaction, the system tries each model in order. If the used context exceeds
34
+ * a model's context limit, it skips to the next larger model. If a model's rate limit
35
+ * is reached, it also skips to the next model.
36
+ * The special value "same" means use the same model as `--model`.
37
+ *
38
+ * Parsed as links notation references sequence (single anonymous link):
39
+ * "(big-pickle minimax-m2.5-free nemotron-3-super-free hy3-preview-free ling-2.6-flash-free gpt-5-nano same)"
40
+ *
41
+ * Context limits (approximate):
42
+ * big-pickle: ~200K
43
+ * minimax-m2.5-free: ~204K
44
+ * nemotron-3-super-free: ~204K
45
+ * hy3-preview-free: ~256K
46
+ * ling-2.6-flash-free: ~262K
47
+ * gpt-5-nano: ~400K
48
+ * same: (base model's context)
49
+ *
50
+ * Note: qwen3.6-plus-free was removed because the free promotion ended in April 2026.
51
+ * Note: minimax-m2.5-free is the default model again as of issue #266.
52
+ * @see https://github.com/link-assistant/agent/issues/266
53
+ * @see https://github.com/link-assistant/agent/issues/242
54
+ * @see https://github.com/link-assistant/agent/issues/232
55
+ */
56
+ export const DEFAULT_COMPACTION_MODELS =
57
+ '(big-pickle minimax-m2.5-free nemotron-3-super-free hy3-preview-free ling-2.6-flash-free gpt-5-nano same)';
58
+
59
+ /** Env var for overriding the default compaction cascade in test runs and automation. */
60
+ export const DEFAULT_COMPACTION_MODELS_ENV =
61
+ 'LINK_ASSISTANT_AGENT_DEFAULT_COMPACTION_MODELS';
62
+
63
+ /**
64
+ * Default compaction safety margin as a percentage of usable context window.
65
+ * Applied only when the compaction model has a context window equal to or smaller
66
+ * than the base model. When the compaction model has a larger context, the margin
67
+ * is automatically set to 0 (allowing 100% context usage).
68
+ *
69
+ * Increased from 15% to 25% to reduce probability of context overflow errors,
70
+ * especially when providers return inaccurate or zero token counts.
71
+ * Matches OpenCode upstream's 75% threshold (25% margin).
72
+ * @see https://github.com/link-assistant/agent/issues/219
73
+ * @see https://github.com/link-assistant/agent/issues/249
74
+ */
75
+ export const DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT = 25;
76
+
77
+ /** Env var for overriding the default compaction safety margin in test runs and automation. */
78
+ export const DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT_ENV =
79
+ 'LINK_ASSISTANT_AGENT_DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT';
80
+
81
+ function clean(value: string | null | undefined): string | undefined {
82
+ const trimmed = value?.trim();
83
+ return trimmed ? trimmed : undefined;
84
+ }
85
+
86
+ function optionString(value: string | null | undefined): string | undefined {
87
+ return clean(value);
88
+ }
89
+
90
+ function envString(env: EnvLike, key: string): string | undefined {
91
+ return clean(env[key]);
92
+ }
93
+
94
+ function optionNumber(
95
+ value: number | string | null | undefined
96
+ ): number | undefined {
97
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
98
+ if (typeof value === 'string') {
99
+ const parsed = Number.parseInt(value.trim(), 10);
100
+ if (Number.isFinite(parsed)) return parsed;
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /** Resolve the effective default model. CLI `--model` still takes precedence later. */
106
+ export function getDefaultModel(options: DefaultConfigOptions = {}): string {
107
+ const env = options.env ?? process.env;
108
+ return (
109
+ optionString(options.defaultModel) ??
110
+ envString(env, DEFAULT_MODEL_ENV) ??
111
+ DEFAULT_MODEL
112
+ );
113
+ }
114
+
115
+ export function getDefaultCompactionModel(
116
+ options: DefaultConfigOptions = {}
117
+ ): string {
118
+ const env = options.env ?? process.env;
119
+ return (
120
+ optionString(options.defaultCompactionModel) ??
121
+ envString(env, DEFAULT_COMPACTION_MODEL_ENV) ??
122
+ DEFAULT_COMPACTION_MODEL
123
+ );
124
+ }
125
+
126
+ export function getDefaultCompactionModels(
127
+ options: DefaultConfigOptions = {}
128
+ ): string {
129
+ const env = options.env ?? process.env;
130
+ return (
131
+ optionString(options.defaultCompactionModels) ??
132
+ envString(env, DEFAULT_COMPACTION_MODELS_ENV) ??
133
+ DEFAULT_COMPACTION_MODELS
134
+ );
135
+ }
136
+
137
+ export function getDefaultCompactionSafetyMarginPercent(
138
+ options: DefaultConfigOptions = {}
139
+ ): number {
140
+ const env = options.env ?? process.env;
141
+ return (
142
+ optionNumber(options.defaultCompactionSafetyMarginPercent) ??
143
+ optionNumber(env[DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT_ENV]) ??
144
+ DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT
145
+ );
146
+ }
147
+
148
+ export function parseModelParts(model: string): {
149
+ providerID: string;
150
+ modelID: string;
151
+ } {
152
+ const [providerID, ...modelParts] = model.split('/');
153
+ return {
154
+ providerID,
155
+ modelID: modelParts.join('/'),
156
+ };
157
+ }
158
+
159
+ export function getDefaultModelParts(options: DefaultConfigOptions = {}): {
160
+ providerID: string;
161
+ modelID: string;
162
+ } {
163
+ return parseModelParts(getDefaultModel(options));
164
+ }
165
+
166
+ /** Default provider ID extracted from DEFAULT_MODEL. */
167
+ export const DEFAULT_PROVIDER_ID = parseModelParts(DEFAULT_MODEL).providerID;
168
+
169
+ /** Default model ID extracted from DEFAULT_MODEL. */
170
+ export const DEFAULT_MODEL_ID = parseModelParts(DEFAULT_MODEL).modelID;
package/src/index.js CHANGED
@@ -488,7 +488,7 @@ async function runServerMode(
488
488
  });
489
489
  unsub = eventUnsub;
490
490
 
491
- // Send message to session with specified model (default: opencode/grok-code)
491
+ // Send message to session with specified model
492
492
  const message = request.message || 'hi';
493
493
  const parts = [{ type: 'text', text: message }];
494
494
 
@@ -0,0 +1,138 @@
1
+ import { Log } from '../util/log';
2
+ import type { ModelsDev } from './models';
3
+
4
+ export namespace OpenCodeZen {
5
+ const log = Log.create({ service: 'opencode-zen' });
6
+ const MODELS_URL = 'https://opencode.ai/zen/v1/models';
7
+ const DEPRECATED_FREE_MODEL_IDS = new Set(['trinity-large-preview-free']);
8
+
9
+ const FREE_MODEL_OVERRIDES: Record<
10
+ string,
11
+ Partial<ModelsDev.Model> & { name: string }
12
+ > = {
13
+ 'minimax-m2.5-free': {
14
+ name: 'MiniMax M2.5 Free',
15
+ reasoning: true,
16
+ release_date: '2026-02-12',
17
+ limit: { context: 204800, output: 131072 },
18
+ provider: { npm: '@ai-sdk/openai-compatible' },
19
+ },
20
+ 'ling-2.6-flash-free': {
21
+ name: 'Ling 2.6 Flash Free',
22
+ reasoning: false,
23
+ release_date: '2026-04-21',
24
+ limit: { context: 262100, output: 32800 },
25
+ provider: { npm: '@ai-sdk/openai-compatible' },
26
+ },
27
+ 'hy3-preview-free': {
28
+ name: 'Hy3 Preview Free',
29
+ reasoning: true,
30
+ release_date: '2026-04-20',
31
+ limit: { context: 256000, output: 64000 },
32
+ provider: { npm: '@ai-sdk/openai-compatible' },
33
+ },
34
+ 'nemotron-3-super-free': {
35
+ name: 'Nemotron 3 Super Free',
36
+ reasoning: true,
37
+ release_date: '2026-03-11',
38
+ limit: { context: 204800, output: 128000 },
39
+ provider: { npm: '@ai-sdk/openai-compatible' },
40
+ },
41
+ 'gpt-5-nano': {
42
+ name: 'GPT-5 Nano',
43
+ attachment: true,
44
+ reasoning: true,
45
+ temperature: false,
46
+ release_date: '2025-08-07',
47
+ limit: { context: 400000, output: 128000 },
48
+ provider: { npm: '@ai-sdk/openai' },
49
+ },
50
+ 'big-pickle': {
51
+ name: 'Big Pickle',
52
+ reasoning: true,
53
+ release_date: '2025-10-17',
54
+ limit: { context: 200000, output: 128000 },
55
+ provider: { npm: '@ai-sdk/openai-compatible' },
56
+ },
57
+ };
58
+
59
+ function displayName(modelID: string) {
60
+ return modelID
61
+ .replace(/:free$/, '')
62
+ .replace(/-free$/, ' Free')
63
+ .split(/[-/]/)
64
+ .filter(Boolean)
65
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
66
+ .join(' ');
67
+ }
68
+
69
+ function isFreeModelID(modelID: string) {
70
+ if (DEPRECATED_FREE_MODEL_IDS.has(modelID)) return false;
71
+ return (
72
+ modelID.endsWith('-free') ||
73
+ modelID === 'gpt-5-nano' ||
74
+ modelID === 'big-pickle'
75
+ );
76
+ }
77
+
78
+ export async function fetchModelIDs(fetchFn: typeof fetch = fetch) {
79
+ const response = await fetchFn(MODELS_URL, {
80
+ headers: { 'User-Agent': 'agent-cli/1.0.0' },
81
+ signal: AbortSignal.timeout(10_000),
82
+ });
83
+ if (!response.ok) {
84
+ log.warn(() => ({
85
+ message: 'OpenCode Zen models endpoint returned non-OK response',
86
+ status: response.status,
87
+ statusText: response.statusText,
88
+ }));
89
+ return new Set<string>();
90
+ }
91
+
92
+ const body = (await response.json().catch(() => undefined)) as
93
+ | { data?: Array<{ id?: unknown }> }
94
+ | undefined;
95
+ const rows = Array.isArray(body?.data) ? body.data : [];
96
+ return new Set(
97
+ rows
98
+ .map((row) => (typeof row.id === 'string' ? row.id : undefined))
99
+ .filter((id): id is string => Boolean(id))
100
+ );
101
+ }
102
+
103
+ export async function getLiveFreeModelInfo(
104
+ modelID: string,
105
+ fetchFn: typeof fetch = fetch
106
+ ): Promise<ModelsDev.Model | undefined> {
107
+ if (!isFreeModelID(modelID)) return undefined;
108
+
109
+ const ids = await fetchModelIDs(fetchFn).catch((error) => {
110
+ log.warn(() => ({
111
+ message: 'failed to fetch OpenCode Zen live model list',
112
+ modelID,
113
+ error: error instanceof Error ? error.message : String(error),
114
+ }));
115
+ return new Set<string>();
116
+ });
117
+ if (!ids.has(modelID)) return undefined;
118
+
119
+ const overrides = FREE_MODEL_OVERRIDES[modelID];
120
+ return {
121
+ id: modelID,
122
+ name: overrides?.name ?? displayName(modelID),
123
+ release_date: overrides?.release_date ?? '',
124
+ attachment: overrides?.attachment ?? false,
125
+ reasoning: overrides?.reasoning ?? modelID.includes('reasoning'),
126
+ temperature: overrides?.temperature ?? true,
127
+ tool_call: overrides?.tool_call ?? true,
128
+ cost: overrides?.cost ?? { input: 0, output: 0, cache_read: 0 },
129
+ limit: overrides?.limit ?? { context: 128000, output: 16384 },
130
+ modalities: overrides?.modalities ?? {
131
+ input: ['text'],
132
+ output: ['text'],
133
+ },
134
+ options: overrides?.options ?? {},
135
+ provider: overrides?.provider ?? { npm: '@ai-sdk/openai-compatible' },
136
+ };
137
+ }
138
+ }
@@ -13,11 +13,13 @@ import { AuthPlugins } from '../auth/plugins';
13
13
  import { Instance } from '../project/instance';
14
14
  import { Global } from '../global';
15
15
  import { config, isVerbose } from '../config/config';
16
+ import { getDefaultModel, getDefaultModelParts } from '../config/defaults';
16
17
  import { iife } from '../util/iife';
17
18
  import { createEchoModel } from './echo';
18
19
  import { createCacheModel } from './cache';
19
20
  import { RetryFetch } from './retry-fetch';
20
21
  import { SSEUsageExtractor } from '../util/sse-usage-extractor';
22
+ import { OpenCodeZen } from './opencode-zen';
21
23
 
22
24
  // Direct imports for bundled providers - these are pre-installed to avoid runtime installation hangs
23
25
  // @see https://github.com/link-assistant/agent/issues/173
@@ -1596,6 +1598,31 @@ export namespace Provider {
1596
1598
  return state().then((s) => s.providers[providerID]);
1597
1599
  }
1598
1600
 
1601
+ async function getLiveModelInfo(providerID: string, modelID: string) {
1602
+ if (providerID !== 'opencode') return undefined;
1603
+ return OpenCodeZen.getLiveFreeModelInfo(modelID);
1604
+ }
1605
+
1606
+ export async function refreshLiveModelInfo(
1607
+ providerID: string,
1608
+ modelID: string
1609
+ ) {
1610
+ const info = await getLiveModelInfo(providerID, modelID);
1611
+ if (!info) return undefined;
1612
+
1613
+ const s = await state();
1614
+ const provider = s.providers[providerID];
1615
+ if (provider) {
1616
+ provider.info.models[modelID] = info;
1617
+ log.info(() => ({
1618
+ message: 'model found in provider live model endpoint',
1619
+ providerID,
1620
+ modelID,
1621
+ }));
1622
+ }
1623
+ return info;
1624
+ }
1625
+
1599
1626
  export async function getModel(providerID: string, modelID: string) {
1600
1627
  const key = `${providerID}/${modelID}`;
1601
1628
  const s = await state();
@@ -1662,15 +1689,28 @@ export namespace Provider {
1662
1689
  }
1663
1690
  }
1664
1691
 
1692
+ if (!isSyntheticProvider && !info) {
1693
+ const liveInfo = await getLiveModelInfo(providerID, modelID);
1694
+ if (liveInfo) {
1695
+ provider.info.models[modelID] = liveInfo;
1696
+ info = liveInfo;
1697
+ log.info(() => ({
1698
+ message: 'model found in provider live model endpoint',
1699
+ providerID,
1700
+ modelID,
1701
+ }));
1702
+ }
1703
+ }
1704
+
1665
1705
  if (!isSyntheticProvider && !info) {
1666
1706
  // Model not found even after cache refresh.
1667
1707
  // Check if this is the default model — if so, create synthetic info and proceed (#239).
1668
1708
  // The models.dev API can lag behind the provider's actual model availability.
1669
1709
  // For user-specified models, fail with a clear error (#231) to prevent silent substitution.
1670
- const { DEFAULT_PROVIDER_ID, DEFAULT_MODEL_ID } =
1671
- await import('../cli/defaults.ts');
1710
+ const { providerID: defaultProviderID, modelID: defaultModelID } =
1711
+ getDefaultModelParts();
1672
1712
  const isDefaultModel =
1673
- providerID === DEFAULT_PROVIDER_ID && modelID === DEFAULT_MODEL_ID;
1713
+ providerID === defaultProviderID && modelID === defaultModelID;
1674
1714
  const availableInProvider = Object.keys(provider.info.models).slice(
1675
1715
  0,
1676
1716
  10
@@ -1793,8 +1833,10 @@ export namespace Provider {
1793
1833
  }
1794
1834
  if (providerID === 'opencode' || providerID === 'local') {
1795
1835
  priority = [
1796
- 'nemotron-3-super-free',
1797
1836
  'minimax-m2.5-free',
1837
+ 'ling-2.6-flash-free',
1838
+ 'hy3-preview-free',
1839
+ 'nemotron-3-super-free',
1798
1840
  'gpt-5-nano',
1799
1841
  'big-pickle',
1800
1842
  ];
@@ -1824,11 +1866,13 @@ export namespace Provider {
1824
1866
  }
1825
1867
 
1826
1868
  const priority = [
1869
+ 'big-pickle',
1870
+ 'gpt-5-nano',
1827
1871
  'nemotron-3-super-free',
1828
- 'glm-5-free',
1872
+ 'hy3-preview-free',
1873
+ 'ling-2.6-flash-free',
1829
1874
  'minimax-m2.5-free',
1830
- 'gpt-5-nano',
1831
- 'big-pickle',
1875
+ 'glm-5-free',
1832
1876
  'gpt-5',
1833
1877
  'claude-sonnet-4',
1834
1878
  'gemini-3-pro',
@@ -1861,6 +1905,15 @@ export namespace Provider {
1861
1905
 
1862
1906
  if (cfg.model) return parseModel(cfg.model);
1863
1907
 
1908
+ const configuredDefaultModel = getDefaultModel();
1909
+ if (configuredDefaultModel) {
1910
+ log.info(() => ({
1911
+ message: 'using configured default model',
1912
+ model: configuredDefaultModel,
1913
+ }));
1914
+ return parseModel(configuredDefaultModel);
1915
+ }
1916
+
1864
1917
  // Prefer opencode provider if available
1865
1918
  const providers = await list().then((val) => Object.values(val));
1866
1919
  const opencodeProvider = providers.find((p) => p.info.id === 'opencode');
@@ -1907,8 +1960,8 @@ export namespace Provider {
1907
1960
  * Priority for free models:
1908
1961
  * 1. If model is uniquely available in one provider, use that provider
1909
1962
  * 2. If model is available in multiple providers, prioritize based on free model availability:
1910
- * - kilo: glm-5-free, glm-4.5-air-free, minimax-m2.5-free, giga-potato-free, deepseek-r1-free (unique to Kilo)
1911
- * - opencode: big-pickle, gpt-5-nano, nemotron-3-super-free (unique to OpenCode)
1963
+ * - kilo: glm-5-free, glm-4.5-air-free, giga-potato-free, deepseek-r1-free (unique to Kilo)
1964
+ * - opencode: minimax-m2.5-free, ling-2.6-flash-free, hy3-preview-free, big-pickle, gpt-5-nano, nemotron-3-super-free
1912
1965
  * 3. For shared models, prefer OpenCode first, then fall back to Kilo on rate limit
1913
1966
  *
1914
1967
  * @param modelID - Short model name without provider prefix
@@ -1924,7 +1977,6 @@ export namespace Provider {
1924
1977
  const kiloUniqueModels = [
1925
1978
  'glm-5-free',
1926
1979
  'glm-4.5-air-free',
1927
- 'minimax-m2.5-free',
1928
1980
  'giga-potato-free',
1929
1981
  'trinity-large-preview',
1930
1982
  'deepseek-r1-free',
@@ -2060,8 +2112,7 @@ export namespace Provider {
2060
2112
  * If user specifies "kilo/deepseek-r1-free", no fallback will occur.
2061
2113
  */
2062
2114
  const SHARED_FREE_MODELS: Record<string, string[]> = {
2063
- // Currently no shared models between OpenCode and Kilo providers.
2064
- // Kilo models use different IDs than OpenCode models.
2115
+ 'minimax-m2.5-free': ['opencode', 'kilo'],
2065
2116
  };
2066
2117
 
2067
2118
  /**
package/src/tool/task.ts CHANGED
@@ -9,7 +9,7 @@ import { Agent } from '../agent/agent';
9
9
  import { SessionPrompt } from '../session/prompt';
10
10
  import { iife } from '../util/iife';
11
11
  import { defer } from '../util/defer';
12
- import { DEFAULT_PROVIDER_ID, DEFAULT_MODEL_ID } from '../cli/defaults';
12
+ import { getDefaultModelParts } from '../cli/defaults';
13
13
 
14
14
  export const TaskTool = Tool.define('task', async () => {
15
15
  const agents = await Agent.list().then((x) =>
@@ -98,10 +98,11 @@ export const TaskTool = Tool.define('task', async () => {
98
98
  });
99
99
  });
100
100
 
101
+ const defaultModel = getDefaultModelParts();
101
102
  const model = agent.model ??
102
103
  parentModel ?? {
103
- modelID: DEFAULT_MODEL_ID,
104
- providerID: DEFAULT_PROVIDER_ID,
104
+ modelID: defaultModel.modelID,
105
+ providerID: defaultModel.providerID,
105
106
  };
106
107
 
107
108
  function cancel() {