@marimo-team/frontend 0.24.1-dev59 → 0.24.1-dev60

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 (113) hide show
  1. package/dist/assets/{CellStatus-D6_pLs0B.js → CellStatus-DeTZJKkI.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-DZnvpjue.js → ConnectedDataExplorerComponent-CsYDGDdB.js} +1 -1
  3. package/dist/assets/{JsonOutput-D0JlHn1Z.js → JsonOutput-CucHb-Xd.js} +11 -11
  4. package/dist/assets/{MarimoErrorOutput-CYOWXi9k.js → MarimoErrorOutput-9BDdSrft.js} +2 -2
  5. package/dist/assets/{RenderHTML-qnAqp9Q0.js → RenderHTML-9AmE10az.js} +1 -1
  6. package/dist/assets/{RunButton-BDkLTxbY.js → RunButton-B1OO4yPE.js} +1 -1
  7. package/dist/assets/{merge-DxlVKwwO.js → _baseMerge-DLqSVasz.js} +1 -1
  8. package/dist/assets/{add-cell-with-ai-DVGZNNUv.js → add-cell-with-ai-B382kx-8.js} +8 -8
  9. package/dist/assets/{add-connection-dialog-CnRoNzHS.js → add-connection-dialog-CCgzVaXE.js} +1 -1
  10. package/dist/assets/{agent-panel-BTY3oeHD.js → agent-panel-tC3KSuFA.js} +1 -1
  11. package/dist/assets/ai-model-dropdown-BwTphSMd.js +3 -0
  12. package/dist/assets/{app-config-button-CB6s4qUy.js → app-config-button-C3LicTtk.js} +1 -1
  13. package/dist/assets/{blob-Dza7uUlT.js → blob-EfjbJ__b.js} +1 -1
  14. package/dist/assets/{cell-editor-D3YsOG7a.js → cell-editor-CR6poe2J.js} +7 -7
  15. package/dist/assets/{cell-link-DqJmxVu3.js → cell-link-q2lv7md_.js} +1 -1
  16. package/dist/assets/{cells-DfJw5iuV.js → cells-v0dAmt79.js} +18 -18
  17. package/dist/assets/{chat-display-NTNQQyoE.js → chat-display-C-KzQ4Sl.js} +1 -1
  18. package/dist/assets/{chat-panel-Bow0QKrA.js → chat-panel-BM8a5kkg.js} +3 -3
  19. package/dist/assets/{chat-ui-Cg69zAqt.js → chat-ui-B10QUDuE.js} +1 -1
  20. package/dist/assets/{chunk-5RXB4S5H-DHd0BPMu.js → chunk-5RXB4S5H-CUU6y4KG.js} +1 -1
  21. package/dist/assets/{chunk-GF5L2VYU-DidcU72l.js → chunk-GF5L2VYU-sioILS_d.js} +1 -1
  22. package/dist/assets/{chunk-J7OUQ5F2-BYO9AUEf.js → chunk-J7OUQ5F2-CO4p8tJJ.js} +2 -2
  23. package/dist/assets/{chunk-JQJVKLGR-BO9dy1YE.js → chunk-JQJVKLGR-BVs0Ec6r.js} +1 -1
  24. package/dist/assets/{classDiagram-JCYQIIEL-DXciwtwD.js → classDiagram-JCYQIIEL-DoI0wWRt.js} +1 -1
  25. package/dist/assets/{classDiagram-v2-OCEON4UE-DXciwtwD.js → classDiagram-v2-OCEON4UE-DoI0wWRt.js} +1 -1
  26. package/dist/assets/{column-preview-BdNXfBsJ.js → column-preview-DahZx7i3.js} +1 -1
  27. package/dist/assets/command-palette-DLDqpso1.js +1 -0
  28. package/dist/assets/{common-D_BAMDHd.js → common-C-hdr6As.js} +1 -1
  29. package/dist/assets/{components-D3x9DfK0.js → components-Cnt-rCdC.js} +1 -1
  30. package/dist/assets/{components-DwH_Yb1N.js → components-ib4asSo7.js} +1 -1
  31. package/dist/assets/config-Cl3eMWxb.js +1 -0
  32. package/dist/assets/{config-CUBl5qBg.js → config-DaNyNpZL.js} +1 -1
  33. package/dist/assets/dagre-B79OegKz.js +1 -0
  34. package/dist/assets/{dagre-VZM6K2ZE-Bztx5p0h.js → dagre-VZM6K2ZE-Bp1Dxcme.js} +1 -1
  35. package/dist/assets/{datasource-B9lSU_L0.js → datasource-Bq9EwG3Q.js} +1 -1
  36. package/dist/assets/{dependency-graph-panel-3-8ygeBR.js → dependency-graph-panel-DLgUYv2G.js} +1 -1
  37. package/dist/assets/{documentation-panel-BzJcTCN3.js → documentation-panel-BIqV8dCY.js} +1 -1
  38. package/dist/assets/{download-4OuOU5F0.js → download-D-WORhyV.js} +1 -1
  39. package/dist/assets/{edit-page-ds4edlkF.js → edit-page-rjLWDywQ.js} +6 -6
  40. package/dist/assets/{erDiagram-JOGREHBK-D7AqNG0H.js → erDiagram-JOGREHBK-L1YPulwW.js} +1 -1
  41. package/dist/assets/{error-entries-BCuZmxgk.js → error-entries-DOcZqCs5.js} +1 -1
  42. package/dist/assets/{error-panel-Dk-wFymb.js → error-panel-xPtHRph4.js} +1 -1
  43. package/dist/assets/{file-explorer-panel-Bprnrp3k.js → file-explorer-panel-YhKO1Kb8.js} +1 -1
  44. package/dist/assets/{file-icons-BnE-lUIr.js → file-icons-BI3Xijjz.js} +1 -1
  45. package/dist/assets/{file-name-input-B-KJyr-U.js → file-name-input-B0v9VVzS.js} +1 -1
  46. package/dist/assets/{floating-outline-BebYvSvL.js → floating-outline-fI7KiW7l.js} +1 -1
  47. package/dist/assets/{flowDiagram-UKHOOZJN-uIR0bHjt.js → flowDiagram-UKHOOZJN-dULvoGtY.js} +1 -1
  48. package/dist/assets/{glide-data-editor-BQNVc0zc.js → glide-data-editor-CSrQxrfb.js} +1 -1
  49. package/dist/assets/{home-page-zNwFmOPQ.js → home-page-BMzFHuuq.js} +1 -1
  50. package/dist/assets/{index-B36dhEV6.js → index-B2a_xizI.js} +8 -8
  51. package/dist/assets/{kiosk-mode-B2WU6CYc.js → kiosk-mode-CjT3sqy8.js} +1 -1
  52. package/dist/assets/{layout-iNJ-1JI2.js → layout-Dqce0OL2.js} +5 -5
  53. package/dist/assets/{logs-panel-CXu4QQwD.js → logs-panel-BlKmgk9O.js} +1 -1
  54. package/dist/assets/{markdown-renderer-BUBFaUWx.js → markdown-renderer-BHFj8yJ6.js} +1 -1
  55. package/dist/assets/{mermaid-CwvTqKGz.js → mermaid-BmbD1i8H.js} +3 -3
  56. package/dist/assets/{mindmap-definition-FAOFIHXS-DhzRwTNz.js → mindmap-definition-FAOFIHXS-aLgvJUwH.js} +1 -1
  57. package/dist/assets/{name-cell-input-DbRrAlCe.js → name-cell-input-9y9cdffD.js} +1 -1
  58. package/dist/assets/{outline-panel-16ugwsMh.js → outline-panel-BkyU6F0r.js} +1 -1
  59. package/dist/assets/{packages-panel-8OASpr5S.js → packages-panel-DGuBWpad.js} +1 -1
  60. package/dist/assets/{pair-with-agent-modal-CBIL9_6g.js → pair-with-agent-modal-TbFSfxnC.js} +1 -1
  61. package/dist/assets/{panel-accordion-state-786YBa3I.js → panel-accordion-state-C2vAFKpP.js} +1 -1
  62. package/dist/assets/panels-ZCSZ8vcg.js +4 -0
  63. package/dist/assets/{process-output-NwNkpkfY.js → process-output-DmEilKB6.js} +1 -1
  64. package/dist/assets/{readonly-code-display-Ce9Vb5Wf.js → readonly-code-display-2N0THiJK.js} +1 -1
  65. package/dist/assets/{readonly-python-code-CQd3Bopx.js → readonly-python-code-DyB_sdtx.js} +1 -1
  66. package/dist/assets/{renderShortcut-D9XYVNe3.js → renderShortcut-CS4ccH3x.js} +1 -1
  67. package/dist/assets/{requirementDiagram-LRYGKXZP-DcON47aG.js → requirementDiagram-LRYGKXZP-BOaR1V0o.js} +1 -1
  68. package/dist/assets/{reveal-component-C4pF9OE7.js → reveal-component-DmwU8_uO.js} +3 -3
  69. package/dist/assets/{run-page-Bo57bjbp.js → run-page-C-fKIihG.js} +1 -1
  70. package/dist/assets/{scratchpad-panel-vBIq9BfA.js → scratchpad-panel-D_0HEFCV.js} +1 -1
  71. package/dist/assets/{session-panel-BdXUO63q.js → session-panel-BAqFjL9-.js} +1 -1
  72. package/dist/assets/{skeleton-B5iAa5n_.js → skeleton-BUOlBrWg.js} +1 -1
  73. package/dist/assets/{snippets-panel-BI6SMTkf.js → snippets-panel-C4dcsNOG.js} +1 -1
  74. package/dist/assets/{state-DmlDv6kf.js → state-C8pDImyF.js} +3 -3
  75. package/dist/assets/{state-CZxxcsV7.js → state-Du63Xg9M.js} +1 -1
  76. package/dist/assets/{stateDiagram-OKZ733FA-CdIMP-aE.js → stateDiagram-OKZ733FA-BZg2y6qd.js} +1 -1
  77. package/dist/assets/{stateDiagram-v2-UEYNNEHI-DaZnzLJO.js → stateDiagram-v2-UEYNNEHI-C1g5a4e0.js} +1 -1
  78. package/dist/assets/{swimlanesDiagram-ULZ7WXOC-DHUJK_QJ.js → swimlanesDiagram-ULZ7WXOC-Cbwe8OlR.js} +1 -1
  79. package/dist/assets/{switch-CBR-ykv3.js → switch-27TbonxF.js} +1 -1
  80. package/dist/assets/{terminal-OC_FnEas.js → terminal-DOqqA9Ha.js} +1 -1
  81. package/dist/assets/{tracing-Bs9V1VRy.js → tracing-DPTkPFxO.js} +1 -1
  82. package/dist/assets/tracing-panel-3SQN2w-o.js +2 -0
  83. package/dist/assets/{tree-actions-A2eVufTn.js → tree-actions-Bn9POqng.js} +1 -1
  84. package/dist/assets/{useAddCell-BBv-60Hs.js → useAddCell-DtdPi8nq.js} +1 -1
  85. package/dist/assets/{useBoolean-DfDMEVCI.js → useBoolean-DWBCpKfT.js} +1 -1
  86. package/dist/assets/{useCellActionButton-DgA6C9_m.js → useCellActionButton-BS300A54.js} +1 -1
  87. package/dist/assets/{useDeleteCell-CqYCNkza.js → useDeleteCell-Ha8ut0yy.js} +1 -1
  88. package/dist/assets/{useDependencyPanelTab-DhGw5Y2R.js → useDependencyPanelTab-DsrUeu8t.js} +1 -1
  89. package/dist/assets/useNotebookActions-DdgIE9oe.js +10 -0
  90. package/dist/assets/{useRunCells-BwYUZxV6.js → useRunCells-CIALOcRX.js} +1 -1
  91. package/dist/assets/{useSplitCell-D0ws6n0g.js → useSplitCell-dEuZjk85.js} +1 -1
  92. package/dist/assets/{useTheme-BRnykVDX.js → useTheme-D3zvxdF3.js} +1 -1
  93. package/dist/assets/utils-CUZQNAWJ.js +1 -0
  94. package/dist/assets/{vega-component-CzgE5HEs.js → vega-component-DCOCpf2B.js} +1 -1
  95. package/dist/index.html +29 -29
  96. package/package.json +1 -1
  97. package/src/components/ai/__tests__/ai-utils.test.ts +64 -66
  98. package/src/components/ai/ai-model-dropdown.tsx +5 -1
  99. package/src/components/ai/ai-utils.ts +37 -21
  100. package/src/components/app-config/ai-config.tsx +86 -24
  101. package/src/components/app-config/user-config-form.tsx +2 -3
  102. package/src/core/ai/config.ts +2 -3
  103. package/src/core/config/__tests__/config-schema.test.ts +31 -0
  104. package/src/core/config/config-schema.ts +1 -0
  105. package/src/core/config/config.ts +15 -2
  106. package/dist/assets/ai-model-dropdown-D2q0Cm0R.js +0 -3
  107. package/dist/assets/command-palette-QFmIvDcT.js +0 -1
  108. package/dist/assets/config-DcB2swfZ.js +0 -1
  109. package/dist/assets/dagre-Bv00Sosz.js +0 -1
  110. package/dist/assets/panels-BbkuRtEi.js +0 -4
  111. package/dist/assets/tracing-panel-CMnYFlDr.js +0 -2
  112. package/dist/assets/useNotebookActions-DGuDG0Px.js +0 -10
  113. package/dist/assets/utils-R_pHe-RB.js +0 -1
