Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stalled-provider-first-byte.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Bound the wait for a provider's first response byte by the request timeout. A provider that accepts a request and returns headers but never sends body data now fails and retries instead of leaving the turn hanging after a tool call completes. The same `timeout` value now covers both the connection phase and the wait for the first byte as a single deadline; streaming responses that have already produced data are unaffected.
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ You can also set options that apply to all models from a provider:
|---|---|---|
| `apiKey` | `string` | API key (supports `{env:VAR}` and `{file:...}` syntax in trusted config — see note below) |
| `baseURL` | `string` | Override the provider's base API URL |
| `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable |
| `timeout` | `number \| false` | Request timeout in milliseconds, covering both the wait for response headers and the wait for the first byte of the response body. Defaults to `300000` (5 minutes); set to `false` to disable. Once data starts arriving the timeout no longer applies, so slow streaming responses are never cut short — use `chunkTimeout` for gaps inside a response |
| `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. |

{% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %}
Expand Down
96 changes: 87 additions & 9 deletions packages/opencode/src/kilocode/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DEFAULT_HEADERS } from "@/kilocode/const"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import { ProviderError } from "@/provider/error"
import { Effect, Schema } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { mapValues, omit, pickBy } from "remeda"
Expand Down Expand Up @@ -245,28 +246,105 @@ export function kiloSmallModelPriority(providerID: string): string[] | undefined
}

// ---------------------------------------------------------------------------
// Fetch timeout wrapper
// Fetch timeout wrappers
// Replaces AbortSignal.timeout() with a cancellable setTimeout+AbortController
// so the timer is cleared once response headers arrive. This prevents healthy
// streaming responses from being aborted mid-stream.
// so the timer is cleared once response headers arrive, then hands the remaining
// deadline to wrapFirstByte until the body produces data. One configured
// `timeout` value bounds both phases together, so providers that accept a
// request and go silent cannot hang the agent loop, while healthy streaming
// responses are never aborted mid-stream.
// ---------------------------------------------------------------------------

/**
* Resolves the configured request timeout in milliseconds. `timeout: false`
* explicitly disables it (returns `undefined`); any other invalid, unset or
* non-positive value falls back to {@link REQUEST_TIMEOUT_MS} so the wait for a
* provider response is always bounded rather than left open-ended.
*/
export function requestTimeout(options: Record<string, any>): number | undefined {
const ms = options["timeout"] ?? REQUEST_TIMEOUT_MS
if (ms === false) return undefined
if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) return ms
return REQUEST_TIMEOUT_MS
}

export function buildTimeoutSignal(options: Record<string, any>): {
signal: AbortSignal | undefined
clear: () => void
} {
const ms = options["timeout"] ?? REQUEST_TIMEOUT_MS
if (ms === false || ms === undefined || ms === null) return { signal: undefined, clear() {} }
const ms = requestTimeout(options)
if (ms === undefined) return { signal: undefined, clear() {} }

const controller = new AbortController()
const timer = setTimeout(
() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")),
ms as number,
)
const timer = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), ms)
return {
signal: controller.signal,
clear() {
clearTimeout(timer)
},
}
}

/**
* Bounds the wait for the response body's first byte by `ms`.
*
* Response headers do not prove a live stream: a provider can answer 200 with
* SSE headers and then never send data. The connection-phase timeout is cleared
* as soon as headers arrive, so that state used to hang the agent loop forever
* (the turn sits between step-finish and the next step-start with no error).
*
* Only the first byte is guarded. Once any data arrives the wrapper becomes a
* passthrough, so idle gaps inside a streaming response (reasoning, buffering,
* slow token generation) are never touched here and remain opt-in through
* `chunkTimeout`.
*/
export function wrapFirstByte(res: Response, ms: number, ctl: AbortController) {
if (typeof ms !== "number" || ms <= 0) return res
if (!res.body) return res

const reader = res.body.getReader()
let seen = false
const body = new ReadableStream<Uint8Array>({
async pull(ctrl) {
if (seen) {
const part = await reader.read()
if (part.done) return ctrl.close()
return ctrl.enqueue(part.value)
}

const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
const id = setTimeout(() => {
const err = new ProviderError.ResponseStreamError(`Provider sent no response data within ${ms}ms`)
ctl.abort(err)
void reader.cancel(err)
reject(err)
}, ms)

reader.read().then(
(part) => {
clearTimeout(id)
resolve(part)
},
(err) => {
clearTimeout(id)
reject(err)
},
)
})

seen = true
if (part.done) return ctrl.close()
ctrl.enqueue(part.value)
},
async cancel(reason) {
ctl.abort(reason)
await reader.cancel(reason)
},
})

