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:
esengine committed 2026-08-14 21:50:47 -07:00
1 parent baaea20023
commit 65afa0eecc
4 files changed
+155 -4

No files matched your search

+17 -4
View File
@@ -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">
+35
View File
@@ -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
}
+102
View File
@@ -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)
}
}
+1
View File
@@ -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)