···
9
9
10
10
# runtime state
11
11
session.json
12
12
+
primary-failover.json
13
13
+
rest-snapshot.json
12
14
13
15
# agent home — ignore everything except the example file
14
16
home/*
15
17
!home/soul.example.md
16
18
17
17
-
.codex
19
19
+
.codex
···
27
27
return path.posix.normalize(root)
28
28
})()
29
29
30
30
-
const DEFAULT_IMAGE_MAX_BYTES = 150_000
30
30
+
const DEFAULT_IMAGE_MAX_BYTES = 1_000_000
31
31
/** Maximum image size (bytes) accepted by `image_tool`. */
32
32
export const IMAGE_MAX_BYTES = (() => {
33
33
const parsed = parseInt(process.env.IMAGE_TOOL_MAX_BYTES ?? `${DEFAULT_IMAGE_MAX_BYTES}`, 10)
···
1
1
+
import test from "node:test"
2
2
+
import assert from "node:assert/strict"
3
3
+
import fs from "node:fs/promises"
4
4
+
import path from "node:path"
5
5
+
import { IMAGE_MAX_BYTES, IMAGE_ROOT, USE_DOCKER_SHELL } from "./config"
6
6
+
import { readImageForModel } from "./tools"
7
7
+
8
8
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
9
9
+
10
10
+
function pngLikeBytes(size: number): Buffer {
11
11
+
const data = Buffer.alloc(size)
12
12
+
PNG_SIGNATURE.copy(data)
13
13
+
return data
14
14
+
}
15
15
+
16
16
+
test("default image tool limit is 1MB", (t) => {
17
17
+
if (process.env.IMAGE_TOOL_MAX_BYTES) {
18
18
+
t.skip("IMAGE_TOOL_MAX_BYTES overrides the default")
19
19
+
return
20
20
+
}
21
21
+
22
22
+
assert.equal(IMAGE_MAX_BYTES, 1_000_000)
23
23
+
})
24
24
+
25
25
+
test("readImageForModel accepts files up to IMAGE_MAX_BYTES and rejects larger files", async (t) => {
26
26
+
if (USE_DOCKER_SHELL) {
27
27
+
t.skip("local filesystem image limit test does not run through Docker shell")
28
28
+
return
29
29
+
}
30
30
+
if (IMAGE_MAX_BYTES > 5_000_000) {
31
31
+
t.skip("configured image limit is too large for a focused unit test")
32
32
+
return
33
33
+
}
34
34
+
35
35
+
await fs.mkdir(IMAGE_ROOT, { recursive: true })
36
36
+
const dir = await fs.mkdtemp(path.join(IMAGE_ROOT, "image-limit-"))
37
37
+
38
38
+
try {
39
39
+
const acceptedPath = path.join(dir, "accepted.png")
40
40
+
await fs.writeFile(acceptedPath, pngLikeBytes(IMAGE_MAX_BYTES))
41
41
+
42
42
+
const accepted = await readImageForModel(acceptedPath, 5_000)
43
43
+
assert.equal(accepted.bytes, IMAGE_MAX_BYTES)
44
44
+
assert.equal(accepted.mime, "image/png")
45
45
+
46
46
+
const oversizedPath = path.join(dir, "oversized.png")
47
47
+
await fs.writeFile(oversizedPath, pngLikeBytes(IMAGE_MAX_BYTES + 1))
48
48
+
49
49
+
await assert.rejects(
50
50
+
readImageForModel(oversizedPath, 5_000),
51
51
+
new RegExp(`file too large: ${IMAGE_MAX_BYTES + 1} bytes \\(max ${IMAGE_MAX_BYTES}\\)`),
52
52
+
)
53
53
+
} finally {
54
54
+
await fs.rm(dir, { recursive: true, force: true })
55
55
+
}
56
56
+
})
···
10
10
FALLBACK_BASE,
11
11
FALLBACK_MODEL,
12
12
FALLBACK_TOOL_CHOICE,
13
13
+
ANTHROPIC_BASE_URL,
13
14
ANTHROPIC_MODEL,
14
15
MODEL,
15
16
PRIMARY_TOOL_CHOICE,
···
19
20
USE_FALLBACK,
20
21
apiErrorDetails,
21
22
client,
23
23
+
clearPrimaryFailover,
22
24
errorSummary,
23
25
estimatePromptTokens,
24
26
fallbackClient,
···
27
29
isContentFilterError,
28
30
isImageParseError,
29
31
isPromptTooLargeError,
32
32
+
isQuotaExhaustedError,
30
33
loadAgentSummaryContext,
34
34
+
primaryFailoverStatus,
35
35
+
recordPrimaryQuotaFailover,
31
36
retryDelayMs,
32
37
sanitizeMessages,
33
38
scrubImagesFromConversation,
34
39
shouldFallback,
40
40
+
shouldRetryProvider,
35
41
summaryClient,
36
42
summarizeConversationViaLLM,
37
43
} from "./util"
···
44
50
*
45
51
* @returns Active summary provider config.
46
52
*/
47
47
-
export function configuredSummaryProvider(): { client: OpenAI | null; model: string } {
53
53
+
export async function configuredSummaryProvider(): Promise<{ client: OpenAI | null; model: string }> {
48
54
if (summaryClient && SUMMARY_MODEL) return { client: summaryClient, model: SUMMARY_MODEL }
55
55
+
const failover = USE_FALLBACK ? null : await primaryFailoverStatus()
56
56
+
if (failover?.active) return { client: fallbackClient, model: FALLBACK_MODEL }
49
57
return {
50
58
client: USE_FALLBACK ? fallbackClient : client,
51
59
model: USE_FALLBACK ? FALLBACK_MODEL : MODEL,
···
56
64
if (!(err instanceof OpenAI.APIError)) return
57
65
console.error(`[api] ${err.status} ${err.message} - ${context}`)
58
66
for (const line of apiErrorDetails(err)) console.error(line)
67
67
+
}
68
68
+
69
69
+
function primaryApiContext(): string {
70
70
+
const model = USE_ANTHROPIC ? ANTHROPIC_MODEL : MODEL
71
71
+
const api = USE_ANTHROPIC ? ANTHROPIC_BASE_URL : API_BASE
72
72
+
return `model=${model} api=${api}`
59
73
}
60
74
61
75
/**
···
138
152
return new Promise((resolve) => setTimeout(resolve, ms))
139
153
}
140
154
141
141
-
function formatRetryAt(retryAfterMs: number): string {
142
142
-
const retryAt = new Date(Date.now() + retryAfterMs)
155
155
+
function formatAbsoluteTime(timeMs: number): string {
156
156
+
const retryAt = new Date(timeMs)
143
157
const local = retryAt.toLocaleString(undefined, {
144
158
hour12: false,
145
159
timeZoneName: "short",
146
160
})
147
161
return `${local} (${retryAt.toISOString()})`
162
162
+
}
163
163
+
164
164
+
function formatRetryAt(retryAfterMs: number): string {
165
165
+
return formatAbsoluteTime(Date.now() + retryAfterMs)
166
166
+
}
167
167
+
168
168
+
const PRIMARY_FAILOVER_NOTICE_INTERVAL_MS = 60 * 60 * 1000
169
169
+
let lastPrimaryFailoverNoticeAtMs = 0
170
170
+
171
171
+
function shouldLogPrimaryFailoverNotice(nowMs = Date.now()): boolean {
172
172
+
if (nowMs - lastPrimaryFailoverNoticeAtMs < PRIMARY_FAILOVER_NOTICE_INTERVAL_MS) return false
173
173
+
lastPrimaryFailoverNoticeAtMs = nowMs
174
174
+
return true
148
175
}
149
176
150
177
function apiErrorSearchText(err: { message: string; error?: unknown }): string {
···
593
620
const beforeCount = state.conversation.length
594
621
const beforeEstimate = estimatePromptTokens(state.conversation)
595
622
596
596
-
const summaryProvider = configuredSummaryProvider()
623
623
+
const summaryProvider = await configuredSummaryProvider()
597
624
if (!summaryProvider.client || !summaryProvider.model) {
598
625
console.warn(`[context] recovery: no summary client available; cannot llm-summarize`)
599
626
return false
···
691
718
: { messages: baseConversation, recalledChunkIds: [] as number[] }
692
719
const requestMessages = requestContext.messages
693
720
721
721
+
const primaryFailoverBefore = USE_FALLBACK ? null : await primaryFailoverStatus()
722
722
+
if (primaryFailoverBefore?.active) {
723
723
+
if (shouldLogPrimaryFailoverNotice()) {
724
724
+
console.warn(
725
725
+
`[api] primary quota cooldown active; using fallback until ${formatAbsoluteTime(primaryFailoverBefore.retryAtMs)} (${Math.ceil(primaryFailoverBefore.remainingMs / 1000)}s remaining)`,
726
726
+
)
727
727
+
}
728
728
+
729
729
+
const fallbackWindow = fallbackContextWindow(requestMessages)
730
730
+
if (fallbackWindow.nearLimit) {
731
731
+
console.warn(
732
732
+
`[fallback] prompt estimate ${fallbackWindow.estimate} nearing fallback limit ${fallbackWindow.softLimit} (${FALLBACK_MODEL})`,
733
733
+
)
734
734
+
}
735
735
+
736
736
+
try {
737
737
+
const completion = await createFallbackCompletion(requestMessages)
738
738
+
state.memoryRecallCooldowns = rememberRecalledMemoryChunks(
739
739
+
state.memoryRecallCooldowns,
740
740
+
requestContext.recalledChunkIds,
741
741
+
state.memoryRecallTurn,
742
742
+
)
743
743
+
return completion
744
744
+
} catch (fallbackErr) {
745
745
+
if (isPromptTooLargeError(fallbackErr) && promptTooLargeAttempts < 2) {
746
746
+
logPromptSizeDebug(state, fallbackErr, `fallback rejected prompt during primary quota cooldown (attempt ${promptTooLargeAttempts + 1}/2)`)
747
747
+
const recovered = await recoverFromPromptTooLarge(state, promptTooLargeAttempts)
748
748
+
promptTooLargeAttempts++
749
749
+
if (recovered) continue
750
750
+
}
751
751
+
if (recoverByScrubbingImages(fallbackErr, "fallback-primary-quota-cooldown")) continue
752
752
+
if (shouldRetryProvider(fallbackErr)) {
753
753
+
const retryAfter = retryDelayMs(fallbackErr)
754
754
+
console.warn(
755
755
+
`[fallback] transient failure (${errorSummary(fallbackErr)}); retrying after ${Math.ceil(retryAfter / 1000)}s`,
756
756
+
)
757
757
+
console.log(
758
758
+
`[runner] backing off ${Math.ceil(retryAfter / 1000)}s (until ${formatRetryAt(retryAfter)}) before retrying fallback...`,
759
759
+
)
760
760
+
await sleep(retryAfter)
761
761
+
continue
762
762
+
}
763
763
+
logApiError(fallbackErr, `model=${FALLBACK_MODEL} api=${FALLBACK_BASE}`)
764
764
+
throw fallbackErr
765
765
+
}
766
766
+
} else if (primaryFailoverBefore?.retryAtMs) {
767
767
+
console.warn(`[api] primary quota cooldown expired; probing primary before fallback`)
768
768
+
}
769
769
+
694
770
if (USE_FALLBACK) {
695
771
const fallbackWindow = fallbackContextWindow(requestMessages)
696
772
if (fallbackWindow.nearLimit) {
···
715
791
if (recovered) continue
716
792
}
717
793
if (recoverByScrubbingImages(fallbackErr, "fallback")) continue
718
718
-
if (shouldFallback(fallbackErr)) {
794
794
+
if (shouldRetryProvider(fallbackErr)) {
719
795
const retryAfter = retryDelayMs(fallbackErr)
720
796
console.warn(
721
797
`[fallback] transient failure (${errorSummary(fallbackErr)}); retrying after ${Math.ceil(retryAfter / 1000)}s`,
···
733
809
734
810
try {
735
811
const completion = await createPrimaryCompletion(requestMessages)
812
812
+
if (primaryFailoverBefore?.retryAtMs && (await clearPrimaryFailover())) {
813
813
+
console.warn(`[api] primary probe succeeded; restored primary routing`)
814
814
+
}
736
815
state.memoryRecallCooldowns = rememberRecalledMemoryChunks(
737
816
state.memoryRecallCooldowns,
738
817
requestContext.recalledChunkIds,
···
745
824
const recovered = await recoverFromPromptTooLarge(state, promptTooLargeAttempts)
746
825
promptTooLargeAttempts++
747
826
if (recovered) continue
748
748
-
logApiError(primaryErr, `model=${MODEL} api=${API_BASE}`)
827
827
+
logApiError(primaryErr, primaryApiContext())
749
828
throw primaryErr
750
829
}
751
830
752
831
if (recoverByScrubbingImages(primaryErr, "primary")) continue
753
832
754
754
-
if (!shouldFallback(primaryErr)) {
755
755
-
logApiError(primaryErr, `model=${MODEL} api=${API_BASE}`)
833
833
+
const primaryQuotaExhausted = isQuotaExhaustedError(primaryErr)
834
834
+
835
835
+
if (!primaryQuotaExhausted && !shouldFallback(primaryErr)) {
836
836
+
logApiError(primaryErr, primaryApiContext())
756
837
throw primaryErr
757
838
}
758
839
840
840
+
if (primaryQuotaExhausted) {
841
841
+
logApiError(primaryErr, `primary quota exhausted; ${primaryApiContext()}`)
842
842
+
const failover = await recordPrimaryQuotaFailover(primaryErr)
843
843
+
console.warn(
844
844
+
`[api] primary quota exhausted; using fallback until ${formatAbsoluteTime(failover.retryAtMs)}`,
845
845
+
)
846
846
+
}
847
847
+
759
848
const fallbackWindow = fallbackContextWindow(requestMessages)
760
760
-
if (fallbackWindow.skip) {
849
849
+
if (!primaryQuotaExhausted && fallbackWindow.skip) {
761
850
console.warn(
762
851
`[api] primary down (${errorSummary(primaryErr)}) and fallback context estimate ${fallbackWindow.estimate} exceeds hard limit ${fallbackWindow.hardLimit}; retrying primary after backoff`,
763
852
)
···
775
864
)
776
865
}
777
866
778
778
-
console.warn(`[api] primary down (${errorSummary(primaryErr)}) - switching to fallback`)
867
867
+
console.warn(
868
868
+
primaryQuotaExhausted
869
869
+
? `[api] primary quota cooldown set - switching to fallback`
870
870
+
: `[api] primary down (${errorSummary(primaryErr)}) - switching to fallback`,
871
871
+
)
779
872
try {
780
873
const completion = await createFallbackCompletion(requestMessages)
781
874
state.memoryRecallCooldowns = rememberRecalledMemoryChunks(
···
792
885
if (recovered) continue
793
886
}
794
887
if (recoverByScrubbingImages(fallbackErr, "fallback-failover")) continue
888
888
+
const retryTarget = primaryQuotaExhausted ? "fallback" : "primary"
795
889
console.warn(
796
796
-
`[api] fallback failed (${errorSummary(fallbackErr)}) after primary failure (${errorSummary(primaryErr)}); retrying primary after backoff`,
890
890
+
`[api] fallback failed (${errorSummary(fallbackErr)}) after primary failure (${errorSummary(primaryErr)}); retrying ${retryTarget} after backoff`,
797
891
)
798
798
-
const retryAfter = retryDelayMs(primaryErr)
892
892
+
const retryAfter = primaryQuotaExhausted ? retryDelayMs(fallbackErr) : retryDelayMs(primaryErr)
799
893
console.log(
800
800
-
`[runner] backing off ${Math.ceil(retryAfter / 1000)}s (until ${formatRetryAt(retryAfter)}) before retrying primary...`,
894
894
+
`[runner] backing off ${Math.ceil(retryAfter / 1000)}s (until ${formatRetryAt(retryAfter)}) before retrying ${retryTarget}...`,
801
895
)
802
896
await sleep(retryAfter)
803
897
}
···
176
176
if (state.contextSize < CONTEXT_COMPACT_TRIGGER_TOKENS) return false
177
177
const beforeEstimate = estimatePromptTokens(state.conversation)
178
178
179
179
-
const summaryProvider = configuredSummaryProvider()
179
179
+
const summaryProvider = await configuredSummaryProvider()
180
180
if (!summaryProvider.client || !summaryProvider.model) {
181
181
console.warn(`[context] ${phase}: no summary client available; skipping llm compaction`)
182
182
return false
···
1
1
import assert from "node:assert/strict"
2
2
+
import fs from "node:fs/promises"
2
3
import test from "node:test"
3
4
import OpenAI from "openai"
4
5
import type { Message } from "../types"
5
6
import {
6
7
AGENT_NAME,
8
8
+
PRIMARY_QUOTA_RETRY_MS,
9
9
+
clearPrimaryFailover,
7
10
isContentFilterError,
8
11
isImageParseError,
12
12
+
isQuotaExhaustedError,
9
13
isTransientTransportError,
14
14
+
primaryFailoverStatus,
15
15
+
recordPrimaryQuotaFailover,
10
16
restForestFromMessages,
11
17
sanitizeMessages,
12
18
scrubImagesFromConversation,
13
19
shouldFallback,
20
20
+
shouldRetryProvider,
14
21
summarizeConversationViaLLM,
15
22
} from "./util"
23
23
+
24
24
+
const PRIMARY_FAILOVER_FILE = new URL("../../primary-failover.json", import.meta.url)
16
25
17
26
type AssistantMessageWithReasoning = OpenAI.Chat.ChatCompletionAssistantMessageParam & {
18
27
reasoning_content?: string
19
28
}
20
29
30
30
+
async function withPreservedPrimaryFailoverFile(fn: () => Promise<void>): Promise<void> {
31
31
+
let original: string | null = null
32
32
+
try {
33
33
+
original = await fs.readFile(PRIMARY_FAILOVER_FILE, "utf-8")
34
34
+
} catch {
35
35
+
original = null
36
36
+
}
37
37
+
38
38
+
try {
39
39
+
await clearPrimaryFailover()
40
40
+
await fn()
41
41
+
} finally {
42
42
+
await clearPrimaryFailover()
43
43
+
if (original !== null) {
44
44
+
await fs.writeFile(PRIMARY_FAILOVER_FILE, original, "utf-8")
45
45
+
}
46
46
+
}
47
47
+
}
48
48
+
21
49
test("sanitizeMessages backfills empty reasoning_content for assistant history", () => {
22
50
const messages = sanitizeMessages([
23
51
{
···
52
80
53
81
assert.equal(isTransientTransportError(nested), true)
54
82
assert.equal(shouldFallback(nested), true)
83
83
+
})
84
84
+
85
85
+
test("quota-exhausted 403 errors trigger failover but not same-provider retry", () => {
86
86
+
const body = {
87
87
+
error: {
88
88
+
type: "permission_error",
89
89
+
message: "You've reached your usage limit for this billing cycle.",
90
90
+
},
91
91
+
type: "error",
92
92
+
}
93
93
+
const err = new OpenAI.APIError(403, body, `403 ${JSON.stringify(body)}`, undefined)
94
94
+
95
95
+
assert.equal(isQuotaExhaustedError(err), true)
96
96
+
assert.equal(shouldFallback(err), true)
97
97
+
assert.equal(shouldRetryProvider(err), false)
98
98
+
})
99
99
+
100
100
+
test("plain 403 permission errors do not trigger failover", () => {
101
101
+
const err = new OpenAI.APIError(
102
102
+
403,
103
103
+
{ error: { type: "permission_error", message: "missing required workspace permission" } },
104
104
+
"403 missing required workspace permission",
105
105
+
undefined,
106
106
+
)
107
107
+
108
108
+
assert.equal(isQuotaExhaustedError(err), false)
109
109
+
assert.equal(shouldFallback(err), false)
110
110
+
assert.equal(shouldRetryProvider(err), false)
111
111
+
})
112
112
+
113
113
+
test("recordPrimaryQuotaFailover sets a daily primary retry cooldown", async () => {
114
114
+
await withPreservedPrimaryFailoverFile(async () => {
115
115
+
const nowMs = Date.parse("2026-06-01T00:00:00.000Z")
116
116
+
const err = new OpenAI.APIError(
117
117
+
403,
118
118
+
{ error: { type: "permission_error", message: "You've reached your usage limit for this billing cycle." } },
119
119
+
"403 usage limit",
120
120
+
undefined,
121
121
+
)
122
122
+
123
123
+
const recorded = await recordPrimaryQuotaFailover(err, nowMs)
124
124
+
assert.equal(recorded.active, true)
125
125
+
assert.equal(recorded.remainingMs, PRIMARY_QUOTA_RETRY_MS)
126
126
+
assert.equal(recorded.retryAt, new Date(nowMs + PRIMARY_QUOTA_RETRY_MS).toISOString())
127
127
+
assert.match(recorded.reason ?? "", /usage limit/i)
128
128
+
129
129
+
const beforeRetry = await primaryFailoverStatus(nowMs + PRIMARY_QUOTA_RETRY_MS - 1)
130
130
+
assert.equal(beforeRetry.active, true)
131
131
+
132
132
+
const atRetry = await primaryFailoverStatus(nowMs + PRIMARY_QUOTA_RETRY_MS)
133
133
+
assert.equal(atRetry.active, false)
134
134
+
assert.equal(atRetry.remainingMs, 0)
135
135
+
})
55
136
})
56
137
57
138
test("z.ai/GLM image parse rejection (code 1210) is detected as an image parse error", () => {
···
12
12
const PROJECT_ROOT = path.resolve(fileURLToPath(import.meta.url), "../../..")
13
13
const SESSION_FILE = path.join(PROJECT_ROOT, "session.json")
14
14
const REST_SNAPSHOT_FILE = path.join(PROJECT_ROOT, "rest-snapshot.json")
15
15
+
const PRIMARY_FAILOVER_FILE = path.join(PROJECT_ROOT, "primary-failover.json")
15
16
16
17
export const TOKEN_NUDGE_THRESHOLD = parseInt(process.env.TOKEN_NUDGE_THRESHOLD ?? "120000")
17
18
export const FALLBACK_TOKEN_NUDGE_THRESHOLD = parseInt(process.env.FALLBACK_TOKEN_NUDGE_THRESHOLD ?? "50000")
18
19
export const CONTEXT_COMPACT_TRIGGER_TOKENS = parseInt(process.env.CONTEXT_COMPACT_TRIGGER_TOKENS ?? "90000")
20
20
+
export const PRIMARY_QUOTA_RETRY_MS = Math.max(
21
21
+
60_000,
22
22
+
Number.parseInt(process.env.PRIMARY_QUOTA_RETRY_MS ?? `${24 * 60 * 60 * 1000}`, 10) || 24 * 60 * 60 * 1000,
23
23
+
)
19
24
20
25
const NIRI_ENV = (process.env.NIRI_ENV ?? "default").trim().toLowerCase()
21
26
export const USE_FALLBACK = NIRI_ENV === "local"
···
541
546
await fs.unlink(SESSION_FILE).catch(() => {})
542
547
}
543
548
549
549
+
type PrimaryFailoverSnapshot = {
550
550
+
failedAt: string
551
551
+
retryAt: string
552
552
+
reason: string
553
553
+
}
554
554
+
555
555
+
export type PrimaryFailoverStatus = {
556
556
+
active: boolean
557
557
+
retryAtMs: number
558
558
+
retryAt: string | null
559
559
+
remainingMs: number
560
560
+
reason: string | null
561
561
+
}
562
562
+
563
563
+
let primaryFailoverLoaded = false
564
564
+
let primaryFailoverRetryAtMs = 0
565
565
+
let primaryFailoverReason: string | null = null
566
566
+
567
567
+
function primaryFailoverStatusFromMemory(nowMs: number): PrimaryFailoverStatus {
568
568
+
const retryAtMs = primaryFailoverRetryAtMs
569
569
+
const remainingMs = Math.max(0, retryAtMs - nowMs)
570
570
+
return {
571
571
+
active: remainingMs > 0,
572
572
+
retryAtMs,
573
573
+
retryAt: retryAtMs > 0 ? new Date(retryAtMs).toISOString() : null,
574
574
+
remainingMs,
575
575
+
reason: primaryFailoverReason,
576
576
+
}
577
577
+
}
578
578
+
579
579
+
async function loadPrimaryFailoverState(): Promise<void> {
580
580
+
if (primaryFailoverLoaded) return
581
581
+
primaryFailoverLoaded = true
582
582
+
583
583
+
try {
584
584
+
const raw = await fs.readFile(PRIMARY_FAILOVER_FILE, "utf-8")
585
585
+
const parsed = JSON.parse(raw) as Partial<PrimaryFailoverSnapshot>
586
586
+
const retryAtMs = typeof parsed.retryAt === "string" ? Date.parse(parsed.retryAt) : NaN
587
587
+
primaryFailoverRetryAtMs = Number.isFinite(retryAtMs) && retryAtMs > 0 ? retryAtMs : 0
588
588
+
primaryFailoverReason = typeof parsed.reason === "string" && parsed.reason.trim() ? parsed.reason : null
589
589
+
} catch {
590
590
+
primaryFailoverRetryAtMs = 0
591
591
+
primaryFailoverReason = null
592
592
+
}
593
593
+
}
594
594
+
595
595
+
export async function primaryFailoverStatus(nowMs = Date.now()): Promise<PrimaryFailoverStatus> {
596
596
+
await loadPrimaryFailoverState()
597
597
+
return primaryFailoverStatusFromMemory(nowMs)
598
598
+
}
599
599
+
600
600
+
export async function recordPrimaryQuotaFailover(err: unknown, nowMs = Date.now()): Promise<PrimaryFailoverStatus> {
601
601
+
await loadPrimaryFailoverState()
602
602
+
603
603
+
primaryFailoverRetryAtMs = nowMs + PRIMARY_QUOTA_RETRY_MS
604
604
+
primaryFailoverReason = errorSummary(err)
605
605
+
606
606
+
const snapshot: PrimaryFailoverSnapshot = {
607
607
+
failedAt: new Date(nowMs).toISOString(),
608
608
+
retryAt: new Date(primaryFailoverRetryAtMs).toISOString(),
609
609
+
reason: primaryFailoverReason,
610
610
+
}
611
611
+
await fs.writeFile(PRIMARY_FAILOVER_FILE, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: "utf-8", mode: 0o666 })
612
612
+
return primaryFailoverStatusFromMemory(nowMs)
613
613
+
}
614
614
+
615
615
+
export async function clearPrimaryFailover(): Promise<boolean> {
616
616
+
await loadPrimaryFailoverState()
617
617
+
const hadFailover = primaryFailoverRetryAtMs > 0 || primaryFailoverReason !== null
618
618
+
primaryFailoverRetryAtMs = 0
619
619
+
primaryFailoverReason = null
620
620
+
if (hadFailover) await fs.unlink(PRIMARY_FAILOVER_FILE).catch(() => {})
621
621
+
return hadFailover
622
622
+
}
623
623
+
544
624
type RestSnapshot = {
545
625
restedAt: string
546
626
note?: string
···
688
768
}
689
769
690
770
/**
691
691
-
* Determines whether an error should trigger fallback model routing.
771
771
+
* Detects provider/account quota exhaustion errors that should fail over to
772
772
+
* the configured fallback provider instead of aborting the runner.
692
773
*
693
693
-
* @param err - Error thrown by the primary API call.
694
694
-
* @returns `true` when fallback should be attempted.
774
774
+
* Some OpenAI-compatible providers return quota exhaustion as a 403
775
775
+
* `permission_error` rather than 429. Treat only quota/billing-shaped 4xx
776
776
+
* errors this way; a plain auth or permission failure should still surface.
695
777
*/
696
696
-
export function shouldFallback(err: unknown): boolean {
778
778
+
export function isQuotaExhaustedError(err: unknown): boolean {
779
779
+
if (!(err instanceof OpenAI.APIError)) return false
780
780
+
if (!err.status || err.status < 400 || err.status >= 500) return false
781
781
+
782
782
+
const text = apiErrorText(err)
783
783
+
return /usage limit|quota|insufficient[_\s-]*quota|billing cycle|billing hard limit|spending limit|monthly limit|out of credits|no credits|credit balance|insufficient balance|balance (?:is )?(?:not enough|too low)|account balance/i.test(
784
784
+
text,
785
785
+
)
786
786
+
}
787
787
+
788
788
+
/**
789
789
+
* Determines whether an error should be retried against the same provider.
790
790
+
*
791
791
+
* @param err - Error thrown by the active API call.
792
792
+
* @returns `true` when a same-provider retry/backoff should be attempted.
793
793
+
*/
794
794
+
export function shouldRetryProvider(err: unknown): boolean {
697
795
if (err instanceof OpenAI.APIError) {
698
796
// 429 + 5xx = overloaded or down; 0/undefined = network-level failure
699
797
if (!err.status || err.status === 429 || err.status >= 500) return true
700
798
return false
701
799
}
702
800
return isTransientTransportError(err)
801
801
+
}
802
802
+
803
803
+
/**
804
804
+
* Determines whether an error should trigger fallback model routing.
805
805
+
*
806
806
+
* @param err - Error thrown by the primary API call.
807
807
+
* @returns `true` when fallback should be attempted.
808
808
+
*/
809
809
+
export function shouldFallback(err: unknown): boolean {
810
810
+
return shouldRetryProvider(err) || isQuotaExhaustedError(err)
811
811
+
}
812
812
+
813
813
+
function apiErrorText(err: InstanceType<typeof OpenAI.APIError>): string {
814
814
+
const parts: string[] = [err.message]
815
815
+
if (typeof err.code === "string") parts.push(err.code)
816
816
+
if (typeof err.type === "string") parts.push(err.type)
817
817
+
if (typeof err.param === "string") parts.push(err.param)
818
818
+
collectApiErrorText(err.error, parts)
819
819
+
return parts.join("\n")
820
820
+
}
821
821
+
822
822
+
function collectApiErrorText(value: unknown, parts: string[], depth = 0): void {
823
823
+
if (depth > 4 || value == null) return
824
824
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
825
825
+
parts.push(String(value))
826
826
+
return
827
827
+
}
828
828
+
if (Array.isArray(value)) {
829
829
+
for (const item of value.slice(0, 20)) collectApiErrorText(item, parts, depth + 1)
830
830
+
return
831
831
+
}
832
832
+
if (typeof value !== "object") return
833
833
+
834
834
+
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
835
835
+
parts.push(key)
836
836
+
collectApiErrorText(nested, parts, depth + 1)
837
837
+
}
703
838
}
704
839
705
840
function errorCauseChainText(err: unknown): string {