crewly 1.11.6 → 1.12.1

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 (142) hide show
  1. package/config/skills/agent/onboarding/synthesize-hierarchy/SKILL.md +65 -0
  2. package/config/skills/agent/onboarding/synthesize-hierarchy/execute.sh +61 -0
  3. package/config/skills/agent/web-search/SKILL.md +70 -0
  4. package/config/skills/agent/web-search/execute.sh +170 -0
  5. package/config/skills/agent/web-search/skill.json +23 -0
  6. package/dist/backend/backend/src/constants.d.ts +12 -0
  7. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  8. package/dist/backend/backend/src/constants.js +12 -0
  9. package/dist/backend/backend/src/constants.js.map +1 -1
  10. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +22 -0
  11. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
  12. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +58 -0
  13. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
  14. package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
  15. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +3 -1
  16. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
  17. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts +27 -0
  18. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts.map +1 -1
  19. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js +108 -0
  20. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js.map +1 -1
  21. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts +6 -2
  22. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts.map +1 -1
  23. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js +9 -3
  24. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js.map +1 -1
  25. package/dist/backend/backend/src/index.d.ts.map +1 -1
  26. package/dist/backend/backend/src/index.js +36 -2
  27. package/dist/backend/backend/src/index.js.map +1 -1
  28. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +18 -0
  29. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +24 -2
  31. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
  32. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts +102 -0
  33. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -0
  34. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +167 -0
  35. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -0
  36. package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts +21 -0
  37. package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/fission/fission-guard.service.js +30 -0
  39. package/dist/backend/backend/src/services/fission/fission-guard.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts +4 -0
  41. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js +8 -0
  43. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js.map +1 -1
  44. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts +79 -58
  45. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js +140 -65
  47. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js.map +1 -1
  48. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts +117 -0
  49. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts.map +1 -0
  50. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js +189 -0
  51. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js.map +1 -0
  52. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.d.ts.map +1 -1
  53. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js +1 -0
  54. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js.map +1 -1
  55. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js +2 -0
  57. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js.map +1 -1
  58. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.d.ts.map +1 -1
  59. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js +17 -1
  60. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js.map +1 -1
  61. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts +50 -0
  62. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
  63. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +71 -0
  64. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
  65. package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts +18 -0
  66. package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts.map +1 -1
  67. package/dist/backend/backend/src/services/reconciler/reconciler.service.js +75 -1
  68. package/dist/backend/backend/src/services/reconciler/reconciler.service.js.map +1 -1
  69. package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts +115 -0
  70. package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts.map +1 -1
  71. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js +189 -3
  72. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js.map +1 -1
  73. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
  74. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
  75. package/dist/backend/backend/src/services/session/pty/pty-session.js +61 -1
  76. package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
  77. package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
  78. package/dist/backend/backend/src/services/template/template.service.js +67 -2
  79. package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
  80. package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts +19 -1
  81. package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts.map +1 -1
  82. package/dist/backend/backend/src/services/v3/cascade-request-status.js +39 -2
  83. package/dist/backend/backend/src/services/v3/cascade-request-status.js.map +1 -1
  84. package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts +41 -0
  85. package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts.map +1 -1
  86. package/dist/backend/backend/src/services/v3/escalation-router.service.js +169 -0
  87. package/dist/backend/backend/src/services/v3/escalation-router.service.js.map +1 -1
  88. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts +4 -1
  89. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts.map +1 -1
  90. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js +21 -0
  91. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js.map +1 -1
  92. package/dist/backend/backend/src/types/intent-task.types.d.ts.map +1 -1
  93. package/dist/backend/backend/src/types/intent-task.types.js +8 -0
  94. package/dist/backend/backend/src/types/intent-task.types.js.map +1 -1
  95. package/dist/backend/backend/src/types/v2/request.types.d.ts +1 -1
  96. package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
  97. package/dist/backend/backend/src/types/v2/request.types.js +1 -0
  98. package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
  99. package/dist/cli/backend/src/constants.d.ts +12 -0
  100. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  101. package/dist/cli/backend/src/constants.js +12 -0
  102. package/dist/cli/backend/src/constants.js.map +1 -1
  103. package/package.json +9 -3
  104. package/packages/crewly-agent/README.md +27 -0
  105. package/packages/crewly-agent/bin/crewly-agent +33 -0
  106. package/packages/crewly-agent/package.json +39 -0
  107. package/packages/crewly-agent/src/cli.ts +168 -0
  108. package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +2355 -0
  109. package/packages/crewly-agent/src/runtime/agent-runner.service.ts +1827 -0
  110. package/packages/crewly-agent/src/runtime/agent-stream.service.test.ts +153 -0
  111. package/packages/crewly-agent/src/runtime/agent-stream.service.ts +225 -0
  112. package/packages/crewly-agent/src/runtime/agent-worker.test.ts +171 -0
  113. package/packages/crewly-agent/src/runtime/agent-worker.ts +193 -0
  114. package/packages/crewly-agent/src/runtime/api-client.ts +143 -0
  115. package/packages/crewly-agent/src/runtime/approval-queue.service.ts +307 -0
  116. package/packages/crewly-agent/src/runtime/audit-log.service.test.ts +208 -0
  117. package/packages/crewly-agent/src/runtime/audit-log.service.ts +332 -0
  118. package/packages/crewly-agent/src/runtime/audit-trail.service.test.ts +178 -0
  119. package/packages/crewly-agent/src/runtime/audit-trail.service.ts +151 -0
  120. package/packages/crewly-agent/src/runtime/auditor-tools.test.ts +274 -0
  121. package/packages/crewly-agent/src/runtime/auditor-tools.ts +311 -0
  122. package/packages/crewly-agent/src/runtime/cloud-config.ts +67 -0
  123. package/packages/crewly-agent/src/runtime/deepseek-sse-transform.test.ts +165 -0
  124. package/packages/crewly-agent/src/runtime/deepseek-sse-transform.ts +168 -0
  125. package/packages/crewly-agent/src/runtime/env-isolation.service.ts +246 -0
  126. package/packages/crewly-agent/src/runtime/in-process-log-buffer.test.ts +280 -0
  127. package/packages/crewly-agent/src/runtime/in-process-log-buffer.ts +317 -0
  128. package/packages/crewly-agent/src/runtime/index.ts +38 -0
  129. package/packages/crewly-agent/src/runtime/mcp-tool-bridge.test.ts +352 -0
  130. package/packages/crewly-agent/src/runtime/mcp-tool-bridge.ts +244 -0
  131. package/packages/crewly-agent/src/runtime/model-manager.test.ts +326 -0
  132. package/packages/crewly-agent/src/runtime/model-manager.ts +363 -0
  133. package/packages/crewly-agent/src/runtime/output-filter.service.ts +175 -0
  134. package/packages/crewly-agent/src/runtime/prompt-guard.service.ts +303 -0
  135. package/packages/crewly-agent/src/runtime/rate-limiter.test.ts +228 -0
  136. package/packages/crewly-agent/src/runtime/rate-limiter.ts +353 -0
  137. package/packages/crewly-agent/src/runtime/tool-registry.test.ts +2510 -0
  138. package/packages/crewly-agent/src/runtime/tool-registry.ts +2104 -0
  139. package/packages/crewly-agent/src/runtime/types.test.ts +519 -0
  140. package/packages/crewly-agent/src/runtime/types.ts +637 -0
  141. package/packages/crewly-agent/src/runtime/web-search.tool.test.ts +131 -0
  142. package/packages/crewly-agent/src/runtime/web-search.tool.ts +140 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * MCP Tool Bridge
