@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/auth-broker/remote-store.d.ts +2 -0
  3. package/dist/types/auth-broker/wire-schemas.d.ts +28 -0
  4. package/dist/types/auth-retry.d.ts +44 -20
  5. package/dist/types/auth-storage.d.ts +51 -10
  6. package/dist/types/error/flags.d.ts +1 -0
  7. package/dist/types/index.d.ts +1 -0
  8. package/dist/types/providers/openai-chat-server-schema.d.ts +3 -3
  9. package/dist/types/providers/openai-shared.d.ts +2 -0
  10. package/dist/types/registry/oauth/types.d.ts +8 -0
  11. package/dist/types/usage/cursor.d.ts +3 -0
  12. package/dist/types/usage/openai-codex-reset.d.ts +1 -1
  13. package/dist/types/usage/openai-codex.d.ts +8 -1
  14. package/dist/types/usage/zai.d.ts +2 -1
  15. package/dist/types/usage.d.ts +4 -0
  16. package/dist/types/utils/idle-iterator.d.ts +6 -6
  17. package/package.json +4 -4
  18. package/src/auth-broker/discover.ts +22 -3
  19. package/src/auth-broker/remote-store.ts +103 -10
  20. package/src/auth-broker/wire-schemas.ts +4 -0
  21. package/src/auth-gateway/server.ts +3 -1
  22. package/src/auth-retry.ts +191 -53
  23. package/src/auth-storage.ts +668 -198
  24. package/src/error/auth-classify.ts +2 -1
  25. package/src/error/flags.ts +10 -0
  26. package/src/error/provider.ts +2 -2
  27. package/src/index.ts +1 -0
  28. package/src/providers/cursor.ts +10 -1
  29. package/src/providers/openai-chat-server-schema.ts +2 -1
  30. package/src/providers/openai-codex-responses.ts +74 -36
  31. package/src/providers/openai-shared.ts +6 -3
  32. package/src/registry/oauth/anthropic.ts +57 -24
  33. package/src/registry/oauth/oauth.html +1 -1
  34. package/src/registry/oauth/types.ts +8 -0
  35. package/src/stream.ts +19 -30
  36. package/src/usage/cursor.ts +192 -0
  37. package/src/usage/openai-codex-reset.ts +6 -2
  38. package/src/usage/openai-codex.ts +33 -0
  39. package/src/usage/zai.ts +49 -6
  40. package/src/usage.ts +4 -0
  41. 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;
@@ -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);
@@ -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 a terminal provider finish, not a transient fault.
40
- if (this.kind === "content-blocked") attach(this, create(Flag.ProviderFinishError));
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";
@@ -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: model.id,
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?": baseContent,
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: summaries then arrive as atomic
1697
- * `response.reasoning_summary_text.done` events and incremental
1698
- * `.delta`/`.part.*` events are ignored (mirrors codex-rs
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
- if (this.#sequentialCutoffSummaries) return firstTokenTime;
1778
- if (this.runtime.currentItem?.type === "reasoning" && this.runtime.currentBlock?.type === "thinking") {
1779
- appendReasoningSummaryTextDelta(
1780
- this.runtime.currentItem,
1781
- this.runtime.currentBlock,
1782
- (rawEvent as { delta?: string }).delta || "",
1783
- stream,
1784
- output,
1785
- output.content.length - 1,
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
- if (this.#sequentialCutoffSummaries) return firstTokenTime;
1831
- if (this.runtime.currentItem?.type === "reasoning" && this.runtime.currentBlock?.type === "thinking") {
1832
- appendReasoningSummaryPartDone(
1833
- this.runtime.currentItem,
1834
- this.runtime.currentBlock,
1835
- stream,
1836
- output,
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.content
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. A pre-response timer still bounds time-to-first-byte (a
3725
- // proxy that accepts the POST but never sends headers would otherwise hang
3726
- // forever, since `timeout: false` disables Bun's native ceiling issue
3727
- // #2422). It MUST be cleared the instant headers arrive: an absolute
3728
- // `AbortSignal.timeout` would keep aborting the actively-streaming body.
3729
- const watchdog = armPreResponseTimeout(signal, firstEventTimeoutMs);
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: watchdog.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: fetchOverride,
3778
+ fetch: fetchAttempt,
3741
3779
  timeout: false,
3742
3780
  });
3743
3781
  } finally {
3744
- watchdog.clear();
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.content
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 }` block onto
104
- * {@link OAuthCredentials} so downstream identity propagation (e.g.
105
- * `metadata.user_id.account_uuid`, usage tracking) works without a separate
106
- * `/api/oauth/profile` round-trip. Returns `undefined` for either field when
107
- * the response omits it or carries a non-string / empty value.
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: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined,
117
- email: typeof emailAddress === "string" && emailAddress.length > 0 ? emailAddress : undefined,
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: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined,
157
- email: typeof accountEmail === "string" && accountEmail.length > 0 ? accountEmail : undefined,
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
- ): Promise<{ accountId?: string; email?: string }> {
186
+ options?: { includeOrg?: boolean },
187
+ ): Promise<AnthropicIdentity> {
165
188
  const identity = extractAccountFromTokenResponse(data);
166
- if (identity.accountId && identity.email) return identity;
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>This window will close automatically.";
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 { AUTH_RETRY_STEPS, isApiKeyResolver, resolveRetryKey } from "./auth-retry";
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
- return status === undefined
990
- ? new AIError.ProviderResponseError(text, { kind: "runtime" })
991
- : new ProviderHttpError(text, status);
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. When
1016
- // `captureAuthFailure` is set, a retryable auth error that arrives before
1017
- // any replay-unsafe event is buffered and returned (so the caller can
1018
- // retry with a fresh key) instead of surfaced. The terminal attempt
1019
- // clears the flag and emits whatever it gets.
1020
- const runAttempt = async (apiKey: string, captureAuthFailure: boolean): Promise<AuthRetryFailure | undefined> => {
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
- let failure = await runAttempt(lastKey, true);
1099
+ const retryState = createAuthRetryKeyState(lastKey);
1100
+ let failure = await runAttempt(lastKey);
1100
1101
  if (!failure) return;
1101
- // a/b/c policy: refresh the same account (lastChance=false), then
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 resolveRetryKey(
1110
- apiKeyResolver,
1111
- AUTH_RETRY_STEPS[step]!,
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
  }