in codex-cli/src/utils/agent/agent-loop.ts [441:1436]
public async run(
input: Array<ResponseInputItem>,
previousResponseId: string = "",
): Promise<void> {
// ---------------------------------------------------------------------
// Top‑level error wrapper so that known transient network issues like
// `ERR_STREAM_PREMATURE_CLOSE` do not crash the entire CLI process.
// Instead we surface the failure to the user as a regular system‑message
// and terminate the current run gracefully. The calling UI can then let
// the user retry the request if desired.
// ---------------------------------------------------------------------
try {
if (this.terminated) {
throw new Error("AgentLoop has been terminated");
}
// Record when we start "thinking" so we can report accurate elapsed time.
const thinkingStart = Date.now();
// Bump generation so that any late events from previous runs can be
// identified and dropped.
const thisGeneration = ++this.generation;
// Reset cancellation flag and stream for a fresh run.
this.canceled = false;
this.currentStream = null;
// Create a fresh AbortController for this run so that tool calls from a
// previous run do not accidentally get signalled.
this.execAbortController = new AbortController();
log(
`AgentLoop.run(): new execAbortController created (${this.execAbortController.signal}) for generation ${this.generation}`,
);
// NOTE: We no longer (re‑)attach an `abort` listener to `hardAbort` here.
// A single listener that forwards the `abort` to the current
// `execAbortController` is installed once in the constructor. Re‑adding a
// new listener on every `run()` caused the same `AbortSignal` instance to
// accumulate listeners which in turn triggered Node's
// `MaxListenersExceededWarning` after ten invocations.
// Track the response ID from the last *stored* response so we can use
// `previous_response_id` when `disableResponseStorage` is enabled. When storage
// is disabled we deliberately ignore the caller‑supplied value because
// the backend will not retain any state that could be referenced.
// If the backend stores conversation state (`disableResponseStorage === false`) we
// forward the caller‑supplied `previousResponseId` so that the model sees the
// full context. When storage is disabled we *must not* send any ID because the
// server no longer retains the referenced response.
let lastResponseId: string = this.disableResponseStorage
? ""
: previousResponseId;
// If there are unresolved function calls from a previously cancelled run
// we have to emit dummy tool outputs so that the API no longer expects
// them. We prepend them to the user‑supplied input so they appear
// first in the conversation turn.
const abortOutputs: Array<ResponseInputItem> = [];
if (this.pendingAborts.size > 0) {
for (const id of this.pendingAborts) {
abortOutputs.push({
type: "function_call_output",
call_id: id,
output: JSON.stringify({
output: "aborted",
metadata: { exit_code: 1, duration_seconds: 0 },
}),
} as ResponseInputItem.FunctionCallOutput);
}
// Once converted the pending list can be cleared.
this.pendingAborts.clear();
}
// Build the input list for this turn. When responses are stored on the
// server we can simply send the *delta* (the new user input as well as
// any pending abort outputs) and rely on `previous_response_id` for
// context. When storage is disabled the server has no memory of the
// conversation, so we must include the *entire* transcript (minus system
// messages) on every call.
let turnInput: Array<ResponseInputItem> = [];
// Keeps track of how many items in `turnInput` stem from the existing
// transcript so we can avoid re‑emitting them to the UI. Only used when
// `disableResponseStorage === true`.
let transcriptPrefixLen = 0;
const stripInternalFields = (
item: ResponseInputItem,
): ResponseInputItem => {
// Clone shallowly and remove fields that are not part of the public
// schema expected by the OpenAI Responses API.
// We shallow‑clone the item so that subsequent mutations (deleting
// internal fields) do not affect the original object which may still
// be referenced elsewhere (e.g. UI components).
const clean = { ...item } as Record<string, unknown>;
delete clean["duration_ms"];
// Remove OpenAI-assigned identifiers and transient status so the
// backend does not reject items that were never persisted because we
// use `store: false`.
delete clean["id"];
delete clean["status"];
return clean as unknown as ResponseInputItem;
};
if (this.disableResponseStorage) {
// Remember where the existing transcript ends – everything after this
// index in the upcoming `turnInput` list will be *new* for this turn
// and therefore needs to be surfaced to the UI.
transcriptPrefixLen = this.transcript.length;
// Ensure the transcript is up‑to‑date with the latest user input so
// that subsequent iterations see a complete history.
// `turnInput` is still empty at this point (it will be filled later).
// We need to look at the *input* items the user just supplied.
this.transcript.push(...filterToApiMessages(input));
turnInput = [...this.transcript, ...abortOutputs].map(
stripInternalFields,
);
} else {
turnInput = [...abortOutputs, ...input].map(stripInternalFields);
}
this.onLoading(true);
const staged: Array<ResponseItem | undefined> = [];
const stageItem = (item: ResponseItem) => {
// Ignore any stray events that belong to older generations.
if (thisGeneration !== this.generation) {
return;
}
// Skip items we've already processed to avoid staging duplicates
if (item.id && alreadyStagedItemIds.has(item.id)) {
return;
}
alreadyStagedItemIds.add(item.id);
// Store the item so the final flush can still operate on a complete list.
// We'll nil out entries once they're delivered.
const idx = staged.push(item) - 1;
// Instead of emitting synchronously we schedule a short‑delay delivery.
//
// This accomplishes two things:
// 1. The UI still sees new messages almost immediately, creating the
// perception of real‑time updates.
// 2. If the user calls `cancel()` in the small window right after the
// item was staged we can still abort the delivery because the
// generation counter will have been bumped by `cancel()`.
//
// Use a minimal 3ms delay for terminal rendering to maintain readable
// streaming.
setTimeout(() => {
if (
thisGeneration === this.generation &&
!this.canceled &&
!this.hardAbort.signal.aborted
) {
this.onItem(item);
// Mark as delivered so flush won't re-emit it
staged[idx] = undefined;
// Handle transcript updates to maintain consistency. When we
// operate without server‑side storage we keep our own transcript
// so we can provide full context on subsequent calls.
if (this.disableResponseStorage) {
// Exclude system messages from transcript as they do not form
// part of the assistant/user dialogue that the model needs.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const role = (item as any).role;
if (role !== "system") {
// Clone the item to avoid mutating the object that is also
// rendered in the UI. We need to strip auxiliary metadata
// such as `duration_ms` which is not part of the Responses
// API schema and therefore causes a 400 error when included
// in subsequent requests whose context is sent verbatim.
// Skip items that we have already inserted earlier or that the
// model does not need to see again in the next turn.
// • function_call – superseded by the forthcoming
// function_call_output.
// • reasoning – internal only, never sent back.
// • user messages – we added these to the transcript when
// building the first turnInput; stageItem would add a
// duplicate.
if (
(item as ResponseInputItem).type === "function_call" ||
(item as ResponseInputItem).type === "reasoning" ||
((item as ResponseInputItem).type === "message" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(item as any).role === "user")
) {
return;
}
const clone: ResponseInputItem = {
...(item as unknown as ResponseInputItem),
} as ResponseInputItem;
// The `duration_ms` field is only added to reasoning items to
// show elapsed time in the UI. It must not be forwarded back
// to the server.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (clone as any).duration_ms;
this.transcript.push(clone);
}
}
}
}, 3); // Small 3ms delay for readable streaming.
};
while (turnInput.length > 0) {
if (this.canceled || this.hardAbort.signal.aborted) {
this.onLoading(false);
return;
}
// send request to openAI
// Only surface the *new* input items to the UI – replaying the entire
// transcript would duplicate messages that have already been shown in
// earlier turns.
// `turnInput` holds the *new* items that will be sent to the API in
// this iteration. Surface exactly these to the UI so that we do not
// re‑emit messages from previous turns (which would duplicate user
// prompts) and so that freshly generated `function_call_output`s are
// shown immediately.
// Figure out what subset of `turnInput` constitutes *new* information
// for the UI so that we don’t spam the interface with repeats of the
// entire transcript on every iteration when response storage is
// disabled.
const deltaInput = this.disableResponseStorage
? turnInput.slice(transcriptPrefixLen)
: [...turnInput];
for (const item of deltaInput) {
stageItem(item as ResponseItem);
}
// Send request to OpenAI with retry on timeout.
let stream;
// Retry loop for transient errors. Up to MAX_RETRIES attempts.
const MAX_RETRIES = 8;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
let reasoning: Reasoning | undefined;
if (this.model.startsWith("o")) {
reasoning = { effort: this.config.reasoningEffort ?? "high" };
if (this.model === "o3" || this.model === "o4-mini") {
reasoning.summary = "auto";
}
}
const mergedInstructions = [prefix, this.instructions]
.filter(Boolean)
.join("\n");
const responseCall =
!this.config.provider ||
this.config.provider?.toLowerCase() === "openai"
? (params: ResponseCreateParams) =>
this.oai.responses.create(params)
: (params: ResponseCreateParams) =>
responsesCreateViaChatCompletions(
this.oai,
params as ResponseCreateParams & { stream: true },
);
log(
`instructions (length ${mergedInstructions.length}): ${mergedInstructions}`,
);
// eslint-disable-next-line no-await-in-loop
stream = await responseCall({
model: this.model,
instructions: mergedInstructions,
input: turnInput,
stream: true,
parallel_tool_calls: false,
reasoning,
...(this.config.flexMode ? { service_tier: "flex" } : {}),
...(this.disableResponseStorage
? { store: false }
: {
store: true,
previous_response_id: lastResponseId || undefined,
}),
tools: [shellTool],
// Explicitly tell the model it is allowed to pick whatever
// tool it deems appropriate. Omitting this sometimes leads to
// the model ignoring the available tools and responding with
// plain text instead (resulting in a missing tool‑call).
tool_choice: "auto",
});
break;
} catch (error) {
const isTimeout = error instanceof APIConnectionTimeoutError;
// Lazily look up the APIConnectionError class at runtime to
// accommodate the test environment's minimal OpenAI mocks which
// do not define the class. Falling back to `false` when the
// export is absent ensures the check never throws.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ApiConnErrCtor = (OpenAI as any).APIConnectionError as // eslint-disable-next-line @typescript-eslint/no-explicit-any
| (new (...args: any) => Error)
| undefined;
const isConnectionError = ApiConnErrCtor
? error instanceof ApiConnErrCtor
: false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errCtx = error as any;
const status =
errCtx?.status ?? errCtx?.httpStatus ?? errCtx?.statusCode;
const isServerError = typeof status === "number" && status >= 500;
if (
(isTimeout || isServerError || isConnectionError) &&
attempt < MAX_RETRIES
) {
log(
`OpenAI request failed (attempt ${attempt}/${MAX_RETRIES}), retrying...`,
);
continue;
}
const isTooManyTokensError =
(errCtx.param === "max_tokens" ||
(typeof errCtx.message === "string" &&
/max_tokens is too large/i.test(errCtx.message))) &&
errCtx.type === "invalid_request_error";
if (isTooManyTokensError) {
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: "⚠️ The current request exceeds the maximum context length supported by the chosen model. Please shorten the conversation, run /clear, or switch to a model with a larger context window and try again.",
},
],
});
this.onLoading(false);
return;
}
const isRateLimit =
status === 429 ||
errCtx.code === "rate_limit_exceeded" ||
errCtx.type === "rate_limit_exceeded" ||
/rate limit/i.test(errCtx.message ?? "");
if (isRateLimit) {
if (attempt < MAX_RETRIES) {
// Exponential backoff: base wait * 2^(attempt-1), or use suggested retry time
// if provided.
let delayMs = RATE_LIMIT_RETRY_WAIT_MS * 2 ** (attempt - 1);
// Parse suggested retry time from error message, e.g., "Please try again in 1.3s"
const msg = errCtx?.message ?? "";
const m = /(?:retry|try) again in ([\d.]+)s/i.exec(msg);
if (m && m[1]) {
const suggested = parseFloat(m[1]) * 1000;
if (!Number.isNaN(suggested)) {
delayMs = suggested;
}
}
log(
`OpenAI rate limit exceeded (attempt ${attempt}/${MAX_RETRIES}), retrying in ${Math.round(
delayMs,
)} ms...`,
);
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
} else {
// We have exhausted all retry attempts. Surface a message so the user understands
// why the request failed and can decide how to proceed (e.g. wait and retry later
// or switch to a different model / account).
const errorDetails = [
`Status: ${status || "unknown"}`,
`Code: ${errCtx.code || "unknown"}`,
`Type: ${errCtx.type || "unknown"}`,
`Message: ${errCtx.message || "unknown"}`,
].join(", ");
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: `⚠️ Rate limit reached. Error details: ${errorDetails}. Please try again later.`,
},
],
});
this.onLoading(false);
return;
}
}
const isClientError =
(typeof status === "number" &&
status >= 400 &&
status < 500 &&
status !== 429) ||
errCtx.code === "invalid_request_error" ||
errCtx.type === "invalid_request_error";
if (isClientError) {
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
// Surface the request ID when it is present on the error so users
// can reference it when contacting support or inspecting logs.
text: (() => {
const reqId =
(
errCtx as Partial<{
request_id?: string;
requestId?: string;
}>
)?.request_id ??
(
errCtx as Partial<{
request_id?: string;
requestId?: string;
}>
)?.requestId;
const errorDetails = [
`Status: ${status || "unknown"}`,
`Code: ${errCtx.code || "unknown"}`,
`Type: ${errCtx.type || "unknown"}`,
`Message: ${errCtx.message || "unknown"}`,
].join(", ");
return `⚠️ OpenAI rejected the request${
reqId ? ` (request ID: ${reqId})` : ""
}. Error details: ${errorDetails}. Please verify your settings and try again.`;
})(),
},
],
});
this.onLoading(false);
return;
}
throw error;
}
}
// If the user requested cancellation while we were awaiting the network
// request, abort immediately before we start handling the stream.
if (this.canceled || this.hardAbort.signal.aborted) {
// `stream` is defined; abort to avoid wasting tokens/server work
try {
(
stream as { controller?: { abort?: () => void } }
)?.controller?.abort?.();
} catch {
/* ignore */
}
this.onLoading(false);
return;
}
// Keep track of the active stream so it can be aborted on demand.
this.currentStream = stream;
// Guard against an undefined stream before iterating.
if (!stream) {
this.onLoading(false);
log("AgentLoop.run(): stream is undefined");
return;
}
const MAX_STREAM_RETRIES = 5;
let streamRetryAttempt = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
let newTurnInput: Array<ResponseInputItem> = [];
// eslint-disable-next-line no-await-in-loop
for await (const event of stream as AsyncIterable<ResponseEvent>) {
log(`AgentLoop.run(): response event ${event.type}`);
// process and surface each item (no-op until we can depend on streaming events)
if (event.type === "response.output_item.done") {
const item = event.item;
// 1) if it's a reasoning item, annotate it
type ReasoningItem = { type?: string; duration_ms?: number };
const maybeReasoning = item as ReasoningItem;
if (maybeReasoning.type === "reasoning") {
maybeReasoning.duration_ms = Date.now() - thinkingStart;
}
if (item.type === "function_call") {
// Track outstanding tool call so we can abort later if needed.
// The item comes from the streaming response, therefore it has
// either `id` (chat) or `call_id` (responses) – we normalise
// by reading both.
const callId =
(item as { call_id?: string; id?: string }).call_id ??
(item as { id?: string }).id;
if (callId) {
this.pendingAborts.add(callId);
}
} else {
stageItem(item as ResponseItem);
}
}
if (event.type === "response.completed") {
if (thisGeneration === this.generation && !this.canceled) {
for (const item of event.response.output) {
stageItem(item as ResponseItem);
}
}
if (
event.response.status === "completed" ||
(event.response.status as unknown as string) ===
"requires_action"
) {
// TODO: remove this once we can depend on streaming events
newTurnInput = await this.processEventsWithoutStreaming(
event.response.output,
stageItem,
);
// When we do not use server‑side storage we maintain our
// own transcript so that *future* turns still contain full
// conversational context. However, whether we advance to
// another loop iteration should depend solely on the
// presence of *new* input items (i.e. items that were not
// part of the previous request). Re‑sending the transcript
// by itself would create an infinite request loop because
// `turnInput.length` would never reach zero.
if (this.disableResponseStorage) {
// 1) Append the freshly emitted output to our local
// transcript (minus non‑message items the model does
// not need to see again).
const cleaned = filterToApiMessages(
event.response.output.map(stripInternalFields),
);
this.transcript.push(...cleaned);
// 2) Determine the *delta* (newTurnInput) that must be
// sent in the next iteration. If there is none we can
// safely terminate the loop – the transcript alone
// does not constitute new information for the
// assistant to act upon.
const delta = filterToApiMessages(
newTurnInput.map(stripInternalFields),
);
if (delta.length === 0) {
// No new input => end conversation.
newTurnInput = [];
} else {
// Re‑send full transcript *plus* the new delta so the
// stateless backend receives complete context.
newTurnInput = [...this.transcript, ...delta];
// The prefix ends at the current transcript length –
// everything after this index is new for the next
// iteration.
transcriptPrefixLen = this.transcript.length;
}
}
}
lastResponseId = event.response.id;
this.onLastResponseId(event.response.id);
}
}
// Set after we have consumed all stream events in case the stream wasn't
// complete or we missed events for whatever reason. That way, we will set
// the next turn to an empty array to prevent an infinite loop.
// And don't update the turn input too early otherwise we won't have the
// current turn inputs available for retries.
turnInput = newTurnInput;
// Stream finished successfully – leave the retry loop.
break;
} catch (err: unknown) {
const isRateLimitError = (e: unknown): boolean => {
if (!e || typeof e !== "object") {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ex: any = e;
return (
ex.status === 429 ||
ex.code === "rate_limit_exceeded" ||
ex.type === "rate_limit_exceeded"
);
};
if (
isRateLimitError(err) &&
streamRetryAttempt < MAX_STREAM_RETRIES
) {
streamRetryAttempt += 1;
const waitMs =
RATE_LIMIT_RETRY_WAIT_MS * 2 ** (streamRetryAttempt - 1);
log(
`OpenAI stream rate‑limited – retry ${streamRetryAttempt}/${MAX_STREAM_RETRIES} in ${waitMs} ms`,
);
// Give the server a breather before retrying.
// eslint-disable-next-line no-await-in-loop
await new Promise((res) => setTimeout(res, waitMs));
// Re‑create the stream with the *same* parameters.
let reasoning: Reasoning | undefined;
if (this.model.startsWith("o")) {
reasoning = { effort: "high" };
if (this.model === "o3" || this.model === "o4-mini") {
reasoning.summary = "auto";
}
}
const mergedInstructions = [prefix, this.instructions]
.filter(Boolean)
.join("\n");
const responseCall =
!this.config.provider ||
this.config.provider?.toLowerCase() === "openai"
? (params: ResponseCreateParams) =>
this.oai.responses.create(params)
: (params: ResponseCreateParams) =>
responsesCreateViaChatCompletions(
this.oai,
params as ResponseCreateParams & { stream: true },
);
log(
"agentLoop.run(): responseCall(1): turnInput: " +
JSON.stringify(turnInput),
);
// eslint-disable-next-line no-await-in-loop
stream = await responseCall({
model: this.model,
instructions: mergedInstructions,
input: turnInput,
stream: true,
parallel_tool_calls: false,
reasoning,
...(this.config.flexMode ? { service_tier: "flex" } : {}),
...(this.disableResponseStorage
? { store: false }
: {
store: true,
previous_response_id: lastResponseId || undefined,
}),
tools: [shellTool],
tool_choice: "auto",
});
this.currentStream = stream;
// Continue to outer while to consume new stream.
continue;
}
// Gracefully handle an abort triggered via `cancel()` so that the
// consumer does not see an unhandled exception.
if (err instanceof Error && err.name === "AbortError") {
if (!this.canceled) {
// It was aborted for some other reason; surface the error.
throw err;
}
this.onLoading(false);
return;
}
// Suppress internal stack on JSON parse failures
if (err instanceof SyntaxError) {
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: "⚠️ Failed to parse streaming response (invalid JSON). Please `/clear` to reset.",
},
],
});
this.onLoading(false);
return;
}
// Handle OpenAI API quota errors
if (
err instanceof Error &&
(err as { code?: string }).code === "insufficient_quota"
) {
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: `\u26a0 Insufficient quota: ${err instanceof Error && err.message ? err.message.trim() : "No remaining quota."} Manage or purchase credits at https://platform.openai.com/account/billing.`,
},
],
});
this.onLoading(false);
return;
}
throw err;
} finally {
this.currentStream = null;
}
} // end while retry loop
log(
`Turn inputs (${turnInput.length}) - ${turnInput
.map((i) => i.type)
.join(", ")}`,
);
}
// Flush staged items if the run concluded successfully (i.e. the user did
// not invoke cancel() or terminate() during the turn).
const flush = () => {
if (
!this.canceled &&
!this.hardAbort.signal.aborted &&
thisGeneration === this.generation
) {
// Only emit items that weren't already delivered above
for (const item of staged) {
if (item) {
this.onItem(item);
}
}
}
// At this point the turn finished without the user invoking
// `cancel()`. Any outstanding function‑calls must therefore have been
// satisfied, so we can safely clear the set that tracks pending aborts
// to avoid emitting duplicate synthetic outputs in subsequent runs.
this.pendingAborts.clear();
// Now emit system messages recording the per‑turn *and* cumulative
// thinking times so UIs and tests can surface/verify them.
// const thinkingEnd = Date.now();
// 1) Per‑turn measurement – exact time spent between request and
// response for *this* command.
// this.onItem({
// id: `thinking-${thinkingEnd}`,
// type: "message",
// role: "system",
// content: [
// {
// type: "input_text",
// text: `🤔 Thinking time: ${Math.round(
// (thinkingEnd - thinkingStart) / 1000
// )} s`,
// },
// ],
// });
// 2) Session‑wide cumulative counter so users can track overall wait
// time across multiple turns.
// this.cumulativeThinkingMs += thinkingEnd - thinkingStart;
// this.onItem({
// id: `thinking-total-${thinkingEnd}`,
// type: "message",
// role: "system",
// content: [
// {
// type: "input_text",
// text: `⏱ Total thinking time: ${Math.round(
// this.cumulativeThinkingMs / 1000
// )} s`,
// },
// ],
// });
this.onLoading(false);
};
// Use a small delay to make sure UI rendering is smooth. Double-check
// cancellation state right before flushing to avoid race conditions.
setTimeout(() => {
if (
!this.canceled &&
!this.hardAbort.signal.aborted &&
thisGeneration === this.generation
) {
flush();
}
}, 3);
// End of main logic. The corresponding catch block for the wrapper at the
// start of this method follows next.
} catch (err) {
// Handle known transient network/streaming issues so they do not crash the
// CLI. We currently match Node/undici's `ERR_STREAM_PREMATURE_CLOSE`
// error which manifests when the HTTP/2 stream terminates unexpectedly
// (e.g. during brief network hiccups).
const isPrematureClose =
err instanceof Error &&
// eslint-disable-next-line
((err as any).code === "ERR_STREAM_PREMATURE_CLOSE" ||
err.message?.includes("Premature close"));
if (isPrematureClose) {
try {
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: "⚠️ Connection closed prematurely while waiting for the model. Please try again.",
},
],
});
} catch {
/* no-op – emitting the error message is best‑effort */
}
this.onLoading(false);
return;
}
// -------------------------------------------------------------------
// Catch‑all handling for other network or server‑side issues so that
// transient failures do not crash the CLI. We intentionally keep the
// detection logic conservative to avoid masking programming errors. A
// failure is treated as retry‑worthy/user‑visible when any of the
// following apply:
// • the error carries a recognised Node.js network errno ‑ style code
// (e.g. ECONNRESET, ETIMEDOUT …)
// • the OpenAI SDK attached an HTTP `status` >= 500 indicating a
// server‑side problem.
// • the error is model specific and detected in stream.
// If matched we emit a single system message to inform the user and
// resolve gracefully so callers can choose to retry.
// -------------------------------------------------------------------
const NETWORK_ERRNOS = new Set([
"ECONNRESET",
"ECONNREFUSED",
"EPIPE",
"ENOTFOUND",
"ETIMEDOUT",
"EAI_AGAIN",
]);
const isNetworkOrServerError = (() => {
if (!err || typeof err !== "object") {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const e: any = err;
// Direct instance check for connection errors thrown by the OpenAI SDK.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ApiConnErrCtor = (OpenAI as any).APIConnectionError as // eslint-disable-next-line @typescript-eslint/no-explicit-any
| (new (...args: any) => Error)
| undefined;
if (ApiConnErrCtor && e instanceof ApiConnErrCtor) {
return true;
}
if (typeof e.code === "string" && NETWORK_ERRNOS.has(e.code)) {
return true;
}
// When the OpenAI SDK nests the underlying network failure inside the
// `cause` property we surface it as well so callers do not see an
// unhandled exception for errors like ENOTFOUND, ECONNRESET …
if (
e.cause &&
typeof e.cause === "object" &&
NETWORK_ERRNOS.has((e.cause as { code?: string }).code ?? "")
) {
return true;
}
if (typeof e.status === "number" && e.status >= 500) {
return true;
}
// Fallback to a heuristic string match so we still catch future SDK
// variations without enumerating every errno.
if (
typeof e.message === "string" &&
/network|socket|stream/i.test(e.message)
) {
return true;
}
return false;
})();
if (isNetworkOrServerError) {
try {
const msgText =
"⚠️ Network error while contacting OpenAI. Please check your connection and try again.";
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: msgText,
},
],
});
} catch {
/* best‑effort */
}
this.onLoading(false);
return;
}
const isInvalidRequestError = () => {
if (!err || typeof err !== "object") {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const e: any = err;
if (
e.type === "invalid_request_error" &&
e.code === "model_not_found"
) {
return true;
}
if (
e.cause &&
e.cause.type === "invalid_request_error" &&
e.cause.code === "model_not_found"
) {
return true;
}
return false;
};
if (isInvalidRequestError()) {
try {
// Extract request ID and error details from the error object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const e: any = err;
const reqId =
e.request_id ??
(e.cause && e.cause.request_id) ??
(e.cause && e.cause.requestId);
const errorDetails = [
`Status: ${e.status || (e.cause && e.cause.status) || "unknown"}`,
`Code: ${e.code || (e.cause && e.cause.code) || "unknown"}`,
`Type: ${e.type || (e.cause && e.cause.type) || "unknown"}`,
`Message: ${
e.message || (e.cause && e.cause.message) || "unknown"
}`,
].join(", ");
const msgText = `⚠️ OpenAI rejected the request${
reqId ? ` (request ID: ${reqId})` : ""
}. Error details: ${errorDetails}. Please verify your settings and try again.`;
this.onItem({
id: `error-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text: msgText,
},
],
});
} catch {
/* best-effort */
}
this.onLoading(false);
return;
}
// Re‑throw all other errors so upstream handlers can decide what to do.
throw err;
}
}