Merge branch 'main' into pip
This commit is contained in:
commit
a42720636b
10 changed files with 851 additions and 49 deletions
|
|
@ -3143,6 +3143,12 @@ class LlamaCppBackend:
|
|||
_CTX_COMPUTE_BYTES_PER_EMBD = 2.25 # quantized KV, regular attention (dequant scratch)
|
||||
_CTX_COMPUTE_BYTES_PER_EMBD_MLA = 1.25 # quantized KV, MLA (compressed attn: measured 0.94x)
|
||||
_CTX_COMPUTE_F16_MASK_SAFETY = 1.5 # f16/bf16/f32 KV: KQ mask only (n_ubatch*2 B/tok)
|
||||
# DeepSeek-V4 (deepseek4): its lightning indexer + sparse attention reserve a large
|
||||
# context-scaling compute buffer the rates above miss (present even with an f16
|
||||
# cache). Measured on UD-Q4_K_XL (ub=512): ~2 GiB at 16k -> ~65.5 GiB at 1M. Without
|
||||
# it auto-fit commits the full 1M train context, OOMs the reserve, and spills to CPU.
|
||||
_DSV4_CTX_COMPUTE_FLAT_BYTES = 2 * 1024**3 # ctx-independent indexer scratch
|
||||
_DSV4_CTX_COMPUTE_BYTES_PER_TOK = 72000 # per token at ub=512 (~72 GiB at 1M)
|
||||
|
||||
def _estimate_compute_buffer_bytes(
|
||||
self,
|
||||
|
|
@ -3192,6 +3198,14 @@ class LlamaCppBackend:
|
|||
if n_embd <= 0 or n_ctx <= 0:
|
||||
return 0
|
||||
ub = max(1, int(n_ubatch if n_ubatch else self._DEFAULT_N_UBATCH))
|
||||
if getattr(self, "_architecture", None) == "deepseek4":
|
||||
# DSV4 indexer/CSA buffer (see constants): flat + linear, ub-scaled. Fires
|
||||
# for any KV type -- the indexer scratch is present even with an f16 cache.
|
||||
ub_scale = ub / self._DEFAULT_N_UBATCH
|
||||
return int(
|
||||
self._DSV4_CTX_COMPUTE_FLAT_BYTES
|
||||
+ self._DSV4_CTX_COMPUTE_BYTES_PER_TOK * n_ctx * ub_scale
|
||||
)
|
||||
if _kv_bytes_per_elem(cache_type_kv) < 2.0:
|
||||
# Quantized cache: the dequant scratch dominates and scales with n_embd.
|
||||
# MLA (compressed KV) needs far less of it: measured 0.94 x n_embd on
|
||||
|
|
|
|||
|
|
@ -65,12 +65,14 @@ def _backend(
|
|||
vocab = 248320,
|
||||
embd = 5120,
|
||||
mla = None,
|
||||
arch = None,
|
||||
):
|
||||
"""Backend with just the dims the compute-buffer estimate reads."""
|
||||
b = LlamaCppBackend.__new__(LlamaCppBackend)
|
||||
b._vocab_size = vocab
|
||||
b._embedding_length = embd
|
||||
b._key_length_mla = mla # non-None -> MLA (compressed attention)
|
||||
b._architecture = arch # GGUF general.architecture (e.g. 'deepseek4')
|
||||
return b
|
||||
|
||||
|
||||
|
|
@ -290,3 +292,62 @@ class TestContextBufferMLA:
|
|||
b = _backend(embd = 6144, mla = 256)
|
||||
est = b._compute_buffer_ctx_bytes(754688, cache_type_kv = "q8_0") / MIB
|
||||
assert est <= 4141 * 1.7
|
||||
|
||||
|
||||
class TestContextBufferDSV4:
|
||||
"""DeepSeek-V4 (deepseek4) reserves a large lightning-indexer / sparse-attention
|
||||
compute buffer the KQ-mask and MLA rates miss (present even with an f16 cache).
|
||||
Measured on UD-Q4_K_XL (ub=512): ~2 GiB at 16k ctx, ~65.5 GiB at 1M. The auto-fit
|
||||
must see this so it does not commit the full 1M train context and OOM (spilling
|
||||
to CPU at ~4 tok/s)."""
|
||||
|
||||
_MEASURED_1M_GIB = 65.5 # 70353790464 B compute-graph reserve that OOM'd at 1M ctx
|
||||
GIB = 1024**3
|
||||
|
||||
def test_covers_measured_1m_buffer(self):
|
||||
b = _backend(embd = 4096, arch = "deepseek4")
|
||||
gib = b._compute_buffer_ctx_bytes(1048576, cache_type_kv = "f16") / self.GIB
|
||||
assert gib >= self._MEASURED_1M_GIB, f"under-reserved {gib:.1f} < {self._MEASURED_1M_GIB}"
|
||||
|
||||
def test_not_wildly_over_at_1m(self):
|
||||
# Within ~1.3x of measured so the fit still grants a large (~256k) context.
|
||||
b = _backend(embd = 4096, arch = "deepseek4")
|
||||
gib = b._compute_buffer_ctx_bytes(1048576, cache_type_kv = "f16") / self.GIB
|
||||
assert gib <= self._MEASURED_1M_GIB * 1.3
|
||||
|
||||
def test_fires_for_f16_cache(self):
|
||||
# The bug: an f16 (default) cache took the tiny mask-only path. DSV4 must
|
||||
# reserve GiB, not the ~MiB a non-DSV4 model reserves at the same ctx.
|
||||
dsv4 = _backend(embd = 4096, arch = "deepseek4")._compute_buffer_ctx_bytes(
|
||||
262144, cache_type_kv = "f16"
|
||||
)
|
||||
other = _backend(embd = 4096, arch = "qwen3")._compute_buffer_ctx_bytes(
|
||||
262144, cache_type_kv = "f16"
|
||||
)
|
||||
assert dsv4 > 40 * other
|
||||
|
||||
def test_cache_type_independent(self):
|
||||
# Indexer scratch is present for an f16 and a quantized cache alike.
|
||||
b = _backend(embd = 4096, arch = "deepseek4")
|
||||
assert b._compute_buffer_ctx_bytes(
|
||||
262144, cache_type_kv = "f16"
|
||||
) == b._compute_buffer_ctx_bytes(262144, cache_type_kv = "q8_0")
|
||||
|
||||
def test_flat_floor_at_small_ctx(self):
|
||||
# ~2 GiB indexer scratch present even at tiny ctx (covers the measured 16k ~2 GiB).
|
||||
b = _backend(embd = 4096, arch = "deepseek4")
|
||||
assert b._compute_buffer_ctx_bytes(16384, cache_type_kv = "f16") / self.GIB >= 2.0
|
||||
|
||||
def test_scales_with_context_and_ubatch(self):
|
||||
b = _backend(embd = 4096, arch = "deepseek4")
|
||||
assert b._compute_buffer_ctx_bytes(131072) > b._compute_buffer_ctx_bytes(65536)
|
||||
assert b._compute_buffer_ctx_bytes(131072, n_ubatch = 1024) > b._compute_buffer_ctx_bytes(
|
||||
131072, n_ubatch = 256
|
||||
)
|
||||
|
||||
def test_non_dsv4_unchanged(self):
|
||||
# Regression guard: a non-deepseek4 model keeps the mask-only f16 rate.
|
||||
b = _backend(embd = 4096, arch = "llama")
|
||||
per_tok = b._compute_buffer_ctx_bytes(100000, cache_type_kv = "f16") / 100000
|
||||
expected = 512 * 2 * LlamaCppBackend._CTX_COMPUTE_F16_MASK_SAFETY
|
||||
assert per_tok == pytest.approx(expected, rel = 1e-6)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,483 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
customProviderDisplayName,
|
||||
parseExternalModelId,
|
||||
useChatPreferencesStore,
|
||||
useChatRuntimeStore,
|
||||
useExternalProvidersStore,
|
||||
} from "@/features/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FileDatabaseIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useMessage, useMessageTiming } from "@assistant-ui/react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type ResponseDetailsMetadata = {
|
||||
modelId?: string;
|
||||
modelLabel?: string;
|
||||
responseModelId?: string;
|
||||
providerId?: string;
|
||||
providerName?: string;
|
||||
providerType?: string;
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
durationMs?: number;
|
||||
sessionId?: string | null;
|
||||
cancelId?: string;
|
||||
toolCalls?: string[];
|
||||
tools?: Record<string, boolean | undefined>;
|
||||
};
|
||||
|
||||
type ContextUsageMetadata = {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
cachedTokens?: number;
|
||||
cacheWriteTokens?: number;
|
||||
modelId?: string;
|
||||
};
|
||||
|
||||
type MessageCustomMetadata = {
|
||||
responseDetails?: ResponseDetailsMetadata;
|
||||
contextUsage?: ContextUsageMetadata;
|
||||
serverTimings?: Record<string, unknown>;
|
||||
reasoningDuration?: number;
|
||||
};
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function formatNumber(value: number | undefined): string | null {
|
||||
return value == null ? null : value.toLocaleString();
|
||||
}
|
||||
|
||||
function formatMs(value: number | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
if (value < 1000) return `${Math.round(value)}ms`;
|
||||
return `${(value / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function formatRate(value: number | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
return `${value.toFixed(1)} tok/s`;
|
||||
}
|
||||
|
||||
function formatDate(value: Date | number | string | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
const TOOL_CATEGORY_LABELS: Record<string, string> = {
|
||||
search: "Search",
|
||||
fetch: "Fetch",
|
||||
code: "Code",
|
||||
images: "Images",
|
||||
mcp: "MCP",
|
||||
docs: "Docs",
|
||||
artifacts: "Canvas",
|
||||
};
|
||||
|
||||
const TOOL_CALL_LABELS: Record<string, string> = {
|
||||
web_search: "Search",
|
||||
web_fetch: "Fetch",
|
||||
code_execution: "Code",
|
||||
python: "Python",
|
||||
terminal: "Terminal",
|
||||
image_generation: "Images",
|
||||
search_knowledge_base: "Docs",
|
||||
render_html: "Canvas",
|
||||
};
|
||||
|
||||
function uniqueValues(values: string[]): string[] {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
function toolCategoryFromCall(toolName: string): string | null {
|
||||
const normalized = toolName.toLowerCase();
|
||||
if (normalized === "web_search") return "search";
|
||||
if (normalized === "web_fetch") return "fetch";
|
||||
if (
|
||||
normalized === "code_execution" ||
|
||||
normalized === "python" ||
|
||||
normalized === "terminal"
|
||||
) {
|
||||
return "code";
|
||||
}
|
||||
if (normalized === "image_generation") return "images";
|
||||
if (normalized === "search_knowledge_base") return "docs";
|
||||
if (normalized === "render_html") return "artifacts";
|
||||
if (normalized.startsWith("mcp__")) return "mcp";
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatToolCallName(toolName: string): string {
|
||||
const normalized = toolName.toLowerCase();
|
||||
if (TOOL_CALL_LABELS[normalized]) return TOOL_CALL_LABELS[normalized];
|
||||
if (normalized.startsWith("mcp__")) return `MCP: ${toolName.slice(5)}`;
|
||||
return toolName
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function toolCallsFromContent(content: unknown): string[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return uniqueValues(
|
||||
content
|
||||
.map((part) =>
|
||||
part && typeof part === "object" && "type" in part
|
||||
? (part as { type?: unknown; toolName?: unknown })
|
||||
: null,
|
||||
)
|
||||
.filter(
|
||||
(part): part is { type: "tool-call"; toolName: string } =>
|
||||
part?.type === "tool-call" &&
|
||||
typeof part.toolName === "string" &&
|
||||
part.toolName.length > 0,
|
||||
)
|
||||
.map((part) => part.toolName),
|
||||
);
|
||||
}
|
||||
|
||||
function enabledTools(
|
||||
tools: Record<string, boolean | undefined> | undefined,
|
||||
toolCalls: string[],
|
||||
): string | null {
|
||||
if (!tools && toolCalls.length === 0) return null;
|
||||
const activeKeys = new Set<string>();
|
||||
for (const key of Object.keys(TOOL_CATEGORY_LABELS)) {
|
||||
if (tools?.[key] === true) activeKeys.add(key);
|
||||
}
|
||||
for (const toolName of toolCalls) {
|
||||
const key = toolCategoryFromCall(toolName);
|
||||
if (key) activeKeys.add(key);
|
||||
}
|
||||
const active = Object.keys(TOOL_CATEGORY_LABELS)
|
||||
.filter((key) => activeKeys.has(key))
|
||||
.map((key) => TOOL_CATEGORY_LABELS[key]);
|
||||
return active.length > 0 ? active.join(", ") : "None";
|
||||
}
|
||||
|
||||
function calledTools(toolCalls: string[]): string | null {
|
||||
if (toolCalls.length === 0) return null;
|
||||
return uniqueValues(toolCalls.map(formatToolCallName)).join(", ");
|
||||
}
|
||||
|
||||
function DetailSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-md bg-muted/45 p-3">
|
||||
<h3 className="mb-2 font-heading text-foreground text-sm">{title}</h3>
|
||||
<div className="grid gap-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode | null | undefined;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
if (value == null || value === "") return null;
|
||||
return (
|
||||
<div className="grid grid-cols-[8.5rem_minmax(0,1fr)] items-start gap-3 text-[13px]">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 break-words text-right text-foreground",
|
||||
mono && "font-mono tabular-nums",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useResponseModelDisplay() {
|
||||
const message = useMessage();
|
||||
const models = useChatRuntimeStore((s) => s.models);
|
||||
const providers = useExternalProvidersStore((s) => s.providers);
|
||||
|
||||
const custom = (
|
||||
message.metadata as Record<string, unknown> | undefined
|
||||
)?.custom as MessageCustomMetadata | undefined;
|
||||
const responseDetails = custom?.responseDetails;
|
||||
const usage = custom?.contextUsage;
|
||||
const serverTimings = custom?.serverTimings;
|
||||
|
||||
const recordedModelId =
|
||||
responseDetails?.responseModelId ??
|
||||
responseDetails?.modelId ??
|
||||
usage?.modelId;
|
||||
const parsedExternal = parseExternalModelId(recordedModelId);
|
||||
const provider = parsedExternal
|
||||
? providers.find((candidate) => candidate.id === parsedExternal.providerId)
|
||||
: null;
|
||||
const modelSummary = models.find(
|
||||
(candidate) => candidate.id === recordedModelId,
|
||||
);
|
||||
const modelLabel =
|
||||
responseDetails?.modelLabel ??
|
||||
responseDetails?.responseModelId ??
|
||||
parsedExternal?.modelId ??
|
||||
modelSummary?.name ??
|
||||
recordedModelId ??
|
||||
"Not recorded";
|
||||
const providerLabel =
|
||||
responseDetails?.providerName ??
|
||||
provider?.name ??
|
||||
(responseDetails?.providerType
|
||||
? customProviderDisplayName(responseDetails.providerType)
|
||||
: parsedExternal
|
||||
? customProviderDisplayName(provider?.providerType)
|
||||
: recordedModelId
|
||||
? "Local model"
|
||||
: null);
|
||||
|
||||
return {
|
||||
message,
|
||||
custom,
|
||||
responseDetails,
|
||||
usage,
|
||||
serverTimings,
|
||||
modelLabel,
|
||||
providerLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export const MessageResponseModelBadge: FC<{ className?: string }> = ({
|
||||
className,
|
||||
}) => {
|
||||
const showResponseModel = useChatPreferencesStore(
|
||||
(state) => state.showResponseModel,
|
||||
);
|
||||
const { modelLabel, providerLabel } = useResponseModelDisplay();
|
||||
|
||||
if (!showResponseModel || modelLabel === "Not recorded") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"aui-response-model-badge inline-flex min-h-5 max-w-full items-center text-muted-foreground/80 text-xs font-medium leading-5 opacity-0 transition-opacity duration-150 group-hover/assistant-message:opacity-100 group-focus-within/assistant-message:opacity-100",
|
||||
className,
|
||||
)}
|
||||
title={providerLabel ? `${modelLabel} - ${providerLabel}` : modelLabel}
|
||||
>
|
||||
<span className="min-w-0 truncate align-middle">{modelLabel}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const MessageResponseDetailsSheet: FC<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}> = ({ open, onOpenChange }) => {
|
||||
const timing = useMessageTiming();
|
||||
const {
|
||||
message,
|
||||
responseDetails,
|
||||
usage,
|
||||
serverTimings,
|
||||
modelLabel,
|
||||
providerLabel,
|
||||
} = useResponseModelDisplay();
|
||||
const promptTokens =
|
||||
usage?.promptTokens ?? asNumber(serverTimings?.prompt_n);
|
||||
const completionTokens =
|
||||
usage?.completionTokens ??
|
||||
timing?.tokenCount ??
|
||||
asNumber(serverTimings?.predicted_n);
|
||||
const totalTokens =
|
||||
usage?.totalTokens ??
|
||||
(promptTokens != null && completionTokens != null
|
||||
? promptTokens + completionTokens
|
||||
: undefined);
|
||||
const totalTime =
|
||||
responseDetails?.durationMs ?? timing?.totalStreamTime ?? undefined;
|
||||
const summaryLabel =
|
||||
modelLabel === "Not recorded" ? "Model not recorded" : `Used ${modelLabel}`;
|
||||
const messageToolCalls = toolCallsFromContent(message.content);
|
||||
const toolCalls =
|
||||
responseDetails?.toolCalls && responseDetails.toolCalls.length > 0
|
||||
? responseDetails.toolCalls
|
||||
: messageToolCalls;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(28rem,100vw)] p-0 sm:max-w-[28rem]"
|
||||
>
|
||||
<SheetHeader className="border-b p-4">
|
||||
<SheetTitle className="flex items-center gap-2 pr-10 font-heading text-base">
|
||||
<HugeiconsIcon
|
||||
icon={FileDatabaseIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon text-chat-icon-fg"
|
||||
/>
|
||||
Response details
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Timing, model, token, and tool details for this response.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||
<div className="min-w-0 rounded-md border border-border/70 bg-card p-3">
|
||||
<p className="min-w-0 break-words font-heading text-foreground text-sm">
|
||||
{summaryLabel}
|
||||
</p>
|
||||
{providerLabel ? (
|
||||
<p className="mt-1 min-w-0 break-words text-muted-foreground text-xs">
|
||||
{providerLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DetailSection title="Response">
|
||||
<DetailRow label="Model" value={modelLabel} />
|
||||
<DetailRow
|
||||
label="Requested"
|
||||
value={
|
||||
responseDetails?.modelId &&
|
||||
responseDetails.modelId !== responseDetails.responseModelId
|
||||
? responseDetails.modelId
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<DetailRow label="Provider" value={providerLabel} />
|
||||
<DetailRow label="Message ID" value={message.id} mono={true} />
|
||||
<DetailRow label="Created" value={formatDate(message.createdAt)} />
|
||||
<DetailRow
|
||||
label="Started"
|
||||
value={formatDate(responseDetails?.startedAt)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Finished"
|
||||
value={formatDate(responseDetails?.finishedAt)}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Tokens">
|
||||
<DetailRow label="Prompt" value={formatNumber(promptTokens)} mono />
|
||||
<DetailRow
|
||||
label="Output"
|
||||
value={formatNumber(completionTokens)}
|
||||
mono
|
||||
/>
|
||||
<DetailRow label="Total" value={formatNumber(totalTokens)} mono />
|
||||
<DetailRow
|
||||
label="Cache hits"
|
||||
value={formatNumber(
|
||||
usage?.cachedTokens ?? asNumber(serverTimings?.cache_n),
|
||||
)}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Cache writes"
|
||||
value={formatNumber(usage?.cacheWriteTokens)}
|
||||
mono
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Timing">
|
||||
<DetailRow label="Total" value={formatMs(totalTime)} mono />
|
||||
<DetailRow
|
||||
label="First token"
|
||||
value={formatMs(timing?.firstTokenTime)}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Prompt eval"
|
||||
value={formatMs(asNumber(serverTimings?.prompt_ms))}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Generation"
|
||||
value={formatMs(asNumber(serverTimings?.predicted_ms))}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Speed"
|
||||
value={formatRate(
|
||||
asNumber(serverTimings?.predicted_per_second) ??
|
||||
timing?.tokensPerSecond,
|
||||
)}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Chunks"
|
||||
value={formatNumber(timing?.totalChunks)}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Tool calls"
|
||||
value={formatNumber(timing?.toolCallCount)}
|
||||
mono
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Tools">
|
||||
<DetailRow
|
||||
label="Enabled"
|
||||
value={enabledTools(responseDetails?.tools, toolCalls)}
|
||||
/>
|
||||
<DetailRow label="Called" value={calledTools(toolCalls)} />
|
||||
<DetailRow
|
||||
label="Confirmation"
|
||||
value={
|
||||
responseDetails?.tools?.confirmToolCalls === true
|
||||
? "On"
|
||||
: responseDetails?.tools?.confirmToolCalls === false
|
||||
? "Off"
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Bypass"
|
||||
value={
|
||||
responseDetails?.tools?.bypassPermissions === true
|
||||
? "On"
|
||||
: responseDetails?.tools?.bypassPermissions === false
|
||||
? "Off"
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<DetailRow label="Session" value={responseDetails?.sessionId} mono />
|
||||
<DetailRow label="Run ID" value={responseDetails?.cancelId} mono />
|
||||
</DetailSection>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { MessageResponseModelBadge } from "@/components/assistant-ui/message-response-details-sheet";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
|
|
@ -390,14 +391,17 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
|
|||
onOpenChange={handleOpenChange}
|
||||
variant={variant}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ReasoningTrigger
|
||||
className="min-w-0 flex-1"
|
||||
className="min-w-0 flex-none"
|
||||
active={isReasoningStreaming}
|
||||
// Prefer server timing when available.
|
||||
duration={persistedDuration || duration}
|
||||
/>
|
||||
<div className="flex w-16 shrink-0 justify-end">
|
||||
<span className="hidden min-w-0 max-w-[12rem] group-hover/assistant-message:inline-flex group-focus-within/assistant-message:inline-flex sm:max-w-[16rem]">
|
||||
<MessageResponseModelBadge className="min-w-0" />
|
||||
</span>
|
||||
<div className="ml-auto flex w-16 shrink-0 justify-end">
|
||||
{isOpen && !isReasoningStreaming && (
|
||||
<ReasoningCopyButton startIndex={startIndex} endIndex={endIndex} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import {
|
|||
import { downloadImagePart } from "@/components/assistant-ui/image";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { MessageHtmlArtifacts } from "@/components/assistant-ui/message-html-artifacts";
|
||||
import {
|
||||
MessageResponseDetailsSheet,
|
||||
MessageResponseModelBadge,
|
||||
} from "@/components/assistant-ui/message-response-details-sheet";
|
||||
import { MessageTiming } from "@/components/assistant-ui/message-timing";
|
||||
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
|
||||
import { RagSourcesGroup } from "@/components/assistant-ui/rag-sources";
|
||||
|
|
@ -3564,6 +3568,9 @@ const AssistantMessage: FC = () => {
|
|||
const aui = useAui();
|
||||
const messageId = useAuiState(({ message }) => message.id);
|
||||
const messageContent = useAuiState(({ message }) => message.content);
|
||||
const hasReasoningParts = useAuiState(({ message }) =>
|
||||
message.parts.some((part) => part.type === "reasoning"),
|
||||
);
|
||||
const incognito = useChatRuntimeStore((s) => s.incognito);
|
||||
|
||||
// Use global store for editing state to ensure a single source of truth
|
||||
|
|
@ -3620,7 +3627,7 @@ const AssistantMessage: FC = () => {
|
|||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) pt-0.5 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em]"
|
||||
className="group/assistant-message aui-assistant-message-root relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) pt-0.5 pb-4 text-[15.5px] [font-weight:410] tracking-[0.01em] dark:tracking-[0.02em]"
|
||||
data-role="assistant"
|
||||
>
|
||||
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-[#0d0d0d] dark:text-foreground leading-relaxed">
|
||||
|
|
@ -3649,6 +3656,11 @@ const AssistantMessage: FC = () => {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!hasReasoningParts ? (
|
||||
<div className="pointer-events-none relative h-0 min-w-0">
|
||||
<MessageResponseModelBadge className="absolute -top-6 left-0 max-w-[min(22rem,100%)]" />
|
||||
</div>
|
||||
) : null}
|
||||
<GeneratingIndicator />
|
||||
<CancelledIndicator />
|
||||
<DiffusionCanvas />
|
||||
|
|
@ -3893,58 +3905,76 @@ const EditAssistantMessageButton: FC = () => {
|
|||
|
||||
const AssistantActionBar: FC = () => {
|
||||
const { forkMessage, forkDisabled } = useForkMessageAction();
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning={true}
|
||||
className="aui-assistant-action-bar-root col-start-3 row-start-2 flex items-center gap-1 text-chat-icon-fg [&_button:not([data-slot=message-timing-trigger])]:size-8 [&_button]:!rounded-full [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
|
||||
>
|
||||
<CopyButton />
|
||||
<EditAssistantMessageButton />
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
<ForkCountBadge />
|
||||
<DeleteMessageButton />
|
||||
<ActionBarMorePrimitive.Root>
|
||||
<ActionBarMorePrimitive.Trigger asChild={true}>
|
||||
<TooltipIconButton
|
||||
tooltip="More"
|
||||
className="data-[state=open]:bg-accent"
|
||||
>
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
<>
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning={true}
|
||||
className="aui-assistant-action-bar-root col-start-3 row-start-2 flex items-center gap-1 text-chat-icon-fg [&_button:not([data-slot=message-timing-trigger])]:size-8 [&_button]:!rounded-full [&_button:hover]:bg-chat-icon-bg-hover [&_button:hover]:text-chat-icon-fg-hover"
|
||||
>
|
||||
<CopyButton />
|
||||
<EditAssistantMessageButton />
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarMorePrimitive.Trigger>
|
||||
<ActionBarMorePrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-[21px] bg-popover px-[9px] py-2 text-popover-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:shadow-none"
|
||||
>
|
||||
<ActionBarMorePrimitive.Item
|
||||
disabled={forkDisabled}
|
||||
onSelect={() => void forkMessage()}
|
||||
className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
</ActionBarPrimitive.Reload>
|
||||
<ForkCountBadge />
|
||||
<DeleteMessageButton />
|
||||
<ActionBarMorePrimitive.Root>
|
||||
<ActionBarMorePrimitive.Trigger asChild={true}>
|
||||
<TooltipIconButton
|
||||
tooltip="More"
|
||||
className="data-[state=open]:bg-accent"
|
||||
>
|
||||
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarMorePrimitive.Trigger>
|
||||
<ActionBarMorePrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-[21px] bg-popover px-[9px] py-2 text-popover-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:shadow-none"
|
||||
>
|
||||
<GitBranchIcon strokeWidth={1.75} className="size-icon" />
|
||||
Fork in new chat
|
||||
</ActionBarMorePrimitive.Item>
|
||||
<ActionBarPrimitive.ExportMarkdown asChild={true}>
|
||||
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
||||
<ActionBarMorePrimitive.Item
|
||||
disabled={forkDisabled}
|
||||
onSelect={() => void forkMessage()}
|
||||
className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<GitBranchIcon strokeWidth={1.75} className="size-icon" />
|
||||
Fork in new chat
|
||||
</ActionBarMorePrimitive.Item>
|
||||
<ActionBarPrimitive.ExportMarkdown asChild={true}>
|
||||
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
||||
<HugeiconsIcon
|
||||
icon={Download01Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
Export as Markdown
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
<ActionBarMorePrimitive.Item
|
||||
onSelect={() => setDetailsOpen(true)}
|
||||
className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-[12px] px-3 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Download01Icon}
|
||||
icon={FileDatabaseIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
Export as Markdown
|
||||
See response details
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
</ActionBarMorePrimitive.Content>
|
||||
</ActionBarMorePrimitive.Root>
|
||||
<MessageTiming side="top" className="h-8 px-2" />
|
||||
</ActionBarPrimitive.Root>
|
||||
</ActionBarMorePrimitive.Content>
|
||||
</ActionBarMorePrimitive.Root>
|
||||
<MessageTiming side="top" className="h-8 px-2" />
|
||||
</ActionBarPrimitive.Root>
|
||||
<MessageResponseDetailsSheet
|
||||
open={detailsOpen}
|
||||
onOpenChange={setDetailsOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,32 @@ interface ServerTimings {
|
|||
diffusion_steps_per_second?: number;
|
||||
}
|
||||
|
||||
interface ResponseDetailsMetadata {
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
responseModelId: string;
|
||||
providerId?: string;
|
||||
providerName: string;
|
||||
providerType: string;
|
||||
startedAt: number;
|
||||
finishedAt: number;
|
||||
durationMs: number;
|
||||
sessionId?: string;
|
||||
cancelId: string;
|
||||
toolCalls: string[];
|
||||
tools: {
|
||||
search: boolean;
|
||||
fetch: boolean;
|
||||
code: boolean;
|
||||
images: boolean;
|
||||
mcp: boolean;
|
||||
docs: boolean;
|
||||
artifacts: boolean;
|
||||
confirmToolCalls: boolean;
|
||||
bypassPermissions: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type RunMessages = Parameters<ChatModelAdapter["run"]>[0]["messages"];
|
||||
type RunMessage = RunMessages[number];
|
||||
|
||||
|
|
@ -1769,6 +1795,9 @@ export function createOpenAIStreamAdapter(
|
|||
(provider) => provider.id === externalSelection.providerId,
|
||||
)
|
||||
: null;
|
||||
const selectedModelSummary = runtime.models.find(
|
||||
(model) => model.id === params.checkpoint,
|
||||
);
|
||||
const externalApiKey = externalProvider
|
||||
? getExternalProviderApiKey(externalProvider.id).trim()
|
||||
: "";
|
||||
|
|
@ -2151,6 +2180,7 @@ export function createOpenAIStreamAdapter(
|
|||
let waitingFirstChunk = true;
|
||||
let firstTokenSettled = false;
|
||||
const streamStartTime = Date.now();
|
||||
let responseModelId = externalSelection?.modelId ?? params.checkpoint;
|
||||
let firstTokenTime: number | undefined;
|
||||
let totalChunks = 0;
|
||||
let resolveFirstToken: (() => void) | null = null;
|
||||
|
|
@ -2372,6 +2402,59 @@ export function createOpenAIStreamAdapter(
|
|||
const externalBackendProviderType = toExternalBackendProviderType(
|
||||
externalProvider?.providerType,
|
||||
);
|
||||
const buildResponseDetails = (
|
||||
finishedAt: number,
|
||||
): ResponseDetailsMetadata => ({
|
||||
modelId: params.checkpoint,
|
||||
modelLabel:
|
||||
(isExternalRequest || responseModelId !== params.checkpoint
|
||||
? responseModelId
|
||||
: selectedModelSummary?.name || responseModelId) ||
|
||||
params.checkpoint ||
|
||||
"Unknown model",
|
||||
responseModelId:
|
||||
responseModelId ||
|
||||
externalSelection?.modelId ||
|
||||
params.checkpoint,
|
||||
...(externalProvider?.id ? { providerId: externalProvider.id } : {}),
|
||||
providerName:
|
||||
externalProvider?.name ??
|
||||
(isExternalRequest ? "External provider" : "Local model"),
|
||||
providerType: externalProvider?.providerType ?? "local",
|
||||
startedAt: streamStartTime,
|
||||
finishedAt,
|
||||
durationMs: finishedAt - streamStartTime,
|
||||
...(sandboxSessionId ? { sessionId: sandboxSessionId } : {}),
|
||||
cancelId,
|
||||
toolCalls: Array.from(
|
||||
new Set(
|
||||
toolCallParts
|
||||
.map((part) => part.toolName)
|
||||
.filter(
|
||||
(toolName): toolName is string =>
|
||||
typeof toolName === "string" && toolName.length > 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
tools: {
|
||||
search:
|
||||
webSearchEnabledForThisTurn ||
|
||||
(!isExternalRequest && supportsTools && toolsEnabled),
|
||||
fetch: webFetchEnabledForThisTurn,
|
||||
code:
|
||||
codeExecEnabledForThisTurn ||
|
||||
(!isExternalRequest && supportsTools && codeToolsEnabled),
|
||||
images: imageGenerationEnabledForThisTurn,
|
||||
mcp: !isExternalRequest && supportsTools && mcpEnabledForChat,
|
||||
docs:
|
||||
!isExternalRequest &&
|
||||
supportsTools &&
|
||||
(ragEnabled || projectRagEnabled),
|
||||
artifacts: renderHtmlToolEnabledForThisTurn,
|
||||
confirmToolCalls,
|
||||
bypassPermissions,
|
||||
},
|
||||
});
|
||||
const externalCapabilities = getProviderCapabilities(
|
||||
externalProvider?.providerType,
|
||||
);
|
||||
|
|
@ -2768,6 +2851,11 @@ export function createOpenAIStreamAdapter(
|
|||
const stream = streamChatCompletions(requestPayload, abortSignal);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const chunkModel = (chunk as { model?: unknown }).model;
|
||||
if (typeof chunkModel === "string" && chunkModel.length > 0) {
|
||||
responseModelId = chunkModel;
|
||||
}
|
||||
|
||||
// Handle tool status events
|
||||
const toolStatusText = (
|
||||
chunk as unknown as { _toolStatus?: string }
|
||||
|
|
@ -3435,11 +3523,12 @@ export function createOpenAIStreamAdapter(
|
|||
});
|
||||
}
|
||||
|
||||
const finishedAt = Date.now();
|
||||
const finalTiming = buildTiming(
|
||||
streamStartTime,
|
||||
totalChunks,
|
||||
serverPromptEvalTime ?? firstTokenTime,
|
||||
Date.now() - streamStartTime,
|
||||
finishedAt - streamStartTime,
|
||||
finalTokenCount,
|
||||
toolCallParts.length,
|
||||
finalTokPerSec,
|
||||
|
|
@ -3475,6 +3564,7 @@ export function createOpenAIStreamAdapter(
|
|||
modelId: params.checkpoint,
|
||||
}
|
||||
: undefined,
|
||||
responseDetails: buildResponseDetails(finishedAt),
|
||||
timing: finalTiming,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ export {
|
|||
type PlusMenuItemId,
|
||||
} from "./stores/plus-menu-prefs-store";
|
||||
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
|
||||
export { isExternalModelId } from "./external-providers";
|
||||
export {
|
||||
customProviderDisplayName,
|
||||
isExternalModelId,
|
||||
parseExternalModelId,
|
||||
} from "./external-providers";
|
||||
export { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
export { ChatSearchDialog } from "./components/chat-search-dialog";
|
||||
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
|
||||
export type { ProjectRecord } from "./types";
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import { persist } from "zustand/middleware";
|
|||
// Client-side chat UI prefs kept in localStorage, not the chat DB.
|
||||
// confirmDeleteChats: when off, deleting a chat skips the confirm dialog.
|
||||
// showModelDisclaimer: when off, hide the "LLMs can make mistakes" footer note.
|
||||
// showResponseModel: when on, assistant responses show the producing model.
|
||||
export interface ChatPreferencesState {
|
||||
confirmDeleteChats: boolean;
|
||||
setConfirmDeleteChats: (value: boolean) => void;
|
||||
showModelDisclaimer: boolean;
|
||||
setShowModelDisclaimer: (value: boolean) => void;
|
||||
showResponseModel: boolean;
|
||||
setShowResponseModel: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
||||
|
|
@ -23,6 +26,9 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
|||
showModelDisclaimer: true,
|
||||
setShowModelDisclaimer: (showModelDisclaimer) =>
|
||||
set({ showModelDisclaimer }),
|
||||
showResponseModel: false,
|
||||
setShowResponseModel: (showResponseModel) =>
|
||||
set({ showResponseModel }),
|
||||
}),
|
||||
{
|
||||
name: "unsloth_chat_preferences",
|
||||
|
|
@ -32,6 +38,7 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
|||
...current,
|
||||
confirmDeleteChats: saved?.confirmDeleteChats ?? true,
|
||||
showModelDisclaimer: saved?.showModelDisclaimer ?? true,
|
||||
showResponseModel: saved?.showResponseModel ?? false,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -213,6 +213,12 @@ export function ChatTab() {
|
|||
const setShowModelDisclaimer = useChatPreferencesStore(
|
||||
(state) => state.setShowModelDisclaimer,
|
||||
);
|
||||
const showResponseModel = useChatPreferencesStore(
|
||||
(state) => state.showResponseModel,
|
||||
);
|
||||
const setShowResponseModel = useChatPreferencesStore(
|
||||
(state) => state.setShowResponseModel,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void countAllChats().then(setCount);
|
||||
|
|
@ -412,6 +418,15 @@ export function ChatTab() {
|
|||
onCheckedChange={setShowModelDisclaimer}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Show response model"
|
||||
description="Show model metadata in assistant responses."
|
||||
>
|
||||
<Switch
|
||||
checked={showResponseModel}
|
||||
onCheckedChange={setShowResponseModel}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.chat.artifacts.title")}>
|
||||
|
|
|
|||
93
tests/studio/test_chat_response_details_ui_contract.py
Normal file
93
tests/studio/test_chat_response_details_ui_contract.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Static contract for the chat response-details action and metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
THREAD_TSX = REPO / "studio/frontend/src/components/assistant-ui/thread.tsx"
|
||||
DETAILS_TSX = (
|
||||
REPO / "studio/frontend/src/components/assistant-ui/message-response-details-sheet.tsx"
|
||||
)
|
||||
REASONING_TSX = REPO / "studio/frontend/src/components/assistant-ui/reasoning.tsx"
|
||||
ADAPTER_TS = REPO / "studio/frontend/src/features/chat/api/chat-adapter.ts"
|
||||
CHAT_PREFS_TS = REPO / "studio/frontend/src/features/chat/stores/chat-preferences-store.ts"
|
||||
CHAT_TAB_TSX = REPO / "studio/frontend/src/features/settings/tabs/chat-tab.tsx"
|
||||
|
||||
|
||||
def test_assistant_more_menu_exposes_response_details_action():
|
||||
src = THREAD_TSX.read_text()
|
||||
assert "MessageResponseDetailsSheet" in src
|
||||
assert "See response details" in src
|
||||
assert "setDetailsOpen(true)" in src
|
||||
|
||||
|
||||
def test_response_details_sheet_uses_unsloth_sheet_and_key_sections():
|
||||
src = DETAILS_TSX.read_text()
|
||||
assert "SheetContent" in src
|
||||
assert "Response details" in src
|
||||
assert "MessageResponseModelBadge" in src
|
||||
assert "showResponseModel" in src
|
||||
assert "ChipIcon" not in src
|
||||
assert "s.params.checkpoint" not in src
|
||||
assert "Not recorded" in src
|
||||
assert "min-w-0 break-words font-heading" in src
|
||||
assert "toolCallsFromContent(message.content)" in src
|
||||
assert 'label="Called"' in src
|
||||
for section in ["Response", "Tokens", "Timing", "Tools"]:
|
||||
assert f'title="{section}"' in src
|
||||
for field in ["Model", "Provider", "Total", "Cache hits", "Enabled", "Called"]:
|
||||
assert f'label="{field}"' in src
|
||||
|
||||
|
||||
def test_response_model_chip_is_user_configurable_and_rendered_in_metadata_rows():
|
||||
prefs_src = CHAT_PREFS_TS.read_text()
|
||||
chat_tab_src = CHAT_TAB_TSX.read_text()
|
||||
thread_src = THREAD_TSX.read_text()
|
||||
reasoning_src = REASONING_TSX.read_text()
|
||||
|
||||
assert "showResponseModel: boolean" in prefs_src
|
||||
assert "showResponseModel: false" in prefs_src
|
||||
assert "showResponseModel: saved?.showResponseModel ?? false" in prefs_src
|
||||
assert "Show response model" in chat_tab_src
|
||||
assert "setShowResponseModel" in chat_tab_src
|
||||
assert "aui-response-model-badge inline-flex min-h-5" in DETAILS_TSX.read_text()
|
||||
assert "leading-5" in DETAILS_TSX.read_text()
|
||||
assert "group-hover/assistant-message:opacity-100" in DETAILS_TSX.read_text()
|
||||
assert "MessageResponseModelBadge" in thread_src
|
||||
assert "hasReasoningParts" in thread_src
|
||||
assert "group/assistant-message aui-assistant-message-root" in thread_src
|
||||
assert "pointer-events-none relative h-0" in thread_src
|
||||
assert "MessageResponseModelBadge" in reasoning_src
|
||||
assert 'className="min-w-0 flex-none"' in reasoning_src
|
||||
assert "hidden min-w-0 max-w-[12rem]" in reasoning_src
|
||||
assert "group-hover/assistant-message:inline-flex" in reasoning_src
|
||||
|
||||
|
||||
def test_response_details_metadata_is_persisted_without_backend_schema_change():
|
||||
src = ADAPTER_TS.read_text()
|
||||
assert "interface ResponseDetailsMetadata" in src
|
||||
assert "buildResponseDetails" in src
|
||||
assert "responseDetails: buildResponseDetails(finishedAt)" in src
|
||||
assert "toolCalls: Array.from(" in src
|
||||
assert "!isExternalRequest && supportsTools && toolsEnabled" in src
|
||||
assert "!isExternalRequest && supportsTools && codeToolsEnabled" in src
|
||||
assert re.search(r"selectedModelSummary\?\.name\s*\|\|\s*responseModelId", src)
|
||||
assert "providerName" in src
|
||||
assert "cancelId" in src
|
||||
metadata_block = src[
|
||||
src.find("interface ResponseDetailsMetadata") : src.find("type RunMessages")
|
||||
]
|
||||
builder_block = src[
|
||||
src.find("const buildResponseDetails") : src.find("const externalCapabilities")
|
||||
]
|
||||
for forbidden in [
|
||||
"encrypted_api_key",
|
||||
"externalApiKey",
|
||||
"apiKey",
|
||||
"providerKey",
|
||||
"secret",
|
||||
]:
|
||||
assert forbidden not in metadata_block
|
||||
assert forbidden not in builder_block
|
||||
Loading…
Add table
Add a link
Reference in a new issue