@open-mercato/ai-assistant 0.4.2-canary-c02407ff85
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.
- package/AGENTS.md +1090 -0
- package/README.md +607 -0
- package/build.mjs +92 -0
- package/dist/di.js +8 -0
- package/dist/di.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandFooter.js +80 -0
- package/dist/frontend/components/CommandPalette/CommandFooter.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandHeader.js +53 -0
- package/dist/frontend/components/CommandPalette/CommandHeader.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandInput.js +29 -0
- package/dist/frontend/components/CommandPalette/CommandInput.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandItem.js +92 -0
- package/dist/frontend/components/CommandPalette/CommandItem.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandPalette.js +244 -0
- package/dist/frontend/components/CommandPalette/CommandPalette.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandPaletteProvider.js +42 -0
- package/dist/frontend/components/CommandPalette/CommandPaletteProvider.js.map +7 -0
- package/dist/frontend/components/CommandPalette/CommandPaletteWrapper.js +18 -0
- package/dist/frontend/components/CommandPalette/CommandPaletteWrapper.js.map +7 -0
- package/dist/frontend/components/CommandPalette/DebugPanel.js +215 -0
- package/dist/frontend/components/CommandPalette/DebugPanel.js.map +7 -0
- package/dist/frontend/components/CommandPalette/MessageBubble.js +64 -0
- package/dist/frontend/components/CommandPalette/MessageBubble.js.map +7 -0
- package/dist/frontend/components/CommandPalette/ToolCallConfirmation.js +91 -0
- package/dist/frontend/components/CommandPalette/ToolCallConfirmation.js.map +7 -0
- package/dist/frontend/components/CommandPalette/ToolCallDisplay.js +47 -0
- package/dist/frontend/components/CommandPalette/ToolCallDisplay.js.map +7 -0
- package/dist/frontend/components/CommandPalette/ToolChatPage.js +74 -0
- package/dist/frontend/components/CommandPalette/ToolChatPage.js.map +7 -0
- package/dist/frontend/components/CommandPalette/index.js +28 -0
- package/dist/frontend/components/CommandPalette/index.js.map +7 -0
- package/dist/frontend/constants.js +41 -0
- package/dist/frontend/constants.js.map +7 -0
- package/dist/frontend/hooks/index.js +13 -0
- package/dist/frontend/hooks/index.js.map +7 -0
- package/dist/frontend/hooks/useCommandPalette.js +1094 -0
- package/dist/frontend/hooks/useCommandPalette.js.map +7 -0
- package/dist/frontend/hooks/useMcpTools.js +66 -0
- package/dist/frontend/hooks/useMcpTools.js.map +7 -0
- package/dist/frontend/hooks/usePageContext.js +48 -0
- package/dist/frontend/hooks/usePageContext.js.map +7 -0
- package/dist/frontend/hooks/useRecentActions.js +56 -0
- package/dist/frontend/hooks/useRecentActions.js.map +7 -0
- package/dist/frontend/hooks/useRecentTools.js +55 -0
- package/dist/frontend/hooks/useRecentTools.js.map +7 -0
- package/dist/frontend/index.js +35 -0
- package/dist/frontend/index.js.map +7 -0
- package/dist/frontend/types.js +1 -0
- package/dist/frontend/types.js.map +7 -0
- package/dist/frontend/utils/index.js +7 -0
- package/dist/frontend/utils/index.js.map +7 -0
- package/dist/frontend/utils/toolMatcher.js +95 -0
- package/dist/frontend/utils/toolMatcher.js.map +7 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/ai_assistant/acl.js +14 -0
- package/dist/modules/ai_assistant/acl.js.map +7 -0
- package/dist/modules/ai_assistant/api/chat/route.js +152 -0
- package/dist/modules/ai_assistant/api/chat/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/health/route.js +27 -0
- package/dist/modules/ai_assistant/api/health/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/route/route.js +123 -0
- package/dist/modules/ai_assistant/api/route/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/settings/route.js +60 -0
- package/dist/modules/ai_assistant/api/settings/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/tools/execute/route.js +58 -0
- package/dist/modules/ai_assistant/api/tools/execute/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/tools/route.js +48 -0
- package/dist/modules/ai_assistant/api/tools/route.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js +28 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/cli.js +192 -0
- package/dist/modules/ai_assistant/cli.js.map +7 -0
- package/dist/modules/ai_assistant/di.js +11 -0
- package/dist/modules/ai_assistant/di.js.map +7 -0
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +257 -0
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/index.js +13 -0
- package/dist/modules/ai_assistant/index.js.map +7 -0
- package/dist/modules/ai_assistant/lib/ai-sdk.js +13 -0
- package/dist/modules/ai_assistant/lib/ai-sdk.js.map +7 -0
- package/dist/modules/ai_assistant/lib/api-discovery-tools.js +249 -0
- package/dist/modules/ai_assistant/lib/api-discovery-tools.js.map +7 -0
- package/dist/modules/ai_assistant/lib/api-endpoint-index-config.js +177 -0
- package/dist/modules/ai_assistant/lib/api-endpoint-index-config.js.map +7 -0
- package/dist/modules/ai_assistant/lib/api-endpoint-index.js +210 -0
- package/dist/modules/ai_assistant/lib/api-endpoint-index.js.map +7 -0
- package/dist/modules/ai_assistant/lib/auth.js +87 -0
- package/dist/modules/ai_assistant/lib/auth.js.map +7 -0
- package/dist/modules/ai_assistant/lib/chat-config.js +117 -0
- package/dist/modules/ai_assistant/lib/chat-config.js.map +7 -0
- package/dist/modules/ai_assistant/lib/client-factory.js +60 -0
- package/dist/modules/ai_assistant/lib/client-factory.js.map +7 -0
- package/dist/modules/ai_assistant/lib/http-server.js +367 -0
- package/dist/modules/ai_assistant/lib/http-server.js.map +7 -0
- package/dist/modules/ai_assistant/lib/in-process-client.js +126 -0
- package/dist/modules/ai_assistant/lib/in-process-client.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-client.js +146 -0
- package/dist/modules/ai_assistant/lib/mcp-client.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js +283 -0
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-server-config.js +160 -0
- package/dist/modules/ai_assistant/lib/mcp-server-config.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-server.js +156 -0
- package/dist/modules/ai_assistant/lib/mcp-server.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-tool-adapter.js +44 -0
- package/dist/modules/ai_assistant/lib/mcp-tool-adapter.js.map +7 -0
- package/dist/modules/ai_assistant/lib/opencode-client.js +247 -0
- package/dist/modules/ai_assistant/lib/opencode-client.js.map +7 -0
- package/dist/modules/ai_assistant/lib/opencode-handlers.js +398 -0
- package/dist/modules/ai_assistant/lib/opencode-handlers.js.map +7 -0
- package/dist/modules/ai_assistant/lib/schema-utils.js +94 -0
- package/dist/modules/ai_assistant/lib/schema-utils.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-executor.js +55 -0
- package/dist/modules/ai_assistant/lib/tool-executor.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-index-config.js +125 -0
- package/dist/modules/ai_assistant/lib/tool-index-config.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-loader.js +88 -0
- package/dist/modules/ai_assistant/lib/tool-loader.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-registry.js +65 -0
- package/dist/modules/ai_assistant/lib/tool-registry.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-search.js +192 -0
- package/dist/modules/ai_assistant/lib/tool-search.js.map +7 -0
- package/dist/modules/ai_assistant/lib/types.js +1 -0
- package/dist/modules/ai_assistant/lib/types.js.map +7 -0
- package/package.json +108 -0
- package/src/di.ts +11 -0
- package/src/frontend/components/CommandPalette/CommandFooter.tsx +113 -0
- package/src/frontend/components/CommandPalette/CommandHeader.tsx +76 -0
- package/src/frontend/components/CommandPalette/CommandInput.tsx +50 -0
- package/src/frontend/components/CommandPalette/CommandItem.tsx +111 -0
- package/src/frontend/components/CommandPalette/CommandPalette.tsx +276 -0
- package/src/frontend/components/CommandPalette/CommandPaletteProvider.tsx +60 -0
- package/src/frontend/components/CommandPalette/CommandPaletteWrapper.tsx +21 -0
- package/src/frontend/components/CommandPalette/DebugPanel.tsx +257 -0
- package/src/frontend/components/CommandPalette/MessageBubble.tsx +73 -0
- package/src/frontend/components/CommandPalette/ToolCallConfirmation.tsx +130 -0
- package/src/frontend/components/CommandPalette/ToolCallDisplay.tsx +57 -0
- package/src/frontend/components/CommandPalette/ToolChatPage.tsx +125 -0
- package/src/frontend/components/CommandPalette/index.ts +14 -0
- package/src/frontend/constants.ts +35 -0
- package/src/frontend/hooks/index.ts +5 -0
- package/src/frontend/hooks/useCommandPalette.ts +1389 -0
- package/src/frontend/hooks/useMcpTools.ts +73 -0
- package/src/frontend/hooks/usePageContext.ts +61 -0
- package/src/frontend/hooks/useRecentActions.ts +64 -0
- package/src/frontend/hooks/useRecentTools.ts +69 -0
- package/src/frontend/index.ts +39 -0
- package/src/frontend/types.ts +260 -0
- package/src/frontend/utils/index.ts +1 -0
- package/src/frontend/utils/toolMatcher.ts +127 -0
- package/src/index.ts +92 -0
- package/src/modules/ai_assistant/acl.ts +10 -0
- package/src/modules/ai_assistant/api/chat/route.ts +213 -0
- package/src/modules/ai_assistant/api/health/route.ts +30 -0
- package/src/modules/ai_assistant/api/route/route.ts +149 -0
- package/src/modules/ai_assistant/api/settings/route.ts +73 -0
- package/src/modules/ai_assistant/api/tools/execute/route.ts +71 -0
- package/src/modules/ai_assistant/api/tools/route.ts +57 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/page.meta.ts +26 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/page.tsx +12 -0
- package/src/modules/ai_assistant/cli.ts +233 -0
- package/src/modules/ai_assistant/di.ts +9 -0
- package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +418 -0
- package/src/modules/ai_assistant/index.ts +11 -0
- package/src/modules/ai_assistant/lib/ai-sdk.ts +5 -0
- package/src/modules/ai_assistant/lib/api-discovery-tools.ts +334 -0
- package/src/modules/ai_assistant/lib/api-endpoint-index-config.ts +243 -0
- package/src/modules/ai_assistant/lib/api-endpoint-index.ts +381 -0
- package/src/modules/ai_assistant/lib/auth.ts +185 -0
- package/src/modules/ai_assistant/lib/chat-config.ts +152 -0
- package/src/modules/ai_assistant/lib/client-factory.ts +130 -0
- package/src/modules/ai_assistant/lib/http-server.ts +498 -0
- package/src/modules/ai_assistant/lib/in-process-client.ts +205 -0
- package/src/modules/ai_assistant/lib/mcp-client.ts +221 -0
- package/src/modules/ai_assistant/lib/mcp-dev-server.ts +373 -0
- package/src/modules/ai_assistant/lib/mcp-server-config.ts +287 -0
- package/src/modules/ai_assistant/lib/mcp-server.ts +214 -0
- package/src/modules/ai_assistant/lib/mcp-tool-adapter.ts +76 -0
- package/src/modules/ai_assistant/lib/opencode-client.ts +426 -0
- package/src/modules/ai_assistant/lib/opencode-handlers.ts +676 -0
- package/src/modules/ai_assistant/lib/schema-utils.ts +142 -0
- package/src/modules/ai_assistant/lib/tool-executor.ts +71 -0
- package/src/modules/ai_assistant/lib/tool-index-config.ts +178 -0
- package/src/modules/ai_assistant/lib/tool-loader.ts +149 -0
- package/src/modules/ai_assistant/lib/tool-registry.ts +114 -0
- package/src/modules/ai_assistant/lib/tool-search.ts +308 -0
- package/src/modules/ai_assistant/lib/types.ts +147 -0
- package/test-schema.ts +37 -0
- package/tsconfig.json +10 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Endpoint Index
|
|
3
|
+
*
|
|
4
|
+
* Parses OpenAPI spec and indexes endpoints for discovery via hybrid search.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { OpenApiDocument } from '@open-mercato/shared/lib/openapi'
|
|
8
|
+
import type { SearchService } from '@open-mercato/search/service'
|
|
9
|
+
import type { IndexableRecord } from '@open-mercato/search/types'
|
|
10
|
+
import {
|
|
11
|
+
API_ENDPOINT_ENTITY_ID,
|
|
12
|
+
GLOBAL_TENANT_ID,
|
|
13
|
+
API_ENDPOINT_SEARCH_CONFIG,
|
|
14
|
+
endpointToIndexableRecord,
|
|
15
|
+
computeEndpointsChecksum,
|
|
16
|
+
} from './api-endpoint-index-config'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Indexed API endpoint structure
|
|
20
|
+
*/
|
|
21
|
+
export interface ApiEndpoint {
|
|
22
|
+
id: string
|
|
23
|
+
operationId: string
|
|
24
|
+
method: string
|
|
25
|
+
path: string
|
|
26
|
+
summary: string
|
|
27
|
+
description: string
|
|
28
|
+
tags: string[]
|
|
29
|
+
requiredFeatures: string[]
|
|
30
|
+
parameters: ApiParameter[]
|
|
31
|
+
requestBodySchema: Record<string, unknown> | null
|
|
32
|
+
deprecated: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ApiParameter {
|
|
36
|
+
name: string
|
|
37
|
+
in: 'path' | 'query' | 'header'
|
|
38
|
+
required: boolean
|
|
39
|
+
type: string
|
|
40
|
+
description: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Entity type for API endpoints in search index
|
|
45
|
+
* @deprecated Use API_ENDPOINT_ENTITY_ID from api-endpoint-index-config.ts
|
|
46
|
+
*/
|
|
47
|
+
export const API_ENDPOINT_ENTITY = API_ENDPOINT_ENTITY_ID
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* In-memory cache of parsed endpoints (avoid re-parsing on each request)
|
|
51
|
+
*/
|
|
52
|
+
let endpointsCache: ApiEndpoint[] | null = null
|
|
53
|
+
let endpointsByOperationId: Map<string, ApiEndpoint> | null = null
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get all parsed API endpoints (cached)
|
|
57
|
+
*/
|
|
58
|
+
export async function getApiEndpoints(): Promise<ApiEndpoint[]> {
|
|
59
|
+
if (endpointsCache) {
|
|
60
|
+
return endpointsCache
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
endpointsCache = await parseApiEndpoints()
|
|
64
|
+
endpointsByOperationId = new Map(endpointsCache.map((e) => [e.operationId, e]))
|
|
65
|
+
|
|
66
|
+
return endpointsCache
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get endpoint by operationId
|
|
71
|
+
*/
|
|
72
|
+
export async function getEndpointByOperationId(operationId: string): Promise<ApiEndpoint | null> {
|
|
73
|
+
await getApiEndpoints() // Ensure cache is populated
|
|
74
|
+
return endpointsByOperationId?.get(operationId) ?? null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse OpenAPI spec into indexable endpoints
|
|
79
|
+
* Fetches the OpenAPI spec from the running app's /api/docs/openapi endpoint
|
|
80
|
+
*/
|
|
81
|
+
async function parseApiEndpoints(): Promise<ApiEndpoint[]> {
|
|
82
|
+
const baseUrl =
|
|
83
|
+
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
|
84
|
+
process.env.NEXT_PUBLIC_APP_URL ||
|
|
85
|
+
process.env.APP_URL ||
|
|
86
|
+
'http://localhost:3000'
|
|
87
|
+
|
|
88
|
+
const openApiUrl = `${baseUrl}/api/docs/openapi`
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
console.error(`[API Index] Fetching OpenAPI spec from ${openApiUrl}...`)
|
|
92
|
+
const response = await fetch(openApiUrl)
|
|
93
|
+
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
console.error(`[API Index] Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`)
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const doc = (await response.json()) as OpenApiDocument
|
|
100
|
+
console.error(`[API Index] Successfully fetched OpenAPI spec`)
|
|
101
|
+
return extractEndpoints(doc)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error('[API Index] Could not fetch OpenAPI spec:', error instanceof Error ? error.message : error)
|
|
104
|
+
console.error('[API Index] Make sure the app is running at', baseUrl)
|
|
105
|
+
return []
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract endpoints from OpenAPI document
|
|
111
|
+
*/
|
|
112
|
+
function extractEndpoints(doc: OpenApiDocument): ApiEndpoint[] {
|
|
113
|
+
const endpoints: ApiEndpoint[] = []
|
|
114
|
+
const validMethods = ['get', 'post', 'put', 'patch', 'delete']
|
|
115
|
+
|
|
116
|
+
if (!doc.paths) {
|
|
117
|
+
return endpoints
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const [path, pathItem] of Object.entries(doc.paths)) {
|
|
121
|
+
if (!pathItem || typeof pathItem !== 'object') continue
|
|
122
|
+
|
|
123
|
+
for (const [method, operation] of Object.entries(pathItem)) {
|
|
124
|
+
if (!validMethods.includes(method.toLowerCase())) continue
|
|
125
|
+
if (!operation || typeof operation !== 'object') continue
|
|
126
|
+
|
|
127
|
+
const op = operation as any
|
|
128
|
+
|
|
129
|
+
// Generate operationId if not present
|
|
130
|
+
const operationId = op.operationId || generateOperationId(path, method)
|
|
131
|
+
|
|
132
|
+
const endpoint: ApiEndpoint = {
|
|
133
|
+
id: operationId,
|
|
134
|
+
operationId,
|
|
135
|
+
method: method.toUpperCase(),
|
|
136
|
+
path,
|
|
137
|
+
summary: op.summary || '',
|
|
138
|
+
description: op.description || op.summary || `${method.toUpperCase()} ${path}`,
|
|
139
|
+
tags: op.tags || [],
|
|
140
|
+
requiredFeatures: op['x-require-features'] || [],
|
|
141
|
+
deprecated: op.deprecated || false,
|
|
142
|
+
parameters: extractParameters(op.parameters || []),
|
|
143
|
+
requestBodySchema: extractRequestBodySchema(op.requestBody, doc.components?.schemas),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
endpoints.push(endpoint)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.error(`[API Index] Parsed ${endpoints.length} endpoints from OpenAPI spec`)
|
|
151
|
+
return endpoints
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Generate operationId from path and method
|
|
156
|
+
*/
|
|
157
|
+
function generateOperationId(path: string, method: string): string {
|
|
158
|
+
const pathParts = path
|
|
159
|
+
.replace(/^\//, '')
|
|
160
|
+
.replace(/\{([^}]+)\}/g, 'by_$1')
|
|
161
|
+
.split('/')
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
.join('_')
|
|
164
|
+
|
|
165
|
+
return `${method.toLowerCase()}_${pathParts}`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Extract parameter info
|
|
170
|
+
*/
|
|
171
|
+
function extractParameters(params: any[]): ApiParameter[] {
|
|
172
|
+
return params
|
|
173
|
+
.filter((p) => p.in === 'path' || p.in === 'query')
|
|
174
|
+
.map((p) => ({
|
|
175
|
+
name: p.name,
|
|
176
|
+
in: p.in,
|
|
177
|
+
required: p.required ?? false,
|
|
178
|
+
type: p.schema?.type || 'string',
|
|
179
|
+
description: p.description || '',
|
|
180
|
+
}))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Extract request body schema (simplified)
|
|
185
|
+
*/
|
|
186
|
+
function extractRequestBodySchema(
|
|
187
|
+
requestBody: any,
|
|
188
|
+
schemas?: Record<string, any>
|
|
189
|
+
): Record<string, unknown> | null {
|
|
190
|
+
if (!requestBody?.content?.['application/json']?.schema) {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const schema = requestBody.content['application/json'].schema
|
|
195
|
+
|
|
196
|
+
// Resolve $ref if present
|
|
197
|
+
if (schema.$ref && schemas) {
|
|
198
|
+
const refPath = schema.$ref.replace('#/components/schemas/', '')
|
|
199
|
+
return schemas[refPath] || schema
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return schema
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Checksum from last indexing operation
|
|
207
|
+
*/
|
|
208
|
+
let lastIndexChecksum: string | null = null
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Index endpoints for search discovery using hybrid search strategies.
|
|
212
|
+
* Uses checksum-based change detection to avoid unnecessary re-indexing.
|
|
213
|
+
*
|
|
214
|
+
* @param searchService - The search service to use for indexing
|
|
215
|
+
* @param force - Force re-indexing even if checksum hasn't changed
|
|
216
|
+
* @returns Number of endpoints indexed
|
|
217
|
+
*/
|
|
218
|
+
export async function indexApiEndpoints(
|
|
219
|
+
searchService: SearchService,
|
|
220
|
+
force = false
|
|
221
|
+
): Promise<number> {
|
|
222
|
+
const endpoints = await getApiEndpoints()
|
|
223
|
+
|
|
224
|
+
if (endpoints.length === 0) {
|
|
225
|
+
console.error('[API Index] No endpoints to index')
|
|
226
|
+
return 0
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Compute checksum to detect changes
|
|
230
|
+
const checksum = computeEndpointsChecksum(
|
|
231
|
+
endpoints.map((e) => ({ operationId: e.operationId, method: e.method, path: e.path }))
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
// Skip if checksum matches and not forced
|
|
235
|
+
if (!force && lastIndexChecksum === checksum) {
|
|
236
|
+
console.error(`[API Index] Skipping indexing - ${endpoints.length} endpoints unchanged`)
|
|
237
|
+
return 0
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Convert to indexable records using the proper format
|
|
241
|
+
const records: IndexableRecord[] = endpoints.map((endpoint) =>
|
|
242
|
+
endpointToIndexableRecord(endpoint)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
console.error(`[API Index] Starting bulk index of ${records.length} endpoints...`)
|
|
247
|
+
// Bulk index using all available strategies (fulltext + vector)
|
|
248
|
+
// Use Promise.race with timeout to prevent hanging
|
|
249
|
+
const timeoutMs = 60000 // 60 second timeout
|
|
250
|
+
const indexPromise = searchService.bulkIndex(records)
|
|
251
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
252
|
+
setTimeout(() => reject(new Error(`Bulk index timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
await Promise.race([indexPromise, timeoutPromise])
|
|
256
|
+
lastIndexChecksum = checksum
|
|
257
|
+
console.error(`[API Index] Indexed ${records.length} API endpoints for hybrid search`)
|
|
258
|
+
return records.length
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error('[API Index] Failed to index endpoints:', error)
|
|
261
|
+
// Still return the count - some strategies may have succeeded
|
|
262
|
+
lastIndexChecksum = checksum
|
|
263
|
+
return records.length
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Build searchable content from endpoint
|
|
269
|
+
*/
|
|
270
|
+
function buildSearchableContent(endpoint: ApiEndpoint): string {
|
|
271
|
+
const parts = [
|
|
272
|
+
endpoint.operationId,
|
|
273
|
+
endpoint.method,
|
|
274
|
+
endpoint.path,
|
|
275
|
+
endpoint.summary,
|
|
276
|
+
endpoint.description,
|
|
277
|
+
...endpoint.tags,
|
|
278
|
+
...endpoint.parameters.map((p) => `${p.name} ${p.description}`),
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
return parts.filter(Boolean).join(' ')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Search endpoints using hybrid search (fulltext + vector).
|
|
286
|
+
* Falls back to in-memory search if search service is not available.
|
|
287
|
+
*/
|
|
288
|
+
export async function searchEndpoints(
|
|
289
|
+
searchService: SearchService | null,
|
|
290
|
+
query: string,
|
|
291
|
+
options: { limit?: number; method?: string } = {}
|
|
292
|
+
): Promise<ApiEndpoint[]> {
|
|
293
|
+
const { limit = API_ENDPOINT_SEARCH_CONFIG.defaultLimit, method } = options
|
|
294
|
+
|
|
295
|
+
// Ensure endpoints are loaded
|
|
296
|
+
await getApiEndpoints()
|
|
297
|
+
|
|
298
|
+
// Try hybrid search first if search service is available
|
|
299
|
+
if (searchService) {
|
|
300
|
+
try {
|
|
301
|
+
// Use hybrid search (fulltext + vector)
|
|
302
|
+
const results = await searchService.search(query, {
|
|
303
|
+
tenantId: GLOBAL_TENANT_ID,
|
|
304
|
+
organizationId: null,
|
|
305
|
+
entityTypes: [API_ENDPOINT_ENTITY_ID],
|
|
306
|
+
limit: limit * 2, // Get extra to account for filtering
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// Map search results back to ApiEndpoint objects
|
|
310
|
+
const endpoints: ApiEndpoint[] = []
|
|
311
|
+
for (const result of results) {
|
|
312
|
+
if (endpoints.length >= limit) break
|
|
313
|
+
|
|
314
|
+
const endpoint = endpointsByOperationId?.get(result.recordId)
|
|
315
|
+
if (endpoint) {
|
|
316
|
+
// Apply method filter if not handled by search
|
|
317
|
+
if (method && endpoint.method !== method.toUpperCase()) continue
|
|
318
|
+
endpoints.push(endpoint)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (endpoints.length > 0) {
|
|
323
|
+
return endpoints
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Fall through to fallback if no results from hybrid search
|
|
327
|
+
console.error('[API Index] No hybrid search results, falling back to in-memory search')
|
|
328
|
+
} catch (error) {
|
|
329
|
+
console.error('[API Index] Hybrid search failed, falling back to in-memory:', error)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Fallback: Simple in-memory text matching
|
|
334
|
+
return searchEndpointsFallback(query, { limit, method })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Fallback in-memory search when hybrid search is not available.
|
|
339
|
+
*/
|
|
340
|
+
function searchEndpointsFallback(
|
|
341
|
+
query: string,
|
|
342
|
+
options: { limit?: number; method?: string } = {}
|
|
343
|
+
): ApiEndpoint[] {
|
|
344
|
+
const { limit = API_ENDPOINT_SEARCH_CONFIG.defaultLimit, method } = options
|
|
345
|
+
|
|
346
|
+
if (!endpointsCache) {
|
|
347
|
+
return []
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const queryLower = query.toLowerCase()
|
|
351
|
+
const queryTerms = queryLower.split(/\s+/).filter(Boolean)
|
|
352
|
+
|
|
353
|
+
let matches = endpointsCache.filter((endpoint) => {
|
|
354
|
+
const content = buildSearchableContent(endpoint).toLowerCase()
|
|
355
|
+
return queryTerms.some((term) => content.includes(term))
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
// Filter by method if specified
|
|
359
|
+
if (method) {
|
|
360
|
+
matches = matches.filter((e) => e.method === method.toUpperCase())
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Sort by relevance (number of matching terms)
|
|
364
|
+
matches.sort((a, b) => {
|
|
365
|
+
const aContent = buildSearchableContent(a).toLowerCase()
|
|
366
|
+
const bContent = buildSearchableContent(b).toLowerCase()
|
|
367
|
+
const aScore = queryTerms.filter((t) => aContent.includes(t)).length
|
|
368
|
+
const bScore = queryTerms.filter((t) => bContent.includes(t)).length
|
|
369
|
+
return bScore - aScore
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
return matches.slice(0, limit)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Clear endpoint cache (for testing)
|
|
377
|
+
*/
|
|
378
|
+
export function clearEndpointCache(): void {
|
|
379
|
+
endpointsCache = null
|
|
380
|
+
endpointsByOperationId = null
|
|
381
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Successful authentication result.
|
|
6
|
+
*/
|
|
7
|
+
export type McpAuthSuccess = {
|
|
8
|
+
success: true
|
|
9
|
+
keyId: string
|
|
10
|
+
keyName: string
|
|
11
|
+
tenantId: string | null
|
|
12
|
+
organizationId: string | null
|
|
13
|
+
userId: string
|
|
14
|
+
features: string[]
|
|
15
|
+
isSuperAdmin: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Failed authentication result.
|
|
20
|
+
*/
|
|
21
|
+
export type McpAuthFailure = {
|
|
22
|
+
success: false
|
|
23
|
+
error: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Result from MCP authentication.
|
|
28
|
+
*/
|
|
29
|
+
export type McpAuthResult = McpAuthSuccess | McpAuthFailure
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Authenticate an MCP request using an API key.
|
|
33
|
+
*
|
|
34
|
+
* This function validates the API key secret and loads the associated
|
|
35
|
+
* ACL (features, organizations, super admin status) from the key's roles.
|
|
36
|
+
*
|
|
37
|
+
* @param apiKeySecret - The full API key secret (e.g., 'omk_xxxx.yyyy...')
|
|
38
|
+
* @param container - Awilix DI container with 'em' and 'rbacService'
|
|
39
|
+
* @returns Authentication result with user context or error
|
|
40
|
+
*/
|
|
41
|
+
export async function authenticateMcpRequest(
|
|
42
|
+
apiKeySecret: string,
|
|
43
|
+
container: AwilixContainer
|
|
44
|
+
): Promise<McpAuthResult> {
|
|
45
|
+
if (!apiKeySecret || typeof apiKeySecret !== 'string') {
|
|
46
|
+
return { success: false, error: 'API key is required' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const trimmedSecret = apiKeySecret.trim()
|
|
50
|
+
if (!trimmedSecret) {
|
|
51
|
+
return { success: false, error: 'API key is required' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!trimmedSecret.startsWith('omk_')) {
|
|
55
|
+
return { success: false, error: 'Invalid API key format' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const em = container.resolve('em') as EntityManager
|
|
60
|
+
|
|
61
|
+
const { findApiKeyBySecret } = await import(
|
|
62
|
+
'@open-mercato/core/modules/api_keys/services/apiKeyService'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const apiKey = await findApiKeyBySecret(em, trimmedSecret)
|
|
66
|
+
|
|
67
|
+
if (!apiKey) {
|
|
68
|
+
return { success: false, error: 'Invalid or expired API key' }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const userId = `api_key:${apiKey.id}`
|
|
72
|
+
|
|
73
|
+
const rbacService = container.resolve('rbacService') as {
|
|
74
|
+
loadAcl: (
|
|
75
|
+
userId: string,
|
|
76
|
+
scope: { tenantId: string | null; organizationId: string | null }
|
|
77
|
+
) => Promise<{
|
|
78
|
+
isSuperAdmin: boolean
|
|
79
|
+
features: string[]
|
|
80
|
+
organizations: string[] | null
|
|
81
|
+
}>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const acl = await rbacService.loadAcl(userId, {
|
|
85
|
+
tenantId: apiKey.tenantId ?? null,
|
|
86
|
+
organizationId: apiKey.organizationId ?? null,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
apiKey.lastUsedAt = new Date()
|
|
91
|
+
await em.persistAndFlush(apiKey)
|
|
92
|
+
} catch {
|
|
93
|
+
// Best-effort update; ignore write failures
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
success: true,
|
|
98
|
+
keyId: apiKey.id,
|
|
99
|
+
keyName: apiKey.name,
|
|
100
|
+
tenantId: apiKey.tenantId ?? null,
|
|
101
|
+
organizationId: apiKey.organizationId ?? null,
|
|
102
|
+
userId,
|
|
103
|
+
features: acl.features,
|
|
104
|
+
isSuperAdmin: acl.isSuperAdmin,
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
108
|
+
console.error('[MCP Auth] Authentication failed:', message)
|
|
109
|
+
return { success: false, error: 'Authentication failed' }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if user has the required features for a resource.
|
|
115
|
+
*
|
|
116
|
+
* Supports:
|
|
117
|
+
* - Super admin bypass (always returns true)
|
|
118
|
+
* - Direct feature match (e.g., 'customers.view')
|
|
119
|
+
* - Global wildcard ('*' grants all features)
|
|
120
|
+
* - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view')
|
|
121
|
+
*
|
|
122
|
+
* @param requiredFeatures - List of features required for access
|
|
123
|
+
* @param userFeatures - List of features the user has
|
|
124
|
+
* @param isSuperAdmin - Whether the user is a super admin
|
|
125
|
+
* @returns True if user has access
|
|
126
|
+
*/
|
|
127
|
+
export function hasRequiredFeatures(
|
|
128
|
+
requiredFeatures: string[] | undefined,
|
|
129
|
+
userFeatures: string[],
|
|
130
|
+
isSuperAdmin: boolean
|
|
131
|
+
): boolean {
|
|
132
|
+
if (isSuperAdmin) return true
|
|
133
|
+
if (!requiredFeatures?.length) return true
|
|
134
|
+
|
|
135
|
+
return requiredFeatures.every((required) => {
|
|
136
|
+
if (userFeatures.includes(required)) return true
|
|
137
|
+
if (userFeatures.includes('*')) return true
|
|
138
|
+
|
|
139
|
+
// Check wildcard patterns (e.g., 'customers.*' grants 'customers.people.view')
|
|
140
|
+
return userFeatures.some((feature) => {
|
|
141
|
+
if (feature.endsWith('.*')) {
|
|
142
|
+
const prefix = feature.slice(0, -2)
|
|
143
|
+
return required.startsWith(prefix + '.')
|
|
144
|
+
}
|
|
145
|
+
return false
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract API key from HTTP request headers.
|
|
152
|
+
*
|
|
153
|
+
* Supports two header formats:
|
|
154
|
+
* - x-api-key: <secret>
|
|
155
|
+
* - Authorization: ApiKey <secret>
|
|
156
|
+
*
|
|
157
|
+
* @param headers - Request headers (Map, Headers, or plain object)
|
|
158
|
+
* @returns The API key secret or null if not found
|
|
159
|
+
*/
|
|
160
|
+
export function extractApiKeyFromHeaders(
|
|
161
|
+
headers: Headers | Map<string, string> | Record<string, string | undefined>
|
|
162
|
+
): string | null {
|
|
163
|
+
const getHeader = (name: string): string | null => {
|
|
164
|
+
if (headers instanceof Headers) {
|
|
165
|
+
return headers.get(name)
|
|
166
|
+
}
|
|
167
|
+
if (headers instanceof Map) {
|
|
168
|
+
return headers.get(name) ?? null
|
|
169
|
+
}
|
|
170
|
+
const value = headers[name] ?? headers[name.toLowerCase()]
|
|
171
|
+
return typeof value === 'string' ? value : null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const xApiKey = getHeader('x-api-key')?.trim()
|
|
175
|
+
if (xApiKey) {
|
|
176
|
+
return xApiKey
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const authHeader = getHeader('authorization')?.trim()
|
|
180
|
+
if (authHeader && authHeader.toLowerCase().startsWith('apikey ')) {
|
|
181
|
+
return authHeader.slice(7).trim()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
2
|
+
|
|
3
|
+
// Types
|
|
4
|
+
export type ChatProviderId = 'openai' | 'anthropic' | 'google'
|
|
5
|
+
|
|
6
|
+
export type ChatModelInfo = {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
contextWindow: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ChatProviderInfo = {
|
|
13
|
+
name: string
|
|
14
|
+
envKeyRequired: string
|
|
15
|
+
defaultModel: string
|
|
16
|
+
models: ChatModelInfo[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ChatProviderConfig = {
|
|
20
|
+
providerId: ChatProviderId
|
|
21
|
+
model: string
|
|
22
|
+
updatedAt: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Constants
|
|
26
|
+
export const CHAT_CONFIG_KEY = 'chat_provider'
|
|
27
|
+
|
|
28
|
+
export const CHAT_PROVIDERS: Record<ChatProviderId, ChatProviderInfo> = {
|
|
29
|
+
openai: {
|
|
30
|
+
name: 'OpenAI',
|
|
31
|
+
envKeyRequired: 'OPENAI_API_KEY',
|
|
32
|
+
defaultModel: 'gpt-4o',
|
|
33
|
+
models: [
|
|
34
|
+
{ id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000 },
|
|
35
|
+
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', contextWindow: 128000 },
|
|
36
|
+
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo', contextWindow: 128000 },
|
|
37
|
+
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', contextWindow: 16385 },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
anthropic: {
|
|
41
|
+
name: 'Anthropic',
|
|
42
|
+
envKeyRequired: 'ANTHROPIC_API_KEY',
|
|
43
|
+
defaultModel: 'claude-sonnet-4-5-20250929',
|
|
44
|
+
models: [
|
|
45
|
+
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', contextWindow: 200000 },
|
|
46
|
+
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', contextWindow: 200000 },
|
|
47
|
+
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', contextWindow: 200000 },
|
|
48
|
+
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku', contextWindow: 200000 },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
google: {
|
|
52
|
+
name: 'Google',
|
|
53
|
+
envKeyRequired: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
|
54
|
+
defaultModel: 'gemini-1.5-pro',
|
|
55
|
+
models: [
|
|
56
|
+
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', contextWindow: 2097152 },
|
|
57
|
+
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', contextWindow: 1048576 },
|
|
58
|
+
{ id: 'gemini-pro', name: 'Gemini Pro', contextWindow: 32000 },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const DEFAULT_CHAT_CONFIG: Omit<ChatProviderConfig, 'updatedAt'> = {
|
|
64
|
+
providerId: 'openai',
|
|
65
|
+
model: 'gpt-4o',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Provider configuration checks
|
|
69
|
+
export function isProviderConfigured(providerId: ChatProviderId): boolean {
|
|
70
|
+
switch (providerId) {
|
|
71
|
+
case 'openai':
|
|
72
|
+
return Boolean(process.env.OPENAI_API_KEY?.trim())
|
|
73
|
+
case 'anthropic':
|
|
74
|
+
return Boolean(process.env.ANTHROPIC_API_KEY?.trim())
|
|
75
|
+
case 'google':
|
|
76
|
+
return Boolean(process.env.GOOGLE_GENERATIVE_AI_API_KEY?.trim())
|
|
77
|
+
default:
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getConfiguredProviders(): ChatProviderId[] {
|
|
83
|
+
const providers: ChatProviderId[] = []
|
|
84
|
+
const allProviders: ChatProviderId[] = ['openai', 'anthropic', 'google']
|
|
85
|
+
for (const providerId of allProviders) {
|
|
86
|
+
if (isProviderConfigured(providerId)) {
|
|
87
|
+
providers.push(providerId)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return providers
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Config resolution
|
|
94
|
+
type Resolver = {
|
|
95
|
+
resolve: <T = unknown>(name: string) => T
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function resolveChatConfig(
|
|
99
|
+
resolver: Resolver,
|
|
100
|
+
options?: { defaultValue?: ChatProviderConfig | null }
|
|
101
|
+
): Promise<ChatProviderConfig | null> {
|
|
102
|
+
const fallback = options?.defaultValue ?? null
|
|
103
|
+
let service: ModuleConfigService
|
|
104
|
+
try {
|
|
105
|
+
service = resolver.resolve<ModuleConfigService>('moduleConfigService')
|
|
106
|
+
} catch {
|
|
107
|
+
return fallback
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const value = await service.getValue<ChatProviderConfig>('ai_assistant', CHAT_CONFIG_KEY, { defaultValue: fallback })
|
|
111
|
+
return value
|
|
112
|
+
} catch {
|
|
113
|
+
return fallback
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function saveChatConfig(
|
|
118
|
+
resolver: Resolver,
|
|
119
|
+
config: Omit<ChatProviderConfig, 'updatedAt'>
|
|
120
|
+
): Promise<ChatProviderConfig> {
|
|
121
|
+
let service: ModuleConfigService
|
|
122
|
+
try {
|
|
123
|
+
service = resolver.resolve<ModuleConfigService>('moduleConfigService')
|
|
124
|
+
} catch {
|
|
125
|
+
throw new Error('Configuration service unavailable')
|
|
126
|
+
}
|
|
127
|
+
const fullConfig: ChatProviderConfig = {
|
|
128
|
+
...config,
|
|
129
|
+
updatedAt: new Date().toISOString(),
|
|
130
|
+
}
|
|
131
|
+
await service.setValue('ai_assistant', CHAT_CONFIG_KEY, fullConfig)
|
|
132
|
+
return fullConfig
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createDefaultConfig(): ChatProviderConfig {
|
|
136
|
+
return { ...DEFAULT_CHAT_CONFIG, updatedAt: new Date().toISOString() }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Get model info by ID
|
|
140
|
+
export function getModelInfo(providerId: ChatProviderId, modelId: string): ChatModelInfo | null {
|
|
141
|
+
const provider = CHAT_PROVIDERS[providerId]
|
|
142
|
+
if (!provider) return null
|
|
143
|
+
return provider.models.find((m) => m.id === modelId) ?? null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Format context window for display
|
|
147
|
+
export function formatContextWindow(contextWindow: number): string {
|
|
148
|
+
if (contextWindow >= 1000000) {
|
|
149
|
+
return `${(contextWindow / 1000000).toFixed(1)}M`
|
|
150
|
+
}
|
|
151
|
+
return `${(contextWindow / 1000).toFixed(0)}K`
|
|
152
|
+
}
|