Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/bot-models.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A repository can assign a selected provider model to each bot role (Chief, Lead, Product, Test, Security, Reviewer, Dev-1, Dev-2). Empty roles follow the default model. Applying models restarts the workspace and Fledge assigns the model through DSH `update-bot`.
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,9 @@ which is default (`repo_model_selection`). Adding a model runs a 1-token test
from Fledge and is refused on failure. The instance patch groups the selected
models under their providers as DSH models, and the gateway maps an alias back
to its provider, key and underlying model. Applying a changed selection rewrites
the patch and restarts the container only when no job is active.
the patch and restarts the container only when no job is active. A repository can
also assign a selected model to each DSH bot role; after the restart Fledge calls
`update-bot` with `model: <provider>/<model>` for every configured role.

### Per-job cost, evidence and metrics

Expand Down
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,7 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
workItems: store.listWorkItems(repo.id),
models: store.listUserProviderModels(repo.user_id),
repoModelIds: repoModels.map((model) => model.id),
repoBotModels: store.listRepoBotModels(repo.id),
repoDefaultModelId: repoModels.find((model) => model.is_default === 1)?.id ?? '',
repoSpendCny: store.repoUsageSince(repo.id, new Date(Date.now() - 30 * 86_400_000).toISOString()) * config.quota.usdCny,
questions,
Expand Down Expand Up @@ -1685,6 +1686,10 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
const requestedModels = [...new Set(formList(request, 'modelIds').filter((id) => allowedModels.has(id)))]
const requestedDefault = (body.defaultModelId ?? '').trim()
const effectiveDefault = requestedDefault !== '' && requestedModels.includes(requestedDefault) ? requestedDefault : requestedModels[0] ?? ''
for (const role of ['chief', 'lead', 'product', 'test', 'security', 'reviewer', 'dev1', 'dev2']) {
const value = (body[`botModel_${role}`] ?? '').trim()
store.setRepoBotModel(repo.id, role, requestedModels.includes(value) ? value : null)
}
const currentModels = store.listRepoModelSelection(repo.id)
const currentDefault = currentModels.find((model) => model.is_default === 1)?.id ?? ''
const currentIds = currentModels.map((model) => model.id).sort().join(',')
Expand Down
37 changes: 34 additions & 3 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ export interface RepoProviderModelRow extends ProviderModelRow {
is_default: number
}

export interface RepoBotModelRow {
repo_id: string
role: string
provider_model_id: string
created_at: string
}

export interface RepoInstanceRow {
repo_id: string
user_id: string
Expand Down Expand Up @@ -620,6 +627,14 @@ CREATE TABLE IF NOT EXISTS repo_model_selection (
PRIMARY KEY (repo_id, provider_model_id)
);

CREATE TABLE IF NOT EXISTS repo_bot_models (
repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
role TEXT NOT NULL,
provider_model_id TEXT NOT NULL REFERENCES provider_models(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (repo_id, role)
);

CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
Expand Down Expand Up @@ -650,9 +665,9 @@ export class Store {
this.db.exec('PRAGMA foreign_keys = ON;')
this.db.exec('PRAGMA busy_timeout = 5000;')
const previousVersion = Number(this.get<{ user_version: number }>('PRAGMA user_version')?.user_version ?? 0)
if (previousVersion > 25) {
if (previousVersion > 26) {
this.db.close()
throw new Error(`database schema version ${previousVersion} is newer than this build supports (25)`)
throw new Error(`database schema version ${previousVersion} is newer than this build supports (26)`)
}
this.db.exec(SCHEMA)
this.migrateColumns()
Expand All @@ -666,7 +681,7 @@ export class Store {
if (previousVersion < 16) {
this.run('UPDATE repos SET role_checked_at = ? WHERE repo_role IS NOT NULL AND role_checked_at IS NULL', nowIso())
}
this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 25)};`)
this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 26)};`)
}

backfillTokenRepoIds(): void {
Expand Down Expand Up @@ -1231,10 +1246,26 @@ export class Store {
for (const modelId of unique) {
this.run('INSERT INTO repo_model_selection (repo_id, provider_model_id, is_default, created_at) VALUES (?, ?, ?, ?)', repoId, modelId, modelId === chosen ? 1 : 0, nowIso())
}
this.run('DELETE FROM repo_bot_models WHERE repo_id = ? AND provider_model_id NOT IN (SELECT provider_model_id FROM repo_model_selection WHERE repo_id = ?)', repoId, repoId)
this.run('UPDATE repos SET models_dirty = 1 WHERE id = ?', repoId)
})
}

listRepoBotModels(repoId: string): RepoBotModelRow[] {
return this.all<RepoBotModelRow>('SELECT * FROM repo_bot_models WHERE repo_id = ? ORDER BY role ASC', repoId)
}

