mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): a second door onto one account reuses its key
The connection list groups on host and credential, so two entries only read as one account when they share a key slot. providerKeyEnv derives that slot from the entry's name, which meant adding the same endpoint under the other protocol produced DEEPSEEK_ANTHROPIC_API_KEY beside DEEPSEEK_API_KEY: two accounts, one of them with no key at all. The 接入方式 switch could never appear for anything the panel itself had added. A blank key at a host that already has one now inherits that slot. Blank is the signal because it is already the panel's word for "keep what is stored", and it distinguishes the two things a second entry at one host can be: another door onto the account, or a different account there — two tenants of one relay, where sharing the slot would overwrite the first tenant's key. A supplied key keeps its own slot for exactly that reason. The form says which one it is about to do, rather than leaving the user to infer it from whether a field is empty. Documentation-impact: none - no docs/*.md describes how api_key_env is chosen when a source is added from the panel.
This commit is contained in:
1 parent
baaea20023
commit
65afa0eecc
4 files changed
+155
-4
No files matched your search
@@ -111,6 +111,7 @@ export function Providers({ port, onChanged, protocol, onProtocol, activeKindFor
|
||||
<AddProvider
|
||||
port={port}
|
||||
taken={list.map((p) => p.name)}
|
||||
known={list}
|
||||
onDone={() => {
|
||||
setAdding(false);
|
||||
reload();
|
||||
@@ -339,9 +340,9 @@ function EditConn({
|
||||
}
|
||||
|
||||
function AddProvider({
|
||||
port, taken, onDone, onCancel,
|
||||
port, taken, known, onDone, onCancel,
|
||||
}: {
|
||||
port: Port; taken: string[]; onDone: () => void; onCancel: () => void;
|
||||
port: Port; taken: string[]; known: ProviderEntry[]; onDone: () => void; onCancel: () => void;
|
||||
}) {
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
@@ -355,6 +356,10 @@ function AddProvider({
|
||||
const [kind, setKind] = useState("");
|
||||
const [picked, setPicked] = useState<string[]>([]);
|
||||
|
||||
// A source already at this host changes what a blank key means: another door
|
||||
// onto that account rather than an account with no credential.
|
||||
const sibling = known.find((p) => hostOf(p.baseUrl) === hostOf(baseUrl) && baseUrl.trim() !== "");
|
||||
|
||||
const connect = async () => {
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
@@ -413,9 +418,17 @@ function AddProvider({
|
||||
/>
|
||||
</label>
|
||||
<label className="grow full">
|
||||
<span>API Key</span>
|
||||
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} spellCheck={false} />
|
||||
<span>API Key{sibling ? "(留空就用现有那个来源的 key)" : ""}</span>
|
||||
<input type="password" value={apiKey} placeholder={sibling ? "········" : ""}
|
||||
onChange={(e) => setApiKey(e.target.value)} spellCheck={false} />
|
||||
</label>
|
||||
{sibling && (
|
||||
<p className="acct-note">
|
||||
这个地址上已经有「{vendorLabel(hostOf(sibling.baseUrl))}」了。留空 key
|
||||
就是给它再开一扇门,两条会并成同一个来源、由「接入方式」切换;填了 key
|
||||
就是这台机器上的另一个账号,各算各的。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="acts">
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// provider_keyreuse.go — a second door onto an account already signed in.
|
||||
package serve
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// keyEnvForNewSource decides which credential slot a newly added source writes.
|
||||
// A blank key at a host that already has one means another door onto that
|
||||
// account, so the two share a slot — a second slot would leave the new entry
|
||||
// unauthenticated and the pair reading as two accounts. A supplied key is a
|
||||
// different account there, and keeps its own slot so it overwrites nothing.
|
||||
func keyEnvForNewSource(name, baseURL, apiKey string) string {
|
||||
own := providerKeyEnv(name)
|
||||
if strings.TrimSpace(apiKey) != "" {
|
||||
return own
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil || cfg == nil {
|
||||
return own
|
||||
}
|
||||
host := vendorOf(baseURL)
|
||||
if host == "" {
|
||||
return own
|
||||
}
|
||||
for i := range cfg.Providers {
|
||||
p := &cfg.Providers[i]
|
||||
if vendorOf(p.BaseURL) == host && strings.TrimSpace(p.APIKeyEnv) != "" {
|
||||
return p.APIKeyEnv
|
||||
}
|
||||
}
|
||||
return own
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func savedProvider(t *testing.T, name string) *config.ProviderEntry {
|
||||
t.Helper()
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, ok := cfg.Provider(name)
|
||||
if !ok {
|
||||
t.Fatalf("provider %q was not saved", name)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// Adding the same endpoint under the other protocol is another door onto one
|
||||
// account. Without a shared credential slot the second entry has no key, and
|
||||
// the pair reads as two accounts in a list that groups on host and key.
|
||||
func TestAddingASecondDoorReusesTheHostsKey(t *testing.T) {
|
||||
s := newProviderEditServer(t)
|
||||
s.AllowProviderEdit()
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
first := postProvider(t, srv.URL, "/providers", `{
|
||||
"name":"acme","kind":"openai","baseUrl":"https://api.acme.test/v1",
|
||||
"apiKey":"sk-acme","models":["m1"],"default":"m1"
|
||||
}`)
|
||||
first.Body.Close()
|
||||
if first.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first save = %d", first.StatusCode)
|
||||
}
|
||||
|
||||
// No key: the user is adding the other protocol for the account already here.
|
||||
second := postProvider(t, srv.URL, "/providers", `{
|
||||
"name":"acme-anthropic","kind":"anthropic","baseUrl":"https://api.acme.test/anthropic",
|
||||
"apiKey":"","models":["m1"],"default":"m1"
|
||||
}`)
|
||||
second.Body.Close()
|
||||
if second.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second save = %d", second.StatusCode)
|
||||
}
|
||||
|
||||
one, two := savedProvider(t, "acme"), savedProvider(t, "acme-anthropic")
|
||||
if one.APIKeyEnv != two.APIKeyEnv {
|
||||
t.Fatalf("key slots differ: %q vs %q — the doors would read as two accounts", one.APIKeyEnv, two.APIKeyEnv)
|
||||
}
|
||||
if two.APIKey() == "" {
|
||||
t.Fatal("the second door has no credential, so nothing it lists is selectable")
|
||||
}
|
||||
}
|
||||
|
||||
// A key of its own means a different account — two tenants of one relay. Reusing
|
||||
// the slot there would overwrite the first tenant's credential.
|
||||
func TestAddingASecondAccountAtOneHostKeepsItsOwnKey(t *testing.T) {
|
||||
s := newProviderEditServer(t)
|
||||
s.AllowProviderEdit()
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
postProvider(t, srv.URL, "/providers", `{
|
||||
"name":"relay","kind":"openai","baseUrl":"https://relay.test/v1",
|
||||
"apiKey":"sk-one","models":["m1"],"default":"m1"
|
||||
}`).Body.Close()
|
||||
postProvider(t, srv.URL, "/providers", `{
|
||||
"name":"relay-work","kind":"openai","baseUrl":"https://relay.test/v1",
|
||||
"apiKey":"sk-two","models":["m1"],"default":"m1"
|
||||
}`).Body.Close()
|
||||
|
||||
one, two := savedProvider(t, "relay"), savedProvider(t, "relay-work")
|
||||
if one.APIKeyEnv == two.APIKeyEnv {
|
||||
t.Fatalf("both tenants share slot %q, so saving the second overwrote the first", one.APIKeyEnv)
|
||||
}
|
||||
if one.APIKey() != "sk-one" || two.APIKey() != "sk-two" {
|
||||
t.Fatalf("credentials crossed: %q and %q", one.APIKey(), two.APIKey())
|
||||
}
|
||||
}
|
||||
|
||||
// A first source at an unseen host has nothing to inherit.
|
||||
func TestFirstSourceAtAHostGetsItsOwnKeySlot(t *testing.T) {
|
||||
s := newProviderEditServer(t)
|
||||
s.AllowProviderEdit()
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
postProvider(t, srv.URL, "/providers", `{
|
||||
"name":"fresh","kind":"openai","baseUrl":"https://fresh.test/v1",
|
||||
"apiKey":"sk-fresh","models":["m1"],"default":"m1"
|
||||
}`).Body.Close()
|
||||
|
||||
if got := savedProvider(t, "fresh").APIKeyEnv; got != "FRESH_API_KEY" {
|
||||
t.Fatalf("key slot = %q, want the one derived from its own name", got)
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,7 @@ func (s *Server) saveProvider(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
entry.APIKeyEnv = keyEnvForNewSource(entry.Name, entry.BaseURL, body.APIKey)
|
||||
if key := strings.TrimSpace(body.APIKey); key != "" {
|
||||
if _, err := config.SetCredential(entry.APIKeyEnv, key); err != nil {
|
||||
http.Error(w, fmt.Sprintf("save provider key: %v", err), http.StatusInternalServerError)
|
||||
|
||||
Reference in new issue
Block a user