Skip to content

Commit 625f9a9

Browse files
committed
fix(realtime): gate scoring capacity by model usecase
Reserve llama.cpp scoring slots only for models that explicitly declare the score usecase, while allowing score to coexist with chat and completion. Reject incompatible unified-KV settings and classifier activation on models without scoring capacity. Propagate application defaults when resolving realtime and preload pipeline stages so unset thread counts are resolved consistently without overriding explicit model settings. Assisted-by: Codex:gpt-5 Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 3d3312b commit 625f9a9

17 files changed

Lines changed: 181 additions & 32 deletions

backend/backend.proto

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,11 @@ message ModelOptions {
448448
// Proxy carries the cloud-proxy backend's per-model configuration.
449449
// Empty for non-proxy backends.
450450
ProxyOptions Proxy = 74;
451+
452+
// EnableScore reserves backend resources for the Score RPC. It is derived
453+
// from the model's explicit `known_usecases: [score]` declaration so models
454+
// that never score retain their ordinary serving footprint.
455+
bool EnableScore = 75;
451456
}
452457

453458
// ProxyOptions configures the cloud-proxy backend. UpstreamURL and

backend/cpp/llama-cpp/grpc-server.cpp

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1336,7 +1336,8 @@ static void params_parse(server_context& /*ctx_server*/, const backend::ModelOpt
13361336
// KV cache — with per-sequence streams the extra ids would shrink every
13371337
// sequence's context to n_ctx / n_seq_max. Decided after both option
13381338
// passes so an explicit kv_unified:false wins and disables forking.
1339-
params.n_seq_score_forks = params.kv_unified ? SERVER_SCORE_FORK_SEQS : 0;
1339+
params.score_enabled = request->enablescore();
1340+
params.n_seq_score_forks = params.score_enabled && params.kv_unified ? SERVER_SCORE_FORK_SEQS : 0;
13401341

13411342
// Terminate/pad the override vectors only after BOTH the named-option loop
13421343
// and the generic passthrough (common_params_parse above) have pushed their
@@ -1394,6 +1395,14 @@ class BackendServiceImpl final : public backend::Backend::Service {
13941395
common_params params;
13951396
params_parse(ctx_server, request, params);
13961397

1398+
if (params.score_enabled && !params.kv_unified) {
1399+
const std::string error_msg =
1400+
"Score requires the unified KV cache; remove kv_unified:false or remove score from known_usecases";
1401+
result->set_message(error_msg);
1402+
result->set_success(false);
1403+
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, error_msg);
1404+
}
1405+
13971406
common_init();
13981407
// Ensure debug logs are enabled after common_init() sets up logging
13991408
common_log_set_verbosity_thold(params.verbosity);
@@ -2889,6 +2898,10 @@ class BackendServiceImpl final : public backend::Backend::Service {
28892898
if (params_base.model.path.empty()) {
28902899
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
28912900
}
2901+
if (!params_base.score_enabled) {
2902+
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION,
2903+
"Score was not enabled when the model was loaded; add score to known_usecases");
2904+
}
28922905
if (request->candidates_size() == 0) {
28932906
return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "candidates must be non-empty");
28942907
}

