@aragon-agent/core 0.2.3 → 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 +44 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -0
- package/dist/llm/model-registry.js +43 -4
- package/dist/llm/output-limit-recovery.d.ts +86 -0
- package/dist/llm/output-limit-recovery.js +263 -0
- package/dist/llm/output-limits.d.ts +108 -0
- package/dist/llm/output-limits.js +274 -0
- package/dist/llm/provider.d.ts +20 -0
- package/dist/llm/providers/anthropic.js +45 -17
- package/dist/llm/providers/google.js +35 -12
- package/dist/llm/providers/openai.d.ts +9 -0
- package/dist/llm/providers/openai.js +62 -16
- package/package.json +1 -1
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/dist/llm/provider.d.ts
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
57
|
-
yield { type: 'error', error: classifyHttpError(response.status,
|
|
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:
|
|
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:
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
body
|
|
41
|
-
|
|
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
|
-
|
|
50
|
-
yield { type: 'error', error: classifyHttpError(response.status,
|
|
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:
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
73
|
-
yield { type: 'error', error: classifyHttpError(response.status,
|
|
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:
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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.
|
|
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",
|