Skip to content

Commit 09e52c0

Browse files
authored
Automatic Port Numbers (#105)
Add automatic port numbers assignment in configuration file. The string `${PORT}` will be substituted in model.cmd and model.proxy for an actual port number. This also allows model.proxy to be omitted from the configuration.
1 parent ca9063f commit 09e52c0

File tree

3 files changed

+132
-6
lines changed

3 files changed

+132
-6
lines changed

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ healthCheckTimeout: 60
7070
# Valid log levels: debug, info (default), warn, error
7171
logLevel: info
7272

73+
# Automatic Port Values
74+
# use ${PORT} in model.cmd and model.proxy to use an automatic port number
75+
# when you use ${PORT} you can omit a custom model.proxy value, as it will
76+
# default to http://localhost:${PORT}
77+
78+
# override the default port (5800) for automatic port values
79+
startPort: 10001
80+
7381
# define valid model values and the upstream server start
7482
models:
7583
"llama":
@@ -83,6 +91,7 @@ models:
8391
- "CUDA_VISIBLE_DEVICES=0"
8492

8593
# where to reach the server started by cmd, make sure the ports match
94+
# can be omitted if you use an automatic ${PORT} in cmd
8695
proxy: http://127.0.0.1:8999
8796

8897
# aliases names to use this model for
@@ -109,14 +118,14 @@ models:
109118
# but they can still be requested as normal
110119
"qwen-unlisted":
111120
unlisted: true
112-
cmd: llama-server --port 9999 -m Llama-3.2-1B-Instruct-Q4_K_M.gguf -ngl 0
121+
cmd: llama-server --port ${PORT} -m Llama-3.2-1B-Instruct-Q4_K_M.gguf -ngl 0
113122

114123
# Docker Support (v26.1.4+ required!)
115124
"docker-llama":
116-
proxy: "http://127.0.0.1:9790"
125+
proxy: "http://127.0.0.1:${PORT}"
117126
cmd: >
118127
docker run --name dockertest
119-
--init --rm -p 9790:8080 -v /mnt/nvme/models:/models
128+
--init --rm -p ${PORT}:8080 -v /mnt/nvme/models:/models
120129
ghcr.io/ggerganov/llama.cpp:server
121130
--model '/models/Qwen2.5-Coder-0.5B-Instruct-Q4_K_M.gguf'
122131

proxy/config.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66
"os"
77
"sort"
8+
"strconv"
89
"strings"
910

1011
"github.com/google/shlex"
@@ -63,6 +64,9 @@ type Config struct {
6364

6465
// map aliases to actual model IDs
6566
aliases map[string]string
67+
68+
// automatic port assignments
69+
StartPort int `yaml:"startPort"`
6670
}
6771

6872
func (c *Config) RealModelName(search string) (string, bool) {
@@ -108,6 +112,14 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
108112
config.HealthCheckTimeout = 15
109113
}
110114

115+
// set default port ranges
116+
if config.StartPort == 0 {
117+
// default to 5800
118+
config.StartPort = 5800
119+
} else if config.StartPort < 1 {
120+
return Config{}, fmt.Errorf("startPort must be greater than 1")
121+
}
122+
111123
// Populate the aliases map
112124
config.aliases = make(map[string]string)
113125
for modelName, modelConfig := range config.Models {
@@ -119,6 +131,31 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
119131
}
120132
}
121133

134+
// iterate over the models and replace any ${PORT} with the next available port
135+
// Get and sort all model IDs first, makes testing more consistent
136+
modelIds := make([]string, 0, len(config.Models))
137+
for modelId := range config.Models {
138+
modelIds = append(modelIds, modelId)
139+
}
140+
sort.Strings(modelIds) // This guarantees stable iteration order
141+
142+
// iterate over the sorted models
143+
nextPort := config.StartPort
144+
for _, modelId := range modelIds {
145+
modelConfig := config.Models[modelId]
146+
if strings.Contains(modelConfig.Cmd, "${PORT}") {
147+
modelConfig.Cmd = strings.ReplaceAll(modelConfig.Cmd, "${PORT}", strconv.Itoa(nextPort))
148+
if modelConfig.Proxy == "" {
149+
modelConfig.Proxy = fmt.Sprintf("http://localhost:%d", nextPort)
150+
} else {
151+
modelConfig.Proxy = strings.ReplaceAll(modelConfig.Proxy, "${PORT}", strconv.Itoa(nextPort))
152+
}
153+
nextPort++
154+
config.Models[modelId] = modelConfig
155+
} else if modelConfig.Proxy == "" {
156+
return Config{}, fmt.Errorf("model %s requires a proxy value when not using automatic ${PORT}", modelId)
157+
}
158+
}
122159
config = AddDefaultGroupToConfig(config)
123160
// check that members are all unique in the groups
124161
memberUsage := make(map[string]string) // maps member to group it appears in

