* feat(studio): share the llama-server --parallel bounds as PARALLEL_MIN/MAX
The per-load parallel-slots field needs the same 1..64 range the CLI flag
validates, but models/inference.py cannot import run.py (run.py builds the
app that imports routes that import models). Promote the bounds into this
dependency-free module, which already owns the -np/--parallel semantics, and
record the deliberate mirrors that cannot import it (run.py, the unsloth CLI,
the web UI). The denylist entry stays: the first-class field is now the single
write path for the slot count, so a pass-through would still desync the
committed bookkeeping from llama-server.
* feat(studio): note the per-load override in the --parallel help text
--parallel is now the server-wide default that a per-load n_parallel (the
Studio Parallel Slots run setting) can override, not the definitive slot
count. Point at the new control so a user does not conclude a restart is the
only way to change slots, and record the shared PARALLEL_MIN/MAX mirror
alongside the existing CLI one.
* feat(studio): add n_parallel to LoadRequest and echo the slot counts
LoadRequest.n_parallel (optional, PARALLEL_MIN..PARALLEL_MAX) lets a load pick
its own llama-server --parallel count; omitted, the server-wide launch default
applies. ValidateModelRequest carries it too so the training-coexistence
estimate sizes the KV cache like the follow-up load rather than passing on a
smaller footprint.
LoadResponse and InferenceStatusResponse gain both requested_parallel_slots
(what the load was invoked with) and parallel_slots (what llama-server
actually runs after the fitter's slot reduction), so a client can tell an
honored request from a reduced one. Both are None where --parallel has no
meaning: non-GGUF loads and the diffusion runner.
* feat(studio): record the requested parallel-slot count on the backend
The auto GPU-memory fit may launch fewer slots than requested to keep the
model fully on GPU, so the committed effective count cannot answer "is the
live server what this request asked for?". Store the invoked count separately
(mirroring the _requested_n_ctx pattern) from the pre-reduction pending
kwargs, expose it as requested_parallel_slots, and have _already_in_target_state
compare requested-vs-requested: comparing against the effective count would
reload -- and re-reduce -- forever on an identical Apply.
The comparison sits in the non-diffusion branch, since the diffusion runner
ignores --parallel entirely. The requested value shares the effective count's
lifecycle, so every unload/kill path clears it and a stale count cannot
poison the next load's dedupe.
* feat(studio): honor a per-load parallel-slot count in /load and /validate
Resolve the slot count once per load -- the request field if set, else the
server-wide launch default -- and feed it to every consumer that must agree:
the training-coexistence guard, the llama-server load kwargs, and the reload
dedupe. Without the dedupe comparison a changed slot count would be swallowed
as already_loaded; it compares requested-vs-requested and skips the diffusion
runner, which ignores --parallel.
app.state.llama_parallel_slots is deliberately never written: it stays the
launch intent and the admission-queue fallback, so one load's override cannot
leak into later loads. /validate resolves the same way so its estimate cannot
undercount what the load then allocates.
Both /load returns and /status echo the counts through one helper, which
reports None for diffusion -- its load never commits a count, so echoing the
reset placeholder would fabricate an "invoked with 1 slot".
* feat(studio): accept nParallel in the chat-preset load config
ChatPresetLoadConfig is extra="forbid", so a preset carrying the new parallel
slots knob would 422 the whole settings sync without this field. Bounds come
from the shared PARALLEL_MIN/MAX rather than literals, so a future range
change cannot start rejecting presets the UI still allows.
* test(studio): cover the per-load parallel-slots knob
Pins the behaviors a regression would silently break: the requested-vs-effective
dedupe (comparing against the reduced count would reload forever), the diffusion
skip and its None echo, the requested count's reset lifecycle, and its commit
from the pre-reduction pending kwargs.
Also pins the three bounds mirrors that cannot import PARALLEL_MIN/MAX (run.py,
the unsloth CLI, the web UI) plus the preset model that can, so a range change
cannot leave one of them clamping or rejecting at the old limit.
* test(studio): refresh the --parallel denylist comments for the UI knob
The pinned rationale said the typer flag owns the slot count and pointed users
at a Studio restart. Parallel Slots / LoadRequest.n_parallel is now the other
managed writer, and the 1..64 guard is the shared PARALLEL_MIN/MAX -- a reader
following the old comments would conclude the UI control does not exist.
* feat(studio): note the per-load override in the CLI --parallel help
Both the plain-serve and `unsloth studio run` flags now describe a server-wide
default the Studio Parallel Slots run setting can override per load, matching
the backend help text.
* feat(studio): remember a per-model Parallel Slots override
nParallel joins the per-model config with the same null-means-follow-the-default
convention as the other knobs: null keeps the server-wide --parallel count, so
a blank control never pins a number and isDefaultConfig still deletes an
otherwise-untouched config instead of storing it.
The value is re-clamped to N_PARALLEL_MIN/MAX on every localStorage read and
write (the store is user-editable), and listing it in STORED_CONFIG_FIELDS
keeps it from being dropped as an unknown key. Legacy blobs predate the knob,
so their migration carries null. No schema-version bump: an additive optional
field, like the GPU fields before it.
* feat(studio): bridge nParallel between the per-model config and the store
The config->store, store->config and equality helpers all need the new field:
without the equality arm a slots-only edit reads as unchanged, so Apply is
dropped and the dirty state never lights up.
* feat(studio): track the parallel-slot override in the chat runtime store
nParallel holds the editable override and loadedNParallel the value the last
successful load sent, which the failed-switch rollback re-sends. Both are
per-model: they clear on unload and on a model switch, unlike the standing
preferences (GPU memory mode, speculative type) that survive one.
There is deliberately no backend-echo field for the control: the echo is the
resolved count, so adopting it would pin a blank "follow the server default"
input to an explicit number.
* feat(studio): type n_parallel and the slot-count echoes
The load request gains the optional per-load slot count, and both the load
response and the status payload gain requested_parallel_slots (invoked) and
parallel_slots (actually running after the fitter's reduction). Keys stay
snake_case: the payload is serialized as-is, with no case conversion.
* feat(studio): forward n_parallel to the validate preflight
validateModel builds its own body rather than forwarding the load payload, so
the slot count has to be listed explicitly. Slots scale the KV estimate, and
the preflight exists to refuse a load the training guard would then 409 -- an
unforwarded count would validate a smaller footprint than the load allocates.
* feat(studio): include nParallel in the active model's config
The sidebar assembles the active model's config from individually subscribed
store fields; an unsubscribed field would leave the form showing a stale value
after any external change.
* feat(studio): add the Parallel Slots control to the run settings
A numeric input in the GGUF advanced section, blank meaning "follow the server
default". It clamps on change like the Draft Tokens field rather than using
NumericValueInput, so there is no blur-draft to lose when the user types a
value and immediately clicks Load.
hasNonDefaultAdvanced counts it too, so a remembered override reopens the
advanced section instead of hiding the setting that is actually in effect.
* feat(studio): key the sidebar config form on nParallel too
The signature drives the remount that re-seeds the form; without the new field
an externally changed slot count would leave the sidebar showing the old one.
* feat(studio): send the Parallel Slots override on load
performLoad snapshots the slot count at click time (staged run-settings config
first, else the store) and sends it on both the validate preflight and the
load, so the two size the same footprint. A cross-model switch re-baselines it
like the other per-model knobs -- the previous model's count must not follow
onto the next one -- and the failed-switch rollback re-sends the previous
model's value so a rescue reload cannot silently drop to the server default.
The success path keeps the click-time value rather than the response echo: the
echo is the count the fitter resolved, so adopting it would turn a blank
"follow the server default" control into an explicit pin. Slots are GGUF-only,
so a transformers load sends and records null instead of a phantom override.
* feat(studio): carry the slot override through the compare-pane load
The compare pane builds its own load request, so it needs the field explicitly
or a pane with a remembered override would load at the server default. Its
validate preflight sends the same count, matching the comment above it that
promises validation is sized exactly as the load below.
GGUF-gated on both calls, and the store adopts the pane's own click-time value
rather than the resolved echo, mirroring the single-model path.
* feat(studio): honor the remembered slot override on startup auto-load
The auto-load path reads the per-model config and forwards every other
remembered knob, so a remembered Parallel Slots value was the one setting lost
on the "load last used model" path: llama-server came back at the server-wide
default with the control showing blank, and the first manual Apply afterwards
then forced a needless reload because the counts disagreed.
* feat(studio): seed the slot baseline from the status echo
Only the rollback baseline is seeded, never the editable control: the echo is
the resolved count, so adopting it would pin a blank "follow the server
default" input to a number. Without the seed, loadedNParallel stayed null
after a tab reload or a second tab adopting the running model, and a failed
switch then rolled the previous model back at the server default while every
other knob was restored.
* feat(studio): capture Parallel Slots in chat presets
The knob joins the preset load config end to end: captured from the store,
re-clamped when read back (persisted presets are untrusted input), applied on
switch, and summarized in the preset chip. Its default is null, so
coalesceDefaultLoadKnobs keeps a default-only preset empty rather than
persisting a no-op override.
* feat(studio): re-derive the preset state when Parallel Slots changes
Both preset memos snapshot the store through capturePresetLoadConfig, so
without the new dependency a slots-only edit left the unsaved-changes flag and
the load summary showing the previous value.
* test(studio): pin the Parallel Slots wiring end to end
Source-contract coverage for the hops a refactor can silently drop: the three
/load builders (interactive, compare pane, startup auto-load) and their
validate preflights, per-model persistence and clamping, the UI row, and the
status seed -- including the negative assertion that hydration seeds only the
rollback baseline, never the control, so the resolved echo cannot pin a blank
"server default" input.
* test(studio): pin nParallel in the preset load config
Covers capture, clamped read-back and apply on the frontend, plus the backend
field itself: ChatPresetLoadConfig is extra="forbid", so a missing or drifted
field 422s every settings sync that carries a preset.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Fall back to one slot when llama-server lacks --kv-unified for PR #7447
Without --kv-unified an explicit --parallel N makes llama-server give each slot -c/N, so on a build without the flag choosing N slots silently shrinks every context window for a feature that build cannot serve. Clamp to one slot and log why, placed after the requested count is captured so the echo still reports it and before the KV estimates so the fit matches what actually launches.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Clear the slot control on load paths that never send it, and size the training guard for diffusion
Four review findings on the per-load Parallel Slots knob.
The editable nParallel control means "follow the server default" when null, so
any success path that does not send a slot count has to clear it. Three paths
kept a value staged for a different model:
- chat-adapter.ts, cached non-GGUF auto-load: the interactive and compare
builders already clear both fields for a non-GGUF response, this third one
did not. The field never renders for a non-GGUF target, so the stale count
was invisible and unclearable from the UI yet still persisted, and it flips
isDefaultConfig so a user with no overrides silently gets a stored entry.
- chat-adapter.ts, fresh-model fallback: its request omits n_parallel but its
success state resynced every other knob and left the slots alone, so a staged
edit survived against a server running the default and the next Apply
reloaded at a count that load never sent.
- apply-inference-status-to-store.ts: on a model change underneath the tab
every sibling knob adopts the new model's status, but nParallel updated only
its baseline, so the previous model's explicit count followed onto the new
model and saving or reloading there pinned it. Clear the control and keep
seeding the baseline for the rollback.
The training-coexistence guard sized a diffusion GGUF with the requested slot
count. _estimate_kv_cache_bytes scales the SWA cache with slots
(swa_limit = swa * slots + ubatch), but load_model hands a diffusion target to
_start_diffusion_server before the slot plumbing, so that runner is always
single-slot. At the new default of 4 this inflated the estimate and could 409 a
load that fits. An unclassified GGUF keeps the requested count.
Backend base KV depends on -c alone, not on --parallel, which is why only the
SWA term is affected: llama.cpp PR 14363 and discussion 4130.
Tests: three training-guard cases in test_parallel_slots_per_load.py and one
source contract in test_model_picker_contracts.py, each mutation-checked.
174 passed across the backend slot/admission/training suites, 56 across the
frontend contract suites.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Keep the slot control when re-adopting the running model, and never record slots for a diffusion load
Two follow-ups from the latest review round.
The first is a regression from c796393. That commit cleared the slot control
whenever hydratingExistingModel was set, to stop model A's count following onto
model B. But that flag is also set on the resident-model adopt path: when the
store checkpoint is an external provider id and the user re-picks the still
loaded local model, applyActiveModelStatusToStore is called with the external
id as previousCheckpoint, so the flag is unconditionally true. The clear then
wiped the config applyPerModelConfigToRuntime had restored two lines earlier,
and it was the only knob that did, because the siblings re-adopt the status
echo while this one cleared. Gate the clear on the tab's own baseline no longer
matching the running count: a genuine A to B swap still clears, re-adopting the
same model keeps its value.
The second revises an earlier call of mine. I rejected the diffusion phantom as
cosmetic because the backend ignores the value on every send. The sharpened
report is right and my rejection was wrong: capturePresetLoadConfig records
nParallel with no model gate, a Preset carries no model id, and applying one
writes nParallel for whatever model is current. So a count recorded against a
diffusion model, which the backend never applied, rides a saved preset onto a
text GGUF and becomes a real override the user never chose. Record slots only
when the load actually committed them, on all three load builders.
Tests: two source contracts in test_model_picker_contracts.py, both mutation
checked. Frontend typecheck clean, 58 passed across the contract and preset
suites.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Clear the slot baseline when status reports a model without slots
Hydrating from a GGUF to a slotless model left loadedNParallel at the previous
model's count: the seed only runs when the echo is non-null, and the control
clear added earlier touches nParallel alone. The stale baseline is what a
failed-switch rollback re-sends, and preset capture reads it, so it could claim
slots for a model that never used them.
Clear it when status describes a model that cannot have slots. /status omits
the echo entirely for non-GGUF and sends an explicit null for the diffusion
runner, so keying on is_gguf === false or an explicit null covers both while an
absent field on a GGUF, which is how an older backend reports one, still leaves
the baseline alone.
Test mutation checked; frontend typecheck clean against a fresh npm ci.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Distinguish a same-model re-adopt from a model swap, and size the training guard at the slots that launch for PR #7447
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Keep the blank slot control across a failed-switch rollback for PR #7447
* Restore a remembered slot override when hydrating a fresh store for PR #7447
* Tighten comments for PR #7447
* Restore a remembered slot override on a model switch too for PR #7447
* Tighten comments and docstrings for PR #7447
* Take the rollback slot intent from the picker's pre-switch snapshot for PR #7447
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
1274 lines
41 KiB
TypeScript
1274 lines
41 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
import { authFetch } from "@/features/auth";
|
|
import { prepareHfTokenForUse } from "@/features/hf-auth";
|
|
// These helpers are deliberately API-layer-only and are not part of their
|
|
// features' React-facing public barrels.
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import { hubTokenHeader } from "@/features/hub/lib/hub-token-header";
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import { consumeNativePathToken } from "@/features/native-intents/api";
|
|
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
|
|
import type {
|
|
MessageRecord,
|
|
ModelType,
|
|
ProjectRecord,
|
|
ThreadRecord,
|
|
} from "../types";
|
|
import type {
|
|
ApiMonitorEntry,
|
|
ApiMonitorResponse,
|
|
AudioGenerationResponse,
|
|
GgufVariantsResponse,
|
|
InferenceStatusResponse,
|
|
ListLorasResponse,
|
|
ListModelsResponse,
|
|
LoadModelRequest,
|
|
LoadModelResponse,
|
|
OpenAIChatChunk,
|
|
OpenAIChatCompletionsRequest,
|
|
UnloadModelRequest,
|
|
ValidateModelResponse,
|
|
} from "../types/api";
|
|
|
|
export const CHAT_HISTORY_UPDATED_EVENT = "unsloth-chat-history-updated";
|
|
export const CHAT_PROJECTS_UPDATED_EVENT = "unsloth-chat-projects-updated";
|
|
|
|
/**
|
|
* Thrown when the chat SSE stream ends without a terminal signal (`[DONE]` or a
|
|
* finish_reason chunk): the connection dropped mid-generation. The adapter
|
|
* surfaces it as an explicit interrupted state instead of ending the turn.
|
|
*/
|
|
export class StreamInterruptedError extends Error {
|
|
constructor() {
|
|
super(
|
|
"Response interrupted: the connection dropped before the model finished. " +
|
|
"Use Retry to regenerate.",
|
|
);
|
|
this.name = "StreamInterruptedError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thrown when a reasoning model consumes its output budget before emitting any
|
|
* standard content. Keeping this distinct from a dropped connection lets the
|
|
* chat UI explain why a completed stream contains only a thinking panel.
|
|
*/
|
|
export class GenerationLengthError extends Error {
|
|
constructor() {
|
|
super(
|
|
"The model reached the Max Tokens limit before producing a final answer. " +
|
|
"Increase Max Tokens or disable thinking, then retry.",
|
|
);
|
|
this.name = "GenerationLengthError";
|
|
}
|
|
}
|
|
|
|
export function notifyChatHistoryUpdated(): void {
|
|
if (typeof window !== "undefined") {
|
|
window.dispatchEvent(new Event(CHAT_HISTORY_UPDATED_EVENT));
|
|
}
|
|
}
|
|
|
|
function notifyChatProjectsUpdated(): void {
|
|
notifyChatHistoryUpdated();
|
|
if (typeof window !== "undefined") {
|
|
window.dispatchEvent(new Event(CHAT_PROJECTS_UPDATED_EVENT));
|
|
}
|
|
}
|
|
|
|
function parseErrorText(status: number, body: unknown): string {
|
|
if (body && typeof body === "object") {
|
|
const detail = (body as { detail?: unknown }).detail;
|
|
const formatted = formatFastApiDetail(detail);
|
|
if (formatted) return formatted;
|
|
const message = (body as { message?: unknown }).message;
|
|
if (typeof message === "string" && message) return message;
|
|
}
|
|
return `Request failed (${status})`;
|
|
}
|
|
|
|
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
|
|
const body = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
throw new Error(parseErrorText(response.status, body));
|
|
}
|
|
return body as T;
|
|
}
|
|
|
|
export async function listModels(): Promise<ListModelsResponse> {
|
|
const response = await authFetch("/api/models/list");
|
|
return parseJsonOrThrow<ListModelsResponse>(response);
|
|
}
|
|
|
|
export async function listLoras(
|
|
outputsDir?: string,
|
|
): Promise<ListLorasResponse> {
|
|
const query = outputsDir
|
|
? `?${new URLSearchParams({ outputs_dir: outputsDir }).toString()}`
|
|
: "";
|
|
const response = await authFetch(`/api/models/loras${query}`);
|
|
return parseJsonOrThrow<ListLorasResponse>(response);
|
|
}
|
|
|
|
export async function getInferenceStatus(): Promise<InferenceStatusResponse> {
|
|
const response = await authFetch("/api/inference/status");
|
|
return parseJsonOrThrow<InferenceStatusResponse>(response);
|
|
}
|
|
|
|
export async function getApiMonitor(): Promise<ApiMonitorResponse> {
|
|
const response = await authFetch("/api/inference/monitor");
|
|
return parseJsonOrThrow<ApiMonitorResponse>(response);
|
|
}
|
|
|
|
export async function getApiMonitorEntry(id: string): Promise<ApiMonitorEntry> {
|
|
const response = await authFetch(
|
|
`/api/inference/monitor/${encodeURIComponent(id)}`,
|
|
);
|
|
return parseJsonOrThrow<ApiMonitorEntry>(response);
|
|
}
|
|
|
|
export interface ActiveGenerationsResponse {
|
|
count: number;
|
|
/** Conversations with a generation in flight. Shorter than `count` when a
|
|
* first turn started before its thread id was persisted. */
|
|
thread_ids: string[];
|
|
/** One entry per in-flight request. `kind` is "chat" unless it is an
|
|
* embeddings / completions / audio call, which has no conversation. */
|
|
active?: { thread_id: string | null; kind?: string }[];
|
|
parallel_slots: number;
|
|
}
|
|
|
|
/**
|
|
* Chats generating on the backend right now. Authoritative where `runningByThreadId` is not:
|
|
* that map is per-tab, empty after a reload and blind to a second tab, and /load and /unload
|
|
* 409 on these.
|
|
*/
|
|
export async function getActiveGenerations(): Promise<ActiveGenerationsResponse> {
|
|
const response = await authFetch("/api/inference/active-generations");
|
|
return parseJsonOrThrow<ActiveGenerationsResponse>(response);
|
|
}
|
|
|
|
export async function loadModel(
|
|
payload: LoadModelRequest,
|
|
): Promise<LoadModelResponse> {
|
|
const preparedToken = await prepareHfTokenForUse(payload.hf_token);
|
|
if (!preparedToken.proceed) throw new Error("Model load cancelled.");
|
|
const response = await authFetch("/api/inference/load", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
...payload,
|
|
hf_token: preparedToken.token,
|
|
native_path_lease: payload.nativePathLease ?? null,
|
|
nativePathLease: undefined,
|
|
}),
|
|
});
|
|
return parseJsonOrThrow<LoadModelResponse>(response);
|
|
}
|
|
|
|
export async function validateModel(
|
|
payload: LoadModelRequest,
|
|
): Promise<ValidateModelResponse> {
|
|
const preparedToken = await prepareHfTokenForUse(payload.hf_token);
|
|
if (!preparedToken.proceed) throw new Error("Model load cancelled.");
|
|
const response = await authFetch("/api/inference/validate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model_path: payload.model_path,
|
|
native_path_lease: payload.nativePathLease ?? null,
|
|
hf_token: preparedToken.token,
|
|
gguf_variant: payload.gguf_variant ?? null,
|
|
// Intended load settings so validate's preflight matches the follow-up
|
|
// /load. Default placement is sized against the selected GPUs.
|
|
max_seq_length: payload.max_seq_length,
|
|
load_in_4bit: payload.load_in_4bit,
|
|
cache_type_kv: payload.cache_type_kv ?? null,
|
|
tensor_parallel: payload.tensor_parallel ?? false,
|
|
gpu_ids: payload.gpu_ids,
|
|
// Manual placement is an explicit override: Auto layers use llama.cpp
|
|
// --fit, while a pinned layer count is owned by the user. Tell validate
|
|
// so it applies the same training-guard policy as /load.
|
|
gpu_memory_mode: payload.gpu_memory_mode,
|
|
// Slots scale the KV estimate; keep validate sized like the load.
|
|
n_parallel: payload.n_parallel,
|
|
}),
|
|
});
|
|
return parseJsonOrThrow<ValidateModelResponse>(response);
|
|
}
|
|
|
|
/**
|
|
* Read a GGUF's header dims (native context length, total layer count, MoE
|
|
* expert-layer count) from its local file (no GPU load, no download). All are
|
|
* null when the file isn't downloaded yet, the model isn't a GGUF, or it's
|
|
* gated. For a native (drag-drop / picked) file, pass `nativePathToken` so the
|
|
* backend reads the granted local path. Used by the deferred-load staging flow
|
|
* to size the context, GPU-layers and MoE sliders before the single load.
|
|
*/
|
|
export async function fetchGgufStagedMetadata(payload: {
|
|
model_path: string;
|
|
gguf_variant?: string | null;
|
|
hf_token?: string | null;
|
|
nativePathToken?: string | null;
|
|
}): Promise<{
|
|
contextLength: number | null;
|
|
layerCount: number | null;
|
|
moeLayerCount: number | null;
|
|
}> {
|
|
let nativePathLease: string | null = null;
|
|
if (payload.nativePathToken) {
|
|
try {
|
|
nativePathLease = (
|
|
await consumeNativePathToken(payload.nativePathToken, "validate-model")
|
|
).nativePathLease;
|
|
} catch {
|
|
// Lease expired / revoked: degrade to no metadata (the load can re-mint).
|
|
return { contextLength: null, layerCount: null, moeLayerCount: null };
|
|
}
|
|
}
|
|
const response = await authFetch("/api/inference/validate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model_path: payload.model_path,
|
|
gguf_variant: payload.gguf_variant ?? null,
|
|
hf_token: payload.hf_token ?? null,
|
|
native_path_lease: nativePathLease,
|
|
include_context_length: true,
|
|
}),
|
|
});
|
|
const res = await parseJsonOrThrow<ValidateModelResponse>(response);
|
|
return {
|
|
contextLength: res.context_length ?? null,
|
|
layerCount: res.layer_count ?? null,
|
|
moeLayerCount: res.moe_layer_count ?? null,
|
|
};
|
|
}
|
|
|
|
export async function unloadModel(payload: UnloadModelRequest): Promise<void> {
|
|
const response = await authFetch("/api/inference/unload", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
await parseJsonOrThrow<unknown>(response);
|
|
}
|
|
|
|
/**
|
|
* Allow or deny a tool call that is paused awaiting user confirmation
|
|
* (when the "Confirm tool calls" toggle is on). The call is identified by
|
|
* the backend ``approvalId`` echoed in the tool_start event; ``sessionId``
|
|
* is a scope check. Resolves to ``true`` only when the backend matched a
|
|
* pending call, so the caller can surface a retry on a stale/failed post.
|
|
*/
|
|
export async function resolveToolConfirmation(
|
|
sessionId: string,
|
|
approvalId: string,
|
|
decision: "allow" | "deny",
|
|
): Promise<boolean> {
|
|
const response = await authFetch("/api/inference/tool-confirm", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
session_id: sessionId,
|
|
approval_id: approvalId,
|
|
decision,
|
|
}),
|
|
});
|
|
const parsed = await parseJsonOrThrow<{ resolved?: boolean }>(response);
|
|
return parsed.resolved === true;
|
|
}
|
|
|
|
export interface CachedGgufRepo {
|
|
repo_id: string;
|
|
load_id?: string | null;
|
|
size_bytes: number;
|
|
cache_path: string;
|
|
/** Epoch seconds of the newest downloaded quant; sorts Downloaded
|
|
* newest-first. Optional for older-backend compatibility. */
|
|
last_modified?: number;
|
|
/** True when the repo ships an mmproj adapter (image inputs). Optional for
|
|
* older-backend compatibility. */
|
|
has_vision?: boolean;
|
|
}
|
|
|
|
export async function getGgufDownloadProgress(
|
|
repoId: string,
|
|
variant: string,
|
|
expectedBytes: number,
|
|
): Promise<{
|
|
downloaded_bytes: number;
|
|
expected_bytes: number;
|
|
progress: number;
|
|
}> {
|
|
const params = new URLSearchParams({
|
|
repo_id: repoId,
|
|
variant,
|
|
expected_bytes: String(expectedBytes),
|
|
});
|
|
const response = await authFetch(
|
|
`/api/models/gguf-download-progress?${params}`,
|
|
);
|
|
return parseJsonOrThrow(response);
|
|
}
|
|
|
|
export interface DownloadProgressResponse {
|
|
downloaded_bytes: number;
|
|
expected_bytes: number;
|
|
progress: number;
|
|
/**
|
|
* On-disk path of the snapshot dir (or cache repo root if no snapshot yet).
|
|
* Null when nothing has been written to the cache for this repo.
|
|
*/
|
|
cache_path: string | null;
|
|
}
|
|
|
|
export async function getDownloadProgress(
|
|
repoId: string,
|
|
): Promise<DownloadProgressResponse> {
|
|
const params = new URLSearchParams({ repo_id: repoId });
|
|
const response = await authFetch(`/api/models/download-progress?${params}`);
|
|
return parseJsonOrThrow(response);
|
|
}
|
|
|
|
export async function getDatasetDownloadProgress(
|
|
repoId: string,
|
|
): Promise<DownloadProgressResponse> {
|
|
const params = new URLSearchParams({ repo_id: repoId });
|
|
const response = await authFetch(`/api/datasets/download-progress?${params}`);
|
|
return parseJsonOrThrow(response);
|
|
}
|
|
|
|
export type ModelLoadPhase = "mmap" | "ready" | null;
|
|
|
|
export interface LoadProgressResponse {
|
|
/**
|
|
* Load phase: "mmap" while llama-server pages weight shards into RAM,
|
|
* "ready" once healthy, or null when no load is in flight.
|
|
*/
|
|
phase: ModelLoadPhase;
|
|
bytes_loaded: number;
|
|
bytes_total: number;
|
|
fraction: number;
|
|
}
|
|
|
|
/**
|
|
* Fetch the active GGUF load's mmap/upload progress. Complements the download
|
|
* progress endpoints for the "download complete" -> "chat ready" window, which
|
|
* for large MoE models can be several minutes of otherwise-opaque spinning.
|
|
*/
|
|
export async function getLoadProgress(): Promise<LoadProgressResponse> {
|
|
const response = await authFetch(`/api/inference/load-progress`);
|
|
return parseJsonOrThrow(response);
|
|
}
|
|
|
|
export interface LocalModelInfo {
|
|
id: string;
|
|
display_name: string;
|
|
path: string;
|
|
source: "models_dir" | "hf_cache" | "lmstudio" | "custom";
|
|
model_id?: string | null;
|
|
// Backend-detected weights format ("gguf" when known), so the UI can
|
|
// classify scanned folders whose name lacks a -GGUF suffix.
|
|
model_format?: string | null;
|
|
// Set when a cached snapshot holds an incomplete download, so consumers can skip
|
|
// weights that cannot load yet.
|
|
partial?: boolean;
|
|
updated_at?: number | null;
|
|
}
|
|
|
|
interface LocalModelListResponse {
|
|
models_dir: string;
|
|
hf_cache_dir?: string | null;
|
|
lmstudio_dirs: string[];
|
|
models: LocalModelInfo[];
|
|
}
|
|
|
|
export async function listLocalModels(
|
|
signal?: AbortSignal,
|
|
): Promise<LocalModelListResponse> {
|
|
const response = await authFetch("/api/models/local", { signal });
|
|
return parseJsonOrThrow<LocalModelListResponse>(response);
|
|
}
|
|
|
|
export async function listCachedGguf(
|
|
signal?: AbortSignal,
|
|
): Promise<CachedGgufRepo[]> {
|
|
const response = await authFetch("/api/hub/cached-gguf", { signal });
|
|
const data = await parseJsonOrThrow<{ cached: CachedGgufRepo[] }>(response);
|
|
return data.cached;
|
|
}
|
|
|
|
export interface CachedModelRepo {
|
|
repo_id: string;
|
|
load_id?: string | null;
|
|
size_bytes: number;
|
|
/** Epoch seconds of the newest downloaded weight file; sorts Downloaded
|
|
* newest-first. Optional for older-backend compatibility. */
|
|
last_modified?: number;
|
|
/** Owning cache dir; sent so a delete targets this copy, not the active
|
|
* cache. Optional for older-backend compatibility. */
|
|
cache_path?: string | null;
|
|
}
|
|
|
|
export async function listCachedModels(
|
|
hfToken?: string | null,
|
|
signal?: AbortSignal,
|
|
): Promise<CachedModelRepo[]> {
|
|
const response = await authFetch("/api/hub/cached-models", {
|
|
headers: hubTokenHeader(hfToken),
|
|
signal,
|
|
});
|
|
const data = await parseJsonOrThrow<{ cached: CachedModelRepo[] }>(response);
|
|
return data.cached;
|
|
}
|
|
|
|
export interface CachedModelPath {
|
|
path: string;
|
|
is_dir: boolean;
|
|
}
|
|
|
|
/** Absolute on-disk path of a cached repo or one of its GGUF variants. */
|
|
export async function getCachedModelPath(
|
|
repoId: string,
|
|
variant?: string,
|
|
): Promise<CachedModelPath> {
|
|
const params = new URLSearchParams({ repo_id: repoId });
|
|
if (variant) params.set("variant", variant);
|
|
const response = await authFetch(
|
|
`/api/models/cached-model-path?${params.toString()}`,
|
|
);
|
|
return parseJsonOrThrow<CachedModelPath>(response);
|
|
}
|
|
|
|
/** Reveal a cached repo (or one GGUF variant's file) in the OS file manager. */
|
|
export async function revealCachedModel(
|
|
repoId: string,
|
|
variant?: string,
|
|
): Promise<void> {
|
|
const payload: Record<string, string> = { repo_id: repoId };
|
|
if (variant) payload.variant = variant;
|
|
const response = await authFetch("/api/models/reveal-cached-model", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
await parseJsonOrThrow<unknown>(response);
|
|
}
|
|
|
|
export async function deleteFineTunedModel(args: {
|
|
modelPath: string;
|
|
source: "training" | "exported";
|
|
exportType?: "lora" | "merged" | "gguf";
|
|
ggufVariant?: string;
|
|
}): Promise<void> {
|
|
const response = await authFetch("/api/models/delete-finetuned", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model_path: args.modelPath,
|
|
source: args.source,
|
|
export_type: args.exportType ?? null,
|
|
gguf_variant: args.ggufVariant ?? null,
|
|
}),
|
|
});
|
|
await parseJsonOrThrow<unknown>(response);
|
|
}
|
|
|
|
export interface ScanFolderInfo {
|
|
id: number;
|
|
path: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export async function listScanFolders(): Promise<ScanFolderInfo[]> {
|
|
const response = await authFetch("/api/models/scan-folders");
|
|
const data = await parseJsonOrThrow<{ folders: ScanFolderInfo[] }>(response);
|
|
return data.folders;
|
|
}
|
|
|
|
export async function addScanFolder(path: string): Promise<ScanFolderInfo> {
|
|
const response = await authFetch("/api/models/scan-folders", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path }),
|
|
});
|
|
return parseJsonOrThrow<ScanFolderInfo>(response);
|
|
}
|
|
|
|
export async function removeScanFolder(id: number): Promise<void> {
|
|
const response = await authFetch(`/api/models/scan-folders/${id}`, {
|
|
method: "DELETE",
|
|
});
|
|
await parseJsonOrThrow<unknown>(response);
|
|
}
|
|
|
|
export async function listChatThreads(
|
|
args: {
|
|
modelType?: ModelType;
|
|
pairId?: string;
|
|
projectId?: string | null;
|
|
includeArchived?: boolean;
|
|
} = {},
|
|
): Promise<ThreadRecord[]> {
|
|
const params = new URLSearchParams();
|
|
if (args.modelType) params.set("model_type", args.modelType);
|
|
if (args.pairId) params.set("pair_id", args.pairId);
|
|
if (args.projectId) params.set("project_id", args.projectId);
|
|
if (args.includeArchived !== undefined) {
|
|
params.set("include_archived", String(args.includeArchived));
|
|
}
|
|
const qs = params.toString();
|
|
const response = await authFetch(`/api/chat/threads${qs ? `?${qs}` : ""}`);
|
|
const data = await parseJsonOrThrow<{ threads: ThreadRecord[] }>(response);
|
|
// Always hand back an array: an older or misbehaving backend may omit the
|
|
// field or send a non-array, which would crash list consumers.
|
|
return Array.isArray(data.threads) ? data.threads : [];
|
|
}
|
|
|
|
/** One chat message attachment, as listed for the settings uploaded-files view. */
|
|
export interface ChatAttachmentRecord {
|
|
id: string;
|
|
messageId: string;
|
|
threadId: string;
|
|
pairId?: string | null;
|
|
threadTitle?: string | null;
|
|
name: string;
|
|
type?: string | null;
|
|
contentType?: string | null;
|
|
sizeBytes?: number | null;
|
|
createdAt?: number | null;
|
|
}
|
|
|
|
export interface ChatAttachmentPage {
|
|
attachments: ChatAttachmentRecord[];
|
|
nextOffset: number | null;
|
|
}
|
|
|
|
export async function listChatAttachments(
|
|
offset = 0,
|
|
limit = 50,
|
|
): Promise<ChatAttachmentPage> {
|
|
const params = new URLSearchParams({
|
|
limit: String(limit),
|
|
offset: String(offset),
|
|
});
|
|
const response = await authFetch(`/api/chat/attachments?${params}`);
|
|
const data = await parseJsonOrThrow<{
|
|
attachments: ChatAttachmentRecord[];
|
|
nextOffset: number | null;
|
|
}>(response);
|
|
return {
|
|
attachments: Array.isArray(data.attachments) ? data.attachments : [],
|
|
nextOffset:
|
|
typeof data.nextOffset === "number" && Number.isFinite(data.nextOffset)
|
|
? data.nextOffset
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/** Stored attachment content (image bytes or extracted text) as a Blob. */
|
|
export async function fetchChatAttachmentBlob(
|
|
messageId: string,
|
|
attachmentId: string,
|
|
): Promise<Blob> {
|
|
const response = await authFetch(
|
|
`/api/chat/attachments/${encodeURIComponent(messageId)}/${encodeURIComponent(attachmentId)}/file`,
|
|
);
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
throw new Error(parseErrorText(response.status, body));
|
|
}
|
|
return response.blob();
|
|
}
|
|
|
|
export async function deleteChatAttachment(
|
|
messageId: string,
|
|
attachmentId: string,
|
|
): Promise<void> {
|
|
const response = await authFetch(
|
|
`/api/chat/attachments/${encodeURIComponent(messageId)}/${encodeURIComponent(attachmentId)}`,
|
|
{ method: "DELETE" },
|
|
);
|
|
await parseJsonOrThrow<{ ok: boolean }>(response);
|
|
}
|
|
|
|
export async function getChatThread(
|
|
threadId: string,
|
|
): Promise<ThreadRecord | null> {
|
|
const response = await authFetch(
|
|
`/api/chat/threads/${encodeURIComponent(threadId)}`,
|
|
);
|
|
if (response.status === 404) return null;
|
|
return parseJsonOrThrow<ThreadRecord>(response);
|
|
}
|
|
|
|
export async function saveChatThread(
|
|
thread: ThreadRecord,
|
|
): Promise<ThreadRecord> {
|
|
const response = await authFetch("/api/chat/threads", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(thread),
|
|
});
|
|
const savedThread = await parseJsonOrThrow<ThreadRecord>(response);
|
|
notifyChatHistoryUpdated();
|
|
return savedThread;
|
|
}
|
|
|
|
export async function updateChatThread(
|
|
threadId: string,
|
|
patch: Partial<ThreadRecord>,
|
|
): Promise<ThreadRecord> {
|
|
const response = await authFetch(
|
|
`/api/chat/threads/${encodeURIComponent(threadId)}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(patch),
|
|
},
|
|
);
|
|
const thread = await parseJsonOrThrow<ThreadRecord>(response);
|
|
notifyChatHistoryUpdated();
|
|
return thread;
|
|
}
|
|
|
|
export interface ForkChatThreadResult {
|
|
thread: ThreadRecord;
|
|
messages: MessageRecord[];
|
|
containerSnapshotWarning: string | null;
|
|
}
|
|
|
|
export async function forkChatThread(
|
|
threadId: string,
|
|
args: { messageId: string; newThreadId: string; createdAt: number },
|
|
): Promise<ForkChatThreadResult> {
|
|
const response = await authFetch(
|
|
`/api/chat/threads/${encodeURIComponent(threadId)}/fork`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(args),
|
|
},
|
|
);
|
|
const data = await parseJsonOrThrow<{
|
|
thread: ThreadRecord;
|
|
messages: MessageRecord[];
|
|
containerSnapshotWarning: string | null;
|
|
}>(response);
|
|
notifyChatHistoryUpdated();
|
|
return data;
|
|
}
|
|
|
|
export async function getForkCount(
|
|
threadId: string,
|
|
messageId: string,
|
|
): Promise<number> {
|
|
const response = await authFetch(
|
|
`/api/chat/threads/${encodeURIComponent(threadId)}/messages/${encodeURIComponent(messageId)}/forks`,
|
|
);
|
|
if (response.status === 404) return 0;
|
|
const data = await parseJsonOrThrow<{ count: number }>(response);
|
|
return data.count;
|
|
}
|
|
|
|
export async function deleteChatThreads(threadIds: string[]): Promise<void> {
|
|
if (threadIds.length === 0) return;
|
|
const response = await authFetch("/api/chat/threads", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ids: threadIds }),
|
|
});
|
|
await parseJsonOrThrow<unknown>(response);
|
|
notifyChatHistoryUpdated();
|
|
}
|
|
|
|
export async function listChatProjects(
|
|
args: { includeArchived?: boolean } = {},
|
|
): Promise<ProjectRecord[]> {
|
|
const params = new URLSearchParams();
|
|
if (args.includeArchived !== undefined) {
|
|
params.set("include_archived", String(args.includeArchived));
|
|
}
|
|
const qs = params.toString();
|
|
const response = await authFetch(`/api/chat/projects${qs ? `?${qs}` : ""}`);
|
|
const data = await parseJsonOrThrow<{ projects: ProjectRecord[] }>(response);
|
|
// Always hand back an array: an older or misbehaving backend may omit the
|
|
// field or send a non-array, which would crash list consumers.
|
|
return Array.isArray(data.projects) ? data.projects : [];
|
|
}
|
|
|
|
export async function getChatProject(
|
|
projectId: string,
|
|
): Promise<ProjectRecord | null> {
|
|
const response = await authFetch(
|
|
`/api/chat/projects/${encodeURIComponent(projectId)}`,
|
|
);
|
|
if (response.status === 404) return null;
|
|
return parseJsonOrThrow<ProjectRecord>(response);
|
|
}
|
|
|
|
export async function saveChatProject(
|
|
project: ProjectRecord,
|
|
): Promise<ProjectRecord> {
|
|
const response = await authFetch("/api/chat/projects", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(project),
|
|
});
|
|
const saved = await parseJsonOrThrow<ProjectRecord>(response);
|
|
notifyChatProjectsUpdated();
|
|
return saved;
|
|
}
|
|
|
|
export async function updateChatProject(
|
|
projectId: string,
|
|
patch: Partial<ProjectRecord>,
|
|
): Promise<ProjectRecord> {
|
|
const response = await authFetch(
|
|
`/api/chat/projects/${encodeURIComponent(projectId)}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(patch),
|
|
},
|
|
);
|
|
const project = await parseJsonOrThrow<ProjectRecord>(response);
|
|
notifyChatProjectsUpdated();
|
|
return project;
|
|
}
|
|
|
|
export async function deleteChatProject(
|
|
projectId: string,
|
|
args: { deleteFiles?: boolean } = {},
|
|
): Promise<void> {
|
|
const params = new URLSearchParams();
|
|
if (args.deleteFiles) params.set("delete_files", "true");
|
|
const qs = params.toString();
|
|
const response = await authFetch(
|
|
`/api/chat/projects/${encodeURIComponent(projectId)}${qs ? `?${qs}` : ""}`,
|
|
{ method: "DELETE" },
|
|
);
|
|
await parseJsonOrThrow<ProjectRecord>(response);
|
|
notifyChatProjectsUpdated();
|
|
}
|
|
|
|
export async function listChatMessages(
|
|
threadId: string,
|
|
): Promise<MessageRecord[]> {
|
|
const response = await authFetch(
|
|
`/api/chat/threads/${encodeURIComponent(threadId)}/messages`,
|
|
);
|
|
if (response.status === 404) return [];
|
|
const data = await parseJsonOrThrow<{ messages: MessageRecord[] }>(response);
|
|
return data.messages;
|
|
}
|
|
|
|
/**
|
|
* Fetch messages for many threads in one HTTP call. Falls back to
|
|
* per-thread listChatMessages on 404/405 (older servers without the
|
|
* batch route).
|
|
*/
|
|
export async function batchListChatMessages(
|
|
threadIds: string[],
|
|
): Promise<Map<string, MessageRecord[]>> {
|
|
const out = new Map<string, MessageRecord[]>();
|
|
if (threadIds.length === 0) return out;
|
|
const response = await authFetch("/api/chat/messages:batch", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ threadIds }),
|
|
});
|
|
if (response.status === 404 || response.status === 405) {
|
|
// Older server: fall back to per-thread fetches.
|
|
const per = await Promise.all(
|
|
threadIds.map(async (id) => [id, await listChatMessages(id)] as const),
|
|
);
|
|
for (const [id, msgs] of per) out.set(id, msgs);
|
|
return out;
|
|
}
|
|
const data = await parseJsonOrThrow<{
|
|
messagesByThreadId: Record<string, MessageRecord[]>;
|
|
}>(response);
|
|
for (const id of threadIds) {
|
|
out.set(id, data.messagesByThreadId[id] ?? []);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function getChatMessage(
|
|
threadId: string,
|
|
messageId: string,
|
|
): Promise<MessageRecord | null> {
|
|
const response = await authFetch(
|
|
`/api/chat/threads/${encodeURIComponent(threadId)}/messages/${encodeURIComponent(messageId)}`,
|
|
);
|
|
if (response.status === 404) return null;
|
|
return parseJsonOrThrow<MessageRecord>(response);
|
|
}
|
|
|
|
export async function saveChatMessage(
|
|
message: MessageRecord,
|
|
): Promise<MessageRecord> {
|
|
const response = await authFetch(
|
|
`/api/chat/threads/${encodeURIComponent(message.threadId)}/messages/${encodeURIComponent(message.id)}`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(message),
|
|
},
|
|
);
|
|
const savedMessage = await parseJsonOrThrow<MessageRecord>(response);
|
|
notifyChatHistoryUpdated();
|
|
return savedMessage;
|
|
}
|
|
|
|
export async function syncChatMessages(
|
|
threadId: string,
|
|
messages: MessageRecord[],
|
|
options: { pruneMissing?: boolean } = {},
|
|
): Promise<MessageRecord[]> {
|
|
const response = await authFetch(
|
|
`/api/chat/threads/${encodeURIComponent(threadId)}/messages`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
messages,
|
|
pruneMissing: options.pruneMissing ?? false,
|
|
}),
|
|
},
|
|
);
|
|
const data = await parseJsonOrThrow<{ messages: MessageRecord[] }>(response);
|
|
notifyChatHistoryUpdated();
|
|
return data.messages;
|
|
}
|
|
|
|
export async function countBackendChats(): Promise<number> {
|
|
const response = await authFetch("/api/chat/count");
|
|
const data = await parseJsonOrThrow<{ count: number }>(response);
|
|
return data.count;
|
|
}
|
|
|
|
export async function clearBackendChats(
|
|
options: { notify?: boolean } = {},
|
|
): Promise<void> {
|
|
const response = await authFetch("/api/chat", { method: "DELETE" });
|
|
await parseJsonOrThrow<unknown>(response);
|
|
if (options.notify !== false) {
|
|
notifyChatHistoryUpdated();
|
|
}
|
|
}
|
|
|
|
export async function buildBackendChatExport(): Promise<{
|
|
exportedAt: string;
|
|
version: number;
|
|
threadCount: number;
|
|
projects?: ProjectRecord[];
|
|
threads: ThreadRecord[];
|
|
messages: MessageRecord[];
|
|
}> {
|
|
const response = await authFetch("/api/chat/export");
|
|
return parseJsonOrThrow(response);
|
|
}
|
|
|
|
// Legacy-Dexie import ledger: server-side source of truth replacing the
|
|
// boolean localStorage sentinel, so a studio.db wipe keeps the import
|
|
// recoverable.
|
|
export async function listChatImportLedger(): Promise<Set<string>> {
|
|
const response = await authFetch("/api/chat/import-ledger");
|
|
// Backends without this endpoint behave like an empty ledger -- caller
|
|
// re-imports every legacy thread. syncChatMessages UPSERTs prevent
|
|
// duplicates, so this fallback is safe.
|
|
if (response.status === 404 || response.status === 405) return new Set();
|
|
const data = await parseJsonOrThrow<{ threadIds: string[] }>(response);
|
|
return new Set(data.threadIds);
|
|
}
|
|
|
|
export interface RecordChatImportLedgerResult {
|
|
accepted: number;
|
|
inserted: number;
|
|
// false when the backend predates /api/chat/import-ledger (404/405/501) so
|
|
// the caller avoids poisoning the localStorage perf hint; next launch
|
|
// retries the (idempotent) import.
|
|
supported: boolean;
|
|
}
|
|
|
|
export async function recordChatImportLedger(
|
|
threadIds: string[],
|
|
): Promise<RecordChatImportLedgerResult> {
|
|
if (threadIds.length === 0) {
|
|
return { accepted: 0, inserted: 0, supported: true };
|
|
}
|
|
const response = await authFetch("/api/chat/import-ledger", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ threadIds }),
|
|
});
|
|
if (
|
|
response.status === 404 ||
|
|
response.status === 405 ||
|
|
response.status === 501
|
|
) {
|
|
return { accepted: 0, inserted: 0, supported: false };
|
|
}
|
|
const data = await parseJsonOrThrow<{ accepted: number; inserted: number }>(
|
|
response,
|
|
);
|
|
return {
|
|
accepted: data.accepted,
|
|
inserted: data.inserted,
|
|
supported: true,
|
|
};
|
|
}
|
|
|
|
export interface BrowseEntry {
|
|
name: string;
|
|
has_models: boolean;
|
|
hidden: boolean;
|
|
}
|
|
|
|
export interface BrowseFoldersResponse {
|
|
current: string;
|
|
parent: string | null;
|
|
entries: BrowseEntry[];
|
|
suggestions: string[];
|
|
truncated?: boolean;
|
|
model_files_here?: number;
|
|
}
|
|
|
|
export async function listRecommendedFolders(): Promise<string[]> {
|
|
const response = await authFetch("/api/models/recommended-folders");
|
|
const data = await parseJsonOrThrow<{ folders: string[] }>(response);
|
|
return data.folders;
|
|
}
|
|
|
|
export async function browseFolders(
|
|
path?: string,
|
|
showHidden = false,
|
|
signal?: AbortSignal,
|
|
): Promise<BrowseFoldersResponse> {
|
|
const params = new URLSearchParams();
|
|
if (path !== undefined && path !== null) params.set("path", path);
|
|
if (showHidden) params.set("show_hidden", "true");
|
|
const qs = params.toString();
|
|
// Forward the AbortSignal through authFetch -> fetch so a cancelled
|
|
// FolderBrowser navigation actually cancels the in-flight request
|
|
// server-side, instead of just dropping the response while the backend
|
|
// keeps walking large directory trees.
|
|
const response = await authFetch(
|
|
`/api/models/browse-folders${qs ? `?${qs}` : ""}`,
|
|
signal ? { signal } : undefined,
|
|
);
|
|
return parseJsonOrThrow<BrowseFoldersResponse>(response);
|
|
}
|
|
|
|
export async function listGgufVariants(
|
|
repoId: string,
|
|
hfToken?: string,
|
|
options?: {
|
|
preferLocalCache?: boolean;
|
|
localPath?: string | null;
|
|
},
|
|
): Promise<GgufVariantsResponse> {
|
|
const params = new URLSearchParams({ repo_id: repoId });
|
|
if (options?.preferLocalCache) {
|
|
params.set("prefer_local_cache", "true");
|
|
}
|
|
const localPath = options?.localPath?.trim();
|
|
if (localPath) {
|
|
params.set("local_path", localPath);
|
|
}
|
|
const response = await authFetch(`/api/models/gguf-variants?${params}`, {
|
|
headers: hubTokenHeader(hfToken),
|
|
});
|
|
return parseJsonOrThrow<GgufVariantsResponse>(response);
|
|
}
|
|
|
|
export interface KvCacheEstimate {
|
|
kv_bytes: number | null;
|
|
weights_bytes: number | null;
|
|
native_context: number | null;
|
|
}
|
|
|
|
/** Estimate KV cache + weight bytes for a downloaded quant at a context length,
|
|
* for the load dialog's memory warning. */
|
|
export async function estimateKvCache(
|
|
repoId: string,
|
|
quant: string,
|
|
nCtx: number,
|
|
cacheTypeKv?: string | null,
|
|
signal?: AbortSignal,
|
|
): Promise<KvCacheEstimate> {
|
|
const params = new URLSearchParams({
|
|
repo_id: repoId,
|
|
quant,
|
|
n_ctx: String(nCtx),
|
|
});
|
|
if (cacheTypeKv) params.set("cache_type_kv", cacheTypeKv);
|
|
const response = await authFetch(
|
|
`/api/models/kv-cache-estimate?${params}`,
|
|
signal ? { signal } : undefined,
|
|
);
|
|
return parseJsonOrThrow<KvCacheEstimate>(response);
|
|
}
|
|
|
|
function parseSseEvent(rawEvent: string): string[] {
|
|
const dataLines: string[] = [];
|
|
for (const line of rawEvent.split(/\r?\n/)) {
|
|
if (line.startsWith("data:")) {
|
|
dataLines.push(line.slice(5).trimStart());
|
|
}
|
|
}
|
|
return dataLines;
|
|
}
|
|
|
|
function hasNonWhitespaceText(value: unknown): boolean {
|
|
if (typeof value === "string") {
|
|
return value.trim().length > 0;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.some((item) => hasNonWhitespaceText(item));
|
|
}
|
|
if (!value || typeof value !== "object") {
|
|
return false;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
return ["thinking", "text", "content", "reasoning", "summary"].some(
|
|
(key) => key in record && hasNonWhitespaceText(record[key]),
|
|
);
|
|
}
|
|
|
|
function classifyStructuredDeltaContent(content: unknown): {
|
|
hasAssistantContent: boolean;
|
|
hasReasoningContent: boolean;
|
|
} {
|
|
if (typeof content === "string") {
|
|
return {
|
|
hasAssistantContent: hasNonWhitespaceText(content),
|
|
hasReasoningContent: false,
|
|
};
|
|
}
|
|
if (!Array.isArray(content)) {
|
|
return {
|
|
hasAssistantContent: false,
|
|
hasReasoningContent: false,
|
|
};
|
|
}
|
|
|
|
let hasAssistantContent = false;
|
|
let hasReasoningContent = false;
|
|
for (const part of content) {
|
|
if (typeof part === "string") {
|
|
hasAssistantContent ||= hasNonWhitespaceText(part);
|
|
continue;
|
|
}
|
|
if (!part || typeof part !== "object") {
|
|
continue;
|
|
}
|
|
const record = part as Record<string, unknown>;
|
|
if (record.type === "thinking" || record.type === "reasoning") {
|
|
hasReasoningContent ||= hasNonWhitespaceText(record);
|
|
} else if (record.type === "text" || record.type === "output_text") {
|
|
const text =
|
|
typeof record.text === "string" ? record.text : record.content;
|
|
hasAssistantContent ||= hasNonWhitespaceText(text);
|
|
}
|
|
}
|
|
return { hasAssistantContent, hasReasoningContent };
|
|
}
|
|
|
|
export async function* streamChatCompletions(
|
|
payload: OpenAIChatCompletionsRequest,
|
|
signal: AbortSignal,
|
|
): AsyncGenerator<OpenAIChatChunk> {
|
|
const response = await authFetch("/v1/chat/completions", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
throw new Error(parseErrorText(response.status, body));
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error("Stream response missing body");
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let completed = false;
|
|
// EOF without `[DONE]` or a finish_reason chunk means the stream was cut
|
|
// mid-generation: surface as interrupted, not silent success.
|
|
let sawTerminalSignal = false;
|
|
let terminalFinishReason: string | null = null;
|
|
let sawAssistantContent = false;
|
|
let sawReasoningContent = false;
|
|
|
|
const throwIfReasoningOnlyLength = () => {
|
|
if (
|
|
terminalFinishReason === "length" &&
|
|
sawReasoningContent &&
|
|
!sawAssistantContent
|
|
) {
|
|
throw new GenerationLengthError();
|
|
}
|
|
};
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
completed = true;
|
|
if (!sawTerminalSignal) {
|
|
throw new StreamInterruptedError();
|
|
}
|
|
throwIfReasoningOnlyLength();
|
|
break;
|
|
}
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
let separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
while (separatorIndex >= 0) {
|
|
const rawEvent = buffer.slice(0, separatorIndex);
|
|
const separatorLength = buffer[separatorIndex] === "\r" ? 4 : 2;
|
|
buffer = buffer.slice(separatorIndex + separatorLength);
|
|
|
|
const dataLines = parseSseEvent(rawEvent);
|
|
if (dataLines.length === 0) {
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
continue;
|
|
}
|
|
|
|
const dataText = dataLines.join("\n");
|
|
if (dataText === "[DONE]") {
|
|
completed = true;
|
|
sawTerminalSignal = true;
|
|
throwIfReasoningOnlyLength();
|
|
return;
|
|
}
|
|
|
|
const parsed = JSON.parse(dataText) as
|
|
| OpenAIChatChunk
|
|
| { type?: string; content?: string; error?: { message?: string } };
|
|
if ("error" in parsed && parsed.error) {
|
|
throw new Error(parsed.error.message || "Stream error");
|
|
}
|
|
// Tool status events are custom SSE payloads, not OpenAI chunks
|
|
if ("type" in parsed && parsed.type === "tool_status") {
|
|
yield {
|
|
_toolStatus: parsed.content ?? "",
|
|
} as unknown as OpenAIChatChunk;
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
continue;
|
|
}
|
|
// Diffusion frame: a per-step canvas snapshot. Custom SSE payload (not an OpenAI chunk) with
|
|
// no assistant text, surfaced as a transient marker for the in-place renderer, never the transcript.
|
|
if ("type" in parsed && parsed.type === "diffusion_frame") {
|
|
yield {
|
|
_diffusionFrame: parsed,
|
|
} as unknown as OpenAIChatChunk;
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
continue;
|
|
}
|
|
// tool_start/end carry full input/output; tool_output streams
|
|
// incremental stdout and tool_args streams the call arguments live.
|
|
if (
|
|
"type" in parsed &&
|
|
(parsed.type === "tool_start" ||
|
|
parsed.type === "tool_end" ||
|
|
parsed.type === "tool_output" ||
|
|
parsed.type === "tool_args")
|
|
) {
|
|
yield { _toolEvent: parsed } as unknown as OpenAIChatChunk;
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
continue;
|
|
}
|
|
// Relay server-side reasoning duration.
|
|
if (
|
|
parsed &&
|
|
typeof parsed === "object" &&
|
|
"type" in parsed &&
|
|
parsed.type === "reasoning_summary"
|
|
) {
|
|
yield {
|
|
_reasoningDurationMs: (parsed as { duration_ms?: number })
|
|
.duration_ms,
|
|
} as unknown as OpenAIChatChunk;
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
continue;
|
|
}
|
|
// finish_reason is a valid terminal signal for providers that close
|
|
// the stream without an explicit [DONE] sentinel.
|
|
const parsedChoices = (
|
|
parsed as {
|
|
choices?: Array<{
|
|
delta?: Record<string, unknown>;
|
|
finish_reason?: string | null;
|
|
}>;
|
|
}
|
|
).choices;
|
|
for (const choice of parsedChoices ?? []) {
|
|
const delta = choice.delta;
|
|
if (delta) {
|
|
const contentState = classifyStructuredDeltaContent(delta.content);
|
|
sawAssistantContent ||= contentState.hasAssistantContent;
|
|
sawReasoningContent ||= contentState.hasReasoningContent;
|
|
const reasoning =
|
|
delta.reasoning_content ??
|
|
delta.reasoning ??
|
|
delta.reasoning_details;
|
|
sawReasoningContent ||= hasNonWhitespaceText(reasoning);
|
|
}
|
|
if (choice.finish_reason) {
|
|
terminalFinishReason = choice.finish_reason;
|
|
}
|
|
}
|
|
const finishReason = parsedChoices?.[0]?.finish_reason;
|
|
if (finishReason) {
|
|
sawTerminalSignal = true;
|
|
}
|
|
yield parsed as OpenAIChatChunk;
|
|
separatorIndex = buffer.search(/\r?\n\r?\n/);
|
|
}
|
|
}
|
|
} finally {
|
|
// Only abort on an early/abnormal exit. After a natural [DONE] (or server
|
|
// EOF) the request is logically complete and the backend finalizes its
|
|
// api-monitor entry right after the sentinel; cancelling here can be seen as
|
|
// a disconnect and mark a successful request as cancelled.
|
|
if (!completed) {
|
|
try {
|
|
await reader.cancel();
|
|
} catch {
|
|
// already closed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function generateAudio(
|
|
payload: OpenAIChatCompletionsRequest,
|
|
signal: AbortSignal,
|
|
): Promise<AudioGenerationResponse> {
|
|
const response = await authFetch("/api/inference/chat/completions", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...payload, stream: false }),
|
|
signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
throw new Error(parseErrorText(response.status, body));
|
|
}
|
|
|
|
return (await response.json()) as AudioGenerationResponse;
|
|
}
|