@code-yeongyu/senpi-ai 2026.10.18 → 2026.10.19-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 (34) hide show
  1. package/dist/api/anthropic-messages.js +10 -2
  2. package/dist/api/anthropic-request-shape-log.d.ts +24 -0
  3. package/dist/api/anthropic-request-shape-log.js +50 -0
  4. package/dist/api/chatgpt-usage-limit.d.ts +26 -0
  5. package/dist/api/chatgpt-usage-limit.js +103 -0
  6. package/dist/api/openai-codex-responses.js +10 -16
  7. package/dist/api/openai-images.js +7 -2
  8. package/dist/api/warm-prompt-cache.js +5 -2
  9. package/dist/auth/oauth/callback-server.d.ts +12 -0
  10. package/dist/auth/oauth/callback-server.js +23 -1
  11. package/dist/auth/oauth/chatgpt-subscription-callback.d.ts +25 -0
  12. package/dist/auth/oauth/chatgpt-subscription-callback.js +94 -0
  13. package/dist/auth/oauth/chatgpt-subscription.js +23 -26
  14. package/dist/providers/data/.manifest.json +1 -1
  15. package/dist/providers/data/azure-openai-responses.json +1 -1
  16. package/dist/providers/data/baseten.json +1 -1
  17. package/dist/providers/data/fireworks.json +1 -1
  18. package/dist/providers/data/huggingface.json +1 -1
  19. package/dist/providers/data/nvidia.json +1 -1
  20. package/dist/providers/data/openai.json +1 -1
  21. package/dist/providers/data/opencode-go.json +1 -1
  22. package/dist/providers/data/opencode.json +1 -1
  23. package/dist/providers/data/openrouter.json +1 -1
  24. package/dist/providers/data/qwen-token-plan-cn.json +1 -1
  25. package/dist/providers/data/radius.json +1 -1
  26. package/dist/providers/data/venice.json +1 -1
  27. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  28. package/dist/types.d.ts +8 -0
  29. package/dist/utils/gzip-request-body.d.ts +11 -0
  30. package/dist/utils/gzip-request-body.js +108 -0
  31. package/dist/utils/prompt-cache-ttl.js +1 -0
  32. package/dist/utils/provider-failure-description.d.ts +5 -0
  33. package/dist/utils/provider-failure-description.js +8 -0
  34. package/package.json +2 -2
@@ -10,6 +10,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.js";
10
10
  import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.js";
11
11
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
12
12
  import { formatGitHubCopilotToolLimitError, limitGitHubCopilotTools, recordGitHubCopilotToolLimit, } from "../utils/github-copilot-tool-limit.js";
13
+ import { withGzipRequestBodies } from "../utils/gzip-request-body.js";
13
14
  import { headersToRecord, providerHeadersToRecord } from "../utils/headers.js";
14
15
  import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js";
15
16
  import { getPiUserAgent } from "../utils/pi-user-agent.js";
@@ -28,6 +29,7 @@ import { sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.
28
29
  import { resolveRootObjectSchema } from "../utils/tool-schema-compat.js";
29
30
  import { getCurrentTools, getDeclaredTools, getInitialSystemMessage, hasToolRedefinitions, normalizeContext, resolveTranscript, } from "../utils/transcript.js";
30
31
  import { isAnthropicWebSearchReplayBlock, isReplayableAnthropicProviderNativeBlock, } from "./anthropic-native-replay-blocks.js";
32
+ import { logAnthropicRequestShape } from "./anthropic-request-shape-log.js";
31
33
  import { sanitizeAnthropicToolPairs } from "./anthropic-tool-pairs.js";
32
34
  import { demoteToolReferenceReplay, demoteUnavailableToolReferences } from "./anthropic-tool-references.js";
33
35
  import { demoteExpiredWebSearchReplay, expiredWebSearchReplayIds, isExpiredWebSearchContentError, } from "./anthropic-web-search-expiry.js";
@@ -959,7 +961,9 @@ export const stream = (model, context, options) => {
959
961
  const cacheRetention = resolveCacheRetention(options?.cacheRetention ?? model.cacheRetention, options?.env, "short");
960
962
  const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
961
963
  openClient = () => {
962
- const created = createClient(model, apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, normalizedContext), options?.refusalFallbacks !== undefined && configuredBetaHeader(model, options) !== null, optionsHeaders, options?.fetch, copilotDynamicHeaders, cacheSessionId, options?.env, federation);
964
+ const created = createClient(model, apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, normalizedContext), options?.refusalFallbacks !== undefined && configuredBetaHeader(model, options) !== null, optionsHeaders, getAnthropicCompat(model).supportsGzipRequestBody
965
+ ? withGzipRequestBodies(options?.fetch)
966
+ : options?.fetch, copilotDynamicHeaders, cacheSessionId, options?.env, federation);
963
967
  client = created.client;
964
968
  isOAuth = created.isOAuthToken;
965
969
  advertisedClaudeCodeVersion = created.claudeCodeVersion;
@@ -1032,7 +1036,11 @@ export const stream = (model, context, options) => {
1032
1036
  maxRetries: 0,
1033
1037
  ...(payloadRequestMetadata.headers ? { headers: payloadRequestMetadata.headers } : {}),
1034
1038
  };
1035
- const send = (body) => awaitProviderTransport(() => client.beta.messages.create({ ...body, stream: true }, requestOptions).asResponse(), anthropicProviderDiagnosticFromError);
1039
+ // Logged before the call: the SDK throws on a non-2xx, and a rejected attempt is the one worth rebuilding.
1040
+ const send = (body) => {
1041
+ logAnthropicRequestShape(model, body);
1042
+ return awaitProviderTransport(() => client.beta.messages.create({ ...body, stream: true }, requestOptions).asResponse(), anthropicProviderDiagnosticFromError);
1043
+ };
1036
1044
  const sent = await sendWithForcedToolChoiceFallback({
1037
1045
  target: model,
1038
1046
  params,
@@ -0,0 +1,24 @@
1
+ /** Debug-only request skeleton: roles, block types and tool ids per index, never content (senpi#3727). */
2
+ export declare const REQUEST_SHAPE_LOG_ENV = "SENPI_DEBUG_REQUEST_SHAPE";
3
+ interface BlockSkeleton {
4
+ readonly type: string;
5
+ readonly id?: string;
6
+ readonly toolUseId?: string;
7
+ }
8
+ interface MessageSkeleton {
9
+ readonly index: number;
10
+ readonly role: string;
11
+ readonly blocks: readonly BlockSkeleton[] | "string";
12
+ }
13
+ export declare function anthropicRequestSkeleton(params: {
14
+ readonly messages?: unknown;
15
+ }): MessageSkeleton[];
16
+ type AppendFile = (path: string, data: string) => void;
17
+ export declare function logAnthropicRequestShape(model: {
18
+ readonly provider: string;
19
+ readonly id: string;
20
+ }, params: {
21
+ readonly messages?: unknown;
22
+ }, env?: Readonly<Record<string, string | undefined>>, appendFile?: AppendFile | undefined): void;
23
+ export {};
24
+ //# sourceMappingURL=anthropic-request-shape-log.d.ts.map
@@ -0,0 +1,50 @@
1
+ /** Debug-only request skeleton: roles, block types and tool ids per index, never content (senpi#3727). */
2
+ export const REQUEST_SHAPE_LOG_ENV = "SENPI_DEBUG_REQUEST_SHAPE";
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null;
5
+ }
6
+ function skeletonBlock(block) {
7
+ if (!isRecord(block))
8
+ return { type: typeof block };
9
+ const type = typeof block.type === "string" ? block.type : "unknown";
10
+ return {
11
+ type,
12
+ ...(typeof block.id === "string" ? { id: block.id } : {}),
13
+ ...(typeof block.tool_use_id === "string" ? { toolUseId: block.tool_use_id } : {}),
14
+ };
15
+ }
16
+ export function anthropicRequestSkeleton(params) {
17
+ const messages = Array.isArray(params.messages) ? params.messages : [];
18
+ return messages.map((message, index) => {
19
+ if (!isRecord(message))
20
+ return { index, role: "unknown", blocks: [] };
21
+ const role = typeof message.role === "string" ? message.role : "unknown";
22
+ const blocks = Array.isArray(message.content) ? message.content.map(skeletonBlock) : "string";
23
+ return { index, role, blocks };
24
+ });
25
+ }
26
+ function resolveAppendFile() {
27
+ if (typeof process === "undefined")
28
+ return undefined;
29
+ const fs = process.getBuiltinModule?.("node:fs");
30
+ return fs ? (path, data) => fs.appendFileSync(path, data, { mode: 0o600 }) : undefined;
31
+ }
32
+ function processEnv() {
33
+ return typeof process === "undefined" ? {} : process.env;
34
+ }
35
+ export function logAnthropicRequestShape(model, params, env = processEnv(), appendFile = undefined) {
36
+ const path = env[REQUEST_SHAPE_LOG_ENV];
37
+ if (path === undefined || path.length === 0)
38
+ return;
39
+ const append = appendFile ?? resolveAppendFile();
40
+ if (append === undefined)
41
+ return;
42
+ const record = {
43
+ ts: new Date().toISOString(),
44
+ provider: model.provider,
45
+ model: model.id,
46
+ messages: anthropicRequestSkeleton(params),
47
+ };
48
+ append(path, `${JSON.stringify(record)}\n`);
49
+ }
50
+ //# sourceMappingURL=anthropic-request-shape-log.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * User-facing wording for a ChatGPT sign-in refusal past the plan's usage limit (senpi#3615).
3
+ *
4
+ * Codex CLI sends no flag to spend credits: the backend decides from the account, and when it
5
+ * refuses it explains which limit applies through `x-codex-rate-limit-reached-type` and
6
+ * `x-codex-credits-has-credits` next to the body's `plan_type` and `resets_at` (openai/codex
7
+ * `codex-rs/protocol/src/error.rs`). This mirrors those cases and always ends on a next step. The
8
+ * text keeps the "You have hit your ChatGPT usage limit" opening every usage-limit classifier
9
+ * already matches, and never says "model": a model word would make the fallback layer treat an
10
+ * account-wide limit as one model's limit. Codex's free-form `x-codex-promo-message` is left out
11
+ * for that reason, since its wording is the backend's and can name models.
12
+ */
13
+ export declare const CHATGPT_USAGE_LIMIT_PREFIX = "You have hit your ChatGPT usage limit";
14
+ export interface ChatGptUsageLimitInput {
15
+ readonly planType?: string;
16
+ /** Reset as Unix seconds, from the error body. */
17
+ readonly resetsAt?: number;
18
+ readonly header: (name: string) => string | undefined;
19
+ readonly nowMs?: number;
20
+ }
21
+ /** HTTP refusal: a usage-limit code or any 429 whose JSON body carries an `error` object. */
22
+ export declare function describeChatGptUsageLimitResponse(status: number, body: unknown, headers: Headers): string | undefined;
23
+ /** WebSocket `error` event, which carries the HTTP status and headers inline (openai/codex `WrappedWebsocketErrorEvent`). */
24
+ export declare function describeChatGptUsageLimitEvent(event: Record<string, unknown>): string | undefined;
25
+ export declare function describeChatGptUsageLimit(input: ChatGptUsageLimitInput): string;
26
+ //# sourceMappingURL=chatgpt-usage-limit.d.ts.map
@@ -0,0 +1,103 @@
1
+ /**
2
+ * User-facing wording for a ChatGPT sign-in refusal past the plan's usage limit (senpi#3615).
3
+ *
4
+ * Codex CLI sends no flag to spend credits: the backend decides from the account, and when it
5
+ * refuses it explains which limit applies through `x-codex-rate-limit-reached-type` and
6
+ * `x-codex-credits-has-credits` next to the body's `plan_type` and `resets_at` (openai/codex
7
+ * `codex-rs/protocol/src/error.rs`). This mirrors those cases and always ends on a next step. The
8
+ * text keeps the "You have hit your ChatGPT usage limit" opening every usage-limit classifier
9
+ * already matches, and never says "model": a model word would make the fallback layer treat an
10
+ * account-wide limit as one model's limit. Codex's free-form `x-codex-promo-message` is left out
11
+ * for that reason, since its wording is the backend's and can name models.
12
+ */
13
+ import { ACCOUNT_LIMIT_NEXT_STEP_LEAD } from "../utils/provider-failure-description.js";
14
+ export const CHATGPT_USAGE_LIMIT_PREFIX = "You have hit your ChatGPT usage limit";
15
+ const OTHER_ROUTES = `${ACCOUNT_LIMIT_NEXT_STEP_LEAD}add an OpenAI API key, sign in with another ChatGPT account, or switch providers.`;
16
+ const USAGE_PAGE = "https://chatgpt.com/settings/usage";
17
+ const REACHED_TYPE_ADVICE = {
18
+ workspace_owner_credits_depleted: `Your workspace is out of credits: add credits at ${USAGE_PAGE}.`,
19
+ workspace_member_credits_depleted: "Your workspace is out of credits: ask your workspace owner to add more.",
20
+ workspace_owner_usage_limit_reached: "You hit the spend cap set for your workspace: raise it to continue.",
21
+ workspace_member_usage_limit_reached: "You hit the spend cap your workspace owner set: ask an owner to raise it to continue.",
22
+ };
23
+ const PLAN_ADVICE = {
24
+ plus: `Upgrade to Pro, or buy credits at ${USAGE_PAGE}.`,
25
+ pro: `Buy credits at ${USAGE_PAGE}.`,
26
+ prolite: `Buy credits at ${USAGE_PAGE}.`,
27
+ promax: `Buy credits at ${USAGE_PAGE}.`,
28
+ free: "Upgrade to ChatGPT Plus to continue.",
29
+ go: "Upgrade to ChatGPT Plus to continue.",
30
+ team: "Ask your workspace admin for more access.",
31
+ business: "Ask your workspace admin for more access.",
32
+ self_serve_business_usage_based: "Ask your workspace admin for more access.",
33
+ enterprise_cbp_usage_based: "Ask your workspace admin for more access.",
34
+ };
35
+ function headerBool(value) {
36
+ const raw = value?.trim().toLowerCase();
37
+ if (raw === "true" || raw === "1")
38
+ return true;
39
+ if (raw === "false" || raw === "0")
40
+ return false;
41
+ return undefined;
42
+ }
43
+ function limitAdvice(input, plan) {
44
+ const reachedType = input.header("x-codex-rate-limit-reached-type")?.trim().toLowerCase();
45
+ const reached = reachedType ? REACHED_TYPE_ADVICE[reachedType] : undefined;
46
+ if (reached)
47
+ return reached;
48
+ if (headerBool(input.header("x-codex-credits-has-credits")) === true) {
49
+ return "This account has ChatGPT credits, but ChatGPT did not apply them to this request.";
50
+ }
51
+ return plan ? PLAN_ADVICE[plan] : undefined;
52
+ }
53
+ const USAGE_LIMIT_CODE = /usage_limit_reached|usage_not_included/i;
54
+ const HTTP_LIMIT_CODE = /usage_limit_reached|usage_not_included|rate_limit_exceeded/i;
55
+ function asUsageLimitError(value) {
56
+ return value !== null && typeof value === "object" ? value : undefined;
57
+ }
58
+ function fromError(error, header) {
59
+ return describeChatGptUsageLimit({
60
+ ...(typeof error.plan_type === "string" ? { planType: error.plan_type } : {}),
61
+ ...(typeof error.resets_at === "number" ? { resetsAt: error.resets_at } : {}),
62
+ header,
63
+ });
64
+ }
65
+ function hasCode(error, pattern) {
66
+ const code = typeof error.code === "string" && error.code ? error.code : error.type;
67
+ return typeof code === "string" && pattern.test(code);
68
+ }
69
+ /** HTTP refusal: a usage-limit code or any 429 whose JSON body carries an `error` object. */
70
+ export function describeChatGptUsageLimitResponse(status, body, headers) {
71
+ const container = asUsageLimitError(body);
72
+ const error = container ? asUsageLimitError(Reflect.get(container, "error")) : undefined;
73
+ if (!error || !(hasCode(error, HTTP_LIMIT_CODE) || status === 429))
74
+ return undefined;
75
+ return fromError(error, (name) => headers.get(name) ?? undefined);
76
+ }
77
+ /** WebSocket `error` event, which carries the HTTP status and headers inline (openai/codex `WrappedWebsocketErrorEvent`). */
78
+ export function describeChatGptUsageLimitEvent(event) {
79
+ const error = asUsageLimitError(event.error);
80
+ if (!error || !hasCode(error, USAGE_LIMIT_CODE))
81
+ return undefined;
82
+ const headers = asUsageLimitError(event.headers);
83
+ return fromError(error, (name) => {
84
+ if (!headers)
85
+ return undefined;
86
+ const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name);
87
+ return typeof entry?.[1] === "string" ? entry[1] : undefined;
88
+ });
89
+ }
90
+ export function describeChatGptUsageLimit(input) {
91
+ const plan = input.planType?.trim().toLowerCase() || undefined;
92
+ const sentences = [`${CHATGPT_USAGE_LIMIT_PREFIX}${plan ? ` (${plan} plan)` : ""}.`];
93
+ const advice = limitAdvice(input, plan);
94
+ if (advice)
95
+ sentences.push(advice);
96
+ sentences.push(OTHER_ROUTES);
97
+ if (input.resetsAt !== undefined && Number.isFinite(input.resetsAt)) {
98
+ const mins = Math.max(0, Math.round((input.resetsAt * 1000 - (input.nowMs ?? Date.now())) / 60000));
99
+ sentences.push(`Try again in ~${mins} min.`);
100
+ }
101
+ return sentences.join(" ");
102
+ }
103
+ //# sourceMappingURL=chatgpt-usage-limit.js.map
@@ -20,6 +20,7 @@ import { appendRetryAfterMsMarker, extract429RetryAfterMs } from "../utils/retry
20
20
  import { getSystemMessageText } from "../utils/text.js";
21
21
  import { getDeclaredTools, getInitialSystemMessage, normalizeContext, resolveTranscript } from "../utils/transcript.js";
22
22
  import { uuidv7 } from "../utils/uuid.js";
23
+ import { describeChatGptUsageLimitEvent, describeChatGptUsageLimitResponse } from "./chatgpt-usage-limit.js";
23
24
  import { createGrammarToolInputProperties } from "./constrained-sampling.js";
24
25
  import { clearWebSocketFallbackState, getOrCreateWebSocketDebugStats, getWebSocketDebugStats, isWebSocketSseFallbackActive, recordWebSocketFailure, recordWebSocketSseFallback, recordWebSocketSuccess, } from "./openai-codex-responses/fallback-state.js";
25
26
  import { buildCodexReasoning } from "./openai-codex-responses/reasoning.js";
@@ -304,7 +305,7 @@ export const stream = (model, context, options) => {
304
305
  status: response.status,
305
306
  statusText: response.statusText,
306
307
  });
307
- const info = await parseErrorResponse(fakeResponse);
308
+ const info = await parseErrorResponse(fakeResponse, response.headers);
308
309
  let errorMessage = info.friendlyMessage || info.message;
309
310
  if (response.status === 429) {
310
311
  const hintMs = extract429RetryAfterMs({
@@ -450,16 +451,17 @@ function buildRequestBody(model, context, options, cacheSessionId, grammarToolIn
450
451
  return body;
451
452
  }
452
453
  function getServiceTierCostMultiplier(model, serviceTier) {
454
+ // This lane spends a ChatGPT plan's included usage, which OpenAI meters at 2.5x Standard for
455
+ // Fast and 8x for GPT-6 Astra / GPT-6.1 Sol Ultrafast (https://developers.openai.com/codex/pricing).
456
+ // The 2x / 6x figures are the purchased-credit and API rates; the openai-responses lane keeps those.
453
457
  switch (serviceTier) {
454
458
  case "ultrafast":
455
- // OpenAI prices GPT-6 Astra and GPT-6.1 Sol Ultrafast at 6x Standard on every
456
- // token class and context tier. Any other model keeps its base rate.
457
- return ["gpt-6-astra", "gpt-6.1-sol"].includes(model.upstreamModelId ?? model.id) ? 6 : 1;
459
+ return ["gpt-6-astra", "gpt-6.1-sol"].includes(model.upstreamModelId ?? model.id) ? 8 : 1;
458
460
  case "flex":
459
461
  return 0.5;
460
462
  case "priority":
461
463
  case "fast":
462
- return model.id === "gpt-5.5" ? 2.5 : 2;
464
+ return 2.5;
463
465
  default:
464
466
  return 1;
465
467
  }
@@ -586,7 +588,7 @@ async function* mapCodexEvents(events, output, model, onProviderStreamEvent) {
586
588
  if (type === "error") {
587
589
  const code = getCodexEventErrorCode(event);
588
590
  const message = getCodexEventErrorMessage(event);
589
- let errorText = `Codex error: ${message || code || JSON.stringify(event)}`;
591
+ let errorText = describeChatGptUsageLimitEvent(event) ?? `Codex error: ${message || code || JSON.stringify(event)}`;
590
592
  const hintMs = extract429RetryAfterMs({ bodyText: JSON.stringify(event) });
591
593
  if (hintMs !== undefined) {
592
594
  errorText = appendRetryAfterMsMarker(errorText, hintMs);
@@ -1282,23 +1284,15 @@ async function processWebSocketStream(url, body, headers, output, stream, model,
1282
1284
  // ============================================================================
1283
1285
  // Error Handling
1284
1286
  // ============================================================================
1285
- async function parseErrorResponse(response) {
1287
+ async function parseErrorResponse(response, headers) {
1286
1288
  const raw = await response.text();
1287
1289
  let message = raw || response.statusText || "Request failed";
1288
1290
  let friendlyMessage;
1289
1291
  try {
1290
1292
  const parsed = JSON.parse(raw);
1293
+ friendlyMessage = describeChatGptUsageLimitResponse(response.status, parsed, headers);
1291
1294
  const err = parsed?.error;
1292
1295
  if (err) {
1293
- const code = err.code || err.type || "";
1294
- if (/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) || response.status === 429) {
1295
- const plan = err.plan_type ? ` (${err.plan_type.toLowerCase()} plan)` : "";
1296
- const mins = err.resets_at
1297
- ? Math.max(0, Math.round((err.resets_at * 1000 - Date.now()) / 60000))
1298
- : undefined;
1299
- const when = mins !== undefined ? ` Try again in ~${mins} min.` : "";
1300
- friendlyMessage = `You have hit your ChatGPT usage limit${plan}.${when}`.trim();
1301
- }
1302
1296
  message = err.message || friendlyMessage || message;
1303
1297
  }
1304
1298
  }
@@ -1,6 +1,8 @@
1
1
  import OpenAI from "openai";
2
+ import { readProviderDiagnostic } from "../provider-diagnostic.js";
2
3
  import { formatProviderError, normalizeProviderError } from "../utils/error-body.js";
3
4
  import { headersToRecord } from "../utils/headers.js";
5
+ import { awaitProviderTransport, openAICompatibleProviderDiagnosticFromError, } from "../utils/provider-diagnostic-sources.js";
4
6
  import { retryProviderRequest } from "../utils/provider-retry.js";
5
7
  import { resolveOpenAIClientAuth } from "./openai-client-auth.js";
6
8
  import { buildEditParams } from "./openai-images-edit.js";
@@ -46,9 +48,9 @@ export const generateImages = async (model, context, options) => {
46
48
  const { data: response, response: rawResponse } = await retryProviderRequest(() => {
47
49
  // openai SDK pinned at 6.26.0 predates gpt-image-2.5 quality tiers
48
50
  const sdkParams = params;
49
- return (method === "edit"
51
+ return awaitProviderTransport(() => (method === "edit"
50
52
  ? client.images.edit(sdkParams, requestOptions)
51
- : client.images.generate(sdkParams, requestOptions)).withResponse();
53
+ : client.images.generate(sdkParams, requestOptions)).withResponse(), openAICompatibleProviderDiagnosticFromError);
52
54
  }, {
53
55
  maxRetries: options?.maxRetries,
54
56
  maxRetryDelayMs: options?.maxRetryDelayMs,
@@ -75,6 +77,9 @@ export const generateImages = async (model, context, options) => {
75
77
  catch (error) {
76
78
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
77
79
  output.errorMessage = formatProviderError(normalizeProviderError(error));
80
+ const providerDiagnostic = output.stopReason === "error" ? readProviderDiagnostic(error) : undefined;
81
+ if (providerDiagnostic !== undefined)
82
+ output.providerDiagnostic = providerDiagnostic;
78
83
  return output;
79
84
  }
80
85
  };
@@ -1,5 +1,6 @@
1
+ import { withGzipRequestBodies } from "../utils/gzip-request-body.js";
1
2
  import { providerHeadersToRecord } from "../utils/headers.js";
2
- import { isAnthropicApiBaseUrl } from "../utils/prompt-cache-ttl.js";
3
+ import { getAnthropicCompat, isAnthropicApiBaseUrl } from "../utils/prompt-cache-ttl.js";
3
4
  import { isOpenAIResponsesPromptCacheModel } from "./openai-responses-prompt-cache.js";
4
5
  import { getPromptCacheWarmer } from "./prompt-cache-warmers.js";
5
6
  export async function warmPromptCache(model, context, options = {}) {
@@ -38,7 +39,9 @@ export async function warmPromptCache(model, context, options = {}) {
38
39
  apiKey: options.apiKey ?? null,
39
40
  baseURL: anthropicModel.baseUrl,
40
41
  dangerouslyAllowBrowser: true,
41
- fetch: options.fetch,
42
+ fetch: getAnthropicCompat(anthropicModel).supportsGzipRequestBody
43
+ ? withGzipRequestBodies(options.fetch)
44
+ : options.fetch,
42
45
  defaultHeaders: { ...anthropicModel.headers, ...headers },
43
46
  });
44
47
  let params = buildAnthropicWarmPromptCacheParams(anthropicModel, context, options);
@@ -17,6 +17,11 @@ export interface OAuthCallbackServerOptions<T> {
17
17
  redirectHost?: string;
18
18
  /** Expected `state` parameter. Omit when the provider does not send one. */
19
19
  state?: string;
20
+ /**
21
+ * Path a newer login on this machine requests to take the port over (Codex CLI's `/cancel`). The
22
+ * listener stops waiting for the browser and frees the port; its login keeps the paste prompt.
23
+ */
24
+ cancelPath?: string;
20
25
  /**
21
26
  * Finishes the sign-in with the received code before the browser page is sent, so the page can
22
27
  * show exchange failures. Pass `async (code) => code` to exchange the code later.
@@ -36,6 +41,13 @@ export interface OAuthCallbackServer<T> {
36
41
  cancel(): void;
37
42
  close(): void;
38
43
  }
44
+ /**
45
+ * Header a newer login on this machine sends with its takeover request. A web page cannot send it:
46
+ * a custom header on a cross-origin request needs a CORS preflight this server never approves, and a
47
+ * plain GET (an `<img>` tag) or form POST cannot carry it. Requests that carry `Origin` came from a
48
+ * page and are refused outright (senpi#3623 review).
49
+ */
50
+ export declare const OAUTH_CANCEL_HEADER = "x-oauth-callback-cancel";
39
51
  export declare function startOAuthCallbackServer<T>(options: OAuthCallbackServerOptions<T>): Promise<OAuthCallbackServer<T>>;
40
52
  /**
41
53
  * Wait for the browser callback, or for the user to paste the code or redirect URL when the browser
@@ -6,6 +6,16 @@
6
6
  */
7
7
  import { createServer } from "node:http";
8
8
  import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.js";
9
+ /**
10
+ * Header a newer login on this machine sends with its takeover request. A web page cannot send it:
11
+ * a custom header on a cross-origin request needs a CORS preflight this server never approves, and a
12
+ * plain GET (an `<img>` tag) or form POST cannot carry it. Requests that carry `Origin` came from a
13
+ * page and are refused outright (senpi#3623 review).
14
+ */
15
+ export const OAUTH_CANCEL_HEADER = "x-oauth-callback-cancel";
16
+ function isLocalCancelRequest(request) {
17
+ return (request.method === "POST" && request.headers[OAUTH_CANCEL_HEADER] === "1" && request.headers.origin === undefined);
18
+ }
9
19
  function sendPage(response, status, html) {
10
20
  response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
11
21
  response.end(html);
@@ -41,12 +51,24 @@ export async function startOAuthCallbackServer(options) {
41
51
  const server = createServer((request, response) => {
42
52
  void (async () => {
43
53
  const url = new URL(request.url ?? "/", "http://localhost");
54
+ if (options.cancelPath !== undefined && url.pathname === options.cancelPath) {
55
+ if (!isLocalCancelRequest(request)) {
56
+ sendPage(response, 403, oauthErrorHtml("Not allowed."));
57
+ return;
58
+ }
59
+ sendPage(response, 200, oauthErrorHtml("This sign-in was replaced by a newer one."));
60
+ if (!claimed) {
61
+ finish({ value: undefined });
62
+ server.close();
63
+ }
64
+ return;
65
+ }
44
66
  if (request.method !== "GET" || url.pathname !== options.path) {
45
67
  sendPage(response, 404, oauthErrorHtml("Callback route not found."));
46
68
  return;
47
69
  }
48
70
  if (options.state !== undefined && url.searchParams.get("state") !== options.state) {
49
- sendPage(response, 400, oauthErrorHtml("State mismatch."));
71
+ sendPage(response, 400, oauthErrorHtml("State mismatch.", "This page belongs to a different sign-in than the one listening here. Copy this page's address and paste it into the session that is waiting for it, or start the sign-in again."));
50
72
  return;
51
73
  }
52
74
  if (claimed || settled) {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Loopback listener for the ChatGPT subscription browser login (senpi#3623).
3
+ *
4
+ * OpenAI registers only ports 1455 and 1457 for this client, so the port cannot be ephemeral. When
5
+ * another login on this machine (a second session, an abandoned RPC prompt, the desktop engine, or
6
+ * Codex CLI) still holds 1455, this asks it to let go with a `POST /cancel` carrying a header no web page can send (Codex CLI's server answers any method on `/cancel`) and retries briefly, then
7
+ * falls back to 1457, the same order as Codex CLI's `bind_server`. The authorize URL must name the
8
+ * port actually bound, so callers build it from the returned `redirectUri`.
9
+ *
10
+ * NOTE: uses node:http; reached only through the lazily loaded ChatGPT OAuth flow.
11
+ */
12
+ import { type OAuthCallbackServer } from "./callback-server.ts";
13
+ export declare const CHATGPT_CALLBACK_PORTS: readonly number[];
14
+ export interface ChatGptCallback {
15
+ readonly server: OAuthCallbackServer<string>;
16
+ readonly redirectUri: string;
17
+ }
18
+ export interface ChatGptCallbackOptions {
19
+ readonly host: string;
20
+ readonly state: string;
21
+ readonly signal: AbortSignal;
22
+ readonly ports?: readonly number[];
23
+ }
24
+ export declare function startChatGptCallback(options: ChatGptCallbackOptions): Promise<ChatGptCallback | undefined>;
25
+ //# sourceMappingURL=chatgpt-subscription-callback.d.ts.map
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Loopback listener for the ChatGPT subscription browser login (senpi#3623).
3
+ *
4
+ * OpenAI registers only ports 1455 and 1457 for this client, so the port cannot be ephemeral. When
5
+ * another login on this machine (a second session, an abandoned RPC prompt, the desktop engine, or
6
+ * Codex CLI) still holds 1455, this asks it to let go with a `POST /cancel` carrying a header no web page can send (Codex CLI's server answers any method on `/cancel`) and retries briefly, then
7
+ * falls back to 1457, the same order as Codex CLI's `bind_server`. The authorize URL must name the
8
+ * port actually bound, so callers build it from the returned `redirectUri`.
9
+ *
10
+ * NOTE: uses node:http; reached only through the lazily loaded ChatGPT OAuth flow.
11
+ */
12
+ import { request } from "node:http";
13
+ import { getProviderEnvValue } from "../../utils/provider-env.js";
14
+ import { sleep } from "../../utils/sleep.js";
15
+ import { OAUTH_CANCEL_HEADER, startOAuthCallbackServer } from "./callback-server.js";
16
+ export const CHATGPT_CALLBACK_PORTS = [1455, 1457];
17
+ const PORTS_ENV = "PI_CHATGPT_OAUTH_CALLBACK_PORTS";
18
+ const CALLBACK_PATH = "/auth/callback";
19
+ const CANCEL_PATH = "/cancel";
20
+ const PRIMARY_BIND_ATTEMPTS = 10;
21
+ const RETRY_DELAY_MS = 200;
22
+ const CANCEL_TIMEOUT_MS = 2_000;
23
+ function configuredPorts() {
24
+ const ports = (getProviderEnvValue(PORTS_ENV) ?? "")
25
+ .split(",")
26
+ .map((value) => Number.parseInt(value.trim(), 10))
27
+ .filter((port) => Number.isInteger(port) && port > 0 && port < 65536);
28
+ return ports.length > 0 ? ports : CHATGPT_CALLBACK_PORTS;
29
+ }
30
+ function isAddressInUse(error) {
31
+ return error instanceof Error && "code" in error && error.code === "EADDRINUSE";
32
+ }
33
+ function askHolderToCancel(host, port) {
34
+ return new Promise((resolve) => {
35
+ const outgoing = request({
36
+ host,
37
+ port,
38
+ path: CANCEL_PATH,
39
+ method: "POST",
40
+ headers: { [OAUTH_CANCEL_HEADER]: "1" },
41
+ timeout: CANCEL_TIMEOUT_MS,
42
+ }, (response) => {
43
+ response.resume();
44
+ response.on("end", resolve);
45
+ response.on("error", () => resolve());
46
+ });
47
+ outgoing.on("timeout", () => outgoing.destroy());
48
+ outgoing.on("error", () => resolve());
49
+ outgoing.end();
50
+ });
51
+ }
52
+ function listen(options, port) {
53
+ return startOAuthCallbackServer({
54
+ providerName: "OpenAI",
55
+ host: options.host,
56
+ port,
57
+ path: CALLBACK_PATH,
58
+ redirectHost: "localhost",
59
+ cancelPath: CANCEL_PATH,
60
+ state: options.state,
61
+ complete: async (code) => code,
62
+ signal: options.signal,
63
+ });
64
+ }
65
+ export async function startChatGptCallback(options) {
66
+ const [primary, ...fallbacks] = options.ports ?? configuredPorts();
67
+ if (primary === undefined)
68
+ return undefined;
69
+ for (let attempt = 0; attempt < PRIMARY_BIND_ATTEMPTS; attempt++) {
70
+ try {
71
+ const server = await listen(options, primary);
72
+ return { server, redirectUri: server.redirectUri };
73
+ }
74
+ catch (error) {
75
+ if (!isAddressInUse(error))
76
+ return undefined;
77
+ if (attempt === 0)
78
+ await askHolderToCancel(options.host, primary);
79
+ await sleep(RETRY_DELAY_MS, options.signal);
80
+ }
81
+ }
82
+ for (const port of fallbacks) {
83
+ try {
84
+ const server = await listen(options, port);
85
+ return { server, redirectUri: server.redirectUri };
86
+ }
87
+ catch (error) {
88
+ if (!isAddressInUse(error))
89
+ return undefined;
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+ //# sourceMappingURL=chatgpt-subscription-callback.js.map