// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

import type {
  ProviderAuthKind,
  ProviderAuthStatus,
} from "./api/providers-api";

export interface ExternalProviderConfig {
  id: string;
  /** Backend provider type (e.g. openai, mistral, gemini). */
  providerType: string;
  /** Display name in UI. */
  name: string;
  /** Provider base URL (default from registry or backend-saved override). */
  baseUrl: string;
  /** Model ids user enabled from `/api/providers/models`. */
  models: string[];
  /** Cached available model ids from the provider's /models response. */
  availableModels?: string[];
  /**
   * The provider type as the BACKEND stores it, which is not always `providerType`
   * above: `resolveUiProviderTypeFromConfig` shows a row saved as `openai` with a
   * custom name or base URL as "custom". Absent means unknown, not custom.
   */
  backendProviderType?: string;
  /** Optional maximum Max Tokens cap for a generic Custom connection. */
  maxOutputTokens?: number;

  /** Whether the backend has an installation-saved key. */
  hasApiKey?: boolean;

  /** Sanitized backend-owned authorization state; never contains OAuth material. */
  authKind?: ProviderAuthKind;
  authStatus?: ProviderAuthStatus;
  /** Whether to ask supported hosted providers to use prompt caching. */
  enablePromptCaching?: boolean;
  /**
   * Anthropic prompt-cache TTL bucket. Only meaningful when `enablePromptCaching`
   * is true and the provider supports the choice (Anthropic today). Maps to
   * backend `prompt_cache_ttl`, which sets `cache_control.ttl` on the cache
   * marker. Omitted = inherit Anthropic's default 5-minute pool.
   */
  promptCacheTtl?: "5m" | "1h";
  /** User-pinned: the loaded vLLM model supports `enable_thinking`. */
  isReasoningModel?: boolean;
  /**
   * Default idle-timeout (minutes) for new OpenAI shell containers. Pre-fills the
   * "Create container" dialog and is the TTL the auto-create-per-thread path POSTs
   * to /v1/containers. OpenAI's hard default is 20. Only for OpenAI cloud.
   */
  openaiContainerTtlMinutes?: number;
  createdAt: number;
  updatedAt: number;
}

// Gemini supports prompt caching, but the wire flow needs a separate POST to
// /v1beta/cachedContents before generateContent can reference the cache; the
// enable_prompt_caching boolean alone isn't enough. Until that two-step flow
// ships, keep the picker off so the toggle doesn't silently no-op for Gemini.
// See https://ai.google.dev/gemini-api/docs/caching.
const PROMPT_CACHING_PROVIDER_TYPES = new Set(["openai", "anthropic"]);

export function supportsProviderPromptCaching(
  providerType: string | null | undefined,
): boolean {
  return providerType != null && PROMPT_CACHING_PROVIDER_TYPES.has(providerType);
}

/**
 * Whether the provider lets the user choose between a short and long prompt-cache
 * pool. Anthropic exposes 5m and 1h ephemeral pools via `cache_control.ttl`;
 * OpenAI's automatic cache has no equivalent knob, so it stays off the picker.
 */
const PROMPT_CACHE_TTL_PROVIDER_TYPES = new Set(["anthropic"]);

export function supportsProviderPromptCacheTtl(
  providerType: string | null | undefined,
): boolean {
  return (
    providerType != null && PROMPT_CACHE_TTL_PROVIDER_TYPES.has(providerType)
  );
}

const PROMPT_CACHE_TTL_VALUES = new Set<"5m" | "1h">(["5m", "1h"]);

export function isPromptCacheTtl(value: unknown): value is "5m" | "1h" {
  return typeof value === "string" && PROMPT_CACHE_TTL_VALUES.has(value as "5m" | "1h");
}

// Provider types exposing the connection-level "reasoning model" toggle.
// vLLM's OpenAI-compat endpoint doesn't advertise this per model.
const REASONING_TOGGLE_PROVIDER_TYPES = new Set(["vllm"]);

export function supportsProviderReasoningToggle(
  providerType: string | null | undefined,
): boolean {
  return (
    providerType != null && REASONING_TOGGLE_PROVIDER_TYPES.has(providerType)
  );
}

