From 2280bd1474f57c0b3eef5431cc5fbcc82ee04777 Mon Sep 17 00:00:00 2001 From: Paul Tran-Van Date: Wed, 29 Jul 2026 17:51:34 +0200 Subject: [PATCH] fix(ai): read the assistant model from the account login The account's encrypted credentials hold the (login, password) pair, and the stack only rebuilds them when a password is sent. Editing an assistant's model without retyping its API key therefore leaves them on the previous model, while the plain `login` is correctly updated. buildLLMOverride preferred the encrypted blob, so such an edit had no effect: the conversation kept running on the model the assistant was created with. Read the model from `login`, and use the blob only for the API key, keeping its login as a fallback for accounts written before the login was kept in clear. --- model/rag/chat.go | 39 +++++++++++++++++------- model/rag/chat_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/model/rag/chat.go b/model/rag/chat.go index 0bd2f387e0d..82376f05606 100644 --- a/model/rag/chat.go +++ b/model/rag/chat.go @@ -359,6 +359,34 @@ func assistantForChat(inst *instance.Instance, chat *ChatConversation) (*chatAss return &assistant, nil } +// llmCredentials extracts the LLM model and API key from a provider account. +// The account's "login" field stores the model name, e.g. +// "Mistral-Small-3.2-24B-Instruct-2506", while "password" stores the API key. +// +// The encrypted credentials hold the same (login, password) pair, but the stack +// only rebuilds them when a password is sent. Editing an assistant's model +// without retyping its API key therefore leaves them on the previous model, +// while the plain "login" is always up to date. So "login" wins, and the +// encrypted blob is only used for the API key, plus as a model fallback for +// accounts written before the login was kept in clear. +func llmCredentials(basic *account.BasicInfo) (model, apiKey string) { + if basic == nil { + return "", "" + } + model, apiKey = basic.Login, basic.Password + if basic.EncryptedCredentials == "" { + return model, apiKey + } + encryptedModel, encryptedAPIKey, err := account.DecryptCredentials(basic.EncryptedCredentials) + if err != nil { + return model, apiKey + } + if model == "" { + model = encryptedModel + } + return model, encryptedAPIKey +} + // buildLLMOverride returns the `metadata.llm_override` map forwarded to // OpenRAG when the conversation is bound to an assistant that uses an // external provider (OpenAI, Mistral, …). It returns nil to leave the @@ -378,16 +406,7 @@ func buildLLMOverride(inst *instance.Instance, assistant *chatAssistant) map[str if err := couchdb.GetDoc(inst, consts.Accounts, provider.ID, &acc); err != nil { return nil } - // The account's "login" field stores the LLM model name, e.g. "Mistral-Small-3.2-24B-Instruct-2506" - // While "password" stores the API key - var model, apiKey string - if acc.Basic != nil { - if acc.Basic.EncryptedCredentials != "" { - model, apiKey, _ = account.DecryptCredentials(acc.Basic.EncryptedCredentials) - } else { - model, apiKey = acc.Basic.Login, acc.Basic.Password - } - } + model, apiKey := llmCredentials(acc.Basic) override := map[string]interface{}{} if model != "" { override["model"] = model diff --git a/model/rag/chat_test.go b/model/rag/chat_test.go index 330c0648234..748cdb4ebca 100644 --- a/model/rag/chat_test.go +++ b/model/rag/chat_test.go @@ -4,10 +4,78 @@ import ( "strings" "testing" + "github.com/cozy/cozy-stack/model/account" + "github.com/cozy/cozy-stack/pkg/config/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestLLMCredentials(t *testing.T) { + config.UseTestFile(t) + + t.Run("no basic auth", func(t *testing.T) { + model, apiKey := llmCredentials(nil) + assert.Empty(t, model) + assert.Empty(t, apiKey) + }) + + t.Run("plain login and password", func(t *testing.T) { + model, apiKey := llmCredentials(&account.BasicInfo{ + Login: "gemini-2.5-flash", + Password: "s3cret", + }) + assert.Equal(t, "gemini-2.5-flash", model) + assert.Equal(t, "s3cret", apiKey) + }) + + t.Run("api key comes from the encrypted credentials", func(t *testing.T) { + encrypted, err := account.EncryptCredentials("gemini-2.5-flash", "s3cret") + require.NoError(t, err) + + model, apiKey := llmCredentials(&account.BasicInfo{ + Login: "gemini-2.5-flash", + EncryptedCredentials: encrypted, + }) + assert.Equal(t, "gemini-2.5-flash", model) + assert.Equal(t, "s3cret", apiKey) + }) + + t.Run("login wins over a stale encrypted model", func(t *testing.T) { + // Editing the model without retyping the API key updates the login but + // leaves the encrypted credentials on the previous model. + encrypted, err := account.EncryptCredentials("gemini-2.5-flash", "s3cret") + require.NoError(t, err) + + model, apiKey := llmCredentials(&account.BasicInfo{ + Login: "gemini-3-pro", + EncryptedCredentials: encrypted, + }) + assert.Equal(t, "gemini-3-pro", model) + assert.Equal(t, "s3cret", apiKey) + }) + + t.Run("encrypted model is used when no login is kept in clear", func(t *testing.T) { + encrypted, err := account.EncryptCredentials("gemini-2.5-flash", "s3cret") + require.NoError(t, err) + + model, apiKey := llmCredentials(&account.BasicInfo{ + EncryptedCredentials: encrypted, + }) + assert.Equal(t, "gemini-2.5-flash", model) + assert.Equal(t, "s3cret", apiKey) + }) + + t.Run("undecipherable credentials fall back to the plain fields", func(t *testing.T) { + model, apiKey := llmCredentials(&account.BasicInfo{ + Login: "gemini-3-pro", + Password: "s3cret", + EncryptedCredentials: "not-a-valid-blob", + }) + assert.Equal(t, "gemini-3-pro", model) + assert.Equal(t, "s3cret", apiKey) + }) +} + func TestForeachSSE(t *testing.T) { t.Run("normal events are passed to callback", func(t *testing.T) { input := `data: {"object":"chat.completion.chunk","content":"hello"}