mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-30 01:58:45 -04:00
* chore(backends): remove the liquid-audio Python backend The backend targets liquid-audio, which loads LFM2 checkpoints only. Remove its directory, the gallery file and the LFM2.5-Audio model entries, the library and generated backend gallery entries, the backend matrix and CI job, the Makefile targets and the golden test fixtures that listed it. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(config): drop liquid-audio from capabilities, importers and UI With the liquid-audio backend gone, remove its capability entry, its importer, its artifact-gate entry and the realtime_audio heuristic that only matched that backend. realtime_audio is now set only through known_usecases. Update the Talk and fine-tuning pages, the docs and the swagger text that named the backend. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(llama-cpp): bump llama.cpp to e486a4b5 Move the llama.cpp pin to e486a4b559617dc88bf79978996f6c670b6bbc9d, the master head of 2026-10-28. It contains the LFM3-Audio support (ggml-org/llama.cpp#31257) and the realtime server split (ggml-org/llama.cpp#31269). The score task patch is regenerated against the new pin. It applies with git apply and with patch -p1, with no offset and no fuzz. Its hunks in server-task.h and server-context.cpp were rebased by hand next to SERVER_TASK_TYPE_REALTIME and the control dispatch. The C++ backend was built on CPU for this change: the llama, mtmd and server-context libraries and the grpc-server binary link. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(llama-cpp): compile server-realtime.cpp into grpc-server server-context.cpp includes server-realtime.h, and its realtime slot type is defined in server-realtime.cpp. Upstream compiles that file into the server library. The gRPC server is one translation unit, so it must include the file too, or the link fails. The include is guarded by __has_include. Forks that predate the realtime split do not ship the file, so they keep building as before. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(llama-cpp): probe realtime and TTS voice support in prepare.sh prepare.sh writes llama_compat.h from probes on the checkout. Add two: LOCALAI_HAS_REALTIME (SERVER_TASK_TYPE_REALTIME in server-task.h) and LOCALAI_HAS_TTS_VOICE (set_voice in mtmd-helper.h). The probes let the gRPC server choose the realtime and voice code paths per checkout, so forks without them keep compiling. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(llama-cpp): advertise speech recognition and speech-to-speech llama-cpp now registers the AudioTranscription and AudioToAudioStream RPCs, with the transcript and realtime_audio usecases. The default usecase stays chat, so a plain GGUF is still a chat model. The backend implements the RPCs in a follow-up change; until then the calls return UNIMPLEMENTED. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(config): add pipeline.any_to_any for speech-to-speech models A realtime_audio model that serves the whole speech loop itself sets pipeline.any_to_any. The flag is off by default, so chained pipelines and existing realtime_audio configs are unchanged. The realtime_audio heuristic stays explicit: a llama-cpp model is never a Talk-page model unless known_usecases says so. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(config): read the audio encoder and speech-to-speech projector flags Add HasAudioEncoderProjector for clip.has_audio_encoder and HasSpeechToSpeechProjector, which requires the LFM3 audio encoder family together with the gen-audio decoder. Qwen3-TTS sets both encoder flags too, so the flags alone cannot tell it apart from LFM3. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(importer): import LFM3-Audio projectors as speech-to-speech An mmproj that carries the LFM3 audio encoder and the gen-audio decoder now imports with known_usecases realtime_audio, chat, transcript and tts, pipeline.any_to_any: true, the parallel:2 option and the tokenizer template. Other gen-audio projectors keep the TTS-only result, Qwen3-TTS included. The probe runs against a local HTTP server in the tests. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(llama-cpp): named-voice TTS and the LFM3 ASR prompt Models without a speaker reference (LFM3-Audio) take a named voice through their "tts" chat template instead of a reference file. The request is validated with needs_reference=false, the prompt is rendered with common_chat_get_tts_prompt(), and the voice is set on the gen-audio input. Qwen3-TTS Base keeps the reference-file rule. The branch is guarded by LOCALAI_HAS_TTS_VOICE, so forks without named voices keep the old behaviour. Transcription takes its prompt from common_chat_get_asr_prompt(). LFM3 gets a system turn that names the language and an empty user turn. Qwen3-ASR's preset is unchanged: fixed user text, no system turn, and a caller prompt is still appended after a newline. The preset call is guarded by a new LOCALAI_HAS_ASR_PRESET probe. The pure parts sit in tts_request_options.h and transcription_prompt.h, with standalone tests run by backend/cpp/run-unit-tests.sh. Add the test-extra-backend-llama-cpp-lfm3 target. It runs the tts, ttsstream and transcription specs against LFM3-Audio GGUF weights. It is a manual run and is not wired into CI. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): mock AudioToAudioStream and pending realtime S2S specs The mock backend serves AudioToAudioStream deterministically: session created, commit with RMS, a tool call on the first turn with tools, then transcript pieces and paced 24 kHz tone frames, with response.cancel ending a reply as cancelled. The realtime S2S specs cover the mock and real-weights variants. They skip until the /v1/realtime S2S path lands, so the mock suite stays green in CI. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(llama-cpp): speech-to-speech AudioToAudioStream on the realtime slot Implement AudioToAudioStream on upstream's realtime slot, so a model that answers after the audio (LFM3-Audio) can hold a conversation over the existing duplex RPC. The proto schema does not change: tools travel in the config, tool calls leave as function_call_arguments.done and tool results arrive as a function_call_output item. realtime_session.h keeps what needs no model: the turn text derived from the chat template (with a tool turn), the conversation state, a filter that keeps <tool_call> blocks out of the transcript and drops audio produced inside them, and the translation of slot results into events. grpc-server.cpp wires it to the slot with control tasks and parses calls with the parser the chat template selects. The unit test uses a fake renderer. A ctest-only check renders the real LFM3 template and compares the slot text with the full render. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add the LFM3-Audio-2B entries Two llama-cpp entries for LiquidAI/LFM3-Audio-2B-GGUF, the Q4_K_M default (lfm3-audio-2b) and the Q8_0 build (lfm3-audio-2b-q8), both with the Q8_0 audio projector. Each declares pipeline.any_to_any and the realtime_audio, chat, transcript and tts usecases the importer writes for an LFM3 projector, with parallel:2 and the tokenizer template. The Q4 entry lists the Q8 one as a variant. File sizes and sha256 come from the Hugging Face API for the pinned repo revision. Speaker reference files and the VAD model are not part of the entries: VAD stays optional. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: describe LFM3-Audio speech-to-speech and its gallery entries Adds a speech-to-speech section to the Realtime API page (any_to_any configuration, VAD versus manual commit, tools, transcripts, barge-in, limits), a pipeline.any_to_any row, a llama.cpp speech-to-speech row in the compatibility table, and LFM3-Audio mentions on the TTS and ASR pages. The feature is marked experimental: the behaviour follows the backend session contract and has not been run with real weights. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): run any-to-any models on AudioToAudioStream A realtime_audio model with pipeline.any_to_any now opens one duplex stream to the backend and keeps it for the session, replacing the "any-to-any models not implemented" branch. The chained pipeline is unchanged. The endpoint forwards input audio (with a pre-roll from speech start when a VAD model is set, otherwise every chunk and a client commit), sends response.create, and turns the backend's events into the usual realtime events: audio and transcript deltas, tool calls through the existing function_call items, and a single response.done. A function call output is forwarded before the next response. Barge-in sends response.cancel ahead of the new speech and stops forwarding audio at once. The user's speech is transcribed on the side and reported on the user item; a failure there does not touch the response. Server VAD is on only when pipeline.vad is set. Classifier mode, compaction, streaming.llm, voice gating, sound detection and semantic_vad are rejected for these models. The mock backend now matches the llama.cpp contract (session.updated, the cancel error when idle) and its VAD reports the loud stretches of the audio, so the S2S e2e specs run a VAD turn. The specs are enabled. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): add the real-weights realtime S2S variant and the gRPC s2s spec The realtime S2S specs now check reply audio on real models: duration, loudness, clipping, and keywords found in the model transcript or in a separate ASR of the output audio. run-realtime-lfm3.sh stages the llama-cpp and sherpa-onnx backends, the LFM3-Audio files and the sherpa VAD, ASR and TTS models, then runs the real variant. It is manual and never part of CI: `make test-realtime` still filters out real-models. The backend suite gains an s2s capability (spoken turn, tool call, tool result, spoken answer over AudioToAudioStream) and optional TTS duration and RMS bounds plus a transcript keyword check, all enabled for test-extra-backend-llama-cpp-lfm3. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): expect the idle-cancel error from the mock S2S The mock now answers a response.cancel with no reply running with response_cancel_not_active, as the llama.cpp backend does. The cancel spec still treated that cancel as a no-op and read the error as extra data after the stream ended. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(talk): show any-to-any models as one speech-to-speech model /api/pipeline-models lists a pipeline.any_to_any model with any_to_any set and its optional VAD. A realtime_audio model that is not any-to-any is listed only when it has the four pipeline slots, since the session takes the chained path for it. The Talk page shows a speech-to-speech model as one model: no four-slot view, the VAD slot only when a VAD model is set, and the female and male voices offered. Without a VAD model the server does not detect turns, so the page offers Done speaking, which sends input_audio_buffer.commit and then response.create. Text-only replies stream through response.output_text.delta and .done. self_contained stays in the response for clients built before any_to_any. Assisted-by: Claude:claude-haiku-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): refuse a llama-cpp realtime model without pipeline.any_to_any llama.cpp has no VAD, transcription or TTS RPCs, so filling the empty pipeline slots with the model name failed later with UNIMPLEMENTED. Fail at session start and name the missing setting instead. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): hold manual-commit S2S audio until the commit In manual-commit mode the endpoint streamed every appended chunk to the backend, so input_audio_buffer.clear only emptied the Go buffer while the model kept the audio, and idle audio filled its context between turns. Keep the audio in the input buffer and send it, behind pending text and tool-result items, when the client commits or asks for a response. Frames and controls now go through one ordered write queue, so no gRPC send happens under the audio buffer lock. A tool result created while a VAD turn is open is refused with a clear error. When the stream ends outside a response the session reports it and closes, and a bare EOF from Send reports the status that Recv holds. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): drop the S2S readiness gate and stale comments The gate was always true. Some comments referred to work items that readers of the repository cannot see. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(realtime): add the liquid-audio migration note and the clear semantics Say that the Python liquid-audio backend is gone, which gallery entries replace it and that LFM2.5-Audio has no replacement. Describe what input_audio_buffer.clear drops for speech-to-speech sessions now that manual-commit audio is held back until the commit. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(talk): do not promise turn detection when no VAD model is set The idle note said the server notices when you stop talking. A speech-to-speech model without a VAD model needs Done speaking, so the note now says that. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): make the mock S2S backend keep the llama-cpp session limits The mock accepted what the real backend refuses and hid two endpoint bugs. It now treats output_modalities from the config as a fixed session mode and picks the default per response from the tools set at that time. A fixed text plus audio mode with tools reads the tool call out loud. After the first commit or message, session.update of instructions, voice or tools is an error that does not belong to a response, committing an empty buffer is an error, and response.create while a reply runs is refused. Errors of a refused response.create carry response_scoped in their meta. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): leave the S2S output mode to the backend unless the client sets one The stream config sent text plus audio when the session had no tools yet. The llama-cpp backend keeps a mode from the config as a fixed session mode, and tools added later by session.update never cleared it. The model then read tool calls out loud. Send output_modalities only when the client chose them. The backend picks the default of each response from the tools it has when the response starts. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(llama-cpp): mark the realtime errors that answer a response.create The error event now carries response_scoped: true when it ends the request for a response (a refused response.create, a failed commit made by it, or a stream failure). Other errors, such as a refused session.update or a commit of an empty buffer, are not about a response and one that runs keeps generating. The client needs the difference to know whether a response.done is still coming. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): end an S2S response only on the errors that belong to it Every backend error ended the response in flight, although the backend also reports errors about other things (a refused session.update, a commit of an empty buffer) and goes on generating. The rest of the reply was lost and the next response.create met either a refusal or the leftover events of the old reply. End the response only on an error marked response_scoped, forward the others to the client and keep waiting for response.done. Drop the events buffered by an earlier response when a new one starts, and skip the commit of a turn that forwarded no audio. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): run the S2S input transcription detached from the commit slot The commit waited for the side transcription of the user audio while it held the commit slot, and response.create waits for that slot. The ASR latency was added before every reply, and on a backend with few slots a busy ASR task could stall the reply. The model already has the audio, so run the transcription detached under the session context and release the slot at once. The transcript is still reported on the user item. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(realtime): do not pin the place of the input transcript in the S2S order The transcript of the user audio is reported on the side and may land before or after the first response events. Check the order of the response events without it. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): stop an S2S reader leak and a cancel that waits on the write queue The reader waited on a channel it closes itself when the events buffer was full, so it outlived the session after a turn ended on an error or timed out. It now also stops when the session closes. Stopping a response sent the cancel through the write queue and waited for it before the grace timer started. With backpressure on the stream the wait never ended and nothing read the events meanwhile. The timer now starts first and the cancel is only queued. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): let a chained pipeline run without a VAD model A pipeline that names no pipeline.vad failed to build, because the VAD slot was always loaded, and a new session still started with server_vad. The slot is now optional for chained pipelines as it already is for any-to-any models: the session starts with turn detection off and the client commits each turn. Turning server_vad on for such a session is refused, and a switch to a pipeline without a VAD drops turn detection. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add LFM3-Audio VAD and chained-pipeline entries Add lfm3-audio-2b-vad and -q8-vad (any-to-any with silero-vad-ggml as the turn detector), and lfm3-audio-2b-pipeline, -pipeline-vad and the -q8 forms (the model chained through its own transcription, chat and speech slots, with and without the VAD). The gallery has no dependencies between models, so the VAD entries state that silero-vad-ggml must be installed first. Drop the variants list from lfm3-audio-2b: it let the installer pick the larger Q8 build on any host with enough memory, so a plain install of the Q4 entry did not get Q4. All entries are now independent. Tests cover the entry shapes, the single-build install and their acceptance by the realtime endpoint. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): run the realtime specs for any-to-any and chained shapes The realtime specs covered one any-to-any model with a VAD. They now run for any-to-any and for a chained pipeline, each with and without a VAD, on the mock backend in CI. VAD-less shapes also check that turn detection starts off and that server_vad is refused. The manual real-weights script writes one config per shape that mirrors the gallery entries and runs the four shapes in turn (LFM3_MATRIX picks a subset). Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(realtime): document the LFM3-Audio VAD and chained-pipeline entries List the eight LFM3-Audio gallery entries, explain that silero-vad-ggml must be installed before the -vad entries, describe the chained pipeline on one model and the behaviour of a pipeline without a VAD, and describe the shape matrix of the e2e specs. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(realtime): wait for the detached input transcription in the S2S specs The input transcription runs in its own goroutine, so the transcription completed and failed events can arrive after the response ends. Poll for them instead of reading the event list right after the response. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): release the S2S stream before running server-side tool calls A response held the stream slot until it returned, and it ran the tool calls from inside that scope. A server-side tool starts a follow-up response on the same stream, which then waited for the slot its parent still held, so the client never got response.done. Run the tool calls once the slot is free; the event order is unchanged. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): end an S2S session whose stream dies, and stop queuing after close A stream that died during a response left the session open: later requests failed one by one and VAD frames kept being forwarded. The response now ends the session through the transport, and enqueue drops requests once the session is closed so the write queue cannot grow. The writer is now the only caller of Send and of CloseSend. close() used to call CloseSend while the writer could be inside Send, which gRPC does not allow. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): close the open S2S turn when the VAD stops Switching turn detection to manual in the middle of a VAD turn left the turn forwarding, and the next client commit sent the whole buffer a second time. Stopping the VAD now commits the forwarded audio on the backend and drops it from the input buffer. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): keep a tool result pending while an S2S user turn is open The check that refuses a tool result during a user turn ran when the item arrived, but the item was forwarded later, and the result was marked as sent before the backend took it. If the user spoke in between, the backend refused it and it was never retried. The result is now claimed and queued under the audio lock only when no turn is open, and otherwise stays pending for the next commit or response. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(talk): empty the input buffer for models without a VAD Without voice detection the microphone fills the server's input buffer for the whole session, so Done speaking committed idle silence and the echo of the previous reply. Clear the buffer when the session starts and when a reply ends. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(importer): keep an existing parallel option on LFM3-Audio imports The speech-to-speech import added parallel:2 even when the options already carried another parallel value. Use the same check as the hardware defaults, which also covers n_parallel. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(talk): list chained pipelines that have no VAD /api/pipeline-models required a VAD for a chained pipeline, so the VAD-less LFM3-Audio pipeline entries never showed up on the Talk page. Any pipeline with transcription, LLM and speech slots now qualifies. The Talk page treats every model without a VAD as manual-turn, and the session sheet says so for chained pipelines too. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(llama-cpp): bump llama.cpp to 0ac1a7fd Move the llama.cpp pin to 0ac1a7fd35c127a18c0cbc6c57d12e256a6a385b, the master head of 2026-10-29. It contains tool calls in the realtime server (ggml-org/llama.cpp#31287). The score task patch is regenerated so that it applies with no offset and no fuzz. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(functions): keep a null tool argument null A tagged parameter whose text was "null" came out as an empty string: unmarshalling JSON null into a string succeeds and leaves it empty, so the value was taken for a string. Check the first byte of the JSON value instead. A parameter with the text "null" now becomes JSON null, as in llama.cpp. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(functions): add an lfm3 XML format preset The LFM3 chat template writes a call as <tool_call>, <function=NAME>, one <parameter=KEY> block per argument, then the closing tags. The qwen3-coder format has the same shape and auto-detection already found it, but xml_format_preset: lfm3 was not a known name. Add the preset, with tests for one call, a call without arguments, two calls, string and non-string values (numbers, booleans, null, arrays, objects), a call between text, and unfinished input. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): parse tool calls with the chat template, as upstream Since ggml-org/llama.cpp#31287 the realtime server handles tools with the parser and grammar of the chat template. Do the same in the speech-to-speech stream instead of a hard-coded tag filter and a parse of the whole text at the end. - Each response gets a common_chat_session with the tools of the session and parallel calls. Its grammar and preserved tokens go into the sampling of the response, so the slot keeps the call tokens in the text and constrains the call. - The text of the response is fed to the session piece by piece (task_result_state::update_chat_msg). Content deltas become the transcript; tool-call deltas are collected into one function_call_arguments.done per call at the end. Audio after the start of a call is dropped. - A tool_choice of auto, none or required is accepted in the params of the config, in session.update and in response.create, with the response overriding the session. - The tool turn is derived from a conversation in which the assistant called a tool, like upstream, not from a plain reply. - response.done carries raw_text, everything the model wrote with the call markup, for a caller that parses it another way. If the parser throws, the rest of the response is content and the raw text is the way out. ToolCallFilter is no longer used and goes in the next commit. The unit tests cover the splitter contract (content, call deltas, the final piece, whole characters only). realtime_chat_check feeds LFM3-format text through the session in pieces of 1, 7 and all bytes and checks the call, the transcript and the grammar. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(realtime): drop the tool-call text filter The parser of the chat template now splits content from tool calls, so ToolCallFilter, which looked for the literal <tool_call> tags, has no caller. Remove it with its tests. Every case it covered (a call between text, split tags, several calls, an unfinished call, text after a call) is handled by the parser, and realtime_chat_check runs the LFM3 format through it in pieces of different sizes. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): fall back to the raw text for S2S tool calls For an any-to-any model, the calls the backend parsed still win. When there are none and the request had tools, parse the raw text that response.done now carries the way the chained pipeline parses the text of its LLM (CleanupLLMResult, ParseFunctionCall, ParseTextContent), so a call format the backend parser does not know can still be served with function.xml_format_preset or the other function settings of the model. The content around the calls replaces the streamed transcript only if the config captures it, as in the chained path. Also pass tool_choice to the backend: the session mode with the stream config and with session.update, and the mode of a response.create override with that event. A named function cannot be forced, so it is not sent. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): carry raw_text in the mock S2S response.done Match the llama-cpp session: with tools set, response.done has raw_text, the call markup of the LFM3 template for a tool turn and the transcript for any other reply. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(functions): parse tool blocks that have text between them The iterative XML parser stopped at the first scope block that was preceded by text, so "<tool_call>A</tool_call> then <tool_call>B" gave one call. Skip text between blocks once a call was found; text before the first block still ends the parse as before. Blocks that follow each other, or only whitespace, already worked. Tests cover three calls back to back and with text between them, with the lfm3 preset and with auto-detection. They also pin the current reading of argument values: the parser has no tool schema, so a value such as 42 or true is read as JSON even when the schema types it as a string. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): read LFM3-Audio tool calls with the lfm3 preset Set function.xml_format_preset: lfm3 on the eight LFM3-Audio-2B entries and in the config the importer writes for a speech-to-speech projector, so the server parses the call format of the LFM3 chat template when the backend returns no call (and in the chained pipeline entries, which use the regular chat path). gallery/index.yaml is regenerated from the library. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(realtime): describe how S2S tool calls are read Replace the description of the hard-coded tag filter with the parser and grammar of the chat template, tool_choice, and the raw-text fallback in the server. Document function.xml_format_preset, and set it in the real-weights e2e model configs like the gallery entries do. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(functions): keep earlier calls when a later block does not parse After a valid call, text that holds the opening tag again (a prose mention, a markdown example, a malformed second block) made the parser treat it as a new block, fail, and return no calls at all. Remember the state after each complete block and fall back to it when a later one fails: the calls found so far are kept, parsing stops there and the remaining text stays in the content. Text skipped between two valid blocks now goes to the content instead of being dropped, so StripToolCallMarkup keeps it. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): do not drop audio after a tool call started In mixed mode the speech for text written before a call can still be pending when the call starts, so dropping every audio frame after the first call delta cut the speech while its transcript was sent. The llama.cpp server never drops audio when it reads a result; do the same. If a model speaks the call markup aloud, that is model behaviour to observe on real weights, not something to filter here. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): flush held-back text when a response is cancelled The parser got is_partial=true on a cancelled response, so text it held back (a possible start of a tag) never reached the transcript. The llama.cpp server parses a cancelled response with is_partial=false; do the same. A cancelled response still makes no tool call. The path where the parser throws already sends the text as content and needs no change. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(realtime): stop the fake S2S stream from sending after Close The barge-in spec failed now and then with a data race and "send on closed channel": its script pushes audio from a goroutine, and the session closes the stream while that goroutine still runs. A push now does nothing once the stream is closed, and Close waits for a push in flight. The production close order is right: the writer closes the stream only after it stops sending. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): align the S2S tool-call fallback with the backend Four gaps in how the Go side reads the tool calls of an any-to-any reply: - With tool_choice none (the response value wins over the session value) the backend builds no tool parser, yet the raw-text fallback still turned a call the model wrote into a function_call item. Skip it. - Without a capture regex the fallback never cleaned the transcript, so the stored item and transcript.done kept the raw call markup. When the call is XML, use the text left after the markup is cut out; a reply that is only a call now has an empty transcript. - A named function as tool_choice mapped to nothing, so the backend kept the previous mode. Refuse it with an error in session.update and response.create. - A response-level tools list changed what the fallback looked for, but the backend only has the tools of the session. Use those in both. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(realtime): say how S2S tool_choice and response tools behave Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(e2e): use the real llama-cpp backend in the real realtime run The suite always registered the mock backend under the name llama-cpp, so a run with REALTIME_S2S_MODEL set never reached the llama-cpp backend found in REALTIME_BACKENDS_PATH. Register the mock only when that run is off. The real run converts the input audio with ffmpeg. Check for it in the suite and in run-realtime-lfm3.sh before any download or backend start, with a message that says what to install, and list it as a prerequisite in the script header and the Makefile target. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(realtime): cover the tools path of a client session.update to the S2S backend A tool call that never came back from an any-to-any model raised the question whether the tools of the session reach the backend at all. Add a spec that sends the session.update of the real-weights e2e (flat Responses API tool, no session type) and checks that the backend gets the tool in the chat-completions form. Log the number of tools sent when the stream opens and when a session.update carries them, so a run log shows it without reading the wire. The "No tools received" line of the llama-cpp backend does not show it: the server prints it for the input transcription request, which has no tools by design. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): interrupt the chained pipeline reply while it is in flight With unary stages, the default of the lfm3 pipeline gallery entries, the chained pipeline synthesizes the whole reply and sends it as one audio delta right before response.done. The barge-in spec waited for that delta and then rendered the interruption with the TTS, so the first response was already done when the speech started and ended completed instead of cancelled. Real runs on a GPU showed it with both quantizations. For a pipeline, start the interruption at response.created, which comes before the LLM and TTS stages. Render the interruption audio before the first utterance is sent, so nothing sits between the reply and the interruption. The any-to-any shape still waits for the first audio delta, which that backend streams while it generates. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): accept a number word and its homophone in the spoken-answer check The check that the reply audio says what the transcript says needs a shared word of 4+ letters. A short answer failed it: the model wrote "Four" and the ASR heard "for". Compare numbers in one spoken form, so digits, number words and their homophones match each other. A homophone only counts when the other text has the number itself, so "for" on both sides still shares nothing and the check does not pass on a stop word. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(llama-cpp): ask LFM3-Audio for function calls when a realtime session has tools The default system prompt of a realtime session was "Respond with interleaved text and audio." whatever the tools. Under that prompt LFM3-Audio answers in words ("I'll check the weather ...") and never writes the call. The model card, and the llama.cpp realtime example client, use "Respond with function calls, and interleaved text and audio." when the session has tools, and the model then calls the tool. Pick the default by the tools of the session, and treat the plain default that the LocalAI server sends back as no choice: it follows the tools too, also when they arrive with a session.update before the first audio. A prompt the client wrote is kept as it is. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): transcribe the input of an any-to-any turn between responses The input transcript of a speech-to-speech turn ran at the same time as the reply, on the same model. llama.cpp serves both from one context: the realtime slot wants embeddings on, the chat slot of the transcript wants them off, and each flip makes it reserve its compute buffers again. In a GB10 run that was two reserves of about 175 ms per step, the reply fell to 2.6 tokens per second and the transcript to 380 ms per token, until the transcript was done. When pipeline.transcription is the model of the stream, run the transcript after the response, or before one that is not started yet, and keep responses out while it runs. A separate transcription model still runs in parallel. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): send the input transcript of an any-to-any turn before its reply The previous change moved the same-model transcript after the response. A client that reads until response.done then never saw conversation.item.input_audio_transcription.completed: the transcript was made (the log shows it) but sent after the end of the window. Run it in line with the commit instead. A VAD turn transcribes and then responds. A client response.create already waits for the commit slot, so it starts once the transcript is out. The transcript event now comes before any output of the response of its turn, for a manual commit and for a VAD turn, and never overlaps the response on the llama.cpp context. A transcription model of its own still runs in parallel. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): transcribe the input of an any-to-any model only when asked The stream answers from the audio, so the transcript of the input is for the client only. On the model of the stream it is costly: llama.cpp serves the transcript request and the realtime stream from one context and rebuilds its compute buffers on every step while both are active. On a GB10 that was 26 to 29 reserves of about 175 ms per turn, against 0 to 1 on the llama.cpp server, and the reply ran at a few tokens per second. An any-to-any model no longer fills pipeline.transcription with its own name. Without it the session does not transcribe the input; with another model it does, in parallel with the reply; with the model itself named it does, before the reply. The reply transcript always comes from the stream. The gallery entries set none, so they get the fast path. The mock configs and run-realtime-lfm3.sh name a transcription model, so the real-weights spec still covers the input transcript. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): end a response as cancelled when a barge-in lands during the LLM call The chained pipeline cancels the response context on a barge-in. When that happens while the model is generating, the backend call fails with the context error, and the response ended as failed: an error event and no response.done. A client that waits for the terminal of the interrupted response then waits forever. On a GB10 the q4 model answered before the barge-in arrived, so only the slower q8 model showed it: the spec read events for 30 s and timed out. When the context is cancelled, a failed Predict call or a failed result is a cancel, as the TTS step and the streamed LLM path already treat it. The response ends with response.done{cancelled} and no error. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): wait for the input transcript that comes from a model of its own An any-to-any model now gets its input transcript from a separate ASR that runs next to the reply. The reply is often over first, so a spec that stops reading at response.done saw no transcript in the VAD shape. Keep reading, up to 30 s, until the transcript arrives. The contract is that it comes, not when. Add a guard spec for what the input transcription is handed: the whole turn, in a WAV that says the rate it was recorded at (the session rate for a client commit, 16 kHz for a VAD turn). Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): check the errors of Close in the realtime specs golangci-lint (errcheck) flagged the unchecked Close of the websocket connections, the response body and the fake stream. Use the `defer func() { _ = x.Close() }()` form the other e2e specs use. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(e2e): write the mock S2S specs with Ginkgo and drop the env read forbidigo only allows Ginkgo/Gomega specs and no os.Getenv outside the sanctioned places. Convert the mock S2S tests to Describe/It with a suite bootstrap (the package had none), with the same 11 cases. The mock no longer reads MOCK_S2S_FRAMES: only these specs set it, so the reply length is a package variable they change per spec and restore afterwards. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
5161 lines
248 KiB
C++
5161 lines
248 KiB
C++
// llama.cpp gRPC C++ backend server
|
|
//
|
|
// Ettore Di Giacinto <mudler@localai.io> and llama.cpp authors
|
|
//
|
|
// This is a gRPC server for llama.cpp compatible with the LocalAI proto
|
|
// Note: this is a re-adaptation of the original llama.cpp example/server.cpp for HTTP (https://github.com/ggerganov/llama.cpp/tree/master/examples/server),
|
|
// but modified to work with gRPC
|
|
//
|
|
|
|
#include "server-task.cpp"
|
|
#include "server-queue.cpp"
|
|
#include "server-common.cpp"
|
|
// server-chat.cpp exists only in llama.cpp after the upstream refactor that
|
|
// split OAI/Anthropic/Responses/transcription conversion helpers out of
|
|
// server-common.cpp. When present, server-context.cpp and server-task.cpp
|
|
// above call into it, so we must pull its definitions into this TU or the
|
|
// link fails. __has_include keeps the source compatible with older pins.
|
|
#if __has_include("server-chat.cpp")
|
|
#include "server-chat.cpp"
|
|
#endif
|
|
// server-schema.cpp exists only in llama.cpp after the upstream refactor that
|
|
// extracted the JSON request-schema evaluation (previously the static
|
|
// server_task::params_from_json_cmpl) into server_schema::eval_llama_cmpl_schema.
|
|
// server-context.cpp and grpc-server.cpp both call into it, so its definitions
|
|
// must be part of this translation unit or the link fails. __has_include keeps
|
|
// the source compatible with older pins/forks (e.g. llama-cpp-turboquant) that
|
|
// predate the split and still expose params_from_json_cmpl (see the guarded
|
|
// call sites below).
|
|
#if __has_include("server-schema.cpp")
|
|
#define LOCALAI_HAS_SERVER_SCHEMA 1
|
|
#include "server-schema.cpp"
|
|
#endif
|
|
// server-stream.cpp exists only in llama.cpp after the upstream refactor that
|
|
// added the SSE stream-resumption layer (stream_session/stream_pipe_producer).
|
|
// server-context.cpp calls into it (spipe->cleanup(), stream_aware_should_stop,
|
|
// stream_session_attach_pipe), so its definitions must be part of this
|
|
// translation unit or the link fails with "undefined reference to
|
|
// stream_pipe_producer::cleanup()". The file is self-contained (its only
|
|
// external symbols come from server-common, already pulled in above) and the
|
|
// http route-handler factories it also defines are unused here but harmless.
|
|
// __has_include keeps the source compatible with older pins/forks that predate
|
|
// the split.
|
|
#if __has_include("server-stream.cpp")
|
|
#include "server-stream.cpp"
|
|
#endif
|
|
#if __has_include("server-decision.cpp")
|
|
#define LOCALAI_HAS_NATIVE_DECISIONS 1
|
|
#include "server-decision.cpp"
|
|
#include "decision_compat.h"
|
|
#include "decision_images.h"
|
|
#endif
|
|
// server-realtime.cpp defines server_realtime_slot, which server-context.cpp
|
|
// reaches through server-realtime.h (SERVER_TASK_TYPE_REALTIME). Upstream
|
|
// llama.cpp compiles it into the server library; this TU must carry it too or
|
|
// the link fails. Forks that predate the realtime split do not ship the file,
|
|
// so the include is guarded.
|
|
#if __has_include("server-realtime.cpp")
|
|
#define LOCALAI_HAS_SERVER_REALTIME 1
|
|
#include "server-realtime.cpp"
|
|
#endif
|
|
#include "server-context.cpp"
|
|
|
|
// LocalAI
|
|
|
|
#include "backend.pb.h"
|
|
#include "backend.grpc.pb.h"
|
|
#include "common.h"
|
|
#include "arg.h"
|
|
#include "chat-auto-parser.h"
|
|
#include "llama_compat.h" // fork-skew switches, generated by prepare.sh
|
|
#include "model_load_error.h"
|
|
#include "thread_params.h"
|
|
#include "parallel_params.h"
|
|
#include "slot_cache.h"
|
|
#include "message_content.h"
|
|
#include "moe_cache_option.h"
|
|
#include "passthrough_options.h"
|
|
#include "stream_peer.h"
|
|
#include "realtime_session.h"
|
|
#include "tts_request_options.h"
|
|
#include "transcription_prompt.h"
|
|
#include <getopt.h>
|
|
#include <grpcpp/ext/proto_server_reflection_plugin.h>
|
|
#include <grpcpp/grpcpp.h>
|
|
#include <grpcpp/health_check_service_interface.h>
|
|
#include <grpcpp/security/server_credentials.h>
|
|
#include <regex>
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cmath>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <list>
|
|
#include <map>
|
|
#include <ctime>
|
|
#include <mutex>
|
|
#include <signal.h>
|
|
#include <thread>
|
|
|
|
#if defined(_WIN32)
|
|
#include <windows.h>
|
|
#endif
|
|
|
|
#include "parent_watch.h" // best-effort parent-death backstop (see header)
|
|
|
|
|
|
using grpc::Server;
|
|
using grpc::ServerBuilder;
|
|
using grpc::ServerContext;
|
|
using grpc::Status;
|
|
|
|
#if LOCALAI_HAS_MTMD_INIT_OPT
|
|
#define LOCALAI_MTMD_INIT_OPT_ARG(value) , value
|
|
#else
|
|
#define LOCALAI_MTMD_INIT_OPT_ARG(value)
|
|
#endif
|
|
|
|
// Newer format_prompt_rerank() takes the decoded media of the query and of the
|
|
// document. The gRPC rerank API is text only, so pass empty lists.
|
|
#if LOCALAI_HAS_RERANK_FILES
|
|
#define LOCALAI_RERANK_FILES_ARGS , std::vector<raw_buffer>(), std::vector<raw_buffer>()
|
|
#else
|
|
#define LOCALAI_RERANK_FILES_ARGS
|
|
#endif
|
|
|
|
// gRPC bearer token auth for distributed mode.
|
|
// Reads LOCALAI_GRPC_AUTH_TOKEN from the environment. When set, rejects
|
|
// requests without a matching "authorization: Bearer <token>" metadata header.
|
|
|
|
// Cached auth token — empty means auth is disabled.
|
|
// Newer llama.cpp returns common_chat_params::prompt as a common_chat_prompt
|
|
// (use .str()); older forks such as turboquant still return std::string.
|
|
static inline std::string chat_prompt_str(const std::string & p) { return p; }
|
|
template <typename T>
|
|
static inline std::string chat_prompt_str(const T & p) { return p.str(); }
|
|
|
|
static std::string g_grpc_auth_token;
|
|
|
|
// Minimal constant-time comparison (avoids OpenSSL dependency)
|
|
static int ct_memcmp(const void* a, const void* b, size_t n) {
|
|
const unsigned char* pa = static_cast<const unsigned char*>(a);
|
|
const unsigned char* pb = static_cast<const unsigned char*>(b);
|
|
unsigned char result = 0;
|
|
for (size_t i = 0; i < n; i++) {
|
|
result |= pa[i] ^ pb[i];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Returns OK when auth is disabled or the token matches.
|
|
static grpc::Status checkAuth(grpc::ServerContext* context) {
|
|
if (g_grpc_auth_token.empty()) {
|
|
return grpc::Status::OK;
|
|
}
|
|
auto metadata = context->client_metadata();
|
|
auto it = metadata.find("authorization");
|
|
if (it != metadata.end()) {
|
|
std::string expected = "Bearer " + g_grpc_auth_token;
|
|
std::string got(it->second.data(), it->second.size());
|
|
if (expected.size() == got.size() &&
|
|
ct_memcmp(expected.data(), got.data(), expected.size()) == 0) {
|
|
return grpc::Status::OK;
|
|
}
|
|
}
|
|
return grpc::Status(grpc::StatusCode::UNAUTHENTICATED, "invalid token");
|
|
}
|
|
|
|
// Minimal base64 encoder. The C++ backend already pulls in base64_decode from
|
|
// llama.cpp's server-common.cpp, but no encoder is exposed — and we need one to
|
|
// hand audio bytes to the existing PredictOptions.audios path (which expects
|
|
// base64-encoded strings, just like images).
|
|
static std::string base64_encode_bytes(const unsigned char* data, size_t len) {
|
|
static const char tbl[] =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
std::string out;
|
|
out.reserve(((len + 2) / 3) * 4);
|
|
for (size_t i = 0; i < len; i += 3) {
|
|
uint32_t triple = (uint32_t(data[i]) << 16);
|
|
if (i + 1 < len) triple |= (uint32_t(data[i + 1]) << 8);
|
|
if (i + 2 < len) triple |= uint32_t(data[i + 2]);
|
|
out.push_back(tbl[(triple >> 18) & 0x3F]);
|
|
out.push_back(tbl[(triple >> 12) & 0x3F]);
|
|
out.push_back(i + 1 < len ? tbl[(triple >> 6) & 0x3F] : '=');
|
|
out.push_back(i + 2 < len ? tbl[triple & 0x3F] : '=');
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// END LocalAI
|
|
|
|
|
|
/////////////////////////////////
|
|
////////////////////////////////
|
|
//////// LOCALAI code starts below here
|
|
/////////////////////////////////
|
|
////////////////////////////////
|
|
|
|
bool loaded_model; // TODO: add a mutex for this, but happens only once loading the model
|
|
|
|
static std::function<void(int)> shutdown_handler;
|
|
static std::atomic_flag is_terminating = ATOMIC_FLAG_INIT;
|
|
|
|
static inline void signal_handler(int signal) {
|
|
if (is_terminating.test_and_set()) {
|
|
// in case it hangs, we can force terminate the server by hitting Ctrl+C twice
|
|
// this is for better developer experience, we can remove when the server is stable enough
|
|
fprintf(stderr, "Received second interrupt, terminating immediately.\n");
|
|
exit(1);
|
|
}
|
|
|
|
shutdown_handler(signal);
|
|
}
|
|
|
|
// Forward declarations
|
|
static void start_llama_server(server_context& ctx_server);
|
|
static json parse_options(bool streaming, const backend::PredictOptions* predict, const common_params& params_base, llama_context* ctx);
|
|
static ggml_type kv_cache_type_from_str(const std::string & s);
|
|
static std::string get_all_kv_cache_types();
|
|
static void add_rpc_devices(std::string servers);
|
|
static void params_parse(server_context& ctx_server, const backend::ModelOptions* request, common_params & params);
|
|
|
|
static void start_llama_server(server_context& ctx_server) {
|
|
|
|
LOG_INF("%s: starting llama server\n", __func__);
|
|
|
|
LOG_INF("%s: waiting for model to be loaded\n", __func__);
|
|
// Wait for model to be loaded first
|
|
while (!loaded_model) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
}
|
|
|
|
LOG_INF("%s: model loaded\n", __func__);
|
|
|
|
// print sample chat example to make it clear which template is used
|
|
// LOG_INF("%s: chat template, chat_template: %s, example_format: '%s'\n", __func__,
|
|
// common_chat_templates_source(ctx_server.impl->chat_params.tmpls.get()),
|
|
// common_chat_format_example(ctx_server.impl->chat_params.tmpls.get(), ctx_server.impl->params_base.use_jinja).c_str(), ctx_server.impl->params_base.default_template_kwargs);
|
|
|
|
// Keep the chat templates initialized in load_model() so they can be used when UseTokenizerTemplate is enabled
|
|
// Templates will only be used conditionally in Predict/PredictStream when UseTokenizerTemplate is true and Messages are provided
|
|
|
|
shutdown_handler = [&](int) {
|
|
// this will unblock start_loop()
|
|
ctx_server.terminate();
|
|
};
|
|
|
|
// TODO: refactor in common/console
|
|
#if defined (__unix__) || (defined (__APPLE__) && defined (__MACH__))
|
|
struct sigaction sigint_action;
|
|
sigint_action.sa_handler = signal_handler;
|
|
sigemptyset (&sigint_action.sa_mask);
|
|
sigint_action.sa_flags = 0;
|
|
sigaction(SIGINT, &sigint_action, NULL);
|
|
sigaction(SIGTERM, &sigint_action, NULL);
|
|
#elif defined (_WIN32)
|
|
auto console_ctrl_handler = +[](DWORD ctrl_type) -> BOOL {
|
|
return (ctrl_type == CTRL_C_EVENT) ? (signal_handler(SIGINT), true) : false;
|
|
};
|
|
SetConsoleCtrlHandler(reinterpret_cast<PHANDLER_ROUTINE>(console_ctrl_handler), true);
|
|
#endif
|
|
|
|
// this call blocks the main thread until ctx_server.terminate() is called
|
|
ctx_server.start_loop();
|
|
}
|
|
|
|
json parse_options(bool streaming, const backend::PredictOptions* predict, const common_params& params_base, llama_context* ctx)
|
|
{
|
|
|
|
// Create now a json data from the prediction options instead
|
|
//
|
|
json data;
|
|
data["stream"] = streaming;
|
|
data["cache_prompt"] = predict->promptcacheall();
|
|
data["n_predict"] = predict->tokens() == 0 ? -1 : predict->tokens();
|
|
data["top_k"] = predict->topk();
|
|
data["top_p"] = predict->topp();
|
|
data["typical_p"] = predict->typicalp();
|
|
data["temperature"] = predict->temperature();
|
|
data["repeat_last_n"] = predict->repeat();
|
|
// PredictOptions.Penalty is a bare proto float, so a caller that names no
|
|
// repetition penalty sends 0 rather than omitting the field. Since
|
|
// llama.cpp 9de0fcf2b, common_sampler_init() rejects a non-positive
|
|
// penalty_repeat outright (it would divide logits by zero), which turned
|
|
// every such request into "Failed to initialize samplers". Treat 0 as
|
|
// "unset" and leave llama.cpp's own neutral default in place.
|
|
if (predict->penalty() > 0.0f) {
|
|
data["repeat_penalty"] = predict->penalty();
|
|
}
|
|
data["frequency_penalty"] = predict->frequencypenalty();
|
|
data["presence_penalty"] = predict->presencepenalty();
|
|
data["mirostat"] = predict->mirostat();
|
|
data["mirostat_tau"] = predict->mirostattau();
|
|
data["mirostat_eta"] = predict->mirostateta();
|
|
data["n_keep"] = predict->nkeep();
|
|
data["seed"] = predict->seed();
|
|
data["min_p"] = predict->minp();
|
|
|
|
|
|
std::string grammar_str = predict->grammar();
|
|
|
|
|
|
|
|
if (!grammar_str.empty()) {
|
|
data["grammar"] = grammar_str;
|
|
SRV_INF("Using grammar: %s\n", grammar_str.c_str());
|
|
}
|
|
|
|
// Only set prompt if UseTokenizerTemplate is false or if no Messages are provided
|
|
// When UseTokenizerTemplate is true and Messages are provided, prompt will be set via chat templates in Predict/PredictStream
|
|
if (!predict->usetokenizertemplate() || predict->messages_size() == 0) {
|
|
data["prompt"] = predict->prompt();
|
|
}
|
|
|
|
// Extract tools and tool_choice from proto and add to data JSON
|
|
SRV_INF("[TOOLS DEBUG] parse_options: Checking for tools in proto, tools().empty()=%d, tools().size()=%zu\n",
|
|
predict->tools().empty() ? 1 : 0, predict->tools().size());
|
|
if (!predict->tools().empty()) {
|
|
SRV_INF("[TOOLS DEBUG] parse_options: Tools string from proto (first 500 chars): %s\n",
|
|
predict->tools().substr(0, std::min<size_t>(500, predict->tools().size())).c_str());
|
|
try {
|
|
// Parse tools JSON string and add to data
|
|
json tools_json = json::parse(predict->tools());
|
|
data["tools"] = tools_json;
|
|
SRV_INF("Extracted tools from proto: %s\n", predict->tools().c_str());
|
|
// Debug: Log tools count and names
|
|
if (tools_json.is_array()) {
|
|
SRV_INF("[TOOLS DEBUG] parse_options: Successfully parsed %zu tools from Go layer\n", tools_json.size());
|
|
for (size_t i = 0; i < tools_json.size(); i++) {
|
|
if (tools_json[i].contains("function") && tools_json[i]["function"].contains("name")) {
|
|
SRV_INF("[TOOLS DEBUG] parse_options: Tool %zu: %s\n", i, tools_json[i]["function"]["name"].get<std::string>().c_str());
|
|
} else if (tools_json[i].contains("name")) {
|
|
SRV_INF("[TOOLS DEBUG] parse_options: Tool %zu: %s\n", i, tools_json[i]["name"].get<std::string>().c_str());
|
|
}
|
|
}
|
|
} else {
|
|
SRV_WRN("[TOOLS DEBUG] parse_options: Parsed tools JSON is not an array: %s\n", tools_json.dump().c_str());
|
|
}
|
|
} catch (const common_json_error& e) {
|
|
SRV_WRN("Failed to parse tools JSON from proto: %s\n", e.what());
|
|
SRV_WRN("[TOOLS DEBUG] parse_options: Tools string that failed to parse: %s\n", predict->tools().c_str());
|
|
}
|
|
} else {
|
|
SRV_INF("%s", "[TOOLS DEBUG] parse_options: No tools received from Go layer (predict->tools() is empty)\n");
|
|
}
|
|
|
|
// Debug: Verify tools are in data after extraction
|
|
if (data.contains("tools")) {
|
|
SRV_INF("[TOOLS DEBUG] parse_options: Tools successfully added to data, count: %zu\n",
|
|
data["tools"].is_array() ? data["tools"].size() : 0);
|
|
} else {
|
|
SRV_INF("%s", "[TOOLS DEBUG] parse_options: WARNING - Tools NOT in data after extraction!\n");
|
|
}
|
|
if (!predict->toolchoice().empty()) {
|
|
try {
|
|
// Parse tool_choice JSON string
|
|
json tool_choice_json = json::parse(predict->toolchoice());
|
|
// tool_choice can be a string ("auto", "none", "required") or an object
|
|
// Store it as-is (string or object) so we can convert object to "required" later when adding to body_json
|
|
if (tool_choice_json.is_string()) {
|
|
data["tool_choice"] = tool_choice_json.get<std::string>();
|
|
SRV_DBG("[TOOLS DEBUG] Received tool_choice from Go layer: %s\n", tool_choice_json.get<std::string>().c_str());
|
|
} else {
|
|
// Store object as-is so we can detect it later and convert to "required"
|
|
data["tool_choice"] = tool_choice_json;
|
|
SRV_DBG("[TOOLS DEBUG] Received tool_choice object from Go layer: %s\n", tool_choice_json.dump().c_str());
|
|
}
|
|
SRV_INF("Extracted tool_choice from proto: %s\n", predict->toolchoice().c_str());
|
|
} catch (const common_json_error& e) {
|
|
// If parsing fails, treat as string
|
|
data["tool_choice"] = predict->toolchoice();
|
|
SRV_INF("Extracted tool_choice as string: %s\n", predict->toolchoice().c_str());
|
|
}
|
|
}
|
|
|
|
// Extract logprobs and top_logprobs from proto and add to JSON data
|
|
// Following server.cpp pattern: logprobs maps to n_probs when provided
|
|
if (predict->logprobs() > 0) {
|
|
data["logprobs"] = predict->logprobs();
|
|
// Map logprobs to n_probs (following server.cpp line 369 pattern)
|
|
// n_probs will be set by params_from_json_cmpl if logprobs is provided
|
|
data["n_probs"] = predict->logprobs();
|
|
SRV_INF("Using logprobs: %d\n", predict->logprobs());
|
|
}
|
|
if (predict->toplogprobs() > 0) {
|
|
data["top_logprobs"] = predict->toplogprobs();
|
|
SRV_INF("Using top_logprobs: %d\n", predict->toplogprobs());
|
|
}
|
|
|
|
// Extract logit_bias from proto and add to JSON data
|
|
if (!predict->logitbias().empty()) {
|
|
try {
|
|
// Parse logit_bias JSON string from proto
|
|
json logit_bias_json = json::parse(predict->logitbias());
|
|
// Add to data - llama.cpp server expects it as an object (map)
|
|
data["logit_bias"] = logit_bias_json;
|
|
SRV_INF("Using logit_bias: %s\n", predict->logitbias().c_str());
|
|
} catch (const common_json_error& e) {
|
|
SRV_ERR("Failed to parse logit_bias JSON from proto: %s\n", e.what());
|
|
}
|
|
}
|
|
|
|
data["ignore_eos"] = predict->ignoreeos();
|
|
data["embeddings"] = predict->embeddings();
|
|
|
|
// Speculative decoding per-request overrides
|
|
// NDraft maps to speculative.n_max (maximum draft tokens per speculation step)
|
|
if (predict->ndraft() > 0) {
|
|
data["speculative.n_max"] = predict->ndraft();
|
|
}
|
|
|
|
// Add the correlationid to json data
|
|
data["correlation_id"] = predict->correlationid();
|
|
|
|
// for each image in the request, add the image data
|
|
//
|
|
for (int i = 0; i < predict->images_size(); i++) {
|
|
data["image_data"].push_back(json
|
|
{
|
|
{"id", i},
|
|
{"data", predict->images(i)},
|
|
});
|
|
}
|
|
|
|
// for each audio in the request, add the audio data
|
|
for (int i = 0; i < predict->audios_size(); i++) {
|
|
data["audio_data"].push_back(json
|
|
{
|
|
{"id", i},
|
|
{"data", predict->audios(i)},
|
|
});
|
|
}
|
|
|
|
// for each video in the request, add the video data
|
|
for (int i = 0; i < predict->videos_size(); i++) {
|
|
data["video_data"].push_back(json
|
|
{
|
|
{"id", i},
|
|
{"data", predict->videos(i)},
|
|
});
|
|
}
|
|
|
|
data["stop"] = json::array();
|
|
for (const auto & stop : predict->stopprompts()) {
|
|
data["stop"].push_back(stop);
|
|
}
|
|
// data["n_probs"] = predict->nprobs();
|
|
//TODO: images,
|
|
|
|
// Serialize grammar triggers from server context to JSON array
|
|
if (!params_base.sampling.grammar_triggers.empty()) {
|
|
json grammar_triggers = json::array();
|
|
for (const auto& trigger : params_base.sampling.grammar_triggers) {
|
|
json trigger_json;
|
|
trigger_json["value"] = trigger.value;
|
|
// Always serialize as WORD type since upstream converts WORD to TOKEN internally
|
|
trigger_json["type"] = static_cast<int>(COMMON_GRAMMAR_TRIGGER_TYPE_WORD);
|
|
grammar_triggers.push_back(trigger_json);
|
|
}
|
|
data["grammar_triggers"] = grammar_triggers;
|
|
}
|
|
|
|
// Serialize preserved tokens from server context to JSON array
|
|
if (!params_base.sampling.preserved_tokens.empty()) {
|
|
json preserved_tokens = json::array();
|
|
for (const auto& token : params_base.sampling.preserved_tokens) {
|
|
preserved_tokens.push_back(common_token_to_piece(ctx, token));
|
|
}
|
|
data["preserved_tokens"] = preserved_tokens;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
|
|
const std::vector<ggml_type> kv_cache_types = {
|
|
GGML_TYPE_F32,
|
|
GGML_TYPE_F16,
|
|
GGML_TYPE_BF16,
|
|
GGML_TYPE_Q8_0,
|
|
GGML_TYPE_Q4_0,
|
|
GGML_TYPE_Q4_1,
|
|
GGML_TYPE_IQ4_NL,
|
|
GGML_TYPE_Q5_0,
|
|
GGML_TYPE_Q5_1,
|
|
};
|
|
|
|
static ggml_type kv_cache_type_from_str(const std::string & s) {
|
|
for (const auto & type : kv_cache_types) {
|
|
if (ggml_type_name(type) == s) {
|
|
return type;
|
|
}
|
|
}
|
|
throw std::runtime_error("Unsupported cache type: " + s);
|
|
}
|
|
|
|
static std::string get_all_kv_cache_types() {
|
|
std::ostringstream msg;
|
|
for (const auto & type : kv_cache_types) {
|
|
msg << ggml_type_name(type) << (&type == &kv_cache_types.back() ? "" : ", ");
|
|
}
|
|
return msg.str();
|
|
}
|
|
|
|
// Adds an RPC server
|
|
// Description here: https://github.com/ggml-org/llama.cpp/blob/master/tools/rpc/README.md
|
|
static void add_rpc_devices(std::string servers) {
|
|
auto rpc_servers = string_split<std::string>(servers, ',');
|
|
// Trim whitespace to allow more flexible configurations, such as having entries on separate lines.
|
|
for (std::string & server : rpc_servers)
|
|
{
|
|
server.erase(0, server.find_first_not_of(" \t\n\r"));
|
|
server.erase(server.find_last_not_of(" \t\n\r") + 1);
|
|
}
|
|
if (rpc_servers.empty()) {
|
|
throw std::invalid_argument("no RPC servers specified");
|
|
}
|
|
ggml_backend_reg_t rpc_reg = ggml_backend_reg_by_name("RPC");
|
|
if (!rpc_reg) {
|
|
throw std::invalid_argument("failed to find RPC backend");
|
|
}
|
|
typedef ggml_backend_reg_t (*ggml_backend_rpc_add_server_t)(const char * endpoint);
|
|
ggml_backend_rpc_add_server_t ggml_backend_rpc_add_server_fn = (ggml_backend_rpc_add_server_t) ggml_backend_reg_get_proc_address(rpc_reg, "ggml_backend_rpc_add_server");
|
|
if (!ggml_backend_rpc_add_server_fn) {
|
|
throw std::invalid_argument("failed to find RPC add server function");
|
|
}
|
|
for (const auto & server : rpc_servers) {
|
|
ggml_backend_reg_t reg = ggml_backend_rpc_add_server_fn(server.c_str());
|
|
ggml_backend_register(reg);
|
|
}
|
|
}
|
|
|
|
static void params_parse(server_context& /*ctx_server*/, const backend::ModelOptions* request,
|
|
common_params & params) {
|
|
|
|
// this is comparable to: https://github.com/ggerganov/llama.cpp/blob/d9b33fe95bd257b36c84ee5769cc048230067d6f/examples/server/server.cpp#L1809
|
|
|
|
params.model.path = request->modelfile();
|
|
if (!request->mmproj().empty()) {
|
|
params.mmproj.path = request->mmproj();
|
|
}
|
|
|
|
// Draft model for speculative decoding
|
|
if (!request->draftmodel().empty()) {
|
|
params.speculative.draft.mparams.path = request->draftmodel();
|
|
// Default to draft type if a draft model is set but no explicit type.
|
|
// Upstream made the speculative type a vector (ggml-org/llama.cpp#22838)
|
|
// and renamed COMMON_SPECULATIVE_TYPE_DRAFT -> ..._DRAFT_SIMPLE (#22964).
|
|
const bool no_spec_type = params.speculative.types.empty() ||
|
|
(params.speculative.types.size() == 1 && params.speculative.types[0] == COMMON_SPECULATIVE_TYPE_NONE);
|
|
if (no_spec_type) {
|
|
params.speculative.types = { COMMON_SPECULATIVE_TYPE_DRAFT_SIMPLE };
|
|
}
|
|
}
|
|
|
|
// params.model_alias ??
|
|
params.model_alias.insert(request->modelfile());
|
|
if (!request->cachetypekey().empty()) {
|
|
params.cache_type_k = kv_cache_type_from_str(request->cachetypekey());
|
|
}
|
|
if (!request->cachetypevalue().empty()) {
|
|
params.cache_type_v = kv_cache_type_from_str(request->cachetypevalue());
|
|
}
|
|
params.n_ctx = request->contextsize();
|
|
//params.memory_f16 = request->f16memory();
|
|
params.cpuparams.n_threads = request->threads();
|
|
params.n_gpu_layers = request->ngpulayers();
|
|
params.n_batch = request->nbatch();
|
|
//params.verbosity = INT_MAX;
|
|
// Enable all debug logs by setting verbosity threshold to maximum
|
|
//common_log_set_verbosity_thold(INT_MAX);
|
|
params.n_ubatch = request->nbatch(); // fixes issue with reranking models being limited to 512 tokens (the default n_ubatch size); allows for setting the maximum input amount of tokens thereby avoiding this error "input is too large to process. increase the physical batch size"
|
|
|
|
// Initialize ctx_shift to false by default (can be overridden by options)
|
|
params.ctx_shift = false;
|
|
// cache_ram_mib keeps llama.cpp's own default (8192 MiB) unless overridden by
|
|
// options. It used to be forced to -1 (no limit): since kv_unified and
|
|
// cache_idle_slots are on by default, every distinct prompt then leaves its
|
|
// slot state in host RAM and the backend grows without bound.
|
|
// Initialize n_parallel to 1 by default (can be overridden by options)
|
|
params.n_parallel = 1;
|
|
// Set only when the model options carry parallel/n_parallel, so an
|
|
// explicit 1 can be told apart from the default.
|
|
std::optional<int> parallel_option;
|
|
// Initialize grpc_servers to empty (can be overridden by options)
|
|
std::string grpc_servers_option = "";
|
|
|
|
// Initialize fit_params options (can be overridden by options)
|
|
// fit_params: whether to auto-adjust params to fit device memory (default: true as in llama.cpp)
|
|
params.fit_params = true;
|
|
// fit_params_target: target margin per device in bytes (default: 1GB per device)
|
|
// Initialize as vector with default value for all devices
|
|
params.fit_params_target = std::vector<size_t>(llama_max_devices(), 1024 * 1024 * 1024);
|
|
// fit_params_min_ctx: minimum context size for fit (default: 4096)
|
|
params.fit_params_min_ctx = 4096;
|
|
|
|
// Initialize additional server options (can be overridden by options)
|
|
// n_cache_reuse: min chunk size for KV cache reuse via shifting (default: 0 = disabled)
|
|
params.n_cache_reuse = 0;
|
|
// slot_prompt_similarity: threshold for slot prompt matching (default: 0.1)
|
|
params.slot_prompt_similarity = 0.1f;
|
|
// swa_full: use full-size SWA cache (default: false)
|
|
params.swa_full = false;
|
|
// cont_batching: continuous batching (default: true, auto-enabled when n_parallel > 1)
|
|
params.cont_batching = true;
|
|
// check_tensors: validate tensor data (default: false)
|
|
params.check_tensors = false;
|
|
// warmup: enable warmup run (default: true)
|
|
params.warmup = true;
|
|
// no_op_offload: disable host tensor op offload (default: false)
|
|
params.no_op_offload = false;
|
|
// kv_unified: enable unified KV cache. Upstream's server auto-enables this
|
|
// when the slot count is auto (-np <0), bumping n_parallel to 4 alongside.
|
|
// LocalAI keeps n_parallel=1 by default, which would skip that auto path
|
|
// and leave kv_unified=false. We flip the default to true here so the
|
|
// server-side prompt cache (cache_idle_slots) is actually usable on the
|
|
// single-slot path that LocalAI ships with: without it, idle slots are
|
|
// never persisted across requests and the prompt cache is dead weight.
|
|
// Users can opt out with `options: [ "kv_unified:false" ]`.
|
|
params.kv_unified = true;
|
|
// n_ctx_checkpoints: max context checkpoints per slot. Match upstream's
|
|
// default (32); the previous LocalAI-specific 8 was unnecessarily tight
|
|
// and limits partial-prefix recovery without a clear memory rationale.
|
|
params.n_ctx_checkpoints = 32;
|
|
// cache_idle_slots: save and clear idle slot KV to the prompt cache on
|
|
// task switch. Upstream default is true; the server auto-disables it if
|
|
// kv_unified=false or cache_ram_mib=0, so flipping kv_unified above is
|
|
// what actually unlocks it.
|
|
params.cache_idle_slots = true;
|
|
// 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. The turboquant
|
|
// fork still lacks common_params::checkpoint_min_step, so skip it there
|
|
// (LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP is injected by
|
|
// backend/cpp/turboquant/patch-grpc-server.sh).
|
|
#ifndef LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP
|
|
params.checkpoint_min_step = 256;
|
|
#endif
|
|
|
|
// Raw upstream llama-server flags collected from any option entry that
|
|
// starts with '-'. Applied once after the loop via common_params_parse.
|
|
std::vector<std::string> extra_argv;
|
|
bool passthrough_main_gpu_layers = false;
|
|
bool passthrough_draft_gpu_layers = false;
|
|
|
|
// O_DIRECT intent from the `direct_io` option. Upstream folded
|
|
// use_mmap/use_mlock/use_direct_io into a single common_params::load_mode
|
|
// enum (ggml-org/llama.cpp#20834), so the three independent LocalAI settings
|
|
// can only be reduced to one value once all of them have been read, held
|
|
// here until the mmap/mlock fields arrive further down.
|
|
bool want_direct_io = false;
|
|
|
|
auto add_device_options = [&](const std::string & devices) {
|
|
const std::regex regex{ R"([,]+)" };
|
|
std::sregex_token_iterator it{ devices.begin(), devices.end(), regex, -1 };
|
|
std::vector<std::string> split_arg{ it, {} };
|
|
|
|
for (std::string device : split_arg) {
|
|
const auto start = device.find_first_not_of(" \t\n\r");
|
|
if (start == std::string::npos) {
|
|
continue;
|
|
}
|
|
const auto end = device.find_last_not_of(" \t\n\r");
|
|
device = device.substr(start, end - start + 1);
|
|
|
|
extra_argv.push_back("--device");
|
|
extra_argv.push_back(device);
|
|
}
|
|
};
|
|
|
|
// decode options. Options are in form optname:optvale, or if booleans only optname.
|
|
for (int i = 0; i < request->options_size(); i++) {
|
|
std::string opt = request->options(i);
|
|
std::vector<char> opt_buf(opt.begin(), opt.end());
|
|
opt_buf.push_back('\0');
|
|
char *optname = strtok(opt_buf.data(), ":");
|
|
char *optval = strtok(NULL, ":");
|
|
std::string optval_str = (optval == NULL) ? "true" : optval;
|
|
|
|
if (!strcmp(optname, "context_shift")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.ctx_shift = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.ctx_shift = false;
|
|
}
|
|
} else if (!strcmp(optname, "use_jinja") || !strcmp(optname, "jinja")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.use_jinja = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.use_jinja = false;
|
|
}
|
|
} else if (!strcmp(optname, "cache_ram")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
params.cache_ram_mib = std::stoi(optval_str);
|
|
} catch (const std::exception& e) {
|
|
// If conversion fails, keep the default value
|
|
}
|
|
}
|
|
} else if (!strcmp(optname, "slot_save_path")) {
|
|
// Directory for SlotCache save/restore files. Take everything after
|
|
// the first ':' so a path that contains one is kept whole. Without
|
|
// this option the RPC stays disabled, as in llama-server.
|
|
const size_t sep = opt.find(':');
|
|
const std::string dir = (sep == std::string::npos) ? "" : opt.substr(sep + 1);
|
|
if (!dir.empty()) {
|
|
// A missing directory is created with mode 0700. An existing
|
|
// one is left as it is, with a warning when others can read it.
|
|
std::string warn, derr;
|
|
#ifdef _WIN32
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(std::filesystem::u8path(dir), ec);
|
|
const bool dir_ok = !ec && std::filesystem::is_directory(std::filesystem::u8path(dir));
|
|
#else
|
|
const bool dir_ok = llama_grpc::slot_ensure_private_dir(dir, warn, derr);
|
|
#endif
|
|
if (!warn.empty()) {
|
|
fprintf(stderr, "slot_save_path: %s\n", warn.c_str());
|
|
}
|
|
if (!dir_ok) {
|
|
fprintf(stderr, "slot_save_path %s is not a usable directory (%s), slot save/restore stays disabled\n", dir.c_str(), derr.c_str());
|
|
} else {
|
|
params.slot_save_path = dir;
|
|
if (params.slot_save_path.back() != '/' && params.slot_save_path.back() != '\\') {
|
|
params.slot_save_path += DIRECTORY_SEPARATOR;
|
|
}
|
|
}
|
|
}
|
|
} else if (!strcmp(optname, "parallel") || !strcmp(optname, "n_parallel")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
parallel_option = std::stoi(optval_str);
|
|
} catch (const std::exception& e) {
|
|
// If conversion fails, fall back to the environment/default
|
|
}
|
|
}
|
|
} else if (!strcmp(optname, "grpc_servers") || !strcmp(optname, "rpc_servers")) {
|
|
if (optval != NULL) {
|
|
grpc_servers_option = optval_str;
|
|
}
|
|
} else if (!strcmp(optname, "fit_params") || !strcmp(optname, "fit")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.fit_params = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.fit_params = false;
|
|
}
|
|
} else if (!strcmp(optname, "fit_params_target") || !strcmp(optname, "fit_target")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
// Value is in MiB, can be comma-separated list for multiple devices
|
|
// Single value is broadcast across all devices
|
|
std::string arg_next = optval_str;
|
|
const std::regex regex{ R"([,/]+)" };
|
|
std::sregex_token_iterator it{ arg_next.begin(), arg_next.end(), regex, -1 };
|
|
std::vector<std::string> split_arg{ it, {} };
|
|
if (split_arg.size() >= llama_max_devices()) {
|
|
// Too many values provided
|
|
continue;
|
|
}
|
|
if (split_arg.size() == 1) {
|
|
// Single value: broadcast to all devices
|
|
size_t value_mib = std::stoul(split_arg[0]);
|
|
std::fill(params.fit_params_target.begin(), params.fit_params_target.end(), value_mib * 1024 * 1024);
|
|
} else {
|
|
// Multiple values: set per device
|
|
for (size_t i = 0; i < split_arg.size() && i < params.fit_params_target.size(); i++) {
|
|
params.fit_params_target[i] = std::stoul(split_arg[i]) * 1024 * 1024;
|
|
}
|
|
}
|
|
} catch (const std::exception& e) {
|
|
// If conversion fails, keep default value (1GB per device)
|
|
}
|
|
}
|
|
} else if (!strcmp(optname, "fit_params_min_ctx") || !strcmp(optname, "fit_ctx")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
params.fit_params_min_ctx = std::stoi(optval_str);
|
|
} catch (const std::exception& e) {
|
|
// If conversion fails, keep default value (4096)
|
|
}
|
|
}
|
|
} else if (!strcmp(optname, "n_cache_reuse") || !strcmp(optname, "cache_reuse")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
params.n_cache_reuse = std::stoi(optval_str);
|
|
} catch (const std::exception& e) {
|
|
// If conversion fails, keep default value (0)
|
|
}
|
|
}
|
|
#ifndef LOCALAI_LLAMA_CPP_NO_SCORE_TASK
|
|
} else if (!strcmp(optname, "n_rs_seq") || !strcmp(optname, "rs_seq")) {
|
|
// Recurrent-state rollback snapshots per sequence. Hybrid models
|
|
// (deltanet/conv layers) cannot rewind their state, so without
|
|
// snapshots any prompt-cache reuse that needs a rewind — e.g. a
|
|
// score task whose probe changed under a stable option-list
|
|
// prefix — falls back to a full re-prefill. Costs recurrent-state
|
|
// memory x (1 + N) per sequence; unsupported archs clamp to 0.
|
|
if (optval != NULL) {
|
|
try {
|
|
params.n_rs_seq = std::stoi(optval_str);
|
|
} catch (const std::exception& e) {
|
|
// If conversion fails, keep default value (0)
|
|
}
|
|
}
|
|
#endif
|
|
} else if (!strcmp(optname, "slot_prompt_similarity") || !strcmp(optname, "sps")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
params.slot_prompt_similarity = std::stof(optval_str);
|
|
} catch (const std::exception& e) {
|
|
// If conversion fails, keep default value (0.1)
|
|
}
|
|
}
|
|
} else if (!strcmp(optname, "swa_full")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.swa_full = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.swa_full = false;
|
|
}
|
|
} else if (!strcmp(optname, "cont_batching") || !strcmp(optname, "continuous_batching")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.cont_batching = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.cont_batching = false;
|
|
}
|
|
} else if (!strcmp(optname, "check_tensors")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.check_tensors = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.check_tensors = false;
|
|
}
|
|
} else if (!strcmp(optname, "warmup")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.warmup = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.warmup = false;
|
|
}
|
|
} else if (!strcmp(optname, "no_op_offload")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.no_op_offload = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.no_op_offload = false;
|
|
}
|
|
} else if (!strcmp(optname, "device") || !strcmp(optname, "devices")) {
|
|
if (optval != NULL) {
|
|
add_device_options(optval_str);
|
|
}
|
|
} else if (!strcmp(optname, "split_mode") || !strcmp(optname, "sm")) {
|
|
// Accepts: none | layer | row | tensor (the latter requires a llama.cpp build
|
|
// that includes ggml-org/llama.cpp#19378, FlashAttention enabled, and KV-cache
|
|
// quantization disabled).
|
|
if (optval != NULL) {
|
|
if (optval_str == "none") {
|
|
params.split_mode = LLAMA_SPLIT_MODE_NONE;
|
|
} else if (optval_str == "layer") {
|
|
params.split_mode = LLAMA_SPLIT_MODE_LAYER;
|
|
} else if (optval_str == "row") {
|
|
params.split_mode = LLAMA_SPLIT_MODE_ROW;
|
|
} else if (optval_str == "tensor") {
|
|
params.split_mode = LLAMA_SPLIT_MODE_TENSOR;
|
|
}
|
|
}
|
|
} else if (!strcmp(optname, "kv_unified") || !strcmp(optname, "unified_kv")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.kv_unified = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.kv_unified = false;
|
|
}
|
|
} else if (!strcmp(optname, "n_ctx_checkpoints") || !strcmp(optname, "ctx_checkpoints")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
params.n_ctx_checkpoints = std::stoi(optval_str);
|
|
} catch (const std::exception& e) {
|
|
// If conversion fails, keep default value (32)
|
|
}
|
|
}
|
|
|
|
// --- server-side idle-slot prompt cache toggle (upstream --cache-idle-slots) ---
|
|
// Saves the slot's KV state into the host-side prompt cache on task
|
|
// switch so a later request with the same prefix can warm-load it.
|
|
// Auto-disabled by the server if kv_unified=false or cache_ram=0.
|
|
} else if (!strcmp(optname, "cache_idle_slots") || !strcmp(optname, "idle_slots_cache")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.cache_idle_slots = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.cache_idle_slots = false;
|
|
}
|
|
|
|
#ifndef LOCALAI_TURBOQUANT_NO_CHECKPOINT_MIN_STEP
|
|
// --- 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.
|
|
//
|
|
// Gated out for the turboquant fork, which lacks common_params::
|
|
// checkpoint_min_step. The leading `}` closing the cache_idle_slots
|
|
// branch is removed with this block; the next `} else if` (n_ubatch)
|
|
// then closes cache_idle_slots, so braces stay balanced under both
|
|
// preprocessor branches.
|
|
} 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_min_step = std::stoi(optval_str);
|
|
} catch (const std::exception& e) {
|
|
// If conversion fails, keep default value (256)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// --- physical batch size (upstream -ub / --ubatch-size) ---
|
|
// Note: line ~482 already aliases n_ubatch to n_batch as a default; this
|
|
// option lets users decouple the two (useful for embeddings/rerank).
|
|
} else if (!strcmp(optname, "n_ubatch") || !strcmp(optname, "ubatch")) {
|
|
if (optval != NULL) {
|
|
try { params.n_ubatch = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
|
|
// --- main-model batch threads (upstream -tb / --threads-batch) ---
|
|
} else if (!strcmp(optname, "threads_batch") || !strcmp(optname, "n_threads_batch")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
int n = std::stoi(optval_str);
|
|
if (n <= 0) n = (int)std::thread::hardware_concurrency();
|
|
params.cpuparams_batch.n_threads = n;
|
|
} catch (...) {}
|
|
}
|
|
|
|
// --- pooling type for embeddings (upstream --pooling) ---
|
|
} else if (!strcmp(optname, "pooling_type") || !strcmp(optname, "pooling")) {
|
|
if (optval != NULL) {
|
|
if (optval_str == "none") params.pooling_type = LLAMA_POOLING_TYPE_NONE;
|
|
else if (optval_str == "mean") params.pooling_type = LLAMA_POOLING_TYPE_MEAN;
|
|
else if (optval_str == "cls") params.pooling_type = LLAMA_POOLING_TYPE_CLS;
|
|
else if (optval_str == "last") params.pooling_type = LLAMA_POOLING_TYPE_LAST;
|
|
else if (optval_str == "rank") params.pooling_type = LLAMA_POOLING_TYPE_RANK;
|
|
// unknown values silently leave UNSPECIFIED (auto-detect)
|
|
}
|
|
|
|
// --- llama log verbosity threshold (upstream -lv / --verbosity) ---
|
|
} else if (!strcmp(optname, "verbosity")) {
|
|
if (optval != NULL) {
|
|
try { params.verbosity = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
|
|
// --- O_DIRECT model loading (upstream --direct-io) ---
|
|
} else if (!strcmp(optname, "direct_io") || !strcmp(optname, "use_direct_io")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
want_direct_io = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
want_direct_io = false;
|
|
}
|
|
|
|
// --- embedding normalization (upstream --embd-normalize) ---
|
|
// -1 none, 0 max-abs, 1 taxicab, 2 L2 (default), >2 p-norm
|
|
} else if (!strcmp(optname, "embd_normalize") || !strcmp(optname, "embedding_normalize")) {
|
|
if (optval != NULL) {
|
|
try { params.embd_normalize = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
|
|
// --- reasoning parser (upstream --reasoning-format) ---
|
|
// Picks the parser for <think> blocks emitted by reasoning models.
|
|
// none / auto / deepseek / deepseek-legacy
|
|
} else if (!strcmp(optname, "reasoning_format")) {
|
|
if (optval != NULL) {
|
|
if (optval_str == "none") params.reasoning_format = COMMON_REASONING_FORMAT_NONE;
|
|
else if (optval_str == "auto") params.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
|
|
else if (optval_str == "deepseek") params.reasoning_format = COMMON_REASONING_FORMAT_DEEPSEEK;
|
|
else if (optval_str == "deepseek-legacy" || optval_str == "deepseek_legacy")
|
|
params.reasoning_format = COMMON_REASONING_FORMAT_DEEPSEEK_LEGACY;
|
|
// unknown values silently keep the upstream default (DEEPSEEK)
|
|
}
|
|
|
|
// --- reasoning budget (upstream --reasoning-budget) ---
|
|
// -1 unlimited, 0 disabled, >0 token budget for thinking blocks.
|
|
// Distinct from per-request `enable_thinking` (chat_template_kwargs).
|
|
} else if (!strcmp(optname, "enable_reasoning") || !strcmp(optname, "reasoning_budget")) {
|
|
if (optval != NULL) {
|
|
try { params.enable_reasoning = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
|
|
// --- prefill assistant turn (upstream --no-prefill-assistant) ---
|
|
} else if (!strcmp(optname, "prefill_assistant")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.prefill_assistant = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.prefill_assistant = false;
|
|
}
|
|
|
|
// --- mmproj GPU offload (upstream --no-mmproj-offload, inverted) ---
|
|
} else if (!strcmp(optname, "mmproj_use_gpu") || !strcmp(optname, "mmproj_offload")) {
|
|
if (optval_str == "true" || optval_str == "1" || optval_str == "yes" || optval_str == "on" || optval_str == "enabled") {
|
|
params.mmproj_use_gpu = true;
|
|
} else if (optval_str == "false" || optval_str == "0" || optval_str == "no" || optval_str == "off" || optval_str == "disabled") {
|
|
params.mmproj_use_gpu = false;
|
|
}
|
|
|
|
// --- per-image vision token budget (upstream --image-min/max-tokens) ---
|
|
} else if (!strcmp(optname, "image_min_tokens")) {
|
|
if (optval != NULL) {
|
|
try { params.image_min_tokens = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "image_max_tokens")) {
|
|
if (optval != NULL) {
|
|
try { params.image_max_tokens = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
|
|
// --- main-model tensor buffer overrides (upstream --override-tensor) ---
|
|
// Format: <tensor regex>=<buffer type>,<tensor regex>=<buffer type>,...
|
|
// Mirrors the existing `draft_override_tensor` parser below.
|
|
} else if (!strcmp(optname, "override_tensor") || !strcmp(optname, "tensor_buft_overrides")) {
|
|
ggml_backend_load_all();
|
|
std::map<std::string, ggml_backend_buffer_type_t> buft_list;
|
|
for (size_t i = 0; i < ggml_backend_dev_count(); ++i) {
|
|
auto * dev = ggml_backend_dev_get(i);
|
|
auto * buft = ggml_backend_dev_buffer_type(dev);
|
|
if (buft) {
|
|
buft_list[ggml_backend_buft_name(buft)] = buft;
|
|
}
|
|
}
|
|
static std::list<std::string> override_names;
|
|
std::string cur;
|
|
auto flush = [&](const std::string & spec) {
|
|
auto pos = spec.find('=');
|
|
if (pos == std::string::npos) return;
|
|
const std::string name = spec.substr(0, pos);
|
|
const std::string type = spec.substr(pos + 1);
|
|
auto it = buft_list.find(type);
|
|
if (it == buft_list.end()) return; // unknown buffer type: ignore
|
|
override_names.push_back(name);
|
|
params.tensor_buft_overrides.push_back(
|
|
{override_names.back().c_str(), it->second});
|
|
};
|
|
for (char c : optval_str) {
|
|
if (c == ',') { if (!cur.empty()) { flush(cur); cur.clear(); } }
|
|
else { cur.push_back(c); }
|
|
}
|
|
if (!cur.empty()) flush(cur);
|
|
|
|
// Speculative decoding options
|
|
} else if (!strcmp(optname, "spec_type") || !strcmp(optname, "speculative_type")) {
|
|
// Upstream switched to a vector of types (comma-separated for multi-type
|
|
// chaining via common_speculative_types_from_names). We keep accepting a
|
|
// single value here, but also tolerate comma-separated lists.
|
|
//
|
|
// ggml-org/llama.cpp#22964 also renamed the registered names from
|
|
// underscore- to dash-separated form, and replaced the bare
|
|
// `draft`/`eagle3` aliases with `draft-simple`/`draft-eagle3`. We
|
|
// normalize each token here so existing model configs keep working.
|
|
auto normalize_spec_name = [](std::string s) -> std::string {
|
|
std::replace(s.begin(), s.end(), '_', '-');
|
|
if (s == "draft") return "draft-simple";
|
|
if (s == "eagle3") return "draft-eagle3";
|
|
return s;
|
|
};
|
|
std::vector<std::string> names;
|
|
std::string item;
|
|
for (char c : optval_str) {
|
|
if (c == ',') {
|
|
if (!item.empty()) { names.push_back(normalize_spec_name(item)); item.clear(); }
|
|
} else {
|
|
item.push_back(c);
|
|
}
|
|
}
|
|
if (!item.empty()) names.push_back(normalize_spec_name(item));
|
|
auto parsed = common_speculative_types_from_names(names);
|
|
if (!parsed.empty()) {
|
|
params.speculative.types = parsed;
|
|
}
|
|
} else if (!strcmp(optname, "spec_n_max") || !strcmp(optname, "draft_max")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.draft.n_max = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_n_min") || !strcmp(optname, "draft_min")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.draft.n_min = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_p_min") || !strcmp(optname, "draft_p_min")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.draft.p_min = std::stof(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_p_split")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.draft.p_split = std::stof(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_ngram_size_n") || !strcmp(optname, "ngram_size_n")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_simple.size_n = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_ngram_size_m") || !strcmp(optname, "ngram_size_m")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_simple.size_m = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_ngram_min_hits") || !strcmp(optname, "ngram_min_hits")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_simple.min_hits = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "draft_gpu_layers")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.draft.n_gpu_layers = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "draft_ctx_size")) {
|
|
// The draft context size is no longer a separate field upstream: the draft
|
|
// shares the target context size. Accept the option for backward
|
|
// compatibility but silently ignore it.
|
|
|
|
// --- ngram_mod family (upstream --spec-ngram-mod-*) ---
|
|
} else if (!strcmp(optname, "spec_ngram_mod_n_min")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_mod.n_min = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_ngram_mod_n_max")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_mod.n_max = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_ngram_mod_n_match")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_mod.n_match = std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
|
|
// --- ngram_map_k family (upstream --spec-ngram-map-k-*) ---
|
|
} else if (!strcmp(optname, "spec_ngram_map_k_size_n")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_map_k.size_n = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_ngram_map_k_size_m")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_map_k.size_m = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_ngram_map_k_min_hits")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_map_k.min_hits = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
|
|
// --- ngram_map_k4v family (upstream --spec-ngram-map-k4v-*) ---
|
|
} else if (!strcmp(optname, "spec_ngram_map_k4v_size_n")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_map_k4v.size_n = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_ngram_map_k4v_size_m")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_map_k4v.size_m = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "spec_ngram_map_k4v_min_hits")) {
|
|
if (optval != NULL) {
|
|
try { params.speculative.ngram_map_k4v.min_hits = (uint16_t)std::stoi(optval_str); } catch (...) {}
|
|
}
|
|
|
|
// --- ngram lookup caches (upstream --lookup-cache-static / -dynamic) ---
|
|
} else if (!strcmp(optname, "spec_lookup_cache_static") || !strcmp(optname, "lookup_cache_static")) {
|
|
params.speculative.ngram_cache.lookup_cache_static = optval_str;
|
|
} else if (!strcmp(optname, "spec_lookup_cache_dynamic") || !strcmp(optname, "lookup_cache_dynamic")) {
|
|
params.speculative.ngram_cache.lookup_cache_dynamic = optval_str;
|
|
|
|
// --- draft model KV cache types (upstream --spec-draft-type-k / -v) ---
|
|
} else if (!strcmp(optname, "draft_cache_type_k") || !strcmp(optname, "spec_draft_cache_type_k")) {
|
|
params.speculative.draft.cache_type_k = kv_cache_type_from_str(optval_str);
|
|
} else if (!strcmp(optname, "draft_cache_type_v") || !strcmp(optname, "spec_draft_cache_type_v")) {
|
|
params.speculative.draft.cache_type_v = kv_cache_type_from_str(optval_str);
|
|
|
|
// --- draft model thread counts (upstream --spec-draft-threads / -batch) ---
|
|
} else if (!strcmp(optname, "draft_threads") || !strcmp(optname, "spec_draft_threads")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
int n = std::stoi(optval_str);
|
|
if (n <= 0) n = (int)std::thread::hardware_concurrency();
|
|
params.speculative.draft.cpuparams.n_threads = n;
|
|
} catch (...) {}
|
|
}
|
|
} else if (!strcmp(optname, "draft_threads_batch") || !strcmp(optname, "spec_draft_threads_batch")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
int n = std::stoi(optval_str);
|
|
if (n <= 0) n = (int)std::thread::hardware_concurrency();
|
|
params.speculative.draft.cpuparams_batch.n_threads = n;
|
|
} catch (...) {}
|
|
}
|
|
|
|
// --- draft model MoE on CPU (upstream --spec-draft-cpu-moe / --spec-draft-n-cpu-moe) ---
|
|
} else if (!strcmp(optname, "draft_cpu_moe") || !strcmp(optname, "spec_draft_cpu_moe")) {
|
|
// Bool-style flag: optval may be missing, "true"/"1"/"yes" enables.
|
|
const bool enable = (optval == NULL) ||
|
|
optval_str == "true" || optval_str == "1" || optval_str == "yes" ||
|
|
optval_str == "on" || optval_str == "enabled";
|
|
if (enable) {
|
|
params.speculative.draft.tensor_buft_overrides.push_back(llm_ffn_exps_cpu_override());
|
|
}
|
|
} else if (!strcmp(optname, "draft_n_cpu_moe") || !strcmp(optname, "spec_draft_n_cpu_moe")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
int n = std::stoi(optval_str);
|
|
if (n < 0) n = 0;
|
|
#if LOCALAI_HAS_N_CPU_FFN_HELPER
|
|
llm_add_n_cpu_ffn_overrides(n, LLM_FFN_EXPS_REGEX, params.speculative.draft.tensor_buft_overrides);
|
|
#else
|
|
static std::list<std::string> buft_overrides_draft;
|
|
for (int i = 0; i < n; ++i) {
|
|
buft_overrides_draft.push_back(llm_ffn_exps_block_regex(i));
|
|
params.speculative.draft.tensor_buft_overrides.push_back(
|
|
{buft_overrides_draft.back().c_str(), ggml_backend_cpu_buffer_type()});
|
|
}
|
|
#endif
|
|
} catch (...) {}
|
|
}
|
|
|
|
// --- main model MoE on CPU (upstream --cpu-moe / --n-cpu-moe) ---
|
|
} else if (!strcmp(optname, "cpu_moe")) {
|
|
// Bool-style flag: keep all MoE expert weights on CPU.
|
|
const bool enable = (optval == NULL) ||
|
|
optval_str == "true" || optval_str == "1" || optval_str == "yes" ||
|
|
optval_str == "on" || optval_str == "enabled";
|
|
if (enable) {
|
|
params.tensor_buft_overrides.push_back(llm_ffn_exps_cpu_override());
|
|
}
|
|
} else if (!strcmp(optname, "moe_cache_mib")) {
|
|
// GPU cache for MoE experts kept in host memory (upstream --moe-cache-mib).
|
|
// Whole MiB, off (0) by default; invalid values keep the default.
|
|
if (optval != NULL) {
|
|
const auto bytes = llama_grpc::parse_moe_cache_bytes(optval_str);
|
|
if (!bytes) {
|
|
SRV_WRN("moe_cache_mib: ignoring invalid value '%s' (expected a non-negative integer in MiB)\n", optval_str.c_str());
|
|
} else {
|
|
#if LOCALAI_HAS_MOE_CACHE
|
|
params.moe_cache_size = *bytes;
|
|
#else
|
|
SRV_WRN("%s", "moe_cache_mib: this llama.cpp build has no MoE cache, ignoring\n");
|
|
#endif
|
|
}
|
|
}
|
|
} else if (!strcmp(optname, "lazy_mode") || !strcmp(optname, "lazy-mode") || !strcmp(optname, "lzm")) {
|
|
// On-demand reading of large tensors such as per-layer embeddings
|
|
// (upstream -lzm / --lazy-mode). Accepts on, auto or off; "on" needs mmap.
|
|
#if LOCALAI_HAS_LAZY_MODE
|
|
if (optval != NULL) {
|
|
if (optval_str == "on") {
|
|
params.lazy_mode = LLAMA_LAZY_MODE_ON;
|
|
} else if (optval_str == "auto") {
|
|
params.lazy_mode = LLAMA_LAZY_MODE_AUTO;
|
|
} else if (optval_str == "off") {
|
|
params.lazy_mode = LLAMA_LAZY_MODE_OFF;
|
|
} else {
|
|
SRV_WRN("lazy_mode: ignoring invalid value '%s' (expected on, auto or off)\n", optval_str.c_str());
|
|
}
|
|
}
|
|
#else
|
|
SRV_WRN("%s", "lazy_mode: this llama.cpp build has no lazy mode, ignoring\n");
|
|
#endif
|
|
} else if (!strcmp(optname, "n_cpu_moe")) {
|
|
if (optval != NULL) {
|
|
try {
|
|
int n = std::stoi(optval_str);
|
|
if (n < 0) n = 0;
|
|
#if LOCALAI_HAS_N_CPU_FFN_HELPER
|
|
llm_add_n_cpu_ffn_overrides(n, LLM_FFN_EXPS_REGEX, params.tensor_buft_overrides);
|
|
#else
|
|
static std::list<std::string> buft_overrides_main;
|
|
for (int i = 0; i < n; ++i) {
|
|
buft_overrides_main.push_back(llm_ffn_exps_block_regex(i));
|
|
params.tensor_buft_overrides.push_back(
|
|
{buft_overrides_main.back().c_str(), ggml_backend_cpu_buffer_type()});
|
|
}
|
|
#endif
|
|
} catch (...) {}
|
|
}
|
|
|
|
// --- draft model tensor buffer overrides (upstream --spec-draft-override-tensor) ---
|
|
} else if (!strcmp(optname, "draft_override_tensor") || !strcmp(optname, "spec_draft_override_tensor")) {
|
|
// Format: <tensor regex>=<buffer type>,<tensor regex>=<buffer type>,...
|
|
// We replicate upstream's parse_tensor_buffer_overrides (static in arg.cpp).
|
|
ggml_backend_load_all();
|
|
std::map<std::string, ggml_backend_buffer_type_t> buft_list;
|
|
for (size_t i = 0; i < ggml_backend_dev_count(); ++i) {
|
|
auto * dev = ggml_backend_dev_get(i);
|
|
auto * buft = ggml_backend_dev_buffer_type(dev);
|
|
if (buft) {
|
|
buft_list[ggml_backend_buft_name(buft)] = buft;
|
|
}
|
|
}
|
|
static std::list<std::string> draft_override_names;
|
|
std::string cur;
|
|
auto flush = [&](const std::string & spec) {
|
|
auto pos = spec.find('=');
|
|
if (pos == std::string::npos) return;
|
|
const std::string name = spec.substr(0, pos);
|
|
const std::string type = spec.substr(pos + 1);
|
|
auto it = buft_list.find(type);
|
|
if (it == buft_list.end()) return; // unknown buffer type: ignore
|
|
draft_override_names.push_back(name);
|
|
params.speculative.draft.tensor_buft_overrides.push_back(
|
|
{draft_override_names.back().c_str(), it->second});
|
|
};
|
|
for (char c : optval_str) {
|
|
if (c == ',') { if (!cur.empty()) { flush(cur); cur.clear(); } }
|
|
else { cur.push_back(c); }
|
|
}
|
|
if (!cur.empty()) flush(cur);
|
|
|
|
// --- generic passthrough: any entry starting with '-' is a raw
|
|
// upstream llama-server flag, forwarded verbatim to the parser. ---
|
|
} else if (optname[0] == '-') {
|
|
std::string flag = optname;
|
|
// These flags make upstream's parser exit() (printing usage /
|
|
// completion), which would kill the backend process. Skip them.
|
|
if (flag == "-h" || flag == "--help" || flag == "--usage" ||
|
|
flag == "--version" || flag == "--license" ||
|
|
flag == "--list-devices" || flag == "-cl" ||
|
|
flag == "--cache-list" ||
|
|
flag.rfind("--completion", 0) == 0) {
|
|
fprintf(stderr,
|
|
"[llama-cpp] ignoring passthrough flag that would exit: %s\n",
|
|
flag.c_str());
|
|
} else {
|
|
extra_argv.push_back(flag);
|
|
passthrough_main_gpu_layers =
|
|
passthrough_main_gpu_layers ||
|
|
flag == "-ngl" ||
|
|
flag == "--gpu-layers" ||
|
|
flag == "--n-gpu-layers";
|
|
passthrough_draft_gpu_layers =
|
|
passthrough_draft_gpu_layers ||
|
|
flag == "--spec-draft-ngl" ||
|
|
flag == "-ngld" ||
|
|
flag == "--gpu-layers-draft" ||
|
|
flag == "--n-gpu-layers-draft";
|
|
// Preserve the whole value after the first ':' so embedded
|
|
// colons (e.g. host:port) survive strtok's truncation of optval.
|
|
auto colon = opt.find(':');
|
|
if (colon != std::string::npos) {
|
|
extra_argv.push_back(opt.substr(colon + 1));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The model options win over LLAMACPP_PARALLEL, including an explicit
|
|
// parallel:1 (previously indistinguishable from the default and replaced
|
|
// by the environment value).
|
|
params.n_parallel = llama_grpc::resolve_n_parallel(parallel_option, std::getenv("LLAMACPP_PARALLEL"));
|
|
if (params.n_parallel > 1) {
|
|
params.cont_batching = true;
|
|
}
|
|
|
|
// Add RPC devices from option or environment variable (fallback)
|
|
if (!grpc_servers_option.empty()) {
|
|
add_rpc_devices(grpc_servers_option);
|
|
} else {
|
|
const char *llama_grpc_servers = std::getenv("LLAMACPP_GRPC_SERVERS");
|
|
if (llama_grpc_servers != NULL) {
|
|
add_rpc_devices(std::string(llama_grpc_servers));
|
|
}
|
|
}
|
|
|
|
// Add kv_overrides
|
|
if (request->overrides_size() > 0) {
|
|
for (int i = 0; i < request->overrides_size(); i++) {
|
|
string_parse_kv_override(request->overrides(i).c_str(), params.kv_overrides);
|
|
}
|
|
}
|
|
|
|
// TODO: Add yarn
|
|
|
|
if (!request->tensorsplit().empty()) {
|
|
std::string arg_next = request->tensorsplit();
|
|
|
|
// split string by , and /
|
|
const std::regex regex{ R"([,/]+)" };
|
|
std::sregex_token_iterator it{ arg_next.begin(), arg_next.end(), regex, -1 };
|
|
std::vector<std::string> split_arg{ it, {} };
|
|
|
|
GGML_ASSERT(split_arg.size() <= llama_max_devices());
|
|
|
|
for (size_t i_device = 0; i_device < llama_max_devices(); ++i_device) {
|
|
if (i_device < split_arg.size()) {
|
|
params.tensor_split[i_device] = std::stof(split_arg[i_device]);
|
|
}
|
|
else {
|
|
params.tensor_split[i_device] = 0.0f;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!request->maingpu().empty()) {
|
|
params.main_gpu = std::stoi(request->maingpu());
|
|
}
|
|
if (!request->loraadapter().empty() && !request->lorabase().empty()) {
|
|
float scale_factor = 1.0f;
|
|
if (request->lorascale() != 0.0f) {
|
|
scale_factor = request->lorascale();
|
|
}
|
|
// get the directory of modelfile
|
|
std::string model_dir = params.model.path.substr(0, params.model.path.find_last_of("/\\"));
|
|
common_adapter_lora_info lora_info;
|
|
lora_info.path = model_dir + "/" + request->loraadapter();
|
|
lora_info.scale = scale_factor;
|
|
lora_info.task_name = "";
|
|
lora_info.prompt_prefix = "";
|
|
lora_info.ptr = nullptr;
|
|
params.lora_adapters.push_back(std::move(lora_info));
|
|
}
|
|
// LocalAI keeps mmap, mlock and direct-I/O as three independent settings,
|
|
// while upstream now carries a single load mode. Fold them with the
|
|
// precedence the separate booleans used to give: direct I/O bypasses the
|
|
// page cache entirely, mlock implies mmap, and everything off is a plain
|
|
// buffered read. Forks that branched before ggml-org/llama.cpp#20834 still
|
|
// expose the booleans; prepare.sh probes the checkout and sets
|
|
// LOCALAI_LEGACY_LOAD_MODE in the generated llama_compat.h accordingly.
|
|
#if LOCALAI_LEGACY_LOAD_MODE
|
|
params.use_mlock = request->mlock();
|
|
params.use_mmap = request->mmap();
|
|
params.use_direct_io = want_direct_io;
|
|
#else
|
|
if (want_direct_io) {
|
|
params.load_mode = LLAMA_LOAD_MODE_DIRECT_IO;
|
|
} else if (request->mlock()) {
|
|
params.load_mode = LLAMA_LOAD_MODE_MLOCK;
|
|
} else if (request->mmap()) {
|
|
params.load_mode = LLAMA_LOAD_MODE_MMAP;
|
|
} else {
|
|
params.load_mode = LLAMA_LOAD_MODE_NONE;
|
|
}
|
|
#endif
|
|
|
|
if (request->flashattention() == "on" || request->flashattention() == "enabled") {
|
|
params.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_ENABLED;
|
|
} else if (request->flashattention() == "off" || request->flashattention() == "disabled") {
|
|
params.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_DISABLED;
|
|
} else if (request->flashattention() == "auto") {
|
|
params.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_AUTO;
|
|
}
|
|
|
|
params.no_kv_offload = request->nokvoffload();
|
|
params.embedding = request->embeddings() || request->reranking();
|
|
if (request->reranking()) {
|
|
params.pooling_type = LLAMA_POOLING_TYPE_RANK;
|
|
}
|
|
|
|
|
|
if (request->ropescaling() == "none") { params.rope_scaling_type = LLAMA_ROPE_SCALING_TYPE_NONE; }
|
|
else if (request->ropescaling() == "yarn") { params.rope_scaling_type = LLAMA_ROPE_SCALING_TYPE_YARN; }
|
|
else if (request->ropescaling() == "linear") { params.rope_scaling_type = LLAMA_ROPE_SCALING_TYPE_LINEAR; }
|
|
|
|
if ( request->yarnextfactor() != 0.0f ) {
|
|
params.yarn_ext_factor = request->yarnextfactor();
|
|
}
|
|
if ( request->yarnattnfactor() != 0.0f ) {
|
|
params.yarn_attn_factor = request->yarnattnfactor();
|
|
}
|
|
if ( request->yarnbetafast() != 0.0f ) {
|
|
params.yarn_beta_fast = request->yarnbetafast();
|
|
}
|
|
if ( request->yarnbetaslow() != 0.0f ) {
|
|
params.yarn_beta_slow = request->yarnbetaslow();
|
|
}
|
|
if ( request->ropefreqbase() != 0.0f ) {
|
|
params.rope_freq_base = request->ropefreqbase();
|
|
}
|
|
if ( request->ropefreqscale() != 0.0f ) {
|
|
params.rope_freq_scale = request->ropefreqscale();
|
|
}
|
|
|
|
if (request->grammartriggers_size() > 0) {
|
|
//params.sampling.grammar_lazy = true;
|
|
// Store grammar trigger words for processing after model is loaded
|
|
for (int i = 0; i < request->grammartriggers_size(); i++) {
|
|
const auto & word = request->grammartriggers(i).word();
|
|
common_grammar_trigger trigger;
|
|
trigger.type = COMMON_GRAMMAR_TRIGGER_TYPE_WORD;
|
|
trigger.value = word;
|
|
params.sampling.grammar_triggers.push_back(std::move(trigger));
|
|
}
|
|
}
|
|
|
|
// Apply any raw upstream flags last so an explicit passthrough flag wins
|
|
// over the LocalAI-resolved field it maps to (e.g. --ctx-size beats
|
|
// context_size). This is the same parser llama-server itself uses.
|
|
if (!extra_argv.empty()) {
|
|
// common_params_parser_init resets a few fields for the SERVER example
|
|
// (n_parallel -> -1, use_color). Snapshot n_parallel so an unrelated
|
|
// passthrough flag can't silently clobber LocalAI's resolved value.
|
|
const int saved_n_parallel = params.n_parallel;
|
|
// Newer upstream parsers assert that these fields still contain their
|
|
// negative initialization sentinels. LocalAI resolves them from the
|
|
// model request before applying passthrough options, so stage the
|
|
// sentinels and restore the values unless a raw flag overrides them.
|
|
const auto saved_gpu_layers =
|
|
llama_grpc::prepare_passthrough_gpu_layers(
|
|
params.n_gpu_layers,
|
|
params.speculative.draft.n_gpu_layers);
|
|
|
|
std::vector<char *> argv;
|
|
std::string prog = "llama-server";
|
|
argv.push_back(prog.data());
|
|
for (auto & a : extra_argv) {
|
|
argv.push_back(a.data());
|
|
}
|
|
|
|
// ctx_arg.params is a reference, so this overlays the given flags onto
|
|
// `params` in place. Returns false on a recoverable parse error (and
|
|
// self-restores params); may exit() on a hard error, exactly as
|
|
// passing the same bad flag to llama-server would.
|
|
if (!common_params_parse((int)argv.size(), argv.data(), params,
|
|
LLAMA_EXAMPLE_SERVER)) {
|
|
fprintf(stderr,
|
|
"[llama-cpp] failed to parse passthrough options; ignoring them\n");
|
|
}
|
|
|
|
// Restore n_parallel unless a passthrough flag explicitly set it
|
|
// (parser_init's reset sentinel for SERVER is -1).
|
|
if (params.n_parallel == -1) {
|
|
params.n_parallel = saved_n_parallel;
|
|
}
|
|
llama_grpc::restore_passthrough_gpu_layers(
|
|
params.n_gpu_layers,
|
|
params.speculative.draft.n_gpu_layers,
|
|
saved_gpu_layers,
|
|
passthrough_main_gpu_layers,
|
|
passthrough_draft_gpu_layers);
|
|
}
|
|
|
|
// The library initializer now creates both threadpools before the server
|
|
// can apply llama_context's fallback for the -1 batch-thread sentinel.
|
|
params.cpuparams_batch.n_threads = llama_grpc::resolve_batch_threads(
|
|
params.cpuparams_batch.n_threads,
|
|
params.cpuparams.n_threads);
|
|
|
|
#ifndef LOCALAI_LLAMA_CPP_NO_SCORE_TASK
|
|
// Score-task suffix forking: reserve seq ids (and recurrent-state cells)
|
|
// beyond the slots so one scoring call decodes all candidate tails in a
|
|
// single batch (SERVER_TASK_TYPE_SCORE, patches/). Requires the unified
|
|
// KV cache — with per-sequence streams the extra ids would shrink every
|
|
// sequence's context to n_ctx / n_seq_max. Decided after both option
|
|
// passes so an explicit kv_unified:false wins and disables forking.
|
|
params.score_enabled = request->enablescore();
|
|
params.n_seq_score_forks = params.score_enabled && params.kv_unified ? SERVER_SCORE_FORK_SEQS : 0;
|
|
#endif
|
|
|
|
// Terminate/pad the override vectors only after BOTH the named-option loop
|
|
// and the generic passthrough (common_params_parse above) have pushed their
|
|
// real entries, so back() is the null sentinel the model loader asserts on.
|
|
// Running these before the passthrough let a passthrough flag (--cpu-moe,
|
|
// --override-tensor, --override-kv, ...) append a real entry after the
|
|
// sentinel: a GGML_ASSERT crash for tensor_buft_overrides, a silent drop for
|
|
// kv_overrides. Double-termination is harmless (the while is a no-op if the
|
|
// passthrough parse already padded; an extra trailing null is ignored).
|
|
|
|
if (!params.kv_overrides.empty()) {
|
|
params.kv_overrides.emplace_back();
|
|
params.kv_overrides.back().key[0] = 0;
|
|
}
|
|
|
|
// tensor_buft_overrides sentinel termination (mirrors upstream common/arg.cpp).
|
|
// Real entries are pushed during option parsing; here we pad/terminate so the
|
|
// model loader sees back().pattern == nullptr (GGML_ASSERT at common.cpp:1543)
|
|
// and so llama_params_fit has the placeholder slots it requires.
|
|
{
|
|
const size_t ntbo = llama_max_tensor_buft_overrides();
|
|
while (params.tensor_buft_overrides.size() < ntbo) {
|
|
params.tensor_buft_overrides.push_back({nullptr, nullptr});
|
|
}
|
|
}
|
|
// Terminate the draft tensor_buft_overrides list with a sentinel, mirroring
|
|
// the main-model handling above.
|
|
if (!params.speculative.draft.tensor_buft_overrides.empty()) {
|
|
params.speculative.draft.tensor_buft_overrides.push_back({nullptr, nullptr});
|
|
}
|
|
}
|
|
|
|
|
|
#ifndef LOCALAI_LLAMA_CPP_NO_TTS_TASK
|
|
// MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM hands back raw float32 samples, but the
|
|
// WAV header core/backend/tts.go builds around the streamed chunks announces
|
|
// 16-bit samples, so the wire has to carry s16 or the client decodes floats as
|
|
// integers and hears noise. The scaling matches write_wav16() in
|
|
// tools/mtmd/mtmd-helper-gen.cpp, which is what the non-streaming path writes.
|
|
static std::string tts_pcm_f32_to_s16(const std::string & samples) {
|
|
const size_t n = samples.size() / sizeof(float);
|
|
std::string out;
|
|
out.resize(n * sizeof(int16_t));
|
|
for (size_t i = 0; i < n; i++) {
|
|
float v = 0.0f;
|
|
std::memcpy(&v, samples.data() + i * sizeof(float), sizeof(float));
|
|
const int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, v)) * 32767.0f);
|
|
std::memcpy(&out[i * sizeof(int16_t)], &s, sizeof(int16_t));
|
|
}
|
|
return out;
|
|
}
|
|
#endif
|
|
|
|
#if LOCALAI_HAS_REALTIME && defined(LOCALAI_HAS_SERVER_REALTIME) && !defined(LOCALAI_LLAMA_CPP_NO_TTS_TASK)
|
|
#define LOCALAI_HAS_AUDIO_TO_AUDIO 1
|
|
|
|
namespace rts = llama_grpc::realtime;
|
|
|
|
// One AudioToAudioStream call: a speech-to-speech conversation on a realtime
|
|
// slot (server_realtime_slot). The slot holds the audio and text of the whole
|
|
// conversation. This class feeds it through SERVER_TASK_TYPE_CONTROL tasks and
|
|
// turns what it produces into AudioToAudioResponse events. The conversation
|
|
// logic that needs no model lives in realtime_session.h.
|
|
//
|
|
// Two threads, as upstream: a reader thread handles the client messages, the RPC
|
|
// thread consumes the results of the slot and writes the events.
|
|
class RealtimeStreamSession {
|
|
static std::string str_field(const rts::json & j, const char * key) {
|
|
return j.is_object() && j.contains(key) && j[key].is_string() ? j[key].get<std::string>() : std::string();
|
|
}
|
|
|
|
using Stream = grpc::ServerReaderWriter<backend::AudioToAudioResponse, backend::AudioToAudioRequest>;
|
|
|
|
public:
|
|
RealtimeStreamSession(server_context & ctx_server, const common_params & params, grpc::ServerContext * gctx, Stream * stream)
|
|
: ctx_server(ctx_server), params(params), gctx(gctx), stream(stream) {}
|
|
|
|
grpc::Status run() {
|
|
const auto & cap = ctx_server.impl->mtmd_cap;
|
|
// the same test as the websocket endpoint of upstream
|
|
if (!(cap.audio_stream && cap.chat)) {
|
|
const std::string msg = "the loaded model does not support speech-to-speech (needs an audio-stream projector and a chat template)";
|
|
send_error("not_supported", msg);
|
|
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED, msg);
|
|
}
|
|
|
|
input_rate = mtmd_get_audio_sample_rate(ctx_server.impl->mctx);
|
|
if (cap.audio_gen) {
|
|
output_rate = mtmd_gen_audio_get_info(ctx_server.impl->mctx).sample_rate;
|
|
}
|
|
|
|
// the first message is the config
|
|
backend::AudioToAudioRequest first;
|
|
if (!stream->Read(&first)) {
|
|
return grpc::Status::OK;
|
|
}
|
|
if (first.payload_case() != backend::AudioToAudioRequest::kConfig) {
|
|
send_error("invalid_request_error", "the first message must be a config");
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "the first message must be a config");
|
|
}
|
|
std::string err;
|
|
if (!apply_config(first.config(), err)) {
|
|
send_error("invalid_request_error", err);
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, err);
|
|
}
|
|
|
|
// the slot answers once it holds the session
|
|
auto rd = ctx_server.get_response_reader();
|
|
{
|
|
server_task task(SERVER_TASK_TYPE_REALTIME);
|
|
task.id = rd.get_new_id();
|
|
task.params.sampling = sampling();
|
|
task.params.stream = true;
|
|
task.params.oaicompat_cmpl_id = id;
|
|
task.params.oaicompat_model = ctx_server.get_meta().model_name;
|
|
rd.post_task(std::move(task));
|
|
}
|
|
{
|
|
auto result = rd.next([this]() { return gctx->IsCancelled(); });
|
|
if (!result) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "client closed the stream");
|
|
}
|
|
if (result->is_error()) {
|
|
const std::string msg = json_value(result->to_json(), "message", std::string("server error"));
|
|
send_error("server_error", msg, true);
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, msg);
|
|
}
|
|
}
|
|
send_event("session.created", rts::session_meta(input_rate, output_rate, rts::default_voices(), voice));
|
|
configured = true;
|
|
|
|
std::thread reader([this]() {
|
|
backend::AudioToAudioRequest req;
|
|
while (!fatal.load() && stream->Read(&req)) {
|
|
handle(req);
|
|
}
|
|
client_done = true;
|
|
});
|
|
|
|
// a response that is still running is allowed to finish after the client half-closed
|
|
auto should_stop = [this]() {
|
|
return fatal.load() || gctx->IsCancelled() || (client_done.load() && !response_active.load());
|
|
};
|
|
while (true) {
|
|
auto result = rd.next(should_stop);
|
|
if (!result) {
|
|
break;
|
|
}
|
|
if (result->is_error()) {
|
|
const json j = result->to_json();
|
|
send_error("server_error", json_value(j, "message", std::string("server error")), true);
|
|
fatal_message = json_value(j, "message", std::string("server error"));
|
|
fatal = true;
|
|
break;
|
|
}
|
|
if (auto * res = dynamic_cast<server_task_result_realtime *>(result.get())) {
|
|
on_slot_result(*res);
|
|
}
|
|
}
|
|
|
|
// a reader blocked in Read() only returns once the call is cancelled
|
|
if (!client_done.load()) {
|
|
gctx->TryCancel();
|
|
}
|
|
reader.join();
|
|
|
|
if (!fatal_message.empty()) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, fatal_message);
|
|
}
|
|
if (peer.gone()) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "client closed the stream");
|
|
}
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
private:
|
|
server_context & ctx_server;
|
|
const common_params & params;
|
|
grpc::ServerContext * gctx;
|
|
Stream * stream;
|
|
|
|
const std::string id = "sess_" + random_string();
|
|
int input_rate = 16000;
|
|
int output_rate = 24000;
|
|
|
|
// session config, owned by the reader thread once it runs
|
|
bool configured = false;
|
|
std::string instructions;
|
|
bool instructions_default = true; // the client chose none: they follow the tools
|
|
std::string voice;
|
|
std::string tools_text;
|
|
std::vector<common_chat_tool> tools;
|
|
rts::SessionParams session_params;
|
|
rts::TurnText turn;
|
|
rts::ConversationState state;
|
|
|
|
// output
|
|
std::mutex out_mutex;
|
|
llama_grpc::StreamPeer peer;
|
|
int64_t seq = 0;
|
|
std::atomic<bool> fatal{false};
|
|
std::string fatal_message;
|
|
std::atomic<bool> client_done{false};
|
|
|
|
// the response being produced, shared by the reader and the result loop
|
|
std::mutex resp_mutex;
|
|
std::unique_ptr<rts::ResponseTranslator> translator;
|
|
std::atomic<bool> response_active{false};
|
|
|
|
//
|
|
// events
|
|
//
|
|
|
|
void write(backend::AudioToAudioResponse & r) {
|
|
std::lock_guard<std::mutex> lock(out_mutex);
|
|
if (peer.gone()) {
|
|
return;
|
|
}
|
|
r.set_sequence(seq++);
|
|
peer.observe_write(stream->Write(r));
|
|
if (peer.gone()) {
|
|
fatal = true;
|
|
}
|
|
}
|
|
|
|
void send_event(const std::string & event, const rts::json & meta) {
|
|
backend::AudioToAudioResponse r;
|
|
r.set_event(event);
|
|
r.set_meta(rts::dump_json(meta));
|
|
write(r);
|
|
}
|
|
|
|
void send_out(const rts::OutEvent & ev) {
|
|
backend::AudioToAudioResponse r;
|
|
r.set_event(ev.event);
|
|
if (!ev.pcm.empty()) {
|
|
r.set_pcm(ev.pcm);
|
|
r.set_sample_rate(ev.sample_rate);
|
|
} else {
|
|
r.set_meta(rts::dump_json(ev.meta));
|
|
}
|
|
write(r);
|
|
}
|
|
|
|
// response_scoped: the error answers a response.create (or ends the stream)
|
|
// and no response.done follows. See rts::error_meta.
|
|
void send_error(const std::string & type, const std::string & message, bool response_scoped = false) {
|
|
send_event("error", rts::error_meta(type, message, response_scoped));
|
|
}
|
|
|
|
//
|
|
// slot controls, run on the slot in the order they are posted
|
|
//
|
|
|
|
bool control(server_control_action action, std::string text = {}, std::vector<float> audio = {}, server_control_response response = {}) {
|
|
auto ctl = ctx_server.get_response_reader();
|
|
server_task task(SERVER_TASK_TYPE_CONTROL);
|
|
task.id = ctl.get_new_id();
|
|
task.params.control_action = action;
|
|
task.params.control_cmpl_id = id;
|
|
task.params.control_text = std::move(text);
|
|
task.params.control_audio = std::move(audio);
|
|
task.params.control_response = response;
|
|
ctl.post_task(std::move(task));
|
|
auto result = ctl.next([this]() { return fatal.load() || gctx->IsCancelled(); });
|
|
auto * res = result ? dynamic_cast<server_task_result_control *>(result.get()) : nullptr;
|
|
return res && res->success;
|
|
}
|
|
|
|
//
|
|
// chat template
|
|
//
|
|
|
|
common_chat_templates_inputs base_inputs() const {
|
|
const auto & chat = ctx_server.impl->chat_params;
|
|
common_chat_templates_inputs in;
|
|
in.use_jinja = chat.use_jinja;
|
|
in.enable_thinking = false;
|
|
in.chat_template_kwargs = chat.chat_template_kwargs;
|
|
in.tools = tools;
|
|
return in;
|
|
}
|
|
|
|
static std::vector<common_chat_msg> to_common(const std::vector<rts::Msg> & msgs) {
|
|
std::vector<common_chat_msg> out;
|
|
for (const auto & m : msgs) {
|
|
common_chat_msg c;
|
|
c.role = m.role;
|
|
c.content = m.content;
|
|
c.tool_call_id = m.tool_call_id;
|
|
for (const auto & tc : m.tool_calls) {
|
|
c.tool_calls.push_back({tc.name, tc.arguments, tc.id});
|
|
}
|
|
out.push_back(std::move(c));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
rts::RenderFn renderer() const {
|
|
return [this](const std::vector<rts::Msg> & msgs, bool add_generation_prompt) {
|
|
auto in = base_inputs();
|
|
in.messages = to_common(msgs);
|
|
in.add_generation_prompt = add_generation_prompt;
|
|
return common_chat_templates_apply(ctx_server.impl->chat_params.tmpls.get(), in).prompt.str();
|
|
};
|
|
}
|
|
|
|
static common_chat_tool_choice to_common_choice(rts::ToolChoice c) {
|
|
switch (c) {
|
|
case rts::ToolChoice::None: return COMMON_CHAT_TOOL_CHOICE_NONE;
|
|
case rts::ToolChoice::Required: return COMMON_CHAT_TOOL_CHOICE_REQUIRED;
|
|
default: return COMMON_CHAT_TOOL_CHOICE_AUTO;
|
|
}
|
|
}
|
|
|
|
// The chat session of one response, as upstream builds it for /v1/realtime:
|
|
// the template selects the grammar and the parser for the tools of the session.
|
|
common_chat_session make_chat_session(rts::ToolChoice choice) const {
|
|
auto in = base_inputs();
|
|
in.add_generation_prompt = true;
|
|
in.parallel_tool_calls = true;
|
|
in.tool_choice = to_common_choice(choice);
|
|
// as the server does: the empty think block of the generation prompt is reasoning, not content
|
|
in.reasoning_format = ctx_server.impl->chat_params.reasoning_format;
|
|
common_chat_msg user;
|
|
user.role = "user";
|
|
user.content = "x";
|
|
in.messages.push_back(user);
|
|
return common_chat_session(ctx_server.impl->chat_params.tmpls.get(), ctx_server.impl->vocab, in);
|
|
}
|
|
|
|
// Splits the text of a response into content and tool calls while it grows.
|
|
// If the parser fails, the rest of the response is content and the full text
|
|
// still goes out in response.done, so the caller can parse it another way.
|
|
static rts::ResponseTranslator::Splitter make_splitter(common_chat_session session) {
|
|
auto state = std::make_shared<task_result_state>(std::move(session));
|
|
auto broken = std::make_shared<bool>(false);
|
|
return [state, broken](const std::string & text, bool final) {
|
|
rts::SplitResult out;
|
|
if (*broken) {
|
|
out.content = text;
|
|
return out;
|
|
}
|
|
try {
|
|
std::vector<common_chat_msg_diff> diffs;
|
|
state->update_chat_msg(common_chat_input(text), !final, diffs, true);
|
|
for (const auto & d : diffs) {
|
|
out.content += d.content_delta;
|
|
if (d.tool_call_index != std::string::npos) {
|
|
out.calls.push_back({d.tool_call_index, d.tool_call_delta.id, d.tool_call_delta.name, d.tool_call_delta.arguments});
|
|
}
|
|
}
|
|
} catch (const std::exception & e) {
|
|
LOG_WRN("realtime: could not parse the tool calls of a response: %s\n", e.what());
|
|
*broken = true;
|
|
out.content = text;
|
|
}
|
|
return out;
|
|
};
|
|
}
|
|
|
|
//
|
|
// session config
|
|
//
|
|
|
|
common_params_sampling sampling() const {
|
|
auto s = params.sampling;
|
|
if (session_params.temperature) {
|
|
s.temp = *session_params.temperature;
|
|
}
|
|
if (session_params.top_p) {
|
|
s.top_p = *session_params.top_p;
|
|
}
|
|
if (session_params.top_k) {
|
|
s.top_k = *session_params.top_k;
|
|
}
|
|
if (session_params.seed) {
|
|
s.seed = *session_params.seed;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// any tool of the session, to derive the tool turn from the chat template
|
|
std::string tool_name() const {
|
|
return tools.empty() ? std::string() : tools.front().name;
|
|
}
|
|
|
|
rts::OutMode session_out_mode() const {
|
|
return session_params.out_mode ? *session_params.out_mode : rts::default_out_mode(!tools.empty());
|
|
}
|
|
|
|
// tools: a JSON array in OpenAI format. Empty means none.
|
|
static bool parse_tools(const std::string & text, std::vector<common_chat_tool> & out, std::string & err) {
|
|
out.clear();
|
|
if (text.empty()) {
|
|
return true;
|
|
}
|
|
try {
|
|
const json parsed = json::parse(text);
|
|
if (!parsed.is_array()) {
|
|
err = "tools must be a JSON array";
|
|
return false;
|
|
}
|
|
out = common_chat_tools_parse_oaicompat(parsed);
|
|
} catch (const std::exception & e) {
|
|
err = std::string("invalid tools: ") + e.what();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Applies a config before the first turn: derives the text around the turns.
|
|
// Nothing changes if it fails.
|
|
bool apply_config(const backend::AudioToAudioConfig & c, std::string & err) {
|
|
if (c.input_sample_rate() != 0 && c.input_sample_rate() != input_rate) {
|
|
err = "the input audio must be PCM16 at " + std::to_string(input_rate) + " Hz";
|
|
return false;
|
|
}
|
|
rts::SessionParams p;
|
|
if (!rts::parse_session_params(std::map<std::string, std::string>(c.params().begin(), c.params().end()), p, err)) {
|
|
return false;
|
|
}
|
|
std::vector<common_chat_tool> new_tools;
|
|
if (!parse_tools(c.tools(), new_tools, err)) {
|
|
return false;
|
|
}
|
|
const std::string new_instructions = rts::resolve_instructions(c.system_prompt(), !new_tools.empty());
|
|
|
|
// render with the new tools, keep the old state until it works
|
|
const auto old_tools = tools;
|
|
tools = new_tools;
|
|
rts::TurnText t;
|
|
if (!rts::derive_turn_text(renderer(), new_instructions, tool_name(), t, err)) {
|
|
tools = old_tools;
|
|
return false;
|
|
}
|
|
instructions = new_instructions;
|
|
instructions_default = c.system_prompt().empty() || rts::is_default_instructions(c.system_prompt());
|
|
tools_text = c.tools();
|
|
voice = c.voice();
|
|
session_params = p;
|
|
turn = t;
|
|
return true;
|
|
}
|
|
|
|
// session.update, or a later config: before the first turn everything changes, afterwards only the response options
|
|
void update_session(const std::string & new_instructions, const std::string & new_tools, const std::string & new_voice,
|
|
const rts::json & modalities, const rts::json & max_tokens, const rts::json & choice) {
|
|
const bool tools_changed = !new_tools.empty() && new_tools != tools_text;
|
|
const bool voice_changed = !new_voice.empty() && new_voice != voice;
|
|
// The default instructions follow the tools, so a client that sends none (or the
|
|
// default back) gets the text that fits the tools it ends up with. A tools update
|
|
// that is not valid keeps the old tools, and the error is sent below.
|
|
std::vector<common_chat_tool> parsed_tools;
|
|
std::string err;
|
|
const bool tools_ok = !tools_changed || parse_tools(new_tools, parsed_tools, err);
|
|
const bool has_tools_after = tools_changed && tools_ok ? !parsed_tools.empty() : !tools.empty();
|
|
std::string target_instructions = instructions;
|
|
if (!new_instructions.empty()) {
|
|
target_instructions = rts::resolve_instructions(new_instructions, has_tools_after);
|
|
} else if (instructions_default) {
|
|
target_instructions = rts::default_instructions(has_tools_after);
|
|
}
|
|
const bool instr_changed = target_instructions != instructions;
|
|
const std::string denied = state.session_update_error(instr_changed && !new_instructions.empty(), tools_changed, voice_changed);
|
|
if (!denied.empty()) {
|
|
send_error("invalid_request_error", "session.update: " + denied);
|
|
return;
|
|
}
|
|
rts::SessionParams p = session_params;
|
|
if (!modalities.is_null()) {
|
|
rts::OutMode m;
|
|
if (!rts::parse_output_modalities(modalities, m, err)) {
|
|
send_error("invalid_request_error", "session.update: " + err);
|
|
return;
|
|
}
|
|
p.out_mode = m;
|
|
}
|
|
if (!max_tokens.is_null() && !rts::parse_max_output_tokens(max_tokens, p.max_output_tokens)) {
|
|
send_error("invalid_request_error", "session.update: max_output_tokens must be an integer or \"inf\"");
|
|
return;
|
|
}
|
|
if (!choice.is_null() && !rts::parse_tool_choice(choice, p.tool_choice, err)) {
|
|
send_error("invalid_request_error", "session.update: " + err);
|
|
return;
|
|
}
|
|
if (!state.started() && (instr_changed || tools_changed)) {
|
|
if (!tools_ok) {
|
|
send_error("invalid_request_error", "session.update: " + err);
|
|
return;
|
|
}
|
|
const auto old_tools = tools;
|
|
if (tools_changed) {
|
|
tools = parsed_tools;
|
|
}
|
|
rts::TurnText tt;
|
|
if (!rts::derive_turn_text(renderer(), target_instructions, tool_name(), tt, err)) {
|
|
tools = old_tools;
|
|
send_error("server_error", err);
|
|
return;
|
|
}
|
|
turn = tt;
|
|
instructions = target_instructions;
|
|
if (!new_instructions.empty()) {
|
|
instructions_default = rts::is_default_instructions(new_instructions);
|
|
}
|
|
if (tools_changed) {
|
|
tools_text = new_tools;
|
|
}
|
|
}
|
|
if (voice_changed) {
|
|
voice = new_voice;
|
|
}
|
|
session_params = p;
|
|
send_event("session.updated", rts::session_meta(input_rate, output_rate, rts::default_voices(), voice));
|
|
}
|
|
|
|
//
|
|
// client messages
|
|
//
|
|
|
|
void handle(const backend::AudioToAudioRequest & req) {
|
|
switch (req.payload_case()) {
|
|
case backend::AudioToAudioRequest::kConfig: on_config(req.config()); break;
|
|
case backend::AudioToAudioRequest::kFrame: on_frame(req.frame()); break;
|
|
case backend::AudioToAudioRequest::kControl: on_control(req.control()); break;
|
|
default: send_error("invalid_request_error", "empty message"); break;
|
|
}
|
|
}
|
|
|
|
void on_config(const backend::AudioToAudioConfig & c) {
|
|
if (c.reset()) {
|
|
if (response_active.load()) {
|
|
send_error("invalid_request_error", "reset: a response is running, cancel it first");
|
|
return;
|
|
}
|
|
if (!control(SERVER_CONTROL_ACTION_CLEAR)) {
|
|
send_error("server_error", "reset: the slot did not accept the control");
|
|
return;
|
|
}
|
|
state.reset();
|
|
std::string err;
|
|
if (!apply_config(c, err)) {
|
|
send_error("invalid_request_error", err);
|
|
return;
|
|
}
|
|
send_event("session.updated", rts::session_meta(input_rate, output_rate, rts::default_voices(), voice));
|
|
return;
|
|
}
|
|
// the options of a later config are applied like a session.update
|
|
const auto & p = c.params();
|
|
rts::json modalities = nullptr;
|
|
rts::json max_tokens = nullptr;
|
|
auto it = p.find("output_modalities");
|
|
if (it != p.end()) {
|
|
rts::OutMode m;
|
|
std::string err;
|
|
if (!rts::parse_modalities_string(it->second, m, err)) {
|
|
send_error("invalid_request_error", "config: " + err);
|
|
return;
|
|
}
|
|
modalities = rts::out_mode_json(m);
|
|
}
|
|
rts::json choice = nullptr;
|
|
auto ct = p.find("tool_choice");
|
|
if (ct != p.end()) {
|
|
choice = ct->second;
|
|
}
|
|
update_session(c.system_prompt(), c.tools(), c.voice(), modalities, max_tokens, choice);
|
|
}
|
|
|
|
// the header of a user turn goes in before its first audio or text
|
|
bool ensure_user_turn() {
|
|
if (state.user_turn_is_open()) {
|
|
return true;
|
|
}
|
|
if (!control(SERVER_CONTROL_ACTION_TEXT_APPEND, state.user_turn_header(turn))) {
|
|
send_error("server_error", "the slot did not accept the turn");
|
|
return false;
|
|
}
|
|
state.user_turn_opened();
|
|
return true;
|
|
}
|
|
|
|
// response_scoped: the commit is part of a response.create
|
|
bool commit(bool response_scoped = false) {
|
|
if (!state.has_audio()) {
|
|
send_error("input_audio_buffer_commit_empty", "input_audio_buffer.commit: the buffer is empty", response_scoped);
|
|
return false;
|
|
}
|
|
if (!control(SERVER_CONTROL_ACTION_AUDIO_END)) {
|
|
send_error("server_error", "the slot did not accept the end of the audio", response_scoped);
|
|
return false;
|
|
}
|
|
state.audio_committed();
|
|
send_event("input_audio_buffer.committed", rts::json::object());
|
|
return true;
|
|
}
|
|
|
|
void on_frame(const backend::AudioToAudioFrame & f) {
|
|
if (!f.pcm().empty()) {
|
|
auto samples = rts::pcm_s16le_to_f32(f.pcm());
|
|
if (samples.empty() || !ensure_user_turn()) {
|
|
return;
|
|
}
|
|
if (!control(SERVER_CONTROL_ACTION_AUDIO_APPEND, {}, std::move(samples))) {
|
|
send_error("server_error", "the slot did not accept the audio");
|
|
return;
|
|
}
|
|
state.audio_appended();
|
|
}
|
|
if (f.end_of_input()) {
|
|
commit();
|
|
}
|
|
}
|
|
|
|
void on_control(const backend::AudioToAudioControl & c) {
|
|
rts::json payload = rts::json::object();
|
|
if (!c.payload().empty()) {
|
|
payload = rts::json::parse(c.payload(), nullptr, false);
|
|
if (payload.is_discarded()) {
|
|
send_error("invalid_request_error", c.event() + ": the payload is not valid JSON", c.event() == "response.create");
|
|
return;
|
|
}
|
|
}
|
|
const std::string & ev = c.event();
|
|
if (ev == "input_audio_buffer.commit") {
|
|
commit();
|
|
} else if (ev == "input_audio_buffer.clear") {
|
|
// the audio already went into the conversation
|
|
send_event("input_audio_buffer.cleared", rts::json::object());
|
|
} else if (ev == "response.create") {
|
|
create_response(payload);
|
|
} else if (ev == "response.cancel") {
|
|
if (!response_active.load()) {
|
|
send_error("response_cancel_not_active", "response.cancel: there is no response to cancel");
|
|
} else if (!control(SERVER_CONTROL_ACTION_RESPONSE_CANCEL)) {
|
|
send_error("server_error", "the slot did not accept the cancel");
|
|
}
|
|
} else if (ev == "conversation.item.create") {
|
|
create_item(payload);
|
|
} else if (ev == "session.update") {
|
|
const rts::json s = payload.contains("session") && payload["session"].is_object() ? payload["session"] : payload;
|
|
update_session(str_field(s, "instructions"),
|
|
s.contains("tools") ? (s["tools"].is_string() ? s["tools"].get<std::string>() : s["tools"].dump()) : std::string(),
|
|
str_field(s, "voice"),
|
|
s.contains("output_modalities") ? s["output_modalities"] : rts::json(nullptr),
|
|
s.contains("max_output_tokens") ? s["max_output_tokens"] : rts::json(nullptr),
|
|
s.contains("tool_choice") ? s["tool_choice"] : rts::json(nullptr));
|
|
} else {
|
|
send_error("invalid_request_error", "unknown event type: " + ev);
|
|
}
|
|
}
|
|
|
|
void create_item(const rts::json & payload) {
|
|
const rts::json item = payload.contains("item") && payload["item"].is_object() ? payload["item"] : payload;
|
|
const std::string type = str_field(item, "type");
|
|
if (type == "function_call_output") {
|
|
const rts::json out = item.value("output", rts::json(""));
|
|
std::string err;
|
|
const std::string text = state.tool_turn_text(turn, out.is_string() ? out.get<std::string>() : out.dump(), err);
|
|
if (text.empty()) {
|
|
send_error("invalid_request_error", err.empty() ? "function_call_output: empty tool turn" : err);
|
|
return;
|
|
}
|
|
if (!control(SERVER_CONTROL_ACTION_TEXT_APPEND, text)) {
|
|
send_error("server_error", "the slot did not accept the tool result");
|
|
return;
|
|
}
|
|
state.tool_turn_opened();
|
|
return;
|
|
}
|
|
if (type != "message" || str_field(item, "role") != "user") {
|
|
send_error("invalid_request_error", "conversation.item.create: only user messages and function_call_output are supported");
|
|
return;
|
|
}
|
|
std::string text;
|
|
for (const auto & part : item.value("content", rts::json::array())) {
|
|
const std::string t = str_field(part, "type");
|
|
if (t != "input_text") {
|
|
send_error("invalid_request_error", "conversation.item.create: unsupported content type: " + t);
|
|
return;
|
|
}
|
|
text += str_field(part, "text");
|
|
}
|
|
if (!ensure_user_turn()) {
|
|
return;
|
|
}
|
|
if (!control(SERVER_CONTROL_ACTION_TEXT_APPEND, text)) {
|
|
send_error("server_error", "the slot did not accept the message");
|
|
}
|
|
}
|
|
|
|
static mtmd_helper_gen_audio_out_mode to_mtmd(rts::OutMode m) {
|
|
switch (m) {
|
|
case rts::OutMode::Text: return MTMD_HELPER_GEN_AUDIO_OUT_TEXT;
|
|
case rts::OutMode::Audio: return MTMD_HELPER_GEN_AUDIO_OUT_AUDIO;
|
|
case rts::OutMode::Mixed: return MTMD_HELPER_GEN_AUDIO_OUT_MIXED;
|
|
default: return MTMD_HELPER_GEN_AUDIO_OUT_AUTO;
|
|
}
|
|
}
|
|
|
|
void create_response(const rts::json & payload) {
|
|
if (response_active.load()) {
|
|
send_error("conversation_already_has_active_response", "response.create: a response is already running", true);
|
|
return;
|
|
}
|
|
const std::string not_ready = state.response_create_error();
|
|
if (!not_ready.empty()) {
|
|
send_error("conversation_empty", not_ready, true);
|
|
return;
|
|
}
|
|
rts::ResponseOptions opts{};
|
|
std::string err;
|
|
if (!rts::parse_response_options(payload, session_out_mode(), session_params.max_output_tokens, session_params.tool_choice, opts, err)) {
|
|
send_error("invalid_request_error", "response.create: " + err, true);
|
|
return;
|
|
}
|
|
const auto & cap = ctx_server.impl->mtmd_cap;
|
|
if (!cap.audio_gen && opts.out_mode != rts::OutMode::Text && opts.out_mode != rts::OutMode::Auto) {
|
|
send_error("invalid_request_error", "response.create: the model cannot generate audio", true);
|
|
return;
|
|
}
|
|
if (!cap.text_gen && opts.out_mode != rts::OutMode::Audio && opts.out_mode != rts::OutMode::Auto) {
|
|
send_error("invalid_request_error", "response.create: the model cannot generate text", true);
|
|
return;
|
|
}
|
|
// audio not committed yet belongs to this turn
|
|
if (state.has_audio() && !commit(true)) {
|
|
return;
|
|
}
|
|
|
|
server_control_response response;
|
|
response.n_predict = opts.max_output_tokens;
|
|
response.out_mode = to_mtmd(opts.out_mode);
|
|
response.sampling = sampling();
|
|
|
|
// with tools, the grammar and the parser of the chat template take part in the response
|
|
rts::ResponseTranslator::Splitter splitter;
|
|
if (!tools.empty()) {
|
|
try {
|
|
common_chat_session chat = make_chat_session(opts.tool_choice);
|
|
chat.apply_sampling(response.sampling);
|
|
splitter = make_splitter(std::move(chat));
|
|
} catch (const std::exception & e) {
|
|
send_error("server_error", std::string("response.create: ") + e.what(), true);
|
|
return;
|
|
}
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> lock(resp_mutex);
|
|
translator = std::make_unique<rts::ResponseTranslator>(opts.out_mode, output_rate, std::move(splitter));
|
|
}
|
|
response_active = true;
|
|
if (!control(SERVER_CONTROL_ACTION_RESPONSE_CREATE, state.response_prompt(turn), {}, response)) {
|
|
response_active = false;
|
|
send_error("server_error", "the slot did not accept the response", true);
|
|
return;
|
|
}
|
|
state.response_created(turn);
|
|
}
|
|
|
|
//
|
|
// results of the slot
|
|
//
|
|
|
|
void on_slot_result(const server_task_result_realtime & res) {
|
|
std::vector<rts::OutEvent> events;
|
|
{
|
|
std::lock_guard<std::mutex> lock(resp_mutex);
|
|
if (!translator) {
|
|
return;
|
|
}
|
|
events = translator->on_result(res.text, res.audio, res.response_done, res.cancelled, res.n_decoded);
|
|
if (translator->done()) {
|
|
translator.reset();
|
|
response_active = false;
|
|
}
|
|
}
|
|
for (const auto & ev : events) {
|
|
send_out(ev);
|
|
}
|
|
}
|
|
};
|
|
#endif
|
|
|
|
// GRPC Server start
|
|
class BackendServiceImpl final : public backend::Backend::Service {
|
|
private:
|
|
server_context& ctx_server;
|
|
common_params params_base; // Store copy of params_base, set after model load
|
|
// The ModelOptions.Model this process was loaded with. Compared against
|
|
// PredictOptions.ModelIdentity so a request that reached us through a stale
|
|
// distributed route is rejected instead of answered from the wrong model
|
|
// (#10952). Written under LoadModel, read by the inference RPCs.
|
|
std::string loaded_model_identity;
|
|
|
|
public:
|
|
BackendServiceImpl(server_context& ctx) : ctx_server(ctx) {}
|
|
|
|
// checkModelIdentity mirrors pkg/grpc/server.go and
|
|
// backend/python/common/model_identity.py. Either side being empty means
|
|
// "skip": the request side is empty for a controller that predates the
|
|
// field and for the synthetic PredictOptions this server builds internally
|
|
// for ASR, and the loaded side is empty when such a controller performed
|
|
// the load. A false rejection is worse than the miss it prevents.
|
|
// Templated over the request type: every guarded request message exposes
|
|
// modelidentity(), and one body keeps the rule identical across modalities
|
|
// rather than repeating it per RPC.
|
|
template <typename Request>
|
|
grpc::Status checkModelIdentity(const Request* request) {
|
|
if (request == nullptr || request->modelidentity().empty()) {
|
|
return grpc::Status::OK;
|
|
}
|
|
if (loaded_model_identity.empty() || loaded_model_identity == request->modelidentity()) {
|
|
return grpc::Status::OK;
|
|
}
|
|
// NOT_FOUND plus this exact sentinel is the cross-language contract the
|
|
// router matches on (grpcerrors.ModelMismatchSentinel). The code alone
|
|
// is not enough: NOT_FOUND is returned for unrelated reasons elsewhere.
|
|
return grpc::Status(grpc::StatusCode::NOT_FOUND,
|
|
"llama-cpp: model identity mismatch: loaded \"" + loaded_model_identity +
|
|
"\", requested \"" + request->modelidentity() + "\"");
|
|
}
|
|
|
|
grpc::Status Health(ServerContext* context, const backend::HealthMessage* /*request*/, backend::Reply* reply) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
// Implement Health RPC
|
|
reply->set_message("OK");
|
|
return Status::OK;
|
|
}
|
|
|
|
grpc::Status LoadModel(ServerContext* context, const backend::ModelOptions* request, backend::Result* result) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
// Implement LoadModel RPC
|
|
common_params params;
|
|
params_parse(ctx_server, request, params);
|
|
|
|
#ifndef LOCALAI_LLAMA_CPP_NO_SCORE_TASK
|
|
if (params.score_enabled && !params.kv_unified) {
|
|
const std::string error_msg =
|
|
"Score requires the unified KV cache; remove kv_unified:false or remove score from known_usecases";
|
|
result->set_message(error_msg);
|
|
result->set_success(false);
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, error_msg);
|
|
}
|
|
#endif
|
|
|
|
common_init();
|
|
// Ensure debug logs are enabled after common_init() sets up logging
|
|
common_log_set_verbosity_thold(params.verbosity);
|
|
|
|
llama_backend_init();
|
|
llama_numa_init(params.numa);
|
|
|
|
|
|
LOG_INF("system info: n_threads = %d, n_threads_batch = %d, total_threads = %d\n", params.cpuparams.n_threads, params.cpuparams_batch.n_threads, std::thread::hardware_concurrency());
|
|
LOG_INF("\n");
|
|
LOG_INF("%s\n", common_params_get_system_info(params).c_str());
|
|
LOG_INF("\n");
|
|
|
|
// Capture error messages during model loading
|
|
struct error_capture {
|
|
std::string captured_error;
|
|
std::mutex error_mutex;
|
|
ggml_log_callback original_callback;
|
|
void* original_user_data;
|
|
} error_capture_data;
|
|
|
|
// Get original log callback
|
|
llama_log_get(&error_capture_data.original_callback, &error_capture_data.original_user_data);
|
|
|
|
// Set custom callback to capture errors
|
|
llama_log_set([](ggml_log_level level, const char * text, void * user_data) {
|
|
auto* capture = static_cast<error_capture*>(user_data);
|
|
|
|
// Capture error messages
|
|
if (level == GGML_LOG_LEVEL_ERROR) {
|
|
std::lock_guard<std::mutex> lock(capture->error_mutex);
|
|
// Append error message, removing trailing newlines
|
|
std::string msg(text);
|
|
while (!msg.empty() && (msg.back() == '\n' || msg.back() == '\r')) {
|
|
msg.pop_back();
|
|
}
|
|
if (!msg.empty()) {
|
|
if (!capture->captured_error.empty()) {
|
|
capture->captured_error.append("; ");
|
|
}
|
|
capture->captured_error.append(msg);
|
|
}
|
|
}
|
|
|
|
// Also call original callback to preserve logging
|
|
if (capture->original_callback) {
|
|
capture->original_callback(level, text, capture->original_user_data);
|
|
}
|
|
}, &error_capture_data);
|
|
|
|
// load the model
|
|
bool load_success = ctx_server.load_model(params);
|
|
|
|
// Restore original log callback
|
|
llama_log_set(error_capture_data.original_callback, error_capture_data.original_user_data);
|
|
|
|
if (!load_success) {
|
|
std::string error_msg = "Failed to load model: " + params.model.path;
|
|
if (!params.mmproj.path.empty()) {
|
|
error_msg += " (with mmproj: " + params.mmproj.path + ")";
|
|
}
|
|
if (params.speculative.has_dft() && !params.speculative.draft.mparams.path.empty()) {
|
|
error_msg += " (with draft model: " + params.speculative.draft.mparams.path + ")";
|
|
}
|
|
|
|
// Add captured error details if available
|
|
{
|
|
std::lock_guard<std::mutex> lock(error_capture_data.error_mutex);
|
|
if (!error_capture_data.captured_error.empty()) {
|
|
error_msg += ". Error: " +
|
|
localai::model_load_error_with_hint(error_capture_data.captured_error);
|
|
} else {
|
|
error_msg += ". Model file may not exist or be invalid.";
|
|
}
|
|
}
|
|
|
|
result->set_message(error_msg);
|
|
result->set_success(false);
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, error_msg);
|
|
}
|
|
|
|
// Process grammar triggers now that vocab is available
|
|
if (!params.sampling.grammar_triggers.empty()) {
|
|
std::vector<common_grammar_trigger> processed_triggers;
|
|
for (const auto& trigger : params.sampling.grammar_triggers) {
|
|
if (trigger.type == COMMON_GRAMMAR_TRIGGER_TYPE_WORD) {
|
|
auto ids = common_tokenize(ctx_server.impl->vocab, trigger.value, /* add_special= */ false, /* parse_special= */ true);
|
|
if (ids.size() == 1) {
|
|
auto token = ids[0];
|
|
// Add the token to preserved_tokens if not already present
|
|
if (params.sampling.preserved_tokens.find(token) == params.sampling.preserved_tokens.end()) {
|
|
params.sampling.preserved_tokens.insert(token);
|
|
LOG_INF("Added grammar trigger token to preserved tokens: %d (`%s`)\n", token, trigger.value.c_str());
|
|
}
|
|
LOG_INF("Grammar trigger token: %d (`%s`)\n", token, trigger.value.c_str());
|
|
common_grammar_trigger processed_trigger;
|
|
processed_trigger.type = COMMON_GRAMMAR_TRIGGER_TYPE_TOKEN;
|
|
processed_trigger.value = trigger.value;
|
|
processed_trigger.token = token;
|
|
processed_triggers.push_back(std::move(processed_trigger));
|
|
} else {
|
|
LOG_INF("Grammar trigger word: `%s`\n", trigger.value.c_str());
|
|
processed_triggers.push_back(trigger);
|
|
}
|
|
} else {
|
|
processed_triggers.push_back(trigger);
|
|
}
|
|
}
|
|
// Update the grammar triggers in params
|
|
params.sampling.grammar_triggers = std::move(processed_triggers);
|
|
}
|
|
|
|
//ctx_server.init();
|
|
result->set_message("Loading succeeded");
|
|
result->set_success(true);
|
|
loaded_model = true;
|
|
loaded_model_identity = request->model();
|
|
// Store copy of params_base for use in parse_options and other methods
|
|
params_base = params;
|
|
|
|
return Status::OK;
|
|
}
|
|
|
|
// Helper function to extract logprobs from JSON response
|
|
static json extract_logprobs_from_json(const json& res_json) {
|
|
json logprobs_json = json::object();
|
|
|
|
// Check for OAI-compatible format: choices[0].logprobs
|
|
if (res_json.contains("choices") && res_json["choices"].is_array() &&
|
|
res_json["choices"].size() > 0 && res_json["choices"][0].contains("logprobs")) {
|
|
logprobs_json = res_json["choices"][0]["logprobs"];
|
|
}
|
|
// Check for non-OAI format: completion_probabilities
|
|
else if (res_json.contains("completion_probabilities")) {
|
|
// Convert completion_probabilities to OAI format
|
|
logprobs_json["content"] = res_json["completion_probabilities"];
|
|
}
|
|
// Check for direct logprobs field
|
|
else if (res_json.contains("logprobs")) {
|
|
logprobs_json = res_json["logprobs"];
|
|
}
|
|
|
|
return logprobs_json;
|
|
}
|
|
|
|
// Helper: populate chat_deltas on a Reply from oaicompat_msg_diffs (streaming chunks)
|
|
static void populate_chat_deltas_from_diffs(backend::Reply & reply,
|
|
const std::vector<common_chat_msg_diff> & diffs) {
|
|
for (const auto & diff : diffs) {
|
|
auto* delta = reply.add_chat_deltas();
|
|
if (!diff.content_delta.empty()) {
|
|
delta->set_content(diff.content_delta);
|
|
}
|
|
if (!diff.reasoning_content_delta.empty()) {
|
|
delta->set_reasoning_content(diff.reasoning_content_delta);
|
|
}
|
|
if (diff.tool_call_index != std::string::npos) {
|
|
auto* tc = delta->add_tool_calls();
|
|
tc->set_index(static_cast<int32_t>(diff.tool_call_index));
|
|
if (!diff.tool_call_delta.id.empty()) {
|
|
tc->set_id(diff.tool_call_delta.id);
|
|
}
|
|
if (!diff.tool_call_delta.name.empty()) {
|
|
tc->set_name(diff.tool_call_delta.name);
|
|
}
|
|
if (!diff.tool_call_delta.arguments.empty()) {
|
|
tc->set_arguments(diff.tool_call_delta.arguments);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper: populate chat_deltas on a Reply from final oaicompat_msg (non-streaming)
|
|
static void populate_chat_deltas_from_final(backend::Reply & reply,
|
|
const common_chat_msg & msg) {
|
|
// Content delta
|
|
if (!msg.content.empty() || !msg.reasoning_content.empty() || !msg.tool_calls.empty()) {
|
|
auto* delta = reply.add_chat_deltas();
|
|
if (!msg.content.empty()) {
|
|
delta->set_content(msg.content);
|
|
}
|
|
if (!msg.reasoning_content.empty()) {
|
|
delta->set_reasoning_content(msg.reasoning_content);
|
|
}
|
|
// Tool calls as individual deltas within the same ChatDelta
|
|
for (size_t i = 0; i < msg.tool_calls.size(); i++) {
|
|
auto* tc = delta->add_tool_calls();
|
|
tc->set_index(static_cast<int32_t>(i));
|
|
tc->set_id(msg.tool_calls[i].id);
|
|
tc->set_name(msg.tool_calls[i].name);
|
|
tc->set_arguments(msg.tool_calls[i].arguments);
|
|
}
|
|
}
|
|
}
|
|
|
|
grpc::Status PredictStream(grpc::ServerContext* context, const backend::PredictOptions* request, grpc::ServerWriter<backend::Reply>* writer) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
auto identity = checkModelIdentity(request);
|
|
if (!identity.ok()) return identity;
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
json data = parse_options(true, request, params_base, ctx_server.get_llama_context());
|
|
|
|
|
|
//Raise error if embeddings is set to true
|
|
if (params_base.embedding) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "Embedding is not supported in streaming mode");
|
|
}
|
|
|
|
|
|
auto completion_id = gen_chatcmplid();
|
|
// get response reader - it contains references to the queues and will stay valid
|
|
auto rd = ctx_server.get_response_reader();
|
|
try {
|
|
std::vector<server_task> tasks;
|
|
|
|
std::string prompt_str;
|
|
std::vector<raw_buffer> files; // Declare files early so it's accessible in both branches
|
|
#if LOCALAI_HAS_CHAT_SESSION
|
|
common_chat_session chat_session;
|
|
#endif
|
|
// Handle chat templates when UseTokenizerTemplate is enabled and Messages are provided
|
|
if (request->usetokenizertemplate() && request->messages_size() > 0 && ctx_server.impl->chat_params.tmpls != nullptr) {
|
|
// Convert proto Messages to JSON format compatible with oaicompat_chat_params_parse
|
|
json body_json;
|
|
json messages_json = json::array();
|
|
|
|
// Find the last user message index to attach images/audio to
|
|
int last_user_msg_idx = -1;
|
|
for (int i = request->messages_size() - 1; i >= 0; i--) {
|
|
if (request->messages(i).role() == "user") {
|
|
last_user_msg_idx = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < request->messages_size(); i++) {
|
|
const auto& msg = request->messages(i);
|
|
llama_grpc::ReconstructedMessageInput rin;
|
|
rin.role = msg.role();
|
|
rin.content = msg.content();
|
|
rin.name = msg.name();
|
|
rin.tool_call_id = msg.tool_call_id();
|
|
rin.reasoning_content = msg.reasoning_content();
|
|
rin.tool_calls = msg.tool_calls();
|
|
rin.is_last_user_msg = (i == last_user_msg_idx);
|
|
if (rin.is_last_user_msg) {
|
|
for (int j = 0; j < request->images_size(); j++) rin.images.push_back(request->images(j));
|
|
for (int j = 0; j < request->audios_size(); j++) rin.audios.push_back(request->audios(j));
|
|
for (int j = 0; j < request->videos_size(); j++) rin.videos.push_back(request->videos(j));
|
|
}
|
|
messages_json.push_back(json::parse(llama_grpc::build_reconstructed_message(rin).dump()));
|
|
}
|
|
|
|
// Final safety check: Ensure no message has null content (Jinja templates require strings)
|
|
SRV_INF("[CONTENT DEBUG] PredictStream: Running final safety check on %zu messages\n", messages_json.size());
|
|
for (size_t idx = 0; idx < messages_json.size(); idx++) {
|
|
auto& msg = messages_json[idx];
|
|
if (msg.contains("content") && msg["content"].is_null()) {
|
|
SRV_INF("[CONTENT DEBUG] PredictStream: Safety check found message %zu with NULL content, converting to empty string\n", idx);
|
|
msg["content"] = "";
|
|
} else if (!msg.contains("content")) {
|
|
SRV_INF("[CONTENT DEBUG] PredictStream: Safety check found message %zu without content field, adding empty string\n", idx);
|
|
msg["content"] = "";
|
|
} else {
|
|
SRV_INF("[CONTENT DEBUG] PredictStream: Safety check message %zu: content OK, type=%s\n",
|
|
idx, msg["content"].is_string() ? "string" :
|
|
msg["content"].is_array() ? "array" :
|
|
msg["content"].is_object() ? "object" : "other");
|
|
}
|
|
}
|
|
|
|
// Debug: Count tool messages
|
|
int tool_msg_count = 0;
|
|
for (const auto& msg : messages_json) {
|
|
if (msg.contains("role") && msg["role"] == "tool") {
|
|
tool_msg_count++;
|
|
}
|
|
}
|
|
SRV_DBG("[TOOLS DEBUG] PredictStream: Built %d tool messages out of %zu total messages\n", tool_msg_count, messages_json.size());
|
|
|
|
// Debug: Print full conversation (messages)
|
|
SRV_DBG("[CONVERSATION DEBUG] PredictStream: Full messages array:\n%s\n", messages_json.dump(2).c_str());
|
|
|
|
body_json["messages"] = messages_json;
|
|
body_json["stream"] = true; // PredictStream is always streaming
|
|
body_json["stream_options"] = {{"include_usage", true}}; // Ensure token counts in final chunk
|
|
|
|
// Check if grammar is provided from Go layer (NoGrammar=false)
|
|
// If grammar is provided, we must use it and NOT let template generate grammar from tools
|
|
// oaicompat_chat_params_parse throws an error if both grammar and tools are provided
|
|
bool has_grammar_from_go = data.contains("grammar") &&
|
|
data["grammar"].is_string() &&
|
|
!data["grammar"].get<std::string>().empty();
|
|
|
|
SRV_INF("[TOOLS DEBUG] PredictStream: has_grammar_from_go=%d, data.contains(\"tools\")=%d, data.contains(\"grammar\")=%d\n",
|
|
has_grammar_from_go ? 1 : 0,
|
|
data.contains("tools") ? 1 : 0,
|
|
data.contains("grammar") ? 1 : 0);
|
|
if (data.contains("grammar")) {
|
|
SRV_INF("[TOOLS DEBUG] PredictStream: grammar type=%s, empty=%d\n",
|
|
data["grammar"].is_string() ? "string" : "other",
|
|
data["grammar"].is_string() && data["grammar"].get<std::string>().empty() ? 1 : 0);
|
|
}
|
|
|
|
// Copy other relevant fields from data that oaicompat_chat_params_parse expects
|
|
// Tools and tool_choice are only passed when NoGrammar is true (grammar not provided)
|
|
// When grammar is provided from Go layer, we use it instead of template-generated grammar
|
|
if (!has_grammar_from_go) {
|
|
// NoGrammar=true: pass tools and let template generate grammar
|
|
if (data.contains("tools")) {
|
|
body_json["tools"] = data["tools"];
|
|
std::string tools_str = data["tools"].dump();
|
|
SRV_INF("Using tools from data (NoGrammar=true): %s\n", tools_str.c_str());
|
|
// Debug: Log tools count and details before template processing
|
|
if (data["tools"].is_array()) {
|
|
SRV_INF("[TOOLS DEBUG] PredictStream: Passing %zu tools to oaicompat_chat_params_parse\n", data["tools"].size());
|
|
for (size_t t_idx = 0; t_idx < data["tools"].size(); t_idx++) {
|
|
const auto& tool = data["tools"][t_idx];
|
|
std::string tool_name = "unknown";
|
|
std::string tool_desc = "";
|
|
if (tool.contains("function")) {
|
|
const auto& func = tool["function"];
|
|
if (func.contains("name")) {
|
|
tool_name = func["name"].get<std::string>();
|
|
}
|
|
if (func.contains("description")) {
|
|
tool_desc = func["description"].is_string() ?
|
|
func["description"].get<std::string>() : "";
|
|
}
|
|
} else if (tool.contains("name")) {
|
|
tool_name = tool["name"].get<std::string>();
|
|
if (tool.contains("description")) {
|
|
tool_desc = tool["description"].is_string() ?
|
|
tool["description"].get<std::string>() : "";
|
|
}
|
|
}
|
|
SRV_INF("[TOOLS DEBUG] PredictStream: Tool %zu: name=%s, description=%s\n",
|
|
t_idx, tool_name.c_str(), tool_desc.substr(0, 100).c_str());
|
|
}
|
|
}
|
|
} else {
|
|
SRV_WRN("%s", "No tools found in data - tool calls will not work without tools field\n");
|
|
SRV_DBG("[TOOLS DEBUG] PredictStream: No tools in data, tool_choice=%s\n", data.contains("tool_choice") ? data["tool_choice"].dump().c_str() : "not set");
|
|
}
|
|
if (data.contains("tool_choice")) {
|
|
// tool_choice can be a string or object, but oaicompat_chat_params_parse expects a string
|
|
// Convert object tool_choice to "required" (since a specific function is requested)
|
|
if (data["tool_choice"].is_string()) {
|
|
body_json["tool_choice"] = data["tool_choice"].get<std::string>();
|
|
} else if (data["tool_choice"].is_object()) {
|
|
// Object tool_choice means a specific function is requested, use "required"
|
|
body_json["tool_choice"] = "required";
|
|
std::string tool_choice_obj_str = data["tool_choice"].dump();
|
|
SRV_INF("Converted object tool_choice to 'required': %s\n", tool_choice_obj_str.c_str());
|
|
} else {
|
|
// Fallback: convert to string
|
|
body_json["tool_choice"] = data["tool_choice"].dump();
|
|
}
|
|
std::string tool_choice_str = body_json["tool_choice"].get<std::string>();
|
|
SRV_INF("Using tool_choice: %s\n", tool_choice_str.c_str());
|
|
} else {
|
|
// Default to "auto" if not specified
|
|
body_json["tool_choice"] = "auto";
|
|
}
|
|
} else {
|
|
// Grammar is provided from Go layer (NoGrammar=false) - use it, don't pass tools
|
|
SRV_INF("%s", "Grammar provided from Go layer - using it instead of template-generated grammar\n");
|
|
// Grammar will be copied from data after parsing (it's already in data)
|
|
}
|
|
|
|
if (data.contains("json_schema")) {
|
|
body_json["json_schema"] = data["json_schema"];
|
|
}
|
|
// If grammar is provided from Go layer, copy it to body_json so it's preserved
|
|
// (though oaicompat_chat_params_parse may not use it if tools are present)
|
|
if (has_grammar_from_go) {
|
|
body_json["grammar"] = data["grammar"];
|
|
}
|
|
if (data.contains("response_format")) {
|
|
body_json["response_format"] = data["response_format"];
|
|
}
|
|
if (data.contains("chat_template_kwargs")) {
|
|
body_json["chat_template_kwargs"] = data["chat_template_kwargs"];
|
|
}
|
|
// Pass parallel_tool_calls if present (used by oaicompat_chat_params_parse)
|
|
if (data.contains("parallel_tool_calls")) {
|
|
body_json["parallel_tool_calls"] = data["parallel_tool_calls"];
|
|
}
|
|
// Pass add_generation_prompt if present (used by oaicompat_chat_params_parse)
|
|
if (data.contains("add_generation_prompt")) {
|
|
body_json["add_generation_prompt"] = data["add_generation_prompt"];
|
|
}
|
|
|
|
// Pass sampling parameters to body_json so oaicompat_chat_params_parse respects them
|
|
// and doesn't overwrite them with defaults in the returned parsed_data
|
|
if (data.contains("n_predict")) {
|
|
body_json["max_tokens"] = data["n_predict"];
|
|
}
|
|
if (data.contains("ignore_eos")) {
|
|
body_json["ignore_eos"] = data["ignore_eos"];
|
|
}
|
|
if (data.contains("stop")) {
|
|
body_json["stop"] = data["stop"];
|
|
}
|
|
if (data.contains("temperature")) {
|
|
body_json["temperature"] = data["temperature"];
|
|
}
|
|
if (data.contains("top_p")) {
|
|
body_json["top_p"] = data["top_p"];
|
|
}
|
|
if (data.contains("frequency_penalty")) {
|
|
body_json["frequency_penalty"] = data["frequency_penalty"];
|
|
}
|
|
if (data.contains("presence_penalty")) {
|
|
body_json["presence_penalty"] = data["presence_penalty"];
|
|
}
|
|
if (data.contains("seed")) {
|
|
body_json["seed"] = data["seed"];
|
|
}
|
|
if (data.contains("logit_bias")) {
|
|
body_json["logit_bias"] = data["logit_bias"];
|
|
}
|
|
if (data.contains("top_k")) {
|
|
body_json["top_k"] = data["top_k"];
|
|
}
|
|
if (data.contains("min_p")) {
|
|
body_json["min_p"] = data["min_p"];
|
|
}
|
|
|
|
// Forward the chat_template_kwargs the Go layer resolved (model config
|
|
// chat_template_kwargs + per-request metadata: enable_thinking,
|
|
// reasoning_effort, preserve_thinking, ...). One generic merge replaces
|
|
// the previous per-key handling - new template levers need no C++ change.
|
|
// oaicompat_chat_params_parse reads these from body_json.
|
|
const auto& metadata = request->metadata();
|
|
auto ctk_it = metadata.find("chat_template_kwargs");
|
|
if (ctk_it != metadata.end() && !ctk_it->second.empty()) {
|
|
try {
|
|
json ctk = json::parse(ctk_it->second);
|
|
if (ctk.is_object()) {
|
|
if (!body_json.contains("chat_template_kwargs")) {
|
|
body_json["chat_template_kwargs"] = json::object();
|
|
}
|
|
for (auto el : ctk.items()) {
|
|
body_json["chat_template_kwargs"][el.key()] = el.value();
|
|
}
|
|
}
|
|
} catch (const std::exception & e) {
|
|
SRV_WRN("failed to parse chat_template_kwargs metadata: %s\n", e.what());
|
|
}
|
|
}
|
|
|
|
// Debug: Print full body_json before template processing (includes messages, tools, tool_choice, etc.)
|
|
SRV_DBG("[CONVERSATION DEBUG] PredictStream: Full body_json before oaicompat_chat_params_parse:\n%s\n", body_json.dump(2).c_str());
|
|
|
|
// Use the same approach as server.cpp: call oaicompat_chat_params_parse
|
|
// This handles all template application, grammar merging, etc. automatically
|
|
// Files extracted from multimodal content in messages will be added to the files vector
|
|
// chat_params already contains tmpls, allow_image, and allow_audio set during model loading
|
|
|
|
// Debug: Log tools before template processing
|
|
if (body_json.contains("tools")) {
|
|
SRV_DBG("[TOOLS DEBUG] PredictStream: Before oaicompat_chat_params_parse - tools count: %zu\n",
|
|
body_json["tools"].is_array() ? body_json["tools"].size() : 0);
|
|
}
|
|
|
|
// Debug: Verify messages content before template processing
|
|
// Also ensure ALL messages have content set to string (not null) - templates expect strings
|
|
if (body_json.contains("messages") && body_json["messages"].is_array()) {
|
|
SRV_INF("[CONTENT DEBUG] PredictStream: Before oaicompat_chat_params_parse - checking %zu messages\n", body_json["messages"].size());
|
|
for (size_t idx = 0; idx < body_json["messages"].size(); idx++) {
|
|
llama_grpc::normalize_template_message(body_json["messages"][idx]);
|
|
}
|
|
}
|
|
|
|
#if LOCALAI_HAS_CHAT_SESSION
|
|
json parsed_data = oaicompat_chat_params_parse(
|
|
ctx_server.impl->vocab, body_json, ctx_server.impl->chat_params, files, chat_session);
|
|
#else
|
|
json parsed_data = oaicompat_chat_params_parse(body_json, ctx_server.impl->chat_params, files);
|
|
#endif
|
|
|
|
// Debug: Log tools after template processing
|
|
if (parsed_data.contains("tools")) {
|
|
SRV_DBG("[TOOLS DEBUG] PredictStream: After oaicompat_chat_params_parse - tools count: %zu\n",
|
|
parsed_data["tools"].is_array() ? parsed_data["tools"].size() : 0);
|
|
} else {
|
|
SRV_DBG("%s", "[TOOLS DEBUG] PredictStream: After oaicompat_chat_params_parse - no tools in parsed_data\n");
|
|
}
|
|
|
|
// Extract the prompt from parsed data
|
|
prompt_str = parsed_data.at("prompt").get<std::string>();
|
|
|
|
// Preserve grammar from Go layer if it was provided (NoGrammar=false)
|
|
// Otherwise, use grammar from parsed_data (template-generated when NoGrammar=true)
|
|
json preserved_grammar;
|
|
if (has_grammar_from_go && data.contains("grammar")) {
|
|
preserved_grammar = data["grammar"];
|
|
}
|
|
|
|
// Merge all fields from parsed_data into data (grammar, grammar_triggers, preserved_tokens, parse_tool_calls, etc.)
|
|
// This ensures all template-generated fields are included
|
|
// parse_tool_calls is set by oaicompat_chat_params_parse when tools are present
|
|
for (const auto& item : parsed_data.items()) {
|
|
if (item.key() != "prompt") { // Don't overwrite prompt_str, we already extracted it
|
|
// If grammar was provided from Go layer, preserve it instead of template-generated grammar
|
|
if (item.key() == "grammar" && has_grammar_from_go && !preserved_grammar.is_null()) {
|
|
data["grammar"] = preserved_grammar;
|
|
} else {
|
|
data[item.key()] = item.value();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Debug: Log parse_tool_calls if present (set by oaicompat_chat_params_parse when tools are present)
|
|
if (data.contains("parse_tool_calls")) {
|
|
SRV_DBG("[TOOLS DEBUG] PredictStream: parse_tool_calls=%s\n", data["parse_tool_calls"].get<bool>() ? "true" : "false");
|
|
}
|
|
} else {
|
|
// Use prompt directly from data
|
|
if (data.contains("prompt") && data["prompt"].is_string()) {
|
|
prompt_str = data["prompt"].get<std::string>();
|
|
} else {
|
|
prompt_str = request->prompt();
|
|
}
|
|
}
|
|
|
|
const auto type = SERVER_TASK_TYPE_COMPLETION;
|
|
// TODO: this log can become very long, put it behind a flag or think about a more compact format
|
|
//SRV_DBG("Prompt: %s\n", prompt.is_string() ? prompt.get<std::string>().c_str() : prompt.dump(2).c_str());
|
|
|
|
// If not using chat templates, extract files from image_data/audio_data fields
|
|
// (If using chat templates, files were already extracted by oaicompat_chat_params_parse)
|
|
if (!request->usetokenizertemplate() || request->messages_size() == 0 || ctx_server.impl->chat_params.tmpls == nullptr) {
|
|
if (data.contains("image_data") && data.at("image_data").is_array())
|
|
{
|
|
for (const auto &img : data.at("image_data"))
|
|
{
|
|
auto decoded_data = base64_decode(img["data"].get<std::string>());
|
|
files.push_back(decoded_data);
|
|
}
|
|
}
|
|
|
|
if (data.contains("audio_data") && data.at("audio_data").is_array())
|
|
{
|
|
for (const auto &audio : data.at("audio_data"))
|
|
{
|
|
auto decoded_data = base64_decode(audio["data"].get<std::string>());
|
|
files.push_back(decoded_data);
|
|
}
|
|
}
|
|
|
|
if (data.contains("video_data") && data.at("video_data").is_array())
|
|
{
|
|
for (const auto &video : data.at("video_data"))
|
|
{
|
|
auto decoded_data = base64_decode(video["data"].get<std::string>());
|
|
files.push_back(decoded_data);
|
|
}
|
|
}
|
|
}
|
|
|
|
const bool has_mtmd = ctx_server.impl->mctx != nullptr;
|
|
|
|
// process prompt
|
|
std::vector<server_tokens> inputs;
|
|
if (has_mtmd) {
|
|
// multimodal
|
|
inputs.push_back(process_mtmd_prompt(ctx_server.impl->mctx, prompt_str, files LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt)));
|
|
} else {
|
|
// Everything else, including multimodal completions.
|
|
inputs = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt_str, true, true LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
|
|
}
|
|
|
|
tasks.reserve(inputs.size());
|
|
for (size_t i = 0; i < inputs.size(); i++) {
|
|
server_task task = server_task(type);
|
|
|
|
task.id = rd.queue_tasks.get_new_id();
|
|
task.index = i;
|
|
|
|
task.tokens = std::move(inputs[i]);
|
|
#ifdef LOCALAI_HAS_SERVER_SCHEMA
|
|
// The schema evaluator no longer takes the per-slot n_ctx: upstream
|
|
// dropped the parameter and server-schema stopped consulting n_ctx at
|
|
// all, leaving the context bound to the slot. Forks that predate the
|
|
// server-schema split still expect it, so only this branch loses it.
|
|
task.params = server_schema::eval_llama_cmpl_schema(
|
|
ctx_server.impl->vocab,
|
|
params_base,
|
|
ctx_server.get_meta().logit_bias_eog,
|
|
data);
|
|
#else
|
|
task.params = server_task::params_from_json_cmpl(
|
|
ctx_server.impl->vocab,
|
|
params_base,
|
|
ctx_server.get_meta().slot_n_ctx,
|
|
ctx_server.get_meta().logit_bias_eog,
|
|
data);
|
|
#endif
|
|
#if LOCALAI_HAS_CHAT_SESSION
|
|
// The parser now keeps grammar, stops and message spans in the session.
|
|
task.apply_chat_session(chat_session);
|
|
#endif
|
|
task.id_slot = json_value(data, "id_slot", -1);
|
|
|
|
// OAI-compat: enable autoparser (PEG-based chat parsing) so that
|
|
// reasoning, tool calls, and content are classified into ChatDeltas.
|
|
// Without this, the PEG parser never produces diffs and the Go side
|
|
// cannot detect tool calls or separate reasoning from content.
|
|
task.params.res_type = TASK_RESPONSE_TYPE_OAI_CHAT;
|
|
task.params.oaicompat_cmpl_id = completion_id;
|
|
// oaicompat_model is already populated by eval_llama_cmpl_schema
|
|
|
|
tasks.push_back(std::move(task));
|
|
}
|
|
|
|
#if LOCALAI_HAS_CHAT_SESSION
|
|
rd.post_tasks(std::move(tasks), chat_session);
|
|
#else
|
|
rd.post_tasks(std::move(tasks));
|
|
#endif
|
|
} catch (const std::exception & e) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
|
|
}
|
|
|
|
// Get first result for error checking (following server.cpp pattern)
|
|
server_task_result_ptr first_result = rd.next([&context]() { return context->IsCancelled(); });
|
|
if (first_result == nullptr) {
|
|
// connection is closed
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
|
|
} else if (first_result->is_error()) {
|
|
// Return the error only as the status. Writing it as a Reply first
|
|
// made it the first content chunk: LocalAI streamed the error text
|
|
// as assistant output on an HTTP 200 instead of failing the request.
|
|
json error_json = first_result->to_json();
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, error_json.value("message", "Error occurred"));
|
|
}
|
|
|
|
// Lambda to build a Reply from JSON + attach chat deltas from a result.
|
|
// Handles both native format ({"content": "..."}) and OAI chat format
|
|
// ({"choices": [{"delta": {"content": "...", "reasoning": "..."}}]}).
|
|
auto build_reply_from_json = [](const json & res_json, server_task_result * raw_result) -> backend::Reply {
|
|
backend::Reply reply;
|
|
std::string completion_text;
|
|
|
|
if (res_json.contains("choices")) {
|
|
// OAI chat format — extract content from choices[0].delta
|
|
const auto & choices = res_json.at("choices");
|
|
if (!choices.empty()) {
|
|
const auto & delta = choices[0].value("delta", json::object());
|
|
if (delta.contains("content") && !delta.at("content").is_null()) {
|
|
completion_text = delta.at("content").get<std::string>();
|
|
}
|
|
}
|
|
} else {
|
|
// Native llama.cpp format
|
|
completion_text = res_json.value("content", "");
|
|
}
|
|
|
|
reply.set_message(completion_text);
|
|
|
|
// Token counts: native format has top-level fields,
|
|
// OAI format has them in "usage" (final chunk only)
|
|
if (res_json.contains("usage")) {
|
|
const auto & usage = res_json.at("usage");
|
|
reply.set_tokens(usage.value("completion_tokens", 0));
|
|
reply.set_prompt_tokens(usage.value("prompt_tokens", 0));
|
|
} else {
|
|
reply.set_tokens(res_json.value("tokens_predicted", 0));
|
|
reply.set_prompt_tokens(res_json.value("tokens_evaluated", 0));
|
|
}
|
|
|
|
// Timings: present as top-level "timings" in both formats
|
|
if (res_json.contains("timings")) {
|
|
reply.set_timing_prompt_processing(res_json.at("timings").value("prompt_ms", 0.0));
|
|
reply.set_timing_token_generation(res_json.at("timings").value("predicted_ms", 0.0));
|
|
}
|
|
|
|
// Logprobs: extract_logprobs_from_json handles both formats
|
|
json logprobs_json = extract_logprobs_from_json(res_json);
|
|
if (!logprobs_json.empty() && !logprobs_json.is_null()) {
|
|
reply.set_logprobs(logprobs_json.dump());
|
|
}
|
|
|
|
return reply;
|
|
};
|
|
|
|
// Attach chat deltas from the autoparser to a Reply.
|
|
// When diffs are available, populate ChatDeltas on the reply.
|
|
// The raw message is always preserved so the Go side can use it
|
|
// for reasoning extraction and tool call parsing as a fallback
|
|
// (important in distributed mode where ChatDeltas may not be
|
|
// the primary parsing path).
|
|
auto attach_chat_deltas = [](backend::Reply & reply, server_task_result * raw_result) {
|
|
// Try streaming partial result first
|
|
auto* partial = dynamic_cast<server_task_result_cmpl_partial*>(raw_result);
|
|
if (partial && !partial->oaicompat_msg_diffs.empty()) {
|
|
populate_chat_deltas_from_diffs(reply, partial->oaicompat_msg_diffs);
|
|
return;
|
|
}
|
|
// Try final result
|
|
auto* final_res = dynamic_cast<server_task_result_cmpl_final*>(raw_result);
|
|
if (final_res && final_res->is_updated) {
|
|
populate_chat_deltas_from_diffs(reply, final_res->oaicompat_msg_diffs);
|
|
}
|
|
};
|
|
|
|
// Process first result.
|
|
// When TASK_RESPONSE_TYPE_OAI_CHAT is used, the first token may
|
|
// produce a JSON array with a role-init element followed by the
|
|
// actual content element. We must only attach chat deltas to the
|
|
// content element — attaching to both would duplicate the first
|
|
// token since oaicompat_msg_diffs is the same for both.
|
|
json first_res_json = first_result->to_json();
|
|
// Upstream llama.cpp (ggml-org/llama.cpp#23884) now emits an initial
|
|
// "begin" partial whose to_json() returns null, used only to signal the
|
|
// HTTP layer to flush 200 status headers before any token. gRPC has no
|
|
// such concept, so there is nothing to emit — the real tokens arrive in
|
|
// the loop below. Feeding this null into build_reply_from_json would
|
|
// throw (uncaught) and surface as a generic RPC error.
|
|
// A write that returns false means the peer is gone for good. Track it
|
|
// so the loop below stops decoding instead of feeding a dead stream —
|
|
// see stream_peer.h for why that matters to everyone else's requests.
|
|
llama_grpc::StreamPeer peer;
|
|
|
|
if (first_res_json.is_null()) {
|
|
// skip the begin-of-stream marker
|
|
} else if (first_res_json.is_array()) {
|
|
for (const auto & res : first_res_json) {
|
|
auto reply = build_reply_from_json(res, first_result.get());
|
|
// Skip chat deltas for role-init elements (have "role" in
|
|
// delta but no content/reasoning diffs of their own).
|
|
bool is_role_init = res.contains("choices") && !res["choices"].empty() &&
|
|
res["choices"][0].value("delta", json::object()).contains("role");
|
|
if (!is_role_init) {
|
|
attach_chat_deltas(reply, first_result.get());
|
|
}
|
|
peer.observe_write(writer->Write(reply));
|
|
if (peer.gone()) {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
auto reply = build_reply_from_json(first_res_json, first_result.get());
|
|
attach_chat_deltas(reply, first_result.get());
|
|
peer.observe_write(writer->Write(reply));
|
|
}
|
|
|
|
// Process subsequent results
|
|
while (rd.has_next()) {
|
|
peer.observe_cancelled(context->IsCancelled());
|
|
if (peer.gone()) {
|
|
break;
|
|
}
|
|
|
|
auto result = rd.next([&context]() { return context->IsCancelled(); });
|
|
if (result == nullptr) {
|
|
break;
|
|
}
|
|
|
|
json res_json = result->to_json();
|
|
if (res_json.is_null()) {
|
|
// begin-of-stream marker (see note above) — nothing to emit
|
|
continue;
|
|
} else if (res_json.is_array()) {
|
|
for (const auto & res : res_json) {
|
|
auto reply = build_reply_from_json(res, result.get());
|
|
bool is_role_init = res.contains("choices") && !res["choices"].empty() &&
|
|
res["choices"][0].value("delta", json::object()).contains("role");
|
|
if (!is_role_init) {
|
|
attach_chat_deltas(reply, result.get());
|
|
}
|
|
peer.observe_write(writer->Write(reply));
|
|
if (peer.gone()) {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
auto reply = build_reply_from_json(res_json, result.get());
|
|
attach_chat_deltas(reply, result.get());
|
|
peer.observe_write(writer->Write(reply));
|
|
}
|
|
}
|
|
|
|
// Returning here is what releases the slot: ~server_response_reader()
|
|
// posts SERVER_TASK_TYPE_CANCEL for whatever is still decoding.
|
|
peer.observe_cancelled(context->IsCancelled());
|
|
if (peer.gone()) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
|
|
}
|
|
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
grpc::Status Predict(ServerContext* context, const backend::PredictOptions* request, backend::Reply* reply) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
auto identity = checkModelIdentity(request);
|
|
if (!identity.ok()) return identity;
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
json data = parse_options(true, request, params_base, ctx_server.get_llama_context());
|
|
|
|
data["stream"] = false;
|
|
//Raise error if embeddings is set to true
|
|
if (params_base.embedding) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "Embedding is not supported in Predict mode");
|
|
}
|
|
std::cout << "[PREDICT] Received result: " << data.dump(2) << std::endl;
|
|
auto completion_id = gen_chatcmplid();
|
|
auto rd = ctx_server.get_response_reader();
|
|
try {
|
|
std::vector<server_task> tasks;
|
|
|
|
std::string prompt_str;
|
|
std::vector<raw_buffer> files; // Declare files early so it's accessible in both branches
|
|
#if LOCALAI_HAS_CHAT_SESSION
|
|
common_chat_session chat_session;
|
|
#endif
|
|
// Handle chat templates when UseTokenizerTemplate is enabled and Messages are provided
|
|
if (request->usetokenizertemplate() && request->messages_size() > 0 && ctx_server.impl->chat_params.tmpls != nullptr) {
|
|
// Convert proto Messages to JSON format compatible with oaicompat_chat_params_parse
|
|
json body_json;
|
|
json messages_json = json::array();
|
|
|
|
// Find the last user message index to attach images/audio to
|
|
int last_user_msg_idx = -1;
|
|
for (int i = request->messages_size() - 1; i >= 0; i--) {
|
|
if (request->messages(i).role() == "user") {
|
|
last_user_msg_idx = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
SRV_INF("[CONTENT DEBUG] Predict: Processing %d messages\n", request->messages_size());
|
|
for (int i = 0; i < request->messages_size(); i++) {
|
|
const auto& msg = request->messages(i);
|
|
llama_grpc::ReconstructedMessageInput rin;
|
|
rin.role = msg.role();
|
|
rin.content = msg.content();
|
|
rin.name = msg.name();
|
|
rin.tool_call_id = msg.tool_call_id();
|
|
rin.reasoning_content = msg.reasoning_content();
|
|
rin.tool_calls = msg.tool_calls();
|
|
rin.is_last_user_msg = (i == last_user_msg_idx);
|
|
if (rin.is_last_user_msg) {
|
|
for (int j = 0; j < request->images_size(); j++) rin.images.push_back(request->images(j));
|
|
for (int j = 0; j < request->audios_size(); j++) rin.audios.push_back(request->audios(j));
|
|
for (int j = 0; j < request->videos_size(); j++) rin.videos.push_back(request->videos(j));
|
|
}
|
|
messages_json.push_back(json::parse(llama_grpc::build_reconstructed_message(rin).dump()));
|
|
}
|
|
|
|
// Final safety check: Ensure no message has null content (Jinja templates require strings)
|
|
SRV_INF("[CONTENT DEBUG] Predict: Running final safety check on %zu messages\n", messages_json.size());
|
|
for (size_t idx = 0; idx < messages_json.size(); idx++) {
|
|
auto& msg = messages_json[idx];
|
|
std::string role_str = msg.contains("role") ? msg["role"].get<std::string>() : "unknown";
|
|
if (msg.contains("content") && msg["content"].is_null()) {
|
|
SRV_INF("[CONTENT DEBUG] Predict: Safety check found message %zu (role=%s) with NULL content, converting to empty string\n", idx, role_str.c_str());
|
|
msg["content"] = "";
|
|
} else if (!msg.contains("content")) {
|
|
SRV_INF("[CONTENT DEBUG] Predict: Safety check found message %zu (role=%s) without content field, adding empty string\n", idx, role_str.c_str());
|
|
msg["content"] = "";
|
|
} else {
|
|
SRV_INF("[CONTENT DEBUG] Predict: Safety check message %zu (role=%s): content OK, type=%s\n",
|
|
idx, role_str.c_str(),
|
|
msg["content"].is_string() ? "string" :
|
|
msg["content"].is_array() ? "array" :
|
|
msg["content"].is_object() ? "object" : "other");
|
|
}
|
|
}
|
|
|
|
// Debug: Count tool messages
|
|
int tool_msg_count = 0;
|
|
for (const auto& msg : messages_json) {
|
|
if (msg.contains("role") && msg["role"] == "tool") {
|
|
tool_msg_count++;
|
|
}
|
|
}
|
|
SRV_DBG("[TOOLS DEBUG] Predict: Built %d tool messages out of %zu total messages\n", tool_msg_count, messages_json.size());
|
|
|
|
// Debug: Print full conversation (messages)
|
|
SRV_DBG("[CONVERSATION DEBUG] Predict: Full messages array:\n%s\n", messages_json.dump(2).c_str());
|
|
|
|
body_json["messages"] = messages_json;
|
|
body_json["stream"] = false;
|
|
|
|
// Check if grammar is provided from Go layer (NoGrammar=false)
|
|
// If grammar is provided, we must use it and NOT let template generate grammar from tools
|
|
// oaicompat_chat_params_parse throws an error if both grammar and tools are provided
|
|
bool has_grammar_from_go = data.contains("grammar") &&
|
|
data["grammar"].is_string() &&
|
|
!data["grammar"].get<std::string>().empty();
|
|
|
|
SRV_INF("[TOOLS DEBUG] Predict: has_grammar_from_go=%d, data.contains(\"tools\")=%d, data.contains(\"grammar\")=%d\n",
|
|
has_grammar_from_go ? 1 : 0,
|
|
data.contains("tools") ? 1 : 0,
|
|
data.contains("grammar") ? 1 : 0);
|
|
if (data.contains("grammar")) {
|
|
SRV_INF("[TOOLS DEBUG] Predict: grammar type=%s, empty=%d\n",
|
|
data["grammar"].is_string() ? "string" : "other",
|
|
data["grammar"].is_string() && data["grammar"].get<std::string>().empty() ? 1 : 0);
|
|
}
|
|
|
|
// Copy other relevant fields from data that oaicompat_chat_params_parse expects
|
|
// Tools and tool_choice are only passed when NoGrammar is true (grammar not provided)
|
|
// When grammar is provided from Go layer, we use it instead of template-generated grammar
|
|
if (!has_grammar_from_go) {
|
|
// NoGrammar=true: pass tools and let template generate grammar
|
|
if (data.contains("tools")) {
|
|
body_json["tools"] = data["tools"];
|
|
std::string tools_str = data["tools"].dump();
|
|
SRV_INF("Using tools from data (NoGrammar=true): %s\n", tools_str.c_str());
|
|
// Debug: Log tools count and details before template processing
|
|
if (data["tools"].is_array()) {
|
|
SRV_INF("[TOOLS DEBUG] Predict: Passing %zu tools to oaicompat_chat_params_parse\n", data["tools"].size());
|
|
for (size_t t_idx = 0; t_idx < data["tools"].size(); t_idx++) {
|
|
const auto& tool = data["tools"][t_idx];
|
|
std::string tool_name = "unknown";
|
|
std::string tool_desc = "";
|
|
if (tool.contains("function")) {
|
|
const auto& func = tool["function"];
|
|
if (func.contains("name")) {
|
|
tool_name = func["name"].get<std::string>();
|
|
}
|
|
if (func.contains("description")) {
|
|
tool_desc = func["description"].is_string() ?
|
|
func["description"].get<std::string>() : "";
|
|
}
|
|
} else if (tool.contains("name")) {
|
|
tool_name = tool["name"].get<std::string>();
|
|
if (tool.contains("description")) {
|
|
tool_desc = tool["description"].is_string() ?
|
|
tool["description"].get<std::string>() : "";
|
|
}
|
|
}
|
|
SRV_INF("[TOOLS DEBUG] Predict: Tool %zu: name=%s, description=%s\n",
|
|
t_idx, tool_name.c_str(), tool_desc.substr(0, 100).c_str());
|
|
}
|
|
}
|
|
} else {
|
|
SRV_WRN("%s", "No tools found in data - tool calls will not work without tools field\n");
|
|
SRV_DBG("[TOOLS DEBUG] Predict: No tools in data, tool_choice=%s\n", data.contains("tool_choice") ? data["tool_choice"].dump().c_str() : "not set");
|
|
}
|
|
if (data.contains("tool_choice")) {
|
|
// tool_choice can be a string or object, but oaicompat_chat_params_parse expects a string
|
|
// Convert object tool_choice to "required" (since a specific function is requested)
|
|
if (data["tool_choice"].is_string()) {
|
|
body_json["tool_choice"] = data["tool_choice"].get<std::string>();
|
|
} else if (data["tool_choice"].is_object()) {
|
|
// Object tool_choice means a specific function is requested, use "required"
|
|
body_json["tool_choice"] = "required";
|
|
std::string tool_choice_obj_str = data["tool_choice"].dump();
|
|
SRV_INF("Converted object tool_choice to 'required': %s\n", tool_choice_obj_str.c_str());
|
|
} else {
|
|
// Fallback: convert to string
|
|
body_json["tool_choice"] = data["tool_choice"].dump();
|
|
}
|
|
std::string tool_choice_str = body_json["tool_choice"].get<std::string>();
|
|
SRV_INF("Using tool_choice: %s\n", tool_choice_str.c_str());
|
|
} else {
|
|
// Default to "auto" if not specified
|
|
body_json["tool_choice"] = "auto";
|
|
}
|
|
} else {
|
|
// Grammar is provided from Go layer (NoGrammar=false) - use it, don't pass tools
|
|
SRV_INF("%s", "Grammar provided from Go layer - using it instead of template-generated grammar\n");
|
|
// Grammar will be copied from data after parsing (it's already in data)
|
|
}
|
|
|
|
if (data.contains("json_schema")) {
|
|
body_json["json_schema"] = data["json_schema"];
|
|
}
|
|
// If grammar is provided from Go layer, copy it to body_json so it's preserved
|
|
// (though oaicompat_chat_params_parse may not use it if tools are present)
|
|
if (has_grammar_from_go) {
|
|
body_json["grammar"] = data["grammar"];
|
|
}
|
|
if (data.contains("response_format")) {
|
|
body_json["response_format"] = data["response_format"];
|
|
}
|
|
if (data.contains("chat_template_kwargs")) {
|
|
body_json["chat_template_kwargs"] = data["chat_template_kwargs"];
|
|
}
|
|
// Pass parallel_tool_calls if present (used by oaicompat_chat_params_parse)
|
|
if (data.contains("parallel_tool_calls")) {
|
|
body_json["parallel_tool_calls"] = data["parallel_tool_calls"];
|
|
}
|
|
// Pass add_generation_prompt if present (used by oaicompat_chat_params_parse)
|
|
if (data.contains("add_generation_prompt")) {
|
|
body_json["add_generation_prompt"] = data["add_generation_prompt"];
|
|
}
|
|
|
|
// Pass sampling parameters to body_json so oaicompat_chat_params_parse respects them
|
|
// and doesn't overwrite them with defaults in the returned parsed_data
|
|
if (data.contains("n_predict")) {
|
|
body_json["max_tokens"] = data["n_predict"];
|
|
}
|
|
if (data.contains("ignore_eos")) {
|
|
body_json["ignore_eos"] = data["ignore_eos"];
|
|
}
|
|
if (data.contains("stop")) {
|
|
body_json["stop"] = data["stop"];
|
|
}
|
|
if (data.contains("temperature")) {
|
|
body_json["temperature"] = data["temperature"];
|
|
}
|
|
if (data.contains("top_p")) {
|
|
body_json["top_p"] = data["top_p"];
|
|
}
|
|
if (data.contains("frequency_penalty")) {
|
|
body_json["frequency_penalty"] = data["frequency_penalty"];
|
|
}
|
|
if (data.contains("presence_penalty")) {
|
|
body_json["presence_penalty"] = data["presence_penalty"];
|
|
}
|
|
if (data.contains("seed")) {
|
|
body_json["seed"] = data["seed"];
|
|
}
|
|
if (data.contains("logit_bias")) {
|
|
body_json["logit_bias"] = data["logit_bias"];
|
|
}
|
|
if (data.contains("top_k")) {
|
|
body_json["top_k"] = data["top_k"];
|
|
}
|
|
if (data.contains("min_p")) {
|
|
body_json["min_p"] = data["min_p"];
|
|
}
|
|
|
|
// Forward the chat_template_kwargs the Go layer resolved (model config
|
|
// chat_template_kwargs + per-request metadata: enable_thinking,
|
|
// reasoning_effort, preserve_thinking, ...). One generic merge replaces
|
|
// the previous per-key handling - new template levers need no C++ change.
|
|
const auto& predict_metadata = request->metadata();
|
|
auto predict_ctk_it = predict_metadata.find("chat_template_kwargs");
|
|
if (predict_ctk_it != predict_metadata.end() && !predict_ctk_it->second.empty()) {
|
|
try {
|
|
json ctk = json::parse(predict_ctk_it->second);
|
|
if (ctk.is_object()) {
|
|
if (!body_json.contains("chat_template_kwargs")) {
|
|
body_json["chat_template_kwargs"] = json::object();
|
|
}
|
|
for (auto el : ctk.items()) {
|
|
body_json["chat_template_kwargs"][el.key()] = el.value();
|
|
}
|
|
}
|
|
} catch (const std::exception & e) {
|
|
SRV_WRN("failed to parse chat_template_kwargs metadata: %s\n", e.what());
|
|
}
|
|
}
|
|
|
|
// Debug: Print full body_json before template processing (includes messages, tools, tool_choice, etc.)
|
|
SRV_DBG("[CONVERSATION DEBUG] Predict: Full body_json before oaicompat_chat_params_parse:\n%s\n", body_json.dump(2).c_str());
|
|
|
|
// Use the same approach as server.cpp: call oaicompat_chat_params_parse
|
|
// This handles all template application, grammar merging, etc. automatically
|
|
// Files extracted from multimodal content in messages will be added to the files vector
|
|
// chat_params already contains tmpls, allow_image, and allow_audio set during model loading
|
|
|
|
// Debug: Log tools before template processing
|
|
if (body_json.contains("tools")) {
|
|
SRV_DBG("[TOOLS DEBUG] Predict: Before oaicompat_chat_params_parse - tools count: %zu\n",
|
|
body_json["tools"].is_array() ? body_json["tools"].size() : 0);
|
|
}
|
|
|
|
// Debug: Verify messages content before template processing
|
|
// Also ensure ALL messages have content set to string (not null) - templates expect strings
|
|
if (body_json.contains("messages") && body_json["messages"].is_array()) {
|
|
SRV_INF("[CONTENT DEBUG] Predict: Before oaicompat_chat_params_parse - checking %zu messages\n", body_json["messages"].size());
|
|
for (size_t idx = 0; idx < body_json["messages"].size(); idx++) {
|
|
llama_grpc::normalize_template_message(body_json["messages"][idx]);
|
|
}
|
|
}
|
|
|
|
#if LOCALAI_HAS_CHAT_SESSION
|
|
json parsed_data = oaicompat_chat_params_parse(
|
|
ctx_server.impl->vocab, body_json, ctx_server.impl->chat_params, files, chat_session);
|
|
#else
|
|
json parsed_data = oaicompat_chat_params_parse(body_json, ctx_server.impl->chat_params, files);
|
|
#endif
|
|
|
|
// Debug: Log tools after template processing
|
|
if (parsed_data.contains("tools")) {
|
|
SRV_DBG("[TOOLS DEBUG] Predict: After oaicompat_chat_params_parse - tools count: %zu\n",
|
|
parsed_data["tools"].is_array() ? parsed_data["tools"].size() : 0);
|
|
} else {
|
|
SRV_DBG("%s", "[TOOLS DEBUG] Predict: After oaicompat_chat_params_parse - no tools in parsed_data\n");
|
|
}
|
|
|
|
// Extract the prompt from parsed data
|
|
prompt_str = parsed_data.at("prompt").get<std::string>();
|
|
|
|
// Preserve grammar from Go layer if it was provided (NoGrammar=false)
|
|
// Otherwise, use grammar from parsed_data (template-generated when NoGrammar=true)
|
|
json preserved_grammar;
|
|
if (has_grammar_from_go && data.contains("grammar")) {
|
|
preserved_grammar = data["grammar"];
|
|
}
|
|
|
|
// Merge all fields from parsed_data into data (grammar, grammar_triggers, preserved_tokens, parse_tool_calls, etc.)
|
|
// This ensures all template-generated fields are included
|
|
// parse_tool_calls is set by oaicompat_chat_params_parse when tools are present
|
|
for (const auto& item : parsed_data.items()) {
|
|
if (item.key() != "prompt") { // Don't overwrite prompt_str, we already extracted it
|
|
// If grammar was provided from Go layer, preserve it instead of template-generated grammar
|
|
if (item.key() == "grammar" && has_grammar_from_go && !preserved_grammar.is_null()) {
|
|
data["grammar"] = preserved_grammar;
|
|
} else {
|
|
data[item.key()] = item.value();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Debug: Log parse_tool_calls if present (set by oaicompat_chat_params_parse when tools are present)
|
|
if (data.contains("parse_tool_calls")) {
|
|
SRV_DBG("[TOOLS DEBUG] Predict: parse_tool_calls=%s\n", data["parse_tool_calls"].get<bool>() ? "true" : "false");
|
|
}
|
|
} else {
|
|
// Use prompt directly from data
|
|
if (data.contains("prompt") && data["prompt"].is_string()) {
|
|
prompt_str = data["prompt"].get<std::string>();
|
|
} else {
|
|
prompt_str = request->prompt();
|
|
}
|
|
}
|
|
|
|
const auto type = SERVER_TASK_TYPE_COMPLETION;
|
|
// TODO: this log can become very long, put it behind a flag or think about a more compact format
|
|
//SRV_DBG("Prompt: %s\n", prompt.is_string() ? prompt.get<std::string>().c_str() : prompt.dump(2).c_str());
|
|
|
|
// If not using chat templates, extract files from image_data/audio_data fields
|
|
// (If using chat templates, files were already extracted by oaicompat_chat_params_parse)
|
|
if (!request->usetokenizertemplate() || request->messages_size() == 0 || ctx_server.impl->chat_params.tmpls == nullptr) {
|
|
if (data.contains("image_data") && data.at("image_data").is_array())
|
|
{
|
|
std::cout << "[PREDICT] Processing " << data.at("image_data").size() << " images" << std::endl;
|
|
for (const auto &img : data.at("image_data"))
|
|
{
|
|
std::cout << "[PREDICT] Processing image" << std::endl;
|
|
auto decoded_data = base64_decode(img["data"].get<std::string>());
|
|
files.push_back(decoded_data);
|
|
}
|
|
}
|
|
|
|
if (data.contains("audio_data") && data.at("audio_data").is_array())
|
|
{
|
|
for (const auto &audio : data.at("audio_data"))
|
|
{
|
|
auto decoded_data = base64_decode(audio["data"].get<std::string>());
|
|
files.push_back(decoded_data);
|
|
}
|
|
}
|
|
|
|
if (data.contains("video_data") && data.at("video_data").is_array())
|
|
{
|
|
for (const auto &video : data.at("video_data"))
|
|
{
|
|
auto decoded_data = base64_decode(video["data"].get<std::string>());
|
|
files.push_back(decoded_data);
|
|
}
|
|
}
|
|
}
|
|
|
|
// process files
|
|
const bool has_mtmd = ctx_server.impl->mctx != nullptr;
|
|
|
|
// process prompt
|
|
std::vector<server_tokens> inputs;
|
|
if (has_mtmd) {
|
|
// multimodal
|
|
inputs.push_back(process_mtmd_prompt(ctx_server.impl->mctx, prompt_str, files LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt)));
|
|
} else {
|
|
// Everything else, including multimodal completions.
|
|
inputs = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt_str, true, true LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
|
|
}
|
|
|
|
tasks.reserve(inputs.size());
|
|
for (size_t i = 0; i < inputs.size(); i++) {
|
|
server_task task = server_task(type);
|
|
|
|
task.id = rd.queue_tasks.get_new_id();
|
|
task.index = i;
|
|
|
|
task.tokens = std::move(inputs[i]);
|
|
#ifdef LOCALAI_HAS_SERVER_SCHEMA
|
|
// The schema evaluator no longer takes the per-slot n_ctx: upstream
|
|
// dropped the parameter and server-schema stopped consulting n_ctx at
|
|
// all, leaving the context bound to the slot. Forks that predate the
|
|
// server-schema split still expect it, so only this branch loses it.
|
|
task.params = server_schema::eval_llama_cmpl_schema(
|
|
ctx_server.impl->vocab,
|
|
params_base,
|
|
ctx_server.get_meta().logit_bias_eog,
|
|
data);
|
|
#else
|
|
task.params = server_task::params_from_json_cmpl(
|
|
ctx_server.impl->vocab,
|
|
params_base,
|
|
ctx_server.get_meta().slot_n_ctx,
|
|
ctx_server.get_meta().logit_bias_eog,
|
|
data);
|
|
#endif
|
|
#if LOCALAI_HAS_CHAT_SESSION
|
|
// The parser now keeps grammar, stops and message spans in the session.
|
|
task.apply_chat_session(chat_session);
|
|
#endif
|
|
task.id_slot = json_value(data, "id_slot", -1);
|
|
|
|
// OAI-compat: enable autoparser (PEG-based chat parsing) so that
|
|
// reasoning, tool calls, and content are classified into ChatDeltas.
|
|
task.params.res_type = TASK_RESPONSE_TYPE_OAI_CHAT;
|
|
task.params.oaicompat_cmpl_id = completion_id;
|
|
// oaicompat_model is already populated by eval_llama_cmpl_schema
|
|
|
|
tasks.push_back(std::move(task));
|
|
}
|
|
|
|
#if LOCALAI_HAS_CHAT_SESSION
|
|
rd.post_tasks(std::move(tasks), chat_session);
|
|
#else
|
|
rd.post_tasks(std::move(tasks));
|
|
#endif
|
|
} catch (const std::exception & e) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
|
|
}
|
|
|
|
|
|
std::cout << "[DEBUG] Waiting for results..." << std::endl;
|
|
|
|
// Wait for all results
|
|
auto all_results = rd.wait_for_all([&context]() { return context->IsCancelled(); });
|
|
|
|
if (all_results.is_terminated) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
|
|
} else if (all_results.error) {
|
|
std::cout << "[DEBUG] Error in results: " << all_results.error->to_json().value("message", "") << std::endl;
|
|
reply->set_message(all_results.error->to_json().value("message", ""));
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, all_results.error->to_json().value("message", "Error occurred"));
|
|
} else {
|
|
std::cout << "[DEBUG] Received " << all_results.results.size() << " results" << std::endl;
|
|
if (all_results.results.size() == 1) {
|
|
// single result
|
|
auto* final_res = dynamic_cast<server_task_result_cmpl_final*>(all_results.results[0].get());
|
|
GGML_ASSERT(final_res != nullptr);
|
|
json result_json = all_results.results[0]->to_json();
|
|
|
|
// Handle both native format ({"content": "...", "tokens_predicted": N})
|
|
// and OAI chat format ({"choices": [{"message": {"content": "..."}}],
|
|
// "usage": {"completion_tokens": N, "prompt_tokens": N}}).
|
|
std::string completion_text;
|
|
int32_t tokens_predicted = 0;
|
|
int32_t tokens_evaluated = 0;
|
|
|
|
if (result_json.contains("choices")) {
|
|
// OAI chat format
|
|
const auto & choices = result_json.at("choices");
|
|
if (!choices.empty()) {
|
|
const auto & msg = choices[0].value("message", json::object());
|
|
if (msg.contains("content") && !msg.at("content").is_null()) {
|
|
completion_text = msg.at("content").get<std::string>();
|
|
}
|
|
}
|
|
if (result_json.contains("usage")) {
|
|
const auto & usage = result_json.at("usage");
|
|
tokens_predicted = usage.value("completion_tokens", 0);
|
|
tokens_evaluated = usage.value("prompt_tokens", 0);
|
|
}
|
|
} else {
|
|
// Native llama.cpp format
|
|
completion_text = result_json.value("content", "");
|
|
tokens_predicted = result_json.value("tokens_predicted", 0);
|
|
tokens_evaluated = result_json.value("tokens_evaluated", 0);
|
|
}
|
|
reply->set_message(completion_text);
|
|
reply->set_tokens(tokens_predicted);
|
|
reply->set_prompt_tokens(tokens_evaluated);
|
|
|
|
// Timings: present in both formats as a top-level "timings" object
|
|
if (result_json.contains("timings")) {
|
|
reply->set_timing_prompt_processing(result_json.at("timings").value("prompt_ms", 0.0));
|
|
reply->set_timing_token_generation(result_json.at("timings").value("predicted_ms", 0.0));
|
|
}
|
|
|
|
// Logprobs: extract_logprobs_from_json handles both formats
|
|
json logprobs_json = extract_logprobs_from_json(result_json);
|
|
if (!logprobs_json.empty() && !logprobs_json.is_null()) {
|
|
reply->set_logprobs(logprobs_json.dump());
|
|
}
|
|
|
|
// Populate chat deltas from the autoparser's final parsed message
|
|
if (final_res->is_updated) {
|
|
populate_chat_deltas_from_final(*reply, final_res->oaicompat_msg);
|
|
}
|
|
|
|
} else {
|
|
// multiple results (multitask)
|
|
json arr = json::array();
|
|
json logprobs_arr = json::array();
|
|
bool has_logprobs = false;
|
|
for (auto & res : all_results.results) {
|
|
GGML_ASSERT(dynamic_cast<server_task_result_cmpl_final*>(res.get()) != nullptr);
|
|
json res_json = res->to_json();
|
|
// Handle both native and OAI chat formats
|
|
std::string result_content;
|
|
if (res_json.contains("choices")) {
|
|
const auto & choices = res_json.at("choices");
|
|
if (!choices.empty()) {
|
|
const auto & msg = choices[0].value("message", json::object());
|
|
if (msg.contains("content") && !msg.at("content").is_null()) {
|
|
result_content = msg.at("content").get<std::string>();
|
|
}
|
|
}
|
|
} else {
|
|
result_content = res_json.value("content", "");
|
|
}
|
|
arr.push_back(result_content);
|
|
|
|
// Extract logprobs for each result
|
|
json logprobs_json = extract_logprobs_from_json(res_json);
|
|
if (!logprobs_json.empty() && !logprobs_json.is_null()) {
|
|
has_logprobs = true;
|
|
logprobs_arr.push_back(logprobs_json);
|
|
} else {
|
|
logprobs_arr.push_back(json::object());
|
|
}
|
|
}
|
|
reply->set_message(arr);
|
|
|
|
// Set logprobs if any result has them
|
|
if (has_logprobs) {
|
|
std::string logprobs_str = logprobs_arr.dump();
|
|
reply->set_logprobs(logprobs_str);
|
|
}
|
|
}
|
|
}
|
|
|
|
std::cout << "[DEBUG] Predict request completed successfully" << std::endl;
|
|
|
|
// Check if context was cancelled during processing
|
|
if (context->IsCancelled()) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
|
|
}
|
|
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
grpc::Status Embedding(ServerContext* context, const backend::PredictOptions* request, backend::EmbeddingResult* embeddingResult) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
auto identity = checkModelIdentity(request);
|
|
if (!identity.ok()) return identity;
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
json body = parse_options(false, request, params_base, ctx_server.get_llama_context());
|
|
|
|
body["stream"] = false;
|
|
|
|
/*
|
|
if (llama_pooling_type(ctx_server.ctx) == LLAMA_POOLING_TYPE_NONE) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "Pooling type 'none' is not OAI compatible. Please use a different pooling type");
|
|
}
|
|
*/
|
|
|
|
// for the shape of input/content, see tokenize_input_prompts()
|
|
json prompt = body.at("embeddings");
|
|
|
|
|
|
auto tokenized_prompts = tokenize_input_prompts(ctx_server.impl->vocab, ctx_server.impl->mctx, prompt, true, true LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
|
|
for (const auto & tokens : tokenized_prompts) {
|
|
// this check is necessary for models that do not add BOS token to the input
|
|
if (tokens.empty()) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "Input content cannot be empty");
|
|
}
|
|
}
|
|
|
|
// Honor the load-time embd_normalize set via options:embd_normalize.
|
|
// -1 none, 0 max-abs, 1 taxicab, 2 L2 (default), >2 p-norm.
|
|
int embd_normalize = params_base.embd_normalize;
|
|
// create and queue the task
|
|
auto rd = ctx_server.get_response_reader();
|
|
{
|
|
std::vector<server_task> tasks;
|
|
for (size_t i = 0; i < tokenized_prompts.size(); i++) {
|
|
server_task task = server_task(SERVER_TASK_TYPE_EMBEDDING);
|
|
|
|
task.id = rd.queue_tasks.get_new_id();
|
|
task.index = i;
|
|
task.tokens = std::move(tokenized_prompts[i]);
|
|
|
|
task.params.res_type = TASK_RESPONSE_TYPE_NONE;
|
|
task.params.embd_normalize = embd_normalize;
|
|
tasks.push_back(std::move(task));
|
|
}
|
|
|
|
rd.post_tasks(std::move(tasks));
|
|
}
|
|
|
|
// Wait for all results
|
|
auto all_results = rd.wait_for_all([&context]() { return context->IsCancelled(); });
|
|
|
|
if (all_results.is_terminated) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
|
|
} else if (all_results.error) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, all_results.error->to_json().value("message", "Error in receiving results"));
|
|
}
|
|
|
|
// Extract the embeddings typed, straight from the task results (no
|
|
// JSON round-trip), and report the payload shape alongside the same
|
|
// flat float array as before: dim is the embedding width, tokens the
|
|
// number of vectors packed into `embeddings` (1 per prompt when the
|
|
// server pooled, one per token with pooling:none; summed across
|
|
// prompts if the request carried several), prompt_tokens the prompt
|
|
// tokens evaluated, for usage accounting. Consumers seeing 0/0 know
|
|
// the backend predates shape reporting.
|
|
int32_t n_vectors = 0;
|
|
int32_t dim = 0;
|
|
int32_t prompt_tokens = 0;
|
|
for (auto & res : all_results.results) {
|
|
auto * embd_res = dynamic_cast<server_task_result_embd*>(res.get());
|
|
GGML_ASSERT(embd_res != nullptr);
|
|
prompt_tokens += embd_res->n_tokens;
|
|
for (const auto & vec : embd_res->embedding) {
|
|
for (const float value : vec) {
|
|
embeddingResult->add_embeddings(value);
|
|
}
|
|
if (!vec.empty()) {
|
|
n_vectors++;
|
|
dim = (int32_t) vec.size();
|
|
}
|
|
}
|
|
}
|
|
embeddingResult->set_tokens(n_vectors);
|
|
embeddingResult->set_dim(dim);
|
|
embeddingResult->set_prompt_tokens(prompt_tokens);
|
|
embeddingResult->set_layout(
|
|
llama_pooling_type(ctx_server.get_llama_context()) == LLAMA_POOLING_TYPE_NONE
|
|
? backend::EMBEDDING_LAYOUT_PER_TOKEN
|
|
: backend::EMBEDDING_LAYOUT_FINAL);
|
|
|
|
std::cout << "[DEBUG] Embedding vectors: " << n_vectors << " x " << dim << std::endl;
|
|
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
grpc::Status Rerank(ServerContext* context, const backend::RerankRequest* request, backend::RerankResult* rerankResult) override {
|
|
auto identity = checkModelIdentity(request);
|
|
if (!identity.ok()) return identity;
|
|
if (!params_base.embedding || params_base.pooling_type != LLAMA_POOLING_TYPE_RANK) {
|
|
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED, "This server does not support reranking. Start it with `--reranking` and without `--embedding`");
|
|
}
|
|
|
|
// Validate request
|
|
if (request->query().empty()) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "\"query\" must be provided");
|
|
}
|
|
|
|
if (request->documents_size() == 0) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "\"documents\" must be a non-empty string array");
|
|
}
|
|
|
|
|
|
// Create and queue the task
|
|
auto rd = ctx_server.get_response_reader();
|
|
{
|
|
std::vector<server_task> tasks;
|
|
std::vector<std::string> documents;
|
|
for (int i = 0; i < request->documents_size(); i++) {
|
|
documents.push_back(request->documents(i));
|
|
}
|
|
|
|
tasks.reserve(documents.size());
|
|
for (size_t i = 0; i < documents.size(); i++) {
|
|
auto tmp = format_prompt_rerank(ctx_server.impl->model_tgt, ctx_server.impl->vocab, ctx_server.impl->mctx, request->query(), documents[i] LOCALAI_RERANK_FILES_ARGS LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
|
|
server_task task = server_task(SERVER_TASK_TYPE_RERANK);
|
|
task.id = rd.queue_tasks.get_new_id();
|
|
task.index = i;
|
|
task.tokens = std::move(tmp);
|
|
tasks.push_back(std::move(task));
|
|
}
|
|
|
|
rd.post_tasks(std::move(tasks));
|
|
}
|
|
|
|
// Wait for all results
|
|
auto all_results = rd.wait_for_all([&context]() { return context->IsCancelled(); });
|
|
|
|
if (all_results.is_terminated) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
|
|
} else if (all_results.error) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, all_results.error->to_json().value("message", "Error in receiving results"));
|
|
}
|
|
|
|
// Collect responses
|
|
std::vector<json> responses;
|
|
for (auto & res : all_results.results) {
|
|
GGML_ASSERT(dynamic_cast<server_task_result_rerank*>(res.get()) != nullptr);
|
|
responses.push_back(res->to_json());
|
|
}
|
|
// Sort responses by score in descending order
|
|
std::sort(responses.begin(), responses.end(), [](const json& a, const json& b) {
|
|
return a.value("score", 0.0f) > b.value("score", 0.0f);
|
|
});
|
|
|
|
// Crop results by request.top_n if specified
|
|
int top_n = request->top_n();
|
|
if (top_n > 0 && top_n < static_cast<int>(responses.size())) {
|
|
responses.resize(top_n);
|
|
}
|
|
// Set usage information
|
|
backend::Usage* usage = rerankResult->mutable_usage();
|
|
int total_tokens = 0;
|
|
int prompt_tokens = 0;
|
|
|
|
// Create document results
|
|
for (const auto& response : responses) {
|
|
backend::DocumentResult* doc_result = rerankResult->add_results();
|
|
doc_result->set_index(response.value("index", 0));
|
|
doc_result->set_text(request->documents(response.value("index", 0)));
|
|
doc_result->set_relevance_score(response.value("score", 0.0f));
|
|
|
|
// Add tokens evaluated for this document
|
|
int tokens_evaluated = response.value("tokens_evaluated", 0);
|
|
total_tokens += tokens_evaluated;
|
|
prompt_tokens += tokens_evaluated;
|
|
}
|
|
|
|
// Set the total tokens in usage
|
|
usage->set_total_tokens(total_tokens);
|
|
usage->set_prompt_tokens(prompt_tokens);
|
|
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
#ifndef LOCALAI_LLAMA_CPP_NO_TTS_TASK
|
|
// Builds the shared TTS task from a request. Returns a non-OK status and
|
|
// leaves `task` untouched when the request is malformed or the loaded model
|
|
// cannot synthesise audio.
|
|
grpc::Status prepareTTSTask(const backend::TTSRequest* request, bool stream, server_task & task) {
|
|
if (!ctx_server.get_meta().has_cap_tts) {
|
|
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
|
|
"the loaded model does not support audio generation (no gen-audio mmproj)");
|
|
}
|
|
|
|
// Qwen3-TTS Base takes a speaker reference file; LFM3-Audio takes a
|
|
// named voice through its "tts" chat template. Forks without the voice
|
|
// probe predate named voices, so they keep the reference rule.
|
|
#if LOCALAI_HAS_TTS_VOICE
|
|
const bool needs_reference = ctx_server.impl->mtmd_cap.audio_gen_spk_ref;
|
|
#else
|
|
const bool needs_reference = true;
|
|
#endif
|
|
|
|
std::map<std::string, std::string> params(request->params().begin(), request->params().end());
|
|
const auto opts = llama_grpc::parse_tts_request_options(
|
|
request->text(),
|
|
request->voice(),
|
|
request->has_language() ? request->language() : std::string(),
|
|
params,
|
|
needs_reference);
|
|
if (!opts.ok) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, opts.error);
|
|
}
|
|
|
|
task.tts_inp.set_prompt(opts.text);
|
|
// core/backend/tts.go always sets TTSRequest.language, so has_language()
|
|
// is true even when the caller named no language and the string is empty.
|
|
// gen_audio::inp::get() already maps a stored blank to nullptr, so this
|
|
// guard is behavior-preserving rather than behavior-fixing. It is kept
|
|
// so the "unset" intent is visible at the call site instead of resting
|
|
// on a detail of the helper.
|
|
if (!opts.language.empty()) {
|
|
task.tts_inp.set_lang(opts.language);
|
|
}
|
|
|
|
if (needs_reference) {
|
|
auto wrapper = mtmd_helper_bitmap_init_from_file(ctx_server.impl->mctx, opts.voice_path.c_str(), false LOCALAI_MTMD_INIT_OPT_ARG(ctx_server.impl->init_opt));
|
|
if (!wrapper.bitmap) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
|
|
"failed to read speaker reference audio: " + opts.voice_path);
|
|
}
|
|
task.tts_inp.set_speaker_ref(mtmd::bitmap_ptr(wrapper.bitmap));
|
|
}
|
|
#if LOCALAI_HAS_TTS_VOICE
|
|
else {
|
|
// Same as upstream POST /tts: the model's "tts" template renders the
|
|
// prompt with the language and voice, and an empty voice takes the
|
|
// template default. Fall back to the raw text when there is no template.
|
|
const std::string tts_prompt = common_chat_get_tts_prompt(
|
|
ctx_server.impl->chat_params.tmpls.get(), opts.text, opts.language, opts.voice);
|
|
if (!tts_prompt.empty()) {
|
|
task.tts_inp.set_prompt(tts_prompt);
|
|
}
|
|
task.tts_inp.set_voice(opts.voice);
|
|
}
|
|
#endif
|
|
task.tts_inp.data.top_k = opts.top_k;
|
|
task.tts_inp.data.top_p = opts.top_p;
|
|
task.tts_inp.data.stream = stream;
|
|
task.tts_inp.data.out_type = stream
|
|
? MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM // Go prepends its own WAV header, see core/backend/tts.go
|
|
: MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
|
|
|
|
task.params.stream = stream;
|
|
// -1 keeps upstream's 512-frame default. The model does not always emit
|
|
// its codec EOS, so a short input can otherwise generate the full cap.
|
|
task.params.n_predict = opts.max_frames > 0 ? opts.max_frames : -1;
|
|
task.params.sampling = params_base.sampling;
|
|
// Both values mirror upstream's draft POST /tts handler. Note that the
|
|
// pair is INERT at this pin: llama_sampler_init_penalties() clamps
|
|
// penalty_last_n with std::max(penalty_last_n, 0), so -1 means "off",
|
|
// not "the whole generation", and the penalty sampler is then built
|
|
// disabled. No repetition penalty is actually applied.
|
|
//
|
|
// That is deliberate. Dropping the second line lets the sampling
|
|
// default of 64 apply and genuinely engages the 1.05 penalty, which was
|
|
// measured here against the model's habit of never emitting its codec
|
|
// EOS and running to the frame cap: 0 of 15 short requests ran away
|
|
// with the penalty inert, 1 of 15 with it active over the last 64
|
|
// tokens. It does not fix the runaway, so the line stays for parity
|
|
// with the draft. Use max_frames to bound the output instead.
|
|
task.params.sampling.penalty_repeat = 1.05f;
|
|
task.params.sampling.penalty_last_n = -1;
|
|
if (opts.top_k > 0) {
|
|
task.params.sampling.top_k = opts.top_k;
|
|
}
|
|
if (opts.top_p > 0) {
|
|
task.params.sampling.top_p = opts.top_p;
|
|
}
|
|
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
grpc::Status TTS(ServerContext* context, const backend::TTSRequest* request, backend::Result* result) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
auto identity = checkModelIdentity(request);
|
|
if (!identity.ok()) return identity;
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
if (request->dst().empty()) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "dst must name an output file path");
|
|
}
|
|
|
|
server_task task(SERVER_TASK_TYPE_TTS);
|
|
auto prepared = prepareTTSTask(request, /* stream= */ false, task);
|
|
if (!prepared.ok()) return prepared;
|
|
|
|
auto rd = ctx_server.get_response_reader();
|
|
task.id = rd.get_new_id();
|
|
rd.post_task(std::move(task));
|
|
|
|
auto should_stop = [context]() { return context->IsCancelled(); };
|
|
|
|
std::string audio;
|
|
while (true) {
|
|
auto res = rd.next(should_stop);
|
|
if (!res) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "TTS request cancelled");
|
|
}
|
|
if (res->is_error()) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, res->to_json().dump());
|
|
}
|
|
auto * tts_res = dynamic_cast<server_task_result_tts *>(res.get());
|
|
if (tts_res == nullptr) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for a TTS task");
|
|
}
|
|
audio.append(tts_res->audio);
|
|
if (tts_res->final) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
std::ofstream out(request->dst(), std::ios::binary | std::ios::trunc);
|
|
if (!out) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to open output file: " + request->dst());
|
|
}
|
|
out.write(audio.data(), (std::streamsize) audio.size());
|
|
if (!out) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to write output file: " + request->dst());
|
|
}
|
|
// Buffered data is flushed here, so a full disk or a failing device can
|
|
// surface for the first time on close. Reporting success then would
|
|
// leave a truncated file behind under the name the caller will read.
|
|
out.close();
|
|
if (!out) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "failed to close output file: " + request->dst());
|
|
}
|
|
|
|
result->set_success(true);
|
|
result->set_message("TTS audio generated");
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
grpc::Status TTSStream(ServerContext* context, const backend::TTSRequest* request, grpc::ServerWriter<backend::Reply>* writer) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
auto identity = checkModelIdentity(request);
|
|
if (!identity.ok()) return identity;
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
|
|
server_task task(SERVER_TASK_TYPE_TTS);
|
|
auto prepared = prepareTTSTask(request, /* stream= */ true, task);
|
|
if (!prepared.ok()) return prepared;
|
|
|
|
auto rd = ctx_server.get_response_reader();
|
|
task.id = rd.get_new_id();
|
|
rd.post_task(std::move(task));
|
|
|
|
auto should_stop = [context]() { return context->IsCancelled(); };
|
|
|
|
// core/backend/tts.go:ModelTTSStream builds the WAV header itself from
|
|
// the sample rate in the first reply's Message, then concatenates every
|
|
// Reply.Audio verbatim. So the rate goes out once, up front, and the
|
|
// chunks stay raw PCM.
|
|
//
|
|
// Send it before draining rather than off the first audio result: a
|
|
// chunk needs a whole 72-frame window, about 5.8 s of audio and far
|
|
// longer in wall time on CPU, and the Go side cannot emit the WAV
|
|
// header until this reply lands. Waiting would hold the client at zero
|
|
// bytes for that entire stretch. The rate is a property of the loaded
|
|
// model, available synchronously, so there is nothing to wait for.
|
|
{
|
|
backend::Reply header;
|
|
const json info = { {"sample_rate", mtmd_gen_audio_get_info(ctx_server.impl->mctx).sample_rate} };
|
|
header.set_message(info.dump());
|
|
if (!writer->Write(header)) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "client closed the TTS stream");
|
|
}
|
|
}
|
|
|
|
while (true) {
|
|
auto res = rd.next(should_stop);
|
|
if (!res) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "TTS request cancelled");
|
|
}
|
|
if (res->is_error()) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, res->to_json().dump());
|
|
}
|
|
auto * tts_res = dynamic_cast<server_task_result_tts *>(res.get());
|
|
if (tts_res == nullptr) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for a TTS task");
|
|
}
|
|
|
|
if (!tts_res->audio.empty()) {
|
|
backend::Reply chunk;
|
|
chunk.set_audio(tts_pcm_f32_to_s16(tts_res->audio));
|
|
if (!writer->Write(chunk)) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "client closed the TTS stream");
|
|
}
|
|
}
|
|
|
|
if (tts_res->final) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return grpc::Status::OK;
|
|
}
|
|
#else
|
|
grpc::Status TTS(ServerContext* context, const backend::TTSRequest* request, backend::Result* result) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
(void) request;
|
|
(void) result;
|
|
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
|
|
"TTS is unavailable in this llama.cpp fork backend");
|
|
}
|
|
|
|
grpc::Status TTSStream(ServerContext* context, const backend::TTSRequest* request, grpc::ServerWriter<backend::Reply>* writer) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
(void) request;
|
|
(void) writer;
|
|
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
|
|
"TTSStream is unavailable in this llama.cpp fork backend");
|
|
}
|
|
#endif
|
|
|
|
#ifdef LOCALAI_HAS_AUDIO_TO_AUDIO
|
|
// Speech-to-speech on llama.cpp's realtime slot. The wire contract (events,
|
|
// controls, tool calls) is described in RealtimeStreamSession and in the
|
|
// comments of AudioToAudioRequest/Response in backend.proto. Forks without
|
|
// the realtime slot keep the base class answer, UNIMPLEMENTED.
|
|
grpc::Status AudioToAudioStream(ServerContext* context,
|
|
grpc::ServerReaderWriter<backend::AudioToAudioResponse, backend::AudioToAudioRequest>* stream) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
RealtimeStreamSession session(ctx_server, params_base, context, stream);
|
|
return session.run();
|
|
}
|
|
#endif
|
|
|
|
// Score returns the model's joint log-probability of each candidate
|
|
// continuation given a shared prompt.
|
|
//
|
|
// Scoring runs as a single SERVER_TASK_TYPE_SCORE task through the
|
|
// slot loop (added by patches/ on top of upstream server-context), so
|
|
// it is safe to interleave with generation on the same process and it
|
|
// reuses any KV prefix the slot already holds across turns. The task
|
|
// decodes the shared prefix (prompt + longest common candidate token
|
|
// prefix) once on the slot's sequence; every candidate's unique tail
|
|
// then rides its own forked sequence and all tails are decoded
|
|
// together in one batch, so a warm scoring call costs roughly one
|
|
// forward pass over the new prompt tokens plus one batched pass over
|
|
// the candidate tails.
|
|
#ifdef LOCALAI_HAS_NATIVE_DECISIONS
|
|
grpc::Status SystemOne(ServerContext* context, const backend::ScoreRequest* request,
|
|
backend::ScoreResponse* response) {
|
|
const auto & decision = ctx_server.impl->decision;
|
|
if (decision.type == COMMON_DECISION_TYPE_NONE) {
|
|
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED, "This model is not a decision model");
|
|
}
|
|
try {
|
|
if (request->prompt().size() > localai_decision::body_bytes) {
|
|
return grpc::Status(grpc::StatusCode::RESOURCE_EXHAUSTED, "Decision request exceeds limit");
|
|
}
|
|
auto checked_body = localai_decision::json::parse(request->prompt());
|
|
const auto image_count = localai_decision::validate(checked_body, request->prompt().size());
|
|
const json body = json::parse(checked_body.dump());
|
|
if (image_count && !localai_decision::supports_images(decision.can_use_images(),
|
|
ctx_server.impl->mctx && mtmd_support_vision(ctx_server.impl->mctx))) {
|
|
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
|
|
"This decision model requires an image-capable projector for image input");
|
|
}
|
|
const auto questions = decision.parse_questions(body);
|
|
std::vector<raw_buffer> files;
|
|
const json state = decision.parse_state(body, files);
|
|
if (context->IsCancelled()) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
|
|
}
|
|
auto rd = ctx_server.get_response_reader(); // destructor cancels outstanding tasks
|
|
std::vector<server_task> tasks;
|
|
size_t expected_results = 0;
|
|
for (const auto & question : questions) {
|
|
for (size_t variant = 0; variant < decision.n_variants(question); ++variant) {
|
|
server_task task(SERVER_TASK_TYPE_DECISION);
|
|
task.id = rd.get_new_id();
|
|
localai_fill_decision_task(decision, state, questions, question, variant, files,
|
|
ctx_server.impl->mctx, ctx_server.impl->init_opt, task);
|
|
tasks.push_back(std::move(task));
|
|
++expected_results;
|
|
}
|
|
}
|
|
if (decision.can_share_prompt()) {
|
|
tasks = server_decision_group_tasks(std::move(tasks), params_base.n_parallel);
|
|
}
|
|
rd.post_tasks(std::move(tasks));
|
|
auto results = rd.wait_for_all([&context]() { return context->IsCancelled(); });
|
|
if (results.is_terminated) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
|
|
}
|
|
if (results.error) {
|
|
auto code = grpc::StatusCode::INTERNAL;
|
|
const auto * error = dynamic_cast<server_task_result_error *>(results.error.get());
|
|
if (!error) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "Unexpected decision error type");
|
|
}
|
|
switch (error->err_type) {
|
|
case ERROR_TYPE_INVALID_REQUEST:
|
|
case ERROR_TYPE_EXCEED_CONTEXT_SIZE: code = grpc::StatusCode::INVALID_ARGUMENT; break;
|
|
case ERROR_TYPE_NOT_SUPPORTED: code = grpc::StatusCode::UNIMPLEMENTED; break;
|
|
default: break;
|
|
}
|
|
return grpc::Status(code, error->err_msg);
|
|
}
|
|
if (results.results.size() != expected_results) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "Unexpected decision result count");
|
|
}
|
|
json answers = json::object();
|
|
int64_t n_tokens = 0;
|
|
size_t index = 0;
|
|
for (const auto & question : questions) {
|
|
std::vector<std::vector<float>> scores;
|
|
for (size_t variant = 0; variant < decision.n_variants(question); ++variant) {
|
|
auto * result = dynamic_cast<server_task_result_decision *>(results.results[index++].get());
|
|
if (!result) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "Unexpected decision result type");
|
|
}
|
|
scores.push_back(result->scores);
|
|
n_tokens += result->n_tokens;
|
|
}
|
|
answers[question.id] = decision.format_answer(question, scores);
|
|
}
|
|
const auto output = json{
|
|
{"model", body.value("model", std::string())}, {"answers", answers},
|
|
{"usage", {{"input_tokens", n_tokens}, {"output_tokens", 0}}}
|
|
}.dump();
|
|
if (output.size() > localai_decision::text_bytes) {
|
|
return grpc::Status(grpc::StatusCode::RESOURCE_EXHAUSTED, "Decision response exceeds limit");
|
|
}
|
|
response->set_response_json(output);
|
|
return grpc::Status::OK;
|
|
} catch (const localai_decision::image_error & err) {
|
|
return grpc::Status(err.too_large ? grpc::StatusCode::RESOURCE_EXHAUSTED : grpc::StatusCode::INVALID_ARGUMENT, err.what());
|
|
} catch (const localai_decision::json::exception & err) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, err.what());
|
|
} catch (const common_json_error & err) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, err.what());
|
|
} catch (const std::invalid_argument & err) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, err.what());
|
|
} catch (const std::exception & err) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, err.what());
|
|
}
|
|
}
|
|
#endif
|
|
|
|
grpc::Status Score(ServerContext* context, const backend::ScoreRequest* request, backend::ScoreResponse* response) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
auto identity = checkModelIdentity(request);
|
|
if (!identity.ok()) return identity;
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
if (request->question_type() == "systemone") {
|
|
#ifdef LOCALAI_HAS_NATIVE_DECISIONS
|
|
return SystemOne(context, request, response);
|
|
#else
|
|
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
|
|
"Native decisions are unavailable in this llama.cpp fork backend");
|
|
#endif
|
|
}
|
|
#ifdef LOCALAI_LLAMA_CPP_NO_SCORE_TASK
|
|
(void) request;
|
|
(void) response;
|
|
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED,
|
|
"Score is unavailable in this llama.cpp fork backend");
|
|
#else
|
|
if (!params_base.score_enabled) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION,
|
|
"Score was not enabled when the model was loaded; add score to known_usecases");
|
|
}
|
|
if (request->candidates_size() == 0) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "candidates must be non-empty");
|
|
}
|
|
|
|
const llama_vocab * vocab = ctx_server.impl->vocab;
|
|
|
|
// Tokenize the shared prompt once with add_special=true so
|
|
// BOS is prepended when the model requires it. parse_special
|
|
// keeps chat-template markers in the prompt intact.
|
|
const std::string prompt = request->prompt();
|
|
std::vector<llama_token> prompt_tokens = common_tokenize(vocab, prompt, /*add_special=*/true, /*parse_special=*/true);
|
|
const int32_t prompt_len = (int32_t) prompt_tokens.size();
|
|
|
|
// Per candidate: full prompt+candidate token list and the
|
|
// divergence point, kept for piece rendering and empty-candidate
|
|
// handling after the task comes back.
|
|
std::vector<std::vector<llama_token>> cand_tokens(request->candidates_size());
|
|
std::vector<int32_t> cand_divergence(request->candidates_size(), 0);
|
|
|
|
// candidates that actually have tokens to score
|
|
std::vector<int32_t> included;
|
|
|
|
for (int ci = 0; ci < request->candidates_size(); ci++) {
|
|
const std::string & candidate_text = request->candidates(ci);
|
|
|
|
// Re-tokenize prompt + candidate as a single string. BPE
|
|
// merges across the boundary can shift the tokenization
|
|
// versus tokenize(prompt) ++ tokenize(candidate), so we
|
|
// find the divergence point against prompt_tokens.
|
|
std::vector<llama_token> full_tokens = common_tokenize(vocab, prompt + candidate_text, /*add_special=*/true, /*parse_special=*/true);
|
|
int32_t divergence = prompt_len;
|
|
const int32_t min_len = std::min<int32_t>(prompt_len, (int32_t) full_tokens.size());
|
|
for (int32_t i = 0; i < min_len; i++) {
|
|
if (prompt_tokens[i] != full_tokens[i]) {
|
|
divergence = i;
|
|
break;
|
|
}
|
|
}
|
|
divergence = std::min<int32_t>(divergence, (int32_t) full_tokens.size());
|
|
|
|
const int32_t cand_len = (int32_t) full_tokens.size() - divergence;
|
|
if (cand_len > 0 && divergence < 1) {
|
|
// Need at least one prior token (typically BOS) to
|
|
// predict the first candidate token's logit. Tokeniser
|
|
// models without BOS + an empty prompt fall in here.
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
|
|
"Score: prompt produced no leading tokens; need at least one (e.g. BOS) to predict candidate");
|
|
}
|
|
if (cand_len > SERVER_SCORE_MAX_CAND_TOKENS) {
|
|
// The context reserves logits outputs for at most this many
|
|
// candidate tokens per slot (server_n_outputs_max).
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
|
|
"Score: candidate " + std::to_string(ci) + " is " + std::to_string(cand_len) +
|
|
" tokens; the maximum is " + std::to_string(SERVER_SCORE_MAX_CAND_TOKENS));
|
|
}
|
|
|
|
cand_divergence[ci] = divergence;
|
|
cand_tokens[ci] = std::move(full_tokens);
|
|
|
|
if (cand_len > 0) {
|
|
included.push_back(ci);
|
|
}
|
|
}
|
|
|
|
auto rd = ctx_server.get_response_reader();
|
|
bool posted_task = false;
|
|
|
|
// Shared prefix bounds, needed again when stitching the results:
|
|
// n_shared is the longest common token prefix of the scored
|
|
// candidates, n_score_prompt the earliest divergence from the
|
|
// bare prompt (scored logprobs start there).
|
|
int32_t n_shared = 0;
|
|
int32_t n_score_prompt = 0;
|
|
|
|
if (!included.empty()) {
|
|
const auto & first = cand_tokens[included[0]];
|
|
|
|
// the common prefix of a set is the shortest common prefix
|
|
// against any fixed member
|
|
n_shared = (int32_t) first.size();
|
|
for (int32_t ci : included) {
|
|
const auto & ft = cand_tokens[ci];
|
|
const int32_t lim = std::min<int32_t>(n_shared, (int32_t) ft.size());
|
|
int32_t match = 0;
|
|
while (match < lim && ft[match] == first[match]) {
|
|
match++;
|
|
}
|
|
n_shared = match;
|
|
}
|
|
|
|
// below its divergence every candidate equals the prompt
|
|
// tokens, so n_score_prompt <= n_shared always holds
|
|
n_score_prompt = cand_divergence[included[0]];
|
|
for (int32_t ci : included) {
|
|
n_score_prompt = std::min(n_score_prompt, cand_divergence[ci]);
|
|
}
|
|
|
|
// Map the caller's stable-prefix byte length onto a token
|
|
// index: the last prompt token that ends at or before the
|
|
// boundary. A checkpoint forced there survives every future
|
|
// probe under the same option list, which is what keeps
|
|
// repeat scoring cheap on models that cannot rewind state.
|
|
int32_t n_stable_prompt = 0;
|
|
if (request->stable_prefix_len() > 0) {
|
|
size_t consumed = 0;
|
|
for (int32_t ti = 0; ti < n_score_prompt; ti++) {
|
|
const size_t piece_len = common_token_to_piece(vocab, prompt_tokens[ti]).size();
|
|
// BOS and other zero-length specials consume no prompt bytes
|
|
if (consumed + piece_len > (size_t) request->stable_prefix_len()) {
|
|
break;
|
|
}
|
|
consumed += piece_len;
|
|
n_stable_prompt = ti + 1;
|
|
}
|
|
}
|
|
|
|
server_task task(SERVER_TASK_TYPE_SCORE);
|
|
task.id = rd.queue_tasks.get_new_id();
|
|
task.index = 0;
|
|
task.tokens = server_tokens(llama_tokens(first.begin(), first.begin() + n_shared), false);
|
|
task.n_score_prompt = n_score_prompt;
|
|
task.n_stable_prompt = n_stable_prompt;
|
|
task.score_suffixes.reserve(included.size());
|
|
for (int32_t ci : included) {
|
|
task.score_suffixes.emplace_back(cand_tokens[ci].begin() + n_shared, cand_tokens[ci].end());
|
|
}
|
|
|
|
std::vector<server_task> tasks;
|
|
tasks.push_back(std::move(task));
|
|
rd.post_tasks(std::move(tasks));
|
|
posted_task = true;
|
|
}
|
|
|
|
// Wait for the shared-prefix and per-candidate logprob vectors.
|
|
// Context overflow and decode failures surface here as task errors.
|
|
std::vector<float> shared_logprobs;
|
|
std::vector<std::vector<float>> cand_logprobs;
|
|
if (posted_task) {
|
|
auto all_results = rd.wait_for_all([&context]() { return context->IsCancelled(); });
|
|
if (all_results.is_terminated) {
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "Request cancelled by client");
|
|
}
|
|
if (all_results.error) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL,
|
|
all_results.error->to_json().value("message", "Error in receiving score results"));
|
|
}
|
|
if (all_results.results.size() != 1) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "expected a single score result");
|
|
}
|
|
auto * score_res = dynamic_cast<server_task_result_score*>(all_results.results[0].get());
|
|
if (score_res == nullptr) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for score task");
|
|
}
|
|
shared_logprobs = std::move(score_res->shared_logprobs);
|
|
cand_logprobs = std::move(score_res->cand_logprobs);
|
|
if (cand_logprobs.size() != included.size()) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "score result candidate count mismatch");
|
|
}
|
|
}
|
|
|
|
size_t inc = 0; // index into included / cand_logprobs
|
|
for (int ci = 0; ci < request->candidates_size(); ci++) {
|
|
const int32_t divergence = cand_divergence[ci];
|
|
const int32_t cand_len = (int32_t) cand_tokens[ci].size() - divergence;
|
|
|
|
backend::CandidateScore * cs = response->add_candidates();
|
|
cs->set_num_tokens(cand_len > 0 ? cand_len : 0);
|
|
if (cand_len <= 0) {
|
|
cs->set_log_prob(0.0);
|
|
if (request->length_normalize()) {
|
|
cs->set_length_normalized_log_prob(0.0);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Stitch the candidate's scored logprobs back together: the
|
|
// stretch inside the shared prefix (identical for every
|
|
// candidate) followed by its forked suffix. Suffix entries
|
|
// before the candidate's own divergence are prompt tokens
|
|
// decoded only as context — not scored.
|
|
std::vector<float> lp;
|
|
lp.reserve(cand_len);
|
|
for (int32_t t = divergence; t < n_shared; t++) {
|
|
const int32_t idx = t - n_score_prompt;
|
|
if (idx < 0 || idx >= (int32_t) shared_logprobs.size()) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL,
|
|
"Score: shared logprob index out of range for candidate " + std::to_string(ci));
|
|
}
|
|
lp.push_back(shared_logprobs[idx]);
|
|
}
|
|
const auto & sfx_lp = cand_logprobs[inc++];
|
|
for (int32_t j = std::max(0, divergence - n_shared); j < (int32_t) sfx_lp.size(); j++) {
|
|
lp.push_back(sfx_lp[j]);
|
|
}
|
|
|
|
if ((int32_t) lp.size() != cand_len) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL,
|
|
"Score: result for candidate " + std::to_string(ci) + " is missing token logprobs");
|
|
}
|
|
|
|
double total_log_prob = 0.0;
|
|
for (int32_t k = 0; k < cand_len; k++) {
|
|
const float token_log_prob = lp[k];
|
|
if (std::isnan(token_log_prob)) {
|
|
return grpc::Status(grpc::StatusCode::INTERNAL,
|
|
"Score: incomplete result for candidate " + std::to_string(ci) +
|
|
" at token " + std::to_string(k));
|
|
}
|
|
total_log_prob += (double) token_log_prob;
|
|
|
|
if (request->include_token_logprobs()) {
|
|
backend::TokenLogProb * tlp = cs->add_tokens();
|
|
tlp->set_token(common_token_to_piece(vocab, cand_tokens[ci][divergence + k]));
|
|
tlp->set_log_prob(token_log_prob);
|
|
}
|
|
}
|
|
|
|
cs->set_log_prob(total_log_prob);
|
|
if (request->length_normalize()) {
|
|
cs->set_length_normalized_log_prob(total_log_prob / (double) cand_len);
|
|
}
|
|
}
|
|
|
|
return grpc::Status::OK;
|
|
#endif
|
|
}
|
|
|
|
grpc::Status TokenizeString(ServerContext* context, const backend::PredictOptions* request, backend::TokenizationResponse* response) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
auto identity = checkModelIdentity(request);
|
|
if (!identity.ok()) return identity;
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
json body = parse_options(false, request, params_base, ctx_server.get_llama_context());
|
|
body["stream"] = false;
|
|
|
|
json tokens_response = json::array();
|
|
if (body.count("prompt") != 0) {
|
|
const bool add_special = json_value(body, "add_special", false);
|
|
|
|
llama_tokens tokens = tokenize_mixed(ctx_server.impl->vocab, body.at("prompt"), add_special, true);
|
|
|
|
|
|
for (const auto& token : tokens) {
|
|
std::string piece = common_token_to_piece(ctx_server.get_llama_context(), token);
|
|
response->add_tokens(token);
|
|
}
|
|
}
|
|
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
// slotStatusToGrpc maps a slot cache helper status to a gRPC status.
|
|
static grpc::Status slotStatusToGrpc(llama_grpc::SlotStatus s, const std::string& msg) {
|
|
switch (s) {
|
|
case llama_grpc::SlotStatus::NotFound: return grpc::Status(grpc::StatusCode::NOT_FOUND, msg);
|
|
case llama_grpc::SlotStatus::Invalid: return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, msg);
|
|
case llama_grpc::SlotStatus::Corrupt: return grpc::Status(grpc::StatusCode::DATA_LOSS, msg);
|
|
default: return grpc::Status(grpc::StatusCode::INTERNAL, msg);
|
|
}
|
|
}
|
|
|
|
// SlotCache saves or restores the prompt cache of one slot. It reuses the
|
|
// task the llama-server /slots endpoint posts, so the file format is the
|
|
// same. The caller never names a file. A save writes
|
|
// <slot_save_path>/<hash of the model identity>/<id> with an ID the backend
|
|
// generates, and records it in that directory's manifest.json. A restore
|
|
// takes an ID, looks it up in the manifest and builds the path from the
|
|
// manifest entry (see slot_cache.h for the checks and their limits).
|
|
grpc::Status SlotCache(ServerContext* context, const backend::SlotCacheRequest* request, backend::SlotCacheResponse* response) override {
|
|
#ifdef _WIN32
|
|
(void)context; (void)request; (void)response;
|
|
return grpc::Status(grpc::StatusCode::UNIMPLEMENTED, "slot save/restore is not supported on Windows");
|
|
#else
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
// Same rule as checkModelIdentity; written out because this request
|
|
// names the field model_identity, so it has no modelidentity().
|
|
if (!request->model_identity().empty() && !loaded_model_identity.empty() &&
|
|
loaded_model_identity != request->model_identity()) {
|
|
return grpc::Status(grpc::StatusCode::NOT_FOUND,
|
|
"llama-cpp: model identity mismatch: loaded \"" + loaded_model_identity +
|
|
"\", requested \"" + request->model_identity() + "\"");
|
|
}
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
if (params_base.slot_save_path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION,
|
|
"slot save/restore is disabled: set the slot_save_path option for this model");
|
|
}
|
|
|
|
const bool is_save = request->action() == backend::SLOT_CACHE_ACTION_SAVE;
|
|
if (!is_save && request->action() != backend::SLOT_CACHE_ACTION_RESTORE) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "action must be save or restore");
|
|
}
|
|
if (request->slot() < 0) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "slot must not be negative");
|
|
}
|
|
|
|
// The identity that names the directory and is written to the manifest.
|
|
std::string model_id = !loaded_model_identity.empty() ? loaded_model_identity : request->model_identity();
|
|
if (model_id.empty()) {
|
|
model_id = params_base.model.path;
|
|
}
|
|
|
|
std::string err, dir, id, filepath;
|
|
llama_grpc::SlotEntry entry;
|
|
if (is_save) {
|
|
if (!request->id().empty()) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "id must be empty on save: the backend issues it");
|
|
}
|
|
auto s = llama_grpc::slot_identity_dir(params_base.slot_save_path, model_id, true, dir, err);
|
|
if (s != llama_grpc::SlotStatus::Ok) return slotStatusToGrpc(s, err);
|
|
// Fail before writing a large file if the manifest is unusable.
|
|
std::vector<llama_grpc::SlotEntry> existing;
|
|
{
|
|
std::lock_guard<std::mutex> lock(llama_grpc::slot_cache_mutex());
|
|
s = llama_grpc::slot_manifest_load(dir, existing, err);
|
|
}
|
|
if (s != llama_grpc::SlotStatus::Ok) return slotStatusToGrpc(s, err);
|
|
s = llama_grpc::slot_reserve_id(dir, id, filepath, err);
|
|
if (s != llama_grpc::SlotStatus::Ok) return slotStatusToGrpc(s, err);
|
|
} else {
|
|
auto s = llama_grpc::slot_lookup_restore(params_base.slot_save_path, model_id, request->id(), entry, filepath, err);
|
|
if (s != llama_grpc::SlotStatus::Ok) return slotStatusToGrpc(s, err);
|
|
id = entry.id;
|
|
}
|
|
|
|
// On a failed save the reserved (empty or partial) file is removed.
|
|
auto drop_reserved = [&]() { if (is_save) { std::error_code rec; std::filesystem::remove(std::filesystem::u8path(filepath), rec); } };
|
|
|
|
server_task task(is_save ? SERVER_TASK_TYPE_SLOT_SAVE : SERVER_TASK_TYPE_SLOT_RESTORE);
|
|
auto rd = ctx_server.get_response_reader();
|
|
task.id = rd.get_new_id();
|
|
task.slot_action.id_slot = request->slot();
|
|
task.slot_action.filename = id;
|
|
task.slot_action.filepath = filepath;
|
|
rd.post_task(std::move(task));
|
|
|
|
auto res = rd.next([context]() { return context->IsCancelled(); });
|
|
if (!res) {
|
|
drop_reserved();
|
|
return grpc::Status(grpc::StatusCode::CANCELLED, "slot cache request cancelled");
|
|
}
|
|
if (res->is_error()) {
|
|
drop_reserved();
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, res->to_json().dump());
|
|
}
|
|
auto * slot_res = dynamic_cast<server_task_result_slot_save_load *>(res.get());
|
|
if (slot_res == nullptr) {
|
|
drop_reserved();
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "unexpected result type for a slot cache task");
|
|
}
|
|
|
|
uint64_t n_bytes = slot_res->n_bytes;
|
|
std::string message;
|
|
if (is_save) {
|
|
// The size is only known after llama.cpp wrote the file. Use the
|
|
// size on disk: restore compares against it.
|
|
std::error_code sec;
|
|
const auto on_disk = std::filesystem::file_size(std::filesystem::u8path(filepath), sec);
|
|
if (sec) {
|
|
drop_reserved();
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "cannot read the size of the saved file");
|
|
}
|
|
n_bytes = static_cast<uint64_t>(on_disk);
|
|
if (n_bytes > llama_grpc::SLOT_CACHE_MAX_FILE_BYTES) {
|
|
drop_reserved();
|
|
return grpc::Status(grpc::StatusCode::RESOURCE_EXHAUSTED,
|
|
"the saved state is larger than the " + std::to_string(llama_grpc::SLOT_CACHE_MAX_FILE_BYTES) +
|
|
" byte limit and was deleted");
|
|
}
|
|
entry.id = id;
|
|
entry.model_identity = model_id;
|
|
entry.slot = request->slot();
|
|
entry.n_tokens = slot_res->n_tokens;
|
|
entry.n_bytes = n_bytes;
|
|
entry.created_unix = static_cast<int64_t>(std::time(nullptr));
|
|
std::vector<std::string> pruned;
|
|
auto s = llama_grpc::slot_commit_save(dir, entry, pruned, err);
|
|
if (s != llama_grpc::SlotStatus::Ok) {
|
|
drop_reserved();
|
|
return slotStatusToGrpc(s, err);
|
|
}
|
|
if (!pruned.empty()) {
|
|
message = "pruned the oldest saved state to keep at most " +
|
|
std::to_string(llama_grpc::SLOT_CACHE_MAX_ENTRIES) + ":";
|
|
for (const auto & p : pruned) message += " " + p;
|
|
}
|
|
}
|
|
|
|
response->set_success(true);
|
|
response->set_message(message);
|
|
response->set_slot(slot_res->id_slot);
|
|
response->set_id(id);
|
|
response->set_n_tokens(slot_res->n_tokens);
|
|
response->set_n_bytes(n_bytes);
|
|
response->set_duration_ms(slot_res->t_ms);
|
|
return grpc::Status::OK;
|
|
#endif
|
|
}
|
|
|
|
grpc::Status Detokenize(ServerContext* context, const backend::DetokenizeRequest* request, backend::DetokenizeResponse* response) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
|
|
std::string content;
|
|
for (const auto token : request->tokens()) {
|
|
content.append(common_token_to_piece(ctx_server.get_llama_context(), token));
|
|
}
|
|
response->set_content(content);
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
grpc::Status GetMetrics(ServerContext* /*context*/, const backend::MetricsRequest* /*request*/, backend::MetricsResponse* response) override {
|
|
|
|
|
|
// request slots data using task queue
|
|
auto rd = ctx_server.get_response_reader();
|
|
int task_id = rd.queue_tasks.get_new_id();
|
|
{
|
|
server_task task(SERVER_TASK_TYPE_METRICS);
|
|
task.id = task_id;
|
|
rd.queue_results.add_waiting_task_id(task_id);
|
|
rd.queue_tasks.post(std::move(task), true); // high-priority task
|
|
}
|
|
|
|
// get the result
|
|
server_task_result_ptr result = rd.queue_results.recv(task_id);
|
|
rd.queue_results.remove_waiting_task_id(task_id);
|
|
|
|
if (result->is_error()) {
|
|
// Handle case when no active slot exists
|
|
response->set_slot_id(0);
|
|
response->set_prompt_json_for_slot("");
|
|
response->set_tokens_per_second(0);
|
|
response->set_tokens_generated(0);
|
|
response->set_prompt_tokens_processed(0);
|
|
return grpc::Status(grpc::StatusCode::INTERNAL, "Error in receiving results");
|
|
}
|
|
|
|
// TODO: get rid of this dynamic_cast
|
|
auto res_metrics = dynamic_cast<server_task_result_metrics*>(result.get());
|
|
GGML_ASSERT(res_metrics != nullptr);
|
|
|
|
// Populate the response with metrics
|
|
response->set_slot_id(0);
|
|
response->set_prompt_json_for_slot("");
|
|
#if LOCALAI_HAS_SERVER_METRICS
|
|
response->set_tokens_per_second(res_metrics->metrics.prompt_bucket.n_per_second());
|
|
response->set_tokens_generated(res_metrics->metrics.predict.count);
|
|
response->set_prompt_tokens_processed(res_metrics->metrics.prompt.count);
|
|
#else
|
|
response->set_tokens_per_second(res_metrics->n_prompt_tokens_processed ? 1.e3 / res_metrics->t_prompt_processing * res_metrics->n_prompt_tokens_processed : 0.);
|
|
response->set_tokens_generated(res_metrics->n_tokens_predicted_total);
|
|
response->set_prompt_tokens_processed(res_metrics->n_prompt_tokens_processed_total);
|
|
#endif
|
|
|
|
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
grpc::Status ModelMetadata(ServerContext* /*context*/, const backend::ModelOptions* /*request*/, backend::ModelMetadataResponse* response) override {
|
|
// Check if model is loaded
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
|
|
// Report the active multimodal media marker so the Go layer can emit the
|
|
// same string when rendering prompts outside the tokenizer-template path.
|
|
// Only meaningful when an mtmd context was initialized (vision/audio models).
|
|
if (ctx_server.impl->mctx != nullptr) {
|
|
response->set_media_marker(get_media_marker());
|
|
}
|
|
|
|
// Check if chat templates are initialized
|
|
if (ctx_server.impl->chat_params.tmpls == nullptr) {
|
|
// If templates are not initialized, we can't detect thinking support
|
|
// Return false as default
|
|
response->set_supports_thinking(false);
|
|
response->set_rendered_template("");
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
// Detect thinking support using llama.cpp's function
|
|
bool supports_thinking = common_chat_templates_support_enable_thinking(ctx_server.impl->chat_params.tmpls.get());
|
|
response->set_supports_thinking(supports_thinking);
|
|
|
|
// Render the template with enable_thinking=true so Go code can detect thinking tokens
|
|
// This allows reusing existing detection functions in Go
|
|
std::string rendered_template = "";
|
|
if (params_base.use_jinja) {
|
|
// Render the template with enable_thinking=true to see what the actual prompt looks like
|
|
common_chat_templates_inputs dummy_inputs;
|
|
common_chat_msg msg;
|
|
msg.role = "user";
|
|
msg.content = "test";
|
|
dummy_inputs.messages = {msg};
|
|
dummy_inputs.enable_thinking = true;
|
|
dummy_inputs.use_jinja = params_base.use_jinja;
|
|
|
|
const auto rendered = common_chat_templates_apply(ctx_server.impl->chat_params.tmpls.get(), dummy_inputs);
|
|
rendered_template = chat_prompt_str(rendered.prompt);
|
|
}
|
|
|
|
response->set_rendered_template(rendered_template);
|
|
|
|
// Run differential template analysis to detect tool format markers
|
|
if (params_base.use_jinja) {
|
|
try {
|
|
// Get template source and reconstruct a common_chat_template for analysis
|
|
std::string tmpl_src = common_chat_templates_source(ctx_server.impl->chat_params.tmpls.get());
|
|
if (!tmpl_src.empty()) {
|
|
const auto * vocab = llama_model_get_vocab(ctx_server.impl->model_tgt);
|
|
std::string token_bos, token_eos;
|
|
if (vocab) {
|
|
auto bos_id = llama_vocab_bos(vocab);
|
|
auto eos_id = llama_vocab_eos(vocab);
|
|
if (bos_id != LLAMA_TOKEN_NULL) {
|
|
token_bos = common_token_to_piece(vocab, bos_id, true);
|
|
}
|
|
if (eos_id != LLAMA_TOKEN_NULL) {
|
|
token_eos = common_token_to_piece(vocab, eos_id, true);
|
|
}
|
|
}
|
|
common_chat_template tmpl(tmpl_src, token_bos, token_eos);
|
|
struct autoparser::autoparser ap;
|
|
ap.analyze_template(tmpl);
|
|
|
|
if (ap.analysis_complete && ap.tools.format.mode != autoparser::tool_format::NONE) {
|
|
auto * tf = response->mutable_tool_format();
|
|
|
|
// Format type
|
|
switch (ap.tools.format.mode) {
|
|
case autoparser::tool_format::JSON_NATIVE:
|
|
tf->set_format_type("json_native");
|
|
break;
|
|
case autoparser::tool_format::TAG_WITH_JSON:
|
|
tf->set_format_type("tag_with_json");
|
|
break;
|
|
case autoparser::tool_format::TAG_WITH_TAGGED:
|
|
tf->set_format_type("tag_with_tagged");
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
// Tool section markers
|
|
tf->set_section_start(ap.tools.format.section_start);
|
|
tf->set_section_end(ap.tools.format.section_end);
|
|
tf->set_per_call_start(ap.tools.format.per_call_start);
|
|
tf->set_per_call_end(ap.tools.format.per_call_end);
|
|
|
|
// Function markers
|
|
tf->set_func_name_prefix(ap.tools.function.name_prefix);
|
|
tf->set_func_name_suffix(ap.tools.function.name_suffix);
|
|
tf->set_func_close(ap.tools.function.close);
|
|
|
|
// Argument markers
|
|
tf->set_arg_name_prefix(ap.tools.arguments.name_prefix);
|
|
tf->set_arg_name_suffix(ap.tools.arguments.name_suffix);
|
|
tf->set_arg_value_prefix(ap.tools.arguments.value_prefix);
|
|
tf->set_arg_value_suffix(ap.tools.arguments.value_suffix);
|
|
tf->set_arg_separator(ap.tools.arguments.separator);
|
|
tf->set_args_start(ap.tools.arguments.start);
|
|
tf->set_args_end(ap.tools.arguments.end);
|
|
|
|
// JSON format fields
|
|
tf->set_name_field(ap.tools.format.name_field);
|
|
tf->set_args_field(ap.tools.format.args_field);
|
|
tf->set_id_field(ap.tools.format.id_field);
|
|
tf->set_fun_name_is_key(ap.tools.format.fun_name_is_key);
|
|
tf->set_tools_array_wrapped(ap.tools.format.tools_array_wrapped);
|
|
tf->set_function_field(ap.tools.format.function_field);
|
|
|
|
tf->set_gen_id_field(ap.tools.format.gen_id_field);
|
|
|
|
for (const auto & p : ap.tools.format.parameter_order) {
|
|
tf->add_parameter_order(p);
|
|
}
|
|
|
|
// Call ID markers
|
|
switch (ap.tools.call_id.pos) {
|
|
case autoparser::call_id_position::NONE:
|
|
tf->set_call_id_position("none");
|
|
break;
|
|
case autoparser::call_id_position::PRE_FUNC_NAME:
|
|
tf->set_call_id_position("pre_func_name");
|
|
break;
|
|
case autoparser::call_id_position::BETWEEN_FUNC_AND_ARGS:
|
|
tf->set_call_id_position("between_func_and_args");
|
|
break;
|
|
case autoparser::call_id_position::POST_ARGS:
|
|
tf->set_call_id_position("post_args");
|
|
break;
|
|
}
|
|
tf->set_call_id_prefix(ap.tools.call_id.prefix);
|
|
tf->set_call_id_suffix(ap.tools.call_id.suffix);
|
|
|
|
// Reasoning markers
|
|
tf->set_reasoning_start(ap.reasoning.start);
|
|
tf->set_reasoning_end(ap.reasoning.end);
|
|
|
|
// Content markers
|
|
tf->set_content_start(ap.content.start);
|
|
tf->set_content_end(ap.content.end);
|
|
}
|
|
}
|
|
} catch (const std::exception & e) {
|
|
SRV_WRN("ModelMetadata: failed to run autoparser analysis: %s\n", e.what());
|
|
}
|
|
}
|
|
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
// runTranscriptionAsCompletion implements OAI /v1/audio/transcriptions on
|
|
// top of the existing chat-completion + multimodal-audio pipeline, exactly
|
|
// the way upstream llama.cpp's server does it (see
|
|
// tools/server/server-context.cpp post_transcriptions_oai → forwards into
|
|
// handle_completions_impl with a single user message attaching the audio
|
|
// file via the mtmd marker).
|
|
//
|
|
// We synthesize a backend::PredictOptions with one user message
|
|
// ("Transcribe audio to text" + optional language hint) and the audio
|
|
// bytes attached via the existing PredictOptions.audios field, then
|
|
// delegate to our own Predict() handler. This keeps every multimodal
|
|
// codepath identical to the chat path and avoids duplicating ~700 lines
|
|
// of task-construction logic.
|
|
grpc::Status runTranscriptionAsCompletion(grpc::ServerContext* context,
|
|
const backend::TranscriptRequest* request,
|
|
backend::Reply* out_reply) {
|
|
if (params_base.model.path.empty()) {
|
|
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
|
|
}
|
|
if (request->dst().empty()) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "dst (audio file path) is required");
|
|
}
|
|
|
|
// Read audio bytes from the path LocalAI's HTTP layer wrote.
|
|
std::ifstream f(request->dst(), std::ios::binary);
|
|
if (!f.is_open()) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "failed to open audio file: " + request->dst());
|
|
}
|
|
std::vector<unsigned char> bytes((std::istreambuf_iterator<char>(f)),
|
|
std::istreambuf_iterator<char>());
|
|
f.close();
|
|
if (bytes.empty()) {
|
|
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "audio file is empty: " + request->dst());
|
|
}
|
|
|
|
std::string b64 = base64_encode_bytes(bytes.data(), bytes.size());
|
|
|
|
// The model's own ASR preset, as upstream convert_transcriptions_to_chatcmpl
|
|
// builds it. LFM3 needs a system turn naming the language and an empty user
|
|
// turn; Qwen3-ASR gets a fixed user text and no system turn. Forks without
|
|
// the preset API keep the Qwen3-ASR text.
|
|
#if LOCALAI_HAS_ASR_PRESET
|
|
const common_chat_prompt_preset preset =
|
|
common_chat_get_asr_prompt(ctx_server.impl->chat_params.tmpls.get(), request->language());
|
|
const std::string preset_system = preset.system;
|
|
const std::string preset_user = preset.user;
|
|
const bool preset_has_language = preset.has_language;
|
|
#else
|
|
const std::string preset_system;
|
|
const std::string preset_user = "Transcribe audio to text";
|
|
const bool preset_has_language = false;
|
|
#endif
|
|
const auto messages = llama_grpc::build_transcription_messages(
|
|
preset_system, preset_user, preset_has_language, request->language(), request->prompt());
|
|
|
|
backend::PredictOptions synthetic;
|
|
synthetic.set_usetokenizertemplate(true);
|
|
synthetic.set_temperature(request->temperature());
|
|
// Generation length: leave at 0 so parse_options uses -1 (model default).
|
|
// The model's stop tokens / EOS handle termination naturally for ASR.
|
|
if (!messages.system.empty()) {
|
|
backend::Message* sys = synthetic.add_messages();
|
|
sys->set_role("system");
|
|
sys->set_content(messages.system);
|
|
}
|
|
backend::Message* msg = synthetic.add_messages();
|
|
msg->set_role("user");
|
|
msg->set_content(messages.user);
|
|
synthetic.add_audios(b64);
|
|
|
|
return Predict(context, &synthetic, out_reply);
|
|
}
|
|
|
|
grpc::Status AudioTranscription(ServerContext* context,
|
|
const backend::TranscriptRequest* request,
|
|
backend::TranscriptResult* response) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
auto identity = checkModelIdentity(request);
|
|
if (!identity.ok()) return identity;
|
|
|
|
backend::Reply reply;
|
|
grpc::Status st = runTranscriptionAsCompletion(context, request, &reply);
|
|
if (!st.ok()) {
|
|
return st;
|
|
}
|
|
response->set_text(reply.message());
|
|
if (!request->language().empty()) {
|
|
response->set_language(request->language());
|
|
}
|
|
return grpc::Status::OK;
|
|
}
|
|
|
|
grpc::Status AudioTranscriptionStream(ServerContext* context,
|
|
const backend::TranscriptRequest* request,
|
|
grpc::ServerWriter<backend::TranscriptStreamResponse>* writer) override {
|
|
auto auth = checkAuth(context);
|
|
if (!auth.ok()) return auth;
|
|
auto identity = checkModelIdentity(request);
|
|
if (!identity.ok()) return identity;
|
|
|
|
// Buffered streaming: run the transcription as a normal chat
|
|
// completion, then emit one delta + one final event. Real
|
|
// token-by-token streaming would require refactoring PredictStream's
|
|
// 700-line writer-coupled body; the HTTP/SSE contract is identical
|
|
// either way, and clients that only consume the assembled text don't
|
|
// notice the difference.
|
|
backend::Reply reply;
|
|
grpc::Status st = runTranscriptionAsCompletion(context, request, &reply);
|
|
if (!st.ok()) {
|
|
return st;
|
|
}
|
|
|
|
const std::string& text = reply.message();
|
|
if (!text.empty()) {
|
|
backend::TranscriptStreamResponse delta_chunk;
|
|
delta_chunk.set_delta(text);
|
|
writer->Write(delta_chunk);
|
|
}
|
|
|
|
backend::TranscriptStreamResponse final_chunk;
|
|
backend::TranscriptResult* final_result = final_chunk.mutable_final_result();
|
|
final_result->set_text(text);
|
|
if (!request->language().empty()) {
|
|
final_result->set_language(request->language());
|
|
}
|
|
writer->Write(final_chunk);
|
|
return grpc::Status::OK;
|
|
}
|
|
};
|
|
|
|
|
|
int main(int argc, char** argv) {
|
|
std::string server_address("localhost:50051");
|
|
|
|
// Define long and short options
|
|
struct option long_options[] = {
|
|
{"addr", required_argument, nullptr, 'a'},
|
|
{nullptr, 0, nullptr, 0}
|
|
};
|
|
|
|
// Parse command-line arguments
|
|
int option;
|
|
int option_index = 0;
|
|
while ((option = getopt_long(argc, argv, "a:", long_options, &option_index)) != -1) {
|
|
switch (option) {
|
|
case 'a':
|
|
server_address = optarg;
|
|
break;
|
|
default:
|
|
std::cerr << "Usage: " << argv[0] << " [--addr=<address>] or [-a <address>]" << std::endl;
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Best-effort backstop: self-terminate if the LocalAI process that spawned
|
|
// us dies without cleaning us up (see parent_watch.h).
|
|
llama_grpc::start_parent_death_watcher();
|
|
|
|
server_context ctx_server;
|
|
BackendServiceImpl service(ctx_server);
|
|
|
|
ServerBuilder builder;
|
|
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
|
|
|
|
// Initialize bearer token auth if LOCALAI_GRPC_AUTH_TOKEN is set
|
|
const char* auth_token = std::getenv("LOCALAI_GRPC_AUTH_TOKEN");
|
|
if (auth_token != nullptr && auth_token[0] != '\0') {
|
|
g_grpc_auth_token = auth_token;
|
|
std::cout << "gRPC auth enabled via LOCALAI_GRPC_AUTH_TOKEN" << std::endl;
|
|
}
|
|
builder.RegisterService(&service);
|
|
builder.SetMaxMessageSize(50 * 1024 * 1024); // 50MB
|
|
builder.SetMaxSendMessageSize(50 * 1024 * 1024); // 50MB
|
|
builder.SetMaxReceiveMessageSize(50 * 1024 * 1024); // 50MB
|
|
|
|
std::unique_ptr<Server> server(builder.BuildAndStart());
|
|
// run the HTTP server in a thread - see comment below
|
|
std::thread t([&]()
|
|
{
|
|
std::cout << "Server listening on " << server_address << std::endl;
|
|
server->Wait();
|
|
return 0;
|
|
});
|
|
|
|
// clean up function, to be called before exit
|
|
auto clean_up = [&server, &ctx_server]() {
|
|
SRV_INF("%s: cleaning up before exit...\n", __func__);
|
|
server->Shutdown();
|
|
ctx_server.terminate();
|
|
llama_backend_free();
|
|
};
|
|
|
|
|
|
//);
|
|
start_llama_server(ctx_server);
|
|
std::cout << "stopping" << std::endl;
|
|
|
|
|
|
clean_up();
|
|
t.join();
|
|
|
|
return 0;
|
|
}
|