Skip to content
Open
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
106 changes: 106 additions & 0 deletions docs/MODELS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Adding or Updating Models

All model definitions live in one file: **`packages/shared/src/models.ts`**. Anything else that
needs to know about models imports from there. Treat this file as the single source of truth — if
you only edit it in one place, that place is `models.ts`.

## What `packages/shared/src/models.ts` defines

| Export | Purpose |
| ------------------------ | ---------------------------------------------------------- |
| `VALID_MODELS` | Every canonical `provider/model` id the system understands |
| `ValidModel` | Union type derived from `VALID_MODELS` |
| `DEFAULT_MODEL` | Fallback when nothing else is configured |
| `MODEL_REASONING_CONFIG` | Which reasoning efforts each model supports + the default |
| `MODEL_OPTIONS` | Display name + description, grouped by provider, for UI |
| `DEFAULT_ENABLED_MODELS` | Subset enabled out-of-the-box for new installations |
| `MODEL_ALIASES` | User-friendly shortcuts (`opus`, `sonnet-4-6`, …) |
| `normalizeModelId` | Adds `anthropic/` / `openai/` prefix to bare ids |
| `resolveModelAlias` | Alias lookup + `normalizeModelId` in one call |
| `isValidModel` | Returns true if the (normalized) id is in `VALID_MODELS` |

## Workflow: adding a new model

### 1. Add the canonical id

Edit `packages/shared/src/models.ts`:

1. Add the `provider/model` id to **`VALID_MODELS`**.
2. Add a **`MODEL_REASONING_CONFIG`** entry if the model supports reasoning efforts. Omit the entry
for models that don't (they'll silently fall through `supportsReasoning`).
3. Add a **`MODEL_OPTIONS`** entry under the right provider group so it shows up in dropdowns.
Include a short `description`.
4. If the model should ship enabled by default, add it to **`DEFAULT_ENABLED_MODELS`** as well.

### 2. (Optional) Update aliases

If the new model is the new "latest" for a family (e.g. you're shipping `claude-opus-4-8` as the
next Opus), update the family alias in **`MODEL_ALIASES`** so `model: opus` directives and
`model:opus` Linear labels start pointing at it:

```ts
opus: "anthropic/claude-opus-4-8",
```

Also add an explicit version alias so users can pin (`opus-4-8` → `anthropic/claude-opus-4-8`) when
they don't want the floating "latest".

You only need alias entries for IDs that `normalizeModelId` can't infer:

- `claude-*` and `gpt-*` bare ids work without an alias.
- Bare versions like `opus-4-8`, `sonnet-4-6` need an alias entry.
- Family shortcuts like `opus`, `sonnet`, `haiku` need an alias entry.

### 3. Rebuild shared and run the suite

```bash
npm run build -w @open-inspect/shared
npm run typecheck
npm test
```

Every other package consumes the shared package's built output, so the rebuild is mandatory before
typechecking the rest of the workspace.

### 4. (Optional) Change the deployed default

`DEFAULT_MODEL` in `packages/shared/src/models.ts` is the in-code fallback. Production deployments
override it via Terraform env bindings:

- `terraform/environments/production/workers-github.tf` — `DEFAULT_MODEL`
- `terraform/environments/production/workers-linear.tf` — `DEFAULT_MODEL`
- `terraform/environments/production/workers-slack.tf` — `DEFAULT_MODEL` and `CLASSIFICATION_MODEL`

Update those bindings only if you want the new model to be the live default for that bot. They're
independent of the in-code defaults so each bot can roll out at its own pace.

### 5. Things you do **not** need to edit

- Per-bot alias maps. There aren't any — both `github-bot` and `linear-bot` import
`resolveModelAlias` from shared. If you find yourself adding a local `MODEL_ALIASES` constant in a
bot, stop and put it in shared instead.
- The control-plane resolver — it just passes through whatever string it receives.
- The web UI — `MODEL_OPTIONS` is the only thing it reads, and dropdowns are generated from it.

## Workflow: removing a model

1. Delete the id from `VALID_MODELS`, `MODEL_REASONING_CONFIG`, `MODEL_OPTIONS`, and
`DEFAULT_ENABLED_MODELS`.
2. Update any `MODEL_ALIASES` entry that pointed at it (either retarget to a newer model in the same
family or remove the alias).
3. Search for hard-coded references and clean them up:
```bash
git grep "<old-model-id>"
```
4. Existing D1/SQLite/KV rows with the old id will fall through to `DEFAULT_MODEL` via
`getValidModelOrDefault` — no migration needed unless you want to backfill stored values.

## Where user-facing alias strings are accepted

- **GitHub bot `@mention` comments:** inline directive `@bot model: <alias> reasoning: <effort> …` —
parsed in `packages/github-bot/src/inline-directive.ts`.
- **Linear issue labels:** `model:<alias>` — parsed in
`packages/linear-bot/src/model-resolution.ts`.

