@marimo-team/frontend 0.19.3-dev47 → 0.19.3-dev48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/assets/{CellStatus-b7Yo2X9j.js → CellStatus--kUu6N2K.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-Cr6-n9Em.js → ConnectedDataExplorerComponent-BKJwCHu7.js} +1 -1
  3. package/dist/assets/{JsonOutput-C8Eo1zBR.js → JsonOutput-BSGE-MRo.js} +1 -1
  4. package/dist/assets/{MarimoErrorOutput-CXBGzjO2.js → MarimoErrorOutput-CX0SCJOZ.js} +1 -1
  5. package/dist/assets/{RenderHTML-SoetmcW2.js → RenderHTML-Do_PVqRy.js} +1 -1
  6. package/dist/assets/{add-cell-with-ai-D2qS3Nos.js → add-cell-with-ai-manh7kBT.js} +1 -1
  7. package/dist/assets/{add-database-form-BBkiGMZ_.js → add-database-form-CgkV0MRs.js} +1 -1
  8. package/dist/assets/{agent-panel-BzV4XUTo.js → agent-panel-D-OmT-rw.js} +1 -1
  9. package/dist/assets/{ai-model-dropdown-CrMTCgo7.js → ai-model-dropdown-DzyBY5VA.js} +1 -1
  10. package/dist/assets/{app-config-button-9izWmQ0X.js → app-config-button-4H0CJmIo.js} +1 -1
  11. package/dist/assets/{cell-editor-Do6lWWk9.js → cell-editor-RHFZmO74.js} +1 -1
  12. package/dist/assets/{cell-link-BP7_Ns0N.js → cell-link-Dqj_nfXA.js} +1 -1
  13. package/dist/assets/{cells-Cv9PtwL9.js → cells-BNQUQiDS.js} +1 -1
  14. package/dist/assets/{chat-components-Be6BPrbT.js → chat-components-CWiXtKu6.js} +1 -1
  15. package/dist/assets/{chat-display-BRKfnhbm.js → chat-display-CGnOamQG.js} +1 -1
  16. package/dist/assets/{chat-panel-71zcilvi.js → chat-panel-CVFlznA-.js} +1 -1
  17. package/dist/assets/{client-CGOlSEYr.js → client-CDjmJmVw.js} +1 -1
  18. package/dist/assets/{column-preview-MC6VOHbd.js → column-preview-CKxT2s-S.js} +1 -1
  19. package/dist/assets/{command-n_oMaKjl.js → command-YPFTinLj.js} +1 -1
  20. package/dist/assets/{command-palette-DfZNcw7W.js → command-palette-FssCj6Ds.js} +1 -1
  21. package/dist/assets/{common-MUZIZluQ.js → common-DJkPpBxC.js} +1 -1
  22. package/dist/assets/{config-DFDEcYvy.js → config-D6nhy4FA.js} +1 -1
  23. package/dist/assets/{datasource-CEsMStKs.js → datasource-DerBLc6V.js} +1 -1
  24. package/dist/assets/{dependency-graph-panel-CNTGbfLZ.js → dependency-graph-panel-Vd-OsVLa.js} +1 -1
  25. package/dist/assets/{documentation-panel-Cb9AHO2C.js → documentation-panel-xG2-zpwg.js} +1 -1
  26. package/dist/assets/{download-24bI2vH0.js → download-B6EJS7Ar.js} +1 -1
  27. package/dist/assets/{edit-page-DSuXLdcn.js → edit-page-DoH9XFiW.js} +3 -3
  28. package/dist/assets/{error-panel-CpYH0GfR.js → error-panel-BxBpZYvt.js} +1 -1
  29. package/dist/assets/{es-BITbuY9w.js → es-BoHEdemq.js} +1 -1
  30. package/dist/assets/{file-explorer-panel-CdA81LHh.js → file-explorer-panel-C9K0vIPl.js} +1 -1
  31. package/dist/assets/{floating-outline-BbJ4ldyu.js → floating-outline-DCrTuu2G.js} +1 -1
  32. package/dist/assets/{focus-D1y1tXyC.js → focus-DM53w5BH.js} +1 -1
  33. package/dist/assets/{form-BAtvsPJL.js → form-BcKfhfZc.js} +1 -1
  34. package/dist/assets/{glide-data-editor-Dv8ZW9dk.js → glide-data-editor-CRb9AiCG.js} +1 -1
  35. package/dist/assets/{globals-C6OH39EA.js → globals-Bf30kOQF.js} +1 -1
  36. package/dist/assets/{home-page-B_YprqxM.js → home-page-CCegkRxN.js} +1 -1
  37. package/dist/assets/{index-VUoDw_Qb.js → index-BVybQnue.js} +6 -6
  38. package/dist/assets/index-DDc_1b-N.css +2 -0
  39. package/dist/assets/{kiosk-mode-DfyjlR7p.js → kiosk-mode-P-NYHJID.js} +1 -1
  40. package/dist/assets/{layout-9uQoV-6h.js → layout-DT91GUei.js} +1 -1
  41. package/dist/assets/links-D529u6GQ.js +1 -0
  42. package/dist/assets/{logs-panel-svcirwjp.js → logs-panel-C2dfrRig.js} +1 -1
  43. package/dist/assets/{markdown-renderer-DlVqlHOL.js → markdown-renderer-BPnVa0ym.js} +1 -1
  44. package/dist/assets/{mermaid-BPkO79lo.js → mermaid--ZwxKP7u.js} +1 -1
  45. package/dist/assets/{mode-PeuS_Lp-.js → mode-Dq8MKjNR.js} +1 -1
  46. package/dist/assets/{name-cell-input-YMoA0SQj.js → name-cell-input-BaEPC7ON.js} +1 -1
  47. package/dist/assets/{outline-panel-RKJ5Mqrt.js → outline-panel-Cca864H0.js} +1 -1
  48. package/dist/assets/{packages-panel-BuiAGEBw.js → packages-panel-BwDA3cjR.js} +1 -1
  49. package/dist/assets/{panels-BKsZUDjc.js → panels-BzlLZfye.js} +1 -1
  50. package/dist/assets/{process-output-KJWsSvCT.js → process-output-Dn1rOp26.js} +1 -1
  51. package/dist/assets/{readonly-python-code-HPlG_YPX.js → readonly-python-code-CXeF74Iq.js} +1 -1
  52. package/dist/assets/{renderShortcut-D0Pei-OA.js → renderShortcut-eU5Hsfml.js} +1 -1
  53. package/dist/assets/{run-page-CBDzVDX3.js → run-page-CM_n6pXD.js} +1 -1
  54. package/dist/assets/{scratchpad-panel-CarbQVYs.js → scratchpad-panel-XCkVY3Hp.js} +1 -1
  55. package/dist/assets/{session-panel-Cv14Ehfm.js → session-panel-BDt6Y_mU.js} +1 -1
  56. package/dist/assets/{snippets-panel-OAdQXQ93.js → snippets-panel-K-JKJQBf.js} +1 -1
  57. package/dist/assets/state-BTHUBVxX.js +1 -0
  58. package/dist/assets/{state-xh6GqNrp.js → state-DWRZTH2y.js} +1 -1
  59. package/dist/assets/{switch-DPeh0R76.js → switch-RowEjq0T.js} +1 -1
  60. package/dist/assets/{terminal-BbAhzgnR.js → terminal-BhbNfCNw.js} +1 -1
  61. package/dist/assets/{textarea-wbzgrXvB.js → textarea-Di1KKcL4.js} +1 -1
  62. package/dist/assets/{tracing-Bh3EJxAS.js → tracing-nvbrZdpf.js} +1 -1
  63. package/dist/assets/{tracing-panel-BzSQ7qvB.js → tracing-panel-CTXJaO-A.js} +2 -2
  64. package/dist/assets/{types-B8Qb1FfB.js → types-CT2U5Ljy.js} +1 -1
  65. package/dist/assets/{useAddCell-DBGvrN8K.js → useAddCell-COb93CUl.js} +1 -1
  66. package/dist/assets/{useBoolean-CyOFPk5r.js → useBoolean-B_S7yTZz.js} +1 -1
  67. package/dist/assets/{useCellActionButton-BlS_HKk-.js → useCellActionButton-D5Zt1dDz.js} +1 -1
  68. package/dist/assets/{useDeleteCell-BvQIJfpI.js → useDeleteCell-DHF_xvAh.js} +1 -1
  69. package/dist/assets/{useDependencyPanelTab-BqEhbPr2.js → useDependencyPanelTab-D59iW_MD.js} +1 -1
  70. package/dist/assets/{useNotebookActions-D1Woz3AV.js → useNotebookActions-Ck-yX5ub.js} +1 -1
  71. package/dist/assets/{useRunCells-B9Xr4tcH.js → useRunCells-CKEmgeKM.js} +1 -1
  72. package/dist/assets/{useSplitCell-7xBW3b8-.js → useSplitCell-D9YiO-z5.js} +1 -1
  73. package/dist/assets/{useTheme-DfP1CWaW.js → useTheme-CNj0G_ol.js} +1 -1
  74. package/dist/assets/{utilities.esm-xahhGpny.js → utilities.esm-DG4qccZc.js} +1 -1
  75. package/dist/assets/utils-pfqq9IdB.js +1 -0
  76. package/dist/assets/{vega-component-dUiiVmIx.js → vega-component-C1voDf5W.js} +1 -1
  77. package/dist/index.html +37 -37
  78. package/package.json +1 -1
  79. package/src/components/app-config/ai-config.tsx +316 -24
  80. package/src/components/app-config/user-config-form.tsx +9 -2
  81. package/src/core/ai/ids/ids.ts +12 -4
  82. package/src/core/config/__tests__/config-schema.test.ts +36 -0
  83. package/src/core/config/config-schema.ts +1 -0
  84. package/dist/assets/index-C30GhE0W.css +0 -2
  85. package/dist/assets/links-DbDrjRnm.js +0 -1
  86. package/dist/assets/state-DYG6kYly.js +0 -1
  87. package/dist/assets/utils-CJJIceVn.js +0 -1
