Skip to content

Commit 2280bd1

Browse files
committed
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.
1 parent f016e94 commit 2280bd1

2 files changed

Lines changed: 97 additions & 10 deletions

File tree

model/rag/chat.go

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,34 @@ func assistantForChat(inst *instance.Instance, chat *ChatConversation) (*chatAss
359359
return &assistant, nil
360360
}
361361

362+
// llmCredentials extracts the LLM model and API key from a provider account.
363+
// The account's "login" field stores the model name, e.g.
364+
// "Mistral-Small-3.2-24B-Instruct-2506", while "password" stores the API key.
365+
//
366+
// The encrypted credentials hold the same (login, password) pair, but the stack
367+
// only rebuilds them when a password is sent. Editing an assistant's model
368+
// without retyping its API key therefore leaves them on the previous model,
369+
// while the plain "login" is always up to date. So "login" wins, and the
370+
// encrypted blob is only used for the API key, plus as a model fallback for
371+
// accounts written before the login was kept in clear.
372+
func llmCredentials(basic *account.BasicInfo) (model, apiKey string) {
373+
if basic == nil {
374+
return "", ""
375+
}
376+
model, apiKey = basic.Login, basic.Password
377+
if basic.EncryptedCredentials == "" {
378+
return model, apiKey
379+
}
380+
encryptedModel, encryptedAPIKey, err := account.DecryptCredentials(basic.EncryptedCredentials)
381+
if err != nil {
382+
return model, apiKey
383+
}
384+
if model == "" {
385+
model = encryptedModel
386+
}
387+
return model, encryptedAPIKey
388+
}
389+
362390
// buildLLMOverride returns the `metadata.llm_override` map forwarded to
363391
// OpenRAG when the conversation is bound to an assistant that uses an
364392
// external provider (OpenAI, Mistral, …). It returns nil to leave the
@@ -378,16 +406,7 @@ func buildLLMOverride(inst *instance.Instance, assistant *chatAssistant) map[str
378406
if err := couchdb.GetDoc(inst, consts.Accounts, provider.ID, &acc); err != nil {
379407
return nil
380408
}
381-
// The account's "login" field stores the LLM model name, e.g. "Mistral-Small-3.2-24B-Instruct-2506"
382-
// While "password" stores the API key
383-
var model, apiKey string
384-
if acc.Basic != nil {
385-
if acc.Basic.EncryptedCredentials != "" {
386-
model, apiKey, _ = account.DecryptCredentials(acc.Basic.EncryptedCredentials)
387-
} else {
388-
model, apiKey = acc.Basic.Login, acc.Basic.Password
389-
}
390-
}
409+
model, apiKey := llmCredentials(acc.Basic)
391410
override := map[string]interface{}{}
392411
if model != "" {
393412
override["model"] = model

model/rag/chat_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,78 @@ import (
44
"strings"
55
"testing"
66

7+
"github.com/cozy/cozy-stack/model/account"
8+
"github.com/cozy/cozy-stack/pkg/config/config"
79
"github.com/stretchr/testify/assert"
810
"github.com/stretchr/testify/require"
911
)
1012

13+
func TestLLMCredentials(t *testing.T) {
14+
config.UseTestFile(t)
15+
16+
t.Run("no basic auth", func(t *testing.T) {
17+
model, apiKey := llmCredentials(nil)
18+
assert.Empty(t, model)
19+
assert.Empty(t, apiKey)
20+
})
21+
22+
t.Run("plain login and password", func(t *testing.T) {
23+
model, apiKey := llmCredentials(&account.BasicInfo{
24+
Login: "gemini-2.5-flash",
25+
Password: "s3cret",
26+
})
27+
assert.Equal(t, "gemini-2.5-flash", model)
28+
assert.Equal(t, "s3cret", apiKey)
29+
})
30+
31+
t.Run("api key comes from the encrypted credentials", func(t *testing.T) {
32+
encrypted, err := account.EncryptCredentials("gemini-2.5-flash", "s3cret")
33+
require.NoError(t, err)
34+
35+
model, apiKey := llmCredentials(&account.BasicInfo{
36+
Login: "gemini-2.5-flash",
37+
EncryptedCredentials: encrypted,
38+
})
39+
assert.Equal(t, "gemini-2.5-flash", model)
40+
assert.Equal(t, "s3cret", apiKey)
41+
})
42+
43+
t.Run("login wins over a stale encrypted model", func(t *testing.T) {
44+
// Editing the model without retyping the API key updates the login but
45+
// leaves the encrypted credentials on the previous model.
46+
encrypted, err := account.EncryptCredentials("gemini-2.5-flash", "s3cret")
47+
require.NoError(t, err)
48+
49+
model, apiKey := llmCredentials(&account.BasicInfo{
50+
Login: "gemini-3-pro",
51+
EncryptedCredentials: encrypted,
52+
})
53+
assert.Equal(t, "gemini-3-pro", model)
54+
assert.Equal(t, "s3cret", apiKey)
55+
})
56+
57+
t.Run("encrypted model is used when no login is kept in clear", func(t *testing.T) {
58+
encrypted, err := account.EncryptCredentials("gemini-2.5-flash", "s3cret")
59+
require.NoError(t, err)
60+
61+
model, apiKey := llmCredentials(&account.BasicInfo{
62+
EncryptedCredentials: encrypted,
63+
})
64+
assert.Equal(t, "gemini-2.5-flash", model)
65+
assert.Equal(t, "s3cret", apiKey)
66+
})
67+
68+
t.Run("undecipherable credentials fall back to the plain fields", func(t *testing.T) {
69+
model, apiKey := llmCredentials(&account.BasicInfo{
70+
Login: "gemini-3-pro",
71+
Password: "s3cret",
72+
EncryptedCredentials: "not-a-valid-blob",
73+
})
74+
assert.Equal(t, "gemini-3-pro", model)
75+
assert.Equal(t, "s3cret", apiKey)
76+
})
77+
}
78+
1179
func TestForeachSSE(t *testing.T) {
1280
t.Run("normal events are passed to callback", func(t *testing.T) {
1381
input := `data: {"object":"chat.completion.chunk","content":"hello"}

0 commit comments

Comments
 (0)