@aragon-agent/core 0.2.2 → 0.2.4

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,6 +5,50 @@ 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
9
+
10
+ ### Added
11
+
12
+ - **`llm/output-limits.ts` — one authority for the output-token cap.**
13
+ `DEFAULT_MAX_OUTPUT_TOKENS` (64000), `MIN_MAX_OUTPUT_TOKENS`,
14
+ `SAFE_FALLBACK_MAX_OUTPUT_TOKENS`, `ABSOLUTE_MAX_OUTPUT_TOKENS`,
15
+ `THINKING_HEADROOM_TOKENS`, `CONTEXT_SAFETY_MARGIN_TOKENS`,
16
+ `resolveOutputTokens`, `staticCeilingFor`, `learnModelCeiling`,
17
+ `getLearnedCeiling`, `clearLearnedCeilings` and `estimatePromptTokens` are all
18
+ exported from the root barrel. The three provider adapters no longer carry
19
+ private copies of the default; they resolve through this module.
20
+
21
+ The resolver treats the default as an *ambition* and the model's real ceiling
22
+ as the law, ranking four sources — learned-from-error > discovery > a static
23
+ per-model table > the product default — so an over-large cap is clamped instead
24
+ of becoming an HTTP 400 that ends the run. It also reconciles Anthropic's
25
+ `max_tokens > thinking.budget_tokens` invariant, which `--thinking xhigh` broke
26
+ on every request.
27
+
28
+ - **`llm/output-limit-recovery.ts` — one-shot self-healing.**
29
+ `classifyOutputLimitFailure`, `sendWithOutputLimitRecovery`, `learnTokenField`,
30
+ `getLearnedTokenField` and `clearLearnedTokenFields`. A 400 that names an
31
+ output-limit problem is repaired and re-sent **once** — never a loop — and what
32
+ was learned is remembered for the rest of the process. A 2xx is returned with
33
+ its body untouched, so streaming is unaffected. OpenAI's reasoning family,
34
+ which rejects `max_tokens` in favour of `max_completion_tokens`, is handled by
35
+ a heuristic plus this memo.
36
+
37
+ - **`LLMRequest.modelLimits?: { maxOutputTokens?, contextWindow? }`** — optional
38
+ caller-supplied metadata. Supplying `contextWindow` enables the
39
+ `max_tokens + prompt <= context` guard on OpenAI. Every existing caller
40
+ compiles unchanged.
41
+
42
+ ### Changed
43
+
44
+ - `ModelRegistry.buildRuntimeModel` defaults `maxOutputTokens` to
45
+ `DEFAULT_MAX_OUTPUT_TOKENS` rather than a private `16384`, the built-in
46
+ Anthropic catalog entries carry their real ceilings, and `registerDefaults` /
47
+ `discoverModels` seed the shared ceiling cache. `listModels` on the Anthropic
48
+ and OpenAI adapters now maps through the static table instead of asserting the
49
+ product default for every model — neither API reports an output ceiling, and
50
+ claiming otherwise outranked the correct value for every small model.
51
+
8
52
  ## 0.3.0
9
53
 
10
54
  ### Added
