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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ PLATFORM_LLM_PRICE_PER_TOKEN=0.0000002
# 1 CNY by default. The gateway stops platform-funded calls at zero.
INITIAL_QUOTA_CNY=1
USD_CNY_RATE=7.2
# Below this remaining balance the first-run checklist warns that the platform
# balance may not cover a first job and recommends an own model. 50 CNY default.
PLATFORM_QUOTA_COMFORT_CNY=50

# ── Service directory (console links) ─────────────────────────────────────
# Point these at your own deployments if you self-host the sibling services.
Expand Down
1 change: 1 addition & 0 deletions changelog.d/732-repo-model-readiness.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A model selected for a repository now counts as an own model everywhere: the first-run checklist, the dashboard quota action, and the job enqueue and dispatch quota checks all use the same repository selection the gateway and bridge use. Adding a provider alone no longer marks the model step done — the step links to the repository picker until a model is selected — and a repository with a selected model can start a job after the platform quota is exhausted instead of being refused with `QUOTA_EXHAUSTED`. The first-run model step also shows the remaining platform balance: below `PLATFORM_QUOTA_COMFORT_CNY` (default 50) it warns that the balance may not cover the first run and recommends adding an own model now, while at or above it it only names the balance and where to configure a model later.
14 changes: 12 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,16 @@ defect.
the first job. Once the quota is exhausted or the platform model is not
configured, the same step becomes required again; the answer is derived from
the rows and config on every request, so no skip cookie or second store is
needed. The checklist reads the account's unfiltered job count, so a
needed. The step is completed by a repository selection (or the legacy
user-level model), not by a provider row: a provider that no repository has
selected links to the repository picker instead of claiming the step is done.
The quota next action is shown only while no usable own model exists. The
model row prints the remaining balance. Below `PLATFORM_QUOTA_COMFORT_CNY`
(default 50) it warns that the balance may not cover the first run and
recommends an own model now (Settings → Model, or the repository picker when a
provider is already configured); at or above it it only names the balance and
keeps the own-model link as a later option. The checklist reads the account's
unfiltered job count, so a
`status`/`repo`/`kind`/`priority` filter on the recent-jobs table cannot bring
the checklist back after the first job. Once a job exists the checklist is
replaced by a first-run summary
Expand All @@ -108,7 +117,8 @@ defect.
- **Next action** (`src/views/pages.ts`, `nextActionFor`): `/dashboard` opens
with one server-derived action instead of a wall of equal cards. The order is
the order a person would take them — an open question only they can answer, an
exhausted platform quota, the most urgent alert (stalled or slow job, stale
exhausted platform quota when no usable own model exists, the most urgent
alert (stalled or slow job, stale
PR, invalid credential, repository budget), the account's newest failed job,
the unfinished first-run step — and when none applies, an all-clear that names
the known next scheduled automatic run (a repository the scheduler has never
Expand Down
1 change: 1 addition & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Key settings:
| `PLATFORM_LLM_KEY` | Platform key; never commit it |
| `INITIAL_QUOTA_CNY` | Default 1 |
| `USD_CNY_RATE` | Default 7.2 |
| `PLATFORM_QUOTA_COMFORT_CNY` | Default 50; below this the first-run checklist recommends an own model |
| `RUNNER_MODE` | `mock` or `flybot` |
| `JOB_TIMEOUT_MINUTES` / `JOB_STALL_MINUTES` | Hard timeout (default 90) and no-heartbeat stall timeout (default 30) |
| `FLYBOT_BRIDGE_URL` / `FLYBOT_BRIDGE_TOKEN` | Bridge endpoint and shared secret |
Expand Down
10 changes: 10 additions & 0 deletions docs/quota.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ Every user starts with **1 CNY** of platform-funded model usage
user to configure their own OpenAI-compatible model service. Their jobs then run
on their key, with no platform metering.

The first-run model step shows the remaining balance. Below
`PLATFORM_QUOTA_COMFORT_CNY` (default 50) it warns that the balance may not cover
the first run and recommends adding an own model now; at or above it the balance
is shown without a warning and Settings → Model remains the later path. The
threshold changes the checklist advice only; it never changes quota accounting.

## Units

- Ledger: the provider's own **currency**, one bucket per currency (`USD`,
Expand Down Expand Up @@ -75,6 +81,10 @@ stream ends. A request that is already in flight is allowed to finish; the

## Enforcement

- A job is platform-funded only when neither the legacy user-level model nor a
repository-selected provider model exists. The enqueue and dispatch paths use
the same predicate as the bridge and the gateway, so selecting a model for the
repository is enough to pass an exhausted platform quota.
- Before a platform-funded request: if `remaining_cny <= 0`, return HTTP 402
immediately and mark that user's active jobs `quota_exhausted` (their job
tokens are revoked) so the UI does not wait for the job timeout.
Expand Down
1 change: 1 addition & 0 deletions public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@ label.check { min-height: var(--touch); }
.setup-step.is-done .setup-main strong { color: var(--muted); font-weight: 600; }
.setup-done { color: var(--good); font-size: 12px; }
.setup-blocked { color: var(--muted-2); font-size: 12px; }
.setup-warning { color: var(--warn); font-size: 12px; line-height: 1.5; }

.form { display: grid; gap: 13px; }
.form label { display: grid; gap: 6px; font-size: 13px; color: var(--muted); }
Expand Down
23 changes: 19 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
import { InstanceManager } from './flybot/instances.js'
import { dshCookie, proxyWorkspaceHttp, proxyWorkspaceUpgrade } from './flybot/workspace.js'
import { managementActor } from './management/actor.js'
import { ManagementService, type CommandOutcome, type ManagementCode } from './management/service.js'

Check warning on line 114 in src/app.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

src/app.ts:114:55: Type 'ManagementCode' is imported but never used.

Check warning on line 114 in src/app.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

src/app.ts:114:55: Type 'ManagementCode' is imported but never used.

Check warning on line 114 in src/app.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

src/app.ts:114:55: Type 'ManagementCode' is imported but never used.
import type { JobKind } from './jobs/runner.js'
import { listEffectiveScopes, platformCapabilitiesForRole, resolveUserActor, type Capability } from './identity/authorization.js'

Expand Down Expand Up @@ -1232,17 +1232,32 @@
// #734: the checklist describes the account, not the job table. `jobsTotal`
// carries the current filter for the table; the checklist must not read it.
const setupJobsTotal = store.countJobsForUser(viewer.user.id)
// #732: a provider row is not an own model until a repository selects it (or
// the legacy user-level model is set). The gateway and the bridge both read
// the repository selection, so the checklist must use the same answer.
const providerConfigured = store
.listModelProviders(viewer.user.id)
.some((provider) => store.listProviderModels(provider.id).length > 0)
const legacyOwnModel =
viewer.user.own_llm_base_url !== null &&
viewer.user.own_llm_base_url !== '' &&
viewer.user.own_llm_key_enc !== null
const setupQuota = quotaView(viewer.user, config)
const platformAvailable =
config.llm.baseUrl !== '' &&
config.llm.key !== '' &&
!quotaView(viewer.user, config).exhausted
!setupQuota.exhausted
const setup = {
repos: reposTotal,
roleChosen: allRepos.some((repo) => repo.repo_role !== null),
modelConfigured: store
.listModelProviders(viewer.user.id)
.some((provider) => store.listProviderModels(provider.id).length > 0),
providerConfigured,
modelConfigured:
legacyOwnModel ||
allRepos.some((repo) => store.listRepoModelSelection(repo.id).length > 0),
platformAvailable,
platformQuotaRemainingCny: setupQuota.remainingCny,
platformQuotaUnlimited: setupQuota.unlimited,
platformQuotaComfortCny: config.quota.comfortCny,
instanceReady: allRepos.some((repo) => store.getRepoInstance(repo.id)?.status === 'ready'),
jobs: setupJobsTotal,
firstJobStatus: firstJob?.status ?? null,
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export interface Config {
quota: {
initialCny: number
usdCny: number
/** Below this remaining balance the first-run checklist recommends an own model. */
comfortCny: number
}
runner: {
mode: RunnerMode
Expand Down Expand Up @@ -319,6 +321,7 @@ export function loadConfig(overrides: Partial<Config> = {}): Config {
quota: {
initialCny: number('INITIAL_QUOTA_CNY', 1),
usdCny: number('USD_CNY_RATE', 7.2),
comfortCny: number('PLATFORM_QUOTA_COMFORT_CNY', 50),
},
runner: {
mode: (text('RUNNER_MODE', 'mock') as RunnerMode) || 'mock',
Expand Down
17 changes: 15 additions & 2 deletions src/jobs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@
import type { JobKind, RepoRef, Runner } from './runner.js'
import type { InstanceManager } from '../flybot/instances.js'

// #732: the platform pays only when the job has no own model to use. A
// repository selection is an own model too: the bridge patches the instance from
// `listRepoModelSelection` and the gateway resolves the same row, so checking
// only the legacy users.own_llm_* columns refused a job that would never spend
// platform quota.
function platformFundedFor(user: UserRow, store: Store, repoId: string): boolean {
const legacyOwnModel =
user.own_llm_base_url !== null &&
user.own_llm_base_url !== '' &&
user.own_llm_key_enc !== null
return !legacyOwnModel && store.listRepoModelSelection(repoId).length === 0
}

export const DEFAULT_GOAL =
'Review this repository for one high-value defect or improvement. Write a failing test first, implement the smallest correct fix, run the relevant tests, and open a pull request. Do not refactor unrelated code.'

Expand Down Expand Up @@ -825,7 +838,7 @@
}
}

private async preflight(repo: RepoRow, baseBranch: string, trigger: string): Promise<{ ok: boolean; baseSha?: string; warning?: string; error?: string; code?: EnqueueRefusalCode; detail?: string }> {

Check warning on line 841 in src/jobs/service.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

src/jobs/service.ts:841:62: Parameter 'trigger' is declared but never used. Unused parameters should start with a '_'.

Check warning on line 841 in src/jobs/service.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

src/jobs/service.ts:841:62: Parameter 'trigger' is declared but never used. Unused parameters should start with a '_'.

Check warning on line 841 in src/jobs/service.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

src/jobs/service.ts:841:62: Parameter 'trigger' is declared but never used. Unused parameters should start with a '_'.
if (this.runner.name !== 'flybot') return { ok: true }
try {
const token = await repoGithubToken(repo, this.config, this.githubApp, this.store)
Expand Down Expand Up @@ -995,7 +1008,7 @@
const jobId = `job_${randomToken(10)}`
const callbackSecret = randomToken(24)
const callbackHash = sha256Hex(callbackSecret)
const platformFunded = user.own_llm_base_url === null || user.own_llm_base_url === '' || user.own_llm_key_enc === null
const platformFunded = platformFundedFor(user, this.store, repo.id)

if (platformFunded && quotaView(user, this.config).exhausted) {
const job = this.store.createJob({ id: jobId, repoId: repo.id, userId: user.id, trigger, goal, callbackHash, platformTokenHash: '', kind, workType: workType.type, ownerStarted: this.ownerStartedFor(trigger, options) })
Expand Down Expand Up @@ -3131,7 +3144,7 @@
} catch (error) {
return fail(`任务密钥无法解密:${String(error)}`)
}
const platformFunded = user.own_llm_base_url === null || user.own_llm_base_url === '' || user.own_llm_key_enc === null
const platformFunded = platformFundedFor(user, this.store, repo.id)
if (platformFunded && quotaView(user, this.config).exhausted) {
this.finish(job.id, { status: 'quota_exhausted', error: '平台额度已用完,请在 Settings → Model 配置自己的模型 API。' }, revoke)
await this.quotaNotifier.notify(user)
Expand Down
85 changes: 70 additions & 15 deletions src/views/checklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,21 @@ export type SetupStepKey = 'repo' | 'role' | 'model' | 'instance' | 'job'
export interface SetupState {
repos: number
roleChosen: boolean
// A provider with at least one model exists, but it may not be selected by a
// repository yet.
providerConfigured: boolean
// A repository can actually run on an own model: it has a selected provider
// model, or the legacy user-level model is configured. Naming this separately
// from `providerConfigured` is #732: adding a model in Settings is not the same
// as making a repository use it.
modelConfigured: boolean
/** The platform model is configured and the user's platform quota is not exhausted. */
platformAvailable: boolean
/** The remaining platform quota in CNY; `platformQuotaUnlimited` wins when true. */
platformQuotaRemainingCny: number
platformQuotaUnlimited: boolean
/** Config value: below this remaining balance the step warns and recommends an own model. */
platformQuotaComfortCny: number
instanceReady: boolean
jobs: number
// Status of the account's *first* job, all-time, not a window-scoped count:
Expand All @@ -34,6 +46,8 @@ export interface SetupStep {
/** Satisfied by the platform quota rather than an own provider. */
optional?: boolean
note?: string
/** A longer advisory shown under the note, e.g. a low platform balance. */
warning?: string
}

export interface SetupChecklist {
Expand All @@ -52,8 +66,46 @@ export interface FirstRunOutcome {

const TOTAL = 5

// The threshold is `PLATFORM_QUOTA_COMFORT_CNY` in config; the checklist keeps
// no second default, so a deployment changes the advice in one place. Below it
// the platform still pays, but the user is advised to bring an own model before
// the first job; at or above it the balance is shown without a warning.
function cny(value: number): string {
return value > 0 && value < 1 ? `¥${value.toFixed(4)}` : `¥${value.toFixed(2)}`
}

export function setupChecklist(state: SetupState, t: Translate): SetupChecklist {
const repoHref = state.repoHref ?? '#add-repo'
const optionalPlatform = state.platformAvailable && !state.modelConfigured
const quotaText = state.platformQuotaUnlimited ? t('不限额', 'unlimited') : cny(state.platformQuotaRemainingCny)
const lowPlatformQuota = optionalPlatform && !state.platformQuotaUnlimited && state.platformQuotaRemainingCny < state.platformQuotaComfortCny
const platformNote = optionalPlatform
? state.platformQuotaUnlimited
? t('使用平台额度(不限额)', 'Using platform quota (unlimited)')
: t(`使用平台额度(默认)· 剩余 ${quotaText}`, `Using platform quota (default) · ${quotaText} left`)
: undefined
const platformWarning = lowPlatformQuota
? state.providerConfigured
? t(`当前平台额度只剩 ${quotaText},首次运行可能不够用;建议现在为仓库选择模型。`, `Only ${quotaText} of platform quota is left; it may not cover the first run. Choose a model for the repository now.`)
: t(`当前平台额度只剩 ${quotaText},首次运行可能不够用;建议现在在 Settings → Model 配置自己的模型。`, `Only ${quotaText} of platform quota is left; it may not cover the first run. Add your own model in Settings → Model now.`)
: undefined
const modelAction = optionalPlatform
? state.providerConfigured
? {
href: repoHref,
label: lowPlatformQuota
? t('立即选择仓库模型', 'Choose a repository model now')
: t('选择仓库模型(可选)', 'Choose a repository model (optional)'),
}
: {
href: '/settings#model-providers',
label: lowPlatformQuota
? t('立即配置自己的模型', 'Add your own model now')
: t('以后在 Settings → Model 配置', 'Configure later in Settings → Model'),
}
: state.providerConfigured && !state.modelConfigured
? { href: repoHref, label: t('选择仓库模型', 'Choose a repository model') }
: { href: '/settings#model-providers', label: t('打开「设置 → 模型」', 'Open Settings → Model') }
const steps: SetupStep[] = [
{
key: 'repo',
Expand All @@ -71,28 +123,31 @@ export function setupChecklist(state: SetupState, t: Translate): SetupChecklist
},
{
key: 'model',
title: t('添加模型提供方', 'Add a model provider'),
// The gateway falls back to the platform model when the user has not
// configured one. The step is therefore already satisfied while the
// platform model is available and the quota is not exhausted; adding an
// own provider is an optional upgrade, not a prerequisite for the first run.
// #732: a provider row is not an own model until a repository selects it.
// #728: the step is still optional while the platform quota can fund the
// run; when the quota is exhausted or the platform model is missing, a
// provider without a selection is the required next action.
title: !state.modelConfigured && state.providerConfigured && !state.platformAvailable
? t('为仓库选择模型', 'Choose a model for the repository')
: t('添加模型提供方', 'Add a model provider'),
done: state.modelConfigured || state.platformAvailable,
optional: state.platformAvailable && !state.modelConfigured,
note: state.platformAvailable && !state.modelConfigured ? t('使用平台额度(默认)', 'Using platform quota (default)') : undefined,
action: {
href: '/settings#model-providers',
label: state.platformAvailable && !state.modelConfigured
? t('添加自己的模型(可选)', 'Add your own model (optional)')
: t('打开「设置 → 模型」', 'Open Settings → Model'),
},
blocked: state.platformAvailable ? null : state.roleChosen ? null : t('先选择你的角色。', 'Choose your role first.'),
optional: optionalPlatform,
note: platformNote,
warning: platformWarning,
action: modelAction,
// Adding or selecting a model does not depend on the repository role.
blocked: null,
},
{
key: 'instance',
title: t('启动 FlyBot 实例', 'Start the FlyBot instance'),
done: state.instanceReady,
action: { href: repoHref, label: t('打开仓库启动', 'Open the repository') },
blocked: !state.modelConfigured && !state.platformAvailable ? t('先添加一个模型。', 'Add a model first.') : null,
blocked: !state.modelConfigured && !state.platformAvailable
? state.providerConfigured
? t('先为仓库选择一个模型。', 'Choose a model for the repository first.')
: t('先添加一个模型。', 'Add a model first.')
: null,
},
{
key: 'job',
Expand Down
7 changes: 5 additions & 2 deletions src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ function setupSection(setup: SetupState | undefined, t: Translate, primaryElsewh
: ''
return `<li class="setup-step${step.done ? ' is-done' : ''}${current ? ' is-current' : ''}" data-setup-step="${step.key}"${current ? ' aria-current="step"' : ''}>
<span class="setup-mark" aria-hidden="true">${mark}</span>
<div class="setup-main"><strong>${escapeHtml(step.title)}</strong>${status}</div>
<div class="setup-main"><strong>${escapeHtml(step.title)}</strong>${status}${step.warning === undefined ? '' : `<span class="setup-warning">${escapeHtml(step.warning)}</span>`}</div>
${action}
</li>`
})
Expand Down Expand Up @@ -842,7 +842,10 @@ function nextActionFor(input: {
setupStep: null,
}
}
if (quota.exhausted) {
// #732: the platform quota only stops a platform-funded job. A repository
// selection (or the legacy own model) is not platform-funded, so the quota
// card must not tell that user to add a model they already have.
if (quota.exhausted && (setup === undefined || !setup.modelConfigured)) {
return {
kind: 'quota',
title: t('平台额度已用完。', 'Platform quota is used up.'),
Expand Down
Loading
Loading