Skip to content

Commit ef724a3

Browse files
feat(api): add /v1/detokenize endpoint (#9620)
* feat(api): add /v1/detokenize endpoint Closes #1649. Mirror of the existing /v1/tokenize path, requested by @benniekiss in the issue thread for "complete API workflow" use cases that need to turn token IDs back into text without local processing. - Add Detokenize gRPC RPC with DetokenizeRequest{tokens} / DetokenizeResponse{content} messages. - Implement in the llama.cpp backend using common_token_to_piece, the same primitive TokenizeString already uses internally. - Other backends inherit the default Unimplemented from base.Base, in line with how Detect, Rerank, etc. are gated per-backend. - Wire up the Go gRPC interface, server, client, and in-process embed wrapper alongside their TokenizeString counterparts. - Add the schema types, ModelDetokenize wrapper, HTTP handler, route registration, RouteFeatureRegistry entry (gated by FeatureTokenize so no new feature flag is needed), and the discovery map entry under ai_functions. - Regenerated swagger reflects the new endpoint and types. - Update authentication.md to list /v1/detokenize alongside /v1/tokenize. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> * test(e2e): add mock backend tests for /v1/detokenize Add Detokenize to the mock gRPC backend and wire up two e2e tests in the MockBackend suite: one that posts known token IDs and asserts a non-empty content response, and a round-trip that tokenizes first then detokenizes the returned IDs. Addresses reviewer feedback on #9620. Assisted-by: Claude:claude-sonnet-4-6 Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> * fix(kokoros): implement detokenize in the Rust backend service The Detokenize RPC added in this PR grows the tonic-generated Backend trait. Unlike the other languages there is nothing to inherit a default from — Rust trait impls must list every method — so backend/rust/kokoros failed to compile: error[E0046]: not all trait items implemented, missing: `detokenize` --> src/service.rs:72:1 72 | impl Backend for KokorosService { Go backends pick up the Unimplemented default from base.Base, and the generated C++/Python servicer bases default to UNIMPLEMENTED, which is why the Rust backend was the only one that broke. kokoros is the sole Rust crate in the tree, so this is the full extent of the fallout. Return Status::unimplemented("Not supported"), matching how this same file already gates tokenize_string and ~20 other unsupported RPCs. Fixes the tests-kokoros and backend-jobs-singlearch-4 (-cpu-kokoros) failures on the previous head. Assisted-by: Claude:claude-opus-5 cargo Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> --------- Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
1 parent 9651805 commit ef724a3

22 files changed

Lines changed: 421 additions & 1 deletion

File tree

backend/backend.proto

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ service Backend {
3535
rpc TTSStream(TTSRequest) returns (stream Reply) {}
3636
rpc SoundGeneration(SoundGenerationRequest) returns (Result) {}
3737
rpc TokenizeString(PredictOptions) returns (TokenizationResponse) {}
38+
rpc Detokenize(DetokenizeRequest) returns (DetokenizeResponse) {}
3839
rpc Status(HealthMessage) returns (StatusResponse) {}
3940
rpc Detect(DetectOptions) returns (DetectResponse) {}
4041
// SoundDetection runs an audio-tagging / sound-event-classification model
@@ -796,6 +797,14 @@ message TokenizationResponse {
796797
repeated int32 tokens = 2;
797798
}
798799

800+
message DetokenizeRequest {
801+
repeated int32 tokens = 1;
802+
}
803+
804+
message DetokenizeResponse {
805+
string content = 1;
806+
}
807+
799808
message MemoryUsageData {
800809
uint64 total = 1;
801810
map<string, uint64> breakdown = 2;

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3279,6 +3279,21 @@ class BackendServiceImpl final : public backend::Backend::Service {
32793279
return grpc::Status::OK;
32803280
}
32813281

3282+
grpc::Status Detokenize(ServerContext* context, const backend::DetokenizeRequest* request, backend::DetokenizeResponse* response) override {
3283+
auto auth = checkAuth(context);
3284+
if (!auth.ok()) return auth;
3285+
if (params_base.model.path.empty()) {
3286+
return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, "Model not loaded");
3287+
}
3288+
3289+
std::string content;
3290+
for (const auto token : request->tokens()) {
3291+
content.append(common_token_to_piece(ctx_server.get_llama_context(), token));
3292+
}
3293+
response->set_content(content);
3294+
return grpc::Status::OK;
3295+
}
3296+
32823297
grpc::Status GetMetrics(ServerContext* /*context*/, const backend::MetricsRequest* /*request*/, backend::MetricsResponse* response) override {
32833298

32843299

backend/rust/kokoros/src/service.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,13 @@ impl Backend for KokorosService {
415415
Err(Status::unimplemented("Not supported"))
416416
}
417417

418+
async fn detokenize(
419+
&self,
420+
_: Request<backend::DetokenizeRequest>,
421+
) -> Result<Response<backend::DetokenizeResponse>, Status> {
422+
Err(Status::unimplemented("Not supported"))
423+
}
424+
418425
async fn detect(
419426
&self,
420427
_: Request<backend::DetectOptions>,

core/backend/detokenize.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package backend
2+
3+
import (
4+
"time"
5+
6+
"github.com/mudler/LocalAI/core/config"
7+
"github.com/mudler/LocalAI/core/schema"
8+
"github.com/mudler/LocalAI/core/trace"
9+
"github.com/mudler/LocalAI/pkg/grpc"
10+
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
11+
"github.com/mudler/LocalAI/pkg/model"
12+
)
13+
14+
func ModelDetokenize(tokens []int32, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (schema.DetokenizeResponse, error) {
15+
16+
var inferenceModel grpc.Backend
17+
var err error
18+
19+
opts := ModelOptions(modelConfig, appConfig)
20+
inferenceModel, err = loader.Load(opts...)
21+
if err != nil {
22+
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
23+
return schema.DetokenizeResponse{}, err
24+
}
25+
26+
var startTime time.Time
27+
if appConfig.EnableTracing {
28+
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
29+
startTime = time.Now()
30+
}
31+
32+
resp, err := inferenceModel.Detokenize(appConfig.Context, &pb.DetokenizeRequest{Tokens: tokens})
33+
34+
if appConfig.EnableTracing {
35+
errStr := ""
36+
if err != nil {
37+
errStr = err.Error()
38+
}
39+
40+
content := ""
41+
if resp != nil {
42+
content = resp.Content
43+
}
44+
45+
trace.RecordBackendTrace(trace.BackendTrace{
46+
Timestamp: startTime,
47+
Duration: time.Since(startTime),
48+
Type: trace.BackendTraceTokenize,
49+
ModelName: modelConfig.Name,
50+
Backend: modelConfig.Backend,
51+
Summary: trace.TruncateString(content, 200),
52+
Error: errStr,
53+
Data: map[string]any{
54+
"token_count": len(tokens),
55+
"output_text": trace.TruncateString(content, 1000),
56+
},
57+
})
58+
}
59+
60+
if err != nil {
61+
return schema.DetokenizeResponse{}, err
62+
}
63+
64+
return schema.DetokenizeResponse{
65+
Content: resp.Content,
66+
}, nil
67+
}

core/http/auth/features.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ var RouteFeatureRegistry = []RouteFeature{
111111

112112
// Tokenize
113113
{"POST", "/v1/tokenize", FeatureTokenize},
114+
{"POST", "/v1/detokenize", FeatureTokenize},
114115

115116
// Rerank
116117
{"POST", "/v1/rerank", FeatureRerank},
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package localai
2+
3+
import (
4+
"github.com/labstack/echo/v4"
5+
"github.com/mudler/LocalAI/core/backend"
6+
"github.com/mudler/LocalAI/core/config"
7+
"github.com/mudler/LocalAI/core/http/middleware"
8+
"github.com/mudler/LocalAI/core/schema"
9+
"github.com/mudler/LocalAI/pkg/model"
10+
)
11+
12+
// DetokenizeEndpoint exposes a REST API to convert token IDs back to text.
13+
// @Summary Detokenize the input.
14+
// @Tags tokenize
15+
// @Param request body schema.DetokenizeRequest true "Request"
16+
// @Success 200 {object} schema.DetokenizeResponse "Response"
17+
// @Router /v1/detokenize [post]
18+
func DetokenizeEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
19+
return func(c echo.Context) error {
20+
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.DetokenizeRequest)
21+
if !ok || input.Model == "" {
22+
return echo.ErrBadRequest
23+
}
24+
25+
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
26+
if !ok || cfg == nil {
27+
return echo.ErrBadRequest
28+
}
29+
30+
resp, err := backend.ModelDetokenize(input.Tokens, ml, *cfg, appConfig)
31+
if err != nil {
32+
return err
33+
}
34+
return c.JSON(200, resp)
35+
}
36+
}

core/http/routes/localai.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
348348
"3d_generation": "/3d/generations",
349349
"detection": "/v1/detection",
350350
"tokenize": "/v1/tokenize",
351+
"detokenize": "/v1/detokenize",
351352
},
352353
"monitoring": monitoringRoutes,
353354
"mcp": map[string]string{
@@ -417,6 +418,12 @@ func RegisterLocalAIRoutes(router *echo.Echo,
417418
requestExtractor.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_TOKENIZE)),
418419
requestExtractor.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.TokenizeRequest) }))
419420

