@cubicecho/agent-core 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -132,28 +132,49 @@ again, with the setting still reading `high` and nothing anywhere saying it had
132
132
  So they hang off the endpoint under the name the endpoint knows the model by. Pass `model` and
133
133
  the same loop answers both levels; leave it out and nothing changes.
134
134
 
135
- A refusal of the *value* is not one of these, however alike the two read: an effort off a list
136
- this package does not know, a `max_tokens` larger than the model's ceiling, a temperature out of
137
- range. Dropping the field answers those too — at the model's own default, latched for the rest of
138
- the process, with the settings row still reading what was typed and nothing saying it had stopped
139
- meaning it. They are passed to the caller instead, where whoever typed the number can see it.
135
+ A refusal of the *value* is not one of these, however alike the two read: a `max_tokens` larger
136
+ than the model's ceiling, a temperature out of range. Dropping the field answers those too — at
137
+ the model's own default, latched for the rest of the process, with the settings row still reading
138
+ what was typed and nothing saying it had stopped meaning it. They are passed to the caller
139
+ instead, where whoever typed the number can see it.
140
+
141
+ The effort is the exception, because there the model has said what it *would* take:
142
+ `Unsupported value: 'reasoning_effort' does not support 'none' with this model. Supported values
143
+ are: 'minimal', 'low', 'medium', and 'high'.` `negotiate` latches the rung that was refused and
144
+ re-sends at the cheapest one above it, which is a request rather than a failure. Where the refusal
145
+ lists nothing it walks `EFFORT_LADDER` — `none`, `minimal`, `low`, `medium`, `high` — a rung per
146
+ refusal. Both latch per `(endpoint, model)` and ride in the snapshot, so a restart does not walk
147
+ the ladder again.
148
+
149
+ Only ever upward, and only from a value on the ladder. Answering a refused `xhigh` with `high`
150
+ would quietly reason less than whoever typed it asked for; stepping up from `none` only costs
151
+ tokens, and says so in a notice. When the ladder runs out, or the value is not on it, the refusal
152
+ goes to the caller as it did before.
153
+
154
+ `effortFor(model, asked)` is what a body builder calls to get the value to send — `buildBody` and
155
+ the side tasks both do — and it answers the empty string for a model that takes no effort at all,
156
+ for `"off"` and for an absent setting.
140
157
 
141
158
  ```ts
142
- const turn = await negotiate(supports, (supports, produced, model) =>
143
- streamTurn(
144
- getClient(config),
145
- {
146
- model: name, messages, stream: true,
147
- // Each rebuilt per attempt from what this model has already refused.
148
- ...(model?.reasoningEffort ? { reasoning_effort: effort } : {}),
149
- ...(model?.legacyTokenLimit === false
150
- ? { max_completion_tokens: limit }
151
- : { max_tokens: limit }),
152
- ...(model?.chosenTemperature ? { temperature } : {}),
153
- tools: supports.strictSchemas ? declared : relaxTools(declared),
154
- },
155
- { produced, signal },
156
- ),
159
+ const turn = await negotiate(
160
+ supports,
161
+ (supports, produced, model) => {
162
+ // Each rebuilt per attempt from what this model has already refused.
163
+ const asked = effortFor(model, effort);
164
+ return streamTurn(
165
+ getClient(config),
166
+ {
167
+ model: name, messages, stream: true,
168
+ ...(asked ? { reasoning_effort: asked } : {}),
169
+ ...(model?.legacyTokenLimit === false
170
+ ? { max_completion_tokens: limit }
171
+ : { max_tokens: limit }),
172
+ ...(model?.chosenTemperature ? { temperature } : {}),
173
+ tools: supports.strictSchemas ? declared : relaxTools(declared),
174
+ },
175
+ { produced, signal },
176
+ );
177
+ },
157
178
  { model: name },
158
179
  );
159
180
  ```
@@ -18,7 +18,8 @@ import { type ToolOrder } from "./tool-loading.ts";
18
18
  * one has to read the same — which is the test one of the three copies had inverted.
19
19
  *
20
20
  * @param config What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
