Skip to content

Commit 58e1f8e

Browse files
bleleveclaude
andcommitted
feat: deployment-wide default model and default plan model
Extends the existing `model_preferences` table with `default_model` and `default_plan_model` columns. Surfaces them via a new section in Settings → Models so the deployment-wide defaults are configurable from the UI instead of being scattered across Terraform env vars on each worker. Why: with plan-mode (preceding PR in stack), every deployment now needs two default models — one for build turns, one for plan turns. The control-plane is the natural source of truth (already serves `enabledModels` via `/model-preferences`). The bots and the web UI read from this single place instead of each worker carrying its own `DEFAULT_MODEL` env var that must be kept in sync. Changes: - D1 migration `0021_add_default_models_to_model_preferences.sql` adds two nullable columns via `ALTER TABLE ADD COLUMN`. Existing deployments keep working because the read path falls back to the env var, then to the shared library constant. - `db/model-preferences.ts` extended to read/write the new fields atomically across the three-field tuple. - `GET /model-preferences` now returns `{ enabledModels, defaultModel, defaultPlanModel }`; `PUT` validates `defaultModel ∈ enabledModels`. - Web Settings → Models gets a new "Default Models" section with two combobox pickers. Disabling a model that's the current default is blocked inline. - Terraform: production workers gain `DEFAULT_MODEL` (control-plane, which didn't have one) and `DEFAULT_PLAN_MODEL` (all four). These remain fallbacks — the DB value wins when set. - `docs/GETTING_STARTED.md` gets a "Configure Default Models" subsection. Verification: `npm run typecheck`, `npm run lint`, `npm test` (shared 183/183, control-plane 1162/1162, web 259/259) — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ca76bda commit 58e1f8e

13 files changed

Lines changed: 783 additions & 56 deletions

File tree

docs/GETTING_STARTED.md

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -318,21 +318,8 @@ Save these values somewhere secure—you'll need them in the next step.
318318
```bash
319319
cd terraform/environments/production
320320
321-
# Copy the example files
321+
# Copy the example file and fill in values
322322
cp terraform.tfvars.example terraform.tfvars
323-
cp backend.tfvars.example backend.tfvars
324-
```
325-
326-
### Configure `backend.tfvars`
327-
328-
Fill in your R2 credentials:
329-
330-
```hcl
331-
access_key = "your-r2-access-key-id"
332-
secret_key = "your-r2-secret-access-key"
333-
endpoints = {
334-
s3 = "https://YOUR_CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com"
335-
}
336323
```
337324

