github.com/anthropics/anthropic-sdk-go@v1.70.1/config/config.go (about)

     1  package config
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"log"
     7  	"os"
     8  	"path/filepath"
     9  	"regexp"
    10  	"runtime"
    11  	"strings"
    12  	"sync"
    13  )
    14  
    15  // ConfigFileVersion is the version written to configs/<profile>.json. Absent
    16  // on read implies "1.0". Two-part major.minor: bump major on backwards-
    17  // incompatible shape changes (readers can reject), minor on additive
    18  // changes that older readers can tolerate.
    19  const ConfigFileVersion = "1.0"
    20  
    21  // profileNamePattern restricts config profile names to characters that can
    22  // never escape the configs/ directory (no path separators, no "..") and
    23  // keeps the name shell-safe.
    24  var profileNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
    25  
    26  func validateProfileName(name string) error {
    27  	if name == "" {
    28  		return fmt.Errorf("profile name is empty")
    29  	}
    30  	if name == "." || name == ".." {
    31  		return fmt.Errorf("profile name %q is not allowed", name)
    32  	}
    33  	if strings.ContainsAny(name, `/\`) {
    34  		return fmt.Errorf("profile name %q must not contain path separators", name)
    35  	}
    36  	if !profileNamePattern.MatchString(name) {
    37  		return fmt.Errorf("profile name %q contains disallowed characters (allowed: letters, digits, '_', '.', '-')", name)
    38  	}
    39  	return nil
    40  }
    41  
    42  // Config holds the raw configuration for authenticating with the Anthropic
    43  // API. It mirrors the data stored in config files (configs/<profile>.json)
    44  // and can be constructed manually or loaded from disk with [LoadConfig].
    45  //
    46  // Authentication-mode-specific fields live inside [AuthenticationInfo], a
    47  // tagged union discriminated on [AuthenticationInfo.Type]. Top-level fields
    48  // apply to every profile regardless of authentication mode.
    49  type Config struct {
    50  	// Version is the file-format version. Set to [ConfigFileVersion] by
    51  	// [SaveProfile] on every write; absent on disk implies "1.0".
    52  	Version string `json:"version,omitempty"`
    53  
    54  	// AuthenticationInfo describes how this profile authenticates. Required.
    55  	AuthenticationInfo *AuthenticationInfo `json:"authentication"`
    56  
    57  	// BaseURL overrides the default API base URL. Optional.
    58  	BaseURL string `json:"base_url,omitempty"`
    59  
    60  	// OrganizationID is the Anthropic organization the profile targets.
    61  	OrganizationID string `json:"organization_id,omitempty"`
    62  
    63  	// WorkspaceID scopes requests to a specific workspace. For non-federation
    64  	// profiles it is sent as the anthropic-workspace-id request header; for
    65  	// oidc_federation profiles it is sent as workspace_id in the jwt-bearer
    66  	// exchange body instead (the minted token is already workspace-scoped).
    67  	WorkspaceID string `json:"workspace_id,omitempty"`
    68  }
    69  
    70  // AuthenticationType is the discriminator for [AuthenticationInfo].
    71  type AuthenticationType string
    72  
    73  const (
    74  	// AuthenticationTypeOIDCFederation exchanges a third-party OIDC JWT
    75  	// ("identity token") for a short-lived Anthropic access token via the
    76  	// jwt-bearer grant on /v1/oauth/token.
    77  	AuthenticationTypeOIDCFederation AuthenticationType = "oidc_federation"
    78  
    79  	// AuthenticationTypeUserOAuth authenticates with an access token minted
    80  	// through a user-interactive OAuth flow (e.g. `ant auth login`), with
    81  	// optional refresh-token rotation when a ClientID is configured.
    82  	AuthenticationTypeUserOAuth AuthenticationType = "user_oauth"
    83  )
    84  
    85  // AuthenticationInfo is a tagged union discriminated on [AuthenticationInfo.Type].
    86  // On the wire it is flat: the top-level JSON object holds `type`, the shared
    87  // `credentials_path`, and the variant-specific fields all at the same level.
    88  // In Go, the variant-specific fields are grouped into strongly-typed sub-structs
    89  // ([OIDCFederation], [UserOAuth]) so callers get type safety and a clean
    90  // non-nil check per variant. Exactly one sub-struct pointer is populated, and
    91  // it must match [AuthenticationInfo.Type].
    92  //
    93  // Because the wire shape is flat, all variant field names share a single
    94  // namespace at the JSON layer — new variants must pick field names that do
    95  // not collide with shared fields or with each other.
    96  //
    97  // [AuthenticationInfo.UnmarshalJSON] silently ignores unknown fields at the
    98  // JSON layer (per the credentials-file-format spec: "SDKs MUST silently
    99  // ignore unrecognized top-level keys in both config and credentials files"
   100  // — the same tolerance rule applies to the nested authentication object).
   101  // Unknown authentication types still fail loud because the SDK has no way
   102  // to meaningfully resolve credentials for an unknown variant.
   103  type AuthenticationInfo struct {
   104  	Type AuthenticationType `json:"-"`
   105  
   106  	// CredentialsPath is the path to the credentials JSON file that stores
   107  	// access / refresh tokens on disk. Leave empty to use the default path
   108  	// (credentials/<profile>.json under the config directory). Shared across
   109  	// all authentication types.
   110  	CredentialsPath string `json:"-"`
   111  
   112  	// OIDCFederation holds the fields for Type == AuthenticationTypeOIDCFederation.
   113  	// Populated by UnmarshalJSON and inlined by MarshalJSON; never appears as
   114  	// a nested JSON object.
   115  	OIDCFederation *OIDCFederation `json:"-"`
   116  
   117  	// UserOAuth holds the fields for Type == AuthenticationTypeUserOAuth.
   118  	// Populated by UnmarshalJSON and inlined by MarshalJSON; never appears as
   119  	// a nested JSON object.
   120  	UserOAuth *UserOAuth `json:"-"`
   121  }
   122  
   123  // OIDCFederation configures a profile that authenticates by exchanging a
   124  // third-party OIDC identity token for an Anthropic access token. Its fields
   125  // are inlined into the parent [AuthenticationInfo] on the wire.
   126  type OIDCFederation struct {
   127  	// FederationRuleID is the tagged ID ("fdrl_...") of the OidcFederationRule
   128  	// that governs the exchange. Required.
   129  	FederationRuleID string `json:"federation_rule_id"`
   130  
   131  	// ServiceAccountID is an optional expected-target check for federation
   132  	// rules with target_type=SERVICE_ACCOUNT. Must be a "svac_..." tagged ID.
   133  	// Omit for target_type=USER rules, where the principal is derived from
   134  	// the JWT claims.
   135  	ServiceAccountID string `json:"service_account_id,omitempty"`
   136  
   137  	// IdentityToken describes how to obtain the OIDC assertion this profile
   138  	// presents at token-exchange time.
   139  	IdentityToken *IdentityTokenConfig `json:"identity_token,omitempty"`
   140  
   141  	// Scope is the OAuth scope string (RFC 6749 §3.3 space-delimited form)
   142  	// the profile expects to be granted. It is stored on the profile for
   143  	// display and configuration purposes only — the SDK does NOT send it
   144  	// on the jwt-bearer exchange. The granted scope is determined by the
   145  	// federation rule on the server; IssueOAuthTokenRequest has no scope
   146  	// field, and the REST gateway's alias transformation strips unknown
   147  	// keys, so any attempt to wire this through to the exchange body
   148  	// would be silently dropped. The granted scope appears on the token
   149  	// response's `scope` field, but the SDK does not currently surface
   150  	// it to callers.
   151  	Scope string `json:"scope,omitempty"`
   152  }
   153  
   154  // UserOAuth configures a profile authenticated via a user-interactive OAuth
   155  // flow. The access and (optional) refresh tokens live on disk under the
   156  // profile's [AuthenticationInfo.CredentialsPath]. Its fields are inlined
   157  // into the parent [AuthenticationInfo] on the wire.
   158  type UserOAuth struct {
   159  	// ClientID is the OAuth client ID used for refresh-token exchange. When
   160  	// empty, the access token is treated as static (no refresh) and the
   161  	// profile fails once it expires.
   162  	ClientID string `json:"client_id,omitempty"`
   163  
   164  	// Scope is the OAuth scope string (RFC 6749 §3.3 space-delimited form)
   165  	// the profile was granted, captured at login time. The SDK does not
   166  	// consult this on refresh — the oauth-server preserves the original
   167  	// scope set when the refresh request omits scope — but `ant auth status`
   168  	// and similar tools display it.
   169  	Scope string `json:"scope,omitempty"`
   170  
   171  	// ConsoleURL is the base URL of the OAuth /authorize page for
   172  	// interactive login. The SDK does not consult it; it's CLI-only state
   173  	// (the `ant auth login` target) that lives on the shared schema so
   174  	// SaveProfile/LoadProfile round-trip it without triggering the
   175  	// unknown-field warning.
   176  	ConsoleURL string `json:"console_url,omitempty"`
   177  }
   178  
   179  // IdentityTokenSource is the source kind for an OIDC identity token.
   180  type IdentityTokenSource string
   181  
   182  const (
   183  	// IdentityTokenSourceFile reads the token from a file on every exchange,
   184  	// which supports rotated tokens (e.g. Kubernetes projected service
   185  	// account tokens).
   186  	IdentityTokenSourceFile IdentityTokenSource = "file"
   187  )
   188  
   189  // IdentityTokenConfig specifies how to obtain an OIDC identity token for
   190  // federation exchange.
   191  type IdentityTokenConfig struct {
   192  	Source IdentityTokenSource `json:"source"`
   193  	Path   string              `json:"path,omitempty"`
   194  }
   195  
   196  // NewUserOAuthAuthentication returns a populated [AuthenticationInfo] for
   197  // the user_oauth variant. Pass an empty clientID to describe a profile whose
   198  // access token is static (no refresh).
   199  func NewUserOAuthAuthentication(clientID string) *AuthenticationInfo {
   200  	return &AuthenticationInfo{
   201  		Type:      AuthenticationTypeUserOAuth,
   202  		UserOAuth: &UserOAuth{ClientID: clientID},
   203  	}
   204  }
   205  
   206  // NewOIDCFederationAuthentication builds an [AuthenticationInfo] for the
   207  // oidc_federation variant.
   208  func NewOIDCFederationAuthentication(oidc OIDCFederation) *AuthenticationInfo {
   209  	return &AuthenticationInfo{
   210  		Type:           AuthenticationTypeOIDCFederation,
   211  		OIDCFederation: &oidc,
   212  	}
   213  }
   214  
   215  // sharedAuthFields are the fields present on every authentication variant.
   216  // The discriminator read uses a tolerant decoder (unknown fields allowed)
   217  // because the variant decode in the second pass is what rejects typos.
   218  type sharedAuthFields struct {
   219  	Type            AuthenticationType `json:"type"`
   220  	CredentialsPath string             `json:"credentials_path,omitempty"`
   221  }
   222  
   223  // oidcFederationWire is the flat wire shape for oidc_federation: shared
   224  // fields + inlined OIDCFederation fields. Unknown fields are tolerated
   225  // per the credentials-file-format spec so forward-compatible additions
   226  // and cross-SDK-written files don't fail loud.
   227  type oidcFederationWire struct {
   228  	sharedAuthFields
   229  	FederationRuleID string               `json:"federation_rule_id"`
   230  	ServiceAccountID string               `json:"service_account_id,omitempty"`
   231  	IdentityToken    *IdentityTokenConfig `json:"identity_token,omitempty"`
   232  	Scope            string               `json:"scope,omitempty"`
   233  }
   234  
   235  // userOAuthWire is the flat wire shape for user_oauth: shared fields +
   236  // inlined UserOAuth fields.
   237  type userOAuthWire struct {
   238  	sharedAuthFields
   239  	ClientID   string `json:"client_id,omitempty"`
   240  	Scope      string `json:"scope,omitempty"`
   241  	ConsoleURL string `json:"console_url,omitempty"`
   242  }
   243  
   244  // UnmarshalJSON decodes the flat tagged-union wire shape into the nested
   245  // Go representation. Unknown fields are silently tolerated per the
   246  // credentials-file-format spec but are also logged (warn-once) so a
   247  // user who typo'd a field name at least sees that it was ignored.
   248  // Unknown authentication types still fail loud.
   249  func (a *AuthenticationInfo) UnmarshalJSON(data []byte) error {
   250  	var raw map[string]json.RawMessage
   251  	if err := json.Unmarshal(data, &raw); err != nil {
   252  		return err
   253  	}
   254  	var authType AuthenticationType
   255  	if b, ok := raw["type"]; ok {
   256  		if err := json.Unmarshal(b, &authType); err != nil {
   257  			return err
   258  		}
   259  	}
   260  
   261  	switch authType {
   262  	case "":
   263  		return fmt.Errorf("authentication.type is required")
   264  	case AuthenticationTypeOIDCFederation:
   265  		var w oidcFederationWire
   266  		if err := json.Unmarshal(data, &w); err != nil {
   267  			return fmt.Errorf("authentication.type %q: %w", authType, err)
   268  		}
   269  		unknown := unknownRawKeys(raw, knownOIDCFederationFields)
   270  		*a = AuthenticationInfo{
   271  			Type:            w.Type,
   272  			CredentialsPath: w.CredentialsPath,
   273  			OIDCFederation: &OIDCFederation{
   274  				FederationRuleID: w.FederationRuleID,
   275  				ServiceAccountID: w.ServiceAccountID,
   276  				IdentityToken:    w.IdentityToken,
   277  				Scope:            w.Scope,
   278  			},
   279  		}
   280  		warnUnknownAuthFields(string(authType), unknown)
   281  		return nil
   282  	case AuthenticationTypeUserOAuth:
   283  		var w userOAuthWire
   284  		if err := json.Unmarshal(data, &w); err != nil {
   285  			return fmt.Errorf("authentication.type %q: %w", authType, err)
   286  		}
   287  		unknown := unknownRawKeys(raw, knownUserOAuthFields)
   288  		*a = AuthenticationInfo{
   289  			Type:            w.Type,
   290  			CredentialsPath: w.CredentialsPath,
   291  			UserOAuth: &UserOAuth{
   292  				ClientID:   w.ClientID,
   293  				Scope:      w.Scope,
   294  				ConsoleURL: w.ConsoleURL,
   295  			},
   296  		}
   297  		warnUnknownAuthFields(string(authType), unknown)
   298  		return nil
   299  	default:
   300  		return fmt.Errorf("authentication.type %q is not a known authentication type", authType)
   301  	}
   302  }
   303  
   304  func unknownRawKeys(raw map[string]json.RawMessage, known map[string]struct{}) []string {
   305  	var unknown []string
   306  	for k := range raw {
   307  		if _, ok := known[k]; ok {
   308  			continue
   309  		}
   310  		unknown = append(unknown, k)
   311  	}
   312  	return unknown
   313  }
   314  
   315  // Keep in sync with [oidcFederationWire] and [userOAuthWire].
   316  var (
   317  	knownOIDCFederationFields = map[string]struct{}{
   318  		"type":               {},
   319  		"credentials_path":   {},
   320  		"federation_rule_id": {},
   321  		"service_account_id": {},
   322  		"identity_token":     {},
   323  		"scope":              {},
   324  	}
   325  	knownUserOAuthFields = map[string]struct{}{
   326  		"type":             {},
   327  		"credentials_path": {},
   328  		"client_id":        {},
   329  		"scope":            {},
   330  		"console_url":      {},
   331  	}
   332  )
   333  
   334  var (
   335  	configWarnOnceMu  sync.Mutex
   336  	configWarnOnceSet = map[string]bool{}
   337  )
   338  
   339  func configWarnOnce(key, format string, args ...any) {
   340  	configWarnOnceMu.Lock()
   341  	already := configWarnOnceSet[key]
   342  	configWarnOnceSet[key] = true
   343  	configWarnOnceMu.Unlock()
   344  	if already {
   345  		return
   346  	}
   347  	log.Printf("anthropic-sdk-go/config: "+format, args...)
   348  }
   349  
   350  func warnUnknownAuthFields(authType string, fields []string) {
   351  	for _, f := range fields {
   352  		configWarnOnce(
   353  			"unknown-auth-field:"+authType+":"+f,
   354  			"profile authentication.%s: unknown field %q (ignored)",
   355  			authType, f,
   356  		)
   357  	}
   358  }
   359  
   360  // ResetConfigWarnOnceForTest clears the warn-once dedupe state so tests
   361  // can observe a warning a prior test may already have triggered.
   362  func ResetConfigWarnOnceForTest() {
   363  	configWarnOnceMu.Lock()
   364  	configWarnOnceSet = map[string]bool{}
   365  	configWarnOnceMu.Unlock()
   366  }
   367  
   368  // MarshalJSON emits the flat tagged-union wire shape: shared fields, then
   369  // the matching variant's fields inlined at the same level. Returns an error
   370  // when the in-memory state is inconsistent (Type mismatches the populated
   371  // sub-struct, or neither/both are set).
   372  func (a AuthenticationInfo) MarshalJSON() ([]byte, error) {
   373  	switch a.Type {
   374  	case "":
   375  		return nil, fmt.Errorf("AuthenticationInfo.Type is required")
   376  	case AuthenticationTypeOIDCFederation:
   377  		if a.OIDCFederation == nil {
   378  			return nil, fmt.Errorf("AuthenticationInfo.Type=%q requires OIDCFederation", a.Type)
   379  		}
   380  		if a.UserOAuth != nil {
   381  			return nil, fmt.Errorf("AuthenticationInfo.Type=%q must not set UserOAuth", a.Type)
   382  		}
   383  		return json.Marshal(oidcFederationWire{
   384  			sharedAuthFields: sharedAuthFields{Type: a.Type, CredentialsPath: a.CredentialsPath},
   385  			FederationRuleID: a.OIDCFederation.FederationRuleID,
   386  			ServiceAccountID: a.OIDCFederation.ServiceAccountID,
   387  			IdentityToken:    a.OIDCFederation.IdentityToken,
   388  			Scope:            a.OIDCFederation.Scope,
   389  		})
   390  	case AuthenticationTypeUserOAuth:
   391  		if a.UserOAuth == nil {
   392  			return nil, fmt.Errorf("AuthenticationInfo.Type=%q requires UserOAuth", a.Type)
   393  		}
   394  		if a.OIDCFederation != nil {
   395  			return nil, fmt.Errorf("AuthenticationInfo.Type=%q must not set OIDCFederation", a.Type)
   396  		}
   397  		return json.Marshal(userOAuthWire{
   398  			sharedAuthFields: sharedAuthFields{Type: a.Type, CredentialsPath: a.CredentialsPath},
   399  			ClientID:         a.UserOAuth.ClientID,
   400  			Scope:            a.UserOAuth.Scope,
   401  			ConsoleURL:       a.UserOAuth.ConsoleURL,
   402  		})
   403  	default:
   404  		return nil, fmt.Errorf("AuthenticationInfo.Type %q is not a known authentication type", a.Type)
   405  	}
   406  }
   407  
   408  // defaultConfigDir returns the platform-specific base directory for Anthropic
   409  // config and credentials. It checks ANTHROPIC_CONFIG_DIR first, then falls
   410  // back to the platform default. On non-Windows platforms XDG_CONFIG_HOME is
   411  // honored per the XDG Base Directory spec before the $HOME/.config fallback.
   412  // Returns an empty string when the platform home directory cannot be
   413  // resolved (e.g., $HOME unset in a minimal container), so callers don't
   414  // silently read/write to a CWD-relative path.
   415  func defaultConfigDir() string {
   416  	if dir, ok := os.LookupEnv("ANTHROPIC_CONFIG_DIR"); ok {
   417  		return dir
   418  	}
   419  	if runtime.GOOS == "windows" {
   420  		if appdata := os.Getenv("APPDATA"); appdata != "" {
   421  			return filepath.Join(appdata, "Anthropic")
   422  		}
   423  		return ""
   424  	}
   425  	if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
   426  		return filepath.Join(xdg, "anthropic")
   427  	}
   428  	home, err := os.UserHomeDir()
   429  	if err != nil || home == "" {
   430  		return ""
   431  	}
   432  	return filepath.Join(home, ".config", "anthropic")
   433  }
   434  
   435  // resolveProfile determines the active profile name.
   436  // Resolution order: ANTHROPIC_PROFILE env -> active_config file -> "default".
   437  func resolveProfile(configDir string) string {
   438  	if profile, ok := os.LookupEnv("ANTHROPIC_PROFILE"); ok {
   439  		return profile
   440  	}
   441  	data, err := os.ReadFile(filepath.Join(configDir, "active_config"))
   442  	if err == nil {
   443  		if name := strings.TrimSpace(string(data)); name != "" {
   444  			return name
   445  		}
   446  	}
   447  	return "default"
   448  }
   449  
   450  // loadProfile reads the config file for a specific profile and returns the
   451  // parsed [Config]. The [Config.CredentialsPath] is defaulted to
   452  // credentials/<profile>.json under configDir if not already set.
   453  func loadProfile(configDir, profile string) (*Config, error) {
   454  	if err := validateProfileName(profile); err != nil {
   455  		return nil, fmt.Errorf("invalid profile %q: %w", profile, err)
   456  	}
   457  	configPath := filepath.Join(configDir, "configs", profile+".json")
   458  	data, err := os.ReadFile(configPath)
   459  	if err != nil {
   460  		return nil, fmt.Errorf("failed to read config file %q: %w", configPath, err)
   461  	}
   462  
   463  	var cfg Config
   464  	if err := json.Unmarshal(data, &cfg); err != nil {
   465  		return nil, fmt.Errorf("failed to parse config file %q: %w", configPath, err)
   466  	}
   467  
   468  	if cfg.AuthenticationInfo == nil {
   469  		return nil, fmt.Errorf("config file %q is missing authentication", configPath)
   470  	}
   471  
   472  	if cfg.AuthenticationInfo.CredentialsPath == "" {
   473  		cfg.AuthenticationInfo.CredentialsPath = filepath.Join(configDir, "credentials", profile+".json")
   474  	}
   475  
   476  	fillMissingFromEnv(&cfg)
   477  
   478  	return &cfg, nil
   479  }
   480  
   481  const (
   482  	envBaseURL           = "ANTHROPIC_BASE_URL"
   483  	envOrganizationID    = "ANTHROPIC_ORGANIZATION_ID"
   484  	envWorkspaceID       = "ANTHROPIC_WORKSPACE_ID"
   485  	envFederationRuleID  = "ANTHROPIC_FEDERATION_RULE_ID"
   486  	envServiceAccountID  = "ANTHROPIC_SERVICE_ACCOUNT_ID"
   487  	envScope             = "ANTHROPIC_SCOPE"
   488  	envIdentityTokenFile = "ANTHROPIC_IDENTITY_TOKEN_FILE"
   489  )
   490  
   491  // fillMissingFromEnv populates cfg fields from ANTHROPIC_* environment
   492  // variables, but only when the corresponding field is empty in the loaded
   493  // profile. Contract lives in the cross-SDK credential precedence spec
   494  // (sdk_credential_precedence.md, section 1 interaction rules): "env vars
   495  // fill in fields omitted from a profile file, but never override present
   496  // ones." This keeps an explicit profile authoritative on a machine that
   497  // also happens to have WIF env vars exported (e.g., a prod profile is
   498  // not silently replaced by a leftover dev federation_rule_id env var).
   499  //
   500  // Empty env values are treated as unset rather than "clear the field" so
   501  // an accidentally-blanked env var can't silently strip a required field.
   502  func fillMissingFromEnv(cfg *Config) {
   503  	if cfg.BaseURL == "" {
   504  		if v, ok := lookupNonEmpty(envBaseURL); ok {
   505  			cfg.BaseURL = v
   506  		}
   507  	}
   508  	if cfg.OrganizationID == "" {
   509  		if v, ok := lookupNonEmpty(envOrganizationID); ok {
   510  			cfg.OrganizationID = v
   511  		}
   512  	}
   513  	if cfg.WorkspaceID == "" {
   514  		if v, ok := lookupNonEmpty(envWorkspaceID); ok {
   515  			cfg.WorkspaceID = v
   516  		}
   517  	}
   518  
   519  	switch cfg.AuthenticationInfo.Type {
   520  	case AuthenticationTypeOIDCFederation:
   521  		oidc := cfg.AuthenticationInfo.OIDCFederation
   522  		if oidc == nil {
   523  			return
   524  		}
   525  		if oidc.FederationRuleID == "" {
   526  			if v, ok := lookupNonEmpty(envFederationRuleID); ok {
   527  				oidc.FederationRuleID = v
   528  			}
   529  		}
   530  		if oidc.ServiceAccountID == "" {
   531  			if v, ok := lookupNonEmpty(envServiceAccountID); ok {
   532  				oidc.ServiceAccountID = v
   533  			}
   534  		}
   535  		if oidc.Scope == "" {
   536  			if v, ok := lookupNonEmpty(envScope); ok {
   537  				oidc.Scope = v
   538  			}
   539  		}
   540  		if oidc.IdentityToken == nil {
   541  			if v, ok := lookupNonEmpty(envIdentityTokenFile); ok {
   542  				oidc.IdentityToken = &IdentityTokenConfig{
   543  					Source: IdentityTokenSourceFile,
   544  					Path:   v,
   545  				}
   546  			}
   547  		}
   548  	case AuthenticationTypeUserOAuth:
   549  		if cfg.AuthenticationInfo.UserOAuth == nil {
   550  			return
   551  		}
   552  		if cfg.AuthenticationInfo.UserOAuth.Scope == "" {
   553  			if v, ok := lookupNonEmpty(envScope); ok {
   554  				cfg.AuthenticationInfo.UserOAuth.Scope = v
   555  			}
   556  		}
   557  	}
   558  }
   559  
   560  // lookupNonEmpty returns the env var's value and true only when it is set
   561  // and non-empty. An empty value is reported as unset.
   562  func lookupNonEmpty(key string) (string, bool) {
   563  	v, ok := os.LookupEnv(key)
   564  	if !ok || v == "" {
   565  		return "", false
   566  	}
   567  	return v, true
   568  }
   569  
   570  // LoadProfile loads the config for the named profile from the given config
   571  // directory, bypassing ANTHROPIC_PROFILE / active_config resolution. Use
   572  // [DefaultDir] for the standard location. This is the building block CLIs use
   573  // to inspect or operate on a profile other than the currently-active one.
   574  func LoadProfile(dir, profile string) (*Config, error) {
   575  	return loadProfile(dir, profile)
   576  }
   577  
   578  // LoadConfig reads the raw configuration from the Anthropic config file system
   579  // (configs/<profile>.json) and returns it without resolving credentials.
   580  // Credential resolution is deferred until the config is passed to
   581  // [option.WithConfig].
   582  //
   583  // The config directory and profile are resolved using the standard resolution
   584  // order (ANTHROPIC_CONFIG_DIR, ANTHROPIC_PROFILE, active_config file, defaults).
   585  func LoadConfig() (*Config, error) {
   586  	configDir := defaultConfigDir()
   587  	profile := resolveProfile(configDir)
   588  	if err := validateProfileName(profile); err != nil {
   589  		return nil, fmt.Errorf("invalid profile name from resolution: %w", err)
   590  	}
   591  	return loadProfile(configDir, profile)
   592  }