@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,207 @@
1
+ import {
2
+ DEFAULT_AGENT_THINKING,
3
+ getModelProviderEntry,
4
+ type ModelProviderRef,
5
+ modelProviderCredentialKeysMatch,
6
+ type SupportedModelProviderId,
7
+ } from '@shipfox/api-agent-dto';
8
+ import {deleteSecrets, getSecretsByNamespace, setSecrets} from '@shipfox/api-secrets';
9
+ import {
10
+ deleteModelProviderConfig as deleteModelProviderConfigRow,
11
+ getModelProviderConfig,
12
+ updateModelProviderDefaultModel,
13
+ upsertModelProviderConfig,
14
+ } from '#db/index.js';
15
+ import {agentSystemNamespace, credentialsToStoreValues} from './credential-fingerprints.js';
16
+ import type {ModelProviderConfig} from './entities/model-provider-config.js';
17
+ import {
18
+ InvalidAgentModelError,
19
+ InvalidCredentialFieldsError,
20
+ ModelProviderConfigNotFoundError,
21
+ UnsupportedModelProviderError,
22
+ } from './errors.js';
23
+ import {buildModelProviderCatalog} from './model-provider-catalog.js';
24
+ import {probeModelProviderCredentials, runProviderProbe} from './model-provider-validation.js';
25
+
26
+ export interface TestAndSaveModelProviderConfigParams {
27
+ workspaceId: string;
28
+ providerId: SupportedModelProviderId;
29
+ defaultModel?: string | null | undefined;
30
+ credentials: Record<string, string>;
31
+ editedBy?: string | null | undefined;
32
+ setAsDefault?: boolean | undefined;
33
+ signal?: AbortSignal | undefined;
34
+ }
35
+
36
+ export interface TestAndSaveModelProviderConfigOptions {
37
+ probe?: typeof probeModelProviderCredentials;
38
+ pruneStaleSecrets?:
39
+ | ((params: {workspaceId: string; namespace: string; expectedKeys: string[]}) => Promise<void>)
40
+ | undefined;
41
+ }
42
+
43
+ export interface UpdateModelProviderConfigDefaultModelParams {
44
+ workspaceId: string;
45
+ providerId: ModelProviderRef;
46
+ defaultModel: string | null;
47
+ }
48
+
49
+ export async function testAndSaveModelProviderConfig(
50
+ params: TestAndSaveModelProviderConfigParams,
51
+ options: TestAndSaveModelProviderConfigOptions = {},
52
+ ): Promise<ModelProviderConfig> {
53
+ const probe = options.probe ?? probeModelProviderCredentials;
54
+ const entry = getModelProviderEntry(params.providerId);
55
+ if (entry === undefined || entry.support_status !== 'supported') {
56
+ throw new UnsupportedModelProviderError(params.providerId);
57
+ }
58
+ if (entry.default_model === null) throw new UnsupportedModelProviderError(params.providerId);
59
+
60
+ if (!modelProviderCredentialKeysMatch(params.providerId, params.credentials)) {
61
+ throw new InvalidCredentialFieldsError(params.providerId);
62
+ }
63
+
64
+ const existingConfig = await getModelProviderConfig({
65
+ workspaceId: params.workspaceId,
66
+ providerId: params.providerId,
67
+ });
68
+ const modelSelection = resolveDefaultModel(
69
+ params.providerId,
70
+ params.defaultModel !== undefined ? params.defaultModel : existingConfig?.defaultModel,
71
+ );
72
+
73
+ await runProviderProbe({
74
+ probe,
75
+ args: {
76
+ providerId: params.providerId,
77
+ model: modelSelection.probeModel,
78
+ credentials: params.credentials,
79
+ ...(params.signal ? {signal: params.signal} : {}),
80
+ },
81
+ metricLabel: params.providerId,
82
+ providerId: params.providerId,
83
+ secrets: Object.values(params.credentials),
84
+ signal: params.signal,
85
+ });
86
+ const pruneStaleSecrets = options.pruneStaleSecrets ?? pruneStaleProviderCredentialSecrets;
87
+ const namespace = agentSystemNamespace(params.providerId);
88
+ const values = credentialsToStoreValues(params.providerId, params.credentials);
89
+ await setSecrets({
90
+ workspaceId: params.workspaceId,
91
+ namespace,
92
+ values,
93
+ editedBy: params.editedBy,
94
+ });
95
+
96
+ const config = await upsertModelProviderConfig({
97
+ workspaceId: params.workspaceId,
98
+ providerId: params.providerId,
99
+ defaultModel: modelSelection.storedModel,
100
+ defaultThinking: DEFAULT_AGENT_THINKING,
101
+ setAsDefault: params.setAsDefault,
102
+ });
103
+
104
+ await pruneStaleSecrets({
105
+ workspaceId: params.workspaceId,
106
+ namespace,
107
+ expectedKeys: Object.keys(values),
108
+ }).catch(() => undefined);
109
+
110
+ return config;
111
+ }
112
+
113
+ export async function updateModelProviderConfigDefaultModel(
114
+ params: UpdateModelProviderConfigDefaultModelParams,
115
+ ): Promise<ModelProviderConfig> {
116
+ const existingConfig = await getModelProviderConfig({
117
+ workspaceId: params.workspaceId,
118
+ providerId: params.providerId,
119
+ });
120
+ if (!existingConfig) {
121
+ throw new ModelProviderConfigNotFoundError(params.workspaceId, params.providerId);
122
+ }
123
+ if (existingConfig.kind === 'custom') {
124
+ if (
125
+ params.defaultModel !== null &&
126
+ !existingConfig.models?.some((model) => model.id === params.defaultModel)
127
+ ) {
128
+ throw new InvalidAgentModelError('pi', params.providerId, params.defaultModel);
129
+ }
130
+
131
+ const config = await updateModelProviderDefaultModel({
132
+ workspaceId: params.workspaceId,
133
+ providerId: params.providerId,
134
+ defaultModel: params.defaultModel,
135
+ });
136
+ if (!config) throw new ModelProviderConfigNotFoundError(params.workspaceId, params.providerId);
137
+ return config;
138
+ }
139
+
140
+ const providerId = params.providerId as SupportedModelProviderId;
141
+ const entry = getModelProviderEntry(providerId);
142
+ if (entry === undefined || entry.support_status !== 'supported') {
143
+ throw new UnsupportedModelProviderError(params.providerId);
144
+ }
145
+ if (entry.default_model === null) throw new UnsupportedModelProviderError(params.providerId);
146
+
147
+ const modelSelection = resolveDefaultModel(providerId, params.defaultModel);
148
+ const config = await updateModelProviderDefaultModel({
149
+ workspaceId: params.workspaceId,
150
+ providerId: params.providerId,
151
+ defaultModel: modelSelection.storedModel,
152
+ });
153
+ if (!config) throw new ModelProviderConfigNotFoundError(params.workspaceId, params.providerId);
154
+ return config;
155
+ }
156
+
157
+ export async function deleteModelProviderConfig(params: {
158
+ workspaceId: string;
159
+ providerId: ModelProviderRef;
160
+ }): Promise<boolean> {
161
+ const deleted = await deleteModelProviderConfigRow(params);
162
+ await deleteSecrets({
163
+ workspaceId: params.workspaceId,
164
+ namespace: agentSystemNamespace(params.providerId),
165
+ });
166
+ return deleted;
167
+ }
168
+
169
+ function resolveDefaultModel(
170
+ providerId: SupportedModelProviderId,
171
+ requestedModel: string | null | undefined,
172
+ ): {probeModel: string; storedModel: string | null} {
173
+ const catalogEntry = buildModelProviderCatalog().find((entry) => entry.id === providerId);
174
+ if (
175
+ catalogEntry === undefined ||
176
+ catalogEntry.support_status !== 'supported' ||
177
+ catalogEntry.default_model === null
178
+ ) {
179
+ throw new UnsupportedModelProviderError(providerId);
180
+ }
181
+
182
+ const model = requestedModel ?? catalogEntry.default_model;
183
+ if (!catalogEntry.models.some((candidate) => candidate.id === model)) {
184
+ throw new InvalidAgentModelError('pi', providerId, model);
185
+ }
186
+ return {probeModel: model, storedModel: requestedModel ?? null};
187
+ }
188
+
189
+ async function pruneStaleProviderCredentialSecrets(params: {
190
+ workspaceId: string;
191
+ namespace: string;
192
+ expectedKeys: string[];
193
+ }): Promise<void> {
194
+ const stored = await getSecretsByNamespace({
195
+ workspaceId: params.workspaceId,
196
+ namespace: params.namespace,
197
+ });
198
+ const expected = new Set(params.expectedKeys);
199
+ const staleKeys = Object.keys(stored).filter((key) => !expected.has(key));
200
+ if (staleKeys.length === 0) return;
201
+
202
+ await deleteSecrets({
203
+ workspaceId: params.workspaceId,
204
+ namespace: params.namespace,
205
+ keys: staleKeys,
206
+ });
207
+ }
@@ -0,0 +1,354 @@
1
+ import type {AssistantMessage, Context, ProviderStreamOptions} from '@earendil-works/pi-ai';
2
+ import {InvalidAgentModelError} from './errors.js';
3
+ import {
4
+ probeCustomModelProviderCredentials,
5
+ probeModelProviderCredentials,
6
+ sanitizeModelProviderError,
7
+ } from './model-provider-validation.js';
8
+
9
+ const piAi = vi.hoisted(() => ({
10
+ complete: vi.fn(),
11
+ getModels: vi.fn(),
12
+ }));
13
+
14
+ vi.mock('@earendil-works/pi-ai', () => piAi);
15
+
16
+ describe('probeModelProviderCredentials', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ piAi.getModels.mockReturnValue([
20
+ {
21
+ id: 'claude-opus-4-8',
22
+ provider: 'anthropic',
23
+ api: 'anthropic-messages',
24
+ name: 'Claude Opus',
25
+ baseUrl: 'https://api.anthropic.com',
26
+ reasoning: true,
27
+ input: ['text'],
28
+ cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0},
29
+ contextWindow: 200000,
30
+ maxTokens: 32000,
31
+ },
32
+ ]);
33
+ piAi.complete.mockResolvedValue(createAssistantMessage());
34
+ });
35
+
36
+ it('resolves the catalog model and sends the catalog secret field as apiKey', async () => {
37
+ const credentials = {api_key: 'sk-ant-secret'};
38
+
39
+ await probeModelProviderCredentials({
40
+ providerId: 'anthropic',
41
+ model: 'claude-opus-4-8',
42
+ credentials,
43
+ });
44
+
45
+ expect(piAi.getModels).toHaveBeenCalledWith('anthropic');
46
+ const [model, context, options] = piAi.complete.mock.calls[0] as [
47
+ {id: string},
48
+ Context,
49
+ ProviderStreamOptions,
50
+ ];
51
+ expect(model.id).toBe('claude-opus-4-8');
52
+ expect(context.messages).toHaveLength(1);
53
+ expect(options).toMatchObject({
54
+ apiKey: 'sk-ant-secret',
55
+ maxRetries: 0,
56
+ maxTokens: 64,
57
+ timeoutMs: 10000,
58
+ });
59
+ });
60
+
61
+ it('throws InvalidAgentModelError when the catalog model is missing from Pi', async () => {
62
+ piAi.getModels.mockReturnValue([]);
63
+
64
+ const probe = probeModelProviderCredentials({
65
+ providerId: 'anthropic',
66
+ model: 'claude-opus-4-8',
67
+ credentials: {api_key: 'sk-ant-secret'},
68
+ });
69
+
70
+ await expect(probe).rejects.toThrow(InvalidAgentModelError);
71
+ expect(piAi.complete).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('rejects provider error results from Pi complete', async () => {
75
+ piAi.complete.mockResolvedValue(
76
+ createAssistantMessage({stopReason: 'error', errorMessage: '401 Unauthorized'}),
77
+ );
78
+
79
+ const probe = probeModelProviderCredentials({
80
+ providerId: 'anthropic',
81
+ model: 'claude-opus-4-8',
82
+ credentials: {api_key: 'sk-ant-secret'},
83
+ });
84
+
85
+ await expect(probe).rejects.toThrow('401 Unauthorized');
86
+ });
87
+
88
+ it('passes Azure endpoint credentials as Pi Azure base URL options', async () => {
89
+ piAi.getModels.mockReturnValue([
90
+ {
91
+ id: 'gpt-5.5-pro',
92
+ provider: 'azure-openai-responses',
93
+ api: 'azure-openai-responses',
94
+ name: 'GPT',
95
+ baseUrl: '',
96
+ reasoning: true,
97
+ input: ['text'],
98
+ cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0},
99
+ contextWindow: 200000,
100
+ maxTokens: 32000,
101
+ },
102
+ ]);
103
+
104
+ await probeModelProviderCredentials({
105
+ providerId: 'azure-openai-responses',
106
+ model: 'gpt-5.5-pro',
107
+ credentials: {
108
+ endpoint: 'https://azure.example.test/openai/v1',
109
+ api_key: 'sk-azure-secret',
110
+ },
111
+ });
112
+
113
+ const [, , options] = piAi.complete.mock.calls[0] as [
114
+ {id: string},
115
+ Context,
116
+ ProviderStreamOptions,
117
+ ];
118
+ expect(options).toMatchObject({
119
+ apiKey: 'sk-azure-secret',
120
+ azureBaseUrl: 'https://azure.example.test/openai/v1',
121
+ });
122
+ });
123
+
124
+ it('passes Cloudflare AI Gateway credentials through provider env options', async () => {
125
+ piAi.getModels.mockReturnValue([
126
+ {
127
+ id: 'claude-opus-4-8',
128
+ provider: 'cloudflare-ai-gateway',
129
+ api: 'anthropic-messages',
130
+ name: 'Claude',
131
+ baseUrl:
132
+ 'https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic',
133
+ reasoning: true,
134
+ input: ['text'],
135
+ cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0},
136
+ contextWindow: 200000,
137
+ maxTokens: 32000,
138
+ },
139
+ ]);
140
+
141
+ await probeModelProviderCredentials({
142
+ providerId: 'cloudflare-ai-gateway',
143
+ model: 'claude-opus-4-8',
144
+ credentials: {
145
+ api_key: 'cf-secret',
146
+ account_id: 'account-123',
147
+ gateway_id: 'gateway-456',
148
+ },
149
+ });
150
+
151
+ const [, , options] = piAi.complete.mock.calls[0] as [
152
+ {id: string},
153
+ Context,
154
+ ProviderStreamOptions,
155
+ ];
156
+ expect(options).toMatchObject({
157
+ apiKey: 'cf-secret',
158
+ env: {
159
+ CLOUDFLARE_ACCOUNT_ID: 'account-123',
160
+ CLOUDFLARE_GATEWAY_ID: 'gateway-456',
161
+ },
162
+ });
163
+ });
164
+
165
+ it('passes Cloudflare Workers AI credentials through provider env options', async () => {
166
+ piAi.getModels.mockReturnValue([
167
+ {
168
+ id: '@cf/moonshotai/kimi-k2.7-code',
169
+ provider: 'cloudflare-workers-ai',
170
+ api: 'openai-completions',
171
+ name: 'Kimi',
172
+ baseUrl: 'https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1',
173
+ reasoning: true,
174
+ input: ['text'],
175
+ cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0},
176
+ contextWindow: 200000,
177
+ maxTokens: 32000,
178
+ },
179
+ ]);
180
+
181
+ await probeModelProviderCredentials({
182
+ providerId: 'cloudflare-workers-ai',
183
+ model: '@cf/moonshotai/kimi-k2.7-code',
184
+ credentials: {
185
+ api_key: 'cf-secret',
186
+ account_id: 'account-123',
187
+ },
188
+ });
189
+
190
+ const [, , options] = piAi.complete.mock.calls[0] as [
191
+ {id: string},
192
+ Context,
193
+ ProviderStreamOptions,
194
+ ];
195
+ expect(options).toMatchObject({
196
+ apiKey: 'cf-secret',
197
+ env: {
198
+ CLOUDFLARE_ACCOUNT_ID: 'account-123',
199
+ },
200
+ });
201
+ });
202
+ });
203
+
204
+ describe('probeCustomModelProviderCredentials', () => {
205
+ it('probes OpenAI-compatible custom providers with redirects disabled', async () => {
206
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('{}'));
207
+
208
+ await probeCustomModelProviderCredentials({
209
+ providerId: 'local-vllm',
210
+ api: 'openai-responses',
211
+ baseUrl: 'https://llm.example.test/v1',
212
+ model: {id: 'llama-3.1', label: 'Llama 3.1'},
213
+ apiKey: 'sk-local',
214
+ headers: {'x-region': 'local'},
215
+ });
216
+
217
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
218
+ expect(fetchSpy).toHaveBeenCalledWith(
219
+ 'https://llm.example.test/v1/responses',
220
+ expect.objectContaining({method: 'POST', redirect: 'error'}),
221
+ );
222
+ expect((init.headers as Headers).get('authorization')).toBe('Bearer sk-local');
223
+ expect((init.headers as Headers).get('x-region')).toBe('local');
224
+ expect(JSON.parse(init.body as string)).toMatchObject({
225
+ model: 'llama-3.1',
226
+ input: 'Reply with OK.',
227
+ stream: false,
228
+ });
229
+ });
230
+
231
+ it('uses provider-specific auth headers for Google custom probes', async () => {
232
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('{}'));
233
+
234
+ await probeCustomModelProviderCredentials({
235
+ providerId: 'local-gemini',
236
+ api: 'google-generative-ai',
237
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
238
+ model: {id: 'gemini-3-pro', label: 'Gemini 3 Pro'},
239
+ apiKey: 'google-key',
240
+ });
241
+
242
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
243
+ expect(fetchSpy).toHaveBeenCalledWith(
244
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro:generateContent',
245
+ expect.objectContaining({redirect: 'error'}),
246
+ );
247
+ expect((init.headers as Headers).get('x-goog-api-key')).toBe('google-key');
248
+ });
249
+
250
+ it('encodes Google custom probe model ids as one URL path segment', async () => {
251
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('{}'));
252
+
253
+ await probeCustomModelProviderCredentials({
254
+ providerId: 'local-gemini',
255
+ api: 'google-generative-ai',
256
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
257
+ model: {id: 'models/gemini-3/pro', label: 'Gemini 3 Pro'},
258
+ apiKey: 'google-key',
259
+ });
260
+
261
+ expect(fetchSpy).toHaveBeenCalledWith(
262
+ 'https://generativelanguage.googleapis.com/v1beta/models/models%2Fgemini-3%2Fpro:generateContent',
263
+ expect.any(Object),
264
+ );
265
+ });
266
+
267
+ it('cancels custom probe response bodies after checking the status', async () => {
268
+ const response = new Response('{}');
269
+ const body = response.body;
270
+ if (body === null) throw new Error('Expected response body');
271
+ const cancelSpy = vi.spyOn(body, 'cancel');
272
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(response);
273
+
274
+ await probeCustomModelProviderCredentials({
275
+ providerId: 'local-vllm',
276
+ api: 'openai-responses',
277
+ baseUrl: 'https://llm.example.test/v1',
278
+ model: {id: 'llama-3.1', label: 'Llama 3.1'},
279
+ });
280
+
281
+ expect(cancelSpy).toHaveBeenCalledOnce();
282
+ });
283
+
284
+ it('rejects non-2xx custom probe responses', async () => {
285
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('{}', {status: 401}));
286
+
287
+ const probe = probeCustomModelProviderCredentials({
288
+ providerId: 'local-vllm',
289
+ api: 'openai-responses',
290
+ baseUrl: 'https://llm.example.test/v1',
291
+ model: {id: 'llama-3.1', label: 'Llama 3.1'},
292
+ });
293
+
294
+ await expect(probe).rejects.toThrow('Provider returned HTTP 401.');
295
+ });
296
+ });
297
+
298
+ function createAssistantMessage(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
299
+ return {
300
+ role: 'assistant',
301
+ content: [{type: 'text', text: 'OK'}],
302
+ api: 'anthropic-messages',
303
+ provider: 'anthropic',
304
+ model: 'claude-opus-4-8',
305
+ usage: {
306
+ input: 0,
307
+ output: 0,
308
+ cacheRead: 0,
309
+ cacheWrite: 0,
310
+ totalTokens: 0,
311
+ cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0},
312
+ },
313
+ stopReason: 'stop',
314
+ timestamp: Date.now(),
315
+ ...overrides,
316
+ };
317
+ }
318
+
319
+ describe('sanitizeModelProviderError', () => {
320
+ it('redacts literal and derived secret wire forms from provider messages', () => {
321
+ const secret = 'sk-ant-api-key-secret-abcd';
322
+ const base64Secret = Buffer.from(secret, 'utf8').toString('base64');
323
+ const error = new Error(`Authentication failed for ${secret} and ${base64Secret}`);
324
+
325
+ const sanitized = sanitizeModelProviderError(error, [secret]);
326
+
327
+ expect(sanitized).not.toContain(secret);
328
+ expect(sanitized).not.toContain(base64Secret);
329
+ expect(sanitized).toContain('***');
330
+ });
331
+
332
+ it('caps long provider messages', () => {
333
+ const error = new Error('x'.repeat(1000));
334
+
335
+ const sanitized = sanitizeModelProviderError(error, ['sk-ant-secret']);
336
+
337
+ expect(sanitized).toHaveLength(500);
338
+ });
339
+
340
+ it('omits the stack from Error objects', () => {
341
+ const error = new Error('Provider said no');
342
+
343
+ const sanitized = sanitizeModelProviderError(error, []);
344
+
345
+ expect(sanitized).toBe('Provider said no');
346
+ expect(sanitized).not.toContain('at ');
347
+ });
348
+
349
+ it('handles non-Error thrown values', () => {
350
+ const sanitized = sanitizeModelProviderError('raw thrown value', ['secret']);
351
+
352
+ expect(sanitized).toBe('Provider validation failed.');
353
+ });
354
+ });