Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions clients/localai_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type LocalAIClient struct {
metadata map[string]string
reasoningEffort string
temperature float32
maxTokens int
client *http.Client

nativePartsMu sync.Mutex
Expand Down Expand Up @@ -84,6 +85,15 @@ func (llm *LocalAIClient) SetTemperature(temperature float32) {
llm.temperature = temperature
}

// SetMaxTokens caps the number of tokens generated per request (llama.cpp
// n_predict / OpenAI max_tokens). Zero leaves the field unset (the backend's
// own default applies). This is the per-completion backstop against runaway
// generation (narration/emoji loops to context end) in the agent loop, which
// neither loop-detection nor iteration limits can stop.
func (llm *LocalAIClient) SetMaxTokens(n int) {
llm.maxTokens = n
}

// SetMetadata sets per-request metadata forwarded to LocalAI under the
// top-level "metadata" object (e.g. {"enable_thinking": "true"}). Pass nil
// or an empty map to clear. LocalAI uses these flags to override per-model
Expand Down Expand Up @@ -296,6 +306,9 @@ func (llm *LocalAIClient) CreateChatCompletion(ctx context.Context, request open
if llm.temperature != 0 {
request.Temperature = llm.temperature
}
if llm.maxTokens > 0 {
request.MaxTokens = llm.maxTokens
}

body, err := llm.marshalRequest(request)
if err != nil {
Expand Down Expand Up @@ -426,6 +439,9 @@ func (llm *LocalAIClient) CreateChatCompletionStream(ctx context.Context, reques
if llm.temperature != 0 {
request.Temperature = llm.temperature
}
if llm.maxTokens > 0 {
request.MaxTokens = llm.maxTokens
}

body, err := llm.marshalRequest(request)
if err != nil {
Expand Down
41 changes: 41 additions & 0 deletions clients/localai_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,44 @@ func TestLocalAIClientSetTemperature(t *testing.T) {
t.Fatalf("request temperature = %v, want 0.7", gotTemperature)
}
}

// TestLocalAIClientSetMaxTokens proves SetMaxTokens injects max_tokens into the
// request body (the per-completion runaway cap), and that leaving it unset omits
// the field. Covers the non-stream path; the injection is shared with the stream
// path (same block in CreateChatCompletionStream).
func TestLocalAIClientSetMaxTokens(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":"ok"}}]}`))
}))
defer srv.Close()

// with cap set
llm := NewLocalAILLM("m", "k", srv.URL+"/v1")
llm.SetMaxTokens(4096)
_, _, err := llm.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
Messages: []openai.ChatCompletionMessage{{Role: "user", Content: "hi"}},
})
if err != nil {
t.Fatalf("CreateChatCompletion: %v", err)
}
if mt, ok := gotBody["max_tokens"]; !ok || int(mt.(float64)) != 4096 {
t.Fatalf("max_tokens = %v (ok=%v), want 4096", gotBody["max_tokens"], ok)
}

// without cap -> field omitted (omitempty)
gotBody = nil
llm2 := NewLocalAILLM("m", "k", srv.URL+"/v1")
_, _, err = llm2.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
Messages: []openai.ChatCompletionMessage{{Role: "user", Content: "hi"}},
})
if err != nil {
t.Fatalf("CreateChatCompletion (no cap): %v", err)
}
if _, ok := gotBody["max_tokens"]; ok {
t.Fatalf("max_tokens present without SetMaxTokens: %v", gotBody["max_tokens"])
}
}