@oh-my-pi/pi-ai 17.0.5 → 17.0.6

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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.6] - 2026-07-20
6
+
7
+ ### Fixed
8
+
9
+ - Fixed OpenAI Codex credentials limited to one ChatGPT workspace per email: a personal Plus/Pro plan and a Team/Enterprise seat under the same email now coexist in the auth store — with separate rotation and usage pools — instead of the second login silently replacing the first. The workspace (`chatgpt_account_id`) is captured as the credential's org at login with the plan type as its display label, and two members of one workspace keep separate rows ([#2966](https://github.com/can1357/oh-my-pi/issues/2966)).
10
+ - Fixed Devin total-token usage omitting cache reads and cache writes.
11
+ - Fixed model switches to Devin rejecting foreign provider response IDs, reasoning signatures, and empty interrupted turns as invalid Cascade history.
12
+ - Classified zero-output Devin `invalid_argument` trailers as context overflow when the serialized message history is already large, routing cumulative tool-output payload failures through context maintenance—including artifact-backed shake rescue—instead of retrying the same rejected history.
13
+
5
14
  ## [17.0.5] - 2026-07-18
6
15
 
7
16
  ### Changed
@@ -99,7 +99,7 @@ export interface CredentialHealthResult {
99
99
  email?: string;
100
100
  /** OAuth account id if known. */
101
101
  accountId?: string;
102
- /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
102
+ /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
103
103
  orgId?: string;
104
104
  orgName?: string;
105
105
  /** `true` when the refresh token lives on a remote broker (sentinel was present). */
@@ -490,7 +490,7 @@ export interface OAuthAccess {
490
490
  projectId?: string;
491
491
  enterpriseUrl?: string;
492
492
  apiEndpoint?: string;
493
- /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
493
+ /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
494
494
  orgId?: string;
495
495
  orgName?: string;
496
496
  }
@@ -513,7 +513,7 @@ export interface OAuthAccessFailure {
513
513
  projectId?: string;
514
514
  enterpriseUrl?: string;
515
515
  apiEndpoint?: string;
516
- /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
516
+ /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
517
517
  orgId?: string;
518
518
  orgName?: string;
519
519
  error: string;
@@ -528,7 +528,7 @@ export interface OAuthAccountIdentity {
528
528
  accountId?: string;
529
529
  email?: string;
530
530
  projectId?: string;
531
- /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
531
+ /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
532
532
  orgId?: string;
533
533
  orgName?: string;
534
534
  }
@@ -549,7 +549,7 @@ export interface OAuthAccountSummary {
549
549
  email?: string;
550
550
  projectId?: string;
551
551
  enterpriseUrl?: string;
552
- /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
552
+ /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
553
553
  orgId?: string;
554
554
  orgName?: string;
555
555
  }
@@ -11,8 +11,9 @@ export type OAuthCredentials = {
11
11
  apiEndpoint?: string;
12
12
  /**
13
13
  * Organization/workspace the token is scoped to (e.g. an Anthropic org
14
- * UUID). Captured once at login; token refreshes never rewrite it. Lets
15
- * one account email hold credentials for multiple subscriptions.
14
+ * UUID or a ChatGPT workspace id). Captured once at login; token refreshes
15
+ * never rewrite it. Lets one account email hold credentials for multiple
16
+ * subscriptions.
16
17
  */
17
18
  orgId?: string;
18
19
  /** Human-readable organization name for display (may embed the email). */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.0.5",
4
+ "version": "17.0.6",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.0.5",
42
- "@oh-my-pi/pi-utils": "17.0.5",
43
- "@oh-my-pi/pi-wire": "17.0.5",
41
+ "@oh-my-pi/pi-catalog": "17.0.6",
42
+ "@oh-my-pi/pi-utils": "17.0.6",
43
+ "@oh-my-pi/pi-wire": "17.0.6",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -186,7 +186,7 @@ export interface CredentialHealthResult {
186
186
  email?: string;
187
187
  /** OAuth account id if known. */
188
188
  accountId?: string;
189
- /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
189
+ /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
190
190
  orgId?: string;
191
191
  orgName?: string;
192
192
  /** `true` when the refresh token lives on a remote broker (sentinel was present). */
@@ -735,7 +735,7 @@ export interface OAuthAccess {
735
735
  projectId?: string;
736
736
  enterpriseUrl?: string;
737
737
  apiEndpoint?: string;
738
- /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
738
+ /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
739
739
  orgId?: string;
740
740
  orgName?: string;
741
741
  }
@@ -760,7 +760,7 @@ export interface OAuthAccessFailure {
760
760
  projectId?: string;
761
761
  enterpriseUrl?: string;
762
762
  apiEndpoint?: string;
763
- /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
763
+ /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
764
764
  orgId?: string;
765
765
  orgName?: string;
766
766
  error: string;
@@ -776,7 +776,7 @@ export interface OAuthAccountIdentity {
776
776
  accountId?: string;
777
777
  email?: string;
778
778
  projectId?: string;
779
- /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
779
+ /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
780
780
  orgId?: string;
781
781
  orgName?: string;
782
782
  }
@@ -795,7 +795,7 @@ export interface OAuthAccountSummary {
795
795
  email?: string;
796
796
  projectId?: string;
797
797
  enterpriseUrl?: string;
798
- /** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
798
+ /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
799
799
  orgId?: string;
800
800
  orgName?: string;
801
801
  }
@@ -3283,15 +3283,14 @@ export class AuthStorage {
3283
3283
  const identifiers: string[] = [];
3284
3284
  const email = this.#getUsageReportMetadataValue(report, "email");
3285
3285
  if (email) identifiers.push(`email:${email.toLowerCase()}`);
3286
- if (report.provider === "anthropic") {
3287
- // Anthropic: one account email can hold several organizations
3288
- // (Team seat + personal Max). Reports from different orgs must not
3289
- // merge — scope every identifier by org when the report carries one.
3290
- // When the email could not be recovered, fall back to the account
3291
- // (identical across orgs, hence the org qualifier is what keeps two
3292
- // subscriptions apart) so no-email reports still merge per org.
3293
- // Org-less reports (pre-upgrade caches) keep their bare identifiers
3294
- // and only merge among themselves.
3286
+ if (report.provider === "anthropic" || report.provider === "openai-codex") {
3287
+ // One account email can hold several org-scoped subscriptions
3288
+ // (Anthropic organizations, ChatGPT workspaces). Reports from
3289
+ // different orgs must not merge — scope every identifier by org
3290
+ // when the report carries one; fall back to the account when the
3291
+ // email could not be recovered so no-email reports still merge
3292
+ // per org. Org-less reports (pre-upgrade caches) keep their bare
3293
+ // identifiers and only merge among themselves.
3295
3294
  if (identifiers.length === 0) {
3296
3295
  const accountId =
3297
3296
  this.#getUsageReportMetadataValue(report, "accountId") ?? this.#getUsageReportScopeAccountId(report);
@@ -3299,12 +3298,11 @@ export class AuthStorage {
3299
3298
  }
3300
3299
  const orgId = this.#getUsageReportMetadataValue(report, "orgId");
3301
3300
  if (orgId) {
3302
- if (identifiers.length === 0) return [`anthropic:org:${orgId.toLowerCase()}`];
3303
- return identifiers.map(identifier => `anthropic:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`);
3301
+ if (identifiers.length === 0) return [`${report.provider}:org:${orgId.toLowerCase()}`];
3302
+ return identifiers.map(
3303
+ identifier => `${report.provider}:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`,
3304
+ );
3304
3305
  }
3305
- return identifiers.map(identifier => `anthropic:${identifier.toLowerCase()}`);
3306
- }
3307
- if (report.provider === "openai-codex") {
3308
3306
  return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
3309
3307
  }
3310
3308
  const projectId =
@@ -5294,9 +5292,15 @@ export class AuthStorage {
5294
5292
  if (credential.type !== "oauth") continue;
5295
5293
  const credentialEmail = credential.email?.trim().toLowerCase();
5296
5294
  const credentialAccountId = credential.accountId?.trim().toLowerCase();
5297
- if ((email && credentialEmail === email) || (accountId && credentialAccountId === accountId)) {
5298
- matches.push(entry.id);
5299
- }
5295
+ // Every identity dimension present on BOTH sides must agree the
5296
+ // account id is shared workspace-wide and one email can span
5297
+ // workspaces, so a single-dimension match can cross-link siblings.
5298
+ const emailComparable = Boolean(email && credentialEmail);
5299
+ const accountComparable = Boolean(accountId && credentialAccountId);
5300
+ if (!emailComparable && !accountComparable) continue;
5301
+ if (emailComparable && credentialEmail !== email) continue;
5302
+ if (accountComparable && credentialAccountId !== accountId) continue;
5303
+ matches.push(entry.id);
5300
5304
  }
5301
5305
  return matches;
5302
5306
  }
@@ -5906,16 +5910,15 @@ function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): Store
5906
5910
 
5907
5911
  function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
5908
5912
  const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
5909
- if (provider === "anthropic") {
5910
- // One Anthropic account email can hold several organizations (e.g. a
5911
- // Team seat plus a personal Max plan), each with its own org-scoped
5912
- // token and limit pools. Scope identity by org so both subscriptions
5913
- // can be stored side by side. The qualifier rides on whichever base
5914
- // identity is available the account UUID is IDENTICAL across the
5915
- // orgs of one login account, so an unqualified account/project
5916
- // fallback would still collapse two subscriptions whenever the email
5917
- // could not be recovered. Org-less credentials (rows written before
5918
- // org capture existed) keep their bare key.
5913
+ if (provider === "anthropic" || provider === "openai-codex") {
5914
+ // One account email can hold several organizations/workspaces (e.g. a
5915
+ // Team seat plus a personal plan), each with its own org-scoped token
5916
+ // and limit pools. Scope identity by org so both subscriptions can be
5917
+ // stored side by side. The qualifier rides on whichever base identity
5918
+ // is available, so an unqualified account/project fallback would
5919
+ // still collapse two subscriptions whenever the email could not be
5920
+ // recovered. Org-less credentials (rows written before org capture
5921
+ // existed) keep their bare key.
5919
5922
  const base =
5920
5923
  emailIdentifier ??
5921
5924
  identifiers.find(identifier => identifier.startsWith("account:")) ??
@@ -5925,7 +5928,6 @@ function resolveProviderCredentialIdentityKey(provider: string, identifiers: str
5925
5928
  // No base identity at all: the org alone still distinguishes the row.
5926
5929
  return orgIdentifier ?? null;
5927
5930
  }
5928
- if (provider === "openai-codex" && emailIdentifier) return emailIdentifier;
5929
5931
  const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
5930
5932
  if (accountIdentifier) return accountIdentifier;
5931
5933
  if (emailIdentifier) return emailIdentifier;
@@ -5962,9 +5964,9 @@ function matchesReplacementCredential(
5962
5964
  if (incomingIdentityKey === existingIdentityKey) return true;
5963
5965
  if (existingIdentityKey === null) return false;
5964
5966
  // One-way upgrade, applied only when the INCOMING identity key carries the
5965
- // org qualifier (only anthropic keys do, so other providers never reach the
5966
- // checks below). An org-scoped login `org:<o>` claims (and re-keys) any
5967
- // existing row that denotes the same subscription:
5967
+ // org qualifier (only anthropic and openai-codex keys do, so other
5968
+ // providers never reach the checks below). An org-scoped login `org:<o>`
5969
+ // claims (and re-keys) any existing row that denotes the same subscription:
5968
5970
  // - `org:<o>` — org-only row stored when identity recovery failed, claimed
5969
5971
  // once a later same-org login recovers a base identity;
5970
5972
  // - `<b>` for any base identity `<b>` (email/account/project) the incoming
@@ -5988,10 +5990,16 @@ function matchesReplacementCredential(
5988
5990
  existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`)
5989
5991
  ? extractOAuthCredentialIdentifiers(existing)
5990
5992
  : null;
5993
+ // A base identifier that merely repeats the org qualifier's id carries no
5994
+ // per-user identity (openai-codex stores the ChatGPT workspace id as both
5995
+ // accountId and orgId, shared by every member) — letting it act as a
5996
+ // claimable base would re-key another member's same-org row.
5997
+ const orgQualifierId = orgIdentifier.slice("org:".length);
5991
5998
  for (const identifier of incomingIdentifiers) {
5992
5999
  const isBase =
5993
6000
  identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:");
5994
6001
  if (!isBase) continue;
6002
+ if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue;
5995
6003
  if (existingIdentityKey === identifier) return true;
5996
6004
  if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true;
5997
6005
  if (existingIdentifiers?.includes(identifier)) return true;
@@ -43,9 +43,11 @@ import type {
43
43
  Tool,
44
44
  ToolCall,
45
45
  } from "../types";
46
+ import { isDemotedThinking } from "../utils/block-symbols";
46
47
  import { deterministicUuid } from "../utils/deterministic-id";
47
48
  import { AssistantMessageEventStream } from "../utils/event-stream";
48
49
  import { toolWireSchema } from "../utils/schema/wire";
50
+ import { transformMessages } from "./transform-messages";
49
51
 
50
52
  /** Base host for Codeium/Windsurf's Cascade chat API (Connect protocol over HTTP/1.1). */
51
53
  export const DEVIN_API_URL = "https://server.codeium.com";
@@ -78,6 +80,13 @@ const CONNECT_END_STREAM_FLAG = 0x02;
78
80
  * fails fast instead of consuming memory.
79
81
  */
80
82
  const MAX_CONNECT_FRAME_PAYLOAD = 16 * 1024 * 1024;
83
+ /**
84
+ * Recovery heuristic for opaque Devin `invalid_argument` trailers. This is not
85
+ * asserted to be the backend's hard limit: small requests can hit the same
86
+ * intermittent error, while compactable message history this large is likely
87
+ * to benefit from the existing context-overflow maintenance path.
88
+ */
89
+ const LARGE_HISTORY_RECOVERY_BYTES = 512 * 1024;
81
90
 
82
91
  export const streamDevin: StreamFunction<"devin-agent"> = (
83
92
  model: Model<"devin-agent">,
@@ -157,10 +166,14 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
157
166
  const auth = await fetchDevinAuthMetadata(apiKey, baseUrl, fetchImpl, options?.signal);
158
167
  const chatBaseUrl = auth.baseUrl ?? baseUrl;
159
168
  const request = buildDevinChatRequest(model, context, options, apiKey, auth.userJwt);
160
- logger.debug("devin: sending chat request", { model: model.id, tools: context.tools?.length ?? 0 });
161
-
162
169
  const reqBytes = toBinary(GetChatMessageRequestSchema, request);
163
170
  const gz = gzipSync(reqBytes);
171
+ logger.debug("devin: sending chat request", {
172
+ model: model.id,
173
+ tools: context.tools?.length ?? 0,
174
+ requestBytes: reqBytes.byteLength,
175
+ compressedBytes: gz.byteLength,
176
+ });
164
177
  const frame = Buffer.alloc(5 + gz.length);
165
178
  frame[0] = CONNECT_COMPRESSED_FLAG;
166
179
  frame.writeUInt32BE(gz.length, 1);
@@ -223,7 +236,53 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
223
236
  if (flag & CONNECT_END_STREAM_FLAG) {
224
237
  const trailerBytes = flag & CONNECT_COMPRESSED_FLAG ? gunzipSync(payload) : payload;
225
238
  const trailerError = readConnectTrailerError(trailerBytes.toString("utf8").trim());
226
- if (trailerError) throw new AIError.ValidationError(trailerError);
239
+ if (trailerError) {
240
+ const error = new AIError.ValidationError(trailerError.formatted);
241
+ if (
242
+ firstTokenTime === undefined &&
243
+ trailerError.code.toLowerCase() === "invalid_argument" &&
244
+ /\binternal error\b/i.test(trailerError.message)
245
+ ) {
246
+ // The full protobuf also contains the system prompt and tool
247
+ // schemas, which history maintenance cannot shrink. Re-encode
248
+ // only the repeated history field before choosing recovery.
249
+ let activeTailCount = 0;
250
+ const lastRole = context.messages.at(-1)?.role;
251
+ if (lastRole === "user" || lastRole === "developer") {
252
+ activeTailCount = 1;
253
+ // A trailing developer message can accompany the current user
254
+ // prompt. Earlier user-role records may instead be flushed
255
+ // execution history and must remain eligible for compaction.
256
+ if (lastRole === "developer") {
257
+ for (let i = context.messages.length - 2; i >= 0; i--) {
258
+ const role = context.messages[i].role;
259
+ if (role !== "user" && role !== "developer") break;
260
+ activeTailCount++;
261
+ }
262
+ }
263
+ }
264
+ const shrinkablePrompts =
265
+ activeTailCount > 0
266
+ ? request.chatMessagePrompts.slice(0, -activeTailCount)
267
+ : request.chatMessagePrompts;
268
+ const historyBytes = toBinary(
269
+ GetChatMessageRequestSchema,
270
+ create(GetChatMessageRequestSchema, {
271
+ chatMessagePrompts: shrinkablePrompts,
272
+ }),
273
+ ).byteLength;
274
+ if (historyBytes >= LARGE_HISTORY_RECOVERY_BYTES) {
275
+ AIError.attach(error, AIError.create(AIError.Flag.ContextOverflow));
276
+ logger.warn("devin: treating large-history invalid_argument as context overflow", {
277
+ model: model.id,
278
+ historyBytes,
279
+ requestBytes: reqBytes.byteLength,
280
+ compressedBytes: gz.byteLength,
281
+ });
282
+ }
283
+ }
284
+ throw error;
285
+ }
227
286
  continue;
228
287
  }
229
288
 
@@ -322,7 +381,8 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
322
381
  output.usage.output = Number(msg.usage.outputTokens);
323
382
  output.usage.cacheRead = Number(msg.usage.cacheReadTokens);
324
383
  output.usage.cacheWrite = Number(msg.usage.cacheWriteTokens);
325
- output.usage.totalTokens = output.usage.input + output.usage.output;
384
+ output.usage.totalTokens =
385
+ output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
326
386
  }
327
387
  }
328
388
 
@@ -442,6 +502,7 @@ function buildDevinChatRequest(
442
502
  options?.stopSequences && options.stopSequences.length > 0
443
503
  ? [...DEVIN_DEFAULT_STOP_PATTERNS, ...options.stopSequences]
444
504
  : DEVIN_DEFAULT_STOP_PATTERNS;
505
+ const messages = transformMessages(context.messages, model);
445
506
  return create(GetChatMessageRequestSchema, {
446
507
  metadata: create(MetadataSchema, {
447
508
  apiKey,
@@ -453,7 +514,7 @@ function buildDevinChatRequest(
453
514
  locale: "en",
454
515
  }),
455
516
  prompt: (context.systemPrompt ?? []).join("\n\n"),
456
- chatMessagePrompts: buildChatMessagePrompts(context.messages, cascadeId),
517
+ chatMessagePrompts: buildChatMessagePrompts(messages, cascadeId, model),
457
518
  chatModelUid: options?.chatModelUid ?? model.requestModelId ?? model.id,
458
519
  requestType: ChatMessageRequestType.CASCADE,
459
520
  plannerMode: ConversationalPlannerMode.DEFAULT,
@@ -485,7 +546,11 @@ function buildDevinChatRequest(
485
546
  }
486
547
 
487
548
  /** Map omp `Message` history onto Cascade `ChatMessagePrompt`s (USER / SYSTEM / TOOL channels). */
488
- function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMessagePrompt[] {
549
+ function buildChatMessagePrompts(
550
+ messages: Message[],
551
+ cascadeId: string,
552
+ model: Model<"devin-agent">,
553
+ ): ChatMessagePrompt[] {
489
554
  const prompts: ChatMessagePrompt[] = [];
490
555
  // messageId seeds are `cascadeId\0index\0role[...]` — prompt text is excluded
491
556
  // so ids stay stable across content edits / history rebuilds.
@@ -513,16 +578,18 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
513
578
  }),
514
579
  );
515
580
  } else if (msg.role === "assistant") {
581
+ const isNativeDevinMessage =
582
+ msg.api === model.api && msg.provider === model.provider && msg.model === model.id;
516
583
  let promptText = "";
517
584
  let thinkingText = "";
518
585
  let signature = "";
519
586
  const toolCalls: ChatToolCall[] = [];
520
587
  for (const part of msg.content) {
521
588
  if (part.type === "text") {
522
- promptText += part.text;
589
+ promptText += `${part.text}${isDemotedThinking(part) ? "\n" : ""}`;
523
590
  } else if (part.type === "thinking") {
524
591
  thinkingText += part.thinking;
525
- if (!signature && part.thinkingSignature) signature = part.thinkingSignature;
592
+ if (isNativeDevinMessage && !signature && part.thinkingSignature) signature = part.thinkingSignature;
526
593
  } else if (part.type === "toolCall") {
527
594
  toolCalls.push(
528
595
  create(ChatToolCallSchema, {
@@ -533,9 +600,13 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
533
600
  );
534
601
  }
535
602
  }
603
+ if (!promptText && !thinkingText && !signature && toolCalls.length === 0) continue;
536
604
  prompts.push(
537
605
  create(ChatMessagePromptSchema, {
538
- messageId: msg.responseId ?? `bot-${deterministicUuid(`${cascadeId}\0${index}\0assistant`)}`,
606
+ messageId:
607
+ isNativeDevinMessage && msg.responseId
608
+ ? msg.responseId
609
+ : `bot-${deterministicUuid(`${cascadeId}\0${index}\0assistant`)}`,
539
610
  source: ChatMessageSource.SYSTEM,
540
611
  prompt: promptText,
541
612
  thinking: thinkingText,
@@ -569,12 +640,18 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe
569
640
  return prompts;
570
641
  }
571
642
 
643
+ interface ConnectTrailerError {
644
+ code: string;
645
+ message: string;
646
+ formatted: string;
647
+ }
648
+
572
649
  /**
573
- * Parse a Connect end-of-stream JSON trailer and return a human-readable error
574
- * string when it carries `{ error: { code, message } }`, else `null`. The trailer
575
- * is untrusted server output, so the shape is checked with guards rather than asserted.
650
+ * Parse a Connect end-of-stream JSON trailer and return its structured error
651
+ * when it carries `{ error: { code, message } }`, else `null`. The trailer is
652
+ * untrusted server output, so the shape is checked with guards rather than asserted.
576
653
  */
577
- function readConnectTrailerError(text: string): string | null {
654
+ function readConnectTrailerError(text: string): ConnectTrailerError | null {
578
655
  if (text.length === 0) return null;
579
656
  let parsed: unknown;
580
657
  try {
@@ -588,5 +665,9 @@ function readConnectTrailerError(text: string): string | null {
588
665
  const code = "code" in err && typeof err.code === "string" ? err.code : "";
589
666
  const message = "message" in err && typeof err.message === "string" ? err.message : "";
590
667
  if (!code && !message) return null;
591
- return `Devin stream error${code ? ` ${code}` : ""}: ${message}`;
668
+ return {
669
+ code,
670
+ message,
671
+ formatted: `Devin stream error${code ? ` ${code}` : ""}: ${message}`,
672
+ };
592
673
  }
@@ -31,6 +31,7 @@ const DEVICE_MAX_POLLS = 120;
31
31
  type JwtPayload = {
32
32
  [JWT_CLAIM_PATH]?: {
33
33
  chatgpt_account_id?: string;
34
+ chatgpt_plan_type?: string;
34
35
  };
35
36
  [JWT_PROFILE_CLAIM]?: {
36
37
  email?: string;
@@ -50,14 +51,27 @@ export function decodeJwt<T = Record<string, unknown>>(token: string): T | null
50
51
  }
51
52
  }
52
53
 
53
- function getTokenProfile(accessToken: string): { accountId?: string; email?: string } {
54
+ /**
55
+ * Identity slice decoded from the token claims. The ChatGPT workspace
56
+ * (`chatgpt_account_id`) is the subscription pool the token draws limits
57
+ * from — one account email can hold several (e.g. a personal Pro plan plus a
58
+ * Team seat). `chatgpt_plan_type` may only be present on the `id_token`.
59
+ */
60
+ function getTokenProfile(
61
+ accessToken: string,
62
+ idToken?: string,
63
+ ): { accountId?: string; email?: string; planType?: string } {
54
64
  const payload = decodeJwt<JwtPayload>(accessToken);
65
+ const idPayload = idToken ? decodeJwt<JwtPayload>(idToken) : null;
55
66
  const auth = payload?.[JWT_CLAIM_PATH];
67
+ const idAuth = idPayload?.[JWT_CLAIM_PATH];
56
68
  const accountId = auth?.chatgpt_account_id;
57
69
  const email = payload?.[JWT_PROFILE_CLAIM]?.email?.trim().toLowerCase();
70
+ const planType = (auth?.chatgpt_plan_type ?? idAuth?.chatgpt_plan_type)?.trim().toLowerCase();
58
71
  return {
59
72
  accountId: typeof accountId === "string" && accountId.length > 0 ? accountId : undefined,
60
73
  email: typeof email === "string" && email.length > 0 ? email : undefined,
74
+ planType: typeof planType === "string" && planType.length > 0 ? planType : undefined,
61
75
  };
62
76
  }
63
77
 
@@ -185,6 +199,7 @@ async function exchangeCodeForToken(
185
199
  const tokenData = (await tokenResponse.json()) as {
186
200
  access_token?: string;
187
201
  refresh_token?: string;
202
+ id_token?: string;
188
203
  expires_in?: number;
189
204
  };
190
205
 
@@ -192,7 +207,7 @@ async function exchangeCodeForToken(
192
207
  throw new AIError.OAuthError("Token response missing required fields", { kind: "validation" });
193
208
  }
194
209
 
195
- const { accountId, email } = getTokenProfile(tokenData.access_token);
210
+ const { accountId, email, planType } = getTokenProfile(tokenData.access_token, tokenData.id_token);
196
211
  if (!accountId) {
197
212
  throw new AIError.OAuthError("Failed to extract accountId from token", { kind: "validation" });
198
213
  }
@@ -203,6 +218,8 @@ async function exchangeCodeForToken(
203
218
  expires: Date.now() + tokenData.expires_in * 1000,
204
219
  accountId,
205
220
  email,
221
+ orgId: accountId,
222
+ orgName: planType,
206
223
  };
207
224
  }
208
225
 
@@ -354,6 +371,9 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
354
371
 
355
372
  const { accountId, email } = getTokenProfile(tokenData.access_token);
356
373
 
374
+ // Deliberately no org fields on the result: the workspace a credential is
375
+ // scoped to is fixed at login. Callers merge refresh results over the
376
+ // stored credential, so omitting org here preserves it verbatim.
357
377
  return {
358
378
  access: tokenData.access_token,
359
379
  refresh: tokenData.refresh_token || refreshToken,
@@ -12,8 +12,9 @@ export type OAuthCredentials = {
12
12
  apiEndpoint?: string;
13
13
  /**
14
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.
15
+ * UUID or a ChatGPT workspace id). Captured once at login; token refreshes
16
+ * never rewrite it. Lets one account email hold credentials for multiple
17
+ * subscriptions.
17
18
  */
18
19
  orgId?: string;
19
20
  /** Human-readable organization name for display (may embed the email). */