@gajae-code/ai 0.10.1 → 0.11.0
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 +19 -2
- package/dist/types/auth-gateway/server.d.ts +19 -0
- package/dist/types/auth-storage.d.ts +2 -0
- package/dist/types/context-cap-policy.d.ts +10 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/providers/openai-codex/response-handler.d.ts +1 -0
- package/dist/types/providers/pi-native-server.d.ts +3 -3
- package/dist/types/types.d.ts +12 -4
- package/dist/types/utils/event-stream.d.ts +9 -3
- package/dist/types/utils/fallback-transport.d.ts +55 -0
- package/dist/types/utils/retry.d.ts +1 -0
- package/dist/types/utils.d.ts +42 -0
- package/package.json +2 -2
- package/src/auth-gateway/server.ts +164 -22
- package/src/auth-storage.ts +62 -40
- package/src/context-cap-policy.ts +59 -0
- package/src/index.ts +2 -0
- package/src/model-manager.ts +12 -7
- package/src/model-thinking.ts +7 -8
- package/src/providers/amazon-bedrock.ts +9 -1
- package/src/providers/anthropic.ts +6 -0
- package/src/providers/azure-openai-responses.ts +6 -1
- package/src/providers/google-gemini-cli.ts +26 -13
- package/src/providers/google-shared.ts +7 -1
- package/src/providers/ollama.ts +7 -1
- package/src/providers/openai-codex/response-handler.ts +11 -3
- package/src/providers/openai-codex-responses.ts +19 -4
- package/src/providers/openai-completions.ts +13 -2
- package/src/providers/openai-responses.ts +9 -3
- package/src/providers/pi-native-client.ts +24 -12
- package/src/providers/pi-native-server.ts +4 -3
- package/src/stream.ts +31 -2
- package/src/types.ts +27 -4
- package/src/utils/discovery/codex.ts +3 -12
- package/src/utils/event-stream.ts +138 -54
- package/src/utils/fallback-transport.ts +185 -0
- package/src/utils/retry.ts +2 -2
- package/src/utils.ts +75 -12
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
import type { AssistantMessage, AssistantMessageEvent } from "../types";
|
|
2
2
|
|
|
3
|
+
interface EventQueueNode<T> {
|
|
4
|
+
type: "event";
|
|
5
|
+
event: T;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type QueueNode<T> = EventQueueNode<T> | { type: "consumer-drain"; drain: ConsumerDrain };
|
|
9
|
+
|
|
10
|
+
interface ConsumerDrain {
|
|
11
|
+
settled: boolean;
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
abortListener: () => void;
|
|
14
|
+
resolve: () => void;
|
|
15
|
+
reject: (reason: unknown) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
19
|
+
return signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
20
|
+
}
|
|
21
|
+
|
|
3
22
|
// Generic event stream class for async iteration
|
|
23
|
+
|
|
4
24
|
export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
5
|
-
#queue: T[] = [];
|
|
25
|
+
#queue: QueueNode<T>[] = [];
|
|
6
26
|
#queueHead = 0;
|
|
7
27
|
waiting: Array<{ resolve: (value: IteratorResult<T>) => void; reject: (err: unknown) => void }> = [];
|
|
28
|
+
#pendingConsumerDrains = new Set<ConsumerDrain>();
|
|
29
|
+
#activeConsumerCount = 0;
|
|
8
30
|
done = false;
|
|
9
31
|
#failed = false;
|
|
10
32
|
#error: unknown = undefined;
|
|
@@ -26,20 +48,20 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
26
48
|
this.extractResult = extractResult;
|
|
27
49
|
}
|
|
28
50
|
|
|
29
|
-
#enqueue(
|
|
30
|
-
this.#queue.push(
|
|
51
|
+
#enqueue(node: QueueNode<T>): void {
|
|
52
|
+
this.#queue.push(node);
|
|
31
53
|
}
|
|
32
54
|
|
|
33
|
-
#dequeue(): T | undefined {
|
|
55
|
+
#dequeue(): QueueNode<T> | undefined {
|
|
34
56
|
if (this.#queueHead >= this.#queue.length) return undefined;
|
|
35
|
-
const
|
|
36
|
-
this.#queue[this.#queueHead] = undefined as T
|
|
57
|
+
const node = this.#queue[this.#queueHead]!;
|
|
58
|
+
this.#queue[this.#queueHead] = undefined as unknown as QueueNode<T>;
|
|
37
59
|
this.#queueHead++;
|
|
38
60
|
if (this.#queueHead > 1024 && this.#queueHead * 2 >= this.#queue.length) {
|
|
39
61
|
this.#queue = this.#queue.slice(this.#queueHead);
|
|
40
62
|
this.#queueHead = 0;
|
|
41
63
|
}
|
|
42
|
-
return
|
|
64
|
+
return node;
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
get #queueLength(): number {
|
|
@@ -49,10 +71,54 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
49
71
|
/**
|
|
50
72
|
* Read-only snapshot of the not-yet-consumed events. Always a fresh copy:
|
|
51
73
|
* external code can never mutate internal queue state or observe head-index
|
|
52
|
-
* tombstones, so the deque cannot desynchronize.
|
|
74
|
+
* tombstones or private consumer-drain sentinels, so the deque cannot desynchronize.
|
|
53
75
|
*/
|
|
54
76
|
get queue(): T[] {
|
|
55
|
-
return this.#queue.slice(this.#queueHead);
|
|
77
|
+
return this.#queue.slice(this.#queueHead).flatMap(node => (node.type === "event" ? [node.event] : []));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read-only test seam for outstanding consumer-drain waiters. */
|
|
81
|
+
get pendingConsumerDrainCountForTests(): number {
|
|
82
|
+
return this.#pendingConsumerDrains.size;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#settleConsumerDrain(drain: ConsumerDrain, status: "resolve" | "reject", reason?: unknown): void {
|
|
86
|
+
if (drain.settled) return;
|
|
87
|
+
drain.settled = true;
|
|
88
|
+
this.#pendingConsumerDrains.delete(drain);
|
|
89
|
+
drain.signal.removeEventListener("abort", drain.abortListener);
|
|
90
|
+
if (status === "resolve") {
|
|
91
|
+
drain.resolve();
|
|
92
|
+
} else {
|
|
93
|
+
drain.reject(reason);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#settleAllConsumerDrains(status: "resolve" | "reject", reason?: unknown): void {
|
|
98
|
+
for (const drain of this.#pendingConsumerDrains) {
|
|
99
|
+
this.#settleConsumerDrain(drain, status, reason);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#drainQueuedNodesToWaitingConsumers(): void {
|
|
104
|
+
while (this.waiting.length > 0 && this.#queueLength > 0) {
|
|
105
|
+
const node = this.#dequeue()!;
|
|
106
|
+
if (node.type === "consumer-drain") {
|
|
107
|
+
this.#settleConsumerDrain(node.drain, "resolve");
|
|
108
|
+
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
this.waiting.shift()!.resolve({ value: node.event, done: false });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#dequeueEvent(): EventQueueNode<T> | undefined {
|
|
116
|
+
while (this.#queueLength > 0) {
|
|
117
|
+
const node = this.#dequeue()!;
|
|
118
|
+
if (node.type === "event") return node;
|
|
119
|
+
this.#settleConsumerDrain(node.drain, "resolve");
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
56
122
|
}
|
|
57
123
|
|
|
58
124
|
push(event: T): void {
|
|
@@ -63,26 +129,52 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
63
129
|
this.resolveFinalResult(this.extractResult(event));
|
|
64
130
|
}
|
|
65
131
|
|
|
66
|
-
|
|
67
|
-
const waiter = this.waiting.shift();
|
|
68
|
-
if (waiter) {
|
|
69
|
-
waiter.resolve({ value: event, done: false });
|
|
70
|
-
} else {
|
|
71
|
-
this.#enqueue(event);
|
|
72
|
-
}
|
|
132
|
+
this.deliver(event);
|
|
73
133
|
}
|
|
74
134
|
|
|
75
135
|
deliver(event: T): void {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
waiter
|
|
79
|
-
|
|
80
|
-
|
|
136
|
+
if (this.#queueLength === 0) {
|
|
137
|
+
const waiter = this.waiting.shift();
|
|
138
|
+
if (waiter) {
|
|
139
|
+
waiter.resolve({ value: event, done: false });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
this.#enqueue({ type: "event", event });
|
|
144
|
+
this.#drainQueuedNodesToWaitingConsumers();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolves after every event enqueued before this call has been yielded and
|
|
149
|
+
* the consumer asks the iterator for its next node. The private sentinel is
|
|
150
|
+
* never exposed through the async iterator.
|
|
151
|
+
*/
|
|
152
|
+
waitForConsumerDrain(signal: AbortSignal): Promise<void> {
|
|
153
|
+
if (signal.aborted) return Promise.reject(abortReason(signal));
|
|
154
|
+
if (this.#failed) return Promise.reject(this.#error);
|
|
155
|
+
if (this.done && this.#activeConsumerCount === 0) {
|
|
156
|
+
if (this.#queueLength === 0) return Promise.resolve();
|
|
157
|
+
return Promise.reject(new Error("Event stream ended before queued events could be drained"));
|
|
81
158
|
}
|
|
159
|
+
|
|
160
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
161
|
+
let drain!: ConsumerDrain;
|
|
162
|
+
const abortListener = () => this.#settleConsumerDrain(drain, "reject", abortReason(signal));
|
|
163
|
+
|
|
164
|
+
drain = { settled: false, signal, abortListener, resolve, reject };
|
|
165
|
+
this.#pendingConsumerDrains.add(drain);
|
|
166
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
167
|
+
this.#enqueue({ type: "consumer-drain", drain });
|
|
168
|
+
this.#drainQueuedNodesToWaitingConsumers();
|
|
169
|
+
return promise;
|
|
82
170
|
}
|
|
83
171
|
|
|
84
172
|
end(result?: R): void {
|
|
85
173
|
this.done = true;
|
|
174
|
+
if (this.#activeConsumerCount === 0) {
|
|
175
|
+
this.#settleAllConsumerDrains("reject", new Error("Event stream ended before consumer drain completed"));
|
|
176
|
+
}
|
|
177
|
+
|
|
86
178
|
if (result !== undefined) {
|
|
87
179
|
this.resolveFinalResult(result);
|
|
88
180
|
}
|
|
@@ -94,6 +186,9 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
94
186
|
}
|
|
95
187
|
|
|
96
188
|
endWaiting(): void {
|
|
189
|
+
if (this.#activeConsumerCount === 0) {
|
|
190
|
+
this.#settleAllConsumerDrains("reject", new Error("Event stream ended before consumer drain completed"));
|
|
191
|
+
}
|
|
97
192
|
while (this.waiting.length > 0) {
|
|
98
193
|
const waiter = this.waiting.shift()!;
|
|
99
194
|
waiter.resolve({ value: undefined as any, done: true });
|
|
@@ -105,6 +200,8 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
105
200
|
this.done = true;
|
|
106
201
|
this.#failed = true;
|
|
107
202
|
this.#error = err;
|
|
203
|
+
this.#settleAllConsumerDrains("reject", err);
|
|
204
|
+
|
|
108
205
|
this.rejectFinalResult(err);
|
|
109
206
|
while (this.waiting.length > 0) {
|
|
110
207
|
const waiter = this.waiting.shift()!;
|
|
@@ -113,20 +210,27 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
113
210
|
}
|
|
114
211
|
|
|
115
212
|
async *[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
213
|
+
this.#activeConsumerCount += 1;
|
|
214
|
+
try {
|
|
215
|
+
while (true) {
|
|
216
|
+
const node = this.#dequeueEvent();
|
|
217
|
+
if (node !== undefined) {
|
|
218
|
+
yield node.event;
|
|
219
|
+
} else if (this.#failed) {
|
|
220
|
+
throw this.#error;
|
|
221
|
+
} else if (this.done) {
|
|
222
|
+
return;
|
|
223
|
+
} else {
|
|
224
|
+
const result = await new Promise<IteratorResult<T>>((resolve, reject) =>
|
|
225
|
+
this.waiting.push({ resolve, reject }),
|
|
226
|
+
);
|
|
227
|
+
if (result.done) return;
|
|
228
|
+
yield result.value;
|
|
229
|
+
}
|
|
129
230
|
}
|
|
231
|
+
} finally {
|
|
232
|
+
this.#activeConsumerCount -= 1;
|
|
233
|
+
this.#settleAllConsumerDrains("reject", new Error("Event stream consumer stopped before drain completed"));
|
|
130
234
|
}
|
|
131
235
|
}
|
|
132
236
|
|
|
@@ -149,24 +253,4 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
|
|
|
149
253
|
},
|
|
150
254
|
);
|
|
151
255
|
}
|
|
152
|
-
|
|
153
|
-
override push(event: AssistantMessageEvent): void {
|
|
154
|
-
if (this.done) return;
|
|
155
|
-
|
|
156
|
-
// Completion resolves the final result and still emits the terminal event.
|
|
157
|
-
if (this.isComplete(event)) {
|
|
158
|
-
this.done = true;
|
|
159
|
-
this.resolveFinalResult(this.extractResult(event));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
this.deliver(event);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
override end(result?: AssistantMessage): void {
|
|
166
|
-
this.done = true;
|
|
167
|
-
if (result !== undefined) {
|
|
168
|
-
this.resolveFinalResult(result);
|
|
169
|
-
}
|
|
170
|
-
this.endWaiting();
|
|
171
|
-
}
|
|
172
256
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
export type FallbackTriggerClass = "rate_limit" | "quota" | "auth" | "server" | "other";
|
|
2
|
+
|
|
3
|
+
export interface FallbackTrigger {
|
|
4
|
+
class: FallbackTriggerClass;
|
|
5
|
+
retryAfterMs?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type TransportHeaders = Headers | Record<string, string | undefined>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Structured facts from an upstream HTTP or transport failure. Retry decisions
|
|
12
|
+
* must use these facts rather than provider- or application-owned error text.
|
|
13
|
+
*/
|
|
14
|
+
export interface TransportFailureFacts {
|
|
15
|
+
kind: "transport";
|
|
16
|
+
status?: number;
|
|
17
|
+
providerCode?: string;
|
|
18
|
+
headers?: TransportHeaders;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Opaque per-invocation marker required by managed fallback transport calls. */
|
|
22
|
+
export interface FallbackAttemptToken {
|
|
23
|
+
readonly modelKey: string;
|
|
24
|
+
readonly attemptId: string | number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const issuedAttemptTokens = new WeakSet<object>();
|
|
28
|
+
const consumedAttemptTokens = new WeakSet<object>();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Marks a single outer fallback invocation. Accounting belongs to the caller;
|
|
32
|
+
* this token prevents managed transport calls from silently bypassing it.
|
|
33
|
+
*/
|
|
34
|
+
export function beginAttempt(modelKey: string, attemptId: string | number): FallbackAttemptToken {
|
|
35
|
+
const token = Object.freeze({ modelKey, attemptId });
|
|
36
|
+
issuedAttemptTokens.add(token);
|
|
37
|
+
return token;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function assertManagedAttempt(
|
|
41
|
+
options: { fallbackManaged?: boolean; fallbackAttempt?: FallbackAttemptToken } | undefined,
|
|
42
|
+
): void {
|
|
43
|
+
if (!options?.fallbackManaged) return;
|
|
44
|
+
const token = options.fallbackAttempt;
|
|
45
|
+
if (!token || !issuedAttemptTokens.has(token)) {
|
|
46
|
+
throw new Error("fallbackManaged transport invocation requires a token returned by beginAttempt()");
|
|
47
|
+
}
|
|
48
|
+
if (consumedAttemptTokens.has(token)) {
|
|
49
|
+
throw new Error("fallbackManaged transport invocation cannot reuse a beginAttempt() token");
|
|
50
|
+
}
|
|
51
|
+
consumedAttemptTokens.add(token);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compatibility input for callers that have not yet wrapped their HTTP facts
|
|
56
|
+
* in the discriminated form. Only its structured fields are inspected.
|
|
57
|
+
*/
|
|
58
|
+
export interface FallbackTriggerInput {
|
|
59
|
+
status?: number;
|
|
60
|
+
providerCode?: string;
|
|
61
|
+
code?: string;
|
|
62
|
+
headers?: TransportHeaders;
|
|
63
|
+
response?: { status?: number; headers?: TransportHeaders };
|
|
64
|
+
error?: { code?: string; type?: string };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isTransportHeaders(value: unknown): value is TransportHeaders {
|
|
68
|
+
return value instanceof Headers || (!!value && typeof value === "object");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Extracts only explicit HTTP/transport metadata; it never parses error text. */
|
|
72
|
+
export function transportFailureFacts(
|
|
73
|
+
error: unknown,
|
|
74
|
+
capturedResponse?: { status?: number; headers?: TransportHeaders },
|
|
75
|
+
): TransportFailureFacts | undefined {
|
|
76
|
+
if (!error || typeof error !== "object") return undefined;
|
|
77
|
+
const value = error as FallbackTriggerInput & { kind?: unknown; type?: unknown };
|
|
78
|
+
const status =
|
|
79
|
+
typeof value.status === "number"
|
|
80
|
+
? value.status
|
|
81
|
+
: typeof value.response?.status === "number"
|
|
82
|
+
? value.response.status
|
|
83
|
+
: capturedResponse?.status;
|
|
84
|
+
const providerCode =
|
|
85
|
+
typeof value.providerCode === "string"
|
|
86
|
+
? value.providerCode
|
|
87
|
+
: typeof value.code === "string"
|
|
88
|
+
? value.code
|
|
89
|
+
: typeof value.error?.code === "string"
|
|
90
|
+
? value.error.code
|
|
91
|
+
: typeof value.type === "string"
|
|
92
|
+
? value.type
|
|
93
|
+
: typeof value.error?.type === "string"
|
|
94
|
+
? value.error.type
|
|
95
|
+
: undefined;
|
|
96
|
+
const headers = isTransportHeaders(value.headers)
|
|
97
|
+
? value.headers
|
|
98
|
+
: isTransportHeaders(value.response?.headers)
|
|
99
|
+
? value.response.headers
|
|
100
|
+
: capturedResponse?.headers;
|
|
101
|
+
const normalizedCode = providerCode?.toLowerCase();
|
|
102
|
+
if (
|
|
103
|
+
status === undefined &&
|
|
104
|
+
headers === undefined &&
|
|
105
|
+
!isQuotaCode(normalizedCode) &&
|
|
106
|
+
!isAuthCode(normalizedCode) &&
|
|
107
|
+
!isRateLimitCode(normalizedCode)
|
|
108
|
+
) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return { kind: "transport", status, providerCode, headers };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function headersOf(headers: TransportHeaders | undefined): Headers | undefined {
|
|
115
|
+
if (headers instanceof Headers) return headers;
|
|
116
|
+
return headers ? new Headers(headers as Record<string, string>) : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseRetryAfterSeconds(value: string | null, now = Date.now()): number | undefined {
|
|
120
|
+
if (!value) return undefined;
|
|
121
|
+
const seconds = Number(value);
|
|
122
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);
|
|
123
|
+
const date = Date.parse(value);
|
|
124
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseRetryAfterMilliseconds(value: string | null): number | undefined {
|
|
128
|
+
if (!value) return undefined;
|
|
129
|
+
const milliseconds = Number(value);
|
|
130
|
+
return Number.isFinite(milliseconds) && milliseconds >= 0 ? Math.round(milliseconds) : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isQuotaCode(code: string | undefined): boolean {
|
|
134
|
+
return (
|
|
135
|
+
code === "insufficient_quota" ||
|
|
136
|
+
code === "quota_exceeded" ||
|
|
137
|
+
code === "quota_exhausted" ||
|
|
138
|
+
code === "usage_limit_reached" ||
|
|
139
|
+
code === "usage_not_included" ||
|
|
140
|
+
code === "out_of_credits"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isAuthCode(code: string | undefined): boolean {
|
|
145
|
+
return (
|
|
146
|
+
code === "authentication_error" ||
|
|
147
|
+
code === "invalid_api_key" ||
|
|
148
|
+
code === "invalid_token" ||
|
|
149
|
+
code === "token_expired" ||
|
|
150
|
+
code === "unauthorized" ||
|
|
151
|
+
code === "forbidden"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isRateLimitCode(code: string | undefined): boolean {
|
|
156
|
+
return (
|
|
157
|
+
code === "rate_limit" ||
|
|
158
|
+
code === "rate_limit_error" ||
|
|
159
|
+
code === "rate_limit_exceeded" ||
|
|
160
|
+
code === "too_many_requests"
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Classifies only typed upstream transport facts without consuming response bodies. */
|
|
165
|
+
export function classifyFallbackTrigger(
|
|
166
|
+
errorOrFacts: TransportFailureFacts | FallbackTriggerInput | unknown,
|
|
167
|
+
): FallbackTrigger {
|
|
168
|
+
const facts = transportFailureFacts(errorOrFacts);
|
|
169
|
+
if (!facts) return { class: "other" };
|
|
170
|
+
const headers = headersOf(facts.headers);
|
|
171
|
+
const retryAfterMs =
|
|
172
|
+
parseRetryAfterMilliseconds(headers?.get("retry-after-ms") ?? null) ??
|
|
173
|
+
parseRetryAfterSeconds(headers?.get("retry-after") ?? null);
|
|
174
|
+
const code = facts.providerCode?.toLowerCase();
|
|
175
|
+
const triggerClass: FallbackTriggerClass = isQuotaCode(code)
|
|
176
|
+
? "quota"
|
|
177
|
+
: facts.status === 401 || facts.status === 403 || isAuthCode(code)
|
|
178
|
+
? "auth"
|
|
179
|
+
: facts.status === 429 || isRateLimitCode(code)
|
|
180
|
+
? "rate_limit"
|
|
181
|
+
: facts.status !== undefined && facts.status >= 500 && facts.status <= 599
|
|
182
|
+
? "server"
|
|
183
|
+
: "other";
|
|
184
|
+
return retryAfterMs === undefined ? { class: triggerClass } : { class: triggerClass, retryAfterMs };
|
|
185
|
+
}
|
package/src/utils/retry.ts
CHANGED
|
@@ -34,9 +34,9 @@ const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400;
|
|
|
34
34
|
*/
|
|
35
35
|
export async function callWithCopilotModelRetry<T>(
|
|
36
36
|
fn: () => Promise<T>,
|
|
37
|
-
options: { provider: string; signal?: AbortSignal; retryBaseDelayMs?: number },
|
|
37
|
+
options: { provider: string; signal?: AbortSignal; retryBaseDelayMs?: number; fallbackManaged?: boolean },
|
|
38
38
|
): Promise<T> {
|
|
39
|
-
if (options.provider !== "github-copilot") return fn();
|
|
39
|
+
if (options.provider !== "github-copilot" || options.fallbackManaged) return fn();
|
|
40
40
|
|
|
41
41
|
let lastError: unknown;
|
|
42
42
|
const retryBaseDelayMs = options.retryBaseDelayMs ?? COPILOT_MODEL_RETRY_BASE_DELAY_MS;
|
package/src/utils.ts
CHANGED
|
@@ -119,22 +119,86 @@ export function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record
|
|
|
119
119
|
return sanitized ? [sanitized] : [];
|
|
120
120
|
});
|
|
121
121
|
}
|
|
122
|
+
const RESERVED_CONTROL_TOKEN_RE =
|
|
123
|
+
/<\|(?=(?:[A-Za-z0-9_]+|(?:system|developer|user|assistant|tool)[ \t]+to=[^\s<>|]+)\|>)/g;
|
|
124
|
+
/**
|
|
125
|
+
* Neutralize leaked OpenAI Harmony / control tokens (`<|channel|>`, `<|message|>`,
|
|
126
|
+
* `<|call|>`, `<|constrain|>`, `<|recipient|>`, `<|content|>`, ...) in replayed
|
|
127
|
+
* history text. A subagent whose tool-call channel degenerates can dump raw
|
|
128
|
+
* control-token scaffolding into its reply text; once that poisoned text lands in
|
|
129
|
+
* history the Codex / Responses endpoint rejects every subsequent request with
|
|
130
|
+
* `Request blocked (code=invalid_prompt)`, permanently wedging the session because
|
|
131
|
+
* the offending item is re-sent on each turn. Insert a zero-width space after `<`
|
|
132
|
+
* so the delimiter can no longer be tokenized as a reserved control token while the
|
|
133
|
+
* text stays human-readable.
|
|
134
|
+
*
|
|
135
|
+
* The pattern matches the two control-token shapes only, so ordinary text and pipe
|
|
136
|
+
* syntax is left untouched:
|
|
137
|
+
* - simple form `<|ident|>` — a leading run of identifier chars then `|>`; and
|
|
138
|
+
* - header form `<|role to=recipient|>` — a known Harmony role
|
|
139
|
+
* (`system`/`developer`/`user`/`assistant`/`tool`) followed by a single
|
|
140
|
+
* recipient assignment `to=<recipient>` whose value is an unbounded run of
|
|
141
|
+
* non-delimiter, non-whitespace chars (so long MCP/custom tool recipients like
|
|
142
|
+
* `to=functions.<long.name>` are covered).
|
|
143
|
+
* The header branch is deliberately scoped to the known role + `to=` recipient
|
|
144
|
+
* grammar rather than an arbitrary `key=value`, so request-boundary sanitization
|
|
145
|
+
* never rewrites non-control delimiter text such as `<|foo bar=baz|>`. A single-line
|
|
146
|
+
* body (no `\n`) and the required leading identifier char also leave compact
|
|
147
|
+
* pipe/operator syntax alone — e.g. F# `value <| f |> g` (space after `<|`),
|
|
148
|
+
* `sum<|a+b|>c` (punctuation body), and `<|foo bar|>` (no assignment) never match.
|
|
149
|
+
* The simple branch is a strict superset of the original identifier-only pattern:
|
|
150
|
+
* every marker the old regex caught still matches.
|
|
151
|
+
*/
|
|
152
|
+
export function neutralizeReservedControlTokens(text: string): string {
|
|
153
|
+
if (!text.includes("<|")) return text;
|
|
154
|
+
return text.replace(RESERVED_CONTROL_TOKEN_RE, "<\u200b|");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Neutralize leaked reserved control tokens across every string in an outgoing
|
|
159
|
+
* Responses `input` array. This is the request-boundary complement to the
|
|
160
|
+
* replay-history sanitizer: leaked Harmony markers (`<|channel|>analysis`, ...)
|
|
161
|
+
* can enter the payload from assistant reasoning summaries, live-converted
|
|
162
|
+
* message/tool-output text, or user-authored content — not just replayed
|
|
163
|
+
* history — and every gpt-5.6 request that carries one is rejected with
|
|
164
|
+
* `Request blocked (code=invalid_prompt)`. Walking every string (rather than an
|
|
165
|
+
* item-type allowlist) guarantees no leak source is missed as item shapes
|
|
166
|
+
* evolve; the zero-width-space insertion is idempotent (`<\u200b|` no longer
|
|
167
|
+
* matches `<|`) and keeps the text human-readable.
|
|
168
|
+
*/
|
|
169
|
+
export function neutralizeResponsesInputControlTokens<T>(items: readonly T[]): T[] {
|
|
170
|
+
return items.map(item => deepNeutralizeReservedControlTokens(item) as T);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function deepNeutralizeReservedControlTokens(value: unknown): unknown {
|
|
174
|
+
if (typeof value === "string") return neutralizeReservedControlTokens(value);
|
|
175
|
+
if (Array.isArray(value)) return value.map(deepNeutralizeReservedControlTokens);
|
|
176
|
+
if (value && typeof value === "object") {
|
|
177
|
+
const out: Record<string, unknown> = {};
|
|
178
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
179
|
+
out[key] = deepNeutralizeReservedControlTokens(nested);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
122
186
|
function stringifyResponsesStringParamForReplay(value: unknown): string {
|
|
123
|
-
if (typeof value === "string") return value.toWellFormed();
|
|
187
|
+
if (typeof value === "string") return neutralizeReservedControlTokens(value.toWellFormed());
|
|
124
188
|
try {
|
|
125
189
|
const encoded = JSON.stringify(value);
|
|
126
|
-
if (typeof encoded === "string") return encoded.toWellFormed();
|
|
190
|
+
if (typeof encoded === "string") return neutralizeReservedControlTokens(encoded.toWellFormed());
|
|
127
191
|
} catch {
|
|
128
192
|
// Fall through to String().
|
|
129
193
|
}
|
|
130
|
-
return String(value ?? "").toWellFormed();
|
|
194
|
+
return neutralizeReservedControlTokens(String(value ?? "").toWellFormed());
|
|
131
195
|
}
|
|
132
196
|
|
|
133
197
|
function normalizeResponsesMessageTextForReplay(value: unknown): string {
|
|
134
|
-
if (typeof value === "string") return value.toWellFormed();
|
|
198
|
+
if (typeof value === "string") return neutralizeReservedControlTokens(value.toWellFormed());
|
|
135
199
|
if (value && typeof value === "object") {
|
|
136
200
|
const nestedText = (value as { text?: unknown }).text;
|
|
137
|
-
if (typeof nestedText === "string") return nestedText.toWellFormed();
|
|
201
|
+
if (typeof nestedText === "string") return neutralizeReservedControlTokens(nestedText.toWellFormed());
|
|
138
202
|
}
|
|
139
203
|
return stringifyResponsesStringParamForReplay(value);
|
|
140
204
|
}
|
|
@@ -163,7 +227,7 @@ function normalizeResponsesImageUrlForReplay(value: unknown): NormalizedResponse
|
|
|
163
227
|
}
|
|
164
228
|
|
|
165
229
|
function sanitizeResponsesMessageContentForReplay(content: unknown): unknown {
|
|
166
|
-
if (typeof content === "string") return content.toWellFormed();
|
|
230
|
+
if (typeof content === "string") return neutralizeReservedControlTokens(content.toWellFormed());
|
|
167
231
|
if (!Array.isArray(content)) return content;
|
|
168
232
|
return content.map(part => {
|
|
169
233
|
if (!part || typeof part !== "object") return part;
|
|
@@ -197,12 +261,11 @@ function sanitizeResponsesStringFieldsForReplay(item: Record<string, unknown>):
|
|
|
197
261
|
if (item.type === "custom_tool_call" && "input" in item && typeof item.input !== "string") {
|
|
198
262
|
item.input = stringifyResponsesStringParamForReplay(item.input);
|
|
199
263
|
}
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
item.output = stringifyResponsesStringParamForReplay(item.output);
|
|
264
|
+
if ((item.type === "function_call_output" || item.type === "custom_tool_call_output") && "output" in item) {
|
|
265
|
+
item.output =
|
|
266
|
+
typeof item.output === "string"
|
|
267
|
+
? neutralizeReservedControlTokens(item.output.toWellFormed())
|
|
268
|
+
: stringifyResponsesStringParamForReplay(item.output);
|
|
206
269
|
}
|
|
207
270
|
}
|
|
208
271
|
|