@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,807 @@
1
+ import {complete} from '@earendil-works/pi-ai';
2
+ import type {ModelProviderRef} from '@shipfox/api-agent-dto';
3
+ import {
4
+ AUTH_USER,
5
+ buildUserContext,
6
+ setUserContext,
7
+ type UserContextMembership,
8
+ } from '@shipfox/api-auth-context';
9
+ import {getSecretsByNamespace, setSecrets} from '@shipfox/api-secrets';
10
+ import type {AuthMethod, FastifyRequest} from '@shipfox/node-fastify';
11
+ import {ClientError, closeApp, createApp} from '@shipfox/node-fastify';
12
+ import {eq} from 'drizzle-orm';
13
+ import {agentSystemNamespace} from '#core/credential-fingerprints.js';
14
+ import {db} from '#db/db.js';
15
+ import {
16
+ getAgentWorkspaceSettings,
17
+ getModelProviderConfig,
18
+ setDefaultHarness,
19
+ setDefaultModelProvider,
20
+ upsertModelProviderConfig,
21
+ } from '#db/index.js';
22
+ import {modelProviderConfigs} from '#db/schema/model-provider-configs.js';
23
+ import {agentRoutes} from './index.js';
24
+
25
+ vi.mock('@earendil-works/pi-ai', async (importOriginal) => {
26
+ const actual = await importOriginal<typeof import('@earendil-works/pi-ai')>();
27
+ return {...actual, complete: vi.fn()};
28
+ });
29
+
30
+ const AUTH_USER_ID = '11111111-1111-4111-8111-111111111111';
31
+ let authenticatedMemberships: UserContextMembership[] = [];
32
+
33
+ const fakeUserAuth: AuthMethod = {
34
+ name: AUTH_USER,
35
+ authenticate: (request: FastifyRequest) => {
36
+ if (request.headers.authorization !== 'Bearer user') {
37
+ throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
38
+ }
39
+
40
+ setUserContext(
41
+ request,
42
+ buildUserContext({
43
+ userId: AUTH_USER_ID,
44
+ email: 'user@example.com',
45
+ memberships: authenticatedMemberships,
46
+ }),
47
+ );
48
+ return Promise.resolve();
49
+ },
50
+ };
51
+
52
+ describe('model provider config routes', () => {
53
+ let app: Awaited<ReturnType<typeof createApp>>;
54
+ let workspaceId: string;
55
+
56
+ beforeEach(async () => {
57
+ await closeApp();
58
+ workspaceId = crypto.randomUUID();
59
+ vi.mocked(complete).mockReset();
60
+ vi.mocked(complete).mockResolvedValue({
61
+ role: 'assistant',
62
+ content: [{type: 'text', text: 'OK'}],
63
+ api: 'anthropic-messages',
64
+ provider: 'anthropic',
65
+ model: 'claude-opus-4-8',
66
+ diagnostics: [],
67
+ usage: {
68
+ input: 0,
69
+ output: 0,
70
+ cacheRead: 0,
71
+ cacheWrite: 0,
72
+ totalTokens: 0,
73
+ cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0},
74
+ },
75
+ stopReason: 'stop',
76
+ timestamp: Date.now(),
77
+ });
78
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
79
+ app = await createApp({
80
+ auth: [fakeUserAuth],
81
+ routes: agentRoutes,
82
+ swagger: false,
83
+ });
84
+ await app.ready();
85
+ });
86
+
87
+ afterEach(async () => {
88
+ await closeApp();
89
+ });
90
+
91
+ test('uses client user auth for agent route groups', () => {
92
+ expect(agentRoutes[0]?.auth).toBe(AUTH_USER);
93
+ expect(agentRoutes[1]?.auth).toBe(AUTH_USER);
94
+ });
95
+
96
+ describe('GET /workspaces/:workspaceId/agent/model-providers', () => {
97
+ it('returns 401 without client auth', async () => {
98
+ const res = await app.inject({
99
+ method: 'GET',
100
+ url: `/workspaces/${workspaceId}/agent/model-providers`,
101
+ });
102
+
103
+ expect(res.statusCode).toBe(401);
104
+ });
105
+
106
+ it('returns 403 when the user is not a workspace member', async () => {
107
+ authenticatedMemberships = [];
108
+
109
+ const res = await app.inject({
110
+ method: 'GET',
111
+ url: `/workspaces/${workspaceId}/agent/model-providers`,
112
+ headers: {authorization: 'Bearer user'},
113
+ });
114
+
115
+ expect(res.statusCode).toBe(403);
116
+ expect(res.json().code).toBe('forbidden');
117
+ });
118
+
119
+ it('returns an empty list with no default model provider', async () => {
120
+ const res = await app.inject({
121
+ method: 'GET',
122
+ url: `/workspaces/${workspaceId}/agent/model-providers`,
123
+ headers: {authorization: 'Bearer user'},
124
+ });
125
+
126
+ expect(res.statusCode, res.body).toBe(200);
127
+ expect(res.json()).toEqual({
128
+ configs: [],
129
+ default_harness_id: null,
130
+ default_provider_id: null,
131
+ });
132
+ });
133
+
134
+ it('returns provider configs and the workspace default model provider', async () => {
135
+ await seedModelProviderConfig({providerId: 'anthropic'});
136
+ await seedModelProviderConfig({providerId: 'openai'});
137
+ await setDefaultModelProvider({workspaceId, providerId: 'openai'});
138
+
139
+ const res = await app.inject({
140
+ method: 'GET',
141
+ url: `/workspaces/${workspaceId}/agent/model-providers`,
142
+ headers: {authorization: 'Bearer user'},
143
+ });
144
+
145
+ expect(res.statusCode, res.body).toBe(200);
146
+ expect(res.json().default_harness_id).toBeNull();
147
+ expect(res.json().default_provider_id).toBe('openai');
148
+ expect(res.json().configs.map((config: {provider_id: string}) => config.provider_id)).toEqual(
149
+ ['anthropic', 'openai'],
150
+ );
151
+ });
152
+
153
+ it('returns the workspace default harness', async () => {
154
+ await setDefaultHarness({workspaceId, harnessId: 'claude'});
155
+
156
+ const res = await app.inject({
157
+ method: 'GET',
158
+ url: `/workspaces/${workspaceId}/agent/model-providers`,
159
+ headers: {authorization: 'Bearer user'},
160
+ });
161
+
162
+ expect(res.statusCode, res.body).toBe(200);
163
+ expect(res.json().default_harness_id).toBe('claude');
164
+ });
165
+ });
166
+
167
+ describe('PUT /workspaces/:workspaceId/agent/default-harness', () => {
168
+ it('persists the workspace default harness', async () => {
169
+ const res = await app.inject({
170
+ method: 'PUT',
171
+ url: `/workspaces/${workspaceId}/agent/default-harness`,
172
+ headers: {authorization: 'Bearer user'},
173
+ payload: {harness_id: 'claude'},
174
+ });
175
+
176
+ const settings = await getAgentWorkspaceSettings(workspaceId);
177
+ expect(res.statusCode, res.body).toBe(200);
178
+ expect(res.json()).toEqual({default_harness_id: 'claude'});
179
+ expect(settings?.defaultHarnessId).toBe('claude');
180
+ });
181
+
182
+ it('rejects an invalid harness id', async () => {
183
+ const res = await app.inject({
184
+ method: 'PUT',
185
+ url: `/workspaces/${workspaceId}/agent/default-harness`,
186
+ headers: {authorization: 'Bearer user'},
187
+ payload: {harness_id: 'codex'},
188
+ });
189
+
190
+ expect(res.statusCode).toBe(400);
191
+ });
192
+ });
193
+
194
+ describe('PUT /workspaces/:workspaceId/agent/model-providers/:providerId', () => {
195
+ it('tests, saves, and replaces a provider config without exposing credentials', async () => {
196
+ const secret = 'sk-ant-secret-abcd';
197
+
198
+ const res = await app.inject({
199
+ method: 'PUT',
200
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
201
+ headers: {authorization: 'Bearer user'},
202
+ payload: {credentials: {api_key: secret}},
203
+ });
204
+ const replace = await app.inject({
205
+ method: 'PUT',
206
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
207
+ headers: {authorization: 'Bearer user'},
208
+ payload: {credentials: {api_key: 'sk-ant-rotated-wxyz'}},
209
+ });
210
+
211
+ expect(res.statusCode).toBe(200);
212
+ expect(res.json().provider_id).toBe('anthropic');
213
+ expect(res.json().default_model).toBeNull();
214
+ expect(res.body).not.toContain(secret);
215
+ expect(res.body).not.toContain('encrypted_credentials');
216
+ expect(replace.statusCode).toBe(200);
217
+ expect(replace.json().default_model).toBeNull();
218
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
219
+ const secrets = await getSecretsByNamespace({
220
+ workspaceId,
221
+ namespace: agentSystemNamespace('anthropic'),
222
+ });
223
+ expect(secrets).toEqual({API_KEY: 'sk-ant-rotated-wxyz'});
224
+ expect(stored?.defaultModel).toBeNull();
225
+ });
226
+
227
+ it('saves an explicit default model for a provider config', async () => {
228
+ const res = await app.inject({
229
+ method: 'PUT',
230
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
231
+ headers: {authorization: 'Bearer user'},
232
+ payload: {
233
+ default_model: 'claude-haiku-4-5',
234
+ credentials: {api_key: 'sk-ant-secret-abcd'},
235
+ },
236
+ });
237
+
238
+ expect(res.statusCode).toBe(200);
239
+ expect(res.json().default_model).toBe('claude-haiku-4-5');
240
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
241
+ expect(stored?.defaultModel).toBe('claude-haiku-4-5');
242
+ });
243
+
244
+ it('sets the provider as the workspace default when requested', async () => {
245
+ const res = await app.inject({
246
+ method: 'PUT',
247
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
248
+ headers: {authorization: 'Bearer user'},
249
+ payload: {
250
+ credentials: {api_key: 'sk-ant-secret-abcd'},
251
+ set_as_default: true,
252
+ },
253
+ });
254
+
255
+ const settings = await getAgentWorkspaceSettings(workspaceId);
256
+ expect(res.statusCode).toBe(200);
257
+ expect(settings?.defaultProviderId).toBe('anthropic');
258
+ });
259
+
260
+ it('passes a request-scoped abort signal to provider validation', async () => {
261
+ const res = await app.inject({
262
+ method: 'PUT',
263
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
264
+ headers: {authorization: 'Bearer user'},
265
+ payload: {credentials: {api_key: 'sk-ant-secret-abcd'}},
266
+ });
267
+
268
+ const [, , options] = vi.mocked(complete).mock.calls[0] ?? [];
269
+ expect(res.statusCode).toBe(200);
270
+ expect(complete).toHaveBeenCalledTimes(1);
271
+ expect(options?.signal).toBeInstanceOf(AbortSignal);
272
+ expect(options?.signal?.aborted).toBe(false);
273
+ });
274
+
275
+ it('replaces the workspace default when set_as_default is requested with existing settings', async () => {
276
+ await seedModelProviderConfig({providerId: 'openai'});
277
+ await setDefaultModelProvider({workspaceId, providerId: 'openai'});
278
+
279
+ const res = await app.inject({
280
+ method: 'PUT',
281
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
282
+ headers: {authorization: 'Bearer user'},
283
+ payload: {
284
+ credentials: {api_key: 'sk-ant-secret-abcd'},
285
+ set_as_default: true,
286
+ },
287
+ });
288
+
289
+ const settings = await getAgentWorkspaceSettings(workspaceId);
290
+ expect(res.statusCode).toBe(200);
291
+ expect(settings?.defaultProviderId).toBe('anthropic');
292
+ });
293
+
294
+ it('does not change the workspace default when set_as_default is omitted', async () => {
295
+ await seedModelProviderConfig({providerId: 'openai'});
296
+ await setDefaultModelProvider({workspaceId, providerId: 'openai'});
297
+
298
+ const res = await app.inject({
299
+ method: 'PUT',
300
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
301
+ headers: {authorization: 'Bearer user'},
302
+ payload: {credentials: {api_key: 'sk-ant-secret-abcd'}},
303
+ });
304
+
305
+ const settings = await getAgentWorkspaceSettings(workspaceId);
306
+ expect(res.statusCode).toBe(200);
307
+ expect(settings?.defaultProviderId).toBe('openai');
308
+ });
309
+
310
+ it('preserves the existing default model when replacing credentials without default_model', async () => {
311
+ await seedModelProviderConfig({providerId: 'anthropic'});
312
+
313
+ const res = await app.inject({
314
+ method: 'PUT',
315
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
316
+ headers: {authorization: 'Bearer user'},
317
+ payload: {credentials: {api_key: 'sk-ant-rotated-wxyz'}},
318
+ });
319
+
320
+ expect(res.statusCode).toBe(200);
321
+ expect(res.json().default_model).toBe('claude-opus-4-8');
322
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
323
+ expect(stored?.defaultModel).toBe('claude-opus-4-8');
324
+ });
325
+
326
+ it('stores Latest when replacing credentials with a null default_model', async () => {
327
+ await seedModelProviderConfig({providerId: 'anthropic'});
328
+
329
+ const res = await app.inject({
330
+ method: 'PUT',
331
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
332
+ headers: {authorization: 'Bearer user'},
333
+ payload: {default_model: null, credentials: {api_key: 'sk-ant-rotated-wxyz'}},
334
+ });
335
+
336
+ expect(res.statusCode).toBe(200);
337
+ expect(res.json().default_model).toBeNull();
338
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
339
+ expect(stored?.defaultModel).toBeNull();
340
+ });
341
+
342
+ it('returns sanitized provider validation errors without leaking the submitted secret', async () => {
343
+ const secret = 'sk-route-secret-leak-sentinel';
344
+ vi.mocked(complete).mockRejectedValueOnce(new Error(`provider rejected ${secret}`));
345
+
346
+ const res = await app.inject({
347
+ method: 'PUT',
348
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
349
+ headers: {authorization: 'Bearer user'},
350
+ payload: {credentials: {api_key: secret}},
351
+ });
352
+
353
+ expect(res.statusCode).toBe(422);
354
+ expect(res.json().code).toBe('provider-validation-failed');
355
+ expect(res.json().details.message).toContain('***');
356
+ expect(res.body).not.toContain(secret);
357
+ });
358
+
359
+ it('returns expected credential keys for wrong credential fields', async () => {
360
+ const res = await app.inject({
361
+ method: 'PUT',
362
+ url: `/workspaces/${workspaceId}/agent/model-providers/cloudflare-ai-gateway`,
363
+ headers: {authorization: 'Bearer user'},
364
+ payload: {credentials: {api_key: 'cf-secret-abcd'}},
365
+ });
366
+
367
+ expect(res.statusCode).toBe(422);
368
+ expect(res.json().code).toBe('invalid-credential-fields');
369
+ expect(res.json().details.expected_keys).toEqual(['account_id', 'api_key', 'gateway_id']);
370
+ });
371
+
372
+ it('maps store value-size errors to a client error', async () => {
373
+ const res = await app.inject({
374
+ method: 'PUT',
375
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
376
+ headers: {authorization: 'Bearer user'},
377
+ payload: {credentials: {api_key: 'a'.repeat(64 * 1024 + 1)}},
378
+ });
379
+
380
+ expect(res.statusCode).toBe(400);
381
+ expect(res.json().code).toBe('value-too-large');
382
+ });
383
+
384
+ it('returns 400 for an unsupported provider id', async () => {
385
+ const res = await app.inject({
386
+ method: 'PUT',
387
+ url: `/workspaces/${workspaceId}/agent/model-providers/amazon-bedrock`,
388
+ headers: {authorization: 'Bearer user'},
389
+ payload: {credentials: {api_key: 'secret'}},
390
+ });
391
+
392
+ expect(res.statusCode).toBe(400);
393
+ });
394
+ });
395
+
396
+ describe('DELETE /workspaces/:workspaceId/agent/model-providers/:providerId', () => {
397
+ it('deletes a provider config', async () => {
398
+ await seedModelProviderConfig({providerId: 'anthropic'});
399
+
400
+ const res = await app.inject({
401
+ method: 'DELETE',
402
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
403
+ headers: {authorization: 'Bearer user'},
404
+ });
405
+
406
+ expect(res.statusCode).toBe(204);
407
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
408
+ const secrets = await getSecretsByNamespace({
409
+ workspaceId,
410
+ namespace: agentSystemNamespace('anthropic'),
411
+ });
412
+ expect(stored).toBeUndefined();
413
+ expect(secrets).toEqual({});
414
+ });
415
+
416
+ it('returns 404 for a missing or foreign provider config', async () => {
417
+ await seedModelProviderConfig({
418
+ providerId: 'anthropic',
419
+ workspaceId: crypto.randomUUID(),
420
+ });
421
+
422
+ const res = await app.inject({
423
+ method: 'DELETE',
424
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
425
+ headers: {authorization: 'Bearer user'},
426
+ });
427
+
428
+ expect(res.statusCode).toBe(404);
429
+ expect(res.json().code).toBe('not-found');
430
+ });
431
+
432
+ it('clears the workspace default when deleting the default model provider config', async () => {
433
+ await seedModelProviderConfig({providerId: 'anthropic'});
434
+ await setDefaultModelProvider({workspaceId, providerId: 'anthropic'});
435
+
436
+ const res = await app.inject({
437
+ method: 'DELETE',
438
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic`,
439
+ headers: {authorization: 'Bearer user'},
440
+ });
441
+
442
+ expect(res.statusCode).toBe(204);
443
+ const settings = await getAgentWorkspaceSettings(workspaceId);
444
+ expect(settings?.defaultProviderId).toBeNull();
445
+ });
446
+
447
+ it('deletes a configured custom model provider ref', async () => {
448
+ await seedCustomModelProviderConfig({providerId: 'local-vllm'});
449
+
450
+ const res = await app.inject({
451
+ method: 'DELETE',
452
+ url: `/workspaces/${workspaceId}/agent/model-providers/local-vllm`,
453
+ headers: {authorization: 'Bearer user'},
454
+ });
455
+
456
+ expect(res.statusCode).toBe(204);
457
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'local-vllm'});
458
+ expect(stored).toBeUndefined();
459
+ });
460
+ });
461
+
462
+ describe('POST /workspaces/:workspaceId/agent/custom-model-providers', () => {
463
+ it('tests and creates a custom model provider without exposing secrets', async () => {
464
+ const secret = 'sk-local-secret-abcd';
465
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('{}'));
466
+
467
+ const res = await app.inject({
468
+ method: 'POST',
469
+ url: `/workspaces/${workspaceId}/agent/custom-model-providers`,
470
+ headers: {authorization: 'Bearer user'},
471
+ payload: {
472
+ slug: 'local-vllm',
473
+ display_name: 'Local vLLM',
474
+ api: 'openai-responses',
475
+ base_url: 'http://127.0.0.1:11434/v1',
476
+ api_key: secret,
477
+ headers: [
478
+ {name: 'authorization', value: 'Bearer local', secret: true},
479
+ {name: 'x-region', value: 'local', secret: false},
480
+ ],
481
+ models: [{id: 'llama-3.1', label: 'Llama 3.1'}],
482
+ },
483
+ });
484
+
485
+ const [probeUrl, probeInit] = fetchSpy.mock.calls.at(-1) as [string, RequestInit];
486
+ expect(res.statusCode).toBe(200);
487
+ expect(res.json()).toMatchObject({
488
+ kind: 'custom',
489
+ provider_id: 'local-vllm',
490
+ display_name: 'Local vLLM',
491
+ secret_header_names: ['authorization'],
492
+ headers: [{name: 'x-region', value: 'local'}],
493
+ });
494
+ expect(probeUrl).toBe('http://127.0.0.1:11434/v1/responses');
495
+ expect(probeInit).toMatchObject({method: 'POST', redirect: 'error'});
496
+ expect((probeInit.headers as Headers).get('authorization')).toBe(`Bearer ${secret}`);
497
+ expect((probeInit.headers as Headers).get('x-region')).toBe('local');
498
+ expect(res.body).not.toContain(secret);
499
+ });
500
+ });
501
+
502
+ describe('POST /workspaces/:workspaceId/agent/custom-model-providers/discover-models', () => {
503
+ it('returns discovered models from an OpenAI-compatible response', async () => {
504
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
505
+ new Response(JSON.stringify({data: [{id: 'llama-3.1'}]}), {
506
+ status: 200,
507
+ headers: {'content-type': 'application/json'},
508
+ }),
509
+ );
510
+
511
+ const res = await app.inject({
512
+ method: 'POST',
513
+ url: `/workspaces/${workspaceId}/agent/custom-model-providers/discover-models`,
514
+ headers: {authorization: 'Bearer user'},
515
+ payload: {
516
+ api: 'openai-responses',
517
+ base_url: 'http://127.0.0.1:11434/v1',
518
+ api_key: 'sk-local',
519
+ },
520
+ });
521
+
522
+ expect(res.statusCode).toBe(200);
523
+ expect(res.json()).toEqual({models: [{id: 'llama-3.1', label: 'llama-3.1'}]});
524
+ expect(fetchSpy).toHaveBeenCalledWith(
525
+ 'http://127.0.0.1:11434/v1/models',
526
+ expect.objectContaining({
527
+ redirect: 'error',
528
+ signal: expect.any(AbortSignal),
529
+ }),
530
+ );
531
+ });
532
+
533
+ it('uses the Google model discovery endpoint and strips resource-name prefixes', async () => {
534
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
535
+ new Response(
536
+ JSON.stringify({
537
+ models: [{name: 'models/gemini-3-pro', displayName: 'Gemini 3 Pro'}],
538
+ }),
539
+ {
540
+ status: 200,
541
+ headers: {'content-type': 'application/json'},
542
+ },
543
+ ),
544
+ );
545
+
546
+ const res = await app.inject({
547
+ method: 'POST',
548
+ url: `/workspaces/${workspaceId}/agent/custom-model-providers/discover-models`,
549
+ headers: {authorization: 'Bearer user'},
550
+ payload: {
551
+ api: 'google-generative-ai',
552
+ base_url: 'https://generativelanguage.googleapis.com/v1beta',
553
+ api_key: 'google-key',
554
+ },
555
+ });
556
+
557
+ expect(res.statusCode).toBe(200);
558
+ expect(res.json()).toEqual({models: [{id: 'gemini-3-pro', label: 'Gemini 3 Pro'}]});
559
+ expect(fetchSpy).toHaveBeenCalledWith(
560
+ 'https://generativelanguage.googleapis.com/v1beta/models',
561
+ expect.objectContaining({
562
+ redirect: 'error',
563
+ signal: expect.any(AbortSignal),
564
+ }),
565
+ );
566
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
567
+ expect((init.headers as Headers).get('x-goog-api-key')).toBe('google-key');
568
+ });
569
+
570
+ it('returns an empty model list on discovery fetch failures', async () => {
571
+ vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new TypeError('redirect'));
572
+
573
+ const res = await app.inject({
574
+ method: 'POST',
575
+ url: `/workspaces/${workspaceId}/agent/custom-model-providers/discover-models`,
576
+ headers: {authorization: 'Bearer user'},
577
+ payload: {
578
+ api: 'openai-responses',
579
+ base_url: 'http://127.0.0.1:11434/v1',
580
+ },
581
+ });
582
+
583
+ expect(res.statusCode).toBe(200);
584
+ expect(res.json()).toEqual({models: []});
585
+ });
586
+
587
+ it('caps large discovery responses at the response DTO model limit', async () => {
588
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
589
+ new Response(
590
+ JSON.stringify({
591
+ data: Array.from({length: 130}, (_, index) => ({id: `model-${index}`})),
592
+ }),
593
+ {
594
+ status: 200,
595
+ headers: {'content-type': 'application/json'},
596
+ },
597
+ ),
598
+ );
599
+
600
+ const res = await app.inject({
601
+ method: 'POST',
602
+ url: `/workspaces/${workspaceId}/agent/custom-model-providers/discover-models`,
603
+ headers: {authorization: 'Bearer user'},
604
+ payload: {
605
+ api: 'openai-responses',
606
+ base_url: 'http://127.0.0.1:11434/v1',
607
+ },
608
+ });
609
+
610
+ expect(res.statusCode).toBe(200);
611
+ expect(res.json().models).toHaveLength(128);
612
+ });
613
+ });
614
+
615
+ describe('POST /workspaces/:workspaceId/agent/custom-model-providers/:slug/discover-models', () => {
616
+ it('discovers models with stored custom provider credentials', async () => {
617
+ await seedCustomModelProviderConfig({providerId: 'local-vllm'});
618
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
619
+ new Response(JSON.stringify({data: [{id: 'llama-3.1'}]}), {
620
+ status: 200,
621
+ headers: {'content-type': 'application/json'},
622
+ }),
623
+ );
624
+
625
+ const res = await app.inject({
626
+ method: 'POST',
627
+ url: `/workspaces/${workspaceId}/agent/custom-model-providers/local-vllm/discover-models`,
628
+ headers: {authorization: 'Bearer user'},
629
+ payload: {},
630
+ });
631
+
632
+ expect(res.statusCode, res.body).toBe(200);
633
+ expect(res.json()).toEqual({models: [{id: 'llama-3.1', label: 'llama-3.1'}]});
634
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
635
+ expect((init.headers as Headers).get('authorization')).toBe('Bearer seeded-secret');
636
+ });
637
+
638
+ it('returns not-found for a non-custom provider slug', async () => {
639
+ const res = await app.inject({
640
+ method: 'POST',
641
+ url: `/workspaces/${workspaceId}/agent/custom-model-providers/local-vllm/discover-models`,
642
+ headers: {authorization: 'Bearer user'},
643
+ payload: {},
644
+ });
645
+
646
+ expect(res.statusCode).toBe(404);
647
+ expect(res.json().code).toBe('not-found');
648
+ });
649
+ });
650
+
651
+ describe('PUT /workspaces/:workspaceId/agent/model-providers/:providerId/default-model', () => {
652
+ it('updates the provider default model without changing credentials', async () => {
653
+ await seedModelProviderConfig({providerId: 'anthropic'});
654
+
655
+ const res = await app.inject({
656
+ method: 'PUT',
657
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic/default-model`,
658
+ headers: {authorization: 'Bearer user'},
659
+ payload: {default_model: 'claude-haiku-4-5'},
660
+ });
661
+
662
+ expect(res.statusCode).toBe(200);
663
+ expect(res.json().default_model).toBe('claude-haiku-4-5');
664
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
665
+ expect(stored?.defaultModel).toBe('claude-haiku-4-5');
666
+ });
667
+
668
+ it('stores Latest as a null provider default model', async () => {
669
+ await seedModelProviderConfig({providerId: 'anthropic'});
670
+
671
+ const res = await app.inject({
672
+ method: 'PUT',
673
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic/default-model`,
674
+ headers: {authorization: 'Bearer user'},
675
+ payload: {default_model: null},
676
+ });
677
+
678
+ expect(res.statusCode).toBe(200);
679
+ expect(res.json().default_model).toBeNull();
680
+ const stored = await getModelProviderConfig({workspaceId, providerId: 'anthropic'});
681
+ expect(stored?.defaultModel).toBeNull();
682
+ });
683
+
684
+ it('returns 422 when updating a default model for a missing provider config', async () => {
685
+ const res = await app.inject({
686
+ method: 'PUT',
687
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic/default-model`,
688
+ headers: {authorization: 'Bearer user'},
689
+ payload: {default_model: null},
690
+ });
691
+
692
+ expect(res.statusCode).toBe(422);
693
+ expect(res.json().code).toBe('provider-not-configured');
694
+ });
695
+
696
+ it('returns 422 when the selected default model is unsupported', async () => {
697
+ await seedModelProviderConfig({providerId: 'anthropic'});
698
+
699
+ const res = await app.inject({
700
+ method: 'PUT',
701
+ url: `/workspaces/${workspaceId}/agent/model-providers/anthropic/default-model`,
702
+ headers: {authorization: 'Bearer user'},
703
+ payload: {default_model: 'missing-model'},
704
+ });
705
+
706
+ expect(res.statusCode).toBe(422);
707
+ expect(res.json().code).toBe('invalid-agent-model');
708
+ expect(res.json().details.model).toBe('missing-model');
709
+ });
710
+ });
711
+
712
+ describe('PUT /workspaces/:workspaceId/agent/default-model-provider', () => {
713
+ it('sets a configured provider as the workspace default', async () => {
714
+ await seedModelProviderConfig({providerId: 'anthropic'});
715
+
716
+ const res = await app.inject({
717
+ method: 'PUT',
718
+ url: `/workspaces/${workspaceId}/agent/default-model-provider`,
719
+ headers: {authorization: 'Bearer user'},
720
+ payload: {provider_id: 'anthropic'},
721
+ });
722
+
723
+ expect(res.statusCode).toBe(200);
724
+ expect(res.json()).toEqual({default_provider_id: 'anthropic'});
725
+ });
726
+
727
+ it('returns 422 when the provider is not configured', async () => {
728
+ const res = await app.inject({
729
+ method: 'PUT',
730
+ url: `/workspaces/${workspaceId}/agent/default-model-provider`,
731
+ headers: {authorization: 'Bearer user'},
732
+ payload: {provider_id: 'anthropic'},
733
+ });
734
+
735
+ expect(res.statusCode).toBe(422);
736
+ expect(res.json().code).toBe('provider-not-configured');
737
+ expect(res.json().details.provider_id).toBe('anthropic');
738
+ });
739
+
740
+ it('sets a configured custom model provider as the workspace default', async () => {
741
+ await seedCustomModelProviderConfig({providerId: 'local-vllm'});
742
+
743
+ const res = await app.inject({
744
+ method: 'PUT',
745
+ url: `/workspaces/${workspaceId}/agent/default-model-provider`,
746
+ headers: {authorization: 'Bearer user'},
747
+ payload: {provider_id: 'local-vllm'},
748
+ });
749
+
750
+ expect(res.statusCode).toBe(200);
751
+ expect(res.json()).toEqual({default_provider_id: 'local-vllm'});
752
+ });
753
+ });
754
+
755
+ async function seedModelProviderConfig(params: {
756
+ providerId: ModelProviderRef;
757
+ workspaceId?: string;
758
+ }) {
759
+ const config = await upsertModelProviderConfig({
760
+ workspaceId: params.workspaceId ?? workspaceId,
761
+ providerId: params.providerId,
762
+ defaultModel: params.providerId === 'anthropic' ? 'claude-opus-4-8' : 'gpt-5.5-pro',
763
+ defaultThinking: 'high',
764
+ });
765
+
766
+ const rows = await db()
767
+ .select()
768
+ .from(modelProviderConfigs)
769
+ .where(eq(modelProviderConfigs.id, config.id));
770
+ await setSecrets({
771
+ workspaceId: params.workspaceId ?? workspaceId,
772
+ namespace: agentSystemNamespace(params.providerId),
773
+ values: {API_KEY: 'seeded-secret'},
774
+ });
775
+ expect(rows).toHaveLength(1);
776
+ }
777
+
778
+ async function seedCustomModelProviderConfig(params: {
779
+ providerId: ModelProviderRef;
780
+ workspaceId?: string;
781
+ }) {
782
+ const config = await upsertModelProviderConfig({
783
+ workspaceId: params.workspaceId ?? workspaceId,
784
+ providerId: params.providerId,
785
+ kind: 'custom',
786
+ displayName: 'Local vLLM',
787
+ api: 'openai-responses',
788
+ baseUrl: 'http://127.0.0.1:11434/v1',
789
+ headers: [],
790
+ secretHeaderNames: [],
791
+ models: [{id: 'llama-3.1', label: 'Llama 3.1'}],
792
+ defaultModel: 'llama-3.1',
793
+ defaultThinking: 'off',
794
+ });
795
+
796
+ const rows = await db()
797
+ .select()
798
+ .from(modelProviderConfigs)
799
+ .where(eq(modelProviderConfigs.id, config.id));
800
+ await setSecrets({
801
+ workspaceId: params.workspaceId ?? workspaceId,
802
+ namespace: agentSystemNamespace(params.providerId),
803
+ values: {API_KEY: 'seeded-secret'},
804
+ });
805
+ expect(rows).toHaveLength(1);
806
+ }
807
+ });