diff --git a/apps/docs/docs/workshop/operator.mdx b/apps/docs/docs/workshop/operator.mdx index da5f8b350..295aa9899 100644 --- a/apps/docs/docs/workshop/operator.mdx +++ b/apps/docs/docs/workshop/operator.mdx @@ -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= \ +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 diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index 399914eb2..e5c3d1b26 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -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", } : {}), diff --git a/apps/workshop/README.md b/apps/workshop/README.md index 714eef157..527021965 100644 --- a/apps/workshop/README.md +++ b/apps/workshop/README.md @@ -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= \ +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 diff --git a/apps/workshop/homarr_provider.go b/apps/workshop/homarr_provider.go index e53955cf2..0c79af62d 100644 --- a/apps/workshop/homarr_provider.go +++ b/apps/workshop/homarr_provider.go @@ -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 { diff --git a/apps/workshop/homarr_provider_test.go b/apps/workshop/homarr_provider_test.go index 8acb205f6..dcdb6df9a 100644 --- a/apps/workshop/homarr_provider_test.go +++ b/apps/workshop/homarr_provider_test.go @@ -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) { diff --git a/apps/workshop/tests/openrouter-live-smoke.mjs b/apps/workshop/tests/openrouter-live-smoke.mjs new file mode 100644 index 000000000..ee1fb35e7 --- /dev/null +++ b/apps/workshop/tests/openrouter-live-smoke.mjs @@ -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"); diff --git a/apps/workshop/tests/openrouter-mock.mjs b/apps/workshop/tests/openrouter-mock.mjs index 28b03c2e3..8cd496ae8 100644 --- a/apps/workshop/tests/openrouter-mock.mjs +++ b/apps/workshop/tests/openrouter-mock.mjs @@ -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, diff --git a/apps/workshop/tests/workshop.integration.mjs b/apps/workshop/tests/workshop.integration.mjs index 5d44789b9..0fd15c49e 100644 --- a/apps/workshop/tests/workshop.integration.mjs +++ b/apps/workshop/tests/workshop.integration.mjs @@ -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") ) { diff --git a/package.json b/package.json index 15b234120..927849b84 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index a5de3b026..2a7864397 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -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) }; };