@gajae-code/ai 0.10.2 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/dist/types/auth-gateway/server.d.ts +19 -0
  3. package/dist/types/auth-storage.d.ts +2 -0
  4. package/dist/types/context-cap-policy.d.ts +10 -0
  5. package/dist/types/index.d.ts +2 -0
  6. package/dist/types/providers/openai-codex/response-handler.d.ts +1 -0
  7. package/dist/types/providers/pi-native-server.d.ts +3 -3
  8. package/dist/types/types.d.ts +12 -4
  9. package/dist/types/utils/event-stream.d.ts +9 -3
  10. package/dist/types/utils/fallback-transport.d.ts +55 -0
  11. package/dist/types/utils/retry.d.ts +1 -0
  12. package/dist/types/utils.d.ts +17 -0
  13. package/package.json +2 -2
  14. package/src/auth-gateway/server.ts +164 -22
  15. package/src/auth-storage.ts +62 -40
  16. package/src/context-cap-policy.ts +59 -0
  17. package/src/index.ts +2 -0
  18. package/src/model-manager.ts +12 -7
  19. package/src/model-thinking.ts +7 -8
  20. package/src/providers/amazon-bedrock.ts +9 -1
  21. package/src/providers/anthropic.ts +6 -0
  22. package/src/providers/azure-openai-responses.ts +6 -1
  23. package/src/providers/google-gemini-cli.ts +26 -13
  24. package/src/providers/google-shared.ts +7 -1
  25. package/src/providers/ollama.ts +7 -1
  26. package/src/providers/openai-codex/response-handler.ts +11 -3
  27. package/src/providers/openai-codex-responses.ts +17 -3
  28. package/src/providers/openai-completions.ts +13 -2
  29. package/src/providers/openai-responses.ts +7 -2
  30. package/src/providers/pi-native-client.ts +24 -12
  31. package/src/providers/pi-native-server.ts +4 -3
  32. package/src/stream.ts +31 -2
  33. package/src/types.ts +27 -4
  34. package/src/utils/discovery/codex.ts +3 -12
  35. package/src/utils/event-stream.ts +138 -54
  36. package/src/utils/fallback-transport.ts +185 -0
  37. package/src/utils/retry.ts +2 -2
  38. package/src/utils.ts +19 -1
@@ -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(event: T): void {
30
- this.#queue.push(event);
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 event = this.#queue[this.#queueHead]!;
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 event;
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
- // Deliver to waiting consumer or queue it
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
- const waiter = this.waiting.shift();
77
- if (waiter) {
78
- waiter.resolve({ value: event, done: false });
79
- } else {
80
- this.#enqueue(event);
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
- while (true) {
117
- if (this.#queueLength > 0) {
118
- yield this.#dequeue()!;
119
- } else if (this.#failed) {
120
- throw this.#error;
121
- } else if (this.done) {
122
- return;
123
- } else {
124
- const result = await new Promise<IteratorResult<T>>((resolve, reject) =>
125
- this.waiting.push({ resolve, reject }),
126
- );
127
- if (result.done) return;
128
- yield result.value;
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
+ }
@@ -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,7 +119,8 @@ export function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record
119
119
  return sanitized ? [sanitized] : [];
120
120
  });
121
121
  }
122
- const RESERVED_CONTROL_TOKEN_RE = /<\|(?=[A-Za-z0-9_]{1,32}\|>)/g;
122
+ const RESERVED_CONTROL_TOKEN_RE =
123
+ /<\|(?=(?:[A-Za-z0-9_]+|(?:system|developer|user|assistant|tool)[ \t]+to=[^\s<>|]+)\|>)/g;
123
124
  /**
124
125
  * Neutralize leaked OpenAI Harmony / control tokens (`<|channel|>`, `<|message|>`,
125
126
  * `<|call|>`, `<|constrain|>`, `<|recipient|>`, `<|content|>`, ...) in replayed
@@ -130,6 +131,23 @@ const RESERVED_CONTROL_TOKEN_RE = /<\|(?=[A-Za-z0-9_]{1,32}\|>)/g;
130
131
  * the offending item is re-sent on each turn. Insert a zero-width space after `<`
131
132
  * so the delimiter can no longer be tokenized as a reserved control token while the
132
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.
133
151
  */
134
152
  export function neutralizeReservedControlTokens(text: string): string {
135
153
  if (!text.includes("<|")) return text;