feat: complete the Homarr provider endpoint

This commit is contained in:
ajnart committed 2026-08-12 13:27:04 +02:00
1 parent 019bd11017
commit 83968be07e
10 files changed
+457 -18

No files matched your search

+19 -3
View File
@@ -61,16 +61,32 @@ container after changing these values, without rebuilding it.
The [Homarr provider](./homarr-provider) proxy is part of the Workshop image. `OPENROUTER_API_KEY` enables the proxy,
`HOMARR_AI_OPENROUTER_BASE_URL` selects its OpenRouter-compatible upstream, `HOMARR_AI_OPENROUTER_MODEL` selects the
private upstream model behind the stable `homarr/model` alias, and the two allowance variables provide per-user and
shared cost ceilings. Lowering either allowance takes effect for existing users. The upstream URL cannot contain
shared request ceilings. Lowering either allowance takes effect for existing users. The upstream URL cannot contain
embedded credentials, a query, or a fragment and must use HTTPS. Tests can explicitly opt into an insecure local mock
with `HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true`; do not use that setting with a real API key.
The proxy overwrites client routing and generation limits. It requires OpenRouter zero-data-retention routing, denies
data-collecting providers, limits responses to 32,768 completion tokens, and does not retain prompts, responses, or
per-request telemetry. Keep an OpenRouter account-level credit limit as the final operational backstop.
per-request telemetry. It accepts only uploaded image data, conservatively bounds text to the advertised context, and
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
limit as the final monetary backstop because request quotas are not dollar accounting.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
failed-request accounting, input and output controls, privacy routing, absence of request logs, and migration rollback.
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
An operator can explicitly smoke-test a deployed Workshop against its real configured upstream with a dedicated,
low-quota test user:
```sh
HOMARR_AI_LIVE_SMOKE=1 \
WORKSHOP_LIVE_TEST_URL=https://workshop.example.com \
WORKSHOP_LIVE_TEST_TOKEN=<temporary-pocketbase-user-token> \
pnpm test:workshop-live-provider
```
This optional check consumes one provider request. The test client needs neither the OpenRouter key nor the private
upstream model ID. Do not run it in CI or with a normal user's Workshop token.
The Custom Widget AI tests can use this proxy. Set `AI_PROVIDER_BASE_URL` to the `/api/ai/v1` endpoint and
`AI_PROVIDER_API_KEY` to a temporary Workshop user token. See
@@ -582,7 +582,7 @@ export async function POST(request: Request) {
...(configuration.provider === "openrouter" || openRouterServerToolsEnabled
? {
"HTTP-Referer": "https://homarr.dev",
"X-Title": "Homarr Assistant",
"X-OpenRouter-Title": "Homarr AI Assistant",
"X-OpenRouter-Metadata": "enabled",
}
: {}),
+12
View File
@@ -123,6 +123,18 @@ pnpm --filter @homarr/docs typecheck
The integration suite uses a separate Compose project and deletes only its disposable volume.
To smoke-test a deployed Workshop against its real configured upstream, use a dedicated low-quota test user:
```sh
HOMARR_AI_LIVE_SMOKE=1 \
WORKSHOP_LIVE_TEST_URL=https://workshop.example.com \
WORKSHOP_LIVE_TEST_TOKEN=<temporary-pocketbase-user-token> \
pnpm test:workshop-live-provider
```
This opt-in check consumes one real request. It verifies the stable model contract, an authenticated completion, quota
headers, and server-side user accounting without requiring the OpenRouter key or upstream model on the test client.
## Backup and restore
PocketBase data lives in `/pb_data`. Stop writes and copy it into a new, empty host directory for each
+114 -6
View File
@@ -1,6 +1,7 @@
package main
import (
"bufio"
"bytes"
"database/sql"
"encoding/json"
@@ -11,6 +12,7 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"
"unicode"
@@ -31,23 +33,32 @@ const (
// The Homarr composer permits five 1 MB images, which expand to about 6.7 MB as
// base64. Keep the authenticated transport bounded without confusing bytes with
// model tokens; validate textual and image content separately below.
maxChatRequestBytes = 12_000_000
maxChatTextBytes = 4 * maxChatInputTokens
maxChatRequestBytes = 12_000_000
// One UTF-8 byte cannot expand into more than one tokenizer fallback token.
// This conservative bound keeps every accepted text payload within 256K tokens
// without coupling the public alias to the private upstream tokenizer.
maxChatTextBytes = maxChatInputTokens
maxChatImageDataBytes = 1_400_000
maxChatImages = 5
maxChatMessages = 512
maxChatResponseBytes = 8 << 20
maxWebSearchResults = 5
maxWebSearchUses = 3
maxUserInFlight = 2
maxGlobalInFlight = 100
openRouterDefaultURL = "https://openrouter.ai/api/v1"
openRouterReferer = "https://homarr.dev"
openRouterTitle = "Homarr AI Assistant"
providerRequestTimeout = 5 * time.Minute
)
var (
errProviderResponseTooLarge = errors.New("provider response is too large")
errInvalidProviderResponse = errors.New("invalid provider response")
errInvalidTools = errors.New("invalid request tools")
errUnsupportedTool = errors.New("unsupported request tool")
errTooManyImages = errors.New("too many images or image too large")
errRemoteImage = errors.New("remote image not allowed")
errInputTooLarge = errors.New("request exceeds model input limit")
)
@@ -67,6 +78,9 @@ type homarrProvider struct {
globalDailyLimit int
httpClient *http.Client
now func() time.Time
inFlightMutex sync.Mutex
inFlightByUser map[string]int
globalInFlight int
}
type quotaSnapshot struct {
@@ -164,7 +178,8 @@ func newHomarrProviderFromEnv() (*homarrProvider, error) {
return http.ErrUseLastResponse
},
},
now: time.Now,
now: time.Now,
inFlightByUser: make(map[string]int),
}, nil
}
@@ -204,6 +219,10 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
if provider.apiKey == "" {
return event.JSON(http.StatusServiceUnavailable, openAIError("The Homarr provider is not configured."))
}
if !provider.acquireRequest(event.Auth.Id) {
return event.JSON(http.StatusTooManyRequests, openAIError("Too many Homarr provider requests are already running."))
}
defer provider.releaseRequest(event.Auth.Id)
body, err := io.ReadAll(io.LimitReader(event.Request.Body, maxChatRequestBytes+1))
if err != nil || len(body) > maxChatRequestBytes {
@@ -229,6 +248,8 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
message := "The request exceeds the Homarr model input limit."
if errors.Is(err, errTooManyImages) {
message = "The request contains too many images or an image that is too large."
} else if errors.Is(err, errRemoteImage) {
message = "Remote image URLs are not supported. Upload the image to Homarr instead."
}
return event.JSON(http.StatusRequestEntityTooLarge, openAIError(message))
}
@@ -267,8 +288,8 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
upstreamRequest.Header.Set("Authorization", "Bearer "+provider.apiKey)
upstreamRequest.Header.Set("Content-Type", "application/json")
upstreamRequest.Header.Set("Accept", "text/event-stream, application/json")
upstreamRequest.Header.Set("HTTP-Referer", "https://homarr.dev")
upstreamRequest.Header.Set("X-Title", "Homarr Provider")
upstreamRequest.Header.Set("HTTP-Referer", openRouterReferer)
upstreamRequest.Header.Set("X-OpenRouter-Title", openRouterTitle)
upstreamRequest.Header.Set("X-OpenRouter-Metadata", "enabled")
upstreamResponse, err := provider.httpClient.Do(upstreamRequest)
@@ -297,7 +318,7 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
if requestBody.Stream {
event.Response.Header().Set("Content-Type", contentType)
event.Response.WriteHeader(upstreamResponse.StatusCode)
_, _ = copyBounded(flushingWriter{event.Response}, upstreamResponse.Body, maxChatResponseBytes)
_, _ = copyAliasedSSE(flushingWriter{event.Response}, upstreamResponse.Body, maxChatResponseBytes)
return nil
}
@@ -305,9 +326,34 @@ func (provider *homarrProvider) chat(event *core.RequestEvent) error {
if readErr != nil {
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint returned an invalid response."))
}
responseBody, readErr = aliasCompletionBody(responseBody)
if readErr != nil {
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint returned an invalid response."))
}
return event.Blob(upstreamResponse.StatusCode, contentType, responseBody)
}
func (provider *homarrProvider) acquireRequest(userID string) bool {
provider.inFlightMutex.Lock()
defer provider.inFlightMutex.Unlock()
if provider.globalInFlight >= maxGlobalInFlight || provider.inFlightByUser[userID] >= maxUserInFlight {
return false
}
provider.globalInFlight++
provider.inFlightByUser[userID]++
return true
}
func (provider *homarrProvider) releaseRequest(userID string) {
provider.inFlightMutex.Lock()
defer provider.inFlightMutex.Unlock()
provider.globalInFlight--
provider.inFlightByUser[userID]--
if provider.inFlightByUser[userID] == 0 {
delete(provider.inFlightByUser, userID)
}
}
func safeUpstreamStatus(status int) int {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return http.StatusBadGateway
@@ -397,7 +443,21 @@ func validateProviderInput(payload map[string]any) error {
}
}
case map[string]any:
if typed["type"] == "image_url" {
image, ok := typed["image_url"].(map[string]any)
imageURL, urlOK := image["url"].(string)
if !ok || !urlOK || !strings.HasPrefix(imageURL, "data:image/") {
return errRemoteImage
}
imageCount++
if imageCount > maxChatImages || len(imageURL) > maxChatImageDataBytes {
return errTooManyImages
}
}
for key, item := range typed {
if typed["type"] == "image_url" && key == "image_url" {
continue
}
textBytes += len(key)
if textBytes > maxChatTextBytes {
return errInputTooLarge
@@ -412,6 +472,54 @@ func validateProviderInput(payload map[string]any) error {
return walk(payload)
}
func aliasCompletionBody(body []byte) ([]byte, error) {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, errInvalidProviderResponse
}
payload["model"] = homarrProviderModelID
aliased, err := json.Marshal(payload)
if err != nil {
return nil, errInvalidProviderResponse
}
return aliased, nil
}
func copyAliasedSSE(writer io.Writer, reader io.Reader, limit int64) (int64, error) {
buffered := bufio.NewReader(io.LimitReader(reader, limit+1))
var written int64
var read int64
for {
line, err := buffered.ReadString('\n')
read += int64(len(line))
if read > limit {
return written, errProviderResponseTooLarge
}
if strings.HasPrefix(line, "data: ") && strings.TrimSpace(strings.TrimPrefix(line, "data: ")) != "[DONE]" {
payload := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
aliased, aliasErr := aliasCompletionBody([]byte(payload))
if aliasErr != nil {
return written, aliasErr
}
line = "data: " + string(aliased) + "\n"
}
if written+int64(len(line)) > limit {
return written, errProviderResponseTooLarge
}
count, writeErr := io.WriteString(writer, line)
written += int64(count)
if writeErr != nil {
return written, writeErr
}
if errors.Is(err, io.EOF) {
return written, nil
}
if err != nil {
return written, err
}
}
}
func copyBounded(writer io.Writer, reader io.Reader, limit int64) (int64, error) {
written, err := io.Copy(writer, io.LimitReader(reader, limit))
if err != nil {
+67
View File
@@ -139,6 +139,73 @@ func TestValidateProviderInput(t *testing.T) {
if err := validateProviderInput(payload); !errors.Is(err, errTooManyImages) {
t.Fatalf("expected oversized image input to be rejected, got %v", err)
}
payload["messages"] = []any{map[string]any{
"role": "user",
"content": []any{map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": "https://attacker.example/expensive-image.png"},
}},
}}
if err := validateProviderInput(payload); !errors.Is(err, errRemoteImage) {
t.Fatalf("expected a remote image URL to be rejected, got %v", err)
}
payload["messages"] = []any{map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
},
}}
if err := validateProviderInput(payload); err != nil {
t.Fatalf("expected five uploaded images to be accepted, got %v", err)
}
payload["messages"].([]any)[0].(map[string]any)["content"] = append(
payload["messages"].([]any)[0].(map[string]any)["content"].([]any),
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
)
if err := validateProviderInput(payload); !errors.Is(err, errTooManyImages) {
t.Fatalf("expected six uploaded images to be rejected, got %v", err)
}
}
func TestProviderInFlightLimits(t *testing.T) {
provider := &homarrProvider{inFlightByUser: make(map[string]int)}
if !provider.acquireRequest("one") || !provider.acquireRequest("one") || provider.acquireRequest("one") {
t.Fatal("expected the per-user in-flight limit to allow two requests")
}
provider.releaseRequest("one")
provider.releaseRequest("one")
for index := range maxGlobalInFlight {
if !provider.acquireRequest(string(rune(index + 1))) {
t.Fatalf("expected global request %d to be admitted", index)
}
}
if provider.acquireRequest("overflow") {
t.Fatal("expected the global in-flight limit to reject overflow")
}
}
func TestCompletionModelAlias(t *testing.T) {
body, err := aliasCompletionBody([]byte(`{"id":"one","model":"private/model","choices":[]}`))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "private/model") || !strings.Contains(string(body), `"model":"homarr/model"`) {
t.Fatalf("private upstream model leaked in JSON: %s", body)
}
var stream strings.Builder
_, err = copyAliasedSSE(
&stream,
strings.NewReader("data: {\"id\":\"one\",\"model\":\"private/model\",\"choices\":[]}\n\ndata: [DONE]\n\n"),
1024,
)
if err != nil || strings.Contains(stream.String(), "private/model") ||
!strings.Contains(stream.String(), `"model":"homarr/model"`) {
t.Fatalf("private upstream model leaked in SSE: %s, %v", stream.String(), err)
}
}
func TestProviderEnvironment(t *testing.T) {
@@ -0,0 +1,52 @@
const enabled = process.env.HOMARR_AI_LIVE_SMOKE === "1";
if (!enabled)
throw new Error("Set HOMARR_AI_LIVE_SMOKE=1 to acknowledge that this check consumes a real provider request");
const baseUrl = process.env.WORKSHOP_LIVE_TEST_URL?.replace(/\/+$/u, "");
const token = process.env.WORKSHOP_LIVE_TEST_TOKEN;
if (!baseUrl || !token) {
throw new Error("WORKSHOP_LIVE_TEST_URL and WORKSHOP_LIVE_TEST_TOKEN are required");
}
const requestJson = async (path, init = {}) => {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { "content-type": "application/json", authorization: `Bearer ${token}`, ...init.headers },
signal: AbortSignal.timeout(60_000),
});
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
return { body, headers: response.headers };
};
const { body: models } = await requestJson("/api/ai/v1/models");
if (models.data?.length !== 1 || models.data[0]?.id !== "homarr/model") {
throw new Error("The live Workshop endpoint did not advertise the stable Homarr model");
}
const { body: before } = await requestJson("/api/ai/usage");
if (before.remaining < 1) throw new Error("The live test user has no remaining Homarr provider allowance");
const { body: completion, headers } = await requestJson("/api/ai/v1/chat/completions", {
method: "POST",
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "Reply with exactly OK." }],
stream: false,
}),
});
if (
completion.model !== "homarr/model" ||
typeof completion.choices?.[0]?.message?.content !== "string" ||
completion.choices[0].message.content.length === 0 ||
headers.get("x-homarr-quota-remaining") === null
) {
throw new Error("The live Homarr provider did not return an OpenAI-compatible completion with quota headers");
}
const { body: after } = await requestJson("/api/ai/usage");
if (after.used < before.used + 1 || after.remaining >= before.remaining) {
throw new Error("The live completion was not charged to the authenticated Workshop user");
}
process.stdout.write("Live Homarr provider smoke test passed\n");
+83 -2
View File
@@ -2,6 +2,7 @@ import { createServer } from "node:http";
const port = Number(process.env.PORT ?? 18091);
const expectedModel = process.env.EXPECTED_MODEL ?? "mock/team-selected-model";
const expectedApiKey = process.env.EXPECTED_API_KEY ?? "workshop-test-openrouter-key";
const json = (response, status, body) => {
response.writeHead(status, { "content-type": "application/json" });
@@ -17,10 +18,18 @@ const server = createServer(async (request, response) => {
json(response, 404, { error: { message: "Not found" } });
return;
}
if (request.headers.authorization !== "Bearer workshop-test-openrouter-key") {
if (request.headers.authorization !== `Bearer ${expectedApiKey}`) {
json(response, 401, { error: { message: "Invalid upstream credentials" } });
return;
}
if (
request.headers["http-referer"] !== "https://homarr.dev" ||
request.headers["x-openrouter-title"] !== "Homarr AI Assistant" ||
request.headers["x-openrouter-metadata"] !== "enabled"
) {
json(response, 400, { error: { message: "Homarr upstream attribution headers are missing or spoofed" } });
return;
}
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
@@ -72,13 +81,42 @@ const server = createServer(async (request, response) => {
json(response, 503, { error: { message: "Simulated upstream failure" } });
return;
}
if (body.messages?.some((message) => message.content === "in-flight probe")) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
const wantsToolCall = body.messages?.some((message) => message.content === "inspect my dashboard");
if (wantsToolCall && !body.tools?.some((tool) => tool.type === "function" && tool.function?.name === "board_list")) {
json(response, 400, { error: { message: "The authenticated function tool was not forwarded" } });
return;
}
const completion = {
id: "gen-workshop-test",
object: "chat.completion",
model: body.model,
choices: [
{ index: 0, message: { role: "assistant", content: "Hello from the Homarr provider" }, finish_reason: "stop" },
wantsToolCall
? {
index: 0,
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "call-board-list",
type: "function",
function: { name: "board_list", arguments: "{}" },
},
],
},
finish_reason: "tool_calls",
}
: {
index: 0,
message: { role: "assistant", content: "Hello from the Homarr provider" },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 12, completion_tokens: 7, total_tokens: 19, cost: 0.00041 },
};
@@ -92,6 +130,49 @@ const server = createServer(async (request, response) => {
"cache-control": "no-cache",
"x-request-id": "mock-openrouter-request",
});
if (wantsToolCall) {
response.write(
`data: ${JSON.stringify({
id: completion.id,
object: "chat.completion.chunk",
model: body.model,
choices: [
{
index: 0,
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "call-board-list",
type: "function",
function: { name: "board_list", arguments: "{" },
},
],
},
finish_reason: null,
},
],
})}\n\n`,
);
response.write(
`data: ${JSON.stringify({
id: completion.id,
object: "chat.completion.chunk",
model: body.model,
choices: [
{
index: 0,
delta: { tool_calls: [{ index: 0, function: { arguments: "}" } }] },
finish_reason: "tool_calls",
},
],
usage: completion.usage,
})}\n\n`,
);
response.end("data: [DONE]\n\n");
return;
}
response.write(
`data: ${JSON.stringify({
id: completion.id,
+107 -5
View File
@@ -101,6 +101,13 @@ if (unchangedAuthor.name !== "widget-author") {
const visitorPassword = "WorkshopVisitor123!";
const visitor = await createUser("widget-visitor@example.invalid", "widget-visitor", visitorPassword);
const visitorSession = await signIn(visitor.email, visitorPassword);
const concurrencyPassword = "WorkshopConcurrency123!";
const concurrencyUser = await createUser(
"provider-concurrency@example.invalid",
"provider-concurrency",
concurrencyPassword,
);
const concurrencySession = await signIn(concurrencyUser.email, concurrencyPassword);
const modelList = await request("/api/ai/v1/models");
if (
@@ -123,6 +130,42 @@ await expectStatus(
},
401,
);
await expectStatus(
"/api/ai/v1/chat/completions",
{
method: "POST",
headers: { authorization: "Bearer forged-workshop-user-token" },
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "forged identity" }],
}),
},
401,
);
const inFlightProbe = () =>
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...concurrencySession.headers },
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "in-flight probe" }],
stream: false,
}),
});
const inFlightResponses = await Promise.all([inFlightProbe(), inFlightProbe(), inFlightProbe()]);
if (
inFlightResponses
.map((response) => response.status)
.toSorted()
.join(",") !== "200,200,429"
) {
throw new Error("The per-user in-flight request limit was not enforced");
}
const concurrencyUsage = await request("/api/ai/usage", { headers: concurrencySession.headers });
if (concurrencyUsage.used !== 2) {
throw new Error(`Rejected in-flight requests must not consume allowance: ${JSON.stringify(concurrencyUsage)}`);
}
const initialUsage = await request("/api/ai/usage", { headers: authorSession.headers });
const initialReset = new Date(initialUsage.resetsAt);
@@ -163,7 +206,24 @@ await expectStatus(
headers: authorSession.headers,
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "x".repeat(4 * 256 * 1024 + 1) }],
messages: [{ role: "user", content: "x".repeat(256 * 1024 + 1) }],
}),
},
413,
);
await expectStatus(
"/api/ai/v1/chat/completions",
{
method: "POST",
headers: authorSession.headers,
body: JSON.stringify({
model: "homarr/model",
messages: [
{
role: "user",
content: [{ type: "image_url", image_url: { url: "https://attacker.example/tracker.png" } }],
},
],
}),
},
413,
@@ -210,6 +270,8 @@ if (
firstChunk.done ||
!streamText.includes("Hello from ") ||
!streamText.includes("the Homarr provider") ||
streamText.includes("mock/team-selected-model") ||
!streamText.includes('"model":"homarr/model"') ||
dataFrames.length < 3 ||
dataFrames.at(-1)?.trim() !== "data: [DONE]" ||
firstChunkAt - streamStartedAt > 5_000
@@ -218,7 +280,13 @@ if (
}
const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...authorSession.headers },
headers: {
"content-type": "application/json",
...authorSession.headers,
"http-referer": "https://attacker.example",
"x-openrouter-title": "Attacker",
"x-openrouter-metadata": "attacker",
},
body: JSON.stringify({
model: "homarr/model",
messages: [
@@ -258,6 +326,40 @@ const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
if (!toolResponse.ok || toolResponse.headers.get("x-homarr-quota-remaining") !== "48") {
throw new Error("Every upstream inference must consume one Homarr provider request unit");
}
const toolCompletion = await toolResponse.json();
if (
toolCompletion.model !== "homarr/model" ||
toolCompletion.choices?.[0]?.finish_reason !== "tool_calls" ||
toolCompletion.choices?.[0]?.message?.tool_calls?.[0]?.function?.name !== "board_list"
) {
throw new Error(
`The public model alias or function tool response was not forwarded: ${JSON.stringify(toolCompletion)}`,
);
}
const streamedToolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...concurrencySession.headers },
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "inspect my dashboard" }],
tools: [{ type: "function", function: { name: "board_list", parameters: { type: "object" } } }],
stream: true,
}),
});
const streamedToolText = await streamedToolResponse.text();
if (
!streamedToolResponse.ok ||
streamedToolText.includes("mock/team-selected-model") ||
!streamedToolText.includes('"model":"homarr/model"') ||
!streamedToolText.includes('"name":"board_list"') ||
!streamedToolText.includes('"arguments":"{"') ||
!streamedToolText.includes('"arguments":"}"') ||
!streamedToolText.includes('"finish_reason":"tool_calls"') ||
!streamedToolText.endsWith("data: [DONE]\n\n")
) {
throw new Error(`Streamed function tool calls were not preserved safely: ${streamedToolText}`);
}
const afterToolsUsage = await request("/api/ai/usage", { headers: authorSession.headers });
if (afterToolsUsage.used !== 2 || afterToolsUsage.remaining !== 48) {
@@ -316,7 +418,7 @@ const resetQuota = (
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ used: 48 }),
body: JSON.stringify({ used: 49 }),
});
const allowanceProbe = () =>
@@ -329,11 +431,11 @@ const allowanceProbe = () =>
stream: false,
}),
});
const concurrentAllowanceResponses = await Promise.all([allowanceProbe(), allowanceProbe(), allowanceProbe()]);
const concurrentAllowanceResponses = await Promise.all([allowanceProbe(), allowanceProbe()]);
const concurrentStatuses = concurrentAllowanceResponses.map((response) => response.status).toSorted();
const exhaustedResponse = concurrentAllowanceResponses.find((response) => response.status === 429);
if (
concurrentStatuses.join(",") !== "200,200,429" ||
concurrentStatuses.join(",") !== "200,429" ||
exhaustedResponse?.headers.get("x-homarr-quota-remaining") !== "0" ||
!exhaustedResponse.headers.get("x-homarr-quota-reset")
) {
+1
View File
@@ -49,6 +49,7 @@
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2",
"test:workshop": "sh apps/workshop/test.sh",
"test:workshop-live-provider": "node apps/workshop/tests/openrouter-live-smoke.mjs",
"test:workshop-image": "sh apps/workshop/test-image.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
"typecheck": "turbo typecheck",
+1 -1
View File
@@ -164,7 +164,7 @@ const getProviderHeaders = (configuration: AssistantConfiguration) => {
}
if (configuration.provider === "openrouter") {
headers["HTTP-Referer"] ??= "https://homarr.dev";
headers["X-Title"] ??= "Homarr Assistant";
headers["X-OpenRouter-Title"] ??= "Homarr AI Assistant";
}
return { ...headers, ...decryptCustomHeaders(configuration.encryptedHeaders) };
};