From 09cbad88b2a2eb14e886b5027661829026beb47c Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 15 Nov 2026 11:34:07 +0800 Subject: [PATCH] fix: treat a repository model as an own model for quota (#732) --- .env.example | 3 + changelog.d/732-repo-model-readiness.fixed.md | 1 + docs/architecture.md | 14 ++- docs/deploy.md | 1 + docs/quota.md | 10 ++ public/app.css | 1 + src/app.ts | 23 +++- src/config.ts | 3 + src/jobs/service.ts | 17 ++- src/views/checklist.ts | 85 ++++++++++--- src/views/pages.ts | 7 +- test/gateway.test.ts | 10 +- test/jobs.test.ts | 29 +++++ test/nextAction.test.ts | 25 +++- test/nextScheduledRun.test.ts | 1 + test/setupChecklist.test.ts | 118 +++++++++++++++++- test/tokenPolicy.test.ts | 2 +- 17 files changed, 311 insertions(+), 39 deletions(-) create mode 100644 changelog.d/732-repo-model-readiness.fixed.md diff --git a/.env.example b/.env.example index 58bb3fe7..5a3b271d 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/changelog.d/732-repo-model-readiness.fixed.md b/changelog.d/732-repo-model-readiness.fixed.md new file mode 100644 index 00000000..922d1215 --- /dev/null +++ b/changelog.d/732-repo-model-readiness.fixed.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 0ca7c76a..be2a7454 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -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 diff --git a/docs/deploy.md b/docs/deploy.md index 320813ae..103e7e22 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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 | diff --git a/docs/quota.md b/docs/quota.md index b6a62938..b0fcae35 100644 --- a/docs/quota.md +++ b/docs/quota.md @@ -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`, @@ -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. diff --git a/public/app.css b/public/app.css index 1b158cca..b233907f 100644 --- a/public/app.css +++ b/public/app.css @@ -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); } diff --git a/src/app.ts b/src/app.ts index 69ae72b6..79699963 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1232,17 +1232,32 @@ export async function buildApp(options: BuildOptions): Promise { // #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, diff --git a/src/config.ts b/src/config.ts index 98c6bf56..954baf74 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 @@ -319,6 +321,7 @@ export function loadConfig(overrides: Partial = {}): 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', diff --git a/src/jobs/service.ts b/src/jobs/service.ts index 9a97d4bc..0d2ce823 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -55,6 +55,19 @@ import { parseTeam } from '../flybot/instances.js' 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.' @@ -995,7 +1008,7 @@ export class Jobs { 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) }) @@ -3131,7 +3144,7 @@ export class Jobs { } 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) diff --git a/src/views/checklist.ts b/src/views/checklist.ts index 5e95877e..e3a2667a 100644 --- a/src/views/checklist.ts +++ b/src/views/checklist.ts @@ -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: @@ -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 { @@ -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', @@ -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', diff --git a/src/views/pages.ts b/src/views/pages.ts index a48ebe69..dc3d25dc 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -676,7 +676,7 @@ function setupSection(setup: SetupState | undefined, t: Translate, primaryElsewh : '' return `
  • -
    ${escapeHtml(step.title)}${status}
    +
    ${escapeHtml(step.title)}${status}${step.warning === undefined ? '' : `${escapeHtml(step.warning)}`}
    ${action}
  • ` }) @@ -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.'), diff --git a/test/gateway.test.ts b/test/gateway.test.ts index edac9431..5d1b4211 100644 --- a/test/gateway.test.ts +++ b/test/gateway.test.ts @@ -23,7 +23,7 @@ test('platform quota is metered, enforced and bypassed by a user model', async ( const config = testConfig({ llm: { baseUrl: `${platform.url}/v1`, key: 'platform-key', model: 'platform-model', pricePerTokenUsd: 0.0000002 }, - quota: { initialCny: 0.001, usdCny: 7.2 }, + quota: { initialCny: 0.001, usdCny: 7.2, comfortCny: 50 }, }) const store = new Store(':memory:') const mailer = new MemoryMailer() @@ -72,7 +72,7 @@ test('streaming usage with nested details is metered', async () => { }) const config = testConfig({ llm: { baseUrl: `${platform.url}/v1`, key: 'platform-key', model: 'platform-model', pricePerTokenUsd: 0.0000002 }, - quota: { initialCny: 1, usdCny: 7.2 }, + quota: { initialCny: 1, usdCny: 7.2, comfortCny: 50 }, }) const store = new Store(':memory:') const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) @@ -102,7 +102,7 @@ test('unlimited quota is never exhausted but still records usage', async () => { }) const config = testConfig({ llm: { baseUrl: `${platform.url}/v1`, key: 'platform-key', model: 'platform-model', pricePerTokenUsd: 0.0000002 }, - quota: { initialCny: 0, usdCny: 7.2 }, + quota: { initialCny: 0, usdCny: 7.2, comfortCny: 50 }, }) const store = new Store(':memory:') const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) @@ -134,7 +134,7 @@ test('a job-scoped token records usage and cost on the job', async () => { await readBody(request) json(response, 200, { id: 'x', choices: [{ message: { role: 'assistant', content: 'ok' } }], usage: { total_tokens: 1000 } }, { 'x-litellm-response-cost': '0.0002' }) }) - const config = testConfig({ llm: { baseUrl: `${platform.url}/v1`, key: 'platform-key', model: 'platform-model', pricePerTokenUsd: 0.0000002 }, quota: { initialCny: 10, usdCny: 7.2 } }) + const config = testConfig({ llm: { baseUrl: `${platform.url}/v1`, key: 'platform-key', model: 'platform-model', pricePerTokenUsd: 0.0000002 }, quota: { initialCny: 10, usdCny: 7.2, comfortCny: 50 } }) const store = new Store(':memory:') const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) try { @@ -158,7 +158,7 @@ test('gateway tokens are scoped to their user', async () => { await readBody(request) json(response, 200, { id: 'x', choices: [{ message: { role: 'assistant', content: 'ok' } }], usage: { total_tokens: 1000 } }, { 'x-litellm-response-cost': '0.0002' }) }) - const config = testConfig({ llm: { baseUrl: `${platform.url}/v1`, key: 'platform-key', model: 'platform-model', pricePerTokenUsd: 0.0000002 }, quota: { initialCny: 10, usdCny: 7.2 } }) + const config = testConfig({ llm: { baseUrl: `${platform.url}/v1`, key: 'platform-key', model: 'platform-model', pricePerTokenUsd: 0.0000002 }, quota: { initialCny: 10, usdCny: 7.2, comfortCny: 50 } }) const store = new Store(':memory:') const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) try { diff --git a/test/jobs.test.ts b/test/jobs.test.ts index f8700d64..7ecf7028 100644 --- a/test/jobs.test.ts +++ b/test/jobs.test.ts @@ -266,6 +266,35 @@ test('a successful callback stores attribution and posts a PR automation comment } }) +test('a repository model bypasses the platform quota check in enqueue and dispatch', async () => { + const base = testConfig() + const config = testConfig({ runner: { ...base.runner, queueDispatch: true } }) + const store = new Store(':memory:') + const runner: Runner = { name: 'capture', async run() { return { status: 'running' } } } + const jobs = new Jobs(store, config, runner, new MemoryMailer()) + try { + const user = makeUser(store, { login: 'repo-model-quota', status: 'approved', quota: 0 }) + const repo = store.createRepo({ id: 'repo_model_quota', userId: user.id, owner: 'o', name: 'model-quota', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'developer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + const plain = store.createRepo({ id: 'repo_plain_quota', userId: user.id, owner: 'o', name: 'plain-quota', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'developer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + + const provider = store.createModelProvider({ id: 'prv_model_quota', userId: user.id, label: 'Own', baseUrl: 'https://own.example/v1', keyEnc: encryptSecret('own-key', config.tokenEncryptionKey), allowPrivate: false }) + const model = store.createProviderModel({ id: 'pvm_model_quota', providerId: provider.id, model: 'own-model', label: 'Own model' }) + store.setRepoModelSelection(repo.id, [model.id], model.id) + + const queued = await jobs.enqueue(repo, 'manual') + assert.equal(queued.ok, true, 'a repository selection is an own model') + await jobs.tick() + const dispatched = store.getJob(queued.job!.id) + assert.notEqual(dispatched?.status, 'quota_exhausted', 'dispatch uses the same own-model answer as enqueue') + + const refused = await jobs.enqueue(plain, 'manual') + assert.equal(refused.ok, false) + assert.equal(refused.code, 'QUOTA_EXHAUSTED', 'a repository without a selection is still platform-funded') + } finally { + store.close() + } +}) + test('quotaBlocked fails that user\'s active jobs and revokes their tokens', async () => { const config = testConfig() const store = new Store(':memory:') diff --git a/test/nextAction.test.ts b/test/nextAction.test.ts index dd18c8de..1ca7e979 100644 --- a/test/nextAction.test.ts +++ b/test/nextAction.test.ts @@ -27,8 +27,11 @@ interface PageInput { const completeSetup: SetupState = { repos: 1, roleChosen: true, + providerConfigured: true, modelConfigured: true, platformAvailable: true, + platformQuotaRemainingCny: 50, + platformQuotaUnlimited: false, platformQuotaComfortCny: 50, instanceReady: true, jobs: 1, firstJobStatus: 'succeeded', @@ -194,7 +197,7 @@ test('the all-clear names the repository that runs next, not one with no known r }) test('an unfinished first run keeps exactly one primary action, in the next-action block', () => { - const setup: SetupState = { repos: 1, roleChosen: false, modelConfigured: false, platformAvailable: false, instanceReady: false, jobs: 0, firstJobStatus: null, firstPrUrl: null, repoHref: '/repos/repo_1' } + const setup: SetupState = { repos: 1, roleChosen: false, providerConfigured: false, modelConfigured: false, platformAvailable: false, platformQuotaRemainingCny: 0, platformQuotaUnlimited: false, platformQuotaComfortCny: 50, instanceReady: false, jobs: 0, firstJobStatus: null, firstPrUrl: null, repoHref: '/repos/repo_1' } const html = render('en', { setup }) const section = nextActionBlock(html) assert.match(section, /data-next-action="setup"/) @@ -208,7 +211,7 @@ test('an unfinished first run keeps exactly one primary action, in the next-acti }) test('platform quota satisfies the model step, so the next action is the instance', () => { - const setup: SetupState = { repos: 1, roleChosen: true, modelConfigured: false, platformAvailable: true, instanceReady: false, jobs: 0, firstJobStatus: null, firstPrUrl: null, repoHref: '/repos/repo_1' } + const setup: SetupState = { repos: 1, roleChosen: true, providerConfigured: false, modelConfigured: false, platformAvailable: true, platformQuotaRemainingCny: 50, platformQuotaUnlimited: false, platformQuotaComfortCny: 50, instanceReady: false, jobs: 0, firstJobStatus: null, firstPrUrl: null, repoHref: '/repos/repo_1' } const html = render('en', { setup }) const section = nextActionBlock(html) assert.match(section, /data-next-action="setup"/) @@ -218,12 +221,26 @@ test('platform quota satisfies the model step, so the next action is the instanc }) test('without platform quota the missing model is still the next action', () => { - const setup: SetupState = { repos: 1, roleChosen: true, modelConfigured: false, platformAvailable: false, instanceReady: false, jobs: 0, firstJobStatus: null, firstPrUrl: null, repoHref: '/repos/repo_1' } + const setup: SetupState = { repos: 1, roleChosen: true, providerConfigured: false, modelConfigured: false, platformAvailable: false, platformQuotaRemainingCny: 0, platformQuotaUnlimited: false, platformQuotaComfortCny: 50, instanceReady: false, jobs: 0, firstJobStatus: null, firstPrUrl: null, repoHref: '/repos/repo_1' } const section = nextActionBlock(render('en', { setup })) assert.match(section, /data-next-action="setup"/) assert.match(section, /Add a model provider/) }) +test('an own repository model keeps the quota card out of the next action', () => { + const setup: SetupState = { repos: 1, roleChosen: true, providerConfigured: true, modelConfigured: true, platformAvailable: false, platformQuotaRemainingCny: 0, platformQuotaUnlimited: false, platformQuotaComfortCny: 50, instanceReady: true, jobs: 1, firstJobStatus: 'succeeded', firstPrUrl: null, repoHref: '/repos/repo_1' } + const html = render('en', { setup }, (store, user) => { store.setUserQuota(user.id, 0) }) + const section = nextActionBlock(html) + assert.doesNotMatch(section, /data-next-action="quota"/, 'a selected repository model is not platform-funded') + assert.match(section, /Nothing needs you/, 'the exhausted platform quota is not the user\'s problem') +}) + +test('without an own model the exhausted quota is still the next action', () => { + const setup: SetupState = { repos: 1, roleChosen: true, providerConfigured: false, modelConfigured: false, platformAvailable: false, platformQuotaRemainingCny: 0, platformQuotaUnlimited: false, platformQuotaComfortCny: 50, instanceReady: false, jobs: 0, firstJobStatus: null, firstPrUrl: null, repoHref: '/repos/repo_1' } + const section = nextActionBlock(render('en', { setup }, (store, user) => { store.setUserQuota(user.id, 0) })) + assert.match(section, /data-next-action="quota"/) +}) + test('secondary sections collapse into one disclosure and lose nothing', () => { const html = render('en', { setup: completeSetup }) const disclosure = html.match(/]*data-secondary[^>]*>/)?.[0] ?? '' @@ -240,7 +257,7 @@ test('secondary sections collapse into one disclosure and lose nothing', () => { }) test('the disclosure opens when the next action points inside it', () => { - const setup: SetupState = { repos: 0, roleChosen: false, modelConfigured: false, platformAvailable: false, instanceReady: false, jobs: 0, firstJobStatus: null, firstPrUrl: null, repoHref: null } + const setup: SetupState = { repos: 0, roleChosen: false, providerConfigured: false, modelConfigured: false, platformAvailable: false, platformQuotaRemainingCny: 0, platformQuotaUnlimited: false, platformQuotaComfortCny: 50, instanceReady: false, jobs: 0, firstJobStatus: null, firstPrUrl: null, repoHref: null } const html = render('en', { setup }) const section = nextActionBlock(html) assert.deepEqual(targets(section), ['#add-repo'], 'the first step is adding a repository') diff --git a/test/nextScheduledRun.test.ts b/test/nextScheduledRun.test.ts index 2ccc301d..972c0659 100644 --- a/test/nextScheduledRun.test.ts +++ b/test/nextScheduledRun.test.ts @@ -206,6 +206,7 @@ test('the dashboard answers with the schedule row, not the interval, when one ex }) store.createModelProvider({ id: 'prov_scheduled', userId: user.id, label: 'p', baseUrl: 'https://example.test', keyEnc: 'x', allowPrivate: false }) store.createProviderModel({ id: 'pm_scheduled', providerId: 'prov_scheduled', model: 'm', label: 'm' }) + store.setRepoModelSelection(repo.id, ['pm_scheduled'], 'pm_scheduled') store.createJob({ id: 'job_scheduled', repoId: repo.id, userId: user.id, trigger: 'manual', goal: 'g', callbackHash: 'cb', platformTokenHash: '' }) store.updateJob('job_scheduled', { status: 'succeeded' }) const row = store.saveSchedule({ id: 'sch_scheduled', repoId: repo.id, kind: 'cron', expression: '0 9 * * 1-5', timezone: 'Asia/Shanghai' }) diff --git a/test/setupChecklist.test.ts b/test/setupChecklist.test.ts index 44d82202..9689aab1 100644 --- a/test/setupChecklist.test.ts +++ b/test/setupChecklist.test.ts @@ -21,8 +21,12 @@ function state(patch: Partial = {}): SetupState { return { repos: 0, roleChosen: false, + providerConfigured: false, modelConfigured: false, platformAvailable: false, + platformQuotaRemainingCny: 0, + platformQuotaUnlimited: false, + platformQuotaComfortCny: 50, instanceReady: false, jobs: 0, firstJobStatus: null, @@ -54,9 +58,10 @@ test('a brand-new account starts at step 1 and every later step names its blocke assert.equal(checklist.next?.key, 'repo') assert.equal(checklist.next?.blocked, null, 'step 1 has no prerequisite') assert.equal(checklist.next?.action.href, '#add-repo') - for (const step of checklist.steps.slice(1)) { + for (const step of checklist.steps.filter((entry) => entry.key !== 'model').slice(1)) { assert.notEqual(step.blocked, null, `${step.key} names the step that blocks it`) } + assert.equal(checklist.steps.find((step) => step.key === 'model')?.blocked, null, 'adding a model does not depend on the role') assert.deepEqual(checklist.steps.map((step) => step.key), ['repo', 'role', 'model', 'instance', 'job']) }) @@ -82,23 +87,71 @@ test('the next step follows the data, and a completed step stops being the actio assert.equal(withInstance.complete, false, 'the job is the step that finishes the run') }) +test('a provider without a repository selection is not a completed model step', () => { + const providerOnly = setupChecklist(state({ repos: 1, roleChosen: true, providerConfigured: true, modelConfigured: false, repoHref: '/repos/r1' }), en) + assert.equal(providerOnly.next?.key, 'model') + assert.equal(providerOnly.next?.title, 'Choose a model for the repository') + assert.equal(providerOnly.next?.action.href, '/repos/r1', 'the add form is not the next step once a provider exists') + assert.equal(providerOnly.steps.find((step) => step.key === 'instance')?.blocked, 'Choose a model for the repository first.') + + const selected = setupChecklist(state({ repos: 1, roleChosen: true, providerConfigured: true, modelConfigured: true, repoHref: '/repos/r1' }), en) + assert.equal(selected.next?.key, 'instance', 'a repository selection is the own model the gateway and bridge use') + assert.equal(selected.steps.find((step) => step.key === 'model')?.done, true) + assert.equal(selected.steps.find((step) => step.key === 'instance')?.blocked, null) +}) + test('platform quota satisfies the model step and skips its NEXT ACTION', () => { - const withRole = setupChecklist(state({ repos: 1, roleChosen: true, platformAvailable: true, repoHref: '/repos/r1' }), en) + const withRole = setupChecklist(state({ repos: 1, roleChosen: true, platformAvailable: true, platformQuotaRemainingCny: 50, repoHref: '/repos/r1' }), en) assert.equal(withRole.next?.key, 'instance', 'the missing own model must not be the next action while platform quota is available') const model = withRole.steps.find((step) => step.key === 'model') assert.ok(model !== undefined) assert.equal(model.done, true) assert.equal(model.optional, true) - assert.equal(model.note, 'Using platform quota (default)') - assert.equal(model.action.label, 'Add your own model (optional)') + assert.equal(model.note, 'Using platform quota (default) · ¥50.00 left') + assert.equal(model.warning, undefined, 'a comfortable balance does not warn') + assert.equal(model.action.label, 'Configure later in Settings → Model') assert.equal(model.blocked, null) assert.equal(withRole.steps.find((step) => step.key === 'instance')?.blocked, null) + const unlimited = setupChecklist(state({ repos: 1, roleChosen: true, platformAvailable: true, platformQuotaUnlimited: true, repoHref: '/repos/r1' }), en) + const unlimitedModel = unlimited.steps.find((step) => step.key === 'model') + assert.equal(unlimitedModel?.note, 'Using platform quota (unlimited)') + assert.equal(unlimitedModel?.warning, undefined) + assert.equal(unlimitedModel?.action.label, 'Configure later in Settings → Model') + const complete = setupChecklist(state({ repos: 1, roleChosen: true, platformAvailable: true, instanceReady: true, jobs: 1, firstJobStatus: 'succeeded', repoHref: '/repos/r1' }), en) assert.equal(complete.done, 5) assert.equal(complete.complete, true) }) +test('the quota comfort threshold defaults to 50 and reads the env override', () => { + assert.equal(testConfig().quota.comfortCny, 50) + assert.equal(testConfig({ quota: { initialCny: 1, usdCny: 7.2, comfortCny: 123 } }).quota.comfortCny, 123) +}) + +test('a low platform balance recommends an own model when the model step is skipped', () => { + const low = setupChecklist(state({ repos: 1, roleChosen: true, platformAvailable: true, platformQuotaRemainingCny: 1, repoHref: '/repos/r1' }), en) + const model = low.steps.find((step) => step.key === 'model') + assert.match(model?.note ?? '', /¥1\.00/) + assert.match(model?.warning ?? '', /may not cover the first run/) + assert.match(model?.warning ?? '', /Settings → Model/) + assert.equal(model?.action.label, 'Add your own model now') + assert.equal(model?.optional, true) + + const providerLow = setupChecklist(state({ repos: 1, roleChosen: true, providerConfigured: true, platformAvailable: true, platformQuotaRemainingCny: 1, repoHref: '/repos/r1' }), en) + const providerModel = providerLow.steps.find((step) => step.key === 'model') + assert.equal(providerModel?.action.href, '/repos/r1') + assert.equal(providerModel?.action.label, 'Choose a repository model now') + assert.match(providerModel?.warning ?? '', /Choose a model for the repository now/) +}) + +test('the quota comfort threshold comes from config, not a second constant', () => { + const strict = setupChecklist(state({ repos: 1, roleChosen: true, platformAvailable: true, platformQuotaRemainingCny: 50, platformQuotaComfortCny: 100, repoHref: '/repos/r1' }), en) + assert.notEqual(strict.steps.find((step) => step.key === 'model')?.warning, undefined, 'a 100 CNY threshold warns at 50 CNY') + const relaxed = setupChecklist(state({ repos: 1, roleChosen: true, platformAvailable: true, platformQuotaRemainingCny: 50, platformQuotaComfortCny: 50, repoHref: '/repos/r1' }), en) + assert.equal(relaxed.steps.find((step) => step.key === 'model')?.warning, undefined, 'a 50 CNY threshold is comfortable at 50 CNY') +}) + test('the dashboard shows platform quota instead of a model requirement when the platform can fund the run', async () => { const config = testConfig() config.llm.baseUrl = 'https://platform.example/v1' @@ -114,6 +167,9 @@ test('the dashboard shows platform quota instead of a model requirement when the assert.match(page.body, /data-setup-next="instance"/) assert.match(page.body, /Using platform quota/) assert.match(page.body, /Add your own model/) + assert.match(page.body, /Only ¥1\.00 of platform quota is left/) + assert.match(page.body, /may not cover the first run/) + assert.match(page.body, /setup-warning/) const nextAction = page.body.match(/
    /)?.[0] ?? '' assert.match(nextAction, /Start the FlyBot instance/, 'the platform-funded run does not stop at the model step') assert.doesNotMatch(nextAction, /Add a model provider/, 'the optional model step is not the primary action') @@ -123,6 +179,49 @@ test('the dashboard shows platform quota instead of a model requirement when the } }) +test('a comfortable platform balance only names the remaining quota', async () => { + const config = testConfig() + config.llm.baseUrl = 'https://platform.example/v1' + config.llm.key = 'platform-key' + const store = new Store(':memory:') + const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) + try { + const user = makeUser(store, { login: 'platform-quota-50', status: 'approved', quota: 50 }) + const session = sessionFor(built, user) + store.createRepo({ id: 'repo_platform_50', userId: user.id, owner: 'o', name: 'platform-50', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'developer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + const page = await built.app.inject({ method: 'GET', url: '/dashboard', headers: { cookie: `${session.cookie}; fledge_lang=en` } }) + assert.equal(page.statusCode, 200) + assert.match(page.body, /Using platform quota \(default\) · ¥50\.00 left/) + assert.match(page.body, /Configure later in Settings → Model/) + assert.doesNotMatch(page.body, /may not cover the first run/) + assert.doesNotMatch(page.body, /setup-warning/) + } finally { + await built.app.close() + store.close() + } +}) + +test('the dashboard uses the configured comfort threshold', async () => { + const config = testConfig() + config.llm.baseUrl = 'https://platform.example/v1' + config.llm.key = 'platform-key' + config.quota.comfortCny = 100 + const store = new Store(':memory:') + const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) + try { + const user = makeUser(store, { login: 'platform-quota-configured', status: 'approved', quota: 50 }) + const session = sessionFor(built, user) + store.createRepo({ id: 'repo_platform_configured', userId: user.id, owner: 'o', name: 'platform-configured', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'developer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + const page = await built.app.inject({ method: 'GET', url: '/dashboard', headers: { cookie: `${session.cookie}; fledge_lang=en` } }) + assert.equal(page.statusCode, 200) + assert.match(page.body, /Only ¥50\.00 of platform quota is left/, 'the route passes config.quota.comfortCny to the checklist') + assert.match(page.body, /may not cover the first run/) + } finally { + await built.app.close() + store.close() + } +}) + test('the checklist completes on the first job, not on the instance being ready', () => { const complete = setupChecklist(state({ repos: 2, roleChosen: true, modelConfigured: true, instanceReady: true, jobs: 1, firstJobStatus: 'succeeded' }), en) assert.equal(complete.done, 5) @@ -215,6 +314,17 @@ test('the route detects the step from rows, so a second browser or a cleared coo const withRole = await built.app.inject({ method: 'GET', url: '/dashboard', headers: { cookie } }) assert.match(withRole.body, /data-setup-next="model"/, 'role chosen and no model: the model step is next') assert.match(withRole.body, /Open Settings → Model/, 'the next action points at the exact form') + + const provider = store.createModelProvider({ id: 'prv_route', userId: user.id, label: 'Own', baseUrl: 'https://own.example/v1', keyEnc: 'x', allowPrivate: false }) + const model = store.createProviderModel({ id: 'pvm_route', providerId: provider.id, model: 'own-model', label: 'Own model' }) + const withProvider = await built.app.inject({ method: 'GET', url: '/dashboard', headers: { cookie } }) + assert.match(withProvider.body, /data-setup-next="model"/, 'a provider alone is not a repository model') + assert.match(withProvider.body, /Choose a model for the repository/, 'the next action is the repository picker') + assert.match(withProvider.body, /href="\/repos\/repo_route"/, 'and it links to the repository') + + store.setRepoModelSelection('repo_route', [model.id], model.id) + const withSelection = await built.app.inject({ method: 'GET', url: '/dashboard', headers: { cookie } }) + assert.match(withSelection.body, /data-setup-next="instance"/, 'a selected repository model completes the model step') } finally { await built.app.close() store.close() diff --git a/test/tokenPolicy.test.ts b/test/tokenPolicy.test.ts index 2951e9b0..563d691a 100644 --- a/test/tokenPolicy.test.ts +++ b/test/tokenPolicy.test.ts @@ -255,7 +255,7 @@ test('a personal token named like a job token cannot write another user\'s job', }) const config = testConfig({ llm: { baseUrl: `${platform.url}/v1`, key: 'platform-key', model: 'platform-model', pricePerTokenUsd: 0.0000002 }, - quota: { initialCny: 10, usdCny: 7.2 }, + quota: { initialCny: 10, usdCny: 7.2, comfortCny: 50 }, }) const store = new Store(':memory:') const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false })