Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a906cd459b | |||
| 78b2bc3dbc | |||
| 6a058e4191 |
+3
-2
@@ -117,9 +117,10 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = watcher.Add(absConfigPath)
|
configDir := filepath.Dir(absConfigPath)
|
||||||
|
err = watcher.Add(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error adding config path (%s) to watcher: %v. File watching disabled.", absConfigPath, err)
|
fmt.Printf("Error adding config path directory (%s) to watcher: %v. File watching disabled.", configDir, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ func LoadConfigFromReader(r io.Reader) (Config, error) {
|
|||||||
for _, modelId := range modelIds {
|
for _, modelId := range modelIds {
|
||||||
modelConfig := config.Models[modelId]
|
modelConfig := config.Models[modelId]
|
||||||
|
|
||||||
|
// Strip comments from command fields before macro expansion
|
||||||
|
modelConfig.Cmd = StripComments(modelConfig.Cmd)
|
||||||
|
modelConfig.CmdStop = StripComments(modelConfig.CmdStop)
|
||||||
|
|
||||||
// go through model config fields: cmd, cmdStop, proxy, checkEndPoint and replace macros with macro values
|
// go through model config fields: cmd, cmdStop, proxy, checkEndPoint and replace macros with macro values
|
||||||
for macroName, macroValue := range config.Macros {
|
for macroName, macroValue := range config.Macros {
|
||||||
macroSlug := fmt.Sprintf("${%s}", macroName)
|
macroSlug := fmt.Sprintf("${%s}", macroName)
|
||||||
@@ -406,3 +410,16 @@ func SanitizeCommand(cmdStr string) ([]string, error) {
|
|||||||
|
|
||||||
return args, nil
|
return args, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func StripComments(cmdStr string) string {
|
||||||
|
var cleanedLines []string
|
||||||
|
for _, line := range strings.Split(cmdStr, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
// Skip comment lines
|
||||||
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleanedLines = append(cleanedLines, line)
|
||||||
|
}
|
||||||
|
return strings.Join(cleanedLines, "\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -325,3 +326,117 @@ models:
|
|||||||
assert.Equal(t, []string{"temperature", "top_k", "top_p"}, sanitized)
|
assert.Equal(t, []string{"temperature", "top_k", "top_p"}, sanitized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStripComments(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no comments",
|
||||||
|
input: "echo hello\necho world",
|
||||||
|
expected: "echo hello\necho world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single comment line",
|
||||||
|
input: "# this is a comment\necho hello",
|
||||||
|
expected: "echo hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple comment lines",
|
||||||
|
input: "# comment 1\necho hello\n# comment 2\necho world",
|
||||||
|
expected: "echo hello\necho world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comment with spaces",
|
||||||
|
input: " # indented comment\necho hello",
|
||||||
|
expected: "echo hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty lines preserved",
|
||||||
|
input: "echo hello\n\necho world",
|
||||||
|
expected: "echo hello\n\necho world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only comments",
|
||||||
|
input: "# comment 1\n# comment 2",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := StripComments(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("StripComments() = %q, expected %q", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_MacroInCommentStrippedBeforeExpansion(t *testing.T) {
|
||||||
|
// Test case that reproduces the original bug where a macro in a comment
|
||||||
|
// would get expanded and cause the comment text to be included in the command
|
||||||
|
content := `
|
||||||
|
startPort: 9990
|
||||||
|
macros:
|
||||||
|
"latest-llama": >
|
||||||
|
/user/llama.cpp/build/bin/llama-server
|
||||||
|
--port ${PORT}
|
||||||
|
|
||||||
|
models:
|
||||||
|
"test-model":
|
||||||
|
cmd: |
|
||||||
|
# ${latest-llama} is a macro that is defined above
|
||||||
|
${latest-llama}
|
||||||
|
--model /path/to/model.gguf
|
||||||
|
-ngl 99
|
||||||
|
`
|
||||||
|
|
||||||
|
config, err := LoadConfigFromReader(strings.NewReader(content))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Get the sanitized command
|
||||||
|
sanitizedCmd, err := SanitizeCommand(config.Models["test-model"].Cmd)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Join the command for easier inspection
|
||||||
|
cmdStr := strings.Join(sanitizedCmd, " ")
|
||||||
|
|
||||||
|
// Verify that comment text is NOT present in the final command as separate arguments
|
||||||
|
commentWords := []string{"is", "macro", "that", "defined", "above"}
|
||||||
|
for _, word := range commentWords {
|
||||||
|
found := slices.Contains(sanitizedCmd, word)
|
||||||
|
assert.False(t, found, "Comment text '%s' should not be present as a separate argument in final command", word)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the actual command components ARE present
|
||||||
|
expectedParts := []string{
|
||||||
|
"/user/llama.cpp/build/bin/llama-server",
|
||||||
|
"--port",
|
||||||
|
"9990",
|
||||||
|
"--model",
|
||||||
|
"/path/to/model.gguf",
|
||||||
|
"-ngl",
|
||||||
|
"99",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range expectedParts {
|
||||||
|
assert.Contains(t, cmdStr, part, "Expected command part '%s' not found in final command", part)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the server path appears exactly once (not duplicated due to macro expansion)
|
||||||
|
serverPath := "/user/llama.cpp/build/bin/llama-server"
|
||||||
|
count := strings.Count(cmdStr, serverPath)
|
||||||
|
assert.Equal(t, 1, count, "Expected exactly 1 occurrence of server path, found %d", count)
|
||||||
|
|
||||||
|
// Verify the expected final command structure
|
||||||
|
expectedCmd := "/user/llama.cpp/build/bin/llama-server --port 9990 --model /path/to/model.gguf -ngl 99"
|
||||||
|
assert.Equal(t, expectedCmd, cmdStr, "Final command does not match expected structure")
|
||||||
|
}
|
||||||
|
|||||||
+3
-3
@@ -212,11 +212,11 @@ func (p *Process) start() error {
|
|||||||
if curState, swapErr := p.swapState(StateStarting, StateStopped); swapErr != nil {
|
if curState, swapErr := p.swapState(StateStarting, StateStopped); swapErr != nil {
|
||||||
p.state = StateStopped // force it into a stopped state
|
p.state = StateStopped // force it into a stopped state
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"failed to start command and state swap failed. command error: %v, current state: %v, state swap error: %v",
|
"failed to start command '%s' and state swap failed. command error: %v, current state: %v, state swap error: %v",
|
||||||
err, curState, swapErr,
|
strings.Join(args, " "), err, curState, swapErr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("start() failed: %v", err)
|
return fmt.Errorf("start() failed for command '%s': %v", strings.Join(args, " "), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the exit error for later signalling
|
// Capture the exit error for later signalling
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func TestProcess_BrokenModelConfig(t *testing.T) {
|
|||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
process.ProxyRequest(w, req)
|
process.ProxyRequest(w, req)
|
||||||
assert.Equal(t, http.StatusBadGateway, w.Code)
|
assert.Equal(t, http.StatusBadGateway, w.Code)
|
||||||
assert.Contains(t, w.Body.String(), "start() failed: ")
|
assert.Contains(t, w.Body.String(), "start() failed for command 'nonexistent-command':")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_UnloadAfterTTL(t *testing.T) {
|
func TestProcess_UnloadAfterTTL(t *testing.T) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Model struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
Unlisted bool `json:"unlisted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func addApiHandlers(pm *ProxyManager) {
|
func addApiHandlers(pm *ProxyManager) {
|
||||||
@@ -72,6 +73,7 @@ func (pm *ProxyManager) getModelStatus() []Model {
|
|||||||
Name: pm.config.Models[modelID].Name,
|
Name: pm.config.Models[modelID].Name,
|
||||||
Description: pm.config.Models[modelID].Description,
|
Description: pm.config.Models[modelID].Description,
|
||||||
State: state,
|
State: state,
|
||||||
|
Unlisted: pm.config.Models[modelID].Unlisted,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Model {
|
|||||||
state: ModelStatus;
|
state: ModelStatus;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
unlisted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface APIProviderType {
|
interface APIProviderType {
|
||||||
@@ -58,7 +59,6 @@ export function APIProvider({ children }: APIProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
const maxRetries = 3;
|
|
||||||
const initialDelay = 1000; // 1 second
|
const initialDelay = 1000; // 1 second
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
@@ -93,11 +93,9 @@ export function APIProvider({ children }: APIProviderProps) {
|
|||||||
};
|
};
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
if (retryCount < maxRetries) {
|
retryCount++;
|
||||||
retryCount++;
|
const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000);
|
||||||
const delay = initialDelay * Math.pow(2, retryCount - 1);
|
setTimeout(connect, delay);
|
||||||
setTimeout(connect, delay);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
apiEventSource.current = eventSource;
|
apiEventSource.current = eventSource;
|
||||||
|
|||||||
+18
-6
@@ -2,10 +2,16 @@ import { useState, useEffect, useCallback, useMemo } from "react";
|
|||||||
import { useAPI } from "../contexts/APIProvider";
|
import { useAPI } from "../contexts/APIProvider";
|
||||||
import { LogPanel } from "./LogViewer";
|
import { LogPanel } from "./LogViewer";
|
||||||
import { processEvalTimes } from "../lib/Utils";
|
import { processEvalTimes } from "../lib/Utils";
|
||||||
|
import { usePersistentState } from "../hooks/usePersistentState";
|
||||||
|
|
||||||
export default function ModelsPage() {
|
export default function ModelsPage() {
|
||||||
const { models, unloadAllModels, loadModel, upstreamLogs, enableAPIEvents } = useAPI();
|
const { models, unloadAllModels, loadModel, upstreamLogs, enableAPIEvents } = useAPI();
|
||||||
const [isUnloading, setIsUnloading] = useState(false);
|
const [isUnloading, setIsUnloading] = useState(false);
|
||||||
|
const [showUnlisted, setShowUnlisted] = usePersistentState("showUnlisted", true);
|
||||||
|
|
||||||
|
const filteredModels = useMemo(() => {
|
||||||
|
return models.filter((model) => showUnlisted || !model.unlisted);
|
||||||
|
}, [models, showUnlisted]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
enableAPIEvents(true);
|
enableAPIEvents(true);
|
||||||
@@ -39,9 +45,15 @@ export default function ModelsPage() {
|
|||||||
<div className="w-full md:w-1/2 flex items-top">
|
<div className="w-full md:w-1/2 flex items-top">
|
||||||
<div className="card w-full">
|
<div className="card w-full">
|
||||||
<h2 className="">Models</h2>
|
<h2 className="">Models</h2>
|
||||||
<button className="btn" onClick={handleUnloadAllModels} disabled={isUnloading}>
|
<div className="flex justify-between">
|
||||||
{isUnloading ? "Unloading..." : "Unload All Models"}
|
<button className="btn" onClick={() => setShowUnlisted(!showUnlisted)} style={{ lineHeight: "1.2" }}>
|
||||||
</button>
|
{showUnlisted ? "🟢 unlisted" : "⚫️ unlisted"}
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={handleUnloadAllModels} disabled={isUnloading}>
|
||||||
|
{isUnloading ? "Stopping ..." : "Stop All"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table className="w-full mt-4">
|
<table className="w-full mt-4">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-primary">
|
<tr className="border-b border-primary">
|
||||||
@@ -51,7 +63,7 @@ export default function ModelsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{models.map((model) => (
|
{filteredModels.map((model) => (
|
||||||
<tr key={model.id} className="border-b hover:bg-secondary-hover border-border">
|
<tr key={model.id} className="border-b hover:bg-secondary-hover border-border">
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<a href={`/upstream/${model.id}/`} className="underline" target="_blank">
|
<a href={`/upstream/${model.id}/`} className="underline" target="_blank">
|
||||||
@@ -63,7 +75,7 @@ export default function ModelsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2 w-[50px]">
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn--sm"
|
||||||
disabled={model.state !== "stopped"}
|
disabled={model.state !== "stopped"}
|
||||||
@@ -72,7 +84,7 @@ export default function ModelsPage() {
|
|||||||
Load
|
Load
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2 w-[75px]">
|
||||||
<span className={`status status--${model.state}`}>{model.state}</span>
|
<span className={`status status--${model.state}`}>{model.state}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user