Skip to content

fix(responses-ws): handle abort during upstream handshake - #1334

Merged
ding113 merged 1 commit into
ding113:devfrom
i-square:localfix/0.8.10/responses-ws-abort
Jul 22, 2026
Merged

fix(responses-ws): handle abort during upstream handshake#1334
ding113 merged 1 commit into
ding113:devfrom
i-square:localfix/0.8.10/responses-ws-abort

Conversation

@i-square

@i-square i-square commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes an uncaughtException that crashed and restarted the app when a client aborted a request while the upstream OpenAI Responses WebSocket was still in the CONNECTING handshake state. Also adds a tri-state ENABLE_OPENAI_RESPONSES_WEBSOCKET env override so immutable-image deployments can disable the feature without database access.

Supersedes #1332 - identical change, retargeted from main to the required dev base.

Problem

When enableOpenaiResponsesWebsocket is on and a client disconnects (or the AbortSignal fires) before the upstream WebSocket handshake completes:

  1. onAbort() calls finishRequest({ closeCode: 1000, forgetSession: true }).
  2. finishRequest() calls cleanupRequestListeners() first, detaching the request-scoped error listener.
  3. It then calls ws.close(code) on a socket that is still CONNECTING.
  4. The ws package asynchronously emits WebSocket was closed before the connection was established.
  5. With the request-level error listener already removed, the error escapes to the process-level handler in instrumentation.ts, which calls process.exit(1).
  6. Under restart: unless-stopped, the container restarts - interrupting in-flight streams and causing repeated, spurious restarts (one deployment saw 11 auto-restarts in a few hours, all from this single exception).

