Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/backends/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const CLAUDE_CODE_ENGINE_DEFINITION: AgentEngineDefinition = {
],
modelSelection: {
type: 'select',
defaultValueLabel: 'Default (Sonnet 4.5)',
defaultValueLabel: 'Default (Sonnet 5)',
options: CLAUDE_CODE_MODELS,
},
logLabel: 'Claude Code Log',
Expand Down
5 changes: 4 additions & 1 deletion src/backends/claude-code/messageProcessing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,11 @@ export function countToolCalls(assistantMsg: SDKAssistantMessage): number {
/**
* Convert a raw Anthropic model ID (e.g. 'claude-sonnet-4-5-20250929') to the
* pricing key format used by calculateCost() (e.g. 'anthropic:claude-sonnet-4-5').
*
* Exported so the pricing-coverage drift-guard test can assert every dropdown model ID
* maps to a MODEL_PRICING row using the exact runtime transform (no regex re-implementation).
*/
function toPricingKey(model: string): string {
export function toPricingKey(model: string): string {
return `anthropic:${model}`.replace(/-\d{8}$/, '');
}

Expand Down
7 changes: 6 additions & 1 deletion src/backends/claude-code/models.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
export const CLAUDE_CODE_MODELS = [
{ value: 'claude-fable-5', label: 'Claude Fable 5' },
// Opus 5 / Sonnet 5 default to 1M context, so — like claude-fable-5 — they intentionally
// have no separate `[1m]` variant. (The `[1m]` suffix is a deployment identifier reserved
// for older generations whose default context is smaller than 1M.)
{ value: 'claude-opus-5', label: 'Claude Opus 5' },
{ value: 'claude-opus-4-8', label: 'Claude Opus 4.8' },
{ value: 'claude-opus-4-8[1m]', label: 'Claude Opus 4.8 (1M context)' },
{ value: 'claude-opus-4-7', label: 'Claude Opus 4.7' },
{ value: 'claude-opus-4-7[1m]', label: 'Claude Opus 4.7 (1M context)' },
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
{ value: 'claude-opus-4-6[1m]', label: 'Claude Opus 4.6 (1M context)' },
{ value: 'claude-sonnet-5', label: 'Claude Sonnet 5' },
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
{ value: 'claude-sonnet-4-6[1m]', label: 'Claude Sonnet 4.6 (1M context)' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
Expand All @@ -14,4 +19,4 @@ export const CLAUDE_CODE_MODELS = [

export const CLAUDE_CODE_MODEL_IDS: string[] = CLAUDE_CODE_MODELS.map((m) => m.value);

export const DEFAULT_CLAUDE_CODE_MODEL = 'claude-sonnet-4-5-20250929';
export const DEFAULT_CLAUDE_CODE_MODEL = 'claude-sonnet-5';
18 changes: 18 additions & 0 deletions src/config/rateLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ export const MODEL_RATE_LIMITS: ModelRateLimits = {
safetyMargin: 0.85,
},

// Claude Opus 5 (Tier 1: 50 RPM, 10K TPM — Opus is throttle-sensitive). Needs its own row:
// Opus 5 draws on a rate-limit pool separate from the combined Opus 4.x pool, and the
// prefix matching in getRateLimitForModel would not reach an Opus 4.x row anyway. These
// bind on the LLMist path (getRateLimitForModel); the claude-code SDK path self-throttles.
'anthropic:claude-opus-5': {
requestsPerMinute: 50,
tokensPerMinute: 10_000,
safetyMargin: 0.85,
},

// Claude Sonnet 5 (Tier 1: 50 RPM, 40K TPM). Binds on the LLMist path only; the
// claude-code SDK path self-throttles.
'anthropic:claude-sonnet-5': {
requestsPerMinute: 50,
tokensPerMinute: 40_000,
safetyMargin: 0.9,
},

// Claude Opus 4.8 (Tier 1: 50 RPM, 10K TPM — Opus is throttle-sensitive)
'anthropic:claude-opus-4-8': {
requestsPerMinute: 50,
Expand Down
8 changes: 8 additions & 0 deletions src/integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,14 @@ The fix matches on the **locale-invariant JIRA status ID** on both ends, with na
- **Wizard** — the status-mapping select now persists the status **ID** (`{ id: s.id, name: s.name }`) while still displaying the name. `normalizeJiraStatusMappingsToIds` auto-upgrades legacy name-valued mappings → IDs in the `SET_JIRA_PROJECT_DETAILS` reducer when project details load, so re-saving any project backfills IDs. Values already-ID or unrecognized (custom) are left untouched.
- **JQL** — `listWorkItems` quotes the status value; JIRA resolves a quoted numeric value against status IDs, so ID-based config values remain valid with no behavior change.

#### JIRA issue-type mapping is read from `issueTypes.task` (MNG-1769)

Same "wizard writes X → runtime reads X" failure shape as the locale-fragile status bug above ([MNG-1768](https://linear.app/mongrel/issue/MNG-1768/jira-status-matching-is-locale-fragile-status-moves-silently-no-op)). The JIRA wizard's `IssueTypeMappingStep` (`web/src/components/projects/pm-providers/jira/issue-type-step.tsx`) persists the operator's Task mapping under `jira.issueTypes.task`, but `JiraPMProvider.createWorkItem` used to read `issueTypes.default` — a key nothing ever wrote — so the optional chain always yielded `undefined` and **every** JIRA issue was hardcoded to type `"Task"`, silently ignoring the operator's mapping.

- **Runtime reads `issueTypes.task`.** `createWorkItem` reads `this.config.issueTypes?.task ?? 'Task'`. The `'Task'` fallback is retained for configs that never set a mapping (backward compatible). The legacy `issueTypes.default` key is intentionally **not** read — honoring it would resurrect the bug — and a regression test in `tests/unit/pm/jira/adapter.test.ts` proves `default` is no longer honored.
- **Actionable failure.** When `jiraClient.createIssue` fails (commonly a JIRA 400 when the mapped/fallback type does not exist on the project), the adapter best-effort calls `jiraClient.getIssueTypesForProject(projectKey)` and re-throws an error naming the attempted type and the project's discovered non-subtask issue types. The diagnostic fetch is guarded so a discovery failure re-throws the original creation error unchanged.
- **`subtask` is intentionally not consumed.** There is no subtask-creation path, so the wizard's subtask row was removed rather than persist config nothing reads. A previously-saved `issueTypes.subtask` value is harmless — it simply stays in config, unread.

### Wizard path — metadata-driven, shared between providers

The PM wizards consume the workflow status definition list through a single tRPC query (`trpc.workflowStatuses.list`) and render mapping rows for every key — built-in and custom alike. The provider's `useProviderHooks` resolves the list and forwards it as `workflowStatuses` on the hook return; the shared `StatusMappingStep` renders rows in the returned order. Reference implementations:
Expand Down
74 changes: 66 additions & 8 deletions src/pm/jira/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ import { adfToPlainText, extractAdfMediaNodes, markdownToAdf } from './adf.js';
* enforces invariance on object-property types. Internally the adapter
* uses `config.containerId` as a JIRA project key — the project-scoped
* entry point.
*
* MNG-1769: `createWorkItem` reads the operator's Task mapping from
* `issueTypes.task` — the exact key the wizard's `IssueTypeMappingStep`
* writes (`web/src/components/projects/pm-providers/jira/issue-type-step.tsx`).
* The `'Task'` string is the last-resort fallback for configs that never set
* a mapping. The legacy `issueTypes.default` key is intentionally NOT read —
* nothing ever wrote it, so honoring it would resurrect the bug where every
* JIRA issue was hardcoded to type `"Task"`. `subtask` is intentionally not
* consumed: there is no subtask-creation path. This is the "wizard writes X →
* runtime reads X" contract, mirroring MNG-1768's parity guard.
*/

interface JiraConfig {
Expand Down Expand Up @@ -194,14 +204,23 @@ export class JiraPMProvider implements PMProvider {
}

async createWorkItem(config: CreateWorkItemConfig): Promise<WorkItem> {
const issueType = this.config.issueTypes?.default ?? 'Task';
const result = await jiraClient.createIssue({
project: { key: config.containerId || this.config.projectKey },
summary: config.title,
description: config.description ? markdownToAdf(config.description) : undefined,
issuetype: { name: issueType },
...(config.labels?.length ? { labels: config.labels } : {}),
});
// MNG-1769: read the key the wizard actually writes (`issueTypes.task`),
// not the never-written legacy `issueTypes.default`. `'Task'` remains the
// last-resort fallback for configs that never set a mapping.
const issueType = this.config.issueTypes?.task ?? 'Task';
const projectKey = config.containerId || this.config.projectKey;
let result: Awaited<ReturnType<typeof jiraClient.createIssue>>;
try {
result = await jiraClient.createIssue({
project: { key: projectKey },
summary: config.title,
description: config.description ? markdownToAdf(config.description) : undefined,
issuetype: { name: issueType },
...(config.labels?.length ? { labels: config.labels } : {}),
});
} catch (err) {
throw await this.enrichCreateIssueError(err, projectKey, issueType);
}
const key = result.key ?? '';

// Transition to backlog status if configured
Expand Down Expand Up @@ -230,6 +249,45 @@ export class JiraPMProvider implements PMProvider {
};
}

/**
* MNG-1769: when `jiraClient.createIssue` fails (commonly a JIRA 400 because
* the configured/fallback issue type does not exist on the project), best-effort
* fetch the project's discovered issue types and re-throw an Error naming the
* attempted type and the available non-subtask types. This turns an opaque JIRA
* 400 into an actionable message pointing the operator at the wizard mapping.
*
* The diagnostic fetch is guarded in its own try/catch so a discovery failure
* never masks the original creation error — if discovery also fails, the
* original error surfaces unchanged.
*/
private async enrichCreateIssueError(
originalError: unknown,
projectKey: string,
attemptedType: string,
): Promise<unknown> {
try {
const types = await jiraClient.getIssueTypesForProject(projectKey);
const available = types
.filter((t) => !t.subtask)
.map((t) => t.name)
.filter((name) => name.length > 0);
return new Error(
`Failed to create JIRA issue in project "${projectKey}" with issue type "${attemptedType}". ` +
`Available issue types for this project: ${
available.length > 0 ? available.join(', ') : '(none discovered)'
}. ` +
`Map the Task role to one of these in the JIRA wizard's issue-type step. ` +
`Original error: ${String(originalError)}`,
);
} catch (discoveryErr) {
logger.warn('[JIRA] Could not fetch issue types while enriching createIssue error', {
projectKey,
error: String(discoveryErr),
});
return originalError;
}
}

async listWorkItems(
containerId: ContainerId | undefined,
filter?: ListWorkItemsFilter,
Expand Down
52 changes: 49 additions & 3 deletions src/utils/llmMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,54 @@
* Provides cost calculation.
*/
import type { TokenUsage } from 'llmist';
import { captureException } from '../sentry.js';
import { logger } from './logging.js';

/**
* Models we've already warned about this process, so a missing-pricing row logs/captures
* once per unique model instead of on every LLM call/turn. Workers are ephemeral (one job
* per container), so process-level dedup is the right granularity.
*/
const warnedMissingPricing = new Set<string>();

/**
* Model pricing per 1M tokens (in USD).
* Prices as of January 2026.
* Prices as of August 2026.
*/
const MODEL_PRICING: Record<string, { input: number; output: number; cachedInput?: number }> = {
export const MODEL_PRICING: Record<
string,
{ input: number; output: number; cachedInput?: number }
> = {
// Anthropic Claude Fable 5 — 1M context by default (max = default), priced at 2× Opus.
// Key matches toPricingKey('claude-fable-5') = 'anthropic:claude-fable-5' (no trailing
// date to strip). cachedInput follows the 0.1× convention used by every Anthropic row.
'anthropic:claude-fable-5': { input: 10.0, output: 50.0, cachedInput: 1.0 },

// Anthropic Claude 5 family
// Opus 5: mirrors Opus 4.8 pricing; cachedInput follows the 0.1× Anthropic convention.
'anthropic:claude-opus-5': { input: 5.0, output: 25.0, cachedInput: 0.5 },
// Sonnet 5: seeded at standard post-intro rates. Intro pricing is $2.00/$10.00 per MTok
// through 2026-08-31; seeding at standard rates over-reports during the intro window,
// which is the safe direction for budgets (never under-reports). Revisit before that
// date if a short-lived intro-rate edit is wanted.
'anthropic:claude-sonnet-5': { input: 3.0, output: 15.0, cachedInput: 0.3 },

// Anthropic Claude 4 family
'anthropic:claude-opus-4-8': { input: 5.0, output: 25.0, cachedInput: 0.5 },
'anthropic:claude-opus-4-8[1m]': { input: 5.0, output: 25.0, cachedInput: 0.5 },
'anthropic:claude-opus-4-7': { input: 5.0, output: 25.0, cachedInput: 0.5 },
'anthropic:claude-opus-4-7[1m]': { input: 5.0, output: 25.0, cachedInput: 0.5 },
// Bare claude-opus-4-6 backfill (was only present as the [1m] variant, so the bare
// dropdown ID ran unpriced at $0 — a silent budget bypass). Mirrors the [1m] row.
'anthropic:claude-opus-4-6': { input: 5.0, output: 25.0, cachedInput: 0.5 },
'anthropic:claude-opus-4-6[1m]': { input: 5.0, output: 25.0, cachedInput: 0.5 },
'anthropic:claude-sonnet-4-6': { input: 3.0, output: 15.0, cachedInput: 0.3 },
'anthropic:claude-sonnet-4-6[1m]': { input: 3.0, output: 15.0, cachedInput: 0.3 },
'anthropic:claude-sonnet-4-5': { input: 3.0, output: 15.0, cachedInput: 0.3 },
'anthropic:claude-opus-4-5': { input: 15.0, output: 75.0, cachedInput: 1.5 },
// Bare claude-haiku-4-5 backfill: claude-haiku-4-5-20251001 is in the dropdown but
// only claude-haiku-3-5 was priced, so Haiku 4.5 ran unpriced at $0.
'anthropic:claude-haiku-4-5': { input: 1.0, output: 5.0, cachedInput: 0.1 },
'anthropic:claude-haiku-3-5': { input: 0.8, output: 4.0, cachedInput: 0.08 },

// Google Gemini
Expand Down Expand Up @@ -74,7 +101,26 @@ const MODEL_PRICING: Record<string, { input: number; output: number; cachedInput
*/
export function calculateCost(model: string, usage: TokenUsage): number {
const pricing = MODEL_PRICING[model];
if (!pricing) return 0;
if (!pricing) {
// A missing pricing row makes calculateCost return 0, which silently disables
// workItemBudget enforcement for that model (checkBudgetExceeded never trips on a
// $0 spend). Make the miss loud — but non-fatal: calculateCost is a hot-path pure
// utility called across all three engines, so throwing would crash runs for any
// model not yet in MODEL_PRICING. Loud-observe + the drift-guard test is the safer
// combination. Dedup so it fires once per unique model per process, not per turn.
if (!warnedMissingPricing.has(model)) {
warnedMissingPricing.add(model);
logger.warn(
`No MODEL_PRICING row for "${model}"; cost reported as $0. This silently disables workItemBudget enforcement for this model — add a pricing row in src/utils/llmMetrics.ts.`,
);
captureException(new Error(`Missing MODEL_PRICING row for model "${model}"`), {
tags: { source: 'model_pricing_missing' },
level: 'warning',
extra: { model },
});
}
return 0;
}

const inputCost = (usage.inputTokens / 1_000_000) * pricing.input;
const outputCost = (usage.outputTokens / 1_000_000) * pricing.output;
Expand Down
29 changes: 28 additions & 1 deletion tests/unit/backends/claude-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import {
resolveClaudeCodeExecutablePath,
resolveClaudeModel,
} from '../../../src/backends/claude-code/index.js';
import { toPricingKey } from '../../../src/backends/claude-code/messageProcessing.js';
import {
CLAUDE_CODE_MODEL_IDS,
CLAUDE_CODE_MODELS,
DEFAULT_CLAUDE_CODE_MODEL,
} from '../../../src/backends/claude-code/models.js';
import { resolveClaudeCodeSettings } from '../../../src/backends/claude-code/settings.js';
import type { AgentExecutionPlan, ToolManifest } from '../../../src/backends/types.js';
import { MODEL_PRICING } from '../../../src/utils/llmMetrics.js';

const mockQuery = vi.mocked(query);

Expand Down Expand Up @@ -295,7 +297,15 @@ describe('buildSystemPrompt', () => {

describe('CLAUDE_CODE_MODELS constants', () => {
it('contains the expected models', () => {
expect(CLAUDE_CODE_MODELS).toHaveLength(11);
expect(CLAUDE_CODE_MODELS).toHaveLength(13);
});

it('includes Opus 5 and Sonnet 5 (1M context by default — no [1m] variant)', () => {
expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-opus-5');
expect(CLAUDE_CODE_MODEL_IDS).toContain('claude-sonnet-5');
// Opus 5 / Sonnet 5 are 1M-context by default, so a [1m] suffix would be redundant.
expect(CLAUDE_CODE_MODEL_IDS).not.toContain('claude-opus-5[1m]');
expect(CLAUDE_CODE_MODEL_IDS).not.toContain('claude-sonnet-5[1m]');
});

it('includes Opus 4.8, Opus 4.7, and the 1M context variants', () => {
Expand Down Expand Up @@ -329,9 +339,26 @@ describe('CLAUDE_CODE_MODELS constants', () => {
});
});

describe('MODEL_PRICING coverage (drift guard)', () => {
// This single assertion would have caught the pre-existing bare claude-opus-4-6 and
// claude-haiku-4-5 pricing gaps, and will catch the next one. A missing MODEL_PRICING
// row makes calculateCost return $0, which silently disables workItemBudget enforcement.
it('every CLAUDE_CODE_MODEL_IDS entry maps to a MODEL_PRICING row', () => {
for (const id of CLAUDE_CODE_MODEL_IDS) {
const pricingKey = toPricingKey(id);
expect(
MODEL_PRICING[pricingKey],
`${id} → ${pricingKey} has no MODEL_PRICING row`,
).toBeDefined();
}
});
});

describe('resolveClaudeModel', () => {
it('passes through known Claude Code model IDs', () => {
expect(resolveClaudeModel('claude-fable-5')).toBe('claude-fable-5');
expect(resolveClaudeModel('claude-opus-5')).toBe('claude-opus-5');
expect(resolveClaudeModel('claude-sonnet-5')).toBe('claude-sonnet-5');
expect(resolveClaudeModel('claude-opus-4-8')).toBe('claude-opus-4-8');
expect(resolveClaudeModel('claude-opus-4-8[1m]')).toBe('claude-opus-4-8[1m]');
expect(resolveClaudeModel('claude-opus-4-7')).toBe('claude-opus-4-7');
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/config/rateLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ describe.concurrent('config/rateLimits', () => {
});
});

it('returns exact match for Claude Opus 5', () => {
const result = getRateLimitForModel('anthropic:claude-opus-5');

expect(result).toEqual({
requestsPerMinute: 50,
tokensPerMinute: 10_000,
safetyMargin: 0.85,
});
});

it('returns exact match for Claude Sonnet 5', () => {
const result = getRateLimitForModel('anthropic:claude-sonnet-5');

expect(result).toEqual({
requestsPerMinute: 50,
tokensPerMinute: 40_000,
safetyMargin: 0.9,
});
});

it('returns prefix match for models with version suffix', () => {
// anthropic:claude-sonnet-4-5-20250929 should match anthropic:claude-sonnet-4-5
const result = getRateLimitForModel('anthropic:claude-sonnet-4-5-20250929');
Expand Down Expand Up @@ -120,6 +140,11 @@ describe.concurrent('config/rateLimits', () => {
expect(MODEL_RATE_LIMITS['anthropic:claude-opus-4-5']).toBeDefined();
});

it('includes Claude Opus 5 and Sonnet 5 configs', () => {
expect(MODEL_RATE_LIMITS['anthropic:claude-opus-5']).toBeDefined();
expect(MODEL_RATE_LIMITS['anthropic:claude-sonnet-5']).toBeDefined();
});

it('includes OpenRouter models', () => {
expect(MODEL_RATE_LIMITS['openrouter:google/gemini-3-flash-preview']).toBeDefined();
expect(MODEL_RATE_LIMITS['openrouter:deepseek/deepseek-chat-v3-0324']).toBeDefined();
Expand Down
Loading
Loading