Skip to content

Commit 77024e4

Browse files
localai-botmudlerclaude
authored
feat(client): per-request metadata passthrough (disable thinking/reasoning) (#56)
Add an optional metadata object that is attached verbatim to every chat-completion request as the OpenAI "metadata" field. Backends such as LocalAI use it for per-request flags, e.g. {"enable_thinking": "false"} to disable a reasoning model's thinking. - OpenAIOptions.Metadata + OpenAIClient.metadata, applied on all call paths: Ask, CreateChatCompletion, CreateChatCompletionStream. - AgentDefinition.Metadata for per-agent-type overrides; resolveLLM now passes it to the agent LLM factory and fires the factory on a metadata-only override (previously only a model override did). - WithAgentLLMFactory signature gains a metadata map (breaking change to the factory type). Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fe7fd5d commit 77024e4

5 files changed

Lines changed: 109 additions & 15 deletions

File tree

agent.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,12 @@ type AgentDefinition struct {
4040
Tools []string // tool-name allow-list for this type (empty = all parent tools)
4141
Model string // optional model override resolved via the agent LLM factory
4242
Temperature float32 // optional sampling temperature for this type
43-
Iterations int // optional per-type iteration cap (0 = inherit parent)
44-
MaxAttempts int // optional per-type attempt cap (0 = inherit parent)
45-
MaxRetries int // optional per-type retry cap (0 = inherit parent)
43+
// Metadata is an optional per-request metadata object for this type,
44+
// passed to the agent LLM factory and attached to its requests.
45+
Metadata map[string]string
46+
Iterations int // optional per-type iteration cap (0 = inherit parent)
47+
MaxAttempts int // optional per-type attempt cap (0 = inherit parent)
48+
MaxRetries int // optional per-type retry cap (0 = inherit parent)
4649
}
4750

4851
// findAgentDefinition returns the definition with the given name, or nil.
@@ -296,7 +299,7 @@ type spawnAgentRunner struct {
296299
agentSpawnCallback func(*AgentState)
297300
completionFormatter func(*AgentState) string
298301
agentDefinitions []AgentDefinition
299-
llmFactory func(model string, temperature float32) LLM
302+
llmFactory func(model string, temperature float32, metadata map[string]string) LLM
300303
}
301304

302305
func (r *spawnAgentRunner) Run(args SpawnAgentArgs) (string, any, error) {
@@ -508,14 +511,16 @@ func derefFragment(f *Fragment) any {
508511
func (r *spawnAgentRunner) resolveLLM(args SpawnAgentArgs, def *AgentDefinition) LLM {
509512
model := args.Model
510513
var temp float32
514+
var meta map[string]string
511515
if def != nil {
512516
if model == "" {
513517
model = def.Model
514518
}
515519
temp = def.Temperature
520+
meta = def.Metadata
516521
}
517-
if model != "" && r.llmFactory != nil {
518-
return r.llmFactory(model, temp)
522+
if (model != "" || len(meta) > 0) && r.llmFactory != nil {
523+
return r.llmFactory(model, temp, meta)
519524
}
520525
return r.llm
521526
}
@@ -587,7 +592,7 @@ func newSpawnAgentTool(
587592
spawnCB func(*AgentState),
588593
completionFormatter func(*AgentState) string,
589594
defs []AgentDefinition,
590-
llmFactory func(model string, temperature float32) LLM,
595+
llmFactory func(model string, temperature float32, metadata map[string]string) LLM,
591596
) ToolDefinitionInterface {
592597
return NewToolDefinition(
593598
&spawnAgentRunner{

agent_definitions_test.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ func TestSpawnUnknownAgentTypeErrorsCleanly(t *testing.T) {
166166
func TestFactoryResolvesModelAndTemperature(t *testing.T) {
167167
var gotModel string
168168
var gotTemp float32
169-
factory := func(model string, temp float32) LLM {
169+
factory := func(model string, temp float32, _ map[string]string) LLM {
170170
gotModel, gotTemp = model, temp
171171
return newInspectingLLM(func(Fragment, []string) {})
172172
}
@@ -184,9 +184,34 @@ func TestFactoryResolvesModelAndTemperature(t *testing.T) {
184184
}
185185
}
186186

187+
func TestFactoryFiresOnMetadataOnlyOverride(t *testing.T) {
188+
var called bool
189+
var gotMeta map[string]string
190+
factory := func(_ string, _ float32, meta map[string]string) LLM {
191+
called = true
192+
gotMeta = meta
193+
return newInspectingLLM(func(Fragment, []string) {})
194+
}
195+
defs := []AgentDefinition{{Name: "nothink", Metadata: map[string]string{"enable_thinking": "false"}}}
196+
runner := &spawnAgentRunner{
197+
llm: newInspectingLLM(func(Fragment, []string) {}),
198+
manager: NewAgentManager(),
199+
ctx: context.Background(),
200+
agentDefinitions: defs,
201+
llmFactory: factory,
202+
}
203+
_, _, _ = runner.Run(SpawnAgentArgs{AgentType: "nothink", Task: "x", Background: false})
204+
if !called {
205+
t.Fatal("factory should fire for a metadata-only override")
206+
}
207+
if gotMeta["enable_thinking"] != "false" {
208+
t.Fatalf("factory got metadata %v, want enable_thinking=false", gotMeta)
209+
}
210+
}
211+
187212
func TestSpawnArgModelBeatsDefinition(t *testing.T) {
188213
var gotModel string
189-
factory := func(model string, temp float32) LLM {
214+
factory := func(model string, temp float32, _ map[string]string) LLM {
190215
gotModel = model
191216
return newInspectingLLM(func(Fragment, []string) {})
192217
}
@@ -203,7 +228,7 @@ func TestSpawnArgModelBeatsDefinition(t *testing.T) {
203228

204229
func TestWithAgentLLMFactoryStores(t *testing.T) {
205230
o := defaultOptions()
206-
o.Apply(WithAgentLLMFactory(func(string, float32) LLM { return nil }))
231+
o.Apply(WithAgentLLMFactory(func(string, float32, map[string]string) LLM { return nil }))
207232
if o.agentLLMFactory == nil {
208233
t.Fatal("factory not stored")
209234
}

clients/openai_client.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@ type OpenAIClient struct {
1616
model string
1717
client *openai.Client
1818
temperature float32
19+
metadata map[string]string
1920
}
2021

2122
// OpenAIOptions carries optional per-client settings.
2223
type OpenAIOptions struct {
2324
Temperature float32
25+
// Metadata is attached verbatim to every chat-completion request as the
26+
// OpenAI "metadata" object. Backends such as LocalAI use it to carry
27+
// per-request flags, e.g. {"enable_thinking": "false"} to disable reasoning.
28+
Metadata map[string]string
2429
}
2530

2631
func NewOpenAILLM(model, apiKey, baseURL string) *OpenAIClient {
@@ -34,6 +39,7 @@ func NewOpenAILLMWithOptions(model, apiKey, baseURL string, opts OpenAIOptions)
3439
model: model,
3540
client: client,
3641
temperature: opts.Temperature,
42+
metadata: opts.Metadata,
3743
}
3844
}
3945

@@ -54,6 +60,9 @@ func (llm *OpenAIClient) Ask(ctx context.Context, f cogito.Fragment) (cogito.Fra
5460
if llm.temperature != 0 {
5561
req.Temperature = llm.temperature
5662
}
63+
if len(llm.metadata) > 0 {
64+
req.Metadata = llm.metadata
65+
}
5766

5867
resp, err := llm.client.CreateChatCompletion(ctx, req)
5968

@@ -83,6 +92,9 @@ func (llm *OpenAIClient) Ask(ctx context.Context, f cogito.Fragment) (cogito.Fra
8392
}
8493
func (llm *OpenAIClient) CreateChatCompletion(ctx context.Context, request openai.ChatCompletionRequest) (cogito.LLMReply, cogito.LLMUsage, error) {
8594
request.Model = llm.model
95+
if len(llm.metadata) > 0 {
96+
request.Metadata = llm.metadata
97+
}
8698
response, err := llm.client.CreateChatCompletion(ctx, request)
8799
if err != nil {
88100
return cogito.LLMReply{}, cogito.LLMUsage{}, err
@@ -107,6 +119,9 @@ func (llm *OpenAIClient) CreateChatCompletionStream(ctx context.Context, request
107119
if llm.temperature != 0 {
108120
request.Temperature = llm.temperature
109121
}
122+
if len(llm.metadata) > 0 {
123+
request.Metadata = llm.metadata
124+
}
110125

111126
stream, err := llm.client.CreateChatCompletionStream(ctx, request)
112127
if err != nil {

clients/openai_client_test.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,54 @@
11
package clients
22

3-
import "testing"
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/sashabaranov/go-openai"
12+
)
13+
14+
func TestNewOpenAILLMWithOptionsSetsMetadata(t *testing.T) {
15+
llm := NewOpenAILLMWithOptions("m", "k", "http://localhost", OpenAIOptions{
16+
Metadata: map[string]string{"enable_thinking": "false"},
17+
})
18+
if llm.metadata["enable_thinking"] != "false" {
19+
t.Fatalf("expected metadata enable_thinking=false, got %v", llm.metadata)
20+
}
21+
}
22+
23+
// TestCreateChatCompletionSendsMetadata verifies the configured metadata is
24+
// serialized into the outgoing request body as the OpenAI "metadata" object.
25+
func TestCreateChatCompletionSendsMetadata(t *testing.T) {
26+
var gotMetadata map[string]string
27+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+
body, _ := io.ReadAll(r.Body)
29+
var req struct {
30+
Metadata map[string]string `json:"metadata"`
31+
}
32+
_ = json.Unmarshal(body, &req)
33+
gotMetadata = req.Metadata
34+
w.Header().Set("Content-Type", "application/json")
35+
_, _ = w.Write([]byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":"ok"}}]}`))
36+
}))
37+
defer srv.Close()
38+
39+
llm := NewOpenAILLMWithOptions("m", "k", srv.URL+"/v1", OpenAIOptions{
40+
Metadata: map[string]string{"enable_thinking": "false"},
41+
})
42+
_, _, err := llm.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
43+
Messages: []openai.ChatCompletionMessage{{Role: "user", Content: "hi"}},
44+
})
45+
if err != nil {
46+
t.Fatalf("CreateChatCompletion: %v", err)
47+
}
48+
if gotMetadata["enable_thinking"] != "false" {
49+
t.Fatalf("request metadata = %v, want enable_thinking=false", gotMetadata)
50+
}
51+
}
452

553
func TestNewOpenAILLMWithOptionsSetsTemperature(t *testing.T) {
654
llm := NewOpenAILLMWithOptions("m", "k", "http://localhost", OpenAIOptions{Temperature: 0.7})

options.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ type Options struct {
9595
agentSpawnCallback func(*AgentState)
9696
agentCompletionFormatter func(*AgentState) string
9797
agentDefinitions []AgentDefinition
98-
agentLLMFactory func(model string, temperature float32) LLM
98+
agentLLMFactory func(model string, temperature float32, metadata map[string]string) LLM
9999
}
100100

101101
type Option func(*Options)
@@ -530,9 +530,10 @@ func WithAgentDefinitions(defs ...AgentDefinition) Option {
530530
}
531531

532532
// WithAgentLLMFactory sets a factory that builds an LLM for a sub-agent from a
533-
// model name and temperature. Used to resolve per-agent-type or per-spawn model
534-
// overrides while reusing the parent's endpoint/credentials.
535-
func WithAgentLLMFactory(fn func(model string, temperature float32) LLM) Option {
533+
// model name, temperature, and per-request metadata. Used to resolve
534+
// per-agent-type or per-spawn model/metadata overrides while reusing the
535+
// parent's endpoint/credentials.
536+
func WithAgentLLMFactory(fn func(model string, temperature float32, metadata map[string]string) LLM) Option {
536537
return func(o *Options) {
537538
o.agentLLMFactory = fn
538539
}

0 commit comments

Comments
 (0)