@miphamai/cli 0.36.1 → 0.36.2
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/package.json +1 -1
- package/src/core/engine.ts +77 -4
- package/src/providers/openai-compat.ts +30 -15
- package/src/providers/registry.ts +40 -0
- package/src/shared/types.ts +1 -0
- package/src/ui/app.tsx +11 -0
- package/src/ui/commands.ts +24 -6
package/package.json
CHANGED
package/src/core/engine.ts
CHANGED
|
@@ -446,13 +446,12 @@ export class QueryEngine {
|
|
|
446
446
|
|
|
447
447
|
// Stream model response
|
|
448
448
|
try {
|
|
449
|
-
for await (const chunk of this.
|
|
450
|
-
model: this.registry.getActiveModel(),
|
|
449
|
+
for await (const chunk of this.chatWithFallback(
|
|
451
450
|
messages,
|
|
452
451
|
systemPrompt,
|
|
453
|
-
|
|
452
|
+
toolDefs.length > 0 ? toolDefs : undefined,
|
|
454
453
|
signal,
|
|
455
|
-
|
|
454
|
+
)) {
|
|
456
455
|
yield chunk
|
|
457
456
|
|
|
458
457
|
if (chunk.type === 'error') {
|
|
@@ -1144,6 +1143,80 @@ export class QueryEngine {
|
|
|
1144
1143
|
return this.registry
|
|
1145
1144
|
}
|
|
1146
1145
|
|
|
1146
|
+
/**
|
|
1147
|
+
* Stream a chat response with graceful provider fallback (v2.1.229 alignment).
|
|
1148
|
+
*
|
|
1149
|
+
* On a connection/availability failure from the active provider (thrown
|
|
1150
|
+
* network error or an error chunk), switches to the configured default
|
|
1151
|
+
* provider and retries once, yielding a `warning` chunk so the UI can tell
|
|
1152
|
+
* the user the provider degraded. Abort errors propagate untouched.
|
|
1153
|
+
*/
|
|
1154
|
+
private async *chatWithFallback(
|
|
1155
|
+
messages: import('../shared/types').Message[],
|
|
1156
|
+
systemPrompt: string,
|
|
1157
|
+
toolDefs: Record<string, unknown>[] | undefined,
|
|
1158
|
+
signal?: AbortSignal,
|
|
1159
|
+
): AsyncGenerator<StreamChunk> {
|
|
1160
|
+
const activeId = this.registry.getActive().config.id
|
|
1161
|
+
const defaultId = this.registry.getDefaultProviderId()
|
|
1162
|
+
|
|
1163
|
+
// ── Attempt 1: active provider ──
|
|
1164
|
+
let failure: string | null = null
|
|
1165
|
+
try {
|
|
1166
|
+
for await (const chunk of this.registry.chat({
|
|
1167
|
+
model: this.registry.getActiveModel(),
|
|
1168
|
+
messages,
|
|
1169
|
+
systemPrompt,
|
|
1170
|
+
tools: toolDefs,
|
|
1171
|
+
signal,
|
|
1172
|
+
})) {
|
|
1173
|
+
if (chunk.type === 'error') {
|
|
1174
|
+
failure = chunk.error ?? 'Unknown error'
|
|
1175
|
+
break
|
|
1176
|
+
}
|
|
1177
|
+
yield chunk
|
|
1178
|
+
}
|
|
1179
|
+
if (failure === null) return
|
|
1180
|
+
} catch (err) {
|
|
1181
|
+
if (isAbortError(err)) throw err
|
|
1182
|
+
failure = String(err)
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ── Fallback: configured default provider, once ──
|
|
1186
|
+
if (!defaultId || defaultId === activeId || !this.registry.get(defaultId)) {
|
|
1187
|
+
yield { type: 'error', error: failure }
|
|
1188
|
+
return
|
|
1189
|
+
}
|
|
1190
|
+
const fallbackModel = this.registry
|
|
1191
|
+
.get(defaultId)!
|
|
1192
|
+
.config.models.find((m) => m.status === 'active')?.id
|
|
1193
|
+
if (!fallbackModel) {
|
|
1194
|
+
yield { type: 'error', error: failure }
|
|
1195
|
+
return
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
this.registry.switchProvider(defaultId, fallbackModel)
|
|
1199
|
+
yield {
|
|
1200
|
+
type: 'warning',
|
|
1201
|
+
content: `${activeId} unreachable — degraded to ${defaultId} (${fallbackModel})`,
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
try {
|
|
1205
|
+
for await (const chunk of this.registry.chat({
|
|
1206
|
+
model: fallbackModel,
|
|
1207
|
+
messages,
|
|
1208
|
+
systemPrompt,
|
|
1209
|
+
tools: toolDefs,
|
|
1210
|
+
signal,
|
|
1211
|
+
})) {
|
|
1212
|
+
yield chunk
|
|
1213
|
+
}
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
if (isAbortError(err)) throw err
|
|
1216
|
+
yield { type: 'error', error: String(err) }
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1147
1220
|
getTools(): Map<string, ToolDefinition> {
|
|
1148
1221
|
return this.tools
|
|
1149
1222
|
}
|
|
@@ -8,9 +8,13 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
8
8
|
constructor(public config: ProviderConfig) {}
|
|
9
9
|
|
|
10
10
|
async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
|
|
11
|
-
// Accept both baseUrl and baseURL (common YAML typo)
|
|
11
|
+
// Accept both baseUrl and baseURL (common YAML typo); resolve env templates
|
|
12
12
|
const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
|
|
13
|
-
const baseUrl =
|
|
13
|
+
const baseUrl =
|
|
14
|
+
this.resolveEnvTemplate(
|
|
15
|
+
rawBase?.replace(/\/+$/, '') || '',
|
|
16
|
+
'OpenAI-compatible provider: baseUrl',
|
|
17
|
+
) || 'https://api.openai.com/v1'
|
|
14
18
|
const apiKey = this.resolveApiKey(this.config.apiKey)
|
|
15
19
|
|
|
16
20
|
const body = {
|
|
@@ -224,11 +228,18 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
224
228
|
async healthCheck(): Promise<boolean> {
|
|
225
229
|
try {
|
|
226
230
|
const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
|
|
227
|
-
const baseUrl =
|
|
231
|
+
const baseUrl =
|
|
232
|
+
this.resolveEnvTemplate(
|
|
233
|
+
rawBase?.replace(/\/+$/, '') || '',
|
|
234
|
+
'OpenAI-compatible provider: baseUrl',
|
|
235
|
+
) || 'https://api.openai.com/v1'
|
|
228
236
|
const apiKey = this.resolveApiKey(this.config.apiKey)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
237
|
+
// 5s timeout so a down endpoint doesn't hang health checks (v2.1.229 alignment)
|
|
238
|
+
const res = await fetchWithRetry(
|
|
239
|
+
`${baseUrl}/models`,
|
|
240
|
+
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
|
241
|
+
{ timeout: 5000, maxRetries: 0 },
|
|
242
|
+
)
|
|
232
243
|
return res.ok
|
|
233
244
|
} catch {
|
|
234
245
|
return false
|
|
@@ -366,21 +377,25 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
366
377
|
}
|
|
367
378
|
}
|
|
368
379
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
let match =
|
|
372
|
-
if (!match) match =
|
|
380
|
+
/** Resolve a `${VAR}` / `$VAR` template against the environment. */
|
|
381
|
+
private resolveEnvTemplate(value: string, warnPrefix: string): string {
|
|
382
|
+
let match = value.match(/^\$\{(.+)\}$/)
|
|
383
|
+
if (!match) match = value.match(/^\$([A-Z_][A-Z0-9_]*)$/)
|
|
373
384
|
if (match?.[1]) {
|
|
374
385
|
const varName = match[1]
|
|
375
|
-
const
|
|
376
|
-
if (!
|
|
386
|
+
const envValue = process.env[varName]
|
|
387
|
+
if (!envValue) {
|
|
377
388
|
process.stderr.write(
|
|
378
|
-
`⚠
|
|
389
|
+
`⚠ ${warnPrefix} references $${varName} but that environment variable is not set\n`,
|
|
379
390
|
)
|
|
380
391
|
return ''
|
|
381
392
|
}
|
|
382
|
-
return
|
|
393
|
+
return envValue
|
|
383
394
|
}
|
|
384
|
-
return
|
|
395
|
+
return value
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private resolveApiKey(keyTemplate: string): string {
|
|
399
|
+
return this.resolveEnvTemplate(keyTemplate, 'OpenAI-compatible provider: apiKey')
|
|
385
400
|
}
|
|
386
401
|
}
|
|
@@ -21,10 +21,24 @@ export class ProviderRegistry {
|
|
|
21
21
|
private providers = new Map<string, ProviderInstance>()
|
|
22
22
|
private activeProviderId: string
|
|
23
23
|
private activeModelId: string
|
|
24
|
+
private defaultProviderId: string
|
|
25
|
+
private defaultModelId: string
|
|
24
26
|
|
|
25
27
|
constructor(providers: ProviderConfig[], defaultProvider: string, defaultModel: string) {
|
|
26
28
|
this.activeProviderId = defaultProvider
|
|
27
29
|
this.activeModelId = defaultModel
|
|
30
|
+
this.defaultProviderId = defaultProvider
|
|
31
|
+
this.defaultModelId = defaultModel
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The configured default provider id (used for fallback routing). */
|
|
35
|
+
getDefaultProviderId(): string {
|
|
36
|
+
return this.defaultProviderId
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The configured default model id. */
|
|
40
|
+
getDefaultModelId(): string {
|
|
41
|
+
return this.defaultModelId
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
register(id: string, instance: ProviderInstance): void {
|
|
@@ -72,6 +86,32 @@ export class ProviderRegistry {
|
|
|
72
86
|
return undefined
|
|
73
87
|
}
|
|
74
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Check a single provider's health. Returns undefined if not registered,
|
|
91
|
+
* otherwise the provider's healthCheck() result. Never throws.
|
|
92
|
+
*/
|
|
93
|
+
async healthStatus(id: string): Promise<boolean | undefined> {
|
|
94
|
+
const provider = this.providers.get(id)
|
|
95
|
+
if (!provider) return undefined
|
|
96
|
+
try {
|
|
97
|
+
return await provider.healthCheck()
|
|
98
|
+
} catch {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Health of all registered providers, checked concurrently.
|
|
105
|
+
* Returns a Map of provider id → reachable boolean.
|
|
106
|
+
*/
|
|
107
|
+
async healthMap(): Promise<Map<string, boolean>> {
|
|
108
|
+
const ids = this.listIds()
|
|
109
|
+
const results = await Promise.all(
|
|
110
|
+
ids.map(async (id) => [id, (await this.healthStatus(id)) ?? false] as const),
|
|
111
|
+
)
|
|
112
|
+
return new Map(results)
|
|
113
|
+
}
|
|
114
|
+
|
|
75
115
|
async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
|
|
76
116
|
const provider = this.getActive()
|
|
77
117
|
yield* provider.chat({ ...req, model: req.model || this.activeModelId })
|
package/src/shared/types.ts
CHANGED
package/src/ui/app.tsx
CHANGED
|
@@ -658,6 +658,17 @@ export function App({
|
|
|
658
658
|
])
|
|
659
659
|
}
|
|
660
660
|
|
|
661
|
+
if (chunk.type === 'warning' && chunk.content) {
|
|
662
|
+
setMessages((prev) => [...prev, { role: 'system', content: `⚠ ${chunk.content}` }])
|
|
663
|
+
// Keep the footer's provider/model in sync after an automatic fallback
|
|
664
|
+
// switch. RemoteEngine's registry is a stub without getActive.
|
|
665
|
+
const registry = engine.getRegistry()
|
|
666
|
+
if ('getActive' in registry) {
|
|
667
|
+
setProviderId(registry.getActive().config.id)
|
|
668
|
+
setModelId(registry.getActiveModel())
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
661
672
|
if (chunk.type === 'task_notification' && chunk.taskNotification) {
|
|
662
673
|
const tn = chunk.taskNotification
|
|
663
674
|
const isDone = tn.status === 'completed'
|
package/src/ui/commands.ts
CHANGED
|
@@ -317,12 +317,20 @@ const contextCmd: CommandHandler = (ctx) => {
|
|
|
317
317
|
}
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
const statusCmd: CommandHandler = (ctx) => {
|
|
320
|
+
const statusCmd: CommandHandler = async (ctx) => {
|
|
321
321
|
const t = resolveT(ctx)
|
|
322
322
|
const c = ctx.engine.getContext()
|
|
323
323
|
const tools = ctx.engine.getTools()
|
|
324
324
|
const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js'
|
|
325
325
|
const runtimeVer = typeof Bun !== 'undefined' ? Bun.version : process.version
|
|
326
|
+
const health = ctx.engine.getRegistry()
|
|
327
|
+
? await ctx.engine.getRegistry().healthMap()
|
|
328
|
+
: new Map<string, boolean>()
|
|
329
|
+
const providerHealth = ctx.config.providers
|
|
330
|
+
.filter((p) => p.status !== 'upcoming')
|
|
331
|
+
.map((p) => `${p.id} ${health.get(p.id) ? '🟢' : '🔴'}`)
|
|
332
|
+
.join(' ')
|
|
333
|
+
|
|
326
334
|
return {
|
|
327
335
|
content: stripIndent`
|
|
328
336
|
${t('commands.status.session_title')}
|
|
@@ -337,6 +345,9 @@ const statusCmd: CommandHandler = (ctx) => {
|
|
|
337
345
|
${t('commands.status.platform')} ${process.platform} ${process.arch}
|
|
338
346
|
${t('commands.status.runtime')} ${runtime} ${runtimeVer}
|
|
339
347
|
${t('commands.status.cwd')} ${process.cwd()}
|
|
348
|
+
|
|
349
|
+
Providers
|
|
350
|
+
${providerHealth}
|
|
340
351
|
`,
|
|
341
352
|
}
|
|
342
353
|
}
|
|
@@ -449,12 +460,19 @@ const providerCmd: CommandHandler = (ctx) => {
|
|
|
449
460
|
}
|
|
450
461
|
}
|
|
451
462
|
|
|
452
|
-
const providersCmd: CommandHandler = (ctx) => {
|
|
463
|
+
const providersCmd: CommandHandler = async (ctx) => {
|
|
453
464
|
const t = resolveT(ctx)
|
|
454
|
-
const
|
|
455
|
-
(
|
|
456
|
-
|
|
457
|
-
|
|
465
|
+
const health = ctx.engine.getRegistry()
|
|
466
|
+
? await ctx.engine.getRegistry().healthMap()
|
|
467
|
+
: new Map<string, boolean>()
|
|
468
|
+
|
|
469
|
+
const lines = ctx.config.providers.map((p) => {
|
|
470
|
+
if (p.status === 'upcoming') {
|
|
471
|
+
return ` ${p.id.padEnd(14)} ${p.name.padEnd(20)} ${p.protocol.padEnd(18)} ${p.models.length} models ⏳ [upcoming]`
|
|
472
|
+
}
|
|
473
|
+
const ok = health.get(p.id) ?? false
|
|
474
|
+
return ` ${p.id.padEnd(14)} ${p.name.padEnd(20)} ${p.protocol.padEnd(18)} ${p.models.length} models ${ok ? '🟢 reachable' : '🔴 unreachable'}`
|
|
475
|
+
})
|
|
458
476
|
return {
|
|
459
477
|
content: `${t('commands.providers.title')}\n\n${lines.join('\n')}\n\n${t('commands.providers.current', { provider: ctx.providerId, model: ctx.modelId })}`,
|
|
460
478
|
}
|