ompchamber 1.2.0 → 2.0.0

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 (223) hide show
  1. package/README.md +131 -147
  2. package/dist/client/index.html +4 -2
  3. package/dist/client/static/css/index.5f630421b7.css +2 -0
  4. package/dist/client/static/js/77.0d81c74cf0.js +139 -0
  5. package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
  6. package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
  7. package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
  8. package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
  9. package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
  10. package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
  11. package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
  12. package/dist/client/static/js/async/e.93214d622b.js +79 -0
  13. package/dist/client/static/js/async/g.74ff36419f.js +2 -0
  14. package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
  15. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  16. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  17. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  18. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  19. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  20. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  21. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  22. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  23. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  24. package/dist/client/static/js/async/q.33d6779f98.js +21 -0
  25. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  26. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  27. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  28. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  29. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  31. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  32. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  33. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  34. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  35. package/dist/client/static/js/index.c05535dbde.js +38 -0
  36. package/package.json +3 -3
  37. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  38. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  39. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  40. package/src/client/components/settings/Modal.tsx +11 -2
  41. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  42. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  43. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  45. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  46. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  47. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  48. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  49. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  50. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  51. package/src/client/components/workspace/btw-panel/index.tsx +129 -45
  52. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  53. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  54. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  59. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  63. package/src/client/components/workspace/editor/index.tsx +11 -2
  64. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  65. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  66. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  67. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  68. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  69. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  70. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  71. package/src/client/data/theme/terminal.ts +50 -52
  72. package/src/client/hooks/chat/btw/index.ts +139 -18
  73. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  74. package/src/client/hooks/chat/btw/mode.ts +111 -16
  75. package/src/client/hooks/chat/composer/index.ts +10 -4
  76. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  77. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  78. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  79. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  80. package/src/client/hooks/chat/timeline/index.ts +8 -20
  81. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  82. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  83. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  84. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  85. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  86. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  87. package/src/client/hooks/settings/providers.ts +34 -3
  88. package/src/client/hooks/ui/open-file.ts +7 -1
  89. package/src/client/hooks/ui/theme.ts +82 -57
  90. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  91. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  92. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  93. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  94. package/src/client/hooks/workspace/search-stream.ts +24 -5
  95. package/src/client/main.tsx +6 -0
  96. package/src/client/tailwind.css +19 -56
  97. package/src/server/lib/btw/child.server.ts +41 -0
  98. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  99. package/src/server/lib/btw/prompt.ts +13 -5
  100. package/src/server/lib/btw/registry.server.ts +13 -4
  101. package/src/server/lib/btw/runtime.server.ts +149 -156
  102. package/src/server/lib/btw/schema.server.ts +27 -0
  103. package/src/server/lib/btw/service.server.ts +106 -5
  104. package/src/server/lib/btw/store.server.ts +57 -6
  105. package/src/server/lib/db/schema.ts +4 -0
  106. package/src/server/lib/db/settings-store.test.ts +65 -0
  107. package/src/server/lib/db/settings-store.ts +25 -0
  108. package/src/server/lib/fs/atomic-write.ts +42 -4
  109. package/src/server/lib/fs/config-lock.ts +74 -0
  110. package/src/server/lib/fs/git-ignore.ts +55 -5
  111. package/src/server/lib/fs/ripgrep.ts +119 -0
  112. package/src/server/lib/lifecycle/identity.ts +10 -64
  113. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  114. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  115. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  116. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  117. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  118. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  119. package/src/server/lib/models/provider-registry.server.ts +2 -2
  120. package/src/server/lib/models/registry.server.ts +1 -1
  121. package/src/server/lib/omp/config/behavior.ts +17 -17
  122. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  123. package/src/server/lib/omp/config/document.ts +128 -0
  124. package/src/server/lib/omp/config/extensions.ts +11 -10
  125. package/src/server/lib/omp/config/mcp.ts +4 -57
  126. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  127. package/src/server/lib/omp/config/models-config.ts +92 -0
  128. package/src/server/lib/omp/config/models-validation.ts +159 -0
  129. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  130. package/src/server/lib/omp/config/providers.ts +241 -211
  131. package/src/server/lib/omp/config/roles.ts +6 -14
  132. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  133. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  134. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  135. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  136. package/src/server/lib/omp/rpc/manager.ts +21 -21
  137. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  138. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  139. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  140. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  141. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  142. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  143. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  144. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  145. package/src/server/lib/queue/delivery-harness.ts +88 -0
  146. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  147. package/src/server/lib/queue/delivery.server.ts +105 -13
  148. package/src/server/lib/queue/store.server.ts +30 -5
  149. package/src/server/lib/terminal/foreground.test.ts +164 -0
  150. package/src/server/lib/terminal/foreground.ts +34 -6
  151. package/src/server/lib/terminal/history.ts +8 -7
  152. package/src/server/plugins/compress.ts +33 -5
  153. package/src/server/plugins/ssr.ts +14 -7
  154. package/src/server/routes/btw/topics.ts +89 -2
  155. package/src/server/routes/fs/git.ts +5 -6
  156. package/src/server/routes/fs/search.ts +17 -26
  157. package/src/server/routes/sessions/queue.ts +17 -4
  158. package/src/server/routes/settings/index.ts +2 -0
  159. package/src/server/routes/settings/model-override.ts +69 -0
  160. package/src/server/routes/settings/provider-models.ts +35 -12
  161. package/src/server/routes/settings/providers.ts +31 -1
  162. package/src/server/routes/settings/root.ts +8 -3
  163. package/src/shared/lib/chat/btw/client.ts +34 -0
  164. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  165. package/src/shared/lib/code/shiki-themes.ts +9 -5
  166. package/src/shared/lib/fs/file-tree.ts +17 -12
  167. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  168. package/src/shared/lib/markdown/autolink.ts +50 -0
  169. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  170. package/src/shared/lib/markdown/document-urls.ts +118 -0
  171. package/src/shared/lib/markdown/marked.ts +27 -15
  172. package/src/shared/lib/markdown/mermaid.ts +2 -2
  173. package/src/shared/lib/models/catalog.ts +1 -1
  174. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  175. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  176. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  177. package/src/shared/lib/settings/client.ts +10 -4
  178. package/src/shared/lib/settings/diff.test.ts +31 -0
  179. package/src/shared/lib/settings/diff.ts +37 -0
  180. package/src/shared/lib/theme/catalog.test.ts +168 -0
  181. package/src/shared/lib/theme/catalog.ts +98 -0
  182. package/src/shared/lib/theme/color.ts +88 -0
  183. package/src/shared/lib/theme/css.ts +70 -0
  184. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  185. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  186. package/src/shared/lib/theme/palettes/light.ts +313 -0
  187. package/src/shared/types/btw.ts +83 -4
  188. package/src/shared/types/index.ts +1 -0
  189. package/src/shared/types/settings/provider.ts +8 -1
  190. package/src/shared/types/settings/state.ts +10 -4
  191. package/src/shared/types/theme.ts +48 -0
  192. package/dist/client/static/css/index.b938f4a693.css +0 -2
  193. package/dist/client/static/js/77.8022a800b5.js +0 -139
  194. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  195. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  196. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  197. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  198. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  199. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  200. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  201. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  202. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  203. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  204. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  205. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  206. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  207. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  208. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  209. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  210. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  211. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  212. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  213. package/dist/client/static/js/index.57b39ffa7f.js +0 -34
  214. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -181
  215. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  216. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  217. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  218. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  219. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  220. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  221. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
  222. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/{index.57b39ffa7f.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
@@ -19,8 +19,8 @@
19
19
  * composer's access control), not here.
20
20
  */
21
21
 
22
- import { writeFileAtomic } from '@/server/lib/fs/atomic-write';
23
- import { asMapping, getOmpConfigPath } from '@/server/lib/omp/config/yaml';
22
+ import { withOmpYamlDocument } from '@/server/lib/omp/config/document';
23
+ import { getOmpConfigPath } from '@/server/lib/omp/config/yaml';
24
24
  import { isRecord } from '@/shared/lib/util/guards';
25
25
 
26
26
  export type ApprovalValue = 'allow' | 'deny' | 'prompt';
@@ -90,24 +90,24 @@ export function parseApprovalRules(text: string): ApprovalFields | null {
90
90
  export async function writeToolsApproval(fields: ApprovalFields): Promise<void> {
91
91
  if (Object.keys(fields).length === 0) return;
92
92
  const path = getOmpConfigPath();
93
- const source = (await Bun.file(path).exists()) ? await Bun.file(path).text() : '';
94
- const doc = asMapping(Bun.YAML.parse(source), path);
95
- const tools = doc.tools;
96
- if (tools !== undefined && !isRecord(tools)) {
97
- throw new Error(`${path} tools section must be a mapping`);
98
- }
99
- if (isRecord(tools)) {
100
- const approval = tools.approval;
93
+ await withOmpYamlDocument(path, (doc) => {
94
+ const tools = doc.get('tools');
95
+ if (tools !== undefined && !isRecord(tools)) {
96
+ throw new Error(`${path} tools section must be a mapping`);
97
+ }
98
+ const approval = isRecord(tools) ? tools.approval : undefined;
101
99
  if (approval !== undefined && !isRecord(approval)) {
102
100
  throw new Error(`${path} tools.approval must be a mapping`);
103
101
  }
104
- if (isRecord(approval)) {
105
- Object.assign(approval, fields);
102
+ // Assign through the document so comments on untouched keys survive; a
103
+ // plain object replacement would drop the whole subtree's annotations.
104
+ if (isRecord(tools) && isRecord(approval)) {
105
+ for (const [key, value] of Object.entries(fields)) doc.setIn(['tools', 'approval', key], value);
106
+ } else if (isRecord(tools)) {
107
+ doc.setIn(['tools', 'approval'], fields);
106
108
  } else {
107
- tools.approval = fields;
109
+ doc.set('tools', { approval: fields });
108
110
  }
109
- } else {
110
- doc.tools = { approval: fields };
111
- }
112
- await writeFileAtomic(path, Bun.YAML.stringify(doc, null, 2));
111
+ return { result: undefined, changed: true };
112
+ });
113
113
  }
@@ -7,36 +7,76 @@
7
7
  * Read/write of the native OMP `disabledProviders` list in
8
8
  * ~/.omp/agent/config.yml — the flag behind connect/disconnect in the provider
9
9
  * settings. Writes are atomic read-modify-writes that preserve every other key
10
- * in the file.
10
+ * in the file, its comments, and its path-scoped entries.
11
+ *
12
+ * `disabledProviders` is a PATH-SCOPED setting in omp: alongside bare slugs the
13
+ * list may carry `{ path: ~/work, providers: [deepseek] }` entries that disable
14
+ * a provider only under that prefix. A writer that filters the list down to
15
+ * strings and writes it back silently deletes the user's per-project policy, so
16
+ * every mutation here edits the entry in place and leaves the other entries
17
+ * untouched.
11
18
  */
12
19
 
13
- import { writeFileAtomic } from '@/server/lib/fs/atomic-write';
20
+ import { homedir } from 'os';
21
+ import { isMap, isScalar, type Document } from 'yaml';
14
22
  import { getOmpConfigPath } from '@/server/lib/omp/config/yaml';
15
- import { isRecord } from '@/shared/lib/util/guards';
23
+ import { seqAt, valueAt, withOmpYamlDocument } from '@/server/lib/omp/config/document';
24
+ import { VALUE_KEYS, isPathScopedEntry, pathScopedEntryValues, resolvePathScopedSlugs } from '@/server/lib/omp/config/path-scoped';
16
25
 
17
- function readDisabledProviderSlugs(data: Record<string, unknown>): string[] {
18
- const current = data.disabledProviders;
19
- return Array.isArray(current)
20
- ? current.filter((item): item is string => typeof item === 'string')
21
- : [];
26
+ /** The string members of a value key, without assuming it is an array. */
27
+ function stringsIn(value: unknown): string[] {
28
+ if (typeof value === 'string') return [value];
29
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
30
+ }
31
+
32
+ /** Every slug the raw list mentions, path-scoped entries included. */
33
+ function listSlugs(value: unknown): string[] {
34
+ if (!Array.isArray(value)) return [];
35
+ return value.flatMap((entry) => (
36
+ typeof entry === 'string' ? [entry] : pathScopedEntryValues(entry)
37
+ ));
22
38
  }
23
39
 
24
40
  /**
25
41
  * Re-enable a disabled provider by removing it from config.yml
26
42
  * disabledProviders. Returns false when it was not disabled in the first place.
43
+ *
44
+ * A path-scoped entry is never deleted — the provider is only removed from its
45
+ * value list, because the entry's prefix policy applies to the other providers
46
+ * it names too.
27
47
  */
28
48
  export async function enableNativeProvider(slug: string): Promise<boolean> {
29
49
  const path = getOmpConfigPath();
30
- const file = Bun.file(path);
31
- if (!(await file.exists())) return false;
32
- const doc = Bun.YAML.parse(await file.text());
33
- if (!isRecord(doc)) return false;
34
- const current = readDisabledProviderSlugs(doc);
35
- const next = current.filter((item) => item !== slug);
36
- if (next.length === current.length) return false;
37
- doc.disabledProviders = next;
38
- await writeFileAtomic(path, Bun.YAML.stringify(doc, null, 2));
39
- return true;
50
+ return withOmpYamlDocument(path, (doc: Document) => {
51
+ const seq = seqAt(doc, ['disabledProviders']);
52
+ if (!seq) return { result: false, changed: false };
53
+
54
+ let changed = false;
55
+ // Walk backwards so deleting an item cannot shift the indices still to visit.
56
+ for (let index = seq.items.length - 1; index >= 0; index--) {
57
+ const item = seq.items[index];
58
+ if (isScalar(item) && item.value === slug) {
59
+ seq.delete(index);
60
+ changed = true;
61
+ continue;
62
+ }
63
+ if (!isMap(item) || !isPathScopedEntry(item.toJS(doc))) continue;
64
+ const record = item.toJS(doc) as Record<string, unknown>;
65
+ const values = pathScopedEntryValues(record);
66
+ if (!values.includes(slug)) continue;
67
+ changed = true;
68
+ const remaining = values.filter((value) => value !== slug);
69
+ if (remaining.length === 0) {
70
+ seq.delete(index);
71
+ continue;
72
+ }
73
+ // Update the key that actually held the slug, so a hand-authored
74
+ // `providers:` list stays `providers:` rather than turning into `values:`.
75
+ const holder = VALUE_KEYS.find((key) => stringsIn(record[key]).includes(slug));
76
+ item.set(holder ?? 'values', remaining);
77
+ }
78
+ return { result: changed, changed };
79
+ });
40
80
  }
41
81
 
42
82
  /**
@@ -47,22 +87,31 @@ export async function enableNativeProvider(slug: string): Promise<boolean> {
47
87
  */
48
88
  export async function disableNativeProvider(slug: string): Promise<boolean> {
49
89
  const path = getOmpConfigPath();
50
- const file = Bun.file(path);
51
- const source = (await file.exists()) ? await file.text() : '';
52
- const doc = Bun.YAML.parse(source);
53
- if (doc !== null && !isRecord(doc)) {
54
- throw new Error(`${path} must contain a YAML mapping`);
55
- }
56
- const disabled = isRecord(doc) ? readDisabledProviderSlugs(doc) : [];
57
- if (disabled.includes(slug)) return false;
90
+ return withOmpYamlDocument(path, (doc: Document) => {
91
+ const seq = seqAt(doc, ['disabledProviders']);
92
+ if (listSlugs(valueAt(doc, ['disabledProviders'])).includes(slug)) {
93
+ return { result: false, changed: false };
94
+ }
95
+ if (seq) seq.add(slug);
96
+ else doc.set('disabledProviders', [slug]);
97
+ return { result: true, changed: true };
98
+ });
99
+ }
58
100
 
59
- let content: string;
60
- if (isRecord(doc)) {
61
- doc.disabledProviders = [...disabled, slug];
62
- content = Bun.YAML.stringify(doc, null, 2);
63
- } else {
64
- content = Bun.YAML.stringify({ disabledProviders: [slug] }, null, 2);
65
- }
66
- await writeFileAtomic(path, content);
67
- return true;
101
+ /** Reads the raw list including path-scoped entries (no cwd filtering). */
102
+ export async function readDisabledProviderEntries(): Promise<unknown[]> {
103
+ const path = getOmpConfigPath();
104
+ if (!(await Bun.file(path).exists())) return [];
105
+ const parsed = Bun.YAML.parse(await Bun.file(path).text()) as { disabledProviders?: unknown } | null;
106
+ return Array.isArray(parsed?.disabledProviders) ? parsed.disabledProviders : [];
107
+ }
108
+
109
+ /**
110
+ * Slugs disabled for `cwd`, with path-scoped entries resolved the way omp
111
+ * resolves them. Defaults to the shared utility RPC process's cwd (`homedir()`),
112
+ * which is the cwd omp itself uses when the chamber asks it for the model list —
113
+ * so the chamber's own filter agrees with the list omp just served.
114
+ */
115
+ export async function readDisabledProviders(cwd: string = homedir()): Promise<Set<string>> {
116
+ return new Set(resolvePathScopedSlugs(await readDisabledProviderEntries(), cwd));
68
117
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Read-modify-write of an omp YAML config file: an exclusive chamber lock, a
8
+ * comment-preserving document model, an external-change retry, and an atomic
9
+ * write that keeps the file's mode and symlink.
10
+ *
11
+ * Why the `yaml` document model rather than `Bun.YAML` (plain objects):
12
+ *
13
+ * - **Comments survive.** `~/.omp/agent/models.yml` is a hand-authored file —
14
+ * its providers carry notes and per-model annotations. `Bun.YAML.parse` +
15
+ * `stringify` discards every comment; a single model fetch would delete them.
16
+ * - **Byte-stable round-trips.** Serializing the live `models.yml` through the
17
+ * document model reproduces the file exactly (23210 bytes in, 23210 out).
18
+ * `Bun.YAML.stringify(doc, null, 2)` rewrites it in a different style — and,
19
+ * worse, emits `key: ` headers with a trailing space, which is not omp's own
20
+ * canonical form (`stringifyYamlConfig` strips that space).
21
+ *
22
+ * `Bun.YAML` remains the *parser* for read-only probes, where comments and
23
+ * formatting are irrelevant.
24
+ *
25
+ * Concurrency: omp serializes its own config writes with an OS-level `flock(2)`
26
+ * on `${path}.lock`. That lock is not reachable from Bun (no `flock` binding,
27
+ * and the lock file omp creates must not be deleted out from under it), so the
28
+ * chamber takes its OWN lock file and closes the remaining window with the
29
+ * external-change check below: if the file moved between the read and the
30
+ * write, the edit is replayed against the fresh content instead of clobbering
31
+ * whatever omp just wrote.
32
+ */
33
+
34
+ import { parseDocument, isMap, isScalar, isSeq, type Document, type YAMLSeq } from 'yaml';
35
+ import { withConfigLock } from '@/server/lib/fs/config-lock';
36
+ import { writeFileAtomic } from '@/server/lib/fs/atomic-write';
37
+
38
+ /** A config file that is not a YAML mapping, or not valid YAML at all. */
39
+ export class OmpConfigError extends Error {
40
+ constructor(message: string) {
41
+ super(message);
42
+ this.name = 'OmpConfigError';
43
+ }
44
+ }
45
+
46
+ export interface DocumentEdit<T> {
47
+ result: T;
48
+ /** `false` when the edit turned out to be a no-op — the file is left as it is. */
49
+ changed: boolean;
50
+ }
51
+
52
+ /** The sequence node at `path`, when it is a sequence — `null` otherwise. */
53
+ export function seqAt(doc: Document, path: (string | number)[]): YAMLSeq | null {
54
+ const node = doc.getIn(path);
55
+ return isSeq(node) ? node : null;
56
+ }
57
+
58
+ /**
59
+ * The plain JavaScript value of a YAML node — never a node.
60
+ *
61
+ * `node.get(key, true)` is not enough: the `keepScalar` flag only unwraps
62
+ * scalars, so a map or sequence still comes back as a node, where
63
+ * `Array.isArray` is `false` and a naive membership check silently sees an
64
+ * empty collection.
65
+ */
66
+ export function plainOf<T = unknown>(doc: Document, node: unknown): T | undefined {
67
+ if (node === undefined || node === null) return undefined;
68
+ if (isScalar(node)) return node.value as T;
69
+ if (isMap(node) || isSeq(node)) return node.toJS(doc) as T;
70
+ return undefined;
71
+ }
72
+
73
+ /** The plain JavaScript value at `path` — never a node. */
74
+ export function valueAt<T = unknown>(doc: Document, path: (string | number)[]): T | undefined {
75
+ return plainOf<T>(doc, doc.getIn(path));
76
+ }
77
+
78
+ const EXTERNAL_CHANGE_RETRIES = 5;
79
+
80
+ /** Identity of a file version: absent files hash to `missing`. */
81
+ async function fileGeneration(path: string): Promise<string> {
82
+ try {
83
+ const stat = await Bun.file(path).stat();
84
+ return `${stat.mtimeMs}:${stat.size}`;
85
+ } catch {
86
+ return 'missing';
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Edit an omp YAML config in place. `edit` receives a live document, mutates it,
92
+ * and reports whether anything actually changed; the write only happens when it
93
+ * did. Replayed when another process writes the file mid-edit, so the edit is
94
+ * never applied on top of stale content.
95
+ *
96
+ * A file created here is `0600`: both omp configs carry plaintext credentials
97
+ * (`models.yml` holds provider `apiKey`s), and omp opens its own config temp
98
+ * with `wx, 0o600` for the same reason. An existing file keeps its own mode.
99
+ */
100
+ export async function withOmpYamlDocument<T>(
101
+ path: string,
102
+ edit: (doc: Document) => DocumentEdit<T> | Promise<DocumentEdit<T>>,
103
+ ): Promise<T> {
104
+ // Not `${path}.lock`: that file belongs to omp's own flock lease.
105
+ return withConfigLock(`${path}.ompchamber.lock`, async () => {
106
+ for (let attempt = 0; ; attempt++) {
107
+ const before = await fileGeneration(path);
108
+ const source = (await Bun.file(path).exists()) ? await Bun.file(path).text() : '';
109
+ const doc = parseDocument(source);
110
+ if (doc.errors.length > 0) {
111
+ throw new OmpConfigError(`${path} is not valid YAML: ${doc.errors[0].message.split('\n')[0]}`);
112
+ }
113
+ if (doc.contents !== null && !isMap(doc.contents)) {
114
+ throw new OmpConfigError(`${path} must contain a YAML mapping`);
115
+ }
116
+
117
+ const { result, changed } = await edit(doc);
118
+ if (!changed) return result;
119
+ if (attempt < EXTERNAL_CHANGE_RETRIES && (await fileGeneration(path)) !== before) {
120
+ // Another process rewrote the file while this edit was being computed.
121
+ // Replay against its content rather than overwrite the newer change.
122
+ continue;
123
+ }
124
+ await writeFileAtomic(path, doc.toString({ indent: 2 }), { createMode: 0o600 });
125
+ return result;
126
+ }
127
+ });
128
+ }
@@ -11,8 +11,8 @@
11
11
 
12
12
  import { join } from 'path';
13
13
  import { getAgentDir, pathExists } from '@/server/lib/omp/core/paths';
14
- import { writeFileAtomic } from '@/server/lib/fs/atomic-write';
15
- import { asMapping, getOmpConfigPath } from '@/server/lib/omp/config/yaml';
14
+ import { getOmpConfigPath } from '@/server/lib/omp/config/yaml';
15
+ import { withOmpYamlDocument } from '@/server/lib/omp/config/document';
16
16
  import { isRecord } from '@/shared/lib/util/guards';
17
17
 
18
18
  export interface DiscoveredExtension {
@@ -79,12 +79,13 @@ export async function readDisabledExtensions(): Promise<Set<string>> {
79
79
  export async function setExtensionDisabled(id: string, disabled: boolean): Promise<boolean> {
80
80
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id)) throw new Error('Invalid extension id');
81
81
  const path = getOmpConfigPath();
82
- const doc = asMapping(Bun.YAML.parse((await Bun.file(path).exists()) ? await Bun.file(path).text() : ''), path);
83
- const already = (await readDisabledExtensions()).has(id);
84
- if (disabled === already) return false;
85
- const list = [...(await readDisabledExtensions())];
86
- const next = disabled ? [...list, id] : list.filter((item) => item !== id);
87
- doc.disabledExtensions = next;
88
- await writeFileAtomic(path, Bun.YAML.stringify(doc, null, 2));
89
- return true;
82
+ return withOmpYamlDocument(path, (doc) => {
83
+ const current = doc.get('disabledExtensions');
84
+ const list = Array.isArray(current)
85
+ ? current.filter((item): item is string => typeof item === 'string')
86
+ : [];
87
+ if (disabled === list.includes(id)) return { result: false, changed: false };
88
+ doc.set('disabledExtensions', disabled ? [...list, id] : list.filter((item) => item !== id));
89
+ return { result: true, changed: true };
90
+ });
90
91
  }
@@ -16,10 +16,10 @@
16
16
  * never exposed to the browser and are preserved when an edit omits them.
17
17
  */
18
18
 
19
- import fs from 'fs';
20
- import { dirname, join, resolve } from 'path';
19
+ import { join, resolve } from 'path';
21
20
  import { getAgentDir, pathExists } from '@/server/lib/omp/core/paths';
22
21
  import { writeFileAtomic } from '@/server/lib/fs/atomic-write';
22
+ import { withConfigLock } from '@/server/lib/fs/config-lock';
23
23
  import { isRecord } from '@/shared/lib/util/guards';
24
24
 
25
25
  const MAX_MCP_CONFIG_BYTES = 512 * 1024;
@@ -86,59 +86,6 @@ export async function readProjectMcpConfig(projectRoot: string): Promise<McpUser
86
86
  return readMcpFile((await resolveProjectMcpConfig(projectRoot)).path);
87
87
  }
88
88
 
89
- // Two writers (e.g. the dev server and the installed app) editing the same
90
- // mcp.json would otherwise silently lose the earlier mutation when the later
91
- // rename wins. A lockfile with exclusive create (`wx`) is atomic on every
92
- // platform; the holder writes its PID and deletes the file on completion.
93
- // Stale locks (writer crashed) are broken after a grace period.
94
- const MCP_LOCK_TIMEOUT_MS = 3_000;
95
- const MCP_LOCK_STALE_MS = 10_000;
96
- const MCP_LOCK_RETRY_MS = 25;
97
-
98
- export async function withMcpConfigLock<T>(configPath: string, fn: () => T | Promise<T>): Promise<T> {
99
- const lockPath = `${configPath}.lock`;
100
- // The config file may not exist yet (first write) — the lockfile needs its
101
- // parent dir to exist before exclusive-create can succeed.
102
- await fs.promises.mkdir(dirname(lockPath), { recursive: true });
103
- const deadline = Date.now() + MCP_LOCK_TIMEOUT_MS;
104
- for (;;) {
105
- let handle: fs.promises.FileHandle | null = null;
106
- try {
107
- handle = await fs.promises.open(lockPath, 'wx');
108
- } catch (error) {
109
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
110
- // Held by another process — break it if stale, otherwise wait and retry.
111
- try {
112
- if (Date.now() - (await fs.promises.stat(lockPath)).mtimeMs > MCP_LOCK_STALE_MS) {
113
- await fs.promises.unlink(lockPath);
114
- continue;
115
- }
116
- } catch {
117
- continue;
118
- }
119
- if (Date.now() >= deadline) {
120
- throw new Error(`Timed out waiting for ${lockPath} (another process holds the MCP config lock)`);
121
- }
122
- await Bun.sleep(MCP_LOCK_RETRY_MS);
123
- continue;
124
- }
125
- try {
126
- await handle.writeFile(String(process.pid));
127
- } finally {
128
- await handle.close();
129
- }
130
- try {
131
- return await fn();
132
- } finally {
133
- try {
134
- await fs.promises.unlink(lockPath);
135
- } catch {
136
- // Already removed (e.g. by cleanup) — the critical section is done.
137
- }
138
- }
139
- }
140
- }
141
-
142
89
  async function readConfigFile(path: string): Promise<McpFile> {
143
90
  if (!(await Bun.file(path).exists())) return { mcpServers: {} };
144
91
  const parsed: unknown = JSON.parse(await Bun.file(path).text());
@@ -152,7 +99,7 @@ async function writeConfigFile(path: string, config: McpFile): Promise<void> {
152
99
  }
153
100
 
154
101
  async function writeServerAt(path: string, name: string, server: McpServer, previousName?: string): Promise<{ path: string }> {
155
- return withMcpConfigLock(path, async () => {
102
+ return withConfigLock(path, async () => {
156
103
  const config = await readConfigFile(path);
157
104
  const servers = { ...(config.mcpServers ?? {}) };
158
105
  // Capture the old entry before any rename: a rename must retain
@@ -173,7 +120,7 @@ async function writeServerAt(path: string, name: string, server: McpServer, prev
173
120
 
174
121
  async function deleteServerAt(path: string, name: string): Promise<{ path: string }> {
175
122
  if (!(await Bun.file(path).exists())) throw new Error('MCP server was not found');
176
- return withMcpConfigLock(path, async () => {
123
+ return withConfigLock(path, async () => {
177
124
  const config = await readConfigFile(path);
178
125
  const servers = { ...(config.mcpServers ?? {}) };
179
126
  if (!(name in servers)) throw new Error('MCP server was not found');
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Per-model overrides in omp's own registry (`models.yml` →
8
+ * `providers.<slug>.modelOverrides.<modelId>`).
9
+ *
10
+ * omp resolves sampling per MODEL from here and globally from `config.yml`
11
+ * (`temperature`, `topP`, `topK`, `minP`), with no per-model temperature at
12
+ * all. The chamber's model dialog therefore only offers what omp honours:
13
+ * `maxTokens` and a reasoning `thinking` block. Writing them anywhere else
14
+ * (the SQLite overlay) left the controls inert — the UI showed a value the
15
+ * agent never applied.
16
+ */
17
+
18
+ import { isMap, type Document } from 'yaml';
19
+ import { getModelsConfigPath } from '@/server/lib/omp/config/models-config';
20
+ import { plainOf, withOmpYamlDocument, OmpConfigError } from '@/server/lib/omp/config/document';
21
+
22
+ /** Effort levels omp's `ThinkingConfig.efforts` accepts. */
23
+ export type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
24
+
25
+ export interface ModelOverrideInput {
26
+ provider: string;
27
+ modelId: string;
28
+ /** Output-token cap; `null` clears the override. */
29
+ maxTokens?: number | null;
30
+ /** Default reasoning effort; `null` clears the thinking override. */
31
+ reasoningEffort?: ReasoningEffort | null;
32
+ }
33
+
34
+ export interface ModelOverrideResult {
35
+ written: boolean;
36
+ /** True when the provider/model pair does not exist in models.yml. */
37
+ unknownTarget?: boolean;
38
+ reason?: string;
39
+ }
40
+
41
+ /** True when the provider entry holds the given model id. */
42
+ function hasModel(doc: Document, provider: string, modelId: string): boolean {
43
+ const providers = doc.get('providers');
44
+ if (!isMap(providers)) return false;
45
+ const entry = providers.get(provider);
46
+ if (!isMap(entry)) return false;
47
+ const models = plainOf<Array<{ id?: string }>>(doc, entry.get('models'));
48
+ return Array.isArray(models) && models.some((model) => model?.id === modelId);
49
+ }
50
+
51
+ /**
52
+ * Write `maxTokens` / `reasoning` overrides for one model. An override that is
53
+ * cleared is deleted rather than written as `null`, so the model falls back to
54
+ * the catalog value; when nothing remains the whole `modelOverrides` entry goes
55
+ * away. The provider must already hold the model — an override for an
56
+ * unregistered id is silently ignored by omp.
57
+ */
58
+ export async function writeModelOverride(input: ModelOverrideInput): Promise<ModelOverrideResult> {
59
+ const path = await getModelsConfigPath();
60
+ return withOmpYamlDocument<ModelOverrideResult>(path, (doc) => {
61
+ if (!hasModel(doc, input.provider, input.modelId)) {
62
+ return {
63
+ result: {
64
+ written: false,
65
+ unknownTarget: true,
66
+ reason: `${input.modelId} is not registered under provider "${input.provider}" in models.yml`,
67
+ },
68
+ changed: false,
69
+ };
70
+ }
71
+
72
+ const overrides = doc.getIn(['providers', input.provider, 'modelOverrides']);
73
+ if (overrides !== undefined && !isMap(overrides)) {
74
+ throw new OmpConfigError(`${path}: providers.${input.provider}.modelOverrides must be a mapping`);
75
+ }
76
+
77
+ const existing = isMap(overrides) ? overrides.get(input.modelId) : undefined;
78
+ if (existing !== undefined && !isMap(existing)) {
79
+ throw new OmpConfigError(`${path}: providers.${input.provider}.modelOverrides.${input.modelId} must be a mapping`);
80
+ }
81
+ const entry = isMap(existing) ? existing : undefined;
82
+
83
+ // Applying through `setIn` materializes the intermediate maps as YAML
84
+ // nodes, so a fresh override nests correctly.
85
+ if (input.maxTokens !== undefined) {
86
+ if (input.maxTokens === null) doc.deleteIn(['providers', input.provider, 'modelOverrides', input.modelId, 'maxTokens']);
87
+ else if (!(input.maxTokens > 0)) throw new OmpConfigError('maxTokens must be a positive number');
88
+ else doc.setIn(['providers', input.provider, 'modelOverrides', input.modelId, 'maxTokens'], input.maxTokens);
89
+ }
90
+
91
+ if (input.reasoningEffort !== undefined) {
92
+ if (input.reasoningEffort === null) {
93
+ doc.deleteIn(['providers', input.provider, 'modelOverrides', input.modelId, 'thinking']);
94
+ } else {
95
+ // `efforts` must list the level the model may use; the previous list is
96
+ // kept when there is one so a catalog-provided vocabulary is not
97
+ // narrowed to a single value by a default-level change.
98
+ const previous = entry ? plainOf<{ efforts?: string[] }>(doc, entry.get('thinking')) : undefined;
99
+ const efforts = Array.isArray(previous?.efforts) && previous.efforts.length > 0
100
+ ? [...new Set([...previous.efforts, input.reasoningEffort])]
101
+ : ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
102
+ doc.setIn(['providers', input.provider, 'modelOverrides', input.modelId, 'thinking'], {
103
+ mode: 'effort',
104
+ efforts,
105
+ defaultLevel: input.reasoningEffort,
106
+ });
107
+ }
108
+ }
109
+
110
+ // An override left with no keys is dead weight — drop it so the model
111
+ // returns to the pure catalog definition.
112
+ const after = doc.getIn(['providers', input.provider, 'modelOverrides', input.modelId]);
113
+ if (isMap(after) && after.items.length === 0) {
114
+ doc.deleteIn(['providers', input.provider, 'modelOverrides', input.modelId]);
115
+ }
116
+ const parent = doc.getIn(['providers', input.provider, 'modelOverrides']);
117
+ if (isMap(parent) && parent.items.length === 0) {
118
+ doc.deleteIn(['providers', input.provider, 'modelOverrides']);
119
+ }
120
+
121
+ return { result: { written: true }, changed: true };
122
+ });
123
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Read side of the native OMP models config: the canonical path and the
8
+ * providers/models registered in it. Read-only — every write lives in
9
+ * `./providers.ts`, which owns the document-model merge.
10
+ */
11
+
12
+ import { join } from 'path';
13
+ import { getAgentDir } from '@/server/lib/omp/core/paths';
14
+ import { isRecord } from '@/shared/lib/util/guards';
15
+
16
+ const MODELS_FILENAMES = ['models.yml', 'models.yaml'] as const;
17
+
18
+ /**
19
+ * Path of the native OMP models config. Prefers the canonical `models.yml`,
20
+ * which is what omp's own `ConfigFile` treats as the primary target and what
21
+ * the JSON→YAML migration writes; `models.yaml` is only the legacy fallback for
22
+ * a file that already exists under that name. A fresh install therefore gets
23
+ * `models.yml`, never the non-canonical spelling.
24
+ */
25
+ export async function getModelsConfigPath(): Promise<string> {
26
+ const dir = getAgentDir();
27
+ for (const name of MODELS_FILENAMES) {
28
+ const candidate = join(dir, name);
29
+ if (await Bun.file(candidate).exists()) return candidate;
30
+ }
31
+ return join(dir, MODELS_FILENAMES[0]);
32
+ }
33
+
34
+ export interface NativeProviderInfo {
35
+ /** Provider slug, e.g. "deepseek" or "custom/my-gateway". */
36
+ slug: string;
37
+ /** Base URL of the provider API, when configured. */
38
+ baseUrl?: string;
39
+ /** Model ids registered under this provider in models.yml. */
40
+ modelIds: string[];
41
+ models: NativeModelInfo[];
42
+ }
43
+
44
+ export interface NativeModelInfo {
45
+ id: string;
46
+ name?: string;
47
+ contextWindow?: number;
48
+ maxTokens?: number;
49
+ reasoning?: boolean;
50
+ imageInput?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Read custom providers/models registered in models.yml. Returns [] when the
55
+ * file is missing or has no providers — never throws for absent files.
56
+ *
57
+ * The `models` collection is array-only here, matching omp's schema. The
58
+ * previous tolerant map-form reader made the chamber report providers as
59
+ * healthy while omp had disabled the entire file for the same content, and the
60
+ * next upsert then rewrote the map as an array, discarding every model in it.
61
+ */
62
+ export async function readNativeProviders(): Promise<NativeProviderInfo[]> {
63
+ const path = await getModelsConfigPath();
64
+ if (!(await Bun.file(path).exists())) return [];
65
+ try {
66
+ const data = Bun.YAML.parse(await Bun.file(path).text());
67
+ if (!isRecord(data) || !isRecord(data.providers)) return [];
68
+ return Object.entries(data.providers).map(([slug, value]) => {
69
+ const info: NativeProviderInfo = { slug, modelIds: [], models: [] };
70
+ if (isRecord(value)) {
71
+ if (typeof value.baseUrl === 'string') info.baseUrl = value.baseUrl;
72
+ if (Array.isArray(value.models)) {
73
+ info.models = value.models.flatMap((model) => {
74
+ if (!isRecord(model) || typeof model.id !== 'string') return [];
75
+ return [{
76
+ id: model.id,
77
+ ...(typeof model.name === 'string' ? { name: model.name } : {}),
78
+ ...(typeof model.contextWindow === 'number' ? { contextWindow: model.contextWindow } : {}),
79
+ ...(typeof model.maxTokens === 'number' ? { maxTokens: model.maxTokens } : {}),
80
+ ...(model.reasoning === true ? { reasoning: true } : {}),
81
+ ...(Array.isArray(model.input) && model.input.includes('image') ? { imageInput: true } : {}),
82
+ }];
83
+ });
84
+ }
85
+ info.modelIds = info.models.map((model) => model.id);
86
+ }
87
+ return info;
88
+ });
89
+ } catch {
90
+ return [];
91
+ }
92
+ }