using Microsoft.Extensions.Configuration; /// /// Resolves the OpenAI-compatible API key for outbound LLM calls. /// Single source of truth shared by Program.cs (primary OpenAI client) /// and (secondary tool-calling HTTP client). /// internal static class ApiKeyResolver { internal const string MockApiKey = "sk-mock-local"; internal const string DefaultOpenAiEndpoint = "https://models.inference.ai.azure.com"; /// /// Resolves the API key using (in order): OPENAI_API_KEY env var, /// configuration["OPENAI_API_KEY"], then configuration["GitHubToken"]. /// /// If none of those are set, behavior depends on the upstream endpoint /// (OPENAI_BASE_URL env / config): when it looks like aimock or /// localhost, the mock key is returned silently (intended dev path); for /// any other endpoint we fail-fast with an /// so misconfigured prod deployments do not silently send a bogus key. /// internal static string ResolveApiKey(IConfiguration configuration, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(configuration); var apiKey = FirstNonBlank( Environment.GetEnvironmentVariable("OPENAI_API_KEY"), configuration["OPENAI_API_KEY"], configuration["GitHubToken"]); if (apiKey is not null) { return apiKey; } var endpoint = ResolveEndpoint(configuration); if (IsMockEndpoint(endpoint)) { // Silent fallback in dev — aimock/localhost ignores the key. return MockApiKey; } var message = "No API key found (checked OPENAI_API_KEY env, configuration[OPENAI_API_KEY], configuration[GitHubToken]) " + $"and OPENAI_BASE_URL ('{endpoint}') is not an aimock/localhost endpoint. " + "Refusing to fall back to the mock key for a non-mock endpoint."; logger?.LogCritical("[api-key-resolver] {Message}", message); throw new InvalidOperationException(message); } /// /// Returns the configured OpenAI endpoint, preferring env var then config, /// then falling back to the public Azure-hosted models endpoint. /// internal static string ResolveEndpoint(IConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); return FirstNonBlank( Environment.GetEnvironmentVariable("OPENAI_BASE_URL"), configuration["OPENAI_BASE_URL"]) ?? DefaultOpenAiEndpoint; } /// /// Returns the first candidate that is neither null nor whitespace-only. /// Used in place of the ?? operator when cascading through env var /// → configuration sources, since ?? only short-circuits on null /// and would otherwise let an empty-string env var mask a configured /// fallback. /// private static string? FirstNonBlank(params string?[] candidates) { foreach (var candidate in candidates) { if (!string.IsNullOrWhiteSpace(candidate)) { return candidate; } } return null; } private static bool IsMockEndpoint(string endpoint) { // Parse and inspect ONLY the host component. Substring matching against // the full URL is exploitable: an attacker-controlled endpoint like // https://attacker.example.com/aimock-decoy or // https://api.openai.com/?env=localhost would otherwise be classified // as a mock and bypass the fail-fast guard, silently returning the // mock key. // // Subdomain matching (e.g. host.StartsWith("aimock.")) is also rejected // as attack surface: an attacker-registered domain like // "aimock.attacker.example.com" would otherwise be classified as a // mock endpoint. Only exact, well-known mock hosts and loopback // addresses are accepted. // // Loopback detection delegates to because // Uri.Host's formatting is not stable across loopback variants: // new Uri("http://[::1]:8000/").Host returns "[::1]" // (with brackets), so a naive host == "::1" check silently // fails. Uri.IsLoopback covers localhost, the entire // 127.0.0.0/8 range, ::1 in any bracketed form, and // IPv4-mapped IPv6 loopback (::ffff:127.0.0.1). if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) { return false; } if (uri.IsLoopback) { return true; } var host = uri.Host; return host == "0.0.0.0" // unspecified address; OS-dependent, kept for back-compat || host.Equals("aimock", StringComparison.OrdinalIgnoreCase); // Docker-compose service name } }