// Known text-only providers on their main chat endpoint.
const NON_VISION_PROVIDER_TYPES = new Set<string>([
  "cohere",
  "deepseek",
  "mistral",
]);
// Providers whose vision-tier model selection accepts images.
const VISION_CAPABLE_PROVIDER_TYPES = new Set<string>([
  "openai",
  "anthropic",
  "gemini",
  "openrouter",
]);

// false = known text-only, true = known vision, null = unknown (default-allow).
export function providerTypeSupportsVision(
  providerType: string | null | undefined,
): boolean | null {
  if (providerType == null) return null;
  if (NON_VISION_PROVIDER_TYPES.has(providerType)) return false;
  if (VISION_CAPABLE_PROVIDER_TYPES.has(providerType)) return true;
  return null;
}


const REGISTRY_MODEL_CAPABILITIES = new Map<
  string,
  Record<string, { vision?: boolean; studio_tools?: boolean }>
>();

const REGISTRY_MODEL_CAPABILITIES_KEY =
  "unsloth_chat_provider_model_capabilities";
let registryCapabilitiesHydrated = false;

function hydrateProviderModelCapabilities(): void {
  if (registryCapabilitiesHydrated) return;
  registryCapabilitiesHydrated = true;
  if (!canUseStorage()) return;
  try {
    const parsed = JSON.parse(
      localStorage.getItem(REGISTRY_MODEL_CAPABILITIES_KEY) ?? "{}",
    ) as Record<
      string,
      Record<string, { vision?: boolean; studio_tools?: boolean }>
    >;
    for (const [providerType, capabilities] of Object.entries(parsed)) {
      if (capabilities && typeof capabilities === "object") {
        REGISTRY_MODEL_CAPABILITIES.set(providerType, capabilities);
      }
    }
  } catch {
    // Ignore invalid browser state; the backend registry will repopulate it.
  }
}

function persistProviderModelCapabilities(): void {
  if (!canUseStorage()) return;
  try {
    localStorage.setItem(
      REGISTRY_MODEL_CAPABILITIES_KEY,
      JSON.stringify(Object.fromEntries(REGISTRY_MODEL_CAPABILITIES)),
    );
  } catch {
    // Ignore storage failures; capabilities remain valid for this session.
  }
}

export function setProviderModelCapabilities(
  providerType: string,
  capabilities: Record<string, { vision?: boolean; studio_tools?: boolean }> | undefined,
): void {
  hydrateProviderModelCapabilities();
  if (capabilities) REGISTRY_MODEL_CAPABILITIES.set(providerType, capabilities);
  else REGISTRY_MODEL_CAPABILITIES.delete(providerType);
  persistProviderModelCapabilities();
}

/** Drop persisted capabilities for provider types the registry no longer lists.
 *
 * This map outlives the backend that wrote it: it is localStorage, so a browser
 * carries it across a downgrade, and a per-entry write can only ever correct the
 * entries the registry still returns. A provider that has been hidden or that a
 * rolled-back backend does not know about is simply absent from the response, so
 * without this its last-known `studio_tools: true` latches forever and the
 * composer keeps offering a loop that backend cannot run.
 *
 * Convergence is the point: the registry response is the whole truth about which
 * provider types exist, so an empty one legitimately means "none", and clearing
 * is the safe direction anyway (an unknown capability reads as null, which every
 * caller treats as "not capable").
 */
export function pruneProviderModelCapabilities(knownProviderTypes: Iterable<string>): void {
  hydrateProviderModelCapabilities();
  const known = new Set(knownProviderTypes);
  let removed = false;
  for (const providerType of [...REGISTRY_MODEL_CAPABILITIES.keys()]) {
    if (!known.has(providerType)) {
      REGISTRY_MODEL_CAPABILITIES.delete(providerType);
      removed = true;
    }
  }
  if (removed) persistProviderModelCapabilities();
}


export function providerModelSupportsVision(
  providerType: string | null | undefined,
  modelId: string | null | undefined,
): boolean | null {

  hydrateProviderModelCapabilities();
  if (providerType && modelId) {
    const capability = REGISTRY_MODEL_CAPABILITIES.get(providerType)?.[modelId];
    if (typeof capability?.vision === "boolean") return capability.vision;
  }
  return providerTypeSupportsVision(providerType);
}


