@yeaft/webchat-agent 0.1.923 → 0.1.925

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/index.js CHANGED
@@ -324,10 +324,22 @@ process.on('SIGTERM', () => {
324
324
  process.exit(0);
325
325
  });
326
326
 
327
- // 启动 - 先确保依赖,再检测能力,再连接
327
+ // 启动 - 先确保依赖,再检测能力,预热 models.dev 缓存,再连接
328
328
  (async () => {
329
329
  await ensureDependencies();
330
330
  await ensureYeaftSkills();
331
331
  ctx.agentCapabilities = await detectCapabilities();
332
+ // Prime the models.dev community catalog so the Yeaft engine's *synchronous*
333
+ // hot path (engine.js / config.js / cli.js all read context-window inline)
334
+ // can resolve real per-model limits without bubbling async up through every
335
+ // call site. Failure here is non-fatal: stale disk cache → DEFAULT 200K
336
+ // fall-through keeps the agent boot-able offline.
337
+ try {
338
+ const { fetchModelsDev } = await import('./yeaft/llm/models-dev.js');
339
+ await fetchModelsDev({ yeaftDir: YEAFT_DIR });
340
+ console.log('[Agent] models.dev cache primed');
341
+ } catch (err) {
342
+ console.warn(`[Agent] models.dev prime failed (will use config/defaults): ${err?.message || err}`);
343
+ }
332
344
  connect();
333
345
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.923",
3
+ "version": "0.1.925",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/cli.js CHANGED
@@ -28,7 +28,7 @@ import { join } from 'path';
28
28
  import { loadConfig } from './config.js';
29
29
  import { DebugTrace } from './debug-trace.js';
30
30
  import { loadSession } from './session.js';
31
- import { listModels, resolveModel, parseModelRef } from './models.js';
31
+ import { listModels, resolveModel, parseModelRef, resolveContextWindow, resolveMaxOutputTokens } from './models.js';
32
32
  import { buildSystemPrompt } from './prompts.js';
33
33
  import { searchMessages } from './conversation/search.js';
34
34
  import { ConversationStore } from './conversation/persist.js';
@@ -488,13 +488,15 @@ async function runREPL(config, args) {
488
488
  session.config.model = modelRef;
489
489
  }
490
490
 
491
- // Re-resolve model info from registry
491
+ // Re-resolve model info from registry (adapter / baseUrl /
492
+ // thinking metadata). Token limits come from the resolver
493
+ // ladder — models.dev snapshot first, config fallback.
492
494
  const newModelInfo = resolveModel(session.config.model);
493
495
  if (newModelInfo) {
494
- session.config.maxContextTokens = newModelInfo.contextWindow;
495
- session.config.maxOutputTokens = newModelInfo.maxOutputTokens;
496
496
  session.config.modelInfo = newModelInfo;
497
497
  }
498
+ session.config.maxContextTokens = resolveContextWindow(session.config.model, session.config);
499
+ session.config.maxOutputTokens = resolveMaxOutputTokens(session.config.model, session.config);
498
500
  const providerStr = providerName ? ` (provider: ${providerName})` : '';
499
501
  console.log(`Model switched to: ${session.config.model}${providerStr}`);
500
502
  console.log('Note: Model change takes effect on next query.');
@@ -766,6 +768,19 @@ async function runOnce(config, args) {
766
768
  async function main() {
767
769
  const args = parseArgs(process.argv);
768
770
 
771
+ // Prime the models.dev community catalog so resolveContextWindow /
772
+ // resolveMaxOutputTokens — called synchronously from loadConfig and the
773
+ // engine hot path — can return real per-model limits instead of falling
774
+ // through to DEFAULT. Failure is non-fatal: stale disk cache or DEFAULT
775
+ // keeps the CLI usable offline. Mirrors the prime in agent/index.js.
776
+ try {
777
+ const { fetchModelsDev } = await import('./llm/models-dev.js');
778
+ const dir = args.dir || process.env.YEAFT_DIR || null;
779
+ await fetchModelsDev({ yeaftDir: dir });
780
+ } catch {
781
+ // best-effort; resolver falls back to config / DEFAULT.
782
+ }
783
+
769
784
  // Load config with CLI overrides (for --trace queries and dry-run, no session needed)
770
785
  const config = loadConfig({
771
786
  model: args.model,
package/yeaft/config.js CHANGED
@@ -22,7 +22,7 @@
22
22
  import { existsSync, readFileSync } from 'fs';
23
23
  import { join } from 'path';
24
24
  import { DEFAULT_YEAFT_DIR } from './init.js';
25
- import { resolveModel, parseModelRef, normalizeProviderModels } from './models.js';
25
+ import { resolveModel, parseModelRef, normalizeProviderModels, resolveContextWindow, resolveMaxOutputTokens } from './models.js';
26
26
 
27
27
  /** Default configuration values. */
28
28
  const DEFAULTS = {
@@ -250,8 +250,15 @@ function loadLegacyConfig(dir, overrides) {
250
250
  if (modelInfo) {
251
251
  config.adapter = modelInfo.adapter === 'anthropic' ? 'anthropic' : 'openai';
252
252
  if (!config.baseUrl) config.baseUrl = modelInfo.baseUrl;
253
- if (config.maxContextTokens === DEFAULTS.maxContextTokens) config.maxContextTokens = modelInfo.contextWindow;
254
- if (config.maxOutputTokens === DEFAULTS.maxOutputTokens) config.maxOutputTokens = modelInfo.maxOutputTokens;
253
+ // Resolve token limits via the resolver ladder (models.dev config
254
+ // DEFAULT). Only fill in when the caller left the slot at the default
255
+ // — explicit env / CLI overrides win.
256
+ if (config.maxContextTokens === DEFAULTS.maxContextTokens) {
257
+ config.maxContextTokens = resolveContextWindow(config.model, config);
258
+ }
259
+ if (config.maxOutputTokens === DEFAULTS.maxOutputTokens) {
260
+ config.maxOutputTokens = resolveMaxOutputTokens(config.model, config);
261
+ }
255
262
  } else {
256
263
  if (config.apiKey) config.adapter = 'anthropic';
257
264
  else if (config.openaiApiKey) config.adapter = 'openai';
@@ -305,9 +312,19 @@ export function loadConfig(overrides = {}) {
305
312
  fastModelId = parsed.modelId;
306
313
  }
307
314
 
308
- // Resolve model info for context window / output limits
315
+ // Resolve model info for adapter/baseUrl/thinking metadata. Token limits
316
+ // (contextWindow / maxOutputTokens) are NOT read from here — they live in
317
+ // models.dev and are resolved via resolveContextWindow / resolveMaxOutputTokens
318
+ // a few lines below so the live models.dev snapshot is the source of truth.
309
319
  const modelInfo = resolveModel(model);
310
320
 
321
+ // Pre-resolve token limits once so we can both write them onto config and
322
+ // pass `config` to the resolver chain consistently below.
323
+ const resolvedMaxContext = overrides.maxContextTokens ?? jsonConfig.maxContextTokens
324
+ ?? resolveContextWindow(model, { modelInfo });
325
+ const resolvedMaxOutput = overrides.maxOutputTokens ?? jsonConfig.maxOutputTokens
326
+ ?? resolveMaxOutputTokens(model, { modelInfo });
327
+
311
328
  const config = {
312
329
  // Model
313
330
  model: overrides.model || model,
@@ -325,9 +342,16 @@ export function loadConfig(overrides = {}) {
325
342
  debug: overrides.debug !== undefined ? overrides.debug : (jsonConfig.debug ?? DEFAULTS.debug),
326
343
  dir,
327
344
 
328
- // Token limits
329
- maxContextTokens: overrides.maxContextTokens ?? jsonConfig.maxContextTokens ?? modelInfo?.contextWindow ?? DEFAULTS.maxContextTokens,
330
- maxOutputTokens: overrides.maxOutputTokens ?? jsonConfig.maxOutputTokens ?? modelInfo?.maxOutputTokens ?? DEFAULTS.maxOutputTokens,
345
+ // Token limits. Resolution order:
346
+ // 1. CLI override (overrides.*)
347
+ // 2. ~/.yeaft/config.json explicit value
348
+ // 3. resolveContextWindow / resolveMaxOutputTokens — which themselves
349
+ // walk: per-provider override → models.dev snapshot → DEFAULT.
350
+ // Anything that needs the *live* number for a model the user picks at
351
+ // runtime (mid-session model switch, e.g.) should call the resolvers
352
+ // directly rather than read these fields.
353
+ maxContextTokens: resolvedMaxContext,
354
+ maxOutputTokens: resolvedMaxOutput,
331
355
  messageTokenBudget: overrides.messageTokenBudget ?? jsonConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
332
356
  maxContinueTurns: overrides.maxContinueTurns ?? jsonConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
333
357
 
package/yeaft/engine.js CHANGED
@@ -37,6 +37,7 @@ import { runStopHooks } from './stop-hooks.js';
37
37
  const MAIN_THREAD_ID = 'main';
38
38
  import { pickEffort, parseEffortPrefix } from './effort.js';
39
39
  import { DEFAULT_CONTEXT_WINDOW, normalizeEffort, resolveContextWindow, resolveModel } from './models.js';
40
+ import { lookupModelLimitSync } from './llm/models-dev.js';
40
41
  import { countTurns } from './turn-utils.js';
41
42
  import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
42
43
  import { resolveThinking } from './router/thinking.js';
@@ -186,7 +187,11 @@ export function shouldAllowGroupReflection({
186
187
  };
187
188
  }
188
189
  const contextWindow = resolveContextWindow(model, config);
189
- const hasRegistryContext = !!resolveModel(model)?.contextWindow;
190
+ // Telemetry: did the resolver hit either of its top non-default rungs?
191
+ // Used by `usedFallbackContextWindow` below — if neither models.dev nor
192
+ // the global config provided a number, we fell through to DEFAULT and
193
+ // callers may want to surface that to the user.
194
+ const hasModelsDevContext = !!lookupModelLimitSync(model, resolveModel(model)?.provider || null)?.context;
190
195
  const hasConfigContext = Number.isFinite(config?.maxContextTokens) && config.maxContextTokens > 0;
191
196
  const threshold = Math.floor(contextWindow * GROUP_CONTEXT_PRESSURE_RATIO);
192
197
  const tokenEstimate = estimateMessagesTokens(system, messages);
@@ -204,7 +209,7 @@ export function shouldAllowGroupReflection({
204
209
  contextWindow,
205
210
  ratio: GROUP_CONTEXT_PRESSURE_RATIO,
206
211
  turnCount,
207
- usedFallbackContextWindow: !hasRegistryContext && !hasConfigContext && contextWindow === DEFAULT_CONTEXT_WINDOW,
212
+ usedFallbackContextWindow: !hasModelsDevContext && !hasConfigContext && contextWindow === DEFAULT_CONTEXT_WINDOW,
208
213
  };
209
214
  }
210
215
 
@@ -163,6 +163,76 @@ export async function listProviderModels(providerId, { yeaftDir = null } = {}) {
163
163
  return Object.keys(models);
164
164
  }
165
165
 
166
+ /**
167
+ * Synchronously look up a model's `{context, output}` limits from whatever
168
+ * models.dev snapshot is currently warmed in memory.
169
+ *
170
+ * The yeaft engine query loop is synchronous (engine.js, config.js, cli.js
171
+ * all read the context window inline). To avoid bubbling async up through
172
+ * every gate, the agent boot script primes `fetchModelsDev()` once so this
173
+ * function can read straight from `_memCache` afterwards.
174
+ *
175
+ * If the cache is empty (boot prime failed or test never warmed it), or the
176
+ * model isn't present in any provider entry, returns `null` — callers fall
177
+ * back to the next rung of their resolver ladder.
178
+ *
179
+ * Collision policy: a given model id can appear under multiple providers
180
+ * (e.g. `qwen3-32b` lives under 7 providers in the live models.dev snapshot
181
+ * with context limits ranging 32K–131K). Behavior:
182
+ *
183
+ * • If `providerHint` is given AND that provider lists the model, we use
184
+ * that provider's numbers verbatim — the caller knew which gateway it
185
+ * was talking to.
186
+ * • Otherwise we take the MIN of every provider's `context` and `output`.
187
+ * Context is a ceiling: under-shooting risks an early compact (bad);
188
+ * over-shooting risks an LLMContextError mid-query (worse). Min picks
189
+ * the safer side. Users who know better can pin numbers explicitly via
190
+ * `providers[].models[].contextWindow` in `~/.yeaft/config.json`.
191
+ *
192
+ * @param {string} modelId
193
+ * @param {string|null} [providerHint] Provider id matching the models.dev
194
+ * top-level key (e.g. 'anthropic', 'openai', 'google', 'deepseek'). The
195
+ * MODEL_REGISTRY's `provider` field is aligned to these ids deliberately.
196
+ * @returns {{ context?: number, output?: number } | null}
197
+ */
198
+ export function lookupModelLimitSync(modelId, providerHint = null) {
199
+ if (!modelId || !_memCache || typeof _memCache !== 'object') return null;
200
+
201
+ // Hit the hint first if it actually lists this model.
202
+ if (providerHint && typeof providerHint === 'string') {
203
+ const provEntry = _memCache[providerHint];
204
+ const m = provEntry?.models?.[modelId];
205
+ if (m && m.limit && typeof m.limit === 'object') {
206
+ const out = {};
207
+ if (Number.isFinite(m.limit.context) && m.limit.context > 0) out.context = m.limit.context;
208
+ if (Number.isFinite(m.limit.output) && m.limit.output > 0) out.output = m.limit.output;
209
+ if (out.context !== undefined || out.output !== undefined) return out;
210
+ }
211
+ // Hint missed — fall through to the scan rather than returning null,
212
+ // because the model genuinely might live under another provider id
213
+ // (e.g. a deepseek model surfaced via a relay).
214
+ }
215
+
216
+ // Scan every provider; collect context/output values, return MIN.
217
+ let minCtx = null;
218
+ let minOut = null;
219
+ for (const provId of Object.keys(_memCache)) {
220
+ const m = _memCache[provId]?.models?.[modelId];
221
+ if (!m || !m.limit || typeof m.limit !== 'object') continue;
222
+ if (Number.isFinite(m.limit.context) && m.limit.context > 0) {
223
+ minCtx = minCtx === null ? m.limit.context : Math.min(minCtx, m.limit.context);
224
+ }
225
+ if (Number.isFinite(m.limit.output) && m.limit.output > 0) {
226
+ minOut = minOut === null ? m.limit.output : Math.min(minOut, m.limit.output);
227
+ }
228
+ }
229
+ if (minCtx === null && minOut === null) return null;
230
+ const result = {};
231
+ if (minCtx !== null) result.context = minCtx;
232
+ if (minOut !== null) result.output = minOut;
233
+ return result;
234
+ }
235
+
166
236
  /**
167
237
  * Reset the in-memory cache. Test seam.
168
238
  */
@@ -171,3 +241,26 @@ export function _resetMemCache() {
171
241
  _memCachePath = null;
172
242
  _memCacheTime = 0;
173
243
  }
244
+
245
+ /**
246
+ * Inject a snapshot into the in-memory cache without going through the
247
+ * fetch/disk path. Test seam: callers that want to exercise
248
+ * `lookupModelLimitSync` deterministically can seed any shape they need.
249
+ *
250
+ * Pass a falsy snapshot to fully reset (equivalent to {@link _resetMemCache}).
251
+ * This avoids the foot-gun where `_setMemCacheForTest(null)` would leave
252
+ * `_memCachePath` and `_memCacheTime` stamped to a fake `__test__` value,
253
+ * confusing any subsequent `fetchModelsDev` call about whether the cache
254
+ * was ever populated.
255
+ *
256
+ * @param {object|null} snapshot models.dev-shaped data, or null/undefined to reset.
257
+ */
258
+ export function _setMemCacheForTest(snapshot) {
259
+ if (!snapshot || typeof snapshot !== 'object') {
260
+ _resetMemCache();
261
+ return;
262
+ }
263
+ _memCache = snapshot;
264
+ _memCachePath = '__test__';
265
+ _memCacheTime = Date.now();
266
+ }
package/yeaft/models.js CHANGED
@@ -1,24 +1,34 @@
1
1
  /**
2
- * models.js — Model ID registry for Yeaft Yeaft
2
+ * models.js — Model ID registry for Yeaft
3
3
  *
4
- * Maps model IDs (e.g. "gpt-5", "claude-sonnet-4-20250514") to their
5
- * adapter type, API base URL, and capabilities.
4
+ * Maps model IDs (e.g. "gpt-5", "claude-sonnet-4-20250514") to the *adapter*
5
+ * metadata Yeaft needs to dispatch requests: which protocol (anthropic vs
6
+ * openai-responses), which base URL, which thinking/reasoning capabilities.
6
7
  *
7
8
  * Yeaft does not provide its own models. The "model" field is always a
8
9
  * model ID from an external provider. This registry lets Yeaft auto-detect
9
10
  * the correct adapter and endpoint from just the model ID, so users only
10
11
  * need to set YEAFT_MODEL=gpt-5 without configuring adapter/baseUrl separately.
11
12
  *
13
+ * **Token limits (contextWindow / maxOutput) are NOT stored here.** They are
14
+ * resolved at runtime from the models.dev community catalog
15
+ * (`agent/yeaft/llm/models-dev.js`), with a per-provider config override
16
+ * (`~/.yeaft/config.json: providers[].models[].contextWindow`) and a
17
+ * conservative DEFAULT as the final rung. See {@link resolveContextWindow}
18
+ * and {@link resolveMaxOutputTokens} for the full ladder.
19
+ *
12
20
  * Unknown model IDs return null — caller falls back to env-based detection.
13
21
  */
14
22
 
23
+ import { lookupModelLimitSync } from './llm/models-dev.js';
24
+
15
25
  /**
16
26
  * @typedef {Object} ModelInfo
17
- * @property {'anthropic' | 'openai' | 'deepseek' | 'google'} provider — Which provider this model belongs to
27
+ * @property {'anthropic' | 'openai' | 'deepseek' | 'google'} provider — Which provider this model belongs to.
28
+ * These ids are intentionally aligned with the top-level keys in the models.dev
29
+ * catalog so they can be used as a `providerHint` to {@link lookupModelLimitSync}.
18
30
  * @property {'anthropic' | 'chat-completions'} adapter — Which wire protocol to use
19
31
  * @property {string} baseUrl — Official API endpoint base URL
20
- * @property {number} contextWindow — Max context tokens
21
- * @property {number} maxOutputTokens — Max output tokens
22
32
  * @property {string} displayName — Human-readable model name
23
33
  * @property {boolean} [supportsThinking] — task-327a: model supports thinking/reasoning effort.
24
34
  * @property {'anthropic' | 'openai-reasoning' | 'none'} [thinkingProtocol] — task-327a:
@@ -39,8 +49,6 @@ export const MODEL_REGISTRY = new Map([
39
49
  provider: 'anthropic',
40
50
  adapter: 'anthropic',
41
51
  baseUrl: 'https://api.anthropic.com',
42
- contextWindow: 200000,
43
- maxOutputTokens: 16384,
44
52
  displayName: 'Claude Sonnet 4',
45
53
  // task-327a: extended thinking supported; budget caps at 32K on Sonnet.
46
54
  supportsThinking: true,
@@ -52,8 +60,6 @@ export const MODEL_REGISTRY = new Map([
52
60
  provider: 'anthropic',
53
61
  adapter: 'anthropic',
54
62
  baseUrl: 'https://api.anthropic.com',
55
- contextWindow: 200000,
56
- maxOutputTokens: 16384,
57
63
  displayName: 'Claude Opus 4',
58
64
  // task-327a: PM decision — Opus max budget = 64K.
59
65
  supportsThinking: true,
@@ -65,8 +71,6 @@ export const MODEL_REGISTRY = new Map([
65
71
  provider: 'anthropic',
66
72
  adapter: 'anthropic',
67
73
  baseUrl: 'https://api.anthropic.com',
68
- contextWindow: 200000,
69
- maxOutputTokens: 8192,
70
74
  displayName: 'Claude Haiku 3',
71
75
  // task-327a: Haiku 3 does not support extended thinking — effort is dropped.
72
76
  supportsThinking: false,
@@ -78,17 +82,12 @@ export const MODEL_REGISTRY = new Map([
78
82
  provider: 'openai',
79
83
  adapter: 'openai-responses',
80
84
  baseUrl: 'https://api.openai.com/v1',
81
- contextWindow: 256000,
82
- maxOutputTokens: 16384,
83
85
  displayName: 'GPT-5',
84
86
  // task-327a: GPT-5 supports reasoning.effort (low/medium/high). No 'max'.
85
87
  supportsThinking: true,
86
88
  thinkingProtocol: 'openai-reasoning',
87
89
  defaultEffort: null,
88
90
  }],
89
- // gpt-5-mini/-nano/-pro: keep id + family/protocol metadata so they appear
90
- // as known models, but do NOT hardcode context/maxOutput — the real limits
91
- // should come from provider config (user-supplied) instead of guesses.
92
91
  ['gpt-5-mini', {
93
92
  provider: 'openai',
94
93
  adapter: 'openai-responses',
@@ -111,40 +110,30 @@ export const MODEL_REGISTRY = new Map([
111
110
  provider: 'openai',
112
111
  adapter: 'openai-responses',
113
112
  baseUrl: 'https://api.openai.com/v1',
114
- contextWindow: 272000,
115
- maxOutputTokens: 16384,
116
113
  displayName: 'GPT-5.4',
117
114
  }],
118
115
  ['gpt-4.1', {
119
116
  provider: 'openai',
120
117
  adapter: 'openai-responses',
121
118
  baseUrl: 'https://api.openai.com/v1',
122
- contextWindow: 1047576,
123
- maxOutputTokens: 32768,
124
119
  displayName: 'GPT-4.1',
125
120
  }],
126
121
  ['gpt-4.1-mini', {
127
122
  provider: 'openai',
128
123
  adapter: 'openai-responses',
129
124
  baseUrl: 'https://api.openai.com/v1',
130
- contextWindow: 1047576,
131
- maxOutputTokens: 16384,
132
125
  displayName: 'GPT-4.1 Mini',
133
126
  }],
134
127
  ['gpt-4.1-nano', {
135
128
  provider: 'openai',
136
129
  adapter: 'openai-responses',
137
130
  baseUrl: 'https://api.openai.com/v1',
138
- contextWindow: 1047576,
139
- maxOutputTokens: 16384,
140
131
  displayName: 'GPT-4.1 Nano',
141
132
  }],
142
133
  ['o3', {
143
134
  provider: 'openai',
144
135
  adapter: 'openai-responses',
145
136
  baseUrl: 'https://api.openai.com/v1',
146
- contextWindow: 200000,
147
- maxOutputTokens: 100000,
148
137
  displayName: 'o3',
149
138
  // task-327a: o-series reasoning models use reasoning.effort.
150
139
  supportsThinking: true,
@@ -155,8 +144,6 @@ export const MODEL_REGISTRY = new Map([
155
144
  provider: 'openai',
156
145
  adapter: 'openai-responses',
157
146
  baseUrl: 'https://api.openai.com/v1',
158
- contextWindow: 200000,
159
- maxOutputTokens: 100000,
160
147
  displayName: 'o4-mini',
161
148
  supportsThinking: true,
162
149
  thinkingProtocol: 'openai-reasoning',
@@ -168,16 +155,12 @@ export const MODEL_REGISTRY = new Map([
168
155
  provider: 'deepseek',
169
156
  adapter: 'openai-responses',
170
157
  baseUrl: 'https://api.deepseek.com',
171
- contextWindow: 131072,
172
- maxOutputTokens: 8192,
173
158
  displayName: 'DeepSeek Chat',
174
159
  }],
175
160
  ['deepseek-reasoner', {
176
161
  provider: 'deepseek',
177
162
  adapter: 'openai-responses',
178
163
  baseUrl: 'https://api.deepseek.com',
179
- contextWindow: 131072,
180
- maxOutputTokens: 8192,
181
164
  displayName: 'DeepSeek Reasoner',
182
165
  }],
183
166
 
@@ -186,16 +169,12 @@ export const MODEL_REGISTRY = new Map([
186
169
  provider: 'google',
187
170
  adapter: 'openai-responses',
188
171
  baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
189
- contextWindow: 1048576,
190
- maxOutputTokens: 65536,
191
172
  displayName: 'Gemini 2.5 Pro',
192
173
  }],
193
174
  ['gemini-2.5-flash', {
194
175
  provider: 'google',
195
176
  adapter: 'openai-responses',
196
177
  baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
197
- contextWindow: 1048576,
198
- maxOutputTokens: 65536,
199
178
  displayName: 'Gemini 2.5 Flash',
200
179
  }],
201
180
  ]);
@@ -214,9 +193,9 @@ export function resolveModel(modelName) {
214
193
  }
215
194
 
216
195
  /**
217
- * Default context window when neither the model registry nor the engine
218
- * config has a value. 200K is a conservative middle-ground — most modern
219
- * production models (Claude, GPT-5, Gemini) have ≥ 128K.
196
+ * Default context window when no upstream source has a value. 200K is a
197
+ * conservative middle-ground — most modern production models (Claude,
198
+ * GPT-5, Gemini) have ≥ 128K.
220
199
  *
221
200
  * Single source of truth: callers (engine.js pre-flight guard,
222
201
  * tools/registry.js per-result cap) MUST use this constant or
@@ -225,40 +204,106 @@ export function resolveModel(modelName) {
225
204
  export const DEFAULT_CONTEXT_WINDOW = 200_000;
226
205
 
227
206
  /**
228
- * Resolve the live context window for a model, with an explicit fallback
229
- * ladder:
230
- * 1. MODEL_REGISTRY entry's `contextWindow` (most accurate)
231
- * 2. caller-supplied config override (e.g. `config.maxContextTokens`)
232
- * 3. {@link DEFAULT_CONTEXT_WINDOW}
207
+ * Default per-call output cap when no upstream source has a value. This is
208
+ * the floor the adapters use to size `max_tokens`; production models give us
209
+ * more, but 16K is enough to make progress on any single turn without
210
+ * tripping a provider-side reject.
211
+ */
212
+ export const DEFAULT_MAX_OUTPUT_TOKENS = 16_384;
213
+
214
+ /**
215
+ * Resolve the live context window for a model.
216
+ *
217
+ * Fallback ladder (first match wins):
218
+ * 1. `config.modelInfo.contextWindow` — per-provider override originating
219
+ * from `~/.yeaft/config.json: providers[].models[].contextWindow`. The
220
+ * caller is responsible for passing the relevant entry; loadConfig
221
+ * threads it through `config.modelInfo` already.
222
+ * 2. {@link lookupModelLimitSync} against the warmed models.dev snapshot,
223
+ * hinted by the MODEL_REGISTRY provider when available so collisions
224
+ * across providers resolve to the right entry.
225
+ * 3. `config.maxContextTokens` — global ceiling from config / CLI.
226
+ * 4. {@link DEFAULT_CONTEXT_WINDOW}.
233
227
  *
234
228
  * Used by the per-tool-result cap and the pre-flight token guard so the
235
229
  * defense layers always see the same number, regardless of which seam
236
230
  * resolves it first.
237
231
  *
238
232
  * @param {string} modelName
239
- * @param {{ maxContextTokens?: number }} [config]
233
+ * @param {{ maxContextTokens?: number, modelInfo?: { contextWindow?: number } } | null} [config]
240
234
  * @returns {number}
241
235
  */
242
236
  export function resolveContextWindow(modelName, config) {
243
- const info = resolveModel(modelName);
244
- if (info && Number.isFinite(info.contextWindow) && info.contextWindow > 0) {
245
- return info.contextWindow;
237
+ // Rung 1: per-provider config override threaded via config.modelInfo.
238
+ const overrideCtx = config?.modelInfo?.contextWindow;
239
+ if (Number.isFinite(overrideCtx) && overrideCtx > 0) return overrideCtx;
240
+
241
+ // Rung 2: models.dev snapshot (warmed at agent boot).
242
+ const reg = MODEL_REGISTRY.get(modelName);
243
+ const limit = lookupModelLimitSync(modelName, reg?.provider || null);
244
+ if (limit && Number.isFinite(limit.context) && limit.context > 0) {
245
+ return limit.context;
246
246
  }
247
+
248
+ // Rung 3: global config ceiling.
247
249
  const cfg = config && Number.isFinite(config.maxContextTokens) && config.maxContextTokens > 0
248
250
  ? config.maxContextTokens : null;
249
251
  if (cfg !== null) return cfg;
252
+
253
+ // Rung 4: default.
250
254
  return DEFAULT_CONTEXT_WINDOW;
251
255
  }
252
256
 
253
257
  /**
254
- * List all known models.
258
+ * Resolve the live per-call output cap for a model. Same ladder as
259
+ * {@link resolveContextWindow} but for the `output` axis:
260
+ * 1. `config.modelInfo.maxOutput` override
261
+ * 2. models.dev `limit.output`
262
+ * 3. `config.maxOutputTokens`
263
+ * 4. {@link DEFAULT_MAX_OUTPUT_TOKENS}
264
+ *
265
+ * @param {string} modelName
266
+ * @param {{ maxOutputTokens?: number, modelInfo?: { maxOutput?: number, maxOutputTokens?: number } } | null} [config]
267
+ * @returns {number}
268
+ */
269
+ export function resolveMaxOutputTokens(modelName, config) {
270
+ const overrideOut = config?.modelInfo?.maxOutput
271
+ ?? config?.modelInfo?.maxOutputTokens;
272
+ if (Number.isFinite(overrideOut) && overrideOut > 0) return overrideOut;
273
+
274
+ const reg = MODEL_REGISTRY.get(modelName);
275
+ const limit = lookupModelLimitSync(modelName, reg?.provider || null);
276
+ if (limit && Number.isFinite(limit.output) && limit.output > 0) {
277
+ return limit.output;
278
+ }
279
+
280
+ const cfg = config && Number.isFinite(config.maxOutputTokens) && config.maxOutputTokens > 0
281
+ ? config.maxOutputTokens : null;
282
+ if (cfg !== null) return cfg;
283
+
284
+ return DEFAULT_MAX_OUTPUT_TOKENS;
285
+ }
286
+
287
+ /**
288
+ * List all known models with their resolved token limits.
289
+ *
290
+ * Token limits are resolved at call time — they reflect whatever models.dev
291
+ * snapshot is currently warmed, plus the DEFAULT fallback for models the
292
+ * snapshot doesn't cover. The returned objects therefore mirror what
293
+ * `resolveContextWindow` / `resolveMaxOutputTokens` would return for the
294
+ * same model id.
255
295
  *
256
296
  * @returns {{ name: string, adapter: string, baseUrl: string, contextWindow: number, maxOutputTokens: number, displayName: string }[]}
257
297
  */
258
298
  export function listModels() {
259
299
  const result = [];
260
300
  for (const [name, info] of MODEL_REGISTRY) {
261
- result.push({ name, ...info });
301
+ result.push({
302
+ name,
303
+ ...info,
304
+ contextWindow: resolveContextWindow(name, null),
305
+ maxOutputTokens: resolveMaxOutputTokens(name, null),
306
+ });
262
307
  }
263
308
  return result;
264
309
  }
@@ -484,11 +529,16 @@ export function serializeModelForPersistence(entry) {
484
529
  *
485
530
  * Lookup order:
486
531
  * 1. provider-config fields (contextWindow / maxOutput) — highest priority
487
- * 2. MODEL_REGISTRY entry (contextWindow / maxOutputTokens)
532
+ * 2. models.dev snapshot (`limit.{context,output}`) via the synchronous
533
+ * reader, hinted by the MODEL_REGISTRY provider when known so cross-
534
+ * provider id collisions resolve to the right entry.
488
535
  * 3. undefined if neither has any info
489
536
  *
490
537
  * Returns a normalized shape: `{ id, contextWindow?, maxOutput?, provider?, adapter?, baseUrl?, displayName? }`.
491
538
  *
539
+ * Note: token limits are intentionally NOT read from MODEL_REGISTRY — those
540
+ * fields were removed when models.dev became the source of truth.
541
+ *
492
542
  * @param {string} model
493
543
  * @param {{ id?: string, contextWindow?: number, maxOutput?: number } | null} [providerConfig]
494
544
  * @returns {object | undefined}
@@ -499,8 +549,12 @@ export function getModelInfo(model, providerConfig) {
499
549
  const overrideCtx = coercePositiveInt(providerConfig?.contextWindow);
500
550
  const overrideMax = coercePositiveInt(providerConfig?.maxOutput);
501
551
 
502
- const ctx = overrideCtx ?? reg?.contextWindow;
503
- const max = overrideMax ?? reg?.maxOutputTokens;
552
+ // Pull the models.dev numbers as the second rung. Hint with the registry's
553
+ // provider id when available — same alignment trick the resolveContext-
554
+ // Window ladder uses.
555
+ const limit = lookupModelLimitSync(model, reg?.provider || null);
556
+ const ctx = overrideCtx ?? (limit && Number.isFinite(limit.context) && limit.context > 0 ? limit.context : undefined);
557
+ const max = overrideMax ?? (limit && Number.isFinite(limit.output) && limit.output > 0 ? limit.output : undefined);
504
558
 
505
559
  // If we have literally no information, return undefined
506
560
  if (!reg && ctx === undefined && max === undefined) return undefined;