teapot-coding-agent 0.29.11 → 0.29.12

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/dist/agent/llm.js CHANGED
@@ -10,44 +10,82 @@ const clients = new WeakMap();
10
10
  /**
11
11
  * Reasoning effort for a request body (#47, #150).
12
12
  *
13
- * MEASURED against the live endpoints in use (one probe per provider, `max_tokens:
14
- * 3` with `reasoning_effort: "low"`):
13
+ * MEASURED against the live endpoints (one probe each, `max_tokens: 3` with
14
+ * `reasoning_effort: "low"`):
15
15
  *
16
- * openrouter — documented, catalogue advertises the field
17
- * commandcode-goat — 200 OK and the reply carried `reasoning_content`
18
- * google — 400 "Thinking level is not supported for this model"
19
- * orcarouter / opencode / experientiallabs — refused before reaching the field
16
+ * commandcode-goat 200 OK, reply carried `reasoning_content` -> accepts
17
+ * google 400 "Thinking level is not supported for this model."
18
+ * openrouter documented, catalogue advertises the field
20
19
  *
21
- * So "OpenRouter only" was too narrow (#150: `commandcode-goat` offers no options
22
- * at all), but "send it everywhere" would break Google, which REJECTS the field
23
- * with a 400 rather than ignoring it. The gate therefore stays opt-in:
20
+ * and — the fact that decides the design — what each endpoint PUBLISHES in
21
+ * `/models`:
24
22
  *
25
- * 1. the configured value must be a level the API documents, AND
26
- * 2. EITHER the endpoint's catalogue advertises `reasoning_effort` (OpenRouter
27
- * and compatible gateways), OR the provider is explicitly marked
28
- * `reasoningEffort: true` in the config for an endpoint that accepts the field
29
- * but publishes no catalogue capability list (#150).
23
+ * openrouter `supported_parameters: [… "reasoning_effort" …]` per model
24
+ * commandcode-goat only `id`, `name`, `context_length`, `supported_endpoints`
25
+ * google no capability list either
30
26
  *
31
- * Implemented here rather than via model-meta to avoid an import cycle —
32
- * model-meta already imports providerHeaders from this file.
27
+ * So for a gateway that publishes nothing there is NO per-model signal to gate on,
28
+ * and the only honest way to find out whether it accepts the field is to TRY it.
29
+ * A wrong guess is cheap to recover from: the endpoint answers 400 with a
30
+ * recognisable message, and `isEffortUnsupported()` + the retry in `chat()` drop
31
+ * the field for that request. That is better than a config flag the operator has to
32
+ * know about, and better than hiding the control behind a capability list most
33
+ * endpoints do not publish.
34
+ *
35
+ * Consequences, in order:
36
+ * 1. a catalogue that explicitly EXCLUDES `reasoning_effort` is respected (no
37
+ * point provoking a 400 for a model that tells us up front it will refuse);
38
+ * 2. a catalogue that INCLUDES it, or publishes no capability list at all, gets
39
+ * the field — and self-corrects on refusal;
40
+ * 3. an invalid or absent level is never sent, whatever the endpoint says.
33
41
  */