@@ -46,6 +46,13 @@ vi.mock("@marimo-team/llm-info/models.json", () => {
46
46
  description: "Ollama Llama 2 model",
47
47
  }),
48
48
  ],
49
+ marimo: [
50
+ make({
51
+ name: "Best for chat",
52
+ model: "best-for-chat",
53
+ description: "marimo hosted chat model",
54
+ }),
55
+ ],
49
56
  };
50
57
 
51
58
  return { models };
@@ -54,8 +61,9 @@ vi.mock("@marimo-team/llm-info/models.json", () => {
54
61
  // Must import after mock
55
62
  import {
56
63
  autoPopulateModels,
57
- getConfiguredProvider,
58
64
  getRecommendedModel,
65
+ listConfiguredProviders,
66
+ listModelsForAiSettings,
59
67
  } from "../ai-utils";
60
68
 
61
69
  describe("ai-utils", () => {
@@ -63,82 +71,72 @@ describe("ai-utils", () => {
63
71
  vi.clearAllMocks();
64
72
  });
65
73
 
66
- describe("getConfiguredProvider", () => {
67
- it("should return undefined when no AI config", () => {
68
- const config: UserConfig = {} as UserConfig;
69
- expect(getConfiguredProvider(config.ai)).toBeUndefined();
70
- });
71
-
72
- it("should return undefined when AI config has no credentials", () => {
73
- const config: UserConfig = {
74
- ai: {},
75
- } as UserConfig;
76
- expect(getConfiguredProvider(config.ai)).toBeUndefined();
77
- });
78
-
79
- it("should return openai when OpenAI API key is set", () => {
80
- const config: UserConfig = {
81
- ai: {
74
+ describe("listConfiguredProviders", () => {
75
+ it("returns known providers with credentials, then custom providers with a base URL", () => {
76
+ expect(
77
+ listConfiguredProviders({
78
+ wandb: { api_key: "unused" },
82
79
  open_ai: { api_key: "sk-test" },
83
- },
84
- } as UserConfig;
85
- expect(getConfiguredProvider(config.ai)).toBe("openai");
86
- });
87
-
88
- it("should return anthropic when Anthropic API key is set", () => {
89
- const config: UserConfig = {
90
- ai: {
91
- anthropic: { api_key: "sk-ant-test" },
92
- },
93
- } as UserConfig;
94
- expect(getConfiguredProvider(config.ai)).toBe("anthropic");
80
+ open_ai_compatible: { api_key: "unused", base_url: "https://x" },
81
+ custom_providers: {
82
+ groq: { base_url: "https://api.groq.com" },
83
+ empty: {},
84
+ },
85
+ }),
86
+ ).toEqual(["openai", "wandb", "marimo", "groq"]);
95
87
  });
96
88
 
97
- it("should return google when Google API key is set", () => {
98
- const config: UserConfig = {
99
- ai: {
100
- google: { api_key: "google-key" },
101
- },
102
- } as UserConfig;
103
- expect(getConfiguredProvider(config.ai)).toBe("google");
89
+ it("treats marimo as configured when open_ai_compatible has a base URL", () => {
90
+ expect(
91
+ listConfiguredProviders({
92
+ open_ai_compatible: { api_key: "unused", base_url: "https://x" },
93
+ }),
94
+ ).toEqual(["marimo"]);
104
95
  });
105
96
 
106
- it("should return ollama when Ollama base URL is set", () => {
107
- const config: UserConfig = {
108
- ai: {
109
- ollama: { base_url: "http://localhost:11434" },
110
- },
111
- } as UserConfig;
112
- expect(getConfiguredProvider(config.ai)).toBe("ollama");
97
+ it("omits openai-compatible as its own provider id", () => {
98
+ expect(
99
+ listConfiguredProviders({
100
+ open_ai: {},
101
+ open_ai_compatible: { api_key: "unused" },
102
+ }),
103
+ ).toEqual([]);
113
104
  });
105
+ });
114
106
 
115
- it("should return azure only when both API key and base URL are set", () => {
116
- const config: UserConfig = {
117
- ai: {
118
- azure: { api_key: "azure-key", base_url: "https://azure.com" },
119
- },
120
- } as UserConfig;
121
- expect(getConfiguredProvider(config.ai)).toBe("azure");
107
+ describe("listModelsForAiSettings", () => {
108
+ const entries: [string, number][] = [
109
+ ["openai", 1],
110
+ ["anthropic", 2],
111
+ ["wandb", 3],
112
+ ["marimo", 4],
113
+ ];
114
+
115
+ it("returns all groups when provider config is allowed", () => {
116
+ expect(
117
+ listModelsForAiSettings(entries, {
118
+ allow_provider_config: true,
119
+ wandb: { api_key: "unused" },
120
+ }),
121
+ ).toEqual(entries);
122
122
  });
123
123
 
124
- it("should return undefined for azure with only API key", () => {
125
- const config: UserConfig = {
126
- ai: {
127
- azure: { api_key: "azure-key" },
128
- },
129
- } as UserConfig;
130
- expect(getConfiguredProvider(config.ai)).toBeUndefined();
124
+ it("filters to configured providers when provider config is locked", () => {
125
+ expect(
126
+ listModelsForAiSettings(entries, {
127
+ allow_provider_config: false,
128
+ wandb: { api_key: "unused" },
129
+ }),
130
+ ).toEqual([["wandb", 3]]);
131
131
  });
132
132
 
133
- it("should return custom provider when configured", () => {
134
- const config = {
135
- ai: {
136
- custom_providers: {
137
- my_provider: { base_url: "https://my-api.com" },
138
- },
139
- },
140
- } as unknown as UserConfig;
141
- expect(getConfiguredProvider(config.ai)).toBe("my_provider");
133
+ it("keeps marimo models when locked with an openai-compatible gateway", () => {
134
+ expect(
135
+ listModelsForAiSettings(entries, {
136
+ allow_provider_config: false,
137
+ open_ai_compatible: { base_url: "https://ai.marimo.app/molab/v1" },
138
+ }),
139
+ ).toEqual([["marimo", 4]]);
142
140
  });
143
141
  });
144
142
 
@@ -33,6 +33,7 @@ import {
33
33
  } from "../ui/dropdown-menu";
34
34
  import { Tooltip } from "../ui/tooltip";
35
35
  import { AiProviderIcon } from "./ai-provider-icon";
36
+ import { listModelsForAiSettings } from "./ai-utils";
36
37
  import { getCurrentRoleTooltip, getTagColour } from "./display-helpers";
37
38
 
38
39
  interface AIModelDropdownProps {
@@ -84,7 +85,10 @@ export const AIModelDropdown = ({
84
85
  ].filter(Boolean),
85
86
  displayedModels: ai?.models?.displayed_models,
86
87
  });
87
- const modelsByProvider = aiModelRegistry.getListModelsByProvider();
88
+ const modelsByProvider = listModelsForAiSettings(
89
+ aiModelRegistry.getListModelsByProvider(),
90
+ ai,
91
+ );
88
92
 
89
93
  const activeModel =
90
94
  forRole === "autocomplete"
@@ -24,39 +24,55 @@ const CREDENTIAL_CHECKERS: Record<KnownProviderId, CredentialChecker> = {
24
24
  "opencode-go": (ai) => Boolean(ai?.opencode_go?.api_key),
25
25
  bedrock: (ai) => Boolean(ai?.bedrock?.region_name),
26
26
  ollama: (ai) => Boolean(ai?.ollama?.base_url),
27
- // These providers don't have user-configurable credentials in the UI
27
+ // No user-configurable credentials in the UI.
28
28
  deepseek: () => false,
29
- marimo: () => false,
29
+ // Hosted marimo models (marimo.app / molab) route through open_ai_compatible.
30
+ marimo: (ai) => Boolean(ai?.open_ai_compatible?.base_url),
30
31
  };
31
32
 
32
33
  /**
33
- * Returns the first configured provider based on credentials.
34
+ * Known providers with credentials, then custom providers with a base URL.
34
35
  */
35
- export function getConfiguredProvider(
36
- config: UserConfig["ai"],
37
- ): ProviderId | undefined {
38
- for (const provider of KNOWN_PROVIDERS) {
39
- if (CREDENTIAL_CHECKERS[provider](config)) {
40
- return provider;
41
- }
42
- }
36
+ export function listConfiguredProviders(
37
+ ai: AiConfig | undefined,
38
+ ): ProviderId[] {
39
+ const knownProviders = KNOWN_PROVIDERS.filter((provider) =>
40
+ CREDENTIAL_CHECKERS[provider](ai),
41
+ );
42
+ const customProviders = Object.entries(ai?.custom_providers ?? {})
43
+ .filter(([, providerConfig]) => Boolean(providerConfig?.base_url))
44
+ .map(([name]) => name);
45
+ return [...knownProviders, ...customProviders];
46
+ }
43
47
 
44
- // Check custom providers
45
- const customProviders = config?.custom_providers;
46
- if (customProviders) {
47
- const firstCustomProvider = Object.entries(customProviders).find(
48
- ([_, providerConfig]) => providerConfig?.base_url,
49
- );
50
- if (firstCustomProvider) {
51
- return firstCustomProvider[0];
52
- }
48
+ /**
49
+ * Keep provider groups whose provider has credentials configured.
50
+ */
51
+ export function filterToConfiguredProviders<T>(
52
+ entries: readonly [ProviderId, T][],
53
+ ai: AiConfig | undefined,
54
+ ): [ProviderId, T][] {
55
+ const configured = new Set<string>(listConfiguredProviders(ai));
56
+ return entries.filter(([provider]) => configured.has(provider));
57
+ }
58
+
59
+ /**
60
+ * When provider setup is locked, only show models for configured providers.
61
+ */
62
+ export function listModelsForAiSettings<T>(
63
+ entries: readonly [ProviderId, T][],
64
+ ai: AiConfig | undefined,
65
+ ): [ProviderId, T][] {
66
+ if (ai?.allow_provider_config !== false) {
67
+ return [...entries];
53
68
  }
69
+ return filterToConfiguredProviders(entries, ai);
54
70
  }
55
71
 
56
72
  export function getRecommendedModel(
57
73
  config: UserConfig["ai"],
58
74
  ): string | undefined {
59
- const provider = getConfiguredProvider(config);
75
+ const provider = listConfiguredProviders(config)[0];
60
76
  if (!provider) {
61
77
  return undefined;
62
78
  }
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { useAtom } from "jotai";
3
+ import { useAtom, useAtomValue } from "jotai";
4
4
  import {
5
5
  BotIcon,
6
6
  BrainIcon,
@@ -43,6 +43,7 @@ import {
43
43
  } from "@/core/ai/ids/ids";
44
44
  import { type AiModel, AiModelRegistry } from "@/core/ai/model-registry";
45
45
  import { CopilotConfig } from "@/core/codemirror/copilot/copilot-config";
46
+ import { resolvedMarimoConfigAtom } from "@/core/config/config";
46
47
  import { DEFAULT_AI_MODEL, type UserConfig } from "@/core/config/config-schema";
47
48
  import { isWasm } from "@/core/wasm/utils";
48
49
  import { cn } from "@/utils/cn";
@@ -53,6 +54,10 @@ import {
53
54
  AiProviderIcon,
54
55
  type AiProviderIconProps,
55
56
  } from "../ai/ai-provider-icon";
57
+ import {
58
+ listConfiguredProviders,
59
+ listModelsForAiSettings,
60
+ } from "../ai/ai-utils";
56
61
  import { getTagColour } from "../ai/display-helpers";
57
62
  import {
58
63
  Accordion,
@@ -575,6 +580,11 @@ export const AiCodeCompletionConfig: React.FC<AiConfigProps> = ({
575
580
  );
576
581
  };
577
582
 
583
+ function useAllowProviderConfig(): boolean {
584
+ const ai = useAtomValue(resolvedMarimoConfigAtom).ai;
585
+ return ai?.allow_provider_config !== false;
586
+ }
587
+
578
588
  const AccordionFormItem = ({
579
589
  title,
580
590
  triggerClassName,
@@ -867,17 +877,55 @@ export const CustomProvidersConfig: React.FC<AiConfigProps> = ({
867
877
  );
868
878
  };
869
879
 
880
+ const LockedProvidersList: React.FC = () => {
881
+ const resolvedAi = useAtomValue(resolvedMarimoConfigAtom).ai;
882
+ const providers = listConfiguredProviders(resolvedAi);
883
+
884
+ return (
885
+ <SettingGroup>
886
+ <p className="text-sm text-muted-secondary">
887
+ AI providers are configured for this environment. You cannot add your
888
+ own keys or custom providers.
889
+ </p>
890
+ {providers.length === 0 ? (
891
+ <p className="text-sm text-muted-foreground">
892
+ No AI providers are configured.
893
+ </p>
894
+ ) : (
895
+ <div className="flex flex-col divide-y border rounded-md">
896
+ {providers.map((provider) => (
897
+ <div key={provider} className="flex items-center gap-2 px-3 py-2">
898
+ <AiProviderIcon provider={provider} className="h-4 w-4" />
899
+ <span className="text-sm font-medium">
900
+ {getProviderLabel(provider)}
901
+ </span>
902
+ <span className="ml-auto px-1 rounded bg-muted text-xs font-medium border">
903
+ Configured
904
+ </span>
905
+ </div>
906
+ ))}
907
+ </div>
908
+ )}
909
+ </SettingGroup>
910
+ );
911
+ };
912
+
870
913
  export const AiProvidersConfig: React.FC<AiConfigProps> = ({
871
914
  form,
872
915
  config,
873
916
  onSubmit,
874
917
  }) => {
875
918
  const isWasmRuntime = isWasm();
919
+ const allowProviderConfig = useAllowProviderConfig();
876
920
 
877
921
  const hasValue = (name: FieldPath<UserConfig>) => {
878
922
  return !!form.getValues(name);
879
923
  };
880
924
 
925
+ if (!allowProviderConfig) {
926
+ return <LockedProvidersList />;
927
+ }
928
+
881
929
  return (
882
930
  <SettingGroup>
883
931
  <p className="text-sm text-muted-secondary">
@@ -1508,6 +1556,8 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1508
1556
  form,
1509
1557
  onSubmit,
1510
1558
  }) => {
1559
+ const resolvedAi = useAtomValue(resolvedMarimoConfigAtom).ai;
1560
+
1511
1561
  const customModels = useWatch({
1512
1562
  control: form.control,
1513
1563
  name: "ai.models.custom_models",
@@ -1518,10 +1568,14 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1518
1568
  name: "ai.custom_providers",
1519
1569
  }) as Record<string, CustomProviderConfig> | undefined;
1520
1570
 
1521
- const customProviderNames = useMemo(
1522
- () => Object.keys(customProviders || {}),
1523
- [customProviders],
1524
- );
1571
+ const customProviderNames = useMemo(() => {
1572
+ return [
1573
+ ...new Set([
1574
+ ...Object.keys(customProviders || {}),
1575
+ ...Object.keys(resolvedAi?.custom_providers || {}),
1576
+ ]),
1577
+ ];
1578
+ }, [customProviders, resolvedAi]);
1525
1579
 
1526
1580
  const aiModelRegistry = useMemo(
1527
1581
  () =>
@@ -1538,7 +1592,10 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1538
1592
  }) as QualifiedModelId[];
1539
1593
  const currentDisplayedModelsSet = new Set(currentDisplayedModels);
1540
1594
  const modelsByProvider = aiModelRegistry.getGroupedModelsByProvider();
1541
- const listModelsByProvider = aiModelRegistry.getListModelsByProvider();
1595
+ const listModelsByProvider = listModelsForAiSettings(
1596
+ aiModelRegistry.getListModelsByProvider(),
1597
+ resolvedAi,
1598
+ );
1542
1599
 
1543
1600
  const toggleModelDisplay = useEvent((modelId: QualifiedModelId) => {
1544
1601
  const newModels = currentDisplayedModelsSet.has(modelId)
@@ -1676,6 +1733,9 @@ export const AddModelForm: React.FC<{
1676
1733
  };
1677
1734
 
1678
1735
  const providerClassName = "w-40 truncate";
1736
+ const knownProviders = KNOWN_PROVIDERS.filter(
1737
+ (p) => p !== "marimo" && !customProviderNames.includes(p),
1738
+ );
1679
1739
 
1680
1740
  const providerSelect = (
1681
1741
  <div className="flex flex-col gap-2">
@@ -1718,14 +1778,14 @@ export const AddModelForm: React.FC<{
1718
1778
  </div>
1719
1779
  </SelectItem>
1720
1780
  ))}
1721
- <p className="px-2 py-1 text-xs text-muted-secondary font-medium mt-1">
1722
- Built-in Providers
1723
- </p>
1781
+ {knownProviders.length > 0 && (
1782
+ <p className="px-2 py-1 text-xs text-muted-secondary font-medium mt-1">
1783
+ Built-in Providers
1784
+ </p>
1785
+ )}
1724
1786
  </>
1725
1787
  )}
1726
- {KNOWN_PROVIDERS.filter(
1727
- (p) => p !== "marimo" && !customProviderNames.includes(p),
1728
- ).map((p) => (
1788
+ {knownProviders.map((p) => (
1729
1789
  <SelectItem key={p} value={p}>
1730
1790
  <div className="flex items-center gap-2">
1731
1791
  <AiProviderIcon provider={p} className="h-4 w-4" />
@@ -1733,18 +1793,20 @@ export const AddModelForm: React.FC<{
1733
1793
  </div>
1734
1794
  </SelectItem>
1735
1795
  ))}
1736
- <p className="px-2 py-1 text-xs text-muted-secondary font-medium mt-1">
1737
- Other
1738
- </p>
1739
- <SelectItem value="custom">
1740
- <div className="flex items-center gap-2">
1741
- <AiProviderIcon
1742
- provider="openai-compatible"
1743
- className="h-4 w-4"
1744
- />
1745
- <span>Enter provider name</span>
1746
- </div>
1747
- </SelectItem>
1796
+ <>
1797
+ <p className="px-2 py-1 text-xs text-muted-secondary font-medium mt-1">
1798
+ Other
1799
+ </p>
1800
+ <SelectItem value="custom">
1801
+ <div className="flex items-center gap-2">
1802
+ <AiProviderIcon
1803
+ provider="openai-compatible"
1804
+ className="h-4 w-4"
1805
+ />
1806
+ <span>Enter provider name</span>
1807
+ </div>
1808
+ </SelectItem>
1809
+ </>
1748
1810
  </SelectGroup>
1749
1811
  </SelectContent>
1750
1812
  </Select>
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { zodResolver } from "@hookform/resolvers/zod";
4
4
  import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
5
- import { merge } from "lodash-es";
6
5
  import {
7
6
  AlertTriangleIcon,
8
7
  BrainIcon,
@@ -35,7 +34,7 @@ import { NumberField } from "@/components/ui/number-field";
35
34
  import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
36
35
  import { KEYMAP_PRESETS } from "@/core/codemirror/keymaps/keymaps";
37
36
  import { capabilitiesAtom } from "@/core/config/capabilities";
38
- import { useUserConfig } from "@/core/config/config";
37
+ import { mergeConfig, useUserConfig } from "@/core/config/config";
39
38
  import {
40
39
  PackageManagerNames,
41
40
  type UserConfig,
@@ -203,7 +202,7 @@ export const UserConfigForm: React.FC = () => {
203
202
  await saveUserConfig({ config: dirtyValues });
204
203
  // Only apply the changed keys; this avoids stale request responses
205
204
  // overwriting newer config changes.
206
- setConfig((prev) => merge({}, prev, dirtyValues));
205
+ setConfig((prev) => mergeConfig(prev, dirtyValues));
207
206
  };
208
207
  const onSubmit = useDebouncedCallback(onSubmitNotDebounced, FORM_DEBOUNCE);
209
208
 
@@ -2,9 +2,8 @@
2
2
 
3
3
  import type { Role } from "@marimo-team/llm-info";
4
4
  import { useSetAtom } from "jotai";
5
- import { merge } from "lodash-es";
6
5
  import type { QualifiedModelId } from "@/core/ai/ids/ids";
7
- import { userConfigAtom } from "@/core/config/config";
6
+ import { mergeConfig, userConfigAtom } from "@/core/config/config";
8
7
  import type {
9
8
  AIModelKey,
10
9
  CopilotMode,
@@ -41,7 +40,7 @@ export const useAIConfigActions = () => {
41
40
  const saveConfig = async (aiConfig: AiConfigPatch) => {
42
41
  const newConfig = { ai: aiConfig };
43
42
  await saveUserConfig({ config: newConfig }).then(() => {
44
- setUserConfig((prev) => merge({}, prev, newConfig));
43
+ setUserConfig((prev) => mergeConfig(prev, newConfig));
45
44
  });
46
45
  };
47
46
 
@@ -5,6 +5,7 @@ import { expect, test } from "vitest";
5
5
  import {
6
6
  configOverridesAtom,
7
7
  connectionTransportTypeAtom,
8
+ mergeConfig,
8
9
  resolvedMarimoConfigAtom,
9
10
  userConfigAtom,
10
11
  } from "../config";
@@ -46,6 +47,7 @@ test("default UserConfig - empty", () => {
46
47
  expect(defaultConfig).toMatchInlineSnapshot(`
47
48
  {
48
49
  "ai": {
50
+ "allow_provider_config": true,
49
51
  "custom_providers": {},
50
52
  "enabled": true,
51
53
  "inline_tooltip": false,
@@ -120,6 +122,7 @@ test("default UserConfig - one level", () => {
120
122
  expect(defaultConfig).toMatchInlineSnapshot(`
121
123
  {
122
124
  "ai": {
125
+ "allow_provider_config": true,
123
126
  "custom_providers": {},
124
127
  "enabled": true,
125
128
  "inline_tooltip": false,
@@ -315,3 +318,31 @@ test("connectionTransportTypeAtom reads server.transport", () => {
315
318
  });
316
319
  expect(store.get(connectionTransportTypeAtom)).toBe("sse");
317
320
  });
321
+
322
+ test("mergeConfig replaces arrays instead of merging by index", () => {
323
+ const prev = {
324
+ ai: {
325
+ models: {
326
+ displayed_models: ["wandb/a", "wandb/b", "wandb/c"],
327
+ custom_models: ["wandb/custom"],
328
+ },
329
+ },
330
+ };
331
+ const patch = {
332
+ ai: {
333
+ models: {
334
+ displayed_models: ["wandb/a"],
335
+ custom_models: [],
336
+ },
337
+ },
338
+ };
339
+
340
+ expect(mergeConfig(prev, patch)).toEqual({
341
+ ai: {
342
+ models: {
343
+ displayed_models: ["wandb/a"],
344
+ custom_models: [],
345
+ },
346
+ },
347
+ });
348
+ });
@@ -169,6 +169,7 @@ export const UserConfigSchema = z
169
169
  max_tokens: z.number().int().positive().nullable().optional(),
170
170
  mode: z.enum(COPILOT_MODES).prefault("manual"),
171
171
  inline_tooltip: z.boolean().prefault(false),
172
+ allow_provider_config: z.boolean().prefault(true),
172
173
  open_ai: AiConfigSchema.optional(),
173
174
  anthropic: AiConfigSchema.optional(),
174
175
  google: AiConfigSchema.optional(),
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
3
- import { merge } from "lodash-es";
3
+ import { mergeWith } from "lodash-es";
4
4
  import { OverridingHotkeyProvider } from "../hotkeys/hotkeys";
5
5
  import { type Platform, resolvePlatform } from "../hotkeys/shortcuts";
6
6
  import { store } from "../state/jotai";
@@ -11,6 +11,19 @@ import {
11
11
  type UserConfig,
12
12
  } from "./config-schema";
13
13
 
14
+ /**
15
+ * Deep-merge config objects, replacing arrays instead of merging them by
16
+ * index. Lodash `merge` keeps leftover tail items. Python `deep_merge` replaces arrays.
17
+ */
18
+ export function mergeConfig<T extends object>(...sources: object[]): T {
19
+ return mergeWith({}, ...sources, (_obj: unknown, src: unknown) => {
20
+ if (Array.isArray(src)) {
21
+ return [...src];
22
+ }
23
+ return undefined;
24
+ }) as T;
25
+ }
26
+
14
27
  /**
15
28
  * Atom for storing the user config.
16
29
  */
@@ -21,7 +34,7 @@ export const configOverridesAtom = atom<{}>({});
21
34
  export const resolvedMarimoConfigAtom = atom<UserConfig>((get) => {
22
35
  const overrides = get(configOverridesAtom);
23
36
  const userConfig = get(userConfigAtom);
24
- return merge({}, userConfig, overrides);
37
+ return mergeConfig(userConfig, overrides);
25
38
  });
26
39
 
27
40
  export const autoInstantiateAtom = atom((get) => {