@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.36.1",
3
+ "version": "0.36.2",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -446,13 +446,12 @@ export class QueryEngine {
446
446
 
447
447
  // Stream model response
448
448
  try {
449
- for await (const chunk of this.registry.chat({
450
- model: this.registry.getActiveModel(),
449
+ for await (const chunk of this.chatWithFallback(
451
450
  messages,
452
451
  systemPrompt,
453
- tools: toolDefs.length > 0 ? toolDefs : undefined,
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 = rawBase?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
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 = rawBase?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
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
- const res = await fetch(`${baseUrl}/models`, {
230
- headers: { Authorization: `Bearer ${apiKey}` },
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
- private resolveApiKey(keyTemplate: string): string {
370
- // Accept both ${VAR} and $VAR syntax
371
- let match = keyTemplate.match(/^\$\{(.+)\}$/)
372
- if (!match) match = keyTemplate.match(/^\$([A-Z_][A-Z0-9_]*)$/)
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 value = process.env[varName]
376
- if (!value) {
386
+ const envValue = process.env[varName]
387
+ if (!envValue) {
377
388
  process.stderr.write(
378
- `⚠ OpenAI-compatible provider: apiKey references $${varName} but that environment variable is not set\n`,
389
+ `⚠ ${warnPrefix} references $${varName} but that environment variable is not set\n`,
379
390
  )
380
391
  return ''
381
392
  }
382
- return value
393
+ return envValue
383
394
  }
384
- return keyTemplate
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 })
@@ -129,6 +129,7 @@ export interface StreamChunk {
129
129
  | 'thinking'
130
130
  | 'stop'
131
131
  | 'error'
132
+ | 'warning'
132
133
  | 'task_notification'
133
134
  | 'usage'
134
135
  content?: string
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'
@@ -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 lines = ctx.config.providers.map(
455
- (p) =>
456
- ` ${p.id.padEnd(14)} ${p.name.padEnd(20)} ${p.protocol.padEnd(18)} ${p.models.length} models ${p.status === 'upcoming' ? '[upcoming]' : '✓'}`,
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
  }