Missing Content-Type bypasses model response body timeout and leaves sessions busy indefinitely
Summary
OpenCode 1.18.28 on Linux x86_64 can wait indefinitely for a model response body when an HTTP 200 response omits or mislabels Content-Type. This is a confirmed client timeout defect that reproduces deterministically with synthetic responses.
Cause
In wrapSSE, OpenCode returns the response unchanged unless its Content-Type contains text/event-stream (source). In resolveSDK, the header timer defaults to 300000 ms and is cleared as soon as fetch returns headers. Bun's native fetch timeout is set to false, and OpenCode creates a total AbortSignal.timeout only when the provider timeout option is explicitly set (source).
Therefore, a response that supplies headers but lacks or mislabels Content-Type bypasses the body-read watchdog. With the total timeout unset, the body has no deadline. SessionProcessor awaits Stream.runDrain, so the session remains busy until the stream finishes, fails, or is cancelled (source). The same guard is present in v1.18.29.
The OpenCode 1.18.28 binary reproduces this behavior against a synthetic localhost Responses server using @ai-sdk/openai. No real model calls are needed.
Reproduction
The following standalone fixture was verified against OpenCode 1.18.28 on Linux x86_64. Verification substituted an available ephemeral localhost port for 8765 to avoid conflicts. All assertions passed with no fixture errors and no real model calls. It follows the Responses event shapes in OpenCode's test fixture (llm-server.ts). Save it as stall_server.py and run it in the first terminal.
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from itertools import count
SEND_CONTENT_TYPE = False # Set True for the positive control.
requests = count(1)
def sse(value):
data = b"data: " + json.dumps(value, separators=(",", ":")).encode() + b"\n\n"
return data
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_args):
pass
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
self.rfile.read(length)
print('request', next(requests), flush=True)
self.send_response(200)
if SEND_CONTENT_TYPE:
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Transfer-Encoding", "chunked")
self.send_header("Connection", "keep-alive")
self.end_headers()
self.wfile.flush()
events = [
{"type": "response.created", "sequence_number": 1,
"response": {"id": "resp_test", "created_at": 1,
"model": "probe", "service_tier": None}},
{"type": "response.output_item.added", "sequence_number": 2,
"output_index": 0,
"item": {"type": "reasoning", "id": "rs_test",
"encrypted_content": None}},
{"type": "response.reasoning_summary_part.added",
"sequence_number": 3, "item_id": "rs_test", "summary_index": 0},
]
for value in events:
data = sse(value)
self.wfile.write(f"{len(data):x}\r\n".encode() + data + b"\r\n")
self.wfile.flush()
threading.Event().wait() # Stall until the server process is interrupted.
class Server(ThreadingHTTPServer):
daemon_threads = True
try:
Server(("127.0.0.1", 8765), Handler).serve_forever()
except KeyboardInterrupt:
pass
In a second terminal, run the client with temporary directories and an inline config. OPENCODE_DISABLE_PROJECT_CONFIG=true is required to prevent ancestor project config discovery.
(
tmp=$(mktemp -d)
mkdir -p "$tmp"/{home,config,data,cache,state,tmp,cwd,managed}
cd "$tmp/cwd"
env -i \
PATH="$PATH" \
HOME="$tmp/home" \
XDG_CONFIG_HOME="$tmp/config" \
XDG_DATA_HOME="$tmp/data" \
XDG_CACHE_HOME="$tmp/cache" \
XDG_STATE_HOME="$tmp/state" \
TMPDIR="$tmp/tmp" \
OPENCODE_TEST_MANAGED_CONFIG_DIR="$tmp/managed" \
OPENCODE_DISABLE_PROJECT_CONFIG=true \
OPENCODE_DISABLE_MODELS_FETCH=true \
OPENCODE_DISABLE_EXTERNAL_SKILLS=true \
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=true \
OPENCODE_PURE=1 \
NO_PROXY=localhost,127.0.0.1 \
no_proxy=localhost,127.0.0.1 \
OPENCODE_CONFIG_CONTENT='{"provider":{"stallprobe":{"npm":"@ai-sdk/openai","name":"Stall Probe","options":{"apiKey":"synthetic-not-a-secret","baseURL":"http://127.0.0.1:8765/v1","headerTimeout":1000,"chunkTimeout":1000,"timeout":false},"models":{"probe":{"name":"probe","limit":{"context":32000,"output":1000},"tool_call":false}}}},"model":"stallprobe/probe","small_model":"stallprobe/probe","enabled_providers":["stallprobe"],"permission":"deny","snapshot":false,"agent":{"build":{"mode":"primary","prompt":"Reply briefly only"}}}' \
timeout 20s opencode run --agent build --format json --model stallprobe/probe 'Reply briefly.'
)
The env -i launch drops inherited environment variables and uses the empty managed directory, so managed or inherited configuration cannot override this test. Only the host PATH is carried into the client. The outer timeout 20s is a test-harness limit, not an OpenCode product timeout. The subshell keeps the temporary HOME, XDG variables, and working directory out of the caller's terminal after the command exits. Set SEND_CONTENT_TYPE = True, restart the server, and rerun for the valid-SSE control. To test an explicit total deadline, change only the provider option "timeout": false to "timeout": 2000 and rerun with the missing header.
For the no-first-event variant, replace the events list with []. Before threading.Event().wait(), send exactly one HTTP chunk:
data = b": ready\n\n"
self.wfile.write(f"{len(data):x}\r\n".encode() + data + b"\r\n")
self.wfile.flush()
The emitted fixture above remains the after-reasoning variant.
The server prints only a request number. The baseline is usually two requests, one for auto-title and one for the main run; repeated request numbers expose retries. This logging does not include payloads or headers and does not change response bytes. It also lets the no-first-event variant be observed when JSON output has no step_start event.
Observed results
| Case |
Result with the actual binary |
SEND_CONTENT_TYPE=True |
Valid SSE shows repeated step_start events for the same assistant and retries after the stalled body. Observed: exit 124, 20.09s, requests=4, step_start=3, error_events=0. |
SEND_CONTENT_TYPE=False |
A missing header stalls before the first event or, in the emitted fixture, after the reasoning events. The after-reasoning case shows one initial step_start and no retry within 20 seconds. Observed: exit 124, 20.09s, requests=2, step_start=1, error_events=0. |
Missing header plus timeout=2000 |
The explicit total deadline produces the expected product timeout failure instead of waiting for the outer limit. Observed: exit 1, 4.02s, requests=2, step_start=1, error_events=1. |
A complete Responses control succeeded. Both stall cases may reach the outer limit and return status 124, so status 124 alone is not a diagnostic criterion. A visible timeout message is not required for the missing-header case. CLI auto-title can add a request, so request counts are not root-call counts; counts above the usual two requests indicate retries.
Expected behavior
Enforce a model-response body-read deadline independently of an absent or incorrect Content-Type. In these tests, timeout=false disables only the optional total deadline; it does not disable chunkTimeout. Treat chunkTimeout=false as the explicit body-watchdog opt-out. Preserve these semantics and existing cancellation behavior. Add regression coverage for absent and wrong headers, stalls before the first event and during reasoning, proper SSE with timeout=false, and the explicit chunkTimeout=false opt-out.
No source fix has been made yet. The 2000 ms total timeout is a synthetic control, not a production recommendation. Any default must allow legitimate long-running requests.
Missing Content-Type bypasses model response body timeout and leaves sessions busy indefinitely
Summary
OpenCode 1.18.28 on Linux x86_64 can wait indefinitely for a model response body when an HTTP 200 response omits or mislabels
Content-Type. This is a confirmed client timeout defect that reproduces deterministically with synthetic responses.Cause
In
wrapSSE, OpenCode returns the response unchanged unless itsContent-Typecontainstext/event-stream(source). InresolveSDK, the header timer defaults to 300000 ms and is cleared as soon asfetchreturns headers. Bun's native fetch timeout is set tofalse, and OpenCode creates a totalAbortSignal.timeoutonly when the providertimeoutoption is explicitly set (source).Therefore, a response that supplies headers but lacks or mislabels
Content-Typebypasses the body-read watchdog. With the total timeout unset, the body has no deadline.SessionProcessorawaitsStream.runDrain, so the session remains busy until the stream finishes, fails, or is cancelled (source). The same guard is present in v1.18.29.The OpenCode 1.18.28 binary reproduces this behavior against a synthetic localhost Responses server using
@ai-sdk/openai. No real model calls are needed.Reproduction
The following standalone fixture was verified against OpenCode 1.18.28 on Linux x86_64. Verification substituted an available ephemeral localhost port for
8765to avoid conflicts. All assertions passed with no fixture errors and no real model calls. It follows the Responses event shapes in OpenCode's test fixture (llm-server.ts). Save it asstall_server.pyand run it in the first terminal.In a second terminal, run the client with temporary directories and an inline config.
OPENCODE_DISABLE_PROJECT_CONFIG=trueis required to prevent ancestor project config discovery.The
env -ilaunch drops inherited environment variables and uses the empty managed directory, so managed or inherited configuration cannot override this test. Only the hostPATHis carried into the client. The outertimeout 20sis a test-harness limit, not an OpenCode product timeout. The subshell keeps the temporaryHOME, XDG variables, and working directory out of the caller's terminal after the command exits. SetSEND_CONTENT_TYPE = True, restart the server, and rerun for the valid-SSE control. To test an explicit total deadline, change only the provider option"timeout": falseto"timeout": 2000and rerun with the missing header.For the no-first-event variant, replace the
eventslist with[]. Beforethreading.Event().wait(), send exactly one HTTP chunk:The emitted fixture above remains the after-reasoning variant.
The server prints only a request number. The baseline is usually two requests, one for auto-title and one for the main run; repeated request numbers expose retries. This logging does not include payloads or headers and does not change response bytes. It also lets the no-first-event variant be observed when JSON output has no
step_startevent.Observed results
SEND_CONTENT_TYPE=Truestep_startevents for the same assistant and retries after the stalled body. Observed:exit 124,20.09s,requests=4,step_start=3,error_events=0.SEND_CONTENT_TYPE=Falsestep_startand no retry within 20 seconds. Observed:exit 124,20.09s,requests=2,step_start=1,error_events=0.timeout=2000exit 1,4.02s,requests=2,step_start=1,error_events=1.A complete Responses control succeeded. Both stall cases may reach the outer limit and return status 124, so status 124 alone is not a diagnostic criterion. A visible
timeoutmessage is not required for the missing-header case. CLI auto-title can add a request, so request counts are not root-call counts; counts above the usual two requests indicate retries.Expected behavior
Enforce a model-response body-read deadline independently of an absent or incorrect
Content-Type. In these tests,timeout=falsedisables only the optional total deadline; it does not disablechunkTimeout. TreatchunkTimeout=falseas the explicit body-watchdog opt-out. Preserve these semantics and existing cancellation behavior. Add regression coverage for absent and wrong headers, stalls before the first event and during reasoning, proper SSE withtimeout=false, and the explicitchunkTimeout=falseopt-out.No source fix has been made yet. The 2000 ms total timeout is a synthetic control, not a production recommendation. Any default must allow legitimate long-running requests.