21
- * absent or `"off"` sends no effort.
21
+ * absent or `"off"` sends no effort, and one the model has refused by value is stepped up to the
22
+ * cheapest it takes by `effortFor`.
22
23
  * @param supports What the endpoint has refused, as `negotiate` hands it to `send`.
23
24
  * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
24
25
  * that has refused nothing.
@@ -1,5 +1,5 @@
1
1
  import { charsPerTokenFor } from "./calibration.js";
2
- import { capabilitiesFor } from "./capabilities.js";
2
+ import { capabilitiesFor, effortFor, } from "./capabilities.js";
3
3
  import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
4
4
  import { continueTurn } from "./continuation.js";
5
5
  import { errorMessage } from "./errors.js";
@@ -35,7 +35,8 @@ const RESERVED = new Set(["model", "messages", "stream", "tools"]);
35
35
  * one has to read the same — which is the test one of the three copies had inverted.
36
36
  *
37
37
  * @param config What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
38
- * absent or `"off"` sends no effort.
38
+ * absent or `"off"` sends no effort, and one the model has refused by value is stepped up to the
39
+ * cheapest it takes by `effortFor`.
39
40
  * @param supports What the endpoint has refused, as `negotiate` hands it to `send`.
40
41
  * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
41
42
  * that has refused nothing.
@@ -51,7 +52,7 @@ export function buildBody(config, supports, refused, messages, tools = [], order
51
52
  const declared = supports.strictSchemas
52
53
  ? sanitizeTools(sorted)
53
54
  : relaxTools(sanitizeTools(sorted));
54
- const effort = config.reasoningEffort;
55
+ const effort = effortFor(refused, config.reasoningEffort);
55
56
  const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED.has(field) && !refused?.refusedFields.has(field));
