| 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 |