proxy/config_test.go

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ models:
4444
checkEndpoint: "/"
4545
model4:
4646
cmd: path/to/cmd --arg1 one
47+
proxy: "http://localhost:8082"
4748
checkEndpoint: "/"
4849
4950
healthCheckTimeout: 15
@@ -74,6 +75,7 @@ groups:
7475
}
7576

7677
expected := Config{
78+
StartPort: 5800,
7779
Models: map[string]ModelConfig{
7880
"model1": {
7981
Cmd: "path/to/cmd --arg1 one",
@@ -98,6 +100,7 @@ groups:
98100
},
99101
"model4": {
100102
Cmd: "path/to/cmd --arg1 one",
103+
Proxy: "http://localhost:8082",
101104
CheckEndpoint: "/",
102105
},
103106
},
@@ -166,8 +169,9 @@ groups:
166169
`
167170
// Load the config and verify
168171
_, err := LoadConfigFromReader(strings.NewReader(content))
169-
assert.Equal(t, "model member model2 is used in multiple groups: group1 and group2", err.Error())
170172

173+
// a Contains as order of the map is not guaranteed
174+
assert.Contains(t, err.Error(), "model member model2 is used in multiple groups:")
171175
}
172176

173177
func TestConfig_ModelAliasesAreUnique(t *testing.T) {
@@ -186,10 +190,12 @@ models:
186190
- m1
187191
- m2
188192
`
189-
190193
// Load the config and verify
191194
_, err := LoadConfigFromReader(strings.NewReader(content))
192-
assert.Equal(t, "duplicate alias m1 found in model: model2", err.Error())
195+
196+
// this is a contains because it could be `model1` or `model2` depending on the order
197+
// go decided on the order of the map
198+
assert.Contains(t, err.Error(), "duplicate alias m1 found in model: model")
193199
}
194200

195201
func TestConfig_ModelConfigSanitizedCommand(t *testing.T) {
@@ -279,3 +285,77 @@ func TestConfig_SanitizeCommand(t *testing.T) {
279285
assert.Error(t, err)
280286
assert.Nil(t, args)
281287
}
288+
289+
func TestConfig_AutomaticPortAssignments(t *testing.T) {
290+
291+
t.Run("Default Port Ranges", func(t *testing.T) {
292+
content := ``
293+
config, err := LoadConfigFromReader(strings.NewReader(content))
294+
if !assert.NoError(t, err) {
295+
t.Fatalf("Failed to load config: %v", err)
296+
}
297+
298+
assert.Equal(t, 5800, config.StartPort)
299+
})
300+
t.Run("User specific port ranges", func(t *testing.T) {
301+
content := `startPort: 1000`
302+
config, err := LoadConfigFromReader(strings.NewReader(content))
303+
if !assert.NoError(t, err) {
304+
t.Fatalf("Failed to load config: %v", err)
305+
}
306+
307+
assert.Equal(t, 1000, config.StartPort)
308+
})
309+
310+
t.Run("Invalid start port", func(t *testing.T) {
311+
content := `startPort: abcd`
312+
_, err := LoadConfigFromReader(strings.NewReader(content))
313+
assert.NotNil(t, err)
314+
})
315+
316+
t.Run("start port must be greater than 1", func(t *testing.T) {
317+
content := `startPort: -99`
318+
_, err := LoadConfigFromReader(strings.NewReader(content))
319+
assert.NotNil(t, err)
320+
})
321+
322+
t.Run("Automatic port assignments", func(t *testing.T) {
323+
content := `
324+
startPort: 5800
325+
models:
326+
model1:
327+
cmd: svr --port ${PORT}
328+
model2:
329+
cmd: svr --port ${PORT}
330+
proxy: "http://172.11.22.33:${PORT}"
331+
model3:
332+
cmd: svr --port 1999
333+
proxy: "http://1.2.3.4:1999"
334+
`
335+
config, err := LoadConfigFromReader(strings.NewReader(content))
336+
if !assert.NoError(t, err) {
337+
t.Fatalf("Failed to load config: %v", err)
338+
}
339+
340+
assert.Equal(t, 5800, config.StartPort)
341+
assert.Equal(t, "svr --port 5800", config.Models["model1"].Cmd)
342+
assert.Equal(t, "http://localhost:5800", config.Models["model1"].Proxy)
343+
344+
assert.Equal(t, "svr --port 5801", config.Models["model2"].Cmd)
345+
assert.Equal(t, "http://172.11.22.33:5801", config.Models["model2"].Proxy)
346+
347+
assert.Equal(t, "svr --port 1999", config.Models["model3"].Cmd)
348+
assert.Equal(t, "http://1.2.3.4:1999", config.Models["model3"].Proxy)
349+
350+
})
351+
352+
t.Run("Proxy value required if no ${PORT} in cmd", func(t *testing.T) {
353+
content := `
354+
models:
355+
model1:
356+
cmd: svr --port 111
357+
`
358+
_, err := LoadConfigFromReader(strings.NewReader(content))
359+
assert.Equal(t, "model model1 requires a proxy value when not using automatic ${PORT}", err.Error())
360+
})
361+
}

0 commit comments

Comments
 (0)