421+
detokenizeHandler := localai.DetokenizeEndpoint(cl, ml, appConfig)
422+
router.POST("/v1/detokenize",
423+
detokenizeHandler,
424+
requestExtractor.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_TOKENIZE)),
425+
requestExtractor.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.DetokenizeRequest) }))
426+
420427
// MCP endpoint - supports both streaming and non-streaming modes
421428
// Note: streaming mode is NOT compatible with the OpenAI apis. We have a set which streams more states.
422429
if evaluator != nil && !appConfig.DisableMCP {

core/schema/tokenize.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,12 @@ type TokenizeRequest struct {
88
type TokenizeResponse struct {
99
Tokens []int32 `json:"tokens"` // token IDs
1010
}
11+
12+
type DetokenizeRequest struct {
13+
BasicModelRequest
14+
Tokens []int32 `json:"tokens"` // token IDs to convert back to text
15+
}
16+
17+
type DetokenizeResponse struct {
18+
Content string `json:"content"` // detokenized text
19+
}

core/services/nodes/health_mock_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ func (c *fakeBackendClient) AudioTranscriptionStream(_ context.Context, _ *pb.Tr
202202
func (c *fakeBackendClient) TokenizeString(_ context.Context, _ *pb.PredictOptions, _ ...ggrpc.CallOption) (*pb.TokenizationResponse, error) {
203203
return nil, nil
204204
}
205+
func (c *fakeBackendClient) Detokenize(_ context.Context, _ *pb.DetokenizeRequest, _ ...ggrpc.CallOption) (*pb.DetokenizeResponse, error) {
206+
return nil, nil
207+
}
205208
func (c *fakeBackendClient) Status(_ context.Context) (*pb.StatusResponse, error) {
206209
return nil, nil
207210
}

core/services/nodes/inflight_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ func (f *fakeGRPCBackend) TokenizeString(_ context.Context, _ *pb.PredictOptions
146146
return &pb.TokenizationResponse{}, nil
147147
}
148148

149+
func (f *fakeGRPCBackend) Detokenize(_ context.Context, _ *pb.DetokenizeRequest, _ ...ggrpc.CallOption) (*pb.DetokenizeResponse, error) {
150+
return &pb.DetokenizeResponse{}, nil
151+
}
152+
149153
func (f *fakeGRPCBackend) Status(_ context.Context) (*pb.StatusResponse, error) {
150154
return &pb.StatusResponse{}, nil
151155
}

0 commit comments

Comments
 (0)