fix(streaming): per-provider fetch-start timeout cap override (#11526 follow-up) (#13002)

* fix(streaming): per-provider fetch-start timeout cap override (#11526 follow-up)

Buffered gateways (opencode-go / command-code Console Go tiers) legitimately
buffer a whole reasoning generation before the first upstream byte, so their
streaming requests can exceed the default 110s headers-wait cap. #11526 capped
every streaming request at that ceiling, so these long generations died at
exactly 'Fetch timeout after 110000ms' (504) before any bytes arrived.

Add a per-provider fetchStartTimeoutCapMs registry knob (600s for opencode-go
and command-code) and project it into the executor's LegacyProvider so
resolveFetchStartTimeout caps only genuinely unbounded providers.

* docs(changelog): add fragment for fetch-start cap per-provider override

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: alvinveroy <alvinveroy@users.noreply.github.com>
This commit is contained in:
Alvin T. Veroy
2026-09-18 22:58:39 +08:00
committed by GitHub
parent 10bb627576
commit 24706705fa
7 changed files with 71 additions and 0 deletions

View File

@@ -0,0 +1 @@
- **fix(streaming):** allow a per-provider override of the fetch-start (headers-wait) timeout cap so providers that buffer the full generation before the first byte (e.g. `command-code`, `opencode-go`) are not cut off at the global 110s cap; the same two entries also gain a reasoning-safe `requestDefaults.maxTokens` of 16384 so thinking models such as `z-ai/glm-5.3-flash` are not cut off mid-reasoning ([#13002](https://github.com/diegosouzapw/OmniRoute/pull/13002)) — thanks @alvinveroy

View File

@@ -54,6 +54,14 @@ export function generateLegacyProviders(): Record<string, LegacyProvider> {
if (typeof entry.timeoutMs === "number") {
p.timeoutMs = entry.timeoutMs;
}
// #11526 follow-up: the headers-wait cap override must reach the executor's
// LegacyProvider config — dropping it here silently fell back to the 110s
// global cap even for providers whose registry entry overrides it (observed:
// opencode-go deepseek thinking generations 504ing at exactly 110s despite
// fetchStartTimeoutCapMs on the registry entry).
if (typeof entry.fetchStartTimeoutCapMs === "number") {
p.fetchStartTimeoutCapMs = entry.fetchStartTimeoutCapMs;
}
// Headers
const mergedHeaders = {

View File

@@ -17,6 +17,13 @@ export const command_codeProvider: RegistryEntry = {
// The discovery response is a partial routing catalog; static registry
// entries omitted from it can still be accepted by the gateway.
liveCatalogAuthoritative: false,
// Reasoning models (e.g. z-ai/glm-5.3-flash) exhaust small client budgets on
// thinking before emitting content; default to a reasoning-safe budget.
requestDefaults: { maxTokens: 16_384 },
// Console Go / Command Code gateways buffer entire generations — no upstream
// bytes flow until the model finishes thinking. Streaming needs a headers-wait
// ceiling well above the 110s global cap for long reasoning generations.
fetchStartTimeoutCapMs: 600_000,
authType: "apikey",
authHeader: "Authorization",
authPrefix: "Bearer ",

View File

@@ -13,6 +13,13 @@ export const opencode_goProvider: RegistryEntry = {
authHeader: "Authorization",
authPrefix: "Bearer",
defaultContextLength: 200000,
// glm-5.3-flash and other always-thinking models need a generous output
// budget or reasoning consumes every token before content is emitted.
requestDefaults: { maxTokens: 16_384 },
// Console Go / Command Code gateways buffer entire generations — no upstream
// bytes flow until the model finishes thinking. Streaming needs a headers-wait
// ceiling well above the 110s global cap for long reasoning generations.
fetchStartTimeoutCapMs: 600_000,
models: [
// Port from decolua/9router 8efacc11: align with official Go endpoints —
// glm-5.2 is now advertised and Kimi chat traffic must route through

View File

@@ -183,6 +183,11 @@ export interface RegistryEntry {
chatPath?: string;
clientVersion?: string;
timeoutMs?: number;
/** Headers-wait ceiling override for streaming requests (#11526). Gateways
* that buffer entire generations (Console Go / Command Code) need this well
* above the 110s global cap — generateLegacyProviders() copies it into the
* executor's LegacyProvider config. */
fetchStartTimeoutCapMs?: number;
passthroughModels?: boolean;
/**
* Whether a non-empty synchronized live model list is exhaustive enough
@@ -281,6 +286,7 @@ export interface LegacyProvider {
chatPath?: string;
clientVersion?: string;
timeoutMs?: number;
fetchStartTimeoutCapMs?: number;
}
export const buildModels = (ids: readonly string[]): RegistryModel[] =>

View File

@@ -162,6 +162,7 @@ export type ProviderConfig = {
headers?: Record<string, string>;
requestDefaults?: ProviderRequestDefaults;
timeoutMs?: number;
fetchStartTimeoutCapMs?: number;
format?: string;
};
@@ -915,6 +916,10 @@ export class BaseExecutor {
const fetchStartTimeoutPolicy = resolveFetchStartTimeout({
baseTimeoutMs: this.getTimeoutMs(),
stream,
// Providers with non-incremental upstreams (whole generation buffered
// behind the gateway, e.g. opencode-go's Console Go GLM tier) can take
// minutes before first bytes; the registry overrides the 110s cap.
capMs: this.config?.fetchStartTimeoutCapMs,
});
const fetchStartTimeoutMs = fetchStartTimeoutPolicy.timeoutMs;
if (fetchStartTimeoutPolicy.capped) {

View File

@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
// #11526 follow-up: generateLegacyProviders() must copy fetchStartTimeoutCapMs
// into the executor's LegacyProvider config. Dropping the field silently fell
// back to the 110s global headers-wait cap even for providers whose registry
// entry overrides it — opencode-go deepseek thinking generations 504ed at
// exactly 110s despite fetchStartTimeoutCapMs on the registry entry (the
// buffered Console Go gateway sends no bytes until the model finishes
// thinking, so the headers phase legitimately spans minutes).
const { generateLegacyProviders } = await import("../../open-sse/config/providerRegistry.ts");
test("generateLegacyProviders copies fetchStartTimeoutCapMs into the executor config", () => {
const providers = generateLegacyProviders();
for (const id of ["opencode-go", "command-code"]) {
const legacy = providers[id];
assert.ok(legacy, `${id} must exist in the legacy provider map`);
assert.equal(
legacy.fetchStartTimeoutCapMs,
600_000,
`${id} buffered-gateway cap must be 600s (registry override must reach the executor)`
);
}
});
test("generateLegacyProviders omits the cap when the registry entry does not set it", () => {
const providers = generateLegacyProviders();
const plain = providers["openai"];
if (!plain) return; // registry shape change guard — only assert when present
assert.equal(
plain.fetchStartTimeoutCapMs,
undefined,
"providers without an override keep the global 110s default"
);
});