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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ _In development — bullets added per PR; finalized at release._

- **fix(executors): strip `client_metadata` from forwarded body for Cerebras and Mistral** — Cerebras returns 400 (`wrong_api_format`) and Mistral returns 422 (`extra_forbidden`) when the passthrough body carries `client_metadata` (an OpenAI Codex / Claude CLI field with no equivalent on these upstreams). The default executor now drops it for these two providers before sending downstream; other providers (notably `openai`/`codex`) keep it. (thanks @saurabh321gupta)
- **fix(codebuddy):** only send reasoning params when the client requests reasoning. (thanks @anki1kr)
- **fix(sse):** keep streaming for forceStream providers when a JSON client requests it. Providers marked `forceStream:true` reject `stream:false` upstream (HTTP 400); `resolveStreamFlag` now guards against this so stream-only providers keep streaming even when the client sends `Accept: application/json` or `stream:false`. (thanks @anki1kr)

---

Expand Down
6 changes: 6 additions & 0 deletions open-sse/config/providers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ export interface RegistryEntry {
defaultContextLength?: number;
/** Optional session pool config for rate limit management */
poolConfig?: Record<string, unknown>;
/**
* When true, the provider rejects non-streaming requests (HTTP 400).
* resolveStreamFlag will keep streaming even when the client requests JSON;
* OmniRoute accumulates the stream and converts it to a JSON body for the client. (#2081)
*/
forceStream?: boolean;
}