backend/cpp/llama-cpp/patches/0001-add-server-task-type-score.patch

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,31 @@ diff --git a/common/common.h b/common/common.h
1717
index 1535317..8cdd664 100644
1818
--- a/common/common.h
1919
+++ b/common/common.h
20-
@@ -454,6 +454,7 @@ struct common_params {
20+
@@ -454,6 +454,8 @@ struct common_params {
2121
int32_t n_keep = 0; // number of tokens to keep from initial prompt
2222
int32_t n_chunks = -1; // max number of chunks to process (-1 = unlimited)
2323
int32_t n_parallel = 1; // number of parallel sequences to decode
2424
+ int32_t n_seq_score_forks = 0; // extra seq ids beyond n_parallel, reserved for server score-task forks
25+
+ bool score_enabled = false; // reserve server resources for the Score task type
2526
int32_t n_sequences = 1; // number of sequences to decode
2627
int32_t n_outputs_max = 0; // max outputs in a batch (0 = n_batch)
2728
int32_t grp_attn_n = 1; // group-attention factor
2829
diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
2930
index 98d0cca..ac9bcac 100644
3031
--- a/tools/server/server-context.cpp
3132
+++ b/tools/server/server-context.cpp
32-
@@ -49,7 +49,11 @@ static uint32_t server_n_outputs_max(const common_params & params) {
33+
@@ -49,7 +49,16 @@ static uint32_t server_n_outputs_max(const common_params & params) {
3334

3435
const uint32_t n_outputs_per_seq = 1 + common_speculative_n_max(&params.speculative);
3536

3637
- const uint64_t n_outputs = (uint64_t) params.n_parallel * n_outputs_per_seq;
3738
+ // score tasks (SERVER_TASK_TYPE_SCORE) output logits for every candidate
3839
+ // token, so reserve room for a bounded candidate tail per parallel slot
40+
+ if (!params.score_enabled) {
41+
+ return std::max<uint32_t>(1, std::min<uint64_t>(n_batch,
42+
+ (uint64_t) params.n_parallel * n_outputs_per_seq));
43+
+ }
44+
+
3945
+ const uint32_t n_outputs_score_seq = 1 + SERVER_SCORE_MAX_CAND_TOKENS;
4046
+
4147
+ const uint64_t n_outputs = (uint64_t) params.n_parallel * std::max(n_outputs_per_seq, n_outputs_score_seq);

core/backend/options.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
422422
Options: withCompanionArtifactOptions(c.Options, c.Artifacts),
423423
Overrides: c.Overrides,
424424
EngineArgs: engineArgsJSON,
425+
EnableScore: c.HasUsecases(config.FLAG_SCORE),
425426
CLIPSkip: int32(c.Diffusers.ClipSkip),
426427
ControlNet: c.Diffusers.ControlNet,
427428
ContextSize: int32(ctxSize),

core/backend/options_internal_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ var _ = Describe("grpcModelOpts NBatch", func() {
120120
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}}
121121
opts := grpcModelOpts(cfg, "/tmp/models")
122122
Expect(opts.NBatch).To(BeEquivalentTo(512))
123+
Expect(opts.EnableScore).To(BeFalse())
123124
})
124125

125126
It("sizes the batch to the context window for score models", func() {
@@ -128,6 +129,14 @@ var _ = Describe("grpcModelOpts NBatch", func() {
128129
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}, KnownUsecases: &scoreUsecase}
129130
opts := grpcModelOpts(cfg, "/tmp/models")
130131
Expect(opts.NBatch).To(BeEquivalentTo(4096))
132+
Expect(opts.EnableScore).To(BeTrue())
133+
})
134+
135+
It("enables score resources for a model with multiple usecases", func() {
136+
usecases := config.FLAG_CHAT | config.FLAG_SCORE
137+
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}, KnownUsecases: &usecases}
138+
opts := grpcModelOpts(cfg, "/tmp/models")
139+
Expect(opts.EnableScore).To(BeTrue())
131140
})
132141

