server: real-time reasoning interruption via control endpoint (#23971)
* server: real-time reasoning interruption via control endpoint Builds on the manual reasoning budget trigger from #23949. Adds a CONTROL task that mirrors the CANCEL path on the live slot and calls common_sampler_reasoning_budget_force to end thinking mid-generation. POST /v1/chat/completions/control with { id_slot, action }, opt-in reasoning_control arms the budget sampler on demand. Router and single model. Minimal WebUI button as a skeleton for further UI work. * ui: track reasoning phase via explicit streaming state Add isReasoning to the chat store, mirroring the isLoading pattern: per conversation map, private setter, public accessor and reactive export. Set from the stream callbacks, true on reasoning chunks, false on the first content chunk, reset on stream end and resynced on conversation switch. The skip button now keys off isReasoning so it shows only during the thinking phase, not the whole generation. * ui: extract control endpoint and action into constants Move the chat completion routes, the slots route and the reasoning control action out of chat.service into api-endpoints and a dedicated control-actions module. No behavior change, drops the magic strings so the control protocol has a single source of truth. * server: target reasoning control by completion id Address @ngxson review on the control endpoint. Switch from id_slot to the chat completion id to avoid a TOCTOU: the slot can be reassigned between the lookup and the control request, so matching the live completion (oaicompat_cmpl_id) is safe and a finished one simply matches nothing. Rename the action to reasoning_end, guard it on the reasoning_control flag of the target slot, and reduce the response to {success} with an optional message. * ui: target reasoning control by completion id Keep the streamed completion id on the message and post it back to the control endpoint instead of probing /slots. Drops the slot discovery and the TOCTOU that came with it. Action renamed to reasoning_end, response read as {success}. * server: address review from @ngxson Move the control fields into task_params and drop the redundant comments on the control path. * server: document the reasoning control endpoint * Update tools/ui/src/lib/types/database.d.ts Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * ui: rename cmplId to completionId Per @allozaur review, clearer name for the streamed completion id. * ui: wire completion id capture through the agentic flow The webui streams through the agentic flow, which relayed onModel but not onCompletionId, so the completion id never reached the message and the control request was never sent. Relay it through the flow and its callbacks type, declare id on the chunk type, and log an explicit error when the button fires without a usable id. * ui: target reasoning control model from the message The model is a property of the completion, so read it from the streaming message like the id, not from the model dropdown which is unrelated UI state. Makes the request self-consistent by construction instead of just unlikely to drift. --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
This commit is contained in:
co-authored by
Aleksander Grygier
parent
1fd5f48037
commit
354ebac8cb
@@ -6,7 +6,10 @@ import {
|
||||
ATTACHMENT_LABEL_MCP_PROMPT,
|
||||
ATTACHMENT_LABEL_MCP_RESOURCE,
|
||||
LEGACY_AGENTIC_REGEX,
|
||||
SETTINGS_KEYS
|
||||
SETTINGS_KEYS,
|
||||
API_CHAT,
|
||||
API_SLOTS,
|
||||
CONTROL_ACTION
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
AttachmentType,
|
||||
@@ -126,6 +129,7 @@ export class ChatService {
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onModel,
|
||||
onCompletionId,
|
||||
onTimings,
|
||||
// Tools for function calling
|
||||
tools,
|
||||
@@ -239,6 +243,9 @@ export class ChatService {
|
||||
? ReasoningFormat.NONE
|
||||
: ReasoningFormat.AUTO;
|
||||
|
||||
// arms the budget sampler so reasoning can be ended at runtime via the control endpoint
|
||||
requestBody.reasoning_control = true;
|
||||
|
||||
if (continueFinalMessage) {
|
||||
requestBody.continue_final_message = true;
|
||||
requestBody.add_generation_prompt = false;
|
||||
@@ -289,7 +296,7 @@ export class ChatService {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`./v1/chat/completions`, {
|
||||
const response = await fetch(API_CHAT.COMPLETIONS, {
|
||||
method: 'POST',
|
||||
headers: getJsonHeaders(),
|
||||
body: JSON.stringify(requestBody),
|
||||
@@ -315,6 +322,7 @@ export class ChatService {
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onModel,
|
||||
onCompletionId,
|
||||
onTimings,
|
||||
conversationId,
|
||||
signal
|
||||
@@ -379,7 +387,7 @@ export class ChatService {
|
||||
*/
|
||||
static async areAllSlotsIdle(model?: string | null, signal?: AbortSignal): Promise<boolean> {
|
||||
try {
|
||||
const url = model ? `./slots?model=${encodeURIComponent(model)}` : './slots';
|
||||
const url = model ? `${API_SLOTS.LIST}?model=${encodeURIComponent(model)}` : API_SLOTS.LIST;
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) return true;
|
||||
|
||||
@@ -390,6 +398,50 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the current reasoning block of a running completion, targeted by its
|
||||
* chat completion id (streamed back as `id`). Matching the completion rather
|
||||
* than a slot index avoids a TOCTOU: a finished completion simply matches
|
||||
* nothing server side. The model is carried so the router forwards to the
|
||||
* right child, single model ignores it. Returns true on success.
|
||||
*/
|
||||
static async stopReasoning(completionId: string, model?: string | null): Promise<boolean> {
|
||||
if (!completionId) {
|
||||
console.error(
|
||||
'stopReasoning: no completion id for the active message, cannot target the running completion'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
id: completionId,
|
||||
action: CONTROL_ACTION.END_REASONING
|
||||
};
|
||||
if (model) body.model = model;
|
||||
|
||||
try {
|
||||
const res = await fetch(API_CHAT.CONTROL, {
|
||||
method: 'POST',
|
||||
headers: getJsonHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || data?.success !== true) {
|
||||
console.error('stopReasoning: control request failed', {
|
||||
status: res.status,
|
||||
completionId,
|
||||
response: data
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('stopReasoning: control request threw', { completionId, error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a fire-and-forget request to pre-encode the conversation in the server's KV cache.
|
||||
* After a response completes, this re-submits the full conversation
|
||||
@@ -457,7 +509,7 @@ export class ChatService {
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`./v1/chat/completions`, {
|
||||
await fetch(API_CHAT.COMPLETIONS, {
|
||||
method: 'POST',
|
||||
headers: getJsonHeaders(),
|
||||
body: JSON.stringify(requestBody),
|
||||
@@ -502,6 +554,7 @@ export class ChatService {
|
||||
onReasoningChunk?: (chunk: string) => void,
|
||||
onToolCallChunk?: (chunk: string) => void,
|
||||
onModel?: (model: string) => void,
|
||||
onCompletionId?: (id: string) => void,
|
||||
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
|
||||
conversationId?: string,
|
||||
abortSignal?: AbortSignal
|
||||
@@ -519,6 +572,7 @@ export class ChatService {
|
||||
let lastTimings: ChatMessageTimings | undefined;
|
||||
let streamFinished = false;
|
||||
let modelEmitted = false;
|
||||
let idEmitted = false;
|
||||
let toolCallIndexOffset = 0;
|
||||
let hasOpenToolCallBatch = false;
|
||||
|
||||
@@ -603,6 +657,11 @@ export class ChatService {
|
||||
onModel?.(chunkModel);
|
||||
}
|
||||
|
||||
if (parsed.id && !idEmitted) {
|
||||
idEmitted = true;
|
||||
onCompletionId?.(parsed.id);
|
||||
}
|
||||
|
||||
if (promptProgress) {
|
||||
ChatService.notifyTimings(undefined, promptProgress, onTimings);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user