@@ -34,7 +34,8 @@ import { Textarea } from "@/components/ui/textarea";
34
34
  import type { SupportedRole } from "@/core/ai/config";
35
35
  import {
36
36
  AiModelId,
37
- PROVIDERS,
37
+ KNOWN_PROVIDERS,
38
+ type KnownProviderId,
38
39
  type ProviderId,
39
40
  type QualifiedModelId,
40
41
  type ShortModelId,
@@ -90,6 +91,11 @@ interface AiProviderTitleProps {
90
91
  children: React.ReactNode;
91
92
  }
92
93
 
94
+ interface CustomProviderConfig {
95
+ api_key?: string;
96
+ base_url?: string;
97
+ }
98
+
93
99
  export const AiProviderTitle: React.FC<AiProviderTitleProps> = ({
94
100
  provider,
95
101
  children,
@@ -555,15 +561,18 @@ const AccordionFormItem = ({
555
561
  provider,
556
562
  children,
557
563
  isConfigured,
564
+ value,
558
565
  }: {
559
566
  title: string;
560
567
  triggerClassName?: string;
561
568
  provider: AiProviderIconProps["provider"];
562
569
  children: React.ReactNode;
563
570
  isConfigured: boolean;
571
+ /** Custom value for the accordion item. Defaults to provider. */
572
+ value?: string;
564
573
  }) => {
565
574
  return (
566
- <AccordionItem value={provider}>
575
+ <AccordionItem value={value ?? provider}>
567
576
  <AccordionTrigger className={triggerClassName}>
568
577
  <AiProviderTitle provider={provider}>
569
578
  {title}
@@ -581,9 +590,228 @@ const AccordionFormItem = ({
581
590
  );
582
591
  };
583
592
 
593
+ export const CustomProvidersConfig: React.FC<AiConfigProps> = ({
594
+ form,
595
+ config,
596
+ onSubmit,
597
+ }) => {
598
+ const [isAddingProvider, setIsAddingProvider] = useState(false);
599
+ const [newProviderName, setNewProviderName] = useState("");
600
+ const [newProviderApiKey, setNewProviderApiKey] = useState("");
601
+ const [newProviderBaseUrl, setNewProviderBaseUrl] = useState("");
602
+
603
+ const providerNameInputId = useId();
604
+ const apiKeyInputId = useId();
605
+ const baseUrlInputId = useId();
606
+
607
+ const normalizedName = newProviderName.toLowerCase().replaceAll(/\s+/g, "_");
608
+ const customProviders = form.watch("ai.custom_providers");
609
+ const isDuplicate =
610
+ KNOWN_PROVIDERS.includes(normalizedName as KnownProviderId) ||
611
+ (customProviders && Object.keys(customProviders).includes(normalizedName));
612
+
613
+ const hasValidValues =
614
+ normalizedName.trim() && newProviderBaseUrl.trim() && !isDuplicate;
615
+
616
+ const resetForm = () => {
617
+ setNewProviderName("");
618
+ setNewProviderApiKey("");
619
+ setNewProviderBaseUrl("");
620
+ setIsAddingProvider(false);
621
+ };
622
+
623
+ return (
624
+ <FormField
625
+ control={form.control}
626
+ name="ai.custom_providers"
627
+ render={({ field }) => {
628
+ const customProviders = (field.value || {}) as Record<
629
+ string,
630
+ CustomProviderConfig
631
+ >;
632
+ const customProviderEntries = Object.entries(customProviders);
633
+
634
+ const addProvider = () => {
635
+ if (!hasValidValues) {
636
+ return;
637
+ }
638
+ field.onChange({
639
+ ...customProviders,
640
+ [normalizedName]: {
641
+ api_key: newProviderApiKey || undefined,
642
+ base_url: newProviderBaseUrl,
643
+ },
644
+ });
645
+ onSubmit(form.getValues());
646
+ resetForm();
647
+ };
648
+
649
+ const removeProvider = (providerName: string) => {
650
+ const { [providerName]: _, ...rest } = customProviders;
651
+ // Reset to clear nested dirty state, then set new value
652
+ form.resetField("ai.custom_providers");
653
+ form.setValue("ai.custom_providers", rest, { shouldDirty: true });
654
+ onSubmit(form.getValues());
655
+ };
656
+
657
+ const providerForm = (
658
+ <div className="flex flex-col gap-3 p-4 border border-border rounded-md bg-muted/20">
659
+ <div className="flex flex-col gap-1.5">
660
+ <Label htmlFor={providerNameInputId}>Provider Name</Label>
661
+ <Input
662
+ id={providerNameInputId}
663
+ placeholder="e.g., together, groq, mistral"
664
+ value={newProviderName}
665
+ onChange={(e) => setNewProviderName(e.target.value)}
666
+ />
667
+ {isDuplicate && (
668
+ <p className="text-xs text-destructive">
669
+ A provider with this name already exists.
670
+ </p>
671
+ )}
672
+ {newProviderName && (
673
+ <p className="text-xs text-muted-secondary">
674
+ Use models with prefix:{" "}
675
+ <Kbd className="inline text-xs">{normalizedName}/</Kbd>
676
+ </p>
677
+ )}
678
+ </div>
679
+
680
+ <div className="flex flex-col gap-1.5">
681
+ <Label htmlFor={baseUrlInputId}>
682
+ Base URL <span className="text-destructive">*</span>
683
+ </Label>
684
+ <Input
685
+ id={baseUrlInputId}
686
+ placeholder="e.g., https://api.together.xyz/v1"
687
+ value={newProviderBaseUrl}
688
+ onChange={(e) => setNewProviderBaseUrl(e.target.value)}
689
+ />
690
+ </div>
691
+
692
+ <div className="flex flex-col gap-1.5">
693
+ <Label htmlFor={apiKeyInputId}>API Key (optional)</Label>
694
+ <Input
695
+ id={apiKeyInputId}
696
+ placeholder="sk-..."
697
+ type="password"
698
+ value={newProviderApiKey}
699
+ onChange={(e) => setNewProviderApiKey(e.target.value)}
700
+ />
701
+ </div>
702
+
703
+ <div className="flex gap-2 mt-1">
704
+ <Button
705
+ onClick={addProvider}
706
+ disabled={!hasValidValues}
707
+ size="xs"
708
+ >
709
+ Add Provider
710
+ </Button>
711
+ <Button variant="outline" onClick={resetForm} size="xs">
712
+ Cancel
713
+ </Button>
714
+ </div>
715
+ </div>
716
+ );
717
+
718
+ const renderAccordionItem = ({
719
+ providerName,
720
+ providerConfig,
721
+ onRemove,
722
+ }: {
723
+ providerName: string;
724
+ providerConfig: CustomProviderConfig;
725
+ onRemove: (name: string) => void;
726
+ }) => {
727
+ const displayName = Strings.startCase(providerName);
728
+ const isConfigured =
729
+ !!providerConfig.api_key || !!providerConfig.base_url;
730
+
731
+ return (
732
+ <AccordionFormItem
733
+ key={`custom-${providerName}`}
734
+ title={displayName}
735
+ provider={providerName}
736
+ value={`custom-${providerName}`}
737
+ isConfigured={isConfigured}
738
+ >
739
+ <ApiKey
740
+ form={form}
741
+ config={config}
742
+ name={
743
+ `ai.custom_providers.${providerName}.api_key` as FieldPath<UserConfig>
744
+ }
745
+ placeholder="sk-..."
746
+ testId={`custom-provider-${providerName}-api-key`}
747
+ />
748
+ <BaseUrl
749
+ form={form}
750
+ config={config}
751
+ name={
752
+ `ai.custom_providers.${providerName}.base_url` as FieldPath<UserConfig>
753
+ }
754
+ placeholder="https://api.example.com/v1"
755
+ testId={`custom-provider-${providerName}-base-url`}
756
+ />
757
+ <Button
758
+ variant="destructive"
759
+ size="xs"
760
+ onClick={(e) => {
761
+ e.stopPropagation();
762
+ e.preventDefault();
763
+ onRemove(providerName);
764
+ }}
765
+ className="w-fit self-end"
766
+ >
767
+ <Trash2Icon className="h-4 w-4 mr-2" />
768
+ Remove Provider
769
+ </Button>
770
+ </AccordionFormItem>
771
+ );
772
+ };
773
+
774
+ return (
775
+ <SettingGroup>
776
+ <SettingSubtitle>Custom Providers</SettingSubtitle>
777
+ <p className="text-sm text-muted-secondary">
778
+ Add your own OpenAI-compatible provider. Once added, you can
779
+ configure models in the AI Models tab.
780
+ </p>
781
+
782
+ {customProviderEntries.length > 0 && (
783
+ <Accordion type="multiple" className="-mt-4">
784
+ {customProviderEntries.map(([name, providerConfig]) =>
785
+ renderAccordionItem({
786
+ providerName: name,
787
+ providerConfig,
788
+ onRemove: removeProvider,
789
+ }),
790
+ )}
791
+ </Accordion>
792
+ )}
793
+
794
+ {isAddingProvider ? (
795
+ providerForm
796
+ ) : (
797
+ <AddButton
798
+ className="self-start"
799
+ isFormOpen={isAddingProvider}
800
+ setIsFormOpen={setIsAddingProvider}
801
+ label="Add Provider"
802
+ />
803
+ )}
804
+ </SettingGroup>
805
+ );
806
+ }}
807
+ />
808
+ );
809
+ };
810
+
584
811
  export const AiProvidersConfig: React.FC<AiConfigProps> = ({
585
812
  form,
586
813
  config,
814
+ onSubmit,
587
815
  }) => {
588
816
  const isWasmRuntime = isWasm();
589
817
 
@@ -903,13 +1131,17 @@ export const AiProvidersConfig: React.FC<AiConfigProps> = ({
903
1131
  </AccordionFormItem>
904
1132
 
905
1133
  <AccordionFormItem
906
- title="OpenAI-Compatible"
1134
+ title="OpenAI-Compatible (Legacy)"
907
1135
  provider="openai-compatible"
908
1136
  isConfigured={
909
1137
  hasValue("ai.open_ai_compatible.api_key") &&
910
1138
  hasValue("ai.open_ai_compatible.base_url")
911
1139
  }
912
1140
  >
1141
+ <p className="text-sm text-amber-600 dark:text-amber-400 mb-2">
1142
+ Consider using Custom Providers instead, which allows you to add
1143
+ multiple providers with distinct names.
1144
+ </p>
913
1145
  <ApiKey
914
1146
  form={form}
915
1147
  config={config}
@@ -933,6 +1165,8 @@ export const AiProvidersConfig: React.FC<AiConfigProps> = ({
933
1165
  />
934
1166
  </AccordionFormItem>
935
1167
  </Accordion>
1168
+
1169
+ <CustomProvidersConfig form={form} config={config} onSubmit={onSubmit} />
936
1170
  </SettingGroup>
937
1171
  );
938
1172
  };
@@ -1133,6 +1367,16 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1133
1367
  name: "ai.models.custom_models",
1134
1368
  }) as QualifiedModelId[];
1135
1369
 
1370
+ const customProviders = useWatch({
1371
+ control: form.control,
1372
+ name: "ai.custom_providers",
1373
+ }) as Record<string, CustomProviderConfig> | undefined;
1374
+
1375
+ const customProviderNames = useMemo(
1376
+ () => Object.keys(customProviders || {}),
1377
+ [customProviders],
1378
+ );
1379
+
1136
1380
  const aiModelRegistry = useMemo(
1137
1381
  () =>
1138
1382
  AiModelRegistry.create({
@@ -1212,6 +1456,7 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1212
1456
  <AddModelForm
1213
1457
  form={form}
1214
1458
  customModels={customModels}
1459
+ customProviderNames={customProviderNames}
1215
1460
  onSubmit={onSubmit}
1216
1461
  />
1217
1462
  </SettingGroup>
@@ -1221,8 +1466,9 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1221
1466
  export const AddModelForm: React.FC<{
1222
1467
  form: UseFormReturn<UserConfig>;
1223
1468
  customModels: QualifiedModelId[];
1469
+ customProviderNames: string[];
1224
1470
  onSubmit: (values: UserConfig) => void;
1225
- }> = ({ form, customModels, onSubmit }) => {
1471
+ }> = ({ form, customModels, customProviderNames, onSubmit }) => {
1226
1472
  const [isFormOpen, setIsFormOpen] = useState(false);
1227
1473
  const [modelAdded, setModelAdded] = useState(false);
1228
1474
  const [provider, setProvider] = useState<ProviderId | "custom" | null>(null);
@@ -1293,23 +1539,46 @@ export const AddModelForm: React.FC<{
1293
1539
  </SelectTrigger>
1294
1540
  <SelectContent>
1295
1541
  <SelectGroup>
1542
+ {customProviderNames.length > 0 && (
1543
+ <>
1544
+ <p className="px-2 py-1 text-xs text-muted-secondary font-medium">
1545
+ Custom Providers
1546
+ </p>
1547
+ {customProviderNames.map((p) => (
1548
+ <SelectItem key={p} value={p}>
1549
+ <div className="flex items-center gap-2">
1550
+ <AiProviderIcon provider={p} className="h-4 w-4" />
1551
+ <span>{Strings.startCase(p)}</span>
1552
+ </div>
1553
+ </SelectItem>
1554
+ ))}
1555
+ <p className="px-2 py-1 text-xs text-muted-secondary font-medium mt-1">
1556
+ Built-in Providers
1557
+ </p>
1558
+ </>
1559
+ )}
1560
+ {KNOWN_PROVIDERS.filter(
1561
+ (p) => p !== "marimo" && !customProviderNames.includes(p),
1562
+ ).map((p) => (
1563
+ <SelectItem key={p} value={p}>
1564
+ <div className="flex items-center gap-2">
1565
+ <AiProviderIcon provider={p} className="h-4 w-4" />
1566
+ <span>{getProviderLabel(p)}</span>
1567
+ </div>
1568
+ </SelectItem>
1569
+ ))}
1570
+ <p className="px-2 py-1 text-xs text-muted-secondary font-medium mt-1">
1571
+ Other
1572
+ </p>
1296
1573
  <SelectItem value="custom">
1297
1574
  <div className="flex items-center gap-2">
1298
1575
  <AiProviderIcon
1299
1576
  provider="openai-compatible"
1300
1577
  className="h-4 w-4"
1301
1578
  />
1302
- <span>Custom</span>
1579
+ <span>Enter provider name</span>
1303
1580
  </div>
1304
1581
  </SelectItem>
1305
- {PROVIDERS.filter((p) => p !== "marimo").map((p) => (
1306
- <SelectItem key={p} value={p}>
1307
- <div className="flex items-center gap-2">
1308
- <AiProviderIcon provider={p} className="h-4 w-4" />
1309
- <span>{getProviderLabel(p)}</span>
1310
- </div>
1311
- </SelectItem>
1312
- ))}
1313
1582
  </SelectGroup>
1314
1583
  </SelectContent>
1315
1584
  </Select>
@@ -1379,17 +1648,12 @@ export const AddModelForm: React.FC<{
1379
1648
  <div>
1380
1649
  {isFormOpen && inputForm}
1381
1650
  <div className="flex flex-row text-sm">
1382
- <Button
1383
- onClick={(e) => {
1384
- e.preventDefault();
1385
- setIsFormOpen(true);
1386
- }}
1387
- variant="link"
1388
- disabled={isFormOpen}
1389
- >
1390
- <PlusIcon className="h-4 w-4 mr-2 mb-0.5" />
1391
- Add Model
1392
- </Button>
1651
+ <AddButton
1652
+ isFormOpen={isFormOpen}
1653
+ setIsFormOpen={setIsFormOpen}
1654
+ label="Add Model"
1655
+ className="pl-2"
1656
+ />
1393
1657
  {modelAdded && (
1394
1658
  <div className="flex items-center gap-1 text-green-700 bg-green-500/10 px-2 py-1 rounded-md ml-auto">
1395
1659
  ✓ Model added
@@ -1400,6 +1664,34 @@ export const AddModelForm: React.FC<{
1400
1664
  );
1401
1665
  };
1402
1666
 
1667
+ const AddButton = ({
1668
+ isFormOpen,
1669
+ setIsFormOpen,
1670
+ label,
1671
+ className,
1672
+ }: {
1673
+ isFormOpen: boolean;
1674
+ setIsFormOpen: (isOpen: boolean) => void;
1675
+ label: string;
1676
+ className?: string;
1677
+ }) => {
1678
+ return (
1679
+ <Button
1680
+ onClick={(e) => {
1681
+ e.stopPropagation();
1682
+ e.preventDefault();
1683
+ setIsFormOpen(true);
1684
+ }}
1685
+ variant="link"
1686
+ disabled={isFormOpen}
1687
+ className={cn("px-0", className)}
1688
+ >
1689
+ <PlusIcon className="h-4 w-4 mr-2 mb-0.5" />
1690
+ {label}
1691
+ </Button>
1692
+ );
1693
+ };
1694
+
1403
1695
  export const AiConfig: React.FC<AiConfigProps> = ({
1404
1696
  form,
1405
1697
  config,
@@ -71,12 +71,19 @@ export function getDirtyValues<T extends FieldValues>(
71
71
  const result: Partial<T> = {};
72
72
  for (const key of Object.keys(dirtyFields) as (keyof T)[]) {
73
73
  const dirty = dirtyFields[key];
74
+ const value = values[key];
75
+
76
+ // Skip if the value no longer exists (e.g., deleted from a record)
77
+ if (value === undefined) {
78
+ continue;
79
+ }
80
+
74
81
  if (dirty === true) {
75
- result[key] = values[key];
82
+ result[key] = value;
76
83
  } else if (typeof dirty === "object" && dirty !== null) {
77
84
  // Nested object - recurse
78
85
  const nested = getDirtyValues(
79
- values[key] as FieldValues,
86
+ value as FieldValues,
80
87
  dirty as Partial<Record<string, unknown>>,
81
88
  );
82
89
  if (Object.keys(nested).length > 0) {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { TypedString } from "@/utils/typed";
4
4
 
5
- export const PROVIDERS = [
5
+ export const KNOWN_PROVIDERS = [
6
6
  "openai",
7
7
  "anthropic",
8
8
  "google",
@@ -15,7 +15,13 @@ export const PROVIDERS = [
15
15
  "wandb",
16
16
  "marimo",
17
17
  ] as const;
18
- export type ProviderId = (typeof PROVIDERS)[number];
18
+ export type KnownProviderId = (typeof KNOWN_PROVIDERS)[number];
19
+
20
+ /**
21
+ * Provider ID can be a known provider or a custom string
22
+ * The (string & {}) pattern allows any string while still providing autocomplete for known providers
23
+ */
24
+ export type ProviderId = KnownProviderId | (string & {});
19
25
 
20
26
  export type ShortModelId = TypedString<"ShortModelId">;
21
27
 
@@ -68,6 +74,8 @@ function guessProviderId(id: string): ProviderId {
68
74
  return "ollama";
69
75
  }
70
76
 
71
- export function isKnownAIProvider(providerId: ProviderId): boolean {
72
- return PROVIDERS.includes(providerId);
77
+ export function isKnownAIProvider(
78
+ providerId: string,
79
+ ): providerId is KnownProviderId {
80
+ return (KNOWN_PROVIDERS as readonly string[]).includes(providerId);
73
81
  }
@@ -45,6 +45,7 @@ test("default UserConfig - empty", () => {
45
45
  expect(defaultConfig).toMatchInlineSnapshot(`
46
46
  {
47
47
  "ai": {
48
+ "custom_providers": {},
48
49
  "inline_tooltip": false,
49
50
  "mode": "manual",
50
51
  "models": {
@@ -114,6 +115,7 @@ test("default UserConfig - one level", () => {
114
115
  expect(defaultConfig).toMatchInlineSnapshot(`
115
116
  {
116
117
  "ai": {
118
+ "custom_providers": {},
117
119
  "inline_tooltip": false,
118
120
  "mode": "manual",
119
121
  "models": {
@@ -198,6 +200,40 @@ test("default UserConfig with additional information", () => {
198
200
  );
199
201
  });
200
202
 
203
+ test("UserConfig with custom_providers", () => {
204
+ const config = UserConfigSchema.parse({
205
+ ai: {
206
+ custom_providers: {
207
+ my_provider: {
208
+ api_key: "test-key",
209
+ base_url: "https://api.example.com/v1",
210
+ },
211
+ another_provider: {
212
+ base_url: "https://api.another.com/v1",
213
+ },
214
+ },
215
+ },
216
+ });
217
+
218
+ expect(config.ai?.custom_providers).toEqual({
219
+ my_provider: {
220
+ api_key: "test-key",
221
+ base_url: "https://api.example.com/v1",
222
+ },
223
+ another_provider: {
224
+ base_url: "https://api.another.com/v1",
225
+ },
226
+ });
227
+ });
228
+
229
+ test("UserConfig custom_providers defaults to empty object", () => {
230
+ const config = UserConfigSchema.parse({
231
+ ai: {},
232
+ });
233
+
234
+ expect(config.ai?.custom_providers).toEqual({});
235
+ });
236
+
201
237
  test("resolvedMarimoConfigAtom overrides correctly and does not mutate the original array", () => {
202
238
  const initialUserConfig = {
203
239
  completion: {
@@ -175,6 +175,7 @@ export const UserConfigSchema = z
175
175
  aws_secret_access_key: z.string().optional(),
176
176
  })
177
177
  .optional(),
178
+ custom_providers: z.record(z.string(), AiConfigSchema).prefault({}),
178
179
  models: AiModelsSchema.prefault({
179
180
  displayed_models: [],
180
181
  custom_models: [],