3
+ *
4
+ * Converts external MCP server tools into Crewly Agent ToolDefinitions
5
+ * so they can be used alongside built-in tools during agent execution.
6
+ * All MCP-sourced tools default to 'sensitive' classification for audit
7
+ * purposes unless explicitly overridden.
8
+ *
9
+ * @module services/agent/crewly-agent/mcp-tool-bridge
10
+ */
11
+
12
+ import { z } from 'zod';
13
+ import type {
14
+ McpClientLike,
15
+ McpToolInfo,
16
+ McpServerConfig,
17
+ ToolDefinition,
18
+ ToolSensitivity,
19
+ } from './types.js';
20
+
21
+ /**
22
+ * Prefix applied to MCP tool names to avoid collisions with built-in tools.
23
+ */
24
+ export const MCP_TOOL_PREFIX = 'mcp_' as const;
25
+
26
+ /**
27
+ * Default sensitivity for MCP-sourced tools.
28
+ * External tools are classified as 'sensitive' because they interact
29
+ * with systems outside the agent's direct control.
30
+ */
31
+ export const MCP_DEFAULT_SENSITIVITY: ToolSensitivity = 'sensitive';
32
+
33
+ /**
34
+ * Configuration for MCP tool sensitivity overrides.
35
+ * Maps `serverName:toolName` or just `toolName` to a sensitivity level.
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * const overrides: McpSensitivityOverrides = {
40
+ * 'filesystem:read_file': 'safe',
41
+ * 'github:create_issue': 'sensitive',
42
+ * 'admin:drop_database': 'destructive',
43
+ * };
44
+ * ```
45
+ */
46
+ /**
47
+ * Convert a JSON Schema object from an MCP tool into a Zod schema.
48
+ *
49
+ * MCP tools declare their input using JSON Schema. The AI SDK expects
50
+ * Zod schemas. This function creates a z.object({}) passthrough schema
51
+ * that accepts any object — actual validation is done server-side by
52
+ * the MCP server itself.
53
+ *
54
+ * @param inputSchema - JSON Schema from the MCP tool definition
55
+ * @returns A Zod schema that passes through any object
56
+ */
57
+ export function jsonSchemaToZodPassthrough(
58
+ inputSchema: Record<string, unknown>,
59
+ ): z.ZodType {
60
+ // Extract property names from JSON Schema for documentation,
61
+ // but use a passthrough object since the MCP server validates inputs.
62
+ const properties = inputSchema.properties as Record<string, unknown> | undefined;
63
+ if (properties && typeof properties === 'object') {
64
+ const shape: Record<string, z.ZodType> = {};
65
+ for (const key of Object.keys(properties)) {
66
+ shape[key] = z.unknown().optional().describe(
67
+ String((properties[key] as Record<string, unknown>)?.description || key),
68
+ );
69
+ }
70
+ return z.object(shape).passthrough();
71
+ }
72
+ // Fallback: accept any object
73
+ return z.record(z.unknown());
74
+ }
75
+
76
+ /**
77
+ * Build the namespaced tool name for an MCP tool.
78
+ *
79
+ * Format: `mcp_{serverName}_{toolName}` to prevent collisions
80
+ * with built-in Crewly tools and tools from other MCP servers.
81
+ *
82
+ * @param serverName - Name of the MCP server
83
+ * @param toolName - Original tool name from the MCP server
84
+ * @returns Namespaced tool name
85
+ */
86
+ export function buildMcpToolName(serverName: string, toolName: string): string {
87
+ return `${MCP_TOOL_PREFIX}${serverName}_${toolName}`;
88
+ }
89
+
90
+ /**
91
+ * Resolve the sensitivity level for an MCP tool.
92
+ *
93
+ * Checks overrides in order of specificity:
94
+ * 1. `serverName:toolName` (most specific)
95
+ * 2. `toolName` (tool-level default)
96
+ * 3. Falls back to MCP_DEFAULT_SENSITIVITY ('sensitive')
97
+ *
98
+ * @param serverName - Name of the MCP server
99
+ * @param toolName - Original tool name
100
+ * @param overrides - Optional sensitivity overrides map
101
+ * @returns Resolved sensitivity level
102
+ */
103
+ export function resolveSensitivity(
104
+ serverName: string,
105
+ toolName: string,
106
+ overrides?: McpSensitivityOverrides,
107
+ ): ToolSensitivity {
108
+ if (!overrides) return MCP_DEFAULT_SENSITIVITY;
109
+
110
+ // Check server-specific override first
111
+ const serverSpecific = overrides[`${serverName}:${toolName}`];
112
+ if (serverSpecific) return serverSpecific;
113
+
114
+ // Check tool-level override
115
+ const toolLevel = overrides[toolName];
116
+ if (toolLevel) return toolLevel;
117
+
118
+ return MCP_DEFAULT_SENSITIVITY;
119
+ }
120
+
121
+ /**
122
+ * Convert a single MCP tool into a Crewly ToolDefinition.
123
+ *
124
+ * The resulting tool definition:
125
+ * - Has a namespaced name (`mcp_{server}_{tool}`)
126
+ * - Uses a passthrough Zod schema for input validation
127
+ * - Delegates execution to McpClientService.callTool()
128
+ * - Defaults to 'sensitive' classification for auditing
129
+ *
130
+ * @param mcpClient - The MCP client service for executing tool calls
131
+ * @param toolInfo - Tool metadata from the MCP server
132
+ * @param overrides - Optional sensitivity overrides
133
+ * @returns A ToolDefinition compatible with the Crewly Agent runtime
134
+ */
135
+ export function convertMcpTool(
136
+ mcpClient: McpClientLike,
137
+ toolInfo: McpToolInfo,
138
+ overrides?: McpSensitivityOverrides,
139
+ ): ToolDefinition {
140
+ const sensitivity = resolveSensitivity(
141
+ toolInfo.serverName,
142
+ toolInfo.name,
143
+ overrides,
144
+ );
145
+
146
+ return {
147
+ description: toolInfo.description
148
+ ? `[MCP:${toolInfo.serverName}] ${toolInfo.description}`
149
+ : `[MCP:${toolInfo.serverName}] ${toolInfo.name}`,
150
+ inputSchema: jsonSchemaToZodPassthrough(toolInfo.inputSchema),
151
+ sensitivity,
152
+ execute: async (args: Record<string, unknown>): Promise<unknown> => {
153
+ try {
154
+ const result = await mcpClient.callTool(
155
+ toolInfo.serverName,
156
+ toolInfo.name,
157
+ args,
158
+ );
159
+
160
+ // Flatten text content for simpler tool results
161
+ if (!result.isError && result.content.length === 1
162
+ && result.content[0].type === 'text' && 'text' in result.content[0]) {
163
+ return { success: true, text: result.content[0].text };
164
+ }
165
+
166
+ return {
167
+ success: !result.isError,
168
+ content: result.content,
169
+ ...(result.isError && { error: 'MCP tool returned an error' }),
170
+ };
171
+ } catch (error) {
172
+ return {
173
+ success: false,
174
+ error: `MCP tool call failed: ${error instanceof Error ? error.message : String(error)}`,
175
+ };
176
+ }
177
+ },
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Load all tools from connected MCP servers and convert them to ToolDefinitions.
183
+ *
184
+ * This is the primary entry point for integrating MCP tools into the agent
185
+ * runtime. It queries all connected MCP servers, converts their tools to
186
+ * the Crewly ToolDefinition format, and returns a map ready to merge with
187
+ * the built-in tool registry.
188
+ *
189
+ * @param mcpClient - The MCP client service with active server connections
190
+ * @param overrides - Optional sensitivity overrides
191
+ * @returns Map of namespaced tool name -> ToolDefinition
192
+ *
193
+ * @example
194
+ * ```typescript
195
+ * const mcpClient = new McpClientService();
196
+ * await mcpClient.connectServer('filesystem', config);
197
+ * const mcpTools = loadMcpTools(mcpClient);
198
+ * // mcpTools = { mcp_filesystem_read_file: {...}, mcp_filesystem_write_file: {...} }
199
+ * ```
200
+ */
201
+ export function loadMcpTools(
202
+ mcpClient: McpClientLike,
203
+ overrides?: McpSensitivityOverrides,
204
+ ): Record<string, ToolDefinition> {
205
+ const tools: Record<string, ToolDefinition> = {};
206
+ const mcpToolInfos = mcpClient.listTools();
207
+
208
+ for (const toolInfo of mcpToolInfos) {
209
+ const toolName = buildMcpToolName(toolInfo.serverName, toolInfo.name);
210
+ tools[toolName] = convertMcpTool(mcpClient, toolInfo, overrides);
211
+ }
212
+
213
+ return tools;
214
+ }
215
+
216
+ /**
217
+ * Connect to MCP servers and load their tools in one step.
218
+ *
219
+ * Convenience function that handles the full lifecycle:
220
+ * 1. Connects to all configured MCP servers (tolerates failures)
221
+ * 2. Loads and converts all available tools
222
+ * 3. Returns tools ready to merge into the agent's tool registry
223
+ *
224
+ * @param mcpClient - The MCP client service instance
225
+ * @param serverConfigs - Map of server name -> server configuration
226
+ * @param overrides - Optional sensitivity overrides
227
+ * @returns Object with loaded tools and any connection errors
228
+ *
229
+ * @example
230
+ * ```typescript
231
+ * const { tools, errors } = await connectAndLoadMcpTools(mcpClient, {
232
+ * filesystem: { command: 'npx', args: ['-y', '@anthropic/mcp-filesystem'] },
233
+ * });
234
+ * ```
235
+ */
236
+ export async function connectAndLoadMcpTools(
237
+ mcpClient: McpClientLike,
238
+ serverConfigs: Record<string, McpServerConfig>,
239
+ overrides?: McpSensitivityOverrides,
240
+ ): Promise<{ tools: Record<string, ToolDefinition>; errors: Map<string, Error> }> {
241
+ const errors = await mcpClient.connectAll(serverConfigs);
242
+ const tools = loadMcpTools(mcpClient, overrides);
243
+ return { tools, errors };
244
+ }
@@ -0,0 +1,326 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi, type Mocked, type MockInstance } from 'vitest';
2
+ import { ModelManager } from './model-manager.js';
3
+
4
+ // Mock provider SDKs so we don't make real API calls. vitest's vi.mock is
5
+ // hoisted to the top of the file, so it intercepts ModelManager's imports.
6
+ vi.mock('@ai-sdk/anthropic', () => ({
7
+ anthropic: vi.fn((modelId: string) => ({ provider: 'anthropic', modelId })),
8
+ }));
9
+
10
+ vi.mock('@ai-sdk/openai', () => ({
11
+ openai: vi.fn((modelId: string) => ({ provider: 'openai', modelId })),
12
+ // DeepSeek wires through createOpenAI({ baseURL }) — return an object with
13
+ // a .chat factory that mints the same { provider, modelId } stub shape so
14
+ // tests can assert routing without standing up a real provider.
15
+ createOpenAI: vi.fn(() => {
16
+ const chatFactory = (modelId: string) => ({ provider: 'openai.chat', modelId });
17
+ return Object.assign(chatFactory, { chat: chatFactory });
18
+ }),
19
+ }));
20
+
21
+ vi.mock('@ai-sdk/google', () => ({
22
+ google: vi.fn((modelId: string) => ({ provider: 'google', modelId })),
23
+ }));
24
+
25
+ vi.mock('ollama-ai-provider', () => ({
26
+ createOllama: vi.fn(() => {
27
+ const provider = vi.fn((modelId: string) => ({ provider: 'ollama', modelId }));
28
+ return provider;
29
+ }),
30
+ }));
31
+
32
+ // Note: the standalone ModelManager inlines its own getSettingsService that
33
+ // reads API keys from process.env directly (no settings file). Tests below
34
+ // just set/clear env vars to control key availability — no module mock needed.
35
+
36
+ describe('ModelManager', () => {
37
+ let manager: ModelManager;
38
+ const originalEnv = { ...process.env };
39
+
40
+ beforeEach(() => {
41
+ manager = new ModelManager();
42
+ });
43
+
44
+ afterEach(() => {
45
+ manager.clearCache();
46
+ process.env = { ...originalEnv };
47
+ });
48
+
49
+ describe('getModel', () => {
50
+ it('should create an Anthropic model', async () => {
51
+ const model = await manager.getModel({ provider: 'anthropic', modelId: 'claude-sonnet-4-20250514' });
52
+ expect(model).toBeDefined();
53
+ expect((model as any).modelId).toBe('claude-sonnet-4-20250514');
54
+ });
55
+
56
+ it('should create an OpenAI model', async () => {
57
+ const model = await manager.getModel({ provider: 'openai', modelId: 'gpt-4o' });
58
+ expect(model).toBeDefined();
59
+ expect((model as any).modelId).toBe('gpt-4o');
60
+ });
61
+
62
+ it('should create a Google model', async () => {
63
+ const model = await manager.getModel({ provider: 'google', modelId: 'gemini-2.0-flash' });
64
+ expect(model).toBeDefined();
65
+ expect((model as any).modelId).toBe('gemini-2.0-flash');
66
+ });
67
+
68
+ it('should create an Ollama model', async () => {
69
+ const model = await manager.getModel({ provider: 'ollama', modelId: 'llama3.3:70b' });
70
+ expect(model).toBeDefined();
71
+ expect((model as any).modelId).toBe('llama3.3:70b');
72
+ });
73
+
74
+ it('should create a DeepSeek model via the OpenAI-compatible API', async () => {
75
+ process.env.DEEPSEEK_API_KEY = 'test-deepseek-key';
76
+ const model = await manager.getModel({ provider: 'deepseek', modelId: 'deepseek-chat' });
77
+ expect(model).toBeDefined();
78
+ // The DeepSeek model is built on top of @ai-sdk/openai's createOpenAI
79
+ // pointed at https://api.deepseek.com/v1; we only assert that the model
80
+ // instance is produced without throwing — baseURL routing is exercised
81
+ // implicitly via the createOpenAI factory, which is covered by upstream
82
+ // tests in @ai-sdk/openai itself.
83
+ expect((model as any).modelId).toBe('deepseek-chat');
84
+ // Regression guard: must route via the .chat() factory (chat-completions
85
+ // path), not the bare function-call form (which @ai-sdk/openai routes to
86
+ // /responses — unsupported by DeepSeek). See PR #400 review M1 / M2.
87
+ expect((model as any).provider).toBe('openai.chat');
88
+ });
89
+
90
+ it('should use default config when none provided', async () => {
91
+ const model = await manager.getModel();
92
+ expect(model).toBeDefined();
93
+ });
94
+
95
+ it('should throw for unknown provider', async () => {
96
+ await expect(
97
+ manager.getModel({ provider: 'azure' as any, modelId: 'test' })
98
+ ).rejects.toThrow('Unknown model provider: azure');
99
+ });
100
+
101
+ it('should cache provider imports', async () => {
102
+ await manager.getModel({ provider: 'anthropic', modelId: 'model-1' });
103
+ await manager.getModel({ provider: 'anthropic', modelId: 'model-2' });
104
+ // Should only import once — the second call uses cached provider function
105
+ // We verify by checking the model is still created correctly
106
+ const model = await manager.getModel({ provider: 'anthropic', modelId: 'model-3' });
107
+ expect((model as any).modelId).toBe('model-3');
108
+ });
109
+ });
110
+
111
+ describe('getAvailableProviders', () => {
112
+ it('should report providers based on environment variables', async () => {
113
+ delete process.env.ANTHROPIC_API_KEY;
114
+ delete process.env.OPENAI_API_KEY;
115
+ delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
116
+ delete process.env.GEMINI_API_KEY;
117
+ delete process.env.DEEPSEEK_API_KEY;
118
+
119
+ const available = await manager.getAvailableProviders();
120
+
121
+ expect(available.anthropic).toBe(false);
122
+ expect(available.openai).toBe(false);
123
+ expect(available.google).toBe(false);
124
+ expect(available.ollama).toBe(true); // Ollama is always available (local)
125
+ expect(available.deepseek).toBe(false);
126
+ });
127
+
128
+ it('should detect DeepSeek API key from env', async () => {
129
+ process.env.DEEPSEEK_API_KEY = 'test-deepseek-key';
130
+ const available = await manager.getAvailableProviders();
131
+ expect(available.deepseek).toBe(true);
132
+ });
133
+
134
+ it('should detect Anthropic API key', async () => {
135
+ process.env.ANTHROPIC_API_KEY = 'test-key';
136
+ const available = await manager.getAvailableProviders();
137
+ expect(available.anthropic).toBe(true);
138
+ });
139
+
140
+ it('should detect Google via GEMINI_API_KEY fallback', async () => {
141
+ delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
142
+ process.env.GEMINI_API_KEY = 'test-key';
143
+ const available = await manager.getAvailableProviders();
144
+ expect(available.google).toBe(true);
145
+ });
146
+ });
147
+
148
+ describe('ensureApiKeyInEnv (settings override)', () => {
149
+ it('should override existing env var with settings key', async () => {
150
+ process.env.GOOGLE_GENERATIVE_AI_API_KEY = 'stale-free-key';
151
+ // getModel calls ensureApiKeyInEnv internally; the mock resolves from process.env
152
+ // But in production, settings.getApiKey returns the paid key which overwrites env
153
+ await manager.getModel({ provider: 'google', modelId: 'gemini-2.0-flash' });
154
+ // The key should now be whatever settings returned (in our mock: the env value itself,
155
+ // but the important thing is ensureApiKeyInEnv does NOT skip when env already set)
156
+ expect(process.env.GOOGLE_GENERATIVE_AI_API_KEY).toBeDefined();
157
+ });
158
+
159
+ it('should set env var when settings returns a key and env is empty', async () => {
160
+ delete process.env.ANTHROPIC_API_KEY;
161
+ process.env.ANTHROPIC_API_KEY = 'paid-key-from-settings';
162
+ await manager.getModel({ provider: 'anthropic', modelId: 'claude-sonnet-4-20250514' });
163
+ expect(process.env.ANTHROPIC_API_KEY).toBe('paid-key-from-settings');
164
+ });
165
+
166
+ /**
167
+ * B1: deepseek now flows through the settings service like every other
168
+ * cloud provider. This test pins down the new wiring — getModel for
169
+ * deepseek must trigger ensureApiKeyInEnv, which calls
170
+ * settingsService.getApiKey('deepseek', ...) and writes the result back
171
+ * to process.env.DEEPSEEK_API_KEY for the @ai-sdk/openai factory.
172
+ *
173
+ * Pre-B1, model-manager.ts short-circuited `if (provider === 'deepseek') return;`
174
+ * inside ensureApiKeyInEnv, meaning deepseek-via-settings was a dead path.
175
+ */
176
+ it('should resolve deepseek key via settings service and write to DEEPSEEK_API_KEY (B1)', async () => {
177
+ // Mock resolves from the env var, simulating either a settings entry or env fallback.
178
+ // Either way, the wired flow must end in process.env.DEEPSEEK_API_KEY being set.
179
+ process.env.DEEPSEEK_API_KEY = 'paid-deepseek-key';
180
+ await manager.getModel({ provider: 'deepseek', modelId: 'deepseek-chat' });
181
+ expect(process.env.DEEPSEEK_API_KEY).toBe('paid-deepseek-key');
182
+ });
183
+
184
+ it('should not throw when no deepseek key is configured (B1)', async () => {
185
+ delete process.env.DEEPSEEK_API_KEY;
186
+ // ensureApiKeyInEnv should silently no-op when settings returns undefined,
187
+ // letting the @ai-sdk/openai factory raise its own clear error if the
188
+ // model is actually invoked.
189
+ await expect(
190
+ manager.getModel({ provider: 'deepseek', modelId: 'deepseek-reasoner' })
191
+ ).resolves.toBeDefined();
192
+ expect(process.env.DEEPSEEK_API_KEY).toBeUndefined();
193
+ });
194
+ });
195
+
196
+ describe('clearCache', () => {
197
+ it('should clear the provider cache', async () => {
198
+ await manager.getModel({ provider: 'anthropic', modelId: 'test' });
199
+ manager.clearCache();
200
+ // After clear, the next call should re-import
201
+ const model = await manager.getModel({ provider: 'anthropic', modelId: 'test-2' });
202
+ expect((model as any).modelId).toBe('test-2');
203
+ });
204
+ });
205
+
206
+ /**
207
+ * I2 — DeepSeek-R1 reasoning_content extraction via custom fetch wrapper.
208
+ *
209
+ * The wrapper is installed when getModel('deepseek') is called. We exercise
210
+ * it by stubbing globalThis.fetch, calling the wrapper directly through
211
+ * an internal accessor, and asserting reasoning is buffered for consume.
212
+ *
213
+ * Note: we don't go through the real @ai-sdk/openai SDK here — that would
214
+ * require simulating the entire chat-completions request lifecycle. Instead
215
+ * we test the seam where reasoning extraction happens (the custom fetch),
216
+ * which is the unit boundary we own. Integration with @ai-sdk is exercised
217
+ * by the Round 3 smoke test (live DeepSeek call).
218
+ */
219
+ describe('DeepSeek custom fetch (I2 reasoning_content)', () => {
220
+ let originalFetch: typeof globalThis.fetch;
221
+
222
+ beforeEach(() => {
223
+ originalFetch = globalThis.fetch;
224
+ process.env.DEEPSEEK_API_KEY = 'test-deepseek-key';
225
+ });
226
+
227
+ afterEach(() => {
228
+ globalThis.fetch = originalFetch;
229
+ });
230
+
231
+ it('extracts reasoning_content from a streaming SSE response', async () => {
232
+ // Stub fetch to return a fake DeepSeek SSE response.
233
+ const sseBody = [
234
+ 'data: {"choices":[{"delta":{"reasoning_content":"chain-of-thought "}}]}\n\n',
235
+ 'data: {"choices":[{"delta":{"reasoning_content":"goes here"}}]}\n\n',
236
+ 'data: {"choices":[{"delta":{"content":"the answer"}}]}\n\n',
237
+ 'data: [DONE]\n\n',
238
+ ].join('');
239
+ const encoder = new TextEncoder();
240
+ const stream = new ReadableStream<Uint8Array>({
241
+ start(controller) {
242
+ controller.enqueue(encoder.encode(sseBody));
243
+ controller.close();
244
+ },
245
+ });
246
+ globalThis.fetch = vi.fn<any>().mockResolvedValue(
247
+ new Response(stream, {
248
+ status: 200,
249
+ headers: { 'content-type': 'text/event-stream' },
250
+ }),
251
+ );
252
+
253
+ // Trigger model creation (installs the custom fetch wrapper inside the provider).
254
+ await manager.getModel({ provider: 'deepseek', modelId: 'deepseek-reasoner' });
255
+
256
+ // Directly invoke the wrapper via the underlying provider invocation path.
257
+ // We can't easily reach `customFetch` without exporting it, so we instead
258
+ // call the known wrapper-creator method and exercise it.
259
+ const customFetch = (manager as any).makeDeepseekFetch();
260
+ const response: Response = await customFetch('https://api.deepseek.com/v1/chat/completions', {});
261
+
262
+ // Drain the consumer side (mimics what AI SDK does)
263
+ const reader = response.body!.getReader();
264
+ const decoder = new TextDecoder();
265
+ let drained = '';
266
+ while (true) {
267
+ const { done, value } = await reader.read();
268
+ if (done) break;
269
+ drained += decoder.decode(value, { stream: true });
270
+ }
271
+ expect(drained).toBe(sseBody); // passthrough must be byte-identical
272
+
273
+ const reasoning = await manager.consumeDeepseekReasoning();
274
+ expect(reasoning).toBe('chain-of-thought goes here');
275
+ });
276
+
277
+ it('returns null from consumeDeepseekReasoning when no fetch happened', async () => {
278
+ const reasoning = await manager.consumeDeepseekReasoning();
279
+ expect(reasoning).toBeNull();
280
+ });
281
+
282
+ it('passes through non-SSE responses unchanged', async () => {
283
+ // 4xx error with JSON body — wrapper must NOT touch it.
284
+ const errorBody = JSON.stringify({ error: 'bad request' });
285
+ globalThis.fetch = vi.fn<any>().mockResolvedValue(
286
+ new Response(errorBody, {
287
+ status: 400,
288
+ headers: { 'content-type': 'application/json' },
289
+ }),
290
+ );
291
+
292
+ const customFetch = (manager as any).makeDeepseekFetch();
293
+ const response: Response = await customFetch('https://api.deepseek.com/v1/chat/completions', {});
294
+ expect(response.status).toBe(400);
295
+ const text = await response.text();
296
+ expect(text).toBe(errorBody);
297
+ });
298
+
299
+ it('consumes reasoning and resets buffer to null on second call', async () => {
300
+ const sseBody =
301
+ 'data: {"choices":[{"delta":{"reasoning_content":"first"}}]}\n\ndata: [DONE]\n\n';
302
+ const encoder = new TextEncoder();
303
+ globalThis.fetch = vi.fn<any>().mockResolvedValue(
304
+ new Response(
305
+ new ReadableStream<Uint8Array>({
306
+ start(c) {
307
+ c.enqueue(encoder.encode(sseBody));
308
+ c.close();
309
+ },
310
+ }),
311
+ { status: 200, headers: { 'content-type': 'text/event-stream' } },
312
+ ),
313
+ );
314
+
315
+ const customFetch = (manager as any).makeDeepseekFetch();
316
+ const r1 = await customFetch('https://api.deepseek.com/v1/chat/completions', {});
317
+ // Drain to ensure the parser branch sees [DONE]
318
+ const reader = r1.body!.getReader();
319
+ while (!(await reader.read()).done) { /* drain */ }
320
+
321
+ expect(await manager.consumeDeepseekReasoning()).toBe('first');
322
+ // Second call: nothing new fetched, buffer was cleared
323
+ expect(await manager.consumeDeepseekReasoning()).toBeNull();
324
+ });
325
+ });
326
+ });