Both call `resolveModelAlias` from shared, so any alias added to `MODEL_ALIASES` works in both
places automatically.
9 changes: 9 additions & 0 deletions packages/control-plane/src/db/integration-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,15 @@ export class IntegrationSettingsStore {
throw new IntegrationSettingsValidationError("commentActionInstructions must be a string");
}

if (
settings.allowInlineDirectiveOverride !== undefined &&
typeof settings.allowInlineDirectiveOverride !== "boolean"
) {
throw new IntegrationSettingsValidationError(
"allowInlineDirectiveOverride must be a boolean"
);
}

if (settings.allowedTriggerUsers !== undefined) {
if (
!Array.isArray(settings.allowedTriggerUsers) ||
Expand Down
1 change: 1 addition & 0 deletions packages/control-plane/src/routes/integration-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ async function handleGetResolvedConfig(
allowedTriggerUsers: githubSettings.allowedTriggerUsers ?? null,
codeReviewInstructions: githubSettings.codeReviewInstructions ?? null,
commentActionInstructions: githubSettings.commentActionInstructions ?? null,
allowInlineDirectiveOverride: githubSettings.allowInlineDirectiveOverride ?? true,
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,54 @@ describe("Integration settings API", () => {
expect(body.config.allowedTriggerUsers).toEqual(["carol"]);
});

it("returns allowInlineDirectiveOverride: true by default when unset", async () => {
const headers = await authHeaders();

await SELF.fetch("https://test.local/integration-settings/github", {
method: "PUT",
headers,
body: JSON.stringify({
settings: {
defaults: { autoReviewOnOpen: true },
},
}),
});

const res = await SELF.fetch(
"https://test.local/integration-settings/github/resolved/acme/widgets",
{ headers }
);
expect(res.status).toBe(200);
const body = await res.json<{
config: { allowInlineDirectiveOverride: boolean };
}>();
expect(body.config.allowInlineDirectiveOverride).toBe(true);
});

it("respects explicit allowInlineDirectiveOverride: false", async () => {
const headers = await authHeaders();

await SELF.fetch("https://test.local/integration-settings/github", {
method: "PUT",
headers,
body: JSON.stringify({
settings: {
defaults: { allowInlineDirectiveOverride: false },
},
}),
});

const res = await SELF.fetch(
"https://test.local/integration-settings/github/resolved/acme/widgets",
{ headers }
);
expect(res.status).toBe(200);
const body = await res.json<{
config: { allowInlineDirectiveOverride: boolean };
}>();
expect(body.config.allowInlineDirectiveOverride).toBe(false);
});

it("returns linear resolved config with merged defaults", async () => {
const headers = await authHeaders();

Expand Down
105 changes: 91 additions & 14 deletions packages/github-bot/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
} from "./types";
import type { Logger } from "./logger";
import { generateInstallationToken, postReaction, checkSenderPermission } from "./github-auth";
import { parseInlineDirective, type ParsedDirective } from "./inline-directive";
import { resolveSessionModelSettings, type ResolvedModelSettings } from "./model-resolution";
import { buildCodeReviewPrompt, buildCommentActionPrompt } from "./prompts";
import { getGitHubConfig, type ResolvedGitHubConfig } from "./utils/integration-config";

Expand Down Expand Up @@ -86,6 +88,53 @@ function stripMention(body: string, botUsername: string): string {
return body.replace(new RegExp(`@${escaped}`, "gi"), "").trim();
}

function resolveModelForSession(
env: Env,
config: ResolvedGitHubConfig,
directive?: ParsedDirective
): ResolvedModelSettings {
return resolveSessionModelSettings({
envDefaultModel: env.DEFAULT_MODEL,
configModel: config.model,
configReasoningEffort: config.reasoningEffort,
allowInlineDirectiveOverride: config.allowInlineDirectiveOverride ?? true,
directiveModel: directive?.model,
directiveReasoningEffort: directive?.reasoningEffort,
});
}

/**
* Build the directive observability fields that augment the `session.created` log.
* When a directive override actually applied, returns
* `{ directive_applied: true, directive_model, directive_reasoning }`. Otherwise
* returns `{ directive_applied: false }`.
*/
function directiveLogFields(
config: ResolvedGitHubConfig,
directive: ParsedDirective | undefined,
resolved: ResolvedModelSettings
): Record<string, unknown> {
if (!directive) return { directive_applied: false };

const allowed = config.allowInlineDirectiveOverride ?? true;
const modelApplied =
allowed && directive.model !== undefined && resolved.model === directive.model;
const reasoningApplied =
allowed &&
directive.reasoningEffort !== undefined &&
resolved.reasoningEffort === directive.reasoningEffort;

if (!modelApplied && !reasoningApplied) {
return { directive_applied: false };
}

return {
directive_applied: true,
directive_model: modelApplied ? directive.model : null,
directive_reasoning: reasoningApplied ? directive.reasoningEffort : null,
};
}

function fireAndForgetReaction(
log: Logger,
token: string,
Expand Down Expand Up @@ -210,17 +259,23 @@ export async function handleReviewRequested(
meta
);

const resolved = resolveModelForSession(env, config);
const sessionId = await createSession(env.CONTROL_PLANE, headers, {
repoOwner: owner,
repoName,
title: `GitHub: Review PR #${pr.number}`,
model: config.model,
reasoningEffort: config.reasoningEffort,
model: resolved.model,
reasoningEffort: resolved.reasoningEffort,
scmLogin: sender.login,
scmUserId: String(sender.id),
scmAvatarUrl: sender.avatar_url,
});
log.info("session.created", { ...meta, session_id: sessionId, action: "review" });
log.info("session.created", {
...meta,
session_id: sessionId,
action: "review",
...directiveLogFields(config, undefined, resolved),
});

const prompt = buildCodeReviewPrompt({
owner,
Expand Down Expand Up @@ -310,17 +365,23 @@ export async function handlePullRequestOpened(
meta
);

const resolved = resolveModelForSession(env, config);
const sessionId = await createSession(env.CONTROL_PLANE, headers, {
repoOwner: owner,
repoName,
title: `GitHub: Review PR #${pr.number}`,
model: config.model,
reasoningEffort: config.reasoningEffort,
model: resolved.model,
reasoningEffort: resolved.reasoningEffort,
scmLogin: sender.login,
scmUserId: String(sender.id),
scmAvatarUrl: sender.avatar_url,
});
log.info("session.created", { ...meta, session_id: sessionId, action: "auto_review" });
log.info("session.created", {
...meta,
session_id: sessionId,
action: "auto_review",
...directiveLogFields(config, undefined, resolved),
});

const prompt = buildCodeReviewPrompt({
owner,
Expand Down Expand Up @@ -405,7 +466,9 @@ export async function handleIssueComment(
if (!gating.allowed) return { outcome: "skipped", skip_reason: gating.reason };
const { ghToken, headers } = gating;

const commentBody = stripMention(comment.body, env.GITHUB_BOT_USERNAME);
const mentionStripped = stripMention(comment.body, env.GITHUB_BOT_USERNAME);
const directive = parseInlineDirective(mentionStripped);
const commentBody = directive.cleanedBody;

const meta = { trace_id: traceId, repo: repoFullName, pull_number: issue.number };
fireAndForgetReaction(
Expand All @@ -416,17 +479,23 @@ export async function handleIssueComment(
meta
);

const resolved = resolveModelForSession(env, config, directive);
const sessionId = await createSession(env.CONTROL_PLANE, headers, {
repoOwner: owner,
repoName,
title: `GitHub: PR #${issue.number} comment`,
model: config.model,
reasoningEffort: config.reasoningEffort,
model: resolved.model,
reasoningEffort: resolved.reasoningEffort,
scmLogin: sender.login,
scmUserId: String(sender.id),
scmAvatarUrl: sender.avatar_url,
});
log.info("session.created", { ...meta, session_id: sessionId, action: "comment" });
log.info("session.created", {
...meta,
session_id: sessionId,
action: "comment",
...directiveLogFields(config, directive, resolved),
});

const prompt = buildCommentActionPrompt({
owner,
Expand Down Expand Up @@ -504,7 +573,9 @@ export async function handleReviewComment(
if (!gating.allowed) return { outcome: "skipped", skip_reason: gating.reason };
const { ghToken, headers } = gating;

const commentBody = stripMention(comment.body, env.GITHUB_BOT_USERNAME);
const mentionStripped = stripMention(comment.body, env.GITHUB_BOT_USERNAME);
const directive = parseInlineDirective(mentionStripped);
const commentBody = directive.cleanedBody;

const meta = { trace_id: traceId, repo: repoFullName, pull_number: pr.number };
fireAndForgetReaction(
Expand All @@ -515,17 +586,23 @@ export async function handleReviewComment(
meta
);

const resolved = resolveModelForSession(env, config, directive);
const sessionId = await createSession(env.CONTROL_PLANE, headers, {
repoOwner: owner,
repoName,
title: `GitHub: PR #${pr.number} review comment`,
model: config.model,
reasoningEffort: config.reasoningEffort,
model: resolved.model,
reasoningEffort: resolved.reasoningEffort,
scmLogin: sender.login,
scmUserId: String(sender.id),
scmAvatarUrl: sender.avatar_url,
});
log.info("session.created", { ...meta, session_id: sessionId, action: "review_comment" });
log.info("session.created", {
...meta,
session_id: sessionId,
action: "review_comment",
...directiveLogFields(config, directive, resolved),
});

const prompt = buildCommentActionPrompt({
owner,
Expand Down
Loading