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);