jeo-code 0.7.26 → 0.7.28
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/CHANGELOG.md +38 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/compaction.ts +24 -0
- package/src/agent/config-schema.ts +1 -0
- package/src/agent/engine.ts +20 -5
- package/src/agent/loop.ts +3 -0
- package/src/agent/state.ts +84 -5
- package/src/agent/task-tool.ts +5 -2
- package/src/ai/model-catalog.ts +23 -0
- package/src/ai/model-discovery.ts +6 -2
- package/src/ai/model-manager.ts +60 -13
- package/src/ai/providers/anthropic.ts +62 -18
- package/src/ai/providers/antigravity.ts +34 -22
- package/src/ai/providers/gemini.ts +81 -27
- package/src/ai/providers/kimi.ts +44 -5
- package/src/ai/providers/openai-responses.ts +36 -2
- package/src/ai/types.ts +10 -0
- package/src/auth/callback-server.ts +19 -5
- package/src/auth/flows/AGENTS.md +7 -6
- package/src/auth/flows/antigravity.ts +3 -0
- package/src/auth/flows/google-project.ts +8 -2
- package/src/auth/flows/google.ts +15 -1
- package/src/auth/flows/index.ts +10 -0
- package/src/auth/flows/kimi.ts +263 -0
- package/src/auth/flows/openai.ts +103 -3
- package/src/auth/oauth.ts +9 -0
- package/src/auth/refresh.ts +6 -1
- package/src/auth/storage.ts +5 -5
- package/src/commands/launch/input.ts +244 -9
- package/src/commands/launch/slash-views.ts +5 -2
- package/src/commands/launch.ts +64 -28
- package/src/commands/team.ts +3 -0
- package/src/tui/clipboard.ts +49 -0
- package/src/tui/components/welcome.ts +98 -39
- package/src/tui/terminal.ts +5 -3
- package/src/util/provider-error.ts +4 -12
- package/src/util/retry.ts +88 -31
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
2
4
|
import { renderForgeMark, FORGE_MARK_ART_GRAND } from "./ascii-art";
|
|
3
5
|
import { truncate, isTTY } from "../terminal";
|
|
4
|
-
import { detectColorLevel, ColorLevel } from "./color";
|
|
6
|
+
import { detectColorLevel, ColorLevel, visibleWidth } from "./color";
|
|
5
7
|
|
|
6
8
|
export interface WelcomeData {
|
|
7
9
|
version: string;
|
|
@@ -23,12 +25,8 @@ export interface WelcomeData {
|
|
|
23
25
|
color?: boolean; // default true
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
function getVisibleWidth(s: string): number {
|
|
27
|
-
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
28
|
function padLine(line: string, width: number, align: "left" | "center" | "right" = "left"): string {
|
|
31
|
-
const vis =
|
|
29
|
+
const vis = visibleWidth(line);
|
|
32
30
|
if (width <= 0 || vis >= width) return line;
|
|
33
31
|
const total = width - vis;
|
|
34
32
|
if (align === "right") return " ".repeat(total) + line;
|
|
@@ -39,12 +37,60 @@ function padLine(line: string, width: number, align: "left" | "center" | "right"
|
|
|
39
37
|
return line + " ".repeat(total);
|
|
40
38
|
}
|
|
41
39
|
|
|
40
|
+
function shortenPath(p: string | undefined, maxWidth: number, unicode: boolean): string {
|
|
41
|
+
if (!p) return "-";
|
|
42
|
+
let s = p;
|
|
43
|
+
const home = os.homedir();
|
|
44
|
+
if (home && (s === home || s.startsWith(home + path.sep))) s = "~" + s.slice(home.length);
|
|
45
|
+
if (visibleWidth(s) <= maxWidth) return s;
|
|
46
|
+
const ell = unicode ? "…" : "...";
|
|
47
|
+
return truncate(`${ell}${s.slice(-Math.max(1, maxWidth - visibleWidth(ell)))}`, maxWidth);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function metaRows(d: WelcomeData, width: number, unicode: boolean, useColor: boolean): string[] {
|
|
51
|
+
const labelWidth = 8;
|
|
52
|
+
const valueWidth = Math.max(1, width - labelWidth - 3);
|
|
53
|
+
const key = useColor ? chalk.dim : (s: string) => s;
|
|
54
|
+
const rows: string[] = [];
|
|
55
|
+
const push = (label: string, value: string): void => {
|
|
56
|
+
const lhs = key(padLine(label, labelWidth, "right"));
|
|
57
|
+
const rhs = truncate(value || "-", valueWidth);
|
|
58
|
+
rows.push(padLine(`${lhs} │ ${rhs}`, width));
|
|
59
|
+
};
|
|
60
|
+
push("version", `v${d.version}`);
|
|
61
|
+
push("folder", shortenPath(d.cwd, valueWidth, unicode));
|
|
62
|
+
push("model", d.model);
|
|
63
|
+
if (d.provider) push("provider", d.provider);
|
|
64
|
+
if (d.thinking) push("thinking", d.thinking);
|
|
65
|
+
if (d.sessionId) push("session", d.sessionId.slice(0, 8));
|
|
66
|
+
|
|
67
|
+
const files = d.contextFiles ?? [];
|
|
68
|
+
if (files.length > 0 && width >= 28) {
|
|
69
|
+
const shown = files.slice(0, width >= 34 ? 3 : 2).map(f => path.basename(f));
|
|
70
|
+
push("files", shown.join(", ") + (files.length > shown.length ? ` +${files.length - shown.length}` : ""));
|
|
71
|
+
}
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function centerColumn(lines: string[], width: number): string[] {
|
|
76
|
+
return lines.map(raw => padLine(truncate(raw, width), width, "center"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function zipColumns(left: string[], right: string[], leftWidth: number, gap: string, rightWidth: number): string[] {
|
|
80
|
+
const n = Math.max(left.length, right.length);
|
|
81
|
+
const out: string[] = [];
|
|
82
|
+
for (let i = 0; i < n; i++) {
|
|
83
|
+
out.push(`${padLine(left[i] ?? "", leftWidth)}${gap}${padLine(right[i] ?? "", rightWidth)}`);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
42
88
|
/**
|
|
43
89
|
* The gjc-style hero welcome box ("JEO forge"): one outer box with the version
|
|
44
|
-
* embedded in the top border
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
90
|
+
* embedded in the top border. Narrow terminals keep a single centered forge
|
|
91
|
+
* column; wide terminals split into a left hero mark and a right workspace table
|
|
92
|
+
* (folder, version, model, context files) so the launch capture uses the extra
|
|
93
|
+
* resolution instead of leaving a dead right side.
|
|
48
94
|
*/
|
|
49
95
|
export function renderWelcome(d: WelcomeData): string[] {
|
|
50
96
|
const cols = d.cols ?? 80;
|
|
@@ -80,11 +126,11 @@ export function renderWelcome(d: WelcomeData): string[] {
|
|
|
80
126
|
const dashStr = g.h.repeat(titleDashes);
|
|
81
127
|
const titleHead = `${dashStr}${titleLabel}`;
|
|
82
128
|
let topBorderLine: string;
|
|
83
|
-
if (titleHead
|
|
84
|
-
const clipped = titleHead
|
|
85
|
-
topBorderLine = lit(g.tl + clipped + g.h.repeat(Math.max(0, inner - clipped
|
|
129
|
+
if (visibleWidth(titleHead) + 2 > inner) {
|
|
130
|
+
const clipped = truncate(titleHead, inner);
|
|
131
|
+
topBorderLine = lit(g.tl + clipped + g.h.repeat(Math.max(0, inner - visibleWidth(clipped))) + g.tr);
|
|
86
132
|
} else {
|
|
87
|
-
const fill = g.h.repeat(inner - titleHead
|
|
133
|
+
const fill = g.h.repeat(inner - visibleWidth(titleHead));
|
|
88
134
|
topBorderLine = useColor
|
|
89
135
|
? lit(g.tl + dashStr) + chalk.bold(lit(titleLabel)) + lit(fill) + lit(g.tr)
|
|
90
136
|
: g.tl + titleHead + fill + g.tr;
|
|
@@ -93,44 +139,57 @@ export function renderWelcome(d: WelcomeData): string[] {
|
|
|
93
139
|
const bottomBorderPlain = g.bl + g.h.repeat(inner) + g.br;
|
|
94
140
|
const bottomBorderLine = shadow(bottomBorderPlain);
|
|
95
141
|
|
|
96
|
-
// Grand symbol when the box is wide enough; compact forge mark otherwise.
|
|
142
|
+
// Grand symbol when the box is wide enough; compact forge mark otherwise. In a
|
|
143
|
+
// split layout, art is constrained to the left pane so the right table remains stable.
|
|
97
144
|
const colorLevel = useColor ? detectColorLevel(process.env, isTTY()) : ColorLevel.None;
|
|
98
|
-
const
|
|
145
|
+
const wideMeta = inner >= 86;
|
|
146
|
+
const gap = wideMeta ? " " : "";
|
|
147
|
+
const rightWidth = wideMeta ? Math.min(38, Math.max(28, Math.floor(inner * 0.34))) : 0;
|
|
148
|
+
const leftWidth = wideMeta ? Math.max(32, inner - rightWidth - visibleWidth(gap)) : inner;
|
|
149
|
+
const grand = leftWidth >= grandWidth;
|
|
99
150
|
const artLines = renderForgeMark({
|
|
100
151
|
color: useColor,
|
|
101
152
|
phase: d.phase ?? 0,
|
|
102
153
|
unicode,
|
|
103
154
|
colorLevel,
|
|
104
155
|
grand,
|
|
105
|
-
cols:
|
|
156
|
+
cols: leftWidth,
|
|
106
157
|
});
|
|
107
158
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
159
|
+
const pill = (icon: string, text: string, paint: (s: string) => string): string => {
|
|
160
|
+
const p = truncate(`[ ${icon} ${text} ]`, leftWidth);
|
|
161
|
+
return useColor ? paint(p) : p;
|
|
162
|
+
};
|
|
163
|
+
const leftContent: string[] = [];
|
|
164
|
+
leftContent.push("");
|
|
165
|
+
leftContent.push(useColor ? chalk.bold.cyan("Jeo forge") : "Jeo forge");
|
|
166
|
+
leftContent.push(useColor ? chalk.dim("evolve · act · prove") : "evolve · act · prove");
|
|
167
|
+
leftContent.push("");
|
|
168
|
+
leftContent.push(...artLines);
|
|
169
|
+
leftContent.push("");
|
|
170
|
+
leftContent.push(pill(unicode ? "◆" : "*", d.model, chalk.cyan));
|
|
171
|
+
if (d.provider) leftContent.push(pill(unicode ? "◇" : "o", d.provider, chalk.blue));
|
|
172
|
+
leftContent.push("");
|
|
173
|
+
|
|
174
|
+
let content: string[];
|
|
175
|
+
if (wideMeta) {
|
|
176
|
+
const tableTitle = useColor ? chalk.bold("workspace") : "workspace";
|
|
177
|
+
const rightContent = [
|
|
178
|
+
"",
|
|
179
|
+
padLine(tableTitle, rightWidth, "center"),
|
|
180
|
+
(useColor ? chalk.dim : (s: string) => s)(padLine("forge context", rightWidth, "center")),
|
|
181
|
+
"",
|
|
182
|
+
...metaRows(d, rightWidth, unicode, useColor),
|
|
183
|
+
"",
|
|
184
|
+
];
|
|
185
|
+
content = zipColumns(centerColumn(leftContent, leftWidth), rightContent, leftWidth, gap, rightWidth);
|
|
186
|
+
} else {
|
|
187
|
+
content = centerColumn(leftContent, inner);
|
|
125
188
|
}
|
|
126
|
-
content.push("");
|
|
127
189
|
|
|
128
190
|
const leftBorder = lit(g.v);
|
|
129
191
|
const rightBorder = shadow(g.v);
|
|
130
|
-
const finalContentLines = content.map(raw =>
|
|
131
|
-
const line = padLine(truncate(raw, inner), inner, "center");
|
|
132
|
-
return leftBorder + line + rightBorder;
|
|
133
|
-
});
|
|
192
|
+
const finalContentLines = content.map(raw => leftBorder + padLine(truncate(raw, inner), inner) + rightBorder);
|
|
134
193
|
|
|
135
194
|
return [topBorderLine, ...finalContentLines, bottomBorderLine];
|
|
136
195
|
}
|
package/src/tui/terminal.ts
CHANGED
|
@@ -130,8 +130,10 @@ export function enableKittyKeyboard(): string {
|
|
|
130
130
|
return `${ESC}>1u`;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/**
|
|
134
|
-
*
|
|
133
|
+
/** Undo {@link enableKittyKeyboard} on exit. `CSI > 1 u` PUSHES an entry onto the
|
|
134
|
+
* terminal's keyboard-mode stack, so the counterpart is a POP (`CSI < u`) — pushing
|
|
135
|
+
* `>0u` instead would leak our entry and leave flags-0 stacked on top of whatever
|
|
136
|
+
* the shell/tmux had. Terminals without kitty-protocol support ignore both. */
|
|
135
137
|
export function disableKittyKeyboard(): string {
|
|
136
|
-
return `${ESC}
|
|
138
|
+
return `${ESC}<u`;
|
|
137
139
|
}
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* (carrying `.status` and a body), and the agent loop surfaces failures both as a
|
|
5
5
|
* thrown error and as a `doneReason`, so this lives in a shared util used by both.
|
|
6
6
|
*/
|
|
7
|
-
import { isUsageLimitError } from "./retry";
|
|
7
|
+
import { isUsageLimitError, isRefusalError } from "./retry";
|
|
8
|
+
// Re-export: engine.ts and callers import the refusal predicate from here; the
|
|
9
|
+
// definition lives in retry.ts so defaultRetryable can fail fast without a cycle.
|
|
10
|
+
export { isRefusalError } from "./retry";
|
|
8
11
|
|
|
9
12
|
/** Provider-authoritative context-overflow signal (HTTP 400/413 family). The
|
|
10
13
|
* local token estimate can drift under the real count (images, tokenizer
|
|
@@ -18,17 +21,6 @@ export function isContextOverflowError(err: unknown): boolean {
|
|
|
18
21
|
return status === 413; // payload-too-large is always an overflow signal
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
/** Provider safety-refusal signal: an HTTP-200 completion that returned NO
|
|
22
|
-
* content because the model/provider declined (Anthropic `stop_reason=refusal`,
|
|
23
|
-
* OpenAI `finish_reason=content_filter`, Gemini `SAFETY`/`PROHIBITED_CONTENT`
|
|
24
|
-
* block reasons). On routine coding work these are usually transient
|
|
25
|
-
* false-positives — the engine retries the step (bounded) instead of killing
|
|
26
|
-
* the turn with a dead "Error: … returned no content". */
|
|
27
|
-
export function isRefusalError(err: unknown): boolean {
|
|
28
|
-
const msg = (err as Error)?.message ?? String(err);
|
|
29
|
-
return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)/i.test(msg);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
24
|
function formatDuration(ms: number): string {
|
|
33
25
|
const totalSeconds = Math.ceil(ms / 1000);
|
|
34
26
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
package/src/util/retry.ts
CHANGED
|
@@ -7,8 +7,14 @@ export interface RetryOptions {
|
|
|
7
7
|
random?: () => number; // injectable RNG for jitter (default Math.random); equal-jitter in [0.5x, 1x]
|
|
8
8
|
/** Notified before each backoff wait; `delayMs` is the wait actually applied. */
|
|
9
9
|
onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
|
|
10
|
+
/** Aborts an in-progress backoff wait (e.g. Ctrl-C / turn cancel). Already-aborted or
|
|
11
|
+
* aborted-mid-wait rejects immediately instead of completing the sleep — the escape hatch
|
|
12
|
+
* for a long, honored provider `Retry-After` (see `rateLimitMaxServerDelayMs`: those are no
|
|
13
|
+
* longer capped by default). Does NOT cancel the in-flight `fn()` call itself; thread the
|
|
14
|
+
* same signal into the request (e.g. `fetch`) for that. */
|
|
15
|
+
signal?: AbortSignal;
|
|
10
16
|
/** Minimum backoff (ms) applied specifically to rate-limit (429) errors. The floor
|
|
11
|
-
* ESCALATES per attempt (floor × 2^(attempt-1), capped at
|
|
17
|
+
* ESCALATES per attempt (floor × 2^(attempt-1), capped at RATE_LIMIT_FLOOR_CAP_MS) because a
|
|
12
18
|
* rate-limit window rarely clears in <1s and often needs tens of seconds — a flat
|
|
13
19
|
* sub-second retry cadence just burns the budget; default 0 (no floor) preserves
|
|
14
20
|
* generic behavior. */
|
|
@@ -17,9 +23,13 @@ export interface RetryOptions {
|
|
|
17
23
|
* When higher than `retries`, rate-limit errors get extra attempts so a transient
|
|
18
24
|
* per-minute window can reset; non-rate-limit errors still use `retries`. */
|
|
19
25
|
rateLimitRetries?: number;
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
26
|
+
/** Opt-in ceiling on a 429's server-directed retry delay: set it and a delay beyond it fails
|
|
27
|
+
* fast with the original error instead of sleeping. Unset (the default) honors ANY
|
|
28
|
+
* server-directed delay in full, however long — gjc parity: a generic rate limit is retried
|
|
29
|
+
* forever, not treated as fatal past some arbitrary budget. Usage/quota-limit errors
|
|
30
|
+
* (`isUsageLimitError`) are unaffected either way: `defaultRetryable` already classifies
|
|
31
|
+
* them non-retryable before this wait is ever computed. Pair with `signal` so an unbounded
|
|
32
|
+
* wait stays user-cancellable. */
|
|
23
33
|
rateLimitMaxServerDelayMs?: number;
|
|
24
34
|
}
|
|
25
35
|
|
|
@@ -38,6 +48,14 @@ export function defaultRetryable(err: unknown): boolean {
|
|
|
38
48
|
if (isUsageLimitError(err)) {
|
|
39
49
|
return false;
|
|
40
50
|
}
|
|
51
|
+
// Safety refusals are DETERMINISTIC for identical conversation content: the
|
|
52
|
+
// classifier trips on what's IN the context, so a transport-level resend of the
|
|
53
|
+
// same payload re-refuses every time (observed: each engine ladder rung burned
|
|
54
|
+
// 2 extra billed calls before this check). Fail fast — the engine's refusal
|
|
55
|
+
// ladder owns recovery because it mutates the context between attempts.
|
|
56
|
+
if (isRefusalError(err)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
41
59
|
|
|
42
60
|
let message = "";
|
|
43
61
|
if (err instanceof Error) {
|
|
@@ -94,37 +112,45 @@ export function defaultRetryable(err: unknown): boolean {
|
|
|
94
112
|
return false;
|
|
95
113
|
}
|
|
96
114
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
//
|
|
115
|
+
// Synthetic per-minute floor for a 429 that carries NO server `Retry-After` (see
|
|
116
|
+
// `rateLimitMinDelayMs`) — caps the escalating floor so a SILENT rate limit still waits a
|
|
117
|
+
// realistic window instead of ~8s. Does NOT cap a genuine server-directed Retry-After: a
|
|
118
|
+
// provider that names its own wait is authoritative and is honored in full (gjc parity);
|
|
119
|
+
// `rateLimitMaxServerDelayMs` is the opt-in ceiling for a caller that truly cannot wait that long.
|
|
120
|
+
const RATE_LIMIT_FLOOR_CAP_MS = 30_000;
|
|
100
121
|
|
|
101
122
|
// Run fn; on a retryable error, back off and retry up to `retries` attempts. Backoff is
|
|
102
|
-
// exponential (baseDelay * 2^(attempt-1), capped at maxDelay) with equal jitter (the wait lands
|
|
103
|
-
// [0.5x, 1x] of that cap)
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
123
|
+
// exponential (baseDelay * 2^(attempt-1), capped at maxDelay) with equal jitter (the wait lands
|
|
124
|
+
// in [0.5x, 1x] of that cap). A server-directed `retryAfterMs` (Retry-After) wins over the
|
|
125
|
+
// jitter and is honored IN FULL — no cap — unless `rateLimitMaxServerDelayMs` is explicitly set
|
|
126
|
+
// and exceeded, in which case the original error is re-thrown immediately instead of sleeping.
|
|
127
|
+
// `opts.signal` can abort an in-progress wait, so an unbounded honored delay stays cancellable.
|
|
107
128
|
export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T> {
|
|
108
129
|
const retries = opts?.retries ?? 3;
|
|
109
130
|
const baseDelayMs = opts?.baseDelayMs ?? 250;
|
|
110
131
|
const maxDelayMs = opts?.maxDelayMs ?? 4000;
|
|
111
132
|
const isRetryable = opts?.isRetryable ?? defaultRetryable;
|
|
112
|
-
const sleep = opts?.sleep ?? ((ms: number) =>
|
|
133
|
+
const sleep = opts?.sleep ?? ((ms: number) => {
|
|
134
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
135
|
+
setTimeout(resolve, ms);
|
|
136
|
+
return promise;
|
|
137
|
+
});
|
|
113
138
|
const random = opts?.random ?? Math.random;
|
|
114
139
|
const onRetry = opts?.onRetry;
|
|
115
140
|
const rateLimitMinDelayMs = opts?.rateLimitMinDelayMs ?? 0;
|
|
116
141
|
const rateLimitRetries = opts?.rateLimitRetries;
|
|
117
142
|
const rateLimitMaxServerDelayMs = opts?.rateLimitMaxServerDelayMs;
|
|
143
|
+
const signal = opts?.signal;
|
|
118
144
|
|
|
119
145
|
let attempt = 1;
|
|
120
146
|
while (true) {
|
|
121
147
|
try {
|
|
122
148
|
return await fn();
|
|
123
149
|
} catch (err) {
|
|
124
|
-
// Rate-limit (429) errors may use a higher attempt cap so a transient
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
150
|
+
// Rate-limit (429) errors may use a higher attempt cap so a transient per-minute window
|
|
151
|
+
// can reset before we surface the failure. `rateLimitMaxServerDelayMs` is opt-in
|
|
152
|
+
// (unset by default): only when explicitly configured and the server's own wait exceeds
|
|
153
|
+
// it do we fail fast instead of sleeping through a window the caller declared too long.
|
|
128
154
|
const rateLimited = isRateLimitError(err);
|
|
129
155
|
const serverDelay = retryAfterOf(err);
|
|
130
156
|
if (
|
|
@@ -143,15 +169,15 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
|
|
|
143
169
|
const capped = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
|
|
144
170
|
// Equal jitter: half fixed + half random → [0.5x, 1x] of the capped backoff.
|
|
145
171
|
const jittered = capped / 2 + random() * (capped / 2);
|
|
146
|
-
// Server `Retry-After` wins (
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
const base =
|
|
172
|
+
// Server `Retry-After` wins, honored IN FULL (no cap — gjc parity: even a multi-hour
|
|
173
|
+
// provider-directed window is retried, never silently compressed). For rate limits,
|
|
174
|
+
// apply the floor in BOTH cases so a 0/near-0 Retry-After (or sub-second jitter) doesn't
|
|
175
|
+
// burn the 429 budget back-to-back with no real pause. The floor escalates per attempt
|
|
176
|
+
// (×2 each retry, capped at RATE_LIMIT_FLOOR_CAP_MS) so a SYNTHETIC (server-silent) wait
|
|
177
|
+
// spans a realistic rate-limit window (~a minute) instead of ~8s.
|
|
178
|
+
const base = serverDelay !== undefined ? serverDelay : jittered;
|
|
153
179
|
const floor = rateLimited
|
|
154
|
-
? Math.min(rateLimitMinDelayMs * Math.pow(2, attempt - 1),
|
|
180
|
+
? Math.min(rateLimitMinDelayMs * Math.pow(2, attempt - 1), RATE_LIMIT_FLOOR_CAP_MS)
|
|
155
181
|
: 0;
|
|
156
182
|
const delay = Math.max(base, floor);
|
|
157
183
|
|
|
@@ -159,12 +185,29 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
|
|
|
159
185
|
onRetry(attempt, err, delay);
|
|
160
186
|
}
|
|
161
187
|
|
|
162
|
-
await sleep
|
|
188
|
+
await abortableSleep(sleep, delay, signal);
|
|
163
189
|
attempt++;
|
|
164
190
|
}
|
|
165
191
|
}
|
|
166
192
|
}
|
|
167
193
|
|
|
194
|
+
/** Race `sleep(ms)` against `signal`; an already-aborted or mid-wait-aborted signal rejects
|
|
195
|
+
* immediately with its abort reason instead of completing the wait. Keeps a long, honored
|
|
196
|
+
* server-directed retry delay (see `withRetry`) user-cancellable even though it is no longer
|
|
197
|
+
* capped. A no-op passthrough when no signal is supplied (existing callers/tests unaffected). */
|
|
198
|
+
function abortableSleep(sleep: (ms: number) => Promise<void>, ms: number, signal?: AbortSignal): Promise<void> {
|
|
199
|
+
if (!signal) return sleep(ms);
|
|
200
|
+
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
|
|
201
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
202
|
+
const onAbort = () => reject(signal.reason ?? new Error("aborted"));
|
|
203
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
204
|
+
sleep(ms).then(
|
|
205
|
+
value => { signal.removeEventListener("abort", onAbort); resolve(value); },
|
|
206
|
+
err => { signal.removeEventListener("abort", onAbort); reject(err); },
|
|
207
|
+
);
|
|
208
|
+
return promise;
|
|
209
|
+
}
|
|
210
|
+
|
|
168
211
|
// Extract a non-negative `retryAfterMs` from an error, if it carries one (e.g. ProviderHttpError).
|
|
169
212
|
function retryAfterOf(err: unknown): number | undefined {
|
|
170
213
|
if (typeof err === "object" && err !== null) {
|
|
@@ -197,12 +240,26 @@ function errorMessageOf(err: unknown): string {
|
|
|
197
240
|
|
|
198
241
|
/**
|
|
199
242
|
* Persistent usage/quota-limit detection (gjc parity: `isUsageLimitError`). These are
|
|
200
|
-
* subscription/window limits ("usage limit reached", "quota exceeded"
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
243
|
+
* subscription/window limits ("usage limit reached", "quota exceeded", model/message
|
|
244
|
+
* window limits, credit exhaustion, Anthropic ACCOUNT-level exhaustion) that need a
|
|
245
|
+
* model or credential switch — retrying within seconds is pure waste, unlike a
|
|
246
|
+
* per-minute 429. Bare "resource exhausted" (gRPC capacity wording) stays transient;
|
|
247
|
+
* only the qualified "Resource has been exhausted (… quota/limit)" form is persistent
|
|
248
|
+
* (gjc fixture parity: rate-limit-utils.test.ts).
|
|
204
249
|
*/
|
|
205
|
-
const USAGE_LIMIT_PATTERN = /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|exceeded your/i;
|
|
250
|
+
const USAGE_LIMIT_PATTERN = /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|model.?limit|message.?limit|limit for this model|quota.?exceeded|out_of_credits|request would exceed your account.?s rate limit|resource has been exhausted[^\n]*(?:quota|limit)|exceeded your/i;
|
|
206
251
|
export function isUsageLimitError(err: unknown): boolean {
|
|
207
252
|
return USAGE_LIMIT_PATTERN.test(errorMessageOf(err));
|
|
208
253
|
}
|
|
254
|
+
|
|
255
|
+
/** Provider safety-refusal signal: an HTTP-200 completion that returned NO
|
|
256
|
+
* content because the model/provider declined (Anthropic `stop_reason=refusal`,
|
|
257
|
+
* OpenAI `finish_reason=content_filter`, Gemini `SAFETY`/`PROHIBITED_CONTENT`
|
|
258
|
+
* block reasons). Lives here (not provider-error.ts) so `defaultRetryable` can
|
|
259
|
+
* fail fast on it: a refusal is DETERMINISTIC for the same conversation content —
|
|
260
|
+
* transport-level resends of an identical payload just burn billed calls. The
|
|
261
|
+
* engine's bounded refusal ladder (resend → context reset → guidance strip) is
|
|
262
|
+
* the correct recovery layer because it MUTATES the context between attempts. */
|
|
263
|
+
export function isRefusalError(err: unknown): boolean {
|
|
264
|
+
return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)/i.test(errorMessageOf(err));
|
|
265
|
+
}
|