branch:
UnifiedModelSelector.tsx
23329 bytesRaw
import { useCombobox } from "downshift";
import { useEffect, useRef, useState } from "react";
import { SpinnerIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
import ModelRow from "./ModelRow";
import type { Model } from "../models";

type GatewayProvider = "openai" | "anthropic" | "google" | "xai";
type AuthMethod = "provider-key" | "gateway";

// Latest external provider models for each provider (top 5)
const EXTERNAL_MODELS: Record<
  GatewayProvider,
  Array<{ id: string; name: string; description: string }>
> = {
  openai: [
    {
      id: "openai/gpt-5.2",
      name: "gpt-5.2",
      description:
        "Premier model for coding and agentic tasks across industries"
    },
    {
      id: "openai/gpt-5.2-pro",
      name: "gpt-5.2-pro",
      description: "Enhanced GPT-5.2 with smarter and more precise responses"
    },
    {
      id: "openai/gpt-5-mini",
      name: "gpt-5-mini",
      description:
        "Faster, cost-efficient version of GPT-5, ideal for well-defined tasks"
    },
    {
      id: "openai/gpt-5-nano",
      name: "gpt-5-nano",
      description: "Fastest and most cost-efficient version of GPT-5"
    },
    {
      id: "openai/gpt-5",
      name: "gpt-5",
      description: "Intelligent reasoning model for coding and agentic tasks"
    }
  ],
  anthropic: [
    {
      id: "anthropic/claude-sonnet-4-5-20250929",
      name: "claude-sonnet-4-5-20250929",
      description:
        "Recommended: Best balance of intelligence, speed, and cost. Excellent for coding and agentic tasks"
    },
    {
      id: "anthropic/claude-opus-4-5-20251101",
      name: "claude-opus-4-5-20251101",
      description:
        "Premium model combining maximum intelligence with practical performance"
    },
    {
      id: "anthropic/claude-haiku-4-5-20251001",
      name: "claude-haiku-4-5-20251001",
      description:
        "Fastest model with near-frontier intelligence, optimized for real-time interactions"
    },
    {
      id: "anthropic/claude-opus-4-1-20250805",
      name: "claude-opus-4-1-20250805",
      description:
        "Legacy: Advanced model for complex reasoning tasks (migrate to Opus 4.5)"
    },
    {
      id: "anthropic/claude-sonnet-4-20250514",
      name: "claude-sonnet-4-20250514",
      description:
        "Legacy: High-performance balanced model (migrate to Sonnet 4.5)"
    }
  ],
  google: [
    {
      id: "google-ai-studio/gemini-3-pro-preview",
      name: "gemini-3-pro-preview",
      description:
        "Most intelligent model for multimodal understanding, best for agentic and vibe-coding tasks"
    },
    {
      id: "google-ai-studio/gemini-3-flash-preview",
      name: "gemini-3-flash-preview",
      description:
        "Most intelligent model built for speed, combining frontier intelligence with superior search"
    },
    {
      id: "google-ai-studio/gemini-2.5-pro",
      name: "gemini-2.5-pro",
      description:
        "State-of-the-art thinking model for complex problems in code, math, and STEM"
    },
    {
      id: "google-ai-studio/gemini-2.5-flash",
      name: "gemini-2.5-flash",
      description:
        "Best price-performance model, well-rounded capabilities for large-scale processing"
    },
    {
      id: "google-ai-studio/gemini-2.5-flash-lite",
      name: "gemini-2.5-flash-lite",
      description:
        "Fastest flash model optimized for cost-efficiency and high throughput"
    }
  ],
  xai: [
    {
      id: "xai/grok-4-1-fast-reasoning",
      name: "grok-4-1-fast-reasoning",
      description:
        "Advanced reasoning capabilities with a 2 million token context window"
    },
    {
      id: "xai/grok-4-1-fast-non-reasoning",
      name: "grok-4-1-fast-non-reasoning",
      description: "Optimized for speed with a 2 million token context window"
    },
    {
      id: "xai/grok-3",
      name: "grok-3",
      description: "Standard model with a 256k token context window"
    },
    {
      id: "xai/grok-3-mini",
      name: "grok-3-mini",
      description: "Smaller, faster variant with a 256k token context window"
    },
    {
      id: "xai/grok-2-vision-1212",
      name: "grok-2-vision-1212",
      description: "Supports image input with a 131,072 token context window"
    }
  ]
};

type FilterState = {
  [key: string]: "show" | "hide" | null;
};

interface UnifiedModelSelectorProps {
  workersAiModels: Model[];
  activeWorkersAiModel: Model | undefined;
  isLoadingWorkersAi: boolean;
  useExternalProvider: boolean;
  externalProvider: GatewayProvider;
  externalModel: string | undefined;
  authMethod: AuthMethod;
  providerApiKey?: string;
  gatewayAccountId?: string;
  gatewayId?: string;
  gatewayApiKey?: string;
  onModeChange: (useExternal: boolean) => void;
  onWorkersAiModelSelect: (model: Model | null) => void;
  onExternalProviderChange: (provider: GatewayProvider) => void;
  onExternalModelSelect: (modelId: string) => void;
  onAuthMethodChange: (method: AuthMethod) => void;
  onProviderApiKeyChange: (key: string) => void;
  onGatewayAccountIdChange: (id: string) => void;
  onGatewayIdChange: (id: string) => void;
  onGatewayApiKeyChange: (key: string) => void;
}

const UnifiedModelSelector = ({
  workersAiModels,
  activeWorkersAiModel,
  isLoadingWorkersAi,
  useExternalProvider,
  externalProvider,
  externalModel,
  authMethod,
  providerApiKey,
  gatewayAccountId,
  gatewayId,
  gatewayApiKey,
  onModeChange,
  onWorkersAiModelSelect,
  onExternalProviderChange,
  onExternalModelSelect,
  onAuthMethodChange,
  onProviderApiKeyChange,
  onGatewayAccountIdChange,
  onGatewayIdChange,
  onGatewayApiKeyChange
}: UnifiedModelSelectorProps) => {
  const [showProviderKey, setShowProviderKey] = useState(false);
  const [showGatewayKey, setShowGatewayKey] = useState(false);
  const [inputItems, setInputItems] = useState(workersAiModels);
  const [inputValue, setInputValue] = useState("");
  const [selectedItem, setSelectedItem] = useState<Model | null>(
    activeWorkersAiModel || null
  );
  const [filterState, setFilterState] = useState<FilterState>(() => {
    const storedFilters = sessionStorage.getItem("modelFilters");
    if (storedFilters) {
      try {
        return JSON.parse(storedFilters);
      } catch (e) {
        console.error("Failed to parse stored filters", e);
      }
    }
    return { Beta: null, LoRA: null, MCP: "show" };
  });

  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (!useExternalProvider) {
      setInputItems(workersAiModels);
      setSelectedItem(activeWorkersAiModel || null);
    }
  }, [workersAiModels, activeWorkersAiModel, useExternalProvider]);

  useEffect(() => {
    if (useExternalProvider) return;

    let filteredItems = workersAiModels;
    if (inputValue) {
      filteredItems = filteredItems.filter((model) =>
        model.name.includes(inputValue)
      );
    }
    for (const [tag, state] of Object.entries(filterState)) {
      if (state === "show") {
        filteredItems = filteredItems.filter((model) =>
          model.properties.some((prop) => {
            if (
              tag === "Beta" &&
              prop.property_id === "beta" &&
              prop.value === "true"
            )
              return true;
            if (
              tag === "LoRA" &&
              prop.property_id === "lora" &&
              prop.value === "true"
            )
              return true;
            if (
              tag === "MCP" &&
              prop.property_id === "function_calling" &&
              prop.value === "true"
            )
              return true;
            return false;
          })
        );
      } else if (state === "hide") {
        filteredItems = filteredItems.filter(
          (model) =>
            !model.properties.some((prop) => {
              if (
                tag === "Beta" &&
                prop.property_id === "beta" &&
                prop.value === "true"
              )
                return true;
              if (
                tag === "LoRA" &&
                prop.property_id === "lora" &&
                prop.value === "true"
              )
                return true;
              if (
                tag === "MCP" &&
                prop.property_id === "function_calling" &&
                prop.value === "true"
              )
                return true;
              return false;
            })
        );
      }
    }
    setInputItems(filteredItems);
    sessionStorage.setItem("modelFilters", JSON.stringify(filterState));
  }, [filterState, inputValue, workersAiModels, useExternalProvider]);

  const toggleFilter = (tag: string, event: React.MouseEvent) => {
    setFilterState((prev) => {
      const currentState = prev[tag];
      let newState = { ...prev };
      if (!event.shiftKey && currentState === null) {
        newState = Object.keys(prev).reduce((acc, key) => {
          acc[key] = null;
          return acc;
        }, {} as FilterState);
      }
      if (currentState === null) newState[tag] = "show";
      else if (currentState === "show") newState[tag] = "hide";
      else newState[tag] = null;
      return newState;
    });
  };

  const {
    isOpen,
    getToggleButtonProps,
    getLabelProps,
    getMenuProps,
    getInputProps,
    highlightedIndex,
    getItemProps
  } = useCombobox({
    inputValue,
    items: inputItems,
    itemToString: (item) => item?.name || "",
    onInputValueChange: ({ inputValue, type }) => {
      if (type === useCombobox.stateChangeTypes.InputChange) {
        setInputValue(inputValue || "");
      }
    },
    onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
      onWorkersAiModelSelect(newSelectedItem);
      setSelectedItem(newSelectedItem);
      inputRef.current?.blur();
    }
  });

  const externalModels = EXTERNAL_MODELS[externalProvider] || [];

  return (
    <div className="space-y-2">
      {/* Mode Toggle */}
      <div className="flex items-center gap-1.5 p-1 bg-kumo-control rounded-md">
        <button
          type="button"
          onClick={() => onModeChange(false)}
          className={`flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors ${
            !useExternalProvider
              ? "bg-kumo-base shadow-sm border border-kumo-line text-kumo-default"
              : "text-kumo-secondary hover:text-kumo-default"
          }`}
        >
          Workers AI
        </button>
        <button
          type="button"
          onClick={() => onModeChange(true)}
          className={`flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors ${
            useExternalProvider
              ? "bg-kumo-base shadow-sm border border-kumo-line text-kumo-default"
              : "text-kumo-secondary hover:text-kumo-default"
          }`}
        >
          Other Providers
        </button>
      </div>

      {!useExternalProvider ? (
        /* Workers AI Model Selector */
        <div className="relative">
          <div className="flex justify-between items-center mb-1">
            <label
              htmlFor="model"
              {...getLabelProps()}
              className="font-medium text-xs text-kumo-default"
            >
              Model
            </label>
            <div className="flex space-x-1 min-h-[22px]">
              {!isLoadingWorkersAi &&
                Object.keys(filterState).map((tag) => (
                  <button
                    type="button"
                    key={tag}
                    onClick={(e) => toggleFilter(tag, e)}
                    className={`text-[10px] px-1.5 py-0.5 rounded-full border ${
                      filterState[tag] === "show"
                        ? "bg-green-500/10 border-green-400 text-green-700"
                        : filterState[tag] === "hide"
                          ? "bg-red-500/10 border-red-400 text-red-700"
                          : "bg-transparent border-transparent text-kumo-inactive"
                    }`}
                  >
                    {tag}
                    {filterState[tag] === "show" && " ✓"}
                    {filterState[tag] === "hide" && " ✗"}
                  </button>
                ))}
            </div>
          </div>
          <div className="bg-kumo-base flex items-center justify-between cursor-pointer w-full border border-kumo-line p-2 rounded-md relative">
            <input
              className="absolute left-2 top-2 right-2 bg-transparent outline-none text-sm text-kumo-default"
              placeholder={isLoadingWorkersAi ? "Fetching models..." : ""}
              {...getInputProps({ ref: inputRef })}
              onBlur={() => setInputValue("")}
              disabled={isLoadingWorkersAi}
            />
            <div className="flex-1 min-h-[20px]">
              {!isLoadingWorkersAi && !inputValue && selectedItem && (
                <ModelRow model={selectedItem} />
              )}
            </div>
            <span
              className="shrink-0 px-1 text-kumo-secondary"
              {...(isLoadingWorkersAi ? {} : getToggleButtonProps())}
            >
              {isLoadingWorkersAi ? (
                <SpinnerIcon
                  size={16}
                  className="animate-spin text-kumo-inactive"
                />
              ) : isOpen ? (
                <>&#8593;</>
              ) : (
                <>&#8595;</>
              )}
            </span>
          </div>
          <ul
            className={`absolute left-0 right-0 bg-kumo-base mt-1 border border-kumo-line px-1.5 py-1.5 rounded-md shadow-lg max-h-72 overflow-scroll z-50 ${
              !isOpen && "hidden"
            }`}
            {...getMenuProps()}
          >
            {isOpen && inputItems.length === 0 && (
              <li className="py-1.5 px-2 flex flex-col rounded-md text-kumo-secondary text-sm">
                No models found
              </li>
            )}
            {isOpen &&
              inputItems.map((item, index) => (
                <li
                  className={`py-1.5 px-2 flex flex-col rounded-md text-kumo-default ${
                    selectedItem === item && "font-bold"
                  } ${highlightedIndex === index && "bg-kumo-tint"}`}
                  key={item.id}
                  {...getItemProps({ index, item })}
                >
                  <ModelRow model={item} />
                </li>
              ))}
          </ul>
        </div>
      ) : (
        /* External Provider Models Selector */
        <div className="space-y-2">
          <div>
            <label
              htmlFor="provider"
              className="text-xs text-kumo-secondary block mb-1"
            >
              Provider
            </label>
            <select
              value={externalProvider}
              onChange={(e) =>
                onExternalProviderChange(e.target.value as GatewayProvider)
              }
              className="w-full p-1.5 border border-kumo-line rounded-md text-xs bg-kumo-base text-kumo-default"
            >
              <option value="openai">OpenAI</option>
              <option value="anthropic">Anthropic</option>
              <option value="google">Google</option>
              <option value="xai">xAI</option>
            </select>
          </div>

          <div>
            <label
              htmlFor="model"
              className="text-xs text-kumo-secondary block mb-1"
            >
              Model
            </label>
            <div className="bg-kumo-base border border-kumo-line rounded-md p-1.5 max-h-36 overflow-y-auto">
              {externalModels.map((model) => (
                <button
                  key={model.id}
                  type="button"
                  onClick={() => onExternalModelSelect(model.id)}
                  className={`w-full text-left py-1.5 px-2 rounded-md text-xs transition-colors mb-0.5 ${
                    externalModel === model.id
                      ? "bg-kumo-brand/10 border border-kumo-brand/30 text-kumo-default"
                      : "hover:bg-kumo-tint border border-transparent text-kumo-default"
                  }`}
                >
                  <span
                    className={`font-medium ${
                      externalModel === model.id ? "text-kumo-brand" : ""
                    }`}
                  >
                    {model.name}
                  </span>
                  <span className="text-kumo-secondary ml-2">
                    {model.description}
                  </span>
                </button>
              ))}
            </div>
          </div>

          {/* Authentication Method */}
          <div>
            <label
              htmlFor="authentication"
              className="text-xs text-kumo-secondary block mb-1"
            >
              Authentication
            </label>
            <div className="flex gap-1.5 mb-2">
              <button
                type="button"
                onClick={() => onAuthMethodChange("provider-key")}
                className={`flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors ${
                  authMethod === "provider-key"
                    ? "bg-kumo-base shadow-sm border border-kumo-line text-kumo-default"
                    : "bg-kumo-control text-kumo-secondary hover:text-kumo-default border border-transparent"
                }`}
              >
                Provider API Key
              </button>
              <button
                type="button"
                onClick={() => onAuthMethodChange("gateway")}
                className={`flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors ${
                  authMethod === "gateway"
                    ? "bg-kumo-base shadow-sm border border-kumo-line text-kumo-default"
                    : "bg-kumo-control text-kumo-secondary hover:text-kumo-default border border-transparent"
                }`}
              >
                AI Gateway
              </button>
            </div>

            {authMethod === "provider-key" ? (
              <div>
                <label
                  htmlFor="provider-api-key"
                  className="text-xs text-kumo-secondary block mb-1"
                >
                  {externalProvider === "openai"
                    ? "OpenAI"
                    : externalProvider === "anthropic"
                      ? "Anthropic"
                      : externalProvider === "google"
                        ? "Google"
                        : "xAI"}{" "}
                  API Key <span className="text-kumo-danger">*</span>
                </label>
                <div className="relative">
                  <input
                    type={showProviderKey ? "text" : "password"}
                    value={providerApiKey || ""}
                    onChange={(e) => onProviderApiKeyChange(e.target.value)}
                    placeholder={`Enter your ${externalProvider === "xai" ? "xAI" : externalProvider} API key`}
                    required
                    className="w-full p-1.5 pr-8 border border-kumo-line rounded-md text-xs bg-kumo-base text-kumo-default"
                  />
                  <button
                    type="button"
                    onClick={() => setShowProviderKey(!showProviderKey)}
                    className="absolute right-2 top-1/2 -translate-y-1/2 text-kumo-secondary hover:text-kumo-default"
                  >
                    {showProviderKey ? (
                      <EyeSlashIcon size={14} />
                    ) : (
                      <EyeIcon size={14} />
                    )}
                  </button>
                </div>
              </div>
            ) : (
              <div className="space-y-2">
                <div>
                  <label
                    htmlFor="account-id"
                    className="text-xs text-kumo-secondary block mb-1"
                  >
                    Account ID <span className="text-kumo-danger">*</span>
                  </label>
                  <input
                    type="text"
                    value={gatewayAccountId || ""}
                    onChange={(e) => onGatewayAccountIdChange(e.target.value)}
                    placeholder="Cloudflare account ID"
                    required
                    className="w-full p-1.5 border border-kumo-line rounded-md text-xs bg-kumo-base text-kumo-default"
                  />
                </div>

                <div>
                  <label
                    htmlFor="gateway-id"
                    className="text-xs text-kumo-secondary block mb-1"
                  >
                    Gateway ID <span className="text-kumo-danger">*</span>
                  </label>
                  <input
                    type="text"
                    value={gatewayId || ""}
                    onChange={(e) => onGatewayIdChange(e.target.value)}
                    placeholder="AI Gateway ID"
                    required
                    className="w-full p-1.5 border border-kumo-line rounded-md text-xs bg-kumo-base text-kumo-default"
                  />
                </div>

                <div>
                  <label
                    htmlFor="cloudflare-api-key"
                    className="text-xs text-kumo-secondary block mb-1"
                  >
                    Cloudflare API Key{" "}
                    <span className="text-kumo-danger">*</span>
                  </label>
                  <div className="relative">
                    <input
                      type={showGatewayKey ? "text" : "password"}
                      value={gatewayApiKey || ""}
                      onChange={(e) => onGatewayApiKeyChange(e.target.value)}
                      placeholder="Cloudflare API key"
                      required
                      className="w-full p-1.5 pr-8 border border-kumo-line rounded-md text-xs bg-kumo-base text-kumo-default"
                    />
                    <button
                      type="button"
                      onClick={() => setShowGatewayKey(!showGatewayKey)}
                      className="absolute right-2 top-1/2 -translate-y-1/2 text-kumo-secondary hover:text-kumo-default"
                    >
                      {showGatewayKey ? (
                        <EyeSlashIcon size={14} />
                      ) : (
                        <EyeIcon size={14} />
                      )}
                    </button>
                  </div>
                </div>

                <div className="bg-kumo-info/10 border border-kumo-info/30 rounded-md p-1.5">
                  <p className="text-[11px] text-kumo-info leading-tight">
                    <strong>Unified Billing:</strong> Uses Cloudflare credits.{" "}
                    <a
                      href="https://dash.cloudflare.com/?to=/:account/ai/ai-gateway"
                      target="_blank"
                      rel="noopener noreferrer"
                      className="underline hover:opacity-80"
                    >
                      Load credits
                    </a>
                    .
                  </p>
                </div>
              </div>
            )}
          </div>
        </div>
      )}
    </div>
  );
};

export default UnifiedModelSelector;