jeopi-ai 16.6.3 → 16.6.6
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 +7 -0
- package/package.json +4 -4
- package/src/providers/google-gemini-cli.ts +104 -83
- package/src/utils/abort.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.6.6] - 2026-10-17
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed Google Cloud Code Assist quota failures retrying exhausted credentials or alternate endpoints before existing authentication recovery. Structured quota errors now retain their status across HTTP, SSE, and empty-stream retries, while transient throttling keeps normal retry behavior.
|
|
10
|
+
- Bounded Google error-body inspection so stalled error responses cannot prevent endpoint recovery, and prevented canceled shared work from leaking unhandled rejections.
|
|
11
|
+
|
|
5
12
|
## [16.6.2] - 2026-10-03
|
|
6
13
|
|
|
7
14
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "jeopi-ai",
|
|
4
|
-
"version": "16.6.
|
|
4
|
+
"version": "16.6.6",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://github.com/akillness/jeopi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"jeopi-catalog": "16.6.
|
|
42
|
-
"jeopi-utils": "16.6.
|
|
43
|
-
"jeopi-wire": "16.6.
|
|
41
|
+
"jeopi-catalog": "16.6.6",
|
|
42
|
+
"jeopi-utils": "16.6.6",
|
|
43
|
+
"jeopi-wire": "16.6.6",
|
|
44
44
|
"arktype": "2.2.3",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "jeopi-catalog/wire/gemini-headers";
|
|
16
16
|
import { extractHttpStatusFromError, fetchWithRetry, readSseJson } from "jeopi-utils";
|
|
17
17
|
import * as AIError from "../error";
|
|
18
|
+
import { parseRateLimitReason } from "../error/rate-limit";
|
|
18
19
|
import type {
|
|
19
20
|
Api,
|
|
20
21
|
AssistantMessage,
|
|
@@ -28,6 +29,7 @@ import type {
|
|
|
28
29
|
ToolCall,
|
|
29
30
|
} from "../types";
|
|
30
31
|
import { normalizeSystemPrompts } from "../utils";
|
|
32
|
+
import { raceWithSignal } from "../utils/abort";
|
|
31
33
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
32
34
|
import { extractGoogleValidationUrl, formatGoogleValidationRequiredMessage } from "../utils/google-validation";
|
|
33
35
|
import type { RawHttpRequestDump } from "../utils/http-inspector";
|
|
@@ -440,51 +442,50 @@ function stringField(record: Record<string, unknown> | undefined, key: string):
|
|
|
440
442
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
441
443
|
}
|
|
442
444
|
|
|
443
|
-
function
|
|
444
|
-
if (!/\bquota\b|QUOTA_EXHAUSTED|RESOURCE_EXHAUSTED/i.test(errorBody)) return undefined;
|
|
445
|
+
function parseCloudCodeAssistError(errorBody: string): Record<string, unknown> | undefined {
|
|
445
446
|
const jsonStart = errorBody.indexOf("{");
|
|
446
447
|
if (jsonStart === -1) return undefined;
|
|
447
|
-
|
|
448
448
|
try {
|
|
449
|
-
|
|
450
|
-
const parsed = JSON.parse(jsonText);
|
|
451
|
-
const root = recordOf(parsed);
|
|
452
|
-
const error = recordOf(root?.error);
|
|
453
|
-
if (!error) return undefined;
|
|
454
|
-
|
|
455
|
-
const message = stringField(error, "message");
|
|
456
|
-
const status = stringField(error, "status");
|
|
457
|
-
const details = Array.isArray(error.details) ? error.details : [];
|
|
458
|
-
|
|
459
|
-
let isQuotaExhausted =
|
|
460
|
-
status === "RESOURCE_EXHAUSTED" && message !== undefined && /\bquota\b|exhausted/i.test(message);
|
|
461
|
-
let model: string | undefined;
|
|
462
|
-
let quotaResetDelay: string | undefined;
|
|
463
|
-
let quotaResetTimeStamp: string | undefined;
|
|
464
|
-
let retryDelay: string | undefined;
|
|
465
|
-
|
|
466
|
-
for (const rawDetail of details) {
|
|
467
|
-
const detail = recordOf(rawDetail);
|
|
468
|
-
if (!detail) continue;
|
|
469
|
-
const reason = stringField(detail, "reason");
|
|
470
|
-
if (reason === "QUOTA_EXHAUSTED") {
|
|
471
|
-
isQuotaExhausted = true;
|
|
472
|
-
}
|
|
473
|
-
retryDelay ??= stringField(detail, "retryDelay");
|
|
474
|
-
const metadata = recordOf(detail.metadata);
|
|
475
|
-
model ??= stringField(metadata, "model");
|
|
476
|
-
quotaResetDelay ??= stringField(metadata, "quotaResetDelay");
|
|
477
|
-
quotaResetTimeStamp ??=
|
|
478
|
-
stringField(metadata, "quotaResetTimeStamp") ?? stringField(metadata, "quotaResetTimestamp");
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
if (!isQuotaExhausted) return undefined;
|
|
482
|
-
return { message, model, quotaResetDelay, quotaResetTimeStamp, retryDelay };
|
|
449
|
+
return recordOf(recordOf(JSON.parse(errorBody.slice(jsonStart).replace(/\r?\n/g, " ")))?.error);
|
|
483
450
|
} catch {
|
|
484
451
|
return undefined;
|
|
485
452
|
}
|
|
486
453
|
}
|
|
487
454
|
|
|
455
|
+
function extractCloudCodeAssistQuotaDetails(errorBody: string): CloudCodeAssistQuotaDetails | undefined {
|
|
456
|
+
if (!/\bquota\b|QUOTA_EXHAUSTED|RESOURCE_EXHAUSTED/i.test(errorBody)) return undefined;
|
|
457
|
+
const error = parseCloudCodeAssistError(errorBody);
|
|
458
|
+
if (!error) return undefined;
|
|
459
|
+
|
|
460
|
+
const message = stringField(error, "message");
|
|
461
|
+
const status = stringField(error, "status");
|
|
462
|
+
const details = Array.isArray(error.details) ? error.details : [];
|
|
463
|
+
let isQuotaExhausted =
|
|
464
|
+
status === "RESOURCE_EXHAUSTED" && message !== undefined && parseRateLimitReason(message) === "QUOTA_EXHAUSTED";
|
|
465
|
+
let model: string | undefined;
|
|
466
|
+
let quotaResetDelay: string | undefined;
|
|
467
|
+
let quotaResetTimeStamp: string | undefined;
|
|
468
|
+
let retryDelay: string | undefined;
|
|
469
|
+
|
|
470
|
+
for (const rawDetail of details) {
|
|
471
|
+
const detail = recordOf(rawDetail);
|
|
472
|
+
if (!detail) continue;
|
|
473
|
+
const reason = stringField(detail, "reason");
|
|
474
|
+
if (reason === "QUOTA_EXHAUSTED") {
|
|
475
|
+
isQuotaExhausted = true;
|
|
476
|
+
}
|
|
477
|
+
retryDelay ??= stringField(detail, "retryDelay");
|
|
478
|
+
const metadata = recordOf(detail.metadata);
|
|
479
|
+
model ??= stringField(metadata, "model");
|
|
480
|
+
quotaResetDelay ??= stringField(metadata, "quotaResetDelay");
|
|
481
|
+
quotaResetTimeStamp ??=
|
|
482
|
+
stringField(metadata, "quotaResetTimeStamp") ?? stringField(metadata, "quotaResetTimestamp");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!isQuotaExhausted) return undefined;
|
|
486
|
+
return { message, model, quotaResetDelay, quotaResetTimeStamp, retryDelay };
|
|
487
|
+
}
|
|
488
|
+
|
|
488
489
|
function sentence(text: string): string {
|
|
489
490
|
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
490
491
|
}
|
|
@@ -517,6 +518,39 @@ function formatCloudCodeAssistQuotaMessage(errorBody: string, email?: string): s
|
|
|
517
518
|
return parts.join(" ");
|
|
518
519
|
}
|
|
519
520
|
|
|
521
|
+
async function createCloudCodeAssistHttpError(
|
|
522
|
+
response: Response,
|
|
523
|
+
email?: string,
|
|
524
|
+
signal?: AbortSignal,
|
|
525
|
+
): Promise<AIError.GeminiCliApiError> {
|
|
526
|
+
let errorText: string;
|
|
527
|
+
try {
|
|
528
|
+
signal?.throwIfAborted();
|
|
529
|
+
errorText = await raceWithSignal(response.text(), signal);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return new AIError.GeminiCliApiError(
|
|
532
|
+
`Cloud Code Assist API error (${response.status}): Could not read response body`,
|
|
533
|
+
response.status,
|
|
534
|
+
{ headers: response.headers, cause: error },
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
const quotaMessage = formatCloudCodeAssistQuotaMessage(errorText, email);
|
|
538
|
+
const validationUrl = quotaMessage ? undefined : extractGoogleValidationUrl(errorText);
|
|
539
|
+
const detail =
|
|
540
|
+
quotaMessage ??
|
|
541
|
+
(validationUrl
|
|
542
|
+
? formatGoogleValidationRequiredMessage(validationUrl, "retry your request", email)
|
|
543
|
+
: (stringField(parseCloudCodeAssistError(errorText), "message") ?? errorText));
|
|
544
|
+
return new AIError.GeminiCliApiError(
|
|
545
|
+
`Cloud Code Assist API error (${response.status}): ${detail}`,
|
|
546
|
+
response.status,
|
|
547
|
+
{
|
|
548
|
+
headers: response.headers,
|
|
549
|
+
code: quotaMessage ? "QUOTA_EXHAUSTED" : undefined,
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
520
554
|
interface CloudCodeAssistRequest {
|
|
521
555
|
project: string;
|
|
522
556
|
model: string;
|
|
@@ -873,7 +907,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
873
907
|
const detail = quotaDetail ?? chunk.error.message ?? chunk.error.status ?? "unknown error";
|
|
874
908
|
const message = `Cloud Code Assist stream error: ${detail}`;
|
|
875
909
|
throw typeof chunk.error.code === "number" && chunk.error.code >= 400
|
|
876
|
-
? new AIError.GeminiCliApiError(message, chunk.error.code
|
|
910
|
+
? new AIError.GeminiCliApiError(message, chunk.error.code, {
|
|
911
|
+
code: quotaDetail ? "QUOTA_EXHAUSTED" : undefined,
|
|
912
|
+
})
|
|
877
913
|
: new AIError.ProviderResponseError(message, { provider: model.provider, kind: "runtime" });
|
|
878
914
|
}
|
|
879
915
|
const responseData = chunk.response;
|
|
@@ -1032,9 +1068,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
1032
1068
|
started = false;
|
|
1033
1069
|
resetOutput();
|
|
1034
1070
|
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
// an absolute `AbortSignal.timeout` would (issue #2422).
|
|
1071
|
+
// Guard headers and error-body inspection; clear before reading a
|
|
1072
|
+
// successful SSE body so ongoing output is not cut off (issue #2422).
|
|
1038
1073
|
const watchdog = armPreResponseTimeout(callerSignal, firstEventTimeoutMs);
|
|
1039
1074
|
let response: Response;
|
|
1040
1075
|
try {
|
|
@@ -1046,38 +1081,18 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
1046
1081
|
maxAttempts: isLastEndpoint ? MAX_RETRIES + 1 : 1,
|
|
1047
1082
|
defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
|
|
1048
1083
|
maxDelayMs: options?.maxRetryDelayMs ?? RATE_LIMIT_BUDGET_MS,
|
|
1084
|
+
shouldRetryResponse: (response, body) =>
|
|
1085
|
+
response.status !== 429 || extractCloudCodeAssistQuotaDetails(body) === undefined,
|
|
1049
1086
|
fetch: options?.fetch,
|
|
1050
1087
|
timeout: false,
|
|
1051
1088
|
});
|
|
1089
|
+
if (!response.ok) {
|
|
1090
|
+
throw await createCloudCodeAssistHttpError(response, parsedCredentials.email, watchdog.signal);
|
|
1091
|
+
}
|
|
1052
1092
|
} finally {
|
|
1053
1093
|
watchdog.clear();
|
|
1054
1094
|
}
|
|
1055
1095
|
|
|
1056
|
-
if (!response.ok) {
|
|
1057
|
-
if (AIError.isTransientStatus(response.status)) {
|
|
1058
|
-
if (!isLastEndpoint) {
|
|
1059
|
-
continue;
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
const errorText = await response.text();
|
|
1063
|
-
const quotaMessage = formatCloudCodeAssistQuotaMessage(errorText, parsedCredentials.email);
|
|
1064
|
-
const validationUrl = quotaMessage ? undefined : extractGoogleValidationUrl(errorText);
|
|
1065
|
-
const errorMessage =
|
|
1066
|
-
quotaMessage ??
|
|
1067
|
-
(validationUrl
|
|
1068
|
-
? formatGoogleValidationRequiredMessage(
|
|
1069
|
-
validationUrl,
|
|
1070
|
-
"retry your request",
|
|
1071
|
-
parsedCredentials.email,
|
|
1072
|
-
)
|
|
1073
|
-
: errorText);
|
|
1074
|
-
throw new AIError.GeminiCliApiError(
|
|
1075
|
-
`Cloud Code Assist API error (${response.status}): ${errorMessage}`,
|
|
1076
|
-
response.status,
|
|
1077
|
-
{ headers: response.headers },
|
|
1078
|
-
);
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
1096
|
const requestUrl = response.url;
|
|
1082
1097
|
let currentResponse = response;
|
|
1083
1098
|
|
|
@@ -1098,22 +1113,24 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
1098
1113
|
throw new AIError.ConfigurationError("Missing request URL");
|
|
1099
1114
|
}
|
|
1100
1115
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1116
|
+
const retryWatchdog = armPreResponseTimeout(callerSignal, firstEventTimeoutMs);
|
|
1117
|
+
try {
|
|
1118
|
+
currentResponse = await (options?.fetch ?? fetch)(requestUrl, {
|
|
1119
|
+
method: "POST",
|
|
1120
|
+
headers: requestHeaders,
|
|
1121
|
+
body: requestBodyJson,
|
|
1122
|
+
signal: retryWatchdog.signal,
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
if (!currentResponse.ok) {
|
|
1126
|
+
throw await createCloudCodeAssistHttpError(
|
|
1127
|
+
currentResponse,
|
|
1128
|
+
parsedCredentials.email,
|
|
1129
|
+
retryWatchdog.signal,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
} finally {
|
|
1133
|
+
retryWatchdog.clear();
|
|
1117
1134
|
}
|
|
1118
1135
|
}
|
|
1119
1136
|
|
|
@@ -1162,7 +1179,11 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
1162
1179
|
break;
|
|
1163
1180
|
} catch (error) {
|
|
1164
1181
|
const status = extractHttpStatusFromError(error);
|
|
1165
|
-
if (
|
|
1182
|
+
if (
|
|
1183
|
+
AIError.isTransientStatus(status) &&
|
|
1184
|
+
!callerSignal?.aborted &&
|
|
1185
|
+
!(error instanceof AIError.GeminiCliApiError && error.code === "QUOTA_EXHAUSTED")
|
|
1186
|
+
) {
|
|
1166
1187
|
if (!isLastEndpoint && !started) {
|
|
1167
1188
|
continue;
|
|
1168
1189
|
}
|
package/src/utils/abort.ts
CHANGED
|
@@ -59,7 +59,11 @@ export function createAbortSourceTracker(callerSignal?: AbortSignal): AbortSourc
|
|
|
59
59
|
*/
|
|
60
60
|
export function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
61
61
|
if (!signal) return promise;
|
|
62
|
-
if (signal.aborted)
|
|
62
|
+
if (signal.aborted) {
|
|
63
|
+
// The supplied work already started; observe its rejection after the caller cancels.
|
|
64
|
+
void promise.catch(() => {});
|
|
65
|
+
return Promise.reject(signal.reason ?? new AIError.AbortError());
|
|
66
|
+
}
|
|
63
67
|
const { promise: aborted, reject } = Promise.withResolvers<never>();
|
|
64
68
|
const onAbort = () => reject(signal.reason ?? new AIError.AbortError());
|
|
65
69
|
signal.addEventListener("abort", onAbort, { once: true });
|