@shipfox/api-agent 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 (274) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +105 -0
  5. package/LICENSE +21 -0
  6. package/dist/config.d.ts +13 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +60 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/core/credential-fingerprints.d.ts +10 -0
  11. package/dist/core/credential-fingerprints.d.ts.map +1 -0
  12. package/dist/core/credential-fingerprints.js +65 -0
  13. package/dist/core/credential-fingerprints.js.map +1 -0
  14. package/dist/core/custom-model-provider-config-service.d.ts +26 -0
  15. package/dist/core/custom-model-provider-config-service.d.ts.map +1 -0
  16. package/dist/core/custom-model-provider-config-service.js +390 -0
  17. package/dist/core/custom-model-provider-config-service.js.map +1 -0
  18. package/dist/core/custom-provider-url.d.ts +4 -0
  19. package/dist/core/custom-provider-url.d.ts.map +1 -0
  20. package/dist/core/custom-provider-url.js +23 -0
  21. package/dist/core/custom-provider-url.js.map +1 -0
  22. package/dist/core/discover-custom-model-provider-models.d.ts +3 -0
  23. package/dist/core/discover-custom-model-provider-models.d.ts.map +1 -0
  24. package/dist/core/discover-custom-model-provider-models.js +122 -0
  25. package/dist/core/discover-custom-model-provider-models.js.map +1 -0
  26. package/dist/core/entities/agent-workspace-settings.d.ts +9 -0
  27. package/dist/core/entities/agent-workspace-settings.d.ts.map +1 -0
  28. package/dist/core/entities/agent-workspace-settings.js +3 -0
  29. package/dist/core/entities/agent-workspace-settings.js.map +1 -0
  30. package/dist/core/entities/model-provider-config.d.ts +19 -0
  31. package/dist/core/entities/model-provider-config.d.ts.map +1 -0
  32. package/dist/core/entities/model-provider-config.js +3 -0
  33. package/dist/core/entities/model-provider-config.js.map +1 -0
  34. package/dist/core/errors.d.ts +62 -0
  35. package/dist/core/errors.d.ts.map +1 -0
  36. package/dist/core/errors.js +80 -0
  37. package/dist/core/errors.js.map +1 -0
  38. package/dist/core/harness/claude.d.ts +6 -0
  39. package/dist/core/harness/claude.d.ts.map +1 -0
  40. package/dist/core/harness/claude.js +104 -0
  41. package/dist/core/harness/claude.js.map +1 -0
  42. package/dist/core/harness/index.d.ts +4 -0
  43. package/dist/core/harness/index.d.ts.map +1 -0
  44. package/dist/core/harness/index.js +5 -0
  45. package/dist/core/harness/index.js.map +1 -0
  46. package/dist/core/harness/pi.d.ts +6 -0
  47. package/dist/core/harness/pi.d.ts.map +1 -0
  48. package/dist/core/harness/pi.js +16 -0
  49. package/dist/core/harness/pi.js.map +1 -0
  50. package/dist/core/harness/registry.d.ts +22 -0
  51. package/dist/core/harness/registry.d.ts.map +1 -0
  52. package/dist/core/harness/registry.js +46 -0
  53. package/dist/core/harness/registry.js.map +1 -0
  54. package/dist/core/index.d.ts +12 -0
  55. package/dist/core/index.d.ts.map +1 -0
  56. package/dist/core/index.js +11 -0
  57. package/dist/core/index.js.map +1 -0
  58. package/dist/core/model-provider-catalog.d.ts +3 -0
  59. package/dist/core/model-provider-catalog.d.ts.map +1 -0
  60. package/dist/core/model-provider-catalog.js +25 -0
  61. package/dist/core/model-provider-catalog.js.map +1 -0
  62. package/dist/core/model-provider-config-service.d.ts +32 -0
  63. package/dist/core/model-provider-config-service.d.ts.map +1 -0
  64. package/dist/core/model-provider-config-service.js +133 -0
  65. package/dist/core/model-provider-config-service.js.map +1 -0
  66. package/dist/core/model-provider-validation.d.ts +31 -0
  67. package/dist/core/model-provider-validation.d.ts.map +1 -0
  68. package/dist/core/model-provider-validation.js +220 -0
  69. package/dist/core/model-provider-validation.js.map +1 -0
  70. package/dist/core/resolve-agent-config.d.ts +32 -0
  71. package/dist/core/resolve-agent-config.d.ts.map +1 -0
  72. package/dist/core/resolve-agent-config.js +143 -0
  73. package/dist/core/resolve-agent-config.js.map +1 -0
  74. package/dist/core/resolve-runtime-credentials.d.ts +21 -0
  75. package/dist/core/resolve-runtime-credentials.d.ts.map +1 -0
  76. package/dist/core/resolve-runtime-credentials.js +101 -0
  77. package/dist/core/resolve-runtime-credentials.js.map +1 -0
  78. package/dist/core/workspace-agent-defaults-resolver.d.ts +3 -0
  79. package/dist/core/workspace-agent-defaults-resolver.d.ts.map +1 -0
  80. package/dist/core/workspace-agent-defaults-resolver.js +26 -0
  81. package/dist/core/workspace-agent-defaults-resolver.js.map +1 -0
  82. package/dist/db/agent-defaults-snapshot.d.ts +9 -0
  83. package/dist/db/agent-defaults-snapshot.d.ts.map +1 -0
  84. package/dist/db/agent-defaults-snapshot.js +24 -0
  85. package/dist/db/agent-defaults-snapshot.js.map +1 -0
  86. package/dist/db/agent-workspace-settings.d.ts +12 -0
  87. package/dist/db/agent-workspace-settings.d.ts.map +1 -0
  88. package/dist/db/agent-workspace-settings.js +53 -0
  89. package/dist/db/agent-workspace-settings.js.map +1 -0
  90. package/dist/db/db.d.ts +771 -0
  91. package/dist/db/db.d.ts.map +1 -0
  92. package/dist/db/db.js +20 -0
  93. package/dist/db/db.js.map +1 -0
  94. package/dist/db/index.d.ts +9 -0
  95. package/dist/db/index.d.ts.map +1 -0
  96. package/dist/db/index.js +11 -0
  97. package/dist/db/index.js.map +1 -0
  98. package/dist/db/model-provider-configs.d.ts +38 -0
  99. package/dist/db/model-provider-configs.d.ts.map +1 -0
  100. package/dist/db/model-provider-configs.js +159 -0
  101. package/dist/db/model-provider-configs.js.map +1 -0
  102. package/dist/db/schema/agent-workspace-settings.d.ts +97 -0
  103. package/dist/db/schema/agent-workspace-settings.d.ts.map +1 -0
  104. package/dist/db/schema/agent-workspace-settings.js +26 -0
  105. package/dist/db/schema/agent-workspace-settings.js.map +1 -0
  106. package/dist/db/schema/common.d.ts +2 -0
  107. package/dist/db/schema/common.d.ts.map +1 -0
  108. package/dist/db/schema/common.js +4 -0
  109. package/dist/db/schema/common.js.map +1 -0
  110. package/dist/db/schema/model-provider-configs.d.ts +296 -0
  111. package/dist/db/schema/model-provider-configs.d.ts.map +1 -0
  112. package/dist/db/schema/model-provider-configs.js +52 -0
  113. package/dist/db/schema/model-provider-configs.js.map +1 -0
  114. package/dist/index.d.ts +5 -0
  115. package/dist/index.d.ts.map +1 -0
  116. package/dist/index.js +18 -0
  117. package/dist/index.js.map +1 -0
  118. package/dist/metrics/index.d.ts +2 -0
  119. package/dist/metrics/index.d.ts.map +1 -0
  120. package/dist/metrics/index.js +3 -0
  121. package/dist/metrics/index.js.map +1 -0
  122. package/dist/metrics/instance.d.ts +10 -0
  123. package/dist/metrics/instance.d.ts.map +1 -0
  124. package/dist/metrics/instance.js +10 -0
  125. package/dist/metrics/instance.js.map +1 -0
  126. package/dist/presentation/dto/index.d.ts +2 -0
  127. package/dist/presentation/dto/index.d.ts.map +1 -0
  128. package/dist/presentation/dto/index.js +3 -0
  129. package/dist/presentation/dto/index.js.map +1 -0
  130. package/dist/presentation/dto/model-provider-config.d.ts +6 -0
  131. package/dist/presentation/dto/model-provider-config.d.ts.map +1 -0
  132. package/dist/presentation/dto/model-provider-config.js +32 -0
  133. package/dist/presentation/dto/model-provider-config.js.map +1 -0
  134. package/dist/presentation/e2eRoutes/create-model-provider.d.ts +2 -0
  135. package/dist/presentation/e2eRoutes/create-model-provider.d.ts.map +1 -0
  136. package/dist/presentation/e2eRoutes/create-model-provider.js +66 -0
  137. package/dist/presentation/e2eRoutes/create-model-provider.js.map +1 -0
  138. package/dist/presentation/e2eRoutes/index.d.ts +3 -0
  139. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  140. package/dist/presentation/e2eRoutes/index.js +9 -0
  141. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  142. package/dist/presentation/index.d.ts +2 -0
  143. package/dist/presentation/index.d.ts.map +1 -0
  144. package/dist/presentation/index.js +3 -0
  145. package/dist/presentation/index.js.map +1 -0
  146. package/dist/presentation/routes/create-custom-model-provider.d.ts +2 -0
  147. package/dist/presentation/routes/create-custom-model-provider.d.ts.map +1 -0
  148. package/dist/presentation/routes/create-custom-model-provider.js +45 -0
  149. package/dist/presentation/routes/create-custom-model-provider.js.map +1 -0
  150. package/dist/presentation/routes/delete-model-provider-config.d.ts +2 -0
  151. package/dist/presentation/routes/delete-model-provider-config.d.ts.map +1 -0
  152. package/dist/presentation/routes/delete-model-provider-config.js +38 -0
  153. package/dist/presentation/routes/delete-model-provider-config.js.map +1 -0
  154. package/dist/presentation/routes/discover-custom-model-provider-models-by-slug.d.ts +2 -0
  155. package/dist/presentation/routes/discover-custom-model-provider-models-by-slug.d.ts.map +1 -0
  156. package/dist/presentation/routes/discover-custom-model-provider-models-by-slug.js +37 -0
  157. package/dist/presentation/routes/discover-custom-model-provider-models-by-slug.js.map +1 -0
  158. package/dist/presentation/routes/discover-custom-model-provider-models.d.ts +2 -0
  159. package/dist/presentation/routes/discover-custom-model-provider-models.d.ts.map +1 -0
  160. package/dist/presentation/routes/discover-custom-model-provider-models.js +31 -0
  161. package/dist/presentation/routes/discover-custom-model-provider-models.js.map +1 -0
  162. package/dist/presentation/routes/errors.d.ts +2 -0
  163. package/dist/presentation/routes/errors.d.ts.map +1 -0
  164. package/dist/presentation/routes/errors.js +112 -0
  165. package/dist/presentation/routes/errors.js.map +1 -0
  166. package/dist/presentation/routes/index.d.ts +3 -0
  167. package/dist/presentation/routes/index.d.ts.map +1 -0
  168. package/dist/presentation/routes/index.js +39 -0
  169. package/dist/presentation/routes/index.js.map +1 -0
  170. package/dist/presentation/routes/list-model-provider-catalog.d.ts +2 -0
  171. package/dist/presentation/routes/list-model-provider-catalog.d.ts.map +1 -0
  172. package/dist/presentation/routes/list-model-provider-catalog.js +18 -0
  173. package/dist/presentation/routes/list-model-provider-catalog.js.map +1 -0
  174. package/dist/presentation/routes/list-model-provider-configs.d.ts +2 -0
  175. package/dist/presentation/routes/list-model-provider-configs.d.ts.map +1 -0
  176. package/dist/presentation/routes/list-model-provider-configs.js +37 -0
  177. package/dist/presentation/routes/list-model-provider-configs.js.map +1 -0
  178. package/dist/presentation/routes/set-default-harness.d.ts +2 -0
  179. package/dist/presentation/routes/set-default-harness.d.ts.map +1 -0
  180. package/dist/presentation/routes/set-default-harness.js +38 -0
  181. package/dist/presentation/routes/set-default-harness.js.map +1 -0
  182. package/dist/presentation/routes/set-default-model-provider.d.ts +2 -0
  183. package/dist/presentation/routes/set-default-model-provider.d.ts.map +1 -0
  184. package/dist/presentation/routes/set-default-model-provider.js +48 -0
  185. package/dist/presentation/routes/set-default-model-provider.js.map +1 -0
  186. package/dist/presentation/routes/update-custom-model-provider.d.ts +2 -0
  187. package/dist/presentation/routes/update-custom-model-provider.d.ts.map +1 -0
  188. package/dist/presentation/routes/update-custom-model-provider.js +47 -0
  189. package/dist/presentation/routes/update-custom-model-provider.js.map +1 -0
  190. package/dist/presentation/routes/update-model-provider-default-model.d.ts +2 -0
  191. package/dist/presentation/routes/update-model-provider-default-model.d.ts.map +1 -0
  192. package/dist/presentation/routes/update-model-provider-default-model.js +38 -0
  193. package/dist/presentation/routes/update-model-provider-default-model.js.map +1 -0
  194. package/dist/presentation/routes/upsert-model-provider-config.d.ts +2 -0
  195. package/dist/presentation/routes/upsert-model-provider-config.d.ts.map +1 -0
  196. package/dist/presentation/routes/upsert-model-provider-config.js +52 -0
  197. package/dist/presentation/routes/upsert-model-provider-config.js.map +1 -0
  198. package/dist/tsconfig.test.tsbuildinfo +1 -0
  199. package/drizzle/0000_daffy_leopardon.sql +28 -0
  200. package/drizzle/0001_default_harness.sql +1 -0
  201. package/drizzle/meta/0000_snapshot.json +199 -0
  202. package/drizzle/meta/0001_snapshot.json +205 -0
  203. package/drizzle/meta/_journal.json +20 -0
  204. package/drizzle.config.ts +7 -0
  205. package/package.json +109 -0
  206. package/src/config.test.ts +118 -0
  207. package/src/config.ts +75 -0
  208. package/src/core/catalog-registry.test.ts +48 -0
  209. package/src/core/credential-fingerprints.test.ts +34 -0
  210. package/src/core/credential-fingerprints.ts +84 -0
  211. package/src/core/custom-model-provider-config-service.ts +537 -0
  212. package/src/core/custom-provider-url.ts +24 -0
  213. package/src/core/discover-custom-model-provider-models.ts +147 -0
  214. package/src/core/entities/agent-workspace-settings.ts +9 -0
  215. package/src/core/entities/model-provider-config.ts +25 -0
  216. package/src/core/errors.ts +139 -0
  217. package/src/core/harness/claude.ts +75 -0
  218. package/src/core/harness/index.ts +19 -0
  219. package/src/core/harness/pi.ts +18 -0
  220. package/src/core/harness/registry.test.ts +281 -0
  221. package/src/core/harness/registry.ts +108 -0
  222. package/src/core/index.ts +56 -0
  223. package/src/core/model-provider-catalog.ts +33 -0
  224. package/src/core/model-provider-config-service.test.ts +940 -0
  225. package/src/core/model-provider-config-service.ts +207 -0
  226. package/src/core/model-provider-validation.test.ts +354 -0
  227. package/src/core/model-provider-validation.ts +276 -0
  228. package/src/core/resolve-agent-config.test.ts +441 -0
  229. package/src/core/resolve-agent-config.ts +272 -0
  230. package/src/core/resolve-runtime-credentials.test.ts +316 -0
  231. package/src/core/resolve-runtime-credentials.ts +147 -0
  232. package/src/core/workspace-agent-defaults-resolver.ts +42 -0
  233. package/src/db/agent-defaults-snapshot.ts +49 -0
  234. package/src/db/agent-workspace-settings.test.ts +111 -0
  235. package/src/db/agent-workspace-settings.ts +92 -0
  236. package/src/db/db.ts +20 -0
  237. package/src/db/index.ts +29 -0
  238. package/src/db/model-provider-configs.test.ts +281 -0
  239. package/src/db/model-provider-configs.ts +230 -0
  240. package/src/db/schema/agent-workspace-settings.ts +27 -0
  241. package/src/db/schema/common.ts +3 -0
  242. package/src/db/schema/model-provider-configs.ts +80 -0
  243. package/src/index.ts +43 -0
  244. package/src/metrics/index.ts +1 -0
  245. package/src/metrics/instance.ts +18 -0
  246. package/src/presentation/dto/index.ts +5 -0
  247. package/src/presentation/dto/model-provider-config.ts +41 -0
  248. package/src/presentation/e2eRoutes/create-model-provider.test.ts +47 -0
  249. package/src/presentation/e2eRoutes/create-model-provider.ts +61 -0
  250. package/src/presentation/e2eRoutes/index.ts +7 -0
  251. package/src/presentation/index.ts +1 -0
  252. package/src/presentation/routes/create-custom-model-provider.ts +45 -0
  253. package/src/presentation/routes/delete-model-provider-config.ts +31 -0
  254. package/src/presentation/routes/discover-custom-model-provider-models-by-slug.ts +42 -0
  255. package/src/presentation/routes/discover-custom-model-provider-models.ts +29 -0
  256. package/src/presentation/routes/errors.ts +122 -0
  257. package/src/presentation/routes/index.ts +37 -0
  258. package/src/presentation/routes/list-model-provider-catalog.ts +15 -0
  259. package/src/presentation/routes/list-model-provider-configs.ts +33 -0
  260. package/src/presentation/routes/model-provider-catalog.test.ts +73 -0
  261. package/src/presentation/routes/model-provider-config.test.ts +807 -0
  262. package/src/presentation/routes/set-default-harness.ts +33 -0
  263. package/src/presentation/routes/set-default-model-provider.ts +47 -0
  264. package/src/presentation/routes/update-custom-model-provider.ts +50 -0
  265. package/src/presentation/routes/update-model-provider-default-model.ts +40 -0
  266. package/src/presentation/routes/upsert-model-provider-config.ts +53 -0
  267. package/test/env.ts +8 -0
  268. package/test/globalSetup.ts +25 -0
  269. package/test/setup.ts +17 -0
  270. package/tsconfig.build.json +9 -0
  271. package/tsconfig.build.tsbuildinfo +1 -0
  272. package/tsconfig.json +3 -0
  273. package/tsconfig.test.json +8 -0
  274. package/vitest.config.ts +11 -0
