From 6b04e7b80ef617c6ff53209891f1df2e5555dbbe Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 4 Aug 2026 16:45:05 +0200 Subject: [PATCH 1/2] fix(jira): read issue type from issueTypes.task, not never-written issueTypes.default (MNG-1769) (#1530) Co-authored-by: Cascade Bot --- src/integrations/README.md | 8 ++ src/pm/jira/adapter.ts | 74 +++++++++++++-- tests/unit/pm/jira/adapter.test.ts | 95 ++++++++++++++++++- tests/unit/web/jira-issue-type-step.test.ts | 29 +++--- .../pm-providers/jira/issue-type-step.tsx | 20 ++-- 5 files changed, 191 insertions(+), 35 deletions(-) diff --git a/src/integrations/README.md b/src/integrations/README.md index 6521d2de1..1553de4a7 100644 --- a/src/integrations/README.md +++ b/src/integrations/README.md @@ -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: diff --git a/src/pm/jira/adapter.ts b/src/pm/jira/adapter.ts index ba4c448b9..272a90fc0 100644 --- a/src/pm/jira/adapter.ts +++ b/src/pm/jira/adapter.ts @@ -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 { @@ -194,14 +204,23 @@ export class JiraPMProvider implements PMProvider { } async createWorkItem(config: CreateWorkItemConfig): Promise { - 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>; + 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 @@ -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 { + 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, diff --git a/tests/unit/pm/jira/adapter.test.ts b/tests/unit/pm/jira/adapter.test.ts index 0677d57dd..9d0b94ca6 100644 --- a/tests/unit/pm/jira/adapter.test.ts +++ b/tests/unit/pm/jira/adapter.test.ts @@ -74,9 +74,12 @@ const mockConfig = { todo: 'To Do', done: 'Done', }, + // MNG-1769: the wizard's IssueTypeMappingStep persists the operator's Task + // mapping under `issueTypes.task` — the exact key createWorkItem now reads. + // The old `default` key was never written by anything and is intentionally + // no longer honored. issueTypes: { - default: 'Task', - subtask: 'Sub-task', + task: 'Story', }, }; @@ -397,7 +400,10 @@ describe('JiraPMProvider', () => { expect.objectContaining({ project: { key: 'PROJ' }, summary: 'New Task', - issuetype: { name: 'Task' }, + // MNG-1769: mockConfig maps Task → 'Story' under `issueTypes.task`; + // createWorkItem must honor that mapping (previously it read the + // never-written `default` key and always sent 'Task'). + issuetype: { name: 'Story' }, labels: ['backend'], }), ); @@ -405,6 +411,89 @@ describe('JiraPMProvider', () => { expect(result.url).toBe('https://mycompany.atlassian.net/browse/PROJ-456'); }); + it('honors the wizard-written issueTypes.task mapping (parity guard, MNG-1769)', async () => { + const mappedProvider = new JiraPMProvider({ + ...mockConfig, + issueTypes: { task: 'Story' }, + }); + mockJiraClient.createIssue.mockResolvedValue({ key: 'PROJ-500' }); + + await mappedProvider.createWorkItem({ containerId: 'PROJ', title: 'Mapped task' }); + + expect(mockJiraClient.createIssue).toHaveBeenCalledWith( + expect.objectContaining({ issuetype: { name: 'Story' } }), + ); + }); + + it('falls back to "Task" when issueTypes is empty/absent (MNG-1769)', async () => { + const noMappingProvider = new JiraPMProvider({ + ...mockConfig, + issueTypes: {}, + }); + mockJiraClient.createIssue.mockResolvedValue({ key: 'PROJ-501' }); + + await noMappingProvider.createWorkItem({ containerId: 'PROJ', title: 'Unmapped task' }); + + expect(mockJiraClient.createIssue).toHaveBeenCalledWith( + expect.objectContaining({ issuetype: { name: 'Task' } }), + ); + }); + + it('does NOT honor the legacy issueTypes.default key (regression guard, MNG-1769)', async () => { + const legacyProvider = new JiraPMProvider({ + ...mockConfig, + // The old, never-written key. It must be ignored — falling back to 'Task'. + issueTypes: { default: 'Story' } as Record, + }); + mockJiraClient.createIssue.mockResolvedValue({ key: 'PROJ-502' }); + + await legacyProvider.createWorkItem({ containerId: 'PROJ', title: 'Legacy config task' }); + + expect(mockJiraClient.createIssue).toHaveBeenCalledWith( + expect.objectContaining({ issuetype: { name: 'Task' } }), + ); + }); + + it('re-throws an enriched error naming discovered issue types when creation fails (MNG-1769)', async () => { + const failingProvider = new JiraPMProvider({ + ...mockConfig, + issueTypes: { task: 'Task' }, + }); + mockJiraClient.createIssue.mockRejectedValue(new Error('JIRA 400: invalid issue type')); + mockJiraClient.getIssueTypesForProject.mockResolvedValue([ + { name: 'Zadanie', subtask: false }, + { name: 'Story', subtask: false }, + { name: 'Podzadanie', subtask: true }, + ]); + + await expect( + failingProvider.createWorkItem({ containerId: 'PROJ', title: 'Doomed task' }), + ).rejects.toThrow(/Zadanie/); + + const thrown = await failingProvider + .createWorkItem({ containerId: 'PROJ', title: 'Doomed task' }) + .catch((e: unknown) => e as Error); + // Names the attempted type, the discovered non-subtask types, and never + // lists subtask-only types in the "available" set. + expect(thrown.message).toContain('Task'); + expect(thrown.message).toContain('Story'); + expect(thrown.message).not.toContain('Podzadanie'); + expect(mockJiraClient.getIssueTypesForProject).toHaveBeenCalledWith('PROJ'); + }); + + it('surfaces the original creation error when issue-type discovery also fails (MNG-1769)', async () => { + const failingProvider = new JiraPMProvider({ + ...mockConfig, + issueTypes: { task: 'Task' }, + }); + mockJiraClient.createIssue.mockRejectedValue(new Error('original creation failure')); + mockJiraClient.getIssueTypesForProject.mockRejectedValue(new Error('discovery failed')); + + await expect( + failingProvider.createWorkItem({ containerId: 'PROJ', title: 'Doomed task' }), + ).rejects.toThrow('original creation failure'); + }); + it('omits labels when not provided', async () => { mockJiraClient.createIssue.mockResolvedValue({ key: 'PROJ-789' }); diff --git a/tests/unit/web/jira-issue-type-step.test.ts b/tests/unit/web/jira-issue-type-step.test.ts index 4da383094..3dbb8d18e 100644 --- a/tests/unit/web/jira-issue-type-step.test.ts +++ b/tests/unit/web/jira-issue-type-step.test.ts @@ -2,10 +2,13 @@ * Tests for the JIRA-specific IssueTypeMappingStep (plan 011/3 task 2). * * Rendered as `kind: 'custom'` in `jiraManifest.wizardSpec`. Maps the - * CASCADE 'task' / 'subtask' roles to JIRA issue types. Only JIRA uses - * this concept — Trello has no equivalent, Linear uses workflow states. - * Stays a custom step rather than an 8th StandardStepKind to avoid - * speculative abstraction for a single consumer. + * CASCADE 'task' role to a JIRA issue type. Only JIRA uses this concept — + * Trello has no equivalent, Linear uses workflow states. Stays a custom + * step rather than an 8th StandardStepKind to avoid speculative abstraction + * for a single consumer. + * + * MNG-1769: the subtask row was removed — nothing consumes + * `issueTypes.subtask`, so only the task mapping is offered/asserted here. */ import { createElement } from 'react'; @@ -35,7 +38,7 @@ describe('IssueTypeMappingStep', () => { expect(html).toContain('data-role="task"'); }); - it('renders a row for the subtask issue type', () => { + it('does NOT render a subtask row (MNG-1769: dead row removed)', () => { const html = renderToStaticMarkup( createElement(IssueTypeMappingStep, { step, @@ -45,7 +48,7 @@ describe('IssueTypeMappingStep', () => { onMappingChange: () => {}, }), ); - expect(html).toContain('data-role="subtask"'); + expect(html).not.toContain('data-role="subtask"'); }); it('populates task dropdown from issueTypes where subtask is false', () => { @@ -66,7 +69,7 @@ describe('IssueTypeMappingStep', () => { expect(taskSelect).not.toContain('Sub-task'); }); - it('populates subtask dropdown from issueTypes where subtask is true', () => { + it('does NOT render a subtask dropdown (MNG-1769: dead row removed)', () => { const html = renderToStaticMarkup( createElement(IssueTypeMappingStep, { step, @@ -76,26 +79,20 @@ describe('IssueTypeMappingStep', () => { onMappingChange: () => {}, }), ); - const subtaskSelect = html.match( - /]*id="issue-type-subtask"[^>]*>[\s\S]*?<\/select>/, - )?.[0]; - expect(subtaskSelect).toBeDefined(); - expect(subtaskSelect).toContain('Sub-task'); - expect(subtaskSelect).not.toContain('Story'); + expect(html).not.toMatch(/id="issue-type-subtask"/); }); - it('preselects current mappings', () => { + it('preselects the current task mapping', () => { const html = renderToStaticMarkup( createElement(IssueTypeMappingStep, { step, providerId: 'jira', issueTypes, - mappings: { task: 'Story', subtask: 'Sub-task' }, + mappings: { task: 'Story' }, onMappingChange: () => {}, }), ); expect(html).toMatch(/]*value="Story"[^>]*selected/); - expect(html).toMatch(/]*value="Sub-task"[^>]*selected/); }); it('renders loading state', () => { diff --git a/web/src/components/projects/pm-providers/jira/issue-type-step.tsx b/web/src/components/projects/pm-providers/jira/issue-type-step.tsx index e021e0a1a..5707a09ff 100644 --- a/web/src/components/projects/pm-providers/jira/issue-type-step.tsx +++ b/web/src/components/projects/pm-providers/jira/issue-type-step.tsx @@ -2,9 +2,14 @@ * JIRA-specific issue-type mapping step (plan 011/3). * * Registered as `kind: 'custom'` in `jiraManifest.wizardSpec`. Maps the - * CASCADE `task` and `subtask` roles to JIRA issue types. Splits the - * discovered `issueTypes` list by the `subtask` flag so each row only - * shows valid options. + * CASCADE `task` role to a JIRA issue type, filtering the discovered + * `issueTypes` list to non-subtask entries. + * + * MNG-1769: the `subtask` row was removed. Nothing consumed + * `issueTypes.subtask` — `JiraPMProvider.createWorkItem` has no + * subtask-creation path — so offering it persisted dead config that no + * runtime read. Real subtask creation, if ever wanted, is a separate feature + * with its own consumer. Only the `task` mapping is offered. * * Stays custom (rather than an 8th StandardStepKind) because JIRA is the * only PM provider with this concept today — Trello has no equivalent, @@ -30,15 +35,14 @@ export interface IssueTypeMappingStepProps { readonly providerId: string; readonly issueTypes: ReadonlyArray; readonly mappings: Readonly>; - readonly onMappingChange: (role: 'task' | 'subtask', issueTypeName: string) => void; + readonly onMappingChange: (role: 'task', issueTypeName: string) => void; readonly loading?: boolean; readonly error?: string; } -const ROLES = [ - { key: 'task' as const, label: 'Task', subtaskFlag: false }, - { key: 'subtask' as const, label: 'Subtask', subtaskFlag: true }, -]; +// MNG-1769: only the `task` role is offered. There is no consumer for a +// `subtask` mapping, so the previous subtask row was removed. +const ROLES = [{ key: 'task' as const, label: 'Task', subtaskFlag: false }]; export function IssueTypeMappingStep({ step, From f65a5fb6caf6159209d37b28217590661722f5e2 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 4 Aug 2026 17:33:12 +0200 Subject: [PATCH 2/2] feat(models): add Claude Opus 5 + Sonnet 5 and fix silent $0 cost budget bypass (#1531) Co-authored-by: Cascade Bot --- src/backends/catalog.ts | 2 +- src/backends/claude-code/messageProcessing.ts | 5 +- src/backends/claude-code/models.ts | 7 +- src/config/rateLimits.ts | 18 +++ src/utils/llmMetrics.ts | 52 +++++++- tests/unit/backends/claude-code.test.ts | 29 ++++- tests/unit/config/rateLimits.test.ts | 25 ++++ tests/unit/utils/llmMetrics.test.ts | 112 +++++++++++++++++- 8 files changed, 240 insertions(+), 10 deletions(-) diff --git a/src/backends/catalog.ts b/src/backends/catalog.ts index 52224d242..8a68516dc 100644 --- a/src/backends/catalog.ts +++ b/src/backends/catalog.ts @@ -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', diff --git a/src/backends/claude-code/messageProcessing.ts b/src/backends/claude-code/messageProcessing.ts index 75d65e4d0..3abcf0846 100644 --- a/src/backends/claude-code/messageProcessing.ts +++ b/src/backends/claude-code/messageProcessing.ts @@ -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}$/, ''); } diff --git a/src/backends/claude-code/models.ts b/src/backends/claude-code/models.ts index 0a2379a33..0d34b424b 100644 --- a/src/backends/claude-code/models.ts +++ b/src/backends/claude-code/models.ts @@ -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' }, @@ -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'; diff --git a/src/config/rateLimits.ts b/src/config/rateLimits.ts index 93b79019e..dc386fe63 100644 --- a/src/config/rateLimits.ts +++ b/src/config/rateLimits.ts @@ -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, diff --git a/src/utils/llmMetrics.ts b/src/utils/llmMetrics.ts index 0e70a4cf9..60f894436 100644 --- a/src/utils/llmMetrics.ts +++ b/src/utils/llmMetrics.ts @@ -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(); /** * Model pricing per 1M tokens (in USD). - * Prices as of January 2026. + * Prices as of August 2026. */ -const MODEL_PRICING: Record = { +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 @@ -74,7 +101,26 @@ const MODEL_PRICING: Record { 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', () => { @@ -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'); diff --git a/tests/unit/config/rateLimits.test.ts b/tests/unit/config/rateLimits.test.ts index d6df07bb0..06b4dbf7f 100644 --- a/tests/unit/config/rateLimits.test.ts +++ b/tests/unit/config/rateLimits.test.ts @@ -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'); @@ -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(); diff --git a/tests/unit/utils/llmMetrics.test.ts b/tests/unit/utils/llmMetrics.test.ts index d3df3b057..09115d9c7 100644 --- a/tests/unit/utils/llmMetrics.test.ts +++ b/tests/unit/utils/llmMetrics.test.ts @@ -1,7 +1,16 @@ -import { describe, expect, it } from 'vitest'; -import { calculateCost } from '../../../src/utils/llmMetrics.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockLogger } from '../../helpers/sharedMocks.js'; -describe.concurrent('llmMetrics', () => { +vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); + +const mockCaptureException = vi.fn(); +vi.mock('../../../src/sentry.js', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), +})); + +import { calculateCost, MODEL_PRICING } from '../../../src/utils/llmMetrics.js'; + +describe('llmMetrics', () => { describe('calculateCost', () => { it('calculates cost for known model', () => { const cost = calculateCost('gemini:gemini-2.5-flash', { @@ -13,6 +22,26 @@ describe.concurrent('llmMetrics', () => { expect(cost).toBeCloseTo(0.75, 6); }); + it('calculates cost for Claude Opus 5', () => { + // input=$5, output=$25 + const cost = calculateCost('anthropic:claude-opus-5', { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + }); + + expect(cost).toBeCloseTo(30.0, 6); + }); + + it('calculates cost for Claude Sonnet 5', () => { + // input=$3, output=$15 + const cost = calculateCost('anthropic:claude-sonnet-5', { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + }); + + expect(cost).toBeCloseTo(18.0, 6); + }); + it('returns 0 for unknown model', () => { const cost = calculateCost('unknown:model', { inputTokens: 1000, @@ -71,4 +100,81 @@ describe.concurrent('llmMetrics', () => { expect(cost).toBeCloseTo(0.00015 + 0.0003, 8); }); }); + + describe('calculateCost loud-miss path', () => { + beforeEach(() => { + mockLogger.warn.mockClear(); + mockCaptureException.mockClear(); + }); + + it('warns and captures Sentry once for a missing pricing row, then returns 0', () => { + // Unique model name so the module-level dedup Set is not already primed by other tests. + const cost = calculateCost('unknown-provider:brand-new-model-a', { + inputTokens: 1000, + outputTokens: 1000, + }); + + expect(cost).toBe(0); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('unknown-provider:brand-new-model-a'), + ); + expect(mockCaptureException).toHaveBeenCalledTimes(1); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { source: 'model_pricing_missing' }, + level: 'warning', + extra: { model: 'unknown-provider:brand-new-model-a' }, + }), + ); + }); + + it('deduplicates the warn/Sentry to once per unique model per process', () => { + // First call primes the dedup Set (warns), subsequent calls stay silent. + calculateCost('unknown-provider:brand-new-model-b', { + inputTokens: 1000, + outputTokens: 1000, + }); + mockLogger.warn.mockClear(); + mockCaptureException.mockClear(); + + calculateCost('unknown-provider:brand-new-model-b', { + inputTokens: 2000, + outputTokens: 2000, + }); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('does not warn for a legitimate zero-priced row (e.g. huggingface)', () => { + const cost = calculateCost('huggingface:MiniMaxAI/MiniMax-M2.1', { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + }); + + expect(cost).toBe(0); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + }); + + describe('MODEL_PRICING backfilled rows', () => { + it('prices bare claude-opus-4-6 (backfilled gap)', () => { + expect(MODEL_PRICING['anthropic:claude-opus-4-6']).toEqual({ + input: 5.0, + output: 25.0, + cachedInput: 0.5, + }); + }); + + it('prices bare claude-haiku-4-5 (backfilled gap)', () => { + expect(MODEL_PRICING['anthropic:claude-haiku-4-5']).toEqual({ + input: 1.0, + output: 5.0, + cachedInput: 0.1, + }); + }); + }); });