133142
It("keeps an explicit batch over the score default", func() {

core/backend/preload.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func PreloadModelByName(ctx context.Context, cl *config.ModelConfigLoader, ml *m
2828
return nil, err
2929
}
3030

31-
stages, err := pipelineStages(cl, &cfg.Pipeline, ml.ModelPath)
31+
stages, err := pipelineStages(cl, &cfg.Pipeline, ml.ModelPath, appConfig.ToConfigLoaderOptions()...)
3232
if err != nil {
3333
return nil, err
3434
}
@@ -59,7 +59,7 @@ var loadStage = PreloadModel
5959
// pipeline itself uses. A stage that fails to resolve is a misconfiguration,
6060
// so it fails fast rather than being deferred to load. A pipeline with no
6161
// stages set returns nil, which callers treat as "not a pipeline".
62-
func pipelineStages(cl *config.ModelConfigLoader, p *config.Pipeline, modelPath string) ([]PreloadStage, error) {
62+
func pipelineStages(cl *config.ModelConfigLoader, p *config.Pipeline, modelPath string, opts ...config.ConfigLoaderOption) ([]PreloadStage, error) {
6363
voiceRec := ""
6464
if p.VoiceRecognition != nil {
6565
voiceRec = p.VoiceRecognition.Model
@@ -76,7 +76,7 @@ func pipelineStages(cl *config.ModelConfigLoader, p *config.Pipeline, modelPath
7676
if s.name == "" {
7777
continue
7878
}
79-
cfg, err := cl.LoadResolvedModelConfig(s.name, modelPath)
79+
cfg, err := cl.LoadResolvedModelConfig(s.name, modelPath, opts...)
8080
if err != nil {
8181
return nil, fmt.Errorf("%s (%s): %w", s.role, s.name, err)
8282
}

core/config/backend_capabilities.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const (
3030
UsecaseFaceRecognition = "face_recognition"
3131
UsecaseSpeakerRecognition = "speaker_recognition"
3232
UsecaseTokenClassify = "token_classify"
33+
UsecaseScore = "score"
3334
)
3435

3536
// GRPCMethod identifies a Backend service RPC from backend.proto.
@@ -60,6 +61,7 @@ const (
6061
MethodVoiceEmbed GRPCMethod = "VoiceEmbed"
6162
MethodVoiceAnalyze GRPCMethod = "VoiceAnalyze"
6263
MethodTokenClassify GRPCMethod = "TokenClassify"
64+
MethodScore GRPCMethod = "Score"
6365
)
6466

6567
// UsecaseInfo describes a single known_usecase value and how it maps
@@ -192,6 +194,11 @@ var UsecaseInfoMap = map[string]UsecaseInfo{
192194
GRPCMethod: MethodTokenClassify,
193195
Description: "Per-token classification (NER) via the TokenClassify RPC — the PII detector tier. Declared explicitly via known_usecases; never auto-guessed, since the token-classification head is not useful as general generation or embeddings.",
194196
},
197+
UsecaseScore: {
198+
Flag: FLAG_SCORE,
199+
GRPCMethod: MethodScore,
200+
Description: "Joint log-probability scoring of candidate continuations via the Score RPC. Declared explicitly via known_usecases and usable alongside generation usecases.",
201+
},
195202
}
196203

197204
// BackendCapability describes which gRPC methods and usecases a backend supports.
@@ -241,8 +248,8 @@ func referenceVoiceCloning() *VoiceCloningCapability {
241248
var BackendCapabilities = map[string]BackendCapability{
242249
// --- LLM / text generation backends ---
243250
"llama-cpp": {
244-
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodEmbedding, MethodTokenizeString},
245-
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseEdit, UsecaseEmbeddings, UsecaseTokenize, UsecaseVision},
251+
GRPCMethods: []GRPCMethod{MethodPredict, MethodPredictStream, MethodEmbedding, MethodTokenizeString, MethodScore},
252+
PossibleUsecases: []string{UsecaseChat, UsecaseCompletion, UsecaseEdit, UsecaseEmbeddings, UsecaseTokenize, UsecaseVision, UsecaseScore},
246253
DefaultUsecases: []string{UsecaseChat},
247254
AcceptsImages: true, // requires mmproj
248255
Description: "llama.cpp GGUF models — LLM inference with optional vision via mmproj",

core/config/model_config_loader.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,8 @@ func (bcl *ModelConfigLoader) LoadModelConfigFileByNameDefaultOptions(modelName
201201
// survives unresolved into model loading and fails downstream — notably in
202202
// distributed mode with "backend name is empty". Mirrors the top-level alias
203203
// resolution in core/http/middleware/request.go.
204-
func (bcl *ModelConfigLoader) LoadResolvedModelConfig(modelName, modelPath string) (*ModelConfig, error) {
205-
cfg, err := bcl.LoadModelConfigFileByName(modelName, modelPath)
204+
func (bcl *ModelConfigLoader) LoadResolvedModelConfig(modelName, modelPath string, opts ...ConfigLoaderOption) (*ModelConfig, error) {
205+
cfg, err := bcl.LoadModelConfigFileByName(modelName, modelPath, opts...)
206206
if err != nil {
207207
return nil, err
208208
}

core/config/model_config_loader_resolve_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,21 @@ alias: real-llm
4949
Expect(direct.Backend).To(Equal("llama-cpp"))
5050
Expect(direct.Name).To(Equal("real-llm"))
5151
})
52+
53+
It("applies loader defaults while preserving explicit model threads", func() {
54+
tmpDir := GinkgoT().TempDir()
55+
Expect(os.WriteFile(filepath.Join(tmpDir, "defaulted.yaml"), []byte("name: defaulted\nbackend: llama-cpp\n"), 0644)).To(Succeed())
56+
Expect(os.WriteFile(filepath.Join(tmpDir, "explicit.yaml"), []byte("name: explicit\nbackend: llama-cpp\nthreads: 3\n"), 0644)).To(Succeed())
57+
58+
cl := config.NewModelConfigLoader(tmpDir)
59+
defaulted, err := cl.LoadResolvedModelConfig("defaulted", tmpDir, config.LoadOptionThreads(11))
60+
Expect(err).NotTo(HaveOccurred())
61+
Expect(defaulted.Threads).NotTo(BeNil())
62+
Expect(*defaulted.Threads).To(Equal(11))
63+
64+
explicit, err := cl.LoadResolvedModelConfig("explicit", tmpDir, config.LoadOptionThreads(11))
65+
Expect(err).NotTo(HaveOccurred())
66+
Expect(explicit.Threads).NotTo(BeNil())
67+
Expect(*explicit.Threads).To(Equal(3))
68+
})
5269
})

core/http/endpoints/openai/realtime.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,10 @@ func runRealtimeSession(application *application.Application, t Transport, model
975975

976976
case types.ResponseCreateEvent:
977977
xlog.Debug("recv", "message", string(msg))
978+
if err := validateClassifierActivation(session.ModelInterface, e.Response.LocalAIClassifier); err != nil {
979+
sendError(t, "invalid_request_error", "Invalid response classifier: "+err.Error(), "", e.EventID)
980+
continue
981+
}
978982

979983
// Handle optional items to add to context
980984
if len(e.Response.Input) > 0 {
@@ -1254,7 +1258,7 @@ func updateSession(session *Session, update *types.SessionUnion, cl *config.Mode
12541258
// Replace-not-merge, like tools: the client owns the whole option
12551259
// list. Invalid configs reject the update without touching the
12561260
// session's current classifier.
1257-
if err := rt.LocalAIClassifier.Validate(); err != nil {
1261+
if err := validateClassifierActivation(session.ModelInterface, rt.LocalAIClassifier); err != nil {
12581262
return err
12591263
}
12601264
session.Classifier = rt.LocalAIClassifier

0 commit comments

Comments
 (0)