@tiens.nguyen/gu-cli 1.0.731 → 1.0.733
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/add-server.mjs +25 -2
- package/agent-model-command.mjs +13 -8
- package/coding-provider-setup.mjs +48 -0
- package/daemon-control.mjs +42 -1
- package/gonext_agent_chat.py +309 -85
- package/gu-repl.mjs +102 -17
- package/model-doctor.mjs +25 -0
- package/package.json +1 -1
package/add-server.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* server kind ("dialect": an Ollama's /api/tags vs an OpenAI-compatible /v1/models) lives in
|
|
15
15
|
* serverTarget() and listRequest().
|
|
16
16
|
*/
|
|
17
|
-
import { defaultProviderUrl, ensureV1Root, hostLocality, isCancelWord, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
|
|
17
|
+
import { defaultProviderUrl, ensureV1Root, hostLocality, isAnthropicUrl, isCancelWord, modelListRequest, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
|
|
18
18
|
|
|
19
19
|
// Re-exported: the add-server screen's own name for the rule every URL prompt shares.
|
|
20
20
|
export { isCancelWord };
|
|
@@ -112,10 +112,28 @@ export function serverTarget(dialect, typed) {
|
|
|
112
112
|
host: r.host,
|
|
113
113
|
locality: r.locality,
|
|
114
114
|
cloud: false,
|
|
115
|
+
// The Claude API (#261): an OpenAI-compatible chat endpoint whose model LIST is Anthropic's own.
|
|
116
|
+
claude: isAnthropicUrl(r.url),
|
|
115
117
|
roleUrl: () => r.url,
|
|
116
118
|
};
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
/**
|
|
122
|
+
* A server this role cannot use → the lines that say why, or [] when it can (#261). The Claude API
|
|
123
|
+
* serves no embedding models — Anthropic's own docs send embeddings to Voyage AI — so saving it as
|
|
124
|
+
* the embedder would only fail at the first index, several steps into a turn.
|
|
125
|
+
*/
|
|
126
|
+
export function roleRefusal(role, target) {
|
|
127
|
+
if (role === "embed" && target?.claude) {
|
|
128
|
+
return [
|
|
129
|
+
"the Claude API has no embedding models — nothing saved.",
|
|
130
|
+
"Anthropic points to Voyage AI for embeddings (https://api.voyageai.com/v1),",
|
|
131
|
+
"or use an Ollama embedder such as nomic-embed-text.",
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
119
137
|
/**
|
|
120
138
|
* Does this server need a key? "required" | "optional" | "none".
|
|
121
139
|
* · An Ollama on this machine or the LAN answers anonymously — "none", not even asked.
|
|
@@ -129,8 +147,13 @@ export function keyRequirement(target) {
|
|
|
129
147
|
return target.locality === "loopback" || target.locality === "private" ? "optional" : "required";
|
|
130
148
|
}
|
|
131
149
|
|
|
132
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* The request that lists a server's models: its URL and headers. An Ollama's /api/tags takes a
|
|
152
|
+
* Bearer when keyed (the cloud); an OpenAI-compatible root goes through modelListRequest, which
|
|
153
|
+
* knows the Claude API lists with x-api-key + anthropic-version instead (#261).
|
|
154
|
+
*/
|
|
133
155
|
export function listRequest(target, key = "") {
|
|
156
|
+
if (target?.dialect !== "ollama") return modelListRequest(target.root ?? target.url, key);
|
|
134
157
|
const k = String(key ?? "").trim();
|
|
135
158
|
return { url: target.listUrl, headers: k && k !== "local" ? { Authorization: `Bearer ${k}` } : {} };
|
|
136
159
|
}
|
package/agent-model-command.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* empty reads as "there are no models", which is never what a network error means.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { modelRetired, ensureV1Root } from "./coding-provider-setup.mjs";
|
|
20
|
+
import { modelRetired, ensureV1Root, isAnthropicUrl, modelListRequest } from "./coding-provider-setup.mjs";
|
|
21
21
|
import { isOllamaCloudUrl } from "./coding-key-prompt.mjs";
|
|
22
22
|
|
|
23
23
|
|
|
@@ -243,8 +243,9 @@ export function agentListSource(settings) {
|
|
|
243
243
|
(b) => b && ensureV1Root(String(b.url ?? "")).toLowerCase() === want
|
|
244
244
|
);
|
|
245
245
|
if (row && String(row.kind ?? "").trim() === "openai") {
|
|
246
|
-
// The row's url already ends at the /v1 root, so the models endpoint hangs directly off it
|
|
247
|
-
|
|
246
|
+
// The row's url already ends at the /v1 root, so the models endpoint hangs directly off it
|
|
247
|
+
// (with the Claude API's page size — #261, see modelListRequest).
|
|
248
|
+
return { url: modelListRequest(ensureV1Root(String(row.url ?? ""))).url, dialect: "openai", row };
|
|
248
249
|
}
|
|
249
250
|
return { url: agentTagsUrl(settings), dialect: "ollama", row: row ?? null };
|
|
250
251
|
}
|
|
@@ -277,8 +278,12 @@ export function agentSourceNeedsKey(source) {
|
|
|
277
278
|
return source.dialect === "openai" || isOllamaCloudUrl(String(row.url ?? ""));
|
|
278
279
|
}
|
|
279
280
|
|
|
280
|
-
/**
|
|
281
|
-
|
|
281
|
+
/**
|
|
282
|
+
* The headers a model-list request needs — the bearer only when there is a key to send. The Claude
|
|
283
|
+
* API lists with x-api-key + anthropic-version instead (#261), so the URL is passed too.
|
|
284
|
+
*/
|
|
285
|
+
export function agentListHeaders(key, url = "") {
|
|
286
|
+
if (isAnthropicUrl(url)) return modelListRequest(url.replace(/\/models(\?.*)?$/, ""), key).headers;
|
|
282
287
|
const k = String(key ?? "").trim();
|
|
283
288
|
return k ? { Authorization: `Bearer ${k}` } : {};
|
|
284
289
|
}
|
|
@@ -346,7 +351,7 @@ export function agentRecoveryOptions({ keyProblem = false, host = "" } = {}) {
|
|
|
346
351
|
{ action: "leave", label: "leave it" },
|
|
347
352
|
...(keyProblem ? [{ action: "key", label: `enter the API key for ${host || "this provider"}…` }] : []),
|
|
348
353
|
{ action: "ollama", label: "use an Ollama server for the agent…" },
|
|
349
|
-
{ action: "provider", label: "add
|
|
354
|
+
{ action: "provider", label: "add a provider for the agent — Claude API, OpenAI, Kimi…" },
|
|
350
355
|
];
|
|
351
356
|
}
|
|
352
357
|
|
|
@@ -438,7 +443,7 @@ export function agentMenuOptions({ host = "", modelCount = 0, needsKey = false }
|
|
|
438
443
|
return [
|
|
439
444
|
{ action: "models", label: `pick another model on ${where}${modelCount ? ` (${modelCount})` : ""}` },
|
|
440
445
|
{ action: "ollama", label: "use an Ollama server for the agent…" },
|
|
441
|
-
{ action: "provider", label: "use another OpenAI
|
|
446
|
+
{ action: "provider", label: "use another provider — Claude API, OpenAI, Kimi…" },
|
|
442
447
|
// Task #245: the SAME key manager /model's ctrl+k and /embed-model open — switch, add, make
|
|
443
448
|
// default, delete — for this server's keys only.
|
|
444
449
|
...(needsKey ? [{ action: "key", label: `API keys for ${where} — switch, add or remove…` }] : []),
|
|
@@ -465,7 +470,7 @@ export function agentProviderChoices(settings) {
|
|
|
465
470
|
const isCurrent = ensureV1Root(String(row.url)).toLowerCase() === current;
|
|
466
471
|
out.push({ action: "row", row, label: `${host}${isCurrent ? " (current)" : ""}` });
|
|
467
472
|
}
|
|
468
|
-
out.push({ action: "add", label: "add a new OpenAI
|
|
473
|
+
out.push({ action: "add", label: "add a new provider — Claude API, OpenAI, Kimi…" });
|
|
469
474
|
return out;
|
|
470
475
|
}
|
|
471
476
|
|
|
@@ -198,6 +198,54 @@ export function modelsUrlFor(url) {
|
|
|
198
198
|
return `${String(url ?? "").replace(/\/+$/, "")}/models`;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* THE CLAUDE API (task #261). Chat goes through Anthropic's OpenAI-compatible endpoint — base
|
|
203
|
+
* https://api.anthropic.com/v1, the key as the usual Bearer — so a Claude row is an ordinary
|
|
204
|
+
* "openai" row everywhere a turn runs. Only LISTING differs (Anthropic docs, api/models/list):
|
|
205
|
+
* GET /v1/models takes `x-api-key` + `anthropic-version`, not a Bearer, and pages (default 20,
|
|
206
|
+
* max 1000). Decided by the host, which is Anthropic's own API — not a guess from a name.
|
|
207
|
+
*/
|
|
208
|
+
export const CLAUDE_API_URL = "https://api.anthropic.com/v1";
|
|
209
|
+
export const ANTHROPIC_VERSION = "2023-06-01";
|
|
210
|
+
|
|
211
|
+
export function isAnthropicUrl(url) {
|
|
212
|
+
try {
|
|
213
|
+
return new URL(String(url ?? "")).hostname.toLowerCase().replace(/\.$/, "") === "api.anthropic.com";
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The request that lists a /v1 root's models → { url, headers }. Anthropic: its two headers and
|
|
221
|
+
* one page of up to 1000 (it lists a few dozen). Everyone else: a Bearer only when keyed — "local"
|
|
222
|
+
* is the placeholder a keyless local server is given, never a key.
|
|
223
|
+
*/
|
|
224
|
+
export function modelListRequest(url, key = "") {
|
|
225
|
+
const k = String(key ?? "").trim();
|
|
226
|
+
const keyed = Boolean(k) && k !== "local";
|
|
227
|
+
if (isAnthropicUrl(url)) {
|
|
228
|
+
return {
|
|
229
|
+
url: `${modelsUrlFor(url)}?limit=1000`,
|
|
230
|
+
headers: { "anthropic-version": ANTHROPIC_VERSION, ...(keyed ? { "x-api-key": k } : {}) },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return { url: modelsUrlFor(url), headers: keyed ? { Authorization: `Bearer ${k}` } : {} };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The hosted providers offered by name when adding one (#261), before "another URL". Claude has
|
|
238
|
+
* no embedding models (Anthropic points to Voyage AI), so it is not offered for the embedder.
|
|
239
|
+
*/
|
|
240
|
+
export function providerPresets(role = "coding") {
|
|
241
|
+
const all = [
|
|
242
|
+
{ name: "Claude API (Anthropic)", url: CLAUDE_API_URL, roles: ["coding", "agent"] },
|
|
243
|
+
{ name: "OpenAI", url: DEFAULT_PROVIDER_URL, roles: ["coding", "agent", "embed"] },
|
|
244
|
+
{ name: "Kimi (Moonshot)", url: "https://api.moonshot.ai/v1", roles: ["coding", "agent"] },
|
|
245
|
+
];
|
|
246
|
+
return all.filter((p) => p.roles.includes(role));
|
|
247
|
+
}
|
|
248
|
+
|
|
201
249
|
/** mlx_lm's placeholder, which is a mapping and not a model anyone can pick. */
|
|
202
250
|
export const PLACEHOLDER_MODEL = "default_model";
|
|
203
251
|
|
package/daemon-control.mjs
CHANGED
|
@@ -321,8 +321,49 @@ export async function readBusyState() {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* WHOSE DAEMON IS IT (task #257)? The `ps` scan sees every gu worker on the machine, but a worker
|
|
326
|
+
* belongs to ONE ~/.gonext: it polls that home's API with that home's key. Counting another
|
|
327
|
+
* home's worker as ours meant gu never started its own, and every turn sat "queued — waiting for
|
|
328
|
+
* the worker". Ours = OUR pidfile names it, or its HOME is ours. HOME unknown (Windows, or not
|
|
329
|
+
* readable) → counted, exactly as before: a second poller on a guess is the worse mistake.
|
|
330
|
+
*/
|
|
331
|
+
export function ownsDaemon({ pid, pidFilePid, home, myHome = homedir() }) {
|
|
332
|
+
if (pidFilePid != null && pid === pidFilePid) return true;
|
|
333
|
+
if (home == null) return true;
|
|
334
|
+
const norm = (p) => String(p).replace(/[\\/]+$/, "");
|
|
335
|
+
return norm(home) === norm(myHome);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The HOME a process runs under → a path, or null when it cannot be read (Windows, gone, denied). */
|
|
339
|
+
export async function daemonHome(pid) {
|
|
340
|
+
if (!pid || !Number.isFinite(pid)) return null;
|
|
341
|
+
if (process.platform === "linux") {
|
|
342
|
+
try {
|
|
343
|
+
const env = await readFile(join("/proc", String(pid), "environ"), "utf8");
|
|
344
|
+
const hit = env.split("\0").find((kv) => kv.startsWith("HOME="));
|
|
345
|
+
return hit ? hit.slice(5) : null;
|
|
346
|
+
} catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (process.platform !== "darwin") return null;
|
|
351
|
+
// macOS: `ps eww` appends the environment to the command line (own processes only).
|
|
352
|
+
return new Promise((resolve) => {
|
|
353
|
+
execFile("ps", ["eww", "-o", "command=", "-p", String(pid)], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
|
354
|
+
if (err) return resolve(null);
|
|
355
|
+
const m = / HOME=(\S+)/.exec(String(stdout || ""));
|
|
356
|
+
resolve(m ? m[1] : null);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
324
361
|
export async function daemonStatus() {
|
|
325
|
-
const [
|
|
362
|
+
const [found, pidFilePid] = await Promise.all([findRunningDaemonPids(), readPidFile()]);
|
|
363
|
+
const pids = [];
|
|
364
|
+
for (const pid of found) {
|
|
365
|
+
if (ownsDaemon({ pid, pidFilePid, home: await daemonHome(pid) })) pids.push(pid);
|
|
366
|
+
}
|
|
326
367
|
const pidFileIsDaemon =
|
|
327
368
|
pidFilePid != null && (pids.includes(pidFilePid) || (await pidIsGuDaemon(pidFilePid)));
|
|
328
369
|
const allPids = pidFileIsDaemon && !pids.includes(pidFilePid) ? [...pids, pidFilePid] : pids;
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1013,16 +1013,229 @@ def _host_of(url) -> str:
|
|
|
1013
1013
|
return str(url or "")
|
|
1014
1014
|
|
|
1015
1015
|
|
|
1016
|
+
def _is_anthropic_url(url: str) -> bool:
|
|
1017
|
+
"""The Claude API (task #261): OpenAI-compatible chat, but its /v1/models takes x-api-key +
|
|
1018
|
+
anthropic-version rather than a Bearer, and pages. Decided by Anthropic's own host."""
|
|
1019
|
+
from urllib.parse import urlparse
|
|
1020
|
+
return (urlparse(url or "").hostname or "").lower().rstrip(".") == "api.anthropic.com"
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _model_list_request(base_url: str, api_key: str = ""):
|
|
1024
|
+
"""(url, headers) that list the models of a /v1 root — same rule as the terminal's
|
|
1025
|
+
modelListRequest (coding-provider-setup.mjs)."""
|
|
1026
|
+
url = (base_url or "").rstrip("/") + "/models"
|
|
1027
|
+
headers = {"Accept": "application/json"}
|
|
1028
|
+
keyed = bool(api_key) and api_key != "local"
|
|
1029
|
+
if _is_anthropic_url(base_url):
|
|
1030
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
1031
|
+
if keyed:
|
|
1032
|
+
headers["x-api-key"] = api_key
|
|
1033
|
+
return url + "?limit=1000", headers
|
|
1034
|
+
if keyed:
|
|
1035
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
1036
|
+
return url, headers
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
# ---- the Claude API, natively, for prompt caching (task #262) ----------------------------------
|
|
1040
|
+
#
|
|
1041
|
+
# Anthropic's OpenAI-compatible endpoint (#261) does NOT cache: every agent step re-sent the whole
|
|
1042
|
+
# prompt at full price, and measured over 1,308 steps 96.4% of what a step reads is repetition. The
|
|
1043
|
+
# native Messages API caches a PREFIX, but only where the request marks it. So the coding model on
|
|
1044
|
+
# api.anthropic.com talks to the native API through this adapter, which speaks the one interface the
|
|
1045
|
+
# rest of the agent already uses — client.chat.completions.create(), streamed or not, returning
|
|
1046
|
+
# OpenAI-shaped chunks — so streaming, the runaway guards, token counts and the cache counters work
|
|
1047
|
+
# unchanged. Our prompt order is already stable-first (#86); the adapter only marks it:
|
|
1048
|
+
# 1. the system prompt (the scaffold — identical for the whole gu session);
|
|
1049
|
+
# 2. the end of the STATIC tool reference inside the first user message, split off the volatile
|
|
1050
|
+
# tail at "----- YOUR TASK -----" (date, deploy target, folder overview, the task);
|
|
1051
|
+
# 3. top-level automatic caching for the step history that grows inside a turn.
|
|
1052
|
+
# Default 5-minute TTL: steps inside a turn are well under 5 minutes apart.
|
|
1053
|
+
|
|
1054
|
+
_TASK_SPLIT_MARKER = "\n----- YOUR TASK -----\n"
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _anthropic_image_block(url: str):
|
|
1058
|
+
"""An OpenAI image_url → an Anthropic image block (data: URLs as base64, else by URL)."""
|
|
1059
|
+
m = re.match(r"^data:([^;,]+);base64,(.*)$", url or "", re.S)
|
|
1060
|
+
if m:
|
|
1061
|
+
return {"type": "image", "source": {"type": "base64", "media_type": m.group(1), "data": m.group(2)}}
|
|
1062
|
+
return {"type": "image", "source": {"type": "url", "url": url}}
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _anthropic_blocks(content) -> list:
|
|
1066
|
+
"""OpenAI message content (a string or a list of parts) → Anthropic content blocks.
|
|
1067
|
+
Empty text is dropped: the Messages API rejects an empty text block."""
|
|
1068
|
+
if isinstance(content, str):
|
|
1069
|
+
return [{"type": "text", "text": content}] if content.strip() else []
|
|
1070
|
+
out = []
|
|
1071
|
+
for part in content or []:
|
|
1072
|
+
if not isinstance(part, dict):
|
|
1073
|
+
continue
|
|
1074
|
+
if part.get("type") == "text" and str(part.get("text") or "").strip():
|
|
1075
|
+
out.append({"type": "text", "text": str(part["text"])})
|
|
1076
|
+
elif part.get("type") == "image_url":
|
|
1077
|
+
url = (part.get("image_url") or {}).get("url") if isinstance(part.get("image_url"), dict) else part.get("image_url")
|
|
1078
|
+
if url:
|
|
1079
|
+
out.append(_anthropic_image_block(str(url)))
|
|
1080
|
+
return out
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _anthropic_request(kw: dict) -> dict:
|
|
1084
|
+
"""OpenAI chat.completions kwargs → Anthropic messages.create kwargs, with the cache marks."""
|
|
1085
|
+
ephemeral = {"type": "ephemeral"}
|
|
1086
|
+
system_blocks, messages = [], []
|
|
1087
|
+
for m in kw.get("messages") or []:
|
|
1088
|
+
role = m.get("role") if isinstance(m, dict) else getattr(m, "role", "")
|
|
1089
|
+
content = m.get("content") if isinstance(m, dict) else getattr(m, "content", "")
|
|
1090
|
+
role = str(getattr(role, "value", role) or "")
|
|
1091
|
+
blocks = _anthropic_blocks(content)
|
|
1092
|
+
if not blocks:
|
|
1093
|
+
continue
|
|
1094
|
+
if role in ("system", "developer"):
|
|
1095
|
+
system_blocks.extend(b for b in blocks if b["type"] == "text")
|
|
1096
|
+
continue
|
|
1097
|
+
role = "assistant" if role == "assistant" else "user"
|
|
1098
|
+
if messages and messages[-1]["role"] == role:
|
|
1099
|
+
messages[-1]["content"].extend(blocks) # the API would merge them anyway; be explicit
|
|
1100
|
+
else:
|
|
1101
|
+
messages.append({"role": role, "content": blocks})
|
|
1102
|
+
if not messages or messages[0]["role"] != "user":
|
|
1103
|
+
messages.insert(0, {"role": "user", "content": [{"type": "text", "text": "(start)"}]})
|
|
1104
|
+
# No assistant prefill: current Claude models reject a conversation that ENDS on the assistant.
|
|
1105
|
+
if messages[-1]["role"] == "assistant":
|
|
1106
|
+
messages.append({"role": "user", "content": [{"type": "text", "text": "Continue."}]})
|
|
1107
|
+
|
|
1108
|
+
# Mark 2: the static reference at the head of the first user message.
|
|
1109
|
+
first = messages[0]["content"]
|
|
1110
|
+
for i, b in enumerate(first):
|
|
1111
|
+
if b["type"] == "text" and _TASK_SPLIT_MARKER in b["text"]:
|
|
1112
|
+
head, tail = b["text"].split(_TASK_SPLIT_MARKER, 1)
|
|
1113
|
+
first[i:i + 1] = [
|
|
1114
|
+
{"type": "text", "text": head, "cache_control": ephemeral},
|
|
1115
|
+
{"type": "text", "text": _TASK_SPLIT_MARKER.lstrip("\n") + tail},
|
|
1116
|
+
]
|
|
1117
|
+
break
|
|
1118
|
+
# Mark 1: the system prompt.
|
|
1119
|
+
if system_blocks:
|
|
1120
|
+
system_blocks[-1] = {**system_blocks[-1], "cache_control": ephemeral}
|
|
1121
|
+
|
|
1122
|
+
stops = [s for s in (kw.get("stop") or []) if isinstance(s, str) and s.strip()]
|
|
1123
|
+
req = {
|
|
1124
|
+
"model": kw.get("model"),
|
|
1125
|
+
"max_tokens": int(kw.get("max_tokens") or kw.get("max_completion_tokens") or 32000),
|
|
1126
|
+
"messages": messages,
|
|
1127
|
+
# Mark 3: automatic caching for the growing tail (the step history).
|
|
1128
|
+
"cache_control": ephemeral,
|
|
1129
|
+
}
|
|
1130
|
+
if system_blocks:
|
|
1131
|
+
req["system"] = system_blocks
|
|
1132
|
+
if stops:
|
|
1133
|
+
req["stop_sequences"] = stops[:8]
|
|
1134
|
+
# NO temperature / top_p: current Claude models reject sampling parameters with a 400, and
|
|
1135
|
+
# the thinking / effort settings are left at the model's defaults so they never vary per
|
|
1136
|
+
# request (a change there invalidates the cache).
|
|
1137
|
+
return req
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
_ANTHROPIC_FINISH = {"end_turn": "stop", "stop_sequence": "stop", "max_tokens": "length",
|
|
1141
|
+
"refusal": "stop", "pause_turn": "stop", "tool_use": "tool_calls"}
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _anthropic_usage(u) -> "types.SimpleNamespace":
|
|
1145
|
+
"""Anthropic usage → the OpenAI-shaped usage the agent reads. prompt_tokens is the WHOLE prompt
|
|
1146
|
+
(input_tokens is only the uncached remainder), and the cache counts ride along under their own
|
|
1147
|
+
names — _cached_tokens_from_usage already reads cache_read_input_tokens."""
|
|
1148
|
+
import types
|
|
1149
|
+
g = (lambda n: int(_usage_field(u, n) or 0))
|
|
1150
|
+
cr, cw, inp = g("cache_read_input_tokens"), g("cache_creation_input_tokens"), g("input_tokens")
|
|
1151
|
+
return types.SimpleNamespace(prompt_tokens=inp + cr + cw, completion_tokens=g("output_tokens"),
|
|
1152
|
+
cache_read_input_tokens=cr, cache_creation_input_tokens=cw)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
class _AnthropicChatAdapter:
|
|
1156
|
+
"""client.chat.completions.create() backed by Anthropic's native Messages API (task #262)."""
|
|
1157
|
+
|
|
1158
|
+
def __init__(self, api_key: str = "", base_url: str = "", timeout=None, sdk_client=None):
|
|
1159
|
+
if sdk_client is None:
|
|
1160
|
+
import anthropic # the caller checked it imports
|
|
1161
|
+
kwargs = {"api_key": api_key or None, "max_retries": 0}
|
|
1162
|
+
root = re.sub(r"/v1/?$", "", (base_url or "").rstrip("/"))
|
|
1163
|
+
if root:
|
|
1164
|
+
kwargs["base_url"] = root
|
|
1165
|
+
if timeout:
|
|
1166
|
+
kwargs["timeout"] = timeout
|
|
1167
|
+
if _PROXY_URL:
|
|
1168
|
+
kwargs["http_client"] = anthropic.DefaultHttpxClient(proxy=_PROXY_URL)
|
|
1169
|
+
sdk_client = anthropic.Anthropic(**kwargs)
|
|
1170
|
+
self._sdk = sdk_client
|
|
1171
|
+
import types
|
|
1172
|
+
self.chat = types.SimpleNamespace(completions=types.SimpleNamespace(create=self._create))
|
|
1173
|
+
|
|
1174
|
+
def _create(self, **kw):
|
|
1175
|
+
req = _anthropic_request(kw)
|
|
1176
|
+
if kw.get("stream"):
|
|
1177
|
+
return self._stream(req)
|
|
1178
|
+
msg = self._sdk.messages.create(**req)
|
|
1179
|
+
import types
|
|
1180
|
+
text = "".join(getattr(b, "text", "") or "" for b in (msg.content or []) if getattr(b, "type", "") == "text")
|
|
1181
|
+
return types.SimpleNamespace(
|
|
1182
|
+
choices=[types.SimpleNamespace(
|
|
1183
|
+
message=types.SimpleNamespace(role="assistant", content=text, tool_calls=None),
|
|
1184
|
+
finish_reason=_ANTHROPIC_FINISH.get(getattr(msg, "stop_reason", "") or "", "stop"))],
|
|
1185
|
+
usage=_anthropic_usage(getattr(msg, "usage", None)))
|
|
1186
|
+
|
|
1187
|
+
def _stream(self, req):
|
|
1188
|
+
import types
|
|
1189
|
+
|
|
1190
|
+
def chunk(content=None, reasoning=None, finish=None, usage=None):
|
|
1191
|
+
delta = types.SimpleNamespace(content=content, reasoning=reasoning, reasoning_content=None,
|
|
1192
|
+
role=None, tool_calls=None)
|
|
1193
|
+
choices = [] if (content is None and reasoning is None and finish is None) else [
|
|
1194
|
+
types.SimpleNamespace(delta=delta, finish_reason=finish, index=0)]
|
|
1195
|
+
return types.SimpleNamespace(choices=choices, usage=usage)
|
|
1196
|
+
|
|
1197
|
+
usage = {}
|
|
1198
|
+
stop_reason = ""
|
|
1199
|
+
with self._sdk.messages.stream(**req) as events:
|
|
1200
|
+
for ev in events:
|
|
1201
|
+
et = getattr(ev, "type", "")
|
|
1202
|
+
if et == "message_start":
|
|
1203
|
+
u = getattr(getattr(ev, "message", None), "usage", None)
|
|
1204
|
+
for n in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "output_tokens"):
|
|
1205
|
+
usage[n] = int(_usage_field(u, n) or 0)
|
|
1206
|
+
elif et == "content_block_delta":
|
|
1207
|
+
d = getattr(ev, "delta", None)
|
|
1208
|
+
dt = getattr(d, "type", "")
|
|
1209
|
+
if dt == "text_delta" and getattr(d, "text", ""):
|
|
1210
|
+
yield chunk(content=d.text)
|
|
1211
|
+
elif dt == "thinking_delta" and getattr(d, "thinking", ""):
|
|
1212
|
+
yield chunk(reasoning=d.thinking)
|
|
1213
|
+
elif et == "message_delta":
|
|
1214
|
+
stop_reason = getattr(getattr(ev, "delta", None), "stop_reason", "") or stop_reason
|
|
1215
|
+
out = _usage_field(getattr(ev, "usage", None), "output_tokens")
|
|
1216
|
+
if out is not None:
|
|
1217
|
+
usage["output_tokens"] = int(out)
|
|
1218
|
+
if stop_reason == "refusal":
|
|
1219
|
+
_log("claude: the model declined this request (stop_reason=refusal)")
|
|
1220
|
+
yield chunk(content="", finish=_ANTHROPIC_FINISH.get(stop_reason, "stop"))
|
|
1221
|
+
yield chunk(usage=_anthropic_usage(usage))
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _anthropic_sdk_available() -> bool:
|
|
1225
|
+
try:
|
|
1226
|
+
import anthropic # noqa: F401
|
|
1227
|
+
return True
|
|
1228
|
+
except Exception: # noqa: BLE001
|
|
1229
|
+
return False
|
|
1230
|
+
|
|
1231
|
+
|
|
1016
1232
|
def _list_model_ids(base_url, api_key=""):
|
|
1017
1233
|
"""Return the model ids an OpenAI-compatible server reports at {base_url}/models.
|
|
1018
1234
|
|
|
1019
1235
|
`base_url` already ends with /v1. Returns [] on any failure so callers can
|
|
1020
1236
|
fall back.
|
|
1021
1237
|
"""
|
|
1022
|
-
url = base_url
|
|
1023
|
-
headers = {"Accept": "application/json"}
|
|
1024
|
-
if api_key and api_key != "local":
|
|
1025
|
-
headers["Authorization"] = f"Bearer {api_key}"
|
|
1238
|
+
url, headers = _model_list_request(base_url, api_key)
|
|
1026
1239
|
req = urllib.request.Request(url, method="GET", headers=headers)
|
|
1027
1240
|
try:
|
|
1028
1241
|
with urllib.request.urlopen(req, timeout=10, context=_ssl_context_insecure()) as resp:
|
|
@@ -2880,7 +3093,7 @@ def _provider_reason(err, limit: int = 180) -> str:
|
|
|
2880
3093
|
_CTX_WINDOW_CACHE: dict = {}
|
|
2881
3094
|
|
|
2882
3095
|
|
|
2883
|
-
def _model_context_window(base_url: str, model_id: str) -> int:
|
|
3096
|
+
def _model_context_window(base_url: str, model_id: str, api_key: str = "") -> int:
|
|
2884
3097
|
"""The coding model's REAL context window, or 0 when it cannot be known.
|
|
2885
3098
|
|
|
2886
3099
|
Ollama knows this exactly and will tell us for free (no GPU work): /api/show returns the
|
|
@@ -2946,14 +3159,17 @@ def _model_context_window(base_url: str, model_id: str) -> int:
|
|
|
2946
3159
|
# say keeps win at 0 and the caller falls back with its eyes open.
|
|
2947
3160
|
if win <= 0:
|
|
2948
3161
|
try:
|
|
2949
|
-
|
|
3162
|
+
list_url, list_headers = _model_list_request(f"{root}/v1", api_key)
|
|
3163
|
+
req = urllib.request.Request(list_url, method="GET", headers=list_headers)
|
|
2950
3164
|
with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as resp:
|
|
2951
3165
|
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
2952
3166
|
for row in (data.get("data") or []):
|
|
2953
3167
|
if str(row.get("id") or "") != str(model_id):
|
|
2954
3168
|
continue
|
|
3169
|
+
# max_input_tokens: the Claude API's own field (#261) — its input cap, which is
|
|
3170
|
+
# exactly the number this protects (see _KNOWN_CTX_WINDOWS on why input, not window).
|
|
2955
3171
|
for field in ("max_model_len", "context_length", "context_window",
|
|
2956
|
-
"max_context_length"):
|
|
3172
|
+
"max_context_length", "max_input_tokens"):
|
|
2957
3173
|
v = row.get(field)
|
|
2958
3174
|
if isinstance(v, int) and v > 4096:
|
|
2959
3175
|
win = v
|
|
@@ -3042,6 +3258,11 @@ _KNOWN_CTX_WINDOWS: list = [
|
|
|
3042
3258
|
# Kimi K3's own README states 1048576. An earlier version of this table said 256,000 — off by
|
|
3043
3259
|
# a factor of four, on the model this feature was built for.
|
|
3044
3260
|
(r"^kimi-k3", 1_048_576, "https://github.com/MoonshotAI/Kimi-K3/blob/main/README.md"),
|
|
3261
|
+
# Claude (#261). The Models API reports max_input_tokens itself (read above), so these only
|
|
3262
|
+
# matter when the list cannot be read. Haiku 4.5 first: the general row would give it 1M.
|
|
3263
|
+
(r"^claude-haiku-4-5", 200_000, "https://platform.claude.com/docs/en/about-claude/models/overview"),
|
|
3264
|
+
(r"^claude-(opus|sonnet|fable|mythos)-(5|4-[678])", 1_000_000,
|
|
3265
|
+
"https://platform.claude.com/docs/en/about-claude/models/overview"),
|
|
3045
3266
|
(r"^kimi-k2", 256_000, "https://platform.kimi.ai/docs/api/chat.en-US"),
|
|
3046
3267
|
(r"^kimi-k1", 128_000, "unverified — no vendor page found; corrected on first refusal"),
|
|
3047
3268
|
# These three state their size in the name, which is its own citation.
|
|
@@ -4160,64 +4381,6 @@ def _ssl_context():
|
|
|
4160
4381
|
return ssl.create_default_context()
|
|
4161
4382
|
|
|
4162
4383
|
|
|
4163
|
-
def _pdf_upload_via_api(api_base: str, worker_key: str, file_name: str,
|
|
4164
|
-
pdf_bytes: bytes) -> str:
|
|
4165
|
-
"""Ask the API to presign an S3 upload, PUT the PDF bytes, return the download URL.
|
|
4166
|
-
|
|
4167
|
-
Keeps all AWS credentials on the API/Lambda — the worker only holds its worker key.
|
|
4168
|
-
"""
|
|
4169
|
-
import urllib.request as _u
|
|
4170
|
-
import urllib.error as _ue
|
|
4171
|
-
|
|
4172
|
-
ctx = _ssl_context()
|
|
4173
|
-
base = (api_base or "").rstrip("/")
|
|
4174
|
-
if not base or not worker_key:
|
|
4175
|
-
raise RuntimeError("PDF upload is not available: worker API base/key missing.")
|
|
4176
|
-
|
|
4177
|
-
# 1) Presign.
|
|
4178
|
-
req = _u.Request(
|
|
4179
|
-
f"{base}/api/worker/pdf-upload-url",
|
|
4180
|
-
data=json.dumps({"fileName": file_name}).encode("utf-8"),
|
|
4181
|
-
headers={
|
|
4182
|
-
"Content-Type": "application/json",
|
|
4183
|
-
"X-Worker-Key": worker_key,
|
|
4184
|
-
},
|
|
4185
|
-
method="POST",
|
|
4186
|
-
)
|
|
4187
|
-
try:
|
|
4188
|
-
with _u.urlopen(req, timeout=30, context=ctx) as resp:
|
|
4189
|
-
ref = json.loads(resp.read().decode("utf-8"))
|
|
4190
|
-
except _ue.HTTPError as e: # noqa: PERF203
|
|
4191
|
-
detail = e.read().decode("utf-8", "replace")[:300]
|
|
4192
|
-
raise RuntimeError(f"Could not get a PDF upload URL (HTTP {e.code}): {detail}")
|
|
4193
|
-
except _ue.URLError as e:
|
|
4194
|
-
raise RuntimeError(
|
|
4195
|
-
f"Could not reach the API at {base} to presign the upload "
|
|
4196
|
-
f"({getattr(e, 'reason', e)}). Is the worker API deployed/online?"
|
|
4197
|
-
)
|
|
4198
|
-
put_url = ref.get("putUrl")
|
|
4199
|
-
get_url = ref.get("getUrl")
|
|
4200
|
-
if not put_url or not get_url:
|
|
4201
|
-
raise RuntimeError("PDF upload URL response was incomplete.")
|
|
4202
|
-
|
|
4203
|
-
# 2) Upload the bytes to the presigned PUT URL.
|
|
4204
|
-
put_req = _u.Request(
|
|
4205
|
-
put_url, data=pdf_bytes,
|
|
4206
|
-
headers={"Content-Type": "application/pdf"},
|
|
4207
|
-
method="PUT",
|
|
4208
|
-
)
|
|
4209
|
-
try:
|
|
4210
|
-
with _u.urlopen(put_req, timeout=60, context=ctx) as up:
|
|
4211
|
-
if up.status not in (200, 201, 204):
|
|
4212
|
-
raise RuntimeError(f"S3 upload failed (HTTP {up.status}).")
|
|
4213
|
-
except _ue.HTTPError as e: # noqa: PERF203
|
|
4214
|
-
detail = e.read().decode("utf-8", "replace")[:200]
|
|
4215
|
-
raise RuntimeError(f"S3 upload failed (HTTP {e.code}): {detail}")
|
|
4216
|
-
except _ue.URLError as e:
|
|
4217
|
-
raise RuntimeError(f"S3 upload could not connect ({getattr(e, 'reason', e)}).")
|
|
4218
|
-
return get_url
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
4384
|
# Generic download support (task #102) — deliver ANY workspace file/folder as a download.
|
|
4222
4385
|
_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024 # 100 MB cap on what we'll upload for download
|
|
4223
4386
|
|
|
@@ -4249,8 +4412,20 @@ def _artifact_local_path(file_name: str) -> str:
|
|
|
4249
4412
|
return candidate
|
|
4250
4413
|
|
|
4251
4414
|
|
|
4415
|
+
def _save_artifact_locally(file_name: str, data: bytes, summary: str, why: str = "") -> str:
|
|
4416
|
+
dest = _artifact_local_path(file_name)
|
|
4417
|
+
with open(dest, "wb") as fh:
|
|
4418
|
+
fh.write(data)
|
|
4419
|
+
_emit({"type": "step", "text": f"Saved → {dest}"})
|
|
4420
|
+
_log(f"artifact saved locally path={_c(dest)} bytes={len(data)}")
|
|
4421
|
+
return (f"✅ \"{os.path.basename(dest)}\" is ready ({summary}).\n"
|
|
4422
|
+
+ (f"{why}\n" if why else "")
|
|
4423
|
+
+ f"Saved on this machine:\n{dest}")
|
|
4424
|
+
|
|
4425
|
+
|
|
4252
4426
|
def _deliver_artifact(file_name: str, data: bytes, content_type: str, summary: str,
|
|
4253
|
-
*, local: bool, api_base: str = "", worker_key: str = ""
|
|
4427
|
+
*, local: bool, api_base: str = "", worker_key: str = "",
|
|
4428
|
+
ask_s3_setup=None) -> str:
|
|
4254
4429
|
"""Hand a finished artifact to the user — as a FILE ON THIS MACHINE, or as a link.
|
|
4255
4430
|
|
|
4256
4431
|
LOCAL IS THE DEFAULT IN CLIENT EXPERT and it is not a degraded fallback: when the user is
|
|
@@ -4262,14 +4437,26 @@ def _deliver_artifact(file_name: str, data: bytes, content_type: str, summary: s
|
|
|
4262
4437
|
the delivery itself.
|
|
4263
4438
|
"""
|
|
4264
4439
|
if local:
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4440
|
+
return _save_artifact_locally(file_name, data, summary)
|
|
4441
|
+
# PRIVATE MODE WITHOUT THE USER'S OWN S3 (task #252): the API says so instead of uploading to
|
|
4442
|
+
# anyone else's bucket. Ask whether to set S3 up now; otherwise the file stays on this computer.
|
|
4443
|
+
try:
|
|
4444
|
+
url = _artifact_upload_via_api(api_base, worker_key, file_name, data, content_type)
|
|
4445
|
+
except _S3NotSetUp:
|
|
4446
|
+
chose = False
|
|
4447
|
+
try:
|
|
4448
|
+
chose = bool(ask_s3_setup and ask_s3_setup())
|
|
4449
|
+
except Exception as e: # noqa: BLE001 — asking must never lose the file
|
|
4450
|
+
_log(f"s3 setup question failed: {type(e).__name__}")
|
|
4451
|
+
if not chose:
|
|
4452
|
+
return _save_artifact_locally(
|
|
4453
|
+
file_name, data, summary,
|
|
4454
|
+
"S3 is not set up, so it was not uploaded anywhere (run /s3 in gu to use your own bucket).")
|
|
4455
|
+
try:
|
|
4456
|
+
url = _artifact_upload_via_api(api_base, worker_key, file_name, data, content_type)
|
|
4457
|
+
except _S3NotSetUp:
|
|
4458
|
+
return _save_artifact_locally(
|
|
4459
|
+
file_name, data, summary, "S3 is still not set up, so it was not uploaded anywhere.")
|
|
4273
4460
|
_emit({"type": "step", "text": f"Download ready → {file_name}"})
|
|
4274
4461
|
_log(f"artifact uploaded name={file_name!r} bytes={len(data)}")
|
|
4275
4462
|
return (f"✅ \"{file_name}\" is ready ({summary}).\n"
|
|
@@ -4292,10 +4479,15 @@ def _content_type_for(name: str) -> str:
|
|
|
4292
4479
|
return _DOWNLOAD_CONTENT_TYPES.get(ext, "application/octet-stream")
|
|
4293
4480
|
|
|
4294
4481
|
|
|
4482
|
+
class _S3NotSetUp(RuntimeError):
|
|
4483
|
+
"""Private mode, and the user has no S3 of their own (task #252). The API refused to sign —
|
|
4484
|
+
without touching the network — so the caller asks the user, or saves the file on disk."""
|
|
4485
|
+
|
|
4486
|
+
|
|
4295
4487
|
def _artifact_upload_via_api(api_base: str, worker_key: str, file_name: str,
|
|
4296
4488
|
data: bytes, content_type: str = "") -> str:
|
|
4297
4489
|
"""Presign a GENERIC S3 upload (any file type), PUT the bytes, return the download URL.
|
|
4298
|
-
Like
|
|
4490
|
+
Like the old PDF-only uploader, but preserves the real extension + Content-Type. The PUT
|
|
4299
4491
|
Content-Type MUST match what the presign signed, so we send content_type in the presign
|
|
4300
4492
|
request and PUT with the value the API echoes back (task #102)."""
|
|
4301
4493
|
import urllib.error as _ue
|
|
@@ -4316,6 +4508,8 @@ def _artifact_upload_via_api(api_base: str, worker_key: str, file_name: str,
|
|
|
4316
4508
|
ref = json.loads(resp.read().decode("utf-8"))
|
|
4317
4509
|
except _ue.HTTPError as e: # noqa: PERF203
|
|
4318
4510
|
detail = e.read().decode("utf-8", "replace")[:300]
|
|
4511
|
+
if e.code == 409 and "S3_NOT_SET_UP" in detail:
|
|
4512
|
+
raise _S3NotSetUp(detail) from None
|
|
4319
4513
|
raise RuntimeError(f"Could not get an upload URL (HTTP {e.code}): {detail}")
|
|
4320
4514
|
except _ue.URLError as e:
|
|
4321
4515
|
raise RuntimeError(
|
|
@@ -6448,7 +6642,7 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
|
|
|
6448
6642
|
the coder can be slow, so it passes a much larger deadline (#110): the REPL now
|
|
6449
6643
|
dismisses the picker the instant this job ends, and the worker's own job-cap kills a
|
|
6450
6644
|
truly-abandoned run, so a long wait here is safe and lets a real "Yes" actually land.
|
|
6451
|
-
|
|
6645
|
+
Same worker-key auth as _artifact_upload_via_api — no new creds on the worker."""
|
|
6452
6646
|
import urllib.request as _u
|
|
6453
6647
|
import uuid as _uuid
|
|
6454
6648
|
import time as _t
|
|
@@ -6485,6 +6679,8 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
|
|
|
6485
6679
|
_emit({"type": "step", "text": "Awaiting your choice: continue past the step budget?"})
|
|
6486
6680
|
elif command.startswith("__COMPACT__::"):
|
|
6487
6681
|
_emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
|
|
6682
|
+
elif command.startswith("__S3SETUP__::"):
|
|
6683
|
+
_emit({"type": "step", "text": "Awaiting your choice: set up S3, or save on this computer?"})
|
|
6488
6684
|
else:
|
|
6489
6685
|
_emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
|
|
6490
6686
|
deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
|
|
@@ -6863,6 +7059,17 @@ def run_agent_chat(cfg):
|
|
|
6863
7059
|
# risky command and asks the user via the API instead of hard-blocking it.
|
|
6864
7060
|
_job_id = (cfg.get("jobId") or "").strip()
|
|
6865
7061
|
_interactive_approval = bool(cfg.get("interactiveApproval"))
|
|
7062
|
+
|
|
7063
|
+
def _ask_s3_setup() -> bool:
|
|
7064
|
+
"""Private mode, a file to hand over, no S3 of the user's own (task #252): ask in the
|
|
7065
|
+
terminal whether to set it up now. True only when it is set up AND tested. No terminal to
|
|
7066
|
+
ask → False, and the file is saved on this computer. The wait covers typing the setup."""
|
|
7067
|
+
if not (_interactive_approval and _job_id):
|
|
7068
|
+
return False
|
|
7069
|
+
return _ws_request_approval(
|
|
7070
|
+
worker_api_base, worker_api_key, _job_id,
|
|
7071
|
+
"__S3SETUP__::S3 is not set up for your files. Set it up now, or save this file on this computer?",
|
|
7072
|
+
"", deadline_s=900)
|
|
6866
7073
|
# "accept edits on/off" from the terminal's bar (task #179). DEFAULTS TO TRUE, and has to:
|
|
6867
7074
|
# an older terminal, the web, and any caller that does not know about the flag send nothing,
|
|
6868
7075
|
# and reading a missing flag as "off" would put a Yes/No question in front of every command
|
|
@@ -7770,6 +7977,7 @@ def run_agent_chat(cfg):
|
|
|
7770
7977
|
out = _deliver_artifact(
|
|
7771
7978
|
f"{doc_title}.pdf", pdf_bytes, "application/pdf", "PDF",
|
|
7772
7979
|
local=local_artifacts, api_base=worker_api_base, worker_key=worker_api_key,
|
|
7980
|
+
ask_s3_setup=_ask_s3_setup,
|
|
7773
7981
|
)
|
|
7774
7982
|
except Exception as e: # noqa: BLE001
|
|
7775
7983
|
_log(f"PDF fast-path delivery error: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
|
|
@@ -8696,23 +8904,22 @@ def run_agent_chat(cfg):
|
|
|
8696
8904
|
_last_obs["text"] = msg
|
|
8697
8905
|
return msg
|
|
8698
8906
|
|
|
8699
|
-
# 3)
|
|
8907
|
+
# 3) Hand it over: a link via the API-presigned PUT (no AWS creds on the worker), or — in
|
|
8908
|
+
# private mode without the user's own S3 — on this computer (task #252, same path as the
|
|
8909
|
+
# PDF fast-path and create_download).
|
|
8700
8910
|
_emit({"type": "step", "text": "Uploading PDF…"})
|
|
8701
8911
|
file_name = f"{doc_title}.pdf"
|
|
8702
8912
|
try:
|
|
8703
|
-
|
|
8704
|
-
|
|
8913
|
+
out = _deliver_artifact(
|
|
8914
|
+
file_name, pdf_bytes, "application/pdf", "PDF",
|
|
8915
|
+
local=local_artifacts, api_base=worker_api_base, worker_key=worker_api_key,
|
|
8916
|
+
ask_s3_setup=_ask_s3_setup,
|
|
8705
8917
|
)
|
|
8706
8918
|
except Exception as e: # noqa: BLE001 — network / API errors must not crash the tool
|
|
8707
8919
|
_log(f"create_pdf upload error: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
|
|
8708
8920
|
msg = f"PDF was created but could not be uploaded: {str(e)[:200]}"
|
|
8709
8921
|
_last_obs["text"] = msg
|
|
8710
8922
|
return msg
|
|
8711
|
-
|
|
8712
|
-
out = (
|
|
8713
|
-
f"✅ Your PDF \"{doc_title}\" is ready.\n"
|
|
8714
|
-
f"Download it here (link valid for a limited time):\n{download_url}"
|
|
8715
|
-
)
|
|
8716
8923
|
_emit({"type": "step", "text": f"PDF ready → {doc_title}.pdf"})
|
|
8717
8924
|
_log(f"create_pdf ok title={_c(doc_title, r=True)} bytes={len(pdf_bytes)}")
|
|
8718
8925
|
_pdf_state["last"] = {
|
|
@@ -8810,6 +9017,7 @@ def run_agent_chat(cfg):
|
|
|
8810
9017
|
out = _deliver_artifact(
|
|
8811
9018
|
file_name, data, content_type, summary,
|
|
8812
9019
|
local=local_artifacts, api_base=worker_api_base, worker_key=worker_api_key,
|
|
9020
|
+
ask_s3_setup=_ask_s3_setup,
|
|
8813
9021
|
)
|
|
8814
9022
|
_last_obs["text"] = out
|
|
8815
9023
|
return out
|
|
@@ -8838,7 +9046,7 @@ def run_agent_chat(cfg):
|
|
|
8838
9046
|
# Per-turn, like _compact_state above: the high-water mark that decides when a trim
|
|
8839
9047
|
# pass is worth breaking the prefix cache for. See _trim_due.
|
|
8840
9048
|
_trim_state: dict = {"next_at": _TRIM_FIRST_AT_CHARS}
|
|
8841
|
-
_ctx_window = _model_context_window(coding_base_url, coding_model_id)
|
|
9049
|
+
_ctx_window = _model_context_window(coding_base_url, coding_model_id, coding_api_key)
|
|
8842
9050
|
_compact_at = _compact_threshold(_ctx_window)
|
|
8843
9051
|
if _ctx_window:
|
|
8844
9052
|
# SAY WHICH SOURCE ANSWERED. A measured window and a remembered one are not the same
|
|
@@ -9239,6 +9447,22 @@ def run_agent_chat(cfg):
|
|
|
9239
9447
|
self._no_code_streak = 0
|
|
9240
9448
|
super().__init__(*a, **kw)
|
|
9241
9449
|
|
|
9450
|
+
def create_client(self):
|
|
9451
|
+
"""The Claude API goes native, for prompt caching (task #262); everything else — and
|
|
9452
|
+
the Claude API when Anthropic's SDK is not installed — keeps the OpenAI client."""
|
|
9453
|
+
ck = self.client_kwargs or {}
|
|
9454
|
+
base = str(ck.get("base_url") or "")
|
|
9455
|
+
if _is_anthropic_url(base):
|
|
9456
|
+
if _anthropic_sdk_available():
|
|
9457
|
+
_log("claude: native Messages API with prompt caching")
|
|
9458
|
+
return _AnthropicChatAdapter(api_key=str(ck.get("api_key") or ""), base_url=base,
|
|
9459
|
+
timeout=ck.get("timeout"))
|
|
9460
|
+
_log("claude: the anthropic package is not installed — using the OpenAI-compatible "
|
|
9461
|
+
"endpoint, which does not cache")
|
|
9462
|
+
_emit({"type": "step", "text": "Claude without prompt caching — every step pays full "
|
|
9463
|
+
"price. Install it once: pip install anthropic"})
|
|
9464
|
+
return super().create_client()
|
|
9465
|
+
|
|
9242
9466
|
def _stream_key(self):
|
|
9243
9467
|
"""(server, model) — the pair the streaming memo is keyed on. `api_base` is a
|
|
9244
9468
|
constructor ARGUMENT in smolagents, not an attribute: it survives only inside
|
package/gu-repl.mjs
CHANGED
|
@@ -783,7 +783,7 @@ const COMMANDS = [
|
|
|
783
783
|
{ name: "/model", desc: "switch the CODING model (the one that writes code) for this session" },
|
|
784
784
|
// /provider IS NOT LISTED HERE ANY MORE — see HIDDEN_COMMANDS. It was promoted to a top-level
|
|
785
785
|
// command when editing a provider was genuinely unreachable, and by the very next change
|
|
786
|
-
// /model carried "add or change
|
|
786
|
+
// /model carried "add or change a provider — Claude API, OpenAI, Kimi…" as a row in EVERY branch of
|
|
787
787
|
// its picker (including the no-models and single-model ones). Two doors into one setting is
|
|
788
788
|
// what made /rag confusing, and it did the same here: reported as "why do we need /provider
|
|
789
789
|
// outside, it is confused". The command still works and still does the same thing.
|
|
@@ -800,7 +800,7 @@ const COMMANDS = [
|
|
|
800
800
|
{ name: "/host-mode", desc: "undo client mode — run agent turns on THIS machine again" },
|
|
801
801
|
{ name: "/server", desc: "pick a deployment server (host/user) for this session" },
|
|
802
802
|
{ name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
|
|
803
|
-
{ name: "/s3", usage: "/s3 [test]", desc: "set up
|
|
803
|
+
{ name: "/s3", usage: "/s3 [test]", desc: "set up YOUR S3 bucket (RAG index, and the PDFs/downloads gu makes) and check the AWS keys work — without it, files are saved on this computer" },
|
|
804
804
|
{ name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
|
|
805
805
|
{ name: "/save-settings", usage: "/save-settings [name|path]", desc: "write this account's providers, models and key NAMES to a file in your home folder (no secrets) — asks for a name, Enter keeps gu-settings.json" },
|
|
806
806
|
{ name: "/load-settings", usage: "/load-settings [name|path]", desc: "read that file back on another machine — picks from the settings files in your home folder, asks before it replaces anything" },
|
|
@@ -1515,6 +1515,11 @@ rl.on("line", (l) => {
|
|
|
1515
1515
|
if (following) {
|
|
1516
1516
|
rl.line = "";
|
|
1517
1517
|
rl.cursor = 0;
|
|
1518
|
+
// No frozen row to carry the notice (piped, or a window too short to pin one): say it once
|
|
1519
|
+
// here, on the Enter that would have sent it (#258).
|
|
1520
|
+
if (typedDuringTurn && !barPinned()) {
|
|
1521
|
+
process.stdout.write(dim(`\n ${TYPED_DURING_TURN_NOTICE.trim()}\n`));
|
|
1522
|
+
}
|
|
1518
1523
|
return;
|
|
1519
1524
|
}
|
|
1520
1525
|
// Enter left the cursor on the row where the live slash-hint started — erase it so the
|
|
@@ -2263,7 +2268,12 @@ function frozenZone() {
|
|
|
2263
2268
|
const zone = inputZoneRows({
|
|
2264
2269
|
headControl,
|
|
2265
2270
|
width,
|
|
2266
|
-
|
|
2271
|
+
// #258: typing during a turn is not sent — the row says so (an ask() label still comes first).
|
|
2272
|
+
prompt: secretInput && secretPrompt
|
|
2273
|
+
? accent(secretPrompt)
|
|
2274
|
+
: following && typedDuringTurn && !inlinePrompt
|
|
2275
|
+
? yellow(typedNotice(width))
|
|
2276
|
+
: inlinePrompt ? accent(inlinePrompt) : PROMPT,
|
|
2267
2277
|
// WHILE A SECRET IS OPEN, THIS ROW IS THE PROMPT — "kimi-k3 API key: ••••" rather than
|
|
2268
2278
|
// ">> ••••". Same row, same masking; what changes is that it now says what it wants. The
|
|
2269
2279
|
// width is measured, not assumed: the label is caller-supplied and every offset in here
|
|
@@ -2271,9 +2281,11 @@ function frozenZone() {
|
|
|
2271
2281
|
promptCols:
|
|
2272
2282
|
secretInput && secretPrompt
|
|
2273
2283
|
? visibleLen(secretPrompt)
|
|
2274
|
-
: inlinePrompt
|
|
2275
|
-
? visibleLen(
|
|
2276
|
-
:
|
|
2284
|
+
: following && typedDuringTurn && !inlinePrompt
|
|
2285
|
+
? visibleLen(typedNotice(width))
|
|
2286
|
+
: inlinePrompt
|
|
2287
|
+
? visibleLen(inlinePrompt)
|
|
2288
|
+
: PROMPT_COLS,
|
|
2277
2289
|
// A SECRET IS MASKED HERE, NOT HIDDEN (task #191, and a #127 hole the frozen zone opened).
|
|
2278
2290
|
// askSecret mutes readline's echo, but this zone does not go through readline — gu
|
|
2279
2291
|
// renders it from rl.line — so while the key prompt was up, the characters being typed
|
|
@@ -3621,6 +3633,13 @@ if (process.stdin.isTTY) {
|
|
|
3621
3633
|
// so the switch did nothing while the agent was thinking — which is exactly when a user
|
|
3622
3634
|
// watching it reach for commands wants to change it.
|
|
3623
3635
|
if (key && key.name === "tab" && key.shift) switchEditMode();
|
|
3636
|
+
// A printable key (or Enter after one) means the user is typing a question that will not
|
|
3637
|
+
// be sent — say so in the input row now, not after they have typed the whole thing (#258).
|
|
3638
|
+
const printable = typeof _ch === "string" && _ch.length > 0 && !/[\x00-\x1f\x7f]/.test(_ch) && !key?.ctrl && !key?.meta;
|
|
3639
|
+
if (printable && !typedDuringTurn) {
|
|
3640
|
+
typedDuringTurn = true;
|
|
3641
|
+
if (barPinned()) paintBar();
|
|
3642
|
+
}
|
|
3624
3643
|
rl.line = "";
|
|
3625
3644
|
rl.cursor = 0;
|
|
3626
3645
|
rl.historyIndex = -1;
|
|
@@ -5407,8 +5426,8 @@ async function manageCodingProviders() {
|
|
|
5407
5426
|
`${p.model || dim("(no model set)")}${dim(` · ${p.host}`)}${p.enabled ? "" : dim(" · off")}`
|
|
5408
5427
|
);
|
|
5409
5428
|
const ADD = labels.length;
|
|
5410
|
-
labels.push("add another provider…");
|
|
5411
|
-
console.log(dim(`
|
|
5429
|
+
labels.push("add another provider — Claude API, OpenAI, Kimi…");
|
|
5430
|
+
console.log(dim(` Providers — pick one to change it, or add another (${PICK_HINT}):`));
|
|
5412
5431
|
const idx = await pickFromList(labels, 0);
|
|
5413
5432
|
if (idx < 0) {
|
|
5414
5433
|
console.log(dim(" (cancelled)\n"));
|
|
@@ -5446,7 +5465,9 @@ async function changeProviderModel(ps, rows, p) {
|
|
|
5446
5465
|
// will refuse, and then the id is typed, which always works.
|
|
5447
5466
|
const probe = await withPendingStatus(`asking ${p.host} what it has`, async () => {
|
|
5448
5467
|
try {
|
|
5449
|
-
|
|
5468
|
+
// Keyless, as above — but in the list's own dialect (the Claude API's headers, #261).
|
|
5469
|
+
const lr = ps.modelListRequest(p.url);
|
|
5470
|
+
const res = await proxyFetch(lr.url, { headers: lr.headers, signal: AbortSignal.timeout(10_000) });
|
|
5450
5471
|
if (!res.ok) return { status: res.status };
|
|
5451
5472
|
return { body: await res.json().catch(() => null) };
|
|
5452
5473
|
} catch (err) {
|
|
@@ -5668,7 +5689,21 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
5668
5689
|
console.log(red(` "${presetUrl}" is ${target.why} — nothing changed.\n`));
|
|
5669
5690
|
return;
|
|
5670
5691
|
}
|
|
5671
|
-
} else {
|
|
5692
|
+
} else if (dialect === "openai") {
|
|
5693
|
+
// THE HOSTED PROVIDERS BY NAME (#261): Claude, OpenAI, Kimi — then any other URL. One picker in
|
|
5694
|
+
// the one add flow, so every role that adds a provider offers the same list (the embedder
|
|
5695
|
+
// without Claude, which serves no embeddings).
|
|
5696
|
+
const presets = ps.providerPresets(role);
|
|
5697
|
+
const rowsShown = [...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`), "another URL…"];
|
|
5698
|
+
console.log(dim(` Which provider? (${PICK_HINT})`));
|
|
5699
|
+
const pi = await pickFromList(rowsShown, 0);
|
|
5700
|
+
if (pi < 0) {
|
|
5701
|
+
console.log(dim(" (nothing saved)\n"));
|
|
5702
|
+
return;
|
|
5703
|
+
}
|
|
5704
|
+
if (pi < presets.length) target = as.serverTarget("openai", presets[pi].url);
|
|
5705
|
+
}
|
|
5706
|
+
if (!target && !presetUrl) {
|
|
5672
5707
|
const dflt = as.defaultServerUrl(role, dialect, settings, existing);
|
|
5673
5708
|
console.log(dim(dialect === "ollama"
|
|
5674
5709
|
? " An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."
|
|
@@ -5697,6 +5732,16 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
5697
5732
|
return;
|
|
5698
5733
|
}
|
|
5699
5734
|
const host = target.host;
|
|
5735
|
+
const refusal = as.roleRefusal(role, target);
|
|
5736
|
+
if (refusal.length) {
|
|
5737
|
+
for (const l of refusal) console.log(GUTTER + yellow(l));
|
|
5738
|
+
console.log("");
|
|
5739
|
+
return;
|
|
5740
|
+
}
|
|
5741
|
+
// Claude as the CODER: prompt caching needs Anthropic's SDK (#262) — offer it once, here.
|
|
5742
|
+
if (target.claude && role === "coding") {
|
|
5743
|
+
await md.ensureClaudeSdk({ log: (l) => console.log(GUTTER + dim(l.trim())), confirm: (q) => askYesNo(` ${q}`, true) });
|
|
5744
|
+
}
|
|
5700
5745
|
|
|
5701
5746
|
// --- 2. the server's key --------------------------------------------------------------
|
|
5702
5747
|
// AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
|
|
@@ -5987,7 +6032,7 @@ async function chooseModel() {
|
|
|
5987
6032
|
// through to add when there is genuinely nothing there.
|
|
5988
6033
|
console.log(dim(" No coding model configured."));
|
|
5989
6034
|
const go = await pickFromList(
|
|
5990
|
-
[dim("leave it"), "add or change
|
|
6035
|
+
[dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…", "change the Ollama server this machine uses…"],
|
|
5991
6036
|
1
|
|
5992
6037
|
);
|
|
5993
6038
|
if (go === 1) await manageCodingProviders();
|
|
@@ -6010,7 +6055,7 @@ async function chooseModel() {
|
|
|
6010
6055
|
[
|
|
6011
6056
|
dim("keep it"),
|
|
6012
6057
|
"change the Ollama server this machine uses…",
|
|
6013
|
-
"add or change
|
|
6058
|
+
"add or change a provider — Claude API, OpenAI, Kimi…",
|
|
6014
6059
|
],
|
|
6015
6060
|
0
|
|
6016
6061
|
);
|
|
@@ -6043,8 +6088,8 @@ async function chooseModel() {
|
|
|
6043
6088
|
// add flow directly when there is nothing to change, so it costs a word and no extra step.
|
|
6044
6089
|
labels.push(
|
|
6045
6090
|
choices.some((c) => (c.kind ?? "") === "openai")
|
|
6046
|
-
? "add or change
|
|
6047
|
-
: "add
|
|
6091
|
+
? "add or change a provider — Claude API, OpenAI, Kimi…"
|
|
6092
|
+
: "add a provider — Claude API, OpenAI, Kimi…"
|
|
6048
6093
|
);
|
|
6049
6094
|
// THREE MODELS ARE IN PLAY AND THIS COMMAND ONLY CHANGES ONE (task #169 req 5). "/model" has
|
|
6050
6095
|
// meant the CODING model since task #44, but "agent model" and "agent coding model" are close
|
|
@@ -6675,7 +6720,7 @@ async function chooseAgentModel() {
|
|
|
6675
6720
|
// terminal that ignored the command. The host is named in the label because on a slow
|
|
6676
6721
|
// one that IS the useful information while you wait.
|
|
6677
6722
|
const r = await withPendingStatus(`asking ${hostOf(url)} for its models`, () =>
|
|
6678
|
-
proxyFetch(url, { headers: am.agentListHeaders(listKey), signal: AbortSignal.timeout(10000) })
|
|
6723
|
+
proxyFetch(url, { headers: am.agentListHeaders(listKey, url), signal: AbortSignal.timeout(10000) })
|
|
6679
6724
|
);
|
|
6680
6725
|
if (!r.ok) failure = `HTTP ${r.status}`;
|
|
6681
6726
|
if (!r.ok && needsKey && (r.status === 401 || r.status === 403)) keyProblem = true;
|
|
@@ -6982,7 +7027,7 @@ async function runS3Command(line) {
|
|
|
6982
7027
|
};
|
|
6983
7028
|
|
|
6984
7029
|
console.log("");
|
|
6985
|
-
console.log(GUTTER + dim("
|
|
7030
|
+
console.log(GUTTER + dim("S3 — your own bucket: the RAG knowledge index, and the files gu makes (PDFs, downloads)"));
|
|
6986
7031
|
for (const row of s3.s3SummaryRows(current)) console.log(GUTTER + dim(` ${row}`));
|
|
6987
7032
|
console.log("");
|
|
6988
7033
|
|
|
@@ -7083,7 +7128,7 @@ async function runS3Command(line) {
|
|
|
7083
7128
|
console.log(GUTTER + accent("✓ saved.") + dim(" now checking it actually works…\n"));
|
|
7084
7129
|
// Tested with what was just SAVED (no body) rather than what was typed: that proves the
|
|
7085
7130
|
// stored config works, which is the thing every later turn will use.
|
|
7086
|
-
await runTest({});
|
|
7131
|
+
return await runTest({});
|
|
7087
7132
|
}
|
|
7088
7133
|
|
|
7089
7134
|
/**
|
|
@@ -8077,6 +8122,16 @@ async function probeWithRecovery() {
|
|
|
8077
8122
|
// gu-cli daemon claims + executes it — its terminal shows the full
|
|
8078
8123
|
// [gu-agent] logs. We poll the job here and stream the persisted chunks live.
|
|
8079
8124
|
let following = false;
|
|
8125
|
+
// SOMETHING WAS TYPED DURING THIS TURN (task #258). Input mid-turn is dropped on purpose (#92) — and
|
|
8126
|
+
// it used to be dropped SILENTLY: the row stayed blank and Enter did nothing, so a whole question
|
|
8127
|
+
// typed while the previous one was still queued simply vanished. Now the input row says so the
|
|
8128
|
+
// moment a key lands, and keeps saying it until the turn ends. Nothing is queued or sent.
|
|
8129
|
+
let typedDuringTurn = false;
|
|
8130
|
+
const TYPED_DURING_TURN_NOTICE = "(a turn is running — typing is not sent; type it again when it finishes) ";
|
|
8131
|
+
const TYPED_DURING_TURN_SHORT = "(turn running — not sent) ";
|
|
8132
|
+
/** The notice that fits: the input row must never wrap because of its label (#109/#132). */
|
|
8133
|
+
const typedNotice = (width) =>
|
|
8134
|
+
visibleLen(TYPED_DURING_TURN_NOTICE) + 12 <= width ? TYPED_DURING_TURN_NOTICE : TYPED_DURING_TURN_SHORT;
|
|
8080
8135
|
// Is the turn we are following one this terminal STARTED, or another terminal's that we merely
|
|
8081
8136
|
// attached to (task #211)? `attached` is local to runAgentTurn, and onInterrupt — which lives in
|
|
8082
8137
|
// main() — had no way to ask. So Ctrl+C while watching somebody else's agent took the "cancel my
|
|
@@ -8160,6 +8215,9 @@ const RUNTOOL_PREFIX = "__RUNTOOL__::";
|
|
|
8160
8215
|
const MAXSTEP_PREFIX = "__MAXSTEP__::";
|
|
8161
8216
|
const TIMEOUT_PREFIX = "__TIMEOUT__::";
|
|
8162
8217
|
const COMPACT_PREFIX = "__COMPACT__::";
|
|
8218
|
+
// Private mode, a file to hand over, and no S3 of the user's own (task #252): set it up now, or
|
|
8219
|
+
// save the file on this computer. Allow = S3 is now set up AND passed its test.
|
|
8220
|
+
const S3SETUP_PREFIX = "__S3SETUP__::";
|
|
8163
8221
|
async function approvalPrompt(command) {
|
|
8164
8222
|
// Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
|
|
8165
8223
|
// redrawing it (the tick early-returns while a picker is active) — otherwise the block
|
|
@@ -8179,6 +8237,31 @@ async function approvalPrompt(command) {
|
|
|
8179
8237
|
);
|
|
8180
8238
|
return { allow: idx === 0, dontAsk: false };
|
|
8181
8239
|
}
|
|
8240
|
+
if (command.startsWith(S3SETUP_PREFIX)) {
|
|
8241
|
+
const label = command.slice(S3SETUP_PREFIX.length);
|
|
8242
|
+
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
8243
|
+
process.stdout.write("\n" + yellow("📦 " + label) + dim(" (no answer in 2 min = save on this computer)") + "\n");
|
|
8244
|
+
const idx = await pickFromList(
|
|
8245
|
+
["Set up S3 now — my own bucket", "No — save it on this computer"],
|
|
8246
|
+
0,
|
|
8247
|
+
{ timeoutMs: 120_000, timeoutIndex: 1 }
|
|
8248
|
+
);
|
|
8249
|
+
if (idx !== 0) return { allow: false, dontAsk: false };
|
|
8250
|
+
// /s3 asks ordinary questions. During a turn `following` swallows every key and drives the
|
|
8251
|
+
// live status block, so the turn is paused on screen for the length of the setup, then resumed.
|
|
8252
|
+
const wasFollowing = following;
|
|
8253
|
+
following = false;
|
|
8254
|
+
let ok = false;
|
|
8255
|
+
try {
|
|
8256
|
+
ok = (await runS3Command("/s3")) === true;
|
|
8257
|
+
} catch (e) {
|
|
8258
|
+
if (!wasPromptCancelled(e)) throw e;
|
|
8259
|
+
console.log(dim(" (cancelled — the file is saved on this computer)\n"));
|
|
8260
|
+
} finally {
|
|
8261
|
+
following = wasFollowing;
|
|
8262
|
+
}
|
|
8263
|
+
return { allow: ok, dontAsk: false };
|
|
8264
|
+
}
|
|
8182
8265
|
// Time-budget prompt (task #81): the agent hit its wall-clock budget. Ask whether to
|
|
8183
8266
|
// keep waiting; auto-answer No after 60s of no input so the run can't hang forever.
|
|
8184
8267
|
if (command.startsWith(TIMEOUT_PREFIX)) {
|
|
@@ -8986,6 +9069,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
8986
9069
|
setPendingStatus(attached ? "attaching — reading its progress" : "queued — waiting for the worker");
|
|
8987
9070
|
following = true;
|
|
8988
9071
|
followingAttached = attached;
|
|
9072
|
+
typedDuringTurn = false;
|
|
8989
9073
|
followAborted = false;
|
|
8990
9074
|
currentJobId = jobId;
|
|
8991
9075
|
jobIdForApprovalRace = jobId; // so an approval picker can watch THIS job for early end (#110)
|
|
@@ -10143,6 +10227,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
10143
10227
|
if (process.stdin.isTTY) process.stdin.removeListener("data", onMouseData);
|
|
10144
10228
|
following = false;
|
|
10145
10229
|
followingAttached = false;
|
|
10230
|
+
typedDuringTurn = false;
|
|
10146
10231
|
currentJobId = null;
|
|
10147
10232
|
// Drop anything typed (but not submitted) during the turn — echo was muted, so it's
|
|
10148
10233
|
// invisible; without this it would surface on the next prompt.
|
package/model-doctor.mjs
CHANGED
|
@@ -1197,6 +1197,31 @@ export async function hasLocalOllama({ timeoutMs = 1200 } = {}) {
|
|
|
1197
1197
|
* imports no mlx/torch/transformers and reaches the model over HTTP, exactly as it already
|
|
1198
1198
|
* reaches a cloud coder.
|
|
1199
1199
|
*/
|
|
1200
|
+
/**
|
|
1201
|
+
* Anthropic's Python SDK — optional, and only for the Claude API (task #262). With it the coding
|
|
1202
|
+
* model uses the native Messages API with prompt caching; without it, the OpenAI-compatible
|
|
1203
|
+
* endpoint, which caches nothing, so every agent step pays full price for a prompt that is ~96%
|
|
1204
|
+
* repetition. Not part of ensureAgentDeps: making it required would re-prompt every install, and
|
|
1205
|
+
* most never use Claude. → "present" | "installed" | "skipped" | "failed".
|
|
1206
|
+
*/
|
|
1207
|
+
export async function ensureClaudeSdk({ log, confirm }) {
|
|
1208
|
+
if (await pyHasModule("anthropic")) return "present";
|
|
1209
|
+
const yes = await confirm(
|
|
1210
|
+
"Install Anthropic's Python SDK? It turns on prompt caching for Claude — without it every step pays full price."
|
|
1211
|
+
);
|
|
1212
|
+
if (!yes) {
|
|
1213
|
+
log(" skipped — Claude still works, without caching. Later: pip install anthropic");
|
|
1214
|
+
return "skipped";
|
|
1215
|
+
}
|
|
1216
|
+
try {
|
|
1217
|
+
await pipInstall(["anthropic"], log);
|
|
1218
|
+
} catch (e) {
|
|
1219
|
+
log(` couldn't install it (${e?.message ?? e}) — Claude still works, without caching.`);
|
|
1220
|
+
return "failed";
|
|
1221
|
+
}
|
|
1222
|
+
return (await pyHasModule("anthropic")) ? "installed" : "failed";
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1200
1225
|
export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels() }) {
|
|
1201
1226
|
const out = {};
|
|
1202
1227
|
log("");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.733",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|