返回 DeepSeek-Reasonix
authentication.go
根目录 / internal / control / authentication.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "sync"
9
10 "reasonix/internal/provider"
11 )
12
13 // AuthenticationStatus describes whether this controller generation may start
14 // a model request. Ready only means local authentication preconditions pass.
15 type AuthenticationStatus string
16
17 const (
18 AuthenticationReady AuthenticationStatus = "ready"
19 AuthenticationMissingCredential AuthenticationStatus = "missing_credential"
20 AuthenticationRejected AuthenticationStatus = "authentication_rejected"
21 AuthenticationCredentialStoreUnavailable AuthenticationStatus = "credential_store_unavailable"
22 )
23
24 // AuthenticationState is safe to expose to frontends. It never contains
25 // credential material.
26 type AuthenticationState struct {
27 Status AuthenticationStatus `json:"status"`
28 ProviderName string `json:"providerName,omitempty"`
29 ModelRef string `json:"modelRef,omitempty"`
30 KeyEnv string `json:"keyEnv,omitempty"`
31 HTTPStatus int `json:"httpStatus,omitempty"`
32 Code string `json:"code,omitempty"`
33 Message string `json:"message,omitempty"`
34 }
35
36 func (s AuthenticationState) normalized() AuthenticationState {
37 if s.Status == "" {
38 s.Status = AuthenticationReady
39 }
40 if s.Code == "" {
41 switch s.Status {
42 case AuthenticationMissingCredential:
43 s.Code = "missing_credential"
44 case AuthenticationRejected:
45 s.Code = "authentication_rejected"
46 case AuthenticationCredentialStoreUnavailable:
47 s.Code = "credential_store_unavailable"
48 }
49 }
50 return s
51 }
52
53 func (s AuthenticationState) Ready() bool { return s.normalized().Status == AuthenticationReady }
54
55 // AuthenticationError is returned before admission, so blocked input does not
56 // create a turn, run submission hooks, or persist a user message.
57 type AuthenticationError struct{ State AuthenticationState }
58
59 func (e *AuthenticationError) Error() string {
60 s := e.State.normalized()
61 if strings.TrimSpace(s.Message) != "" {
62 return s.Message
63 }
64 label := strings.TrimSpace(s.ProviderName)
65 if label == "" {
66 label = strings.TrimSpace(s.ModelRef)
67 }
68 if label == "" {
69 label = "the selected model connection"
70 }
71 switch s.Status {
72 case AuthenticationMissingCredential:
73 return fmt.Sprintf("%s is missing its API key; use /setup here, or exit and run `reasonix setup` in your shell", label)
74 case AuthenticationRejected:
75 return fmt.Sprintf("%s rejected the configured credential; update it with /setup, choose another model, or retry explicitly", label)
76 case AuthenticationCredentialStoreUnavailable:
77 return fmt.Sprintf("credentials for %s could not be read; open credential diagnostics before retrying", label)
78 default:
79 return "model authentication is not ready"
80 }
81 }
82
83 type authenticationGate struct {
84 mu sync.RWMutex
85 state AuthenticationState
86 primary string
87 rejections map[string]AuthenticationState
88 initialForModel func(string) AuthenticationState
89 }
90
91 func newAuthenticationGate(initial AuthenticationState, primary string) authenticationGate {
92 return authenticationGate{state: initial.normalized(), primary: primary, rejections: map[string]AuthenticationState{}}
93 }
94
95 func (g *authenticationGate) snapshot() AuthenticationState {
96 g.mu.RLock()
97 defer g.mu.RUnlock()
98 return g.state.normalized()
99 }
100
101 func (g *authenticationGate) admissionError() error {
102 state := g.snapshot()
103 if state.Ready() {
104 return nil
105 }
106 return &AuthenticationError{State: state}
107 }
108
109 func (g *authenticationGate) admissionErrorForModel(ref string) error {
110 g.mu.RLock()
111 defer g.mu.RUnlock()
112 if !g.state.Ready() && (ref == g.primary || (g.state.Status != AuthenticationRejected && sameAuthenticationConnection(ref, g.primary))) {
113 return &AuthenticationError{State: g.state}
114 }
115 if g.initialForModel != nil {
116 if state := g.initialForModel(ref); !state.Ready() {
117 return &AuthenticationError{State: state}
118 }
119 }
120 for failedRef, state := range g.rejections {
121 if ref == failedRef || (state.HTTPStatus == 401 && sameAuthenticationConnection(ref, failedRef)) {
122 return &AuthenticationError{State: state}
123 }
124 }
125 return nil
126 }
127
128 func sameAuthenticationConnection(a, b string) bool {
129 left, _, leftOK := strings.Cut(a, "/")
130 right, _, rightOK := strings.Cut(b, "/")
131 return leftOK && rightOK && left == right
132 }
133
134 func (g *authenticationGate) recordFailure(err error, modelRef string) {
135 var authErr *provider.AuthError
136 if !errors.As(err, &authErr) || authErr == nil {
137 return
138 }
139 if authErr.ModelRef != "" {
140 modelRef = authErr.ModelRef
141 } else if name, _, _ := strings.Cut(modelRef, "/"); authErr.Provider != "" && authErr.Provider != name {
142 // Legacy/custom runners may return an unscoped error from a child.
143 // Never attribute that child's failure to the parent's selected model.
144 modelRef = authErr.Provider + "/"
145 }
146 g.mu.Lock()
147 defer g.mu.Unlock()
148 state := AuthenticationState{Status: AuthenticationRejected, ProviderName: authErr.Provider, ModelRef: modelRef, KeyEnv: authErr.KeyEnv, HTTPStatus: authErr.Status, Code: "authentication_rejected"}
149 g.rejections[modelRef] = state
150 // Optional operations can target another model. A model-scoped 403 there
151 // must not disable the primary chat connection.
152 if modelRef == g.primary || (authErr.Status == 401 && sameAuthenticationConnection(modelRef, g.primary)) {
153 g.state = state
154 }
155 }
156
157 func (g *authenticationGate) BeforeModelRequest(ref string) error {
158 return g.admissionErrorForModel(ref)
159 }
160 func (g *authenticationGate) ModelRequestFailed(ref string, err error) { g.recordFailure(err, ref) }
161 func (c *Controller) withAuthentication(ctx context.Context) context.Context {
162 return provider.WithRequestGate(ctx, &c.authentication)
163 }
164
165 func (c *Controller) AuthenticationState() AuthenticationState {
166 if c == nil {
167 return AuthenticationState{Status: AuthenticationReady}
168 }
169 return c.authentication.snapshot()
170 }
171
172 // RetryAuthentication permits one explicit attempt. Another 401/403 closes
173 // the gate when that turn completes.
174 func (c *Controller) RetryAuthentication() bool {
175 if c == nil {
176 return false
177 }
178 c.authentication.mu.Lock()
179 defer c.authentication.mu.Unlock()
180 if c.authentication.state.normalized().Status != AuthenticationRejected {
181 return false
182 }
183 for ref, state := range c.authentication.rejections {
184 if ref == c.authentication.state.ModelRef || (state.HTTPStatus == 401 && sameAuthenticationConnection(ref, c.authentication.state.ModelRef)) {
185 delete(c.authentication.rejections, ref)
186 }
187 }
188 c.authentication.state.Status = AuthenticationReady
189 c.authentication.state.Code = ""
190 c.authentication.state.Message = ""
191 return true
192 }
193
193 lines GO