clauderipple 0.2.0 → 0.3.1
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 +96 -0
- package/README.ko.md +48 -4
- package/README.md +58 -4
- package/dist/cli/src/claude-auth.js +3 -2
- package/dist/cli/src/codex.js +20 -1
- package/dist/cli/src/hooks/agent-title.js +1 -1
- package/dist/cli/src/index.js +4 -4
- package/dist/cli/src/schtasks.js +43 -1
- package/dist/cli/src/settings.js +73 -6
- package/dist/cli/src/tray.js +17 -2
- package/dist/router/src/admin.js +489 -56
- package/dist/router/src/agents.js +250 -0
- package/dist/router/src/bootstrap.js +24 -8
- package/dist/router/src/capabilities.js +214 -0
- package/dist/router/src/compat.js +5 -1
- package/dist/router/src/config.js +264 -11
- package/dist/router/src/index.js +14 -1
- package/dist/router/src/ingress/server.js +24 -14
- package/dist/router/src/picker.js +14 -6
- package/dist/router/src/pool.js +233 -0
- package/dist/router/src/presets.js +163 -2
- package/dist/router/src/providers/anthropic-account-pool.js +139 -0
- package/dist/router/src/providers/anthropic-accounts.js +281 -0
- package/dist/router/src/providers/chatgpt/catalog.js +97 -0
- package/dist/router/src/providers/chatgpt/index.js +343 -12
- package/dist/router/src/providers/chatgpt/sse.js +4 -0
- package/dist/router/src/providers/chatgpt/translate.js +156 -14
- package/dist/router/src/providers/claude-oauth.js +61 -19
- package/dist/router/src/providers/openai/index.js +55 -11
- package/dist/router/src/providers/openai/translate.js +82 -14
- package/dist/router/src/providers/retry.js +88 -0
- package/dist/router/src/proxy.js +713 -82
- package/dist/router/src/requestlog.js +5 -2
- package/dist/router/src/routing.js +151 -17
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +307 -0
- package/dist/router/src/x509.js +7 -2
- package/dist/ui/app.js +740 -160
- package/dist/ui/i18n.js +14 -6
- package/dist/ui/index.html +18 -5
- package/dist/ui/presets-fallback.js +2 -0
- package/dist/ui/style.css +133 -9
- package/docs/ARCHITECTURE.md +381 -20
- package/package.json +5 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// stable prompt prefix can retain their native prompt-cache behavior.
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
import { clampEffort } from "../../compat.js";
|
|
6
|
-
import { conversationKey, estimateTokens, normalizeSchema, systemText } from "../chatgpt/translate.js";
|
|
6
|
+
import { conversationKey, estimateTokens, normalizeSchema, serverToolNames, systemText, toolNameForResponses } from "../chatgpt/translate.js";
|
|
7
7
|
import { identityPrefix, instructionsSuffix } from "../../identity.js";
|
|
8
8
|
function textOf(content) {
|
|
9
9
|
if (typeof content === "string")
|
|
@@ -11,10 +11,19 @@ function textOf(content) {
|
|
|
11
11
|
if (!Array.isArray(content))
|
|
12
12
|
return "";
|
|
13
13
|
return content
|
|
14
|
-
.
|
|
14
|
+
.filter((block) => block.type === "text")
|
|
15
|
+
.map((block) => String(block.text ?? ""))
|
|
15
16
|
.filter((text) => text.length > 0)
|
|
16
17
|
.join("\n");
|
|
17
18
|
}
|
|
19
|
+
function imagesOf(content) {
|
|
20
|
+
if (!Array.isArray(content))
|
|
21
|
+
return [];
|
|
22
|
+
return content
|
|
23
|
+
.filter((block) => block.type === "image")
|
|
24
|
+
.map(imageUrl)
|
|
25
|
+
.filter((url) => url !== null);
|
|
26
|
+
}
|
|
18
27
|
function imageUrl(block) {
|
|
19
28
|
const source = block.source;
|
|
20
29
|
if (!source || typeof source !== "object")
|
|
@@ -25,10 +34,17 @@ function imageUrl(block) {
|
|
|
25
34
|
return source.url;
|
|
26
35
|
return null;
|
|
27
36
|
}
|
|
37
|
+
// Anthropic's server-side tools cannot run here; see the rule in the ChatGPT translator.
|
|
28
38
|
function functionTools(tools) {
|
|
39
|
+
const dropped = serverToolNames(tools);
|
|
29
40
|
return (tools ?? [])
|
|
30
|
-
.filter((tool) => typeof tool.name === "string")
|
|
31
|
-
.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description ?? "", parameters: normalizeSchema(tool.input_schema) } }));
|
|
41
|
+
.filter((tool) => typeof tool.name === "string" && !dropped.has(tool.name))
|
|
42
|
+
.map((tool) => ({ type: "function", function: { name: toolNameForResponses(tool.name), description: tool.description ?? "", parameters: normalizeSchema(tool.input_schema) } }));
|
|
43
|
+
}
|
|
44
|
+
/** A choice that named a dropped tool would force the model onto something no longer declared. */
|
|
45
|
+
function choiceSurvives(req) {
|
|
46
|
+
const choice = req.tool_choice;
|
|
47
|
+
return choice?.type === "tool" && !!choice.name && !serverToolNames(req.tools).has(choice.name);
|
|
32
48
|
}
|
|
33
49
|
function mapToolChoice(req, tools) {
|
|
34
50
|
if (!tools.length)
|
|
@@ -40,7 +56,7 @@ function mapToolChoice(req, tools) {
|
|
|
40
56
|
return "required";
|
|
41
57
|
if (choice.type === "none")
|
|
42
58
|
return "none";
|
|
43
|
-
return
|
|
59
|
+
return choiceSurvives(req) ? { type: "function", function: { name: toolNameForResponses(choice.name) } } : undefined;
|
|
44
60
|
}
|
|
45
61
|
function mapResponsesToolChoice(req, tools) {
|
|
46
62
|
if (!tools.length)
|
|
@@ -52,7 +68,7 @@ function mapResponsesToolChoice(req, tools) {
|
|
|
52
68
|
return "required";
|
|
53
69
|
if (choice.type === "none")
|
|
54
70
|
return "none";
|
|
55
|
-
return
|
|
71
|
+
return choiceSurvives(req) ? { type: "function", name: toolNameForResponses(choice.name) } : undefined;
|
|
56
72
|
}
|
|
57
73
|
/** The system text this provider should see: what it is, the caller's prompt, the configured addendum. */
|
|
58
74
|
function systemWithIdentity(sys, opts) {
|
|
@@ -94,7 +110,7 @@ export function toChatMessages(req, opts) {
|
|
|
94
110
|
if (block.type === "tool_use") {
|
|
95
111
|
const call = block;
|
|
96
112
|
knownCalls.add(call.id);
|
|
97
|
-
calls.push({ id: call.id, type: "function", function: { name: call.name, arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) } });
|
|
113
|
+
calls.push({ id: call.id, type: "function", function: { name: toolNameForResponses(call.name), arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) } });
|
|
98
114
|
}
|
|
99
115
|
}
|
|
100
116
|
if (text.some(Boolean) || calls.length)
|
|
@@ -124,12 +140,17 @@ export function toChatMessages(req, opts) {
|
|
|
124
140
|
flush();
|
|
125
141
|
const result = block;
|
|
126
142
|
let output = textOf(result.content);
|
|
143
|
+
const images = imagesOf(result.content);
|
|
127
144
|
if (result.is_error && !output)
|
|
128
145
|
output = "Tool execution failed";
|
|
146
|
+
if (!output && images.length > 0)
|
|
147
|
+
output = "Tool returned image content.";
|
|
129
148
|
if (knownCalls.has(result.tool_use_id))
|
|
130
149
|
messages.push({ role: "tool", tool_call_id: result.tool_use_id, content: output });
|
|
131
150
|
else
|
|
132
151
|
parts.push({ type: "text", text: `[Tool result]\n${output}` });
|
|
152
|
+
for (const url of images)
|
|
153
|
+
parts.push({ type: "image_url", image_url: { url } });
|
|
133
154
|
}
|
|
134
155
|
}
|
|
135
156
|
flush();
|
|
@@ -170,18 +191,23 @@ export function toResponsesInput(req) {
|
|
|
170
191
|
flush();
|
|
171
192
|
const call = block;
|
|
172
193
|
knownCalls.add(call.id);
|
|
173
|
-
input.push({ type: "function_call", call_id: call.id, name: call.name, arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) });
|
|
194
|
+
input.push({ type: "function_call", call_id: call.id, name: toolNameForResponses(call.name), arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) });
|
|
174
195
|
}
|
|
175
196
|
else if (block.type === "tool_result") {
|
|
176
197
|
flush();
|
|
177
198
|
const result = block;
|
|
178
199
|
let output = textOf(result.content);
|
|
200
|
+
const images = imagesOf(result.content);
|
|
179
201
|
if (result.is_error && !output)
|
|
180
202
|
output = "Tool execution failed";
|
|
203
|
+
if (!output && images.length > 0)
|
|
204
|
+
output = "Tool returned image content.";
|
|
181
205
|
if (knownCalls.has(result.tool_use_id))
|
|
182
206
|
input.push({ type: "function_call_output", call_id: result.tool_use_id, output });
|
|
183
207
|
else
|
|
184
208
|
parts.push({ type: "input_text", text: `[Tool result]\n${output}` });
|
|
209
|
+
for (const image_url of images)
|
|
210
|
+
parts.push({ type: "input_image", image_url });
|
|
185
211
|
}
|
|
186
212
|
}
|
|
187
213
|
flush();
|
|
@@ -232,8 +258,13 @@ export function toOpenAiRequest(req, opts) {
|
|
|
232
258
|
if (choice)
|
|
233
259
|
out.tool_choice = choice;
|
|
234
260
|
}
|
|
261
|
+
// Responses will not accept a cap below sixteen, and answers a smaller one with a 400 naming the
|
|
262
|
+
// parameter. Anthropic Messages has no such floor, so a client that asks for a single token is
|
|
263
|
+
// asking something legal that this wire cannot express: Claude Code checks a model by requesting
|
|
264
|
+
// one token, and every switch to a Responses model failed on it (measured 2026-09-22). Raise the
|
|
265
|
+
// floor rather than pass the refusal on — a cap is a limit, and a larger one still obeys it.
|
|
235
266
|
if (typeof req.max_tokens === "number")
|
|
236
|
-
out.max_output_tokens = req.max_tokens;
|
|
267
|
+
out.max_output_tokens = Math.max(16, req.max_tokens);
|
|
237
268
|
if (typeof req.temperature === "number")
|
|
238
269
|
out.temperature = req.temperature;
|
|
239
270
|
if (effort)
|
|
@@ -257,11 +288,24 @@ export class OpenAiStreamMapper {
|
|
|
257
288
|
stopReason = "end_turn";
|
|
258
289
|
model;
|
|
259
290
|
startInput;
|
|
260
|
-
|
|
291
|
+
/** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
|
|
292
|
+
toolNames;
|
|
293
|
+
constructor(model, startInput = 0, toolNames = new Map()) {
|
|
261
294
|
this.model = model;
|
|
262
295
|
this.startInput = startInput;
|
|
296
|
+
this.toolNames = toolNames;
|
|
263
297
|
}
|
|
264
298
|
get isFinished() { return this.finished; }
|
|
299
|
+
/**
|
|
300
|
+
* Whether the vendor said the answer was over: `response.completed`/`response.incomplete` on
|
|
301
|
+
* Responses, a `finish_reason` on Chat. A stream that closes without either was cut off, and
|
|
302
|
+
* finishing it as `end_turn` hands the client an empty or half answer it accepts as final — the
|
|
303
|
+
* worker that "stalls and dies" (muse, 84 of 1,815 turns, 2026-09-18..22).
|
|
304
|
+
*/
|
|
305
|
+
get completed() { return this.sawCompletion; }
|
|
306
|
+
sawCompletion = false;
|
|
307
|
+
/** The error `fail` reported, so a non-streaming caller can answer with it instead of a 200. */
|
|
308
|
+
failure;
|
|
265
309
|
start() {
|
|
266
310
|
if (this.started)
|
|
267
311
|
return [];
|
|
@@ -294,11 +338,15 @@ export class OpenAiStreamMapper {
|
|
|
294
338
|
const cached = typeof cachedValue === "number" && Number.isFinite(cachedValue) ? Math.max(0, cachedValue) : 0;
|
|
295
339
|
this.usage = { input_tokens: Math.max(0, (input ?? this.usage.input_tokens + cached) - cached), output_tokens: Math.max(0, output ?? this.usage.output_tokens), cache_read_input_tokens: cached, cache_creation_input_tokens: 0 };
|
|
296
340
|
}
|
|
341
|
+
/** The vendor echoes the name it was given; Claude Code only recognises the original. */
|
|
342
|
+
restore(name) {
|
|
343
|
+
return this.toolNames.get(name) ?? name;
|
|
344
|
+
}
|
|
297
345
|
toolFor(index, delta) {
|
|
298
346
|
let tool = this.content.find((block) => block.type === "tool_use" && block.index === index);
|
|
299
347
|
if (!tool) {
|
|
300
348
|
const id = typeof delta.id === "string" ? delta.id : `call_${crypto.randomBytes(8).toString("hex")}`;
|
|
301
|
-
const name = typeof delta.function?.name === "string" ? delta.function.name : "tool";
|
|
349
|
+
const name = typeof delta.function?.name === "string" ? this.restore(delta.function.name) : "tool";
|
|
302
350
|
tool = { type: "tool_use", id, name, input: {}, args: "", index };
|
|
303
351
|
this.content.push(tool);
|
|
304
352
|
this.sawTool = true;
|
|
@@ -306,7 +354,7 @@ export class OpenAiStreamMapper {
|
|
|
306
354
|
if (typeof delta.id === "string")
|
|
307
355
|
tool.id = delta.id;
|
|
308
356
|
if (typeof delta.function?.name === "string")
|
|
309
|
-
tool.name = delta.function.name;
|
|
357
|
+
tool.name = this.restore(delta.function.name);
|
|
310
358
|
return tool;
|
|
311
359
|
}
|
|
312
360
|
feedChat(ev) {
|
|
@@ -317,6 +365,20 @@ export class OpenAiStreamMapper {
|
|
|
317
365
|
const choices = Array.isArray(ev.choices) ? ev.choices : [];
|
|
318
366
|
for (const choice of choices) {
|
|
319
367
|
const delta = choice.delta ?? {};
|
|
368
|
+
// Thinking arrives before the answer, so it opens the first block of the turn. `reasoning` is
|
|
369
|
+
// accepted alongside `reasoning_content` because vendors on this wire disagree on the name.
|
|
370
|
+
const reasoning = typeof delta.reasoning_content === "string" && delta.reasoning_content ? delta.reasoning_content
|
|
371
|
+
: typeof delta.reasoning === "string" && delta.reasoning ? delta.reasoning : "";
|
|
372
|
+
if (reasoning) {
|
|
373
|
+
if (!this.open || this.open.kind !== "thinking") {
|
|
374
|
+
this.content.push({ type: "thinking", thinking: "" });
|
|
375
|
+
out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
|
|
376
|
+
}
|
|
377
|
+
const last = this.content[this.content.length - 1];
|
|
378
|
+
if (last?.type === "thinking")
|
|
379
|
+
last.thinking += reasoning;
|
|
380
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: reasoning } } });
|
|
381
|
+
}
|
|
320
382
|
if (typeof delta.content === "string" && delta.content) {
|
|
321
383
|
if (!this.open || this.open.kind !== "text") {
|
|
322
384
|
this.content.push({ type: "text", text: "" });
|
|
@@ -343,8 +405,10 @@ export class OpenAiStreamMapper {
|
|
|
343
405
|
}
|
|
344
406
|
}
|
|
345
407
|
}
|
|
346
|
-
if (typeof choice.finish_reason === "string" && choice.finish_reason)
|
|
408
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason) {
|
|
347
409
|
this.finishReason = choice.finish_reason;
|
|
410
|
+
this.sawCompletion = true;
|
|
411
|
+
}
|
|
348
412
|
}
|
|
349
413
|
return out;
|
|
350
414
|
}
|
|
@@ -452,6 +516,7 @@ export class OpenAiStreamMapper {
|
|
|
452
516
|
this.setUsage(response?.usage);
|
|
453
517
|
if (response?.incomplete_details?.reason === "max_output_tokens")
|
|
454
518
|
this.finishReason = "length";
|
|
519
|
+
this.sawCompletion = true;
|
|
455
520
|
}
|
|
456
521
|
else if (type === "response.failed" || type === "error") {
|
|
457
522
|
const error = (type === "error" ? ev.error : ev.response?.error);
|
|
@@ -482,7 +547,10 @@ export class OpenAiStreamMapper {
|
|
|
482
547
|
if (this.finished)
|
|
483
548
|
return [];
|
|
484
549
|
this.finished = true;
|
|
485
|
-
|
|
550
|
+
// `overloaded_error` is the one mid-stream error Claude Code retries on its own (CLI 2.1.278
|
|
551
|
+
// matches `"type":"overloaded_error"` in the message); `api_error` ends the turn for good.
|
|
552
|
+
const type = code === "rate_limit_exceeded" ? "rate_limit_error" : code === "server_is_overloaded" ? "overloaded_error" : "api_error";
|
|
553
|
+
this.failure = { type, message };
|
|
486
554
|
return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
|
|
487
555
|
}
|
|
488
556
|
message() {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Retrying a translated provider before its turn is committed.
|
|
2
|
+
//
|
|
3
|
+
// A provider behind a relay fails for reasons that have nothing to do with the request: the relay's
|
|
4
|
+
// own upstream breaks, an edge hiccups, a connection is refused. The user then sees an error, asks
|
|
5
|
+
// again, and the identical request succeeds — which is the whole evidence that the request was
|
|
6
|
+
// never the problem (2026-09-21, OpenCode Go relaying DeepSeek: every failing turn answered 403
|
|
7
|
+
// `Upstream request failed`, and every retry by hand worked).
|
|
8
|
+
//
|
|
9
|
+
// Only a failure another attempt could answer is retried. `classify` already owns that judgement
|
|
10
|
+
// for what failed — a 400 is our request being wrong and is refused everywhere, a 403 wrapping a
|
|
11
|
+
// relay's broken upstream is not about us at all. Reusing it keeps one vocabulary rather than two.
|
|
12
|
+
//
|
|
13
|
+
// It is not quite the same question, though, and the difference is the whole of `isWorthRetrying`.
|
|
14
|
+
// `classify` answers it for a *pool*: a 401 is retryable because another credential could answer.
|
|
15
|
+
// Here there is only the one credential the adapter already holds, so a 401 would be re-sent — nine
|
|
16
|
+
// times — to the key that just refused it, and a 429 to the limit that just refused it. Those fail
|
|
17
|
+
// identically on every attempt and only delay the error the user eventually sees. What is left is
|
|
18
|
+
// `transient`: a connect failure, a 502/503/504, a relay with a broken upstream. Those are the ones
|
|
19
|
+
// where the identical request genuinely succeeds a moment later.
|
|
20
|
+
//
|
|
21
|
+
// **Nothing may have been written to the client.** This is called around the `fetch` alone, before
|
|
22
|
+
// any status or byte is sent, so a retry cannot splice two answers together. Once the first byte is
|
|
23
|
+
// out the turn is committed and this must not be used.
|
|
24
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
25
|
+
import { classify } from "../pool.js";
|
|
26
|
+
/** Attempts in total — one ask plus nine retries, matching the CLI's own ceiling (2026-09-21). */
|
|
27
|
+
export const DEFAULT_ATTEMPTS = 10;
|
|
28
|
+
const RETRY_BASE_MS = 250;
|
|
29
|
+
const RETRY_MAX_MS = 2_000;
|
|
30
|
+
/**
|
|
31
|
+
* 250ms, 500ms, 1s, then 2s each. A hiccup is absorbed in a blink, which is the observed case; a
|
|
32
|
+
* sustained outage is not hammered, and the ceiling is deliberately low so a dead relay fails to the
|
|
33
|
+
* user in seconds rather than tens of them.
|
|
34
|
+
*/
|
|
35
|
+
function backoffMs(attempt) {
|
|
36
|
+
return Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** (attempt - 1));
|
|
37
|
+
}
|
|
38
|
+
/** Whether asking the same provider the identical request again could plausibly work. */
|
|
39
|
+
function isWorthRetrying(status, text) {
|
|
40
|
+
const verdict = classify(status, undefined, text);
|
|
41
|
+
return verdict.retryable && verdict.kind === "transient";
|
|
42
|
+
}
|
|
43
|
+
/** Wait between attempts, but stop immediately when the client abandons the turn. */
|
|
44
|
+
async function wait(ms, signal) {
|
|
45
|
+
await sleep(ms, undefined, signal ? { signal } : undefined);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Ask until an answer arrives, a status says asking again is pointless, or the attempt ceiling is
|
|
49
|
+
* reached. The last `Response` is returned either way, so the caller still reports the vendor's own
|
|
50
|
+
* refusal when retrying was not going to help.
|
|
51
|
+
*
|
|
52
|
+
* `init.body` must be reusable (a string or buffer). A stream would already have been consumed.
|
|
53
|
+
*/
|
|
54
|
+
export async function fetchWithRetry(url, init, opts) {
|
|
55
|
+
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
56
|
+
for (let n = 1; n <= attempts; n++) {
|
|
57
|
+
let res;
|
|
58
|
+
try {
|
|
59
|
+
res = await fetch(url, init);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
// An abort is the client going away, not the provider: retrying it would keep working on a
|
|
63
|
+
// turn nobody is waiting for.
|
|
64
|
+
if (init.signal?.aborted)
|
|
65
|
+
throw e;
|
|
66
|
+
if (n === attempts)
|
|
67
|
+
throw e;
|
|
68
|
+
opts.log(`attempt ${n}/${attempts} could not connect (${e.message}); retrying`);
|
|
69
|
+
await wait(backoffMs(n), init.signal);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (res.ok)
|
|
73
|
+
return res;
|
|
74
|
+
// A 403's meaning is in its body and a relay reports its own broken upstream there; every other
|
|
75
|
+
// status is judged from the status alone. Cloned rather than read, so the caller still gets the
|
|
76
|
+
// body to show when this attempt turns out to be the last one.
|
|
77
|
+
const text = res.status === 403 ? await res.clone().text().catch(() => "") : "";
|
|
78
|
+
if (!isWorthRetrying(res.status, text) || n === attempts)
|
|
79
|
+
return res;
|
|
80
|
+
// This response will never reach the caller. Cancel it before opening another request so a relay
|
|
81
|
+
// with a slow or unbounded error body cannot hold one connection per discarded attempt.
|
|
82
|
+
await res.body?.cancel().catch(() => { });
|
|
83
|
+
opts.log(`attempt ${n}/${attempts}: upstream ${res.status} (transient); retrying`);
|
|
84
|
+
await wait(backoffMs(n), init.signal);
|
|
85
|
+
}
|
|
86
|
+
// Unreachable: the loop returns or throws on its last iteration.
|
|
87
|
+
throw new Error("retry loop fell through");
|
|
88
|
+
}
|