mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
feat(stream): add per-key JSON stream default mode
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
</span>
|
||||
)}
|
||||
{hasJsonStreamDefault && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">data_object</span>
|
||||
{t("streamDefaultBadge")}
|
||||
</span>
|
||||
)}
|
||||
{hasSessionLimit && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">group</span>
|
||||
@@ -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<{ limit: number; window: number }>>(
|
||||
Array.isArray(apiKey?.rateLimits) ? apiKey.rateLimits : []
|
||||
);
|
||||
const [streamDefaultMode, setStreamDefaultMode] = useState<StreamDefaultMode>(
|
||||
apiKey?.streamDefaultMode === "json" ? "json" : "legacy"
|
||||
);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [selectedConnections, setSelectedConnections] = useState<string[]>(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({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stream Default Compatibility */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-main">{t("streamDefaultMode")}</p>
|
||||
<p className="text-xs text-text-muted">{t("streamDefaultModeDesc")}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 p-0.5 bg-surface rounded-md shrink-0 w-full sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStreamDefaultMode("legacy")}
|
||||
className={`inline-flex flex-1 sm:flex-none items-center justify-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-semibold transition-all ${
|
||||
streamDefaultMode === "legacy"
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">settings_backup_restore</span>
|
||||
{t("streamDefaultLegacy")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStreamDefaultMode("json")}
|
||||
className={`inline-flex flex-1 sm:flex-none items-center justify-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-semibold transition-all ${
|
||||
streamDefaultMode === "json"
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">data_object</span>
|
||||
{t("streamDefaultJson")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ban Toggle (SECURITY) */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -78,6 +78,8 @@ export async function PATCH(request, { params }) {
|
||||
accessSchedule,
|
||||
rateLimits,
|
||||
scopes,
|
||||
allowedEndpoints,
|
||||
streamDefaultMode,
|
||||
} = validation.data;
|
||||
|
||||
const payload: Parameters<typeof updateApiKeyPermissions>[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);
|
||||
|
||||
@@ -83,6 +83,7 @@ export async function POST(request) {
|
||||
id: apiKey.id,
|
||||
machineId: apiKey.machineId,
|
||||
noLog: noLog === true,
|
||||
streamDefaultMode: "legacy",
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<TRow = unknown> {
|
||||
@@ -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<ApiKeyRow>(
|
||||
"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<string> {
|
||||
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<string, unknown>).maxSessions === undefined &&
|
||||
(normalized as Record<string, unknown>).scopes === undefined &&
|
||||
(normalized as Record<string, unknown>).allowedEndpoints === undefined
|
||||
(normalized as Record<string, unknown>).allowedEndpoints === undefined &&
|
||||
(normalized as Record<string, unknown>).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<string, unknown>).allowedEndpoints = JSON.stringify(nextEndpoints);
|
||||
}
|
||||
|
||||
const streamDefaultModeUpdate = (normalized as Record<string, unknown>).streamDefaultMode;
|
||||
if (streamDefaultModeUpdate !== undefined) {
|
||||
updates.push("stream_default_mode = @streamDefaultMode");
|
||||
params.streamDefaultMode = parseStreamDefaultMode(streamDefaultModeUpdate);
|
||||
}
|
||||
|
||||
const scopesUpdate = (normalized as Record<string, unknown>).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) {
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user