@oh-my-pi/pi-ai 16.5.0 → 16.5.2
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 +37 -0
- package/dist/types/auth-broker/remote-store.d.ts +2 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +28 -0
- package/dist/types/auth-retry.d.ts +44 -20
- package/dist/types/auth-storage.d.ts +51 -10
- package/dist/types/error/flags.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +3 -3
- package/dist/types/providers/openai-shared.d.ts +2 -0
- package/dist/types/registry/oauth/types.d.ts +8 -0
- package/dist/types/usage/cursor.d.ts +3 -0
- package/dist/types/usage/openai-codex-reset.d.ts +1 -1
- package/dist/types/usage/openai-codex.d.ts +8 -1
- package/dist/types/usage/zai.d.ts +2 -1
- package/dist/types/usage.d.ts +4 -0
- package/dist/types/utils/idle-iterator.d.ts +6 -6
- package/package.json +4 -4
- package/src/auth-broker/discover.ts +22 -3
- package/src/auth-broker/remote-store.ts +103 -10
- package/src/auth-broker/wire-schemas.ts +4 -0
- package/src/auth-gateway/server.ts +3 -1
- package/src/auth-retry.ts +191 -53
- package/src/auth-storage.ts +668 -198
- package/src/error/auth-classify.ts +2 -1
- package/src/error/flags.ts +10 -0
- package/src/error/provider.ts +2 -2
- package/src/index.ts +1 -0
- package/src/providers/cursor.ts +10 -1
- package/src/providers/openai-chat-server-schema.ts +2 -1
- package/src/providers/openai-codex-responses.ts +74 -36
- package/src/providers/openai-shared.ts +6 -3
- package/src/registry/oauth/anthropic.ts +57 -24
- package/src/registry/oauth/oauth.html +1 -1
- package/src/registry/oauth/types.ts +8 -0
- package/src/stream.ts +19 -30
- package/src/usage/cursor.ts +192 -0
- package/src/usage/openai-codex-reset.ts +6 -2
- package/src/usage/openai-codex.ts +33 -0
- package/src/usage/zai.ts +49 -6
- package/src/usage.ts +4 -0
- package/src/utils/idle-iterator.ts +6 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
|
|
2
|
-
import { isOAuthExpiry } from "./flags";
|
|
2
|
+
import { isOAuthExpiry, isUsageLimit } from "./flags";
|
|
3
3
|
import { isUsageLimitOutcome } from "./rate-limit";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -21,6 +21,7 @@ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
|
|
|
21
21
|
* upstream-backoff lane.
|
|
22
22
|
*/
|
|
23
23
|
export function isAuthRetryableError(error: unknown): boolean {
|
|
24
|
+
if (isUsageLimit(error)) return true;
|
|
24
25
|
const httpStatus = extractHttpStatusFromError(error);
|
|
25
26
|
if (httpStatus === 401) return true;
|
|
26
27
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
package/src/error/flags.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const Flag = {
|
|
|
18
18
|
StaleResponsesItem: 0x0010_0000,
|
|
19
19
|
MalformedFunctionCall: 0x0020_0000,
|
|
20
20
|
ProviderFinishError: 0x0040_0000,
|
|
21
|
+
ContentBlocked: 0x0000_8000,
|
|
21
22
|
ContextOverflow: 0x0080_0000,
|
|
22
23
|
AuthFailed: 0x0100_0000,
|
|
23
24
|
SilentAbort: 0x0200_0000,
|
|
@@ -41,6 +42,7 @@ const KIND_MASK =
|
|
|
41
42
|
Flag.StaleResponsesItem |
|
|
42
43
|
Flag.MalformedFunctionCall |
|
|
43
44
|
Flag.ProviderFinishError |
|
|
45
|
+
Flag.ContentBlocked |
|
|
44
46
|
Flag.ContextOverflow |
|
|
45
47
|
Flag.AuthFailed |
|
|
46
48
|
Flag.SilentAbort |
|
|
@@ -93,6 +95,7 @@ const AUTH_FAILURE_PATTERN =
|
|
|
93
95
|
/\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
|
|
94
96
|
const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
|
|
95
97
|
const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
|
|
98
|
+
const CONTENT_FILTER_PATTERN = /\b(?:incomplete:\s*)?content_filter\b/i;
|
|
96
99
|
const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
|
|
97
100
|
const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i;
|
|
98
101
|
/**
|
|
@@ -174,6 +177,7 @@ const ERROR_KIND_LABELS: readonly [Flag, string][] = [
|
|
|
174
177
|
[Flag.StaleResponsesItem, "stale-responses-item"],
|
|
175
178
|
[Flag.MalformedFunctionCall, "malformed-function-call"],
|
|
176
179
|
[Flag.ProviderFinishError, "provider-finish-error"],
|
|
180
|
+
[Flag.ContentBlocked, "content-blocked"],
|
|
177
181
|
[Flag.ContextOverflow, "context-overflow"],
|
|
178
182
|
[Flag.AuthFailed, "auth-failed"],
|
|
179
183
|
[Flag.SilentAbort, "silent-abort"],
|
|
@@ -200,6 +204,7 @@ export function is(id: number | undefined, flag: Flag): boolean {
|
|
|
200
204
|
}
|
|
201
205
|
|
|
202
206
|
export function retriable(id: number | undefined, opts?: { replayUnsafe?: boolean }): boolean {
|
|
207
|
+
if (is(id, Flag.ContentBlocked)) return false;
|
|
203
208
|
if (is(id, Flag.MalformedFunctionCall)) return true;
|
|
204
209
|
if (opts?.replayUnsafe) return false;
|
|
205
210
|
return ((id ?? 0) & RETRIABLE_KINDS) !== 0;
|
|
@@ -292,6 +297,10 @@ function isProviderFinishErrorText(text: string): boolean {
|
|
|
292
297
|
return PROVIDER_FINISH_ERROR_PATTERN.test(text);
|
|
293
298
|
}
|
|
294
299
|
|
|
300
|
+
function isContentBlockedText(text: string): boolean {
|
|
301
|
+
return CONTENT_FILTER_PATTERN.test(text);
|
|
302
|
+
}
|
|
303
|
+
|
|
295
304
|
function matchesOverflowText(text: string): boolean {
|
|
296
305
|
return OVERFLOW_PATTERNS.some(p => p.test(text)) || OVERFLOW_NO_BODY_PATTERN.test(text);
|
|
297
306
|
}
|
|
@@ -302,6 +311,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
|
|
|
302
311
|
if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow;
|
|
303
312
|
if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall;
|
|
304
313
|
if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
|
|
314
|
+
if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
|
|
305
315
|
if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
|
|
306
316
|
|
|
307
317
|
const statusClean = errorStatus ? errorStatus : (status({ message: errorMessage }) ?? undefined);
|
package/src/error/provider.ts
CHANGED
|
@@ -36,8 +36,8 @@ export class ProviderResponseError extends Error {
|
|
|
36
36
|
this.name = "ProviderResponseError";
|
|
37
37
|
this.provider = options.provider;
|
|
38
38
|
this.kind = options.kind ?? "output";
|
|
39
|
-
// A safety filter block is
|
|
40
|
-
if (this.kind === "content-blocked") attach(this, create(Flag.
|
|
39
|
+
// A safety filter block is terminal and intentionally non-retryable.
|
|
40
|
+
if (this.kind === "content-blocked") attach(this, create(Flag.ContentBlocked));
|
|
41
41
|
// An incomplete stream (connection dropped / truncated before any terminal
|
|
42
42
|
// event) or an empty body never produced any content — the request didn't
|
|
43
43
|
// complete, so it is safe to retry and eligible for model fallback. The
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export * from "./stream";
|
|
|
29
29
|
export * from "./types";
|
|
30
30
|
export * from "./usage";
|
|
31
31
|
export * from "./usage/claude";
|
|
32
|
+
export * from "./usage/cursor";
|
|
32
33
|
export * from "./usage/gemini";
|
|
33
34
|
export * from "./usage/github-copilot";
|
|
34
35
|
export * from "./usage/google-antigravity";
|
package/src/providers/cursor.ts
CHANGED
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
RequestContextResultSchema,
|
|
77
77
|
RequestContextSchema,
|
|
78
78
|
RequestContextSuccessSchema,
|
|
79
|
+
RequestedModelSchema,
|
|
79
80
|
ResumeActionSchema,
|
|
80
81
|
SelectedContextSchema,
|
|
81
82
|
SelectedImageSchema,
|
|
@@ -2797,16 +2798,24 @@ function buildGrpcRequest(
|
|
|
2797
2798
|
turns,
|
|
2798
2799
|
});
|
|
2799
2800
|
|
|
2801
|
+
const wireModelId = model.requestModelId ?? model.id;
|
|
2802
|
+
const cursorMaxMode = model.cursorMaxMode === true;
|
|
2800
2803
|
const modelDetails = create(ModelDetailsSchema, {
|
|
2801
|
-
modelId:
|
|
2804
|
+
modelId: wireModelId,
|
|
2802
2805
|
displayModelId: model.id,
|
|
2803
2806
|
displayName: model.name,
|
|
2807
|
+
...(cursorMaxMode ? { maxMode: true } : undefined),
|
|
2808
|
+
});
|
|
2809
|
+
const requestedModel = create(RequestedModelSchema, {
|
|
2810
|
+
modelId: wireModelId,
|
|
2811
|
+
maxMode: cursorMaxMode,
|
|
2804
2812
|
});
|
|
2805
2813
|
|
|
2806
2814
|
const runRequest = create(AgentRunRequestSchema, {
|
|
2807
2815
|
conversationState,
|
|
2808
2816
|
action,
|
|
2809
2817
|
modelDetails,
|
|
2818
|
+
requestedModel,
|
|
2810
2819
|
conversationId: state.conversationId,
|
|
2811
2820
|
});
|
|
2812
2821
|
|
|
@@ -114,6 +114,7 @@ export const toolChoiceSchema = type("'auto' | 'none' | 'required'")
|
|
|
114
114
|
// ─── Messages ───────────────────────────────────────────────────────────────
|
|
115
115
|
|
|
116
116
|
const baseContent = type("string").or(userContentPartSchema.array());
|
|
117
|
+
const assistantContent = baseContent.or("null");
|
|
117
118
|
|
|
118
119
|
export const systemMessageSchema = type({
|
|
119
120
|
role: "'system'",
|
|
@@ -132,7 +133,7 @@ export const userMessageSchema = type({
|
|
|
132
133
|
|
|
133
134
|
export const assistantMessageSchema = type({
|
|
134
135
|
role: "'assistant'",
|
|
135
|
-
"content?":
|
|
136
|
+
"content?": assistantContent,
|
|
136
137
|
"tool_calls?": toolCallSchema.array(),
|
|
137
138
|
// DeepSeek-style reasoning channel. The gateway emits it on the way out
|
|
138
139
|
// (encodeResponse/encodeStream); accept it back so thinking-mode
|
|
@@ -100,6 +100,7 @@ import {
|
|
|
100
100
|
encodeResponsesToolCallId,
|
|
101
101
|
encodeTextSignatureV1,
|
|
102
102
|
finalizeCustomToolCallInputDone,
|
|
103
|
+
finalizeMessageText,
|
|
103
104
|
finalizePendingResponsesToolCalls,
|
|
104
105
|
finalizeReasoningThinking,
|
|
105
106
|
finalizeToolCallArgumentsDone,
|
|
@@ -691,6 +692,8 @@ class CodexStreamRuntime {
|
|
|
691
692
|
nativeOutputItems: Array<Record<string, unknown>> = [];
|
|
692
693
|
/** Sequential-cutoff summary sections/emitted text, global to the response (indices span reasoning items). */
|
|
693
694
|
cutoffSummaries: SequentialCutoffSummaryState = createSequentialCutoffSummaryState();
|
|
695
|
+
/** Summary deltas buffered while waiting to see whether atomic `.done` events arrive. */
|
|
696
|
+
pendingSummaryDeltas = new Map<CodexOpenItem, string[]>();
|
|
694
697
|
websocketStreamRetries = 0;
|
|
695
698
|
providerRetryAttempt = 0;
|
|
696
699
|
sawTerminalEvent = false;
|
|
@@ -723,6 +726,7 @@ class CodexStreamRuntime {
|
|
|
723
726
|
this.currentItem = null;
|
|
724
727
|
this.currentBlock = null;
|
|
725
728
|
this.nativeOutputItems.length = 0;
|
|
729
|
+
this.pendingSummaryDeltas.clear();
|
|
726
730
|
this.cutoffSummaries = createSequentialCutoffSummaryState();
|
|
727
731
|
}
|
|
728
732
|
|
|
@@ -744,6 +748,19 @@ class CodexStreamRuntime {
|
|
|
744
748
|
if (outputIndex !== undefined) return this.openItemsByOutputIndex.get(outputIndex) ?? null;
|
|
745
749
|
return this.currentEntry;
|
|
746
750
|
}
|
|
751
|
+
queueSummaryDelta(entry: CodexOpenItem | null | undefined, delta: string): void {
|
|
752
|
+
if (entry?.block?.type !== "thinking" || delta.length === 0) return;
|
|
753
|
+
const pending = this.pendingSummaryDeltas.get(entry) ?? [];
|
|
754
|
+
pending.push(delta);
|
|
755
|
+
this.pendingSummaryDeltas.set(entry, pending);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
takeSummaryDeltas(entry: CodexOpenItem | null | undefined): string[] {
|
|
759
|
+
if (!entry) return [];
|
|
760
|
+
const pending = this.pendingSummaryDeltas.get(entry) ?? [];
|
|
761
|
+
this.pendingSummaryDeltas.delete(entry);
|
|
762
|
+
return pending;
|
|
763
|
+
}
|
|
747
764
|
|
|
748
765
|
closeOpenItem(entry: CodexOpenItem | null | undefined): void {
|
|
749
766
|
if (!entry) return;
|
|
@@ -1693,10 +1710,9 @@ class CodexStreamProcessor {
|
|
|
1693
1710
|
|
|
1694
1711
|
/**
|
|
1695
1712
|
* Whether the request actually sent (post-`onPayload`) opted into
|
|
1696
|
-
* sequential-cutoff summary delivery
|
|
1697
|
-
*
|
|
1698
|
-
*
|
|
1699
|
-
* `uses_sequential_cutoff_reasoning_summaries`).
|
|
1713
|
+
* sequential-cutoff summary delivery. Atomic `.done` events are preferred;
|
|
1714
|
+
* incremental deltas remain buffered as a compatibility fallback when a
|
|
1715
|
+
* transport emits them instead.
|
|
1700
1716
|
*/
|
|
1701
1717
|
get #sequentialCutoffSummaries(): boolean {
|
|
1702
1718
|
return this.runtime.requestBodyForState.stream_options?.reasoning_summary_delivery === "sequential_cutoff";
|
|
@@ -1772,18 +1788,19 @@ class CodexStreamProcessor {
|
|
|
1772
1788
|
}
|
|
1773
1789
|
return firstTokenTime;
|
|
1774
1790
|
}
|
|
1775
|
-
|
|
1776
1791
|
if (eventType === "response.reasoning_summary_text.delta") {
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1792
|
+
const entry = this.runtime.openItemForEvent(rawEvent);
|
|
1793
|
+
const delta = typeof rawEvent.delta === "string" ? rawEvent.delta : "";
|
|
1794
|
+
if (this.#sequentialCutoffSummaries) {
|
|
1795
|
+
// Some Codex transports still emit incremental summary deltas even
|
|
1796
|
+
// after opting into sequential-cutoff delivery. Buffer them until
|
|
1797
|
+
// output_item.done so atomic `.done` events can supersede them
|
|
1798
|
+
// without duplicate UI output.
|
|
1799
|
+
this.runtime.queueSummaryDelta(entry, delta);
|
|
1800
|
+
return firstTokenTime;
|
|
1801
|
+
}
|
|
1802
|
+
if (entry?.item.type === "reasoning" && entry.block?.type === "thinking") {
|
|
1803
|
+
appendReasoningSummaryTextDelta(entry.item, entry.block, delta, stream, output, entry.contentIndex);
|
|
1787
1804
|
}
|
|
1788
1805
|
return firstTokenTime;
|
|
1789
1806
|
}
|
|
@@ -1793,6 +1810,7 @@ class CodexStreamProcessor {
|
|
|
1793
1810
|
if (!this.#sequentialCutoffSummaries) return firstTokenTime;
|
|
1794
1811
|
const entry = this.runtime.openItemForEvent(rawEvent);
|
|
1795
1812
|
if (entry?.item.type === "reasoning" && entry.block?.type === "thinking") {
|
|
1813
|
+
this.runtime.takeSummaryDeltas(entry);
|
|
1796
1814
|
if (!firstTokenTime) firstTokenTime = performance.now();
|
|
1797
1815
|
const summaryIndex =
|
|
1798
1816
|
typeof rawEvent.summary_index === "number" && Number.isFinite(rawEvent.summary_index)
|
|
@@ -1827,15 +1845,13 @@ class CodexStreamProcessor {
|
|
|
1827
1845
|
}
|
|
1828
1846
|
|
|
1829
1847
|
if (eventType === "response.reasoning_summary_part.done") {
|
|
1830
|
-
|
|
1831
|
-
if (this
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
output.content.length - 1,
|
|
1838
|
-
);
|
|
1848
|
+
const entry = this.runtime.openItemForEvent(rawEvent);
|
|
1849
|
+
if (this.#sequentialCutoffSummaries) {
|
|
1850
|
+
if (entry && this.runtime.pendingSummaryDeltas.has(entry)) this.runtime.queueSummaryDelta(entry, "\n\n");
|
|
1851
|
+
return firstTokenTime;
|
|
1852
|
+
}
|
|
1853
|
+
if (entry?.item.type === "reasoning" && entry.block?.type === "thinking") {
|
|
1854
|
+
appendReasoningSummaryPartDone(entry.item, entry.block, stream, output, entry.contentIndex);
|
|
1839
1855
|
}
|
|
1840
1856
|
return firstTokenTime;
|
|
1841
1857
|
}
|
|
@@ -1930,6 +1946,18 @@ class CodexStreamProcessor {
|
|
|
1930
1946
|
return firstTokenTime;
|
|
1931
1947
|
}
|
|
1932
1948
|
|
|
1949
|
+
#flushSummaryDeltas(entry: CodexOpenItem | null): void {
|
|
1950
|
+
if (entry?.block?.type !== "thinking") return;
|
|
1951
|
+
for (const delta of this.runtime.takeSummaryDeltas(entry)) {
|
|
1952
|
+
entry.block.thinking += delta;
|
|
1953
|
+
this.stream.push({
|
|
1954
|
+
type: "thinking_delta",
|
|
1955
|
+
contentIndex: entry.contentIndex,
|
|
1956
|
+
delta,
|
|
1957
|
+
partial: this.output,
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1933
1961
|
#handleOutputItemDone(rawEvent: Record<string, unknown>): void {
|
|
1934
1962
|
const { runtime, output, stream } = this;
|
|
1935
1963
|
const rawItem = rawEvent.item;
|
|
@@ -1948,6 +1976,7 @@ class CodexStreamProcessor {
|
|
|
1948
1976
|
const contentIndex = entry?.contentIndex ?? output.content.length - 1;
|
|
1949
1977
|
|
|
1950
1978
|
if (item.type === "reasoning" && block?.type === "thinking") {
|
|
1979
|
+
this.#flushSummaryDeltas(entry);
|
|
1951
1980
|
block.thinking = finalizeReasoningThinking(
|
|
1952
1981
|
item,
|
|
1953
1982
|
block.thinking,
|
|
@@ -1965,9 +1994,7 @@ class CodexStreamProcessor {
|
|
|
1965
1994
|
}
|
|
1966
1995
|
|
|
1967
1996
|
if (item.type === "message" && block?.type === "text") {
|
|
1968
|
-
block.text = item.
|
|
1969
|
-
.map(content => (content.type === "output_text" ? content.text : content.refusal))
|
|
1970
|
-
.join("");
|
|
1997
|
+
block.text = finalizeMessageText(item, block.text);
|
|
1971
1998
|
const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
|
|
1972
1999
|
block.textSignature = encodeTextSignatureV1(item.id, phase);
|
|
1973
2000
|
stream.push({
|
|
@@ -3721,27 +3748,38 @@ async function openCodexSseEventStream(
|
|
|
3721
3748
|
sentModelsEtagHeader: headers.has(X_MODELS_ETAG_HEADER),
|
|
3722
3749
|
});
|
|
3723
3750
|
// `wrapCodexSseStream` arms the iterator-level idle watchdog only after this
|
|
3724
|
-
// fetch resolves.
|
|
3725
|
-
//
|
|
3726
|
-
//
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3751
|
+
// fetch resolves. Each transport attempt needs its own pre-response timer:
|
|
3752
|
+
// the retry loop's base signal remains reserved for caller cancellation, so
|
|
3753
|
+
// an internal timeout stays retryable while an explicit abort fails fast.
|
|
3754
|
+
let clearPreResponseTimeout: (() => void) | undefined;
|
|
3755
|
+
const fetchAttempt: FetchImpl = async (input, init) => {
|
|
3756
|
+
try {
|
|
3757
|
+
return await (fetchOverride ?? fetch)(input, init);
|
|
3758
|
+
} finally {
|
|
3759
|
+
clearPreResponseTimeout?.();
|
|
3760
|
+
clearPreResponseTimeout = undefined;
|
|
3761
|
+
}
|
|
3762
|
+
};
|
|
3730
3763
|
let response: Response;
|
|
3731
3764
|
try {
|
|
3732
3765
|
response = await fetchWithRetry(url, {
|
|
3733
3766
|
method: "POST",
|
|
3734
3767
|
headers,
|
|
3735
3768
|
body: JSON.stringify(body),
|
|
3736
|
-
signal
|
|
3769
|
+
signal,
|
|
3770
|
+
prepareInit: () => {
|
|
3771
|
+
const watchdog = armPreResponseTimeout(signal, firstEventTimeoutMs);
|
|
3772
|
+
clearPreResponseTimeout = watchdog.clear;
|
|
3773
|
+
return { signal: watchdog.signal };
|
|
3774
|
+
},
|
|
3737
3775
|
maxAttempts: CODEX_MAX_RETRIES + 1,
|
|
3738
3776
|
defaultDelayMs: attempt => CODEX_RETRY_DELAY_MS * (attempt + 1),
|
|
3739
3777
|
maxDelayMs: CODEX_RATE_LIMIT_BUDGET_MS,
|
|
3740
|
-
fetch:
|
|
3778
|
+
fetch: fetchAttempt,
|
|
3741
3779
|
timeout: false,
|
|
3742
3780
|
});
|
|
3743
3781
|
} finally {
|
|
3744
|
-
|
|
3782
|
+
clearPreResponseTimeout?.();
|
|
3745
3783
|
}
|
|
3746
3784
|
CODEX_DEBUG &&
|
|
3747
3785
|
logger.debug("[codex] codex response", {
|
|
@@ -1978,6 +1978,11 @@ export function appendMessageTextDelta(
|
|
|
1978
1978
|
}
|
|
1979
1979
|
stream.push({ type: "text_delta", contentIndex, delta, partial: output });
|
|
1980
1980
|
}
|
|
1981
|
+
/** Chooses final message text while treating non-empty terminal content as authoritative. */
|
|
1982
|
+
export function finalizeMessageText(item: ResponseOutputMessage, streamedText: string): string {
|
|
1983
|
+
if (!item.content?.length) return streamedText || "";
|
|
1984
|
+
return item.content.map(part => (part.type === "output_text" ? (part.text ?? "") : (part.refusal ?? ""))).join("");
|
|
1985
|
+
}
|
|
1981
1986
|
|
|
1982
1987
|
export function accumulateToolCallArgumentsDelta(
|
|
1983
1988
|
block: ResponsesToolCallBlock,
|
|
@@ -2455,9 +2460,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
2455
2460
|
closeOpenItem(event.output_index, item.id, entry);
|
|
2456
2461
|
} else if (item.type === "message") {
|
|
2457
2462
|
const block = entry?.block.type === "text" ? entry.block : undefined;
|
|
2458
|
-
const text = item
|
|
2459
|
-
.map(part => (part.type === "output_text" ? (part.text ?? "") : (part.refusal ?? "")))
|
|
2460
|
-
.join("");
|
|
2463
|
+
const text = finalizeMessageText(item, block?.text ?? "");
|
|
2461
2464
|
const textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
|
|
2462
2465
|
let contentIndex: number;
|
|
2463
2466
|
if (block) {
|
|
@@ -79,15 +79,35 @@ interface AnthropicTokenResponse {
|
|
|
79
79
|
refresh_token: string;
|
|
80
80
|
expires_in: number;
|
|
81
81
|
account?: { uuid?: string; email_address?: string };
|
|
82
|
+
organization?: { uuid?: string; name?: string };
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
interface AnthropicBootstrapResponse {
|
|
85
86
|
oauth_account?: {
|
|
86
87
|
account_uuid?: string;
|
|
87
88
|
account_email?: string;
|
|
89
|
+
organization_uuid?: string;
|
|
90
|
+
organization_name?: string;
|
|
88
91
|
};
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Account + organization identity slice resolved from the token response
|
|
96
|
+
* and/or the `/api/claude_cli/bootstrap` endpoint. The organization is the
|
|
97
|
+
* subscription workspace the token draws limits from — one account email can
|
|
98
|
+
* hold several (e.g. a Team seat plus a personal Max plan).
|
|
99
|
+
*/
|
|
100
|
+
interface AnthropicIdentity {
|
|
101
|
+
accountId?: string;
|
|
102
|
+
email?: string;
|
|
103
|
+
orgId?: string;
|
|
104
|
+
orgName?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
108
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
function parseOAuthTokenResponse(responseBody: string, operation: string): AnthropicTokenResponse {
|
|
92
112
|
try {
|
|
93
113
|
return JSON.parse(responseBody) as AnthropicTokenResponse;
|
|
@@ -100,28 +120,23 @@ function parseOAuthTokenResponse(responseBody: string, operation: string): Anthr
|
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
/**
|
|
103
|
-
* Lift the OAuth response's `account: { uuid, email_address }`
|
|
104
|
-
* {@link OAuthCredentials} so
|
|
105
|
-
* `metadata.user_id.account_uuid`,
|
|
106
|
-
*
|
|
107
|
-
*
|
|
123
|
+
* Lift the OAuth response's `account: { uuid, email_address }` and
|
|
124
|
+
* `organization: { uuid, name }` blocks onto {@link OAuthCredentials} so
|
|
125
|
+
* downstream identity propagation (e.g. `metadata.user_id.account_uuid`,
|
|
126
|
+
* usage tracking, org-scoped credential identity) works without a separate
|
|
127
|
+
* `/api/oauth/profile` round-trip. Returns `undefined` for any field the
|
|
128
|
+
* response omits or carries as a non-string / empty value.
|
|
108
129
|
*/
|
|
109
|
-
function extractAccountFromTokenResponse(data: AnthropicTokenResponse): {
|
|
110
|
-
accountId?: string;
|
|
111
|
-
email?: string;
|
|
112
|
-
} {
|
|
113
|
-
const accountUuid = data.account?.uuid;
|
|
114
|
-
const emailAddress = data.account?.email_address;
|
|
130
|
+
function extractAccountFromTokenResponse(data: AnthropicTokenResponse): AnthropicIdentity {
|
|
115
131
|
return {
|
|
116
|
-
accountId:
|
|
117
|
-
email:
|
|
132
|
+
accountId: nonEmpty(data.account?.uuid),
|
|
133
|
+
email: nonEmpty(data.account?.email_address),
|
|
134
|
+
orgId: nonEmpty(data.organization?.uuid),
|
|
135
|
+
orgName: nonEmpty(data.organization?.name),
|
|
118
136
|
};
|
|
119
137
|
}
|
|
120
138
|
|
|
121
|
-
async function fetchBootstrapIdentity(
|
|
122
|
-
accessToken: string,
|
|
123
|
-
fetchImpl: FetchImpl,
|
|
124
|
-
): Promise<{ accountId?: string; email?: string }> {
|
|
139
|
+
async function fetchBootstrapIdentity(accessToken: string, fetchImpl: FetchImpl): Promise<AnthropicIdentity> {
|
|
125
140
|
const url = `${BOOTSTRAP_URL}?entrypoint=cli&model=${encodeURIComponent(CLAUDE_CODE_BOOTSTRAP_MODEL)}`;
|
|
126
141
|
const response = await fetchImpl(url, {
|
|
127
142
|
method: "GET",
|
|
@@ -150,25 +165,36 @@ async function fetchBootstrapIdentity(
|
|
|
150
165
|
{ kind: "validation", provider: "anthropic", cause: error },
|
|
151
166
|
);
|
|
152
167
|
}
|
|
153
|
-
const accountUuid = data.oauth_account?.account_uuid;
|
|
154
|
-
const accountEmail = data.oauth_account?.account_email;
|
|
155
168
|
return {
|
|
156
|
-
accountId:
|
|
157
|
-
email:
|
|
169
|
+
accountId: nonEmpty(data.oauth_account?.account_uuid),
|
|
170
|
+
email: nonEmpty(data.oauth_account?.account_email),
|
|
171
|
+
orgId: nonEmpty(data.oauth_account?.organization_uuid),
|
|
172
|
+
orgName: nonEmpty(data.oauth_account?.organization_name),
|
|
158
173
|
};
|
|
159
174
|
}
|
|
160
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Resolve account (and optionally organization) identity for a token
|
|
178
|
+
* response. `includeOrg` is login-only: the org an access token is scoped to
|
|
179
|
+
* is captured once when the credential is created and deliberately never
|
|
180
|
+
* refreshed afterwards — rewriting identity during background token
|
|
181
|
+
* refreshes could silently re-key stored credentials.
|
|
182
|
+
*/
|
|
161
183
|
async function resolveAccountIdentity(
|
|
162
184
|
data: AnthropicTokenResponse,
|
|
163
185
|
fetchImpl: FetchImpl,
|
|
164
|
-
|
|
186
|
+
options?: { includeOrg?: boolean },
|
|
187
|
+
): Promise<AnthropicIdentity> {
|
|
165
188
|
const identity = extractAccountFromTokenResponse(data);
|
|
166
|
-
|
|
189
|
+
const orgSatisfied = !options?.includeOrg || identity.orgId !== undefined;
|
|
190
|
+
if (identity.accountId && identity.email && orgSatisfied) return identity;
|
|
167
191
|
try {
|
|
168
192
|
const bootstrap = await fetchBootstrapIdentity(data.access_token, fetchImpl);
|
|
169
193
|
return {
|
|
170
194
|
accountId: identity.accountId ?? bootstrap.accountId,
|
|
171
195
|
email: identity.email ?? bootstrap.email,
|
|
196
|
+
orgId: identity.orgId ?? bootstrap.orgId,
|
|
197
|
+
orgName: identity.orgName ?? bootstrap.orgName,
|
|
172
198
|
};
|
|
173
199
|
} catch {
|
|
174
200
|
return identity;
|
|
@@ -243,7 +269,9 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
|
|
243
269
|
}
|
|
244
270
|
|
|
245
271
|
const tokenData = parseOAuthTokenResponse(responseBody, "token exchange");
|
|
246
|
-
const { accountId, email } = await resolveAccountIdentity(tokenData, this.#fetch
|
|
272
|
+
const { accountId, email, orgId, orgName } = await resolveAccountIdentity(tokenData, this.#fetch, {
|
|
273
|
+
includeOrg: true,
|
|
274
|
+
});
|
|
247
275
|
|
|
248
276
|
return {
|
|
249
277
|
refresh: tokenData.refresh_token,
|
|
@@ -251,6 +279,8 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
|
|
251
279
|
expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
|
|
252
280
|
accountId,
|
|
253
281
|
email,
|
|
282
|
+
orgId,
|
|
283
|
+
orgName,
|
|
254
284
|
};
|
|
255
285
|
}
|
|
256
286
|
}
|
|
@@ -299,6 +329,9 @@ export async function refreshAnthropicToken(
|
|
|
299
329
|
}
|
|
300
330
|
|
|
301
331
|
const data = parseOAuthTokenResponse(responseBody, "token refresh");
|
|
332
|
+
// Deliberately no `includeOrg` and no org fields on the result: the org a
|
|
333
|
+
// credential is scoped to is fixed at login. Callers merge refresh results
|
|
334
|
+
// over the stored credential, so omitting org here preserves it verbatim.
|
|
302
335
|
const { accountId, email } = await resolveAccountIdentity(data, fetchImpl);
|
|
303
336
|
|
|
304
337
|
return {
|
|
@@ -305,7 +305,7 @@
|
|
|
305
305
|
if (serverState.ok) {
|
|
306
306
|
app.classList.add("success", "countdown");
|
|
307
307
|
title.textContent = "Authentication Successful";
|
|
308
|
-
message.innerHTML = "You have successfully logged in.<br>
|
|
308
|
+
message.innerHTML = "You have successfully logged in.<br>You can now close this tab.";
|
|
309
309
|
setTimeout(() => window.close(), 3000);
|
|
310
310
|
} else {
|
|
311
311
|
app.classList.add("error");
|
|
@@ -10,6 +10,14 @@ export type OAuthCredentials = {
|
|
|
10
10
|
email?: string;
|
|
11
11
|
accountId?: string;
|
|
12
12
|
apiEndpoint?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Organization/workspace the token is scoped to (e.g. an Anthropic org
|
|
15
|
+
* UUID). Captured once at login; token refreshes never rewrite it. Lets
|
|
16
|
+
* one account email hold credentials for multiple subscriptions.
|
|
17
|
+
*/
|
|
18
|
+
orgId?: string;
|
|
19
|
+
/** Human-readable organization name for display (may embed the email). */
|
|
20
|
+
orgName?: string;
|
|
13
21
|
};
|
|
14
22
|
|
|
15
23
|
export type OAuthProvider = OAuthProviderUnion;
|
package/src/stream.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { CATALOG_PROVIDERS, type ProviderCatalogEntry } from "@oh-my-pi/pi-catal
|
|
|
17
17
|
import { CODEX_BASE_URL } from "@oh-my-pi/pi-catalog/wire/codex";
|
|
18
18
|
import { $env, $pickenv, getConfigRootDir, isEnoent, logger, withExtraCaFetch } from "@oh-my-pi/pi-utils";
|
|
19
19
|
import { getCustomApi } from "./api-registry";
|
|
20
|
-
import {
|
|
20
|
+
import { createAuthRetryKeyState, isApiKeyResolver, resolveNextAuthRetryKey } from "./auth-retry";
|
|
21
21
|
import * as AIError from "./error";
|
|
22
22
|
import { ProviderHttpError } from "./error";
|
|
23
23
|
import { isUsageLimitOutcome } from "./error/rate-limit";
|
|
@@ -978,17 +978,19 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me
|
|
|
978
978
|
// per-minute caps) classify as RATE_LIMIT_EXCEEDED in
|
|
979
979
|
// `parseRateLimitReason` and stay in the provider's own backoff layer
|
|
980
980
|
// instead of burning siblings.
|
|
981
|
+
if (AIError.isUsageLimit(error)) return true;
|
|
981
982
|
if (status === 401) return true;
|
|
982
|
-
void error;
|
|
983
983
|
return isUsageLimitOutcome(status, message);
|
|
984
984
|
}
|
|
985
985
|
|
|
986
986
|
function createAssistantAuthError(message: AssistantMessage): Error {
|
|
987
987
|
const text = message.errorMessage ?? "Provider authentication failed";
|
|
988
988
|
const status = extractStatusFromAssistantError(message);
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
989
|
+
const error =
|
|
990
|
+
status === undefined
|
|
991
|
+
? new AIError.ProviderResponseError(text, { kind: "runtime" })
|
|
992
|
+
: new ProviderHttpError(text, status);
|
|
993
|
+
return typeof message.errorId === "number" ? AIError.attach(error, message.errorId) : error;
|
|
992
994
|
}
|
|
993
995
|
|
|
994
996
|
function emitBufferedEvents(stream: AssistantMessageEventStream, events: AssistantMessageEvent[]): void {
|
|
@@ -1012,12 +1014,12 @@ export function streamSimple<TApi extends Api>(
|
|
|
1012
1014
|
if (apiKeyResolver) {
|
|
1013
1015
|
const outer = new AssistantMessageEventStream();
|
|
1014
1016
|
const signal = requestOptions?.signal;
|
|
1015
|
-
// One inner attempt against a resolved string key.
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
const runAttempt = async (apiKey: string
|
|
1017
|
+
// One inner attempt against a resolved string key. A retryable auth error
|
|
1018
|
+
// that arrives before any replay-unsafe event is buffered and returned
|
|
1019
|
+
// (so the caller can retry with a fresh key) instead of surfaced. Once any
|
|
1020
|
+
// non-start event escapes, retry is no longer safe and the failure is
|
|
1021
|
+
// emitted directly.
|
|
1022
|
+
const runAttempt = async (apiKey: string): Promise<AuthRetryFailure | undefined> => {
|
|
1021
1023
|
const bufferedEvents: AssistantMessageEvent[] = [];
|
|
1022
1024
|
let emittedReplayUnsafeEvent = false;
|
|
1023
1025
|
const flushBuffered = (): void => {
|
|
@@ -1034,7 +1036,6 @@ export function streamSimple<TApi extends Api>(
|
|
|
1034
1036
|
}
|
|
1035
1037
|
if (
|
|
1036
1038
|
!emittedReplayUnsafeEvent &&
|
|
1037
|
-
captureAuthFailure &&
|
|
1038
1039
|
event.type === "error" &&
|
|
1039
1040
|
isRetryableUpstreamError(
|
|
1040
1041
|
event.error,
|
|
@@ -1054,7 +1055,6 @@ export function streamSimple<TApi extends Api>(
|
|
|
1054
1055
|
} catch (error) {
|
|
1055
1056
|
if (
|
|
1056
1057
|
!emittedReplayUnsafeEvent &&
|
|
1057
|
-
captureAuthFailure &&
|
|
1058
1058
|
isRetryableUpstreamError(
|
|
1059
1059
|
error,
|
|
1060
1060
|
AIError.status(error),
|
|
@@ -1096,27 +1096,16 @@ export function streamSimple<TApi extends Api>(
|
|
|
1096
1096
|
outer.fail(new AIError.MissingApiKeyError(model.provider));
|
|
1097
1097
|
return;
|
|
1098
1098
|
}
|
|
1099
|
-
|
|
1099
|
+
const retryState = createAuthRetryKeyState(lastKey);
|
|
1100
|
+
let failure = await runAttempt(lastKey);
|
|
1100
1101
|
if (!failure) return;
|
|
1101
|
-
|
|
1102
|
-
// switch to a sibling (lastChance=true). A step is skipped when the
|
|
1103
|
-
// resolver yields the same key it just tried or `undefined`; the
|
|
1104
|
-
// final step's attempt clears the capture flag so it emits directly.
|
|
1105
|
-
for (let step = 0; step < AUTH_RETRY_STEPS.length; step++) {
|
|
1102
|
+
while (true) {
|
|
1106
1103
|
// Caller aborted between attempts: don't mint a fresh token or fire
|
|
1107
1104
|
// another doomed request — emit the captured failure instead.
|
|
1108
1105
|
if (signal?.aborted) break;
|
|
1109
|
-
const nextKey = await
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
failure.error,
|
|
1113
|
-
signal,
|
|
1114
|
-
lastKey,
|
|
1115
|
-
);
|
|
1116
|
-
if (nextKey === undefined || nextKey === lastKey) continue;
|
|
1117
|
-
lastKey = nextKey;
|
|
1118
|
-
const isLastStep = step === AUTH_RETRY_STEPS.length - 1;
|
|
1119
|
-
const next = await runAttempt(nextKey, !isLastStep);
|
|
1106
|
+
const nextKey = await resolveNextAuthRetryKey(retryState, apiKeyResolver, failure.error, signal);
|
|
1107
|
+
if (nextKey === undefined) break;
|
|
1108
|
+
const next = await runAttempt(nextKey);
|
|
1120
1109
|
if (!next) return;
|
|
1121
1110
|
failure = next;
|
|
1122
1111
|
}
|