package/dist/index.d.ts CHANGED
@@ -9,6 +9,10 @@ export type { ContentPart, TextContentPart, ImageContentPart, ContentBlock, Text
9
9
  export { THINKING_BUDGET } from './llm/types.js';
10
10
  export type { LLMProvider, LLMRequest, LLMErrorType, } from './llm/provider.js';
11
11
  export { LLMError, classifyHttpError, wrapFetchError, } from './llm/provider.js';
12
+ export { DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, SAFE_FALLBACK_MAX_OUTPUT_TOKENS, ABSOLUTE_MAX_OUTPUT_TOKENS, THINKING_HEADROOM_TOKENS, CONTEXT_SAFETY_MARGIN_TOKENS, resolveOutputTokens, staticCeilingFor, learnModelCeiling, getLearnedCeiling, clearLearnedCeilings, estimatePromptTokens, } from './llm/output-limits.js';
13
+ export type { OutputLimitInput, OutputLimitResolution, CeilingSource, } from './llm/output-limits.js';
14
+ export { classifyOutputLimitFailure, sendWithOutputLimitRecovery, learnTokenField, getLearnedTokenField, clearLearnedTokenFields, } from './llm/output-limit-recovery.js';
15
+ export type { OutputLimitFailure, RecoveryAttempt, RecoveryOptions, TokenField, } from './llm/output-limit-recovery.js';
12
16
  export type { SSEEvent } from './llm/stream-utils.js';
13
17
  export { parseSSEStream, consumeStream, } from './llm/stream-utils.js';
14
18
  export { ProviderRegistry, initProviders, getProviderRegistry, setProviderRegistry, streamLLM, completeLLM, } from './llm/providers/index.js';
package/dist/index.js CHANGED
@@ -7,6 +7,12 @@
7
7
  */
8
8
  export { THINKING_BUDGET } from './llm/types.js';
9
9
  export { LLMError, classifyHttpError, wrapFetchError, } from './llm/provider.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Output-token limits — the authority for "how many tokens may this model
12
+ // produce". Every adapter resolves through here; nothing else spells 64000.
13
+ // ---------------------------------------------------------------------------
14
+ export { DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, SAFE_FALLBACK_MAX_OUTPUT_TOKENS, ABSOLUTE_MAX_OUTPUT_TOKENS, THINKING_HEADROOM_TOKENS, CONTEXT_SAFETY_MARGIN_TOKENS, resolveOutputTokens, staticCeilingFor, learnModelCeiling, getLearnedCeiling, clearLearnedCeilings, estimatePromptTokens, } from './llm/output-limits.js';
15
+ export { classifyOutputLimitFailure, sendWithOutputLimitRecovery, learnTokenField, getLearnedTokenField, clearLearnedTokenFields, } from './llm/output-limit-recovery.js';
10
16
  export { parseSSEStream, consumeStream, } from './llm/stream-utils.js';
11
17
  // ---------------------------------------------------------------------------
12
18
  // Provider registry
@@ -6,6 +6,7 @@
6
6
  * discovery logic mirrors `server/services/model-detection.ts`, but uses the
7
7
  * new LLMProvider interface for API queries.
8
8
  */
9
+ import { DEFAULT_MAX_OUTPUT_TOKENS, learnModelCeiling } from './output-limits.js';
9
10
  /** Cache TTL: 5 minutes. */
10
11
  const CACHE_TTL_MS = 5 * 60 * 1000;
11
12
  // ---------------------------------------------------------------------------
@@ -56,6 +57,26 @@ export class ModelRegistry {
56
57
  return [];
57
58
  try {
58
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');
79
+ }
59
80
  this.discoveryCache.set(cacheKey, { models, timestamp: Date.now() });
60
81
  return models;
61
82
  }
@@ -87,7 +108,10 @@ export class ModelRegistry {
87
108
  name: modelId,
88
109
  provider: providerId,
89
110
  contextWindow: 128_000,
90
- maxOutputTokens: 16_384,
111
+ // The PRODUCT DEFAULT, not a private guess. An unknown model that claims a
112
+ // ceiling nothing else agrees with is how one part of the system clamps
113
+ // output the rest of it never asked for.
114
+ maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
91
115
  supportsThinking: false,
92
116
  supportsTools: true,
93
117
  supportsImages: false,
@@ -108,10 +132,25 @@ export class ModelRegistry {
108
132
  this.builtinModels.set('google', GOOGLE_MODELS);
109
133
  this.builtinModels.set('xai', XAI_MODELS);
110
134
  this.builtinModels.set('groq', GROQ_MODELS);
135
+ // Seed the shared ceiling cache at the LOWEST rank, so discovery and
136
+ // recovery both override it. Without this the catalog's per-model knowledge
137
+ // is never consulted when a request body is built — the table is read by the
138
+ // model picker and by nothing else.
139
+ for (const models of this.builtinModels.values()) {
140
+ for (const model of models) {
141
+ learnModelCeiling(model.provider, model.id, model.maxOutputTokens, 'catalog');
142
+ }
143
+ }
111
144
  }
112
145
  }
113
146
  // ---------------------------------------------------------------------------
114
147
  // Builtin model lists
148
+ //
149
+ // `maxOutputTokens` here MUST agree with `CEILING_TABLE` in `output-limits.ts`.
150
+ // The two are consulted by different code paths — this one by the model picker,
151
+ // that one when a request body is built — and a disagreement shows up as
152
+ // "the picker says 64000 but my answers stop at 8192", which is unattributable
153
+ // from a transcript.
115
154
  // ---------------------------------------------------------------------------
116
155
  const ANTHROPIC_MODELS = [
117
156
  {
@@ -119,7 +158,7 @@ const ANTHROPIC_MODELS = [
119
158
  name: 'Claude Sonnet 4.5',
120
159
  provider: 'anthropic',
121
160
  contextWindow: 200_000,
122
- maxOutputTokens: 8192,
161
+ maxOutputTokens: 64_000,
123
162
  supportsThinking: true,
124
163
  supportsTools: true,
125
164
  supportsImages: true,
@@ -130,7 +169,7 @@ const ANTHROPIC_MODELS = [
130
169
  name: 'Claude Opus 4.6',
131
170
  provider: 'anthropic',
132
171
  contextWindow: 200_000,
133
- maxOutputTokens: 32_000,
172
+ maxOutputTokens: 64_000,
134
173
  supportsThinking: true,
135
174
  supportsTools: true,
136
175
  supportsImages: true,
@@ -141,7 +180,7 @@ const ANTHROPIC_MODELS = [
141
180
  name: 'Claude Haiku 4.5',
142
181
  provider: 'anthropic',
143
182
  contextWindow: 200_000,
144
- maxOutputTokens: 8192,
183
+ maxOutputTokens: 64_000,
145
184
  supportsThinking: true,
146
185
  supportsTools: true,
147
186
  supportsImages: true,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * One-shot self-healing for a request the provider rejected because of the
3
+ * output-token cap.
4
+ *
5
+ * The static table in `output-limits.ts` makes the common case correct without a
6
+ * round trip; this module makes the UNCOMMON case survivable — a proxy, a model
7
+ * that shipped last week, a dialect the heuristic guessed wrong. It reads the
8
+ * true ceiling out of the provider's own error text, repairs the request, sends
9
+ * it EXACTLY ONCE more, and teaches `output-limits.ts` what it learned so the
10
+ * rest of the process gets it right on the first try.
11
+ *
12
+ * TWO CONTRACTS THAT BREAK LOUDLY IF IGNORED:
13
+ *
14
+ * 1. A `Response` body can be read once. `RecoveryAttempt.bodyText` carries the
15
+ * already-consumed error body; callers MUST pass it on rather than calling
16
+ * `response.text()` again, which throws `TypeError: Body is unusable`. On
17
+ * the success path the body is NEVER touched — a streaming response has to
18
+ * reach the SSE parser intact.
19
+ *
20
+ * 2. Exactly one retry, structurally: `send` is called at most twice and there
21
+ * is no loop construct in this file. A recovery loop doubles every user's
22
+ * bill.
23
+ *
24
+ * TEST HYGIENE: `learnTokenField` writes a module-level map with process
25
+ * lifetime. Any test that memoizes a dialect MUST call `clearLearnedTokenFields()`
26
+ * from `afterEach`.
27
+ */
28
+ export type OutputLimitFailure = {
29
+ kind: 'exceeds_ceiling';
30
+ ceiling?: number;
31
+ } | {
32
+ kind: 'context_sum';
33
+ contextWindow?: number;
34
+ promptTokens?: number;
35
+ } | {
36
+ kind: 'unsupported_field';
37
+ expected: 'max_tokens' | 'max_completion_tokens';
38
+ } | {
39
+ kind: 'thinking_conflict';
40
+ };
41
+ /** The wire name of the field carrying the cap. Google's is a dotted path. */
42
+ export type TokenField = 'max_tokens' | 'max_completion_tokens' | 'generationConfig.maxOutputTokens';
43
+ /**
44
+ * Decide whether a provider rejection was about the output-token cap.
45
+ *
46
+ * ONLY `status === 400` is inspected — every other status is somebody else's
47
+ * problem and repairing the cap would just spend a second round trip to fail the
48
+ * same way.
49
+ */
50
+ export declare function classifyOutputLimitFailure(providerId: string, status: number, body: string): OutputLimitFailure | undefined;
51
+ type OpenAiTokenField = 'max_tokens' | 'max_completion_tokens';
52
+ /**
53
+ * Remember which parameter name this `provider:model` actually accepts.
54
+ *
55
+ * The heuristic in `openai.ts` is only the first guess; a proxy fronting an
56
+ * o-series model under a `gpt-` alias disagrees with it. One round trip corrects
57
+ * that, permanently for the life of the process.
58
+ */
59
+ export declare function learnTokenField(providerId: string, modelId: string, field: OpenAiTokenField): void;
60
+ export declare function getLearnedTokenField(providerId: string, modelId: string): OpenAiTokenField | undefined;
61
+ /** Test hook. MUST be called from `afterEach` in every test that memoizes a dialect. */
62
+ export declare function clearLearnedTokenFields(): void;
63
+ export interface RecoveryAttempt {
64
+ response: Response;
65
+ /** The 400 body, ALREADY CONSUMED. Callers must not call `response.text()`. */
66
+ bodyText: string;
67
+ adjustment?: {
68
+ from: number;
69
+ to: number;
70
+ reason: OutputLimitFailure['kind'];
71
+ };
72
+ }
73
+ export interface RecoveryOptions {
74
+ providerId: string;
75
+ modelId: string;
76
+ body: Record<string, unknown>;
77
+ tokenField: TokenField;
78
+ send: (body: Record<string, unknown>) => Promise<Response>;
79
+ }
80
+ /**
81
+ * Send a request, and on an output-limit 400 repair it and send it once more.
82
+ *
83
+ * The hot path is untouched: a 2xx returns immediately with an unread body.
84
+ */
85
+ export declare function sendWithOutputLimitRecovery(opts: RecoveryOptions): Promise<RecoveryAttempt>;
86
+ export {};
@@ -0,0 +1,263 @@
1
+ /**
2
+ * One-shot self-healing for a request the provider rejected because of the
3
+ * output-token cap.
4
+ *
5
+ * The static table in `output-limits.ts` makes the common case correct without a
6
+ * round trip; this module makes the UNCOMMON case survivable — a proxy, a model
7
+ * that shipped last week, a dialect the heuristic guessed wrong. It reads the
8
+ * true ceiling out of the provider's own error text, repairs the request, sends
9
+ * it EXACTLY ONCE more, and teaches `output-limits.ts` what it learned so the
10
+ * rest of the process gets it right on the first try.
11
+ *
12
+ * TWO CONTRACTS THAT BREAK LOUDLY IF IGNORED:
13
+ *
14
+ * 1. A `Response` body can be read once. `RecoveryAttempt.bodyText` carries the
15
+ * already-consumed error body; callers MUST pass it on rather than calling
16
+ * `response.text()` again, which throws `TypeError: Body is unusable`. On
17
+ * the success path the body is NEVER touched — a streaming response has to
18
+ * reach the SSE parser intact.
19
+ *
20
+ * 2. Exactly one retry, structurally: `send` is called at most twice and there
21
+ * is no loop construct in this file. A recovery loop doubles every user's
22
+ * bill.
23
+ *
24
+ * TEST HYGIENE: `learnTokenField` writes a module-level map with process
25
+ * lifetime. Any test that memoizes a dialect MUST call `clearLearnedTokenFields()`
26
+ * from `afterEach`.
27
+ */
28
+ import { CONTEXT_SAFETY_MARGIN_TOKENS, MIN_MAX_OUTPUT_TOKENS, MIN_THINKING_BUDGET_TOKENS, SAFE_FALLBACK_MAX_OUTPUT_TOKENS, THINKING_HEADROOM_TOKENS, learnModelCeiling, normalizeModelId, } from './output-limits.js';
29
+ const UNSUPPORTED_FIELD_PATTERNS = [
30
+ /unsupported parameter:?\s*'?max_tokens'?/i,
31
+ /use\s+'?max_completion_tokens'?/i,
32
+ ];
33
+ const THINKING_CONFLICT_PATTERN = /max_tokens must be greater than (?:thinking\.)?budget_tokens/i;
34
+ /** Numeric ceiling captures, in priority order; group 1 is the real ceiling. */
35
+ const CEILING_PATTERNS = [
36
+ /max_tokens:\s*\d+\s*>\s*(\d+),\s*which is the maximum/i,
37
+ /supports at most (\d+) completion tokens/i,
38
+ /exceeds the model limit of (\d+)/i,
39
+ ];
40
+ const CONTEXT_SUM_PATTERN = /maximum context length is (\d+) tokens.*?you requested \d+ tokens \((\d+) in the messages/is;
41
+ /** Last resort: the provider named the field but gave us no number to work with. */
42
+ const GENERIC_LIMIT_PATTERN = /max(?:imum)?[_ ](?:output|completion)[_ ]tokens/i;
43
+ function parseCount(raw) {
44
+ if (raw === undefined)
45
+ return undefined;
46
+ const n = Number.parseInt(raw, 10);
47
+ return Number.isFinite(n) && n > 0 ? n : undefined;
48
+ }
49
+ /**
50
+ * Decide whether a provider rejection was about the output-token cap.
51
+ *
52
+ * ONLY `status === 400` is inspected — every other status is somebody else's
53
+ * problem and repairing the cap would just spend a second round trip to fail the
54
+ * same way.
55
+ */
56
+ export function classifyOutputLimitFailure(providerId, status, body) {
57
+ if (status !== 400 || typeof body !== 'string' || body.length === 0)
58
+ return undefined;
59
+ void providerId; // the patterns are provider-specific but mutually exclusive
60
+ if (UNSUPPORTED_FIELD_PATTERNS.some((p) => p.test(body))) {
61
+ return { kind: 'unsupported_field', expected: 'max_completion_tokens' };
62
+ }
63
+ if (THINKING_CONFLICT_PATTERN.test(body))
64
+ return { kind: 'thinking_conflict' };
65
+ for (const pattern of CEILING_PATTERNS) {
66
+ const match = pattern.exec(body);
67
+ if (match) {
68
+ const ceiling = parseCount(match[1]);
69
+ return ceiling === undefined ? { kind: 'exceeds_ceiling' } : { kind: 'exceeds_ceiling', ceiling };
70
+ }
71
+ }
72
+ const ctx = CONTEXT_SUM_PATTERN.exec(body);
73
+ if (ctx) {
74
+ return {
75
+ kind: 'context_sum',
76
+ ...(parseCount(ctx[1]) !== undefined ? { contextWindow: parseCount(ctx[1]) } : {}),
77
+ ...(parseCount(ctx[2]) !== undefined ? { promptTokens: parseCount(ctx[2]) } : {}),
78
+ };
79
+ }
80
+ if (GENERIC_LIMIT_PATTERN.test(body))
81
+ return { kind: 'exceeds_ceiling' };
82
+ return undefined;
83
+ }
84
+ const learnedTokenFields = new Map();
85
+ function fieldKey(providerId, modelId) {
86
+ return `${String(providerId ?? '').toLowerCase()}:${normalizeModelId(modelId)}`;
87
+ }
88
+ /**
89
+ * Remember which parameter name this `provider:model` actually accepts.
90
+ *
91
+ * The heuristic in `openai.ts` is only the first guess; a proxy fronting an
92
+ * o-series model under a `gpt-` alias disagrees with it. One round trip corrects
93
+ * that, permanently for the life of the process.
94
+ */
95
+ export function learnTokenField(providerId, modelId, field) {
96
+ learnedTokenFields.set(fieldKey(providerId, modelId), field);
97
+ }
98
+ export function getLearnedTokenField(providerId, modelId) {
99
+ return learnedTokenFields.get(fieldKey(providerId, modelId));
100
+ }
101
+ /** Test hook. MUST be called from `afterEach` in every test that memoizes a dialect. */
102
+ export function clearLearnedTokenFields() {
103
+ learnedTokenFields.clear();
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Body field access (Google's field is a dotted path)
107
+ // ---------------------------------------------------------------------------
108
+ function readTokenValue(body, field) {
109
+ if (field !== 'generationConfig.maxOutputTokens') {
110
+ const v = body[field];
111
+ return typeof v === 'number' ? v : undefined;
112
+ }
113
+ const nested = body.generationConfig;
114
+ const v = nested?.maxOutputTokens;
115
+ return typeof v === 'number' ? v : undefined;
116
+ }
117
+ function withTokenValue(body, field, value) {
118
+ if (field !== 'generationConfig.maxOutputTokens') {
119
+ return { ...body, [field]: value };
120
+ }
121
+ const nested = (body.generationConfig ?? {});
122
+ return { ...body, generationConfig: { ...nested, maxOutputTokens: value } };
123
+ }
124
+ function readThinkingBudget(body) {
125
+ const thinking = body.thinking;
126
+ const v = thinking?.budget_tokens;
127
+ return typeof v === 'number' ? v : undefined;
128
+ }
129
+ function repairCeiling(ctx, current, ceiling) {
130
+ const target = Math.max(MIN_MAX_OUTPUT_TOKENS, ceiling ?? Math.min(current, SAFE_FALLBACK_MAX_OUTPUT_TOKENS));
131
+ if (target >= current)
132
+ return undefined;
133
+ learnModelCeiling(ctx.providerId, ctx.modelId, target, 'error');
134
+ return {
135
+ body: withTokenValue(ctx.body, ctx.field, target),
136
+ from: current,
137
+ to: target,
138
+ summary: `output cap ${current} -> ${target}`,
139
+ };
140
+ }
141
+ function repairContextSum(ctx, current, failure) {
142
+ const window = failure.contextWindow;
143
+ const prompt = failure.promptTokens;
144
+ if (window === undefined || prompt === undefined)
145
+ return undefined;
146
+ // NOT learned: a context-sum failure is conversation-specific, not a property
147
+ // of the model, and recording it as a ceiling would cap every later turn.
148
+ const target = Math.max(MIN_MAX_OUTPUT_TOKENS, window - prompt - CONTEXT_SAFETY_MARGIN_TOKENS);
149
+ if (target >= current)
150
+ return undefined;
151
+ return {
152
+ body: withTokenValue(ctx.body, ctx.field, target),
153
+ from: current,
154
+ to: target,
155
+ summary: `output cap ${current} -> ${target} to fit the context window`,
156
+ };
157
+ }
158
+ function repairThinkingConflict(ctx, current) {
159
+ const budget = readThinkingBudget(ctx.body);
160
+ if (budget === undefined || budget <= 0)
161
+ return undefined;
162
+ const raised = budget + THINKING_HEADROOM_TOKENS;
163
+ if (raised > current) {
164
+ return {
165
+ body: withTokenValue(ctx.body, ctx.field, raised),
166
+ from: current,
167
+ to: raised,
168
+ summary: `output cap ${current} -> ${raised} to clear the thinking budget`,
169
+ };
170
+ }
171
+ // The ceiling forbids raising the cap, so lower the budget instead.
172
+ const lowered = Math.max(MIN_THINKING_BUDGET_TOKENS, current - THINKING_HEADROOM_TOKENS);
173
+ if (lowered >= current)
174
+ return undefined;
175
+ const thinking = ctx.body.thinking;
176
+ return {
177
+ body: { ...ctx.body, thinking: { ...thinking, budget_tokens: lowered } },
178
+ from: budget,
179
+ to: lowered,
180
+ // `from`/`to` are the BUDGET here, not the cap — the cap is what could not
181
+ // move. Saying "output cap" would name the wrong number.
182
+ summary: `thinking budget ${budget} -> ${lowered}`,
183
+ };
184
+ }
185
+ function planRepair(opts, failure) {
186
+ const current = readTokenValue(opts.body, opts.tokenField);
187
+ const ctx = {
188
+ providerId: opts.providerId,
189
+ modelId: opts.modelId,
190
+ body: opts.body,
191
+ field: opts.tokenField,
192
+ };
193
+ if (failure.kind === 'unsupported_field') {
194
+ if (opts.tokenField === 'generationConfig.maxOutputTokens')
195
+ return undefined;
196
+ if (opts.tokenField === failure.expected)
197
+ return undefined;
198
+ learnTokenField(opts.providerId, opts.modelId, failure.expected);
199
+ const renamed = { ...opts.body };
200
+ delete renamed[opts.tokenField];
201
+ // The reasoning family rejects `temperature` for the same reason it rejects
202
+ // `max_tokens`; leaving it behind just buys a second 400 on a different key.
203
+ delete renamed.temperature;
204
+ renamed[failure.expected] = current ?? MIN_MAX_OUTPUT_TOKENS;
205
+ return {
206
+ body: renamed,
207
+ from: current ?? 0,
208
+ to: current ?? 0,
209
+ summary: `token parameter ${opts.tokenField} -> ${failure.expected}`,
210
+ };
211
+ }
212
+ if (current === undefined)
213
+ return undefined;
214
+ if (failure.kind === 'thinking_conflict')
215
+ return repairThinkingConflict(ctx, current);
216
+ if (failure.kind === 'context_sum')
217
+ return repairContextSum(ctx, current, failure);
218
+ return repairCeiling(ctx, current, failure.ceiling);
219
+ }
220
+ /**
221
+ * Send a request, and on an output-limit 400 repair it and send it once more.
222
+ *
223
+ * The hot path is untouched: a 2xx returns immediately with an unread body.
224
+ */
225
+ export async function sendWithOutputLimitRecovery(opts) {
226
+ const first = await opts.send(opts.body);
227
+ // The body is NOT read here — a streaming response must reach the SSE parser
228
+ // intact, and `bodyUsed` would already be true if we peeked.
229
+ if (first.ok)
230
+ return { response: first, bodyText: '' };
231
+ const bodyText = await first.text().catch(() => '');
232
+ const failure = classifyOutputLimitFailure(opts.providerId, first.status, bodyText);
233
+ if (!failure)
234
+ return { response: first, bodyText };
235
+ const plan = planRepair(opts, failure);
236
+ if (!plan)
237
+ return { response: first, bodyText };
238
+ const second = await opts.send(plan.body);
239
+ const adjustment = { from: plan.from, to: plan.to, reason: failure.kind };
240
+ if (!second.ok) {
241
+ // A second failure surfaces exactly as it would have without recovery.
242
+ return { response: second, bodyText: await second.text().catch(() => ''), adjustment };
243
+ }
244
+ warnAdjusted(opts.providerId, opts.modelId, plan.summary, failure.kind);
245
+ return { response: second, bodyText: '', adjustment };
246
+ }
247
+ /** Display names for the adapters this package ships; `Openai` reads as a typo. */
248
+ const PROVIDER_LABELS = {
249
+ anthropic: 'Anthropic',
250
+ openai: 'OpenAI',
251
+ google: 'Google',
252
+ };
253
+ /**
254
+ * Core has no logger and must not acquire one (that would be host coupling), so
255
+ * this is `console.warn` — exactly what `anthropic.ts` already does for a
256
+ * malformed tool call. In the CLI it reaches the transcript through the console
257
+ * bridge; in a headless embedder it is a plain warning.
258
+ */
259
+ function warnAdjusted(providerId, modelId, summary, reason) {
260
+ const key = String(providerId ?? '').toLowerCase();
261
+ const name = PROVIDER_LABELS[key] ?? providerId.charAt(0).toUpperCase() + providerId.slice(1);
262
+ console.warn(`[${name}] adjusted ${summary} (${reason}) for ${modelId}`);
263
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Output-token authority — the single source of truth for "how many tokens may
3
+ * this model produce", and the resolver every provider adapter consults before
4
+ * putting a number on the wire.
5
+ *
6
+ * Why this module exists at all: sending a generous `max_tokens` unconditionally
7
+ * is only safe for models whose ceiling is at least that generous. Anthropic,
8
+ * OpenAI and Google all answer an over-large cap with an HTTP 400, and a 400
9
+ * during the agent loop ends the turn with a raw provider string in the
10
+ * transcript. So the product default is the AMBITION and the model's real
11
+ * ceiling is the LAW, with the gap closed silently rather than by a failed run.
12
+ *
13
+ * Four ranked ceiling sources feed one resolver:
14
+ *
15
+ * learned-from-error > discovery (API-reported) > static table > default
16
+ *
17
+ * TEST HYGIENE: `learnModelCeiling` writes a MODULE-LEVEL map that lives for the
18
+ * whole process. Any test that learns MUST call `clearLearnedCeilings()` from
19
+ * `afterEach`, or a learned 8192 leaks into the next test file and produces a
20
+ * failure whose cause is not in the failing file.
21
+ */
22
+ import type { Message } from './types.js';
23
+ /** The product default. THE single source of truth; nothing else may spell 64000. */
24
+ export declare const DEFAULT_MAX_OUTPUT_TOKENS = 64000;
25
+ /** Below this a turn cannot produce a usable tool call; never clamp under it. */
26
+ export declare const MIN_MAX_OUTPUT_TOKENS = 256;
27
+ /**
28
+ * Every model in the supported set accepts at least this. Used when recovery
29
+ * cannot parse a real ceiling out of the provider's error.
30
+ */
31
+ export declare const SAFE_FALLBACK_MAX_OUTPUT_TOKENS = 8192;
32
+ /** Anthropic requires max_tokens > thinking.budget_tokens; this is the margin. */
33
+ export declare const THINKING_HEADROOM_TOKENS = 4096;
34
+ /** Sanity ceiling for a hand-edited config file. */
35
+ export declare const ABSOLUTE_MAX_OUTPUT_TOKENS = 200000;
36
+ /** Subtracted from a context-window computation so the estimate may be wrong. */
37
+ export declare const CONTEXT_SAFETY_MARGIN_TOKENS = 1024;
38
+ /**
39
+ * Anthropic's floor for `thinking.budget_tokens`. A downgrade that lands below
40
+ * it is illegal, so the resolver returns `thinkingBudget: 0` instead and the
41
+ * adapter omits the block entirely.
42
+ */
43
+ export declare const MIN_THINKING_BUDGET_TOKENS = 1024;
44
+ /** Lowercase and strip Google's `models/` prefix so one key shape is used everywhere. */
45
+ export declare function normalizeModelId(modelId: string): string;
46
+ /**
47
+ * The statically known output ceiling for a model, or `undefined`.
48
+ *
49
+ * A MISS RETURNS `undefined`, NOT A NUMBER. "Unknown" and "small" must stay
50
+ * distinguishable: an unknown model gets the product default and, if that is
51
+ * wrong, recovery fixes it. Baking a pessimistic 8192 into every unrecognised id
52
+ * would silently halve the output of every proxy user.
53
+ */
54
+ export declare function staticCeilingFor(providerId: string, modelId: string): number | undefined;
55
+ export type CeilingSource = 'catalog' | 'discovery' | 'error';
56
+ interface LearnedCeiling {
57
+ ceiling: number;
58
+ source: CeilingSource;
59
+ }
60
+ /** Record a ceiling. A lower-ranked source never overwrites a higher-ranked one. */
61
+ export declare function learnModelCeiling(providerId: string, modelId: string, ceiling: number, source: CeilingSource): void;
62
+ export declare function getLearnedCeiling(providerId: string, modelId: string): LearnedCeiling | undefined;
63
+ /** Test hook. MUST be called from `afterEach` in every test that learns. */
64
+ export declare function clearLearnedCeilings(): void;
65
+ export interface OutputLimitInput {
66
+ providerId: string;
67
+ modelId: string;
68
+ /** The user's explicit setting. `undefined` means AUTO. */
69
+ requested?: number;
70
+ /** Caller-supplied metadata (optional; see `LLMRequest.modelLimits`). */
71
+ modelLimits?: {
72
+ maxOutputTokens?: number;
73
+ contextWindow?: number;
74
+ };
75
+ /** Resolved thinking budget for this request; 0/undefined when off. */
76
+ thinkingBudget?: number;
77
+ /** Rough prompt size, for providers where max + prompt <= context. */
78
+ estimatedPromptTokens?: number;
79
+ }
80
+ export interface OutputLimitResolution {
81
+ /** The value to put on the wire. Always >= MIN_MAX_OUTPUT_TOKENS. */
82
+ value: number;
83
+ /** The ceiling that was applied, if any was known. */
84
+ ceiling?: number;
85
+ /** Where `value` came from before clamping. */
86
+ source: 'requested' | 'auto';
87
+ /** Which rule reduced it, if any — drives the one-time warning. */
88
+ clampedBy?: 'ceiling' | 'context' | 'absolute';
89
+ /** Adjusted thinking budget when the cap could not accommodate the original. */
90
+ thinkingBudget?: number;
91
+ }
92
+ /**
93
+ * Resolve the output-token cap for one request.
94
+ *
95
+ * Pure apart from reading the learned map. Never throws, never returns `NaN`,
96
+ * `0` or a negative.
97
+ */
98
+ export declare function resolveOutputTokens(input: OutputLimitInput): OutputLimitResolution;
99
+ /**
100
+ * Deliberately crude: ~4 chars/token plus per-message framing. Used ONLY to keep
101
+ * `max_tokens + prompt <= context` on OpenAI, where being 20 % wrong is absorbed
102
+ * by `CONTEXT_SAFETY_MARGIN_TOKENS` and, failing that, by `context_sum` recovery.
103
+ *
104
+ * No tokenizer dependency is added on purpose — this is a guard rail, not an
105
+ * accounting function.
106
+ */
107
+ export declare function estimatePromptTokens(messages: Message[], systemPrompt?: string): number;
108
+ export {};
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Output-token authority — the single source of truth for "how many tokens may
3
+ * this model produce", and the resolver every provider adapter consults before
4
+ * putting a number on the wire.
5
+ *
6
+ * Why this module exists at all: sending a generous `max_tokens` unconditionally
7
+ * is only safe for models whose ceiling is at least that generous. Anthropic,
8
+ * OpenAI and Google all answer an over-large cap with an HTTP 400, and a 400
9
+ * during the agent loop ends the turn with a raw provider string in the
10
+ * transcript. So the product default is the AMBITION and the model's real
11
+ * ceiling is the LAW, with the gap closed silently rather than by a failed run.
12
+ *
13
+ * Four ranked ceiling sources feed one resolver:
14
+ *
15
+ * learned-from-error > discovery (API-reported) > static table > default
16
+ *
17
+ * TEST HYGIENE: `learnModelCeiling` writes a MODULE-LEVEL map that lives for the
18
+ * whole process. Any test that learns MUST call `clearLearnedCeilings()` from
19
+ * `afterEach`, or a learned 8192 leaks into the next test file and produces a
20
+ * failure whose cause is not in the failing file.
21
+ */
22
+ // ---------------------------------------------------------------------------
23
+ // Constants — nothing else in this package may spell these numbers
24
+ // ---------------------------------------------------------------------------
25
+ /** The product default. THE single source of truth; nothing else may spell 64000. */
26
+ export const DEFAULT_MAX_OUTPUT_TOKENS = 64_000;
27
+ /** Below this a turn cannot produce a usable tool call; never clamp under it. */
28
+ export const MIN_MAX_OUTPUT_TOKENS = 256;
29
+ /**
30
+ * Every model in the supported set accepts at least this. Used when recovery
31
+ * cannot parse a real ceiling out of the provider's error.
32
+ */
33
+ export const SAFE_FALLBACK_MAX_OUTPUT_TOKENS = 8_192;
34
+ /** Anthropic requires max_tokens > thinking.budget_tokens; this is the margin. */
35
+ export const THINKING_HEADROOM_TOKENS = 4_096;
36
+ /** Sanity ceiling for a hand-edited config file. */
37
+ export const ABSOLUTE_MAX_OUTPUT_TOKENS = 200_000;
38
+ /** Subtracted from a context-window computation so the estimate may be wrong. */
39
+ export const CONTEXT_SAFETY_MARGIN_TOKENS = 1_024;
40
+ /**
41
+ * Anthropic's floor for `thinking.budget_tokens`. A downgrade that lands below
42
+ * it is illegal, so the resolver returns `thinkingBudget: 0` instead and the
43
+ * adapter omits the block entirely.
44
+ */
45
+ export const MIN_THINKING_BUDGET_TOKENS = 1_024;
46
+ /**
47
+ * Ordered; FIRST MATCH WINS. Matched against the model id lowercased with any
48
+ * `models/` prefix stripped (Google returns `models/gemini-1.5-pro`).
49
+ *
50
+ * ORDER IS LOAD-BEARING, not cosmetic: `claude-3-7-sonnet` also matches the
51
+ * broad `^claude-3(-|\.)` rule, so the specific 3.7 entry has to come first or
52
+ * it is dead code and every 3.7 request is clamped to 8192 (see the design's
53
+ * "Issues found during implementation" note).
54
+ */
55
+ const CEILING_TABLE = {
56
+ anthropic: [
57
+ { pattern: /^claude-3-7-sonnet/, ceiling: 64_000 },
58
+ { pattern: /^claude-3-5-sonnet/, ceiling: 8_192 },
59
+ { pattern: /^claude-3(-|\.)/, ceiling: 8_192 },
60
+ { pattern: /^claude-opus-4-(0|1)\b/, ceiling: 32_000 },
61
+ { pattern: /^claude-(sonnet|haiku|opus)-4/, ceiling: 64_000 },
62
+ ],
63
+ openai: [
64
+ { pattern: /^(o1|o3|o4)-mini/, ceiling: 65_536 },
65
+ { pattern: /^(o1|o3|o4)\b/, ceiling: 100_000 },
66
+ { pattern: /^gpt-5/, ceiling: 128_000 },
67
+ { pattern: /^gpt-4\.1/, ceiling: 32_768 },
68
+ { pattern: /^gpt-4o/, ceiling: 16_384 },
69
+ { pattern: /^gpt-4(-turbo)?$/, ceiling: 4_096 },
70
+ { pattern: /^gpt-3\.5/, ceiling: 4_096 },
71
+ ],
72
+ google: [
73
+ { pattern: /^gemini-1\.5/, ceiling: 8_192 },
74
+ { pattern: /^gemini-2\.0-flash/, ceiling: 8_192 },
75
+ { pattern: /^gemini-2\.5/, ceiling: 65_536 },
76
+ ],
77
+ };
78
+ /** Lowercase and strip Google's `models/` prefix so one key shape is used everywhere. */
79
+ export function normalizeModelId(modelId) {
80
+ return String(modelId ?? '').trim().toLowerCase().replace(/^models\//, '');
81
+ }
82
+ /**
83
+ * The statically known output ceiling for a model, or `undefined`.
84
+ *
85
+ * A MISS RETURNS `undefined`, NOT A NUMBER. "Unknown" and "small" must stay
86
+ * distinguishable: an unknown model gets the product default and, if that is
87
+ * wrong, recovery fixes it. Baking a pessimistic 8192 into every unrecognised id
88
+ * would silently halve the output of every proxy user.
89
+ */
90
+ export function staticCeilingFor(providerId, modelId) {
91
+ const rules = CEILING_TABLE[String(providerId ?? '').toLowerCase()];
92
+ if (!rules)
93
+ return undefined;
94
+ const id = normalizeModelId(modelId);
95
+ if (id.length === 0)
96
+ return undefined;
97
+ for (const rule of rules) {
98
+ if (rule.pattern.test(id))
99
+ return rule.ceiling;
100
+ }
101
+ return undefined;
102
+ }
103
+ /** `'error'` outranks everything because it came from the API itself. */
104
+ const RANK = { catalog: 1, discovery: 2, error: 3 };
105
+ /** Process-lifetime only; never persisted. Key: `${providerId}:${normalizedModelId}`. */
106
+ const learnedCeilings = new Map();
107
+ function ceilingKey(providerId, modelId) {
108
+ return `${String(providerId ?? '').toLowerCase()}:${normalizeModelId(modelId)}`;
109
+ }
110
+ /** Record a ceiling. A lower-ranked source never overwrites a higher-ranked one. */
111
+ export function learnModelCeiling(providerId, modelId, ceiling, source) {
112
+ if (!Number.isFinite(ceiling) || ceiling <= 0)
113
+ return;
114
+ const key = ceilingKey(providerId, modelId);
115
+ if (key.endsWith(':'))
116
+ return; // no model id — nothing worth remembering
117
+ const existing = learnedCeilings.get(key);
118
+ if (existing && RANK[existing.source] > RANK[source])
119
+ return;
120
+ learnedCeilings.set(key, { ceiling: Math.floor(ceiling), source });
121
+ }
122
+ export function getLearnedCeiling(providerId, modelId) {
123
+ return learnedCeilings.get(ceilingKey(providerId, modelId));
124
+ }
125
+ /** Test hook. MUST be called from `afterEach` in every test that learns. */
126
+ export function clearLearnedCeilings() {
127
+ learnedCeilings.clear();
128
+ }
129
+ /** OpenAI charges `max_tokens` against the context window; the others do not. */
130
+ function providerUsesContextSum(providerId) {
131
+ return String(providerId ?? '').toLowerCase() === 'openai';
132
+ }
133
+ /** Step 1: the strongest ceiling anyone knows about, or `undefined`. */
134
+ function resolveCeiling(input) {
135
+ const learned = getLearnedCeiling(input.providerId, input.modelId);
136
+ if (learned)
137
+ return learned.ceiling;
138
+ const declared = input.modelLimits?.maxOutputTokens;
139
+ if (typeof declared === 'number' && Number.isFinite(declared) && declared > 0) {
140
+ return Math.floor(declared);
141
+ }
142
+ return staticCeilingFor(input.providerId, input.modelId);
143
+ }
144
+ /** Step 4: `max_tokens + prompt <= context` on the providers that enforce it. */
145
+ function contextBound(input) {
146
+ if (!providerUsesContextSum(input.providerId))
147
+ return undefined;
148
+ const window = input.modelLimits?.contextWindow;
149
+ const prompt = input.estimatedPromptTokens;
150
+ if (typeof window !== 'number' || !Number.isFinite(window) || window <= 0)
151
+ return undefined;
152
+ if (typeof prompt !== 'number' || !Number.isFinite(prompt) || prompt < 0)
153
+ return undefined;
154
+ return window - prompt - CONTEXT_SAFETY_MARGIN_TOKENS;
155
+ }
156
+ /**
157
+ * Step 6: keep Anthropic's `max_tokens > thinking.budget_tokens` invariant.
158
+ *
159
+ * Returns the (possibly raised) cap and the budget to send. A returned budget of
160
+ * `0` means "omit the thinking block entirely" — `budget_tokens` has a hard floor
161
+ * of 1024 and sending less is illegal, so refusing to think is the only legal
162
+ * move left on a model whose ceiling cannot house both.
163
+ */
164
+ function fitThinking(value, thinkingBudget, ceiling) {
165
+ if (thinkingBudget <= 0 || value > thinkingBudget)
166
+ return { value };
167
+ const raised = Math.min(ceiling ?? Number.POSITIVE_INFINITY, thinkingBudget + THINKING_HEADROOM_TOKENS);
168
+ if (raised > thinkingBudget)
169
+ return { value: raised };
170
+ const lowered = Math.max(MIN_THINKING_BUDGET_TOKENS, raised - THINKING_HEADROOM_TOKENS);
171
+ if (lowered < raised)
172
+ return { value: raised, thinkingBudget: lowered };
173
+ return { value: raised, thinkingBudget: 0 };
174
+ }
175
+ /**
176
+ * Resolve the output-token cap for one request.
177
+ *
178
+ * Pure apart from reading the learned map. Never throws, never returns `NaN`,
179
+ * `0` or a negative.
180
+ */
181
+ export function resolveOutputTokens(input) {
182
+ const ceiling = resolveCeiling(input);
183
+ const requested = typeof input.requested === 'number' && Number.isFinite(input.requested) && input.requested > 0
184
+ ? Math.floor(input.requested)
185
+ : undefined;
186
+ // AUTO NEVER EXCEEDS THE PRODUCT DEFAULT, even when the model would allow
187
+ // 100000: on OpenAI `max_tokens` is charged against the context window, so an
188
+ // AUTO of 100000 would make long conversations fail with a context error. An
189
+ // explicit `requested` may exceed 64000 up to the ceiling — the user's call.
190
+ //
191
+ // The AMBITION is kept separate from the clamp on purpose. Folding the ceiling
192
+ // into the AUTO base (`min(DEFAULT, ceiling)`) yields the same number but
193
+ // leaves `clampedBy` unset, and `clampedBy` is what the settings preview and
194
+ // the one-time warning are built on: the user would see a bare
195
+ // `Effective: 16384` with nothing saying where the other 47616 went.
196
+ const ambition = requested ?? DEFAULT_MAX_OUTPUT_TOKENS;
197
+ let value = ambition;
198
+ let clampedBy;
199
+ if (ceiling !== undefined && ceiling < value) {
200
+ value = ceiling;
201
+ clampedBy = 'ceiling';
202
+ }
203
+ const bound = contextBound(input);
204
+ if (bound !== undefined && bound < value) {
205
+ value = bound;
206
+ clampedBy = 'context';
207
+ }
208
+ if (value > ABSOLUTE_MAX_OUTPUT_TOKENS) {
209
+ value = ABSOLUTE_MAX_OUTPUT_TOKENS;
210
+ clampedBy = 'absolute';
211
+ }
212
+ const fitted = fitThinking(value, input.thinkingBudget ?? 0, ceiling);
213
+ value = Math.floor(Math.max(fitted.value, MIN_MAX_OUTPUT_TOKENS));
214
+ return {
215
+ value,
216
+ ...(ceiling !== undefined ? { ceiling } : {}),
217
+ source: requested !== undefined ? 'requested' : 'auto',
218
+ ...(clampedBy ? { clampedBy } : {}),
219
+ ...(fitted.thinkingBudget !== undefined ? { thinkingBudget: fitted.thinkingBudget } : {}),
220
+ };
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Prompt estimation
224
+ // ---------------------------------------------------------------------------
225
+ /** Average characters per token across the model families this package speaks to. */
226
+ const CHARS_PER_TOKEN = 4;
227
+ /** Role markers, separators and tool-call scaffolding, per message. */
228
+ const PER_MESSAGE_OVERHEAD_TOKENS = 4;
229
+ function partLength(content) {
230
+ if (typeof content === 'string')
231
+ return content.length;
232
+ if (!Array.isArray(content))
233
+ return 0;
234
+ let total = 0;
235
+ for (const part of content) {
236
+ if (typeof part?.text === 'string')
237
+ total += part.text.length;
238
+ // Base64 image payloads are counted at a flat rate rather than by length:
239
+ // their character count says nothing useful about their token cost.
240
+ else if (part?.type === 'image')
241
+ total += CHARS_PER_TOKEN * 256;
242
+ }
243
+ return total;
244
+ }
245
+ function messageLength(message) {
246
+ if (message.role === 'assistant') {
247
+ let total = 0;
248
+ for (const block of message.content) {
249
+ if (block.type === 'text' || block.type === 'thinking')
250
+ total += block.text.length;
251
+ else if (block.type === 'tool_call')
252
+ total += JSON.stringify(block.args ?? {}).length;
253
+ }
254
+ return total;
255
+ }
256
+ return partLength(message.content);
257
+ }
258
+ /**
259
+ * Deliberately crude: ~4 chars/token plus per-message framing. Used ONLY to keep
260
+ * `max_tokens + prompt <= context` on OpenAI, where being 20 % wrong is absorbed
261
+ * by `CONTEXT_SAFETY_MARGIN_TOKENS` and, failing that, by `context_sum` recovery.
262
+ *
263
+ * No tokenizer dependency is added on purpose — this is a guard rail, not an
264
+ * accounting function.
265
+ */
266
+ export function estimatePromptTokens(messages, systemPrompt) {
267
+ let chars = systemPrompt ? systemPrompt.length : 0;
268
+ let overhead = 0;
269
+ for (const message of messages ?? []) {
270
+ chars += messageLength(message);
271
+ overhead += PER_MESSAGE_OVERHEAD_TOKENS;
272
+ }
273
+ return Math.ceil(chars / CHARS_PER_TOKEN) + overhead;
274
+ }
@@ -18,7 +18,27 @@ export interface LLMRequest {
18
18
  /** Abort signal for cancellation. */
19
19
  signal?: AbortSignal;
20
20
  temperature?: number;
21
+ /**
22
+ * The user's explicit output-token cap. `undefined` means AUTO — the adapter
23
+ * asks `resolveOutputTokens()` for a per-model value instead.
24
+ *
25
+ * An explicit value is still CLAMPED DOWN to the model's real ceiling: this is
26
+ * an ambition, not a promise, and a value the model cannot accept is an HTTP
27
+ * 400 that ends the turn.
28
+ */
21
29
  maxTokens?: number;
30
+ /**
31
+ * What the caller knows about this model's limits, if anything.
32
+ *
33
+ * Optional and additive: every existing caller compiles unchanged, and an
34
+ * absent value simply leaves the resolver with the static table. Supplying
35
+ * `contextWindow` is what enables the `max_tokens + prompt <= context` guard
36
+ * on OpenAI.
37
+ */
38
+ modelLimits?: {
39
+ maxOutputTokens?: number;
40
+ contextWindow?: number;
41
+ };
22
42
  /** Anthropic extended thinking level. Ignored by non-Anthropic providers. */
23
43
  thinkingLevel?: ThinkingLevel;
24
44
  /** Explicit thinking budget (tokens). Overrides thinkingLevel if both are set. */
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import { THINKING_BUDGET } from '../types.js';
15
15
  import { LLMError, classifyHttpError, wrapFetchError } from '../provider.js';
16
+ import { DEFAULT_MAX_OUTPUT_TOKENS, resolveOutputTokens, staticCeilingFor, } from '../output-limits.js';
17
+ import { sendWithOutputLimitRecovery } from '../output-limit-recovery.js';
16
18
  import { parseSSEStream } from '../stream-utils.js';
17
19
  import { consumeStream } from '../stream-utils.js';
18
20
  // ---------------------------------------------------------------------------
@@ -20,7 +22,6 @@ import { consumeStream } from '../stream-utils.js';
20
22
  // ---------------------------------------------------------------------------
21
23
  const DEFAULT_BASE_URL = 'https://api.anthropic.com';
22
24
  const ANTHROPIC_VERSION = '2023-06-01';
23
- const DEFAULT_MAX_TOKENS = 16384;
24
25
  // ---------------------------------------------------------------------------
25
26
  // Anthropic provider
26
27
  // ---------------------------------------------------------------------------
@@ -36,25 +37,34 @@ export class AnthropicProvider {
36
37
  const url = `${baseUrl}/v1/messages`;
37
38
  const body = buildRequestBody(request);
38
39
  let response;
40
+ let errorBody = '';
39
41
  try {
40
- response = await fetch(url, {
41
- method: 'POST',
42
- headers: {
43
- 'Content-Type': 'application/json',
44
- 'x-api-key': request.apiKey,
45
- 'anthropic-version': ANTHROPIC_VERSION,
46
- },
47
- body: JSON.stringify(body),
48
- signal: request.signal,
42
+ const attempt = await sendWithOutputLimitRecovery({
43
+ providerId: this.id,
44
+ modelId: request.model,
45
+ body,
46
+ tokenField: 'max_tokens',
47
+ send: (payload) => fetch(url, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ 'x-api-key': request.apiKey,
52
+ 'anthropic-version': ANTHROPIC_VERSION,
53
+ },
54
+ body: JSON.stringify(payload),
55
+ signal: request.signal,
56
+ }),
49
57
  });
58
+ response = attempt.response;
59
+ errorBody = attempt.bodyText;
50
60
  }
51
61
  catch (err) {
52
62
  yield { type: 'error', error: wrapFetchError(err, this.id) };
53
63
  return;
54
64
  }
55
65
  if (!response.ok) {
56
- const text = await response.text().catch(() => '');
57
- yield { type: 'error', error: classifyHttpError(response.status, text, this.id) };
66
+ // `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
67
+ yield { type: 'error', error: classifyHttpError(response.status, errorBody, this.id) };
58
68
  return;
59
69
  }
60
70
  if (!response.body) {
@@ -270,12 +280,16 @@ export class AnthropicProvider {
270
280
  if (!res.ok)
271
281
  return [];
272
282
  const data = await res.json();
283
+ // The Anthropic models endpoint does NOT report an output ceiling, so this
284
+ // maps through the static table and deliberately does NOT call
285
+ // `learnModelCeiling`: asserting 64000 here is how `claude-3-5-haiku` ends
286
+ // up with a "discovered" ceiling that outranks its correct table entry.
273
287
  return (data.data || []).map((m) => ({
274
288
  id: String(m.id || ''),
275
289
  name: String(m.display_name || m.id || ''),
276
290
  provider: this.id,
277
291
  contextWindow: 200_000,
278
- maxOutputTokens: 8192,
292
+ maxOutputTokens: staticCeilingFor(this.id, String(m.id || '')) ?? DEFAULT_MAX_OUTPUT_TOKENS,
279
293
  supportsThinking: /claude-(sonnet-4|opus-4)/i.test(String(m.id)),
280
294
  supportsTools: true,
281
295
  supportsImages: true,
@@ -291,11 +305,24 @@ export class AnthropicProvider {
291
305
  // Internal: request body builder
292
306
  // ---------------------------------------------------------------------------
293
307
  function buildRequestBody(request) {
308
+ // Requested thinking budget FIRST: the cap and the budget are resolved
309
+ // together because Anthropic requires `max_tokens > budget_tokens`, and
310
+ // `--thinking xhigh` (65536) against the 64000 default is otherwise a
311
+ // guaranteed 400 on every single request.
312
+ const requestedBudget = request.thinkingBudget
313
+ ?? (request.thinkingLevel ? THINKING_BUDGET[request.thinkingLevel] : 0);
314
+ const resolution = resolveOutputTokens({
315
+ providerId: 'anthropic',
316
+ modelId: request.model,
317
+ ...(request.maxTokens !== undefined ? { requested: request.maxTokens } : {}),
318
+ ...(request.modelLimits ? { modelLimits: request.modelLimits } : {}),
319
+ thinkingBudget: requestedBudget,
320
+ });
294
321
  const body = {
295
322
  model: request.model,
296
323
  messages: convertMessages(request.messages),
297
324
  stream: true,
298
- max_tokens: request.maxTokens ?? DEFAULT_MAX_TOKENS,
325
+ max_tokens: resolution.value,
299
326
  };
300
327
  if (request.systemPrompt) {
301
328
  body.system = request.systemPrompt;
@@ -309,9 +336,10 @@ function buildRequestBody(request) {
309
336
  if (request.stopSequences && request.stopSequences.length > 0) {
310
337
  body.stop_sequences = request.stopSequences;
311
338
  }
312
- // Extended thinking
313
- const thinkingBudget = request.thinkingBudget
314
- ?? (request.thinkingLevel ? THINKING_BUDGET[request.thinkingLevel] : 0);
339
+ // Extended thinking. A resolved budget of 0 means the cap cannot legally house
340
+ // a `budget_tokens` at all, so the block is omitted entirely — and `temperature`
341
+ // is therefore NOT deleted, because nothing is enabled that forbids it.
342
+ const thinkingBudget = resolution.thinkingBudget ?? requestedBudget;
315
343
  if (thinkingBudget > 0) {
316
344
  body.thinking = {
317
345
  type: 'enabled',
@@ -12,6 +12,8 @@
12
12
  */
13
13
  import { randomUUID } from 'node:crypto';
14
14
  import { LLMError, classifyHttpError, wrapFetchError } from '../provider.js';
15
+ import { DEFAULT_MAX_OUTPUT_TOKENS, learnModelCeiling, resolveOutputTokens, } from '../output-limits.js';
16
+ import { sendWithOutputLimitRecovery } from '../output-limit-recovery.js';
15
17
  import { parseSSEStream } from '../stream-utils.js';
16
18
  import { consumeStream } from '../stream-utils.js';
17
19
  // ---------------------------------------------------------------------------
@@ -33,21 +35,30 @@ export class GoogleProvider {
33
35
  const url = `${baseUrl}/v1beta/models/${request.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(request.apiKey)}`;
34
36
  const body = buildRequestBody(request);
35
37
  let response;
38
+ let errorBody = '';
36
39
  try {
37
- response = await fetch(url, {
38
- method: 'POST',
39
- headers: { 'Content-Type': 'application/json' },
40
- body: JSON.stringify(body),
41
- signal: request.signal,
40
+ const attempt = await sendWithOutputLimitRecovery({
41
+ providerId: this.id,
42
+ modelId: request.model,
43
+ body,
44
+ tokenField: 'generationConfig.maxOutputTokens',
45
+ send: (payload) => fetch(url, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify(payload),
49
+ signal: request.signal,
50
+ }),
42
51
  });
52
+ response = attempt.response;
53
+ errorBody = attempt.bodyText;
43
54
  }
44
55
  catch (err) {
45
56
  yield { type: 'error', error: wrapFetchError(err, this.id) };
46
57
  return;
47
58
  }
48
59
  if (!response.ok) {
49
- const text = await response.text().catch(() => '');
50
- yield { type: 'error', error: classifyHttpError(response.status, text, this.id) };
60
+ // `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
61
+ yield { type: 'error', error: classifyHttpError(response.status, errorBody, this.id) };
51
62
  return;
52
63
  }
53
64
  if (!response.body) {
@@ -159,12 +170,19 @@ export class GoogleProvider {
159
170
  })
160
171
  .map((m) => {
161
172
  const id = String(m.name || '').replace(/^models\//, '');
173
+ // Google is the ONE provider of the three that reports a real
174
+ // per-model output ceiling, so its answer is worth remembering: it
175
+ // outranks the static table for every later request in this process.
176
+ const reported = m.outputTokenLimit;
177
+ if (typeof reported === 'number' && reported > 0) {
178
+ learnModelCeiling(this.id, id, reported, 'discovery');
179
+ }
162
180
  return {
163
181
  id,
164
182
  name: String(m.displayName || id),
165
183
  provider: this.id,
166
184
  contextWindow: m.inputTokenLimit || 128_000,
167
- maxOutputTokens: m.outputTokenLimit || 8192,
185
+ maxOutputTokens: reported || DEFAULT_MAX_OUTPUT_TOKENS,
168
186
  supportsThinking: false,
169
187
  supportsTools: true,
170
188
  supportsImages: true,
@@ -197,10 +215,15 @@ function buildRequestBody(request) {
197
215
  ];
198
216
  }
199
217
  // Generation config
200
- const genConfig = {};
201
- if (request.maxTokens) {
202
- genConfig.maxOutputTokens = request.maxTokens;
203
- }
218
+ const resolution = resolveOutputTokens({
219
+ providerId: 'google',
220
+ modelId: request.model,
221
+ ...(request.maxTokens !== undefined ? { requested: request.maxTokens } : {}),
222
+ ...(request.modelLimits ? { modelLimits: request.modelLimits } : {}),
223
+ });
224
+ const genConfig = {
225
+ maxOutputTokens: resolution.value,
226
+ };
204
227
  if (request.temperature !== undefined) {
205
228
  genConfig.temperature = request.temperature;
206
229
  }
@@ -12,6 +12,15 @@
12
12
  */
13
13
  import type { AssistantMessage, ModelInfo, StreamEvent } from '../types.js';
14
14
  import type { LLMProvider, LLMRequest } from '../provider.js';
15
+ /**
16
+ * Which parameter name carries the cap for this model.
17
+ *
18
+ * A HEURISTIC, and only the first guess: a proxy fronting an o-series model
19
+ * under a `gpt-` alias disagrees with it. `unsupported_field` recovery renames
20
+ * the parameter after one round trip and memoizes the answer, which is why the
21
+ * learned dialect is consulted first.
22
+ */
23
+ export declare function openAiTokenField(modelId: string): 'max_tokens' | 'max_completion_tokens';
15
24
  export declare class OpenAIProvider implements LLMProvider {
16
25
  readonly id = "openai";
17
26
  readonly displayName = "OpenAI";
@@ -11,13 +11,16 @@
11
11
  * - The `[DONE]` sentinel terminates the SSE stream.
12
12
  */
13
13
  import { LLMError, classifyHttpError, wrapFetchError } from '../provider.js';
14
+ import { DEFAULT_MAX_OUTPUT_TOKENS, estimatePromptTokens, normalizeModelId, resolveOutputTokens, staticCeilingFor, } from '../output-limits.js';
15
+ import { getLearnedTokenField, sendWithOutputLimitRecovery } from '../output-limit-recovery.js';
14
16
  import { parseSSEStream } from '../stream-utils.js';
15
17
  import { consumeStream } from '../stream-utils.js';
16
18
  // ---------------------------------------------------------------------------
17
19
  // Constants
18
20
  // ---------------------------------------------------------------------------
19
21
  const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
20
- const DEFAULT_MAX_TOKENS = 16384;
22
+ /** Reasoning-family models reject `max_tokens` in favour of `max_completion_tokens`. */
23
+ const REASONING_MODEL_PATTERN = /^(o1|o3|o4|gpt-5)/;
21
24
  // Model ID patterns to filter out non-chat models
22
25
  const NON_CHAT_PATTERNS = [
23
26
  /^text-embedding/i,
@@ -38,6 +41,22 @@ const NON_CHAT_PATTERNS = [
38
41
  function isChatModel(id) {
39
42
  return !NON_CHAT_PATTERNS.some((pat) => pat.test(id));
40
43
  }
44
+ /**
45
+ * Which parameter name carries the cap for this model.
46
+ *
47
+ * A HEURISTIC, and only the first guess: a proxy fronting an o-series model
48
+ * under a `gpt-` alias disagrees with it. `unsupported_field` recovery renames
49
+ * the parameter after one round trip and memoizes the answer, which is why the
50
+ * learned dialect is consulted first.
51
+ */
52
+ export function openAiTokenField(modelId) {
53
+ const learned = getLearnedTokenField('openai', modelId);
54
+ if (learned)
55
+ return learned;
56
+ return REASONING_MODEL_PATTERN.test(normalizeModelId(modelId))
57
+ ? 'max_completion_tokens'
58
+ : 'max_tokens';
59
+ }
41
60
  // ---------------------------------------------------------------------------
42
61
  // OpenAI provider
43
62
  // ---------------------------------------------------------------------------
@@ -53,24 +72,33 @@ export class OpenAIProvider {
53
72
  const url = `${baseUrl}/chat/completions`;
54
73
  const body = buildRequestBody(request);
55
74
  let response;
75
+ let errorBody = '';
56
76
  try {
57
- response = await fetch(url, {
58
- method: 'POST',
59
- headers: {
60
- 'Content-Type': 'application/json',
61
- 'Authorization': `Bearer ${request.apiKey}`,
62
- },
63
- body: JSON.stringify(body),
64
- signal: request.signal,
77
+ const attempt = await sendWithOutputLimitRecovery({
78
+ providerId: this.id,
79
+ modelId: request.model,
80
+ body,
81
+ tokenField: openAiTokenField(request.model),
82
+ send: (payload) => fetch(url, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ 'Authorization': `Bearer ${request.apiKey}`,
87
+ },
88
+ body: JSON.stringify(payload),
89
+ signal: request.signal,
90
+ }),
65
91
  });
92
+ response = attempt.response;
93
+ errorBody = attempt.bodyText;
66
94
  }
67
95
  catch (err) {
68
96
  yield { type: 'error', error: wrapFetchError(err, this.id) };
69
97
  return;
70
98
  }
71
99
  if (!response.ok) {
72
- const text = await response.text().catch(() => '');
73
- yield { type: 'error', error: classifyHttpError(response.status, text, this.id) };
100
+ // `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
101
+ yield { type: 'error', error: classifyHttpError(response.status, errorBody, this.id) };
74
102
  return;
75
103
  }
76
104
  if (!response.body) {
@@ -214,6 +242,10 @@ export class OpenAIProvider {
214
242
  if (!res.ok)
215
243
  return [];
216
244
  const data = await res.json();
245
+ // The OpenAI models endpoint does NOT report an output ceiling, so this
246
+ // maps through the static table and deliberately does NOT call
247
+ // `learnModelCeiling` — claiming knowledge we do not have would outrank
248
+ // the (correct) table entry for every small model.
217
249
  return (data.data || [])
218
250
  .filter((m) => isChatModel(String(m.id || '')))
219
251
  .map((m) => ({
@@ -221,7 +253,7 @@ export class OpenAIProvider {
221
253
  name: String(m.id || ''),
222
254
  provider: this.id,
223
255
  contextWindow: 128_000,
224
- maxOutputTokens: 16_384,
256
+ maxOutputTokens: staticCeilingFor(this.id, String(m.id || '')) ?? DEFAULT_MAX_OUTPUT_TOKENS,
225
257
  supportsThinking: false,
226
258
  supportsTools: true,
227
259
  supportsImages: /gpt-4|o1|o3/i.test(String(m.id)),
@@ -244,10 +276,24 @@ function buildRequestBody(request) {
244
276
  stream: true,
245
277
  stream_options: { include_usage: true },
246
278
  };
247
- if (request.maxTokens) {
248
- body.max_tokens = request.maxTokens;
249
- }
250
- if (request.temperature !== undefined) {
279
+ const tokenField = openAiTokenField(request.model);
280
+ // The prompt estimate is only ever consumed when the caller told us the
281
+ // context window, so it is computed only then — walking every message on a
282
+ // hot path that cannot use the result would be pure waste.
283
+ const contextWindow = request.modelLimits?.contextWindow;
284
+ const resolution = resolveOutputTokens({
285
+ providerId: 'openai',
286
+ modelId: request.model,
287
+ ...(request.maxTokens !== undefined ? { requested: request.maxTokens } : {}),
288
+ ...(request.modelLimits ? { modelLimits: request.modelLimits } : {}),
289
+ ...(contextWindow !== undefined
290
+ ? { estimatedPromptTokens: estimatePromptTokens(request.messages, request.systemPrompt) }
291
+ : {}),
292
+ });
293
+ body[tokenField] = resolution.value;
294
+ // The reasoning family rejects `temperature` alongside `max_tokens`; sending
295
+ // it would just buy a second 400 on a different key.
296
+ if (request.temperature !== undefined && tokenField === 'max_tokens') {
251
297
  body.temperature = request.temperature;
252
298
  }
253
299
  if (request.stopSequences && request.stopSequences.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/core",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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",