/** Provider-level capability key. Self-hosted model ids are user-supplied, so
 * there is no per-model entry to look up: the registry declares the capability
 * once for the whole provider type and it applies to every model on it. */
export const PROVIDER_CAPABILITY_WILDCARD = "*";

export function providerModelSupportsStudioTools(
  providerType: string | null | undefined,
  modelId: string | null | undefined,
): boolean | null {
  if (!providerType) return null;
  hydrateProviderModelCapabilities();
  const capabilities = REGISTRY_MODEL_CAPABILITIES.get(providerType);
  if (modelId) {
    const value = capabilities?.[modelId]?.studio_tools;
    if (typeof value === "boolean") return value;
  }
  const providerDefault = capabilities?.[PROVIDER_CAPABILITY_WILDCARD]?.studio_tools;
  return typeof providerDefault === "boolean" ? providerDefault : null;
}

/** Whether the connection behind an ``external::`` model id runs Studio tools.
 *
 * Resolves the provider type from the saved connection, so callers that only
 * have a checkpoint id (the runtime store) can ask the capability question
 * without reaching for the providers store and risking an import cycle.
 */
export function externalModelSupportsStudioTools(
  checkpoint: string | null | undefined,
): boolean {
  const selection = parseExternalModelId(checkpoint);
  if (!selection) return false;
  const provider = loadExternalProviders().find(
    (candidate) => candidate.id === selection.providerId,
  );
  if (!provider) return false;
  return (
    providerModelSupportsStudioTools(provider.providerType, selection.modelId) === true
  );
}

export const CUSTOM_BACKEND_PROVIDER_TYPE = "openai";
export const LEGACY_CUSTOM_PROVIDER_TYPE = "custom";
export const CUSTOM_PROVIDER_DISPLAY_NAME = "Custom";
export const CUSTOM_MAX_OUTPUT_TOKENS_MIN = 64;

export function normalizeCustomMaxOutputTokens(
  providerType: string | null | undefined,
  value: unknown,
): number | undefined {
  if (
    providerType !== LEGACY_CUSTOM_PROVIDER_TYPE ||
    typeof value !== "number" ||
    !Number.isSafeInteger(value) ||
    value < CUSTOM_MAX_OUTPUT_TOKENS_MIN
  ) {
    return undefined;
  }
  return value;
}

/**
 * Whether a connection may carry a per-connection Max Tokens limit.
 *
 * Both types have to agree: the UI type decides what the dialog draws, the stored type
 * decides what the server accepts, and they differ for a row saved as `openai` with a
 * custom name or base URL. An unknown stored type (synced before this field, or a
 * connection with no server row yet) falls back to what the create call will send.
 */
export function supportsCustomMaxOutputTokens(
  uiProviderType: string | null | undefined,
  backendProviderType: string | null | undefined,
): boolean {
  if (uiProviderType !== LEGACY_CUSTOM_PROVIDER_TYPE) return false;
  const effective =
    typeof backendProviderType === "string" && backendProviderType.length > 0
      ? backendProviderType
      : toExternalBackendProviderType(uiProviderType);
  return effective === LEGACY_CUSTOM_PROVIDER_TYPE;
}

export const CUSTOM_PROVIDER_PRESETS = [
  {
    providerType: "llama_cpp",
    displayName: "llama.cpp",
    baseUrlPlaceholder: "http://localhost:8080/v1",
    modelIdsPlaceholder: "gpt-oss-20b\nqwen3-14b",
  },
  {
    providerType: "vllm",
    displayName: "vLLM",
    baseUrlPlaceholder: "https://my-vllm-server.com/v1",
    modelIdsPlaceholder: "openai/gpt-oss-20b\nQwen/Qwen3-14B",
  },
  {
    providerType: "ollama",
    displayName: "Ollama",
    baseUrlPlaceholder: "http://localhost:11434/v1",
    modelIdsPlaceholder: "gpt-oss:20b\nqwen3:14b",
  },
] as const;

const CUSTOM_PROVIDER_LABELS: Record<string, string> = {
  [LEGACY_CUSTOM_PROVIDER_TYPE]: CUSTOM_PROVIDER_DISPLAY_NAME,
  ...Object.fromEntries(
    CUSTOM_PROVIDER_PRESETS.map((preset) => [
      preset.providerType,
      preset.displayName,
    ]),
  ),
};

const CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS: Record<string, string> = {
  [LEGACY_CUSTOM_PROVIDER_TYPE]: "https://my-vllm-server.com/v1",
  ...Object.fromEntries(
    CUSTOM_PROVIDER_PRESETS.map((preset) => [
      preset.providerType,
      preset.baseUrlPlaceholder,
    ]),
  ),
};

const CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS: Record<string, string> = {
  [LEGACY_CUSTOM_PROVIDER_TYPE]: "openai/gpt-oss-20b\nQwen/Qwen3-14B",
  ...Object.fromEntries(
    CUSTOM_PROVIDER_PRESETS.map((preset) => [
      preset.providerType,
      preset.modelIdsPlaceholder,
    ]),
  ),
};

export function isCustomProviderType(
  providerType: string | null | undefined,
): boolean {
  if (!providerType) return false;
  return providerType in CUSTOM_PROVIDER_LABELS;
}

/** OpenAI-compat custom types that may expose GET /v1/models. */
const REMOTE_MODEL_CATALOG_CUSTOM_PROVIDER_TYPES = new Set([
  LEGACY_CUSTOM_PROVIDER_TYPE,
  "ollama",
  "vllm",
  "llama_cpp",
]);

export function supportsRemoteModelCatalog(
  providerType: string | null | undefined,
): boolean {
  return (
    providerType != null &&
    REMOTE_MODEL_CATALOG_CUSTOM_PROVIDER_TYPES.has(providerType)
  );
}

/** Presets that hide the API-key field. Ollama is not skipped: Ollama cloud
 * requires a key; local servers leave the optional field empty. */
export function customPresetSkipsApiKeyField(
  providerType: string | null | undefined,
): boolean {
  return providerType === "llama_cpp";
}

/** Catalog load plus optional manual model IDs. */
export function allowsManualModelIdsWithCatalog(
  providerType: string | null | undefined,
): boolean {
  if (!providerType) return false;
  if (providerType === "openrouter") return true;
  return supportsRemoteModelCatalog(providerType);
}

export function customProviderDisplayName(
  providerType: string | null | undefined,
): string {
  if (!providerType) return CUSTOM_PROVIDER_DISPLAY_NAME;
  return CUSTOM_PROVIDER_LABELS[providerType] ?? providerType;
}

export function customProviderBaseUrlPlaceholder(
  providerType: string | null | undefined,
): string {
  if (!providerType) {
    return CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE];
  }
  return (
    CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS[providerType] ??
    CUSTOM_PROVIDER_BASE_URL_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE]
  );
}

export function customProviderModelIdsPlaceholder(
  providerType: string | null | undefined,
): string {
  if (!providerType) {
    return CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE];
  }
  return (
    CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS[providerType] ??
    CUSTOM_PROVIDER_MODEL_IDS_PLACEHOLDERS[LEGACY_CUSTOM_PROVIDER_TYPE]
  );
}

export function toExternalBackendProviderType(providerType: string): string;
export function toExternalBackendProviderType(
  providerType: null | undefined,
): undefined;
export function toExternalBackendProviderType(
  providerType: string | null | undefined,
): string | undefined;
export function toExternalBackendProviderType(
  providerType: string | null | undefined,
): string | undefined {
  if (!providerType) return undefined;
  // vLLM's /v1/responses applies the loaded model's chat template, which 400s on
  // strict-alternation templates (e.g. Gemma 3). Pass the type through so the
  // backend routes vLLM to /v1/chat/completions instead of the Responses path.
  if (providerType === "vllm") return "vllm";
  if (providerType === "ollama") return "ollama";
  if (providerType === "llama_cpp") return "llama_cpp";
  // Generic custom servers are OpenAI-compatible, but should still use the
  // chat-completions backend path instead of OpenAI's Responses API route.
  if (providerType === LEGACY_CUSTOM_PROVIDER_TYPE) {
    return LEGACY_CUSTOM_PROVIDER_TYPE;
  }
  return isCustomProviderType(providerType)
    ? CUSTOM_BACKEND_PROVIDER_TYPE
    : providerType;
}

