diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2aff929deb..c26c36a725 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2136,7 +2136,10 @@ export async function handleChatCore({ const stream = nativeCodexPassthrough && isCompactResponsesEndpoint(endpointPath) ? false - : resolveStreamFlag(body?.stream, acceptHeader, sourceFormat, streamUserAgent); + : resolveStreamFlag(body?.stream, acceptHeader, sourceFormat, { + userAgent: streamUserAgent, + streamDefaultMode: apiKeyInfo?.streamDefaultMode, + }); const settings = cachedSettings ?? (await getCachedSettings()); credentials = applyCodexGlobalFastServiceTier(provider, credentials, settings, { model: requestedModel, diff --git a/open-sse/utils/aiSdkCompat.ts b/open-sse/utils/aiSdkCompat.ts index 60b468cd67..86c4e31461 100644 --- a/open-sse/utils/aiSdkCompat.ts +++ b/open-sse/utils/aiSdkCompat.ts @@ -2,6 +2,28 @@ * AI SDK compatibility helpers (T26). */ +export type StreamDefaultMode = "legacy" | "json"; + +export interface ResolveStreamFlagOptions { + userAgent?: unknown; + streamDefaultMode?: unknown; +} + +function normalizeResolveStreamFlagOptions(optionsOrUserAgent?: unknown): ResolveStreamFlagOptions { + if ( + optionsOrUserAgent && + typeof optionsOrUserAgent === "object" && + !Array.isArray(optionsOrUserAgent) + ) { + return optionsOrUserAgent as ResolveStreamFlagOptions; + } + return { userAgent: optionsOrUserAgent }; +} + +export function normalizeStreamDefaultMode(value: unknown): StreamDefaultMode { + return value === "json" ? "json" : "legacy"; +} + /** * Detects when a client explicitly prefers JSON (non-SSE) responses. */ @@ -30,12 +52,15 @@ export function resolveStreamFlag( bodyStream: unknown, acceptHeader: unknown, sourceFormat?: string, - userAgent?: unknown + optionsOrUserAgent?: unknown ): boolean { // Explicit body value always wins if (bodyStream === true) return true; if (bodyStream === false) return false; + const options = normalizeResolveStreamFlagOptions(optionsOrUserAgent); + const streamDefaultMode = normalizeStreamDefaultMode(options.streamDefaultMode); + const acceptsEventStream = typeof acceptHeader === "string" && /text\/event-stream/i.test(acceptHeader); @@ -52,7 +77,14 @@ export function resolveStreamFlag( // does not set `stream: false`. With a wildcard/empty Accept header, the legacy // OmniRoute fallback would force SSE upstream and fail JSON-only providers as // STREAM_EARLY_EOF before Nextcloud could receive a response. - if (isKnownJsonOnlyClient(userAgent) && !acceptsEventStream) { + if (isKnownJsonOnlyClient(options.userAgent) && !acceptsEventStream) { + return false; + } + + // Per-key compatibility mode for synchronous OpenAI-compatible clients that + // omit `stream`. This preserves legacy behavior by default while allowing an + // API key to use the OpenAI-compatible JSON default unless SSE is explicit. + if (streamDefaultMode === "json" && !acceptsEventStream) { return false; } diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index f9480e52c9..ea83c4890d 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -80,6 +80,8 @@ interface AccessSchedule { tz: string; } +type StreamDefaultMode = "legacy" | "json"; + interface ApiKey { id: string; name: string; @@ -98,6 +100,7 @@ interface ApiKey { rateLimits?: Array<{ limit: number; window: number }> | null; scopes?: string[]; allowedEndpoints?: string[]; + streamDefaultMode?: StreamDefaultMode; createdAt: string; } @@ -480,7 +483,8 @@ export default function ApiManagerPageClient() { accessSchedule: AccessSchedule | null, rateLimits: Array<{ limit: number; window: number }> | null, scopes: string[], - allowedEndpoints: string[] + allowedEndpoints: string[], + streamDefaultMode: StreamDefaultMode ) => { if (!editingKey || !editingKey.id) return; @@ -541,6 +545,7 @@ export default function ApiManagerPageClient() { rateLimits, scopes, allowedEndpoints, + streamDefaultMode, }), }); @@ -791,6 +796,7 @@ export default function ApiManagerPageClient() { : 0; const hasThrottle = throttleDelayMs > 0; const hasManageScope = Array.isArray(key.scopes) && key.scopes.includes("manage"); + const hasJsonStreamDefault = key.streamDefaultMode === "json"; const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0; const hasSessionLimit = maxSessions > 0; const activeSessions = sessionCounts[key.id] || 0; @@ -885,6 +891,12 @@ export default function ApiManagerPageClient() { Auto-Resolve )} + {hasJsonStreamDefault && ( + + data_object + {t("streamDefaultBadge")} + + )} {hasSessionLimit && ( group @@ -1238,7 +1250,8 @@ const PermissionsModal = memo(function PermissionsModal({ accessSchedule: AccessSchedule | null, rateLimits: Array<{ limit: number; window: number }> | null, scopes: string[], - allowedEndpoints: string[] + allowedEndpoints: string[], + streamDefaultMode: StreamDefaultMode ) => void; }) { const t = useTranslations("apiManager"); @@ -1289,6 +1302,9 @@ const PermissionsModal = memo(function PermissionsModal({ const [rateLimits, setRateLimits] = useState>( Array.isArray(apiKey?.rateLimits) ? apiKey.rateLimits : [] ); + const [streamDefaultMode, setStreamDefaultMode] = useState( + apiKey?.streamDefaultMode === "json" ? "json" : "legacy" + ); const [nameError, setNameError] = useState(null); const [saveError, setSaveError] = useState(null); const [selectedConnections, setSelectedConnections] = useState(initialConnections); @@ -1453,7 +1469,8 @@ const PermissionsModal = memo(function PermissionsModal({ selfUsageEnabled, selfAccountQuotaEnabled, }), - allowAllEndpoints ? [] : selectedEndpoints + allowAllEndpoints ? [] : selectedEndpoints, + streamDefaultMode ); }, [ onSave, @@ -1482,6 +1499,7 @@ const PermissionsModal = memo(function PermissionsModal({ rateLimits, allowAllEndpoints, selectedEndpoints, + streamDefaultMode, apiKey?.scopes, t, ]); @@ -1863,6 +1881,40 @@ const PermissionsModal = memo(function PermissionsModal({ + {/* Stream Default Compatibility */} +
+
+

{t("streamDefaultMode")}

+

{t("streamDefaultModeDesc")}

+
+
+ + +
+
+ {/* Ban Toggle (SECURITY) */}
diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index 36ff779742..64a3727e59 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -78,6 +78,8 @@ export async function PATCH(request, { params }) { accessSchedule, rateLimits, scopes, + allowedEndpoints, + streamDefaultMode, } = validation.data; const payload: Parameters[1] = {}; @@ -95,6 +97,8 @@ export async function PATCH(request, { params }) { if (accessSchedule !== undefined) payload.accessSchedule = accessSchedule; if (rateLimits !== undefined) payload.rateLimits = rateLimits; if (scopes !== undefined) payload.scopes = scopes; + if (allowedEndpoints !== undefined) payload.allowedEndpoints = allowedEndpoints; + if (streamDefaultMode !== undefined) payload.streamDefaultMode = streamDefaultMode; const updated = await updateApiKeyPermissions(id, payload); if (!updated) { @@ -120,6 +124,8 @@ export async function PATCH(request, { params }) { ...(accessSchedule !== undefined && { accessSchedule }), ...(rateLimits !== undefined && { rateLimits }), ...(scopes !== undefined && { scopes }), + ...(allowedEndpoints !== undefined && { allowedEndpoints }), + ...(streamDefaultMode !== undefined && { streamDefaultMode }), }); } catch (error) { log.error("keys", "Error updating key permissions", error); diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index 2a1747a4c1..0c69f50be3 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -83,6 +83,7 @@ export async function POST(request) { id: apiKey.id, machineId: apiKey.machineId, noLog: noLog === true, + streamDefaultMode: "legacy", }, { status: 201 } ); diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index bd3c426d43..9b971445d1 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1481,6 +1481,11 @@ "endpointsRestricted": "__MISSING__:Restricted to {count} endpoint{count, plural, one {} other {s}}.", "autoResolve": "Auto-Resolve", "autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.", + "streamDefaultMode": "Stream-Standardverhalten", + "streamDefaultModeDesc": "Steuert fehlende `stream`-Angaben für diesen Schlüssel. Im JSON-Modus werden nicht-streamende Antworten zurückgegeben, außer der Client fordert SSE explizit an.", + "streamDefaultLegacy": "Legacy", + "streamDefaultJson": "JSON-kompatibel", + "streamDefaultBadge": "JSON-Standardstream", "keyActive": "Key Active", "keyActiveDesc": "Enable or disable this API key. Disabled keys are immediately rejected with 403.", "accessSchedule": "Access Schedule", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1b6dd03b2a..295bac46c4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1481,6 +1481,11 @@ "endpointsRestricted": "Restricted to {count} endpoint{count, plural, one {} other {s}}.", "autoResolve": "Auto-Resolve", "autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.", + "streamDefaultMode": "Stream Default Compatibility", + "streamDefaultModeDesc": "Controls omitted `stream` flags for this key. JSON mode returns non-streaming responses unless the client explicitly requests SSE.", + "streamDefaultLegacy": "Legacy", + "streamDefaultJson": "JSON Compatible", + "streamDefaultBadge": "JSON stream default", "keyActive": "Key Active", "keyActiveDesc": "Enable or disable this API key. Disabled keys are immediately rejected with 403.", "accessSchedule": "Access Schedule", diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index f41e30e9a0..c1c1bcc772 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -60,6 +60,7 @@ interface ApiKeyMetadata { isBanned: boolean; keyHash: string | null; allowedEndpoints: string[]; + streamDefaultMode: "legacy" | "json"; } interface ApiKeyRow extends JsonRecord { @@ -84,6 +85,8 @@ interface ApiKeyRow extends JsonRecord { accessSchedule?: unknown; rate_limits?: unknown; rateLimits?: unknown; + stream_default_mode?: unknown; + streamDefaultMode?: unknown; } interface StatementLike { @@ -121,6 +124,7 @@ interface ApiKeyView extends JsonRecord { isBanned?: boolean; expiresAt?: string | null; allowedEndpoints: string[]; + streamDefaultMode: "legacy" | "json"; } // LRU cache for API key validation (valid keys only) @@ -156,6 +160,7 @@ const API_KEY_COLUMN_FALLBACKS = [ { name: "is_banned", definition: "is_banned INTEGER NOT NULL DEFAULT 0" }, { name: "key_hash", definition: "key_hash TEXT" }, { name: "allowed_endpoints", definition: "allowed_endpoints TEXT" }, + { name: "stream_default_mode", definition: "stream_default_mode TEXT NOT NULL DEFAULT 'legacy'" }, ] as const; // Cache for model permission checks @@ -355,7 +360,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { "SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtGetKeyMetadata = db.prepare( - "SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints FROM api_keys WHERE key = ? OR key_hash = ?" + "SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode FROM api_keys WHERE key = ? OR key_hash = ?" ); _stmtInsertKey = db.prepare( "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" @@ -401,6 +406,7 @@ export async function getApiKeys() { camelRow.isBanned = parseIsBanned(camelRow.isBanned); camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); + camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -425,6 +431,7 @@ export async function getApiKeyById(id: string) { camelRow.isBanned = parseIsBanned(camelRow.isBanned); camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints); + camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -552,6 +559,10 @@ function parseIsBanned(value: unknown): boolean { return value === 1 || value === "1" || value === true; } +function parseStreamDefaultMode(value: unknown): "legacy" | "json" { + return value === "json" ? "json" : "legacy"; +} + async function hashKey(key: string): Promise { if (!key || typeof key !== "string") return ""; // CodeQL: This is intentionally SHA-256, NOT password hashing. API keys are @@ -661,6 +672,7 @@ export async function updateApiKeyPermissions( maxSessions?: number | null; scopes?: string[] | null; allowedEndpoints?: string[] | null; + streamDefaultMode?: "legacy" | "json" | null; } ) { const db = getDbInstance() as ApiKeysDbLike; @@ -687,6 +699,8 @@ export async function updateApiKeyPermissions( maxSessions: (update as { maxSessions?: number | null }).maxSessions, scopes: (update as { scopes?: string[] | null }).scopes, allowedEndpoints: (update as { allowedEndpoints?: string[] | null }).allowedEndpoints, + streamDefaultMode: (update as { streamDefaultMode?: "legacy" | "json" | null }) + .streamDefaultMode, }; if ( @@ -706,7 +720,8 @@ export async function updateApiKeyPermissions( normalized.expiresAt === undefined && (normalized as Record).maxSessions === undefined && (normalized as Record).scopes === undefined && - (normalized as Record).allowedEndpoints === undefined + (normalized as Record).allowedEndpoints === undefined && + (normalized as Record).streamDefaultMode === undefined ) { return false; } @@ -730,6 +745,7 @@ export async function updateApiKeyPermissions( maxSessions?: number; expiresAt?: string | null; scopes?: string; + streamDefaultMode?: "legacy" | "json"; } = { id }; if (normalized.name !== undefined) { @@ -817,13 +833,17 @@ export async function updateApiKeyPermissions( if (allowedEndpointsUpdate !== undefined) { updates.push("allowed_endpoints = @allowedEndpoints"); const nextEndpoints: string[] = Array.isArray(allowedEndpointsUpdate) - ? (allowedEndpointsUpdate as unknown[]).filter( - (s): s is string => typeof s === "string" - ) + ? (allowedEndpointsUpdate as unknown[]).filter((s): s is string => typeof s === "string") : []; (params as Record).allowedEndpoints = JSON.stringify(nextEndpoints); } + const streamDefaultModeUpdate = (normalized as Record).streamDefaultMode; + if (streamDefaultModeUpdate !== undefined) { + updates.push("stream_default_mode = @streamDefaultMode"); + params.streamDefaultMode = parseStreamDefaultMode(streamDefaultModeUpdate); + } + const scopesUpdate = (normalized as Record).scopes; const nextScopes: string[] = Array.isArray(scopesUpdate) ? (scopesUpdate as unknown[]).filter((s): s is string => typeof s === "string") @@ -1171,6 +1191,7 @@ export async function getApiKeyMetadata( keyHash: null, scopes: ["manage"], allowedEndpoints: [], + streamDefaultMode: "legacy", }; } @@ -1228,6 +1249,9 @@ export async function getApiKeyMetadata( allowedEndpoints: parseStringList( (record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints ), + streamDefaultMode: parseStreamDefaultMode( + (record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode + ), }; if (!metadata.id) { diff --git a/src/lib/db/migrations/077_api_key_stream_default_mode.sql b/src/lib/db/migrations/077_api_key_stream_default_mode.sql new file mode 100644 index 0000000000..0c8ffff0e4 --- /dev/null +++ b/src/lib/db/migrations/077_api_key_stream_default_mode.sql @@ -0,0 +1,3 @@ +-- 077: Per-API-key default for omitted chat completion stream flags. + +ALTER TABLE api_keys ADD COLUMN stream_default_mode TEXT NOT NULL DEFAULT 'legacy'; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 2965c1d8a4..bd0f87f2a5 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1856,6 +1856,7 @@ export const updateKeyPermissionsSchema = z .optional(), scopes: z.array(z.string().trim().min(1).max(64)).max(32).optional(), allowedEndpoints: z.array(z.string().trim().min(1).max(64)).max(20).optional(), + streamDefaultMode: z.enum(["legacy", "json"]).optional(), }) .superRefine((value, ctx) => { if ( @@ -1873,7 +1874,8 @@ export const updateKeyPermissionsSchema = z value.accessSchedule === undefined && value.rateLimits === undefined && value.scopes === undefined && - value.allowedEndpoints === undefined + value.allowedEndpoints === undefined && + value.streamDefaultMode === undefined ) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/tests/unit/chatcore-sanitization.test.ts b/tests/unit/chatcore-sanitization.test.ts index b62211f789..03c17f33fd 100644 --- a/tests/unit/chatcore-sanitization.test.ts +++ b/tests/unit/chatcore-sanitization.test.ts @@ -103,10 +103,14 @@ async function invokeChatCore({ const originalFetch = globalThis.fetch; const calls = []; const nextcloudJsonOnlyClient = /nextcloud\s+openai\/localai\s+integration/i.test(userAgent); + const jsonStreamDefault = apiKeyInfo?.streamDefaultMode === "json"; const resolvedStream = body?.stream === true || (body?.stream === undefined && String(accept).toLowerCase().includes("text/event-stream")) || - (body?.stream === undefined && !nextcloudJsonOnlyClient && !String(accept).includes("json")); + (body?.stream === undefined && + !nextcloudJsonOnlyClient && + !jsonStreamDefault && + !String(accept).includes("json")); globalThis.fetch = async (url, init = {}) => { const parsedBody = init.body ? JSON.parse(String(init.body)) : null; @@ -527,6 +531,28 @@ test("chatCore treats Nextcloud OpenAI integration requests as non-streaming by assert.equal(nextcloudExplicitStream.call.headers.Accept, "text/event-stream"); }); +test("chatCore honors API key JSON stream-default compatibility mode", async () => { + const jsonCompatibleDefault = await invokeChatCore({ + accept: "*/*", + userAgent: "generic-openai-client", + apiKeyInfo: { id: "json-stream-default-key", streamDefaultMode: "json" }, + body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "hello" }] }, + }); + const explicitSse = await invokeChatCore({ + accept: "text/event-stream", + userAgent: "generic-openai-client", + apiKeyInfo: { id: "json-stream-default-key", streamDefaultMode: "json" }, + body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "hello" }] }, + }); + + assert.equal(jsonCompatibleDefault.call.headers.Accept, "application/json"); + assert.equal( + jsonCompatibleDefault.result.response.headers.get("content-type"), + "application/json" + ); + assert.equal(explicitSse.call.headers.Accept, "text/event-stream"); +}); + test("chatCore injects memories when enabled and memories are found", async () => { await settingsDb.updateSettings({ memoryEnabled: true, diff --git a/tests/unit/db-apikeys-crud.test.ts b/tests/unit/db-apikeys-crud.test.ts index b29dacf2d9..57547e7bdb 100644 --- a/tests/unit/db-apikeys-crud.test.ts +++ b/tests/unit/db-apikeys-crud.test.ts @@ -62,6 +62,7 @@ test("createApiKey requires machineId and returns a persisted key with defaults" assert.equal(byId.autoResolve, false); assert.equal(byId.isActive, true); assert.equal(byId.maxSessions, 0); + assert.equal(byId.streamDefaultMode, "legacy"); }); test("updateApiKeyPermissions persists settings, schedule and rate limits", async () => { @@ -87,6 +88,7 @@ test("updateApiKeyPermissions persists settings, schedule and rate limits", asyn maxRequestsPerMinute: 15, throttleDelayMs: 250, maxSessions: -3, + streamDefaultMode: "json", }); const row = await apiKeysDb.getApiKeyById(created.id); const metadata = await apiKeysDb.getApiKeyMetadata(created.key); @@ -104,6 +106,8 @@ test("updateApiKeyPermissions persists settings, schedule and rate limits", asyn assert.equal(metadata.maxRequestsPerMinute, 15); assert.equal(metadata.throttleDelayMs, 250); assert.equal(metadata.maxSessions, 0); + assert.equal(row.streamDefaultMode, "json"); + assert.equal(metadata.streamDefaultMode, "json"); }); test("validateApiKey and deleteApiKey stay consistent after cache invalidation", async () => { diff --git a/tests/unit/t07-no-log-key-config.test.ts b/tests/unit/t07-no-log-key-config.test.ts index 9eeaf922ae..9ce5ded52d 100644 --- a/tests/unit/t07-no-log-key-config.test.ts +++ b/tests/unit/t07-no-log-key-config.test.ts @@ -64,6 +64,11 @@ test("updateKeyPermissionsSchema accepts noLog-only updates and rejects empty pa }); assert.equal(maxSessionsOnly.success, true); + const streamDefaultOnly = schemas.validateBody(schemas.updateKeyPermissionsSchema, { + streamDefaultMode: "json", + }); + assert.equal(streamDefaultOnly.success, true); + const emptyPayload = schemas.validateBody(schemas.updateKeyPermissionsSchema, {}); assert.equal(emptyPayload.success, false); }); diff --git a/tests/unit/t26-ai-sdk-accept-header-compat.test.ts b/tests/unit/t26-ai-sdk-accept-header-compat.test.ts index 983379c6f9..0fcc59bb74 100644 --- a/tests/unit/t26-ai-sdk-accept-header-compat.test.ts +++ b/tests/unit/t26-ai-sdk-accept-header-compat.test.ts @@ -93,6 +93,17 @@ test("T26: Nextcloud OpenAI integration defaults to non-streaming JSON", () => { assert.equal(resolveStreamFlag(true, "application/json", "openai", ua), true); }); +test("T26: per-key JSON stream default keeps omitted stream non-streaming", () => { + const options = { streamDefaultMode: "json", userAgent: "generic-openai-client" }; + + assert.equal(resolveStreamFlag(undefined, undefined, "openai", options), false); + assert.equal(resolveStreamFlag(undefined, "*/*", "openai", options), false); + assert.equal(resolveStreamFlag(undefined, "application/json", "openai", options), false); + assert.equal(resolveStreamFlag(undefined, "text/event-stream", "openai", options), true); + assert.equal(resolveStreamFlag(true, "application/json", "openai", options), true); + assert.equal(resolveStreamFlag(false, "text/event-stream", "openai", options), false); +}); + test("T26: explicit non-stream aliases are detected", () => { assert.equal(hasExplicitNoStreamParam({ non_stream: true }), true); assert.equal(hasExplicitNoStreamParam({ disable_stream: true }), true);