@aragon-agent/core 0.2.25 → 0.2.27

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/CHANGELOG.md CHANGED
@@ -5,7 +5,18 @@ All notable changes to `@aragon-agent/core` are documented here. Entries at
5
5
  — those releases really were published under that name, so they are left as they
6
6
  were written.
7
7
 
8
- ## Unreleased
8
+ ## Unreleased
9
+
10
+ ### Fixed
11
+
12
+ - Model context resolution now reads explicit OpenAI-compatible, Anthropic and
13
+ Google API limits, preserves their provenance, and resolves endpoint-scoped
14
+ discovery before catalog/alias fallbacks. Unknown limits stay marked as such.
15
+ Context metadata does not imply known pricing.
16
+ - Google/Anthropic discovery follows bounded pagination under a single 15-second
17
+ deadline. All three adapters accept cancellation; cancelled or invalidated
18
+ lookups cannot overwrite current metadata. Clearing one provider's cache leaves
19
+ other providers' pending discoveries intact.
9
20
 
10
21
  ### Added
11
22
 
@@ -292,7 +292,9 @@ export async function runAgentLoop(ctx) {
292
292
  // 3. INSIDE the `while`, not before it. A run that starts under the
293
293
  // threshold and crosses it at turn 12 is the normal case, and it is the
294
294
  // case an `agent_end`-based design cannot serve at all (D-1).
295
- await runCompaction(ctx, { trigger: 'pressure', lastUsage, turnIndex });
295
+ if (await runCompaction(ctx, { trigger: 'pressure', lastUsage, turnIndex })) {
296
+ lastUsage = undefined;
297
+ }
296
298
  if (ctx.signal.aborted)
297
299
  break;
298
300
  // ----- Turn start -----
@@ -351,6 +353,8 @@ export async function runAgentLoop(ctx) {
351
353
  throw err;
352
354
  overflowRecovered = true;
353
355
  const recovered = await runCompaction(ctx, { trigger: 'overflow', lastUsage, turnIndex });
356
+ if (recovered)
357
+ lastUsage = undefined;
354
358
  if (!recovered)
355
359
  throw err;
356
360
  if (ctx.signal.aborted)
@@ -0,0 +1,9 @@
1
+ import type { ModelInfo } from './types.js';
2
+ export type ContextWindowInfo = Pick<ModelInfo, 'contextWindow'> & {
3
+ contextWindowSource: 'api' | 'catalog' | 'fallback';
4
+ };
5
+ /** Never interpret output-token limits as an input/context limit. */
6
+ export declare function reportedContextWindow(model: Record<string, unknown>): ContextWindowInfo;
7
+ /** Recognize only explicit vendor namespaces and dated snapshots, not arbitrary aliases. */
8
+ export declare function catalogModelId(modelId: string): string;
9
+ export declare function catalogContextWindow(modelId: string): number | undefined;
@@ -0,0 +1,46 @@
1
+ /** Never interpret output-token limits as an input/context limit. */
2
+ export function reportedContextWindow(model) {
3
+ const top = model.top_provider;
4
+ const candidates = [top?.context_length, model.context_length, model.context_window,
5
+ model.max_input_tokens, model.inputTokenLimit, model.contextWindow];
6
+ const value = candidates.find((n) => typeof n === 'number' && Number.isSafeInteger(n) && n > 0);
7
+ return typeof value === 'number'
8
+ ? { contextWindow: value, contextWindowSource: 'api' }
9
+ : { contextWindow: 128_000, contextWindowSource: 'fallback' };
10
+ }
11
+ /** Recognize only explicit vendor namespaces and dated snapshots, not arbitrary aliases. */
12
+ export function catalogModelId(modelId) {
13
+ return modelId.trim().toLowerCase()
14
+ .replace(/^(models|openai|anthropic|google)\//, '')
15
+ .replace(/-(?:\d{4}-\d{2}-\d{2}|\d{8})$/, '');
16
+ }
17
+ // Context-only fallbacks: do not invent prices or change the model picker.
18
+ // https://developers.openai.com/api/docs/models/gpt-5
19
+ // https://developers.openai.com/api/docs/models/gpt-5.1
20
+ // https://developers.openai.com/api/docs/models/gpt-5.2
21
+ // https://developers.openai.com/api/docs/models/gpt-5.4
22
+ // https://developers.openai.com/api/docs/models/gpt-5.4-mini
23
+ // https://developers.openai.com/api/docs/models/gpt-5-mini
24
+ // https://developers.openai.com/api/docs/models/gpt-5.3-codex
25
+ // https://developers.openai.com/api/docs/models/gpt-4.1
26
+ // https://ai.google.dev/gemini-api/docs/models/gemini-2.5-pro
27
+ // https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash
28
+ // https://platform.claude.com/docs/en/models/sonnet-4-6/overview
29
+ // Explicit IDs avoid assuming that future versions or chat variants share a limit.
30
+ const CATALOG = {
31
+ 'gpt-5': 400_000,
32
+ 'gpt-5.1': 400_000,
33
+ 'gpt-5.2': 400_000,
34
+ 'gpt-5.4': 1_050_000,
35
+ 'gpt-5.4-mini': 400_000,
36
+ 'gpt-5-mini': 400_000,
37
+ 'gpt-5.3-codex': 400_000,
38
+ 'gpt-4.1': 1_047_576,
39
+ 'gemini-2.5-pro': 1_048_576,
40
+ 'gemini-2.5-flash': 1_048_576,
41
+ 'claude-sonnet-4-6': 1_000_000,
42
+ };
43
+ export function catalogContextWindow(modelId) {
44
+ const id = catalogModelId(modelId);
45
+ return Object.prototype.hasOwnProperty.call(CATALOG, id) ? CATALOG[id] : undefined;
46
+ }
@@ -0,0 +1,5 @@
1
+ /** One bounded lifetime for all pages, also cancelled when the owning session ends. */
2
+ export declare function createModelDiscoverySignal(parent?: AbortSignal): {
3
+ signal: AbortSignal;
4
+ dispose: () => void;
5
+ };
@@ -0,0 +1,18 @@
1
+ /** One bounded lifetime for all pages, also cancelled when the owning session ends. */
2
+ export function createModelDiscoverySignal(parent) {
3
+ const controller = new AbortController();
4
+ const abort = () => controller.abort(parent?.reason);
5
+ const timer = setTimeout(() => controller.abort(new Error('Model discovery timed out')), 15_000);
6
+ timer.unref();
7
+ if (parent?.aborted)
8
+ abort();
9
+ else
10
+ parent?.addEventListener('abort', abort, { once: true });
11
+ return {
12
+ signal: controller.signal,
13
+ dispose: () => {
14
+ clearTimeout(timer);
15
+ parent?.removeEventListener('abort', abort);
16
+ },
17
+ };
18
+ }
@@ -8,11 +8,13 @@
8
8
  */
9
9
  import type { ModelInfo } from './types.js';
10
10
  import type { ProviderRegistry } from './providers/index.js';
11
+ import { type ContextWindowInfo } from './context-window.js';
11
12
  export declare class ModelRegistry {
12
13
  /** Static/builtin models keyed by provider id. */
13
14
  private readonly builtinModels;
14
15
  /** API-discovered models cache keyed by `${providerId}:${baseUrl}`. */
15
16
  private readonly discoveryCache;
17
+ private readonly pendingDiscovery;
16
18
  /** Optional reference to the provider registry for API discovery. */
17
19
  private providerRegistry;
18
20
  constructor(providerRegistry?: ProviderRegistry);
@@ -22,12 +24,15 @@ export declare class ModelRegistry {
22
24
  getModels(providerId: string): ModelInfo[];
23
25
  /** Look up a specific model by provider + model id. */
24
26
  getModel(providerId: string, modelId: string): ModelInfo | undefined;
27
+ /** Resolve context separately from price, scoped to the configured endpoint. */
28
+ getContextWindow(providerId: string, modelId: string, baseUrl?: string): ContextWindowInfo;
29
+ private cacheKey;
25
30
  /**
26
31
  * Discover models from the provider's API. Results are cached for 5 min.
27
32
  *
28
33
  * Falls back to an empty array on failure — never throws.
29
34
  */
30
- discoverModels(providerId: string, apiKey: string, baseUrl?: string): Promise<ModelInfo[]>;
35
+ discoverModels(providerId: string, apiKey: string, baseUrl?: string, signal?: AbortSignal): Promise<ModelInfo[]>;
31
36
  /** Invalidate the discovery cache for a provider (or all). */
32
37
  clearCache(providerId?: string): void;
33
38
  /**
@@ -7,6 +7,7 @@
7
7
  * new LLMProvider interface for API queries.
8
8
  */
9
9
  import { DEFAULT_MAX_OUTPUT_TOKENS, learnModelCeiling } from './output-limits.js';
10
+ import { catalogContextWindow, catalogModelId } from './context-window.js';
10
11
  /** Cache TTL: 5 minutes. */
11
12
  const CACHE_TTL_MS = 5 * 60 * 1000;
12
13
  // ---------------------------------------------------------------------------
@@ -17,6 +18,7 @@ export class ModelRegistry {
17
18
  builtinModels = new Map();
18
19
  /** API-discovered models cache keyed by `${providerId}:${baseUrl}`. */
19
20
  discoveryCache = new Map();
21
+ pendingDiscovery = new Map();
20
22
  /** Optional reference to the provider registry for API discovery. */
21
23
  providerRegistry;
22
24
  constructor(providerRegistry) {
@@ -39,54 +41,87 @@ export class ModelRegistry {
39
41
  const models = this.builtinModels.get(providerId);
40
42
  return models?.find((m) => m.id === modelId);
41
43
  }
44
+ /** Resolve context separately from price, scoped to the configured endpoint. */
45
+ getContextWindow(providerId, modelId, baseUrl) {
46
+ const discovered = this.discoveryCache.get(this.cacheKey(providerId, baseUrl))?.models
47
+ .find((m) => m.id.replace(/^models\//, '') === modelId.replace(/^models\//, ''));
48
+ if (discovered?.contextWindowSource === 'api' &&
49
+ Number.isSafeInteger(discovered.contextWindow) && discovered.contextWindow > 0) {
50
+ return { contextWindow: discovered.contextWindow, contextWindowSource: 'api' };
51
+ }
52
+ const id = catalogModelId(modelId);
53
+ const builtin = this.getModel(providerId, modelId) ?? this.getModels(providerId)
54
+ .find((m) => catalogModelId(m.id) === id) ?? [...this.builtinModels.values()]
55
+ .flat().find((m) => catalogModelId(m.id) === id);
56
+ const builtinWindow = builtin?.contextWindowSource !== 'fallback' &&
57
+ Number.isSafeInteger(builtin?.contextWindow) && (builtin?.contextWindow ?? 0) > 0
58
+ ? builtin?.contextWindow : undefined;
59
+ const window = builtinWindow ?? catalogContextWindow(id);
60
+ return window !== undefined
61
+ ? { contextWindow: window, contextWindowSource: 'catalog' }
62
+ : { contextWindow: 128_000, contextWindowSource: 'fallback' };
63
+ }
64
+ cacheKey(providerId, baseUrl) {
65
+ const url = baseUrl || this.providerRegistry?.get(providerId)?.defaultBaseUrl || '';
66
+ return `${providerId}:${url.replace(/\/+$/, '')}`;
67
+ }
42
68
  /**
43
69
  * Discover models from the provider's API. Results are cached for 5 min.
44
70
  *
45
71
  * Falls back to an empty array on failure — never throws.
46
72
  */
47
- async discoverModels(providerId, apiKey, baseUrl) {
48
- const cacheKey = `${providerId}:${baseUrl || ''}`;
73
+ async discoverModels(providerId, apiKey, baseUrl, signal) {
74
+ if (signal?.aborted)
75
+ return [];
76
+ const cacheKey = this.cacheKey(providerId, baseUrl);
49
77
  const cached = this.discoveryCache.get(cacheKey);
50
78
  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
51
79
  return cached.models;
52
80
  }
81
+ const pending = this.pendingDiscovery.get(cacheKey);
82
+ if (pending && !pending.signal?.aborted)
83
+ return pending.promise;
53
84
  if (!this.providerRegistry)
54
85
  return [];
55
86
  const provider = this.providerRegistry.get(providerId);
56
87
  if (!provider)
57
88
  return [];
58
- try {
59
- const models = await provider.listModels(apiKey, baseUrl);
60
- // Discovery outranks the static table (§4.1.2). The adapters that can
61
- // report a REAL ceiling already learn it themselves; this feeds anything
62
- // reached through the registry, including third-party providers.
63
- //
64
- // A ceiling that is EXACTLY the product default is skipped, because that
65
- // is precisely what `listModels` returns when it has nothing to report —
66
- // neither the Anthropic nor the OpenAI models endpoint carries an output
67
- // ceiling, so both fall back to it (see the notes in their adapters).
68
- // Learning it here at `'discovery'` rank would launder that placeholder
69
- // into an assertion that outranks the static table, and would collapse the
70
- // distinction `staticCeilingFor`'s `undefined` return exists to keep:
71
- // "unknown" would become "capped at 64000", silently clamping an explicit
72
- // larger request the user is entitled to make against a proxy. Skipping it
73
- // costs nothing — AUTO tops out at the default anyway, and a genuinely
74
- // over-large request is repaired by `output-limit-recovery.ts`.
75
- for (const model of models) {
76
- if (model.maxOutputTokens === DEFAULT_MAX_OUTPUT_TOKENS)
77
- continue;
78
- learnModelCeiling(providerId, model.id, model.maxOutputTokens, 'discovery');
89
+ const token = Symbol();
90
+ const work = (async () => {
91
+ try {
92
+ const models = await provider.listModels(apiKey, baseUrl, signal);
93
+ if (signal?.aborted || this.pendingDiscovery.get(cacheKey)?.token !== token)
94
+ return [];
95
+ // An output placeholder must not outrank the static ceiling table.
96
+ // Context provenance is resolved separately by getContextWindow().
97
+ for (const model of models) {
98
+ if (model.maxOutputTokens === DEFAULT_MAX_OUTPUT_TOKENS)
99
+ continue;
100
+ learnModelCeiling(providerId, model.id, model.maxOutputTokens, 'discovery');
101
+ }
102
+ this.discoveryCache.set(cacheKey, { models, timestamp: Date.now() });
103
+ return models;
104
+ }
105
+ catch {
106
+ return [];
79
107
  }
80
- this.discoveryCache.set(cacheKey, { models, timestamp: Date.now() });
81
- return models;
108
+ })();
109
+ this.pendingDiscovery.set(cacheKey, { promise: work, signal, token });
110
+ try {
111
+ return await work;
82
112
  }
83
- catch {
84
- return [];
113
+ finally {
114
+ if (this.pendingDiscovery.get(cacheKey)?.promise === work)
115
+ this.pendingDiscovery.delete(cacheKey);
85
116
  }
86
117
  }
87
118
  /** Invalidate the discovery cache for a provider (or all). */
88
119
  clearCache(providerId) {
89
120
  if (providerId) {
121
+ for (const key of this.pendingDiscovery.keys()) {
122
+ if (key.startsWith(`${providerId}:`))
123
+ this.pendingDiscovery.delete(key);
124
+ }
90
125
  for (const key of this.discoveryCache.keys()) {
91
126
  if (key.startsWith(`${providerId}:`)) {
92
127
  this.discoveryCache.delete(key);
@@ -94,6 +129,7 @@ export class ModelRegistry {
94
129
  }
95
130
  }
96
131
  else {
132
+ this.pendingDiscovery.clear();
97
133
  this.discoveryCache.clear();
98
134
  }
99
135
  }
@@ -108,6 +144,7 @@ export class ModelRegistry {
108
144
  name: modelId,
109
145
  provider: providerId,
110
146
  contextWindow: 128_000,
147
+ contextWindowSource: 'fallback',
111
148
  // The PRODUCT DEFAULT, not a private guess. An unknown model that claims a
112
149
  // ceiling nothing else agrees with is how one part of the system clamps
113
150
  // output the rest of it never asked for.
@@ -168,7 +205,8 @@ const ANTHROPIC_MODELS = [
168
205
  id: 'claude-opus-4-6',
169
206
  name: 'Claude Opus 4.6',
170
207
  provider: 'anthropic',
171
- contextWindow: 200_000,
208
+ // https://platform.claude.com/docs/en/models/opus-4-6/overview
209
+ contextWindow: 1_000_000,
172
210
  maxOutputTokens: 64_000,
173
211
  supportsThinking: true,
174
212
  supportsTools: true,
@@ -71,7 +71,7 @@ export interface LLMProvider {
71
71
  * Implementations should apply a reasonable timeout (15 s) and return an
72
72
  * empty array on failure rather than throwing.
73
73
  */
74
- listModels(apiKey: string, baseUrl?: string): Promise<ModelInfo[]>;
74
+ listModels(apiKey: string, baseUrl?: string, signal?: AbortSignal): Promise<ModelInfo[]>;
75
75
  }
76
76
  export type LLMErrorType = 'auth_error' | 'rate_limit' | 'overloaded' | 'invalid_request' | 'context_overflow' | 'network_error' | 'timeout' | 'server_error' | 'unknown';
77
77
  export declare class LLMError extends Error {
@@ -19,5 +19,5 @@ export declare class AnthropicProvider implements LLMProvider {
19
19
  readonly defaultBaseUrl = "https://api.anthropic.com";
20
20
  stream(request: LLMRequest): AsyncIterableIterator<StreamEvent>;
21
21
  complete(request: LLMRequest): Promise<AssistantMessage>;
22
- listModels(apiKey: string, baseUrl?: string): Promise<ModelInfo[]>;
22
+ listModels(apiKey: string, baseUrl?: string, signal?: AbortSignal): Promise<ModelInfo[]>;
23
23
  }
@@ -11,6 +11,8 @@
11
11
  * - Multimodal user messages (text + image)
12
12
  * - Model list via `GET /v1/models`
13
13
  */
14
+ import { reportedContextWindow } from '../context-window.js';
15
+ import { createModelDiscoverySignal } from '../model-discovery.js';
14
16
  import { THINKING_BUDGET } from '../types.js';
15
17
  import { LLMError, classifyHttpError, wrapFetchError } from '../provider.js';
16
18
  import { DEFAULT_MAX_OUTPUT_TOKENS, resolveOutputTokens, staticCeilingFor, } from '../output-limits.js';
@@ -282,30 +284,49 @@ export class AnthropicProvider {
282
284
  // -----------------------------------------------------------------------
283
285
  // listModels
284
286
  // -----------------------------------------------------------------------
285
- async listModels(apiKey, baseUrl) {
287
+ async listModels(apiKey, baseUrl, signal) {
286
288
  const base = (baseUrl || this.defaultBaseUrl).replace(/\/+$/, '');
287
289
  const url = `${base}/v1/models`;
290
+ const scope = createModelDiscoverySignal(signal);
288
291
  try {
289
- const res = await fetch(url, {
290
- method: 'GET',
291
- headers: {
292
- 'x-api-key': apiKey,
293
- 'anthropic-version': ANTHROPIC_VERSION,
294
- },
295
- signal: AbortSignal.timeout(15_000),
296
- });
297
- if (!res.ok)
292
+ const models = [];
293
+ const seen = new Set();
294
+ let cursor;
295
+ for (let page = 0; page < 20 && !scope.signal.aborted; page++) {
296
+ try {
297
+ const pageUrl = cursor === undefined ? url : `${url}?after_id=${encodeURIComponent(cursor)}`;
298
+ const res = await fetch(pageUrl, {
299
+ method: 'GET',
300
+ headers: { 'x-api-key': apiKey, 'anthropic-version': ANTHROPIC_VERSION },
301
+ signal: scope.signal,
302
+ });
303
+ if (!res.ok)
304
+ break;
305
+ const data = await res.json();
306
+ if (!Array.isArray(data.data))
307
+ break;
308
+ models.push(...data.data);
309
+ const next = data.last_id;
310
+ if (data.has_more !== true || typeof next !== 'string' || next.length === 0 || seen.has(next))
311
+ break;
312
+ seen.add(next);
313
+ cursor = next;
314
+ }
315
+ catch {
316
+ break; // Preserve metadata from earlier pages when continuation fails.
317
+ }
318
+ }
319
+ if (signal?.aborted)
298
320
  return [];
299
- const data = await res.json();
300
321
  // The Anthropic models endpoint does NOT report an output ceiling, so this
301
322
  // maps through the static table and deliberately does NOT call
302
323
  // `learnModelCeiling`: asserting 64000 here is how `claude-3-5-haiku` ends
303
324
  // up with a "discovered" ceiling that outranks its correct table entry.
304
- return (data.data || []).map((m) => ({
325
+ return models.map((m) => ({
305
326
  id: String(m.id || ''),
306
327
  name: String(m.display_name || m.id || ''),
307
328
  provider: this.id,
308
- contextWindow: 200_000,
329
+ ...reportedContextWindow(m),
309
330
  maxOutputTokens: staticCeilingFor(this.id, String(m.id || '')) ?? DEFAULT_MAX_OUTPUT_TOKENS,
310
331
  supportsThinking: /claude-(sonnet-4|opus-4)/i.test(String(m.id)),
311
332
  supportsTools: true,
@@ -316,6 +337,9 @@ export class AnthropicProvider {
316
337
  catch {
317
338
  return [];
318
339
  }
340
+ finally {
341
+ scope.dispose();
342
+ }
319
343
  }
320
344
  }
321
345
  // ---------------------------------------------------------------------------
@@ -18,5 +18,5 @@ export declare class GoogleProvider implements LLMProvider {
18
18
  readonly defaultBaseUrl = "https://generativelanguage.googleapis.com";
19
19
  stream(request: LLMRequest): AsyncIterableIterator<StreamEvent>;
20
20
  complete(request: LLMRequest): Promise<AssistantMessage>;
21
- listModels(apiKey: string, baseUrl?: string): Promise<ModelInfo[]>;
21
+ listModels(apiKey: string, baseUrl?: string, signal?: AbortSignal): Promise<ModelInfo[]>;
22
22
  }
@@ -11,6 +11,8 @@
11
11
  * - The API key is passed as a query parameter, not a header.
12
12
  */
13
13
  import { randomUUID } from 'node:crypto';
14
+ import { reportedContextWindow } from '../context-window.js';
15
+ import { createModelDiscoverySignal } from '../model-discovery.js';
14
16
  import { LLMError, classifyHttpError, wrapFetchError } from '../provider.js';
15
17
  import { DEFAULT_MAX_OUTPUT_TOKENS, learnModelCeiling, resolveOutputTokens, } from '../output-limits.js';
16
18
  import { sendWithOutputLimitRecovery } from '../output-limit-recovery.js';
@@ -199,18 +201,39 @@ export class GoogleProvider {
199
201
  // -----------------------------------------------------------------------
200
202
  // listModels
201
203
  // -----------------------------------------------------------------------
202
- async listModels(apiKey, baseUrl) {
204
+ async listModels(apiKey, baseUrl, signal) {
203
205
  const base = (baseUrl || this.defaultBaseUrl).replace(/\/+$/, '');
204
206
  const url = `${base}/v1beta/models?key=${encodeURIComponent(apiKey)}`;
207
+ const scope = createModelDiscoverySignal(signal);
205
208
  try {
206
- const res = await fetch(url, {
207
- method: 'GET',
208
- signal: AbortSignal.timeout(15_000),
209
- });
210
- if (!res.ok)
209
+ const models = [];
210
+ const seen = new Set();
211
+ let cursor;
212
+ for (let page = 0; page < 20 && !scope.signal.aborted; page++) {
213
+ try {
214
+ const pageUrl = cursor === undefined ? url : `${url}&pageToken=${encodeURIComponent(cursor)}`;
215
+ const res = await fetch(pageUrl, { method: 'GET', signal: scope.signal });
216
+ if (!res.ok)
217
+ break;
218
+ const data = await res.json();
219
+ if (!Array.isArray(data.models))
220
+ break;
221
+ models.push(...data.models);
222
+ const next = data.nextPageToken;
223
+ if (typeof next !== 'string' || next.length === 0 || seen.has(next))
224
+ break;
225
+ seen.add(next);
226
+ cursor = next;
227
+ }
228
+ catch {
229
+ break; // Preserve metadata from earlier pages when continuation fails.
230
+ }
231
+ }
232
+ // Explicit session cancellation must not publish partial results or learn ceilings.
233
+ // The internal timeout still permits useful metadata from completed pages.
234
+ if (signal?.aborted)
211
235
  return [];
212
- const data = await res.json();
213
- return (data.models || [])
236
+ return models
214
237
  .filter((m) => {
215
238
  const methods = (m.supportedGenerationMethods || []);
216
239
  return methods.includes('generateContent') || methods.includes('streamGenerateContent');
@@ -228,7 +251,7 @@ export class GoogleProvider {
228
251
  id,
229
252
  name: String(m.displayName || id),
230
253
  provider: this.id,
231
- contextWindow: m.inputTokenLimit || 128_000,
254
+ ...reportedContextWindow(m),
232
255
  maxOutputTokens: reported || DEFAULT_MAX_OUTPUT_TOKENS,
233
256
  supportsThinking: false,
234
257
  supportsTools: true,
@@ -240,6 +263,9 @@ export class GoogleProvider {
240
263
  catch {
241
264
  return [];
242
265
  }
266
+ finally {
267
+ scope.dispose();
268
+ }
243
269
  }
244
270
  }
245
271
  // ---------------------------------------------------------------------------
@@ -28,5 +28,5 @@ export declare class OpenAIProvider implements LLMProvider {
28
28
  readonly defaultBaseUrl = "https://api.openai.com/v1";
29
29
  stream(request: LLMRequest): AsyncIterableIterator<StreamEvent>;
30
30
  complete(request: LLMRequest): Promise<AssistantMessage>;
31
- listModels(apiKey: string, baseUrl?: string): Promise<ModelInfo[]>;
31
+ listModels(apiKey: string, baseUrl?: string, signal?: AbortSignal): Promise<ModelInfo[]>;
32
32
  }
@@ -18,6 +18,8 @@ import { consumeStream } from '../stream-utils.js';
18
18
  // ---------------------------------------------------------------------------
19
19
  // Constants
20
20
  // ---------------------------------------------------------------------------
21
+ import { reportedContextWindow } from '../context-window.js';
22
+ import { createModelDiscoverySignal } from '../model-discovery.js';
21
23
  const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
22
24
  export const OPENAI_TERMINAL_DRAIN_MS = 750;
23
25
  /** Reasoning-family models reject `max_tokens` in favour of `max_completion_tokens`. */
@@ -111,20 +113,25 @@ export class OpenAIProvider {
111
113
  // -----------------------------------------------------------------------
112
114
  // listModels
113
115
  // -----------------------------------------------------------------------
114
- async listModels(apiKey, baseUrl) {
116
+ async listModels(apiKey, baseUrl, signal) {
115
117
  const base = (baseUrl || this.defaultBaseUrl).replace(/\/+$/, '');
116
118
  const url = `${base}/models`;
119
+ const scope = createModelDiscoverySignal(signal);
117
120
  try {
121
+ if (scope.signal.aborted)
122
+ return [];
118
123
  const res = await fetch(url, {
119
124
  method: 'GET',
120
125
  headers: {
121
126
  'Authorization': `Bearer ${apiKey}`,
122
127
  },
123
- signal: AbortSignal.timeout(15_000),
128
+ signal: scope.signal,
124
129
  });
125
130
  if (!res.ok)
126
131
  return [];
127
132
  const data = await res.json();
133
+ if (signal?.aborted)
134
+ return [];
128
135
  // The OpenAI models endpoint does NOT report an output ceiling, so this
129
136
  // maps through the static table and deliberately does NOT call
130
137
  // `learnModelCeiling` — claiming knowledge we do not have would outrank
@@ -135,7 +142,7 @@ export class OpenAIProvider {
135
142
  id: String(m.id || ''),
136
143
  name: String(m.id || ''),
137
144
  provider: this.id,
138
- contextWindow: 128_000,
145
+ ...reportedContextWindow(m),
139
146
  maxOutputTokens: staticCeilingFor(this.id, String(m.id || '')) ?? DEFAULT_MAX_OUTPUT_TOKENS,
140
147
  supportsThinking: false,
141
148
  supportsTools: true,
@@ -146,6 +153,9 @@ export class OpenAIProvider {
146
153
  catch {
147
154
  return [];
148
155
  }
156
+ finally {
157
+ scope.dispose();
158
+ }
149
159
  }
150
160
  }
151
161
  /** Owns the request signal and the absolute, non-renewable terminal deadline. */
@@ -160,6 +160,8 @@ export interface ModelInfo {
160
160
  name: string;
161
161
  provider: string;
162
162
  contextWindow: number;
163
+ /** Provenance of the context limit; omitted by legacy/custom providers. */
164
+ contextWindowSource?: 'api' | 'catalog' | 'fallback';
163
165
  maxOutputTokens: number;
164
166
  supportsThinking: boolean;
165
167
  supportsTools: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/core",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "description": "AragonAgent core engine — LLM provider adapters, streaming, tool registry/execution, steering, idle watchdog, and an optional isolated-vm sandbox.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",