@@ -0,0 +1,940 @@
1
+ import crypto from 'node:crypto';
2
+ import * as secretStore from '@shipfox/api-secrets';
3
+ import * as modelProviderDb from '#db/index.js';
4
+ import {getModelProviderConfig, upsertModelProviderConfig} from '#db/index.js';
5
+ import {agentSystemNamespace} from './credential-fingerprints.js';
6
+ import {
7
+ createCustomModelProviderConfig,
8
+ resolveCustomModelProviderDiscoveryParams,
9
+ updateCustomModelProviderConfig,
10
+ } from './custom-model-provider-config-service.js';
11
+ import {
12
+ CustomModelProviderSlugCollisionError,
13
+ CustomModelProviderStoredSecretBaseUrlChangeError,
14
+ InvalidAgentModelError,
15
+ InvalidCredentialFieldsError,
16
+ InvalidCustomModelProviderHeaderKeepError,
17
+ ModelProviderConfigNotFoundError,
18
+ ModelProviderValidationError,
19
+ } from './errors.js';
20
+ import {
21
+ deleteModelProviderConfig,
22
+ testAndSaveModelProviderConfig,
23
+ updateModelProviderConfigDefaultModel,
24
+ } from './model-provider-config-service.js';
25
+
26
+ const {getSecretsByNamespace, setSecrets} = secretStore;
27
+
28
+ describe('testAndSaveModelProviderConfig', () => {
29
+ let workspaceId: string;
30
+
31
+ beforeEach(() => {
32
+ workspaceId = crypto.randomUUID();
33
+ });
34
+
35
+ afterEach(() => {
36
+ vi.unstubAllEnvs();
37
+ });
38
+
39
+ it('validates, stores secrets, and stores a provider config', async () => {
40
+ const credentials = {api_key: 'sk-ant-secret-abcd'};
41
+ const probe = vi.fn().mockResolvedValue(undefined);
42
+
43
+ const config = await testAndSaveModelProviderConfig(
44
+ {
45
+ workspaceId,
46
+ providerId: 'anthropic',
47
+ credentials,
48
+ editedBy: '11111111-1111-4111-8111-111111111111',
49
+ },
50
+ {probe},
51
+ );
52
+
53
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
54
+ const secrets = await getSecretsByNamespace({
55
+ workspaceId,
56
+ namespace: agentSystemNamespace('anthropic'),
57
+ });
58
+
59
+ expect(probe).toHaveBeenCalledWith({
60
+ providerId: 'anthropic',
61
+ model: 'claude-opus-4-8',
62
+ credentials,
63
+ });
64
+ expect(stored).toEqual(config);
65
+ expect(secrets).toEqual({API_KEY: credentials.api_key});
66
+ expect(stored?.defaultModel).toBeNull();
67
+ expect(stored?.defaultThinking).toBe('xhigh');
68
+ });
69
+
70
+ it('validates and stores an explicit provider default model', async () => {
71
+ const credentials = {api_key: 'sk-ant-secret-abcd'};
72
+ const probe = vi.fn().mockResolvedValue(undefined);
73
+
74
+ const config = await testAndSaveModelProviderConfig(
75
+ {
76
+ workspaceId,
77
+ providerId: 'anthropic',
78
+ defaultModel: 'claude-haiku-4-5',
79
+ credentials,
80
+ },
81
+ {probe},
82
+ );
83
+
84
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
85
+ expect(probe).toHaveBeenCalledWith({
86
+ providerId: 'anthropic',
87
+ model: 'claude-haiku-4-5',
88
+ credentials,
89
+ });
90
+ expect(stored).toEqual(config);
91
+ expect(stored?.defaultModel).toBe('claude-haiku-4-5');
92
+ });
93
+
94
+ it('passes the abort signal to the provider probe', async () => {
95
+ const credentials = {api_key: 'sk-ant-secret-abcd'};
96
+ const abortController = new AbortController();
97
+ const probe = vi.fn().mockResolvedValue(undefined);
98
+
99
+ await testAndSaveModelProviderConfig(
100
+ {workspaceId, providerId: 'anthropic', credentials, signal: abortController.signal},
101
+ {probe},
102
+ );
103
+
104
+ expect(probe).toHaveBeenCalledWith({
105
+ providerId: 'anthropic',
106
+ model: 'claude-opus-4-8',
107
+ credentials,
108
+ signal: abortController.signal,
109
+ });
110
+ });
111
+
112
+ it('rethrows aborted probe errors without treating them as validation failures', async () => {
113
+ const abortController = new AbortController();
114
+ const error = new Error('Provider probe aborted');
115
+ const probe = vi.fn().mockRejectedValue(error);
116
+ abortController.abort();
117
+
118
+ const save = testAndSaveModelProviderConfig(
119
+ {
120
+ workspaceId,
121
+ providerId: 'anthropic',
122
+ credentials: {api_key: 'sk-ant-secret-abcd'},
123
+ signal: abortController.signal,
124
+ },
125
+ {probe},
126
+ );
127
+
128
+ await expect(save).rejects.toBe(error);
129
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
130
+ expect(stored).toBeUndefined();
131
+ });
132
+
133
+ it('preserves an existing default model when rotating credentials without a model selection', async () => {
134
+ await upsertModelProviderConfig({
135
+ workspaceId,
136
+ providerId: 'anthropic',
137
+ defaultModel: 'claude-haiku-4-5',
138
+ defaultThinking: 'high',
139
+ });
140
+ const credentials = {api_key: 'sk-ant-rotated-abcd'};
141
+ const probe = vi.fn().mockResolvedValue(undefined);
142
+
143
+ const config = await testAndSaveModelProviderConfig(
144
+ {
145
+ workspaceId,
146
+ providerId: 'anthropic',
147
+ credentials,
148
+ },
149
+ {probe},
150
+ );
151
+
152
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
153
+ expect(probe).toHaveBeenCalledWith({
154
+ providerId: 'anthropic',
155
+ model: 'claude-haiku-4-5',
156
+ credentials,
157
+ });
158
+ expect(stored).toEqual(config);
159
+ expect(stored?.defaultModel).toBe('claude-haiku-4-5');
160
+ });
161
+
162
+ it('stores Latest when rotating credentials with a null default model selection', async () => {
163
+ await upsertModelProviderConfig({
164
+ workspaceId,
165
+ providerId: 'anthropic',
166
+ defaultModel: 'claude-haiku-4-5',
167
+ defaultThinking: 'high',
168
+ });
169
+ const credentials = {api_key: 'sk-ant-rotated-abcd'};
170
+ const probe = vi.fn().mockResolvedValue(undefined);
171
+
172
+ const config = await testAndSaveModelProviderConfig(
173
+ {
174
+ workspaceId,
175
+ providerId: 'anthropic',
176
+ defaultModel: null,
177
+ credentials,
178
+ },
179
+ {probe},
180
+ );
181
+
182
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
183
+ expect(probe).toHaveBeenCalledWith({
184
+ providerId: 'anthropic',
185
+ model: 'claude-opus-4-8',
186
+ credentials,
187
+ });
188
+ expect(stored).toEqual(config);
189
+ expect(stored?.defaultModel).toBeNull();
190
+ });
191
+
192
+ it('does not persist when provider validation fails and sanitizes the surfaced error', async () => {
193
+ const secret = 'sk-ant-secret-abcd';
194
+ const base64Secret = Buffer.from(secret, 'utf8').toString('base64');
195
+ const probe = vi
196
+ .fn()
197
+ .mockRejectedValue(new Error(`Provider rejected ${secret} and ${base64Secret}`));
198
+
199
+ const save = testAndSaveModelProviderConfig(
200
+ {workspaceId, providerId: 'anthropic', credentials: {api_key: secret}},
201
+ {probe},
202
+ );
203
+
204
+ await expect(save).rejects.toThrow(ModelProviderValidationError);
205
+ await expect(save).rejects.not.toThrow(secret);
206
+ await expect(save).rejects.not.toThrow(base64Secret);
207
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
208
+ expect(stored).toBeUndefined();
209
+ });
210
+
211
+ it('sanitizes non-Error probe failures and persists nothing', async () => {
212
+ const probe = vi.fn().mockRejectedValue('raw thrown value');
213
+
214
+ const save = testAndSaveModelProviderConfig(
215
+ {workspaceId, providerId: 'anthropic', credentials: {api_key: 'sk-ant-secret-abcd'}},
216
+ {probe},
217
+ );
218
+
219
+ await expect(save).rejects.toThrow('Provider validation failed.');
220
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
221
+ expect(stored).toBeUndefined();
222
+ });
223
+
224
+ it('leaves an existing row unchanged when credential rotation validation fails', async () => {
225
+ const existing = await upsertModelProviderConfig({
226
+ workspaceId,
227
+ providerId: 'anthropic',
228
+ defaultModel: 'claude-opus-4-8',
229
+ defaultThinking: 'high',
230
+ });
231
+ const probe = vi.fn().mockRejectedValue(new Error('bad key'));
232
+
233
+ const save = testAndSaveModelProviderConfig(
234
+ {workspaceId, providerId: 'anthropic', credentials: {api_key: 'sk-new-secret-abcd'}},
235
+ {probe},
236
+ );
237
+
238
+ await expect(save).rejects.toThrow(ModelProviderValidationError);
239
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
240
+ expect(stored).toEqual(existing);
241
+ });
242
+
243
+ it('propagates InvalidAgentModelError and persists nothing', async () => {
244
+ const error = new InvalidAgentModelError('pi', 'anthropic', 'claude-opus-4-8');
245
+ const probe = vi.fn().mockRejectedValue(error);
246
+
247
+ const save = testAndSaveModelProviderConfig(
248
+ {workspaceId, providerId: 'anthropic', credentials: {api_key: 'sk-ant-secret-abcd'}},
249
+ {probe},
250
+ );
251
+
252
+ await expect(save).rejects.toBe(error);
253
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
254
+ expect(stored).toBeUndefined();
255
+ });
256
+
257
+ it('rejects an explicit default model outside the model provider catalog', async () => {
258
+ const probe = vi.fn();
259
+
260
+ const save = testAndSaveModelProviderConfig(
261
+ {
262
+ workspaceId,
263
+ providerId: 'anthropic',
264
+ defaultModel: 'missing-model',
265
+ credentials: {api_key: 'sk-ant-secret-abcd'},
266
+ },
267
+ {probe},
268
+ );
269
+
270
+ await expect(save).rejects.toThrow(InvalidAgentModelError);
271
+ expect(probe).not.toHaveBeenCalled();
272
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
273
+ expect(stored).toBeUndefined();
274
+ });
275
+
276
+ it('updates the default model without changing credentials', async () => {
277
+ const existing = await upsertModelProviderConfig({
278
+ workspaceId,
279
+ providerId: 'anthropic',
280
+ defaultModel: null,
281
+ defaultThinking: 'high',
282
+ });
283
+
284
+ const updated = await updateModelProviderConfigDefaultModel({
285
+ workspaceId,
286
+ providerId: 'anthropic',
287
+ defaultModel: 'claude-haiku-4-5',
288
+ });
289
+
290
+ expect(updated).toMatchObject({
291
+ defaultModel: 'claude-haiku-4-5',
292
+ defaultThinking: existing.defaultThinking,
293
+ });
294
+ });
295
+
296
+ it('stores null when the default model is set to Latest', async () => {
297
+ await upsertModelProviderConfig({
298
+ workspaceId,
299
+ providerId: 'anthropic',
300
+ defaultModel: 'claude-haiku-4-5',
301
+ defaultThinking: 'high',
302
+ });
303
+
304
+ const updated = await updateModelProviderConfigDefaultModel({
305
+ workspaceId,
306
+ providerId: 'anthropic',
307
+ defaultModel: null,
308
+ });
309
+
310
+ expect(updated.defaultModel).toBeNull();
311
+ });
312
+
313
+ it('rejects a default model update for a missing config', async () => {
314
+ const update = updateModelProviderConfigDefaultModel({
315
+ workspaceId,
316
+ providerId: 'anthropic',
317
+ defaultModel: null,
318
+ });
319
+
320
+ await expect(update).rejects.toThrow(ModelProviderConfigNotFoundError);
321
+ });
322
+
323
+ it('rejects a default model update outside the model provider catalog', async () => {
324
+ await upsertModelProviderConfig({
325
+ workspaceId,
326
+ providerId: 'anthropic',
327
+ defaultModel: null,
328
+ defaultThinking: 'high',
329
+ });
330
+
331
+ const update = updateModelProviderConfigDefaultModel({
332
+ workspaceId,
333
+ providerId: 'anthropic',
334
+ defaultModel: 'missing-model',
335
+ });
336
+
337
+ await expect(update).rejects.toThrow(InvalidAgentModelError);
338
+ });
339
+
340
+ it('validates and stores Azure provider configs with multi-field credentials', async () => {
341
+ const probe = vi.fn();
342
+ const credentials = {
343
+ endpoint: 'https://azure.example.test/openai/v1',
344
+ api_key: 'sk-azure-secret-abcd',
345
+ };
346
+
347
+ const config = await testAndSaveModelProviderConfig(
348
+ {
349
+ workspaceId,
350
+ providerId: 'azure-openai-responses',
351
+ credentials,
352
+ },
353
+ {probe},
354
+ );
355
+
356
+ const stored = await getModelProviderConfig({
357
+ workspaceId,
358
+ providerId: 'azure-openai-responses',
359
+ });
360
+ const secrets = await getSecretsByNamespace({
361
+ workspaceId,
362
+ namespace: agentSystemNamespace('azure-openai-responses'),
363
+ });
364
+
365
+ expect(stored).toEqual(config);
366
+ expect(secrets).toEqual({
367
+ ENDPOINT: credentials.endpoint,
368
+ API_KEY: credentials.api_key,
369
+ });
370
+ });
371
+
372
+ it('validates and stores Cloudflare provider configs with multi-field credentials', async () => {
373
+ const probe = vi.fn();
374
+ const credentials = {
375
+ api_key: 'cf-secret-abcd',
376
+ account_id: 'account-123',
377
+ gateway_id: 'gateway-456',
378
+ };
379
+
380
+ const config = await testAndSaveModelProviderConfig(
381
+ {
382
+ workspaceId,
383
+ providerId: 'cloudflare-ai-gateway',
384
+ credentials,
385
+ },
386
+ {probe},
387
+ );
388
+
389
+ const stored = await getModelProviderConfig({
390
+ workspaceId,
391
+ providerId: 'cloudflare-ai-gateway',
392
+ });
393
+ const secrets = await getSecretsByNamespace({
394
+ workspaceId,
395
+ namespace: agentSystemNamespace('cloudflare-ai-gateway'),
396
+ });
397
+
398
+ expect(stored).toEqual(config);
399
+ expect(secrets).toEqual({
400
+ API_KEY: credentials.api_key,
401
+ ACCOUNT_ID: credentials.account_id,
402
+ GATEWAY_ID: credentials.gateway_id,
403
+ });
404
+ });
405
+
406
+ it('rejects mismatched credential fields and persists nothing', async () => {
407
+ const probe = vi.fn();
408
+
409
+ const save = testAndSaveModelProviderConfig(
410
+ {workspaceId, providerId: 'anthropic', credentials: {wrong_key: 'sk-ant-secret-abcd'}},
411
+ {probe},
412
+ );
413
+
414
+ await expect(save).rejects.toThrow(InvalidCredentialFieldsError);
415
+ expect(probe).not.toHaveBeenCalled();
416
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
417
+ expect(stored).toBeUndefined();
418
+ });
419
+
420
+ it('replaces the provider namespace exactly on credential rotation', async () => {
421
+ await setSecrets({
422
+ workspaceId,
423
+ namespace: agentSystemNamespace('anthropic'),
424
+ values: {API_KEY: 'sk-old-secret', STALE_KEY: 'stale-secret'},
425
+ });
426
+ const probe = vi.fn().mockResolvedValue(undefined);
427
+
428
+ await testAndSaveModelProviderConfig(
429
+ {
430
+ workspaceId,
431
+ providerId: 'anthropic',
432
+ credentials: {api_key: 'sk-ant-rotated-abcd'},
433
+ },
434
+ {probe},
435
+ );
436
+
437
+ const secrets = await getSecretsByNamespace({
438
+ workspaceId,
439
+ namespace: agentSystemNamespace('anthropic'),
440
+ });
441
+ expect(secrets).toEqual({API_KEY: 'sk-ant-rotated-abcd'});
442
+ });
443
+
444
+ it('keeps a successful save when stale credential pruning fails', async () => {
445
+ const probe = vi.fn().mockResolvedValue(undefined);
446
+ const pruneStaleSecrets = vi.fn().mockRejectedValue(new Error('prune failed'));
447
+
448
+ const config = await testAndSaveModelProviderConfig(
449
+ {
450
+ workspaceId,
451
+ providerId: 'anthropic',
452
+ credentials: {api_key: 'sk-ant-secret-abcd'},
453
+ },
454
+ {probe, pruneStaleSecrets},
455
+ );
456
+
457
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
458
+ const secrets = await getSecretsByNamespace({
459
+ workspaceId,
460
+ namespace: agentSystemNamespace('anthropic'),
461
+ });
462
+ expect(stored).toEqual(config);
463
+ expect(secrets).toEqual({API_KEY: 'sk-ant-secret-abcd'});
464
+ expect(pruneStaleSecrets).toHaveBeenCalledWith({
465
+ workspaceId,
466
+ namespace: agentSystemNamespace('anthropic'),
467
+ expectedKeys: ['API_KEY'],
468
+ });
469
+ });
470
+
471
+ it('deletes provider secrets even when the config row is already absent', async () => {
472
+ await setSecrets({
473
+ workspaceId,
474
+ namespace: agentSystemNamespace('anthropic'),
475
+ values: {API_KEY: 'orphaned-secret'},
476
+ });
477
+
478
+ const deleted = await deleteModelProviderConfig({workspaceId, providerId: 'anthropic'});
479
+
480
+ const secrets = await getSecretsByNamespace({
481
+ workspaceId,
482
+ namespace: agentSystemNamespace('anthropic'),
483
+ });
484
+ expect(deleted).toBe(false);
485
+ expect(secrets).toEqual({});
486
+ });
487
+ });
488
+
489
+ describe('custom model provider config service', () => {
490
+ let workspaceId: string;
491
+
492
+ beforeEach(() => {
493
+ workspaceId = crypto.randomUUID();
494
+ });
495
+
496
+ it('creates custom provider configs insert-only and rejects duplicate slugs', async () => {
497
+ const probe = vi.fn().mockResolvedValue(undefined);
498
+ const first = await createCustomModelProviderConfig(
499
+ {
500
+ workspaceId,
501
+ body: createCustomBody({api_key: 'sk-local-original'}),
502
+ },
503
+ {probe},
504
+ );
505
+
506
+ const duplicate = createCustomModelProviderConfig(
507
+ {
508
+ workspaceId,
509
+ body: createCustomBody({api_key: 'sk-local-replacement'}),
510
+ },
511
+ {probe},
512
+ );
513
+
514
+ await expect(duplicate).rejects.toThrow(CustomModelProviderSlugCollisionError);
515
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'local-vllm'});
516
+ expect(stored?.id).toBe(first.id);
517
+ expect(stored?.requiresApiKey).toBe(true);
518
+ });
519
+
520
+ it('stores custom provider key intent from create bodies', async () => {
521
+ const probe = vi.fn().mockResolvedValue(undefined);
522
+
523
+ const keyed = await createCustomModelProviderConfig(
524
+ {
525
+ workspaceId,
526
+ body: createCustomBody({slug: 'keyed-provider', api_key: 'sk-keyed'}),
527
+ },
528
+ {probe},
529
+ );
530
+ const keyless = await createCustomModelProviderConfig(
531
+ {
532
+ workspaceId,
533
+ body: createCustomBody({slug: 'keyless-provider', api_key: undefined}),
534
+ },
535
+ {probe},
536
+ );
537
+
538
+ expect(keyed.requiresApiKey).toBe(true);
539
+ expect(keyless.requiresApiKey).toBe(false);
540
+ });
541
+
542
+ it('does not let concurrent duplicate custom creates overwrite the winner secrets', async () => {
543
+ let probeCalls = 0;
544
+ let releaseProbes: () => void = () => undefined;
545
+ const bothProbesStarted = new Promise<void>((resolve) => {
546
+ releaseProbes = resolve;
547
+ });
548
+ const probe = vi.fn(async () => {
549
+ probeCalls += 1;
550
+ if (probeCalls === 2) releaseProbes();
551
+ await bothProbesStarted;
552
+ });
553
+ const firstSecret = 'sk-local-original-abcd';
554
+ const secondSecret = 'sk-local-replacement-wxyz';
555
+
556
+ const results = await Promise.allSettled([
557
+ createCustomModelProviderConfig(
558
+ {workspaceId, body: createCustomBody({api_key: firstSecret})},
559
+ {probe},
560
+ ),
561
+ createCustomModelProviderConfig(
562
+ {workspaceId, body: createCustomBody({api_key: secondSecret})},
563
+ {probe},
564
+ ),
565
+ ]);
566
+
567
+ const fulfilled = results.filter((result) => result.status === 'fulfilled');
568
+ const rejected = results.filter((result) => result.status === 'rejected');
569
+ const secrets = await getSecretsByNamespace({
570
+ workspaceId,
571
+ namespace: agentSystemNamespace('local-vllm'),
572
+ });
573
+ expect(fulfilled).toHaveLength(1);
574
+ expect(rejected).toHaveLength(1);
575
+ expect(rejected[0]?.reason).toBeInstanceOf(CustomModelProviderSlugCollisionError);
576
+ expect([firstSecret, secondSecret]).toContain(secrets.API_KEY);
577
+ });
578
+
579
+ it('updates custom headers as a full replacement and preserves an omitted api key', async () => {
580
+ const probe = vi.fn().mockResolvedValue(undefined);
581
+ await createCustomModelProviderConfig(
582
+ {
583
+ workspaceId,
584
+ body: createCustomBody({
585
+ api_key: 'sk-local-original',
586
+ headers: [
587
+ {name: 'authorization', value: 'Bearer old', secret: true},
588
+ {name: 'x-region', value: 'us', secret: false},
589
+ ],
590
+ }),
591
+ },
592
+ {probe},
593
+ );
594
+
595
+ const updated = await updateCustomModelProviderConfig(
596
+ {
597
+ workspaceId,
598
+ providerId: 'local-vllm',
599
+ body: {
600
+ headers: [
601
+ {name: 'x-region', value: 'eu', secret: true},
602
+ {name: 'x-plain', value: 'plain', secret: false},
603
+ ],
604
+ },
605
+ },
606
+ {probe},
607
+ );
608
+
609
+ const secrets = await getSecretsByNamespace({
610
+ workspaceId,
611
+ namespace: agentSystemNamespace('local-vllm'),
612
+ });
613
+ expect(probe).toHaveBeenLastCalledWith(
614
+ expect.objectContaining({
615
+ apiKey: 'sk-local-original',
616
+ headers: {'x-region': 'eu', 'x-plain': 'plain'},
617
+ }),
618
+ );
619
+ expect(secrets).toEqual({API_KEY: 'sk-local-original', HEADER_782D726567696F6E: 'eu'});
620
+ expect(updated.headers).toEqual([{name: 'x-plain', value: 'plain'}]);
621
+ expect(updated.secretHeaderNames).toEqual(['x-region']);
622
+ expect(updated.requiresApiKey).toBe(true);
623
+ });
624
+
625
+ it('marks a keyless custom provider as requiring a key when an api key is added', async () => {
626
+ const probe = vi.fn().mockResolvedValue(undefined);
627
+ await createCustomModelProviderConfig(
628
+ {
629
+ workspaceId,
630
+ body: createCustomBody({api_key: undefined}),
631
+ },
632
+ {probe},
633
+ );
634
+
635
+ const updated = await updateCustomModelProviderConfig(
636
+ {
637
+ workspaceId,
638
+ providerId: 'local-vllm',
639
+ body: {api_key: 'sk-local-added'},
640
+ },
641
+ {probe},
642
+ );
643
+
644
+ expect(updated.requiresApiKey).toBe(true);
645
+ });
646
+
647
+ it('rolls back custom provider secrets when config update persistence fails', async () => {
648
+ const probe = vi.fn().mockResolvedValue(undefined);
649
+ await createCustomModelProviderConfig(
650
+ {
651
+ workspaceId,
652
+ body: createCustomBody({
653
+ api_key: 'sk-local-original',
654
+ headers: [{name: 'authorization', value: 'Bearer old', secret: true}],
655
+ }),
656
+ },
657
+ {probe},
658
+ );
659
+ vi.spyOn(modelProviderDb, 'upsertModelProviderConfig').mockRejectedValueOnce(
660
+ new Error('config write failed'),
661
+ );
662
+
663
+ const update = updateCustomModelProviderConfig(
664
+ {
665
+ workspaceId,
666
+ providerId: 'local-vllm',
667
+ body: {
668
+ api_key: 'sk-local-replacement',
669
+ headers: [{name: 'x-replacement', value: 'new secret', secret: true}],
670
+ },
671
+ },
672
+ {probe},
673
+ );
674
+
675
+ await expect(update).rejects.toThrow('config write failed');
676
+ const secrets = await getSecretsByNamespace({
677
+ workspaceId,
678
+ namespace: agentSystemNamespace('local-vllm'),
679
+ });
680
+ expect(secrets).toEqual({
681
+ API_KEY: 'sk-local-original',
682
+ HEADER_617574686F72697A6174696F6E: 'Bearer old',
683
+ });
684
+ });
685
+
686
+ it('keeps stored secret header values during custom provider updates', async () => {
687
+ const probe = vi.fn().mockResolvedValue(undefined);
688
+ await createCustomModelProviderConfig(
689
+ {
690
+ workspaceId,
691
+ body: createCustomBody({
692
+ headers: [
693
+ {name: 'authorization', value: 'Bearer old', secret: true},
694
+ {name: 'x-region', value: 'us', secret: false},
695
+ ],
696
+ }),
697
+ },
698
+ {probe},
699
+ );
700
+
701
+ const updated = await updateCustomModelProviderConfig(
702
+ {
703
+ workspaceId,
704
+ providerId: 'local-vllm',
705
+ body: {
706
+ headers: [
707
+ {name: 'authorization', secret: true, keep: true},
708
+ {name: 'x-region', value: 'eu', secret: false},
709
+ ],
710
+ },
711
+ },
712
+ {probe},
713
+ );
714
+
715
+ const secrets = await getSecretsByNamespace({
716
+ workspaceId,
717
+ namespace: agentSystemNamespace('local-vllm'),
718
+ });
719
+ expect(probe).toHaveBeenLastCalledWith(
720
+ expect.objectContaining({
721
+ headers: {authorization: 'Bearer old', 'x-region': 'eu'},
722
+ }),
723
+ );
724
+ expect(secrets).toEqual({
725
+ API_KEY: 'sk-local-original',
726
+ HEADER_617574686F72697A6174696F6E: 'Bearer old',
727
+ });
728
+ expect(updated.headers).toEqual([{name: 'x-region', value: 'eu'}]);
729
+ expect(updated.secretHeaderNames).toEqual(['authorization']);
730
+ });
731
+
732
+ it('rejects custom updates that reuse stored secrets after changing the base URL', async () => {
733
+ const probe = vi.fn().mockResolvedValue(undefined);
734
+ await createCustomModelProviderConfig(
735
+ {
736
+ workspaceId,
737
+ body: createCustomBody({
738
+ api_key: 'sk-local-original',
739
+ headers: [{name: 'authorization', value: 'Bearer old', secret: true}],
740
+ }),
741
+ },
742
+ {probe},
743
+ );
744
+
745
+ const update = updateCustomModelProviderConfig(
746
+ {
747
+ workspaceId,
748
+ providerId: 'local-vllm',
749
+ body: {
750
+ base_url: 'https://attacker.example.com/v1',
751
+ headers: [{name: 'authorization', secret: true, keep: true}],
752
+ },
753
+ },
754
+ {probe},
755
+ );
756
+
757
+ await expect(update).rejects.toThrow(CustomModelProviderStoredSecretBaseUrlChangeError);
758
+ expect(probe).toHaveBeenCalledTimes(1);
759
+ });
760
+
761
+ it('allows stored secret reuse when the base URL only changes trailing slashes', async () => {
762
+ const probe = vi.fn().mockResolvedValue(undefined);
763
+ const provider = await createCustomModelProviderConfig(
764
+ {
765
+ workspaceId,
766
+ body: createCustomBody({
767
+ api_key: 'sk-local-original',
768
+ headers: [{name: 'authorization', value: 'Bearer old', secret: true}],
769
+ }),
770
+ },
771
+ {probe},
772
+ );
773
+
774
+ const updated = await updateCustomModelProviderConfig(
775
+ {
776
+ workspaceId,
777
+ providerId: provider.providerId,
778
+ body: {
779
+ base_url: 'http://127.0.0.1:11434/v1/',
780
+ headers: [{name: 'authorization', secret: true, keep: true}],
781
+ },
782
+ },
783
+ {probe},
784
+ );
785
+
786
+ expect(updated.baseUrl).toBe('http://127.0.0.1:11434/v1/');
787
+ expect(probe).toHaveBeenCalledTimes(2);
788
+ });
789
+
790
+ it('rejects kept secret headers that are renamed or missing', async () => {
791
+ const probe = vi.fn().mockResolvedValue(undefined);
792
+ await createCustomModelProviderConfig(
793
+ {
794
+ workspaceId,
795
+ body: createCustomBody({
796
+ headers: [{name: 'authorization', value: 'Bearer old', secret: true}],
797
+ }),
798
+ },
799
+ {probe},
800
+ );
801
+
802
+ const update = updateCustomModelProviderConfig(
803
+ {
804
+ workspaceId,
805
+ providerId: 'local-vllm',
806
+ body: {headers: [{name: 'x-authorization', secret: true, keep: true}]},
807
+ },
808
+ {probe},
809
+ );
810
+
811
+ await expect(update).rejects.toThrow(InvalidCustomModelProviderHeaderKeepError);
812
+ });
813
+
814
+ it('resolves slug-scoped discovery params from stored custom provider secrets', async () => {
815
+ const probe = vi.fn().mockResolvedValue(undefined);
816
+ await createCustomModelProviderConfig(
817
+ {
818
+ workspaceId,
819
+ body: createCustomBody({
820
+ api_key: 'sk-local-original',
821
+ headers: [
822
+ {name: 'authorization', value: 'Bearer old', secret: true},
823
+ {name: 'x-region', value: 'us', secret: false},
824
+ ],
825
+ }),
826
+ },
827
+ {probe},
828
+ );
829
+
830
+ const discoveryParams = await resolveCustomModelProviderDiscoveryParams({
831
+ workspaceId,
832
+ providerId: 'local-vllm',
833
+ body: {
834
+ headers: [
835
+ {name: 'authorization', secret: true, keep: true},
836
+ {name: 'x-region', value: 'eu', secret: false},
837
+ ],
838
+ },
839
+ });
840
+
841
+ expect(discoveryParams).toEqual({
842
+ api: 'openai-responses',
843
+ base_url: 'http://127.0.0.1:11434/v1',
844
+ api_key: 'sk-local-original',
845
+ headers: [
846
+ {name: 'authorization', value: 'Bearer old'},
847
+ {name: 'x-region', value: 'eu'},
848
+ ],
849
+ });
850
+ });
851
+
852
+ it('rejects slug-scoped discovery that reuses stored secrets after changing the base URL', async () => {
853
+ const probe = vi.fn().mockResolvedValue(undefined);
854
+ await createCustomModelProviderConfig(
855
+ {
856
+ workspaceId,
857
+ body: createCustomBody({
858
+ api_key: 'sk-local-original',
859
+ headers: [{name: 'authorization', value: 'Bearer old', secret: true}],
860
+ }),
861
+ },
862
+ {probe},
863
+ );
864
+
865
+ const discoveryParams = resolveCustomModelProviderDiscoveryParams({
866
+ workspaceId,
867
+ providerId: 'local-vllm',
868
+ body: {
869
+ base_url: 'https://attacker.example.com/v1',
870
+ headers: [{name: 'authorization', secret: true, keep: true}],
871
+ },
872
+ });
873
+
874
+ await expect(discoveryParams).rejects.toThrow(
875
+ CustomModelProviderStoredSecretBaseUrlChangeError,
876
+ );
877
+ });
878
+
879
+ it('clears a stored custom default model when replacement models drop it', async () => {
880
+ const probe = vi.fn().mockResolvedValue(undefined);
881
+ await createCustomModelProviderConfig(
882
+ {
883
+ workspaceId,
884
+ body: createCustomBody({default_model: 'llama-3.1'}),
885
+ },
886
+ {probe},
887
+ );
888
+
889
+ const updated = await updateCustomModelProviderConfig(
890
+ {
891
+ workspaceId,
892
+ providerId: 'local-vllm',
893
+ body: {models: [{id: 'llama-4', label: 'Llama 4'}]},
894
+ },
895
+ {probe},
896
+ );
897
+
898
+ expect(updated.defaultModel).toBeNull();
899
+ expect(probe).toHaveBeenLastCalledWith(
900
+ expect.objectContaining({model: {id: 'llama-4', label: 'Llama 4'}}),
901
+ );
902
+ });
903
+
904
+ it('rejects an explicit custom default model outside the custom model list', async () => {
905
+ const probe = vi.fn().mockResolvedValue(undefined);
906
+ await createCustomModelProviderConfig(
907
+ {
908
+ workspaceId,
909
+ body: createCustomBody({default_model: 'llama-3.1'}),
910
+ },
911
+ {probe},
912
+ );
913
+
914
+ const update = updateCustomModelProviderConfig(
915
+ {
916
+ workspaceId,
917
+ providerId: 'local-vllm',
918
+ body: {default_model: 'missing-model'},
919
+ },
920
+ {probe},
921
+ );
922
+
923
+ await expect(update).rejects.toThrow(InvalidAgentModelError);
924
+ });
925
+ });
926
+
927
+ function createCustomBody(
928
+ overrides: Partial<Parameters<typeof createCustomModelProviderConfig>[0]['body']> = {},
929
+ ): Parameters<typeof createCustomModelProviderConfig>[0]['body'] {
930
+ return {
931
+ slug: 'local-vllm',
932
+ display_name: 'Local vLLM',
933
+ api: 'openai-responses',
934
+ base_url: 'http://127.0.0.1:11434/v1',
935
+ api_key: 'sk-local-original',
936
+ headers: [],
937
+ models: [{id: 'llama-3.1', label: 'Llama 3.1'}],
938
+ ...overrides,
939
+ };
940
+ }