56
57
  return {
57
58
  ...(config.maxTokens > 0
@@ -60,9 +61,7 @@ export function buildBody(config, supports, refused, messages, tools = [], order
60
61
  : { max_tokens: config.maxTokens }
61
62
  : {}),
62
63
  ...(refused?.chosenTemperature === false ? {} : { temperature: config.temperature }),
63
- ...(effort && effort !== "off" && refused?.reasoningEffort !== false
64
- ? { reasoning_effort: effort }
65
- : {}),
64
+ ...(effort ? { reasoning_effort: effort } : {}),
66
65
  ...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
67
66
  ...Object.fromEntries(extra),
68
67
  model: config.model,
@@ -93,6 +93,22 @@ export interface ModelCapabilities {
93
93
  * endpoint's because one key reaches models that differ here, the way they differ on effort.
94
94
  */
95
95
  structuredOutput: boolean;
96
+ /**
97
+ * Efforts this model refused by value rather than by field — a request that named `none` on a
98
+ * model whose list starts at `minimal`. Only ever grows, which is this set's way of latching.
99
+ *
100
+ * Separate from `reasoningEffort` because the two refusals mean opposite things: that one says
101
+ * the model cannot reason and the field must go, this one says it reasons and was handed an
102
+ * effort off a list this package does not know. Dropping the field there would run at the
103
+ * model's own default, which is neither what the caller asked for nor something it can see.
104
+ */
105
+ refusedEfforts: Set<string>;
106
+ /**
107
+ * The efforts a refusal published as this model's, in ladder order, filtered to the ones
108
+ * `EFFORT_LADDER` can place. Absent until a refusal lists them, and a model that lists nothing
109
+ * is walked up the ladder a rung per refusal instead.
110
+ */
111
+ supportedEfforts?: string[];
96
112
  /**
97
113
  * Continues a trailing assistant message rather than answering afresh, which `continueTurn`
98
114
  * relies on. llama.cpp renders one as a prefill and picks up mid-word; hosted OpenAI takes the
@@ -170,6 +186,30 @@ export declare function resetCapabilities(endpoint?: {
170
186
  * @returns How many endpoints were forgotten.
171
187
  */
172
188
  export declare function expireCapabilities(maxAgeMs: number, now?: number): number;
189
+ /**
190
+ * The efforts this package can place, cheapest first. A substitution only ever walks *up* it.
191
+ *
192
+ * Not a list of what any model takes — `medium` is refused by a model whose list is
193
+ * `minimal, low, high`, and `xhigh` is real and deliberately absent. It is the order the values
194
+ * OpenAI has shipped stand in, which is all a step needs to know. A value not on it cannot be
195
+ * placed, and an unplaceable value is left alone rather than guessed at: stepping down from a
196
+ * refused `xhigh` to `high` would quietly answer a question with less deliberation than whoever
197
+ * typed it asked for, where stepping up from `none` to `minimal` only costs tokens and says so.
198
+ */
199
+ export declare const EFFORT_LADDER: readonly ["none", "minimal", "low", "medium", "high"];
200
+ /**
201
+ * What to actually put in `reasoning_effort` for a model that has refused the value asked for.
202
+ *
203
+ * The caller's own value, until this model has said that value is not one of its own; then the
204
+ * cheapest it will take that is at least as much deliberation. A model that takes no effort at
205
+ * all, and an absent or `"off"` setting, both answer the empty string, which is the body
206
+ * builders' signal to send no field.
207
+ *
208
+ * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
209
+ * has refused nothing.
210
+ * @param asked What the config asks for. `"off"` and absent mean no effort.
211
+ */
212
+ export declare function effortFor(refused: ModelCapabilities | undefined, asked: string | undefined): string;
173
213
  /** What `negotiate` takes besides the request. All optional. */
174
214
  export interface NegotiateOptions {
175
215
  /**
@@ -56,6 +56,7 @@ export function modelCapabilitiesFor(supports, model) {
56
56
  chosenTemperature: true,
57
57
  refusedFields: new Set(),
58
58
  structuredOutput: true,
59
+ refusedEfforts: new Set(),
59
60
  assistantPrefill: true,
60
61
  };
61
62
  supports.models.set(model, known);
@@ -154,7 +155,8 @@ const rejectsResponseFormat = (detail) => /response_format|json_schema/i.test(de
154
155
  * perfectly well; it was handed an effort off a list this package does not know. Dropping the
155
156
  * field succeeds, at the model's own default effort, which is neither what the caller asked for
156
157
  * nor something it can see — and the drop latches, so every later turn on that model reasons at
157
- * the default with the setting still reading what the operator typed.
158
+ * the default with the setting still reading what the operator typed. `stepEffort` answers this
159
+ * one instead, by naming an effort the model does take.
158
160
  */
159
161
  const REFUSED_VALUE = /unsupported value|invalid value|supported values/i;
160
162
  /**
@@ -165,6 +167,127 @@ const REFUSED_VALUE = /unsupported value|invalid value|supported values/i;
165
167
  * latching.
166
168
  */
167
169
  const rejectsEffort = (detail) => /reasoning_effort/i.test(detail) && !REFUSED_VALUE.test(detail);
170
+ /**
171
+ * The efforts this package can place, cheapest first. A substitution only ever walks *up* it.
172
+ *
173
+ * Not a list of what any model takes — `medium` is refused by a model whose list is
174
+ * `minimal, low, high`, and `xhigh` is real and deliberately absent. It is the order the values
175
+ * OpenAI has shipped stand in, which is all a step needs to know. A value not on it cannot be
176
+ * placed, and an unplaceable value is left alone rather than guessed at: stepping down from a
177
+ * refused `xhigh` to `high` would quietly answer a question with less deliberation than whoever
178
+ * typed it asked for, where stepping up from `none` to `minimal` only costs tokens and says so.
179
+ */
180
+ export const EFFORT_LADDER = ["none", "minimal", "low", "medium", "high"];
181
+ const rankOf = (effort) => EFFORT_LADDER.indexOf(effort.toLowerCase());
182
+ /**
183
+ * The efforts a refusal lists as this model's: `Supported values are: 'minimal', 'low', 'medium',
184
+ * and 'high'.` Quoted or bare, separated by commas and a trailing `and` or `or`.
185
+ */
186
+ function listedEfforts(detail) {
187
+ const listed = detail.match(/supported values(?:\s+\w+)?\s*(?:are|is|include)?\s*:?\s*([^\n.]+)/i)?.[1];
188
+ if (!listed)
189
+ return undefined;
190
+ const values = listed
191
+ .split(/,|\band\b|\bor\b/)
192
+ .map((value) => value
193
+ .trim()
194
+ .replace(/^['"`]+|['"`]+$/g, "")
195
+ .toLowerCase())
196
+ .filter((value) => rankOf(value) >= 0);
197
+ return values.length ? values : undefined;
198
+ }
199
+ /**
200
+ * Which effort a refusal says was refused, in the two shapes the wording takes: the field quoted
201
+ * then `does not support 'none'`, and `reasoning_effort: none`.
202
+ *
203
+ * Read rather than remembered, because `negotiate` builds no request and so does not know what
204
+ * went out — and a proxy that rewrites the value before passing it on is refusing the one it sent
205
+ * rather than the one it was given. A refusal that names no value steps nothing: guessing which
206
+ * rung was refused is how a ladder walks past the value that would have worked.
207
+ *
208
+ * @param detail The refusal.
209
+ * @param supported What the same refusal listed, which the value cannot be one of.
210
+ */
211
+ function refusedEffortValue(detail, supported = []) {
212
+ const found = detail.match(/reasoning_effort['"`]?\s*(?:does not support|is not supported with|:)\s*['"`]?([\w-]+)/i)?.[1];
213
+ const value = found?.toLowerCase();
214
+ return value && !supported.includes(value) ? value : undefined;
215
+ }
216
+ /**
217
+ * The cheapest effort above this one that the model has not refused, or `undefined` when the
218
+ * ladder is out of rungs.
219
+ *
220
+ * Above, never below: see `EFFORT_LADDER`. Where a refusal published a list that is the whole of
221
+ * what is tried, so the step lands in one request; where it published none the ladder is walked a
222
+ * rung at a time, each refusal latching the rung it named.
223
+ */
224
+ function nextEffort(refused, asked) {
225
+ const rank = rankOf(asked);
226
+ if (rank < 0)
227
+ return undefined;
228
+ let best;
229
+ for (const value of refused.supportedEfforts ?? EFFORT_LADDER) {
230
+ const at = rankOf(value);
231
+ if (at <= rank || refused.refusedEfforts.has(value))
232
+ continue;
233
+ if (best === undefined || at < rankOf(best))
234
+ best = value;
235
+ }
236
+ return best;
237
+ }
238
+ /**
239
+ * What to actually put in `reasoning_effort` for a model that has refused the value asked for.
240
+ *
241
+ * The caller's own value, until this model has said that value is not one of its own; then the
242
+ * cheapest it will take that is at least as much deliberation. A model that takes no effort at
243
+ * all, and an absent or `"off"` setting, both answer the empty string, which is the body
244
+ * builders' signal to send no field.
245
+ *
246
+ * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
247
+ * has refused nothing.
248
+ * @param asked What the config asks for. `"off"` and absent mean no effort.
249
+ */
250
+ export function effortFor(refused, asked) {
251
+ if (!asked || asked === "off")
252
+ return "";
253
+ if (!refused)
254
+ return asked;
255
+ if (!refused.reasoningEffort)
256
+ return "";
257
+ const known = refused.supportedEfforts;
258
+ const listed = known ? known.includes(asked.toLowerCase()) : true;
259
+ if (listed && !refused.refusedEfforts.has(asked.toLowerCase()))
260
+ return asked;
261
+ return nextEffort(refused, asked) ?? asked;
262
+ }
263
+ /**
264
+ * How to answer a refused effort *value*, or `undefined` when there is nothing new to learn or
265
+ * nowhere left to go — in which case the refusal is the caller's, as it was before this existed.
266
+ *
267
+ * Pure, so `negotiate` can work out whether this refusal is one of its own before deciding to
268
+ * answer it, and latch only in the branch it takes.
269
+ */
270
+ function planEffortStep(detail, refused) {
271
+ if (!refused.reasoningEffort)
272
+ return undefined;
273
+ if (!/reasoning_effort/i.test(detail) || !REFUSED_VALUE.test(detail))
274
+ return undefined;
275
+ const supported = listedEfforts(detail);
276
+ const value = refusedEffortValue(detail, supported);
277
+ if (value === undefined)
278
+ return undefined;
279
+ // Something has to be new, or a server that answers every request by naming an effort nobody
280
+ // sent would have `negotiate` re-send for as long as it kept saying it.
281
+ const fresh = supported?.join() !== refused.supportedEfforts?.join();
282
+ if (refused.refusedEfforts.has(value) && !fresh)
283
+ return undefined;
284
+ const next = nextEffort({
285
+ ...refused,
286
+ refusedEfforts: new Set(refused.refusedEfforts).add(value),
287
+ supportedEfforts: supported ?? refused.supportedEfforts,
288
+ }, value);
289
+ return next === undefined ? undefined : { value, next, ...(supported ? { supported } : {}) };
290
+ }
168
291
  /**
169
292
  * Read only alongside the name it is asking for: `'max_tokens' is not supported with this model.
170
293
  * Use 'max_completion_tokens' instead.`
@@ -280,6 +403,7 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
280
403
  if (produced.any)
281
404
  throw error;
282
405
  const detail = errorMessage(error);
406
+ const stepped = named && planEffortStep(detail, named.refused);
283
407
  if (supports.strictSchemas && isGrammarError(detail)) {
284
408
  supports.strictSchemas = false;
285
409
  onNotice?.("server could not build a grammar; retrying without pattern/format");
@@ -292,6 +416,12 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
292
416
  named.refused.reasoningEffort = false;
293
417
  onNotice?.(`${named.name} does not take a reasoning effort; retrying without one`);
294
418
  }
419
+ else if (named && stepped) {
420
+ named.refused.refusedEfforts.add(stepped.value);
421
+ if (stepped.supported)
422
+ named.refused.supportedEfforts = stepped.supported;
423
+ onNotice?.(`${named.name} does not reason at ${stepped.value}; retrying at ${stepped.next}`);
424
+ }
295
425
  else if (named?.refused.legacyTokenLimit && wantsCompletionLimit(detail)) {
296
426
  named.refused.legacyTokenLimit = false;
297
427
  onNotice?.(`${named.name} wants max_completion_tokens; retrying with the limit spelled that way`);
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type ToolCallOutcome, type ToolCallRequest, } from "./agent-loop.ts";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
14
- export { type Capabilities, capabilitiesFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
14
+ export { type Capabilities, capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
15
15
  export type { CatalogServer } from "./catalog.ts";
16
16
  export { type ClientPoolOptions, configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, type ModelInfo, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.ts";
17
17
  export { applyCompaction, COMPACT_AT, type CompactionOptions, type CompactionPlan, type CompactionRecord, type CompactionRunOptions, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.js";
14
- export { capabilitiesFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
14
+ export { capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
15
15
  export { configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
16
16
  export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
17
17
  export { continueTurn, isContinuable, } from "./continuation.js";
package/dist/side-task.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import OpenAI from "openai";
2
- import { capabilitiesFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
2
+ import { capabilitiesFor, effortFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
3
3
  import { endpointId, getClient } from "./client.js";
4
4
  import { errorMessage } from "./errors.js";
5
5
  import { isTransient } from "./retry.js";
@@ -85,7 +85,11 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
85
85
  // Whether the last request carried an effort, which `negotiate` decides and not this function.
86
86
  let sentEffort = false;
87
87
  const send = (hints, effort, supports, refused) => {
88
- sentEffort = effort && refused?.reasoningEffort !== false;
88
+ // `none` is what a side task wants and not always what the model offers: OpenAI's reasoning
89
+ // models refuse it by value and list `minimal` as their floor. `effortFor` answers with the
90
+ // cheapest rung this one takes, which `negotiate` has been stepping up as it was refused.
91
+ const asked = effort ? effortFor(refused, "none") : "";
92
+ sentEffort = asked !== "";
89
93
  return getClient(config).chat.completions.create({
90
94
  model,
91
95
  // The reasoning models want the ceiling spelled the other way, and they are exactly the
@@ -103,7 +107,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
103
107
  ...(hints ? NO_THINKING : {}),
104
108
  // Not gated on `hints`: a model that refuses `chat_template_kwargs` may still read the
105
109
  // effort, and the two latches would otherwise contradict each other.
106
- ...(sentEffort ? { reasoning_effort: "none" } : {}),
110
+ ...(sentEffort ? { reasoning_effort: asked } : {}),
107
111
  ...(format && refused ? format(supports, refused) : {}),
108
112
  }, { signal });
109
113
  };
@@ -22,6 +22,14 @@ export interface ModelSnapshot {
22
22
  assistantPrefill: boolean;
23
23
  /** Takes the no-thinking hints `ask` sends. */
24
24
  thinkingHints: boolean;
25
+ /**
26
+ * Efforts refused by value rather than by field, so a restart does not spend a request per rung
27
+ * walking the ladder again. Absent in a snapshot taken before they were latched, which reads as
28
+ * none refused.
29
+ */
30
+ refusedEfforts?: string[];
31
+ /** The efforts a refusal published as this model's, in ladder order. Absent is none published. */
32
+ supportedEfforts?: string[];
25
33
  }
26
34
  /** What one endpoint refused, and under it what each of its models did. */
27
35
  export interface EndpointSnapshot {
package/dist/snapshot.js CHANGED
@@ -18,6 +18,7 @@ const optimisticModel = () => ({
18
18
  structuredOutput: true,
19
19
  assistantPrefill: true,
20
20
  thinkingHints: true,
21
+ refusedEfforts: [],
21
22
  });
22
23
  const refusedAnything = (model) => !model.reasoningEffort ||
23
24
  !model.legacyTokenLimit ||
@@ -25,7 +26,8 @@ const refusedAnything = (model) => !model.reasoningEffort ||
25
26
  !model.thinkingHints ||
26
27
  !model.structuredOutput ||
27
28
  !model.assistantPrefill ||
28
- model.refusedFields.length > 0;
29
+ model.refusedFields.length > 0 ||
30
+ (model.refusedEfforts?.length ?? 0) > 0;
29
31
  /**
30
32
  * Every refusal this process has latched, as a JSON-safe blob to store and hand back on boot.
31
33
  *
@@ -59,6 +61,10 @@ export function exportCapabilities() {
59
61
  refusedFields: [...refused.refusedFields].sort(),
60
62
  structuredOutput: refused.structuredOutput,
61
63
  assistantPrefill: refused.assistantPrefill,
64
+ refusedEfforts: [...refused.refusedEfforts].sort(),
65
+ // Only alongside a refusal, since on its own a published list latches nothing: the model
66
+ // named it while refusing a rung, and that rung is in `refusedEfforts`.
67
+ ...(refused.supportedEfforts ? { supportedEfforts: [...refused.supportedEfforts] } : {}),
62
68
  };
63
69
  if (refusedAnything(model))
64
70
  models[name] = model;
@@ -128,6 +134,19 @@ export function importCapabilities(snapshot) {
128
134
  refused.structuredOutput = false;
129
135
  if (model.assistantPrefill === false)
130
136
  refused.assistantPrefill = false;
137
+ if (Array.isArray(model.refusedEfforts)) {
138
+ for (const effort of model.refusedEfforts) {
139
+ if (typeof effort === "string")
140
+ refused.refusedEfforts.add(effort);
141
+ }
142
+ }
143
+ // Replaced rather than merged: two lists of what one model takes are two readings of the
144
+ // same fact, and the stored one is at least as recent as an empty absent.
145
+ if (Array.isArray(model.supportedEfforts)) {
146
+ const listed = model.supportedEfforts.filter((value) => typeof value === "string");
147
+ if (listed.length)
148
+ refused.supportedEfforts = listed;
149
+ }
131
150
  if (Array.isArray(model.refusedFields)) {
132
151
  for (const field of model.refusedFields) {
133
152
  if (typeof field === "string")
package/llms.txt CHANGED
@@ -38,6 +38,8 @@ What an endpoint turned out not to support, and answering it when it says so.
38
38
 
39
39
  - `Capabilities` (type) — What one endpoint turned out not to support.
40
40
  - `capabilitiesFor` — What this endpoint is known not to support.
41
+ - `EFFORT_LADDER` — The efforts this package can place, cheapest first.
42
+ - `effortFor` — What to actually put in `reasoning_effort` for a model that has refused the value asked for.
41
43
  - `expireCapabilities` — Forgets every endpoint whose entry is older than this, so the next request finds out again.
42
44
  - `ModelCapabilities` (type) — What one model on that endpoint turned out not to support.
43
45
  - `modelCapabilitiesFor` — What this model on this endpoint is known not to support.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",