feat: complete the Homarr provider endpoint
This commit is contained in:
1 parent
019bd11017
commit
83968be07e
10 files changed
+457
-18
No files matched your search
@@ -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",
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) };
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user