setRepoBotModel(repoId: string, role: string, providerModelId: string | null): void {
if (providerModelId === null || providerModelId === '') {
this.run('DELETE FROM repo_bot_models WHERE repo_id = ? AND role = ?', repoId, role)
return
}
this.run(
'INSERT INTO repo_bot_models (repo_id, role, provider_model_id, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(repo_id, role) DO UPDATE SET provider_model_id = excluded.provider_model_id, created_at = excluded.created_at',
repoId, role, providerModelId, nowIso(),
)
}

createSession(input: { idHash: string; userId: string; expiresAt: string; ip: string | null; userAgent: string | null }): void {
this.run(
'INSERT INTO sessions (id_hash, user_id, created_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)',
Expand Down
39 changes: 38 additions & 1 deletion src/flybot/instances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,42 @@ server.listen(3081, '0.0.0.0')
return lines.join('\n') + '\n'
}

async applyBotModels(repoId: string): Promise<{ updated: number; skipped: string[] }> {
const instance = this.store.getRepoInstance(repoId)
if (instance === undefined || instance.status !== 'ready') return { updated: 0, skipped: [] }
const mappings = this.store.listRepoBotModels(repoId)
if (mappings.length === 0) return { updated: 0, skipped: [] }
const selected = new Map(this.store.listRepoModelSelection(repoId).map((model) => [model.id, model]))
const token = decryptSecret(instance.bridge_token_enc ?? '', this.config.tokenEncryptionKey)
const state = await this.state(instance.bridge_port, token)
const suffix: Record<string, string> = { lead: 'Lead', product: 'Product', test: 'Test', security: 'Security', reviewer: 'Reviewer', dev1: 'Dev-1', dev2: 'Dev-2' }
let updated = 0
const skipped: string[] = []
for (const mapping of mappings) {
const model = selected.get(mapping.provider_model_id)
if (model === undefined) {
skipped.push(mapping.role)
continue
}
const bot = mapping.role === 'chief'
? state.bots.find((entry) => String(entry.name ?? '') === 'Chief')
: state.bots.find((entry) => String(entry.name ?? '').endsWith(` ${suffix[mapping.role] ?? ''}`))
if (bot === undefined || typeof bot.id !== 'string') {
skipped.push(mapping.role)
continue
}
try {
await this.dsh(instance.bridge_port, token, 'update-bot', { id: bot.id, model: `fledge-${model.provider_id}/${model.id}` })
updated += 1
} catch (error) {
skipped.push(mapping.role)
log.warn('bot model update failed', { repo: repoId, role: mapping.role, error: String(error) })
}
}
this.store.audit(instance.user_id, 'flybot.bot_models.applied', `${repoId} updated=${updated} skipped=${skipped.join(',')}`)
return { updated, skipped }
}

async applyModels(repoId: string): Promise<{ ok: boolean; code?: string; error?: string }> {
const instance = this.store.getRepoInstance(repoId)
if (instance === undefined || instance.status !== 'ready') return { ok: false, code: 'INSTANCE_NOT_READY', error: '实例尚未就绪。' }
Expand All @@ -414,7 +450,8 @@ server.listen(3081, '0.0.0.0')
await this.docker(['restart', '-t', '15', instance.container])
await this.waitForDsh(instance.dsh_port, instance)
this.store.markRepoModelsApplied(repoId)
this.store.audit(instance.user_id, 'flybot.models.applied', `${repoId} models=${this.store.listRepoModels(repoId).length}`)
await this.applyBotModels(repoId).catch((error) => log.warn('bot model assignment failed', { repo: repoId, error: String(error) }))
this.store.audit(instance.user_id, 'flybot.models.applied', `${repoId} models=${this.store.listRepoModelSelection(repoId).length}`)
log.info('models applied', { repo: repoId })
return { ok: true }
}
Expand Down
6 changes: 4 additions & 2 deletions src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,13 +460,14 @@ export function dashboardPage(input: {
return layout({ title: t('仓库', 'Repositories'), viewer, body, config, locale, path: '/dashboard', active: 'dashboard', notice, error })
}

export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer; repo: RepoRow; instance?: RepoInstanceRow; jobs: JobRow[]; prJobs?: JobRow[]; workItems?: WorkItemRow[]; models?: Array<ProviderModelRow & { provider_label: string }>; repoModelIds?: string[]; repoDefaultModelId?: string; repoSpendCny?: number; questions?: InboxQuestion[]; chiefMessages?: ChiefMessage[]; activeJob?: boolean; jobsPage?: number; jobsTotal?: number; notice?: string; error?: string }): string {
export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer; repo: RepoRow; instance?: RepoInstanceRow; jobs: JobRow[]; prJobs?: JobRow[]; workItems?: WorkItemRow[]; models?: Array<ProviderModelRow & { provider_label: string }>; repoModelIds?: string[]; repoDefaultModelId?: string; repoBotModels?: Array<{ role: string; provider_model_id: string }>; repoSpendCny?: number; questions?: InboxQuestion[]; chiefMessages?: ChiefMessage[]; activeJob?: boolean; jobsPage?: number; jobsTotal?: number; notice?: string; error?: string }): string {
const { config, locale, viewer, repo, instance, jobs, notice, error } = input
const workItems = input.workItems ?? []
const prJobs = input.prJobs ?? []
const models = input.models ?? []
const repoModelIds = new Set(input.repoModelIds ?? [])
const repoDefaultModelId = input.repoDefaultModelId ?? ''
const botModelMap = new Map((input.repoBotModels ?? []).map((entry) => [entry.role, entry.provider_model_id]))
const repoSpendCny = input.repoSpendCny ?? 0
const questions = input.questions ?? []
const chiefMessages = input.chiefMessages ?? []
Expand Down Expand Up @@ -741,7 +742,8 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
</label>
</div>
<label>${escapeHtml(t('默认模型', 'Default model'))} <select name="defaultModelId"><option value="">${escapeHtml(t('平台默认', 'Platform default'))}</option>${models.map((model) => `<option value="${escapeHtml(model.id)}"${repoDefaultModelId === model.id ? ' selected' : ''}>${escapeHtml(model.provider_label)} · ${escapeHtml(model.label)}${repoModelIds.has(model.id) ? '' : ' *'}</option>`).join('')}</select></label>
<p class="fine">${escapeHtml(t('保存选择后,空闲时点击“应用模型并重启工作区”才会生效。', 'After saving the selection, use "Apply models and restart workspace" while idle for it to take effect.'))}</p>
${models.length === 0 ? '' : `<div class="row">${([['chief', 'Chief'], ['lead', 'Lead'], ['product', 'Product'], ['test', 'Test'], ['security', 'Security'], ['reviewer', 'Reviewer'], ['dev1', 'Dev-1'], ['dev2', 'Dev-2']] as const).map(([role, label]) => `<label>${escapeHtml(label)} <select name="botModel_${role}"><option value="">${escapeHtml(t('跟随默认', 'Follow default'))}</option>${models.filter((model) => repoModelIds.has(model.id)).map((model) => `<option value="${escapeHtml(model.id)}"${botModelMap.get(role) === model.id ? ' selected' : ''}>${escapeHtml(model.provider_label)} · ${escapeHtml(model.label)}</option>`).join('')}</select></label>`).join('')}</div>
<p class="fine">${escapeHtml(t('每个 bot 可以单独选择模型;不选则跟随默认。保存选择后,空闲时点击“应用模型并重启工作区”才会生效,Fledge 会通过 DSH 把模型分配给对应 bot。', 'Each bot can choose a model; empty follows the default. After saving, use "Apply models and restart workspace" while idle; Fledge assigns the model through DSH.'))}</p>`}
${roleMissing ? `<p class="fine">${escapeHtml(t('先选择你的角色,审核和自动合并才会生效。', 'Choose your role before review or auto-merge can take effect.'))}</p>` : !githubCanReview ? `<p class="fine">${escapeHtml(t('你在这个上游没有 admin/maintain 权限,审核角色不可用。', 'You do not have admin/maintain permission on this upstream, so the reviewer role is unavailable.'))}</p>` : !canMaintain ? `<p class="fine">${escapeHtml(t('developer 不能启用 PR 审核或自动合并;切换为 maintainer 后才可用。', 'A developer cannot enable PR review or auto-merge; switch to maintainer first.'))}</p>` : ''}
${advanced ? `<details class="fine advanced-help"><summary>${escapeHtml(t('团队与调度规则', 'Team and scheduling rules'))}</summary>
<p>${escapeHtml(t('保存目标后,团队 playbook 会按新目标更新:总控 → 负责人 → 产品/测试/安全/审核/开发。审核角色只有勾选“启用 PR 审核”后才会参与。', 'Saving the goal updates the team playbook: Chief -> Lead -> Product/Test/Security/Reviewer/Dev. The Reviewer takes part only when "Enable PR reviewer" is checked.'))}</p>
Expand Down
22 changes: 21 additions & 1 deletion test/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ test('the role backfill runs once and future schema versions are refused', () =>
const reopened = new Store(path)
assert.equal(reopened.getRepo(repo.id)?.repo_role, 'maintainer')
assert.notEqual(reopened.getRepo(repo.id)?.role_checked_at, null)
reopened.db.exec('PRAGMA user_version = 26;')
reopened.db.exec('PRAGMA user_version = 27;')
reopened.close()

assert.throws(() => new Store(path), /newer than this build/)
Expand Down Expand Up @@ -351,3 +351,23 @@ test('legacy user models and own model migrate to providers', () => {
store.close()
}
})

test('repository bot model assignments persist and are cleaned with the selection', () => {
const store = new Store(':memory:')
try {
const user = makeUser(store, { login: 'bot-model-user', status: 'approved' })
const repo = store.createRepo({ id: 'repo_bot_models', userId: user.id, owner: 'o', name: 'b', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 })
const provider = store.createModelProvider({ id: 'prv_bot', userId: user.id, label: 'Provider', baseUrl: 'https://p.example/v1', keyEnc: 'enc', allowPrivate: false })
const first = store.createProviderModel({ id: 'pvm_bot_a', providerId: provider.id, model: 'a', label: 'A' })
const second = store.createProviderModel({ id: 'pvm_bot_b', providerId: provider.id, model: 'b', label: 'B' })
store.setRepoModelSelection(repo.id, [first.id, second.id], first.id)
store.setRepoBotModel(repo.id, 'lead', first.id)
store.setRepoBotModel(repo.id, 'product', second.id)
assert.equal(store.listRepoBotModels(repo.id).length, 2)
store.setRepoModelSelection(repo.id, [second.id], second.id)
const remaining = store.listRepoBotModels(repo.id)
assert.deepEqual(remaining.map((entry) => entry.role), ['product'])
} finally {
store.close()
}
})
44 changes: 43 additions & 1 deletion test/db.test.ts.bak
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ test('the role backfill runs once and future schema versions are refused', () =>
const reopened = new Store(path)
assert.equal(reopened.getRepo(repo.id)?.repo_role, 'maintainer')
assert.notEqual(reopened.getRepo(repo.id)?.role_checked_at, null)
reopened.db.exec('PRAGMA user_version = 25;')
reopened.db.exec('PRAGMA user_version = 26;')
reopened.close()

assert.throws(() => new Store(path), /newer than this build/)
Expand Down Expand Up @@ -309,3 +309,45 @@ test('existing instance and job tokens are backfilled with their repository', ()
store.close()
}
})

test('model providers and provider models can be selected per repository', () => {
const store = new Store(':memory:')
try {
const user = makeUser(store, { login: 'provider-user', status: 'approved' })
const repo = store.createRepo({ id: 'repo_provider', userId: user.id, owner: 'o', name: 'p', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 })
const provider = store.createModelProvider({ id: 'prv_a', userId: user.id, label: 'Provider A', baseUrl: 'https://a.example/v1', keyEnc: 'enc', allowPrivate: false })
const first = store.createProviderModel({ id: 'pvm_a1', providerId: provider.id, model: 'model-a', label: 'A' })
const second = store.createProviderModel({ id: 'pvm_a2', providerId: provider.id, model: 'model-b', label: 'B' })
assert.equal(store.listModelProviders(user.id).length, 1)
assert.equal(store.listProviderModels(provider.id).length, 2)
store.setRepoModelSelection(repo.id, [first.id, second.id], second.id)
const selected = store.listRepoModelSelection(repo.id)
assert.deepEqual(selected.map((model) => model.id), [first.id, second.id])
assert.equal(selected.find((model) => model.is_default === 1)?.id, second.id)
assert.equal(store.providerModelForRun(repo.id, second.id)?.model, 'model-b')
assert.equal(store.deleteProviderModel(first.id, user.id), true)
assert.equal(store.listRepoModelSelection(repo.id).length, 1)
} finally {
store.close()
}
})

test('legacy user models and own model migrate to providers', () => {
const store = new Store(':memory:')
try {
const user = makeUser(store, { login: 'legacy-model-user', status: 'approved' })
const repo = store.createRepo({ id: 'repo_legacy_models', userId: user.id, owner: 'o', name: 'l', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 })
const legacy = store.createUserModel({ id: 'mdl_legacy', userId: user.id, label: 'Legacy', baseUrl: 'https://legacy.example/v1', keyEnc: 'enc', model: 'legacy-model', allowPrivate: false })
store.setRepoModels(repo.id, [legacy.id], legacy.id)
store.setUserOwnModel(user.id, 'https://own.example/v1', 'own-enc', 'own-model')
store.deleteSetting('models_v2')
store.migrateModelsV2()
assert.equal(store.listModelProviders(user.id).length, 2)
assert.equal(store.listRepoModelSelection(repo.id).length, 1)
const labels = store.listUserProviderModels(user.id).map((model) => model.model)
assert.ok(labels.includes('legacy-model'))
assert.ok(labels.includes('own-model'))
} finally {
store.close()
}
})
Loading
Loading