338325
### Configure `terraform.tfvars`
@@ -454,8 +441,11 @@ Then run:
454441
```bash
455442
cd terraform/environments/production
456443
457-
# Initialize Terraform with backend config
458-
terraform init -backend-config=backend.tfvars
444+
# Initialize Terraform with R2 backend credentials
445+
terraform init \
446+
-backend-config="access_key=YOUR_R2_ACCESS_KEY_ID" \
447+
-backend-config="secret_key=YOUR_R2_SECRET_ACCESS_KEY" \
448+
-backend-config='endpoints={s3="https://YOUR_CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com"}'
459449
460450
# Deploy (phase 1 - creates workers without bindings)
461451
terraform apply
@@ -645,13 +635,38 @@ curl -I https://open-inspect-web-{deployment_name}.YOUR-SUBDOMAIN.workers.dev
645635
3. Create a new session with a repository
646636
4. Send a prompt and verify the sandbox starts
647637
638+
### Configure Default Models
639+
640+
The web UI exposes a **Settings → Models → Default Models** section that controls which build and
641+
plan models the deployment uses by default. Bots (Linear, GitHub, Slack) read these values at
642+
session-creation time, so changes propagate without a Terraform redeploy.
643+
644+
1. Open **Settings → Models** in the web UI.
645+
2. Under **Default Models**, pick:
646+
- **Default model** — the build model used when no per-request override is in play.
647+
- **Default plan model** — the model that runs the planning turn when plan mode is enabled.
648+
3. Save. The values are stored in D1; bots fall back to the worker's `DEFAULT_MODEL` /
649+
`DEFAULT_PLAN_MODEL` env var only when the control plane is unreachable, then to a shared
650+
constant.
651+
652+
Disabling a model that is the current default is blocked inline — pick a new default first.
653+
654+
See [PLAN_MODE.md](PLAN_MODE.md) for the full plan-mode workflow these defaults feed into.
655+
648656
---
649657
650658
## Step 10: Set Up CI/CD (Optional)
651659
652-
Enable automatic deployments when you push to main by adding GitHub Secrets.
660+
Enable automatic deployments by configuring GitHub Environments and secrets:
661+
662+
- **`main` branch** → deploys **staging** (`terraform/environments/staging`)
663+
- **`stable` branch** → deploys **production** (`terraform/environments/production`)
664+
665+
Create `staging` and `production` environments under Settings → Environments. Add secrets to each
666+
environment (or use repository-level secrets shared by both). Pull requests to `main` run
667+
`terraform plan` against staging.
653668
654-
Go to your fork's Settings → Secrets and variables → Actions, and add:
669+
Go to your fork's Settings → Secrets and variables → Actions (or per-environment secrets), and add:
655670
656671
| Secret Name | Value |
657672
| ----------------------------- | ----------------------------------------------------------------------------- |
@@ -719,8 +734,9 @@ gh secret set GH_APP_PRIVATE_KEY < private-key-pkcs8.pem
719734
720735
Once configured, the GitHub Actions workflow will:
721736
722-
- Run `terraform plan` on pull requests (with PR comment)
723-
- Run `terraform apply` when merged to main
737+
- Run `terraform plan` on pull requests to `main` (staging, with PR comment)
738+
- Run `terraform apply` on pushes to `main` (staging)
739+
- Run `terraform apply` on pushes to `stable` (production)
724740
725741
---
726742
@@ -749,7 +765,10 @@ terraform apply
749765
Re-run init with backend config:
750766
751767
```bash
752-
terraform init -backend-config=backend.tfvars
768+
terraform init \
769+
-backend-config="access_key=YOUR_R2_ACCESS_KEY_ID" \
770+
-backend-config="secret_key=YOUR_R2_SECRET_ACCESS_KEY" \
771+
-backend-config='endpoints={s3="https://YOUR_CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com"}'
753772
```
754773
755774
### GitHub App authentication fails

packages/control-plane/src/db/model-preferences.ts

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,54 @@ export class ModelPreferencesValidationError extends Error {
77
}
88
}
99

10+
export interface ModelPreferences {
11+
enabledModels: string[];
12+
defaultModel: string | null;
13+
defaultPlanModel: string | null;
14+
}
15+
16+
interface ModelPreferencesRow {
17+
enabled_models: string;
18+
default_model: string | null;
19+
default_plan_model: string | null;
20+
}
21+
1022
export class ModelPreferencesStore {
1123
constructor(private readonly db: D1Database) {}
1224

1325
/**
14-
* Get the list of enabled model IDs, or null if no preferences stored.
26+
* Get the full singleton preferences row, or null if no preferences stored.
1527
*/
16-
async getEnabledModels(): Promise<string[] | null> {
28+
async getPreferences(): Promise<ModelPreferences | null> {
1729
const row = await this.db
18-
.prepare("SELECT enabled_models FROM model_preferences WHERE id = 'global'")
19-
.first<{ enabled_models: string }>();
30+
.prepare(
31+
"SELECT enabled_models, default_model, default_plan_model FROM model_preferences WHERE id = 'global'"
32+
)
33+
.first<ModelPreferencesRow>();
2034

2135
if (!row) return null;
2236

23-
return JSON.parse(row.enabled_models) as string[];
37+
return {
38+
enabledModels: JSON.parse(row.enabled_models) as string[],
39+
defaultModel: row.default_model,
40+
defaultPlanModel: row.default_plan_model,
41+
};
2442
}
2543

2644
/**
27-
* Set the list of enabled model IDs.
28-
* Validates all IDs against VALID_MODELS.
45+
* Back-compat shim. Prefer getPreferences() for new callers.
2946
*/
30-
async setEnabledModels(modelIds: string[]): Promise<void> {
31-
const unique = [...new Set(modelIds)];
47+
async getEnabledModels(): Promise<string[] | null> {
48+
return (await this.getPreferences())?.enabledModels ?? null;
49+
}
50+
51+
/**
52+
* Atomically persist the three preference fields. defaultModel /
53+
* defaultPlanModel may be null (= delegate to env/shared fallback). When
54+
* non-null, they must be members of enabledModels.
55+
*/
56+
async setPreferences(prefs: ModelPreferences): Promise<void> {
57+
const unique = [...new Set(prefs.enabledModels)];
3258
const invalid = unique.filter((id) => !isValidModel(id));
3359
if (invalid.length > 0) {
3460
throw new ModelPreferencesValidationError(`Invalid model IDs: ${invalid.join(", ")}`);
@@ -38,16 +64,46 @@ export class ModelPreferencesStore {
3864
throw new ModelPreferencesValidationError("At least one model must be enabled");
3965
}
4066

67+
const enabledSet = new Set(unique);
68+
69+
if (prefs.defaultModel !== null) {
70+
if (!isValidModel(prefs.defaultModel)) {
71+
throw new ModelPreferencesValidationError(
72+
`Invalid default model ID: ${prefs.defaultModel}`
73+
);
74+
}
75+
if (!enabledSet.has(prefs.defaultModel)) {
76+
throw new ModelPreferencesValidationError(
77+
`Default model "${prefs.defaultModel}" is not in the enabled models list`
78+
);
79+
}
80+
}
81+
82+
if (prefs.defaultPlanModel !== null) {
83+
if (!isValidModel(prefs.defaultPlanModel)) {
84+
throw new ModelPreferencesValidationError(
85+
`Invalid default plan model ID: ${prefs.defaultPlanModel}`
86+
);
87+
}
88+
if (!enabledSet.has(prefs.defaultPlanModel)) {
89+
throw new ModelPreferencesValidationError(
90+
`Default plan model "${prefs.defaultPlanModel}" is not in the enabled models list`
91+
);
92+
}
93+
}
94+
4195
const now = Date.now();
4296
await this.db
4397
.prepare(
44-
`INSERT INTO model_preferences (id, enabled_models, updated_at)
45-
VALUES ('global', ?, ?)
98+
`INSERT INTO model_preferences (id, enabled_models, default_model, default_plan_model, updated_at)
99+
VALUES ('global', ?, ?, ?, ?)
46100
ON CONFLICT(id) DO UPDATE SET
47-
enabled_models = excluded.enabled_models,
48-
updated_at = excluded.updated_at`
101+
enabled_models = excluded.enabled_models,
102+
default_model = excluded.default_model,
103+
default_plan_model = excluded.default_plan_model,
104+
updated_at = excluded.updated_at`
49105
)
50-
.bind(JSON.stringify(unique), now)
106+
.bind(JSON.stringify(unique), prefs.defaultModel, prefs.defaultPlanModel, now)
51107
.run();
52108
}
53109
}

0 commit comments

Comments
 (0)