return new Response(body, {
headers: new Headers(res.headers),
status: res.status,
statusText: res.statusText,
})
}
16 changes: 14 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
patchKiloProviderPrivacy,
kiloSmallModelPriority,
buildTimeoutSignal,
requestTimeout,
wrapFirstByte,
} from "@/kilocode/provider/provider"
import * as ModelsRefresh from "@/kilocode/provider/models-refresh"
// kilocode_change end
Expand Down Expand Up @@ -1763,6 +1765,11 @@ export const layer = Layer.effect(
const opts = init ?? {}
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
const timeout = buildTimeoutSignal(options) // kilocode_change - use cancellable timeout for connection phase
// kilocode_change start - extend the same deadline to the first response byte
const firstByteMs = requestTimeout(options)
const firstByteCtl = firstByteMs === undefined ? undefined : new AbortController()
const deadline = firstByteMs === undefined ? undefined : Date.now() + firstByteMs
// kilocode_change end
const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout
const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined
const signals: AbortSignal[] = []
Expand All @@ -1771,6 +1778,7 @@ export const layer = Layer.effect(
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
if (headerTimeoutCtl) signals.push(headerTimeoutCtl.signal)
if (timeout.signal) signals.push(timeout.signal) // kilocode_change
if (firstByteCtl) signals.push(firstByteCtl.signal) // kilocode_change

const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
if (combined) opts.signal = combined
Expand All @@ -1783,8 +1791,12 @@ export const layer = Layer.effect(
timeout: false,
}).finally(() => headerTimeoutCtl?.clear())
timeout.clear()
if (!chunkAbortCtl) return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
// kilocode_change start - hand the remaining deadline to the first-byte guard
const remaining = deadline !== undefined ? deadline - Date.now() : undefined
const live = remaining !== undefined && firstByteCtl ? wrapFirstByte(res, Math.max(remaining, 1), firstByteCtl) : res
if (!chunkAbortCtl) return live
return wrapSSE(live, chunkTimeout, chunkAbortCtl)
// kilocode_change end
} catch (err) {
timeout.clear()
throw err
Expand Down
30 changes: 30 additions & 0 deletions packages/opencode/test/kilocode/fixture/stall-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Plugin used by the issue #8656 regression tests.
//
// It attaches the simulated socket from stall-transport.ts to the `mock`
// provider through the plugin `config` hook. This is the supported injection
// point: src/provider/provider.ts loads plugins before reading `cfg.provider`
// exactly so hooks can add options such as `fetch`. Injecting here keeps the
// simulation scoped to the test's own instance instead of replacing
// `globalThis.fetch` for the whole test process.

import { createStallTransport } from "./stall-transport"

type Options = { state?: unknown; answer?: unknown; provider?: unknown }

type Draft = {
provider?: Record<string, { options?: Record<string, unknown> } | undefined>
}

export default async (_input: unknown, options?: Options) => ({
config: async (cfg: Draft) => {
const id = typeof options?.provider === "string" ? options.provider : "mock"
const provider = cfg.provider?.[id]
const state = typeof options?.state === "string" ? options.state : undefined
if (!provider || !state) return
provider.options ??= {}
provider.options["fetch"] = createStallTransport({
state,
answer: typeof options?.answer === "string" ? options.answer : undefined,
})
},
})
119 changes: 119 additions & 0 deletions packages/opencode/test/kilocode/fixture/stall-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Simulated provider socket for the issue #8656 regression tests.
//
// Only the socket is simulated. The transport is injected as the provider's
// `fetch` option, so Kilo's own fetch wrapper (connection timeout, first-byte
// guard, SSE chunk watchdog), the openai-compatible SDK, SSE parsing, the
// session processor and the agent loop are all the production ones.
//
// Request script:
// 1. title request -> short text answer
// 2. no tool result in the messages -> a bash tool call
// 3. first request carrying a tool result -> SSE headers, body never sends a
// byte (the stall reported in #8656)
// 4. later requests carrying a tool result -> final text answer, so a bounded
// stall can recover through the normal retry path
//
// Progress is mirrored to a JSON file so tests can assert what the provider saw
// without sharing module state with the plugin that loads this file.

export type StallState = { calls: number; stalls: number; recovered: number }

const HEAD = { id: "chatcmpl-stall", object: "chat.completion.chunk", created: 0, model: "mock-model" }

const chunk = (obj: unknown) => `data: ${JSON.stringify(obj)}\n\n`

const usage = () =>
chunk({ ...HEAD, choices: [], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } })

function sse(body: BodyInit | null) {
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } })
}

function answer(value: string) {
return sse(
[
chunk({ ...HEAD, choices: [{ index: 0, delta: { role: "assistant", content: value }, finish_reason: null }] }),
chunk({ ...HEAD, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }),
usage(),
"data: [DONE]\n\n",
].join(""),
)
}

function toolCall(command: string) {
return sse(
[
chunk({
...HEAD,
choices: [
{
index: 0,
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "bash", arguments: JSON.stringify({ command }) },
},
],
},
finish_reason: null,
},
],
}),
chunk({ ...HEAD, choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }),
usage(),
"data: [DONE]\n\n",
].join(""),
)
}

/** Response headers arrive, then the body never produces a byte. */
function stalling() {
return sse(
new ReadableStream<Uint8Array>({
start() {},
cancel() {
// the first-byte guard cancels this reader when it gives up
},
}),
)
}

export function createStallTransport(input: { state: string; answer?: string; command?: string }) {
const state: StallState = { calls: 0, stalls: 0, recovered: 0 }
const save = () => Bun.write(input.state, JSON.stringify(state))

return async (_input: unknown, init?: { body?: unknown }) => {
const body = typeof init?.body === "string" ? init.body : ""
state.calls++

if (body.includes("Generate a title")) {
await save()
return answer("Stall repro")
}

if (!body.includes('"role":"tool"')) {
await save()
return toolCall(input.command ?? "echo repro-8656")
}

if (state.stalls === 0) {
state.stalls++
await save()
return stalling()
}

state.recovered++
await save()
return answer(input.answer ?? "recovered after the stall")
}
}

export async function readStallState(file: string): Promise<StallState> {
const handle = Bun.file(file)
if (!(await handle.exists())) return { calls: 0, stalls: 0, recovered: 0 }
return JSON.parse(await handle.text()) as StallState
}
Loading
Loading