34
42
  export function effortForRequest(cfg, supported = cfg.supportedParameters) {
35
43
  const e = cfg.reasoningEffort;
36
44
  if (!e || !REASONING_EFFORTS.includes(e))
37
45
  return {};
38
- // the catalogue is a capability claim from a GATEWAY, so trust it only where the
39
- // gateway format is the one that carries it. MEASURED: of the endpoints in use,
40
- // only OpenRouter-family ones publish `supported_parameters` at all — google,
41
- // commandcode, orcarouter and opencode all return 0 models with the field, so
42
- // this cannot silently widen the blast radius.
43
- if (supported?.includes("reasoning_effort") && isOpenRouterEndpoint(cfg.baseUrl)) {
44
- return { reasoning_effort: e };
45
- }
46
- // #150: an endpoint that ACCEPTS the field but publishes no capability list needs
47
- // the provider to say so explicitly (provider.reasoningEffort: true in config).
48
- if (cfg.reasoningEffortOptIn === true)
49
- return { reasoning_effort: e };
50
- return {};
46
+ // a catalogue that names the parameter it supports and does NOT list this one is
47
+ // a definite "no" — only the ones that omit the question are worth a probe
48
+ if (supported && !supported.includes("reasoning_effort"))
49
+ return {};
50
+ // Clamp into the model's own levels when it publishes them (#150). The USER's input
51
+ // is validated against the four levels we offer, but the CLAMPED result may be a
52
+ // provider-specific one (`xhigh`, `max`) — deepseek advertises `["max","high","low"]`,
53
+ // so refusing to send `max` because it is not in our own four would throw away the
54
+ // model's real top tier.
55
+ const clamped = clampEffort(e, cfg.supportedEfforts);
56
+ if (!EFFORT_LADDER.includes(clamped))
57
+ return {};
58
+ return { reasoning_effort: clamped };
59
+ }
60
+ /**
61
+ * Did the endpoint refuse `reasoning_effort` as an unsupported PARAMETER? (#150)
62
+ *
63
+ * Google's wording is "Thinking level is not supported for this model."; other
64
+ * gateways phrase it as an unknown/unsupported parameter or field. Matched
65
+ * narrowly: a retry that drops the field is only correct when THIS is the
66
+ * complaint, and misreading an unrelated 400 as one would silently discard the
67
+ * operator's choice instead of surfacing the real error.
68
+ */
69
+ export function isEffortUnsupported(err) {
70
+ const msg = String(err?.message ?? err ?? "");
71
+ if (!/reasoning_effort|thinking level|effort/i.test(msg))
72
+ return false;
73
+ return /not supported|unsupported|unknown (parameter|field)|invalid/i.test(msg);
74
+ }
75
+ /**
76
+ * Is this model OFFERED a reasoning-effort control? (#150)
77
+ *
78
+ * The right panel shows the row when it can do something. A model whose catalogue
79
+ * says it does not take the parameter hides it; one whose catalogue is silent
80
+ * still shows it, because the endpoints that publish nothing (measured: commandcode,
81
+ * google) accept the field for some models and refuse it for others, and a silent
82
+ * 400 with an automatic retry is a better experience than a control that is missing
83
+ * where it would have worked.
84
+ */
85
+ export function effortChoosable(supported) {
86
+ if (!supported)
87
+ return true; // no capability list published: worth a try
88
+ return supported.includes("reasoning_effort");
51
89
  }