const EXTERNAL_PROVIDERS_KEY = "unsloth_chat_external_providers";
const EXTERNAL_PROVIDER_KEYS_KEY = "unsloth_chat_external_provider_keys";
const CONNECTIONS_ENABLED_KEY = "unsloth_chat_connections_enabled";
const EXTERNAL_MODEL_PREFIX = "external::";

function canUseStorage(): boolean {
  return typeof window !== "undefined";
}

export function isExternalModelId(
  value: string | null | undefined,
): value is string {
  return typeof value === "string" && value.startsWith(EXTERNAL_MODEL_PREFIX);
}

export function buildExternalModelId(providerId: string, modelId: string): string {
  return `${EXTERNAL_MODEL_PREFIX}${providerId}::${encodeURIComponent(modelId)}`;
}

export function parseExternalModelId(
  value: string | null | undefined,
): { providerId: string; modelId: string } | null {
  if (!isExternalModelId(value)) return null;
  const payload = value.slice(EXTERNAL_MODEL_PREFIX.length);
  const separator = payload.indexOf("::");
  if (separator < 0) return null;
  const providerId = payload.slice(0, separator);
  const encodedModelId = payload.slice(separator + 2);
  if (!providerId || !encodedModelId) return null;
  try {
    return { providerId, modelId: decodeURIComponent(encodedModelId) };
  } catch {
    return null;
  }
}

function isExternalProviderConfig(value: unknown): value is ExternalProviderConfig {
  if (!value || typeof value !== "object") return false;
  const maybe = value as Partial<ExternalProviderConfig>;
  return (
    typeof maybe.id === "string" &&
    typeof maybe.providerType === "string" &&
    typeof maybe.name === "string" &&
    typeof maybe.baseUrl === "string" &&
    Array.isArray(maybe.models)
  );
}

function mapLegacyPresetToProviderType(presetId: string): string {
  if (presetId === "google") return "gemini";
  return presetId;
}

function normalizeProvider(raw: ExternalProviderConfig): ExternalProviderConfig {
  const providerType = raw.providerType.trim();
  return {
    ...raw,
    providerType,
    name: raw.name.trim(),
    baseUrl: raw.baseUrl.trim(),
    models: raw.models
      .map((model) => model.trim())
      .filter((model) => model.length > 0),
    availableModels: (raw.availableModels ?? [])
      .map((model) => model.trim())
      .filter((model) => model.length > 0),
    // Junk from a hand-edited entry becomes undefined, i.e. unknown.
    backendProviderType:
      typeof raw.backendProviderType === "string" &&
      raw.backendProviderType.trim().length > 0
        ? raw.backendProviderType.trim()
        : undefined,
    maxOutputTokens: normalizeCustomMaxOutputTokens(
      providerType,
      raw.maxOutputTokens,
    ),
    enablePromptCaching: supportsProviderPromptCaching(providerType)
      ? raw.enablePromptCaching !== false
      : undefined,
    promptCacheTtl:
      supportsProviderPromptCacheTtl(providerType) &&
      isPromptCacheTtl(raw.promptCacheTtl)
        ? raw.promptCacheTtl
        : undefined,
    isReasoningModel: supportsProviderReasoningToggle(providerType)
      ? raw.isReasoningModel === true
      : undefined,
    openaiContainerTtlMinutes:
      providerType === "openai" &&
      typeof raw.openaiContainerTtlMinutes === "number" &&
      raw.openaiContainerTtlMinutes >= 1
        ? Math.min(raw.openaiContainerTtlMinutes, 20)
        : undefined,
  };
}

function isCompleteProvider(provider: ExternalProviderConfig): boolean {
  if (!provider.id || !provider.name || !provider.providerType) return false;
  return true;
}

type LegacyProviderConfig = {
  id?: unknown;
  presetId?: unknown;
  name?: unknown;
  baseUrl?: unknown;
  models?: unknown;
  createdAt?: unknown;
  updatedAt?: unknown;
};

