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/README.md +4 -2
- package/dist/agent/agent.js +262 -118
- package/dist/agent/llm.js +152 -39
- package/dist/config-schema.js +0 -9
- package/dist/master.js +5 -8
- package/dist/model-meta.js +10 -0
- package/dist/server/api.js +0 -3
- package/package.json +1 -1
- package/public/assets/{index-CnA7NDLr.js → index-LiaEnt9Q.js} +5 -5
- package/public/assets/index-LiaEnt9Q.js.map +1 -0
- package/public/assets/{shiki-D0aY9msf.js → shiki-kbuISzpz.js} +2 -2
- package/public/assets/{shiki-D0aY9msf.js.map → shiki-kbuISzpz.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-CnA7NDLr.js.map +0 -1
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
|
|
14
|
-
*
|
|
13
|
+
* MEASURED against the live endpoints (one probe each, `max_tokens: 3` with
|
|
14
|
+
* `reasoning_effort: "low"`):
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
return {
|
|
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.
|
|
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 (
|
|
92
|
-
return {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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)) {
|
package/dist/config-schema.js
CHANGED
|
@@ -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
|
|
808
|
-
|
|
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
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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)
|
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 });
|
package/dist/server/api.js
CHANGED
|
@@ -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({
|