|
8 | 8 | "strings" |
9 | 9 |
|
10 | 10 | "github.com/Gitlawb/zero/internal/credstore" |
| 11 | + "github.com/Gitlawb/zero/internal/providercatalog" |
11 | 12 | ) |
12 | 13 |
|
13 | 14 | // ProviderKeyStoreAt opens the encrypted credential store whose file backend lives |
@@ -168,6 +169,86 @@ func (profile ProviderProfile) HasConfiguredCredential() bool { |
168 | 169 | strings.TrimSpace(profile.AuthHeaderValue) != "" |
169 | 170 | } |
170 | 171 |
|
| 172 | +// MissingCredentialEnv reports the environment variable this profile is |
| 173 | +// expected to read an API key from, and whether that expectation is |
| 174 | +// currently unmet. It is the single source of truth for "does this saved |
| 175 | +// provider need a credential it doesn't have" — the CLI (`providers check`, |
| 176 | +// `usableSavedProviders`) and TUI (`/providers` status, provider wizard |
| 177 | +// summary) surfaces all delegate here so they never disagree about a |
| 178 | +// profile's status. |
| 179 | +// |
| 180 | +// For the catalog's two "bring your own endpoint" descriptors |
| 181 | +// (custom-openai-compatible, custom-anthropic-compatible), RequiresAuth is |
| 182 | +// just a wizard template default, not a fact about the user's actual |
| 183 | +// endpoint: a custom target may need no auth at all. So for those two only, |
| 184 | +// a credential is expected exclusively when this profile carries its own |
| 185 | +// explicit APIKeyEnv that differs from the catalog's guessed default — a |
| 186 | +// value matching that default is indistinguishable from the wizard's old |
| 187 | +// auto-stamp bug (issue #555) and is treated the same as unset, which also |
| 188 | +// self-heals profiles saved before that bug was fixed. |
| 189 | +func (profile ProviderProfile) MissingCredentialEnv() (string, bool) { |
| 190 | + if profile.HasConfiguredCredential() { |
| 191 | + return "", false |
| 192 | + } |
| 193 | + if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" { |
| 194 | + descriptor, err := providercatalog.Require(catalogID) |
| 195 | + if err != nil { |
| 196 | + return "", false |
| 197 | + } |
| 198 | + defaultEnvVar := firstNonEmptyTrimmed(descriptor.AuthEnvVars...) |
| 199 | + if descriptor.Custom { |
| 200 | + envVar := strings.TrimSpace(profile.APIKeyEnv) |
| 201 | + if envVar == "" || envVar == defaultEnvVar { |
| 202 | + return "", false |
| 203 | + } |
| 204 | + return envVar, true |
| 205 | + } |
| 206 | + if !descriptor.RequiresAuth { |
| 207 | + return "", false |
| 208 | + } |
| 209 | + if envVar := strings.TrimSpace(profile.APIKeyEnv); envVar != "" { |
| 210 | + return envVar, true |
| 211 | + } |
| 212 | + return defaultEnvVar, true |
| 213 | + } |
| 214 | + |
| 215 | + switch profile.normalizedProviderKind() { |
| 216 | + case ProviderKindOpenAI, ProviderKindOpenAICompatible: |
| 217 | + return firstNonEmptyTrimmed(profile.APIKeyEnv, "OPENAI_API_KEY"), true |
| 218 | + case ProviderKindAnthropic, ProviderKindAnthropicCompat: |
| 219 | + return firstNonEmptyTrimmed(profile.APIKeyEnv, "ANTHROPIC_API_KEY"), true |
| 220 | + case ProviderKindGoogle: |
| 221 | + return firstNonEmptyTrimmed(profile.APIKeyEnv, "GEMINI_API_KEY"), true |
| 222 | + default: |
| 223 | + if envVar := strings.TrimSpace(profile.APIKeyEnv); envVar != "" { |
| 224 | + return envVar, true |
| 225 | + } |
| 226 | + return "", false |
| 227 | + } |
| 228 | +} |
| 229 | + |
| 230 | +// normalizedProviderKind resolves this profile's kind, falling back to the |
| 231 | +// legacy Provider string field (lowercased) when ProviderKind is unset — |
| 232 | +// matching how the resolver (resolver.go) treats the two fields as synonyms. |
| 233 | +func (profile ProviderProfile) normalizedProviderKind() ProviderKind { |
| 234 | + if kind := strings.TrimSpace(string(profile.ProviderKind)); kind != "" { |
| 235 | + return ProviderKind(strings.ToLower(kind)) |
| 236 | + } |
| 237 | + if provider := strings.TrimSpace(profile.Provider); provider != "" { |
| 238 | + return ProviderKind(strings.ToLower(provider)) |
| 239 | + } |
| 240 | + return "" |
| 241 | +} |
| 242 | + |
| 243 | +func firstNonEmptyTrimmed(values ...string) string { |
| 244 | + for _, value := range values { |
| 245 | + if trimmed := strings.TrimSpace(value); trimmed != "" { |
| 246 | + return trimmed |
| 247 | + } |
| 248 | + } |
| 249 | + return "" |
| 250 | +} |
| 251 | + |
171 | 252 | // OAuthLoginCandidates returns the login names to try, in order, when resolving |
172 | 253 | // this profile's OAuth token — used by the runtime bearer resolver, the Codex |
173 | 254 | // account-header resolver, and the onboarding presence check so all three agree. |
|
0 commit comments