Skip to content

perf(core): warm the model provider connection at prompt admission - #58803

Open
kitlangton wants to merge 2 commits into
devfrom
perf/model-preconnect
Open

kitlangton wants to merge 2 commits into
devfrom
perf/model-preconnect

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

What it does

Every turn's first model request pays DNS, TCP and TLS setup when the provider socket has gone idle. This opens that connection while the prompt is admitted and its context loads, so the real request reuses a warm socket. It also warms the provider you switch to.

It's opt-in and off by default. To try it:

{
  "experimental": {
    "preconnect": true,
  },
}

How it works

  1. A new ModelPreconnect Effect service (global node) holds the remembered origins and the per-origin throttle. Tests use ModelPreconnect.noopLayer or a fresh layer.
  2. After model.request hooks resolve the route, each primary request reads experimental.preconnect from config. When it's on, the request records its origin under the session's agent and directory (or the session's provider when a model is selected), and under the resolved provider.
  3. When a prompt is admitted, Session.prompt looks that origin up and fires a background GET to it. switchModel does the same for the new provider.
  4. What's sent: an empty, unauthenticated GET to the origin only (no path, query, credentials or headers), with redirect: "manual" and a 5 s timeout. The body is drained so HTTP/1.1 can reuse the socket. It goes at most once per origin every 4 min, under Bun's 5 min keep-alive.
  5. Errors are swallowed and the origin can be retried. It never blocks or fails the prompt.

HEAD isn't used because Bun 1.4.4 never pools a HEAD connection, and a HEAD answered with Content-Length can leave the socket broken. fetch.preconnect rejects every URL on this Bun.

Risk

  • Off by default: with the flag absent or false, nothing is remembered and no request is sent.
  • When on, each warm-up appears in provider and gateway logs as an unauthenticated hit on the origin, usually a small 404. One known API origin returns a ~5.6 KB HTML 404.
  • Skipped when it couldn't be reused: HTTP(S)_PROXY/ALL_PROXY set, non-https base URLs, websocket transport, ai-sdk routes, providers with http.request/http.response hooks, and non-Bun runtimes. The first prompt of a fresh server isn't warmed, since no route has been resolved yet.
  • Title generation still opens its own connection.

Proof

  • Gap this can overlap: over ~4,000 real turns in 3 days, prompt admission → model request start took a median of 89 ms (p75 171 ms, p90 438 ms). Warming at request build would overlap only ~14 ms.
  • Time to response headers, no auth, 5 runs each: OpenAI 134–296 ms cold vs 82–150 ms after the warm-up. Anthropic 161–172 ms cold vs 114–159 ms after.
  • bun test test/model-preconnect.test.ts passes, 11 tests:
    • Flag absent or false: no request.
    • Flag on: a warm-up plus a real call share one connection on a local server.
    • Redirects aren't followed, only the origin is sent, throttling, retry after async or sync fetch failures, skip rules.
    • A default-model session, a switched-to provider, and a model.request-rewritten origin are warmed; HTTP-hooked providers are skipped.
  • packages/server/test/session-instances.test.ts (the CI failure) passes.
  • Core session, provider plugin and config normalization tests: 389 pass. Core and server typecheck, oxlint, prettier, check:generated: clean.

Added by the opencode bot (AI-generated).

Public API: adds the optional experimental.preconnect config key (default false; backwards compatible)

Opens DNS/TCP/TLS to the model API's origin when a prompt is admitted and on model switch, with an empty unauthenticated GET, so the real request reuses the pooled socket. The origin comes from the session's last primary request after model.request hooks. Skips proxies, non-https, websocket, ai-sdk and HTTP-hooked routes; at most once per origin per 4 min; OPENCODE_DISABLE_PRECONNECT=1 opts out.

@opencode-agent opencode-agent 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.

The approach is sound. Warming is cheap, best-effort, sends only the origin with no credentials, skips cases where the warm socket wouldn't be reused, and can never fail a prompt. I ran model-preconnect.test.ts (10 pass, including the local-server connection-reuse and no-redirect tests) and the session-model-request and session-prompt suites (53 pass). The package typecheck couldn't run here because this Bun (1.4.2) has no bun check. I couldn't measure the latency gain end to end, since the simulated model isn't an https route and there are no provider credentials.

Public API: adds the OPENCODE_DISABLE_PRECONNECT environment variable (backwards compatible). The server also now makes a new kind of outbound request by default, an unauthenticated GET / to provider origins, which users behind strict egress rules may notice. No SDK, HTTP API, plugin or config changes.

One non-blocking design point, inline: the warm-up state lives in module-level maps rather than an Effect service.

Comment thread packages/core/src/model-preconnect.ts Outdated
readonly model?: { readonly providerID: Provider.ID }
}

const targets = new Map<string, URL | 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.

targets and warmed are process-wide mutable state outside any Layer. Every instance, test and server in the process shares them, so the tests only stay independent by using unique directories and origins. The env opt-out is read from process.env at call time, which is why the test has to mutate process.env in beforeAll. Holding this in a small service (with a no-op layer for the session test preload instead of the env var) would make it scoped and easier to test. It would also drop the Bun.sleep waits in tests that check something did not happen.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved into a ModelPreconnect Effect service (global node, per-instance state, noopLayer for tests), gated by experimental.preconnect read from Config; the env var and process.env mutation are gone, and the negative checks no longer sleep. 2a605ef

Moves the warm-up state into a ModelPreconnect Effect service with a no-op layer for tests, reads the flag from config (off by default), and drops OPENCODE_DISABLE_PRECONNECT.

@opencode-agent opencode-agent 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.

The earlier point about process-global state is addressed: the maps now live in a ModelPreconnect service with its own layer and noopLayer, and the env var and preload change are gone. Making it opt-in under experimental.preconnect also settles the default-on outbound request. I re-ran the preconnect, session-owned, model-request and prompt tests in core (84 pass) and session-instances in server (passes). The type check couldn't run here (no bun check on this Bun). I didn't verify it end to end, because the simulated model isn't an https route and there are no provider credentials.

Public API: adds the optional experimental.preconnect config key (boolean, default false), reflected in the OpenAPI spec and the generated client types. This is backwards compatible. The OPENCODE_DISABLE_PRECONNECT env var from the earlier revision is gone, so it never shipped.

One new point, inline: the provider-keyed target isn't scoped to the directory, so it can leak across projects.


// A session without a model runs its agent's model, or the project's default, so it shares a target with every
// other such session on that agent and directory.
const key = (session: Selection) =>

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.

Now that this is a global node, the provider:<id> key is shared by every project in the process, but experimental.preconnect and provider baseURLs are per-location config. If project A has preconnect on and uses provider openai, a session in project B with openai selected gets warmed at admission or on switchModel. That happens even when B has preconnect off, or when B's openai points at a different gateway, in which case it warms an origin B never uses. Including session.location.directory in the provider key, as the agent key already does, would keep each project's opt-in and origins separate.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant