From 7a66de9f23822a38a6019c731126c020e6eb0031 Mon Sep 17 00:00:00 2001 From: Lambda Date: Fri, 31 Jul 2026 16:35:01 +0800 Subject: [PATCH 1/2] fix(selfhost): deliver MULTICA_LLM_* to the backend and log the layer state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat auto-titling and chat follow-up suggestions are gated on the server-side LLM layer, which reads MULTICA_LLM_API_KEY / MULTICA_LLM_BASE_URL through os.Getenv. The self-host compose file enumerates backend environment explicitly and has no env_file, and those three vars were not in the list — so setting them in .env did nothing, the container never saw them, and the layer was off on every compose deployment regardless of configuration. .env.example documents them, which made it look configurable. That was survivable while quick actions were generated by the daemon's own provider pass, which needed no server key. MUL-5573 moved generation server- side and removed the daemon path, so on those deployments the feature stopped appearing entirely rather than degrading. Adds the passthrough, and one startup line reporting whether the layer is enabled. Every consumer degrades silently by design, so without that line an unset key is indistinguishable from a broken feature — which is exactly how this surfaced. Helm already supports it through the operator-managed Secret; values.yaml now says so instead of leaving it undocumented. Co-authored-by: multica-agent --- SELF_HOSTING_AI.md | 23 +++++++++++++++++++++++ deploy/helm/multica/values.yaml | 7 +++++++ docker-compose.selfhost.yml | 8 ++++++++ server/internal/handler/handler.go | 11 +++++++++++ 4 files changed, 49 insertions(+) diff --git a/SELF_HOSTING_AI.md b/SELF_HOSTING_AI.md index 4c1bfad8f3b..e1158569ffc 100644 --- a/SELF_HOSTING_AI.md +++ b/SELF_HOSTING_AI.md @@ -80,6 +80,29 @@ invoked directly is the reverse again — there the environment outranks `.env`. startup output is read back from Docker Compose, so the address it prints is the one the stack is actually published on. +## Optional: server-side LLM features + +Chat auto-titling and chat follow-up suggestions ("quick actions") are produced +by a small server-side model call, separate from the agent runtimes. They are +off until the backend has an upstream configured: + +```bash +# In .env — either the key or the base URL is enough to enable the layer. +MULTICA_LLM_API_KEY=sk-... +MULTICA_LLM_BASE_URL= # optional: any OpenAI-compatible gateway +MULTICA_LLM_DEFAULT_MODEL= # optional: defaults to a small built-in model +``` + +Both features degrade silently when this is unset — titles stay as the first +message and no follow-up suggestions appear — so confirm the state from the +backend's startup log rather than from the UI: + +```bash +docker compose -f docker-compose.selfhost.yml logs backend | grep "llm layer" +# "llm layer enabled" -> configured +# "llm layer disabled" -> unset; the two features above will not appear +``` + ## Troubleshooting - **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend` diff --git a/deploy/helm/multica/values.yaml b/deploy/helm/multica/values.yaml index a593b41e86d..c5713bd3632 100644 --- a/deploy/helm/multica/values.yaml +++ b/deploy/helm/multica/values.yaml @@ -43,6 +43,13 @@ images: # The chart references this Secret by name; it does not template it, so real # values never need to land in git. # ----------------------------------------------------------------------------- +# Name of a pre-created Secret mounted onto the backend with envFrom, so any +# key in it becomes an environment variable. Server-side LLM features (chat +# auto-titling, chat follow-up suggestions) are configured here rather than in +# this file, because they are credentials: add MULTICA_LLM_API_KEY (and +# optionally MULTICA_LLM_BASE_URL / MULTICA_LLM_DEFAULT_MODEL) to this Secret. +# Without them the backend logs "llm layer disabled" at startup and both +# features stay silently off. existingSecret: multica-secrets # ----------------------------------------------------------------------------- diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 53306e30a00..46ab9751e65 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -102,6 +102,14 @@ services: DISABLE_WORKSPACE_CREATION: ${DISABLE_WORKSPACE_CREATION:-} GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-} GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-} + # Server-internal LLM layer (MUL-4238). Backs chat auto-titling and chat + # follow-up suggestions. Setting either the key or the base URL enables + # it; leaving both empty disables those features silently. Without these + # three lines the vars in .env never reach the container, so the features + # cannot be turned on at all no matter what the operator configures. + MULTICA_LLM_API_KEY: ${MULTICA_LLM_API_KEY:-} + MULTICA_LLM_BASE_URL: ${MULTICA_LLM_BASE_URL:-} + MULTICA_LLM_DEFAULT_MODEL: ${MULTICA_LLM_DEFAULT_MODEL:-} # Public URL the API is reachable at from the open internet, no # trailing slash. Used to mint absolute webhook URLs for autopilot # webhook triggers. Leave unset behind a same-origin reverse proxy diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index ca5a27e141a..a1d7299e690 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -319,6 +319,17 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event DefaultModel: cfg.LLMDefaultModel, }) + // One boot-time line so an operator can tell configured-and-working apart + // from silently-off. Every consumer of this layer degrades quietly by + // design (auto-titling keeps the original title, chat follow-up suggestions + // simply never appear), which without this log makes an unset key + // indistinguishable from a broken feature. + if llmClient.Enabled() { + slog.Info("llm layer enabled", "default_model", llmClient.DefaultModel()) + } else { + slog.Warn("llm layer disabled: set MULTICA_LLM_API_KEY or MULTICA_LLM_BASE_URL to enable chat auto-titling and chat follow-up suggestions") + } + taskSvc := service.NewTaskService(queries, txStarter, hub, bus, daemonHub) taskSvc.Analytics = analyticsClient // Chat follow-up suggestions run through the same internal LLM layer that From 56d766b180210b4fae145d6b3d69d6f8b57f9459 Mon Sep 17 00:00:00 2001 From: Lambda Date: Fri, 31 Jul 2026 17:00:16 +0800 Subject: [PATCH 2/2] test(chat): cover the automatic quick-actions pass end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only existing coverage called the generator directly. Everything between the completion callback and the pills landing on the row — the eligibility decision, the chat:done flag, the detached dispatch — went untested, which is exactly the stretch you have to rule out when the feature 'just does not appear'. Co-authored-by: multica-agent --- .../handler/chat_input_ownership_test.go | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/server/internal/handler/chat_input_ownership_test.go b/server/internal/handler/chat_input_ownership_test.go index 2f4b4bec1d5..38713d64ef4 100644 --- a/server/internal/handler/chat_input_ownership_test.go +++ b/server/internal/handler/chat_input_ownership_test.go @@ -6,6 +6,7 @@ import ( "errors" "strings" "testing" + "time" "github.com/multica-ai/multica/server/internal/service" db "github.com/multica-ai/multica/server/pkg/db/generated" @@ -674,6 +675,48 @@ func TestChatQuickActions_ContextAnchorsOnTargetTurn(t *testing.T) { } } +// TestChatQuickActions_AutomaticPassEndToEnd exercises the path a real chat +// turn takes — CompleteTask decides eligibility, broadcasts, and starts the +// pass — rather than calling the generator directly. Everything between the +// completion callback and the pills landing on the row is only covered here. +func TestChatQuickActions_AutomaticPassEndToEnd(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + ctx := context.Background() + agentID, sessionID, _, _ := setupDirectChatSession(t, ctx, "quick-actions auto e2e") + + taskID := sendDirectChat(t, ctx, agentID, sessionID, "what should I do next?") + markTaskRunning(t, ctx, taskID) + + restore := installStubQuickActions() + defer restore() + + if _, err := testHandler.TaskService.CompleteTask( + ctx, parseUUID(taskID), completeResult(t, "Here is the plan."), "", "", false, ""); err != nil { + t.Fatalf("complete task: %v", err) + } + + // The pass runs on a detached goroutine; poll for its write. + var actions []protocol.ChatQuickAction + for i := 0; i < 100; i++ { + rows := assistantRows(t, ctx, sessionID) + if len(rows) == 1 { + actions = nil + if err := json.Unmarshal(rows[0].QuickActions, &actions); err != nil { + t.Fatalf("decode quick actions: %v", err) + } + if len(actions) > 0 { + break + } + } + time.Sleep(20 * time.Millisecond) + } + if len(actions) != 1 || actions[0].Label != "Next" { + t.Fatalf("automatic pass must attach suggestions to the completed turn, got %+v", actions) + } +} + // installStubQuickActions enables suggestion generation and returns the undo. // Scoped tightly around the synchronous RegenerateChatQuickActions calls rather // than installed for a whole test: CompleteTask starts a background pass when