Merge pull request #1369 from MrWangBigData/main

feat(agent-teams): 显示成员模型信息 + 修复 CORS 预检 + 补 Fable 槽位
This commit is contained in:
程序员阿江-Relakkes
2026-09-23 15:51:21 +08:00
committed by GitHub
34 changed files with 543 additions and 35 deletions
+78 -1
View File
@@ -3010,6 +3010,7 @@ describe('Settings > Providers tab', () => {
expect(providerStoreState.createProvider).toHaveBeenCalledWith(expect.objectContaining({
model1mSupport: {
main: true,
fable: false,
haiku: false,
sonnet: true,
opus: false,
@@ -3030,6 +3031,82 @@ describe('Settings > Providers tab', () => {
}))
})
it('persists the Fable slot to its own env var with its own 1M marker', async () => {
providerStoreState.createProvider = vi.fn().mockResolvedValue({
id: 'provider-fable',
presetId: 'custom',
name: 'Custom',
apiKey: 'sk-test',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
models: {
main: 'claude-sonnet-4-6',
fable: 'claude-fable-5',
haiku: 'claude-sonnet-4-6',
sonnet: 'claude-sonnet-4-6',
opus: 'claude-sonnet-4-6',
},
model1mSupport: {
main: false,
fable: true,
haiku: false,
sonnet: false,
opus: false,
},
})
providerStoreState.presets = [
{
id: 'custom',
name: 'Custom',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
// Fable must be settable from the form, not only by hand-editing
// settings.json or importing a cc-switch profile.
defaultModels: {
main: 'claude-sonnet-4-6',
fable: 'claude-fable-5',
haiku: '',
sonnet: '',
opus: '',
},
needsApiKey: true,
websiteUrl: '',
},
]
render(<Settings />)
fireEvent.click(screen.getByRole('button', { name: /Add Model|添加模型/i }))
const dialog = screen.getByRole('dialog')
await waitFor(() => {
const settingsTextarea = dialog.querySelector('textarea')
expect(settingsTextarea?.value).toContain('"ANTHROPIC_MODEL"')
})
fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-test' } })
// The slot is editable from the form rather than being carried invisibly.
// Queried by label because the input only takes the `combobox` role once a
// model list has been fetched.
const fableInput = within(dialog).getByLabelText(/Fable Model/i)
expect(fableInput).toHaveValue('claude-fable-5')
// Fable is the one slot that does not fall back to the main model, so it
// must not advertise "Same as main" the way the tier slots do.
expect(fableInput).toHaveAttribute('placeholder', 'Leave blank to let Claude Code choose')
expect(within(dialog).getByLabelText(/Haiku Model/i))
.toHaveAttribute('placeholder', 'Same as main')
fireEvent.click(within(dialog).getByRole('checkbox', { name: /1M support: fable/i }))
fireEvent.click(within(dialog).getByRole('button', { name: /Save|Add|保存|添加/i }))
await waitFor(() => {
expect(MOCK_UPDATE_SETTINGS).toHaveBeenCalledWith(expect.objectContaining({
env: expect.objectContaining({
ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5[1m]',
}),
}))
})
})
it('hides the API key by default and reveals it from the eye button', () => {
providerStoreState.presets = [
{
@@ -3157,7 +3234,7 @@ describe('Settings > Providers tab', () => {
})
})
expect(await within(dialog).findByText(/Model list loaded \(2\)/i)).toBeInTheDocument()
expect(within(dialog).getAllByRole('button', { name: /from the fetched list/i })).toHaveLength(4)
expect(within(dialog).getAllByRole('button', { name: /from the fetched list/i })).toHaveLength(5)
// The picker supplements the field; a model id that is not on the list must
// still be typeable. Queried by role because the picker's own accessible
@@ -354,4 +354,51 @@ describe('AgentTeamsCanvas', () => {
/@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.agent-teams-task-running-fill\s*\{[^}]*opacity:\s*0;/,
)
})
it('chips each member card with its model family and keeps the full id on hover', () => {
const current = snapshot('current')
const withModels: TeamWorkbenchSnapshot = {
...current,
team: {
...current.team,
members: current.team.members.map(member => (
member.agentId === 'team-lead@canvas-team'
? { ...member, model: 'claude-opus-4-8' }
: member.agentId === 'builder@canvas-team'
? { ...member, model: 'claude-sonnet-5' }
: member
)),
},
}
render(<AgentTeamsCanvas {...props({
snapshots: [withModels],
selectedIndex: 0,
snapshot: withModels,
previousSnapshot: undefined,
activeMessageId: null,
})} />)
// A member with its own model shows the short family, full id on hover.
const builder = screen.getByTestId('agent-teams-canvas-member-model-builder@canvas-team')
expect(builder.textContent).toBe('sonnet')
expect(builder.getAttribute('title')).toBe('claude-sonnet-5')
expect(builder.getAttribute('data-model-inherited')).toBe('false')
// The lead shows its own model, and a member with none inherits it.
const lead = screen.getByTestId('agent-teams-canvas-member-model-team-lead@canvas-team')
expect(lead.textContent).toBe('opus')
const reviewer = screen.getByTestId('agent-teams-canvas-member-model-reviewer@canvas-team')
expect(reviewer.textContent).toBe('opus')
expect(reviewer.getAttribute('data-model-inherited')).toBe('true')
})
it('renders no model chip at all when neither the member nor the lead has one', () => {
render(<AgentTeamsCanvas {...props()} />)
const builder = screen.queryByTestId('agent-teams-canvas-member-model-builder@canvas-team')
expect(builder).toBeNull()
const lead = screen.queryByTestId('agent-teams-canvas-member-model-team-lead@canvas-team')
expect(lead).toBeNull()
})
})
@@ -15,6 +15,7 @@ import {
inferTaskOwner,
layoutWorkbenchTasks,
parseWorkbenchMessageBody,
resolveMemberModel,
resolveTeamMemberIdentity,
taskOwnedByMember,
type MemberWorkState,
@@ -437,6 +438,7 @@ function MemberNode({
hasActiveMessage,
waitingDependency,
leadStatus,
leadModel,
onSelect,
t,
}: {
@@ -445,10 +447,12 @@ function MemberNode({
hasActiveMessage: boolean
waitingDependency?: string
leadStatus?: string
leadModel?: string
onSelect: () => void
t: TranslationFn
}) {
const { member, isLead, centerX, accent, workState: state } = position
const model = resolveMemberModel(member, isLead ? undefined : leadModel)
const top = isLead ? LEAD_TOP : MEMBER_TOP
const avatarSize = isLead ? 96 : 84
const identityWidth = isLead ? 28 : 24
@@ -519,6 +523,16 @@ function MemberNode({
<span className="truncate font-mono text-[10.5px] font-extrabold text-[var(--color-text-primary)]">
{memberName(member)}
</span>
{model ? (
<span
className="max-w-[54px] shrink-0 truncate rounded-full border border-[var(--color-border)] px-1.5 py-px text-[9px] font-extrabold text-[var(--color-text-tertiary)]"
title={model.full}
data-testid={`agent-teams-canvas-member-model-${member.agentId}`}
data-model-inherited={model.inherited ? 'true' : 'false'}
>
{model.label}
</span>
) : null}
{isLead ? (
<span className="shrink-0 rounded-full border border-[var(--color-brand)] bg-[var(--color-brand-soft)] px-1.5 py-px text-[9px] font-extrabold text-[var(--color-brand)]">
{t('agentTeams.leader')}
@@ -714,6 +728,7 @@ export function AgentTeamsCanvas({
const leadMember = uniqueMembers.find(member => member.agentId === snapshot.team.leadAgentId)
?? uniqueMembers.find(member => aliases(member.name).includes('team-lead'))
?? uniqueMembers[0]
const leadModel = leadMember?.model
const workers = uniqueMembers.filter(member => member !== leadMember)
const naturalWidth = Math.max(CANVAS_MIN_WIDTH, 460 + Math.max(0, workers.length - 1) * MEMBER_PITCH)
const layout = useMemo(
@@ -1033,6 +1048,7 @@ export function AgentTeamsCanvas({
hasActiveMessage={activeParticipants.has(position.member.agentId)}
waitingDependency={waitingDependency}
leadStatus={position.isLead ? leadStatusLabel(snapshot, t) : undefined}
leadModel={leadModel}
onSelect={() => onSelectMember(position.member, position.isLead)}
t={t}
/>
@@ -170,4 +170,53 @@ describe('AgentTeamsMemberInspector', () => {
expect(onClose).toHaveBeenCalledTimes(1)
expect(onOpenExecution).toHaveBeenCalledTimes(1)
})
it('reports the model a teammate runs on, and flags one that inherits the lead', () => {
const lead: TeamMember = {
agentId: 'lead@team-a',
name: 'lead',
role: 'Lead',
status: 'running',
model: 'claude-opus-4-8',
}
const base = snapshot('2026-08-08T07:00:00.000Z', 'pending')
const withLead: TeamWorkbenchSnapshot = {
...base,
team: { ...base.team, members: [lead, builder, reviewer] },
}
const renderMember = (member: TeamMember) => render(
<AgentTeamsMemberInspector
snapshots={[withLead]}
selectedIndex={0}
snapshot={withLead}
member={member}
isLead={false}
leadIsStreaming={false}
onBack={vi.fn()}
onClose={vi.fn()}
onOpenExecution={vi.fn()}
/>,
)
// A teammate with its own model shows the full id, not the short family.
const own = renderMember({ ...builder, model: 'claude-haiku-4-5' })
const ownCell = screen.getByTestId('agent-teams-member-model')
expect(ownCell.textContent).toBe('claude-haiku-4-5')
expect(ownCell.getAttribute('data-model-inherited')).toBe('false')
own.unmount()
// One with no model of its own names where the model comes from.
renderMember({ ...builder })
const inheritedCell = screen.getByTestId('agent-teams-member-model')
expect(inheritedCell.textContent).toBe('Inherit from lead · claude-opus-4-8')
expect(inheritedCell.getAttribute('data-model-inherited')).toBe('true')
})
it('never leaves the model cell blank when nothing is known', () => {
renderInspector()
const cell = screen.getByTestId('agent-teams-member-model')
expect(cell.textContent).toBe('Unknown')
expect(cell.textContent).not.toContain('undefined')
})
})
@@ -10,6 +10,7 @@ import {
getWorkbenchTaskState,
inferTaskOwner,
parseWorkbenchMessageBody,
resolveMemberModel,
resolveTeamMemberIdentity,
taskOwnedByMember,
type MemberWorkState,
@@ -297,6 +298,8 @@ export function AgentTeamsMemberInspector({
const workState = snapshot.deletedAt
? 'exited'
: getMemberWorkState(member, { isLead, leadIsStreaming })
const leadMember = snapshot.team.members.find(candidate => candidate.agentId === snapshot.team.leadAgentId)
const model = resolveMemberModel(member, isLead ? undefined : leadMember?.model)
const taskHistory = useMemo(
() => deriveTaskHistory(snapshots, selectedIndex, snapshot, member),
[member, selectedIndex, snapshot, snapshots],
@@ -396,6 +399,26 @@ export function AgentTeamsMemberInspector({
</dt>
<dd className="mt-0.5 font-extrabold tabular-nums">{messages.length}</dd>
</div>
{/* Model spans the full width: a model id plus an "inherited from" prefix
does not fit a third of the drawer, and truncating it would cut the
model name itself — the one part that matters. */}
<div className="col-span-3 min-w-0">
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
{t('agentTeams.model.label')}
</dt>
<dd
className="mt-0.5 truncate font-extrabold"
title={model?.full}
data-testid="agent-teams-member-model"
data-model-inherited={model?.inherited ? 'true' : 'false'}
>
{model === undefined
? t('agentTeams.model.unknown')
: model.inherited
? t('agentTeams.model.inheritFromLead', { model: model.full })
: model.full}
</dd>
</div>
</dl>
</header>
@@ -9,8 +9,10 @@ import {
inferTaskOwner,
layoutWorkbenchTasks,
parseWorkbenchMessageBody,
resolveMemberModel,
resolveTeamMemberIdentity,
runningTaskForMember,
shortModelLabel,
snapshotWithHistoricalMembers,
taskOwnedByMember,
WORKBENCH_TASK_WIDTH,
@@ -442,3 +444,103 @@ describe('Agent Teams workbench model', () => {
expect(formatWorkbenchMessageTime('not-a-date')).toBe('')
})
})
describe('Agent Teams member model display', () => {
const worker: TeamMember = {
agentId: 'builder@team-a',
name: 'builder',
role: 'Frontend',
status: 'running',
}
function memberSnapshot(generatedAt: string, members: TeamMember[]): TeamWorkbenchSnapshot {
return {
version: generatedAt,
generatedAt,
team: {
name: 'team-a',
leadAgentId: 'lead@team-a',
leadSessionId: 'session-a',
members,
},
tasks: [],
messages: [],
}
}
it('shortens a full model id to its family and passes unknown ids through', () => {
expect(shortModelLabel('claude-opus-4-8')).toBe('opus')
expect(shortModelLabel('eu.anthropic.claude-sonnet-5')).toBe('sonnet')
expect(shortModelLabel('claude-haiku-4-5')).toBe('haiku')
expect(shortModelLabel('claude-fable-5')).toBe('fable')
expect(shortModelLabel('sonnet')).toBe('sonnet')
// An unknown family is preserved rather than forced into a known one.
expect(shortModelLabel('gpt-5')).toBe('gpt-5')
})
it('keeps the full id for the tooltip while the chip shows the short family', () => {
expect(resolveMemberModel({ ...worker, model: 'claude-opus-4-8' }))
.toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: false })
})
it('trims the stored id so no padding leaks into the label or the tooltip', () => {
expect(resolveMemberModel({ ...worker, model: ' claude-opus-4-8 ' }))
.toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: false })
})
it('drops the context-window tag from the chip while the tooltip keeps it', () => {
// `[1m]` is a routing detail, and leaving it on made the chip wide enough to
// squeeze the member name out of the card.
expect(shortModelLabel('qwen3.7-plus[1m]')).toBe('qwen3.7-plus')
expect(shortModelLabel('claude-opus-4-8[1m]')).toBe('opus')
expect(resolveMemberModel({ ...worker, model: 'qwen3.7-plus[1m]' }))
.toEqual({ label: 'qwen3.7-plus', full: 'qwen3.7-plus[1m]', inherited: false })
// A value that is nothing but a tag must not collapse to an empty label.
expect(shortModelLabel('[1m]')).toBe('[1m]')
})
it('marks a member with no model of its own as inheriting the lead model', () => {
expect(resolveMemberModel({ ...worker }, 'claude-sonnet-5'))
.toEqual({ label: 'sonnet', full: 'claude-sonnet-5', inherited: true })
})
it('treats an explicit inherit alias as inheritance, not as a model name', () => {
expect(resolveMemberModel({ ...worker, model: 'inherit' }, 'claude-opus-4-8'))
.toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: true })
expect(resolveMemberModel({ ...worker, model: 'INHERIT' }, 'claude-opus-4-8'))
.toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: true })
})
it('returns undefined when nothing is known, so callers render no empty label', () => {
expect(resolveMemberModel({ ...worker })).toBeUndefined()
expect(resolveMemberModel({ ...worker, model: '' })).toBeUndefined()
expect(resolveMemberModel({ ...worker, model: ' ' })).toBeUndefined()
expect(resolveMemberModel({ ...worker, model: 'inherit' })).toBeUndefined()
// A blank lead model must not be promoted into an inherited label either.
expect(resolveMemberModel({ ...worker }, ' ')).toBeUndefined()
})
it('keeps a model learned from an earlier frame when a later frame omits it', () => {
const earlier = memberSnapshot('2026-08-08T07:00:00.000Z', [
{ ...worker, model: 'claude-opus-4-8' },
])
const later = memberSnapshot('2026-08-08T07:05:00.000Z', [{ ...worker }])
const merged = snapshotWithHistoricalMembers([earlier, later], 1)
expect(merged?.team.members[0]?.model).toBe('claude-opus-4-8')
})
it('lets a later frame update the model when it actually carries one', () => {
const earlier = memberSnapshot('2026-08-08T07:00:00.000Z', [
{ ...worker, model: 'claude-opus-4-8' },
])
const later = memberSnapshot('2026-08-08T07:05:00.000Z', [
{ ...worker, model: 'claude-haiku-4-5' },
])
const merged = snapshotWithHistoricalMembers([earlier, later], 1)
expect(merged?.team.members[0]?.model).toBe('claude-haiku-4-5')
})
})
@@ -111,6 +111,69 @@ export function getMemberAvatarKey(member: TeamMember, isLead = false): MemberAv
return WORKER_AVATARS[stableHash(member.agentId) % WORKER_AVATARS.length]!.key
}
/**
* Short family label for a model id or alias, used by compact UI chips.
* 'claude-opus-4-8' -> 'opus', 'eu.anthropic.claude-sonnet-5' -> 'sonnet',
* 'sonnet' -> 'sonnet'. Unrecognized values are returned trimmed and unchanged
* rather than being forced into a family.
*
* A trailing context-window tag (`[1m]`) is a routing detail, not part of the
* name a reader recognises, so it is dropped before matching.
*/
export function shortModelLabel(model: string): string {
const untagged = model.trim().replace(/\[[^\]]*\]$/, '').trim()
// A value that was nothing but a tag keeps its original form rather than
// collapsing to an empty label.
const trimmed = untagged || model.trim()
if (!trimmed) return trimmed
const lowered = trimmed.toLowerCase()
if (lowered.includes('opus')) return 'opus'
if (lowered.includes('sonnet')) return 'sonnet'
if (lowered.includes('haiku')) return 'haiku'
if (lowered.includes('fable')) return 'fable'
return trimmed
}
export type MemberModelDisplay = {
/** Short chip label, e.g. 'opus'. */
label: string
/** Full model id or alias, used for the hover tooltip. */
full: string
/** True when the member runs on the lead's model rather than one of its own. */
inherited: boolean
}
/**
* Model display for one member. Returns undefined when nothing is known, so
* callers render no chip instead of an empty or 'undefined' label.
*
* `leadModel` is the lead member's model. Callers pass undefined for the lead
* itself; a lead without an explicit model therefore renders no chip.
*/
export function resolveMemberModel(
member: TeamMember,
leadModel?: string,
): MemberModelDisplay | undefined {
const memberModelRaw = member.model
const memberModel = typeof memberModelRaw === 'string' ? memberModelRaw.trim() : ''
if (memberModel && memberModel.toLowerCase() !== 'inherit') {
return {
label: shortModelLabel(memberModel),
full: memberModel,
inherited: false,
}
}
const resolvedLead = typeof leadModel === 'string' ? leadModel.trim() : ''
if (resolvedLead) {
return {
label: shortModelLabel(resolvedLead),
full: resolvedLead,
inherited: true,
}
}
return undefined
}
function identityAliases(value: string): string[] {
const normalized = value.trim().toLowerCase()
const short = normalized.split('@')[0] ?? normalized
@@ -153,7 +216,13 @@ export function snapshotWithHistoricalMembers(
}
}
const [currentMember] = remaining.splice(currentIndex, 1)
return { ...historicalMember, ...currentMember! }
const merged = { ...historicalMember, ...currentMember! }
// A later frame can land with `model: undefined`, which would clobber a
// model already learned from an earlier snapshot. Restore the historical
// model when the incoming member has no model of its own.
const currentModel = currentMember?.model
const hasCurrentModel = typeof currentModel === 'string' && currentModel.trim() !== ''
return hasCurrentModel ? merged : { ...merged, model: historicalMember.model }
})
members.push(...remaining)
}
@@ -79,7 +79,7 @@ describe('ModelSelector', () => {
id: 'provider-1m', presetId: 'custom', name: 'Provider 1M',
apiFormat: 'anthropic', apiKey: 'fixture', baseUrl: 'http://127.0.0.1:9999',
models: { main: 'main-model', haiku: 'haiku-model', sonnet: 'sonnet-model', opus: 'opus-model' },
model1mSupport: { main: enabled, haiku: enabled, sonnet: enabled, opus: enabled },
model1mSupport: { main: enabled, fable: enabled, haiku: enabled, sonnet: enabled, opus: enabled },
}],
})
const runtimeChange = vi.fn()
@@ -30,7 +30,7 @@ function makeCandidate(overrides: Partial<CcSwitchCandidate> = {}): CcSwitchCand
sonnet: 'relay-sonnet',
opus: 'relay-opus',
},
model1mSupport: { main: false, haiku: false, sonnet: false, opus: false },
model1mSupport: { main: false, fable: false, haiku: false, sonnet: false, opus: false },
apiFormat: 'anthropic',
authStrategy: 'auth_token',
presetId: 'custom',
+7 -5
View File
@@ -99,6 +99,7 @@ function galleryTeam(
agentId: string,
role: string,
status: TeamDetail['members'][number]['status'],
model?: string,
): TeamDetail['members'][number] => ({
agentId,
name: agentId,
@@ -108,6 +109,7 @@ function galleryTeam(
currentTask: activeTaskByMember[agentId],
color: AGENT_TEAMS_GALLERY_COLORS[agentId],
sessionId: `gallery-${agentId}`,
...(model ? { model } : {}),
})
return {
@@ -117,11 +119,11 @@ function galleryTeam(
leadSessionId: AGENT_TEAMS_GALLERY_SESSION_ID,
createdAt,
members: [
member('team-lead', '队长', 'running'),
member('backend-dev', '后端实现', 'running'),
member('frontend-dev', '前端实现', 'running'),
member('ui-reviewer', 'UI 走查', 'running'),
member('code-reviewer', '代码审查', 'running'),
member('team-lead', '队长', 'running', 'claude-opus-4-8'),
member('backend-dev', '后端实现', 'running', 'claude-sonnet-5'),
member('frontend-dev', '前端实现', 'running', 'claude-sonnet-5'),
member('ui-reviewer', 'UI 走查', 'running', 'claude-haiku-4-5'),
member('code-reviewer', '代码审查', 'running', 'qwen3.7-plus[1m]'),
member('test-runner', '测试验证', 'running'),
],
}
+5
View File
@@ -1300,6 +1300,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.providers.getApiKey': 'Get API Key',
'settings.providers.modelMapping': 'Model Mapping',
'settings.providers.mainModel': 'Main Model',
'settings.providers.fableModel': 'Fable Model',
'settings.providers.haikuModel': 'Haiku Model',
'settings.providers.sonnetModel': 'Sonnet Model',
'settings.providers.opusModel': 'Opus Model',
@@ -1308,6 +1309,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.providers.model1mSupportTooltip': 'Declares that this model endpoint supports a 1M-token context: the model name gets a [1m] suffix and the context window is set to 1,000,000. If the endpoint is actually smaller, auto-compaction never fires and requests hit the provider’s hard context limit. Clearing the box restores the preset window.',
'settings.providers.modelIdPlaceholder': 'e.g. deepseek-v4-flash',
'settings.providers.sameAsMain': 'Same as main',
'settings.providers.fableModelPlaceholder': 'Leave blank to let Claude Code choose',
'settings.providers.contextSettingsTitle': 'Context and auto-compact',
'settings.providers.contextSettingsDesc': 'Preset limits are applied automatically. Edit only when the provider changes limits or you use a custom model ID.',
'settings.providers.contextSettingsEdit': 'Advanced',
@@ -3086,6 +3088,9 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'agentTeams.inspector.taskHistoryHint': 'One teammate executes multiple tasks in sequence',
'agentTeams.inspector.sent': 'Sent',
'agentTeams.inspector.received': 'Received',
'agentTeams.model.label': 'Model',
'agentTeams.model.inheritFromLead': 'Inherit from lead · {model}',
'agentTeams.model.unknown': 'Unknown',
'agentTeams.hideReport': 'Hide Agent Teams run report',
'agentTeams.closeReport': 'Close Agent Teams run report',
'agentTeams.loading': 'Waiting for Agent Teams activity…',
+5
View File
@@ -1302,6 +1302,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.providers.getApiKey': 'API キーを取得',
'settings.providers.modelMapping': 'モデルマッピング',
'settings.providers.mainModel': 'メインモデル',
'settings.providers.fableModel': 'Fable モデル',
'settings.providers.haikuModel': 'Haiku モデル',
'settings.providers.sonnetModel': 'Sonnet モデル',
'settings.providers.opusModel': 'Opus モデル',
@@ -1310,6 +1311,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.providers.model1mSupportTooltip': 'このモデルのエンドポイントが 100 万トークンのコンテキストに対応していることを宣言します。モデル名に [1m] が付き、コンテキストウィンドウは 1,000,000 に設定されます。実際のウィンドウが 1M より小さい場合、自動圧縮は実行されず、リクエストはプロバイダーのコンテキスト上限に達します。チェックを外すとプリセットのウィンドウに戻ります。',
'settings.providers.modelIdPlaceholder': '例: deepseek-v4-flash',
'settings.providers.sameAsMain': 'メインと同じ',
'settings.providers.fableModelPlaceholder': '空欄の場合は Claude Code が決定',
'settings.providers.contextSettingsTitle': 'コンテキストと自動コンパクト',
'settings.providers.contextSettingsDesc': 'プリセットの上限は自動的に適用されます。プロバイダーが上限を変更した場合やカスタムモデル ID を使用する場合のみ編集してください。',
'settings.providers.contextSettingsEdit': '詳細設定',
@@ -3088,6 +3090,9 @@ export const jp: Record<TranslationKey, string> = {
'agentTeams.inspector.taskHistoryHint': '1 人のメンバーが複数タスクを順番に実行',
'agentTeams.inspector.sent': '送信',
'agentTeams.inspector.received': '受信',
'agentTeams.model.label': 'モデル',
'agentTeams.model.inheritFromLead': 'リードから継承 · {model}',
'agentTeams.model.unknown': '不明',
'agentTeams.hideReport': 'Agent Teams 実行レポートを隠す',
'agentTeams.closeReport': 'Agent Teams 実行レポートを閉じる',
'agentTeams.loading': 'Agent Teams のアクティビティを待機中…',
+5
View File
@@ -1304,6 +1304,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.providers.getApiKey': 'API 키 가져오기',
'settings.providers.modelMapping': '모델 매핑',
'settings.providers.mainModel': '메인 모델',
'settings.providers.fableModel': 'Fable 모델',
'settings.providers.haikuModel': 'Haiku 모델',
'settings.providers.sonnetModel': 'Sonnet 모델',
'settings.providers.opusModel': 'Opus 모델',
@@ -1312,6 +1313,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.providers.model1mSupportTooltip': '이 모델 엔드포인트가 100만 토큰 컨텍스트를 지원한다고 선언합니다. 모델 이름에 [1m]이 추가되고 컨텍스트 창이 1,000,000으로 설정됩니다. 엔드포인트의 실제 창이 1M보다 작으면 자동 압축이 실행되지 않고 요청이 공급자의 컨텍스트 한도에 도달합니다. 선택을 해제하면 사전 설정 창으로 되돌아갑니다.',
'settings.providers.modelIdPlaceholder': '예: deepseek-v4-flash',
'settings.providers.sameAsMain': '메인과 동일',
'settings.providers.fableModelPlaceholder': '비워 두면 Claude Code가 결정',
'settings.providers.contextSettingsTitle': '컨텍스트 및 자동 압축',
'settings.providers.contextSettingsDesc': '사전 설정 한도가 자동으로 적용됩니다. 공급자가 한도를 변경하거나 사용자 지정 모델 ID를 사용할 때만 편집하세요.',
'settings.providers.contextSettingsEdit': '고급',
@@ -3090,6 +3092,9 @@ export const kr: Record<TranslationKey, string> = {
'agentTeams.inspector.taskHistoryHint': '한 멤버가 여러 작업을 순차 실행',
'agentTeams.inspector.sent': '보냄',
'agentTeams.inspector.received': '받음',
'agentTeams.model.label': '모델',
'agentTeams.model.inheritFromLead': '리드로부터 상속 · {model}',
'agentTeams.model.unknown': '알 수 없음',
'agentTeams.hideReport': 'Agent Teams 실행 보고서 숨기기',
'agentTeams.closeReport': 'Agent Teams 실행 보고서 닫기',
'agentTeams.loading': 'Agent Teams 활동을 기다리는 중…',
+5
View File
@@ -1301,6 +1301,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.getApiKey': '獲取 API Key',
'settings.providers.modelMapping': '模型對映',
'settings.providers.mainModel': '主模型',
'settings.providers.fableModel': 'Fable 模型',
'settings.providers.haikuModel': 'Haiku 模型',
'settings.providers.sonnetModel': 'Sonnet 模型',
'settings.providers.opusModel': 'Opus 模型',
@@ -1309,6 +1310,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.model1mSupportTooltip': '宣告該模型端點支援 100 萬 token 上下文:模型名稱會附加 [1m],上下文視窗自動填為 1,000,000。若端點實際視窗小於 1M,自動壓縮不會觸發,請求會撞上服務商的上下文硬限制。取消勾選會還原為預設視窗。',
'settings.providers.modelIdPlaceholder': '例如: deepseek-v4-flash',
'settings.providers.sameAsMain': '與主模型相同',
'settings.providers.fableModelPlaceholder': '留空則由 Claude Code 決定',
'settings.providers.contextSettingsTitle': '上下文與自動壓縮',
'settings.providers.contextSettingsDesc': '預設會自動應用模型視窗。只有服務商調整限制,或使用自定義模型 ID 時才需要修改。',
'settings.providers.contextSettingsEdit': '高階',
@@ -3087,6 +3089,9 @@ export const zh: Record<TranslationKey, string> = {
'agentTeams.inspector.taskHistoryHint': '一位成員依序執行多個任務',
'agentTeams.inspector.sent': '送出',
'agentTeams.inspector.received': '收到',
'agentTeams.model.label': '模型',
'agentTeams.model.inheritFromLead': '繼承主代理 · {model}',
'agentTeams.model.unknown': '未知',
'agentTeams.hideReport': '隱藏 Agent Teams 執行報告',
'agentTeams.closeReport': '關閉 Agent Teams 執行報告',
'agentTeams.loading': '正在等待 Agent Teams 活動…',
+5
View File
@@ -1300,6 +1300,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.getApiKey': '获取 API Key',
'settings.providers.modelMapping': '模型映射',
'settings.providers.mainModel': '主模型',
'settings.providers.fableModel': 'Fable 模型',
'settings.providers.haikuModel': 'Haiku 模型',
'settings.providers.sonnetModel': 'Sonnet 模型',
'settings.providers.opusModel': 'Opus 模型',
@@ -1308,6 +1309,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.model1mSupportTooltip': '声明该模型端点支持 100 万 token 上下文:模型名会追加 [1m],上下文窗口自动填为 1,000,000。若端点实际窗口小于 1M,自动压缩不会触发,请求会撞上服务商的上下文硬限制。取消勾选会恢复预设窗口。',
'settings.providers.modelIdPlaceholder': '例如: deepseek-v4-flash',
'settings.providers.sameAsMain': '与主模型相同',
'settings.providers.fableModelPlaceholder': '留空则由 Claude Code 决定',
'settings.providers.contextSettingsTitle': '上下文与自动压缩',
'settings.providers.contextSettingsDesc': '预设会自动应用模型窗口。只有服务商调整限制,或使用自定义模型 ID 时才需要修改。',
'settings.providers.contextSettingsEdit': '高级',
@@ -3086,6 +3088,9 @@ export const zh: Record<TranslationKey, string> = {
'agentTeams.inspector.taskHistoryHint': '一个成员串行执行多个任务',
'agentTeams.inspector.sent': '发出',
'agentTeams.inspector.received': '收到',
'agentTeams.model.label': '模型',
'agentTeams.model.inheritFromLead': '继承主代理 · {model}',
'agentTeams.model.unknown': '未知',
'agentTeams.hideReport': '隐藏 Agent Teams 运行报告',
'agentTeams.closeReport': '关闭 Agent Teams 运行报告',
'agentTeams.loading': '正在等待 Agent Teams 活动…',
@@ -9,7 +9,7 @@ import {
} from '../providerModelContext'
function inputs(overrides: Partial<ModelContextInputs> = {}): ModelContextInputs {
return { main: '', haiku: '', sonnet: '', opus: '', ...overrides }
return { main: '', fable: '', haiku: '', sonnet: '', opus: '', ...overrides }
}
describe('1M support and the context window field', () => {
@@ -87,6 +87,7 @@ describe('1M support and the context window field', () => {
const next = apply1mSupportToContextInputs(before, {
main: true,
fable: false,
haiku: false,
sonnet: false,
opus: false,
+1 -1
View File
@@ -2,7 +2,7 @@
import type { Model1mSupport } from '../types/provider'
export const MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const
export const MODEL_SLOTS = ['main', 'fable', 'haiku', 'sonnet', 'opus'] as const
export type ModelSlot = typeof MODEL_SLOTS[number]
export type ModelContextInputs = Record<ModelSlot, string>
+1 -1
View File
@@ -144,7 +144,7 @@ describe('provider 1M runtime selection', () => {
id: 'provider', name: 'Provider', presetId: 'custom', apiKey: 'fixture',
baseUrl: 'http://127.0.0.1:9999', apiFormat: 'anthropic',
models: { main: ' main-model ', haiku: 'fast-model', sonnet: 'balanced-model', opus: 'large-model' },
model1mSupport: { main: true, haiku: false, sonnet: true, opus: false },
model1mSupport: { main: true, fable: false, haiku: false, sonnet: true, opus: false },
}
it('materializes the active provider main slot by id and by legacy name', () => {
+1 -1
View File
@@ -838,7 +838,7 @@ describe('EmptySession', () => {
sonnet: 'MiniMax-M3',
opus: 'MiniMax-M3',
},
model1mSupport: { main: enabled, haiku: enabled, sonnet: enabled, opus: enabled },
model1mSupport: { main: enabled, fable: enabled, haiku: enabled, sonnet: enabled, opus: enabled },
toolSearchEnabled: true,
}],
activeId: 'provider-minimax',
+27 -12
View File
@@ -7,7 +7,7 @@ import aruhubLogo from '../../../../docs/images/sponsors/aruhub-logo.png'
import { useSettingsStore } from '../../stores/settingsStore'
import { useProviderStore } from '../../stores/providerStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import { useTranslation, type TranslationKey } from '../../i18n'
import { Modal } from '@/components/ui/Modal'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { Input } from '@/components/ui/Input'
@@ -59,6 +59,15 @@ type ProviderListItem =
| { id: typeof GROK_OFFICIAL_PROVIDER_ID; kind: 'grok-official' }
| { id: string; kind: 'saved'; provider: SavedProvider }
/** Row labels for the model-mapping form, in `MODEL_SLOTS` order. */
const MODEL_SLOT_LABEL_KEYS: Record<ModelSlot, TranslationKey> = {
main: 'settings.providers.mainModel',
fable: 'settings.providers.fableModel',
haiku: 'settings.providers.haikuModel',
sonnet: 'settings.providers.sonnetModel',
opus: 'settings.providers.opusModel',
}
function defaultProviderOrder(providers: SavedProvider[]): string[] {
return [
...providers.map((provider) => provider.id),
@@ -562,6 +571,7 @@ const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS'
const DISABLE_EXPERIMENTAL_BETAS_ENV_KEY = 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS'
const DEFAULT_MODEL_1M_SUPPORT: Model1mSupport = {
main: false,
fable: false,
haiku: false,
sonnet: false,
opus: false,
@@ -699,6 +709,7 @@ function getInitialModel1mSupport(
): Model1mSupport {
return {
main: provider?.model1mSupport?.main === true || hasModel1mMarker(models.main),
fable: provider?.model1mSupport?.fable === true || (models.fable ? hasModel1mMarker(models.fable) : false),
haiku: provider?.model1mSupport?.haiku === true || hasModel1mMarker(models.haiku),
sonnet: provider?.model1mSupport?.sonnet === true || hasModel1mMarker(models.sonnet),
opus: provider?.model1mSupport?.opus === true || hasModel1mMarker(models.opus),
@@ -716,7 +727,7 @@ function applyModel1mSupportMapping(
): ModelMapping {
return {
main: applyModel1mSupport(models.main, model1mSupport.main),
...(models.fable ? { fable: stripModel1mMarker(models.fable) } : {}),
...(models.fable ? { fable: applyModel1mSupport(models.fable, model1mSupport.fable) } : {}),
haiku: applyModel1mSupport(models.haiku, model1mSupport.haiku),
sonnet: applyModel1mSupport(models.sonnet, model1mSupport.sonnet),
opus: applyModel1mSupport(models.opus, model1mSupport.opus),
@@ -1856,23 +1867,26 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
)}
<div className={browserMode ? "grid grid-cols-1 sm:grid-cols-2 gap-2" : "grid grid-cols-2 gap-2"}>
{MODEL_SLOTS.map((slot) => {
const labelKey = slot === 'main'
? 'settings.providers.mainModel'
: slot === 'haiku'
? 'settings.providers.haikuModel'
: slot === 'sonnet'
? 'settings.providers.sonnetModel'
: 'settings.providers.opusModel'
const label = t(labelKey)
const label = t(MODEL_SLOT_LABEL_KEYS[slot])
const pickLabel = t('settings.providers.fetchModelsPick', { label })
return (
<div key={slot} className="min-w-0">
<ModelIdCombobox
label={label}
required={slot === 'main'}
value={models[slot]}
value={models[slot] ?? ''}
onChange={(value) => handleModelChange(slot, value)}
placeholder={slot === 'main' ? t('settings.providers.modelIdPlaceholder') : t('settings.providers.sameAsMain')}
placeholder={
// Fable is the one slot that does not fall back to the
// main model: an empty value leaves the choice to the
// runtime, which resolves it to the provider's Opus-tier
// model (third-party) or a real Fable model (official).
slot === 'main'
? t('settings.providers.modelIdPlaceholder')
: slot === 'fable'
? t('settings.providers.fableModelPlaceholder')
: t('settings.providers.sameAsMain')
}
groups={modelPickerGroups}
pickerLabel={pickLabel}
noMatchesLabel={t('model.noMatches')}
@@ -2167,6 +2181,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
const mergedModels = { ...prev, ...newModels }
const nextModel1mSupport = {
main: hasModel1mMarker(mergedModels.main),
fable: mergedModels.fable ? hasModel1mMarker(mergedModels.fable) : false,
haiku: hasModel1mMarker(mergedModels.haiku),
sonnet: hasModel1mMarker(mergedModels.sonnet),
opus: hasModel1mMarker(mergedModels.opus),
+2 -2
View File
@@ -5265,7 +5265,7 @@ describe('chatStore history mapping', () => {
id: 'provider-1', presetId: 'custom', name: 'DeepSeek', apiKey: 'fixture',
baseUrl: 'http://127.0.0.1:1', apiFormat: 'anthropic',
models: { main: model, haiku: '', sonnet: '', opus: '' },
model1mSupport: { main: enabled, haiku: false, sonnet: false, opus: false },
model1mSupport: { main: enabled, fable: false, haiku: false, sonnet: false, opus: false },
}]
const staleSelection = { providerId: 'provider-1', modelId: `${model}${enabled ? '' : '[1m]'}`, effortLevel: 'high' as const }
const expectedSelection = { ...staleSelection, modelId: `${model}${enabled ? '[1m]' : ''}` }
@@ -5290,7 +5290,7 @@ describe('chatStore history mapping', () => {
id: 'provider-1', presetId: 'custom', name: 'DeepSeek', apiKey: 'fixture',
baseUrl: 'http://127.0.0.1:1', apiFormat: 'anthropic',
models: { main: 'deepseek-v4.1', haiku: '', sonnet: '', opus: '' },
model1mSupport: { main: true, haiku: false, sonnet: false, opus: false },
model1mSupport: { main: true, fable: false, haiku: false, sonnet: false, opus: false },
}]
useChatStore.getState().sendMessage(TEST_SESSION_ID, 'continue')
expect(sendMock.mock.calls.slice(0, 2)).toEqual([
+2 -2
View File
@@ -152,7 +152,7 @@ describe('providerStore runtime refresh', () => {
})
it.each([true, false])('refreshes saved model capabilities for idle, disconnected and draft selections (1m=%s)', async (enabled) => {
const provider = makeProvider({ model1mSupport: { main: enabled, haiku: false, sonnet: false, opus: enabled } })
const provider = makeProvider({ model1mSupport: { main: enabled, fable: false, haiku: false, sonnet: false, opus: enabled } })
providersApiMock.update.mockResolvedValue({ provider })
providersApiMock.list.mockResolvedValue({ providers: [provider], activeId: provider.id })
chatStoreState.sessions = {
@@ -197,7 +197,7 @@ describe('providerStore runtime refresh', () => {
})
it('reconciles restored selections when provider capabilities arrive after connection', async () => {
const provider = makeProvider({ model1mSupport: { main: true, haiku: false, sonnet: false, opus: false } })
const provider = makeProvider({ model1mSupport: { main: true, fable: false, haiku: false, sonnet: false, opus: false } })
providersApiMock.list.mockResolvedValue({ providers: [provider], activeId: provider.id })
chatStoreState.sessions = { restored: { connectionState: 'connected', chatState: 'idle' } }
runtimeStoreState.selections = { restored: { providerId: provider.id, modelId: 'model-main' } }
+43
View File
@@ -1387,6 +1387,49 @@ describe('teamStore workbench timeline', () => {
useTeamStore.getState().clearTeam()
})
it('keeps each member model through the roster read instead of dropping it', async () => {
const snapshot = workbench('v1', 'in_progress')
getWorkbenchForSessionMock.mockResolvedValue({
sessionId: 'lead-session',
teamName: 'team-workbench',
snapshots: [{
...snapshot,
team: {
...snapshot.team,
members: [
{ ...snapshot.team.members[0]!, model: 'claude-opus-4-8' },
// A mapped provider id is not a known family — it must still survive
// verbatim rather than being normalised away.
{ ...snapshot.team.members[1]!, model: 'qwen3.7-plus[1m]' },
],
},
}],
source: 'live',
})
await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true })
const members = useTeamStore.getState()
.workbenchesBySession['lead-session']?.snapshots.at(-1)?.team.members ?? []
expect(members.map(member => member.model)).toEqual(['claude-opus-4-8', 'qwen3.7-plus[1m]'])
})
it('reports no model rather than inventing one when the roster omits it', async () => {
const snapshot = workbench('v1', 'in_progress')
getWorkbenchForSessionMock.mockResolvedValue({
sessionId: 'lead-session',
teamName: 'team-workbench',
snapshots: [snapshot],
source: 'live',
})
await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true })
const members = useTeamStore.getState()
.workbenchesBySession['lead-session']?.snapshots.at(-1)?.team.members ?? []
expect(members.map(member => member.model)).toEqual([undefined, undefined])
})
it('keeps unknown watcher identities out of each roster and refreshes authoritative data', () => {
getTeamMock.mockReturnValue(new Promise(() => {}))
getWorkbenchMock.mockReturnValue(new Promise(() => {}))
+1
View File
@@ -144,6 +144,7 @@ function toTeamMember(raw: Record<string, unknown>): TeamMember {
currentTask: raw.currentTask as string | undefined,
color: raw.color as AgentColor | undefined,
sessionId: raw.sessionId as string | undefined,
model: typeof raw.model === 'string' ? raw.model : undefined,
}
}
+1
View File
@@ -21,6 +21,7 @@ export type ModelMapping = {
export type Model1mSupport = {
main: boolean
fable: boolean
haiku: boolean
sonnet: boolean
opus: boolean
+1
View File
@@ -25,6 +25,7 @@ export type TeamMember = {
currentTask?: string
color?: AgentColor
sessionId?: string
model?: string
}
export type TeamDetail = {
+18 -1
View File
@@ -10,7 +10,7 @@ import { handleSessionCollaborationApi } from './api/sessionCollaboration.js'
import { handleApiRequest } from './router.js'
import { handleWebSocket, type WebSocketData } from './ws/handler.js'
import { resolveCors, type CorsResolution } from './middleware/cors.js'
import { isLoopbackBrowserOrigin, resolveCors, type CorsResolution } from './middleware/cors.js'
import { requireAuth, requireH5Token } from './middleware/auth.js'
import { teamWatcher } from './services/teamWatcher.js'
import { cronScheduler } from './services/cronScheduler.js'
@@ -377,6 +377,23 @@ export function startServer(port = PORT, host = HOST) {
})
const h5AccessControlBlocked = isH5AccessControlRequest(req, url, h5RequestContext)
// A *loopback* renderer's CORS preflight is answered before the
// capability gates. A preflight never carries credentials, so a
// browser-hosted renderer (the Vite dev server on
// http://localhost:<port>) could never reach the API once H5 access was
// disabled: the preflight was rejected with a CORS-less 403, the
// browser refused to send the real request, and the app could only
// report an opaque `TypeError: Failed to fetch`. This grants nothing —
// the actual request is still gated below and still has to present the
// process token. Remote origins deliberately skip this block and keep
// hitting the strict rejection path beneath it.
if (req.method === 'OPTIONS' && origin && isLoopbackBrowserOrigin(origin)) {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
return new Response(null, { status: 204, headers: cors.headers })
}
if (h5AccessControlBlocked) {
return isLocalCredentialOnlyPath(url.pathname)
? localCredentialRejectedResponse()
+1 -1
View File
@@ -45,7 +45,7 @@ function isLocalOrigin(origin?: string | null): boolean {
return LOCAL_DESKTOP_ORIGINS.has(origin) || isLoopbackBrowserOrigin(origin)
}
function isLoopbackBrowserOrigin(origin: string): boolean {
export function isLoopbackBrowserOrigin(origin: string): boolean {
let parsed: URL
try {
parsed = new URL(origin)
@@ -1062,6 +1062,7 @@ describe('cc-switch candidate mapping', () => {
})
expect(candidate.model1mSupport).toEqual({
main: false,
fable: false,
haiku: false,
sonnet: false,
opus: false,
@@ -1093,6 +1094,7 @@ describe('cc-switch candidate mapping', () => {
ANTHROPIC_BASE_URL: 'https://api.example.com',
ANTHROPIC_AUTH_TOKEN: FULL_KEY,
ANTHROPIC_MODEL: 'gemini-3.6-flash-high[1M]',
ANTHROPIC_DEFAULT_FABLE_MODEL: 'gemini-3.6-pro:1m',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3.6-flash-lite',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.6-flash-high:1m',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.6-pro[1m]',
@@ -1103,12 +1105,14 @@ describe('cc-switch candidate mapping', () => {
expect(candidate.models).toEqual({
main: 'gemini-3.6-flash-high',
fable: 'gemini-3.6-pro',
haiku: 'gemini-3.6-flash-lite',
sonnet: 'gemini-3.6-flash-high',
opus: 'gemini-3.6-pro',
})
expect(candidate.model1mSupport).toEqual({
main: true,
fable: true,
haiku: false,
sonnet: true,
opus: true,
+1
View File
@@ -624,6 +624,7 @@ function stripModel1mMarkers(models: ModelMapping): ModelMapping {
function readModel1mSupport(models: ModelMapping): Model1mSupport {
return {
main: hasModel1mMarker(models.main),
fable: models.fable ? hasModel1mMarker(models.fable) : false,
haiku: hasModel1mMarker(models.haiku),
sonnet: hasModel1mMarker(models.sonnet),
opus: hasModel1mMarker(models.opus),
+4 -1
View File
@@ -176,6 +176,7 @@ function normalizeModel1mSupport(
if (!model1mSupport) return undefined
const normalized = {
main: model1mSupport.main === true,
fable: model1mSupport.fable === true,
haiku: model1mSupport.haiku === true,
sonnet: model1mSupport.sonnet === true,
opus: model1mSupport.opus === true,
@@ -209,7 +210,9 @@ function applyModel1mSupportMapping(
): SavedProvider['models'] {
return {
main: applyModel1mSupport(models.main, model1mSupport?.main),
...(models.fable ? { fable: models.fable.trim() } : {}),
...(models.fable !== undefined
? { fable: applyModel1mSupport(models.fable, model1mSupport?.fable) }
: {}),
haiku: applyModel1mSupport(models.haiku, model1mSupport?.haiku),
sonnet: applyModel1mSupport(models.sonnet, model1mSupport?.sonnet),
opus: applyModel1mSupport(models.opus, model1mSupport?.opus),
+10 -2
View File
@@ -269,7 +269,15 @@ function carryForwardArchivedMembers(
const identity = memberArchiveIdentity(historicalMember)
historicalIdentities.add(identity)
const currentMember = currentByIdentity.get(identity)
if (currentMember) return { ...historicalMember, ...currentMember }
if (currentMember) {
return {
...historicalMember,
...currentMember,
...(currentMember.model === undefined && historicalMember.model !== undefined
? { model: historicalMember.model }
: {}),
}
}
return {
...historicalMember,
status: historicalMember.status === 'failed'
@@ -1515,7 +1523,7 @@ export class TeamService {
agentId: m.agentId,
name: m.name,
agentType: m.agentType,
model: m.model,
...(m.model ? { model: m.model } : {}),
color: m.color,
backendType: m.backendType,
status: this.deriveStatus(m.isActive),
+3
View File
@@ -55,6 +55,9 @@ export const ModelMappingSchema = z.object({
export const Model1mSupportSchema = z.object({
main: z.boolean(),
// Defaulted rather than required: providers saved before the Fable slot
// existed carry no `fable` key, and a required field would reject them.
fable: z.boolean().default(false),
haiku: z.boolean(),
sonnet: z.boolean(),
opus: z.boolean(),
+1 -1
View File
@@ -59,7 +59,7 @@ export function getTeammateStatuses(teamName: string): TeammateStatus[] {
name: member.name,
agentId: member.agentId,
agentType: member.agentType,
model: member.model,
...(member.model ? { model: member.model } : {}),
prompt: member.prompt,
status,
color: member.color,