@code-yeongyu/senpi-ai 2026.10.18 → 2026.10.19-1
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/dist/api/anthropic-messages.js +10 -2
- package/dist/api/anthropic-request-shape-log.d.ts +24 -0
- package/dist/api/anthropic-request-shape-log.js +50 -0
- package/dist/api/chatgpt-usage-limit.d.ts +26 -0
- package/dist/api/chatgpt-usage-limit.js +103 -0
- package/dist/api/openai-codex-responses.js +10 -16
- package/dist/api/openai-images.js +7 -2
- package/dist/api/warm-prompt-cache.js +5 -2
- package/dist/auth/oauth/callback-server.d.ts +12 -0
- package/dist/auth/oauth/callback-server.js +23 -1
- package/dist/auth/oauth/chatgpt-subscription-callback.d.ts +25 -0
- package/dist/auth/oauth/chatgpt-subscription-callback.js +94 -0
- package/dist/auth/oauth/chatgpt-subscription.js +23 -26
- package/dist/providers/data/.manifest.json +1 -1
- package/dist/providers/data/baseten.json +1 -1
- package/dist/providers/data/fireworks.json +1 -1
- package/dist/providers/data/huggingface.json +1 -1
- package/dist/providers/data/nvidia.json +1 -1
- package/dist/providers/data/opencode-go.json +1 -1
- package/dist/providers/data/opencode.json +1 -1
- package/dist/providers/data/openrouter.json +1 -1
- package/dist/providers/data/qwen-token-plan-cn.json +1 -1
- package/dist/providers/data/radius.json +1 -1
- package/dist/providers/data/venice.json +1 -1
- package/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/utils/gzip-request-body.d.ts +11 -0
- package/dist/utils/gzip-request-body.js +108 -0
- package/dist/utils/prompt-cache-ttl.js +1 -0
- package/dist/utils/provider-failure-description.d.ts +5 -0
- package/dist/utils/provider-failure-description.js +8 -0
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|