mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
fix(tui): disambiguate /model picker rows when provider display names collide
If the gateway returns two providers that resolve to the same display name (e.g. `kimi-coding` and `kimi-coding-cn` both → "Kimi For Coding"), the picker now appends the slug so users can tell them apart, in both the provider list and the selected-provider header. No-op when names are already unique. Refs #10526 — the Python backend dedupe from #10599 skips one alias, but user-defined providers, canonical overlays, and future regressions can still surface as indistinguishable rows in the picker. This is a client-side safety net on top of that.
This commit is contained in:
parent
0175ff7516
commit
4aa52590d8
3 changed files with 84 additions and 3 deletions
62
ui-tui/src/__tests__/providers.test.ts
Normal file
62
ui-tui/src/__tests__/providers.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { providerDisplayNames } from '../domain/providers.js'
|
||||||
|
|
||||||
|
describe('providerDisplayNames', () => {
|
||||||
|
it('returns bare names when all are unique', () => {
|
||||||
|
expect(providerDisplayNames([{ name: 'Anthropic', slug: 'anthropic' }, { name: 'OpenAI', slug: 'openai' }])).toEqual(
|
||||||
|
['Anthropic', 'OpenAI']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends slug to every collision so the disambiguation is symmetric', () => {
|
||||||
|
expect(
|
||||||
|
providerDisplayNames([
|
||||||
|
{ name: 'Kimi For Coding', slug: 'kimi-coding' },
|
||||||
|
{ name: 'Kimi For Coding', slug: 'kimi-coding-cn' }
|
||||||
|
])
|
||||||
|
).toEqual(['Kimi For Coding (kimi-coding)', 'Kimi For Coding (kimi-coding-cn)'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only disambiguates the colliding group', () => {
|
||||||
|
expect(
|
||||||
|
providerDisplayNames([
|
||||||
|
{ name: 'Anthropic', slug: 'anthropic' },
|
||||||
|
{ name: 'Foo', slug: 'foo-a' },
|
||||||
|
{ name: 'Foo', slug: 'foo-b' }
|
||||||
|
])
|
||||||
|
).toEqual(['Anthropic', 'Foo (foo-a)', 'Foo (foo-b)'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to plain name if slug is empty', () => {
|
||||||
|
expect(
|
||||||
|
providerDisplayNames([
|
||||||
|
{ name: 'Foo', slug: '' },
|
||||||
|
{ name: 'Foo', slug: '' }
|
||||||
|
])
|
||||||
|
).toEqual(['Foo', 'Foo'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips disambiguation when slug equals name', () => {
|
||||||
|
expect(
|
||||||
|
providerDisplayNames([
|
||||||
|
{ name: 'foo', slug: 'foo' },
|
||||||
|
{ name: 'foo', slug: 'foo' }
|
||||||
|
])
|
||||||
|
).toEqual(['foo', 'foo'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty input', () => {
|
||||||
|
expect(providerDisplayNames([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves order', () => {
|
||||||
|
const input = [
|
||||||
|
{ name: 'Z', slug: 'z' },
|
||||||
|
{ name: 'A', slug: 'a1' },
|
||||||
|
{ name: 'A', slug: 'a2' }
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(providerDisplayNames(input)).toEqual(['Z', 'A (a1)', 'A (a2)'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Box, Text, useInput } from '@hermes/ink'
|
import { Box, Text, useInput } from '@hermes/ink'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { providerDisplayNames } from '../domain/providers.js'
|
||||||
import type { GatewayClient } from '../gatewayClient.js'
|
import type { GatewayClient } from '../gatewayClient.js'
|
||||||
import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js'
|
import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js'
|
||||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||||
|
|
@ -59,6 +60,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||||
|
|
||||||
const provider = providers[providerIdx]
|
const provider = providers[providerIdx]
|
||||||
const models = provider?.models ?? []
|
const models = provider?.models ?? []
|
||||||
|
const names = useMemo(() => providerDisplayNames(providers), [providers])
|
||||||
|
|
||||||
useInput((ch, key) => {
|
useInput((ch, key) => {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
|
|
@ -160,7 +162,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||||
|
|
||||||
if (stage === 'provider') {
|
if (stage === 'provider') {
|
||||||
const rows = providers.map(
|
const rows = providers.map(
|
||||||
p => `${p.is_current ? '*' : ' '} ${p.name} · ${p.total_models ?? p.models?.length ?? 0} models`
|
(p, i) => `${p.is_current ? '*' : ' '} ${names[i]} · ${p.total_models ?? p.models?.length ?? 0} models`
|
||||||
)
|
)
|
||||||
|
|
||||||
const { items, off } = visibleItems(rows, providerIdx)
|
const { items, off } = visibleItems(rows, providerIdx)
|
||||||
|
|
@ -201,7 +203,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
||||||
Select Model
|
Select Model
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim}>{provider?.name || '(unknown provider)'}</Text>
|
<Text color={t.color.dim}>{names[providerIdx] || '(unknown provider)'}</Text>
|
||||||
{!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null}
|
{!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null}
|
||||||
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
|
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
|
||||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||||
|
|
|
||||||
17
ui-tui/src/domain/providers.ts
Normal file
17
ui-tui/src/domain/providers.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export const providerDisplayNames = (providers: readonly { name: string; slug: string }[]): string[] => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const p of providers) {
|
||||||
|
counts.set(p.name, (counts.get(p.name) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers.map(p => {
|
||||||
|
const dup = (counts.get(p.name) ?? 0) > 1
|
||||||
|
|
||||||
|
if (!dup || !p.slug || p.slug === p.name) {
|
||||||
|
return p.name
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${p.name} (${p.slug})`
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue