diff --git a/docs/MODELS.md b/docs/MODELS.md new file mode 100644 index 000000000..d949c8a0b --- /dev/null +++ b/docs/MODELS.md @@ -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 "" + ``` +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: reasoning: …` — + parsed in `packages/github-bot/src/inline-directive.ts`. +- **Linear issue labels:** `model:` — 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. diff --git a/packages/control-plane/src/db/integration-settings.ts b/packages/control-plane/src/db/integration-settings.ts index 614f0ee6f..710fba16e 100644 --- a/packages/control-plane/src/db/integration-settings.ts +++ b/packages/control-plane/src/db/integration-settings.ts @@ -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) || diff --git a/packages/control-plane/src/routes/integration-settings.ts b/packages/control-plane/src/routes/integration-settings.ts index ab8b6efa5..b5f3c50cb 100644 --- a/packages/control-plane/src/routes/integration-settings.ts +++ b/packages/control-plane/src/routes/integration-settings.ts @@ -312,6 +312,7 @@ async function handleGetResolvedConfig( allowedTriggerUsers: githubSettings.allowedTriggerUsers ?? null, codeReviewInstructions: githubSettings.codeReviewInstructions ?? null, commentActionInstructions: githubSettings.commentActionInstructions ?? null, + allowInlineDirectiveOverride: githubSettings.allowInlineDirectiveOverride ?? true, }, }); } diff --git a/packages/control-plane/test/integration/integration-settings.test.ts b/packages/control-plane/test/integration/integration-settings.test.ts index 9a7ff3c01..8249d8c44 100644 --- a/packages/control-plane/test/integration/integration-settings.test.ts +++ b/packages/control-plane/test/integration/integration-settings.test.ts @@ -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(); diff --git a/packages/github-bot/src/handlers.ts b/packages/github-bot/src/handlers.ts index 9f6167665..044c482bf 100644 --- a/packages/github-bot/src/handlers.ts +++ b/packages/github-bot/src/handlers.ts @@ -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"; @@ -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 { + 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, @@ -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, @@ -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, @@ -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( @@ -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, @@ -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( @@ -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, diff --git a/packages/github-bot/src/inline-directive.ts b/packages/github-bot/src/inline-directive.ts new file mode 100644 index 000000000..8956b50b8 --- /dev/null +++ b/packages/github-bot/src/inline-directive.ts @@ -0,0 +1,114 @@ +/** + * Parses inline `model:` and `reasoning:` directives out of a comment body. + * + * Grammar: + * - Case-insensitive keys: `model: `, `reasoning: `. + * - Word-boundary required: must be preceded by start-of-string or whitespace, + * followed by whitespace or end-of-string. Prevents URL fragments like + * `https://example.com/model:opus` and code-span prefixes like `` `model:opus `` from matching. + * - First occurrence per key wins; subsequent occurrences are still stripped from + * `cleanedBody` so the prompt does not contain stray directive text. + * - Model values may be a bare alias (`opus`, `sonnet-4-6`) or fully-qualified + * (`anthropic/claude-opus-4-7`); normalized via `normalizeModelId` and + * validated via `isValidModel`. Invalid model → field unset, token still stripped. + * - Reasoning values are validated globally here (against the shared union); if a + * directive model is supplied, also validated against that model. The final + * model-specific compatibility check happens in `resolveSessionModelSettings`. + */ + +import { + isValidModel, + isValidReasoningEffort, + normalizeModelId, + resolveModelAlias, +} from "@open-inspect/shared"; + +export interface ParsedDirective { + /** Validated, normalized "provider/model" id, or undefined if no valid model directive. */ + model?: string; + /** Raw reasoning string; further validated against the resolved model downstream. */ + reasoningEffort?: string; + /** Body with directive tokens (and one trailing whitespace each) removed. */ + cleanedBody: string; +} + +/** + * Reasoning effort values accepted globally across all providers. Mirrors the + * `ReasoningEffort` union in `@open-inspect/shared`. + */ +const GLOBAL_REASONING_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh", "max"]); + +/** + * Match a directive token. Anchored on either start-of-string or a whitespace char + * (captured into group 1) so we can preserve correct spacing when stripping. + * + * Captures: + * 1. leading boundary (empty string at start-of-string, otherwise a single whitespace char) + * 2. key ("model" or "reasoning"), case-insensitive + * 3. value (run of non-whitespace chars) + */ +const DIRECTIVE_RE = /(^|\s)(model|reasoning)\s*:\s*(\S+)/gi; + +export function parseInlineDirective(body: string): ParsedDirective { + if (!body) { + return { cleanedBody: "" }; + } + + let firstModelRaw: string | undefined; + let firstReasoningRaw: string | undefined; + + // Walk all matches once to locate first occurrences; we need a separate pass + // for stripping because `replace` semantics differ from match-walking when we + // also want to consume one trailing whitespace character. + const scanRe = new RegExp(DIRECTIVE_RE.source, "gi"); + for (let m = scanRe.exec(body); m !== null; m = scanRe.exec(body)) { + const key = m[2].toLowerCase(); + const value = m[3]; + if (key === "model" && firstModelRaw === undefined) { + firstModelRaw = value; + } else if (key === "reasoning" && firstReasoningRaw === undefined) { + firstReasoningRaw = value; + } + } + + // Strip every directive token from the body. We replace each directive with + // its leading boundary char (preserving whitespace between surrounding text) + // and then collapse any resulting runs of whitespace to a single space. This + // approach is robust to back-to-back directives where the second directive's + // leading whitespace would otherwise be consumed by the first match. + const stripRe = new RegExp(DIRECTIVE_RE.source, "gi"); + const cleanedBody = body + .replace(stripRe, (_match, lead: string) => lead) + .replace(/[ \t]+/g, " ") + .trim(); + + let model: string | undefined; + if (firstModelRaw !== undefined) { + const normalized = resolveModelAlias(firstModelRaw); + if (isValidModel(normalized)) { + model = normalizeModelId(normalized); + } + } + + let reasoningEffort: string | undefined; + if (firstReasoningRaw !== undefined) { + const value = firstReasoningRaw.toLowerCase(); + if (GLOBAL_REASONING_EFFORTS.has(value)) { + // If a directive model is also supplied, only keep reasoning when compatible + // with it. Otherwise let the resolver validate against the resolved model. + if (model !== undefined) { + if (isValidReasoningEffort(model, value)) { + reasoningEffort = value; + } + } else { + reasoningEffort = value; + } + } + } + + return { + cleanedBody, + ...(model !== undefined ? { model } : {}), + ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), + }; +} diff --git a/packages/github-bot/src/model-resolution.ts b/packages/github-bot/src/model-resolution.ts new file mode 100644 index 000000000..e8836f169 --- /dev/null +++ b/packages/github-bot/src/model-resolution.ts @@ -0,0 +1,74 @@ +/** + * Resolves the model and reasoning effort for a single GitHub bot session. + * + * Priority (highest first): + * 1. Inline directive (when allowed and the directive carries a valid model) + * 2. Resolved integration config (`configModel`) + * 3. Env-level floor (`envDefaultModel`) + * + * Reasoning effort follows the chosen model: a directive reasoning effort can + * override config reasoning if compatible with whichever model wins. Reasoning + * that is incompatible with the resolved model is silently dropped (becomes null). + * + * Intentionally a near-clone of `packages/linear-bot/src/model-resolution.ts`; the + * github and linear bots own their resolvers separately so the policies can drift. + */ + +import { isValidModel, isValidReasoningEffort, normalizeModelId } from "@open-inspect/shared"; + +export interface ResolveInput { + envDefaultModel: string; + configModel: string | null; + configReasoningEffort: string | null; + allowInlineDirectiveOverride: boolean; + directiveModel?: string; + directiveReasoningEffort?: string; +} + +export interface ResolvedModelSettings { + model: string; + reasoningEffort: string | null; +} + +export function resolveSessionModelSettings(input: ResolveInput): ResolvedModelSettings { + const directiveAllowed = input.allowInlineDirectiveOverride === true; + + // Step 1: did the directive supply a valid model? + let model: string; + let modelFromDirective = false; + if (directiveAllowed && input.directiveModel && isValidModel(input.directiveModel)) { + model = normalizeModelId(input.directiveModel); + modelFromDirective = true; + } else { + // Fall back to config model (if valid) or env default. Note: we honor the + // *passed-in* env default rather than the shared `DEFAULT_MODEL` constant + // because Terraform sets the env-level floor explicitly. + if (input.configModel && isValidModel(input.configModel)) { + model = normalizeModelId(input.configModel); + } else { + model = normalizeModelId(input.envDefaultModel); + } + } + + // Step 2: pick reasoning. Directive reasoning wins if allowed and compatible. + if ( + directiveAllowed && + input.directiveReasoningEffort && + isValidReasoningEffort(model, input.directiveReasoningEffort) + ) { + return { model, reasoningEffort: input.directiveReasoningEffort }; + } + + // If the directive supplied an *incompatible* reasoning along with a model, + // do not fall back to the config reasoning — the user explicitly asked to override. + if (modelFromDirective) { + return { model, reasoningEffort: null }; + } + + // No directive in play: use config reasoning if compatible with the resolved model. + if (input.configReasoningEffort && isValidReasoningEffort(model, input.configReasoningEffort)) { + return { model, reasoningEffort: input.configReasoningEffort }; + } + + return { model, reasoningEffort: null }; +} diff --git a/packages/github-bot/src/utils/integration-config.ts b/packages/github-bot/src/utils/integration-config.ts index db4193ff6..f7b33a7de 100644 --- a/packages/github-bot/src/utils/integration-config.ts +++ b/packages/github-bot/src/utils/integration-config.ts @@ -10,6 +10,8 @@ export interface ResolvedGitHubConfig { allowedTriggerUsers: string[] | null; codeReviewInstructions: string | null; commentActionInstructions: string | null; + /** Whether per-comment `model:` / `reasoning:` directives may override config. */ + allowInlineDirectiveOverride: boolean; } const FAIL_CLOSED: Omit = { @@ -19,6 +21,7 @@ const FAIL_CLOSED: Omit = { allowedTriggerUsers: [], codeReviewInstructions: null, commentActionInstructions: null, + allowInlineDirectiveOverride: true, }; export async function getGitHubConfig( @@ -62,6 +65,7 @@ export async function getGitHubConfig( allowedTriggerUsers: string[] | null; codeReviewInstructions: string | null; commentActionInstructions: string | null; + allowInlineDirectiveOverride?: boolean; } | null; }; @@ -74,6 +78,7 @@ export async function getGitHubConfig( allowedTriggerUsers: null, codeReviewInstructions: null, commentActionInstructions: null, + allowInlineDirectiveOverride: true, }; } @@ -85,5 +90,6 @@ export async function getGitHubConfig( allowedTriggerUsers: data.config.allowedTriggerUsers, codeReviewInstructions: data.config.codeReviewInstructions, commentActionInstructions: data.config.commentActionInstructions, + allowInlineDirectiveOverride: data.config.allowInlineDirectiveOverride ?? true, }; } diff --git a/packages/github-bot/test/handlers.test.ts b/packages/github-bot/test/handlers.test.ts index a0c1e9d12..e1b6e9e35 100644 --- a/packages/github-bot/test/handlers.test.ts +++ b/packages/github-bot/test/handlers.test.ts @@ -31,6 +31,7 @@ vi.mock("../src/utils/integration-config", () => ({ allowedTriggerUsers: null, codeReviewInstructions: null, commentActionInstructions: null, + allowInlineDirectiveOverride: true, }), })); @@ -42,6 +43,7 @@ const defaultConfig: ResolvedGitHubConfig = { allowedTriggerUsers: null, codeReviewInstructions: null, commentActionInstructions: null, + allowInlineDirectiveOverride: true, }; import { @@ -689,6 +691,7 @@ describe("integration config", () => { allowedTriggerUsers: [], codeReviewInstructions: null, commentActionInstructions: null, + allowInlineDirectiveOverride: true, }); const env = createMockEnv(); const log = createMockLogger(); @@ -915,6 +918,115 @@ describe("integration config", () => { expect(promptBody.content).toContain("Prefer minimal diffs."); }); + it("inline directive in @mention overrides model and strips token from prompt", async () => { + const env = createMockEnv(); + const log = createMockLogger(); + const payload: IssueCommentPayload = { + ...issueCommentPayload, + comment: { + ...issueCommentPayload.comment, + body: "@test-bot[bot] model: opus reasoning: max please review carefully", + }, + }; + + await handleIssueComment(env, log, payload, "trace-directive"); + + const cpFetch = getControlPlaneFetch(env); + const sessionBody = JSON.parse(cpFetch.mock.calls[0][1].body); + expect(sessionBody.model).toBe("anthropic/claude-opus-4-7"); + expect(sessionBody.reasoningEffort).toBe("max"); + + const promptBody = JSON.parse(cpFetch.mock.calls[1][1].body); + expect(promptBody.content).toContain("please review carefully"); + expect(promptBody.content).not.toMatch(/\bmodel:/i); + expect(promptBody.content).not.toMatch(/\breasoning:/i); + + expect(log.info).toHaveBeenCalledWith( + "session.created", + expect.objectContaining({ + directive_applied: true, + directive_model: "anthropic/claude-opus-4-7", + directive_reasoning: "max", + }) + ); + }); + + it("inline directive ignored when allowInlineDirectiveOverride=false but tokens still stripped", async () => { + vi.mocked(getGitHubConfig).mockResolvedValue({ + ...defaultConfig, + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: "high", + allowInlineDirectiveOverride: false, + }); + const env = createMockEnv(); + const log = createMockLogger(); + const payload: IssueCommentPayload = { + ...issueCommentPayload, + comment: { + ...issueCommentPayload.comment, + body: "@test-bot[bot] model: opus please review", + }, + }; + + await handleIssueComment(env, log, payload, "trace-directive-disabled"); + + const cpFetch = getControlPlaneFetch(env); + const sessionBody = JSON.parse(cpFetch.mock.calls[0][1].body); + expect(sessionBody.model).toBe("anthropic/claude-sonnet-4-6"); + expect(sessionBody.reasoningEffort).toBe("high"); + + const promptBody = JSON.parse(cpFetch.mock.calls[1][1].body); + expect(promptBody.content).not.toMatch(/\bmodel:/i); + + expect(log.info).toHaveBeenCalledWith( + "session.created", + expect.objectContaining({ directive_applied: false }) + ); + }); + + it("inline directive in review comment overrides model and strips token", async () => { + const env = createMockEnv(); + const log = createMockLogger(); + const payload: ReviewCommentPayload = { + ...reviewCommentPayload, + comment: { + ...reviewCommentPayload.comment, + body: "@test-bot[bot] model: opus fix this code", + }, + }; + + await handleReviewComment(env, log, payload, "trace-rc-directive"); + + const cpFetch = getControlPlaneFetch(env); + const sessionBody = JSON.parse(cpFetch.mock.calls[0][1].body); + expect(sessionBody.model).toBe("anthropic/claude-opus-4-7"); + + const promptBody = JSON.parse(cpFetch.mock.calls[1][1].body); + expect(promptBody.content).toContain("fix this code"); + expect(promptBody.content).not.toMatch(/\bmodel:/i); + }); + + it("auto-review uses env default when config has no model", async () => { + vi.mocked(getGitHubConfig).mockResolvedValue({ + ...defaultConfig, + model: "anthropic/claude-haiku-4-5", + }); + const env = createMockEnv(); + const log = createMockLogger(); + + await handlePullRequestOpened(env, log, pullRequestOpenedPayload, "trace-auto-default"); + + const cpFetch = getControlPlaneFetch(env); + const sessionBody = JSON.parse(cpFetch.mock.calls[0][1].body); + // Even with no directive, resolveSessionModelSettings normalizes the chosen model. + expect(sessionBody.model).toBe("anthropic/claude-haiku-4-5"); + + expect(log.info).toHaveBeenCalledWith( + "session.created", + expect.objectContaining({ directive_applied: false }) + ); + }); + it("null instructions produce no Custom Instructions section (backward compat)", async () => { vi.mocked(getGitHubConfig).mockResolvedValue({ ...defaultConfig }); const env = createMockEnv(); diff --git a/packages/github-bot/test/inline-directive.test.ts b/packages/github-bot/test/inline-directive.test.ts new file mode 100644 index 000000000..87212715d --- /dev/null +++ b/packages/github-bot/test/inline-directive.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { parseInlineDirective } from "../src/inline-directive"; + +describe("parseInlineDirective", () => { + it("returns empty cleanedBody and no overrides for empty input", () => { + expect(parseInlineDirective("")).toEqual({ cleanedBody: "" }); + }); + + it("ignores body with no directive tokens", () => { + const result = parseInlineDirective("please review this PR"); + expect(result).toEqual({ cleanedBody: "please review this PR" }); + }); + + it("parses model only and strips the token from the body", () => { + const result = parseInlineDirective("please review this. model: opus"); + expect(result.model).toBe("anthropic/claude-opus-4-7"); + expect(result.reasoningEffort).toBeUndefined(); + expect(result.cleanedBody).toBe("please review this."); + }); + + it("parses model + reasoning when reasoning appears first", () => { + const result = parseInlineDirective("reasoning: high model: sonnet-4-6 do X"); + expect(result.model).toBe("anthropic/claude-sonnet-4-6"); + expect(result.reasoningEffort).toBe("high"); + expect(result.cleanedBody).toBe("do X"); + }); + + it("parses model + reasoning when model appears first", () => { + const result = parseInlineDirective("model: sonnet-4-6 reasoning: high do X"); + expect(result.model).toBe("anthropic/claude-sonnet-4-6"); + expect(result.reasoningEffort).toBe("high"); + expect(result.cleanedBody).toBe("do X"); + }); + + it("normalizes bare alias to canonical provider/model id", () => { + const result = parseInlineDirective("model: opus please review"); + expect(result.model).toBe("anthropic/claude-opus-4-7"); + }); + + it("accepts fully-qualified provider/model id", () => { + const result = parseInlineDirective("model: anthropic/claude-opus-4-7 please review"); + expect(result.model).toBe("anthropic/claude-opus-4-7"); + }); + + it("is case-insensitive on the key", () => { + const result = parseInlineDirective("MODEL: opus REASONING: high go"); + expect(result.model).toBe("anthropic/claude-opus-4-7"); + expect(result.reasoningEffort).toBe("high"); + }); + + it("first occurrence wins; subsequent tokens are still stripped", () => { + const result = parseInlineDirective("model: opus please model: sonnet-4-6 do it"); + expect(result.model).toBe("anthropic/claude-opus-4-7"); + expect(result.cleanedBody).not.toContain("model:"); + expect(result.cleanedBody).toBe("please do it"); + }); + + it("invalid model: token stripped, no model override applied", () => { + const result = parseInlineDirective("model: nonsense please review this"); + expect(result.model).toBeUndefined(); + expect(result.cleanedBody).toBe("please review this"); + }); + + it("invalid reasoning effort for chosen model: ignored, model still applied", () => { + // claude-sonnet-4-5 only supports "high" and "max" + const result = parseInlineDirective("model: sonnet-4-5 reasoning: low go"); + expect(result.model).toBe("anthropic/claude-sonnet-4-5"); + expect(result.reasoningEffort).toBeUndefined(); + expect(result.cleanedBody).toBe("go"); + }); + + it("reasoning value not in the global set is dropped entirely", () => { + const result = parseInlineDirective("reasoning: turbo go"); + expect(result.reasoningEffort).toBeUndefined(); + // token still stripped + expect(result.cleanedBody).toBe("go"); + }); + + it("flexible whitespace after colon: model:opus", () => { + const result = parseInlineDirective("model:opus go"); + expect(result.model).toBe("anthropic/claude-opus-4-7"); + expect(result.cleanedBody).toBe("go"); + }); + + it("flexible whitespace after colon: extra spaces", () => { + const result = parseInlineDirective("model: opus go"); + expect(result.model).toBe("anthropic/claude-opus-4-7"); + expect(result.cleanedBody).toBe("go"); + }); + + it("does not match URL fragment containing model:foo", () => { + const body = "see https://example.com/model:opus for details"; + const result = parseInlineDirective(body); + expect(result.model).toBeUndefined(); + expect(result.cleanedBody).toBe(body); + }); + + it("does not match code-span prefixed by backtick", () => { + const body = "`model:opus` is the syntax"; + const result = parseInlineDirective(body); + expect(result.model).toBeUndefined(); + expect(result.cleanedBody).toBe(body); + }); + + it("does not match xmodel:opus (no whitespace boundary)", () => { + const body = "xmodel:opus please review"; + const result = parseInlineDirective(body); + expect(result.model).toBeUndefined(); + expect(result.cleanedBody).toBe(body); + }); + + it("matches when directive is at start of string", () => { + const result = parseInlineDirective("model: opus please review"); + expect(result.model).toBe("anthropic/claude-opus-4-7"); + expect(result.cleanedBody).toBe("please review"); + }); + + it("matches when directive is at end of string", () => { + const result = parseInlineDirective("please review model: opus"); + expect(result.model).toBe("anthropic/claude-opus-4-7"); + expect(result.cleanedBody).toBe("please review"); + }); + + it("does not strip the bot mention", () => { + // The handler strips the @mention before calling parseInlineDirective. This + // test asserts the parser leaves arbitrary @-text alone. + const result = parseInlineDirective("@bot hello"); + expect(result.cleanedBody).toBe("@bot hello"); + }); + + it("preserves other text around stripped directives", () => { + const result = parseInlineDirective("hello model: opus world reasoning: high end"); + expect(result.model).toBe("anthropic/claude-opus-4-7"); + expect(result.reasoningEffort).toBe("high"); + expect(result.cleanedBody).toBe("hello world end"); + }); +}); diff --git a/packages/github-bot/test/integration-config.test.ts b/packages/github-bot/test/integration-config.test.ts index 4b832cc7b..b25a46797 100644 --- a/packages/github-bot/test/integration-config.test.ts +++ b/packages/github-bot/test/integration-config.test.ts @@ -66,6 +66,7 @@ describe("getGitHubConfig", () => { allowedTriggerUsers: null, codeReviewInstructions: "Be thorough", commentActionInstructions: null, + allowInlineDirectiveOverride: true, }); expect(log.warn).not.toHaveBeenCalled(); }); @@ -84,6 +85,7 @@ describe("getGitHubConfig", () => { allowedTriggerUsers: [], codeReviewInstructions: null, commentActionInstructions: null, + allowInlineDirectiveOverride: true, }); expect(log.warn).toHaveBeenCalledWith( "config.fetch_error", @@ -110,6 +112,7 @@ describe("getGitHubConfig", () => { allowedTriggerUsers: [], codeReviewInstructions: null, commentActionInstructions: null, + allowInlineDirectiveOverride: true, }); expect(log.warn).toHaveBeenCalledWith( "config.fetch_failed", @@ -134,6 +137,7 @@ describe("getGitHubConfig", () => { allowedTriggerUsers: [], codeReviewInstructions: null, commentActionInstructions: null, + allowInlineDirectiveOverride: true, }); }); @@ -153,7 +157,59 @@ describe("getGitHubConfig", () => { allowedTriggerUsers: null, codeReviewInstructions: null, commentActionInstructions: null, + allowInlineDirectiveOverride: true, }); expect(log.warn).not.toHaveBeenCalled(); }); + + it("propagates explicit allowInlineDirectiveOverride: false from response", async () => { + const env = createMockEnv(() => + Promise.resolve( + new Response( + JSON.stringify({ + config: { + model: null, + reasoningEffort: null, + autoReviewOnOpen: true, + enabledRepos: null, + allowedTriggerUsers: null, + codeReviewInstructions: null, + commentActionInstructions: null, + allowInlineDirectiveOverride: false, + }, + }), + { status: 200 } + ) + ) + ); + + const result = await getGitHubConfig(env, "acme/widgets"); + + expect(result.allowInlineDirectiveOverride).toBe(false); + }); + + it("defaults allowInlineDirectiveOverride to true when missing from response", async () => { + const env = createMockEnv(() => + Promise.resolve( + new Response( + JSON.stringify({ + config: { + model: null, + reasoningEffort: null, + autoReviewOnOpen: true, + enabledRepos: null, + allowedTriggerUsers: null, + codeReviewInstructions: null, + commentActionInstructions: null, + }, + }), + { status: 200 } + ) + ) + ); + + const result = await getGitHubConfig(env, "acme/widgets"); + + expect(result.allowInlineDirectiveOverride).toBe(true); + }); }); diff --git a/packages/github-bot/test/model-resolution.test.ts b/packages/github-bot/test/model-resolution.test.ts new file mode 100644 index 000000000..50f25a556 --- /dev/null +++ b/packages/github-bot/test/model-resolution.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { resolveSessionModelSettings } from "../src/model-resolution"; + +const ENV_DEFAULT = "anthropic/claude-sonnet-4-6"; + +describe("resolveSessionModelSettings", () => { + it("falls back to env default when config has no model", () => { + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: null, + configReasoningEffort: null, + allowInlineDirectiveOverride: true, + }); + expect(result).toEqual({ model: ENV_DEFAULT, reasoningEffort: null }); + }); + + it("uses config model when provided", () => { + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "anthropic/claude-opus-4-6", + configReasoningEffort: "high", + allowInlineDirectiveOverride: true, + }); + expect(result).toEqual({ + model: "anthropic/claude-opus-4-6", + reasoningEffort: "high", + }); + }); + + it("priority: directive model overrides config model", () => { + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "anthropic/claude-sonnet-4-6", + configReasoningEffort: "high", + allowInlineDirectiveOverride: true, + directiveModel: "anthropic/claude-opus-4-7", + directiveReasoningEffort: "max", + }); + expect(result).toEqual({ + model: "anthropic/claude-opus-4-7", + reasoningEffort: "max", + }); + }); + + it("priority: config model overrides env default", () => { + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "anthropic/claude-opus-4-6", + configReasoningEffort: null, + allowInlineDirectiveOverride: true, + }); + expect(result.model).toBe("anthropic/claude-opus-4-6"); + }); + + it("allowInlineDirectiveOverride=false ignores directive entirely", () => { + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "anthropic/claude-sonnet-4-6", + configReasoningEffort: "max", + allowInlineDirectiveOverride: false, + directiveModel: "anthropic/claude-opus-4-7", + directiveReasoningEffort: "low", + }); + expect(result).toEqual({ + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: "max", + }); + }); + + it("directive supplies only reasoning: model from config, reasoning applied if compatible", () => { + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "anthropic/claude-opus-4-7", + configReasoningEffort: "high", + allowInlineDirectiveOverride: true, + directiveReasoningEffort: "max", + }); + expect(result).toEqual({ + model: "anthropic/claude-opus-4-7", + reasoningEffort: "max", + }); + }); + + it("directive supplies only reasoning, but reasoning incompatible with config model: falls back to config reasoning", () => { + // claude-sonnet-4-5 supports only "high" and "max" + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "anthropic/claude-sonnet-4-5", + configReasoningEffort: "max", + allowInlineDirectiveOverride: true, + directiveReasoningEffort: "low", + }); + expect(result).toEqual({ + model: "anthropic/claude-sonnet-4-5", + reasoningEffort: "max", + }); + }); + + it("directive model with incompatible reasoning: model wins, reasoning becomes null", () => { + // claude-sonnet-4-5 supports only "high" and "max" + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "anthropic/claude-opus-4-7", + configReasoningEffort: "high", + allowInlineDirectiveOverride: true, + directiveModel: "anthropic/claude-sonnet-4-5", + directiveReasoningEffort: "low", + }); + expect(result).toEqual({ + model: "anthropic/claude-sonnet-4-5", + reasoningEffort: null, + }); + }); + + it("directive model with no directive reasoning: drops config reasoning entirely", () => { + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "anthropic/claude-opus-4-7", + configReasoningEffort: "high", + allowInlineDirectiveOverride: true, + directiveModel: "anthropic/claude-sonnet-4-6", + }); + // Reasoning is not carried from config when directive supplied a fresh model. + expect(result).toEqual({ + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + }); + }); + + it("config reasoning incompatible with config model: reasoning becomes null", () => { + // claude-haiku-4-5 supports only "high" and "max" + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "anthropic/claude-haiku-4-5", + configReasoningEffort: "low", + allowInlineDirectiveOverride: true, + }); + expect(result).toEqual({ + model: "anthropic/claude-haiku-4-5", + reasoningEffort: null, + }); + }); + + it("invalid directive model is treated as no directive model", () => { + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "anthropic/claude-sonnet-4-6", + configReasoningEffort: "high", + allowInlineDirectiveOverride: true, + directiveModel: "totally-not-a-model", + }); + expect(result).toEqual({ + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: "high", + }); + }); + + it("invalid config model falls back to env default via getValidModelOrDefault", () => { + const result = resolveSessionModelSettings({ + envDefaultModel: ENV_DEFAULT, + configModel: "bogus/model", + configReasoningEffort: null, + allowInlineDirectiveOverride: true, + }); + expect(result.model).toBe(ENV_DEFAULT); + }); +}); diff --git a/packages/linear-bot/src/__tests__/pure-functions.test.ts b/packages/linear-bot/src/__tests__/pure-functions.test.ts index f9954f6e8..cd43edf20 100644 --- a/packages/linear-bot/src/__tests__/pure-functions.test.ts +++ b/packages/linear-bot/src/__tests__/pure-functions.test.ts @@ -30,12 +30,12 @@ describe("buildOAuthSuccessHtml", () => { // ─── extractModelFromLabels ────────────────────────────────────────────────── describe("extractModelFromLabels", () => { - it("returns model for a valid label", () => { - expect(extractModelFromLabels([{ name: "model:opus" }])).toBe("anthropic/claude-opus-4-5"); + it("returns model for a valid label (family alias points at latest)", () => { + expect(extractModelFromLabels([{ name: "model:opus" }])).toBe("anthropic/claude-opus-4-7"); }); it("returns model for case-insensitive label", () => { - expect(extractModelFromLabels([{ name: "Model:Sonnet" }])).toBe("anthropic/claude-sonnet-4-5"); + expect(extractModelFromLabels([{ name: "Model:Sonnet" }])).toBe("anthropic/claude-sonnet-4-6"); }); it("returns GPT 5.4 for model:gpt-5.4 label", () => { diff --git a/packages/linear-bot/src/model-resolution.ts b/packages/linear-bot/src/model-resolution.ts index 77456b0e0..a2c107260 100644 --- a/packages/linear-bot/src/model-resolution.ts +++ b/packages/linear-bot/src/model-resolution.ts @@ -6,7 +6,9 @@ import type { TeamRepoMapping, StaticRepoConfig } from "./types"; import { getDefaultReasoningEffort, getValidModelOrDefault, + isValidModel, isValidReasoningEffort, + resolveModelAlias, } from "@open-inspect/shared"; /** @@ -28,28 +30,19 @@ export function resolveStaticRepo( ); } -const MODEL_LABEL_MAP: Record = { - haiku: "anthropic/claude-haiku-4-5", - sonnet: "anthropic/claude-sonnet-4-5", - opus: "anthropic/claude-opus-4-5", - "opus-4-6": "anthropic/claude-opus-4-6", - "opus-4-7": "anthropic/claude-opus-4-7", - "gpt-5.2": "openai/gpt-5.2", - "gpt-5.4": "openai/gpt-5.4", - "gpt-5.5": "openai/gpt-5.5", - "gpt-5.2-codex": "openai/gpt-5.2-codex", - "gpt-5.3-codex": "openai/gpt-5.3-codex", -}; - /** - * Extract model override from issue labels (e.g., "model:opus" → "anthropic/claude-opus-4-5"). + * Extract model override from issue labels (e.g., "model:opus" → canonical id). + * + * Alias resolution is delegated to the shared `resolveModelAlias` helper so + * Linear, GitHub, and any future bot stay in lockstep with the canonical + * model list. Returns null when the label doesn't resolve to a valid model. */ export function extractModelFromLabels(labels: Array<{ name: string }>): string | null { for (const label of labels) { const match = label.name.match(/^model:(.+)$/i); if (match) { - const key = match[1].toLowerCase(); - if (MODEL_LABEL_MAP[key]) return MODEL_LABEL_MAP[key]; + const resolved = resolveModelAlias(match[1].toLowerCase()); + if (isValidModel(resolved)) return resolved; } } return null; diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index e86bd74e3..4648b45c3 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -176,6 +176,52 @@ export function normalizeModelId(modelId: string): string { return modelId; } +/** + * User-friendly model aliases that `normalizeModelId` cannot infer. + * + * Two flavors of entry: + * 1. Family shortcuts (`opus`, `sonnet`, `haiku`) — always point at the + * *latest* canonical id in that family. Updating these is part of the + * "promote a new model" workflow (see docs/MODELS.md). + * 2. Bare version ids (`opus-4-7`, `sonnet-4-6`) for Anthropic that aren't + * already prefixed with `claude-` so `normalizeModelId` can't help. + * + * OpenAI bare names (`gpt-5.4`, etc.) and bare claude ids (`claude-opus-4-7`) + * fall through to `normalizeModelId` — no entry needed here. + */ +const MODEL_ALIASES: Record = { + // Family shortcuts → latest in each family + haiku: "anthropic/claude-haiku-4-5", + sonnet: "anthropic/claude-sonnet-4-6", + opus: "anthropic/claude-opus-4-7", + // Bare Anthropic versions (no `claude-` prefix) + "haiku-4-5": "anthropic/claude-haiku-4-5", + "sonnet-4-5": "anthropic/claude-sonnet-4-5", + "sonnet-4-6": "anthropic/claude-sonnet-4-6", + "opus-4-5": "anthropic/claude-opus-4-5", + "opus-4-6": "anthropic/claude-opus-4-6", + "opus-4-7": "anthropic/claude-opus-4-7", +}; + +/** + * Resolve a user-supplied model string to a canonical "provider/model" id. + * + * Resolution order: + * 1. Family/version alias lookup (case-insensitive) — `opus`, `sonnet-4-6`, etc. + * 2. `normalizeModelId` for bare `claude-*` / `gpt-*` ids and pass-through + * for already-prefixed ids. + * + * The returned string is NOT guaranteed to be in `VALID_MODELS` — callers + * that need a validity check should pair this with `isValidModel`. Centralizing + * the alias map here keeps the GitHub-bot, Linear-bot, and any future bot in + * lockstep with the canonical model list. + */ +export function resolveModelAlias(raw: string): string { + const lower = raw.toLowerCase(); + if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower]; + return normalizeModelId(lower); +} + // === Validation helpers === /** diff --git a/packages/shared/src/types/integrations.ts b/packages/shared/src/types/integrations.ts index 91e4314b0..aec5f6d16 100644 --- a/packages/shared/src/types/integrations.ts +++ b/packages/shared/src/types/integrations.ts @@ -22,6 +22,7 @@ export interface GitHubBotSettings { allowedTriggerUsers?: string[]; codeReviewInstructions?: string; commentActionInstructions?: string; + allowInlineDirectiveOverride?: boolean; } /** Overridable behavior settings for the Linear bot. Used at both global (defaults) and per-repo (overrides) levels. */ diff --git a/packages/web/src/components/settings/integrations/github-integration-settings.tsx b/packages/web/src/components/settings/integrations/github-integration-settings.tsx index 2b4a0ffcb..b415a0722 100644 --- a/packages/web/src/components/settings/integrations/github-integration-settings.tsx +++ b/packages/web/src/components/settings/integrations/github-integration-settings.tsx @@ -99,7 +99,11 @@ export function GitHubIntegrationSettings() { )} - +
{ if (settings !== undefined && !initialized) { if (settings) { + setModel(settings.defaults?.model ?? ""); + setEffort(settings.defaults?.reasoningEffort ?? ""); + setAllowInlineDirectiveOverride(settings.defaults?.allowInlineDirectiveOverride ?? true); setAutoReviewOnOpen(settings.defaults?.autoReviewOnOpen ?? true); setEnabledRepos(settings.enabledRepos ?? []); setRepoScopeMode(settings.enabledRepos === undefined ? "all" : "selected"); @@ -181,6 +195,9 @@ function GlobalSettingsSection({ if (res.ok) { mutate(GLOBAL_SETTINGS_KEY); + setModel(""); + setEffort(""); + setAllowInlineDirectiveOverride(true); setAutoReviewOnOpen(true); setEnabledRepos([]); setRepoScopeMode("all"); @@ -209,6 +226,9 @@ function GlobalSettingsSection({ const body: GitHubGlobalConfig = { defaults: { autoReviewOnOpen, + allowInlineDirectiveOverride, + ...(model ? { model } : {}), + ...(effort ? { reasoningEffort: effort } : {}), ...(triggerUserMode === "specific" ? { allowedTriggerUsers } : {}), ...(codeReviewInstructions ? { codeReviewInstructions } : {}), ...(commentActionInstructions ? { commentActionInstructions } : {}), @@ -260,10 +280,104 @@ function GlobalSettingsSection({ setError(""); }; + const reasoningConfig = model ? MODEL_REASONING_CONFIG[model as ValidModel] : undefined; + return (
{error && } +
+

Model defaults

+
+ + + +
+ + +
+