@tiens.nguyen/gu-cli 1.0.804 → 1.0.805
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/gonext_agent_chat.py +434 -34
- package/gu-repl.mjs +103 -3
- package/model-limits-notice.mjs +50 -0
- package/package.json +2 -1
- package/terminal-layout.mjs +25 -0
package/gonext_agent_chat.py
CHANGED
|
@@ -796,7 +796,8 @@ def _synthesize_search(query, context, base_url, model_id, api_key="local",
|
|
|
796
796
|
parts = []
|
|
797
797
|
t0 = _t.time()
|
|
798
798
|
try:
|
|
799
|
-
stream =
|
|
799
|
+
stream = _create_with_repair(
|
|
800
|
+
client,
|
|
800
801
|
model=model_id or "default_model",
|
|
801
802
|
messages=[
|
|
802
803
|
{"role": "system", "content": (
|
|
@@ -1084,9 +1085,28 @@ class _OutputBudgetExhausted(_AgentConfigError):
|
|
|
1084
1085
|
same."""
|
|
1085
1086
|
|
|
1086
1087
|
|
|
1087
|
-
# The retry of a budget-exhausted reply gets this much room
|
|
1088
|
+
# The retry of a budget-exhausted reply gets at least this much room.
|
|
1088
1089
|
_BUDGET_RETRY_MAX_TOKENS = 64000
|
|
1089
1090
|
|
|
1091
|
+
# Native Claude default output budget when the caller sets none. Thinking shares this budget on
|
|
1092
|
+
# the adaptive-thinking models, so 32,000 left too little room for code (cut-offs on big
|
|
1093
|
+
# requests). 64,000 still finishes inside GONEXT_MAX_STREAM_SECONDS at ~50 tok/s. Unknown
|
|
1094
|
+
# Claude models keep the old 32,000. Override with GU_CLAUDE_MAX_TOKENS.
|
|
1095
|
+
_CLAUDE_DEFAULT_MAX_TOKENS = 64000
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _claude_default_max_tokens(model: str) -> int:
|
|
1099
|
+
"""Default max_tokens for a native Claude request: 64K, never above the model's known cap."""
|
|
1100
|
+
try:
|
|
1101
|
+
env = int(os.environ.get("GU_CLAUDE_MAX_TOKENS") or 0)
|
|
1102
|
+
except ValueError:
|
|
1103
|
+
env = 0
|
|
1104
|
+
cap = _known_max_output((model or "").lower())
|
|
1105
|
+
want = env or _CLAUDE_DEFAULT_MAX_TOKENS
|
|
1106
|
+
if cap:
|
|
1107
|
+
return min(want, cap)
|
|
1108
|
+
return env or 32000
|
|
1109
|
+
|
|
1090
1110
|
|
|
1091
1111
|
def _output_budget_exhausted(content, finish_reason) -> bool:
|
|
1092
1112
|
"""Did this reply end at the output limit with nothing written? Pure.
|
|
@@ -1104,7 +1124,13 @@ def _budget_retry_kwargs(completion_kwargs, native_claude: bool, with_effort: bo
|
|
|
1104
1124
|
kw = dict(completion_kwargs)
|
|
1105
1125
|
current = int(kw.get("max_tokens") or kw.get("max_completion_tokens") or 0)
|
|
1106
1126
|
if native_claude:
|
|
1107
|
-
|
|
1127
|
+
# More room than the first attempt: double it, up to the model's own cap (128K on the
|
|
1128
|
+
# Opus/Sonnet/Fable 5.x rows), never below the flat 64,000.
|
|
1129
|
+
_cap = _known_max_output(str(kw.get("model") or "").lower())
|
|
1130
|
+
# No limit of ours on the first request: the adapter's default applied, so start from it.
|
|
1131
|
+
current = current or _claude_default_max_tokens(str(kw.get("model") or ""))
|
|
1132
|
+
_want = max(_BUDGET_RETRY_MAX_TOKENS, current * 2)
|
|
1133
|
+
kw["max_tokens"] = max(_BUDGET_RETRY_MAX_TOKENS, current, min(_want, _cap) if _cap else current)
|
|
1108
1134
|
if with_effort:
|
|
1109
1135
|
kw["gu_effort"] = "low"
|
|
1110
1136
|
else:
|
|
@@ -1114,6 +1140,10 @@ def _budget_retry_kwargs(completion_kwargs, native_claude: bool, with_effort: bo
|
|
|
1114
1140
|
# server's own and the request is left as it is.
|
|
1115
1141
|
key = "max_tokens" if kw.get("max_tokens") else "max_completion_tokens"
|
|
1116
1142
|
kw[key] = max(_BUDGET_RETRY_MAX_TOKENS, current * 2)
|
|
1143
|
+
# Never ask for more than the model can give: a known cap beats the flat 64,000.
|
|
1144
|
+
_cap = _known_max_output(str(kw.get("model") or ""))
|
|
1145
|
+
if _cap:
|
|
1146
|
+
kw[key] = min(kw[key], _cap)
|
|
1117
1147
|
return kw
|
|
1118
1148
|
|
|
1119
1149
|
|
|
@@ -1281,7 +1311,8 @@ def _anthropic_request(kw: dict) -> dict:
|
|
|
1281
1311
|
stops = [s for s in (kw.get("stop") or []) if isinstance(s, str) and s.strip()]
|
|
1282
1312
|
req = {
|
|
1283
1313
|
"model": kw.get("model"),
|
|
1284
|
-
"max_tokens": int(kw.get("max_tokens") or kw.get("max_completion_tokens")
|
|
1314
|
+
"max_tokens": int(kw.get("max_tokens") or kw.get("max_completion_tokens")
|
|
1315
|
+
or _claude_default_max_tokens(str(kw.get("model") or ""))),
|
|
1285
1316
|
"messages": messages,
|
|
1286
1317
|
# Mark 3: automatic caching for the growing tail (the step history).
|
|
1287
1318
|
"cache_control": ephemeral,
|
|
@@ -1715,6 +1746,35 @@ def _clip(text: str, limit: int = 280) -> str:
|
|
|
1715
1746
|
return f"{cut}…"
|
|
1716
1747
|
|
|
1717
1748
|
|
|
1749
|
+
def _trim_words(text: str, limit: int = 120) -> str:
|
|
1750
|
+
"""Shorten a one-line command preview for a status line WITHOUT cutting mid-word
|
|
1751
|
+
(task #20). Ends on a whole token (a full flag, a whole path segment) and adds "…",
|
|
1752
|
+
so "Command finished → gh issue comment 109 --body-f" reads as "… --repo tiennsloit/gu…".
|
|
1753
|
+
Prefers a space boundary; falls back to the last "/" when the only space is far back
|
|
1754
|
+
(a long path), and to a hard cut only for one giant token. A dangling shell operator
|
|
1755
|
+
(&&, |, ;) is dropped so the line never ends on a half-chain."""
|
|
1756
|
+
text = " ".join(str(text).split())
|
|
1757
|
+
if len(text) <= limit:
|
|
1758
|
+
return text
|
|
1759
|
+
budget = max(1, limit - 1) # room for the ellipsis
|
|
1760
|
+
if len(text) > budget and text[budget] == " ":
|
|
1761
|
+
head = text[:budget]
|
|
1762
|
+
else:
|
|
1763
|
+
seg = text[:budget]
|
|
1764
|
+
sp = seg.rfind(" ")
|
|
1765
|
+
sl = seg.rfind("/")
|
|
1766
|
+
if sp >= budget * 0.6 or (sp > 0 and sl <= sp):
|
|
1767
|
+
head = seg[:sp]
|
|
1768
|
+
elif sl > 0:
|
|
1769
|
+
head = seg[:sl + 1]
|
|
1770
|
+
else:
|
|
1771
|
+
head = seg
|
|
1772
|
+
toks = head.rstrip().split(" ")
|
|
1773
|
+
while len(toks) > 1 and toks[-1] in ("&&", "||", "|", ";", "\\", "-", "--"):
|
|
1774
|
+
toks.pop()
|
|
1775
|
+
return " ".join(toks).rstrip() + "…"
|
|
1776
|
+
|
|
1777
|
+
|
|
1718
1778
|
def _render_numbered_tool_list(tools) -> str:
|
|
1719
1779
|
"""Render ' N. name(args) — description' lines from the REAL registered smolagents
|
|
1720
1780
|
tool objects — their actual name/inputs/docstring — instead of a hand-maintained
|
|
@@ -2942,32 +3002,260 @@ def _without_params(kwargs: dict, names: list) -> dict:
|
|
|
2942
3002
|
return {k: v for k, v in kwargs.items() if k not in names}
|
|
2943
3003
|
|
|
2944
3004
|
|
|
3005
|
+
# ---------------------------------------------------------------------------------------------
|
|
3006
|
+
# REQUEST-SHAPE REPAIR. A user can pick ANY model, and providers reject request parameters per
|
|
3007
|
+
# model: newer OpenAI / reasoning models want max_completion_tokens and refuse max_tokens, kimi
|
|
3008
|
+
# and the o-series refuse any temperature but 1, old Ollama/vLLM/mlx servers refuse
|
|
3009
|
+
# max_completion_tokens, some servers refuse stop / stream_options. A list of model names goes
|
|
3010
|
+
# stale the day a model ships, so every call reacts to the 400 ITSELF: read what the provider
|
|
3011
|
+
# rejected, change exactly that, ask again, and remember the working shape per (server, model).
|
|
3012
|
+
# ---------------------------------------------------------------------------------------------
|
|
3013
|
+
_REQUEST_SHAPE_CACHE: dict = {}
|
|
3014
|
+
_REQUEST_SHAPE_LOADED = [False]
|
|
3015
|
+
_REPAIR_MAX_ATTEMPTS = 4
|
|
3016
|
+
# A reasoning model spends max_completion_tokens on hidden thinking first, so a tiny budget (the
|
|
3017
|
+
# router probe asks for 3) comes back EMPTY with finish_reason="length". Small budgets are raised.
|
|
3018
|
+
_REASONING_OUTPUT_FLOOR = 1024
|
|
3019
|
+
_REASONING_MODEL_RE = re.compile(r"^(o\d|gpt-5|gpt-6)")
|
|
3020
|
+
_TOKEN_KEYS = ("max_tokens", "max_completion_tokens")
|
|
3021
|
+
# Optional request fields that are safe to drop. NEVER model / messages / stream / tools.
|
|
3022
|
+
_DROPPABLE_PARAMS = ("stop", "presence_penalty", "frequency_penalty", "stream_options", "seed",
|
|
3023
|
+
"logprobs", "top_logprobs", "logit_bias", "n", "user",
|
|
3024
|
+
"parallel_tool_calls", "service_tier", "store", "metadata")
|
|
3025
|
+
_REJECT_WORDS = ("unsupported", "not supported", "unrecognized", "unknown parameter",
|
|
3026
|
+
"extra inputs", "not permitted", "unexpected keyword")
|
|
3027
|
+
|
|
3028
|
+
|
|
3029
|
+
def _token_key(kw: dict) -> str:
|
|
3030
|
+
for k in _TOKEN_KEYS:
|
|
3031
|
+
if kw.get(k):
|
|
3032
|
+
return k
|
|
3033
|
+
return ""
|
|
3034
|
+
|
|
3035
|
+
|
|
3036
|
+
def _output_limit_from_error(text) -> int:
|
|
3037
|
+
"""The output cap a provider named while refusing a too-large max_tokens, or 0. Pure.
|
|
3038
|
+
|
|
3039
|
+
"max_tokens is too large: 200000. This model supports at most 16384 completion tokens"."""
|
|
3040
|
+
s = str(text or "").lower()
|
|
3041
|
+
if not s or "context length" in s:
|
|
3042
|
+
return 0
|
|
3043
|
+
if not any(k in s for k in ("max_tokens", "max_completion_tokens", "completion tokens",
|
|
3044
|
+
"output tokens")):
|
|
3045
|
+
return 0
|
|
3046
|
+
for pat in (r"(?:at most|less than or equal to|no more than|up to|"
|
|
3047
|
+
r"maximum (?:allowed )?(?:value )?(?:is|of))\s*:?\s*(\d[\d,]*)",
|
|
3048
|
+
r"(?:supports?|allows?)\s+(?:a\s+)?(?:max(?:imum)?(?: of)?\s+)?(\d[\d,]*)\s+"
|
|
3049
|
+
r"(?:completion|output|max)"):
|
|
3050
|
+
m = re.search(pat, s)
|
|
3051
|
+
if m:
|
|
3052
|
+
try:
|
|
3053
|
+
n = int(m.group(1).replace(",", ""))
|
|
3054
|
+
except ValueError:
|
|
3055
|
+
continue
|
|
3056
|
+
if n >= 256:
|
|
3057
|
+
return n
|
|
3058
|
+
return 0
|
|
3059
|
+
|
|
3060
|
+
|
|
3061
|
+
def _repair_request(kwargs: dict, err):
|
|
3062
|
+
"""One fix for a 400 that rejected the request SHAPE. Pure.
|
|
3063
|
+
|
|
3064
|
+
Returns (new_kwargs, action) or None when nothing here can help. Actions: "clamp:<key>",
|
|
3065
|
+
"rename-token", "drop:<names>". Never touches model / messages / stream."""
|
|
3066
|
+
msg = str(err).lower()
|
|
3067
|
+
if not any(k in msg for k in ("400", "invalid_request", "invalid request") + _REJECT_WORDS):
|
|
3068
|
+
return None
|
|
3069
|
+
kw = dict(kwargs)
|
|
3070
|
+
tk = _token_key(kw)
|
|
3071
|
+
# 1. the provider named its output cap
|
|
3072
|
+
lim = _output_limit_from_error(msg)
|
|
3073
|
+
if lim and tk:
|
|
3074
|
+
try:
|
|
3075
|
+
if int(kw[tk]) > lim:
|
|
3076
|
+
kw[tk] = lim
|
|
3077
|
+
return kw, f"clamp:{tk}"
|
|
3078
|
+
except (TypeError, ValueError):
|
|
3079
|
+
pass
|
|
3080
|
+
rejects = any(w in msg for w in _REJECT_WORDS)
|
|
3081
|
+
# 2. which spelling of the output budget this model wants
|
|
3082
|
+
if "max_tokens" in kw and "max_tokens" in msg and (
|
|
3083
|
+
"max_completion_tokens" in msg or rejects):
|
|
3084
|
+
kw["max_completion_tokens"] = kw.pop("max_tokens")
|
|
3085
|
+
return kw, "rename-token"
|
|
3086
|
+
if ("max_completion_tokens" in kw and "max_completion_tokens" in msg
|
|
3087
|
+
and "max_tokens" not in msg and rejects):
|
|
3088
|
+
kw["max_tokens"] = kw.pop("max_completion_tokens")
|
|
3089
|
+
return kw, "rename-token"
|
|
3090
|
+
# 3. sampling parameters a reasoning model will not take
|
|
3091
|
+
bad = [p for p in _unsupported_sampling_params(err) if p in kw]
|
|
3092
|
+
if bad:
|
|
3093
|
+
return _without_params(kw, bad), "drop:" + ",".join(bad)
|
|
3094
|
+
# 4. any other optional field the provider says it does not know
|
|
3095
|
+
if rejects:
|
|
3096
|
+
names = [n for n in re.findall(r"['\"`]([a-z_]+)['\"`]", msg)
|
|
3097
|
+
if n in _DROPPABLE_PARAMS and n in kw]
|
|
3098
|
+
if not names:
|
|
3099
|
+
names = [n for n in _DROPPABLE_PARAMS
|
|
3100
|
+
if ("_" in n or n in ("seed", "logprobs")) and n in kw
|
|
3101
|
+
and re.search(r"(?<![a-z_])" + n + r"(?![a-z_])", msg)]
|
|
3102
|
+
names = list(dict.fromkeys(names))
|
|
3103
|
+
if names:
|
|
3104
|
+
return _without_params(kw, names), "drop:" + ",".join(names)
|
|
3105
|
+
return None
|
|
3106
|
+
|
|
3107
|
+
|
|
3108
|
+
def _shape_path() -> str:
|
|
3109
|
+
return os.path.join(os.path.expanduser("~"), ".gonext", "request-shapes.json")
|
|
3110
|
+
|
|
3111
|
+
|
|
3112
|
+
def _shape_entries() -> dict:
|
|
3113
|
+
"""In-memory shapes, loaded once from disk (the agent process dies every turn)."""
|
|
3114
|
+
if not _REQUEST_SHAPE_LOADED[0]:
|
|
3115
|
+
_REQUEST_SHAPE_LOADED[0] = True
|
|
3116
|
+
try:
|
|
3117
|
+
with open(_shape_path(), "r", encoding="utf-8") as fh:
|
|
3118
|
+
data = json.load(fh)
|
|
3119
|
+
if isinstance(data, dict):
|
|
3120
|
+
for k, v in data.items():
|
|
3121
|
+
if isinstance(v, dict):
|
|
3122
|
+
_REQUEST_SHAPE_CACHE.setdefault(str(k), v)
|
|
3123
|
+
except Exception: # noqa: BLE001 — missing/corrupt just means learning again
|
|
3124
|
+
pass
|
|
3125
|
+
return _REQUEST_SHAPE_CACHE
|
|
3126
|
+
|
|
3127
|
+
|
|
3128
|
+
def _save_shape(key: str, entry: dict) -> None:
|
|
3129
|
+
"""Persist one shape atomically (read-merge-write then rename, like the context windows)."""
|
|
3130
|
+
if not key.split("|", 1)[0]:
|
|
3131
|
+
return # no server identity → nothing a later process could match
|
|
3132
|
+
path = _shape_path()
|
|
3133
|
+
try:
|
|
3134
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
3135
|
+
merged = {}
|
|
3136
|
+
try:
|
|
3137
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
3138
|
+
loaded = json.load(fh)
|
|
3139
|
+
if isinstance(loaded, dict):
|
|
3140
|
+
merged = loaded
|
|
3141
|
+
except Exception: # noqa: BLE001
|
|
3142
|
+
merged = {}
|
|
3143
|
+
merged[key] = entry
|
|
3144
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
3145
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
3146
|
+
json.dump(merged, fh, indent=2, sort_keys=True)
|
|
3147
|
+
os.replace(tmp, path)
|
|
3148
|
+
except Exception as e: # noqa: BLE001 — never fail a turn over a cache write
|
|
3149
|
+
_log(f"could not persist the learned request shape: {e}")
|
|
3150
|
+
|
|
3151
|
+
|
|
3152
|
+
def _clamp_output_budget(kw: dict, model: str, learned_cap: int = 0) -> dict:
|
|
3153
|
+
"""Keep the output budget inside the model's cap, and above the reasoning floor. Pure."""
|
|
3154
|
+
tk = _token_key(kw)
|
|
3155
|
+
if not tk:
|
|
3156
|
+
return kw
|
|
3157
|
+
try:
|
|
3158
|
+
v = int(kw[tk])
|
|
3159
|
+
except (TypeError, ValueError):
|
|
3160
|
+
return kw
|
|
3161
|
+
mid = (model or "").lower()
|
|
3162
|
+
if _REASONING_MODEL_RE.match(mid) or tk == "max_completion_tokens":
|
|
3163
|
+
v = max(v, _REASONING_OUTPUT_FLOOR)
|
|
3164
|
+
cap = int(learned_cap or 0) or _known_max_output(mid)
|
|
3165
|
+
if cap and v > cap:
|
|
3166
|
+
v = cap
|
|
3167
|
+
if v != kw[tk]:
|
|
3168
|
+
kw = dict(kw)
|
|
3169
|
+
kw[tk] = v
|
|
3170
|
+
return kw
|
|
3171
|
+
|
|
3172
|
+
|
|
3173
|
+
def _apply_request_shape(kwargs: dict, key: str, model: str) -> dict:
|
|
3174
|
+
"""The request as this (server, model) is already known to accept it."""
|
|
3175
|
+
kw = dict(kwargs)
|
|
3176
|
+
entry = _shape_entries().get(key) or {}
|
|
3177
|
+
for old, new in (entry.get("rename") or {}).items():
|
|
3178
|
+
if old in kw and new not in kw:
|
|
3179
|
+
kw[new] = kw.pop(old)
|
|
3180
|
+
for p in entry.get("drop") or []:
|
|
3181
|
+
if p not in ("model", "messages", "stream"):
|
|
3182
|
+
kw.pop(p, None)
|
|
3183
|
+
return _clamp_output_budget(kw, model, entry.get("max_out") or 0)
|
|
3184
|
+
|
|
3185
|
+
|
|
3186
|
+
def _remember_shape(key: str, new_kw: dict, action: str) -> None:
|
|
3187
|
+
entry = dict(_shape_entries().get(key) or {})
|
|
3188
|
+
if action == "rename-token":
|
|
3189
|
+
entry["rename"] = ({"max_tokens": "max_completion_tokens"}
|
|
3190
|
+
if "max_completion_tokens" in new_kw
|
|
3191
|
+
else {"max_completion_tokens": "max_tokens"})
|
|
3192
|
+
elif action.startswith("drop:"):
|
|
3193
|
+
entry["drop"] = sorted(set(entry.get("drop") or []) | set(action[5:].split(",")))
|
|
3194
|
+
elif action.startswith("clamp:"):
|
|
3195
|
+
entry["max_out"] = int(new_kw[action[6:]])
|
|
3196
|
+
_REQUEST_SHAPE_CACHE[key] = entry
|
|
3197
|
+
_save_shape(key, entry)
|
|
3198
|
+
|
|
3199
|
+
|
|
3200
|
+
def _create_with_repair(client, **kwargs):
|
|
3201
|
+
"""client.chat.completions.create that fixes a 400 about the request SHAPE and retries.
|
|
3202
|
+
|
|
3203
|
+
Up to _REPAIR_MAX_ATTEMPTS repairs; each KIND of fix is applied at most once, so it can never
|
|
3204
|
+
ping-pong between max_tokens and max_completion_tokens. An unfixable 400 is raised as it came."""
|
|
3205
|
+
base = str(getattr(client, "base_url", "") or "")
|
|
3206
|
+
model = str(kwargs.get("model") or "")
|
|
3207
|
+
key = f"{base}|{model}"
|
|
3208
|
+
kw = _apply_request_shape(kwargs, key, model)
|
|
3209
|
+
done = set()
|
|
3210
|
+
for _ in range(_REPAIR_MAX_ATTEMPTS):
|
|
3211
|
+
try:
|
|
3212
|
+
return client.chat.completions.create(**kw)
|
|
3213
|
+
except Exception as e: # noqa: BLE001
|
|
3214
|
+
fixed = _repair_request(kw, e)
|
|
3215
|
+
if not fixed:
|
|
3216
|
+
raise
|
|
3217
|
+
new_kw, action = fixed
|
|
3218
|
+
if action in done or new_kw == kw:
|
|
3219
|
+
raise
|
|
3220
|
+
done.add(action)
|
|
3221
|
+
dropped = [k for k in kw if k not in new_kw]
|
|
3222
|
+
added = [k for k in new_kw if k not in kw]
|
|
3223
|
+
if action == "rename-token":
|
|
3224
|
+
_new_tk = _token_key(new_kw)
|
|
3225
|
+
_old_tk = "max_tokens" if _new_tk == "max_completion_tokens" else "max_completion_tokens"
|
|
3226
|
+
_log(f"model {model!r} wants {_new_tk} instead of {_old_tk}; "
|
|
3227
|
+
"retrying with it (remembered)")
|
|
3228
|
+
elif action.startswith("clamp:"):
|
|
3229
|
+
_log(f"model {model!r} caps its output at {new_kw[action[6:]]} tokens; "
|
|
3230
|
+
"retrying with it (remembered)")
|
|
3231
|
+
else:
|
|
3232
|
+
_log(f"model {model!r} rejects {', '.join(dropped)}; "
|
|
3233
|
+
"retrying at the model's own default")
|
|
3234
|
+
_remember_shape(key, new_kw, action)
|
|
3235
|
+
kw = new_kw
|
|
3236
|
+
return client.chat.completions.create(**kw)
|
|
3237
|
+
|
|
3238
|
+
|
|
2945
3239
|
def _chat_create(client, **kwargs):
|
|
2946
|
-
"""chat.completions.create with
|
|
2947
|
-
NAME as an unknown repo, retry once with
|
|
2948
|
-
sentinel to whatever model it was launched with
|
|
2949
|
-
|
|
2950
|
-
we only know the bare name. Non-mlx servers surface their ORIGINAL error unchanged."""
|
|
3240
|
+
"""chat.completions.create with request-shape repair (see _create_with_repair) and an
|
|
3241
|
+
mlx_lm safety net: if the server rejects the model NAME as an unknown repo, retry once with
|
|
3242
|
+
'default_model' — mlx_lm.server maps that sentinel to whatever model it was launched with
|
|
3243
|
+
(`--model <path>`). Non-mlx servers surface their ORIGINAL error unchanged."""
|
|
2951
3244
|
try:
|
|
2952
|
-
return client
|
|
3245
|
+
return _create_with_repair(client, **kwargs)
|
|
2953
3246
|
except Exception as e: # noqa: BLE001
|
|
2954
3247
|
# THE PROVIDER JUST NAMED ITS OWN LIMIT. Recorded before anything else is tried: this is
|
|
2955
3248
|
# the only source that cannot be stale, and without it a too-generous prior would refuse
|
|
2956
3249
|
# every turn forever instead of being corrected by the first one.
|
|
2957
3250
|
_learn_context_window(str(getattr(client, "base_url", "") or ""),
|
|
2958
3251
|
str(kwargs.get("model") or ""), e)
|
|
2959
|
-
bad = [p for p in _unsupported_sampling_params(e) if p in kwargs]
|
|
2960
|
-
if bad:
|
|
2961
|
-
_log(f"model {kwargs.get('model')!r} rejects {', '.join(bad)}; "
|
|
2962
|
-
"retrying at the model's own default")
|
|
2963
|
-
return client.chat.completions.create(**_without_params(kwargs, bad))
|
|
2964
3252
|
if kwargs.get("model") != "default_model" and _retry_as_default_model_helps(e):
|
|
2965
3253
|
_log(f"model {kwargs.get('model')!r} not found on server; retrying as "
|
|
2966
3254
|
"'default_model' (mlx_lm preloaded model)")
|
|
2967
3255
|
try:
|
|
2968
3256
|
kwargs2 = dict(kwargs)
|
|
2969
3257
|
kwargs2["model"] = "default_model"
|
|
2970
|
-
return client
|
|
3258
|
+
return _create_with_repair(client, **kwargs2)
|
|
2971
3259
|
except Exception: # noqa: BLE001
|
|
2972
3260
|
raise e # surface the clearer original error, not the retry's
|
|
2973
3261
|
raise
|
|
@@ -2976,15 +3264,15 @@ def _chat_create(client, **kwargs):
|
|
|
2976
3264
|
def _chat_create_stream(client, on_delta, **kwargs) -> str:
|
|
2977
3265
|
"""Like _chat_create but STREAMS the completion: on_delta(text) is called for each
|
|
2978
3266
|
content piece AS IT ARRIVES (so the caller can emit it live), and the full assembled
|
|
2979
|
-
text is returned once the stream ends. Applies the same
|
|
2980
|
-
as _chat_create —
|
|
2981
|
-
round-trip (before any token streams), so a clean retry from scratch never risks
|
|
3267
|
+
text is returned once the stream ends. Applies the same request-shape repair and mlx_lm
|
|
3268
|
+
model-not-found retry as _chat_create — those failures always happen at/near the very first
|
|
3269
|
+
network round-trip (before any token streams), so a clean retry from scratch never risks
|
|
2982
3270
|
re-emitting a partial answer twice."""
|
|
2983
3271
|
kwargs = dict(kwargs)
|
|
2984
3272
|
kwargs["stream"] = True
|
|
2985
3273
|
|
|
2986
3274
|
def _run(kw):
|
|
2987
|
-
stream = client
|
|
3275
|
+
stream = _create_with_repair(client, **kw)
|
|
2988
3276
|
parts = []
|
|
2989
3277
|
for chunk in stream:
|
|
2990
3278
|
choices = getattr(chunk, "choices", None)
|
|
@@ -3002,13 +3290,6 @@ def _chat_create_stream(client, on_delta, **kwargs) -> str:
|
|
|
3002
3290
|
# authority that cannot go stale, so it is recorded before any retry is considered.
|
|
3003
3291
|
_learn_context_window(str(getattr(client, "base_url", "") or ""),
|
|
3004
3292
|
str(kwargs.get("model") or ""), e)
|
|
3005
|
-
# Same safety as the model-not-found retry below: a 400 rejects the REQUEST, so it
|
|
3006
|
-
# lands before any delta streams and a clean re-run can't emit a partial answer twice.
|
|
3007
|
-
bad = [p for p in _unsupported_sampling_params(e) if p in kwargs]
|
|
3008
|
-
if bad:
|
|
3009
|
-
_log(f"model {kwargs.get('model')!r} rejects {', '.join(bad)}; "
|
|
3010
|
-
"retrying at the model's own default")
|
|
3011
|
-
return _run(_without_params(kwargs, bad))
|
|
3012
3293
|
if kwargs.get("model") != "default_model" and _retry_as_default_model_helps(e):
|
|
3013
3294
|
_log(f"model {kwargs.get('model')!r} not found on server; retrying as "
|
|
3014
3295
|
"'default_model' (mlx_lm preloaded model)")
|
|
@@ -3241,6 +3522,11 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
|
|
|
3241
3522
|
# gets persisted to conversation history); raising lets it become a genuine job
|
|
3242
3523
|
# failure instead (see docstring). Kept short/clean (no embedded newlines/HTML — a
|
|
3243
3524
|
# raw nginx 502 page can be a whole document) for whichever caller logs/surfaces it.
|
|
3525
|
+
# The FIRST error is the model the user asked for; when the stand-in failed differently
|
|
3526
|
+
# (e.g. a 502 on the fallback), the real cause would otherwise be hidden.
|
|
3527
|
+
if first_err is not None and last_err is not first_err and str(first_err) != str(last_err):
|
|
3528
|
+
raise RuntimeError(f"couldn't get a response ({_clip(str(first_err), 110)}; "
|
|
3529
|
+
f"then {_clip(str(last_err), 70)})")
|
|
3244
3530
|
raise RuntimeError(f"couldn't get a response ({_clip(str(last_err), 160)})")
|
|
3245
3531
|
|
|
3246
3532
|
|
|
@@ -3486,7 +3772,7 @@ def _model_context_window(base_url: str, model_id: str, api_key: str = "") -> in
|
|
|
3486
3772
|
# rather than living forever. Being here at all is what stops a hosted model quietly
|
|
3487
3773
|
# throwing away history on every turn.
|
|
3488
3774
|
if win <= 0:
|
|
3489
|
-
win = _known_context_window(model_id)
|
|
3775
|
+
win = _known_context_window(model_id, base_url)
|
|
3490
3776
|
if win > 0:
|
|
3491
3777
|
_log(f"context window: no source reported one for {model_id!r}; using the published "
|
|
3492
3778
|
f"{win} for its family. /context-window <tokens> overrides this.")
|
|
@@ -3580,17 +3866,125 @@ _KNOWN_CTX_WINDOWS: list = [
|
|
|
3580
3866
|
]
|
|
3581
3867
|
|
|
3582
3868
|
|
|
3583
|
-
def _known_context_window(model_id: str) -> int:
|
|
3584
|
-
"""A published window for a well-known family, or 0. LAST RESORT — see the note above.
|
|
3869
|
+
def _known_context_window(model_id: str, base_url: str = "") -> int:
|
|
3870
|
+
"""A published window for a well-known family, or 0. LAST RESORT — see the note above.
|
|
3871
|
+
The cloud token table (_cloud_limit) is asked first, then the built-in rows."""
|
|
3585
3872
|
mid = (model_id or "").strip().lower()
|
|
3586
3873
|
if not mid:
|
|
3587
3874
|
return 0
|
|
3875
|
+
cloud = _cloud_limit(mid, "ctx", base_url)
|
|
3876
|
+
if cloud:
|
|
3877
|
+
return cloud
|
|
3588
3878
|
for pattern, tokens, _source in _KNOWN_CTX_WINDOWS:
|
|
3589
3879
|
if re.match(pattern, mid):
|
|
3590
3880
|
return tokens
|
|
3591
3881
|
return 0
|
|
3592
3882
|
|
|
3593
3883
|
|
|
3884
|
+
# OUTPUT-TOKEN CAPS — the sibling of _KNOWN_CTX_WINDOWS, for the reply instead of the prompt.
|
|
3885
|
+
# Only rows with a citation are listed; a model that matches nothing is left UNCLAMPED and the
|
|
3886
|
+
# provider's own refusal ("max_tokens is too large … at most N") is learned by _repair_request and
|
|
3887
|
+
# remembered per (server, model). A missing row therefore costs one retry, a wrong row would cost
|
|
3888
|
+
# every request, so this list stays short on purpose. Specific rows first.
|
|
3889
|
+
_KNOWN_MAX_OUTPUT = [
|
|
3890
|
+
# Anthropic models overview (https://platform.claude.com/docs/en/models/overview, read 2026-10-05):
|
|
3891
|
+
# Fable 5.1 / Opus 5.5 / Sonnet 5.5 = 128K max output, Haiku 4.5 = 64K.
|
|
3892
|
+
(r"^claude-haiku-4-5", 64_000, "https://platform.claude.com/docs/en/models/overview"),
|
|
3893
|
+
(r"^claude-(opus|sonnet|fable)-5", 128_000, "https://platform.claude.com/docs/en/models/overview"),
|
|
3894
|
+
(r"^gpt-6", 128_000, "OpenAI API models overview (read through a web search summary; "
|
|
3895
|
+
"GPT-6 Astra / 6.1 Sol / 6 Luna: 128,000 output tokens)"),
|
|
3896
|
+
(r"^gpt-5\.1-codex-max", 128_000, "https://developers.openai.com/api/docs/models/gpt-5-codex "
|
|
3897
|
+
"(the note beside _KNOWN_CTX_WINDOWS: 128,000 max output)"),
|
|
3898
|
+
(r"^gpt-4o-mini", 16_384, "OpenAI model page for GPT-4o mini, read through a web search "
|
|
3899
|
+
"summary: 16,384 maximum output tokens"),
|
|
3900
|
+
]
|
|
3901
|
+
|
|
3902
|
+
|
|
3903
|
+
def _known_max_output(model_id: str, base_url: str = "") -> int:
|
|
3904
|
+
"""A published output cap for a well-known family, or 0 (= do not clamp). Cloud table first."""
|
|
3905
|
+
mid = (model_id or "").strip().lower()
|
|
3906
|
+
if not mid:
|
|
3907
|
+
return 0
|
|
3908
|
+
cloud = _cloud_limit(mid, "out", base_url)
|
|
3909
|
+
if cloud:
|
|
3910
|
+
return cloud
|
|
3911
|
+
for pattern, tokens, _source in _KNOWN_MAX_OUTPUT:
|
|
3912
|
+
if re.match(pattern, mid):
|
|
3913
|
+
return tokens
|
|
3914
|
+
return 0
|
|
3915
|
+
|
|
3916
|
+
|
|
3917
|
+
# CLOUD TOKEN TABLE (GET /api/model-limits, pushed from the deploy app's "Push token table to
|
|
3918
|
+
# cloud" button). Read ONCE per job, best effort: a missing or failing API never stops a turn, it
|
|
3919
|
+
# only means the built-in tables below are used. Order at lookup time: this machine's learned
|
|
3920
|
+
# limits and the server's own answer come first (callers), then THIS table, then the built-in one.
|
|
3921
|
+
_CLOUD_LIMITS: dict = {"rows": [], "loaded": False}
|
|
3922
|
+
|
|
3923
|
+
|
|
3924
|
+
def _fetch_cloud_limits(api_base: str, timeout: float = 5.0) -> int:
|
|
3925
|
+
"""Load the cloud token-limit rows into _CLOUD_LIMITS; returns how many. Never raises."""
|
|
3926
|
+
base = (api_base or "").rstrip("/")
|
|
3927
|
+
_CLOUD_LIMITS["loaded"] = True
|
|
3928
|
+
if not base:
|
|
3929
|
+
return 0
|
|
3930
|
+
try:
|
|
3931
|
+
req = urllib.request.Request(f"{base}/api/model-limits", method="GET")
|
|
3932
|
+
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
|
|
3933
|
+
data = json.loads(resp.read().decode("utf-8", "replace") or "{}")
|
|
3934
|
+
except Exception as e: # noqa: BLE001
|
|
3935
|
+
_log(f"cloud token table not read ({type(e).__name__}); using the built-in table")
|
|
3936
|
+
return 0
|
|
3937
|
+
rows = []
|
|
3938
|
+
for r in (data.get("rows") if isinstance(data, dict) else None) or []:
|
|
3939
|
+
if not isinstance(r, dict) or not isinstance(r.get("model"), str) or not r.get("model"):
|
|
3940
|
+
continue
|
|
3941
|
+
host = str(r.get("host") or "").strip().lower()
|
|
3942
|
+
rows.append({
|
|
3943
|
+
"host": "" if host == "*" else host,
|
|
3944
|
+
"model": r["model"],
|
|
3945
|
+
"regex": r.get("origin") == "table",
|
|
3946
|
+
"ctx": r.get("contextWindow") if isinstance(r.get("contextWindow"), int) and r.get("contextWindow") > 0 else 0,
|
|
3947
|
+
"out": r.get("maxOutput") if isinstance(r.get("maxOutput"), int) and r.get("maxOutput") > 0 else 0,
|
|
3948
|
+
})
|
|
3949
|
+
_CLOUD_LIMITS["rows"] = rows
|
|
3950
|
+
_log(f"cloud token table: {len(rows)} rows")
|
|
3951
|
+
return len(rows)
|
|
3952
|
+
|
|
3953
|
+
|
|
3954
|
+
def _cloud_limit(model_id: str, field: str, base_url: str = "") -> int:
|
|
3955
|
+
"""The cloud table's value for this model (field "ctx" or "out"), or 0.
|
|
3956
|
+
|
|
3957
|
+
A row for THIS server beats an any-server row; an exact model id beats a family regex."""
|
|
3958
|
+
mid = (model_id or "").strip().lower()
|
|
3959
|
+
if not mid or not _CLOUD_LIMITS["rows"]:
|
|
3960
|
+
return 0
|
|
3961
|
+
try:
|
|
3962
|
+
from urllib.parse import urlparse
|
|
3963
|
+
host = (urlparse(base_url).hostname or "").lower() if base_url else ""
|
|
3964
|
+
except Exception: # noqa: BLE001
|
|
3965
|
+
host = ""
|
|
3966
|
+
best, best_rank = 0, -1
|
|
3967
|
+
for r in _CLOUD_LIMITS["rows"]:
|
|
3968
|
+
val = r.get(field) or 0
|
|
3969
|
+
if not val:
|
|
3970
|
+
continue
|
|
3971
|
+
if r["host"] and r["host"] != host:
|
|
3972
|
+
continue
|
|
3973
|
+
if r["regex"]:
|
|
3974
|
+
try:
|
|
3975
|
+
hit = re.match(r["model"], mid) is not None
|
|
3976
|
+
except re.error:
|
|
3977
|
+
hit = False
|
|
3978
|
+
else:
|
|
3979
|
+
hit = r["model"].strip().lower() == mid
|
|
3980
|
+
if not hit:
|
|
3981
|
+
continue
|
|
3982
|
+
rank = (2 if r["host"] else 0) + (1 if not r["regex"] else 0)
|
|
3983
|
+
if rank > best_rank:
|
|
3984
|
+
best, best_rank = val, rank
|
|
3985
|
+
return best
|
|
3986
|
+
|
|
3987
|
+
|
|
3594
3988
|
# "maximum context length is 128000 tokens" · "max_tokens is too large: 200000" ·
|
|
3595
3989
|
# "input length ... exceeds ... 32768" — every provider words it differently and every one of
|
|
3596
3990
|
# them names the number, which is the only part that matters.
|
|
@@ -7241,7 +7635,7 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
|
|
|
7241
7635
|
_srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
|
|
7242
7636
|
_emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
|
|
7243
7637
|
else:
|
|
7244
|
-
_emit({"type": "step", "text": f"Awaiting your approval to run: {command
|
|
7638
|
+
_emit({"type": "step", "text": f"Awaiting your approval to run: {_trim_words(command)}"})
|
|
7245
7639
|
deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
|
|
7246
7640
|
while _t.time() < deadline:
|
|
7247
7641
|
_t.sleep(1.2)
|
|
@@ -9900,6 +10294,10 @@ def run_agent_chat(cfg):
|
|
|
9900
10294
|
# Per-turn, like _compact_state above: the high-water mark that decides when a trim
|
|
9901
10295
|
# pass is worth breaking the prefix cache for. See _trim_due.
|
|
9902
10296
|
_trim_state: dict = {"next_at": _TRIM_FIRST_AT_CHARS}
|
|
10297
|
+
# Cloud token table (GET /api/model-limits): read before the window is resolved so the first
|
|
10298
|
+
# turn already uses it. Best effort, once per process; a failure only means the built-in table.
|
|
10299
|
+
if not _CLOUD_LIMITS["loaded"]:
|
|
10300
|
+
_fetch_cloud_limits(worker_api_base)
|
|
9903
10301
|
_ctx_window = _model_context_window(coding_base_url, coding_model_id, coding_api_key)
|
|
9904
10302
|
_compact_at = _compact_threshold(_ctx_window)
|
|
9905
10303
|
_caps = _set_obs_caps(_ctx_window)
|
|
@@ -10407,7 +10805,9 @@ def run_agent_chat(cfg):
|
|
|
10407
10805
|
self._apply_rate_limit()
|
|
10408
10806
|
t0 = time.monotonic()
|
|
10409
10807
|
_log("streamed generate: request issued (stream=True), awaiting first token…")
|
|
10410
|
-
|
|
10808
|
+
# Request-shape repair (max_tokens vs max_completion_tokens, sampling params the
|
|
10809
|
+
# model refuses, output cap named by the provider) — see _create_with_repair.
|
|
10810
|
+
stream = _create_with_repair(self.client, **completion_kwargs)
|
|
10411
10811
|
parts = []
|
|
10412
10812
|
rparts = [] # reasoning-channel text (task #111: may hold the <code> block)
|
|
10413
10813
|
role = "assistant"
|
|
@@ -13099,7 +13499,7 @@ def run_agent_chat(cfg):
|
|
|
13099
13499
|
_emit({"type": "step", "text": f"Server already up → {_urls} (reused)"})
|
|
13100
13500
|
_last_obs["text"] = msg
|
|
13101
13501
|
return msg
|
|
13102
|
-
_emit({"type": "step", "text": f"Running → {command
|
|
13502
|
+
_emit({"type": "step", "text": f"Running → {_trim_words(command)}"})
|
|
13103
13503
|
t = max(5, min(int(timeout_seconds or 180), 600))
|
|
13104
13504
|
# Own process group (start_new_session) + output to a log file. This is
|
|
13105
13505
|
# what lets a server OUTLIVE this job process, and what lets a timeout
|
|
@@ -13154,7 +13554,7 @@ def run_agent_chat(cfg):
|
|
|
13154
13554
|
if rc == 0 else f"FAILED (exit {rc})"
|
|
13155
13555
|
)
|
|
13156
13556
|
_emit({"type": "step", "text":
|
|
13157
|
-
f"Command {'finished' if rc == 0 else 'failed'} → {command
|
|
13557
|
+
f"Command {'finished' if rc == 0 else 'failed'} → {_trim_words(command)}"})
|
|
13158
13558
|
# SAY "no output" RATHER THAN SHOW NOTHING (task #204). An empty result
|
|
13159
13559
|
# was indistinguishable from a command that never ran — which is exactly
|
|
13160
13560
|
# what `cd X && Y` used to do — and the model read the silence as a
|
package/gu-repl.mjs
CHANGED
|
@@ -130,6 +130,7 @@ import {
|
|
|
130
130
|
shouldCancelPrompt,
|
|
131
131
|
rankSlashCommands,
|
|
132
132
|
slashMenuWindow,
|
|
133
|
+
trimWords,
|
|
133
134
|
} from "./terminal-layout.mjs";
|
|
134
135
|
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
135
136
|
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
@@ -1512,6 +1513,11 @@ function applyClick({ row, col }) {
|
|
|
1512
1513
|
void runPricesUpdate();
|
|
1513
1514
|
return;
|
|
1514
1515
|
}
|
|
1516
|
+
if (limitsRowTarget && row === updateRowAbs &&
|
|
1517
|
+
col - 1 >= limitsRowTarget.from && col - 1 < limitsRowTarget.to) {
|
|
1518
|
+
void runLimitsSeen();
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1515
1521
|
if (logsRowTarget && row === updateRowAbs &&
|
|
1516
1522
|
col - 1 >= logsRowTarget.from && col - 1 < logsRowTarget.to) {
|
|
1517
1523
|
void runClearLogs();
|
|
@@ -2814,6 +2820,7 @@ function zoneNotice(width) {
|
|
|
2814
2820
|
if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
|
|
2815
2821
|
}
|
|
2816
2822
|
if (priceUpdate) items.push({ key: "prices", clickable: true, paint: accent, text: `${priceUpdateText()} (click)` });
|
|
2823
|
+
if (limitsUpdate) items.push({ key: "limits", clickable: true, paint: accent, text: `${limitsUpdate.label} (click)` });
|
|
2817
2824
|
if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
|
|
2818
2825
|
const agents = agentsIndicator(otherAgents);
|
|
2819
2826
|
if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
|
|
@@ -2900,6 +2907,7 @@ function frozenZone() {
|
|
|
2900
2907
|
updateRowTarget = notice?.spans.update ?? null;
|
|
2901
2908
|
agentsRowTarget = notice?.spans.agents ?? null;
|
|
2902
2909
|
pricesRowTarget = notice?.spans.prices ?? null;
|
|
2910
|
+
limitsRowTarget = notice?.spans.limits ?? null;
|
|
2903
2911
|
logsRowTarget = notice?.spans.logs ?? null;
|
|
2904
2912
|
jumpSpan = notice?.spans.jump ?? null;
|
|
2905
2913
|
return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
|
|
@@ -2970,7 +2978,7 @@ function paintBar() {
|
|
|
2970
2978
|
// THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
|
|
2971
2979
|
const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
|
|
2972
2980
|
jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
|
|
2973
|
-
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || logsRowTarget ? noticeAbs : 0;
|
|
2981
|
+
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || limitsRowTarget || logsRowTarget ? noticeAbs : 0;
|
|
2974
2982
|
// preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
|
|
2975
2983
|
// is parked on the input line right after, so there is nothing to preserve here.
|
|
2976
2984
|
ui(
|
|
@@ -3132,6 +3140,8 @@ let updateRowAbs = 0;
|
|
|
3132
3140
|
let agentsRowTarget = null;
|
|
3133
3141
|
// …and "new prices updated <date>" (#323).
|
|
3134
3142
|
let pricesRowTarget = null;
|
|
3143
|
+
// …and "output token limits updated <date>" — the cloud token table changed.
|
|
3144
|
+
let limitsRowTarget = null;
|
|
3135
3145
|
// …and "clear logs · 175 MB" (#329), shown only once the logs are worth a row.
|
|
3136
3146
|
let logsRowTarget = null;
|
|
3137
3147
|
// A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
|
|
@@ -8095,6 +8105,88 @@ async function runPricesUpdate() {
|
|
|
8095
8105
|
}
|
|
8096
8106
|
}
|
|
8097
8107
|
|
|
8108
|
+
// ---------------------------------------------------------------------------------------------
|
|
8109
|
+
// "OUTPUT TOKEN LIMITS UPDATED <date>": the cloud token table (GET /api/model-limits, Mongo
|
|
8110
|
+
// "model_limits", pushed by the Deploy app's "Push token table to cloud" button) is checked at
|
|
8111
|
+
// start and hourly beside the version and price checks. When its updatedAt is newer than the one
|
|
8112
|
+
// this machine last saw (~/.gonext/model-limits-seen.json), a clickable item joins the notice
|
|
8113
|
+
// row. Nothing needs applying: the worker reads the cloud table on every run. The click lists
|
|
8114
|
+
// what changed and marks it seen.
|
|
8115
|
+
// ---------------------------------------------------------------------------------------------
|
|
8116
|
+
let limitsUpdate = null; // { updatedAt, label, rows } while unseen changes wait
|
|
8117
|
+
const limitsSeenFile = () => join(homedir(), ".gonext", "model-limits-seen.json");
|
|
8118
|
+
|
|
8119
|
+
async function readLimitsSeen() {
|
|
8120
|
+
try {
|
|
8121
|
+
const { readFile } = await import("node:fs/promises");
|
|
8122
|
+
return String(JSON.parse(await readFile(limitsSeenFile(), "utf8"))?.seenAt ?? "");
|
|
8123
|
+
} catch {
|
|
8124
|
+
return "";
|
|
8125
|
+
}
|
|
8126
|
+
}
|
|
8127
|
+
|
|
8128
|
+
async function fetchCloudLimits() {
|
|
8129
|
+
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
8130
|
+
const base = (String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
8131
|
+
try {
|
|
8132
|
+
const r = await fetch(`${base}/api/model-limits`, { signal: AbortSignal.timeout(8000) });
|
|
8133
|
+
if (!r.ok) return null;
|
|
8134
|
+
const j = await r.json().catch(() => null);
|
|
8135
|
+
return j && Array.isArray(j.rows) ? j : null;
|
|
8136
|
+
} catch {
|
|
8137
|
+
return null;
|
|
8138
|
+
}
|
|
8139
|
+
}
|
|
8140
|
+
|
|
8141
|
+
/** Did the cloud token table change since this machine looked? Fails silently to "no". */
|
|
8142
|
+
async function refreshLimitsUpdate() {
|
|
8143
|
+
if (process.env.GU_NO_UPDATE_CHECK === "1") return;
|
|
8144
|
+
try {
|
|
8145
|
+
const ln = await import("./model-limits-notice.mjs");
|
|
8146
|
+
const cloud = await fetchCloudLimits();
|
|
8147
|
+
if (!cloud) return;
|
|
8148
|
+
const seenAt = await readLimitsSeen();
|
|
8149
|
+
const was = Boolean(limitsUpdate);
|
|
8150
|
+
if (!seenAt && cloud.updatedAt) {
|
|
8151
|
+
// First run on this machine: the current table is the baseline, not news.
|
|
8152
|
+
await markLimitsSeen(cloud.updatedAt);
|
|
8153
|
+
limitsUpdate = null;
|
|
8154
|
+
} else {
|
|
8155
|
+
limitsUpdate = ln.hasNewerLimits(seenAt, cloud.updatedAt)
|
|
8156
|
+
? { updatedAt: cloud.updatedAt, label: ln.limitsUpdateLabel(cloud.updatedAt), rows: cloud.rows, seenAt }
|
|
8157
|
+
: null;
|
|
8158
|
+
}
|
|
8159
|
+
if (Boolean(limitsUpdate) !== was && barPinned()) paintBar();
|
|
8160
|
+
} catch {
|
|
8161
|
+
/* a limits notice is never worth an error */
|
|
8162
|
+
}
|
|
8163
|
+
}
|
|
8164
|
+
|
|
8165
|
+
async function markLimitsSeen(updatedAt) {
|
|
8166
|
+
try {
|
|
8167
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
8168
|
+
await mkdir(join(homedir(), ".gonext"), { recursive: true });
|
|
8169
|
+
await writeFile(limitsSeenFile(), JSON.stringify({ seenAt: updatedAt }) + "\n");
|
|
8170
|
+
} catch {
|
|
8171
|
+
/* not remembering only means the notice comes back */
|
|
8172
|
+
}
|
|
8173
|
+
}
|
|
8174
|
+
|
|
8175
|
+
/** The notice's click: say what changed, then mark it seen. */
|
|
8176
|
+
async function runLimitsSeen() {
|
|
8177
|
+
const u = limitsUpdate;
|
|
8178
|
+
if (!u) return;
|
|
8179
|
+
const ln = await import("./model-limits-notice.mjs");
|
|
8180
|
+
const c = ln.changedLimitLines(u.rows, u.seenAt);
|
|
8181
|
+
console.log("\n" + GUTTER + accent(`✓ ${u.label}`) +
|
|
8182
|
+
dim(` — ${c.count} row${c.count === 1 ? "" : "s"} changed in the cloud token table; gu uses them from the next turn.`));
|
|
8183
|
+
for (const l of c.lines) console.log(GUTTER + dim(" " + l));
|
|
8184
|
+
console.log("");
|
|
8185
|
+
await markLimitsSeen(u.updatedAt);
|
|
8186
|
+
limitsUpdate = null;
|
|
8187
|
+
if (barPinned()) paintBar();
|
|
8188
|
+
}
|
|
8189
|
+
|
|
8098
8190
|
// ---------------------------------------------------------------------------------------------
|
|
8099
8191
|
// "CLEAR LOGS" (#329): a quiet item at the right of the notice row once gu's logs are worth a row,
|
|
8100
8192
|
// and /clear-logs for where there is no mouse. A click deletes nothing: it MEASURES — the logs,
|
|
@@ -11421,7 +11513,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11421
11513
|
// long CPU-bound step reads as what it's actually reasoning about, not "Thinking")
|
|
11422
11514
|
// > the "almost done" heartbeat > the bare fallback.
|
|
11423
11515
|
const max = Math.max(24, (process.stdout.columns || 80) - 14);
|
|
11424
|
-
const fit = (s) => (s
|
|
11516
|
+
const fit = (s) => trimWords(s, max); // whole words + "…" (task #20)
|
|
11425
11517
|
// Prompt tokens this turn (task #83) + the peak single request (task #84). The global
|
|
11426
11518
|
// OUTPUT total (task #104) used to hang off this line too, but the bottom bar carries it
|
|
11427
11519
|
// now ("… total ↓ n") one row below — showing the same figure twice read as a bug.
|
|
@@ -12047,7 +12139,12 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12047
12139
|
continue;
|
|
12048
12140
|
}
|
|
12049
12141
|
const st = classifyBullet(stepText);
|
|
12050
|
-
|
|
12142
|
+
// A finished/failed command fits ONE row, trimmed at a word boundary with "…" (task #20).
|
|
12143
|
+
const mCmd = /^(Command (?:finished|failed) → )(.+)$/.exec(stepText);
|
|
12144
|
+
const shown = mCmd
|
|
12145
|
+
? mCmd[1] + trimWords(mCmd[2], Math.max(20, (process.stdout.columns || 80) - GUTTER.length - mCmd[1].length - 1))
|
|
12146
|
+
: stepText;
|
|
12147
|
+
const bRows = wrapBlock(shown, GUTTER, process.stdout.columns || 80);
|
|
12051
12148
|
// One empty line above EVERY bullet now (see openBullet), which SUBSUMES the old
|
|
12052
12149
|
// special case that opened only the turn's final phase — opensAnswerPhase's blank is
|
|
12053
12150
|
// just one of these. Still idempotent, so it cannot double the seam the caller owns.
|
|
@@ -13304,6 +13401,9 @@ async function main() {
|
|
|
13304
13401
|
// #323: gu's newest published price + cache tables — same cadence, same silent failure.
|
|
13305
13402
|
void refreshPriceUpdate();
|
|
13306
13403
|
setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
|
|
13404
|
+
// The cloud token table (model_limits): "output token limits updated <date>" — same cadence.
|
|
13405
|
+
void refreshLimitsUpdate();
|
|
13406
|
+
setInterval(() => void refreshLimitsUpdate(), UPDATE_CHECK_MS).unref();
|
|
13307
13407
|
void refreshLogsSize();
|
|
13308
13408
|
setInterval(() => void refreshLogsSize(), UPDATE_CHECK_MS).unref();
|
|
13309
13409
|
// The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "OUTPUT TOKEN LIMITS UPDATED <date>": the cloud token table (GET /api/model-limits, Mongo
|
|
3
|
+
* "model_limits", written by the Deploy app's "Push token table to cloud" button through
|
|
4
|
+
* PUT /api/model-limits) changed since this machine last looked.
|
|
5
|
+
*
|
|
6
|
+
* The worker already READS the cloud table at the start of every run, so nothing has to be
|
|
7
|
+
* applied. The notice is there so a change is VISIBLE. It sits in the same place as gu's other
|
|
8
|
+
* updates ("⭯ updates…", "new prices updated…"). A click lists what changed and marks it seen.
|
|
9
|
+
*
|
|
10
|
+
* Pure: the fetch, the clock and the file belong to gu-repl.mjs. A failed probe means "no notice".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
14
|
+
|
|
15
|
+
/** Is the cloud table's updatedAt newer than the one this machine saw? ("" seen = never.) */
|
|
16
|
+
export function hasNewerLimits(seenAt, updatedAt) {
|
|
17
|
+
const u = Date.parse(updatedAt ?? "");
|
|
18
|
+
if (!Number.isFinite(u)) return false;
|
|
19
|
+
const s = Date.parse(seenAt ?? "");
|
|
20
|
+
return !Number.isFinite(s) || u > s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The notice: "output token limits updated 5 Oct 2026". */
|
|
24
|
+
export function limitsUpdateLabel(updatedAt) {
|
|
25
|
+
const d = new Date(updatedAt ?? "");
|
|
26
|
+
if (Number.isNaN(d.getTime())) return "output token limits updated";
|
|
27
|
+
return `output token limits updated ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const fmt = (n) => (Number.isFinite(n) ? `${Math.round(n / 1000)}K` : "—");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The rows changed after `seenAt`, newest first, as lines:
|
|
34
|
+
* "claude-opus-5-5 · any host · max output 128K · window 1000K"
|
|
35
|
+
* Everything when nothing was seen yet. At most `max` lines, then "…and N more".
|
|
36
|
+
*/
|
|
37
|
+
export function changedLimitLines(rows, seenAt = "", { max = 12 } = {}) {
|
|
38
|
+
const s = Date.parse(seenAt ?? "");
|
|
39
|
+
const changed = (Array.isArray(rows) ? rows : [])
|
|
40
|
+
.filter((r) => r && r.model && (!Number.isFinite(s) || Date.parse(r.updatedAt ?? "") > s))
|
|
41
|
+
.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
|
|
42
|
+
const lines = changed.slice(0, max).map((r) => {
|
|
43
|
+
const parts = [r.model, r.host ? r.host : "any host"];
|
|
44
|
+
if (r.maxOutput) parts.push(`max output ${fmt(r.maxOutput)}`);
|
|
45
|
+
if (r.contextWindow) parts.push(`window ${fmt(r.contextWindow)}`);
|
|
46
|
+
return parts.join(" · ");
|
|
47
|
+
});
|
|
48
|
+
if (changed.length > max) lines.push(`…and ${changed.length - max} more`);
|
|
49
|
+
return { count: changed.length, lines };
|
|
50
|
+
}
|
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.805",
|
|
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",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"preset-editor.mjs",
|
|
74
74
|
"preset-apply.mjs",
|
|
75
75
|
"model-prices.mjs",
|
|
76
|
+
"model-limits-notice.mjs",
|
|
76
77
|
"device-login.mjs",
|
|
77
78
|
"daemon-control.mjs",
|
|
78
79
|
"log-policy.mjs",
|
package/terminal-layout.mjs
CHANGED
|
@@ -1007,6 +1007,31 @@ export const isToolStepSummary = (line) => / \| → /.test(line);
|
|
|
1007
1007
|
// Glyph vocabulary for an action line: a FAILURE pops (✗, full-bright), a completed
|
|
1008
1008
|
// action recedes (✓, dim), anything else is neutral (●, dim). Returns the KIND only —
|
|
1009
1009
|
// the REPL owns the colours, so this stays pure and testable.
|
|
1010
|
+
/**
|
|
1011
|
+
* Shorten a one-line command preview WITHOUT cutting mid-word (task #20): end on a whole
|
|
1012
|
+
* token (a full flag, a whole path segment) and add "…". Prefers a space; falls back to the
|
|
1013
|
+
* last "/" when the only space is far back (a long path); hard-cuts only one giant token.
|
|
1014
|
+
* A dangling shell operator (&&, |, ;) is dropped. Mirrors _trim_words in gonext_agent_chat.py.
|
|
1015
|
+
*/
|
|
1016
|
+
export function trimWords(text, limit) {
|
|
1017
|
+
const s = String(text ?? "").split(/\s+/).filter(Boolean).join(" ");
|
|
1018
|
+
if (s.length <= limit) return s;
|
|
1019
|
+
const budget = Math.max(1, limit - 1);
|
|
1020
|
+
let head;
|
|
1021
|
+
if (s[budget] === " ") head = s.slice(0, budget);
|
|
1022
|
+
else {
|
|
1023
|
+
const seg = s.slice(0, budget);
|
|
1024
|
+
const sp = seg.lastIndexOf(" ");
|
|
1025
|
+
const sl = seg.lastIndexOf("/");
|
|
1026
|
+
if (sp >= budget * 0.6 || (sp > 0 && sl <= sp)) head = seg.slice(0, sp);
|
|
1027
|
+
else if (sl > 0) head = seg.slice(0, sl + 1);
|
|
1028
|
+
else head = seg;
|
|
1029
|
+
}
|
|
1030
|
+
const toks = head.trimEnd().split(" ");
|
|
1031
|
+
while (toks.length > 1 && ["&&", "||", "|", ";", "\\", "-", "--"].includes(toks[toks.length - 1])) toks.pop();
|
|
1032
|
+
return toks.join(" ").trimEnd() + "…";
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1010
1035
|
export function bulletKind(text) {
|
|
1011
1036
|
if (/\bfail(ed|s|ure)?\b|\berror(ed)?\b|→\s*(?:HTTP\s*)?[45]\d\d\b/i.test(text)) return "fail";
|
|
1012
1037
|
if (/^(?:Command finished|Server up|App launched)\b|→\s*(?:HTTP\s*)?2\d\d\b|\b(?:passed|succeeded|ready)\b/i.test(text))
|