function fromUnknownProvider(value: unknown): ExternalProviderConfig | null {
  if (!value || typeof value !== "object") return null;
  if (isExternalProviderConfig(value)) {
    return value;
  }
  const legacy = value as LegacyProviderConfig;
  const id = typeof legacy.id === "string" ? legacy.id : "";
  const presetId = typeof legacy.presetId === "string" ? legacy.presetId : "";
  if (!id || !presetId || presetId === "custom") return null;
  const providerType = mapLegacyPresetToProviderType(presetId);
  if (!providerType) return null;
  return {
    id,
    providerType,
    name: typeof legacy.name === "string" ? legacy.name : providerType,
    baseUrl: typeof legacy.baseUrl === "string" ? legacy.baseUrl : "",
    models: Array.isArray(legacy.models)
      ? legacy.models.filter((item): item is string => typeof item === "string")
      : [],
    createdAt: typeof legacy.createdAt === "number" ? legacy.createdAt : Date.now(),
    updatedAt: typeof legacy.updatedAt === "number" ? legacy.updatedAt : Date.now(),
  };
}

export function loadConnectionsEnabled(): boolean {
  if (!canUseStorage()) return true;
  try {
    const raw = localStorage.getItem(CONNECTIONS_ENABLED_KEY);
    if (raw == null) return true;
    return raw === "true";
  } catch {
    return true;
  }
}

export function saveConnectionsEnabled(enabled: boolean): void {
  if (!canUseStorage()) return;
  try {
    localStorage.setItem(CONNECTIONS_ENABLED_KEY, enabled ? "true" : "false");
  } catch {
    // ignore
  }
}

export function loadExternalProviders(): ExternalProviderConfig[] {
  if (!canUseStorage()) return [];
  try {
    const raw = localStorage.getItem(EXTERNAL_PROVIDERS_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw) as unknown;
    if (!Array.isArray(parsed)) return [];
    return parsed
      .map(fromUnknownProvider)
      .filter((provider): provider is ExternalProviderConfig => provider !== null)
      .map(normalizeProvider)
      .filter(isCompleteProvider);
  } catch {
    return [];
  }
}



/** Load legacy browser keys for retry-safe backend migration. */
function loadRawKeyMap(): Record<string, string> {
  if (!canUseStorage()) return {};
  try {
    const raw = localStorage.getItem(EXTERNAL_PROVIDER_KEYS_KEY);
    if (!raw) return {};
    const parsed = JSON.parse(raw) as unknown;
    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
    const out: Record<string, string> = {};
    for (const [providerId, value] of Object.entries(parsed)) {
      if (typeof providerId === "string" && typeof value === "string") {
        out[providerId] = value;
      }
    }
    return out;
  } catch {
    return {};
  }
}

function saveRawKeyMap(map: Record<string, string>): void {
  if (!canUseStorage()) return;
  try {
    localStorage.setItem(EXTERNAL_PROVIDER_KEYS_KEY, JSON.stringify(map));
  } catch {
    // ignore
  }
}

export function saveExternalProviders(
  providers: ExternalProviderConfig[],
): void {
  if (!canUseStorage()) return;
  try {
    localStorage.setItem(EXTERNAL_PROVIDERS_KEY, JSON.stringify(providers));
    // Legacy keys are migration input. Preserve unmatched entries until the
    // backend confirms the exact key was stored.
  } catch {
    // ignore
  }
}

/** Retrieve a legacy provider key used only as migration/request fallback. */
export function getExternalProviderApiKey(
  providerId: string,
): string {

  const keys = loadRawKeyMap();
  return keys[providerId] ?? "";
}

export function pruneExternalProviderApiKeys(providerIds: Iterable<string>): void {
  if (!canUseStorage()) return;
  const retainedIds = new Set(providerIds);
  try {
    const keys = loadRawKeyMap();
    let changed = false;
    for (const providerId of Object.keys(keys)) {
      if (retainedIds.has(providerId)) continue;
      delete keys[providerId];
      changed = true;
    }
    if (changed) saveRawKeyMap(keys);
  } catch {
    // Keep legacy data untouched when storage is unavailable.
  }
}



export function removeExternalProviderApiKey(
  providerId: string,
  expectedApiKey?: string,
): void {
  if (!canUseStorage()) return;
  try {
    const keys = loadRawKeyMap();

    if (expectedApiKey !== undefined && keys[providerId] !== expectedApiKey) return;
    delete keys[providerId];
    saveRawKeyMap(keys);
  } catch {
    // ignore
  }
}