export interface LegacyProvider {
Expand Down
7 changes: 6 additions & 1 deletion open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThink
import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts";
import { echoModelInObject } from "../services/responseModelEcho.ts";
import { stripGpt5SamplingWhenReasoning } from "../services/gpt5SamplingGuard.ts";
import { getUnsupportedParams } from "../config/providerRegistry.ts";
import { getUnsupportedParams, REGISTRY } from "../config/providerRegistry.ts";

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.

high

Instead of importing and querying the raw REGISTRY object directly, use the getRegistryEntry helper. This ensures that if provider is an alias, the registry entry is still correctly resolved. Direct lookup via REGISTRY[provider] will return undefined for aliased providers, which would bypass the forceStream guard and cause HTTP 400 errors when clients request JSON.

Suggested change
import { getUnsupportedParams, REGISTRY } from "../config/providerRegistry.ts";
import { getUnsupportedParams, getRegistryEntry } from "../config/providerRegistry.ts";

import { supportsMaxTokens } from "@/lib/modelCapabilities.ts";
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
import {
Expand Down Expand Up @@ -783,12 +783,17 @@ export async function handleChatCore({
// sourceFormat="claude" applies the Anthropic Messages spec default (stream=false
// when body omits stream), preventing STREAM_EARLY_EOF on /v1/messages when
// clients send Accept: */* without an explicit stream flag.
// providerRequiresStreaming: providers with forceStream:true reject stream:false
// upstream (HTTP 400); keep streaming so OmniRoute can convert the stream to JSON
// for the client via handleForcedSSEToJson. (#2081)
const providerRequiresStreaming = REGISTRY[provider]?.forceStream === true;

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.

high

Use getRegistryEntry(provider) instead of REGISTRY[provider] to correctly resolve the provider configuration even when an alias is used.

Suggested change
const providerRequiresStreaming = REGISTRY[provider]?.forceStream === true;
const providerRequiresStreaming = getRegistryEntry(provider)?.forceStream === true;

const stream =
nativeCodexPassthrough && isCompactResponsesEndpoint(endpointPath)
? false
: resolveStreamFlag(body?.stream, acceptHeader, sourceFormat, {
userAgent: streamUserAgent,
streamDefaultMode: apiKeyInfo?.streamDefaultMode,
providerRequiresStreaming,
});

// `settings` is already consolidated once near the top of handleChatCore
Expand Down
22 changes: 19 additions & 3 deletions open-sse/utils/aiSdkCompat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ export type StreamDefaultMode = "legacy" | "json";
export interface ResolveStreamFlagOptions {
userAgent?: unknown;
streamDefaultMode?: unknown;
/**
* When true, the provider rejects non-streaming requests (e.g. forceStream providers
* such as CodeBuddy). resolveStreamFlag will keep streaming even when the client sends
* Accept: application/json or stream:false; the caller is responsible for accumulating
* the stream and converting it to a JSON response for the client. (#2081)
*/
providerRequiresStreaming?: boolean;
}

function normalizeResolveStreamFlagOptions(optionsOrUserAgent?: unknown): ResolveStreamFlagOptions {
Expand Down Expand Up @@ -35,7 +42,9 @@ export function clientWantsJsonResponse(acceptHeader: unknown): boolean {

/**
* Resolves stream behavior from request body + Accept header.
* Priority: explicit `stream: true/false` in body wins.
* Priority: explicit `stream: true/false` in body wins, UNLESS the provider
* requires streaming (`providerRequiresStreaming: true`) — in that case the
* result is always `true` regardless of client preference (#2081).
* Accept header only acts as fallback when stream is not explicitly set.
* Fixes #656: clients sending both `stream: true` and `Accept: application/json`
* should still get streaming responses — body intent takes precedence.
Expand All @@ -53,11 +62,18 @@ export function resolveStreamFlag(
sourceFormat?: string,
optionsOrUserAgent?: unknown
): boolean {
// Explicit body value always wins
const options = normalizeResolveStreamFlagOptions(optionsOrUserAgent);

// Stream-only providers must keep streaming even when the client asked for JSON;
// OmniRoute accumulates the provider stream and converts it to JSON for the client
// downstream (handleForcedSSEToJson). Sending stream:false to such a provider
// returns HTTP 400. (#2081)
if (options.providerRequiresStreaming) return true;

// Explicit body value always wins (for non-stream-only providers)
if (bodyStream === true) return true;
if (bodyStream === false) return false;

const options = normalizeResolveStreamFlagOptions(optionsOrUserAgent);
const streamDefaultMode = normalizeStreamDefaultMode(options.streamDefaultMode);

const acceptsEventStream =
Expand Down
73 changes: 73 additions & 0 deletions tests/unit/resolve-stream-flag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Port of upstream #2081 — forceStream (stream-only) providers must keep streaming even
// when the client asks for a non-streaming/JSON response. OmniRoute then accumulates the
// provider stream and returns a normal JSON body to the client (handleForcedSSEToJson).
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { resolveStreamFlag } from "../../open-sse/utils/aiSdkCompat.ts";

describe("resolveStreamFlag — forceStream / providerRequiresStreaming guard (#2081)", () => {
it("keeps streaming for a forceStream provider even when client prefers JSON and sets stream:false", () => {
// The bug: Accept: application/json + stream:false used to override providerRequiresStreaming,
// sending stream:false to a stream-only provider (e.g. CodeBuddy) → HTTP 400.
const result = resolveStreamFlag(
false, // body.stream = false
"application/json", // Accept header
undefined, // sourceFormat
{ providerRequiresStreaming: true }
);
assert.equal(result, true, "stream-only provider must stay streaming even when client prefers JSON");
});

it("non-forceStream provider: client prefers JSON + stream:false → non-streaming (unchanged behavior)", () => {
const result = resolveStreamFlag(
false,
"application/json",
undefined,
{ providerRequiresStreaming: false }
);
assert.equal(result, false, "normal provider should respect client JSON preference");
});

it("forceStream provider: no explicit stream flag → streams by default", () => {
const result = resolveStreamFlag(
undefined,
undefined,
undefined,
{ providerRequiresStreaming: true }
);
assert.equal(result, true);
});

it("ordinary provider with no special flags streams by default (backward compat)", () => {
const result = resolveStreamFlag(undefined, undefined);
assert.equal(result, true);
});

it("forceStream provider: client explicitly sends stream:true → stays true", () => {
const result = resolveStreamFlag(
true,
"application/json",
undefined,
{ providerRequiresStreaming: true }
);
assert.equal(result, true);
});

it("forceStream provider: client sends Accept: text/event-stream + stream:false → stays true", () => {
// SSE Accept header alone shouldn't be needed for stream-only providers,
// but providerRequiresStreaming should still force true.
const result = resolveStreamFlag(
false,
"text/event-stream",
undefined,
{ providerRequiresStreaming: true }
);
assert.equal(result, true);
});

it("without providerRequiresStreaming option, JSON client + stream:false still gets non-streaming", () => {
// Verify backward compatibility — no regression for callers that don't pass the option
const result = resolveStreamFlag(false, "application/json");
assert.equal(result, false);
});
});
Loading