teapot-coding-agent 0.29.10 → 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/README.md +4 -2
- package/dist/agent/agent.js +263 -117
- package/dist/agent/llm.js +154 -21
- package/dist/master.js +5 -1
- package/dist/model-meta.js +10 -0
- package/package.json +1 -1
- package/public/assets/{index-C5nwg8RO.js → index-LiaEnt9Q.js} +6 -6
- package/public/assets/index-LiaEnt9Q.js.map +1 -0
- package/public/assets/{shiki-BSQfUh7j.js → shiki-kbuISzpz.js} +2 -2
- package/public/assets/{shiki-BSQfUh7j.js.map → shiki-kbuISzpz.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-C5nwg8RO.js.map +0 -1
package/dist/agent/llm.js
CHANGED
|
@@ -8,26 +8,84 @@
|
|
|
8
8
|
import OpenAI from "openai";
|
|
9
9
|
const clients = new WeakMap();
|
|
10
10
|
/**
|
|
11
|
-
* Reasoning effort for a request body (#47).
|
|
11
|
+
* Reasoning effort for a request body (#47, #150).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* catalogue says this model advertises `reasoning_effort`. Support is far from
|
|
16
|
-
* universal (verified against the live /models endpoint: 196 of 462 models),
|
|
17
|
-
* so an unsent field is much safer than a wrong one.
|
|
13
|
+
* MEASURED against the live endpoints (one probe each, `max_tokens: 3` with
|
|
14
|
+
* `reasoning_effort: "low"`):
|
|
18
15
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
19
|
+
*
|
|
20
|
+
* and — the fact that decides the design — what each endpoint PUBLISHES in
|
|
21
|
+
* `/models`:
|
|
22
|
+
*
|
|
23
|
+
* openrouter `supported_parameters: [… "reasoning_effort" …]` per model
|
|
24
|
+
* commandcode-goat only `id`, `name`, `context_length`, `supported_endpoints`
|
|
25
|
+
* google no capability list either
|
|
26
|
+
*
|
|
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.
|
|
21
41
|
*/
|
|
22
42
|
export function effortForRequest(cfg, supported = cfg.supportedParameters) {
|
|
23
43
|
const e = cfg.reasoningEffort;
|
|
24
44
|
if (!e || !REASONING_EFFORTS.includes(e))
|
|
25
45
|
return {};
|
|
26
|
-
|
|
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"))
|
|
27
49
|
return {};
|
|
28
|
-
|
|
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))
|
|
29
57
|
return {};
|
|
30
|
-
return { reasoning_effort:
|
|
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");
|
|
31
89
|
}
|
|
32
90
|
function isOpenRouterEndpoint(baseUrl) {
|
|
33
91
|
try {
|
|
@@ -60,21 +118,85 @@ export function cacheControlForRequest(cfg) {
|
|
|
60
118
|
}
|
|
61
119
|
const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
|
|
62
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
|
+
*
|
|
63
172
|
* OpenRouter app attribution (https://openrouter.ai/docs/app-attribution):
|
|
64
173
|
* `HTTP-Referer` is the required identifier that puts teapot's usage on the
|
|
65
174
|
* public rankings/analytics; the title names it; `programming-app` files it
|
|
66
|
-
* under coding tools.
|
|
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.
|
|
67
184
|
*/
|
|
68
|
-
export function providerHeaders(baseUrl) {
|
|
185
|
+
export function providerHeaders(baseUrl, sessionId) {
|
|
69
186
|
try {
|
|
70
187
|
const host = new URL(baseUrl).host;
|
|
71
|
-
if (
|
|
72
|
-
return {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
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 {};
|
|
78
200
|
}
|
|
79
201
|
catch {
|
|
80
202
|
return {}; // unparseable baseUrl — never block a request over metadata
|
|
@@ -88,7 +210,7 @@ function client(cfg) {
|
|
|
88
210
|
apiKey: cfg.apiKey,
|
|
89
211
|
timeout: cfg.timeoutMs ?? 120_000,
|
|
90
212
|
maxRetries: 4, // SDK handles backoff for 429/5xx/network errors
|
|
91
|
-
defaultHeaders: providerHeaders(cfg.baseUrl),
|
|
213
|
+
defaultHeaders: providerHeaders(cfg.baseUrl, cfg.sessionId),
|
|
92
214
|
});
|
|
93
215
|
clients.set(cfg, c);
|
|
94
216
|
}
|
|
@@ -282,6 +404,11 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
|
282
404
|
};
|
|
283
405
|
}
|
|
284
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
|
+
}
|
|
285
412
|
const e = err;
|
|
286
413
|
// #50: our own shape-diagnosis reads as a full sentence but carries no HTTP
|
|
287
414
|
// status (the request SUCCEEDED — that is the whole problem), so the generic
|
|
@@ -405,6 +532,12 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
405
532
|
// Everything else — 5xx, an error frame, a mid-stream failure — is the ANSWER,
|
|
406
533
|
// rethrown untouched so overflow recovery can see it.
|
|
407
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
|
+
}
|
|
408
541
|
const rejectedByProvider = typeof status === "number" && STREAM_REJECTED_STATUS.has(status);
|
|
409
542
|
const emptyStream = err?.emptyStream === true;
|
|
410
543
|
if (!gotChunk && !signal?.aborted && (rejectedByProvider || emptyStream)) {
|
package/dist/master.js
CHANGED
|
@@ -804,6 +804,8 @@ export class Master {
|
|
|
804
804
|
...(modelMeta?.supportedParameters
|
|
805
805
|
? { supportedParameters: modelMeta.supportedParameters }
|
|
806
806
|
: {}),
|
|
807
|
+
// #150: the model's own reasoning levels, so the picker offers exactly those
|
|
808
|
+
...(modelMeta?.supportedEfforts?.length ? { supportedEfforts: modelMeta.supportedEfforts } : {}),
|
|
807
809
|
});
|
|
808
810
|
// pricing metadata (USD/token) for the runtime cost estimate — best
|
|
809
811
|
// effort: providers without pricing in /models simply show no cost
|
|
@@ -1661,7 +1663,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1661
1663
|
if (cfgEntry)
|
|
1662
1664
|
cfgEntry.reasoningEffort = opts.opts.reasoningEffort || undefined;
|
|
1663
1665
|
}
|
|
1664
|
-
|
|
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;
|
|
1665
1669
|
if (!llm.model)
|
|
1666
1670
|
throw new Error("no model resolved (pass model or set one on the provider)");
|
|
1667
1671
|
if (!llm.baseUrl)
|
package/dist/model-meta.js
CHANGED
|
@@ -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 });
|