mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-26 09:38:32 -04:00
Compare commits
17 Commits
fix/galler
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
437f0fa193 | ||
|
|
aa743f8824 | ||
|
|
2162611dca | ||
|
|
4aad97971c | ||
|
|
e4c70fca7a | ||
|
|
4b398c9798 | ||
|
|
4a5219fa9c | ||
|
|
b5a620294e | ||
|
|
5d544a7868 | ||
|
|
87e01aa290 | ||
|
|
f17d99f6e5 | ||
|
|
597daa925b | ||
|
|
3e0612b8b4 | ||
|
|
de2ce74bea | ||
|
|
1c6c3adad6 | ||
|
|
c2cd3b9ada | ||
|
|
9ff270eb65 |
2
.github/workflows/stalebot.yml
vendored
2
.github/workflows/stalebot.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
if: github.repository == 'mudler/LocalAI'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v9
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v9
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 10 days.'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# ds4 backend Makefile.
|
||||
#
|
||||
# Upstream pin lives below as DS4_VERSION?=f91c12b50a1448527c435c028bfc70d1b00f6c33
|
||||
# Upstream pin lives below as DS4_VERSION?=ad0209f6a4b067574d2b4afe896c08c177156b31
|
||||
# (.github/bump_deps.sh) can find and update it - matches the
|
||||
# llama-cpp / ik-llama-cpp / turboquant convention.
|
||||
|
||||
DS4_VERSION?=f91c12b50a1448527c435c028bfc70d1b00f6c33
|
||||
DS4_VERSION?=ad0209f6a4b067574d2b4afe896c08c177156b31
|
||||
DS4_REPO?=https://github.com/antirez/ds4
|
||||
|
||||
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
IK_LLAMA_VERSION?=9f7ba245ab41e118f03aa8dd5134d18a81159d02
|
||||
IK_LLAMA_VERSION?=b4e1d916c5ec7e75ea3c124dd090425a99fc613f
|
||||
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=549b9d84330c327e6791fa812a7d60c0cf63572e
|
||||
LLAMA_VERSION?=35c9b1f39ebe5a7bb83986d64415a079218be78d
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -570,9 +570,11 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
|
||||
// kv_unified=false or cache_ram_mib=0, so flipping kv_unified above is
|
||||
// what actually unlocks it.
|
||||
params.cache_idle_slots = true;
|
||||
// checkpoint_every_nt: create a context checkpoint every N tokens during
|
||||
// prefill (-1 disables). Match upstream's default (8192).
|
||||
params.checkpoint_every_nt = 8192;
|
||||
// checkpoint_min_step: minimum spacing between context checkpoints in
|
||||
// tokens (0 disables the minimum). Match upstream's default (256). This
|
||||
// field was renamed from `checkpoint_every_nt` in llama.cpp; the semantics
|
||||
// also shifted from a fixed cadence to a minimum spacing.
|
||||
params.checkpoint_min_step = 256;
|
||||
|
||||
// decode options. Options are in form optname:optvale, or if booleans only optname.
|
||||
for (int i = 0; i < request->options_size(); i++) {
|
||||
@@ -746,14 +748,18 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
|
||||
params.cache_idle_slots = false;
|
||||
}
|
||||
|
||||
// --- prefill checkpoint cadence (upstream -cpent / --checkpoint-every-n-tokens) ---
|
||||
// -1 disables checkpointing during prefill.
|
||||
} else if (!strcmp(optname, "checkpoint_every_nt") || !strcmp(optname, "checkpoint_every_n_tokens")) {
|
||||
// --- minimum context-checkpoint spacing (upstream -cms / --checkpoint-min-step) ---
|
||||
// 0 disables the minimum-spacing gate. Old option names (`checkpoint_every_nt`,
|
||||
// `checkpoint_every_n_tokens`) are kept as aliases for backward compatibility
|
||||
// with existing user configs: upstream renamed the field and shifted its
|
||||
// semantics from a fixed cadence to a minimum spacing.
|
||||
} else if (!strcmp(optname, "checkpoint_min_step") || !strcmp(optname, "checkpoint_min_spacing") ||
|
||||
!strcmp(optname, "checkpoint_every_nt") || !strcmp(optname, "checkpoint_every_n_tokens")) {
|
||||
if (optval != NULL) {
|
||||
try {
|
||||
params.checkpoint_every_nt = std::stoi(optval_str);
|
||||
params.checkpoint_min_step = std::stoi(optval_str);
|
||||
} catch (const std::exception& e) {
|
||||
// If conversion fails, keep default value (8192)
|
||||
// If conversion fails, keep default value (256)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# stablediffusion.cpp (ggml)
|
||||
STABLEDIFFUSION_GGML_REPO?=https://github.com/leejet/stable-diffusion.cpp
|
||||
STABLEDIFFUSION_GGML_VERSION?=a397e03488cc27e1a42da646b82dfce9f50741c0
|
||||
STABLEDIFFUSION_GGML_VERSION?=1ceb5bd9df7784bcdf67dd9ed8bf0198b542ebc9
|
||||
|
||||
CMAKE_ARGS+=-DGGML_MAX_NAME=128
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <regex>
|
||||
#include <errno.h>
|
||||
#include <inttypes.h>
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
@@ -1075,9 +1076,71 @@ static uint8_t* load_and_resize_image(const char* path, int target_width, int ta
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Write sd.cpp's audio buffer to a temp WAV file (IEEE float, interleaved).
|
||||
// sd_audio_t.data is planar (all channel 0 samples, then channel 1, etc.) — we
|
||||
// interleave on the fly so ffmpeg's standard wav demuxer can read it directly.
|
||||
// Returns 0 on success and fills wav_path (must be at least 64 bytes).
|
||||
static int write_planar_float_wav(const sd_audio_t* a, char* wav_path, size_t wav_path_sz) {
|
||||
if (!a || !a->data || a->sample_count == 0 || a->channels == 0 || a->sample_rate == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(wav_path, wav_path_sz, "/tmp/gosd-audio-XXXXXX.wav");
|
||||
int fd = mkstemps(wav_path, 4);
|
||||
if (fd < 0) { perror("mkstemps wav"); return -1; }
|
||||
FILE* f = fdopen(fd, "wb");
|
||||
if (!f) { perror("fdopen wav"); close(fd); return -1; }
|
||||
|
||||
uint64_t frames = a->sample_count;
|
||||
uint32_t channels = a->channels;
|
||||
uint32_t sample_rate = a->sample_rate;
|
||||
uint64_t total_samples64 = frames * (uint64_t)channels;
|
||||
uint64_t data_bytes64 = total_samples64 * sizeof(float);
|
||||
if (data_bytes64 > 0xFFFFFFFFull - 44) {
|
||||
fprintf(stderr, "audio too large for 32-bit WAV (%" PRIu64 " bytes)\n", data_bytes64);
|
||||
fclose(f);
|
||||
unlink(wav_path);
|
||||
return -1;
|
||||
}
|
||||
uint32_t data_bytes = (uint32_t)data_bytes64;
|
||||
uint32_t riff_size = 36 + data_bytes;
|
||||
uint16_t fmt_code = 3; // WAVE_FORMAT_IEEE_FLOAT
|
||||
uint16_t bits_per_sample = 32;
|
||||
uint16_t block_align = (uint16_t)(channels * sizeof(float));
|
||||
uint32_t byte_rate = sample_rate * block_align;
|
||||
uint16_t ch16 = (uint16_t)channels;
|
||||
uint32_t fmt_size = 16;
|
||||
|
||||
fwrite("RIFF", 1, 4, f);
|
||||
fwrite(&riff_size, 4, 1, f);
|
||||
fwrite("WAVEfmt ", 1, 8, f);
|
||||
fwrite(&fmt_size, 4, 1, f);
|
||||
fwrite(&fmt_code, 2, 1, f);
|
||||
fwrite(&ch16, 2, 1, f);
|
||||
fwrite(&sample_rate, 4, 1, f);
|
||||
fwrite(&byte_rate, 4, 1, f);
|
||||
fwrite(&block_align, 2, 1, f);
|
||||
fwrite(&bits_per_sample, 2, 1, f);
|
||||
fwrite("data", 1, 4, f);
|
||||
fwrite(&data_bytes, 4, 1, f);
|
||||
|
||||
// Interleave planar [ch0_samples..., ch1_samples...] → [ch0_s0, ch1_s0, ...]
|
||||
for (uint64_t s = 0; s < frames; s++) {
|
||||
for (uint32_t c = 0; c < channels; c++) {
|
||||
float v = a->data[(size_t)c * frames + s];
|
||||
fwrite(&v, sizeof(float), 1, f);
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Pipe raw RGB/RGBA frames to ffmpeg stdin and let it produce an MP4 at dst.
|
||||
// Uses fork+execvp to avoid shell interpretation of dst.
|
||||
static int ffmpeg_mux_raw_to_mp4(sd_image_t* frames, int num_frames, int fps, const char* dst) {
|
||||
// Uses fork+execvp to avoid shell interpretation of dst. When `audio` is
|
||||
// non-null, the audio waveform is staged to a temp WAV and added as a second
|
||||
// ffmpeg input so the final MP4 contains both video and AAC audio.
|
||||
static int ffmpeg_mux_raw_to_mp4(sd_image_t* frames, int num_frames, int fps,
|
||||
const sd_audio_t* audio, const char* dst) {
|
||||
if (num_frames <= 0 || !frames || !frames[0].data) {
|
||||
fprintf(stderr, "ffmpeg_mux: empty frames\n");
|
||||
return 1;
|
||||
@@ -1092,38 +1155,87 @@ static int ffmpeg_mux_raw_to_mp4(sd_image_t* frames, int num_frames, int fps, co
|
||||
snprintf(size_str, sizeof(size_str), "%dx%d", width, height);
|
||||
snprintf(fps_str, sizeof(fps_str), "%d", fps);
|
||||
|
||||
// Optional audio: write a temp WAV file if the model produced audio.
|
||||
char wav_path[64] = {0};
|
||||
bool have_audio = false;
|
||||
if (audio && audio->data && audio->sample_count > 0 && audio->channels > 0 && audio->sample_rate > 0) {
|
||||
if (write_planar_float_wav(audio, wav_path, sizeof(wav_path)) == 0) {
|
||||
have_audio = true;
|
||||
fprintf(stderr, "ffmpeg_mux: audio %u Hz × %u ch × %" PRIu64 " frames → %s\n",
|
||||
audio->sample_rate, audio->channels, audio->sample_count, wav_path);
|
||||
} else {
|
||||
fprintf(stderr, "ffmpeg_mux: failed to stage audio; producing silent video\n");
|
||||
}
|
||||
}
|
||||
|
||||
int pipefd[2];
|
||||
if (pipe(pipefd) != 0) { perror("pipe"); return 1; }
|
||||
if (pipe(pipefd) != 0) {
|
||||
perror("pipe");
|
||||
if (have_audio) unlink(wav_path);
|
||||
return 1;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) { perror("fork"); close(pipefd[0]); close(pipefd[1]); return 1; }
|
||||
if (pid < 0) {
|
||||
perror("fork");
|
||||
close(pipefd[0]); close(pipefd[1]);
|
||||
if (have_audio) unlink(wav_path);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
// child
|
||||
close(pipefd[1]);
|
||||
if (dup2(pipefd[0], STDIN_FILENO) < 0) { perror("dup2"); _exit(127); }
|
||||
close(pipefd[0]);
|
||||
std::vector<char*> argv = {
|
||||
const_cast<char*>("ffmpeg"),
|
||||
const_cast<char*>("-y"),
|
||||
const_cast<char*>("-hide_banner"),
|
||||
const_cast<char*>("-loglevel"), const_cast<char*>("warning"),
|
||||
const_cast<char*>("-f"), const_cast<char*>("rawvideo"),
|
||||
const_cast<char*>("-pix_fmt"), const_cast<char*>(pix_fmt_in),
|
||||
const_cast<char*>("-s"), size_str,
|
||||
const_cast<char*>("-framerate"), fps_str,
|
||||
const_cast<char*>("-i"), const_cast<char*>("-"),
|
||||
const_cast<char*>("-c:v"), const_cast<char*>("libx264"),
|
||||
const_cast<char*>("-pix_fmt"), const_cast<char*>("yuv420p"),
|
||||
const_cast<char*>("-movflags"), const_cast<char*>("+faststart"),
|
||||
// Force MP4 container. Distributed LocalAI hands us a staging
|
||||
// path (e.g. /staging/localai-output-NNN.tmp) with a non-standard
|
||||
// extension; relying on filename suffix makes ffmpeg bail with
|
||||
// "Unable to choose an output format".
|
||||
const_cast<char*>("-f"), const_cast<char*>("mp4"),
|
||||
const_cast<char*>(dst),
|
||||
nullptr
|
||||
};
|
||||
std::vector<char*> argv;
|
||||
argv.push_back(const_cast<char*>("ffmpeg"));
|
||||
argv.push_back(const_cast<char*>("-y"));
|
||||
argv.push_back(const_cast<char*>("-hide_banner"));
|
||||
argv.push_back(const_cast<char*>("-loglevel"));
|
||||
argv.push_back(const_cast<char*>("warning"));
|
||||
// Input 0: raw video from stdin
|
||||
argv.push_back(const_cast<char*>("-f"));
|
||||
argv.push_back(const_cast<char*>("rawvideo"));
|
||||
argv.push_back(const_cast<char*>("-pix_fmt"));
|
||||
argv.push_back(const_cast<char*>(pix_fmt_in));
|
||||
argv.push_back(const_cast<char*>("-s"));
|
||||
argv.push_back(size_str);
|
||||
argv.push_back(const_cast<char*>("-framerate"));
|
||||
argv.push_back(fps_str);
|
||||
argv.push_back(const_cast<char*>("-i"));
|
||||
argv.push_back(const_cast<char*>("-"));
|
||||
// Input 1: optional audio WAV
|
||||
if (have_audio) {
|
||||
argv.push_back(const_cast<char*>("-i"));
|
||||
argv.push_back(wav_path);
|
||||
argv.push_back(const_cast<char*>("-map"));
|
||||
argv.push_back(const_cast<char*>("0:v:0"));
|
||||
argv.push_back(const_cast<char*>("-map"));
|
||||
argv.push_back(const_cast<char*>("1:a:0"));
|
||||
argv.push_back(const_cast<char*>("-c:a"));
|
||||
argv.push_back(const_cast<char*>("aac"));
|
||||
argv.push_back(const_cast<char*>("-b:a"));
|
||||
argv.push_back(const_cast<char*>("192k"));
|
||||
// -shortest so the final clip ends with the shorter of the two
|
||||
// streams — guards against an audio buffer that overshoots the
|
||||
// video duration (or vice versa) on certain LTX variants.
|
||||
argv.push_back(const_cast<char*>("-shortest"));
|
||||
}
|
||||
argv.push_back(const_cast<char*>("-c:v"));
|
||||
argv.push_back(const_cast<char*>("libx264"));
|
||||
argv.push_back(const_cast<char*>("-pix_fmt"));
|
||||
argv.push_back(const_cast<char*>("yuv420p"));
|
||||
argv.push_back(const_cast<char*>("-movflags"));
|
||||
argv.push_back(const_cast<char*>("+faststart"));
|
||||
// Force MP4 container. Distributed LocalAI hands us a staging
|
||||
// path (e.g. /staging/localai-output-NNN.tmp) with a non-standard
|
||||
// extension; relying on filename suffix makes ffmpeg bail with
|
||||
// "Unable to choose an output format".
|
||||
argv.push_back(const_cast<char*>("-f"));
|
||||
argv.push_back(const_cast<char*>("mp4"));
|
||||
argv.push_back(const_cast<char*>(dst));
|
||||
argv.push_back(nullptr);
|
||||
execvp(argv[0], argv.data());
|
||||
perror("execvp ffmpeg");
|
||||
_exit(127);
|
||||
@@ -1148,6 +1260,7 @@ static int ffmpeg_mux_raw_to_mp4(sd_image_t* frames, int num_frames, int fps, co
|
||||
close(pipefd[1]);
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
if (have_audio) unlink(wav_path);
|
||||
return 1;
|
||||
}
|
||||
p += n;
|
||||
@@ -1158,8 +1271,13 @@ static int ffmpeg_mux_raw_to_mp4(sd_image_t* frames, int num_frames, int fps, co
|
||||
|
||||
int status = 0;
|
||||
while (waitpid(pid, &status, 0) < 0) {
|
||||
if (errno != EINTR) { perror("waitpid"); return 1; }
|
||||
if (errno != EINTR) {
|
||||
perror("waitpid");
|
||||
if (have_audio) unlink(wav_path);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (have_audio) unlink(wav_path);
|
||||
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
||||
fprintf(stderr, "ffmpeg exited with status %d\n", status);
|
||||
return 1;
|
||||
@@ -1234,7 +1352,7 @@ int gen_video(sd_vid_gen_params_t *p, int steps, char *dst, float cfg_scale, int
|
||||
|
||||
fprintf(stderr, "Generated %d frames, muxing to %s via ffmpeg\n", num_frames_out, dst);
|
||||
|
||||
int rc = ffmpeg_mux_raw_to_mp4(frames, num_frames_out, fps, dst);
|
||||
int rc = ffmpeg_mux_raw_to_mp4(frames, num_frames_out, fps, audio, dst);
|
||||
|
||||
for (int i = 0; i < num_frames_out; i++) {
|
||||
if (frames[i].data) free(frames[i].data);
|
||||
|
||||
@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
|
||||
|
||||
# whisper.cpp version
|
||||
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
|
||||
WHISPER_CPP_VERSION?=0ccd896f5b882628e1c077f9769735ef4ce52860
|
||||
WHISPER_CPP_VERSION?=e0fd1f6787a5bd4a4957dd97c5b64df882ee7b0c
|
||||
SO_TARGET?=libgowhisper.so
|
||||
|
||||
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
|
||||
|
||||
@@ -68,6 +68,57 @@ func mergeToolCallDeltas(existing []schema.ToolCall, deltas []schema.ToolCall) [
|
||||
return existing
|
||||
}
|
||||
|
||||
// applyAutoparserOverride replaces the Go-side reasoning-extraction result with
|
||||
// the C++ autoparser's classified ChatDeltas when those deltas contain
|
||||
// actionable content or reasoning. It preserves the original logprobs.
|
||||
//
|
||||
// When the autoparser did not classify any reasoning (deltaReasoning == "") but
|
||||
// deltaContent still carries an unparsed reasoning tag pair (e.g. the
|
||||
// non-jinja "pure content" fallback path on a <think> model — issue #9985),
|
||||
// the Go-side reasoning extractor is run on deltaContent as a defensive
|
||||
// fallback so <think>…</think> blocks do not leak into the OpenAI `content`
|
||||
// field.
|
||||
func applyAutoparserOverride(
|
||||
chatDeltas []*pb.ChatDelta,
|
||||
thinkingStartToken string,
|
||||
reasoningConfig reason.Config,
|
||||
existing []schema.Choice,
|
||||
) []schema.Choice {
|
||||
if len(chatDeltas) == 0 {
|
||||
return existing
|
||||
}
|
||||
deltaContent := functions.ContentFromChatDeltas(chatDeltas)
|
||||
deltaReasoning := functions.ReasoningFromChatDeltas(chatDeltas)
|
||||
if deltaContent == "" && deltaReasoning == "" {
|
||||
return existing
|
||||
}
|
||||
// Fallback for non-jinja models (issue #9985): when the C++ autoparser
|
||||
// did not classify reasoning but the raw content still contains a known
|
||||
// reasoning tag pair, run Go-side extraction on the content so that the
|
||||
// <think>…</think> block does not leak into the OpenAI `content` field.
|
||||
// When the autoparser DID populate ReasoningContent, leave its
|
||||
// content/reasoning split alone — trust the parser. We replace
|
||||
// deltaContent unconditionally because ExtractReasoningWithConfig is a
|
||||
// no-op when no tag pair matches; this also strips empty thinking
|
||||
// blocks like "<think></think>" that some models emit when reasoning
|
||||
// is disabled.
|
||||
if deltaReasoning == "" && deltaContent != "" {
|
||||
deltaReasoning, deltaContent = reason.ExtractReasoningWithConfig(deltaContent, thinkingStartToken, reasoningConfig)
|
||||
}
|
||||
xlog.Debug("[ChatDeltas] non-SSE no-tools: overriding result with C++ autoparser deltas",
|
||||
"content_len", len(deltaContent), "reasoning_len", len(deltaReasoning))
|
||||
stopReason := FinishReasonStop
|
||||
message := &schema.Message{Role: "assistant", Content: &deltaContent}
|
||||
if deltaReasoning != "" {
|
||||
message.Reasoning = &deltaReasoning
|
||||
}
|
||||
newChoice := schema.Choice{FinishReason: &stopReason, Index: 0, Message: message}
|
||||
if len(existing) > 0 && existing[0].Logprobs != nil {
|
||||
newChoice.Logprobs = existing[0].Logprobs
|
||||
}
|
||||
return []schema.Choice{newChoice}
|
||||
}
|
||||
|
||||
// ChatEndpoint is the OpenAI Completion API endpoint https://platform.openai.com/docs/api-reference/chat/create
|
||||
// @Summary Generate a chat completions for a given prompt and model.
|
||||
// @Tags inference
|
||||
@@ -757,24 +808,8 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
|
||||
// For non-tool requests: prefer C++ autoparser chat deltas over
|
||||
// Go-side tag extraction (which can mangle output when thinkingStartToken
|
||||
// differs from the model's actual reasoning tags, e.g. Gemma 4).
|
||||
if !shouldUseFn && len(chatDeltas) > 0 {
|
||||
deltaContent := functions.ContentFromChatDeltas(chatDeltas)
|
||||
deltaReasoning := functions.ReasoningFromChatDeltas(chatDeltas)
|
||||
if deltaContent != "" || deltaReasoning != "" {
|
||||
xlog.Debug("[ChatDeltas] non-SSE no-tools: overriding result with C++ autoparser deltas",
|
||||
"content_len", len(deltaContent), "reasoning_len", len(deltaReasoning))
|
||||
stopReason := FinishReasonStop
|
||||
message := &schema.Message{Role: "assistant", Content: &deltaContent}
|
||||
if deltaReasoning != "" {
|
||||
message.Reasoning = &deltaReasoning
|
||||
}
|
||||
newChoice := schema.Choice{FinishReason: &stopReason, Index: 0, Message: message}
|
||||
// Preserve logprobs from the original result
|
||||
if len(result) > 0 && result[0].Logprobs != nil {
|
||||
newChoice.Logprobs = result[0].Logprobs
|
||||
}
|
||||
result = []schema.Choice{newChoice}
|
||||
}
|
||||
if !shouldUseFn {
|
||||
result = applyAutoparserOverride(chatDeltas, thinkingStartToken, config.ReasoningConfig, result)
|
||||
}
|
||||
|
||||
// Tool parsing is deferred here (only when shouldUseFn) so chat deltas are available
|
||||
|
||||
99
core/http/endpoints/openai/chat_stream_reasoning_test.go
Normal file
99
core/http/endpoints/openai/chat_stream_reasoning_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
reason "github.com/mudler/LocalAI/pkg/reasoning"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Regression test for the prefill-misclassification artifact surfaced in
|
||||
// the review of #9991: when LocalAI templates qwen3 with
|
||||
// use_tokenizer_template (the post-#9985 gallery shape),
|
||||
// DetectThinkingStartToken finds <think> in the model's jinja chat
|
||||
// template — without evaluating the surrounding {% if enable_thinking %}
|
||||
// guard — and the Go-side extractor's PrependThinkingTokenIfNeeded then
|
||||
// treats every non-thinking output token as reasoning. The autoparser does
|
||||
// not classify qwen3's tool calls into ChatDelta.ToolCalls (qwen3's tool
|
||||
// format isn't on llama.cpp's recognized-tool list), so all tokens land in
|
||||
// ChatDelta.Content while the Go-side extractor silently accumulates a
|
||||
// "reasoning" string equal to the raw tool-call JSON. End-of-stream this
|
||||
// is flushed as a trailing `delta.reasoning` chunk to the client.
|
||||
//
|
||||
// chooseDeferredReasoning is the gate: when the autoparser was active for
|
||||
// any chunk (preferAutoparser sticky), we trust its reasoning_content
|
||||
// classification (usually empty) instead of the polluted Go-side state.
|
||||
var _ = Describe("chooseDeferredReasoning", func() {
|
||||
// Simulate the qwen3-after-#9985 misclassification: build a real
|
||||
// extractor with a <think> thinking-start token, then feed it
|
||||
// non-thinking content. The extractor will (correctly per its own
|
||||
// contract) treat the content as reasoning because
|
||||
// PrependThinkingTokenIfNeeded synthesizes a leading <think>.
|
||||
pollutedExtractor := func(content string) *reason.ReasoningExtractor {
|
||||
e := reason.NewReasoningExtractor("<think>", reason.Config{})
|
||||
e.ProcessToken(content)
|
||||
Expect(e.Reasoning()).To(Equal(content),
|
||||
"sanity: when the thinking-start token is set and content has no real <think>...</think>, "+
|
||||
"the extractor classifies all content as reasoning — this is exactly the prefill pollution "+
|
||||
"we want chooseDeferredReasoning to guard against")
|
||||
return e
|
||||
}
|
||||
|
||||
Context("autoparser was active (preferAutoparser=true)", func() {
|
||||
It("returns the autoparser's reasoning classification, ignoring the polluted Go-side state", func() {
|
||||
toolCallJSON := `{"arguments": {"cmd": "echo hello"}, "name": "exec"}`
|
||||
extractor := pollutedExtractor(toolCallJSON)
|
||||
// What the C++ autoparser sent: content chunks but no
|
||||
// reasoning_content (qwen3 tool calls aren't classified by
|
||||
// the upstream PEG parser).
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: toolCallJSON, ReasoningContent: ""},
|
||||
}
|
||||
|
||||
got := chooseDeferredReasoning(true, chatDeltas, extractor)
|
||||
|
||||
Expect(got).To(BeEmpty(),
|
||||
"chooseDeferredReasoning must NOT return the polluted extractor state "+
|
||||
"when the autoparser was active — the autoparser correctly classified zero reasoning")
|
||||
})
|
||||
|
||||
It("returns the autoparser's reasoning when it actually did classify reasoning", func() {
|
||||
// The other side of the contract: when the autoparser was
|
||||
// in jinja-with-recognized-format mode and DID classify
|
||||
// reasoning, pass that through verbatim.
|
||||
actualReasoning := "Okay, the user asked X. I should call exec."
|
||||
extractor := pollutedExtractor("ignored polluted state")
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: "", ReasoningContent: actualReasoning},
|
||||
}
|
||||
|
||||
got := chooseDeferredReasoning(true, chatDeltas, extractor)
|
||||
|
||||
Expect(got).To(Equal(actualReasoning))
|
||||
})
|
||||
})
|
||||
|
||||
Context("autoparser was NOT active (preferAutoparser=false)", func() {
|
||||
It("falls back to the Go-side extractor — the right source for vLLM and other autoparser-less backends", func() {
|
||||
realReasoning := "Genuine reasoning from a backend without an autoparser"
|
||||
extractor := reason.NewReasoningExtractor("<think>", reason.Config{})
|
||||
extractor.ProcessToken("<think>" + realReasoning + "</think>final answer")
|
||||
|
||||
got := chooseDeferredReasoning(false, nil, extractor)
|
||||
|
||||
Expect(got).To(Equal(realReasoning))
|
||||
})
|
||||
|
||||
It("falls back even when ChatDeltas are present but the autoparser never classified anything", func() {
|
||||
// Defensive: chatDeltas could carry vestigial data; if
|
||||
// preferAutoparser wasn't flipped, we still use the
|
||||
// extractor.
|
||||
extractor := reason.NewReasoningExtractor("", reason.Config{})
|
||||
extractor.ProcessToken("<think>some thoughts</think>answer")
|
||||
|
||||
got := chooseDeferredReasoning(false, []*pb.ChatDelta{{Content: "answer"}}, extractor)
|
||||
|
||||
Expect(got).To(Equal("some thoughts"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,11 +7,111 @@ import (
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
"github.com/mudler/LocalAI/pkg/functions"
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
reason "github.com/mudler/LocalAI/pkg/reasoning"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// emitJSONToolCallDeltas iterates the JSON tool-call objects produced by the
|
||||
// streaming tool-call detector and emits SSE chunks for the ones the caller
|
||||
// hasn't already emitted. It returns the new lastEmittedCount.
|
||||
//
|
||||
// Semantics:
|
||||
// - Skips entries before lastEmittedCount (already emitted).
|
||||
// - Emits one tool_call chunk per consecutive entry that has a usable
|
||||
// `name` string.
|
||||
// - Stops at the first entry without a name (typically the partial-JSON
|
||||
// tail or a healing-marker stub — see issue #9988) so the caller doesn't
|
||||
// advance past it. Bumping lastEmittedCount past an unparsed stub
|
||||
// permanently gates off content emission for the rest of the stream.
|
||||
// - When jsonResults is empty (the autoparser-working case, where the raw
|
||||
// text result is cleared and only ChatDeltas carry tool calls), this is
|
||||
// a no-op and lastEmittedCount is returned unchanged.
|
||||
//
|
||||
// The autoparser-correctly-classifying-tool-calls path is unaffected: it
|
||||
// delivers tool calls via TokenUsage.ChatDeltas, and the deferred
|
||||
// end-of-stream block (ToolCallsFromChatDeltas → buildDeferredToolCallChunks)
|
||||
// emits them; this helper sees an empty jsonResults and emits nothing.
|
||||
func emitJSONToolCallDeltas(
|
||||
jsonResults []map[string]any,
|
||||
lastEmittedCount int,
|
||||
id, model string,
|
||||
created int,
|
||||
responses chan<- schema.OpenAIResponse,
|
||||
) int {
|
||||
for i := lastEmittedCount; i < len(jsonResults); i++ {
|
||||
jsonObj := jsonResults[i]
|
||||
name, ok := jsonObj["name"].(string)
|
||||
if !ok || name == "" {
|
||||
break
|
||||
}
|
||||
args := "{}"
|
||||
if argsVal, ok := jsonObj["arguments"]; ok {
|
||||
if argsStr, ok := argsVal.(string); ok {
|
||||
args = argsStr
|
||||
} else {
|
||||
argsBytes, _ := json.Marshal(argsVal)
|
||||
args = string(argsBytes)
|
||||
}
|
||||
}
|
||||
responses <- schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []schema.Choice{{
|
||||
Delta: &schema.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []schema.ToolCall{
|
||||
{
|
||||
Index: i,
|
||||
ID: id,
|
||||
Type: "function",
|
||||
FunctionCall: schema.FunctionCall{
|
||||
Name: name,
|
||||
Arguments: args,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Index: 0,
|
||||
FinishReason: nil,
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
lastEmittedCount = i + 1
|
||||
}
|
||||
return lastEmittedCount
|
||||
}
|
||||
|
||||
// chooseDeferredReasoning picks the source of truth for the end-of-stream
|
||||
// reasoning flush in processStreamWithTools. When the C++ autoparser was
|
||||
// active during the stream (preferAutoparser), it returns the autoparser's
|
||||
// own classified reasoning_content from ChatDeltas — usually empty when the
|
||||
// autoparser is in pure-content fallback mode. Otherwise it falls back to
|
||||
// the Go-side streaming extractor, which is the right source for backends
|
||||
// without an autoparser (vLLM, etc.).
|
||||
//
|
||||
// Why: the Go-side extractor's accumulated Reasoning() can be polluted by
|
||||
// PrependThinkingTokenIfNeeded — when the tokenizer template contains a
|
||||
// thinking start token (qwen3's jinja template has <think> inside an
|
||||
// {% if enable_thinking %} block, and DetectThinkingStartToken does not
|
||||
// evaluate jinja conditionals), prefill detection treats every chunk's
|
||||
// content as reasoning, even when the model emitted a raw tool-call JSON
|
||||
// in non-thinking mode. Without this guard, qwen3-4b with streaming + tools
|
||||
// (after #9985 flipped the gallery to use_tokenizer_template) emits a
|
||||
// trailing SSE chunk where `reasoning` carries the tool-call JSON.
|
||||
func chooseDeferredReasoning(
|
||||
preferAutoparser bool,
|
||||
chatDeltas []*pb.ChatDelta,
|
||||
extractor *reason.ReasoningExtractor,
|
||||
) string {
|
||||
if preferAutoparser {
|
||||
return functions.ReasoningFromChatDeltas(chatDeltas)
|
||||
}
|
||||
return extractor.Reasoning()
|
||||
}
|
||||
|
||||
// processStream is the streaming worker for chat completions with no
|
||||
// tool/function calling involved. It pushes SSE-shaped chunks onto
|
||||
// `responses` and returns the authoritative cumulative TokenUsage from
|
||||
@@ -52,6 +152,13 @@ func processStream(
|
||||
thinkingStartToken := reason.DetectThinkingStartToken(template, &cfg.ReasoningConfig)
|
||||
extractor := reason.NewReasoningExtractor(thinkingStartToken, cfg.ReasoningConfig)
|
||||
|
||||
// preferAutoparser is sticky: once the C++ autoparser has ever classified
|
||||
// reasoning_content, we trust it for the rest of the stream. Until then we
|
||||
// fall back to Go-side extraction so that a "pure content" autoparser
|
||||
// (non-jinja path, issue #9985) does not leak <think>…</think> tokens
|
||||
// straight into the OpenAI `content` field.
|
||||
preferAutoparser := false
|
||||
|
||||
_, finalUsage, _, err := ComputeChoices(req, s, cfg, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, tokenUsage backend.TokenUsage) bool {
|
||||
var reasoningDelta, contentDelta string
|
||||
|
||||
@@ -64,8 +171,16 @@ func processStream(
|
||||
// Otherwise fall back to Go-side extraction.
|
||||
if tokenUsage.HasChatDeltaContent() {
|
||||
rawReasoning, cd := tokenUsage.ChatDeltaReasoningAndContent()
|
||||
contentDelta = cd
|
||||
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
|
||||
if rawReasoning != "" {
|
||||
preferAutoparser = true
|
||||
}
|
||||
if preferAutoparser {
|
||||
contentDelta = cd
|
||||
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
|
||||
} else {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
}
|
||||
} else {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
@@ -142,6 +257,17 @@ func processStreamWithTools(
|
||||
hasChatDeltaToolCalls := false
|
||||
hasChatDeltaContent := false
|
||||
|
||||
// preferAutoparser is sticky: once the C++ autoparser has ever delivered
|
||||
// content or reasoning via ChatDeltas, we trust its classification for the
|
||||
// rest of the stream — including for the end-of-stream reasoning flush in
|
||||
// buildDeferredToolCallChunks. Otherwise the Go-side extractor's
|
||||
// accumulated Reasoning() can be polluted by prefill detection
|
||||
// misclassifying content as reasoning (this happens when <think> appears
|
||||
// in the tokenizer template and the model emits non-reasoning content
|
||||
// like a raw tool-call JSON — qwen3-4b after #9985 enabled
|
||||
// use_tokenizer_template). Mirrors the analogous flag in processStream.
|
||||
preferAutoparser := false
|
||||
|
||||
// X-LocalAI-Node attribution is handled by middleware.ExposeNodeHeader
|
||||
// at the wrapper layer; no in-band signalling from this worker.
|
||||
|
||||
@@ -165,12 +291,17 @@ func processStreamWithTools(
|
||||
|
||||
if usage.HasChatDeltaContent() {
|
||||
rawReasoning, cd := usage.ChatDeltaReasoningAndContent()
|
||||
preferAutoparser = true
|
||||
contentDelta = cd
|
||||
reasoningDelta = extractor.ProcessChatDeltaReasoning(rawReasoning)
|
||||
} else {
|
||||
} else if !preferAutoparser {
|
||||
reasoningDelta = goReasoning
|
||||
contentDelta = goContent
|
||||
}
|
||||
// If preferAutoparser is already true but this chunk carried no
|
||||
// autoparser data, leave both deltas empty — the next autoparser
|
||||
// chunk will pick things up. Falling back to Go-side here would
|
||||
// re-introduce the prefill-misclassification leak.
|
||||
|
||||
// Emit reasoning deltas in their own SSE chunks before any tool-call chunks
|
||||
// (OpenAI spec: reasoning and tool_calls never share a delta)
|
||||
@@ -264,49 +395,10 @@ func processStreamWithTools(
|
||||
// Try JSON tool call parsing for streaming.
|
||||
// Only emit NEW tool calls (same guard as XML parser above).
|
||||
jsonResults, jsonErr := functions.ParseJSONIterative(cleanedResult, true)
|
||||
if jsonErr == nil && len(jsonResults) > lastEmittedCount {
|
||||
for i := lastEmittedCount; i < len(jsonResults); i++ {
|
||||
jsonObj := jsonResults[i]
|
||||
name, ok := jsonObj["name"].(string)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
args := "{}"
|
||||
if argsVal, ok := jsonObj["arguments"]; ok {
|
||||
if argsStr, ok := argsVal.(string); ok {
|
||||
args = argsStr
|
||||
} else {
|
||||
argsBytes, _ := json.Marshal(argsVal)
|
||||
args = string(argsBytes)
|
||||
}
|
||||
}
|
||||
initialMessage := schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: req.Model,
|
||||
Choices: []schema.Choice{{
|
||||
Delta: &schema.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []schema.ToolCall{
|
||||
{
|
||||
Index: i,
|
||||
ID: id,
|
||||
Type: "function",
|
||||
FunctionCall: schema.FunctionCall{
|
||||
Name: name,
|
||||
Arguments: args,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Index: 0,
|
||||
FinishReason: nil,
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
}
|
||||
responses <- initialMessage
|
||||
}
|
||||
lastEmittedCount = len(jsonResults)
|
||||
if jsonErr == nil {
|
||||
lastEmittedCount = emitJSONToolCallDeltas(
|
||||
jsonResults, lastEmittedCount, id, req.Model, created, responses,
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -352,7 +444,14 @@ func processStreamWithTools(
|
||||
} else {
|
||||
// Fallback: parse tool calls from raw text (no chat deltas from backend)
|
||||
xlog.Debug("[ChatDeltas] no pre-parsed tool calls, falling back to Go-side text parsing")
|
||||
reasoning = extractor.Reasoning()
|
||||
// When the autoparser was active during streaming (preferAutoparser),
|
||||
// trust its reasoning classification rather than the Go-side
|
||||
// extractor's accumulated state — the latter may have misclassified
|
||||
// content as reasoning due to prefill detection on a tokenizer
|
||||
// template that contains <think>. This was visible on qwen3-4b after
|
||||
// #9985 enabled use_tokenizer_template: a streaming tool-call JSON
|
||||
// would leak as a trailing reasoning chunk via the deferred flush.
|
||||
reasoning = chooseDeferredReasoning(preferAutoparser, chatDeltas, extractor)
|
||||
cleanedResult := extractor.CleanedContent()
|
||||
*textContentToReturn = functions.ParseTextContent(cleanedResult, cfg.FunctionsConfig)
|
||||
cleanedResult = functions.CleanupLLMResult(cleanedResult, cfg.FunctionsConfig)
|
||||
|
||||
197
core/http/endpoints/openai/chat_stream_workers_test.go
Normal file
197
core/http/endpoints/openai/chat_stream_workers_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// drainChannel reads everything currently buffered on a channel without
|
||||
// blocking on close. The helper test channels are sized for the assertions.
|
||||
func drainChannel(ch <-chan schema.OpenAIResponse) []schema.OpenAIResponse {
|
||||
var out []schema.OpenAIResponse
|
||||
for {
|
||||
select {
|
||||
case r, ok := <-ch:
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
out = append(out, r)
|
||||
default:
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nameOf returns the name of the first tool call carried on the choice's
|
||||
// delta, or "" if none.
|
||||
func nameOf(r schema.OpenAIResponse) string {
|
||||
if len(r.Choices) == 0 || r.Choices[0].Delta == nil {
|
||||
return ""
|
||||
}
|
||||
if len(r.Choices[0].Delta.ToolCalls) == 0 {
|
||||
return ""
|
||||
}
|
||||
return r.Choices[0].Delta.ToolCalls[0].FunctionCall.Name
|
||||
}
|
||||
|
||||
var _ = Describe("emitJSONToolCallDeltas", func() {
|
||||
const (
|
||||
id = "test-stream"
|
||||
model = "test-model"
|
||||
created = 1700000000
|
||||
)
|
||||
|
||||
// The case that motivated this helper. With the previous version of
|
||||
// the streaming worker, ParseJSONIterative would hand back a stub
|
||||
// object like `{"4310046988783340008":1}` after the model had only
|
||||
// emitted `{`. The worker bumped lastEmittedCount unconditionally,
|
||||
// which permanently gated off content emission for the rest of the
|
||||
// stream (qwen3-4b with stream:true + tools dribbled only `{"` to
|
||||
// the client and then nothing). See issue #9988.
|
||||
Context("partial stub without a usable name", func() {
|
||||
It("does NOT bump lastEmittedCount and emits nothing", func() {
|
||||
responses := make(chan schema.OpenAIResponse, 4)
|
||||
// What ParseJSONIterative used to return for `{`:
|
||||
stubResults := []map[string]any{
|
||||
{"4310046988783340008": float64(1)},
|
||||
}
|
||||
|
||||
next := emitJSONToolCallDeltas(stubResults, 0, id, model, created, responses)
|
||||
|
||||
Expect(next).To(Equal(0),
|
||||
"lastEmittedCount must NOT advance past a stub without a name "+
|
||||
"— otherwise content emission gets permanently gated off")
|
||||
Expect(drainChannel(responses)).To(BeEmpty(),
|
||||
"no tool_call chunk should be emitted for a stub without a name")
|
||||
})
|
||||
})
|
||||
|
||||
// No-regression #1: the autoparser-correctly-working path. When the
|
||||
// C++ autoparser classifies tool calls itself, the raw text result is
|
||||
// cleared and ParseJSONIterative on it returns no results — this
|
||||
// helper must be a no-op so the deferred end-of-stream code can emit
|
||||
// the tool calls from TokenUsage.ChatDeltas.
|
||||
Context("empty jsonResults (autoparser-correctly-working path)", func() {
|
||||
It("is a no-op and leaves lastEmittedCount unchanged", func() {
|
||||
responses := make(chan schema.OpenAIResponse, 4)
|
||||
next := emitJSONToolCallDeltas(nil, 0, id, model, created, responses)
|
||||
Expect(next).To(Equal(0))
|
||||
Expect(drainChannel(responses)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("leaves a non-zero lastEmittedCount unchanged when later called with the same length", func() {
|
||||
responses := make(chan schema.OpenAIResponse, 4)
|
||||
results := []map[string]any{
|
||||
{"name": "search", "arguments": map[string]any{"q": "hi"}},
|
||||
}
|
||||
// First call emits the one available tool call.
|
||||
next := emitJSONToolCallDeltas(results, 0, id, model, created, responses)
|
||||
Expect(next).To(Equal(1))
|
||||
Expect(drainChannel(responses)).To(HaveLen(1))
|
||||
|
||||
// Subsequent chunks haven't grown the slice — must be a no-op.
|
||||
next = emitJSONToolCallDeltas(results, next, id, model, created, responses)
|
||||
Expect(next).To(Equal(1))
|
||||
Expect(drainChannel(responses)).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
// No-regression #2: the normal completed-JSON path. When the model
|
||||
// emits a real, complete tool call as JSON in raw content (e.g. qwen3
|
||||
// without jinja but with tools), we should emit exactly one tool_call
|
||||
// SSE chunk on the first call and become a no-op on later calls.
|
||||
Context("single complete tool call", func() {
|
||||
It("emits one tool_call chunk and bumps lastEmittedCount to 1", func() {
|
||||
responses := make(chan schema.OpenAIResponse, 4)
|
||||
results := []map[string]any{
|
||||
{
|
||||
"name": "search",
|
||||
"arguments": map[string]any{
|
||||
"q": "hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
next := emitJSONToolCallDeltas(results, 0, id, model, created, responses)
|
||||
|
||||
Expect(next).To(Equal(1))
|
||||
out := drainChannel(responses)
|
||||
Expect(out).To(HaveLen(1))
|
||||
Expect(nameOf(out[0])).To(Equal("search"))
|
||||
Expect(out[0].Choices[0].Delta.ToolCalls[0].FunctionCall.Arguments).
|
||||
To(ContainSubstring(`"q":"hello"`))
|
||||
})
|
||||
|
||||
It("accepts arguments already serialized as a string", func() {
|
||||
responses := make(chan schema.OpenAIResponse, 4)
|
||||
results := []map[string]any{
|
||||
{
|
||||
"name": "search",
|
||||
"arguments": `{"q":"hello"}`,
|
||||
},
|
||||
}
|
||||
|
||||
emitJSONToolCallDeltas(results, 0, id, model, created, responses)
|
||||
|
||||
out := drainChannel(responses)
|
||||
Expect(out).To(HaveLen(1))
|
||||
Expect(out[0].Choices[0].Delta.ToolCalls[0].FunctionCall.Arguments).
|
||||
To(Equal(`{"q":"hello"}`))
|
||||
})
|
||||
})
|
||||
|
||||
// No-regression #3: multiple tool calls (parallel tool calling).
|
||||
// Both must be emitted, lastEmittedCount must end at 2.
|
||||
Context("multiple complete tool calls", func() {
|
||||
It("emits one chunk per tool call and bumps lastEmittedCount to len(results)", func() {
|
||||
responses := make(chan schema.OpenAIResponse, 8)
|
||||
results := []map[string]any{
|
||||
{"name": "search", "arguments": map[string]any{"q": "a"}},
|
||||
{"name": "browse", "arguments": map[string]any{"url": "b"}},
|
||||
}
|
||||
|
||||
next := emitJSONToolCallDeltas(results, 0, id, model, created, responses)
|
||||
|
||||
Expect(next).To(Equal(2))
|
||||
out := drainChannel(responses)
|
||||
Expect(out).To(HaveLen(2))
|
||||
Expect(nameOf(out[0])).To(Equal("search"))
|
||||
Expect(nameOf(out[1])).To(Equal("browse"))
|
||||
})
|
||||
})
|
||||
|
||||
// The streaming-tail case: incremental chunks. First parse returns
|
||||
// one complete tool call followed by a partial stub; later chunks
|
||||
// complete the second tool call. We must emit the first immediately
|
||||
// and the second on the later call — without ever bumping past the
|
||||
// stub mid-stream.
|
||||
Context("partial tail behind a real tool call", func() {
|
||||
It("emits the complete entry, stops at the stub, and resumes once the tail completes", func() {
|
||||
responses := make(chan schema.OpenAIResponse, 8)
|
||||
|
||||
// Chunk 1: one real call + a partial stub for the next.
|
||||
chunk1 := []map[string]any{
|
||||
{"name": "search", "arguments": map[string]any{"q": "a"}},
|
||||
{"4310046988783340008": float64(1)},
|
||||
}
|
||||
next := emitJSONToolCallDeltas(chunk1, 0, id, model, created, responses)
|
||||
Expect(next).To(Equal(1),
|
||||
"must NOT advance to 2 — the stub at index 1 has no usable name")
|
||||
out := drainChannel(responses)
|
||||
Expect(out).To(HaveLen(1))
|
||||
Expect(nameOf(out[0])).To(Equal("search"))
|
||||
|
||||
// Chunk 2: the stub completes into a real call.
|
||||
chunk2 := []map[string]any{
|
||||
{"name": "search", "arguments": map[string]any{"q": "a"}},
|
||||
{"name": "browse", "arguments": map[string]any{"url": "b"}},
|
||||
}
|
||||
next = emitJSONToolCallDeltas(chunk2, next, id, model, created, responses)
|
||||
Expect(next).To(Equal(2))
|
||||
out = drainChannel(responses)
|
||||
Expect(out).To(HaveLen(1))
|
||||
Expect(nameOf(out[0])).To(Equal("browse"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,8 @@ package openai
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/pkg/functions"
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
reason "github.com/mudler/LocalAI/pkg/reasoning"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
@@ -94,6 +96,98 @@ var _ = Describe("handleQuestion", func() {
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("applyAutoparserOverride", func() {
|
||||
// Regression test for https://github.com/mudler/LocalAI/issues/9985.
|
||||
// When LocalAI templates a <think>-style reasoning model outside of jinja
|
||||
// (e.g. the gallery qwen3 entry), the llama.cpp autoparser falls back to
|
||||
// the "pure content" PEG parser which dumps the entire raw response,
|
||||
// including <think>…</think>, into ChatDelta.Content and leaves
|
||||
// ChatDelta.ReasoningContent empty. The Go side previously trusted that
|
||||
// content verbatim and clobbered the tokenCallback's correctly-split
|
||||
// reasoning, so <think> blocks leaked into the OpenAI `content` field.
|
||||
Context("autoparser delivered content with embedded <think> tags and empty reasoning (issue #9985)", func() {
|
||||
It("splits <think>…</think> out of content into the reasoning field", func() {
|
||||
raw := "<think>\nOkay, the user said \"Hello\". I should reply warmly.\n</think>\n\nHello! How can I assist you today? 😊"
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: raw, ReasoningContent: ""},
|
||||
}
|
||||
|
||||
result := applyAutoparserOverride(chatDeltas, "", reason.Config{}, nil)
|
||||
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Message).ToNot(BeNil())
|
||||
Expect(result[0].Message.Content).ToNot(BeNil())
|
||||
|
||||
content := *(result[0].Message.Content.(*string))
|
||||
Expect(content).ToNot(ContainSubstring("<think>"),
|
||||
"raw <think> tag must not leak into OpenAI content field")
|
||||
Expect(content).ToNot(ContainSubstring("</think>"),
|
||||
"raw </think> tag must not leak into OpenAI content field")
|
||||
Expect(content).To(ContainSubstring("Hello! How can I assist you today?"),
|
||||
"the model's actual answer must still be in content")
|
||||
|
||||
Expect(result[0].Message.Reasoning).ToNot(BeNil(),
|
||||
"reasoning extracted from <think>…</think> must populate Reasoning")
|
||||
Expect(*result[0].Message.Reasoning).To(ContainSubstring("Okay, the user said"))
|
||||
})
|
||||
|
||||
It("does not run extraction when the autoparser already populated reasoning", func() {
|
||||
// When the autoparser actually classified reasoning, leave its
|
||||
// content/reasoning split untouched.
|
||||
content := "Hello! How can I assist you today?"
|
||||
reasoning := "Already split by the C++ autoparser."
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: content, ReasoningContent: reasoning},
|
||||
}
|
||||
|
||||
result := applyAutoparserOverride(chatDeltas, "", reason.Config{}, nil)
|
||||
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(*(result[0].Message.Content.(*string))).To(Equal(content))
|
||||
Expect(result[0].Message.Reasoning).ToNot(BeNil())
|
||||
Expect(*result[0].Message.Reasoning).To(Equal(reasoning))
|
||||
})
|
||||
|
||||
It("passes plain content through unchanged when no reasoning tags are present", func() {
|
||||
content := "Just a normal answer with no reasoning at all."
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: content, ReasoningContent: ""},
|
||||
}
|
||||
|
||||
result := applyAutoparserOverride(chatDeltas, "", reason.Config{}, nil)
|
||||
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(*(result[0].Message.Content.(*string))).To(Equal(content))
|
||||
Expect(result[0].Message.Reasoning).To(BeNil())
|
||||
})
|
||||
|
||||
It("strips an empty <think></think> block (qwen3 /no_think mode)", func() {
|
||||
// qwen3 with the /no_think directive still emits an empty thinking
|
||||
// block. The Go-side fallback must strip it from content rather than
|
||||
// pass <think></think> through verbatim. No reasoning is set because
|
||||
// the block has no body.
|
||||
raw := "<think>\n\n</think>\n\nHello! How can I assist you today?"
|
||||
chatDeltas := []*pb.ChatDelta{
|
||||
{Content: raw, ReasoningContent: ""},
|
||||
}
|
||||
|
||||
result := applyAutoparserOverride(chatDeltas, "", reason.Config{}, nil)
|
||||
|
||||
Expect(result).To(HaveLen(1))
|
||||
content := *(result[0].Message.Content.(*string))
|
||||
Expect(content).ToNot(ContainSubstring("<think>"))
|
||||
Expect(content).ToNot(ContainSubstring("</think>"))
|
||||
Expect(content).To(ContainSubstring("Hello! How can I assist you today?"))
|
||||
})
|
||||
|
||||
It("returns the existing result when chatDeltas is empty", func() {
|
||||
existing := []schema.Choice{{Index: 7}}
|
||||
result := applyAutoparserOverride(nil, "", reason.Config{}, existing)
|
||||
Expect(result).To(Equal(existing))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("mergeToolCallDeltas", func() {
|
||||
Context("with new tool calls", func() {
|
||||
It("should append new tool calls", func() {
|
||||
|
||||
@@ -1572,6 +1572,15 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
|
||||
"tool_calls", len(deltaToolCalls),
|
||||
"content_len", len(deltaContent),
|
||||
"reasoning_len", len(deltaReasoning))
|
||||
// Issue #9985: when the autoparser only delivered content (no
|
||||
// reasoning_content), it may be running in the "pure content"
|
||||
// PEG fallback (non-jinja path) which leaves <think>…</think>
|
||||
// embedded in the content. Run Go-side extraction defensively.
|
||||
// ExtractReasoningWithConfig is a no-op when no tag pair matches,
|
||||
// so it's safe to apply unconditionally in the no-reasoning branch.
|
||||
if deltaReasoning == "" && deltaContent != "" {
|
||||
deltaReasoning, deltaContent = reasoning.ExtractReasoningWithConfig(deltaContent, thinkingStartToken, config.ReasoningConfig)
|
||||
}
|
||||
reasoningText = deltaReasoning
|
||||
responseWithoutReasoning = deltaContent
|
||||
textContent = deltaContent
|
||||
|
||||
@@ -1971,6 +1971,10 @@ func handleOpenResponsesStream(c echo.Context, responseID string, createdAt int6
|
||||
|
||||
// Source reasoning from: (1) ChatDeltas from C++ autoparser, (2) extractor's
|
||||
// streaming state, (3) final extraction from the finetuned result.
|
||||
// Issue #9985: when the autoparser delivered Content but no
|
||||
// ReasoningContent, it was running in the "pure content" PEG fallback
|
||||
// (non-jinja path) which leaves reasoning tags embedded in content.
|
||||
// Fall back to the streaming Go-side extractor's split in that case.
|
||||
if chatDeltaReasoning := functions.ReasoningFromChatDeltas(chatDeltas); chatDeltaReasoning != "" {
|
||||
finalReasoning = chatDeltaReasoning
|
||||
finalCleanedResult = functions.ContentFromChatDeltas(chatDeltas)
|
||||
|
||||
@@ -515,7 +515,7 @@ The `llama.cpp` backend supports additional configuration options that can be sp
|
||||
| `kv_unified` or `unified_kv` | boolean | Use a single unified KV buffer shared across all sequences. Default: `true` (LocalAI override; upstream defaults to `false` but auto-enables it when slot count is auto). **Required for `cache_idle_slots` to work**: without it the server force-disables idle-slot saving at init, and the prompt cache is never written across requests. | `kv_unified:false` |
|
||||
| `cache_idle_slots` or `idle_slots_cache` | boolean | On a new task, save the previous slot's KV state into the prompt cache (and clear the slot) so a later request with the same prefix can warm-load it. Default: `true`. Auto-disabled by the server if `kv_unified=false` or `cache_ram=0`. | `cache_idle_slots:false` |
|
||||
| `n_ctx_checkpoints` or `ctx_checkpoints` | integer | Maximum number of context checkpoints per slot (used for partial-prefix recovery, e.g. SWA). Default: `32`. | `ctx_checkpoints:16` |
|
||||
| `checkpoint_every_nt` or `checkpoint_every_n_tokens` | integer | Create a context checkpoint every N tokens during prefill. `-1` disables checkpointing. Default: `8192`. | `checkpoint_every_nt:4096` |
|
||||
| `checkpoint_min_step` or `checkpoint_min_spacing` (aliases: `checkpoint_every_nt`, `checkpoint_every_n_tokens`) | integer | Minimum spacing in tokens between context checkpoints. `0` disables the minimum-spacing gate. Default: `256`. (Renamed upstream from `checkpoint_every_nt`; semantics shifted from a fixed cadence to a minimum spacing.) | `checkpoint_min_step:1024` |
|
||||
| `split_mode` or `sm` | string | How to split the model across multiple GPUs: `none` (single GPU only), `layer` (default — split layers and KV across GPUs), `row` (split rows across GPUs), `tensor` (experimental tensor parallelism — requires `flash_attention: true`, no KV-cache quantization, manually set `context_size`, and a llama.cpp build that includes [#19378](https://github.com/ggml-org/llama.cpp/pull/19378)). | `split_mode:tensor` |
|
||||
|
||||
**Example configuration with options:**
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "v4.2.6"
|
||||
"version": "v4.3.1"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
---
|
||||
- name: "qwopus3.6-27b-v2-mtp"
|
||||
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
||||
urls:
|
||||
- https://huggingface.co/Jackrong/Qwopus3.6-27B-v2-MTP-GGUF
|
||||
description: |
|
||||
🪐 Qwopus3.6-27B-v2-MTP
|
||||
MTP Release
|
||||
|
||||
Multi-Token Prediction reasoning model fine-tuned from Qwen3.6-27B
|
||||
|
||||
🧬 Trace Inversion & Negentropy
|
||||
🧠 27B Parameters
|
||||
⚡ Speculative Decoding
|
||||
🛠️ Coding / DevOps / Math
|
||||
|
||||
💡 What is Qwopus3.6-27B-v2-MTP?
|
||||
🪐 Qwopus3.6-27B-v2-MTP is a speed-oriented reasoning release built on top of Qwen3.6-27B. It keeps the Qwopus line's focus on reconstructed reasoning traces, coding discipline, DevOps procedures, and mathematical derivations, while adding Multi-Token Prediction for faster generation. The goal is simple: preserve the depth and structure of a 27B reasoning model while making real interactive use noticeably faster.
|
||||
|
||||
⚡ MTP DecodingAuxiliary future-token prediction improves throughput on long reasoning, code, math, and strict-format prompts.
|
||||
🧩 Structured ReasoningInherits the Qwopus training recipe built around reconstructed step-by-step reasoning trajectories.
|
||||
🧪 GB10 TestedValidated on a 30-question local benchmark across Logic, Coding, DevOps, Math, and Edge tasks.
|
||||
🚀 Practical SpeedDesigned for workflows where strong answers matter, but waiting several extra minutes per task does not.
|
||||
|
||||
...
|
||||
license: "apache-2.0"
|
||||
tags:
|
||||
- llm
|
||||
- gguf
|
||||
- reasoning
|
||||
overrides:
|
||||
backend: llama-cpp
|
||||
function:
|
||||
automatic_tool_parsing_fallback: true
|
||||
grammar:
|
||||
disable: true
|
||||
known_usecases:
|
||||
- chat
|
||||
options:
|
||||
- use_jinja:true
|
||||
- spec_type:draft-mtp
|
||||
- spec_n_max:6
|
||||
- spec_p_min:0.75
|
||||
parameters:
|
||||
model: llama-cpp/models/Qwopus3.6-27B-v2-MTP-GGUF/Qwopus3.6-27B-v2-MTP-Q4_K_M.gguf
|
||||
template:
|
||||
use_tokenizer_template: true
|
||||
files:
|
||||
- filename: llama-cpp/models/Qwopus3.6-27B-v2-MTP-GGUF/Qwopus3.6-27B-v2-MTP-Q4_K_M.gguf
|
||||
sha256: 818d68223be4d8518dac0b3b5604dde633cbbcbae1f491d842a3e26711c6606d
|
||||
uri: https://huggingface.co/Jackrong/Qwopus3.6-27B-v2-MTP-GGUF/resolve/main/Qwopus3.6-27B-v2-MTP-Q4_K_M.gguf
|
||||
- name: "qwen3.6-40b-claude-4.6-opus-deckard-heretic-uncensored-thinking-neo-code-di-imatrix-max"
|
||||
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
||||
urls:
|
||||
@@ -30844,6 +30894,8 @@
|
||||
parameters:
|
||||
model: ltx-2.3-22b-dev-UD-Q4_K_M.gguf
|
||||
options:
|
||||
- diffusion_model
|
||||
- "vae_decode_only:false"
|
||||
- llm_path:gemma-3-12b-it-qat-UD-Q4_K_XL.gguf
|
||||
- vae_path:ltx-2.3-22b-dev_video_vae.safetensors
|
||||
- audio_vae_path:ltx-2.3-22b-dev_audio_vae.safetensors
|
||||
@@ -30875,6 +30927,8 @@
|
||||
parameters:
|
||||
model: ltx-2.3-22b-dev-Q4_K_M.gguf
|
||||
options:
|
||||
- diffusion_model
|
||||
- "vae_decode_only:false"
|
||||
- llm_path:gemma-3-12b-it-qat-UD-Q4_K_XL.gguf
|
||||
- vae_path:ltx-2.3-22b-dev_video_vae.safetensors
|
||||
- audio_vae_path:ltx-2.3-22b-dev_audio_vae.safetensors
|
||||
@@ -30906,6 +30960,8 @@
|
||||
parameters:
|
||||
model: ltx-2.3-22b-dev-Q8_0.gguf
|
||||
options:
|
||||
- diffusion_model
|
||||
- "vae_decode_only:false"
|
||||
- llm_path:gemma-3-12b-it-qat-UD-Q4_K_XL.gguf
|
||||
- vae_path:ltx-2.3-22b-dev_video_vae.safetensors
|
||||
- audio_vae_path:ltx-2.3-22b-dev_audio_vae.safetensors
|
||||
@@ -30965,6 +31021,8 @@
|
||||
parameters:
|
||||
model: ltx-2.3-22b-distilled-UD-Q4_K_M.gguf
|
||||
options:
|
||||
- diffusion_model
|
||||
- "vae_decode_only:false"
|
||||
- llm_path:gemma-3-12b-it-qat-UD-Q4_K_XL.gguf
|
||||
- vae_path:ltx-2.3-22b-distilled_video_vae.safetensors
|
||||
- audio_vae_path:ltx-2.3-22b-distilled_audio_vae.safetensors
|
||||
@@ -30995,6 +31053,8 @@
|
||||
parameters:
|
||||
model: ltx-2.3-22b-distilled-Q4_K_M.gguf
|
||||
options:
|
||||
- diffusion_model
|
||||
- "vae_decode_only:false"
|
||||
- llm_path:gemma-3-12b-it-qat-UD-Q4_K_XL.gguf
|
||||
- vae_path:ltx-2.3-22b-distilled_video_vae.safetensors
|
||||
- audio_vae_path:ltx-2.3-22b-distilled_audio_vae.safetensors
|
||||
@@ -31025,6 +31085,8 @@
|
||||
parameters:
|
||||
model: ltx-2.3-22b-distilled-Q8_0.gguf
|
||||
options:
|
||||
- diffusion_model
|
||||
- "vae_decode_only:false"
|
||||
- llm_path:gemma-3-12b-it-qat-UD-Q4_K_XL.gguf
|
||||
- vae_path:ltx-2.3-22b-distilled_video_vae.safetensors
|
||||
- audio_vae_path:ltx-2.3-22b-distilled_audio_vae.safetensors
|
||||
|
||||
@@ -11,36 +11,12 @@ config_file: |
|
||||
- <dummy32000>
|
||||
- </s>
|
||||
- <|endoftext|>
|
||||
# Delegate templating to llama.cpp's jinja runtime so the C++ autoparser
|
||||
# can classify <think>…</think> blocks into reasoning_content natively
|
||||
# (issue #9985). Without use_jinja the autoparser falls back to a
|
||||
# "pure content" PEG parser that leaks reasoning tags into content.
|
||||
options:
|
||||
- use_jinja:true
|
||||
template:
|
||||
chat: |
|
||||
{{.Input -}}
|
||||
<|im_start|>assistant
|
||||
chat_message: |
|
||||
<|im_start|>{{if eq .RoleName "tool" }}user{{else}}{{ .RoleName }}{{end}}
|
||||
{{ if eq .RoleName "tool" -}}
|
||||
<tool_response>
|
||||
{{ end -}}
|
||||
{{ if .Content -}}
|
||||
{{.Content }}
|
||||
{{ end -}}
|
||||
{{ if eq .RoleName "tool" -}}
|
||||
</tool_response>
|
||||
{{ end -}}
|
||||
{{ if .FunctionCall -}}
|
||||
<tool_call>
|
||||
{{toJson .FunctionCall}}
|
||||
</tool_call>
|
||||
{{ end -}}<|im_end|>
|
||||
completion: |
|
||||
{{.Input}}
|
||||
function: |
|
||||
<|im_start|>system
|
||||
You are a function calling AI model. You are provided with functions to execute. You may call one or more functions to assist with the user query. Don't make assumptions about what values to plug into functions. Here are the available tools:
|
||||
{{range .Functions}}
|
||||
{"type": "function", "function": {"name": "{{.Name}}", "description": "{{.Description}}", "parameters": {{toJson .Parameters}} }}
|
||||
{{end}}
|
||||
For each function call return a json object with function name and arguments: {"name": <function-name>, "arguments": <json-arguments-object>}
|
||||
<|im_end|>
|
||||
{{.Input -}}
|
||||
<|im_start|>assistant
|
||||
use_tokenizer_template: true
|
||||
name: qwen3
|
||||
|
||||
26
go.mod
26
go.mod
@@ -10,7 +10,7 @@ require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.42.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.17
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1
|
||||
github.com/charmbracelet/glamour v1.0.0
|
||||
github.com/containerd/containerd v1.7.31
|
||||
@@ -41,7 +41,7 @@ require (
|
||||
github.com/mudler/go-processmanager v0.1.1
|
||||
github.com/mudler/memory v0.0.0-20260406210934-424c1ecf2cf8
|
||||
github.com/mudler/xlog v0.0.6
|
||||
github.com/nats-io/nats.go v1.50.0
|
||||
github.com/nats-io/nats.go v1.52.0
|
||||
github.com/ollama/ollama v0.20.4
|
||||
github.com/onsi/ginkgo/v2 v2.29.0
|
||||
github.com/onsi/gomega v1.40.0
|
||||
@@ -81,18 +81,18 @@ require (
|
||||
filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
||||
github.com/aws/smithy-go v1.25.1 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/blang/semver v3.5.1+incompatible // indirect
|
||||
@@ -498,7 +498,7 @@ require (
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
|
||||
52
go.sum
52
go.sum
@@ -150,36 +150,36 @@ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQ
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.48.2 h1:aL8Y/AbB6I+uw0MjLbdo68NQ8t5lNs3CY3S848HpETk=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.48.2/go.mod h1:VJcNH6BLr+3VJwinRKdotLOMglHO8mIKlD3ea5c7hbw=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
@@ -982,10 +982,6 @@ github.com/mudler/localrecall v0.6.1-0.20260507074622-a7724fef6f81 h1:8D9NJ/ikhs
|
||||
github.com/mudler/localrecall v0.6.1-0.20260507074622-a7724fef6f81/go.mod h1:28k5n19raUrkuwXkacdNsBlj8yuSnGhpT16tu+2+4dU=
|
||||
github.com/mudler/memory v0.0.0-20260406210934-424c1ecf2cf8 h1:Ry8RiWy8fZ6Ff4E7dPmjRsBrnHOnPeOOj2LhCgyjQu0=
|
||||
github.com/mudler/memory v0.0.0-20260406210934-424c1ecf2cf8/go.mod h1:EA8Ashhd56o32qN7ouPKFSRUs/Z+LrRCF4v6R2Oarm8=
|
||||
github.com/mudler/skillserver v0.0.6 h1:ixz6wUekLdTmbnpAavCkTydDF6UdXAG3ncYufSPK9G0=
|
||||
github.com/mudler/skillserver v0.0.6/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU=
|
||||
github.com/mudler/skillserver v0.0.7-0.20260520212528-3dae7f041b1e h1:ryXE1UEzGhLkDFYuaxJ0fZ6fg4l++TWfMCTJ1E7bYS8=
|
||||
github.com/mudler/skillserver v0.0.7-0.20260520212528-3dae7f041b1e/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU=
|
||||
github.com/mudler/skillserver v0.0.7-0.20260520220837-a7317cbf9145 h1:z59tA3IDYPt71nzH1jpxeaA1LuDw8aZfpTQFNU43Zb8=
|
||||
github.com/mudler/skillserver v0.0.7-0.20260520220837-a7317cbf9145/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU=
|
||||
github.com/mudler/water v0.0.0-20250808092830-dd90dcf09025 h1:WFLP5FHInarYGXi6B/Ze204x7Xy6q/I4nCZnWEyPHK0=
|
||||
@@ -1022,8 +1018,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/nats-io/nats.go v1.50.0 h1:5zAeQrTvyrKrWLJ0fu02W3br8ym57qf7csDzgLOpcds=
|
||||
github.com/nats-io/nats.go v1.50.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
|
||||
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
|
||||
@@ -577,6 +577,21 @@ func trimPotentialPartialWord(content string, format *XMLToolCallFormat, startTh
|
||||
func removeHealingMarkerFromJSON(value map[string]any, marker string) map[string]any {
|
||||
result := make(map[string]any)
|
||||
for k, v := range value {
|
||||
// Strip the healing marker from KEYS. parseJSONWithStack appends the
|
||||
// marker to close a partial key (e.g. `{ "code` heals into
|
||||
// `{"code<marker>":1}`); we want to preserve the prefix the model
|
||||
// actually emitted. If the entire key was the marker (i.e. the input
|
||||
// was just `{` heals into `{"<marker>":1}`), the truncated key is
|
||||
// empty — drop the entry. Without this, downstream callers see a
|
||||
// stub object with a random integer-looking key and treat it as a
|
||||
// complete result, the shape that trips chat_stream_workers.go's
|
||||
// streaming tool-call detector in issue #9988.
|
||||
if idx := strings.Index(k, marker); idx != -1 {
|
||||
k = k[:idx]
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if str, ok := v.(string); ok {
|
||||
if idx := strings.Index(str, marker); idx != -1 {
|
||||
v = str[:idx]
|
||||
|
||||
@@ -325,7 +325,17 @@ func ParseJSONIterative(s string, isPartial bool) ([]map[string]any, error) {
|
||||
if jsonValue != nil {
|
||||
// Convert to map[string]any if it's an object, or handle arrays
|
||||
if obj, ok := jsonValue.(map[string]any); ok {
|
||||
results = append(results, obj)
|
||||
// Skip stub objects that healed away to nothing. Partial inputs
|
||||
// like `{`, `{"`, or `{"n` go through parseJSONWithStack and
|
||||
// come back as `{"<marker>":1}`; after removeHealingMarkerFromJSON
|
||||
// drops the marker key the map is empty. Returning it as a
|
||||
// real result trips the streaming tool-call detector
|
||||
// (chat_stream_workers.go) into thinking a tool call landed,
|
||||
// gating off content emission for the rest of the stream
|
||||
// (issue #9988).
|
||||
if !(isPartialJSON && len(obj) == 0) {
|
||||
results = append(results, obj)
|
||||
}
|
||||
} else if arr, ok := jsonValue.([]any); ok {
|
||||
// Handle arrays: extract objects from array
|
||||
for _, item := range arr {
|
||||
|
||||
@@ -1782,6 +1782,101 @@ value
|
||||
// Results may be empty or contain partial data
|
||||
Expect(len(results)).To(BeNumerically(">=", 0))
|
||||
})
|
||||
|
||||
// Regression: https://github.com/mudler/LocalAI/issues/9988.
|
||||
// The streaming tool-call detector calls ParseJSONIterative on each
|
||||
// new content chunk. If the parser returns a stub object whose only
|
||||
// key is the synthetic healing marker, the caller treats it as
|
||||
// "tool call detected" and gates content emission — qwen3 with
|
||||
// streaming + tools used to leak only the first two characters of
|
||||
// the JSON ("{\"") to clients as a result.
|
||||
// Regression: https://github.com/mudler/LocalAI/issues/9988.
|
||||
// parseJSONWithStack inserts a random-integer healing marker into
|
||||
// keys (and sometimes values) to make a partial input parseable.
|
||||
// Those marker characters must never reach the caller — keys made
|
||||
// entirely of the marker must be dropped, and a marker suffix on a
|
||||
// partial key must be stripped down to the prefix the model
|
||||
// actually typed. Without this the streaming worker sees garbage
|
||||
// keys like `"4310046988783340008"` and mistakes the stub for a
|
||||
// completed tool call, then gates off content emission.
|
||||
DescribeTable("partial JSON starts must not surface healing markers in keys",
|
||||
func(input string) {
|
||||
parser := NewChatMsgParser(input, true)
|
||||
marker := parser.HealingMarker()
|
||||
results, err := ParseJSONIterative(input, true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, obj := range results {
|
||||
for k := range obj {
|
||||
Expect(k).NotTo(ContainSubstring(marker),
|
||||
"healing marker leaked into key %q for input=%q (full=%+v)", k, input, obj)
|
||||
Expect(k).NotTo(MatchRegexp(`^[A-Za-z]?\d{6,}$`),
|
||||
"key %q looks like a synthetic numeric marker for input=%q (full=%+v)",
|
||||
k, input, obj)
|
||||
}
|
||||
}
|
||||
},
|
||||
Entry("just an opening brace", `{`),
|
||||
Entry("brace + quote", `{"`),
|
||||
Entry("brace + partial key", `{"n`),
|
||||
Entry("brace + quoted partial key", `{"na`),
|
||||
Entry("brace + complete key, no value yet", `{"name"`),
|
||||
Entry("brace + key + colon", `{"name":`),
|
||||
Entry("brace + key + opening quote of value", `{"name":"`),
|
||||
Entry("brace + partial value", `{"name":"ans`),
|
||||
)
|
||||
|
||||
DescribeTable("partial JSON that has not yet committed a tool name must not surface a stub object",
|
||||
// The streaming tool-call detector treats every entry returned
|
||||
// by ParseJSONIterative as a potential new tool call. For very
|
||||
// early partial inputs like `{` or `{"` there is nothing the
|
||||
// caller can act on yet — returning a stub object bumps
|
||||
// lastEmittedCount and gates off content emission.
|
||||
// (Partial-key results like `{"n` → `{"n": 1}` are OK at the
|
||||
// parser level — the streaming caller filters them by
|
||||
// requiring a usable `name` field. See the streaming
|
||||
// defense in chat_stream_workers.go.)
|
||||
func(input string) {
|
||||
results, err := ParseJSONIterative(input, true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
Expect(results).To(BeEmpty(),
|
||||
"ParseJSONIterative(%q) should return no results — the partial input has no anchor", input)
|
||||
},
|
||||
Entry("just an opening brace", `{`),
|
||||
Entry("brace + quote", `{"`),
|
||||
)
|
||||
|
||||
It("returns a clean tool call once the JSON has a real name (issue #9988)", func() {
|
||||
results, err := ParseJSONIterative(`{"name":"answer","arguments":{"message":"Hi"}}`, true)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0]).To(HaveKeyWithValue("name", "answer"))
|
||||
for k := range results[0] {
|
||||
Expect(k).NotTo(MatchRegexp(`^[A-Za-z]?\d{6,}$`),
|
||||
"healing marker leaked as key %q", k)
|
||||
}
|
||||
})
|
||||
|
||||
It("strips healing-marker keys even when a real name is present (issue #9988)", func() {
|
||||
// `{"name":"answer"` with no closing brace healed into a stub
|
||||
// with both `name:"answer"` AND a marker-only key. The marker
|
||||
// key must not surface.
|
||||
parser := NewChatMsgParser(`{"name":"answer"`, true)
|
||||
parser.SetHealingMarker("$marker$")
|
||||
jsonValue, isPartial, _, err := parser.TryConsumeJSON()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(isPartial).To(BeTrue())
|
||||
obj, ok := jsonValue.(map[string]any)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(obj).To(HaveKeyWithValue("name", "answer"))
|
||||
for k := range obj {
|
||||
Expect(k).NotTo(ContainSubstring("$marker$"),
|
||||
"healing marker leaked into key %q", k)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Comprehensive JSON partial parsing tests (matching llama.cpp)", func() {
|
||||
|
||||
240
swagger/docs.go
240
swagger/docs.go
@@ -1121,6 +1121,117 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/pii/decide": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"pii"
|
||||
],
|
||||
"summary": "Scan text for PII and return findings + suggested action (decision oracle)",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "decide params",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.PIIDecideRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.PIIDecideResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/router/decide": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"router"
|
||||
],
|
||||
"summary": "Classify a prompt against a router model's policies (decision oracle)",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "decide params",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.RouterDecideRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.RouterDecideResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/traces": {
|
||||
"get": {
|
||||
"description": "Returns captured API exchange traces (request/response pairs) in reverse chronological order",
|
||||
@@ -3286,7 +3397,6 @@ const docTemplate = `{
|
||||
"downloaded_size": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {},
|
||||
"file_name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4709,27 +4819,6 @@ const docTemplate = `{
|
||||
"description": "The message role",
|
||||
"type": "string"
|
||||
},
|
||||
"string_audios": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"string_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"string_images": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"string_videos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tool_call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5323,6 +5412,10 @@ const docTemplate = `{
|
||||
}
|
||||
]
|
||||
},
|
||||
"max_completion_tokens": {
|
||||
"description": "MaxCompletionTokens is the modern alias for max_tokens\n(OpenAI deprecated max_tokens; gpt-5 / o-series reject it).\nAccepted on the wire so up-to-date clients can use the new\nname; the request middleware collapses it into Maxtokens so\ninternal code reads exactly one field.",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_tokens": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -5654,6 +5747,109 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.PIIDecideRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"description": "Text is the user-visible content to inspect. Required.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.PIIDecideResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"findings": {
|
||||
"description": "Findings is one entry per matched span — pattern id, byte\nrange, and audit-safe hash prefix (never the matched value).",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/schema.PIIFinding"
|
||||
}
|
||||
},
|
||||
"redacted_preview": {
|
||||
"description": "RedactedPreview is the input with mask-action spans replaced\nby their placeholders. Identical to Text when no findings or\nwhen the strongest action is block/route_local (which don't\nrewrite content).",
|
||||
"type": "string"
|
||||
},
|
||||
"suggested_action": {
|
||||
"description": "SuggestedAction is the strongest action across all findings:\n\"block\", \"route_local\", \"mask\", or \"allow\" (no findings).",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.PIIFinding": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hash_prefix": {
|
||||
"type": "string"
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string"
|
||||
},
|
||||
"start": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.RouterDecideRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"description": "Input is the user-visible prompt text to classify. Required.\nSchema-shape extraction (chat-message concatenation, etc.) is\nthe caller's responsibility — matches the Probe contract used\nby the in-band middleware.",
|
||||
"type": "string"
|
||||
},
|
||||
"router": {
|
||||
"description": "Router is the name of the router model (a ModelConfig with a\n` + "`" + `router:` + "`" + ` block). Required.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.RouterDecideResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cache_similarity": {
|
||||
"description": "CacheSimilarity carries the cosine similarity of the cache hit\n(0 when not cached).",
|
||||
"type": "number"
|
||||
},
|
||||
"cached": {
|
||||
"description": "Cached is true when the decision came from the L2 embedding\ncache rather than a fresh classifier run.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"candidate": {
|
||||
"description": "Candidate is the model that would be routed to. Empty when no\ncandidate covers Labels AND no fallback is configured.",
|
||||
"type": "string"
|
||||
},
|
||||
"classifier": {
|
||||
"description": "Classifier is the classifier name that produced the decision\n(e.g. \"score\").",
|
||||
"type": "string"
|
||||
},
|
||||
"fallback": {
|
||||
"description": "Fallback is true when Candidate is the router's configured\nfallback because no candidate covered Labels. Lets callers\ndistinguish \"matched\" from \"fell back\" without comparing names.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"labels": {
|
||||
"description": "Labels is the set of active policy labels.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"latency_ms": {
|
||||
"description": "LatencyMs is the classifier's wall-clock cost.",
|
||||
"type": "integer"
|
||||
},
|
||||
"router": {
|
||||
"description": "Router echoes the requested router model.",
|
||||
"type": "string"
|
||||
},
|
||||
"score": {
|
||||
"description": "Score is the top label's softmax probability (the\nclassifier-side confidence signal).",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.StreamOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1118,6 +1118,117 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/pii/decide": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"pii"
|
||||
],
|
||||
"summary": "Scan text for PII and return findings + suggested action (decision oracle)",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "decide params",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.PIIDecideRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.PIIDecideResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/router/decide": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"router"
|
||||
],
|
||||
"summary": "Classify a prompt against a router model's policies (decision oracle)",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "decide params",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.RouterDecideRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.RouterDecideResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/traces": {
|
||||
"get": {
|
||||
"description": "Returns captured API exchange traces (request/response pairs) in reverse chronological order",
|
||||
@@ -3283,7 +3394,6 @@
|
||||
"downloaded_size": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {},
|
||||
"file_name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4706,27 +4816,6 @@
|
||||
"description": "The message role",
|
||||
"type": "string"
|
||||
},
|
||||
"string_audios": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"string_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"string_images": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"string_videos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tool_call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5320,6 +5409,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"max_completion_tokens": {
|
||||
"description": "MaxCompletionTokens is the modern alias for max_tokens\n(OpenAI deprecated max_tokens; gpt-5 / o-series reject it).\nAccepted on the wire so up-to-date clients can use the new\nname; the request middleware collapses it into Maxtokens so\ninternal code reads exactly one field.",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_tokens": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -5651,6 +5744,109 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.PIIDecideRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"description": "Text is the user-visible content to inspect. Required.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.PIIDecideResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"findings": {
|
||||
"description": "Findings is one entry per matched span — pattern id, byte\nrange, and audit-safe hash prefix (never the matched value).",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/schema.PIIFinding"
|
||||
}
|
||||
},
|
||||
"redacted_preview": {
|
||||
"description": "RedactedPreview is the input with mask-action spans replaced\nby their placeholders. Identical to Text when no findings or\nwhen the strongest action is block/route_local (which don't\nrewrite content).",
|
||||
"type": "string"
|
||||
},
|
||||
"suggested_action": {
|
||||
"description": "SuggestedAction is the strongest action across all findings:\n\"block\", \"route_local\", \"mask\", or \"allow\" (no findings).",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.PIIFinding": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hash_prefix": {
|
||||
"type": "string"
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string"
|
||||
},
|
||||
"start": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.RouterDecideRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"description": "Input is the user-visible prompt text to classify. Required.\nSchema-shape extraction (chat-message concatenation, etc.) is\nthe caller's responsibility — matches the Probe contract used\nby the in-band middleware.",
|
||||
"type": "string"
|
||||
},
|
||||
"router": {
|
||||
"description": "Router is the name of the router model (a ModelConfig with a\n`router:` block). Required.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.RouterDecideResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cache_similarity": {
|
||||
"description": "CacheSimilarity carries the cosine similarity of the cache hit\n(0 when not cached).",
|
||||
"type": "number"
|
||||
},
|
||||
"cached": {
|
||||
"description": "Cached is true when the decision came from the L2 embedding\ncache rather than a fresh classifier run.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"candidate": {
|
||||
"description": "Candidate is the model that would be routed to. Empty when no\ncandidate covers Labels AND no fallback is configured.",
|
||||
"type": "string"
|
||||
},
|
||||
"classifier": {
|
||||
"description": "Classifier is the classifier name that produced the decision\n(e.g. \"score\").",
|
||||
"type": "string"
|
||||
},
|
||||
"fallback": {
|
||||
"description": "Fallback is true when Candidate is the router's configured\nfallback because no candidate covered Labels. Lets callers\ndistinguish \"matched\" from \"fell back\" without comparing names.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"labels": {
|
||||
"description": "Labels is the set of active policy labels.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"latency_ms": {
|
||||
"description": "LatencyMs is the classifier's wall-clock cost.",
|
||||
"type": "integer"
|
||||
},
|
||||
"router": {
|
||||
"description": "Router echoes the requested router model.",
|
||||
"type": "string"
|
||||
},
|
||||
"score": {
|
||||
"description": "Score is the top label's softmax probability (the\nclassifier-side confidence signal).",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.StreamOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -244,7 +244,6 @@ definitions:
|
||||
type: boolean
|
||||
downloaded_size:
|
||||
type: string
|
||||
error: {}
|
||||
file_name:
|
||||
type: string
|
||||
file_size:
|
||||
@@ -1226,20 +1225,6 @@ definitions:
|
||||
role:
|
||||
description: The message role
|
||||
type: string
|
||||
string_audios:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
string_content:
|
||||
type: string
|
||||
string_images:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
string_videos:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
tool_call_id:
|
||||
type: string
|
||||
tool_calls:
|
||||
@@ -1636,6 +1621,14 @@ definitions:
|
||||
OpenAI API logprobs parameters
|
||||
logprobs: boolean - if true, returns log probabilities of each output token
|
||||
top_logprobs: integer 0-20 - number of most likely tokens to return at each token position
|
||||
max_completion_tokens:
|
||||
description: |-
|
||||
MaxCompletionTokens is the modern alias for max_tokens
|
||||
(OpenAI deprecated max_tokens; gpt-5 / o-series reject it).
|
||||
Accepted on the wire so up-to-date clients can use the new
|
||||
name; the request middleware collapses it into Maxtokens so
|
||||
internal code reads exactly one field.
|
||||
type: integer
|
||||
max_tokens:
|
||||
type: integer
|
||||
messages:
|
||||
@@ -1872,6 +1865,105 @@ definitions:
|
||||
$ref: '#/definitions/schema.NodeData'
|
||||
type: array
|
||||
type: object
|
||||
schema.PIIDecideRequest:
|
||||
properties:
|
||||
text:
|
||||
description: Text is the user-visible content to inspect. Required.
|
||||
type: string
|
||||
type: object
|
||||
schema.PIIDecideResponse:
|
||||
properties:
|
||||
findings:
|
||||
description: |-
|
||||
Findings is one entry per matched span — pattern id, byte
|
||||
range, and audit-safe hash prefix (never the matched value).
|
||||
items:
|
||||
$ref: '#/definitions/schema.PIIFinding'
|
||||
type: array
|
||||
redacted_preview:
|
||||
description: |-
|
||||
RedactedPreview is the input with mask-action spans replaced
|
||||
by their placeholders. Identical to Text when no findings or
|
||||
when the strongest action is block/route_local (which don't
|
||||
rewrite content).
|
||||
type: string
|
||||
suggested_action:
|
||||
description: |-
|
||||
SuggestedAction is the strongest action across all findings:
|
||||
"block", "route_local", "mask", or "allow" (no findings).
|
||||
type: string
|
||||
type: object
|
||||
schema.PIIFinding:
|
||||
properties:
|
||||
end:
|
||||
type: integer
|
||||
hash_prefix:
|
||||
type: string
|
||||
pattern:
|
||||
type: string
|
||||
start:
|
||||
type: integer
|
||||
type: object
|
||||
schema.RouterDecideRequest:
|
||||
properties:
|
||||
input:
|
||||
description: |-
|
||||
Input is the user-visible prompt text to classify. Required.
|
||||
Schema-shape extraction (chat-message concatenation, etc.) is
|
||||
the caller's responsibility — matches the Probe contract used
|
||||
by the in-band middleware.
|
||||
type: string
|
||||
router:
|
||||
description: |-
|
||||
Router is the name of the router model (a ModelConfig with a
|
||||
`router:` block). Required.
|
||||
type: string
|
||||
type: object
|
||||
schema.RouterDecideResponse:
|
||||
properties:
|
||||
cache_similarity:
|
||||
description: |-
|
||||
CacheSimilarity carries the cosine similarity of the cache hit
|
||||
(0 when not cached).
|
||||
type: number
|
||||
cached:
|
||||
description: |-
|
||||
Cached is true when the decision came from the L2 embedding
|
||||
cache rather than a fresh classifier run.
|
||||
type: boolean
|
||||
candidate:
|
||||
description: |-
|
||||
Candidate is the model that would be routed to. Empty when no
|
||||
candidate covers Labels AND no fallback is configured.
|
||||
type: string
|
||||
classifier:
|
||||
description: |-
|
||||
Classifier is the classifier name that produced the decision
|
||||
(e.g. "score").
|
||||
type: string
|
||||
fallback:
|
||||
description: |-
|
||||
Fallback is true when Candidate is the router's configured
|
||||
fallback because no candidate covered Labels. Lets callers
|
||||
distinguish "matched" from "fell back" without comparing names.
|
||||
type: boolean
|
||||
labels:
|
||||
description: Labels is the set of active policy labels.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
latency_ms:
|
||||
description: LatencyMs is the classifier's wall-clock cost.
|
||||
type: integer
|
||||
router:
|
||||
description: Router echoes the requested router model.
|
||||
type: string
|
||||
score:
|
||||
description: |-
|
||||
Score is the top label's softmax probability (the
|
||||
classifier-side confidence signal).
|
||||
type: number
|
||||
type: object
|
||||
schema.StreamOptions:
|
||||
properties:
|
||||
include_usage:
|
||||
@@ -2984,6 +3076,79 @@ paths:
|
||||
summary: Show the P2P token
|
||||
tags:
|
||||
- p2p
|
||||
/api/pii/decide:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: decide params
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/schema.PIIDecideRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/schema.PIIDecideResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Scan text for PII and return findings + suggested action (decision
|
||||
oracle)
|
||||
tags:
|
||||
- pii
|
||||
/api/router/decide:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: decide params
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/schema.RouterDecideRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/schema.RouterDecideResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Classify a prompt against a router model's policies (decision oracle)
|
||||
tags:
|
||||
- router
|
||||
/api/traces:
|
||||
get:
|
||||
description: Returns captured API exchange traces (request/response pairs) in
|
||||
|
||||
Reference in New Issue
Block a user