@animus-labs/cortex 0.2.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 (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/budget-guard.d.ts +75 -0
  4. package/dist/budget-guard.d.ts.map +1 -0
  5. package/dist/budget-guard.js +142 -0
  6. package/dist/budget-guard.js.map +1 -0
  7. package/dist/compaction/compaction.d.ts +99 -0
  8. package/dist/compaction/compaction.d.ts.map +1 -0
  9. package/dist/compaction/compaction.js +302 -0
  10. package/dist/compaction/compaction.js.map +1 -0
  11. package/dist/compaction/failsafe.d.ts +57 -0
  12. package/dist/compaction/failsafe.d.ts.map +1 -0
  13. package/dist/compaction/failsafe.js +135 -0
  14. package/dist/compaction/failsafe.js.map +1 -0
  15. package/dist/compaction/index.d.ts +381 -0
  16. package/dist/compaction/index.d.ts.map +1 -0
  17. package/dist/compaction/index.js +979 -0
  18. package/dist/compaction/index.js.map +1 -0
  19. package/dist/compaction/microcompaction.d.ts +219 -0
  20. package/dist/compaction/microcompaction.d.ts.map +1 -0
  21. package/dist/compaction/microcompaction.js +536 -0
  22. package/dist/compaction/microcompaction.js.map +1 -0
  23. package/dist/compaction/observational/buffering.d.ts +225 -0
  24. package/dist/compaction/observational/buffering.d.ts.map +1 -0
  25. package/dist/compaction/observational/buffering.js +354 -0
  26. package/dist/compaction/observational/buffering.js.map +1 -0
  27. package/dist/compaction/observational/constants.d.ts +70 -0
  28. package/dist/compaction/observational/constants.d.ts.map +1 -0
  29. package/dist/compaction/observational/constants.js +507 -0
  30. package/dist/compaction/observational/constants.js.map +1 -0
  31. package/dist/compaction/observational/index.d.ts +219 -0
  32. package/dist/compaction/observational/index.d.ts.map +1 -0
  33. package/dist/compaction/observational/index.js +641 -0
  34. package/dist/compaction/observational/index.js.map +1 -0
  35. package/dist/compaction/observational/observer.d.ts +97 -0
  36. package/dist/compaction/observational/observer.d.ts.map +1 -0
  37. package/dist/compaction/observational/observer.js +424 -0
  38. package/dist/compaction/observational/observer.js.map +1 -0
  39. package/dist/compaction/observational/recall-tool.d.ts +27 -0
  40. package/dist/compaction/observational/recall-tool.d.ts.map +1 -0
  41. package/dist/compaction/observational/recall-tool.js +93 -0
  42. package/dist/compaction/observational/recall-tool.js.map +1 -0
  43. package/dist/compaction/observational/reflector.d.ts +94 -0
  44. package/dist/compaction/observational/reflector.d.ts.map +1 -0
  45. package/dist/compaction/observational/reflector.js +167 -0
  46. package/dist/compaction/observational/reflector.js.map +1 -0
  47. package/dist/compaction/observational/types.d.ts +271 -0
  48. package/dist/compaction/observational/types.d.ts.map +1 -0
  49. package/dist/compaction/observational/types.js +15 -0
  50. package/dist/compaction/observational/types.js.map +1 -0
  51. package/dist/context-manager.d.ts +134 -0
  52. package/dist/context-manager.d.ts.map +1 -0
  53. package/dist/context-manager.js +170 -0
  54. package/dist/context-manager.js.map +1 -0
  55. package/dist/cortex-agent.d.ts +1020 -0
  56. package/dist/cortex-agent.d.ts.map +1 -0
  57. package/dist/cortex-agent.js +3589 -0
  58. package/dist/cortex-agent.js.map +1 -0
  59. package/dist/error-classifier.d.ts +48 -0
  60. package/dist/error-classifier.d.ts.map +1 -0
  61. package/dist/error-classifier.js +152 -0
  62. package/dist/error-classifier.js.map +1 -0
  63. package/dist/event-bridge.d.ts +166 -0
  64. package/dist/event-bridge.d.ts.map +1 -0
  65. package/dist/event-bridge.js +381 -0
  66. package/dist/event-bridge.js.map +1 -0
  67. package/dist/index.d.ts +55 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +57 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/mcp-client.d.ts +119 -0
  72. package/dist/mcp-client.d.ts.map +1 -0
  73. package/dist/mcp-client.js +474 -0
  74. package/dist/mcp-client.js.map +1 -0
  75. package/dist/model-wrapper.d.ts +58 -0
  76. package/dist/model-wrapper.d.ts.map +1 -0
  77. package/dist/model-wrapper.js +86 -0
  78. package/dist/model-wrapper.js.map +1 -0
  79. package/dist/noop-logger.d.ts +4 -0
  80. package/dist/noop-logger.d.ts.map +1 -0
  81. package/dist/noop-logger.js +8 -0
  82. package/dist/noop-logger.js.map +1 -0
  83. package/dist/prompt-diagnostics.d.ts +47 -0
  84. package/dist/prompt-diagnostics.d.ts.map +1 -0
  85. package/dist/prompt-diagnostics.js +230 -0
  86. package/dist/prompt-diagnostics.js.map +1 -0
  87. package/dist/provider-manager.d.ts +224 -0
  88. package/dist/provider-manager.d.ts.map +1 -0
  89. package/dist/provider-manager.js +563 -0
  90. package/dist/provider-manager.js.map +1 -0
  91. package/dist/provider-registry.d.ts +115 -0
  92. package/dist/provider-registry.d.ts.map +1 -0
  93. package/dist/provider-registry.js +305 -0
  94. package/dist/provider-registry.js.map +1 -0
  95. package/dist/schema-converter.d.ts +20 -0
  96. package/dist/schema-converter.d.ts.map +1 -0
  97. package/dist/schema-converter.js +48 -0
  98. package/dist/schema-converter.js.map +1 -0
  99. package/dist/skill-preprocessor.d.ts +46 -0
  100. package/dist/skill-preprocessor.d.ts.map +1 -0
  101. package/dist/skill-preprocessor.js +237 -0
  102. package/dist/skill-preprocessor.js.map +1 -0
  103. package/dist/skill-registry.d.ts +107 -0
  104. package/dist/skill-registry.d.ts.map +1 -0
  105. package/dist/skill-registry.js +330 -0
  106. package/dist/skill-registry.js.map +1 -0
  107. package/dist/skill-tool.d.ts +54 -0
  108. package/dist/skill-tool.d.ts.map +1 -0
  109. package/dist/skill-tool.js +88 -0
  110. package/dist/skill-tool.js.map +1 -0
  111. package/dist/sub-agent-manager.d.ts +90 -0
  112. package/dist/sub-agent-manager.d.ts.map +1 -0
  113. package/dist/sub-agent-manager.js +192 -0
  114. package/dist/sub-agent-manager.js.map +1 -0
  115. package/dist/token-estimator.d.ts +23 -0
  116. package/dist/token-estimator.d.ts.map +1 -0
  117. package/dist/token-estimator.js +27 -0
  118. package/dist/token-estimator.js.map +1 -0
  119. package/dist/tool-contract.d.ts +68 -0
  120. package/dist/tool-contract.d.ts.map +1 -0
  121. package/dist/tool-contract.js +35 -0
  122. package/dist/tool-contract.js.map +1 -0
  123. package/dist/tool-result-persistence.d.ts +89 -0
  124. package/dist/tool-result-persistence.d.ts.map +1 -0
  125. package/dist/tool-result-persistence.js +152 -0
  126. package/dist/tool-result-persistence.js.map +1 -0
  127. package/dist/tools/bash/index.d.ts +71 -0
  128. package/dist/tools/bash/index.d.ts.map +1 -0
  129. package/dist/tools/bash/index.js +485 -0
  130. package/dist/tools/bash/index.js.map +1 -0
  131. package/dist/tools/bash/interactive.d.ts +47 -0
  132. package/dist/tools/bash/interactive.d.ts.map +1 -0
  133. package/dist/tools/bash/interactive.js +262 -0
  134. package/dist/tools/bash/interactive.js.map +1 -0
  135. package/dist/tools/bash/safety.d.ts +149 -0
  136. package/dist/tools/bash/safety.d.ts.map +1 -0
  137. package/dist/tools/bash/safety.js +1116 -0
  138. package/dist/tools/bash/safety.js.map +1 -0
  139. package/dist/tools/edit.d.ts +57 -0
  140. package/dist/tools/edit.d.ts.map +1 -0
  141. package/dist/tools/edit.js +310 -0
  142. package/dist/tools/edit.js.map +1 -0
  143. package/dist/tools/glob.d.ts +34 -0
  144. package/dist/tools/glob.d.ts.map +1 -0
  145. package/dist/tools/glob.js +268 -0
  146. package/dist/tools/glob.js.map +1 -0
  147. package/dist/tools/grep.d.ts +53 -0
  148. package/dist/tools/grep.d.ts.map +1 -0
  149. package/dist/tools/grep.js +673 -0
  150. package/dist/tools/grep.js.map +1 -0
  151. package/dist/tools/index.d.ts +62 -0
  152. package/dist/tools/index.d.ts.map +1 -0
  153. package/dist/tools/index.js +52 -0
  154. package/dist/tools/index.js.map +1 -0
  155. package/dist/tools/read.d.ts +43 -0
  156. package/dist/tools/read.d.ts.map +1 -0
  157. package/dist/tools/read.js +459 -0
  158. package/dist/tools/read.js.map +1 -0
  159. package/dist/tools/runtime.d.ts +62 -0
  160. package/dist/tools/runtime.d.ts.map +1 -0
  161. package/dist/tools/runtime.js +116 -0
  162. package/dist/tools/runtime.js.map +1 -0
  163. package/dist/tools/shared/cwd-tracker.d.ts +32 -0
  164. package/dist/tools/shared/cwd-tracker.d.ts.map +1 -0
  165. package/dist/tools/shared/cwd-tracker.js +44 -0
  166. package/dist/tools/shared/cwd-tracker.js.map +1 -0
  167. package/dist/tools/shared/edit-history.d.ts +55 -0
  168. package/dist/tools/shared/edit-history.d.ts.map +1 -0
  169. package/dist/tools/shared/edit-history.js +72 -0
  170. package/dist/tools/shared/edit-history.js.map +1 -0
  171. package/dist/tools/shared/edit-matcher.d.ts +83 -0
  172. package/dist/tools/shared/edit-matcher.d.ts.map +1 -0
  173. package/dist/tools/shared/edit-matcher.js +359 -0
  174. package/dist/tools/shared/edit-matcher.js.map +1 -0
  175. package/dist/tools/shared/file-mutation-lock.d.ts +22 -0
  176. package/dist/tools/shared/file-mutation-lock.d.ts.map +1 -0
  177. package/dist/tools/shared/file-mutation-lock.js +35 -0
  178. package/dist/tools/shared/file-mutation-lock.js.map +1 -0
  179. package/dist/tools/shared/gitignore.d.ts +17 -0
  180. package/dist/tools/shared/gitignore.d.ts.map +1 -0
  181. package/dist/tools/shared/gitignore.js +59 -0
  182. package/dist/tools/shared/gitignore.js.map +1 -0
  183. package/dist/tools/shared/pdf-extractor.d.ts +96 -0
  184. package/dist/tools/shared/pdf-extractor.d.ts.map +1 -0
  185. package/dist/tools/shared/pdf-extractor.js +196 -0
  186. package/dist/tools/shared/pdf-extractor.js.map +1 -0
  187. package/dist/tools/shared/read-registry.d.ts +66 -0
  188. package/dist/tools/shared/read-registry.d.ts.map +1 -0
  189. package/dist/tools/shared/read-registry.js +65 -0
  190. package/dist/tools/shared/read-registry.js.map +1 -0
  191. package/dist/tools/shared/safe-env.d.ts +18 -0
  192. package/dist/tools/shared/safe-env.d.ts.map +1 -0
  193. package/dist/tools/shared/safe-env.js +70 -0
  194. package/dist/tools/shared/safe-env.js.map +1 -0
  195. package/dist/tools/sub-agent.d.ts +91 -0
  196. package/dist/tools/sub-agent.d.ts.map +1 -0
  197. package/dist/tools/sub-agent.js +89 -0
  198. package/dist/tools/sub-agent.js.map +1 -0
  199. package/dist/tools/task-output.d.ts +38 -0
  200. package/dist/tools/task-output.d.ts.map +1 -0
  201. package/dist/tools/task-output.js +186 -0
  202. package/dist/tools/task-output.js.map +1 -0
  203. package/dist/tools/tool-search/index.d.ts +40 -0
  204. package/dist/tools/tool-search/index.d.ts.map +1 -0
  205. package/dist/tools/tool-search/index.js +110 -0
  206. package/dist/tools/tool-search/index.js.map +1 -0
  207. package/dist/tools/tool-search/registry.d.ts +82 -0
  208. package/dist/tools/tool-search/registry.d.ts.map +1 -0
  209. package/dist/tools/tool-search/registry.js +238 -0
  210. package/dist/tools/tool-search/registry.js.map +1 -0
  211. package/dist/tools/undo-edit.d.ts +51 -0
  212. package/dist/tools/undo-edit.d.ts.map +1 -0
  213. package/dist/tools/undo-edit.js +231 -0
  214. package/dist/tools/undo-edit.js.map +1 -0
  215. package/dist/tools/web-fetch/cache.d.ts +49 -0
  216. package/dist/tools/web-fetch/cache.d.ts.map +1 -0
  217. package/dist/tools/web-fetch/cache.js +89 -0
  218. package/dist/tools/web-fetch/cache.js.map +1 -0
  219. package/dist/tools/web-fetch/index.d.ts +53 -0
  220. package/dist/tools/web-fetch/index.d.ts.map +1 -0
  221. package/dist/tools/web-fetch/index.js +513 -0
  222. package/dist/tools/web-fetch/index.js.map +1 -0
  223. package/dist/tools/write.d.ts +59 -0
  224. package/dist/tools/write.d.ts.map +1 -0
  225. package/dist/tools/write.js +316 -0
  226. package/dist/tools/write.js.map +1 -0
  227. package/dist/types.d.ts +881 -0
  228. package/dist/types.d.ts.map +1 -0
  229. package/dist/types.js +16 -0
  230. package/dist/types.js.map +1 -0
  231. package/dist/working-tags.d.ts +44 -0
  232. package/dist/working-tags.d.ts.map +1 -0
  233. package/dist/working-tags.js +103 -0
  234. package/dist/working-tags.js.map +1 -0
  235. package/package.json +87 -0
  236. package/src/budget-guard.ts +170 -0
  237. package/src/compaction/compaction.ts +386 -0
  238. package/src/compaction/failsafe.ts +185 -0
  239. package/src/compaction/index.ts +1199 -0
  240. package/src/compaction/microcompaction.ts +709 -0
  241. package/src/compaction/observational/buffering.ts +430 -0
  242. package/src/compaction/observational/constants.ts +532 -0
  243. package/src/compaction/observational/index.ts +837 -0
  244. package/src/compaction/observational/observer.ts +510 -0
  245. package/src/compaction/observational/recall-tool.ts +130 -0
  246. package/src/compaction/observational/reflector.ts +221 -0
  247. package/src/compaction/observational/types.ts +343 -0
  248. package/src/context-manager.ts +237 -0
  249. package/src/cortex-agent.ts +4297 -0
  250. package/src/error-classifier.ts +199 -0
  251. package/src/event-bridge.ts +508 -0
  252. package/src/index.ts +292 -0
  253. package/src/mcp-client.ts +582 -0
  254. package/src/model-wrapper.ts +128 -0
  255. package/src/noop-logger.ts +9 -0
  256. package/src/prompt-diagnostics.ts +296 -0
  257. package/src/provider-manager.ts +823 -0
  258. package/src/provider-registry.ts +386 -0
  259. package/src/schema-converter.ts +51 -0
  260. package/src/skill-preprocessor.ts +314 -0
  261. package/src/skill-registry.ts +378 -0
  262. package/src/skill-tool.ts +130 -0
  263. package/src/sub-agent-manager.ts +236 -0
  264. package/src/token-estimator.ts +26 -0
  265. package/src/tool-contract.ts +113 -0
  266. package/src/tool-result-persistence.ts +197 -0
  267. package/src/tools/bash/index.ts +633 -0
  268. package/src/tools/bash/interactive.ts +302 -0
  269. package/src/tools/bash/safety.ts +1297 -0
  270. package/src/tools/edit.ts +422 -0
  271. package/src/tools/glob.ts +330 -0
  272. package/src/tools/grep.ts +819 -0
  273. package/src/tools/index.ts +110 -0
  274. package/src/tools/read.ts +580 -0
  275. package/src/tools/runtime.ts +173 -0
  276. package/src/tools/shared/cwd-tracker.ts +50 -0
  277. package/src/tools/shared/edit-history.ts +96 -0
  278. package/src/tools/shared/edit-matcher.ts +457 -0
  279. package/src/tools/shared/file-mutation-lock.ts +40 -0
  280. package/src/tools/shared/gitignore.ts +61 -0
  281. package/src/tools/shared/pdf-extractor.ts +290 -0
  282. package/src/tools/shared/read-registry.ts +93 -0
  283. package/src/tools/shared/safe-env.ts +82 -0
  284. package/src/tools/sub-agent.ts +171 -0
  285. package/src/tools/task-output.ts +236 -0
  286. package/src/tools/tool-search/index.ts +167 -0
  287. package/src/tools/tool-search/registry.ts +278 -0
  288. package/src/tools/undo-edit.ts +314 -0
  289. package/src/tools/web-fetch/cache.ts +112 -0
  290. package/src/tools/web-fetch/index.ts +604 -0
  291. package/src/tools/write.ts +385 -0
  292. package/src/types.ts +1057 -0
  293. package/src/working-tags.ts +118 -0
@@ -0,0 +1,823 @@
1
+ /**
2
+ * ProviderManager: standalone class wrapping pi-ai for provider discovery,
3
+ * OAuth login/refresh, API key validation, model resolution, and custom
4
+ * endpoint creation.
5
+ *
6
+ * ProviderManager and CortexAgent are fully independent. Neither knows
7
+ * about the other. The consumer creates both, uses ProviderManager for
8
+ * auth/discovery, and provides a getApiKey callback to CortexAgent.
9
+ *
10
+ * Pi-ai is loaded dynamically so consumers never import it directly.
11
+ * If the dependency is missing or unavailable, methods that require it
12
+ * throw clear errors.
13
+ *
14
+ * Reference: provider-manager.md
15
+ */
16
+
17
+ import {
18
+ PROVIDER_REGISTRY,
19
+ OAUTH_PROVIDER_IDS,
20
+ UTILITY_MODEL_DEFAULTS,
21
+ } from './provider-registry.js';
22
+ import type { ThinkingLevel } from './types.js';
23
+ import type { ProviderInfo, ModelInfo } from './provider-registry.js';
24
+ import { wrapModel } from './model-wrapper.js';
25
+ import type { CortexModel } from './model-wrapper.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // OAuth types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Callbacks provided by the consumer during an OAuth flow. */
32
+ export interface OAuthCallbacks {
33
+ /**
34
+ * Called when the user needs to visit a URL to authorize.
35
+ * The consumer should open the URL in a browser or display it.
36
+ */
37
+ onAuth: (info: { url: string; instructions?: string }) => void;
38
+
39
+ /**
40
+ * Called when the OAuth flow needs user input (e.g., a prompt).
41
+ * The consumer should display the prompt and return the user's response.
42
+ */
43
+ onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
44
+
45
+ /**
46
+ * Called with progress messages during the flow.
47
+ */
48
+ onProgress?: (message: string) => void;
49
+
50
+ /**
51
+ * Called when a callback-server OAuth flow needs the user to paste a
52
+ * manual authorization code.
53
+ */
54
+ onManualCodeInput?: () => Promise<string>;
55
+
56
+ /**
57
+ * Called when the OAuth flow needs the user to choose from provider-specific
58
+ * options, such as a Copilot organization or endpoint.
59
+ */
60
+ onSelect?: (prompt: {
61
+ message: string;
62
+ options: Array<{ id: string; label: string }>;
63
+ }) => Promise<string | undefined>;
64
+ }
65
+
66
+ /** Display-safe metadata extracted at login time. */
67
+ export interface OAuthMeta {
68
+ /** Provider identifier. */
69
+ provider: string;
70
+ /** Display name, email, or account identifier (if available). */
71
+ displayName?: string | undefined;
72
+ /** When the access token expires (Unix timestamp ms). Undefined if non-expiring. */
73
+ expiresAt?: number | undefined;
74
+ /** Whether the credential supports automatic refresh. */
75
+ refreshable: boolean;
76
+ }
77
+
78
+ /** Result of a successful OAuth login. */
79
+ export interface OAuthResult {
80
+ /**
81
+ * Serialized credential payload. The consumer stores this (encrypted)
82
+ * and passes it back to resolveOAuthApiKey() for refresh.
83
+ * Treat as an opaque blob: encrypt, store, decrypt, pass back. Never parse.
84
+ */
85
+ credentials: string;
86
+ /** Display-safe metadata extracted at login time. */
87
+ meta: OAuthMeta;
88
+ }
89
+
90
+ /** Result of resolving/refreshing an OAuth API key. */
91
+ export interface OAuthRefreshResult {
92
+ /** The API key to use for LLM calls. */
93
+ apiKey: string;
94
+ /**
95
+ * Credential payload (may be updated if refresh occurred).
96
+ * Same format as OAuthResult.credentials.
97
+ */
98
+ credentials: string;
99
+ /** Updated metadata. */
100
+ meta: OAuthMeta;
101
+ /** Whether the credentials were actually refreshed (true) or reused as-is (false). */
102
+ changed: boolean;
103
+ }
104
+
105
+ /** Configuration for creating a custom model endpoint. */
106
+ export interface CustomModelConfig {
107
+ /** Base URL of the OpenAI-compatible API (e.g., 'http://localhost:11434/v1'). */
108
+ baseUrl: string;
109
+ /** Model identifier to send in API requests. */
110
+ modelId: string;
111
+ /** Context window size (default: 128,000). */
112
+ contextWindow?: number | undefined;
113
+ /** Optional API key (some local servers don't require one). */
114
+ apiKey?: string | undefined;
115
+ /** Compatibility settings for non-standard servers. */
116
+ compat?: {
117
+ /** Whether the server supports the 'developer' role (default: true). */
118
+ supportsDeveloperRole?: boolean | undefined;
119
+ /** Whether the server supports reasoning_effort (default: true). */
120
+ supportsReasoningEffort?: boolean | undefined;
121
+ } | undefined;
122
+ }
123
+
124
+ export type ApiKeyValidationStatus =
125
+ | 'valid'
126
+ | 'invalid_credentials'
127
+ | 'transient_error'
128
+ | 'resolution_error';
129
+
130
+ export interface ApiKeyValidationResult {
131
+ provider: string;
132
+ modelId: string | null;
133
+ valid: boolean;
134
+ retryable: boolean;
135
+ status: ApiKeyValidationStatus;
136
+ message?: string | undefined;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // IProviderManager interface
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Interface for provider management operations.
145
+ * Consumers can mock this for testing.
146
+ */
147
+ export interface IProviderManager {
148
+ // Discovery
149
+ listProviders(): ProviderInfo[];
150
+ listOAuthProviders(): string[];
151
+ listModels(provider: string): Promise<ModelInfo[]>;
152
+
153
+ // OAuth
154
+ initiateOAuth(provider: string, callbacks: OAuthCallbacks): Promise<OAuthResult>;
155
+ cancelOAuth(): void;
156
+ resolveOAuthApiKey(provider: string, credentials: string): Promise<OAuthRefreshResult>;
157
+
158
+ // API Key
159
+ validateApiKey(provider: string, apiKey: string): Promise<ApiKeyValidationResult>;
160
+ checkEnvApiKey(provider: string): string | null;
161
+
162
+ // Model Resolution
163
+ resolveModel(provider: string, modelId: string): Promise<CortexModel>;
164
+ createCustomModel(config: CustomModelConfig): Promise<CortexModel>;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Pi-ai dynamic import types
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /** Shape of the pi-ai main module functions we use. */
172
+ interface PiAiModule {
173
+ getModel: (provider: string, modelId: string) => unknown;
174
+ getModels: (provider: string) => Array<Record<string, unknown>>;
175
+ getEnvApiKey: (provider: string) => string | undefined;
176
+ getSupportedThinkingLevels?: ((model: unknown) => string[]) | undefined;
177
+ completeSimple?: ((model: unknown, context: unknown, options?: unknown) => Promise<unknown>) | undefined;
178
+ complete?: ((model: unknown, context: unknown, options?: unknown) => Promise<unknown>) | undefined;
179
+ }
180
+
181
+ interface PiOAuthProvider {
182
+ id: string;
183
+ name: string;
184
+ login: (callbacks: Record<string, unknown>) => Promise<Record<string, unknown>>;
185
+ }
186
+
187
+ interface PiAiOAuthModule {
188
+ getOAuthProvider?: ((id: string) => PiOAuthProvider | undefined) | undefined;
189
+ getOAuthProviders?: (() => PiOAuthProvider[]) | undefined;
190
+ getOAuthApiKey?: ((
191
+ provider: string,
192
+ credentials: Record<string, unknown>,
193
+ ) => Promise<{ apiKey: string; newCredentials: Record<string, unknown> } | null>) | undefined;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Pi-ai dynamic import helpers
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /**
201
+ * Lazily load the pi-ai main module.
202
+ * Throws a clear error if pi-ai is not installed.
203
+ */
204
+ async function loadPiAi(): Promise<PiAiModule> {
205
+ try {
206
+ // Dynamic import with string literal to avoid bundler resolution
207
+ const modulePath = '@earendil-works/pi-ai';
208
+ return await import(/* @vite-ignore */ modulePath) as PiAiModule;
209
+ } catch {
210
+ throw new Error(
211
+ 'pi-ai is not installed. Install @earendil-works/pi-ai to use ProviderManager.'
212
+ );
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Lazily load the pi-ai OAuth module.
218
+ * Throws a clear error if pi-ai is not installed.
219
+ */
220
+ async function loadPiAiOAuth(): Promise<PiAiOAuthModule> {
221
+ try {
222
+ const modulePath = '@earendil-works/pi-ai/oauth';
223
+ return await import(/* @vite-ignore */ modulePath) as PiAiOAuthModule;
224
+ } catch {
225
+ throw new Error(
226
+ 'pi-ai is not installed. Install @earendil-works/pi-ai to use OAuth features.'
227
+ );
228
+ }
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Display name extraction
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * Extract the best available display name from OAuth credentials.
237
+ * Different providers include different identity information.
238
+ */
239
+ function extractDisplayName(credentials: Record<string, unknown>): string | undefined {
240
+ // Try common fields across providers
241
+ const email = credentials['email'];
242
+ if (typeof email === 'string') return email;
243
+
244
+ const accountId = credentials['accountId'];
245
+ if (typeof accountId === 'string') return accountId;
246
+
247
+ const idToken = credentials['idToken'];
248
+ if (typeof idToken === 'string') {
249
+ // JWT id_token may contain email in payload
250
+ try {
251
+ const parts = idToken.split('.');
252
+ if (parts.length >= 2) {
253
+ const payload = JSON.parse(atob(parts[1]!)) as Record<string, unknown>;
254
+ const payloadEmail = payload['email'];
255
+ if (typeof payloadEmail === 'string') return payloadEmail;
256
+ }
257
+ } catch {
258
+ // Ignore malformed tokens
259
+ }
260
+ }
261
+ return undefined;
262
+ }
263
+
264
+ /**
265
+ * Build OAuthMeta from raw credential data.
266
+ */
267
+ function buildOAuthMeta(
268
+ provider: string,
269
+ rawCredentials: Record<string, unknown>,
270
+ ): OAuthMeta {
271
+ const displayName = extractDisplayName(rawCredentials);
272
+ const expiresAtRaw = rawCredentials['expiresAt'] ?? rawCredentials['expires'];
273
+ const expiresAt = typeof expiresAtRaw === 'number' ? expiresAtRaw : undefined;
274
+
275
+ const meta: OAuthMeta = {
276
+ provider,
277
+ refreshable: !!(rawCredentials['refreshToken'] ?? rawCredentials['refresh']),
278
+ };
279
+
280
+ if (displayName !== undefined) {
281
+ meta.displayName = displayName;
282
+ }
283
+ if (expiresAt !== undefined) {
284
+ meta.expiresAt = expiresAt;
285
+ }
286
+
287
+ return meta;
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Legacy model filtering
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /**
295
+ * Model ID prefixes considered legacy/deprecated per provider.
296
+ * Pi-ai doesn't flag deprecation, so we maintain this list to keep
297
+ * the model picker clean and prevent users from selecting models that
298
+ * produce poor results with modern tool-use patterns.
299
+ */
300
+ const LEGACY_MODEL_PREFIXES: Record<string, string[]> = {
301
+ anthropic: [
302
+ 'claude-3-', // Claude 3.x family (Haiku/Sonnet/Opus from 2024)
303
+ 'claude-3.', // Alternate naming
304
+ ],
305
+ openai: [
306
+ 'gpt-3.5-', // GPT-3.5 family
307
+ 'gpt-4-', // GPT-4 original (not 4o/4.1)
308
+ ],
309
+ google: [
310
+ 'gemini-1.', // Gemini 1.x family
311
+ 'gemini-pro', // Original Gemini Pro
312
+ ],
313
+ };
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Model mapping helper
317
+ // ---------------------------------------------------------------------------
318
+
319
+ const CORTEX_THINKING_LEVELS: readonly ThinkingLevel[] = [
320
+ 'off',
321
+ 'minimal',
322
+ 'low',
323
+ 'medium',
324
+ 'high',
325
+ 'max',
326
+ ];
327
+
328
+ function mapPiThinkingLevel(level: string): ThinkingLevel | null {
329
+ const mapped = level === 'xhigh' ? 'max' : level;
330
+ return (CORTEX_THINKING_LEVELS as readonly string[]).includes(mapped)
331
+ ? mapped as ThinkingLevel
332
+ : null;
333
+ }
334
+
335
+ function mapPiThinkingLevels(levels: readonly string[]): ThinkingLevel[] {
336
+ const mapped: ThinkingLevel[] = [];
337
+ for (const level of levels) {
338
+ const cortexLevel = mapPiThinkingLevel(level);
339
+ if (cortexLevel && !mapped.includes(cortexLevel)) {
340
+ mapped.push(cortexLevel);
341
+ }
342
+ }
343
+ return mapped;
344
+ }
345
+
346
+ /**
347
+ * Map a raw pi-ai model object to our ModelInfo type.
348
+ */
349
+ function mapRawToModelInfo(
350
+ raw: Record<string, unknown>,
351
+ getSupportedThinkingLevels?: (model: unknown) => string[],
352
+ ): ModelInfo {
353
+ // pi-ai models have 'id' (API identifier like "claude-sonnet-4-6") and
354
+ // 'name' (display name like "Claude Sonnet 4.6"). Use 'id' as our id.
355
+ const rawId = raw['id'];
356
+ const id = typeof rawId === 'string' ? rawId : String(rawId ?? raw['name'] ?? 'unknown');
357
+
358
+ const rawDisplayName = raw['displayName'];
359
+ const rawName = raw['name'];
360
+ const name = typeof rawDisplayName === 'string'
361
+ ? rawDisplayName
362
+ : typeof rawName === 'string'
363
+ ? rawName
364
+ : id;
365
+
366
+ const rawContextWindow = raw['contextWindow'];
367
+ const contextWindow = typeof rawContextWindow === 'number' ? rawContextWindow : 200_000;
368
+
369
+ let supportedThinkingLevels: ThinkingLevel[] = [];
370
+ if (getSupportedThinkingLevels) {
371
+ try {
372
+ supportedThinkingLevels = mapPiThinkingLevels(getSupportedThinkingLevels(raw));
373
+ } catch {
374
+ supportedThinkingLevels = [];
375
+ }
376
+ }
377
+ if (supportedThinkingLevels.length === 0 && raw['reasoning'] === true) {
378
+ supportedThinkingLevels = ['minimal', 'low', 'medium', 'high'];
379
+ }
380
+
381
+ const info: ModelInfo = {
382
+ id,
383
+ name,
384
+ contextWindow,
385
+ supportsThinking: supportedThinkingLevels.some(level => level !== 'off')
386
+ || !!(raw['supportsThinking'] || raw['reasoning']),
387
+ supportedThinkingLevels,
388
+ supportsImages: !!raw['supportsImages'],
389
+ };
390
+
391
+ const rawPricing = raw['pricing'];
392
+ if (rawPricing && typeof rawPricing === 'object') {
393
+ const pricing = rawPricing as Record<string, unknown>;
394
+ const inputPrice = pricing['input'];
395
+ const outputPrice = pricing['output'];
396
+ info.pricing = {
397
+ input: typeof inputPrice === 'number' ? inputPrice : 0,
398
+ output: typeof outputPrice === 'number' ? outputPrice : 0,
399
+ };
400
+ }
401
+
402
+ return info;
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // ProviderManager implementation
407
+ // ---------------------------------------------------------------------------
408
+
409
+ export class ProviderManager implements IProviderManager {
410
+ /** Active OAuth AbortController, if any. */
411
+ private activeOAuthAbort: AbortController | null = null;
412
+
413
+ // -----------------------------------------------------------------------
414
+ // Discovery
415
+ // -----------------------------------------------------------------------
416
+
417
+ /**
418
+ * List all known providers with their metadata.
419
+ */
420
+ listProviders(): ProviderInfo[] {
421
+ return PROVIDER_REGISTRY;
422
+ }
423
+
424
+ /**
425
+ * List provider IDs that support OAuth authentication.
426
+ */
427
+ listOAuthProviders(): string[] {
428
+ return OAUTH_PROVIDER_IDS;
429
+ }
430
+
431
+ /**
432
+ * List models available from a provider.
433
+ * Delegates to pi-ai's getModels().
434
+ *
435
+ * @param provider - Provider identifier
436
+ * @returns Array of ModelInfo
437
+ * @throws Error if pi-ai is not installed
438
+ */
439
+ async listModels(provider: string): Promise<ModelInfo[]> {
440
+ const piAi = await loadPiAi();
441
+ const rawModels = piAi.getModels(provider);
442
+ const models = rawModels.map(raw => mapRawToModelInfo(raw, piAi.getSupportedThinkingLevels));
443
+
444
+ // Filter pipeline:
445
+ // 1. Remove legacy/deprecated generation models
446
+ // 2. Remove "-latest" alias duplicates
447
+ // 3. Remove duplicate display names
448
+ const legacyPrefixes = LEGACY_MODEL_PREFIXES[provider];
449
+ const filtered = legacyPrefixes
450
+ ? models.filter(m => !legacyPrefixes.some(prefix => m.id.startsWith(prefix)))
451
+ : models;
452
+
453
+ const seen = new Set<string>();
454
+ return filtered.filter(m => {
455
+ // Strip "-latest" suffix to check for duplicate base names
456
+ const baseName = m.id.replace(/-latest$/, '');
457
+ if (m.id.endsWith('-latest')) {
458
+ // Only include the "-latest" alias if no pinned version exists
459
+ return !filtered.some(other => other.id === baseName);
460
+ }
461
+ // Skip duplicates with identical names (different IDs but same display name)
462
+ if (seen.has(m.name)) return false;
463
+ seen.add(m.name);
464
+ return true;
465
+ });
466
+ }
467
+
468
+ // -----------------------------------------------------------------------
469
+ // OAuth
470
+ // -----------------------------------------------------------------------
471
+
472
+ /**
473
+ * Initiate an OAuth login flow for a provider.
474
+ *
475
+ * @param provider - OAuth provider identifier
476
+ * @param callbacks - UI callbacks for auth URL, prompts, and progress
477
+ * @returns The OAuth credentials and display metadata
478
+ * @throws Error if the provider does not support OAuth or pi-ai is not installed
479
+ */
480
+ async initiateOAuth(provider: string, callbacks: OAuthCallbacks): Promise<OAuthResult> {
481
+ const oauthModule = await loadPiAiOAuth();
482
+ const oauthProvider = oauthModule.getOAuthProvider?.(provider);
483
+ if (!oauthProvider) {
484
+ throw new Error(`Provider "${provider}" does not support OAuth`);
485
+ }
486
+
487
+ this.activeOAuthAbort = new AbortController();
488
+
489
+ try {
490
+ const rawCredentials = await oauthProvider.login({
491
+ onAuth: callbacks.onAuth,
492
+ onPrompt: callbacks.onPrompt,
493
+ onProgress: callbacks.onProgress,
494
+ onManualCodeInput: callbacks.onManualCodeInput,
495
+ onSelect: callbacks.onSelect,
496
+ signal: this.activeOAuthAbort.signal,
497
+ });
498
+
499
+ this.activeOAuthAbort = null;
500
+
501
+ const credentials = JSON.stringify(rawCredentials);
502
+ const meta = buildOAuthMeta(provider, rawCredentials);
503
+
504
+ return { credentials, meta };
505
+ } catch (err) {
506
+ this.activeOAuthAbort = null;
507
+ throw err;
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Cancel any in-progress OAuth flow.
513
+ */
514
+ cancelOAuth(): void {
515
+ if (this.activeOAuthAbort) {
516
+ this.activeOAuthAbort.abort();
517
+ this.activeOAuthAbort = null;
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Resolve an API key from stored OAuth credentials, refreshing if needed.
523
+ *
524
+ * @param provider - The OAuth provider
525
+ * @param credentials - Serialized credential blob from initiateOAuth()
526
+ * @returns The API key and potentially updated credentials
527
+ * @throws Error if pi-ai is not installed or resolution fails
528
+ */
529
+ async resolveOAuthApiKey(provider: string, credentials: string): Promise<OAuthRefreshResult> {
530
+ const oauthModule = await loadPiAiOAuth();
531
+ const getOAuthApiKeyFn = oauthModule.getOAuthApiKey;
532
+ if (typeof getOAuthApiKeyFn !== 'function') {
533
+ throw new Error('getOAuthApiKey not found in pi-ai/oauth');
534
+ }
535
+
536
+ const rawCredentials = JSON.parse(credentials) as Record<string, unknown>;
537
+ // Security: spread rawCredentials first so Cortex-owned 'type' cannot be overridden
538
+ const credMap = { [provider]: { ...rawCredentials, type: 'oauth' as const } };
539
+
540
+ const result = await getOAuthApiKeyFn(provider, credMap);
541
+
542
+ if (!result) {
543
+ throw new Error(`OAuth resolution failed for provider "${provider}"`);
544
+ }
545
+
546
+ const originalSerialized = credentials;
547
+ const newSerialized = JSON.stringify(result.newCredentials);
548
+ const changed = newSerialized !== originalSerialized;
549
+ const meta = buildOAuthMeta(provider, result.newCredentials);
550
+
551
+ return {
552
+ apiKey: result.apiKey,
553
+ credentials: changed ? newSerialized : credentials,
554
+ meta,
555
+ changed,
556
+ };
557
+ }
558
+
559
+ // -----------------------------------------------------------------------
560
+ // API Key
561
+ // -----------------------------------------------------------------------
562
+
563
+ /**
564
+ * Validate an API key by making a minimal LLM call (maxTokens: 1).
565
+ *
566
+ * @param provider - The provider to validate against
567
+ * @param apiKey - The API key to validate
568
+ * @returns True if the key is valid, false otherwise
569
+ * @throws Error if pi-ai is not installed
570
+ */
571
+ async validateApiKey(provider: string, apiKey: string): Promise<ApiKeyValidationResult> {
572
+ const piAi = await loadPiAi();
573
+
574
+ // Find the cheapest model for this provider to minimize validation cost
575
+ const cheapestModelId = this.getSmallestModelId(provider);
576
+ if (!cheapestModelId) {
577
+ // No known model, try a generic test with the provider's first model
578
+ const models = piAi.getModels(provider);
579
+ if (models.length === 0) {
580
+ return {
581
+ provider,
582
+ modelId: null,
583
+ valid: false,
584
+ retryable: false,
585
+ status: 'resolution_error',
586
+ message: `No models found for provider "${provider}"`,
587
+ };
588
+ }
589
+ const firstRawId = models[0]!['id'];
590
+ const firstRawName = models[0]!['name'];
591
+ const firstModelId = typeof firstRawId === 'string'
592
+ ? firstRawId
593
+ : typeof firstRawName === 'string'
594
+ ? firstRawName
595
+ : String(firstRawId ?? firstRawName);
596
+ return this.tryValidation(piAi, provider, firstModelId, apiKey);
597
+ }
598
+
599
+ return this.tryValidation(piAi, provider, cheapestModelId, apiKey);
600
+ }
601
+
602
+ /**
603
+ * Check whether a provider's API key is available in environment variables.
604
+ *
605
+ * @param provider - The provider to check
606
+ * @returns The API key if found, null otherwise
607
+ */
608
+ checkEnvApiKey(provider: string): string | null {
609
+ const entry = PROVIDER_REGISTRY.find(p => p.id === provider);
610
+ if (entry?.envVar) {
611
+ const value = process.env[entry.envVar];
612
+ if (value && value.length > 0) return value;
613
+ }
614
+ return null;
615
+ }
616
+
617
+ // -----------------------------------------------------------------------
618
+ // Model Resolution
619
+ // -----------------------------------------------------------------------
620
+
621
+ /**
622
+ * Resolve a provider + model ID into a CortexModel.
623
+ *
624
+ * @param provider - The provider identifier
625
+ * @param modelId - The model identifier
626
+ * @returns A CortexModel handle
627
+ * @throws Error if pi-ai is not installed or the model is not found
628
+ */
629
+ async resolveModel(provider: string, modelId: string): Promise<CortexModel> {
630
+ const piAi = await loadPiAi();
631
+ const piModel = piAi.getModel(provider, modelId);
632
+ let contextWindow: number | undefined;
633
+ if (piModel && typeof piModel === 'object') {
634
+ const raw = piModel as Record<string, unknown>;
635
+ const cw = raw['contextWindow'];
636
+ if (typeof cw === 'number') {
637
+ contextWindow = cw;
638
+ }
639
+ }
640
+ return wrapModel(piModel, provider, modelId, contextWindow);
641
+ }
642
+
643
+ /**
644
+ * Create a custom model for an OpenAI-compatible endpoint.
645
+ *
646
+ * @param config - Custom model configuration
647
+ * @returns A CortexModel handle
648
+ * @throws Error if pi-ai is not installed
649
+ */
650
+ async createCustomModel(config: CustomModelConfig): Promise<CortexModel> {
651
+ const piAi = await loadPiAi();
652
+ // Clone an OpenAI model as a base for streaming/format compatibility,
653
+ // then override to use the Chat Completions API. The base model
654
+ // (openai/gpt-4.1) uses the newer Responses API which most
655
+ // OpenAI-compatible endpoints (Ollama, vLLM, etc.) do not support.
656
+ const baseModel = piAi.getModel('openai', 'gpt-4.1');
657
+ const piModel = {
658
+ ...(baseModel as Record<string, unknown>),
659
+ id: config.modelId,
660
+ name: config.modelId,
661
+ api: 'openai-completions',
662
+ baseUrl: config.baseUrl,
663
+ provider: 'custom',
664
+ contextWindow: config.contextWindow ?? 128_000,
665
+ // Conservative compat for OpenAI-compatible endpoints: disable
666
+ // features that are OpenAI-specific or may not be supported.
667
+ // Consumer-provided compat overrides are merged on top.
668
+ compat: {
669
+ supportsStore: false,
670
+ supportsDeveloperRole: false,
671
+ supportsStrictMode: false,
672
+ maxTokensField: 'max_tokens' as const,
673
+ ...config.compat,
674
+ },
675
+ };
676
+ // Set API key, using a placeholder for keyless endpoints (e.g., Ollama).
677
+ // The OpenAI SDK client requires a non-empty apiKey value.
678
+ (piModel as Record<string, unknown>)['apiKey'] = config.apiKey ?? 'sk-no-key-required';
679
+ return wrapModel(
680
+ piModel,
681
+ 'custom',
682
+ config.modelId,
683
+ config.contextWindow ?? 128_000,
684
+ );
685
+ }
686
+
687
+ // -----------------------------------------------------------------------
688
+ // Private helpers
689
+ // -----------------------------------------------------------------------
690
+
691
+ /**
692
+ * Get the cheapest known model ID for a provider.
693
+ * Uses the UTILITY_MODEL_DEFAULTS as a proxy for "smallest model."
694
+ */
695
+ private getSmallestModelId(provider: string): string | null {
696
+ return UTILITY_MODEL_DEFAULTS[provider] ?? null;
697
+ }
698
+
699
+ /**
700
+ * Attempt to validate an API key by making a minimal LLM call.
701
+ */
702
+ private async tryValidation(
703
+ piAi: PiAiModule,
704
+ provider: string,
705
+ modelId: string,
706
+ apiKey: string,
707
+ ): Promise<ApiKeyValidationResult> {
708
+ try {
709
+ const model = piAi.getModel(provider, modelId);
710
+
711
+ // Try completeSimple first, then complete
712
+ const completeFn = piAi.completeSimple ?? piAi.complete;
713
+
714
+ if (typeof completeFn !== 'function') {
715
+ // Cannot validate without a complete function; assume valid
716
+ // (the consumer will discover failures at first real call)
717
+ return {
718
+ provider,
719
+ modelId,
720
+ valid: true,
721
+ retryable: false,
722
+ status: 'valid',
723
+ };
724
+ }
725
+
726
+ const result = await completeFn(
727
+ model,
728
+ { messages: [{ role: 'user', content: 'hi' }] },
729
+ { apiKey, maxTokens: 1 },
730
+ );
731
+ const silentError = this.extractSilentValidationError(result);
732
+ if (silentError) {
733
+ throw new Error(silentError);
734
+ }
735
+ return {
736
+ provider,
737
+ modelId,
738
+ valid: true,
739
+ retryable: false,
740
+ status: 'valid',
741
+ };
742
+ } catch (err) {
743
+ return this.classifyValidationError(provider, modelId, err);
744
+ }
745
+ }
746
+
747
+ private classifyValidationError(
748
+ provider: string,
749
+ modelId: string,
750
+ err: unknown,
751
+ ): ApiKeyValidationResult {
752
+ const message = err instanceof Error ? err.message : String(err);
753
+ const normalized = message.toLowerCase();
754
+
755
+ if (
756
+ /\b401\b/.test(normalized) ||
757
+ /\b403\b/.test(normalized) ||
758
+ normalized.includes('invalid api key') ||
759
+ normalized.includes('incorrect api key') ||
760
+ normalized.includes('authentication failed') ||
761
+ normalized.includes('invalid_auth') ||
762
+ normalized.includes('unauthorized') ||
763
+ normalized.includes('forbidden') ||
764
+ normalized.includes('invalid credential')
765
+ ) {
766
+ return {
767
+ provider,
768
+ modelId,
769
+ valid: false,
770
+ retryable: false,
771
+ status: 'invalid_credentials',
772
+ message,
773
+ };
774
+ }
775
+
776
+ if (
777
+ /\b429\b/.test(normalized) ||
778
+ /\b500\b/.test(normalized) ||
779
+ /\b502\b/.test(normalized) ||
780
+ /\b503\b/.test(normalized) ||
781
+ /\b504\b/.test(normalized) ||
782
+ normalized.includes('rate limit') ||
783
+ normalized.includes('timeout') ||
784
+ normalized.includes('timed out') ||
785
+ normalized.includes('temporar') ||
786
+ normalized.includes('overloaded') ||
787
+ normalized.includes('unavailable') ||
788
+ normalized.includes('server error') ||
789
+ normalized.includes('network') ||
790
+ normalized.includes('econn') ||
791
+ normalized.includes('enotfound') ||
792
+ normalized.includes('eai_again')
793
+ ) {
794
+ return {
795
+ provider,
796
+ modelId,
797
+ valid: false,
798
+ retryable: true,
799
+ status: 'transient_error',
800
+ message,
801
+ };
802
+ }
803
+
804
+ return {
805
+ provider,
806
+ modelId,
807
+ valid: false,
808
+ retryable: false,
809
+ status: 'resolution_error',
810
+ message,
811
+ };
812
+ }
813
+
814
+ private extractSilentValidationError(result: unknown): string | null {
815
+ if (!result || typeof result !== 'object') return null;
816
+ const msg = result as Record<string, unknown>;
817
+ if (msg['stopReason'] !== 'error') return null;
818
+ const errorMessage = msg['errorMessage'];
819
+ return typeof errorMessage === 'string'
820
+ ? errorMessage
821
+ : 'Provider validation failed';
822
+ }
823
+ }