@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,316 @@
1
+ import type {ModelProviderRef} from '@shipfox/api-agent-dto';
2
+ import {SecretDecryptionError, setSecrets} from '@shipfox/api-secrets';
3
+ import {deleteModelProviderConfig, upsertModelProviderConfig} from '#db/index.js';
4
+ import {agentSystemNamespace, customCredentialsToStoreValues} from './credential-fingerprints.js';
5
+ import {ModelProviderConfigNotFoundError} from './errors.js';
6
+ import {resolveRuntimeCredentials} from './resolve-runtime-credentials.js';
7
+
8
+ describe('resolveRuntimeCredentials', () => {
9
+ let workspaceId: string;
10
+
11
+ beforeEach(() => {
12
+ workspaceId = crypto.randomUUID();
13
+ });
14
+
15
+ it('returns decrypted workspace credentials', async () => {
16
+ await saveProviderConfig({
17
+ workspaceId,
18
+ providerId: 'anthropic',
19
+ credentials: {api_key: 'sk-workspace-secret'},
20
+ });
21
+
22
+ const result = await resolveRuntimeCredentials({
23
+ workspaceId,
24
+ harness: 'pi',
25
+ provider: 'anthropic',
26
+ model: 'claude-opus-4-8',
27
+ thinking: 'high',
28
+ });
29
+
30
+ expect(result).toEqual({
31
+ harness: 'pi',
32
+ provider_id: 'anthropic',
33
+ model: 'claude-opus-4-8',
34
+ thinking: 'high',
35
+ credentials: {api_key: 'sk-workspace-secret'},
36
+ });
37
+ });
38
+
39
+ it('prefers workspace credentials over the instance fallback', async () => {
40
+ await saveProviderConfig({
41
+ workspaceId,
42
+ providerId: 'anthropic',
43
+ credentials: {api_key: 'sk-workspace-secret'},
44
+ });
45
+ const result = await resolveRuntimeCredentials(
46
+ {
47
+ workspaceId,
48
+ harness: 'pi',
49
+ provider: 'anthropic',
50
+ model: 'claude-opus-4-8',
51
+ thinking: 'high',
52
+ },
53
+ {runtimeConfig: instanceConfig()},
54
+ );
55
+
56
+ expect(result.credentials).toEqual({api_key: 'sk-workspace-secret'});
57
+ });
58
+
59
+ it('returns the instance fallback only for the instance default model provider', async () => {
60
+ const matching = await resolveRuntimeCredentials(
61
+ {
62
+ workspaceId,
63
+ harness: 'pi',
64
+ provider: 'anthropic',
65
+ model: 'claude-opus-4-8',
66
+ thinking: 'high',
67
+ },
68
+ {runtimeConfig: instanceConfig()},
69
+ );
70
+ const mismatched = resolveRuntimeCredentials(
71
+ {
72
+ workspaceId,
73
+ harness: 'pi',
74
+ provider: 'openai',
75
+ model: 'gpt-5.5-pro',
76
+ thinking: 'high',
77
+ },
78
+ {runtimeConfig: instanceConfig()},
79
+ );
80
+
81
+ expect(matching.credentials).toEqual({api_key: 'sk-instance-secret'});
82
+ await expect(mismatched).rejects.toMatchObject({name: 'ModelProviderConfigNotFoundError'});
83
+ });
84
+
85
+ it('returns custom provider runtime descriptors for custom rows', async () => {
86
+ await saveProviderConfig({
87
+ workspaceId,
88
+ providerId: 'local-vllm',
89
+ kind: 'custom',
90
+ displayName: 'Local vLLM',
91
+ api: 'openai-responses',
92
+ baseUrl: 'http://127.0.0.1:11434/v1',
93
+ headers: [{name: 'x-region', value: 'local'}],
94
+ models: [{id: 'llama-3.1', label: 'Llama 3.1'}],
95
+ requiresApiKey: true,
96
+ credentials: {api_key: 'sk-local-secret', 'header:authorization': 'Bearer local'},
97
+ });
98
+
99
+ const result = await resolveRuntimeCredentials({
100
+ workspaceId,
101
+ harness: 'pi',
102
+ provider: 'local-vllm',
103
+ model: 'llama-3.1',
104
+ thinking: 'high',
105
+ });
106
+
107
+ expect(result).toEqual({
108
+ harness: 'pi',
109
+ provider_id: 'local-vllm',
110
+ model: 'llama-3.1',
111
+ thinking: 'high',
112
+ credentials: {api_key: 'sk-local-secret', 'header:authorization': 'Bearer local'},
113
+ custom_provider: {
114
+ api: 'openai-responses',
115
+ base_url: 'http://127.0.0.1:11434/v1',
116
+ headers: [{name: 'x-region', value: 'local'}],
117
+ secret_header_names: ['authorization'],
118
+ models: [{id: 'llama-3.1', label: 'Llama 3.1'}],
119
+ requires_api_key: true,
120
+ },
121
+ });
122
+ });
123
+
124
+ it('returns keyless custom provider runtime descriptors for keyless custom rows', async () => {
125
+ await saveProviderConfig({
126
+ workspaceId,
127
+ providerId: 'local-ollama',
128
+ kind: 'custom',
129
+ displayName: 'Local Ollama',
130
+ api: 'openai-responses',
131
+ baseUrl: 'http://127.0.0.1:11434/v1',
132
+ headers: [],
133
+ models: [{id: 'llama-3.1', label: 'Llama 3.1'}],
134
+ requiresApiKey: false,
135
+ credentials: {},
136
+ });
137
+
138
+ const result = await resolveRuntimeCredentials({
139
+ workspaceId,
140
+ harness: 'pi',
141
+ provider: 'local-ollama',
142
+ model: 'llama-3.1',
143
+ thinking: 'high',
144
+ });
145
+
146
+ expect(result).toMatchObject({
147
+ provider_id: 'local-ollama',
148
+ credentials: {},
149
+ custom_provider: {
150
+ requires_api_key: false,
151
+ },
152
+ });
153
+ });
154
+
155
+ it('throws when no workspace or instance credential is available', async () => {
156
+ const result = resolveRuntimeCredentials({
157
+ workspaceId,
158
+ harness: 'pi',
159
+ provider: 'anthropic',
160
+ model: 'claude-opus-4-8',
161
+ thinking: 'high',
162
+ });
163
+
164
+ await expect(result).rejects.toThrow(ModelProviderConfigNotFoundError);
165
+ });
166
+
167
+ it('throws after a workspace credential is deleted', async () => {
168
+ await saveProviderConfig({
169
+ workspaceId,
170
+ providerId: 'anthropic',
171
+ credentials: {api_key: 'sk-workspace-secret'},
172
+ });
173
+ await deleteModelProviderConfig({workspaceId, providerId: 'anthropic'});
174
+
175
+ const result = resolveRuntimeCredentials({
176
+ workspaceId,
177
+ harness: 'pi',
178
+ provider: 'anthropic',
179
+ model: 'claude-opus-4-8',
180
+ thinking: 'high',
181
+ });
182
+
183
+ await expect(result).rejects.toThrow(ModelProviderConfigNotFoundError);
184
+ });
185
+
186
+ it('throws when a configured row has no secret bag', async () => {
187
+ await upsertModelProviderConfig({
188
+ workspaceId,
189
+ providerId: 'anthropic',
190
+ defaultModel: null,
191
+ defaultThinking: 'high',
192
+ });
193
+
194
+ const result = resolveRuntimeCredentials({
195
+ workspaceId,
196
+ harness: 'pi',
197
+ provider: 'anthropic',
198
+ model: 'claude-opus-4-8',
199
+ thinking: 'high',
200
+ });
201
+
202
+ await expect(result).rejects.toThrow(ModelProviderConfigNotFoundError);
203
+ });
204
+
205
+ it('throws when a multi-field provider secret bag is incomplete', async () => {
206
+ await setSecrets({
207
+ workspaceId,
208
+ namespace: agentSystemNamespace('cloudflare-ai-gateway'),
209
+ values: {API_KEY: 'cf-secret'},
210
+ });
211
+ await upsertModelProviderConfig({
212
+ workspaceId,
213
+ providerId: 'cloudflare-ai-gateway',
214
+ defaultModel: null,
215
+ defaultThinking: 'high',
216
+ });
217
+
218
+ const result = resolveRuntimeCredentials({
219
+ workspaceId,
220
+ harness: 'pi',
221
+ provider: 'cloudflare-ai-gateway',
222
+ model: '@cf/meta/llama-3.1-8b-instruct',
223
+ thinking: 'high',
224
+ });
225
+
226
+ await expect(result).rejects.toThrow(ModelProviderConfigNotFoundError);
227
+ });
228
+
229
+ it('does not resolve an orphaned secret without a provider config row', async () => {
230
+ await setSecrets({
231
+ workspaceId,
232
+ namespace: agentSystemNamespace('anthropic'),
233
+ values: {API_KEY: 'sk-orphaned-secret'},
234
+ });
235
+
236
+ const result = resolveRuntimeCredentials({
237
+ workspaceId,
238
+ harness: 'pi',
239
+ provider: 'anthropic',
240
+ model: 'claude-opus-4-8',
241
+ thinking: 'high',
242
+ });
243
+
244
+ await expect(result).rejects.toThrow(ModelProviderConfigNotFoundError);
245
+ });
246
+
247
+ it('does not expose credential material on store decryption errors', async () => {
248
+ const error = new SecretDecryptionError();
249
+ await upsertModelProviderConfig({
250
+ workspaceId,
251
+ providerId: 'anthropic',
252
+ defaultModel: null,
253
+ defaultThinking: 'high',
254
+ });
255
+
256
+ const result = resolveRuntimeCredentials(
257
+ {
258
+ workspaceId,
259
+ harness: 'pi',
260
+ provider: 'anthropic',
261
+ model: 'claude-opus-4-8',
262
+ thinking: 'high',
263
+ },
264
+ {getCredentialBag: vi.fn().mockRejectedValue(error)},
265
+ );
266
+
267
+ await expect(result).rejects.toThrow(SecretDecryptionError);
268
+ try {
269
+ await result;
270
+ } catch (error) {
271
+ expect(String(error)).not.toContain('sk-workspace-secret');
272
+ }
273
+ });
274
+ });
275
+
276
+ async function saveProviderConfig(params: {
277
+ workspaceId: string;
278
+ providerId: ModelProviderRef;
279
+ kind?: 'builtin' | 'custom' | undefined;
280
+ displayName?: string | undefined;
281
+ api?: 'openai-responses' | undefined;
282
+ baseUrl?: string | undefined;
283
+ headers?: {name: string; value: string}[] | undefined;
284
+ models?: {id: string; label: string}[] | undefined;
285
+ requiresApiKey?: boolean | undefined;
286
+ credentials: Record<string, string>;
287
+ }) {
288
+ await setSecrets({
289
+ workspaceId: params.workspaceId,
290
+ namespace: agentSystemNamespace(params.providerId),
291
+ values:
292
+ params.kind === 'custom'
293
+ ? customCredentialsToStoreValues(params.credentials)
294
+ : {API_KEY: params.credentials.api_key ?? ''},
295
+ });
296
+ return await upsertModelProviderConfig({
297
+ workspaceId: params.workspaceId,
298
+ providerId: params.providerId,
299
+ kind: params.kind,
300
+ displayName: params.displayName,
301
+ api: params.api,
302
+ baseUrl: params.baseUrl,
303
+ headers: params.headers,
304
+ models: params.models,
305
+ requiresApiKey: params.requiresApiKey,
306
+ defaultModel: null,
307
+ defaultThinking: 'high',
308
+ });
309
+ }
310
+
311
+ function instanceConfig() {
312
+ return {
313
+ AGENT_DEFAULT_PROVIDER: 'anthropic' as const,
314
+ AGENT_DEFAULT_PROVIDER_API_KEY: 'sk-instance-secret',
315
+ };
316
+ }
@@ -0,0 +1,147 @@
1
+ import {
2
+ type AgentRuntimeCredentialsResponseDto,
3
+ type AgentThinking,
4
+ getModelProviderEntry,
5
+ type Harness,
6
+ type ModelProviderRef,
7
+ modelProviderCredentialKeysMatch,
8
+ type SupportedModelProviderId,
9
+ } from '@shipfox/api-agent-dto';
10
+ import {getSecretsByNamespace, SecretDecryptionError} from '@shipfox/api-secrets';
11
+ import {config} from '#config.js';
12
+ import {getModelProviderConfig} from '#db/index.js';
13
+ import {agentRuntimeConfigResolvedCount} from '#metrics/index.js';
14
+ import {
15
+ agentSystemNamespace,
16
+ storeValuesToCustomRuntimeCredentials,
17
+ storeValuesToRuntimeCredentials,
18
+ } from './credential-fingerprints.js';
19
+ import type {ModelProviderConfig} from './entities/model-provider-config.js';
20
+ import {ModelProviderConfigNotFoundError} from './errors.js';
21
+
22
+ export interface ResolveRuntimeCredentialsParams {
23
+ workspaceId: string;
24
+ harness: Harness;
25
+ provider: ModelProviderRef;
26
+ model: string;
27
+ thinking: AgentThinking;
28
+ }
29
+
30
+ interface RuntimeCredentialsConfig {
31
+ AGENT_DEFAULT_PROVIDER?: SupportedModelProviderId | undefined;
32
+ AGENT_DEFAULT_PROVIDER_API_KEY?: string | undefined;
33
+ }
34
+
35
+ interface ResolveRuntimeCredentialsOptions {
36
+ runtimeConfig?: RuntimeCredentialsConfig | undefined;
37
+ getCredentialBag?: typeof getSecretsByNamespace | undefined;
38
+ }
39
+
40
+ export async function resolveRuntimeCredentials(
41
+ params: ResolveRuntimeCredentialsParams,
42
+ options?: ResolveRuntimeCredentialsOptions,
43
+ ): Promise<AgentRuntimeCredentialsResponseDto> {
44
+ const runtimeConfig = options?.runtimeConfig ?? config;
45
+ const getCredentialBag = options?.getCredentialBag ?? getSecretsByNamespace;
46
+ const providerConfig = await getModelProviderConfig({
47
+ workspaceId: params.workspaceId,
48
+ providerId: params.provider,
49
+ });
50
+
51
+ if (providerConfig) {
52
+ try {
53
+ const values = await getCredentialBag({
54
+ workspaceId: params.workspaceId,
55
+ namespace: agentSystemNamespace(params.provider),
56
+ });
57
+ if (providerConfig.kind === 'custom') {
58
+ const credentials = storeValuesToCustomRuntimeCredentials(values);
59
+ agentRuntimeConfigResolvedCount.add(1, {source: 'workspace', outcome: 'resolved'});
60
+ return toResponse(params, credentials, providerConfig);
61
+ }
62
+
63
+ const providerId = params.provider as SupportedModelProviderId;
64
+ const credentials = storeValuesToRuntimeCredentials(providerId, values);
65
+ if (!modelProviderCredentialKeysMatch(providerId, credentials)) {
66
+ throw new ModelProviderConfigNotFoundError(params.workspaceId, params.provider);
67
+ }
68
+ agentRuntimeConfigResolvedCount.add(1, {source: 'workspace', outcome: 'resolved'});
69
+ return toResponse(params, credentials, providerConfig);
70
+ } catch (error) {
71
+ if (error instanceof SecretDecryptionError) {
72
+ agentRuntimeConfigResolvedCount.add(1, {
73
+ source: 'workspace',
74
+ outcome: 'decryption_failed',
75
+ });
76
+ }
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ const instanceCredentials = instanceFallbackCredentials(params.provider, runtimeConfig);
82
+ if (instanceCredentials) {
83
+ agentRuntimeConfigResolvedCount.add(1, {source: 'instance', outcome: 'resolved'});
84
+ return toResponse(params, instanceCredentials);
85
+ }
86
+
87
+ agentRuntimeConfigResolvedCount.add(1, {
88
+ source: params.provider === runtimeConfig.AGENT_DEFAULT_PROVIDER ? 'instance' : 'workspace',
89
+ outcome: 'unavailable',
90
+ });
91
+ throw new ModelProviderConfigNotFoundError(params.workspaceId, params.provider);
92
+ }
93
+
94
+ export function getInstanceDefaultModelProviderApiKeyField(
95
+ providerId: ModelProviderRef,
96
+ ): 'api_key' | undefined {
97
+ const credentialFields =
98
+ getModelProviderEntry(providerId as SupportedModelProviderId)?.credential_fields ?? [];
99
+ const field = credentialFields[0];
100
+ if (credentialFields.length !== 1 || field?.key !== 'api_key' || !field.secret) {
101
+ return undefined;
102
+ }
103
+ return 'api_key';
104
+ }
105
+
106
+ function instanceFallbackCredentials(
107
+ providerId: ModelProviderRef,
108
+ runtimeConfig: RuntimeCredentialsConfig,
109
+ ): Record<string, string> | undefined {
110
+ const instanceApiKey = runtimeConfig.AGENT_DEFAULT_PROVIDER_API_KEY;
111
+ if (providerId !== runtimeConfig.AGENT_DEFAULT_PROVIDER || !instanceApiKey) return undefined;
112
+
113
+ const fieldKey = getInstanceDefaultModelProviderApiKeyField(providerId);
114
+ if (!fieldKey) return undefined;
115
+
116
+ return {[fieldKey]: instanceApiKey};
117
+ }
118
+
119
+ function toResponse(
120
+ params: ResolveRuntimeCredentialsParams,
121
+ credentials: Record<string, string>,
122
+ providerConfig?: ModelProviderConfig | undefined,
123
+ ): AgentRuntimeCredentialsResponseDto {
124
+ const response: AgentRuntimeCredentialsResponseDto = {
125
+ harness: params.harness,
126
+ provider_id: params.provider,
127
+ model: params.model,
128
+ thinking: params.thinking,
129
+ credentials,
130
+ };
131
+
132
+ if (providerConfig?.kind === 'custom') {
133
+ response.custom_provider = {
134
+ api: providerConfig.api ?? 'openai-responses',
135
+ base_url: providerConfig.baseUrl ?? '',
136
+ headers: providerConfig.headers ?? [],
137
+ secret_header_names: Object.keys(credentials)
138
+ .filter((key) => key.startsWith('header:'))
139
+ .map((key) => key.slice('header:'.length))
140
+ .sort(),
141
+ models: providerConfig.models ?? [],
142
+ requires_api_key: providerConfig.requiresApiKey,
143
+ };
144
+ }
145
+
146
+ return response;
147
+ }
@@ -0,0 +1,42 @@
1
+ import type {
2
+ AgentThinking,
3
+ ModelProviderRef,
4
+ SupportedModelProviderId,
5
+ } from '@shipfox/api-agent-dto';
6
+ import {config} from '#config.js';
7
+ import {getAgentWorkspaceDefaultsSnapshot} from '#db/index.js';
8
+ import type {AgentConfigResolutionContext, AgentDefaultsResolver} from './resolve-agent-config.js';
9
+ import {resolveAgentConfig} from './resolve-agent-config.js';
10
+
11
+ export async function createWorkspaceAgentDefaultsResolver(
12
+ workspaceId: string,
13
+ ): Promise<AgentDefaultsResolver> {
14
+ const snapshot = await getAgentWorkspaceDefaultsSnapshot(workspaceId);
15
+ const workspaceProviderConfigs = new Map<
16
+ ModelProviderRef,
17
+ {
18
+ kind: 'builtin' | 'custom';
19
+ defaultModel: string | null;
20
+ defaultThinking: AgentThinking;
21
+ models: (typeof snapshot.providerConfigs)[number]['models'];
22
+ }
23
+ >();
24
+ for (const providerConfig of snapshot.providerConfigs) {
25
+ workspaceProviderConfigs.set(providerConfig.providerId, {
26
+ kind: providerConfig.kind,
27
+ defaultModel: providerConfig.defaultModel,
28
+ defaultThinking: providerConfig.defaultThinking,
29
+ models: providerConfig.models,
30
+ });
31
+ }
32
+ const ctx: AgentConfigResolutionContext = {
33
+ workspaceDefaultHarnessId: snapshot.defaultHarnessId ?? null,
34
+ workspaceDefaultProviderId: snapshot.defaultProviderId ?? null,
35
+ workspaceProviderConfigs,
36
+ instanceDefaultProvider: config.AGENT_DEFAULT_PROVIDER as SupportedModelProviderId | undefined,
37
+ instanceDefaultModel: config.AGENT_DEFAULT_PROVIDER_MODEL,
38
+ instanceDefaultThinking: config.AGENT_DEFAULT_PROVIDER_THINKING as AgentThinking | undefined,
39
+ };
40
+
41
+ return (step) => resolveAgentConfig(step, ctx);
42
+ }
@@ -0,0 +1,49 @@
1
+ import {type Harness, harnessSchema, type ModelProviderRef} from '@shipfox/api-agent-dto';
2
+ import {eq, or} from 'drizzle-orm';
3
+ import type {ModelProviderConfig} from '#core/entities/model-provider-config.js';
4
+ import {db} from './db.js';
5
+ import {agentWorkspaceSettings} from './schema/agent-workspace-settings.js';
6
+ import {modelProviderConfigs, toModelProviderConfig} from './schema/model-provider-configs.js';
7
+
8
+ export interface AgentWorkspaceDefaultsSnapshot {
9
+ readonly defaultHarnessId?: Harness | null | undefined;
10
+ readonly defaultProviderId?: ModelProviderRef | null | undefined;
11
+ readonly providerConfigs: ModelProviderConfig[];
12
+ }
13
+
14
+ export async function getAgentWorkspaceDefaultsSnapshot(
15
+ workspaceId: string,
16
+ ): Promise<AgentWorkspaceDefaultsSnapshot> {
17
+ const rows = await db()
18
+ .select({
19
+ settingsWorkspaceId: agentWorkspaceSettings.workspaceId,
20
+ workspaceDefaultHarnessId: agentWorkspaceSettings.defaultHarnessId,
21
+ workspaceDefaultProviderId: agentWorkspaceSettings.defaultProviderId,
22
+ providerConfig: modelProviderConfigs,
23
+ })
24
+ .from(agentWorkspaceSettings)
25
+ .fullJoin(
26
+ modelProviderConfigs,
27
+ eq(modelProviderConfigs.workspaceId, agentWorkspaceSettings.workspaceId),
28
+ )
29
+ .where(
30
+ or(
31
+ eq(agentWorkspaceSettings.workspaceId, workspaceId),
32
+ eq(modelProviderConfigs.workspaceId, workspaceId),
33
+ ),
34
+ )
35
+ .orderBy(modelProviderConfigs.providerId);
36
+
37
+ const settingsRow = rows.find((row) => row.settingsWorkspaceId !== null);
38
+ const parsedHarness = harnessSchema.safeParse(settingsRow?.workspaceDefaultHarnessId);
39
+ return {
40
+ defaultHarnessId: parsedHarness.success ? parsedHarness.data : null,
41
+ defaultProviderId: settingsRow?.workspaceDefaultProviderId as
42
+ | ModelProviderRef
43
+ | null
44
+ | undefined,
45
+ providerConfigs: rows.flatMap((row) =>
46
+ row.providerConfig === null ? [] : [toModelProviderConfig(row.providerConfig)],
47
+ ),
48
+ };
49
+ }
@@ -0,0 +1,111 @@
1
+ import crypto from 'node:crypto';
2
+ import type {AgentThinking, SupportedModelProviderId} from '@shipfox/api-agent-dto';
3
+ import {ModelProviderConfigNotFoundError} from '#core/index.js';
4
+ import {getAgentWorkspaceSettings, setDefaultHarness, setDefaultModelProvider} from '#db/index.js';
5
+ import {
6
+ type UpsertModelProviderConfigParams,
7
+ upsertModelProviderConfig,
8
+ } from './model-provider-configs.js';
9
+
10
+ describe('agent workspace settings', () => {
11
+ let workspaceId: string;
12
+
13
+ beforeEach(() => {
14
+ workspaceId = crypto.randomUUID();
15
+ });
16
+
17
+ it('persists the workspace default model provider', async () => {
18
+ await upsertModelProviderConfig(
19
+ createModelProviderConfigParams({workspaceId, providerId: 'anthropic'}),
20
+ );
21
+
22
+ const settings = await setDefaultModelProvider({workspaceId, providerId: 'anthropic'});
23
+
24
+ const found = await getAgentWorkspaceSettings(workspaceId);
25
+ expect(found).toEqual(settings);
26
+ expect(found).toMatchObject({
27
+ workspaceId,
28
+ defaultHarnessId: null,
29
+ defaultProviderId: 'anthropic',
30
+ });
31
+ expect(found?.createdAt).toBeInstanceOf(Date);
32
+ expect(found?.updatedAt).toBeInstanceOf(Date);
33
+ });
34
+
35
+ it('updates the workspace default model provider', async () => {
36
+ await upsertModelProviderConfig(
37
+ createModelProviderConfigParams({workspaceId, providerId: 'anthropic'}),
38
+ );
39
+ await upsertModelProviderConfig(
40
+ createModelProviderConfigParams({workspaceId, providerId: 'openai'}),
41
+ );
42
+ await setDefaultModelProvider({workspaceId, providerId: 'anthropic'});
43
+
44
+ const updated = await setDefaultModelProvider({workspaceId, providerId: 'openai'});
45
+
46
+ const found = await getAgentWorkspaceSettings(workspaceId);
47
+ expect(found).toEqual(updated);
48
+ expect(found?.defaultProviderId).toBe('openai');
49
+ });
50
+
51
+ it('clears the workspace default model provider', async () => {
52
+ await upsertModelProviderConfig(
53
+ createModelProviderConfigParams({workspaceId, providerId: 'anthropic'}),
54
+ );
55
+ await setDefaultModelProvider({workspaceId, providerId: 'anthropic'});
56
+
57
+ await setDefaultModelProvider({workspaceId, providerId: null});
58
+
59
+ const found = await getAgentWorkspaceSettings(workspaceId);
60
+ expect(found?.defaultProviderId).toBeNull();
61
+ });
62
+
63
+ it('returns undefined for a workspace without settings', async () => {
64
+ const found = await getAgentWorkspaceSettings(workspaceId);
65
+
66
+ expect(found).toBeUndefined();
67
+ });
68
+
69
+ it('persists the workspace default harness', async () => {
70
+ const settings = await setDefaultHarness({workspaceId, harnessId: 'claude'});
71
+
72
+ const found = await getAgentWorkspaceSettings(workspaceId);
73
+ expect(found).toEqual(settings);
74
+ expect(found).toMatchObject({
75
+ workspaceId,
76
+ defaultHarnessId: 'claude',
77
+ defaultProviderId: null,
78
+ });
79
+ });
80
+
81
+ it('updates the workspace default harness without clearing the model provider default', async () => {
82
+ await upsertModelProviderConfig(
83
+ createModelProviderConfigParams({workspaceId, providerId: 'anthropic'}),
84
+ );
85
+ await setDefaultModelProvider({workspaceId, providerId: 'anthropic'});
86
+
87
+ const updated = await setDefaultHarness({workspaceId, harnessId: 'claude'});
88
+
89
+ expect(updated.defaultHarnessId).toBe('claude');
90
+ expect(updated.defaultProviderId).toBe('anthropic');
91
+ });
92
+
93
+ it('rejects a default model provider without a matching config', async () => {
94
+ const result = setDefaultModelProvider({workspaceId, providerId: 'anthropic'});
95
+
96
+ await expect(result).rejects.toBeInstanceOf(ModelProviderConfigNotFoundError);
97
+ });
98
+ });
99
+
100
+ function createModelProviderConfigParams(params: {
101
+ workspaceId: string;
102
+ providerId: SupportedModelProviderId;
103
+ defaultThinking?: AgentThinking | undefined;
104
+ }): UpsertModelProviderConfigParams {
105
+ return {
106
+ workspaceId: params.workspaceId,
107
+ providerId: params.providerId,
108
+ defaultModel: 'claude-opus-4-8',
109
+ defaultThinking: params.defaultThinking ?? 'high',
110
+ };
111
+ }