The issue also reports that the system-settings UI no longer renders a toggle for enableOpenaiResponsesWebsocket (deliberately hidden in #1162), so operators on immutable images have no way to turn the feature off without editing the database. This PR provides the env-var mitigation the issue requested rather than re-adding the UI switch.

Related Issues & PRs:

Solution

Two coordinated changes keep the request-level abort from becoming a process-level crash:

  1. State-aware close that always owns its errors. closeWs() / terminateWs() are replaced by a single safelyCloseWebSocket() that installs a one-shot error sink before closing, then branches on readyState: CLOSED no-ops; CLOSING keeps the sink but skips a duplicate close; CONNECTING calls terminate() (aborting the incomplete handshake) instead of close() (which is what emits the stray error); OPEN does a normal close() with a terminate() fallback on a readyState race.
  2. Idempotent finishRequest() that detaches listeners last. A requestFinished guard prevents double execution, and cleanupRequestListeners() now runs after the close so the one-shot error sink is in place when the async error fires. The first-event-timeout and open-failure paths are rerouted through this safe path instead of calling closeAndForget() / terminateWs() directly.

The env override is implemented at the read site in isOpenaiResponsesWebsocketEnabled(): unset falls through to the DB setting; true/1 or false/0 force the value; anything else warns once and falls back to the DB. This preserves existing deployments (no default change, no migration).

Changes

Core Changes

  • src/app/v1/_lib/responses-ws/upstream-adapter.ts - new safelyCloseWebSocket() (one-shot error sink + readyState-aware close/terminate), idempotent finishRequest() that cleans up listeners last, first-event-timeout and open-failure paths rerouted through the safe path.

Supporting Changes

  • src/lib/config/system-settings-cache.ts - getOpenaiResponsesWebsocketEnvOverride() tri-state parser with single-warn-on-invalid, applied in isOpenaiResponsesWebsocketEnabled().
  • .env.example - documents ENABLE_OPENAI_RESPONSES_WEBSOCKET.
  • tests/unit/lib/config/system-settings-cache.test.ts - covers true/false/1/0 override, unset fallthrough, and invalid-value warn-once behavior.
  • src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts - new tests for the CONNECTING-state abort (post-construction and pre-aborted signal), the abort/first-event-timeout race (close only once), and the CLOSING-state race (error sink retained); all assert no uncaughtException/unhandledRejection.

Breaking Changes

None. The env var is opt-in (unset preserves current behavior); the close-path changes are internal.

Testing

Automated Tests

  • Unit tests added - 4 new upstream-adapter cases + 6 system-settings-cache cases
  • Integration tests - N/A (unit tests directly cover the crash path and the env override)

Manual Testing

  1. Enable enableOpenaiResponsesWebsocket in DB (or set ENABLE_OPENAI_RESPONSES_WEBSOCKET=true).
  2. Send a /v1/responses request to a Codex provider whose upstream WebSocket handshake stalls (e.g. point at a TCP blackhole / delayed-upgrade server).
  3. Abort the client while the upstream socket is still CONNECTING.
  4. Expected: the request fails fast with aborted before first upstream WebSocket event; no uncaughtException is logged; the process does not restart; other requests are unaffected.
  5. Verify ENABLE_OPENAI_RESPONSES_WEBSOCKET=false forces HTTP Responses routing regardless of the DB value.

Checklist

  • Code follows project conventions
  • Self-review completed
  • Tests pass locally
  • Documentation updated (.env.example)

Description enhanced by Claude AI

Greptile Summary

This PR fixes a crash (uncaughtException → process.exit(1)) that occurred when a client aborted a request while the upstream OpenAI Responses WebSocket was still in the CONNECTING handshake state. It also adds a tri-state ENABLE_OPENAI_RESPONSES_WEBSOCKET env-var override for immutable-image deployments.

  • Core fix: safelyCloseWebSocket() replaces the old closeWs/terminateWs pair with a single state-aware helper that installs a one-shot error sink before calling terminate() (for CONNECTING) or close() (for OPEN), ensuring the async "WebSocket was closed before the connection was established" error is absorbed rather than escaping to the process-level crash handler.
  • Idempotent teardown: finishRequest() gains a requestFinished guard to prevent double-execution, and cleanupRequestListeners() now runs after the close so the one-shot error sink is in place before the request-level onError listener is detached.
  • Env override: getOpenaiResponsesWebsocketEnvOverride() in system-settings-cache.ts parses true/false/1/0, falls through to the DB on unset, and emits a single warn-once log on invalid values.

Confidence Score: 5/5

Safe to merge; the crash path is correctly closed and the change is backward compatible.

The root cause (async error escaping after request listeners were detached) is accurately diagnosed and the fix is mechanically sound: the one-shot error sink is installed synchronously before terminate()/close() is called, and cleanupRequestListeners() now runs after the close so the sink is always in place when the deferred error fires. The requestFinished guard prevents double-execution of the teardown path. The ENABLE_OPENAI_RESPONSES_WEBSOCKET env override is additive, defaults to unset, and has no effect on existing deployments.

No files require special attention beyond the minor known issues already tracked in prior review comments on upstream-adapter.ts.

Important Files Changed

Filename Overview
src/app/v1/_lib/responses-ws/upstream-adapter.ts Core fix: new safelyCloseWebSocket with state-aware close/terminate and one-shot error sink, idempotent finishRequest that detaches listeners after closing.
src/lib/config/system-settings-cache.ts Clean addition of getOpenaiResponsesWebsocketEnvOverride() tri-state parser with warn-once guard; correctly short-circuits the DB lookup when the env var is set to a valid value.
src/app/v1/_lib/responses-ws/tests/upstream-adapter.test.ts Four new tests covering CONNECTING-state abort, already-aborted signal, abort/first-event-timeout race, and CLOSING-state race; uses a stalled-upgrade HTTP server for realistic CONNECTING-state testing.
tests/unit/lib/config/system-settings-cache.test.ts Six new parameterised tests validate all tri-state override values, unset fallthrough, and the single-warn-on-invalid behavior; env cleanup in afterEach is correct.
.env.example Documents the new ENABLE_OPENAI_RESPONSES_WEBSOCKET env var with a commented-out example and explanation of accepted values.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Adapter as upstream-adapter.ts
    participant WS as WebSocket (ws)
    participant Proc as process (crash handler)

    Note over Adapter,WS: BEFORE fix — crash path
    Client->>Adapter: abort signal fires
    Adapter->>Adapter: onAbort() → finishOpen() + cleanupRequestListeners() [removes onError]
    Adapter->>WS: ws.close(1000) on CONNECTING socket
    WS-->>Proc: async error: WebSocket was closed before the connection was established
    Proc->>Proc: uncaughtException → process.exit(1)

    Note over Adapter,WS: AFTER fix — safe path
    Client->>Adapter: abort signal fires
    Adapter->>Adapter: onAbort() → finishOpen() + finishRequest()
    Note right of Adapter: requestFinished guard prevents re-entry
    Adapter->>WS: safelyCloseWebSocket(ws, 1000)
    Note right of WS: readyState=0 (CONNECTING) 1. Install once error consumeCloseError 2. Install once close remove-sink-cleaner 3. ws.terminate()
    Adapter->>Adapter: cleanupRequestListeners() [removes onError, listeners]
    WS-->>WS: async error fires
    WS->>WS: consumeCloseError() absorbs error
    WS-->>Adapter: close event → remove-sink-cleaner fires
    Adapter-->>Client: failed true message aborted before first upstream WebSocket event
Loading

Reviews (2): Last reviewed commit: "fix(responses-ws): handle abort during u..." | Re-trigger Greptile

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Responses WebSocket 生命周期修复

Layer / File(s) Summary
安全关闭与幂等收尾
src/app/v1/_lib/responses-ws/upstream-adapter.ts
新增安全关闭逻辑和请求完成标志,统一处理连接中、关闭中、超时及 openPromise 失败时的清理流程。
取消竞态测试覆盖
src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts
新增停滞握手服务器、进程错误捕获及多种 AbortSignal 竞态测试,验证关闭调用和监听器清理行为。

Responses WebSocket 配置覆盖

Layer / File(s) Summary
环境变量覆盖与验证
.env.example, src/lib/config/system-settings-cache.ts, tests/unit/lib/config/system-settings-cache.test.ts
新增环境变量三态覆盖、非法值一次性告警、数据库回退逻辑及对应测试隔离和断言。

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: ding113

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed 代码覆盖了 CONNECTING 取消崩溃修复和 ENABLE_OPENAI_RESPONSES_WEBSOCKET 的三态覆盖要求。
Out of Scope Changes check ✅ Passed 改动集中在相关适配器、配置和测试,没有明显无关变更。
Title check ✅ Passed 标题准确概括了核心变更:处理上游握手阶段的 abort。
Description check ✅ Passed 描述与变更内容一致,清楚说明了崩溃修复和环境变量覆盖。
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an environment variable override (ENABLE_OPENAI_RESPONSES_WEBSOCKET) to control the OpenAI Responses WebSocket feature, bypassing database settings when configured. It also hardens the WebSocket lifecycle management in upstream-adapter.ts by introducing safelyCloseWebSocket to handle aborted handshakes and prevent unhandled asynchronous errors. The review feedback suggests two key improvements: caching the parsed environment variable to avoid reading process.env on every request, and tracking the error sink registration in safelyCloseWebSocket to prevent duplicate listeners when the function is called multiple times on a closing socket.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +209 to +223
function safelyCloseWebSocket(ws: WebSocketType, code: number): void {
if (ws.readyState === 3) return;

// Active closes can still surface an asynchronous error after request-level
// listeners are detached. This one-shot sink keeps the error owned by this
// socket instead of letting it escape to process-level crash handlers.
const consumeCloseError = () => {};
ws.once("error", consumeCloseError);
ws.once("close", () => {
ws.off("error", consumeCloseError);
});

// Another close path may already have started the handshake. Keep the
// temporary sink above, but do not issue a duplicate close or terminate.
if (ws.readyState === 2) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In safelyCloseWebSocket, if the function is called multiple times on a socket that is in the CLOSING (readyState === 2) state (which can happen in certain failure paths like sendFrame or onUnexpectedResponse where closeAndForget is called first and then finishRequest is called), it will register duplicate once("error") and once("close") listeners.

To prevent duplicate listener registration and potential listener leaks, we can track whether the error sink has already been registered on the socket using a custom property.

function safelyCloseWebSocket(ws: WebSocketType, code: number): void {
  if (ws.readyState === 3) return;

  const customWs = ws as any;
  if (!customWs._cchCloseErrorSink) {
    const consumeCloseError = () => {};
    customWs._cchCloseErrorSink = consumeCloseError;
    ws.once("error", consumeCloseError);
    ws.once("close", () => {
      ws.off("error", consumeCloseError);
      delete customWs._cchCloseErrorSink;
    });
  }

  // Another close path may already have started the handshake. Keep the
  // temporary sink above, but do not issue a duplicate close or terminate.
  if (ws.readyState === 2) return;

Comment on lines +26 to +53
/** Avoid repeating the same invalid environment-variable warning on every request. */
let hasWarnedInvalidResponsesWebsocketEnv = false;

function getOpenaiResponsesWebsocketEnvOverride(): boolean | undefined {
const rawValue = process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET;

if (rawValue === undefined) {
return undefined;
}

switch (rawValue) {
case "true":
case "1":
return true;
case "false":
case "0":
return false;
default:
if (!hasWarnedInvalidResponsesWebsocketEnv) {
hasWarnedInvalidResponsesWebsocketEnv = true;
logger.warn(
"[SystemSettingsCache] Invalid ENABLE_OPENAI_RESPONSES_WEBSOCKET, using database setting",
{ value: rawValue }
);
}
return undefined;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Reading process.env on every request can introduce unnecessary overhead. Since environment variables are static during the application's lifecycle, we can parse and cache the ENABLE_OPENAI_RESPONSES_WEBSOCKET override value once on first access. This improves performance and simplifies the warning logic by eliminating the need for a separate hasWarnedInvalidResponsesWebsocketEnv flag.

let cachedEnvOverride: boolean | undefined = undefined;
let hasParsedEnvOverride = false;

function getOpenaiResponsesWebsocketEnvOverride(): boolean | undefined {
  if (hasParsedEnvOverride) {
    return cachedEnvOverride;
  }

  const rawValue = process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET;
  hasParsedEnvOverride = true;

  if (rawValue === undefined) {
    cachedEnvOverride = undefined;
    return undefined;
  }

  switch (rawValue) {
    case "true":
    case "1":
      cachedEnvOverride = true;
      break;
    case "false":
    case "0":
      cachedEnvOverride = false;
      break;
    default:
      logger.warn(
        "[SystemSettingsCache] Invalid ENABLE_OPENAI_RESPONSES_WEBSOCKET, using database setting",
        { value: rawValue }
      );
      cachedEnvOverride = undefined;
      break;
  }

  return cachedEnvOverride;
}

@github-actions github-actions Bot added bug Something isn't working area:OpenAI labels Jul 13, 2026
Comment on lines +209 to 250
function safelyCloseWebSocket(ws: WebSocketType, code: number): void {
if (ws.readyState === 3) return;

// Active closes can still surface an asynchronous error after request-level
// listeners are detached. This one-shot sink keeps the error owned by this
// socket instead of letting it escape to process-level crash handlers.
const consumeCloseError = () => {};
ws.once("error", consumeCloseError);
ws.once("close", () => {
ws.off("error", consumeCloseError);
});

// Another close path may already have started the handshake. Keep the
// temporary sink above, but do not issue a duplicate close or terminate.
if (ws.readyState === 2) return;

if (ws.readyState === 0) {
// ws emits this error asynchronously when a CONNECTING socket is closed
// or terminated, so abort the incomplete handshake instead of attempting
// a normal close handshake.
try {
ws.terminate();
} catch {
// ignore
}
return;
}
}

function terminateWs(ws: WebSocketType): void {
try {
ws.terminate?.();
ws.close(code);
} catch {
// ignore
// A readyState transition can race with close(). If the socket is still
// live, terminate it using the error consumer installed above.
if (!isWsClosingOrClosed(ws)) {
try {
ws.terminate();
} catch {
// ignore
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 safelyCloseWebSocket can install two overlapping error sinks on the same socket

safelyCloseWebSocket has no idempotency guard of its own. closeAndForget (which wraps it) is also unguarded. A late-firing ws.send callback (e.g. from sendFrame on a reused socket) can call closeAndForget after finishRequest has already run and initiated a first close — at that point the socket is in readyState === 2 (CLOSING), so safelyCloseWebSocket installs a second once("error", consumeCloseError) and a second once("close", ...) cleaner. The first close event fires, removes only the first error sink, and the second consumeCloseError listener is orphaned for the lifetime of the socket. The impact is a minor listener leak on an already-dying socket, not a crash, but it runs counter to the careful cleanup the rest of this function performs. Adding a simple if (isWsClosingOrClosed(ws)) return; early-exit — or tracking whether the sink is already installed — would close the gap.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/responses-ws/upstream-adapter.ts
Line: 209-250

Comment:
**`safelyCloseWebSocket` can install two overlapping error sinks on the same socket**

`safelyCloseWebSocket` has no idempotency guard of its own. `closeAndForget` (which wraps it) is also unguarded. A late-firing `ws.send` callback (e.g. from `sendFrame` on a reused socket) can call `closeAndForget` after `finishRequest` has already run and initiated a first close — at that point the socket is in `readyState === 2` (CLOSING), so `safelyCloseWebSocket` installs a second `once("error", consumeCloseError)` and a second `once("close", ...)` cleaner. The first `close` event fires, removes only the first error sink, and the second `consumeCloseError` listener is orphaned for the lifetime of the socket. The impact is a minor listener leak on an already-dying socket, not a crash, but it runs counter to the careful cleanup the rest of this function performs. Adding a simple `if (isWsClosingOrClosed(ws)) return;` early-exit — or tracking whether the sink is already installed — would close the gap.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +914 to +920
"already-aborted upstream WebSocket attempt hung"
);
await flushProcessEvents();

expect("failed" in result).toBe(true);
if (!("failed" in result)) return;
expect(result.message).toContain("aborted before first upstream WebSocket event");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Direct mutation of private _readyState field is fragile

The CLOSING-race test sets mutableClientSocket._readyState = WebSocket.CLOSING by casting through an intersection type to reach the ws library's internal private field. This works today because ws stores readyState in _readyState, but the library makes no API promise about that name. If a future ws patch renames or wraps the field, the cast silently succeeds while the socket stays OPEN, and the test becomes a false-positive. Consider whether there is a higher-level way to reach CLOSING state (e.g. by having the mock server close its end and waiting for the half-close) or, if the direct field mutation is necessary, adding a runtime assertion right after (expect(clientSocket.readyState).toBe(WebSocket.CLOSING)) to fail loudly when the internal shape changes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts
Line: 914-920

Comment:
**Direct mutation of private `_readyState` field is fragile**

The CLOSING-race test sets `mutableClientSocket._readyState = WebSocket.CLOSING` by casting through an intersection type to reach the `ws` library's internal private field. This works today because `ws` stores `readyState` in `_readyState`, but the library makes no API promise about that name. If a future `ws` patch renames or wraps the field, the cast silently succeeds while the socket stays `OPEN`, and the test becomes a false-positive. Consider whether there is a higher-level way to reach `CLOSING` state (e.g. by having the mock server close its end and waiting for the half-close) or, if the direct field mutation is necessary, adding a runtime assertion right after (`expect(clientSocket.readyState).toBe(WebSocket.CLOSING)`) to fail loudly when the internal shape changes.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@github-actions github-actions Bot added the size/M Medium PR (< 500 lines) label Jul 13, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR cleanly fixes a process-crash class bug (abort during the upstream WebSocket handshake could emit an async 'error' with no listener) and adds an optional ENABLE_OPENAI_RESPONSES_WEBSOCKET env override. The core mechanism — a unified safelyCloseWebSocket that installs a one-shot error sink and routes CONNECTING sockets through terminate(), plus an idempotent finishRequest — is well-reasoned, and the new tests cover the hard race conditions (abort-vs-timeout, already-aborted signal, CLOSING-state abort). No issues survived validation.

PR Size: M

  • Lines changed: 508 (478 additions, 30 deletions)
  • Files changed: 5

Notes from deep review (no action required)

  • All four call sites of the removed closeWs/terminateWs are updated (lines 238, 444, 666, 727); no dangling references. Build will not break.
  • The catch { // ignore } blocks and the no-op consumeCloseError sink in safelyCloseWebSocket deliberately swallow close-time ws errors. This is intentional and pre-existing (the original closeWs/terminateWs had the identical pattern); the new sink is strictly safer (it catches the async error the old code let escape to process-level handlers). The explanatory comments document the intent.
  • finishRequest reordering (cleanup moved to the end, behind an idempotency guard) is correct: the request's onError/onClose stay attached during the close so bookkeeping stays coherent, and the requestFinished guard prevents the double-close/double-cleanup that the abort-vs-timeout race would otherwise cause. Verified the error sink reliably catches the post-close async error (no async gap exists between attaching the sink and cleanupRequestListeners).
  • getOpenaiResponsesWebsocketEnvOverride short-circuits before any DB read on valid values; the once-per-process invalid-value warning is intentional (per-request call path, documented in the comment) and avoids log spam.

Review Coverage

  • Logic and correctness - Clean
  • Security (OWASP Top 10) - Clean
  • Error handling - Clean (silent swallows are intentional/pre-existing)
  • Type safety - Clean
  • Documentation accuracy - Clean (comments match behavior)
  • Test coverage - Strong (abort races, CONNECTING/CLOSING states, env override)
  • Code clarity - Good

Automated review by Claude AI

@ding113
ding113 force-pushed the localfix/0.8.10/responses-ws-abort branch from d7feeb0 to 8e8965a Compare July 22, 2026 19:59
@coderabbitai
coderabbitai Bot requested a review from ding113 July 22, 2026 20:00
@ding113
ding113 merged commit f2a93ce into ding113:dev Jul 22, 2026
2 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Claude Code Hub Roadmap Jul 22, 2026
@i-square
i-square deleted the localfix/0.8.10/responses-ws-abort branch August 6, 2026 03:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:OpenAI bug Something isn't working size/M Medium PR (< 500 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants