Compare commits

...

5 Commits

Author SHA1 Message Date
Benson Wong 7985e94ba4 add tokens processed to ui models page 2025-08-08 13:28:39 -07:00
Benson Wong 74556c3a36 Update bug-report.md [skip ci] 2025-08-08 09:52:05 -07:00
Benson Wong 5c381e4b30 Add gofmt linting to ci 2025-08-07 20:29:18 -07:00
Benson Wong 10569ed546 Fix model alias usage in upstream path (#230)
Model alias values are not properly resolved and work in upstream/ path.

Related to #229.
2025-08-07 20:16:56 -07:00
Benson Wong 5b10b3c23f UI Tweaks (#228)
* sort model names in UI

* add toggle to show model id/name on UI model page
2025-08-07 11:07:03 -07:00
7 changed files with 129 additions and 83 deletions
+3 -1
View File
@@ -1,11 +1,13 @@
--- ---
name: Bug Report name: Bug Report
about: Something is not working as expected... about: I found a defect
title: '' title: ''
labels: bug labels: bug
assignees: '' assignees: ''
--- ---
> [!IMPORTANT]
> If you have questions about llama-swap please post in the Q&A in Discussions. Use bug reports when you've found a defect and wish to discuss a fix.
**Describe the bug** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
+7
View File
@@ -22,6 +22,13 @@ jobs:
with: with:
go-version: '1.23' go-version: '1.23'
# Only run in this linux based runner
- name: Check Formatting
run: |
if [ "$(gofmt -l . | grep -v 'event/.*_test.go' | wc -l)" -gt 0 ]; then
gofmt -l . | grep -v 'event/.*_test.go'
exit 1
fi
# cache simple-responder to save the build time # cache simple-responder to save the build time
- name: Restore Simple Responder - name: Restore Simple Responder
id: restore-simple-responder id: restore-simple-responder
+2 -2
View File
@@ -361,7 +361,7 @@ func (pm *ProxyManager) proxyToUpstream(c *gin.Context) {
return return
} }
processGroup, _, err := pm.swapProcessGroup(requestedModel) processGroup, realModelName, err := pm.swapProcessGroup(requestedModel)
if err != nil { if err != nil {
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error())) pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error()))
return return
@@ -369,7 +369,7 @@ func (pm *ProxyManager) proxyToUpstream(c *gin.Context) {
// rewrite the path // rewrite the path
c.Request.URL.Path = c.Param("upstreamPath") c.Request.URL.Path = c.Param("upstreamPath")
processGroup.ProxyRequest(requestedModel, c.Writer, c.Request) processGroup.ProxyRequest(realModelName, c.Writer, c.Request)
} }
func (pm *ProxyManager) proxyOAIHandler(c *gin.Context) { func (pm *ProxyManager) proxyOAIHandler(c *gin.Context) {
+21 -7
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv" "strconv"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -656,21 +657,34 @@ func TestProxyManager_CORSOptionsHandler(t *testing.T) {
} }
func TestProxyManager_Upstream(t *testing.T) { func TestProxyManager_Upstream(t *testing.T) {
config := AddDefaultGroupToConfig(Config{ configStr := fmt.Sprintf(`
HealthCheckTimeout: 15, logLevel: error
Models: map[string]ModelConfig{ models:
"model1": getTestSimpleResponderConfig("model1"), model1:
}, cmd: %s -port ${PORT} -silent -respond model1
LogLevel: "error", aliases: [model-alias]
}) `, getSimpleResponderPath())
config, err := LoadConfigFromReader(strings.NewReader(configStr))
assert.NoError(t, err)
proxy := New(config) proxy := New(config)
defer proxy.StopProcesses(StopWaitForInflightRequest) defer proxy.StopProcesses(StopWaitForInflightRequest)
t.Run("main model name", func(t *testing.T) {
req := httptest.NewRequest("GET", "/upstream/model1/test", nil) req := httptest.NewRequest("GET", "/upstream/model1/test", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
proxy.ServeHTTP(rec, req) proxy.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "model1", rec.Body.String()) assert.Equal(t, "model1", rec.Body.String())
})
t.Run("model alias", func(t *testing.T) {
req := httptest.NewRequest("GET", "/upstream/model-alias/test", nil)
rec := httptest.NewRecorder()
proxy.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "model1", rec.Body.String())
})
} }
func TestProxyManager_ChatContentLength(t *testing.T) { func TestProxyManager_ChatContentLength(t *testing.T) {
+6
View File
@@ -83,6 +83,12 @@ export function APIProvider({ children, autoStartAPIEvents = true }: APIProvider
case "modelStatus": case "modelStatus":
{ {
const models = JSON.parse(message.data) as Model[]; const models = JSON.parse(message.data) as Model[];
// sort models by name and id
models.sort((a, b) => {
return (a.name + a.id).localeCompare(b.name + b.id);
});
setModels(models); setModels(models);
} }
break; break;
+34 -17
View File
@@ -4,7 +4,7 @@ import { LogPanel } from "./LogViewer";
import { usePersistentState } from "../hooks/usePersistentState"; import { usePersistentState } from "../hooks/usePersistentState";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { useTheme } from "../contexts/ThemeProvider"; import { useTheme } from "../contexts/ThemeProvider";
import { RiEyeFill, RiEyeOffFill, RiStopCircleLine } from "react-icons/ri"; import { RiEyeFill, RiEyeOffFill, RiStopCircleLine, RiSwapBoxFill } from "react-icons/ri";
export default function ModelsPage() { export default function ModelsPage() {
const { isNarrow } = useTheme(); const { isNarrow } = useTheme();
@@ -40,6 +40,7 @@ function ModelsPanel() {
const { models, loadModel, unloadAllModels } = useAPI(); const { models, loadModel, unloadAllModels } = useAPI();
const [isUnloading, setIsUnloading] = useState(false); const [isUnloading, setIsUnloading] = useState(false);
const [showUnlisted, setShowUnlisted] = usePersistentState("showUnlisted", true); const [showUnlisted, setShowUnlisted] = usePersistentState("showUnlisted", true);
const [showIdorName, setShowIdorName] = usePersistentState<"id" | "name">("showIdorName", "id"); // true = show ID, false = show name
const filteredModels = useMemo(() => { const filteredModels = useMemo(() => {
return models.filter((model) => showUnlisted || !model.unlisted); return models.filter((model) => showUnlisted || !model.unlisted);
@@ -58,11 +59,20 @@ function ModelsPanel() {
} }
}, [unloadAllModels]); }, [unloadAllModels]);
const toggleIdorName = useCallback(() => {
setShowIdorName((prev) => (prev === "name" ? "id" : "name"));
}, [showIdorName]);
return ( return (
<div className="card h-full flex flex-col"> <div className="card h-full flex flex-col">
<div className="shrink-0"> <div className="shrink-0">
<h2>Models</h2> <h2>Models</h2>
<div className="flex justify-between"> <div className="flex justify-between">
<div className="flex gap-2">
<button className="btn flex items-center gap-2" onClick={toggleIdorName} style={{ lineHeight: "1.2" }}>
<RiSwapBoxFill /> {showIdorName === "id" ? "ID" : "Name"}
</button>
<button <button
className="btn flex items-center gap-2" className="btn flex items-center gap-2"
onClick={() => setShowUnlisted(!showUnlisted)} onClick={() => setShowUnlisted(!showUnlisted)}
@@ -70,6 +80,7 @@ function ModelsPanel() {
> >
{showUnlisted ? <RiEyeFill /> : <RiEyeOffFill />} unlisted {showUnlisted ? <RiEyeFill /> : <RiEyeOffFill />} unlisted
</button> </button>
</div>
<button className="btn flex items-center gap-2" onClick={handleUnloadAllModels} disabled={isUnloading}> <button className="btn flex items-center gap-2" onClick={handleUnloadAllModels} disabled={isUnloading}>
<RiStopCircleLine size="24" /> {isUnloading ? "Unloading..." : "Unload"} <RiStopCircleLine size="24" /> {isUnloading ? "Unloading..." : "Unload"}
</button> </button>
@@ -80,7 +91,7 @@ function ModelsPanel() {
<table className="w-full"> <table className="w-full">
<thead className="sticky top-0 bg-card z-10"> <thead className="sticky top-0 bg-card z-10">
<tr className="border-b border-primary bg-surface"> <tr className="border-b border-primary bg-surface">
<th className="text-left p-2">Name</th> <th className="text-left p-2">{showIdorName === "id" ? "Model ID" : "Name"}</th>
<th className="text-left p-2"></th> <th className="text-left p-2"></th>
<th className="text-left p-2">State</th> <th className="text-left p-2">State</th>
</tr> </tr>
@@ -90,7 +101,7 @@ function ModelsPanel() {
<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 ${model.unlisted ? "text-txtsecondary" : ""}`}> <td className={`p-2 ${model.unlisted ? "text-txtsecondary" : ""}`}>
<a href={`/upstream/${model.id}/`} className={`underline`} target="_blank"> <a href={`/upstream/${model.id}/`} className={`underline`} target="_blank">
{model.name !== "" ? model.name : model.id} {showIdorName === "id" ? model.id : model.name !== "" ? model.name : model.id}
</a> </a>
{model.description !== "" && ( {model.description !== "" && (
<p className={model.unlisted ? "text-opacity-70" : ""}> <p className={model.unlisted ? "text-opacity-70" : ""}>
@@ -122,35 +133,41 @@ function ModelsPanel() {
function StatsPanel() { function StatsPanel() {
const { metrics } = useAPI(); const { metrics } = useAPI();
const [totalRequests, totalTokens, avgTokensPerSecond] = useMemo(() => { const [totalRequests, totalInputTokens, totalOutputTokens, avgTokensPerSecond] = useMemo(() => {
const totalRequests = metrics.length; const totalRequests = metrics.length;
if (totalRequests === 0) { if (totalRequests === 0) {
return [0, 0, 0]; return [0, 0, 0];
} }
const totalTokens = metrics.reduce((sum, m) => sum + m.output_tokens, 0); const totalInputTokens = metrics.reduce((sum, m) => sum + m.input_tokens, 0);
const totalOutputTokens = metrics.reduce((sum, m) => sum + m.output_tokens, 0);
const avgTokensPerSecond = (metrics.reduce((sum, m) => sum + m.tokens_per_second, 0) / totalRequests).toFixed(2); const avgTokensPerSecond = (metrics.reduce((sum, m) => sum + m.tokens_per_second, 0) / totalRequests).toFixed(2);
return [totalRequests, totalTokens, avgTokensPerSecond]; return [totalRequests, totalInputTokens, totalOutputTokens, avgTokensPerSecond];
}, [metrics]); }, [metrics]);
return ( return (
<div className="card"> <div className="card">
<h2>Chat Activity</h2> <div className="rounded-lg overflow-hidden border border-gray-200">
<table className="w-full border border-gray-200"> <table className="w-full">
<tbody> <tbody>
<tr className="border-b border-gray-200"> <tr>
<td className="py-2 px-4 font-medium border-r border-gray-200">Requests</td> <th className="p-2 font-medium border-b border-gray-200 text-right">Requests</th>
<td className="py-2 px-4 text-right">{totalRequests}</td> <th className="p-2 font-medium border-l border-b border-gray-200 text-right">Processed</th>
</tr> <th className="p-2 font-medium border-l border-b border-gray-200 text-right">Generated</th>
<tr className="border-b border-gray-200"> <th className="p-2 font-medium border-l border-b border-gray-200 text-right">Tokens/Sec</th>
<td className="py-2 px-4 font-medium border-r border-gray-200">Total Tokens Generated</td>
<td className="py-2 px-4 text-right">{totalTokens}</td>
</tr> </tr>
<tr> <tr>
<td className="py-2 px-4 font-medium border-r border-gray-200">Average Tokens/Second</td> <td className="p-2 text-right border-r border-gray-200">{totalRequests}</td>
<td className="py-2 px-4 text-right">{avgTokensPerSecond}</td> <td className="p-2 text-right border-r border-gray-200">
{new Intl.NumberFormat().format(totalInputTokens)}
</td>
<td className="p-2 text-right border-r border-gray-200">
{new Intl.NumberFormat().format(totalOutputTokens)}
</td>
<td className="p-2 text-right">{avgTokensPerSecond}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
); );
} }