52
90
  function isOpenRouterEndpoint(baseUrl) {
53
91
  try {
@@ -80,21 +118,85 @@ export function cacheControlForRequest(cfg) {
80
118
  }
81
119
  const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
82
120
  /**
121
+ * Canonical low→high ordering used for clamping, including the levels this harness
122
+ * does not itself offer (`none`, `xhigh`, `max`) because a provider's own list is
123
+ * spelled in those terms and the comparison needs the full ladder.
124
+ *
125
+ * Same ladder and the same rules as Hermes' `EFFORT_LADDER` / `clamp_effort`, which
126
+ * is the clearest statement of the problem: a provider that does not have the
127
+ * requested level should not receive a request it will refuse, and should not be
128
+ * silently escalated either.
129
+ */
130
+ const EFFORT_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
131
+ /**
132
+ * Map a requested level onto the levels a MODEL actually accepts (#150).
133
+ *
134
+ * Measured: OpenRouter publishes `reasoning.supported_efforts` per model (198 of 458
135
+ * models), and the levels are not the four a naive UI would offer — deepseek-v4.1-flash
136
+ * advertises `["max","high","low"]`, so `minimal` and `medium` do not exist for it and
137
+ * `max` does. Sending an absent level is a refused turn; offering one is a control that
138
+ * does nothing.
139
+ *
140
+ * Rules, matching Hermes:
141
+ * - unknown or empty support → the request passes through (we cannot judge it)
142
+ * - already supported → unchanged
143
+ * - a level that is not on the ladder at all → unchanged (custom providers use
144
+ * bespoke names, and rewriting one would be guesswork)
145
+ * - otherwise the nearest WEAKER level, so a clamp never escalates cost; if nothing
146
+ * weaker exists, the weakest supported level
147
+ * - never to `none`, which would silently switch thinking OFF for a caller who asked
148
+ * for it to be on
149
+ */
150
+ export function clampEffort(requested, supported) {
151
+ if (!requested || !supported?.length)
152
+ return requested;
153
+ const norm = supported.map((s) => String(s).toLowerCase());
154
+ if (norm.includes(requested))
155
+ return requested;
156
+ const reqIdx = EFFORT_LADDER.indexOf(requested);
157
+ if (reqIdx < 0)
158
+ return requested; // not a ladder level: not ours to rewrite
159
+ const candidates = norm.filter((l) => l !== "none" && EFFORT_LADDER.includes(l));
160
+ if (!candidates.length)
161
+ return requested;
162
+ const weaker = candidates.filter((l) => EFFORT_LADDER.indexOf(l) < reqIdx);
163
+ if (weaker.length) {
164
+ return weaker.reduce((best, l) => EFFORT_LADDER.indexOf(l) > EFFORT_LADDER.indexOf(best) ? l : best);
165
+ }
166
+ return candidates.reduce((best, l) => EFFORT_LADDER.indexOf(l) < EFFORT_LADDER.indexOf(best) ? l : best);
167
+ }
168
+ /**
169
+ * Per-endpoint request headers. Empty (harmless no-op) for every provider that
170
+ * does not ask for any.
171
+ *
83
172
  * OpenRouter app attribution (https://openrouter.ai/docs/app-attribution):
84
173
  * `HTTP-Referer` is the required identifier that puts teapot's usage on the
85
174
  * public rankings/analytics; the title names it; `programming-app` files it
86
- * under coding tools. Empty (harmless no-op) for every other provider.
175
+ * under coding tools.
176
+ *
177
+ * OpenCode Go (https://opencode.ai/docs/go/#where-can-i-use-it) identifies a
178
+ * conversation by `x-opencode-session` and returns HTTP 400 without it —
179
+ * "Request is missing x-opencode-session and cannot be routed efficiently" —
180
+ * so a turn on that endpoint must name the session it belongs to. The value is
181
+ * the session directory name, which is stable for the whole conversation:
182
+ * a compaction is a new request but not a new conversation, so it keeps the id,
183
+ * while a fork/incarnation switch (`currentSession`) gets its own.
87
184
  */
88
- export function providerHeaders(baseUrl) {
185
+ export function providerHeaders(baseUrl, sessionId) {
89
186
  try {
90
187
  const host = new URL(baseUrl).host;
91
- if (!/(^|\.)openrouter\.ai$/i.test(host))
92
- return {};
93
- return {
94
- "HTTP-Referer": "https://github.com/akku1139/teapot",
95
- "X-OpenRouter-Title": "teapot",
96
- "X-OpenRouter-Categories": "programming-app",
97
- };
188
+ if (/(^|\.)openrouter\.ai$/i.test(host)) {
189
+ return {
190
+ "HTTP-Referer": "https://github.com/akku1139/teapot",
191
+ "X-OpenRouter-Title": "teapot",
192
+ "X-OpenRouter-Categories": "programming-app",
193
+ };
194
+ }
195
+ // scoped to this host so the id cannot follow the operator to another
196
+ // provider, and skipped entirely when there is no session to name
197
+ if (sessionId && /(^|\.)opencode\.ai$/i.test(host))
198
+ return { "x-opencode-session": sessionId };
199
+ return {};
98
200
  }
99
201
  catch {
100
202
  return {}; // unparseable baseUrl — never block a request over metadata
@@ -108,7 +210,7 @@ function client(cfg) {
108
210
  apiKey: cfg.apiKey,
109
211
  timeout: cfg.timeoutMs ?? 120_000,
110
212
  maxRetries: 4, // SDK handles backoff for 429/5xx/network errors
111
- defaultHeaders: providerHeaders(cfg.baseUrl),
213
+ defaultHeaders: providerHeaders(cfg.baseUrl, cfg.sessionId),
112
214
  });
113
215
  clients.set(cfg, c);
114
216
  }
@@ -302,6 +404,11 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
302
404
  };
303
405
  }
304
406
  catch (err) {
407
+ // #150: retry ONCE without the effort field when the endpoint says it does not
408
+ // take it, so a level that a particular model refuses never costs a turn
409
+ if (!signal?.aborted && isEffortUnsupported(err) && effortForRequest(cfg).reasoning_effort) {
410
+ return chat({ ...cfg, reasoningEffort: "" }, messages, tools, signal);
411
+ }
305
412
  const e = err;
306
413
  // #50: our own shape-diagnosis reads as a full sentence but carries no HTTP
307
414
  // status (the request SUCCEEDED — that is the whole problem), so the generic
@@ -425,6 +532,12 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
425
532
  // Everything else — 5xx, an error frame, a mid-stream failure — is the ANSWER,
426
533
  // rethrown untouched so overflow recovery can see it.
427
534
  const status = err?.status;
535
+ // #150: the endpoint refused `reasoning_effort` itself. Retry ONCE without it,
536
+ // so choosing a level never costs a turn — this is what makes the control safe
537
+ // to offer on an endpoint whose capabilities cannot be known in advance.
538
+ if (!signal?.aborted && isEffortUnsupported(err) && effortForRequest(cfg).reasoning_effort) {
539
+ return chatStream({ ...cfg, reasoningEffort: "" }, messages, tools, signal, onDelta);
540
+ }
428
541
  const rejectedByProvider = typeof status === "number" && STREAM_REJECTED_STATUS.has(status);
429
542
  const emptyStream = err?.emptyStream === true;
430
543
  if (!gotChunk && !signal?.aborted && (rejectedByProvider || emptyStream)) {
@@ -11,15 +11,6 @@ export const ProviderSchema = z.object({
11
11
  baseUrl: z.union([z.string().url("baseUrl must be a valid URL"), z.literal("")]).optional(),
12
12
  apiKey: z.string().optional(),
13
13
  model: z.string().optional(),
14
- /**
15
- * Does this endpoint accept `reasoning_effort`? (#150)
16
- *
17
- * Needed for gateways that ACCEPT the field but publish no capability list in
18
- * their /models response, so the gateway-style catalogue check cannot see it.
19
- * Off by default because some endpoints (Google) reject the field with a 400
20
- * rather than ignoring it — an unset field is safer than a wrong one.
21
- */
22
- reasoningEffort: z.boolean().optional(),
23
14
  });
24
15
  export const AgentEntrySchema = z.object({
25
16
  id: z
package/dist/master.js CHANGED
@@ -804,9 +804,8 @@ export class Master {
804
804
  ...(modelMeta?.supportedParameters
805
805
  ? { supportedParameters: modelMeta.supportedParameters }
806
806
  : {}),
807
- // #150: the provider opts in explicitly when its catalogue does not carry
808
- // capability metadata, so the gate above cannot see the support
809
- ...(prov?.reasoningEffort ? { reasoningEffortOptIn: true } : {}),
807
+ // #150: the model's own reasoning levels, so the picker offers exactly those
808
+ ...(modelMeta?.supportedEfforts?.length ? { supportedEfforts: modelMeta.supportedEfforts } : {}),
810
809
  });
811
810
  // pricing metadata (USD/token) for the runtime cost estimate — best
812
811
  // effort: providers without pricing in /models simply show no cost
@@ -1664,11 +1663,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1664
1663
  if (cfgEntry)
1665
1664
  cfgEntry.reasoningEffort = opts.opts.reasoningEffort || undefined;
1666
1665
  }
1667
- opts.opts.supportedParameters = meta?.supportedParameters ?? [];
1668
- // #150: keep the explicit provider opt-in in step with the (possibly new)
1669
- // provider, so switching to an opted-in gateway enables the dropdown at once
1670
- const nowProv = this.config.providers?.[effectiveProvider];
1671
- opts.opts.reasoningEffortOptIn = nowProv?.reasoningEffort === true;
1666
+ // keep `undefined` for "published nothing" — see the note on the option (#150)
1667
+ opts.opts.supportedParameters = meta?.supportedParameters;
1668
+ opts.opts.supportedEfforts = meta?.supportedEfforts;
1672
1669
  if (!llm.model)
1673
1670
  throw new Error("no model resolved (pass model or set one on the provider)");
1674
1671
  if (!llm.baseUrl)
@@ -92,6 +92,16 @@ export async function fetchModelList(baseUrl, apiKey) {
92
92
  supportedParameters: Array.isArray(m.supported_parameters)
93
93
  ? m.supported_parameters.filter((p) => typeof p === "string")
94
94
  : undefined,
95
+ // per-model reasoning levels when published (OpenRouter): the UI offers
96
+ // exactly these, and the request is clamped into them — offering a level the
97
+ // model does not have is a control that does nothing (#150)
98
+ supportedEfforts: Array.isArray(m.reasoning?.supported_efforts)
99
+ ? m.reasoning.supported_efforts.filter((e) => typeof e === "string")
100
+ : undefined,
101
+ ...(typeof m.reasoning?.default_effort === "string"
102
+ ? { defaultEffort: m.reasoning.default_effort }
103
+ : {}),
104
+ ...(m.reasoning?.mandatory === true ? { reasoningMandatory: true } : {}),
95
105
  }))
96
106
  .filter((m) => m.id);
97
107
  cache.set(root, { at: Date.now(), list });
@@ -749,9 +749,6 @@ export function buildApp(master) {
749
749
  baseUrl: p.baseUrl ?? "",
750
750
  apiKey: masked ? prev[name]?.apiKey : p.apiKey,
751
751
  ...(p.model ? { model: p.model } : {}),
752
- // #150: the opt-in must survive a config PUT, or saving anything else
753
- // would silently disable the effort dropdown for that provider
754
- ...(p.reasoningEffort ? { reasoningEffort: true } : {}),
755
752
  };
756
753
  }
757
754
  master.updateConfig({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.29.11",
3
+ "version": "0.29.12",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",